diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md deleted file mode 100644 index 1866be87e..000000000 --- a/.claude/agents/code-reviewer.md +++ /dev/null @@ -1,26 +0,0 @@ -You are a code reviewer for a Node.js/TypeScript monorepo (socket-cli). - -Apply the rules from CLAUDE.md sections listed below. Reference the full section in CLAUDE.md for details — these are summaries, not the complete rules. - -**Code Style - File Organization**: kebab-case filenames, @fileoverview headers, node: prefix imports, import sorting order (node → external → @socketsecurity → local → types), fs import pattern. - -**Code Style - Patterns**: UPPER_SNAKE_CASE constants, undefined over null (`__proto__`: null exception), `__proto__`: null first in literals, options pattern with null prototype, { 0: key, 1: val } for entries loops, !array.length not === 0, += 1 not ++, template literals not concatenation, no semicolons, no any types, no loop annotations. - -**Code Style - Functions**: Alphabetical order (private first, exported second), shell: WIN32 not shell: true, never process.chdir(), use @socketsecurity/registry/lib/spawn not child_process. - -**Code Style - Comments**: Default NO comments. Only when WHY is non-obvious. Multi-sentence comments end with periods; single phrases may not. Single-line only. JSDoc: description + @throws only. - -**Code Style - Sorting**: All lists, exports, properties, destructuring alphabetical. Type properties: required first, optional second. - -**Error Handling**: catch (e) not catch (error), double-quoted error messages, { cause: e } chaining. - -**Compat shims**: FORBIDDEN — actively remove compat shims, don't maintain them. - -**Test Style**: Functional tests over source scanning. Never read source files and assert on contents. Verify behavior with real function calls. - -For each file reviewed, report: - -- **Style violations** with file:line -- **Logic issues** (bugs, edge cases, missing error handling) -- **Test gaps** (untested code paths) -- Suggested fix for each finding diff --git a/.claude/agents/refactor-cleaner.md b/.claude/agents/refactor-cleaner.md deleted file mode 100644 index af41f17de..000000000 --- a/.claude/agents/refactor-cleaner.md +++ /dev/null @@ -1,26 +0,0 @@ -You are a refactoring specialist for a Node.js/TypeScript monorepo (socket-cli). - -Apply these rules from CLAUDE.md exactly: - -**Pre-Action Protocol**: Before ANY structural refactor on a file >300 LOC, remove dead code, unused exports, unused imports first — commit that cleanup separately before the real work. Multi-file changes: break into phases (≤5 files each), verify each phase. - -**Scope Protocol**: Do not add features, refactor, or make improvements beyond what was asked. Try simplest approach first. - -**Verification Protocol**: Run the actual command after changes. State what you verified. Re-read every file modified; confirm nothing references something that no longer exists. - -**Procedure:** - -1. **Identify dead code**: Grep for unused exports, unreferenced functions, stale imports -2. **Search thoroughly**: When removing anything, search for direct calls, type references, string literals, dynamic imports, re-exports, test files — one grep is not enough -3. **Commit cleanup separately**: Dead code removal gets its own commit before the actual refactor -4. **Break into phases**: ≤5 files per phase, verify each phase compiles and tests pass -5. **Verify nothing broke**: Run `pnpm run check` and `pnpm test` after each phase - -**What to look for:** - -- Unused exports (exported but never imported elsewhere) -- Dead imports (imported but never used) -- Unreachable code paths -- Duplicate logic that should be consolidated -- Files >400 LOC that should be split (flag to user, don't split without approval) -- Compat shims (FORBIDDEN per CLAUDE.md — actively remove) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md deleted file mode 100644 index 9388ed11d..000000000 --- a/.claude/agents/security-reviewer.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: security-reviewer -description: Reviews findings from AgentShield + zizmor against the project's CLAUDE.md security rules and grades the result A-F. Spawned by the scanning-security skill after the static scans run. -tools: Read, Grep, Glob, Bash(git:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(cat:*), Bash(head:*), Bash(tail:*) ---- - -You are a security reviewer for Socket Security Node.js repositories. - -Apply these rules from CLAUDE.md exactly: - -**Safe File Operations**: Use safeDelete()/safeDeleteSync() from @socketsecurity/lib/fs. NEVER fs.rm(), fs.rmSync(), or rm -rf. Use os.tmpdir() + fs.mkdtemp() for temp dirs. NEVER use fetch() — use httpJson/httpText/httpRequest from @socketsecurity/lib/http-request. - -**Absolute Rules**: NEVER use npx, pnpm dlx, or yarn dlx. Use pnpm exec or pnpm run with pinned devDeps. # zizmor: documentation-prohibition - -**Work Safeguards**: Scripts modifying multiple files must have backup/rollback. Git operations that rewrite history require explicit confirmation. - -**Review checklist:** - -1. **Secrets**: Hardcoded API keys, passwords, tokens, private keys in code or config -2. **Injection**: Command injection via shell: true or string interpolation in spawn/exec. Path traversal in file operations. -3. **Dependencies**: npx/dlx usage. Unpinned versions (^ or ~). Missing soak-time bypass justification (pnpm-workspace.yaml `minimumReleaseAgeExclude`). # zizmor: documentation-checklist -4. **File operations**: fs.rm without safeDelete. process.chdir usage. fetch() usage (must use lib's httpRequest). -5. **GitHub Actions**: Unpinned action versions (must use full SHA). Secrets outside env blocks. Template injection from untrusted inputs. -6. **Error handling**: Sensitive data in error messages. Stack traces exposed to users. - -For each finding, report: - -- **Severity**: CRITICAL / HIGH / MEDIUM / LOW -- **Location**: file:line -- **Issue**: what's wrong -- **Fix**: how to fix it - -Run `pnpm audit` for dependency vulnerabilities. Run `pnpm run security` for config/workflow scanning. diff --git a/.claude/commands/quality-loop.md b/.claude/commands/quality-loop.md deleted file mode 100644 index 2f660a20c..000000000 --- a/.claude/commands/quality-loop.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -description: Run /scanning-quality and fix all issues found, repeating until clean or 5 iterations complete ---- - -Run the `/scanning-quality` skill and fix all issues found. Repeat until zero issues remain or 5 iterations complete. - -**Interactive only** — this command makes code changes and commits. Do not use as an automated pipeline gate. - -## Process - -1. Run `/scanning-quality` skill (all scan types) -2. If issues found: spawn the `refactor-cleaner` agent (see `agents/refactor-cleaner.md`) to fix them, grouped by category -3. Run verify-build (see `_shared/verify-build.md`) after fixes -4. Run `/scanning-quality` again -5. Repeat until: - - Zero issues found (success), OR - - 5 iterations completed (stop) -6. Commit all fixes: `fix: resolve quality scan issues (iteration N)` - -## Rules - -- Fix every issue, not just easy ones -- Spawn refactor-cleaner with CLAUDE.md's pre-action protocol: dead code first, then structural changes, ≤5 files per phase -- Run tests after fixes to verify nothing broke -- Track iteration count and report progress diff --git a/.claude/commands/security-scan.md b/.claude/commands/security-scan.md deleted file mode 100644 index b1de1a517..000000000 --- a/.claude/commands/security-scan.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -description: Chain AgentShield (AI scanner) + Zizmor (GH Actions scanner) + security-reviewer agent for a graded security report ---- - -Run the `/scanning-security` skill. This chains AgentShield (Claude config audit) → zizmor (GitHub Actions security) → security-reviewer agent (grading). - -For a quick manual run without the full pipeline: `pnpm run security` diff --git a/.claude/commands/setup-security-tools.md b/.claude/commands/setup-security-tools.md deleted file mode 100644 index 009773ca7..000000000 --- a/.claude/commands/setup-security-tools.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -description: Install Socket Firewall (SFW) + AgentShield (AI scanner) + Zizmor (GH Actions scanner) for local security scanning ---- - -Set up all Socket security tools for local development. - -## What this sets up - -1. **AgentShield** — scans Claude config for prompt injection and secrets -2. **Zizmor** — static analysis for GitHub Actions workflows -3. **SFW (Socket Firewall)** — intercepts package manager commands to scan for malware - -## Setup - -First, ask the user if they have a Socket API token for SFW enterprise features. - -If they do: - -1. Ask them to provide it -2. Write it to `.env.local` as `SOCKET_API_TOKEN=` (create if needed). The deprecated `SOCKET_API_KEY` name is also accepted as an alias for one cycle, but new files should use `SOCKET_API_TOKEN`. -3. Verify `.env.local` is in `.gitignore` — if not, add it and warn - -If they don't, proceed with SFW free mode. - -Then run: - -```bash -node .claude/hooks/setup-security-tools/index.mts -``` - -After the script completes, add the SFW shim directory to PATH: - -```bash -export PATH="$HOME/.socket/_wheelhouse/shims:$PATH" -``` - -## Notes - -- Safe to re-run (idempotent) -- AgentShield needs `pnpm install` (it's a devDep) -- Zizmor is cached at `~/.socket/zizmor/bin/` -- SFW binary is cached via dlx at `~/.socket/_dlx/` -- SFW shims are shared across repos at `~/.socket/_wheelhouse/shims/` -- `.env.local` must NEVER be committed -- `/update` will check for new versions of these tools via `node .claude/hooks/setup-security-tools/update.mts` diff --git a/.claude/commands/sync-checksums.md b/.claude/commands/sync-checksums.md deleted file mode 100644 index ccebd86df..000000000 --- a/.claude/commands/sync-checksums.md +++ /dev/null @@ -1,38 +0,0 @@ -Sync SHA-256 checksums from GitHub releases to bundle-tools.json using the syncing-checksums skill. - -## What it does - -1. Fetches checksums.txt from GitHub releases (or computes from assets) -2. Updates packages/cli/bundle-tools.json -3. Validates JSON syntax -4. Commits changes (if any) - -## Tools synced - -Only `github-release` type tools are synced: - -- opengrep - OpenGrep SAST/code analysis engine -- python - Python runtime from python-build-standalone -- socket-patch - Socket Patch CLI (Rust binary) -- sfw - Socket Firewall -- trivy - Container vulnerability scanner -- trufflehog - Secret detection - -## Usage - -```bash -/sync-checksums -``` - -## Manual commands - -```bash -# Sync all GitHub release tools -node packages/cli/scripts/sync-checksums.mjs - -# Sync specific tool -node packages/cli/scripts/sync-checksums.mjs --tool=opengrep - -# Dry run -node packages/cli/scripts/sync-checksums.mjs --dry-run -``` diff --git a/.claude/hooks/_shared/README.md b/.claude/hooks/_shared/README.md deleted file mode 100644 index 9d5a1770b..000000000 --- a/.claude/hooks/_shared/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# `.claude/hooks/_shared/` - -Helper modules shared across multiple hooks under `.claude/hooks/`. **Not a deployable hook** — has no `index.mts` entry point and no Claude Code hook lifecycle wiring. - -## What lives here - -- **`shell-command.mts`** — Tokenizes a Bash command string with `shell-quote` into discrete `Command`s (`binary`, `args`, leading env `assignments`, plus `viaVariable` / `viaEval` indirection flags). Exposes `parseCommands`, `findInvocation`, `commandsFor`, `invocationHasFlag`, and `hasOpaqueInvocation`. Used by every structure-sensitive Bash guard (`codex-no-write-guard`, `release-workflow-guard`, `no-empty-commit-guard`, the git-detection guards, …) so a forbidden invocation is matched on the actual parsed command — `$(…)` / `$VAR` / `eval` indirection is seen rather than evaded, and a quoted mention inside an `echo` or `-m` body can't false-trigger. - -- **`hook-env.mts`** — `isHookDisabled(slug)` and `hookLog(slug, ...lines)`. Standardizes the `SOCKET__DISABLED` env-var convention every hook supports plus the `[] ` stderr prefix shape. Use these in new hooks so every hook gets a uniform kill switch + output format for free. - -- **`markers.mts`** — Shared sentinel constants for bypass phrases the user can type to override a hook (`Allow bypass`, etc.). - -- **`payload.mts`** — `ToolCallPayload` and `ToolInput` types for the PreToolUse JSON payload, plus `readCommand` / `readFilePath` / `readWriteContent` narrowing helpers. **Use this instead of re-declaring `tool_input` types per-hook** — the fleet had 7 hand-rolled variants before this module landed. - -- **`stop-reminder.mts`** — `runStopReminder(config)` scaffold for Stop hooks that are pure pattern-sweep over the last assistant turn. Reduces a typical pattern-only hook from 100-200 LOC to ~50. Pass `patterns: [{label, regex, why}, ...]` and `closingHint`; the scaffold handles stdin parse, transcript walk, code-fence strip, per-hit snippet extraction, and stderr emit. - -- **`token-patterns.mts`** — Canonical catalog of secret-bearing env-var key names (Socket, LLM providers, GitHub, Linear, Notion, AWS, Stripe, …). Used by `token-guard` (Bash) and `no-token-in-dotenv-guard` (Edit/Write) for the same shape detection. - -- **`transcript.mts`** — `readStdin()` for hook payloads, plus `readLastAssistantText()` and `readLastAssistantToolUses()` for walking the Claude Code session transcript JSONL. Tolerates the harness's 3 historical schema variants in one place so a schema bump is a one-file fix. - -- **`wheelhouse-root.mts`** — Walks up from `cwd` to find the local `socket-wheelhouse` checkout (used by hooks that need wheelhouse-relative paths, e.g. `new-hook-claude-md-guard`, `drift-check-reminder`). - -## When to reach for what (new hook quick-reference) - -- Writing a **Stop hook** that just emits a reminder when patterns match? → `import { runStopReminder } from '../_shared/stop-reminder.mts'`. See `comment-tone-reminder` or `excuse-detector` for the shape. - -- Writing a **PreToolUse hook** that inspects a tool call's input? → `import { ToolCallPayload, readCommand, readFilePath } from '../_shared/payload.mts'`. Saves you the `typeof === 'string'` guard. - -- Detecting whether a Bash command really invokes some binary/subcommand (and want `$(…)` / `$VAR` / quoted-mention false positives handled)? → `import { commandsFor, findInvocation } from '../_shared/shell-command.mts'`. - -- Want a kill switch for your hook? → `import { isHookDisabled, hookLog } from '../_shared/hook-env.mts'`. The hook is enabled by default and `SOCKET__DISABLED=1` opts out — same shape across the fleet. - -- Need to scan secret-bearing env-var names? → `import { ALL_TOKEN_KEY_PATTERNS } from '../_shared/token-patterns.mts'`. - -## Adding to `_shared/` - -A module belongs in `_shared/` when: - -1. Two or more hooks under `.claude/hooks/*/index.mts` need the same parsing / matching / IO logic. -2. The logic is self-contained — no Claude Code hook lifecycle (`process.stdin`, exit codes, blocking semantics). -3. Test coverage lives in `_shared/test/` alongside the helper. - -If only one hook uses it, keep it inline in that hook's directory. If three or more hooks need it across `.claude/hooks/` AND `.git-hooks/`, escalate it to `_helpers.mts` (the cross-boundary shared module) instead. - -## Not a hook - -The `audit-claude` script and the sync-scaffolding `every-hook-has-test` check skip `_shared/` because it carries no `index.mts`. Future contributors who add an `index.mts` here are mis-using the directory — the file should live in a sibling `/` directory instead. diff --git a/.claude/hooks/_shared/acorn/README.md b/.claude/hooks/_shared/acorn/README.md deleted file mode 100644 index e03921366..000000000 --- a/.claude/hooks/_shared/acorn/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# acorn-wasm — shared parser for fleet hooks - -Vendored from -[`@ultrathink/acorn-monorepo`](https://github.com/SocketDev/ultrathink/tree/main/packages/acorn)'s -Rust → WebAssembly prod build (path: -`packages/acorn/lang/rust/build/prod/darwin-arm64/wasm/out/Final/`). -Pending `@ultrathink/acorn` ship to the npm registry, fleet hooks -that need AST-aware analysis `import` from here. - -## Provenance - -The three vendored files come straight from the ultrathink prod build: - -- `acorn.wasm` — compiled Rust acorn parser, ~3.3 MB. -- `acorn-bindgen.cjs` — wasm-bindgen JS glue. -- `acorn-wasm-sync.mts` — sync ESM loader (no top-level await, - `WebAssembly.Instance` constructed at module import). - -The artifact is rebuilt in ultrathink with `pnpm run -build:wasm:node:release` from `packages/acorn/lang/rust`. - -## Refreshing - -Hooks importing this directory don't need to do anything special — -the cascade keeps the files byte-identical with the ultrathink -canonical source. To pull a newer build: - -```bash -# Inside socket-wheelhouse (the canonical source for fleet template): -node scripts/refresh-vendored-acorn.mts -``` - -The script reads from -`$ULTRATHINK_ROOT/packages/acorn/lang/rust/build/prod/darwin-arm64/wasm/out/Final/`, -copies the three files into this directory, and updates this README's -"Last refreshed" line. - -Last refreshed: 2026-05-20 (ultrathink build dated 2026-05-20). - -## Public surface - -`template/.claude/hooks/_shared/acorn/index.mts` is the canonical -import path for fleet hooks. It re-exports a narrow `tryParse` / -`walkSimple` / `findBareCallsTo` surface — see the module's JSDoc for -the parse-failure tolerance + visitor patterns hook authors rely on. - -Don't import `acorn-wasm-sync.mts` directly from hooks; the `index.mts` -wrapper provides the failure-handling + visitor adapters every hook -needs. - -## Why vendor instead of `import 'acorn'` - -- **No JS parser in the npm dep graph.** Hooks fire on every Edit/Write. - A 3-5 MB JS bundle in `node_modules` adds startup latency and Socket- - score risk on every fleet repo. -- **AST parity with the lint plugin.** Both surfaces (oxlint via plugin - - hook via this loader) use the same acorn semantics — the rules can - share visitor logic without divergence between commit-time and - edit-time. -- **wasm sandbox.** The parser runs in WebAssembly with no filesystem - / network access — even a malicious source file under analysis can't - reach the host. - -Retire this directory once `@ultrathink/acorn` ships and the wheelhouse -catalog can pin it. diff --git a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs b/.claude/hooks/_shared/acorn/acorn-bindgen.cjs deleted file mode 100644 index 44a5ca100..000000000 --- a/.claude/hooks/_shared/acorn/acorn-bindgen.cjs +++ /dev/null @@ -1,993 +0,0 @@ -let imports = {} -imports['__wbindgen_placeholder__'] = module.exports - -let heap = new Array(128).fill(undefined) - -heap.push(undefined, null, true, false) - -function getObject(idx) { - return heap[idx] -} - -let heap_next = heap.length - -function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1) - const idx = heap_next - heap_next = heap[idx] - - heap[idx] = obj - return idx -} - -function handleError(f, args) { - try { - return f.apply(this, args) - } catch (e) { - wasm.__wbindgen_export_0(addHeapObject(e)) - } -} - -let cachedUint8ArrayMemory0 = null - -function getUint8ArrayMemory0() { - if ( - cachedUint8ArrayMemory0 === null || - cachedUint8ArrayMemory0.byteLength === 0 - ) { - cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer) - } - return cachedUint8ArrayMemory0 -} - -let cachedTextDecoder = new TextDecoder('utf-8', { - ignoreBOM: true, - fatal: true, -}) - -cachedTextDecoder.decode() - -function decodeText(ptr, len) { - return cachedTextDecoder.decode( - getUint8ArrayMemory0().subarray(ptr, ptr + len), - ) -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0 - return decodeText(ptr, len) -} - -function isLikeNone(x) { - return x === undefined || x === null -} - -function debugString(val) { - // primitive types - const type = typeof val - if (type == 'number' || type == 'boolean' || val == null) { - return `${val}` - } - if (type == 'string') { - return `"${val}"` - } - if (type == 'symbol') { - const description = val.description - if (description == null) { - return 'Symbol' - } else { - return `Symbol(${description})` - } - } - if (type == 'function') { - const name = val.name - if (typeof name == 'string' && name.length > 0) { - return `Function(${name})` - } else { - return 'Function' - } - } - // objects - if (Array.isArray(val)) { - const length = val.length - let debug = '[' - if (length > 0) { - debug += debugString(val[0]) - } - for (let i = 1; i < length; i++) { - debug += ', ' + debugString(val[i]) - } - debug += ']' - return debug - } - // Test for built-in - const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)) - let className - if (builtInMatches && builtInMatches.length > 1) { - className = builtInMatches[1] - } else { - // Failed to match the standard '[object ClassName]' - return toString.call(val) - } - if (className == 'Object') { - // we're a user defined class or Object - // JSON.stringify avoids problems with cycles, and is generally much - // easier than looping through ownProperties of `val`. - try { - return 'Object(' + JSON.stringify(val) + ')' - } catch (_) { - return 'Object' - } - } - // errors - if (val instanceof Error) { - return `${val.name}: ${val.message}\n${val.stack}` - } - // TODO we could test for more things here, like `Set`s and `Map`s. - return className -} - -let WASM_VECTOR_LEN = 0 - -const cachedTextEncoder = new TextEncoder() - -if (!('encodeInto' in cachedTextEncoder)) { - cachedTextEncoder.encodeInto = function (arg, view) { - const buf = cachedTextEncoder.encode(arg) - view.set(buf) - return { - read: arg.length, - written: buf.length, - } - } -} - -function passStringToWasm0(arg, malloc, realloc) { - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg) - const ptr = malloc(buf.length, 1) >>> 0 - getUint8ArrayMemory0() - .subarray(ptr, ptr + buf.length) - .set(buf) - WASM_VECTOR_LEN = buf.length - return ptr - } - - let len = arg.length - let ptr = malloc(len, 1) >>> 0 - - const mem = getUint8ArrayMemory0() - - let offset = 0 - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset) - if (code > 0x7f) break - mem[ptr + offset] = code - } - - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset) - } - ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0 - const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len) - const ret = cachedTextEncoder.encodeInto(arg, view) - - offset += ret.written - ptr = realloc(ptr, len, offset, 1) >>> 0 - } - - WASM_VECTOR_LEN = offset - return ptr -} - -let cachedDataViewMemory0 = null - -function getDataViewMemory0() { - if ( - cachedDataViewMemory0 === null || - cachedDataViewMemory0.buffer.detached === true || - (cachedDataViewMemory0.buffer.detached === undefined && - cachedDataViewMemory0.buffer !== wasm.memory.buffer) - ) { - cachedDataViewMemory0 = new DataView(wasm.memory.buffer) - } - return cachedDataViewMemory0 -} - -function dropObject(idx) { - if (idx < 132) return - heap[idx] = heap_next - heap_next = idx -} - -function takeObject(idx) { - const ret = getObject(idx) - dropObject(idx) - return ret -} -/** - * Parse `source`, compile `selector`, run the matcher, return a JSON-encoded - * result string. Meant to be called from JavaScript as: - * - * const result = JSON.parse(aqs_match(source, selector)) - * - * @param {string} source - * @param {string} selector - * - * @returns {string} - */ -exports.aqs_match = function (source, selector) { - let deferred3_0 - let deferred3_1 - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - source, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - selector, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len1 = WASM_VECTOR_LEN - wasm.aqs_match(retptr, ptr0, len0, ptr1, len1) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - deferred3_0 = r0 - deferred3_1 = r1 - return getStringFromWasm0(r0, r1) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_export_3(deferred3_0, deferred3_1, 1) - } -} - -/** - * Standalone parse function (matches Acorn API) - * - * @param {string} code - * @param {any} options - * - * @returns {any} - */ -exports.parse = function (code, options) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.parse(retptr, ptr0, len0, addHeapObject(options)) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Check if code has syntax errors (returns true if valid) - * - * @param {string} code - * - * @returns {boolean} - */ -exports.is_valid = function (code) { - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - const ret = wasm.is_valid(ptr0, len0) - return ret !== 0 -} - -/** - * Get version information. - * - * @returns {string} - */ -exports.version = function () { - let deferred1_0 - let deferred1_1 - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.version(retptr) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - deferred1_0 = r0 - deferred1_1 = r1 - return getStringFromWasm0(r0, r1) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_export_3(deferred1_0, deferred1_1, 1) - } -} - -/** - * Find innermost node containing position. - * - * @param {string} code - * @param {number} pos - * @param {string | null | undefined} node_type - * @param {any} options_js - * - * @returns {any} - */ -exports.findNodeAround = function (code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - var ptr1 = isLikeNone(node_type) - ? 0 - : passStringToWasm0( - node_type, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - var len1 = WASM_VECTOR_LEN - wasm.findNodeAround( - retptr, - ptr0, - len0, - pos, - ptr1, - len1, - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Find first node starting at or after position. - * - * @param {string} code - * @param {number} pos - * @param {string | null | undefined} node_type - * @param {any} options_js - * - * @returns {any} - */ -exports.findNodeAfter = function (code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - var ptr1 = isLikeNone(node_type) - ? 0 - : passStringToWasm0( - node_type, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - var len1 = WASM_VECTOR_LEN - wasm.findNodeAfter( - retptr, - ptr0, - len0, - pos, - ptr1, - len1, - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Find outermost node ending before position. - * - * @param {string} code - * @param {number} pos - * @param {string | null | undefined} node_type - * @param {any} options_js - * - * @returns {any} - */ -exports.findNodeBefore = function (code, pos, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - var ptr1 = isLikeNone(node_type) - ? 0 - : passStringToWasm0( - node_type, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - var len1 = WASM_VECTOR_LEN - wasm.findNodeBefore( - retptr, - ptr0, - len0, - pos, - ptr1, - len1, - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Simple walk - parse code and call visitor for each node type. - * - * @param {string} code - * @param {any} visitors_obj - * @param {any} options_js - */ -exports.simple = function (code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.simple( - retptr, - ptr0, - len0, - addHeapObject(visitors_obj), - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - if (r1) { - throw takeObject(r0) - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Walk with ancestors. - * - * @param {string} code - * @param {any} visitors_obj - * @param {any} options_js - */ -exports.walk = function (code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.walk( - retptr, - ptr0, - len0, - addHeapObject(visitors_obj), - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - if (r1) { - throw takeObject(r0) - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Full walk with enter/exit. - * - * @param {string} code - * @param {any} visitors_obj - * @param {any} options_js - */ -exports.full = function (code, visitors_obj, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.full( - retptr, - ptr0, - len0, - addHeapObject(visitors_obj), - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - if (r1) { - throw takeObject(r0) - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Recursive walk — visitor controls child traversal via c(child, state) - * - * @param {string} code - * @param {any} state - * @param {any} funcs - * @param {any} options_js - */ -exports.recursive = function (code, state, funcs, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.recursive( - retptr, - ptr0, - len0, - addHeapObject(state), - addHeapObject(funcs), - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - if (r1) { - throw takeObject(r0) - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Find all nodes matching a type string. - * - * @param {string} code - * @param {string} node_type - * @param {any} options_js - * - * @returns {any} - */ -exports.findAll = function (code, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - node_type, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len1 = WASM_VECTOR_LEN - wasm.findAll(retptr, ptr0, len0, ptr1, len1, addHeapObject(options_js)) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Count nodes by type. - * - * @param {string} code - * @param {any} options_js - * - * @returns {any} - */ -exports.countNodes = function (code, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.countNodes(retptr, ptr0, len0, addHeapObject(options_js)) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Walk all nodes, calling callback with (node, ancestors) for every node. - * - * @param {string} code - * @param {any} callback - * @param {any} options_js - */ -exports.fullAncestor = function (code, callback, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.fullAncestor( - retptr, - ptr0, - len0, - addHeapObject(callback), - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - if (r1) { - throw takeObject(r0) - } - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Find innermost node at exact start/end position. - * - * @param {string} code - * @param {number | null | undefined} start - * @param {number | null | undefined} end - * @param {string | null | undefined} node_type - * @param {any} options_js - * - * @returns {any} - */ -exports.findNodeAt = function (code, start, end, node_type, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - var ptr1 = isLikeNone(node_type) - ? 0 - : passStringToWasm0( - node_type, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - var len1 = WASM_VECTOR_LEN - wasm.findNodeAt( - retptr, - ptr0, - len0, - isLikeNone(start) ? 0x100000001 : start >>> 0, - isLikeNone(end) ? 0x100000001 : end >>> 0, - ptr1, - len1, - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -const WasmParserFinalization = - typeof FinalizationRegistry === 'undefined' - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry(ptr => wasm.__wbg_wasmparser_free(ptr >>> 0, 1)) - -class WasmParser { - __destroy_into_raw() { - const ptr = this.__wbg_ptr - this.__wbg_ptr = 0 - WasmParserFinalization.unregister(this) - return ptr - } - - free() { - const ptr = this.__destroy_into_raw() - wasm.__wbg_wasmparser_free(ptr, 0) - } - constructor() { - const ret = wasm.wasmparser_new() - this.__wbg_ptr = ret >>> 0 - WasmParserFinalization.register(this, this.__wbg_ptr, this) - return this - } - /** - * Parse JavaScript code and return AST as JsValue (WASM) or JSON string - * (native). - * - * The WASM path goes: options_js (JS object) → options_from_jsvalue - * (Reflect-based reads, no serde_json) → parser → JSON string → JSON::parse - * (one cheap JS-side parse) → JsValue handed back to JS as the AST root. - * - * @param {string} code - * @param {any} options_js - * - * @returns {any} - */ - parse(code, options_js) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - code, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len0 = WASM_VECTOR_LEN - wasm.wasmparser_parse( - retptr, - this.__wbg_ptr, - ptr0, - len0, - addHeapObject(options_js), - ) - var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true) - var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true) - var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true) - if (r2) { - throw takeObject(r1) - } - return takeObject(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } - } -} -if (Symbol.dispose) - WasmParser.prototype[Symbol.dispose] = WasmParser.prototype.free - -exports.WasmParser = WasmParser - -exports.__wbg_call_641db1bb5db5a579 = function () { - return handleError(function (arg0, arg1, arg2, arg3) { - const ret = getObject(arg0).call( - getObject(arg1), - getObject(arg2), - getObject(arg3), - ) - return addHeapObject(ret) - }, arguments) -} - -exports.__wbg_call_a5400b25a865cfd8 = function () { - return handleError(function (arg0, arg1, arg2) { - const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)) - return addHeapObject(ret) - }, arguments) -} - -exports.__wbg_get_0da715ceaecea5c8 = function (arg0, arg1) { - const ret = getObject(arg0)[arg1 >>> 0] - return addHeapObject(ret) -} - -exports.__wbg_get_458e874b43b18b25 = function () { - return handleError(function (arg0, arg1) { - const ret = Reflect.get(getObject(arg0), getObject(arg1)) - return addHeapObject(ret) - }, arguments) -} - -exports.__wbg_isArray_030cce220591fb41 = function (arg0) { - const ret = Array.isArray(getObject(arg0)) - return ret -} - -exports.__wbg_keys_ef52390b2ae0e714 = function (arg0) { - const ret = Object.keys(getObject(arg0)) - return addHeapObject(ret) -} - -exports.__wbg_length_186546c51cd61acd = function (arg0) { - const ret = getObject(arg0).length - return ret -} - -exports.__wbg_new_19c25a3f2fa63a02 = function () { - const ret = new Object() - return addHeapObject(ret) -} - -exports.__wbg_new_1f3a344cf3123716 = function () { - const ret = new Array() - return addHeapObject(ret) -} - -exports.__wbg_new_da9dc54c5db29dfa = function (arg0, arg1) { - const ret = new Error(getStringFromWasm0(arg0, arg1)) - return addHeapObject(ret) -} - -exports.__wbg_parse_442f5ba02e5eaf8b = function () { - return handleError(function (arg0, arg1) { - const ret = JSON.parse(getStringFromWasm0(arg0, arg1)) - return addHeapObject(ret) - }, arguments) -} - -exports.__wbg_pop_5aaf63e29ea83074 = function (arg0) { - const ret = getObject(arg0).pop() - return addHeapObject(ret) -} - -exports.__wbg_push_330b2eb93e4e1212 = function (arg0, arg1) { - const ret = getObject(arg0).push(getObject(arg1)) - return ret -} - -exports.__wbg_set_453345bcda80b89a = function () { - return handleError(function (arg0, arg1, arg2) { - const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2)) - return ret - }, arguments) -} - -exports.__wbg_setname_832b43d4602cb930 = function (arg0, arg1, arg2) { - getObject(arg0).name = getStringFromWasm0(arg1, arg2) -} - -exports.__wbg_wbindgenbooleanget_3fe6f642c7d97746 = function (arg0) { - const v = getObject(arg0) - const ret = typeof v === 'boolean' ? v : undefined - return isLikeNone(ret) ? 0xffffff : ret ? 1 : 0 -} - -exports.__wbg_wbindgendebugstring_99ef257a3ddda34d = function (arg0, arg1) { - const ret = debugString(getObject(arg1)) - const ptr1 = passStringToWasm0( - ret, - wasm.__wbindgen_export_1, - wasm.__wbindgen_export_2, - ) - const len1 = WASM_VECTOR_LEN - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true) - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true) -} - -exports.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function (arg0) { - const ret = typeof getObject(arg0) === 'function' - return ret -} - -exports.__wbg_wbindgenisnull_f3037694abe4d97a = function (arg0) { - const ret = getObject(arg0) === null - return ret -} - -exports.__wbg_wbindgenisobject_307a53c6bd97fbf8 = function (arg0) { - const val = getObject(arg0) - const ret = typeof val === 'object' && val !== null - return ret -} - -exports.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function (arg0) { - const ret = getObject(arg0) === undefined - return ret -} - -exports.__wbg_wbindgennumberget_f74b4c7525ac05cb = function (arg0, arg1) { - const obj = getObject(arg1) - const ret = typeof obj === 'number' ? obj : undefined - getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true) - getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true) -} - -exports.__wbg_wbindgenstringget_0f16a6ddddef376f = function (arg0, arg1) { - const obj = getObject(arg1) - const ret = typeof obj === 'string' ? obj : undefined - var ptr1 = isLikeNone(ret) - ? 0 - : passStringToWasm0(ret, wasm.__wbindgen_export_1, wasm.__wbindgen_export_2) - var len1 = WASM_VECTOR_LEN - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true) - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true) -} - -exports.__wbg_wbindgenthrow_451ec1a8469d7eb6 = function (arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)) -} - -exports.__wbindgen_cast_2241b6af4c4b2941 = function (arg0, arg1) { - // Cast intrinsic for `Ref(String) -> Externref`. - const ret = getStringFromWasm0(arg0, arg1) - return addHeapObject(ret) -} - -exports.__wbindgen_cast_d6cd19b81560fd6e = function (arg0) { - // Cast intrinsic for `F64 -> Externref`. - const ret = arg0 - return addHeapObject(ret) -} - -exports.__wbindgen_object_clone_ref = function (arg0) { - const ret = getObject(arg0) - return addHeapObject(ret) -} - -exports.__wbindgen_object_drop_ref = function (arg0) { - takeObject(arg0) -} - -const wasmPath = `${__dirname}/./acorn.wasm` -const wasmBytes = require('fs').readFileSync(wasmPath) -const wasmModule = new WebAssembly.Module(wasmBytes) -const wasm = (exports.__wasm = new WebAssembly.Instance( - wasmModule, - imports, -).exports) diff --git a/.claude/hooks/_shared/acorn/acorn-wasm-sync.mts b/.claude/hooks/_shared/acorn/acorn-wasm-sync.mts deleted file mode 100644 index 10efb49e8..000000000 --- a/.claude/hooks/_shared/acorn/acorn-wasm-sync.mts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Sync external WASM loader (ESM). - * - * Reads `./acorn.wasm` from disk synchronously at module-load time via - * fs.readFileSync + new WebAssembly.Module + new WebAssembly.Instance. No async - * init, no top-level await. - * - * Pairs with acorn.wasm in the same directory. - */ - -import { createRequire } from 'node:module' - -const require = createRequire(import.meta.url) -const wasm = require('./acorn-bindgen.cjs') - -export const aqs_match: (source: string, selector: string) => string = - wasm.aqs_match -export const countNodes: (code: string, options_js: any) => number = - wasm.countNodes -export const findNodeAfter: ( - code: string, - pos: number, - node_type: string | null | undefined, - options_js: any, -) => any = wasm.findNodeAfter -export const findNodeAround: ( - code: string, - pos: number, - node_type: string | null | undefined, - options_js: any, -) => any = wasm.findNodeAround -export const findNodeAt: ( - code: string, - start: number, - end: number | null | undefined, - node_type: string | null | undefined, - options_js: any, -) => any = wasm.findNodeAt -export const findNodeBefore: ( - code: string, - pos: number, - node_type: string | null | undefined, - options_js: any, -) => any = wasm.findNodeBefore -export const full: (code: string, visitors_obj: any, options_js: any) => void = - wasm.full -export const fullAncestor: ( - code: string, - visitors_obj: any, - options_js: any, -) => void = wasm.fullAncestor -export const is_valid: (code: string) => boolean = wasm.is_valid -export const parse: (code: string, options: any) => any = wasm.parse -export const recursive: ( - code: string, - state: any, - funcs: any, - options_js: any, -) => void = wasm.recursive -export const simple: ( - code: string, - visitors_obj: any, - options_js: any, -) => void = wasm.simple -export const version: () => string = wasm.version -export const walk: (code: string, visitors_obj: any, options_js: any) => void = - wasm.walk diff --git a/.claude/hooks/_shared/acorn/acorn.wasm b/.claude/hooks/_shared/acorn/acorn.wasm deleted file mode 100644 index fb3cccf5a..000000000 Binary files a/.claude/hooks/_shared/acorn/acorn.wasm and /dev/null differ diff --git a/.claude/hooks/_shared/acorn/index.mts b/.claude/hooks/_shared/acorn/index.mts deleted file mode 100644 index 92f7421b2..000000000 --- a/.claude/hooks/_shared/acorn/index.mts +++ /dev/null @@ -1,1122 +0,0 @@ -/** - * @file Shared acorn-wasm wrapper for fleet hooks. Vendored from - * socket-lib/vendor/acorn-wasm pending the `@ultrathink/acorn` npm publish; - * once that lands, fleet hooks switch to the published package and this - * directory can be retired. Surface kept narrow: `parse(source, opts)` for - * raw AST + `simple(source, visitors, opts)` for visitor-based walks. - * Higher-level shape detectors (`findCallsTo`, `findBareCallsTo`) cover the - * common "lint a specific identifier call" pattern that hooks need. - */ - -import { parse as wasmParse, simple as wasmSimple } from './acorn-wasm-sync.mts' - -export interface AcornNode { - type: string - start: number - end: number - // Index signature lets hooks read whatever the node type exposes. - [key: string]: unknown -} - -export interface ParseOptions { - /** - * ECMAScript version. Default 2026 — matches the fleet's Node 26 floor. - */ - ecmaVersion?: number | undefined - /** - * `module` (default) or `script`. Hooks should leave this alone unless - * inspecting CJS source where top-level `await` would surprise them. - */ - sourceType?: 'module' | 'script' | undefined - /** - * Allow TypeScript syntax (type annotations, generics, satisfies, etc.). - * Default `true` because every fleet hook file is `.ts` / `.mts` / `.cts`. - * Set to `false` only when you genuinely need strict JS-only parsing. - */ - typescript?: boolean | undefined - /** - * Allow JSX. Default `false` — hooks rarely parse JSX. Pure-JSX detectors set - * this `true`. - */ - jsx?: boolean | undefined - /** - * Collect comments. Default `false` — most hooks don't inspect comments and - * pay zero scanner cost when this is off. - * - * When `true`, `walkComments(source, { comments: true })` returns the - * populated `CommentSite[]`. Modeled on oxc-project's collection-on-demand - * model. - */ - comments?: boolean | undefined -} - -const DEFAULT_PARSE_OPTIONS: Required = { - ecmaVersion: 2026, - sourceType: 'module', - typescript: true, - jsx: false, - comments: false, -} - -/** - * Pre-classify a comment body into a `CommentContent` annotation variant. - * Modeled on oxc's classifier — same set of categories, same priority order. - * Fleet hooks consume the `content` field rather than re-running these regexes - * on every comment. - * - * The marker char passed in distinguishes `/*!` (Legal) from `/**` (Jsdoc) - * since both look the same to a body-only scan. - */ -export function classifyCommentContent( - kind: CommentKind, - fullText: string, - body: string, -): CommentContent { - // `Hashbang` and `Line` comments don't carry block-only annotations. - // We still classify `Line` against the Pure / NoSideEffects / coverage - // markers because some tools (uglify, terser) accept them in line form. - const trimmedBody = body.trim() - - // Block-style annotations — only relevant when this is a block. - if (kind === 'MultiLineBlock' || kind === 'SingleLineBlock') { - // Legal: `/*!` opener OR contains `@license` / `@preserve`. - const isLegalMarker = fullText.startsWith('/*!') - const hasLegalAnnotation = /@(?:license|preserve)\b/.test(body) - // Jsdoc: `/**` opener (but NOT `/***`). - const isJsdoc = fullText.startsWith('/**') && !fullText.startsWith('/***') - - if (isJsdoc && hasLegalAnnotation) { - return 'JsdocLegal' - } - if (isJsdoc) { - return 'Jsdoc' - } - if (isLegalMarker || hasLegalAnnotation) { - return 'Legal' - } - if (/^\s*#__PURE__\s*$/.test(trimmedBody)) { - return 'Pure' - } - if (/^\s*#__NO_SIDE_EFFECTS__\s*$/.test(trimmedBody)) { - return 'NoSideEffects' - } - if (/@vite-ignore\b/.test(body)) { - return 'Vite' - } - if (/\bwebpack[A-Z]\w*\s*:/.test(body)) { - return 'Webpack' - } - if (/\bturbopack[A-Z]\w*\s*:/.test(body)) { - return 'Turbopack' - } - } - - // Coverage-ignore markers can appear in `Line` form too. - if ( - /\b(?:v8\s+ignore|c8\s+ignore|node:coverage|istanbul\s+ignore)\b/.test(body) - ) { - return 'CoverageIgnore' - } - - // `//!` opener — terser/uglify treat this as a legal line comment. - if (kind === 'Line' && fullText.startsWith('//!')) { - return 'Legal' - } - - return 'None' -} - -/** - * Comment-kind enum modeled on oxc-project's `CommentKind`. Three variants - * because downstream tools (formatters, code-mods) need to distinguish a - * one-line `/* … *\/` from a multi-line one — preserving the latter on rewrites - * matters more. - * - * `Hashbang` is a fleet extension on top of oxc's kinds: oxc treats `#!` as a - * separate node type entirely (not a comment), but for fleet-hook purposes a - * hashbang IS comment-shaped trivia that hooks may want to walk uniformly with - * line/block comments. - */ -export type CommentKind = - | 'Line' - | 'SingleLineBlock' - | 'MultiLineBlock' - | 'Hashbang' - -/** - * Pre-classified comment content. Modeled on oxc's `CommentContent` — saves - * every consumer a regex scan of every comment body to detect common annotation - * shapes: JSDoc, esbuild legal-comment, `#__PURE__` annotations, - * `@vite-ignore`, webpack magic comments, etc. - * - * `None` is the default for comments that don't match any annotation pattern. - * Most code comments fall here. - */ -export type CommentContent = - | 'None' - | 'Legal' // /*! …*\/ or starts with /*! / //! or contains @license / @preserve - | 'Jsdoc' // /** … *\/ — block opening with /**, not /*** - | 'JsdocLegal' // /** … @preserve / @license *\/ - | 'Pure' // /* #__PURE__ *\/ - | 'NoSideEffects' // /* #__NO_SIDE_EFFECTS__ *\/ - | 'Webpack' // /* webpackChunkName: "…" *\/ / /* webpack* *\/ - | 'Vite' // /* @vite-ignore *\/ - | 'CoverageIgnore' // /* v8 ignore *\/ / /* c8 ignore *\/ / /* node:coverage *\/ / /* istanbul ignore *\/ - | 'Turbopack' // /* turbopack* *\/ - -/** - * Where the comment sits relative to the nearest token. - * - * `Leading` — comment precedes a token. JSDoc on a function, comments - * documenting the next statement, etc. - * - * `Trailing` — comment follows a token on the same source line. `// trailing` - * style. - * - * Tools that auto-attach explanations to declarations (formatter, - * doc-extractor) read this. Hooks that just grade comment bodies usually don't - * need it. - */ -export type CommentPosition = 'Leading' | 'Trailing' - -/** - * Bitflag-style record of newlines around a comment. Encoded as a flat object - * rather than a numeric bitflag to stay idiomatic in JS — every consumer just - * reads booleans. - */ -export interface CommentNewlines { - /** - * True if a newline appears before the opening marker. - */ - before: boolean - /** - * True if a newline appears after the closing marker (or end-of-line for - * `Line`). - */ - after: boolean -} - -/** - * Wire-shape of a single Comment record on the AST root, emitted by the - * vendored Rust acorn-wasm when `parse(source, { collectComments: true })` is - * set. Mirrors oxc's program.comments. `walkComments` translates this into - * `CommentSite` (which adds the legacy `line` / `text` / `value` fields). - */ -interface ParsedComment { - start: number - end: number - attachedTo: number | null - kind: CommentKind - content: CommentContent - position: CommentPosition - newlineBefore: boolean - newlineAfter: boolean -} - -/** - * One comment in the source. Modeled on oxc-project's `Comment`. Hooks filter - * on `kind` + `content` to find relevant comments without re-scanning bodies. - */ -export interface CommentSite { - /** - * Line / SingleLineBlock / MultiLineBlock / Hashbang. - */ - kind: CommentKind - /** - * Pre-classified annotation kind. `None` for ordinary comments. - */ - content: CommentContent - /** - * Position relative to the nearest token. - */ - position: CommentPosition - /** - * Newlines before / after the comment. - */ - newlines: CommentNewlines - /** - * Byte offset of the start of the comment (including marker). - */ - start: number - /** - * Byte offset of the end of the comment (after closing marker). - */ - end: number - /** - * Byte offset of the next non-trivia token after a leading comment. `-1` when - * the comment is trailing or has no following token. Mirrors oxc's - * `attached_to`. Hooks that want to associate a comment with the symbol it - * documents read this. - */ - attachedTo: number - /** - * Raw comment body (text between markers, no marker chars). - */ - value: string - /** - * 1-based line of the opening marker. - */ - line: number - /** - * Trimmed source line containing the comment opening. - */ - text: string -} - -/** - * Legacy convenience: `'Line' | 'Block' | 'Hashbang'` collapse used by older - * callers. Maps `SingleLineBlock` and `MultiLineBlock` to `Block`. New code - * should read `c.kind` directly so the single-vs-multi distinction is - * preserved. - * - * @deprecated Read `c.kind` directly. Will be removed once all hooks - * are migrated. - */ -export function commentTypeCompat( - kind: CommentKind, -): 'Line' | 'Block' | 'Hashbang' { - if (kind === 'MultiLineBlock' || kind === 'SingleLineBlock') { - return 'Block' - } - return kind -} - -/** - * Find every BARE call to the named identifier in `source`. "Bare" means the - * callee is an `Identifier` node (not a `MemberExpression`) — so - * `structuredClone(x)` matches but `obj.structuredClone(x)` does not. Hook - * callers use this to flag a specific global-function call without - * false-positives on member-call methods that happen to share the name. - * - * Skips calls whose immediately-preceding line contains `// - * oxlint-disable-next-line ` (matching the lint rule's per-line - * opt-out shape). The marker comes through as plain text in the source, so we - * re-scan around each match for it. - * - * Returns an empty array on parse failure (fragment tolerance). - */ -export function findBareCallsTo( - source: string, - identifierName: string, - options?: - | (ParseOptions & { - /** - * Optional lint-rule name. When provided, calls whose preceding line - * contains `oxlint-disable-next-line ` are filtered out. - */ - oxlintRuleName?: string | undefined - }) - | undefined, -): CallSite[] { - const matches: CallSite[] = [] - const lines = splitLines(source) - const disableMarker = options?.oxlintRuleName - ? `oxlint-disable-next-line ${options.oxlintRuleName}` - : undefined - - walkSimple( - source, - { - CallExpression(node) { - const callee = node['callee'] as AcornNode | undefined - if (!callee || callee.type !== 'Identifier') { - return - } - if ((callee['name'] as string) !== identifierName) { - return - } - const start = node['start'] as number | undefined - if (typeof start !== 'number') { - return - } - const { line, column } = offsetToLineCol(source, start) - if (disableMarker && line >= 2) { - const prev = lines[line - 2] ?? '' - if (prev.includes(disableMarker)) { - return - } - } - matches.push({ - line, - column, - text: (lines[line - 1] ?? '').trim(), - }) - }, - }, - options, - ) - return matches -} - -export interface MemberCallSite extends CallSite { - /** - * First-argument source text if a string literal, else undefined. - */ - firstStringArg: string | undefined - /** - * Number of arguments (positional + spreads). - */ - argCount: number - /** - * True when every argument is a string Literal (callers use this for - * "all-literal call site" detection like path.join('a', 'b', 'c')). - */ - allStringLiteralArgs: boolean -} - -/** - * Find every `.(...)` member-call in `source`. Used by hooks - * that want to flag specific known APIs (`console.log`, `path.join`, - * `process.stdout.write`, etc.) without false-positives on string literals or - * comments that happen to mention the same dotted name. - * - * `object` and `property` are matched exactly. To match `process.stdout.write` - * (a 3-segment member expression), pass `object: 'process.stdout'` — the helper - * accepts dotted object paths and walks the nested `MemberExpression`s to - * confirm the chain. - */ -export function findMemberCalls( - source: string, - object: string, - property: string, - options?: ParseOptions | undefined, -): MemberCallSite[] { - const matches: MemberCallSite[] = [] - const lines = splitLines(source) - const objectChain = object.split('.') - - function calleeMatches(callee: AcornNode | undefined): boolean { - if (!callee || callee.type !== 'MemberExpression') { - return false - } - const prop = callee['property'] as AcornNode | undefined - if ( - !prop || - prop.type !== 'Identifier' || - (prop['name'] as string) !== property - ) { - return false - } - let head: AcornNode | undefined = callee['object'] as AcornNode | undefined - // Walk the dotted chain right-to-left. For object='process.stdout', - // we expect head to be MemberExpression{object: process, property: stdout}. - for (let i = objectChain.length - 1; i >= 0; i -= 1) { - const segment = objectChain[i]! - if (i === 0) { - // Leftmost segment must be an Identifier. - if (!head || head.type !== 'Identifier') { - return false - } - return (head['name'] as string) === segment - } - // Inner segments are MemberExpression{property: segment}. - if (!head || head.type !== 'MemberExpression') { - return false - } - const innerProp = head['property'] as AcornNode | undefined - if ( - !innerProp || - innerProp.type !== 'Identifier' || - (innerProp['name'] as string) !== segment - ) { - return false - } - head = head['object'] as AcornNode | undefined - } - return true - } - - walkSimple( - source, - { - CallExpression(node) { - if (!calleeMatches(node['callee'] as AcornNode | undefined)) { - return - } - const start = node['start'] as number | undefined - if (typeof start !== 'number') { - return - } - const args = (node['arguments'] as AcornNode[] | undefined) ?? [] - let firstStringArg: string | undefined - let allStringLiteralArgs = args.length > 0 - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]! - const isStringLit = - arg.type === 'Literal' && typeof arg['value'] === 'string' - if (!isStringLit) { - allStringLiteralArgs = false - } - if (i === 0 && isStringLit) { - firstStringArg = arg['value'] as string - } - } - const { line, column } = offsetToLineCol(source, start) - matches.push({ - line, - column, - text: (lines[line - 1] ?? '').trim(), - firstStringArg, - argCount: args.length, - allStringLiteralArgs, - }) - }, - }, - options, - ) - return matches -} - -export interface RegexLiteralSite extends CallSite { - /** - * The regex pattern source (without surrounding `/`). - */ - pattern: string - /** - * The flags string (`g`, `i`, `m`, etc.). - */ - flags: string -} - -/** - * Find every regex literal (`/pattern/flags`) in `source`. Used by the - * path-regex-normalize-reminder rule to flag patterns that try to match both - * path separators inline (`[/\\]`, `[\\\\/]`). Pure regex literals only; - * doesn't reach into `new RegExp('…')` constructor calls. - * - * AST shape: `Literal { regex: { pattern, flags }, value: RegExp }`. - */ -export function findRegexLiterals( - source: string, - options?: ParseOptions | undefined, -): RegexLiteralSite[] { - const matches: RegexLiteralSite[] = [] - const lines = splitLines(source) - - walkSimple( - source, - { - Literal(node) { - const regex = node['regex'] as - | { pattern: string; flags: string } - | undefined - if (!regex || typeof regex.pattern !== 'string') { - return - } - const start = node['start'] as number | undefined - if (typeof start !== 'number') { - return - } - const { line, column } = offsetToLineCol(source, start) - matches.push({ - line, - column, - text: (lines[line - 1] ?? '').trim(), - pattern: regex.pattern, - flags: regex.flags ?? '', - }) - }, - }, - options, - ) - return matches -} - -export interface TemplateLiteralSite extends CallSite { - /** - * The concatenated quasi (static text) segments of the template, with `${…}` - * expression slots replaced by a single `\0` NUL byte sentinel. Callers split - * this on `/`, `.`, etc. to inspect path segments without mistaking - * interpolated content for a segment. - * - * Example: a backtick template with two expression slots and three static - * parts yields a string with two `\0` sentinels separating those parts. - */ - segments: string - /** - * Number of `${…}` expressions in the template. - */ - expressionCount: number -} - -/** - * Find every template literal in `source`. Used by hooks that detect - * multi-segment patterns encoded in backtick strings. Returns the concatenated - * quasi text with expression slots marked by `\0` so callers can split on path - * separators without false-positives on interpolated content. - * - * Tagged templates (`html`-tagged etc.) are skipped — the tag fundamentally - * changes the meaning; only bare template literals participate. - */ -export function findTemplateLiterals( - source: string, - options?: ParseOptions | undefined, -): TemplateLiteralSite[] { - const matches: TemplateLiteralSite[] = [] - const lines = splitLines(source) - - walkSimple( - source, - { - TemplateLiteral(node) { - const start = node['start'] as number | undefined - if (typeof start !== 'number') { - return - } - // Look backward through whitespace for a tag prefix - // (Identifier / `)` / `]`). If found, this is a tagged - // template; the tag changes semantics so we skip. - let i = start - 1 - while (i >= 0 && /\s/.test(source[i]!)) { - i -= 1 - } - if (i >= 0 && /[\w$)\]]/.test(source[i]!)) { - return - } - const quasis = (node['quasis'] as AcornNode[] | undefined) ?? [] - const parts: string[] = [] - for (let qi = 0; qi < quasis.length; qi += 1) { - const q = quasis[qi]! - const value = q['value'] as - | { raw?: string | undefined; cooked?: string | undefined } - | undefined - const cooked = value?.cooked ?? value?.raw ?? '' - parts.push(cooked) - if (qi < quasis.length - 1) { - parts.push('\0') - } - } - const { line, column } = offsetToLineCol(source, start) - matches.push({ - line, - column, - text: (lines[line - 1] ?? '').trim(), - segments: parts.join(''), - expressionCount: Math.max(0, quasis.length - 1), - }) - }, - }, - options, - ) - return matches -} - -export interface ThrowSite extends CallSite { - /** - * The constructor name used in `throw new (…)`. - */ - ctorName: string - /** - * First-argument source text if a string literal, else undefined. - */ - message: string | undefined -} - -/** - * Find every `throw new (…)` expression in `source`. Used by the - * error-message-quality rule to inspect the message string of thrown errors. - * `ctor` semantics: - * - * - `undefined` — match every constructor (custom error classes too). - * - `string` — exact-match `NewExpression.callee.name`. - * - `RegExp` — match the callee name against the regex. Use this to catch - * class-name patterns like `/Error$/` (every *Error class). - */ -export function findThrowNew( - source: string, - ctor: string | RegExp | undefined, - options?: ParseOptions | undefined, -): ThrowSite[] { - const matches: ThrowSite[] = [] - const lines = splitLines(source) - - walkSimple( - source, - { - ThrowStatement(node) { - const arg = node['argument'] as AcornNode | undefined - if (!arg || arg.type !== 'NewExpression') { - return - } - const callee = arg['callee'] as AcornNode | undefined - if (!callee || callee.type !== 'Identifier') { - return - } - const calleeName = callee['name'] as string - if (ctor !== undefined) { - if (typeof ctor === 'string') { - if (calleeName !== ctor) { - return - } - } else if (!ctor.test(calleeName)) { - return - } - } - const args = (arg['arguments'] as AcornNode[] | undefined) ?? [] - let message: string | undefined - const first = args[0] - if ( - first && - first.type === 'Literal' && - typeof first['value'] === 'string' - ) { - message = first['value'] as string - } - const start = node['start'] as number | undefined - if (typeof start !== 'number') { - return - } - const { line, column } = offsetToLineCol(source, start) - matches.push({ - line, - column, - text: (lines[line - 1] ?? '').trim(), - ctorName: calleeName, - message, - }) - }, - }, - options, - ) - return matches -} - -/** - * Convert a byte offset into 1-based line + 0-based column. The wasm parser - * doesn't emit `loc` data even with `locations: true`, but every node carries - * `start` / `end` byte offsets — this function bridges the gap. - * - * Counts `\n`, `\r`, AND `\r\n` (treated as one newline) so the line number - * agrees with `splitLines(source)[line - 1]` regardless of the source's newline - * convention. - */ -export function offsetToLineCol( - source: string, - offset: number, -): { line: number; column: number } { - let line = 1 - let lineStart = 0 - for (let i = 0; i < offset && i < source.length; i += 1) { - const code = source.charCodeAt(i) - if (code === 13 /* \r */) { - line += 1 - // `\r\n` counts as one newline — skip the `\n` if present. - if (source.charCodeAt(i + 1) === 10) { - i += 1 - } - lineStart = i + 1 - } else if (code === 10 /* \n */) { - line += 1 - lineStart = i + 1 - } - } - return { line, column: offset - lineStart } -} - -export interface CallSite { - /** - * 1-based line number of the call. - */ - line: number - /** - * 0-based column of the call. - */ - column: number - /** - * Source snippet of the line containing the call (best-effort). - */ - text: string -} - -/** - * Split source text into lines while normalizing the three legal newline - * conventions: `\r\n` (Windows), `\n` (Unix), `\r` (legacy Mac). Hooks that - * inspect source line-by-line should ALWAYS go through this helper — a raw - * `source.split('\n')` over a CRLF file leaves a trailing `\r` on every line, - * breaking line-snippet display and regex anchors. - * - * Returns one entry per logical line. A trailing newline produces an empty - * trailing entry, matching `split('\n')` semantics. - */ -export function splitLines(source: string): string[] { - // Single regex pass: collapse `\r\n` and bare `\r` to `\n`, then split. - return source.replace(/\r\n?/g, '\n').split('\n') -} - -/** - * Parse a JS/TS source string into an acorn AST. Returns `undefined` on parse - * failure — hooks see incomplete fragments (Edit's `new_string` is a snippet, - * not a whole file) and shouldn't crash on syntax error. - */ -export function tryParse( - source: string, - options?: ParseOptions | undefined, -): AcornNode | undefined { - try { - return wasmParse(source, { - __proto__: null, - ...DEFAULT_PARSE_OPTIONS, - ...options, - } as unknown as ParseOptions) as AcornNode - } catch { - return undefined - } -} - -/** - * Walk every comment token in `source`. Hooks that grade or filter comments - * (no-meta-comments, pointer-comment, comment-tone) use this so they don't - * false-positive on comment-looking content inside string literals or template - * strings. - * - * Each `CommentSite` carries oxc-shape metadata: `kind` (Line / SingleLineBlock - * / MultiLineBlock / Hashbang), `content` (pre- classified annotation), - * `position` (Leading / Trailing), `newlines`, and `attachedTo` (offset of the - * next token for leading comments). - * - * Opt-in: comment collection is OFF by default. Pass `{ comments: true }` (or - * set `parser.comments = true` in the future parser config). The default-off - * shape matches oxc's "free at lex time but you have to ask for it" stance — - * `walkComments` returns `[]` when off, with zero scanner cost. - * - * Implementation note: the vendored acorn-wasm doesn't currently expose an - * `onComment` callback (the Rust lexer skips comments without collection — no - * parser-level hook). This function uses a character-level scanner that's aware - * of `'…'`, `"…"`, and `\`…`` to skip strings/templates correctly; - * comment-looking text inside a string literal won't be reported. - * - * Limitations of the scanner vs a true parser-level callback: - * - * - Regex literals: `/foo \/\/ bar/` — the scanner doesn't disambiguate `/` - * start-of-regex from `/` division. Real-world: comments inside regex - * literals are rare and a regex containing `//` would be a - * line-comment-marker inside a slash-delimited region, which most patterns - * don't construct. Documented edge case. - * - JSX: `{/* comment *\/}` inside JSX is handled (parses as block comment in the - * JS scanner pass). - * - * Returns the comments in source order. Empty array if source is empty. - * - * TODO (parser feature gap): land `onComment` in the ultrathink Rust parser, - * sync to Go/C++/TypeScript ports, rebuild wasm. Then this function can switch - * to the parser-level callback. The scanner stays as the fragment-tolerant - * fallback when the parser rejects the input. - */ -export function walkComments( - source: string, - options?: ParseOptions | undefined, -): CommentSite[] { - // Opt-in. Default is OFF — caller must explicitly enable with - // `{ comments: true }`. Modeled on oxc's collection-on-demand. - if (options?.comments !== true) { - return [] - } - // Fast path: parser-level collection. The vendored Rust acorn-wasm - // now exposes Options.collectComments — when set, the AST root - // carries a `comments` array of oxc-shape records ready-classified - // (kind / content / position / attachedTo / newlineBefore+after). - // We just need to bolt on the legacy `line` + `text` + `value` fields - // that pre-date the parser support and that CommentSite still ships. - try { - const parsed = wasmParse(source, { - __proto__: null, - ...DEFAULT_PARSE_OPTIONS, - ...options, - collectComments: true, - } as unknown as ParseOptions) as - | (AcornNode & { comments?: ParsedComment[] | undefined }) - | undefined - const parsedComments = parsed?.['comments'] - if (Array.isArray(parsedComments) && parsedComments.length >= 0) { - const lines = splitLines(source) - return parsedComments.map((pc): CommentSite => { - const { line } = offsetToLineCol(source, pc.start) - const fullText = source.slice(pc.start, pc.end) - let value: string - if (pc.kind === 'Line') { - value = fullText.startsWith('//') ? fullText.slice(2) : fullText - } else if (pc.kind === 'Hashbang') { - value = fullText.startsWith('#!') ? fullText.slice(2) : fullText - } else { - // SingleLineBlock or MultiLineBlock. - value = - fullText.startsWith('/*') && fullText.endsWith('*/') - ? fullText.slice(2, -2) - : fullText - } - return { - kind: pc.kind, - content: pc.content, - position: pc.position, - newlines: { - before: pc.newlineBefore, - after: pc.newlineAfter, - }, - start: pc.start, - end: pc.end, - attachedTo: pc.attachedTo == null ? -1 : pc.attachedTo, - value, - line, - text: (lines[line - 1] ?? '').trim(), - } - }) - } - } catch { - // Parser rejected the input (fragment, syntax error, future-syntax - // not yet supported). Fall through to the legacy scanner — it's - // tolerant of incomplete inputs and is the documented escape hatch. - } - // Internal record shape during the scan. We fill in `position`, - // `newlines`, `attachedTo`, and `content` in a second pass after - // the full comment list is known. - interface PendingComment { - kind: CommentKind - start: number - end: number - value: string - fullText: string - line: number - text: string - } - const pending: PendingComment[] = [] - const lines = splitLines(source) - const len = source.length - let i = 0 - let stringQuote: string | undefined - let templateDepth = 0 - // Hashbang: only valid at offset 0 per ES2023 grammar. - if ( - len >= 2 && - source.charCodeAt(0) === 35 /* # */ && - source.charCodeAt(1) === 33 /* ! */ - ) { - let j = 2 - while (j < len && source.charCodeAt(j) !== 10 /* \n */) { - j += 1 - } - pending.push({ - kind: 'Hashbang', - start: 0, - end: j, - value: source.slice(2, j), - fullText: source.slice(0, j), - line: 1, - text: (lines[0] ?? '').trim(), - }) - i = j - } - while (i < len) { - const c = source[i]! - if (stringQuote !== undefined) { - if (c === '\\') { - i += 2 - continue - } - if (c === stringQuote) { - stringQuote = undefined - } - i += 1 - continue - } - if (templateDepth > 0) { - if (c === '\\') { - i += 2 - continue - } - // `${` opens an expression slot — drop out of template mode. - if (c === '$' && source[i + 1] === '{') { - templateDepth -= 1 - i += 2 - continue - } - if (c === '`') { - templateDepth -= 1 - } - i += 1 - continue - } - if (c === '"' || c === "'") { - stringQuote = c - i += 1 - continue - } - if (c === '`') { - templateDepth += 1 - i += 1 - continue - } - if (c === '/' && source[i + 1] === '/') { - const start = i - let j = i + 2 - while (j < len && source.charCodeAt(j) !== 10) { - j += 1 - } - const { line } = offsetToLineCol(source, start) - pending.push({ - kind: 'Line', - start, - end: j, - value: source.slice(start + 2, j), - fullText: source.slice(start, j), - line, - text: (lines[line - 1] ?? '').trim(), - }) - i = j - continue - } - if (c === '/' && source[i + 1] === '*') { - const start = i - let j = i + 2 - while (j < len - 1) { - if (source[j] === '*' && source[j + 1] === '/') { - j += 2 - break - } - j += 1 - } - const body = source.slice(start + 2, j - 2) - // SingleLine vs MultiLine block — does the body contain a newline? - const isMulti = body.includes('\n') || body.includes('\r') - const kind: CommentKind = isMulti ? 'MultiLineBlock' : 'SingleLineBlock' - const { line } = offsetToLineCol(source, start) - pending.push({ - kind, - start, - end: j, - value: body, - fullText: source.slice(start, j), - line, - text: (lines[line - 1] ?? '').trim(), - }) - i = j - continue - } - i += 1 - } - - // Second pass: compute position / newlines / attachedTo / content. - // We need to know the offset of the next non-trivia token AFTER each - // comment to fill in `attachedTo`. Approach: scan forward from each - // comment's end, skipping whitespace and any subsequent comments. - function nextNonTriviaOffset(from: number): number { - let p = from - while (p < len) { - const ch = source.charCodeAt(p) - // Whitespace. - if ( - ch === 32 /* space */ || - ch === 9 /* tab */ || - ch === 10 /* \n */ || - ch === 13 /* \r */ - ) { - p += 1 - continue - } - // Line comment to skip. - if (ch === 47 /* / */ && source.charCodeAt(p + 1) === 47 /* / */) { - while (p < len && source.charCodeAt(p) !== 10) { - p += 1 - } - continue - } - // Block comment to skip. - if (ch === 47 /* / */ && source.charCodeAt(p + 1) === 42 /* * */) { - p += 2 - while (p < len - 1) { - if ( - source.charCodeAt(p) === 42 /* * */ && - source.charCodeAt(p + 1) === 47 /* / */ - ) { - p += 2 - break - } - p += 1 - } - continue - } - return p - } - return -1 - } - - function hasNewlineBefore(offset: number): boolean { - let p = offset - 1 - while (p >= 0) { - const ch = source.charCodeAt(p) - if (ch === 10 /* \n */ || ch === 13 /* \r */) { - return true - } - if (ch !== 32 && ch !== 9) { - return false - } - p -= 1 - } - // Start-of-file counts as having a newline before (the start - // boundary is effectively a newline for attachment purposes). - return true - } - - function hasNewlineAfter(offset: number): boolean { - let p = offset - while (p < len) { - const ch = source.charCodeAt(p) - if (ch === 10 /* \n */ || ch === 13 /* \r */) { - return true - } - if (ch !== 32 && ch !== 9) { - return false - } - p += 1 - } - return true - } - - return pending.map((pc): CommentSite => { - // Position: a comment is Trailing if there's NO newline before it - // AND there IS a token earlier on the same line. Easiest detector: - // the preceding source line up to `start` contains a non-comment - // non-whitespace char with no intervening newline. - const before = hasNewlineBefore(pc.start) - const after = hasNewlineAfter(pc.end) - const position: CommentPosition = before ? 'Leading' : 'Trailing' - const attachedTo = position === 'Leading' ? nextNonTriviaOffset(pc.end) : -1 - const content = classifyCommentContent(pc.kind, pc.fullText, pc.value) - return { - kind: pc.kind, - content, - position, - newlines: { before, after }, - start: pc.start, - end: pc.end, - attachedTo, - value: pc.value, - line: pc.line, - text: pc.text, - } - }) -} - -/** - * Visit every node in `source` whose type matches a key in `visitors`. Errors - * during parse are silently swallowed — see `tryParse` for the - * fragment-tolerance rationale. - */ -export function walkSimple( - source: string, - visitors: Record void>, - options?: ParseOptions | undefined, -): void { - try { - wasmSimple( - source, - visitors as unknown as Record void>, - { - __proto__: null, - ...DEFAULT_PARSE_OPTIONS, - ...options, - } as unknown as ParseOptions, - ) - } catch { - // Parse failure — caller's hook should fail open. - } -} diff --git a/.claude/hooks/_shared/fleet-repos.mts b/.claude/hooks/_shared/fleet-repos.mts deleted file mode 100644 index b16ca0549..000000000 --- a/.claude/hooks/_shared/fleet-repos.mts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file Single source of truth for fleet-repo membership, shared by the - * hooks that need to know "is this one of ours?": - * - * - `cross-repo-guard` — blocks `..//…` sibling-path imports. - * - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the - * fleet (a non-fleet repo never has the fleet hook chain installed, so - * the guard has to live agent-side and know the roster itself). - * - * This is the BROAD membership set, intentionally wider than the cascade - * roster in `cascading-fleet/lib/fleet-repos.json` (which lists only - * template-cascade targets and omits e.g. `ultrathink`). Membership here - * answers "may fleet tooling act on this repo at all", not "does the - * wheelhouse cascade into it". Keep the two distinct: a repo can be a - * fleet member (pushable, importable) without being a cascade target. - */ - -// All under the SocketDev org. Names match the GitHub repo slug -// (`github.com:SocketDev/`). Sorted; add new fleet repos here and -// both consuming guards pick them up. -export const FLEET_REPO_NAMES = [ - 'claude-code', - 'skills', - 'socket-addon', - 'socket-btm', - 'socket-cli', - 'socket-lib', - 'socket-packageurl-js', - 'socket-registry', - 'socket-sdk-js', - 'socket-sdxgen', - 'socket-stuie', - 'socket-vscode', - 'socket-webext', - 'socket-wheelhouse', - 'ultrathink', -] as const - -const FLEET_REPO_SET: ReadonlySet = new Set(FLEET_REPO_NAMES) - -/** - * True when `slug` (a bare repo name like `socket-cli`) is a fleet member. - * Case-insensitive — GitHub slugs are case-insensitive and remotes can be - * typed in any case. - */ -export function isFleetRepo(slug: string): boolean { - return FLEET_REPO_SET.has(slug.toLowerCase()) -} - -/** - * Extract the bare repo slug from a git remote URL, or `undefined` when the - * URL isn't a recognizable GitHub remote. Handles the three forms git emits: - * - * git@github.com:SocketDev/socket-cli.git (SSH scp-like) - * ssh://git@github.com/SocketDev/socket-cli.git (SSH URL) - * https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git) - * - * Returns the slug only (`socket-cli`), lowercased. The owner is dropped on - * purpose: membership is keyed on the repo name, and a fork under a - * different owner is still not a fleet push target. - */ -export function slugFromRemoteUrl(url: string): string | undefined { - const trimmed = url.trim() - if (!trimmed) { - return undefined - } - // Capture `/` from any of the three remote shapes, then - // strip a trailing `.git`. The `[^/:]+` owner segment is bounded by the - // `:` (scp form) or `/` (URL forms) that precedes it. - const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(trimmed) - if (!match) { - return undefined - } - return match[2]!.toLowerCase() -} diff --git a/.claude/hooks/_shared/hook-env.mts b/.claude/hooks/_shared/hook-env.mts deleted file mode 100644 index f0e517c21..000000000 --- a/.claude/hooks/_shared/hook-env.mts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file Hook-runtime helpers: disable-env check + prefixed stderr writer. Two - * responsibilities every hook needs: - * - * 1. `isHookDisabled(slug)` — check the canonical `SOCKET__DISABLED` - * env var so every hook gets a uniform kill switch. The hook's name is the - * only input; the env-var name is derived (kebab → upper-snake + - * `_DISABLED` suffix). Today 15 of 47 hooks have a manually-named disable - * env; this helper makes it free for every hook. - * 2. `hookLog(slug, ...lines)` — write `[] ` to stderr. Hooks have - * long duplicated this prefix shape with - * `process.stderr.write(\`[hook-name] ...`)`; centralizing it keeps the - * format consistent and lets us evolve it later (color, level prefixes, - * etc.) in one file. No dependency on `@socketsecurity/lib-stable` — hooks - * load fast at PreTool- Use time and the lib's logger ships a chunk of - * code (spinners, color detection, header/footer) that's wasted on a - * single stderr.write. Plain process.stderr is the right tool here. - */ - -import process from 'node:process' - -/** - * Convert a hook slug (kebab-case) to its canonical disable env-var name. Pure - * string transform — exposed for tests + for hooks that want to mention the - * env-var name in their disable hint. - * - * HookDisableEnvVar('no-revert-guard') → 'SOCKET_NO_REVERT_GUARD_DISABLED' - * hookDisableEnvVar('comment-tone-reminder') → - * 'SOCKET_COMMENT_TONE_REMINDER_DISABLED' - * hookDisableEnvVar('auth-rotation-reminder') → - * 'SOCKET_AUTH_ROTATION_REMINDER_DISABLED' - */ -export function hookDisableEnvVar(slug: string): string { - const upper = slug.toUpperCase().replace(/-/g, '_') - return `SOCKET_${upper}_DISABLED` -} - -/** - * Write one or more lines to stderr, each prefixed with `[] `. Trailing - * newlines are added automatically. Empty-string args are written as bare - * newlines (useful for visual separation). - * - * HookLog('foo', 'first line', '', 'after blank') → [foo] first line\n \n [foo] - * after blank\n. - */ -export function hookLog(slug: string, ...lines: readonly string[]): void { - const prefix = `[${slug}] ` - for (let i = 0, { length } = lines; i < length; i += 1) { - const ln = lines[i]! - if (ln === '') { - process.stderr.write('\n') - } else { - process.stderr.write(`${prefix}${ln}\n`) - } - } -} - -/** - * True when the canonical disable env is set to a truthy value. The fleet - * treats any non-empty value as "disabled" — `=1`, `=true`, `=yes`, all the - * same. An explicit `=0` or `=false` is also still non-empty, so technically - * "disabled"; if a user wants to enable after a session-wide disable, they - * should `unset` the var. - */ -export function isHookDisabled(slug: string): boolean { - return Boolean(process.env[hookDisableEnvVar(slug)]) -} diff --git a/.claude/hooks/_shared/markers.mts b/.claude/hooks/_shared/markers.mts deleted file mode 100644 index 8593f4989..000000000 --- a/.claude/hooks/_shared/markers.mts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @file Canonical opt-out marker handling shared across hooks. The fleet `// - * socket-hook: allow ` marker has two surfaces: - * - * 1. `.claude/hooks/*-guard/index.mts` (PreToolUse hooks, Claude Code). - * 2. `.git-hooks/_helpers.mts` (pre-commit / pre-push scanners). Both surfaces - * need the same regex, the same suppression check, and the same alias map. - * Defining them in one place means a future `RULE_ALIASES` addition can't - * silently diverge between the two — the "Marker name was logger, now it's - * console" episode showed why inline-duplicating the alias check is a - * footgun. - */ - -// `` is `#`, `//`, or `/*` to match shell, JS/TS, and -// C-block comment lexers. The capture group catches the optional rule -// name (`socket-hook: allow personal-path` → `'personal-path'`); the -// bare form (`socket-hook: allow`) leaves capture undefined and means -// "blanket suppress every scanner on this line." -export const SOCKET_HOOK_MARKER_RE: RegExp = - /(?:#|\/\/|\/\*)\s*socket-hook:\s*allow(?:\s+([\w-]+))?/ - -/** - * Legacy marker names recognized as equivalent to a current rule for one - * deprecation cycle. Keys are aliases; values are the canonical rule name. The - * match is bidirectional in `aliasMatches` so callers can ask either side. - * - * Add entries when renaming a rule. Drop them after one cycle. - */ -export const RULE_ALIASES: Readonly> = Object.freeze({ - __proto__: null, - // `logger` was the original marker when the scanner only flagged - // process.std{out,err}.write; renamed to `console` once console.* - // entered scope. Keep the alias one cycle so existing markers in - // downstream repos don't have to migrate atomically. - logger: 'console', -} as unknown as Record) - -/** - * True when `marker` and `rule` name the same logical rule, either directly or - * via a `RULE_ALIASES` entry in either direction. - */ -export function aliasMatches(marker: string, rule: string): boolean { - if (marker === rule) { - return true - } - return RULE_ALIASES[marker] === rule || RULE_ALIASES[rule] === marker -} - -/** - * True when `line` carries a marker that suppresses `rule`. A bare - * `socket-hook: allow` (no rule name) is treated as a blanket allow and returns - * true for every `rule`. - * - * `rule === undefined` means "is any marker present at all" — used by generic - * line-iteration helpers that don't carry a rule context. - */ -export function lineIsSuppressed(line: string, rule?: string): boolean { - const m = line.match(SOCKET_HOOK_MARKER_RE) - if (!m) { - return false - } - // No rule named on the marker → blanket allow. - if (!m[1]) { - return true - } - if (rule === undefined) { - return true - } - return aliasMatches(m[1], rule) -} diff --git a/.claude/hooks/_shared/payload.mts b/.claude/hooks/_shared/payload.mts deleted file mode 100644 index a67e5b3f0..000000000 --- a/.claude/hooks/_shared/payload.mts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file Shared types for Claude Code PreToolUse hook payloads. Claude Code - * sends a JSON object on stdin to every PreToolUse hook: { "tool_name": - * "Edit" | "Write" | "Bash" | ..., "tool_input": {...} } The shape of - * `tool_input` varies by tool. The fleet's hooks need three subsets: - * - * - Edit/Write hooks read `file_path` (always present) and either `content` - * (Write) or `new_string` (Edit). - * - Bash hooks read `command` (the shell line to run). - * - A few hooks (cross-repo-guard, no-fleet-fork-guard) read `file_path` to - * gate edits to specific paths. Each hook used to declare its own - * `tool_input` type inline — 7 distinct shapes existed across the fleet for - * the same data. This file centralizes them so: - * - * 1. Future hooks copy-paste the right type instead of inventing one. - * 2. A schema change (new tool, new field) is a one-file edit. - * 3. The `unknown`-vs-`string` widening choice is consistent across hooks (we - * widen to `unknown` and narrow at use; that's the defensive shape for a - * payload we don't fully control). All fields are optional + `unknown` - * because: - * - * - Hooks never know which tool they're inspecting until they read `tool_name`. - * A Bash hook gets a Bash payload but the type is the same union shape. - * - The harness reserves the right to add fields; explicit `unknown` forces - * callers to narrow at use, which prevents silent breakage when an - * unexpected value lands in a known field. - */ - -/** - * The full PreToolUse payload Claude Code sends on stdin. Every hook imports - * this and narrows the `tool_input` fields it reads. - */ -export interface ToolCallPayload { - readonly tool_name?: string | undefined - readonly tool_input?: ToolInput | undefined -} - -/** - * Union of the `tool_input` fields the fleet's hooks read. Tool- specific - * fields are all optional and typed `unknown` — narrow at the use site so a - * payload-shape surprise (number where a string expected, etc.) doesn't crash - * the hook. - */ -export interface ToolInput { - // Edit/Write - readonly file_path?: unknown | undefined - readonly content?: unknown | undefined - readonly new_string?: unknown | undefined - readonly old_string?: unknown | undefined - // Bash - readonly command?: unknown | undefined -} - -/** - * Narrow `tool_input.command` to a string. Returns `undefined` when the field - * is missing or non-string. Use as the canonical entry point for Bash hooks so - * the narrowing logic is one line at every call site: - * - * Const cmd = readCommand(payload) if (!cmd) return. - */ -export function readCommand(payload: ToolCallPayload): string | undefined { - const cmd = payload?.tool_input?.command - return typeof cmd === 'string' ? cmd : undefined -} - -/** - * Narrow `tool_input.file_path` to a string. Same shape as `readCommand` — - * single entry point so callers don't repeat the `typeof === 'string'` guard. - */ -export function readFilePath(payload: ToolCallPayload): string | undefined { - const fp = payload?.tool_input?.file_path - return typeof fp === 'string' ? fp : undefined -} - -/** - * Narrow the write-content field. For Write tools the field is `content`; for - * Edit it's `new_string`. Returns the first present string field or - * `undefined`. Useful for hooks that want to scan "what's about to land on - * disk" without caring whether it's a Write or an Edit. - */ -export function readWriteContent(payload: ToolCallPayload): string | undefined { - const content = payload?.tool_input?.content - if (typeof content === 'string') { - return content - } - const newStr = payload?.tool_input?.new_string - if (typeof newStr === 'string') { - return newStr - } - return undefined -} diff --git a/.claude/hooks/_shared/shell-command.mts b/.claude/hooks/_shared/shell-command.mts deleted file mode 100644 index c62aa8fa8..000000000 --- a/.claude/hooks/_shared/shell-command.mts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @file Shell-command parsing for Bash-allowlist hooks. Wraps `shell-quote` (a - * maintained, zero-dep JS tokenizer) so structure-sensitive guards can reason - * about "what binary actually runs, at each command position" instead of - * regex-matching the raw string. Why this exists: regex command detection is - * evaded by ordinary shell indirection — `g=git; $g push`, `eval "git push"`, - * `git $(printf push)`, `\git push`. CLAUDE.md ("Background Bash") mandates - * AST-based parsing for structure-sensitive Bash rules; this is the fleet's - * JS parser layer, built on `shell-quote` (the fleet-canonical shell parser). - * What it gives you: - * - * - `parseCommands(command)` — split a command line into Command segments, one - * per shell command (separated by `;`, `&&`, `||`, `|`, `&`, and the - * boundaries of `$(…)` substitutions). Each segment carries its binary, - * args, leading `VAR=val` assignments, and indirection flags. - * - `findInvocation(command, { binary, subcommand })` — true when any segment - * invokes `binary` (optionally with `subcommand` as its first non-flag - * argument). Sees through chains, substitution, and quoting. - * - Each Command exposes `viaVariable` (binary resolved from `$VAR` → - * shell-quote yields an empty binary token) and `viaEval` (the binary is - * `eval`), so a guard can choose to BLOCK or fail-loud on indirection it - * can't statically resolve rather than silently allow it. Limitation: - * shell-quote tokenizes, it doesn't fully evaluate. It cannot expand a - * variable's value (`g=git; $g push` yields an empty binary, not `git`) — - * but it FLAGS that the binary was variable-sourced, which is the - * actionable signal. Aliases defined elsewhere and wrapper scripts remain - * out of scope for any static parser. - */ - -// shell-quote ships no types and we don't want a second dep (@types/ -// shell-quote) + its own soak entry just for a 2-shape union. The -// runtime contract is stable and narrow: parse() returns an array whose -// entries are bare strings, `{ op }` operator objects, or `{ comment }` -// objects. Type it locally. -// oxlint-disable-next-line no-explicit-any -- shell-quote has no types; parse is the documented entry point. -import { parse as shellQuoteParse } from 'shell-quote' - -type ParseEntry = string | { op: string } | { comment: string } - -const parse = shellQuoteParse as unknown as (cmd: string) => ParseEntry[] - -// shell-quote emits operator objects ({ op }), comment objects ({ comment }), -// and bare strings. These ops separate one command from the next. -const COMMAND_SEPARATORS = new Set(['\n', '&', '&&', ';', '|', '||']) - -export interface Command { - /** - * The resolved binary (first non-assignment token), or '' when it could not - * be statically resolved (e.g. `$VAR` indirection). - */ - readonly binary: string - /** - * Arguments after the binary, bare strings only (ops/comments dropped). - */ - readonly args: readonly string[] - /** - * Leading `NAME=value` assignments that prefixed the command. - */ - readonly assignments: readonly string[] - /** - * True when the binary token came from a variable (`$g push` → ''). - */ - readonly viaVariable: boolean - /** - * True when the binary is `eval` (the command it runs is opaque). - */ - readonly viaEval: boolean -} - -function isOp(e: ParseEntry): e is { op: string } { - return typeof e === 'object' && e !== null && 'op' in e -} - -function isComment(e: ParseEntry): boolean { - return typeof e === 'object' && e !== null && 'comment' in e -} - -const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/ - -/** - * Parse a shell command line into its constituent Command segments. - * - * Token handling: - * - * - Operators in COMMAND_SEPARATORS start a new segment. - * - `$(…)` substitution shows up as `"$" ( … )`; the `(`/`)` ops bound an inner - * command, which becomes its own segment (so a substituted binary like `git - * $(printf push)` surfaces `printf` as a command too). - * - Comments are dropped. - * - A leading run of `NAME=value` tokens are assignments; the first - * non-assignment token is the binary. - * - An empty-string binary token means the binary was `$VAR`-sourced. - */ -export function parseCommands(command: string): Command[] { - let entries: ParseEntry[] - try { - entries = parse(command) - } catch { - return [] - } - - const commands: Command[] = [] - let tokens: string[] = [] - let sawVarPlaceholder = false - - const flush = () => { - if (tokens.length === 0) { - // A segment that was nothing but a `$VAR` placeholder still counts — - // the binary was variable-sourced. - if (sawVarPlaceholder) { - commands.push({ - binary: '', - args: [], - assignments: [], - viaVariable: true, - viaEval: false, - }) - } - sawVarPlaceholder = false - return - } - const assignments: string[] = [] - let i = 0 - while (i < tokens.length && ASSIGNMENT_RE.test(tokens[i]!)) { - assignments.push(tokens[i]!) - i += 1 - } - const binary = i < tokens.length ? tokens[i]! : '' - const args = tokens.slice(i + 1) - commands.push({ - binary, - args, - assignments, - // Empty binary after assignments means a `$VAR` placeholder collapsed - // to '' sat in the binary slot. - viaVariable: binary === '' && sawVarPlaceholder, - viaEval: binary === 'eval', - }) - tokens = [] - sawVarPlaceholder = false - } - - for (let i = 0, { length } = entries; i < length; i += 1) { - const e = entries[i]! - if (isComment(e)) { - continue - } - if (isOp(e)) { - if (COMMAND_SEPARATORS.has(e.op) || e.op === '(' || e.op === ')') { - flush() - } - // Redirect ops (`>`, `>>`, `<`, etc.) and the `$` substitution sigil - // are not separators; the redirect TARGET that follows is dropped by - // not being a command token we care about. Simplest correct behavior: - // treat a redirect op as ending the meaningful args (skip the rest of - // this segment's tokens until a separator). We keep it lenient — args - // after a redirect aren't binaries. - continue - } - // Bare string token. - if (e === '') { - // shell-quote collapses `$VAR` / `${VAR}` to ''. Mark indirection; - // hold a placeholder so an all-variable command still flushes. - sawVarPlaceholder = true - tokens.push('') - continue - } - tokens.push(e) - } - flush() - return commands -} - -export interface InvocationQuery { - /** - * Binary name to match, e.g. 'git' or 'gh'. Case-sensitive. - */ - readonly binary: string - /** - * Optional first non-flag argument, e.g. 'push' or 'workflow'. - */ - readonly subcommand?: string | undefined -} - -/** - * True when `command` invokes `query.binary` (optionally with `subcommand` as - * its first non-flag argument) in any of its command segments. - * - * "First non-flag argument" skips leading `-x` / `--long` / `-x value` option - * tokens so `git -C /x push` matches `{ binary: 'git', subcommand: 'push' }`. - * Flags that take a separate-word value (`-C `) are handled by skipping a - * non-flag token that immediately follows a known value-taking flag is NOT - * attempted — instead we scan for `subcommand` among the non-flag args, which - * is robust for the subcommand-detection use case. - */ -export function findInvocation( - command: string, - query: InvocationQuery, -): boolean { - const commands = parseCommands(command) - for (const cmd of commands) { - if (cmd.binary !== query.binary) { - continue - } - if (query.subcommand === undefined) { - return true - } - // Scan ALL non-flag args for the subcommand verb. The first non-flag - // token is NOT reliable: a global option's separate-word VALUE (e.g. - // `/x` after `-C`, or `k=v` after `-c`) is itself non-flag and would - // shadow the real subcommand. Scanning every non-flag arg is safe - // because those VALUES are paths / kv strings, not subcommand verbs - // like `push` / `workflow`, so a match on the verb is unambiguous. - if (cmd.args.some(a => !a.startsWith('-') && a === query.subcommand)) { - return true - } - } - return false -} - -/** - * Every command segment that invokes `binary`. Use when a guard needs the - * matched command's args (to check for a flag like `--write` or a subcommand) - * rather than a yes/no. Returns [] when `binary` isn't invoked. - * - * This is the right entry point for "binary X with flag/arg Y" rules: a guard - * reads `binary === 'codex'` segments and inspects their `args`, instead of - * regex-matching `--write` anywhere in the raw command (which trips on the flag - * appearing in a path, a sibling command, or a quoted string). - */ -export function commandsFor(command: string, binary: string): Command[] { - return parseCommands(command).filter(c => c.binary === binary) -} - -/** - * True when any `binary` segment carries one of `flags` as an argument. Matches - * both the exact flag token (`--write`, `-w`) and the `--flag=value` form (so - * `--write=true` counts for `--write`). Bundled short flags (`-wf`) are NOT - * decomposed — list each short flag you care about. - */ -export function invocationHasFlag( - command: string, - binary: string, - flags: readonly string[], -): boolean { - const flagSet = new Set(flags) - return commandsFor(command, binary).some(c => - c.args.some(a => { - if (flagSet.has(a)) { - return true - } - const eq = a.indexOf('=') - return eq > 0 && flagSet.has(a.slice(0, eq)) - }), - ) -} - -/** - * True when the command uses indirection a static parser can't resolve to a - * concrete binary: a `$VAR`-sourced binary or an `eval`. A guard that wants to - * be strict (fail-closed on evasion attempts) can treat this as suspicious; a - * guard that wants to stay permissive can ignore it. - */ -export function hasOpaqueInvocation(command: string): boolean { - return parseCommands(command).some(c => c.viaVariable || c.viaEval) -} diff --git a/.claude/hooks/_shared/stop-reminder.mts b/.claude/hooks/_shared/stop-reminder.mts deleted file mode 100644 index 2e8712229..000000000 --- a/.claude/hooks/_shared/stop-reminder.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @file Shared scaffold for Stop-hook reminders. Most fleet reminders share the - * same shape: - * - * 1. Read the Stop payload JSON from stdin. - * 2. Read the most-recent assistant turn from the transcript. - * 3. Run a list of regex patterns against the (code-fence-stripped) text. - * 4. If any match, emit a stderr block summarizing the hits. - * 5. Always exit 0 (informational). This module factors that loop so each new - * reminder is just a name + env-var + pattern list. Keeps every hook under - * ~50 lines and ensures the harness contract (JSON parse, fail-open, - * code-fence strip) lives in one place. - */ - -import process from 'node:process' - -import { - readLastAssistantText, - readStdin, - stripCodeFences, - stripQuotedSpans, -} from './transcript.mts' - -/** - * Pull a ~80-char snippet around the match for the warning message. - */ -export function extractSnippet( - text: string, - index: number, - length: number, -): string { - const start = Math.max(0, index - 30) - const end = Math.min(text.length, index + length + 30) - const prefix = start > 0 ? '…' : '' - const suffix = end < text.length ? '…' : '' - return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix -} - -interface StopPayload { - readonly transcript_path?: string | undefined - readonly stop_hook_active?: boolean | undefined -} - -export interface RuleViolation { - readonly label: string - readonly regex: RegExp - readonly why: string -} - -export interface ReminderHit { - readonly label: string - readonly why: string - readonly snippet: string -} - -export interface ReminderConfig { - readonly name: string - readonly disabledEnvVar: string - readonly patterns: readonly RuleViolation[] - readonly closingHint?: string | undefined - /** - * Optional extra check, invoked after the regex sweep. Receives the - * code-fence-stripped text and returns any additional hits to merge with the - * regex matches. Use when the regex layer is insufficient (e.g. NLP - * modal-verb detection in judgment-reminder). - * - * Fail-open: if the check throws, the hook ignores it and reports only the - * regex hits. A buggy extra-check must not block the rest of the warning - * surface. - */ - readonly extraCheck?: - | (( - text: string, - ) => readonly ReminderHit[] | Promise) - | undefined - /** - * When true, hits trigger a blocking Stop-hook decision so the assistant must - * continue the turn and address the matched phrase rather than ending on the - * excuse. The block is suppressed when Claude Code reports `stop_hook_active: - * true` to avoid loops. - */ - readonly blocking?: boolean | undefined - /** - * When true, strip ASCII / smart quoted spans from the scanned text before - * pattern-matching. Stop hooks that detect _meta-discussion_ of phrases (e.g. - * excuse-detector explaining what it detects) should enable this so the hook - * doesn't self-fire on its own changelog or post-mortem. Code-fence stripping - * is always on; this is the narrower, prose-only escape hatch. - */ - readonly stripQuotedSpans?: boolean | undefined -} - -/** - * Run a Stop-hook reminder. Reads stdin, scans the most-recent assistant turn, - * and writes hits to stderr. Always exits 0. - */ -export async function runStopReminder(config: ReminderConfig): Promise { - const payloadRaw = await readStdin() - if (process.env[config.disabledEnvVar]) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - - const rawText = readLastAssistantText(payload.transcript_path) - if (!rawText) { - process.exit(0) - } - const fencesStripped = stripCodeFences(rawText) - const text = config.stripQuotedSpans - ? stripQuotedSpans(fencesStripped) - : fencesStripped - - const hits: ReminderHit[] = [] - const { patterns } = config - const { length: patternsLength } = patterns - for (let i = 0; i < patternsLength; i += 1) { - const pattern = patterns[i]! - const match = pattern.regex.exec(text) - if (!match) { - continue - } - hits.push({ - label: pattern.label, - why: pattern.why, - snippet: extractSnippet(text, match.index, match[0].length), - }) - } - - if (config.extraCheck) { - try { - const extra = await config.extraCheck(text) - for ( - let i = 0, { length: extraLength } = extra; - i < extraLength; - i += 1 - ) { - hits.push(extra[i]!) - } - } catch { - // Fail-open: a buggy extra-check must not suppress the regex hits. - } - } - - if (hits.length === 0) { - process.exit(0) - } - - const lines = [ - `[${config.name}] Assistant turn matched reminder patterns:`, - '', - ...hits.flatMap(h => [` • "${h.label}" — ${h.snippet}`, ` ${h.why}`]), - ] - if (config.closingHint) { - lines.push('', ` ${config.closingHint}`) - } - lines.push('') - const message = lines.join('\n') - - // Blocking mode: emit a Stop-hook block decision so Claude must - // continue the turn and address the matched phrase. Suppressed - // when `stop_hook_active` is already set, to avoid loops. - if (config.blocking && !payload.stop_hook_active) { - const reason = - message + - '\nFix the underlying issue now (or, if it truly cannot be fixed in this session, ' + - 'say so explicitly with the trade-off — do not end the turn on the excuse phrase).' - process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n') - process.exit(0) - } - - process.stderr.write(message + '\n') - process.exit(0) -} diff --git a/.claude/hooks/_shared/test/fleet-repos.test.mts b/.claude/hooks/_shared/test/fleet-repos.test.mts deleted file mode 100644 index 96ef1e9d7..000000000 --- a/.claude/hooks/_shared/test/fleet-repos.test.mts +++ /dev/null @@ -1,97 +0,0 @@ -// node --test specs for the shared fleet-repos membership helpers. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - FLEET_REPO_NAMES, - isFleetRepo, - slugFromRemoteUrl, -} from '../fleet-repos.mts' - -test('FLEET_REPO_NAMES includes the broad membership set', () => { - // ultrathink is a fleet member but NOT in the cascade roster - // (fleet-repos.json) — the broad set must carry it. - assert.ok(FLEET_REPO_NAMES.includes('ultrathink')) - assert.ok(FLEET_REPO_NAMES.includes('socket-cli')) - assert.ok(FLEET_REPO_NAMES.includes('socket-wheelhouse')) -}) - -test('FLEET_REPO_NAMES is sorted + has no duplicates', () => { - const sorted = [...FLEET_REPO_NAMES].toSorted() - assert.deepStrictEqual([...FLEET_REPO_NAMES], sorted) - assert.strictEqual(new Set(FLEET_REPO_NAMES).size, FLEET_REPO_NAMES.length) -}) - -test('isFleetRepo: member names pass', () => { - assert.ok(isFleetRepo('socket-cli')) - assert.ok(isFleetRepo('ultrathink')) -}) - -test('isFleetRepo: case-insensitive', () => { - assert.ok(isFleetRepo('Socket-CLI')) - assert.ok(isFleetRepo('ULTRATHINK')) -}) - -test('isFleetRepo: non-members fail', () => { - assert.ok(!isFleetRepo('depot')) - assert.ok(!isFleetRepo('some-personal-repo')) - assert.ok(!isFleetRepo('')) -}) - -test('slugFromRemoteUrl: SSH scp-like form', () => { - assert.strictEqual( - slugFromRemoteUrl('git@github.com:SocketDev/socket-cli.git'), - 'socket-cli', - ) -}) - -test('slugFromRemoteUrl: SSH URL form', () => { - assert.strictEqual( - slugFromRemoteUrl('ssh://git@github.com/SocketDev/socket-lib.git'), - 'socket-lib', - ) -}) - -test('slugFromRemoteUrl: HTTPS form with .git', () => { - assert.strictEqual( - slugFromRemoteUrl('https://github.com/SocketDev/ultrathink.git'), - 'ultrathink', - ) -}) - -test('slugFromRemoteUrl: HTTPS form without .git', () => { - assert.strictEqual( - slugFromRemoteUrl('https://github.com/SocketDev/depot'), - 'depot', - ) -}) - -test('slugFromRemoteUrl: trailing slash tolerated', () => { - assert.strictEqual( - slugFromRemoteUrl('https://github.com/SocketDev/depot/'), - 'depot', - ) -}) - -test('slugFromRemoteUrl: lowercases the slug', () => { - assert.strictEqual( - slugFromRemoteUrl('git@github.com:SocketDev/Socket-CLI.git'), - 'socket-cli', - ) -}) - -test('slugFromRemoteUrl: a fork under a different owner still yields the slug', () => { - // Owner is dropped on purpose — a fork is still not a fleet push target, - // and isFleetRepo keys on the bare name. - assert.strictEqual( - slugFromRemoteUrl('git@github.com:someuser/socket-cli.git'), - 'socket-cli', - ) -}) - -test('slugFromRemoteUrl: unrecognized input → undefined', () => { - assert.strictEqual(slugFromRemoteUrl(''), undefined) - assert.strictEqual(slugFromRemoteUrl(' '), undefined) - assert.strictEqual(slugFromRemoteUrl('not-a-url'), undefined) -}) diff --git a/.claude/hooks/_shared/test/shell-command.test.mts b/.claude/hooks/_shared/test/shell-command.test.mts deleted file mode 100644 index 2d8b6ff44..000000000 --- a/.claude/hooks/_shared/test/shell-command.test.mts +++ /dev/null @@ -1,140 +0,0 @@ -// node --test specs for the shared shell-command parser util. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - commandsFor, - findInvocation, - hasOpaqueInvocation, - invocationHasFlag, - parseCommands, -} from '../shell-command.mts' - -test('parseCommands: simple command → binary + args', () => { - const [cmd] = parseCommands('git push origin main') - assert.strictEqual(cmd!.binary, 'git') - assert.deepStrictEqual(cmd!.args, ['push', 'origin', 'main']) - assert.strictEqual(cmd!.viaVariable, false) - assert.strictEqual(cmd!.viaEval, false) -}) - -test('parseCommands: leading assignments are separated from the binary', () => { - const [cmd] = parseCommands('A=1 B=2 git push') - assert.deepStrictEqual(cmd!.assignments, ['A=1', 'B=2']) - assert.strictEqual(cmd!.binary, 'git') - assert.deepStrictEqual(cmd!.args, ['push']) -}) - -test('parseCommands: && / ; / | split into separate segments', () => { - const cmds = parseCommands('cd /x && git push ; echo done | cat') - const bins = cmds.map(c => c.binary) - assert.ok(bins.includes('cd')) - assert.ok(bins.includes('git')) - assert.ok(bins.includes('echo')) - assert.ok(bins.includes('cat')) -}) - -test('parseCommands: $(…) substitution surfaces the inner command', () => { - const cmds = parseCommands('git $(printf push)') - const bins = cmds.map(c => c.binary) - assert.ok(bins.includes('git')) - assert.ok(bins.includes('printf')) -}) - -test('parseCommands: comments dropped', () => { - const cmds = parseCommands('git push # remember to do this') - assert.strictEqual(cmds.length, 1) - assert.strictEqual(cmds[0]!.binary, 'git') -}) - -test('findInvocation: matches plain git push', () => { - assert.ok(findInvocation('git push origin main', { binary: 'git', subcommand: 'push' })) -}) - -test('findInvocation: matches git -C push (subcommand after option value)', () => { - assert.ok(findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' })) -}) - -test('findInvocation: matches git -c k=v push', () => { - assert.ok(findInvocation('git -c foo=bar push', { binary: 'git', subcommand: 'push' })) -}) - -test('findInvocation: matches push reached via && chain', () => { - assert.ok( - findInvocation('cd /x/depot && git push', { binary: 'git', subcommand: 'push' }), - ) -}) - -test('findInvocation: matches push in a pipe chain', () => { - assert.ok( - findInvocation('ls | grep x && git push', { binary: 'git', subcommand: 'push' }), - ) -}) - -test('findInvocation: a different subcommand does not match', () => { - assert.ok(!findInvocation('git status', { binary: 'git', subcommand: 'push' })) -}) - -test('findInvocation: quoted "git push" in a commit message is NOT a push', () => { - assert.ok( - !findInvocation('git commit -m "remember to git push later"', { - binary: 'git', - subcommand: 'push', - }), - ) -}) - -test('findInvocation: binary-only query (no subcommand)', () => { - assert.ok(findInvocation('gh auth status', { binary: 'gh' })) - assert.ok(!findInvocation('git status', { binary: 'gh' })) -}) - -test('hasOpaqueInvocation: eval flagged', () => { - assert.ok(hasOpaqueInvocation('eval "git push"')) -}) - -test('hasOpaqueInvocation: $VAR-sourced binary flagged', () => { - assert.ok(hasOpaqueInvocation('g=git; $g push')) -}) - -test('hasOpaqueInvocation: plain command is not opaque', () => { - assert.ok(!hasOpaqueInvocation('git push origin main')) -}) - -test('parseCommands: empty / unparseable input → empty list, no throw', () => { - assert.deepStrictEqual(parseCommands(''), []) -}) - -test('commandsFor: returns matching segments with args', () => { - const cmds = commandsFor('codex --write "do the thing"', 'codex') - assert.strictEqual(cmds.length, 1) - assert.ok(cmds[0]!.args.includes('--write')) -}) - -test('commandsFor: binary-in-a-path is NOT the binary', () => { - // `codex-no-write-guard` as a path token must not count as invoking codex. - assert.deepStrictEqual(commandsFor('ls codex-no-write-guard/', 'codex'), []) - assert.deepStrictEqual( - commandsFor('grep -n "codex --write" file.mts', 'codex'), - [], - ) -}) - -test('invocationHasFlag: exact flag', () => { - assert.ok(invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w'])) - assert.ok(invocationHasFlag('codex -w prompt', 'codex', ['--write', '-w'])) -}) - -test('invocationHasFlag: --flag=value form', () => { - assert.ok(invocationHasFlag('codex --write=true x', 'codex', ['--write'])) -}) - -test('invocationHasFlag: flag only inside a quoted string does NOT count', () => { - // the flag is part of an arg STRING to a different binary - assert.ok(!invocationHasFlag('echo "codex --write"', 'codex', ['--write'])) -}) - -test('invocationHasFlag: flag on a different binary does NOT count', () => { - assert.ok(!invocationHasFlag('rm --write-protect x', 'codex', ['--write'])) -}) diff --git a/.claude/hooks/_shared/test/transcript.test.mts b/.claude/hooks/_shared/test/transcript.test.mts deleted file mode 100644 index c68a61867..000000000 --- a/.claude/hooks/_shared/test/transcript.test.mts +++ /dev/null @@ -1,280 +0,0 @@ -// node --test specs for the shared transcript helper. -// -// Run from this dir: -// node --test test/*.test.mts - -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - bypassPhrasePresent, - readLastAssistantText, - readUserText, -} from '../transcript.mts' - -function writeTranscript(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'transcript-test-')) - const file = path.join(dir, 'session.jsonl') - writeFileSync(file, content) - return file -} - -function cleanup(file: string): void { - rmSync(path.dirname(file), { recursive: true, force: true }) -} - -test('readUserText: undefined path returns empty', () => { - assert.equal(readUserText(undefined), '') -}) - -test('readUserText: missing file returns empty', () => { - assert.equal(readUserText('/tmp/does-not-exist-xyz.jsonl'), '') -}) - -test('readUserText: bare role+content shape', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'hello' }), - JSON.stringify({ role: 'assistant', content: 'hi' }), - ].join('\n'), - ) - try { - assert.equal(readUserText(f), 'hello') - } finally { - cleanup(f) - } -}) - -test('readUserText: nested message.content string shape', () => { - const f = writeTranscript( - JSON.stringify({ - type: 'user', - message: { role: 'user', content: 'nested text' }, - }), - ) - try { - assert.equal(readUserText(f), 'nested text') - } finally { - cleanup(f) - } -}) - -test('readUserText: array-of-blocks content shape', () => { - const f = writeTranscript( - JSON.stringify({ - type: 'user', - message: { - content: [ - { type: 'text', text: 'block one' }, - { type: 'text', text: 'block two' }, - ], - }, - }), - ) - try { - assert.equal(readUserText(f), 'block one\nblock two') - } finally { - cleanup(f) - } -}) - -test('readUserText: skips assistant turns', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'user one' }), - JSON.stringify({ role: 'assistant', content: 'assistant one' }), - JSON.stringify({ role: 'user', content: 'user two' }), - ].join('\n'), - ) - try { - assert.equal(readUserText(f), 'user one\nuser two') - } finally { - cleanup(f) - } -}) - -test('readUserText: skips malformed JSON lines', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'good' }), - 'not json', - JSON.stringify({ role: 'user', content: 'also good' }), - ].join('\n'), - ) - try { - assert.equal(readUserText(f), 'good\nalso good') - } finally { - cleanup(f) - } -}) - -test('readUserText: lookbackUserTurns=1 returns only the most-recent user turn', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'first' }), - JSON.stringify({ role: 'assistant', content: 'reply' }), - JSON.stringify({ role: 'user', content: 'second' }), - JSON.stringify({ role: 'assistant', content: 'reply' }), - JSON.stringify({ role: 'user', content: 'third' }), - ].join('\n'), - ) - try { - assert.equal(readUserText(f, 1), 'third') - } finally { - cleanup(f) - } -}) - -test('readUserText: lookbackUserTurns=2 returns the two most-recent user turns', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'first' }), - JSON.stringify({ role: 'user', content: 'second' }), - JSON.stringify({ role: 'user', content: 'third' }), - ].join('\n'), - ) - try { - // Reversed in chronological order at return time. - assert.equal(readUserText(f, 2), 'second\nthird') - } finally { - cleanup(f) - } -}) - -test('bypassPhrasePresent: finds the phrase', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'Allow revert bypass please' }), - ) - try { - assert.equal(bypassPhrasePresent(f, 'Allow revert bypass'), true) - } finally { - cleanup(f) - } -}) - -test('bypassPhrasePresent: case-sensitive (lowercase does not count)', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'allow revert bypass please' }), - ) - try { - assert.equal(bypassPhrasePresent(f, 'Allow revert bypass'), false) - } finally { - cleanup(f) - } -}) - -test('bypassPhrasePresent: paraphrase does not count', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'please revert that' }), - ) - try { - assert.equal(bypassPhrasePresent(f, 'Allow revert bypass'), false) - } finally { - cleanup(f) - } -}) - -test('bypassPhrasePresent: missing transcript returns false', () => { - assert.equal(bypassPhrasePresent(undefined, 'Allow revert bypass'), false) -}) - -test('bypassPhrasePresent: array of equivalent spellings — any matches', () => { - const variants = [ - 'Allow soaktime bypass', - 'Allow soak time bypass', - 'Allow soak-time bypass', - ] - for (let i = 0, { length } = variants; i < length; i += 1) { - const present = variants[i]! - const f = writeTranscript( - JSON.stringify({ role: 'user', content: `please ${present} now` }), - ) - try { - assert.equal(bypassPhrasePresent(f, variants), true) - } finally { - cleanup(f) - } - } -}) - -test('bypassPhrasePresent: array — none matches', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'please bypass the soak rule' }), - ) - try { - assert.equal( - bypassPhrasePresent(f, [ - 'Allow soaktime bypass', - 'Allow soak time bypass', - 'Allow soak-time bypass', - ]), - false, - ) - } finally { - cleanup(f) - } -}) - -test('bypassPhrasePresent: empty array returns false', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'Allow anything bypass' }), - ) - try { - assert.equal(bypassPhrasePresent(f, []), false) - } finally { - cleanup(f) - } -}) - -test('readLastAssistantText: returns most-recent assistant turn', () => { - const f = writeTranscript( - [ - JSON.stringify({ role: 'user', content: 'user one' }), - JSON.stringify({ role: 'assistant', content: 'assistant one' }), - JSON.stringify({ role: 'user', content: 'user two' }), - JSON.stringify({ role: 'assistant', content: 'assistant two' }), - ].join('\n'), - ) - try { - assert.equal(readLastAssistantText(f), 'assistant two') - } finally { - cleanup(f) - } -}) - -test('readLastAssistantText: returns empty when no assistant turn', () => { - const f = writeTranscript( - JSON.stringify({ role: 'user', content: 'user only' }), - ) - try { - assert.equal(readLastAssistantText(f), '') - } finally { - cleanup(f) - } -}) - -test('readLastAssistantText: handles array-of-blocks shape', () => { - const f = writeTranscript( - JSON.stringify({ - type: 'assistant', - message: { - content: [ - { type: 'text', text: 'block one' }, - { type: 'text', text: 'block two' }, - ], - }, - }), - ) - try { - assert.equal(readLastAssistantText(f), 'block one\nblock two') - } finally { - cleanup(f) - } -}) - -test('readLastAssistantText: undefined path returns empty', () => { - assert.equal(readLastAssistantText(undefined), '') -}) diff --git a/.claude/hooks/_shared/token-patterns.mts b/.claude/hooks/_shared/token-patterns.mts deleted file mode 100644 index bd91c3fdf..000000000 --- a/.claude/hooks/_shared/token-patterns.mts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @file Shared catalog of secret-bearing env-var key names. Used by every hook - * that scans for accidentally-checked-in or accidentally-printed - * credentials: - * - * - token-guard (Bash): blocks commands that print these to stdout. - * - no-token-in-dotenv-guard (Edit|Write): blocks writing these to `.env` / - * `.env.local` / similar dotfiles. - * - (future) repo-wide secret scanner: same catalog feeds a scripts/ gate that - * walks the working tree at commit time. Keep the catalog narrow + - * auditable. Adding a name here means every consumer will scan for it; - * false-positives on legitimate config keys (e.g. `FOO_API_VERSION=2.1`) - * are real friction. Names follow the published env-var convention of each - * tool — when in doubt, prefer the official docs over guessed shapes. - * Layout: - * - Per-category arrays so consumers can opt out of specific categories if - * needed (e.g. an AWS-only repo might not care about Linear). - * - `ALL_TOKEN_KEY_PATTERNS` is the flat union used by default. - * - `GENERIC_TOKEN_SUFFIX_RE` catches anything ending in `_TOKEN` / `_KEY` / - * `_SECRET` after the named lists; consumers decide whether to include it. - * The trade-off: catches more leaks but also fires on - * `JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY----` etc. The named lists are the - * recommended primary pass. If you need to add a name, add it to the - * matching category. If the category doesn't exist yet, add it (with a - * comment naming the vendor / product) — don't dump it into MISC. - */ - -// ── Socket fleet ───────────────────────────────────────────────────── -export const SOCKET_FLEET_TOKEN_PATTERNS: readonly RegExp[] = [ - /^SOCKET_API_(?:KEY|TOKEN)$/, - /^SOCKET_CLI_API_(?:KEY|TOKEN)$/, - /^SOCKET_SECURITY_API_(?:KEY|TOKEN)$/, -] - -// ── LLM providers ──────────────────────────────────────────────────── -// Each entry uses the vendor's published env-var name. CLAUDE_API_KEY -// is included alongside ANTHROPIC_API_KEY because the older `claude` -// CLI variants still ship docs referencing it. -export const LLM_TOKEN_PATTERNS: readonly RegExp[] = [ - /^ANTHROPIC_API_KEY$/, - /^CLAUDE_API_KEY$/, - /^OPENAI_API_KEY$/, - /^OPENAI_ORG_ID$/, - /^OPENAI_PROJECT_ID$/, - /^GEMINI_API_KEY$/, - /^GOOGLE_AI_(?:API_KEY|STUDIO_KEY)$/, - /^COHERE_API_KEY$/, - /^MISTRAL_API_KEY$/, - /^GROQ_API_KEY$/, - /^TOGETHER_API_KEY$/, - /^FIREWORKS_API_KEY$/, - /^PERPLEXITY_API_KEY$/, - /^OPENROUTER_API_KEY$/, - /^DEEPSEEK_API_KEY$/, - /^XAI_API_KEY$/, -] - -// ── Source control / code hosting ─────────────────────────────────── -export const VCS_TOKEN_PATTERNS: readonly RegExp[] = [ - /^GH_TOKEN$/, - /^GITHUB_(?:PAT|TOKEN)$/, - /^GITLAB_(?:PAT|PRIVATE_TOKEN|TOKEN)$/, - /^BITBUCKET_(?:APP_PASSWORD|TOKEN)$/, -] - -// ── Product tracking / docs ────────────────────────────────────────── -export const PRODUCT_TOKEN_PATTERNS: readonly RegExp[] = [ - /^LINEAR_API_(?:KEY|TOKEN)$/, - /^NOTION_(?:API_KEY|API_TOKEN|INTEGRATION_TOKEN|TOKEN)$/, - /^JIRA_API_(?:KEY|TOKEN)$/, - /^ATLASSIAN_API_(?:KEY|TOKEN)$/, - /^CONFLUENCE_API_(?:KEY|TOKEN)$/, - /^ASANA_(?:ACCESS_TOKEN|API_TOKEN|PERSONAL_ACCESS_TOKEN|TOKEN)$/, - /^TRELLO_(?:API_KEY|API_TOKEN|TOKEN)$/, - /^MONDAY_API_(?:KEY|TOKEN)$/, -] - -// ── Chat / comms ───────────────────────────────────────────────────── -export const CHAT_TOKEN_PATTERNS: readonly RegExp[] = [ - /^SLACK_(?:APP_TOKEN|BOT_TOKEN|SIGNING_SECRET|TOKEN|USER_TOKEN|WEBHOOK_URL)$/, - /^DISCORD_(?:BOT_TOKEN|TOKEN|WEBHOOK_URL)$/, - /^TELEGRAM_BOT_TOKEN$/, - /^TWILIO_(?:API_KEY|API_SECRET|AUTH_TOKEN)$/, -] - -// ── Cloud providers ────────────────────────────────────────────────── -export const CLOUD_TOKEN_PATTERNS: readonly RegExp[] = [ - /^AWS_(?:ACCESS|SECRET)_(?:ACCESS_KEY|KEY_ID)$/, - /^AWS_SESSION_TOKEN$/, - /^GCP_API_KEY$/, - /^GOOGLE_(?:APPLICATION_CREDENTIALS|CLIENT_SECRET)$/, - /^AZURE_(?:API_KEY|CLIENT_SECRET)$/, - /^DO_(?:ACCESS|API)_TOKEN$/, - /^CLOUDFLARE_(?:API_KEY|API_TOKEN)$/, - /^FLY_API_TOKEN$/, - /^HEROKU_API_KEY$/, -] - -// ── Package registries ────────────────────────────────────────────── -export const REGISTRY_TOKEN_PATTERNS: readonly RegExp[] = [ - /^NPM_TOKEN$/, - /^NODE_AUTH_TOKEN$/, - /^PYPI_(?:API_TOKEN|TOKEN)$/, - /^CARGO_REGISTRY_TOKEN$/, - /^RUBYGEMS_(?:API_KEY|HOST)$/, - /^MAVEN_(?:PASSWORD|USERNAME)$/, -] - -// ── Payments / billing ────────────────────────────────────────────── -export const PAYMENT_TOKEN_PATTERNS: readonly RegExp[] = [ - /^STRIPE_(?:API|PUBLISHABLE|RESTRICTED|SECRET)_KEY$/, - /^SQUARE_ACCESS_TOKEN$/, - /^PAYPAL_(?:API_KEY|CLIENT_SECRET)$/, -] - -// ── Email / messaging providers ───────────────────────────────────── -export const EMAIL_TOKEN_PATTERNS: readonly RegExp[] = [ - /^SENDGRID_API_KEY$/, - /^MAILGUN_API_KEY$/, - /^POSTMARK_(?:API_TOKEN|SERVER_TOKEN)$/, - /^RESEND_API_KEY$/, - /^MAILCHIMP_API_KEY$/, -] - -// ── Observability ─────────────────────────────────────────────────── -export const OBSERVABILITY_TOKEN_PATTERNS: readonly RegExp[] = [ - /^DATADOG_(?:API_KEY|APP_KEY)$/, - /^SENTRY_(?:AUTH_TOKEN|DSN)$/, - /^NEW_RELIC_(?:API_KEY|LICENSE_KEY)$/, - /^HONEYCOMB_API_KEY$/, - /^GRAFANA_API_KEY$/, - /^LOGTAIL_(?:API_KEY|TOKEN)$/, -] - -// ── CI providers ──────────────────────────────────────────────────── -export const CI_TOKEN_PATTERNS: readonly RegExp[] = [ - /^CIRCLECI_(?:API_TOKEN|TOKEN)$/, - /^TRAVIS_API_TOKEN$/, - /^BUILDKITE_API_TOKEN$/, - /^DRONE_(?:API_TOKEN|TOKEN)$/, -] - -/** - * Flat union of every named category above. Default catalog for consumers that - * don't need per-category granularity. - */ -export const ALL_TOKEN_KEY_PATTERNS: readonly RegExp[] = [ - ...SOCKET_FLEET_TOKEN_PATTERNS, - ...LLM_TOKEN_PATTERNS, - ...VCS_TOKEN_PATTERNS, - ...PRODUCT_TOKEN_PATTERNS, - ...CHAT_TOKEN_PATTERNS, - ...CLOUD_TOKEN_PATTERNS, - ...REGISTRY_TOKEN_PATTERNS, - ...PAYMENT_TOKEN_PATTERNS, - ...EMAIL_TOKEN_PATTERNS, - ...OBSERVABILITY_TOKEN_PATTERNS, - ...CI_TOKEN_PATTERNS, -] - -/** - * Fallback: anything that _looks_ like a token by suffix. Catches vendors not - * in the named lists at the cost of false-positives on things like - * `JWT_PUBLIC_KEY` (which is decidedly NOT a secret). Consumers should use this - * as an additional pass after the named lists, not in place of them. - * - * The shape: `__` — at least one - * underscore-separated qualifier word in front of the suffix to avoid matching - * bare `KEY=`/`TOKEN=` keys (which are usually loop indices, not secrets). - */ -export const GENERIC_TOKEN_SUFFIX_RE = - /^[A-Z_]*(?:ACCESS|API|AUTH|BOT|CLIENT|PRIVATE|SECRET|SESSION|WEBHOOK)_(?:KEY|SECRET|TOKEN)$/ - -/** - * Convenience: returns true if the given key name matches any pattern in - * `ALL_TOKEN_KEY_PATTERNS`. Doesn't include the generic suffix fallback — - * callers that want it should test `isTokenKey(key) || - * GENERIC_TOKEN_SUFFIX_RE.test(key)`. - */ -export function isTokenKey(key: string): boolean { - for (let i = 0, { length } = ALL_TOKEN_KEY_PATTERNS; i < length; i += 1) { - const re = ALL_TOKEN_KEY_PATTERNS[i]! - if (re.test(key)) { - return true - } - } - return false -} - -/** - * Substring fragments matched case-insensitively against Bash command text by - * `token-guard`. Different shape from `ALL_TOKEN_KEY_PATTERNS`: those match a - * parsed KEY= identifier exactly, these match anywhere in arbitrary command - * text (`curl -H "Authorization: $TOKEN"` → matches "TOKEN" → flag for - * inspection). - * - * Kept short to minimize false positives. A "PASSWORD" mention in a - * commit-message body would otherwise trip every commit, so token-guard - * narrows matches to assignment / flag-value positions rather than any - * occurrence in arbitrary text. - */ -export const SENSITIVE_NAME_FRAGMENTS: readonly string[] = [ - 'TOKEN', - 'SECRET', - 'PASSWORD', - 'PASS', - 'API_KEY', - 'APIKEY', - 'SIGNING_KEY', - 'PRIVATE_KEY', - 'AUTH', - 'CREDENTIAL', -] diff --git a/.claude/hooks/_shared/transcript.mts b/.claude/hooks/_shared/transcript.mts deleted file mode 100644 index ac7fcf4c6..000000000 --- a/.claude/hooks/_shared/transcript.mts +++ /dev/null @@ -1,502 +0,0 @@ -/** - * @file Shared helpers for Claude Code PreToolUse / Stop hooks. Two - * responsibilities the fleet's hooks were each duplicating: - * - * 1. `readStdin()` — pull the JSON payload Claude Code sends on stdin. Always - * the same shape, always the same code. - * 2. `bypassPhrasePresent()` / `readUserText()` — scan the conversation - * transcript JSONL for a canonical `Allow bypass` phrase. The - * transcript format has 3 variant shapes across harness versions; - * centralizing the parser means a schema change is a one-file fix. Why one - * file: KISS. Both helpers want the same imports (`node:fs` + the JSONL - * parser); separating into two files would just shuffle imports. The file - * is small (~100 LOC) so cohesion wins. Fail-open contract: every helper - * here returns a safe default on any parse / I/O error rather than - * throwing. A hook that crashes blocks every Claude Code call - * indefinitely; one that returns "no bypass present" or "empty user text" - * simply falls through to the hook's default decision. Per the fleet's - * hook contract: "a buggy hook silently allows" is preferable to "a buggy - * hook wedges the session." - */ - -import { existsSync, readFileSync } from 'node:fs' - -/** - * Is any canonical bypass phrase present in a recent user turn? Substring - * match, case-sensitive (intentional — `allow X bypass` lowercase doesn't - * count, matches the fleet rule stated in docs/claude.md/bypass-phrases.md). - * - * Accepts a string or string[] so callers with a single canonical spelling and - * callers with equivalent spellings (e.g. "soaktime" / "soak time" / - * "soak-time") share the same helper. The transcript is read once; each phrase - * substring-checks against the same text. - * - * Use this when the bypass is **broad** — one phrase authorizes any matching - * action for the rest of the conversation window. For **per-trigger** - * authorization (one phrase = one action), use `bypassPhraseRemaining` instead - * so a single phrase doesn't open the door for a follow-up action of the same - * shape later. - */ -/** - * Normalize a bypass phrase / haystack so hyphens and runs of whitespace - * collapse to a single space. `Allow workflow-scope bypass`, `Allow workflow - * scope bypass`, and `Allow workflow—scope bypass` all collapse to the same - * canonical form for matching. The transcript-reading helpers run user text - * through this so minor punctuation variations don't break the bypass match. - */ -function normalizeBypassText(text: string): string { - // NFKC: canonical-decompose + compose + compatibility-fold so - // visually-similar variants collapse — smart quotes, full-width, - // ligatures all map to ASCII-canonical. - // \p{Cf} strip: format / zero-width / bidi-override chars are removed - // so an attacker can't inject a benign-rendering turn that contains - // the bypass phrase only after invisible chars are stripped — nor - // can a user accidentally type a phrase that fails to match because - // an editor inserted a zero-width-space. - return text - .normalize('NFKC') - .replace(/\p{Cf}/gu, '') - .replace(/[-—–\s]+/g, ' ') -} - -export function bypassPhrasePresent( - transcriptPath: string | undefined, - phrases: string | readonly string[], - lookbackUserTurns?: number | undefined, -): boolean { - const list = typeof phrases === 'string' ? [phrases] : phrases - const { length } = list - if (length === 0) { - return false - } - const text = readUserText(transcriptPath, lookbackUserTurns) - if (!text) { - return false - } - const haystack = normalizeBypassText(text) - for (let i = 0; i < length; i += 1) { - const needle = normalizeBypassText(list[i]!) - if (haystack.includes(needle)) { - return true - } - } - return false -} - -/** - * Returns the count of bypass phrases NOT YET CONSUMED by prior actions. The - * caller supplies `priorActionCount` — usually a count of past tool-use - * invocations that would have consumed a phrase if it had been present. The - * phrase budget is replenished by every fresh user-typed occurrence. - * - * Remaining = phraseCount - priorActionCount remaining > 0 → caller may proceed - * (one slot consumed by this action) remaining <= 0 → caller must block; phrase - * budget exhausted. - * - * Per-trigger semantics: a single `Allow X bypass` authorizes exactly one - * action of the gated shape. To do a second, the user types the phrase again. - * - * For workflow_dispatch and similar "name the target" bypasses, the phrase - * format is `Allow bypass: ` and the caller passes only - * target-matching phrases. - */ -export function bypassPhraseRemaining( - transcriptPath: string | undefined, - phrases: string | readonly string[], - priorActionCount: number, - lookbackUserTurns?: number | undefined, -): number { - const phraseCount = countBypassPhrases( - transcriptPath, - phrases, - lookbackUserTurns, - ) - return phraseCount - priorActionCount -} - -/** - * Count the number of bypass-phrase occurrences in recent user turns. Each - * occurrence is a separate authorization slot — the user typing the phrase - * twice authorizes two actions, not one. - * - * Substring-counted, non-overlapping (each match consumes its own span of - * characters), case-sensitive. Multiple accepted spellings (`phrases: - * string[]`) each contribute their own count. - * - * Use with `bypassPhraseRemaining(...) > 0` to gate one-time bypasses where the - * hook also tracks prior consumption (e.g. count of prior workflow_dispatch - * invocations of the same workflow in the assistant tool-use history). - */ -export function countBypassPhrases( - transcriptPath: string | undefined, - phrases: string | readonly string[], - lookbackUserTurns?: number | undefined, -): number { - const list = typeof phrases === 'string' ? [phrases] : phrases - const { length } = list - if (length === 0) { - return 0 - } - const rawText = readUserText(transcriptPath, lookbackUserTurns) - if (!rawText) { - return 0 - } - // Normalize hyphens / em-dashes / runs of whitespace to single - // spaces so `Allow workflow-scope bypass` and `Allow workflow scope - // bypass` match the same phrase. Indices below run in the - // normalized string's coordinate space. - const text = normalizeBypassText(rawText) - // Track which `[start, end)` spans were already counted by a prior - // phrase so a shorter phrase that's a substring of a longer one - // doesn't double-count (e.g. `Allow workflow-dispatch bypass: build` - // shouldn't match again inside `Allow workflow-dispatch bypass: - // build.yml`). Sort longest-first so the more specific phrase - // claims the span first. - const sorted = [...list] - .filter(p => p) - .map(p => normalizeBypassText(p)) - .toSorted((a, b) => b.length - a.length) - const claimed: Array<[number, number]> = [] - let total = 0 - for (let i = 0, sortedLen = sorted.length; i < sortedLen; i += 1) { - const phrase = sorted[i]! - let idx = 0 - while ((idx = text.indexOf(phrase, idx)) !== -1) { - const start = idx - const end = idx + phrase.length - const overlaps = claimed.some(([cs, ce]) => start < ce && end > cs) - if (!overlaps) { - // Word-boundary check on the trailing edge: the char right - // after `end` must not be an identifier char (alnum / . / -), - // otherwise we matched a prefix of a longer token (e.g. - // "build" inside "build.yml" without the longer phrase - // having claimed it for whatever reason). - const next = text.charCodeAt(end) - // 0–9 (48–57), A–Z (65–90), a–z (97–122), `-` (45), `.` (46), `_` (95) - const isIdentChar = - (next >= 48 && next <= 57) || - (next >= 65 && next <= 90) || - (next >= 97 && next <= 122) || - next === 45 || - next === 46 || - next === 95 - if (!isIdentChar) { - total += 1 - claimed.push([start, end]) - } - } - idx = end - } - } - return total -} - -/** - * Inverse of `stripCodeFences`: extract the contents of fenced code blocks. - * Returns each block's body (the lines between the opening and closing fence) - * as a separate string. The leading language tag (e.g. ` ```ts `) is stripped — - * only the code lines are kept. - * - * Used by hooks (error-message-quality-reminder) that need to inspect the code - * the assistant wrote rather than the prose around it. - */ -export interface CodeFence { - lang: string - body: string -} - -export function extractCodeFences(text: string): CodeFence[] { - const out: CodeFence[] = [] - // Match ```optional-lang\n...code...\n``` - // The lang tag is optional; the content is anything (non-greedy) up - // to the closing fence. We're permissive — bad markdown still gets - // captured as a block. - const re = /```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g - let match: RegExpExecArray | null - while ((match = re.exec(text)) !== null) { - const body = match[2] - if (body !== undefined) { - out.push({ lang: match[1] ?? '', body }) - } - } - return out -} - -/** - * Shape of a tool-use event extracted from an assistant turn. The harness emits - * these as content blocks with `type: 'tool_use'`, carrying the tool name (e.g. - * 'Write', 'Edit', 'Bash') and the structured `input` object passed to that - * tool. - * - * Inputs are intentionally typed `Record` because each tool - * has its own schema and we don't want to enumerate them here. Callers narrow - * on `name` and inspect the fields they care about (e.g. `input.file_path` for - * Write/Edit). - */ -export interface ToolUseEvent { - readonly name: string - readonly input: Record -} - -/** - * Extract tool-use blocks from a single turn's content array. Skips - * non-tool-use blocks (text, etc.) and ignores malformed entries. - */ -export function extractToolUseBlocks(content: unknown): ToolUseEvent[] { - if (!Array.isArray(content)) { - return [] - } - const out: ToolUseEvent[] = [] - for (let i = 0, { length } = content; i < length; i += 1) { - const block = content[i] - if (!block || typeof block !== 'object') { - continue - } - const b = block as Record - if (b['type'] !== 'tool_use') { - continue - } - const name = typeof b['name'] === 'string' ? b['name'] : undefined - const input = b['input'] - if (!name || !input || typeof input !== 'object') { - continue - } - out.push({ name, input: input as Record }) - } - return out -} - -type Role = 'user' | 'assistant' - -/** - * Extract this turn's text content into a flat array of pieces. Handles the 3 - * content shapes the harness emits (string / array-of-blocks / nested - * message.content). - */ -export function extractTurnPieces(content: unknown): string[] { - const pieces: string[] = [] - if (typeof content === 'string') { - pieces.push(content) - } else if (Array.isArray(content)) { - for (let i = 0, { length } = content; i < length; i += 1) { - const block = content[i]! - if (typeof block === 'string') { - pieces.push(block) - } else if (block && typeof block === 'object') { - const b = block as Record - if (typeof b['text'] === 'string') { - pieces.push(b['text']) - } else if (typeof b['content'] === 'string') { - pieces.push(b['content']) - } - } - } - } - return pieces -} - -/** - * Read the most-recent assistant-turn text content. Same shape parser as - * `readUserText`; used by hooks (excuse-detector) that scan what the assistant - * just said rather than what the user typed. - */ -export function readLastAssistantText( - transcriptPath: string | undefined, -): string { - return readRoleText(transcriptPath, 'assistant', 1) -} - -/** - * Walk the transcript newest → oldest, return every tool-use event from the - * most recent assistant turn. Returns an empty array if the transcript is - * missing or the most recent assistant turn has no tool uses. Used by hooks - * that gate on what the assistant just did (e.g. file-size-reminder reading - * Write/Edit events). - */ -export function readLastAssistantToolUses( - transcriptPath: string | undefined, -): readonly ToolUseEvent[] { - const lines = readLines(transcriptPath) - for (let i = lines.length - 1; i >= 0; i -= 1) { - let evt: unknown - try { - evt = JSON.parse(lines[i]!) - } catch { - continue - } - const r = resolveRoleAndContent(evt) - if (!r || r.role !== 'assistant') { - continue - } - return extractToolUseBlocks(r.content) - } - return [] -} - -/** - * Read the transcript JSONL file into newline-filtered lines. Returns an empty - * array on missing path or read error — every caller in this module wants the - * same empty-on-failure semantics. - */ -export function readLines(transcriptPath: string | undefined): string[] { - if (!transcriptPath || !existsSync(transcriptPath)) { - return [] - } - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return [] - } - return raw.split('\n').filter(Boolean) -} - -/** - * Generic turn-walker: walk the transcript newest → oldest, collecting text - * from turns whose role matches `role`. Joins all turns' pieces with newlines - * and returns chronological order at the end. - * - * `lookback` (optional) limits the search to the most-recent N matching turns - * so callers don't pay the full-transcript cost when they only need recent - * context. - */ -export function readRoleText( - transcriptPath: string | undefined, - role: Role, - lookback?: number | undefined, -): string { - const lines = readLines(transcriptPath) - const out: string[] = [] - let matched = 0 - for (let i = lines.length - 1; i >= 0; i -= 1) { - let evt: unknown - try { - evt = JSON.parse(lines[i]!) - } catch { - continue - } - const r = resolveRoleAndContent(evt) - if (!r || r.role !== role) { - continue - } - const pieces = extractTurnPieces(r.content) - if (pieces.length) { - // Buffer this turn's blocks together so the final reverse swaps - // *turn order*, not intra-turn block order. - out.push(pieces.join('\n')) - } - matched += 1 - if (lookback !== undefined && matched >= lookback) { - break - } - } - // Reverse to chronological order so substring matches that span - // multiple turns (rare) read naturally. - return out.toReversed().join('\n') -} - -/** - * Read the entire stdin buffer into a string. Used by every PreToolUse hook to - * slurp the JSON payload Claude Code sends. - */ -export function readStdin(): Promise { - return new Promise(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - buf += chunk - }) - process.stdin.on('end', () => resolve(buf)) - }) -} - -/** - * Read every user-turn text content from a transcript JSONL, joined by - * newlines. Returns empty string when the path is unset, missing, or - * unparseable. `lookbackUserTurns` limits to the most-recent N user turns - * (counted from the tail); omit to read all turns. - */ -export function readUserText( - transcriptPath: string | undefined, - lookbackUserTurns?: number | undefined, -): string { - return readRoleText(transcriptPath, 'user', lookbackUserTurns) -} - -/** - * Resolve a JSONL event's role (`'user'` / `'assistant'`) and content - * tolerantly across the 3 variant shapes seen in harness versions: - * - * { role: 'user', content: '...' } { type: 'user', message: { role: 'user', - * content: '...' } } { type: 'user', message: { content: [{ type: 'text', text: - * '...' }] } } - * - * Returns undefined for malformed events so the caller can skip cleanly. - */ -export function resolveRoleAndContent(evt: unknown): - | { - content: unknown - role: string | undefined - } - | undefined { - if (!evt || typeof evt !== 'object') { - return undefined - } - const e = evt as Record - const role = - typeof e['role'] === 'string' - ? e['role'] - : typeof e['type'] === 'string' - ? e['type'] - : undefined - const message = e['message'] - const content = - e['content'] ?? - (message && typeof message === 'object' - ? (message as Record)['content'] - : undefined) - return { content, role } -} - -/** - * Strip fenced code blocks (`…`) and inline code (`…`) from a text snapshot - * before pattern-matching. Assistant prose frequently quotes phrases as code - * examples (`` `out of scope` ``) which would otherwise false-positive phrase - * detectors. Cheap to run: two regex passes, O(n) over the input. - */ -export function stripCodeFences(text: string): string { - return text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ') -} - -/** - * Strip text that's clearly _quoted_ rather than asserted — i.e. text the - * assistant is referring to as a phrase, not using as one. Used by Stop hooks - * that scan for excuse phrases: a summary like when Claude says "pre-existing", - * … the hook now blocks mentions the trigger but isn't an excuse. Without this - * strip, the hook self-fires every time it explains itself. - * - * Heuristic: strip the contents of paired ASCII double-quotes (`"…"`), paired - * smart double-quotes (`"…"`), and the same for single quotes (`'…'`, `'…'`). - * Strips only short spans (<= 80 chars between the quote marks) so prose - * paragraphs with stray quotation marks don't disappear wholesale. Falls back - * to leaving the text alone if no matching close is found on the same line — - * quoted speech doesn't span paragraphs and a runaway match would erase real - * content. - * - * Combine with `stripCodeFences` for full noise filtering. Order doesn't matter - * (the two strip disjoint surfaces). - */ -export function stripQuotedSpans(text: string): string { - // ASCII double quotes: "…" — up to 80 chars, single line. - // ASCII single quotes: '…' — same constraint. Word-boundary - // gate on the opening quote so we don't strip apostrophes - // mid-word (e.g. "don't", "Claude's"). The closing quote can - // be followed by anything. - // Smart quotes get their own pass — Unicode codepoints don't fit - // the ASCII charset and benefit from a separate, simpler regex. - return text - .replace(/"[^"\n]{1,80}"/g, ' ') - .replace(/(^|[\s([{,;:>])'[^'\n]{1,80}'/g, '$1 ') - .replace(/“[^”\n]{1,80}”/g, ' ') - .replace(/‘[^’\n]{1,80}’/g, ' ') -} diff --git a/.claude/hooks/_shared/wheelhouse-root.mts b/.claude/hooks/_shared/wheelhouse-root.mts deleted file mode 100644 index 1fb71ae37..000000000 --- a/.claude/hooks/_shared/wheelhouse-root.mts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @file Locate socket-wheelhouse's source-of-truth tree from any fleet repo - * session. Hooks that enforce wheelhouse-level invariants (e.g. - * new-hook-claude-md-guard ensuring every fleet hook has a CLAUDE.md - * citation) need to read `template/CLAUDE.md` — the canonical fleet block — - * regardless of which session the assistant is operating from. - * CLAUDE_PROJECT_DIR points at the _session's_ project; that's socket-cli - * most of the time, not socket-wheelhouse. Resolution order: - * - * 1. The session's project dir IS socket-wheelhouse. - * 2. A sibling directory named `socket-wheelhouse` at `../`. - * 3. A grandparent layout (worktrees): `../../socket-wheelhouse`. - * 4. `$HOME/projects/socket-wheelhouse` — the documented fleet checkout layout. - * 5. `$SOCKET_WHEELHOUSE_DIR` env override — escape hatch for non-standard - * layouts. Returns the absolute path to the wheelhouse repo root (the dir - * containing `template/`), or `undefined` when none of the lookups - * resolves. Callers should fail-open on undefined (the hook can't enforce - * a rule it can't read). - */ - -import { existsSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -/** - * Walk the candidate list and return the first hit. Cheap — at most 5 file-stat - * probes, all on local disk. - */ -export function findWheelhouseRoot( - options: { startDir?: string | undefined } = {}, -): string | undefined { - const startDir = - options.startDir ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() - - // 1. Override via env var — used by CI / non-standard layouts. - const envOverride = process.env['SOCKET_WHEELHOUSE_DIR'] - if (envOverride && isWheelhouseRoot(envOverride)) { - return envOverride - } - - const candidates: string[] = [ - // 2. The session's project dir IS the wheelhouse. - startDir, - // 3. A sibling repo named socket-wheelhouse. - path.join(startDir, '..', 'socket-wheelhouse'), - // 4. Worktree layout — wheelhouse is two levels up. - path.join(startDir, '..', '..', 'socket-wheelhouse'), - // 5. Documented fleet layout under $HOME. - path.join(os.homedir(), 'projects', 'socket-wheelhouse'), - ] - - for (let i = 0, { length } = candidates; i < length; i += 1) { - const candidate = candidates[i]! - if (isWheelhouseRoot(candidate)) { - return path.resolve(candidate) - } - } - return undefined -} - -/** - * Convenience: return the path to `template/CLAUDE.md` if the wheelhouse can be - * located, else undefined. - */ -export function findWheelhouseTemplateClaudeMd( - options: { startDir?: string | undefined } = {}, -): string | undefined { - const root = findWheelhouseRoot(options) - if (!root) { - return undefined - } - return path.join(root, 'template', 'CLAUDE.md') -} - -/** - * Test whether `dir` is a socket-wheelhouse checkout. Looks for the - * `template/CLAUDE.md` byte-canonical marker — every wheelhouse has this file, - * downstream repos don't. - */ -export function isWheelhouseRoot(dir: string): boolean { - if (!existsSync(dir)) { - return false - } - return existsSync(path.join(dir, 'template', 'CLAUDE.md')) -} diff --git a/.claude/hooks/actionlint-on-workflow-edit/README.md b/.claude/hooks/actionlint-on-workflow-edit/README.md deleted file mode 100644 index 5482d655d..000000000 --- a/.claude/hooks/actionlint-on-workflow-edit/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# actionlint-on-workflow-edit - -PostToolUse Edit/Write hook that runs local `actionlint` against any -`.github/workflows/*.y*ml` file after the edit. Reports any actionlint -errors via stderr; never blocks (the edit already landed). - -## Why - -GitHub Actions' YAML parser fails silently — a malformed workflow shows -"0 jobs" on the next push with no error in the UI. `actionlint` catches -the same YAML / shell / SHA-pin issues locally, instantly. The fleet -already has actionlint installed on dev machines (homebrew default -`/opt/homebrew/bin/actionlint`). - -## What it covers - -Any Edit/Write to a file matching `.github/workflows/*.y*ml`. Runs -`actionlint `. If exit code is non-zero, surfaces stdout + stderr -to Claude via this hook's stderr. If `actionlint` isn't on PATH, no-op. - -## Not a blocker - -This hook is reporting-only. Blocking is covered by: - -- `workflow-uses-comment-guard` (SHA-pin comment format) -- `workflow-yaml-multiline-body-guard` (multi-line `--body "..."`) -- `pull-request-target-guard` (privileged context misuse) - -If a future block-worthy actionlint check is identified, promote it to -its own PreToolUse hook with a focused detection pattern. diff --git a/.claude/hooks/actionlint-on-workflow-edit/index.mts b/.claude/hooks/actionlint-on-workflow-edit/index.mts deleted file mode 100644 index b5de8ff34..000000000 --- a/.claude/hooks/actionlint-on-workflow-edit/index.mts +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — actionlint-on-workflow-edit. -// -// After an Edit/Write touches `.github/workflows/*.y*ml`, invoke local -// `actionlint` AND `zizmor` (if installed) against the file. Surface -// findings as stderr so Claude sees them before the next turn. -// -// Two scanners, independent: -// - actionlint catches YAML / shell / SHA-pin issues that GitHub's -// parser would silently reject as "0 jobs" -// - zizmor catches security-sensitive patterns: pull_request_target -// misuse, untrusted-input-in-script, secret leaks, privilege -// escalation — supply-chain risks actionlint doesn't model -// -// PostToolUse (not PreToolUse) so the edit lands first and the scanners -// read on-disk state. No block — reporting only. The block surface is -// covered by sibling hooks (`workflow-uses-comment-guard`, -// `workflow-yaml-multiline-body-guard`, `pull-request-target-guard`). -// -// No-op for either scanner when it isn't on PATH — most fleet machines -// have both via brew or setup-security-tools, CI runners have them -// preinstalled, but downstreams may not. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -export function actionlintAvailable(): boolean { - const r = spawnSync('command', ['-v', 'actionlint'], { - timeout: 2_000, - }) - return r.status === 0 && String(r.stdout ?? '').trim().length > 0 -} - -export function zizmorAvailable(): boolean { - const r = spawnSync('command', ['-v', 'zizmor'], { - timeout: 2_000, - }) - return r.status === 0 && String(r.stdout ?? '').trim().length > 0 -} - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly file_path?: string | undefined } | undefined -} - -export function isWorkflowYaml(filePath: string): boolean { - return /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(filePath) -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !isWorkflowYaml(filePath)) { - process.exit(0) - } - - // actionlint — YAML / shell / SHA-pin issues. - if (actionlintAvailable()) { - const r = spawnSync('actionlint', [filePath], { timeout: 10_000 }) - if (r.status !== 0) { - process.stderr.write( - [ - '[actionlint-on-workflow-edit] actionlint reported errors', - '', - ` File: ${filePath}`, - '', - ' Output:', - ...String(r.stdout ?? '') - .trim() - .split('\n') - .map((l: string) => ` ${l}`), - ...(r.stderr - ? String(r.stderr) - .trim() - .split('\n') - .map((l: string) => ` ${l}`) - : []), - '', - ' Fix the workflow before relying on it firing in CI. actionlint', - " catches the same YAML / shell / SHA-pin issues GitHub Actions'", - ' parser would (silently) reject as "0 jobs."', - '', - ].join('\n'), - ) - } - } - - // zizmor — security-focused workflow auditor. Catches privilege - // escalation, secret injection, untrusted-input-in-script patterns, - // and pull_request_target misuse — the supply-chain threats that - // actionlint doesn't model. Independent scan; both can flag the - // same file. - if (zizmorAvailable()) { - const r = spawnSync( - 'zizmor', - ['--no-progress', '--format', 'plain', filePath], - { - timeout: 15_000, - }, - ) - // zizmor exits non-zero when findings exist. Surface the output - // regardless so even informational findings are visible. - if (r.status !== 0) { - process.stderr.write( - [ - '[actionlint-on-workflow-edit] zizmor reported findings', - '', - ` File: ${filePath}`, - '', - ' Output:', - ...String(r.stdout ?? '') - .trim() - .split('\n') - .map((l: string) => ` ${l}`), - ...(r.stderr - ? String(r.stderr) - .trim() - .split('\n') - .map((l: string) => ` ${l}`) - : []), - '', - ' zizmor scans for security-sensitive workflow patterns:', - ' pull_request_target misuse, untrusted-input-in-script,', - ' secret leaks, privilege escalation. Address findings', - ' before merging.', - '', - ].join('\n'), - ) - } - } - - // PostToolUse — emit warnings to stderr but don't block the edit - // (the edit already happened). Exit 0 so Claude sees the stderr. - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[actionlint-on-workflow-edit] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/actionlint-on-workflow-edit/package.json b/.claude/hooks/actionlint-on-workflow-edit/package.json deleted file mode 100644 index 1c54d3068..000000000 --- a/.claude/hooks/actionlint-on-workflow-edit/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-actionlint-on-workflow-edit", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts b/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts deleted file mode 100644 index 9ff0de2a8..000000000 --- a/.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts +++ /dev/null @@ -1,83 +0,0 @@ -// node --test specs for the actionlint-on-workflow-edit hook. - -import { - spawn, - spawnSync, -} from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const actionlintInstalled = (() => { - const r = spawnSync('command', ['-v', 'actionlint']) - return r.status === 0 -})() - -test('non-workflow file passes silently', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/foo.txt' }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('non-Edit/Write tool passes silently', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo hi' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow edit always exits 0 (PostToolUse — reporting only)', async () => { - // We don't need actionlint installed to verify the exit code; the - // hook short-circuits to 0 on actionlint-not-found. - const r = await runHook({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/some.github/workflows/x.yml' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow edit with installed actionlint runs the tool (smoke)', async t => { - if (!actionlintInstalled) { - t.skip('actionlint not on PATH') - return - } - // Smoke test only — provide a path to a nonexistent file; actionlint - // will error but the hook itself exits 0. We just check it doesn't - // crash. - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/this/path/does/not/exist/.github/workflows/x.yml', - }, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/actionlint-on-workflow-edit/tsconfig.json b/.claude/hooks/actionlint-on-workflow-edit/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/actionlint-on-workflow-edit/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/ask-suppression-reminder/README.md b/.claude/hooks/ask-suppression-reminder/README.md deleted file mode 100644 index 46821ceef..000000000 --- a/.claude/hooks/ask-suppression-reminder/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# ask-suppression-reminder - -PreToolUse hook (reminder, NOT a block) that fires on AskUserQuestion when -the recent transcript carries explicit go-ahead directives. - -## Why - -The user has flagged repeated AskUserQuestion as friction-generating -behavior. Memory captures the rule in `feedback_dont_ask_proceed`: when the -user has said "do it" / "yes" / "proceed" / "1", the assistant should pick -the obvious default and execute, not pose a clarifying question. - -A blocker would be too aggressive — sometimes a binary question after "yes" -is genuinely scoping (e.g. "yes proceed — but which of these N approaches?"). -A reminder gives the assistant the signal to reconsider without preventing -legitimate scoping. - -## What it surfaces - -| User turn pattern | Reminder? | -| --------------------------------------------- | --------- | -| `yes` / `y` / `do it` / `proceed` / `go` | yes | -| `continue` / `1` / `all of them` / `ship it` | yes | -| `ok` / `sure` / `k` | yes | -| Long paragraph that happens to contain "yes" | no | -| (must be the full trimmed message body) | | -| Question or scoping requests in the user turn | no | - -Scans the last 3 user turns. The matched turn must be the ENTIRE trimmed -message body, not a substring — this avoids firing on "yes" buried in -sentence prose. - -## Disable - -Set `SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1` in the environment. diff --git a/.claude/hooks/ask-suppression-reminder/index.mts b/.claude/hooks/ask-suppression-reminder/index.mts deleted file mode 100644 index c49553b20..000000000 --- a/.claude/hooks/ask-suppression-reminder/index.mts +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — ask-suppression-reminder. -// -// Fires (with a stderr reminder, not a block) when the assistant invokes -// AskUserQuestion while the recent transcript carries an explicit go-ahead -// directive from the user. The hook DOES NOT block — it surfaces a one-line -// reminder so the assistant notices the dont-ask-proceed signal and picks -// the obvious default instead of asking. -// -// Reasoning behind reminder-only: -// - Sometimes the question is genuinely scoping ("which of these N -// options?" after the user said "yes, proceed"). Blocking would prevent -// legitimate scoping. -// - A noisy stderr nudge keeps the cost low; the assistant's response is -// to skip the question, not to refuse. -// -// Detection model: -// - Fires only on AskUserQuestion tool calls. -// - Reads the most recent N user turns from the transcript. -// - Looks for go-ahead directives: standalone "yes" / "do it" / "proceed" -// / "go" / "continue" / digit-only ("1") / "all of them". -// - Conservative: only flags when at least one directive appears AS the -// most recent user turn's text content (not buried in a paragraph). -// -// Disable: SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1 env var. - -import { readFileSync } from 'node:fs' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED' - -// Patterns that signal "you have go-ahead; don't ask again". Match against -// the full trimmed text of a user turn — must be the entire message body, -// not a substring (to avoid firing on "yes" mid-paragraph). -const GO_AHEAD_PATTERNS = [ - /^yes\.?$/i, - /^y\.?$/i, - /^do it\.?$/i, - /^proceed\.?$/i, - /^go\.?$/i, - /^continue\.?$/i, - /^continue\.?\s*$/i, - /^[0-9]+\.?$/, // digit-only ("1", "2") - /^all of them\.?$/i, - /^all\.?$/i, - /^ship (?:it|them)\.?$/i, - /^k\.?$/i, - /^ok\.?$/i, - /^sure\.?$/i, -] - -// How many recent user turns to scan. Larger windows catch stale directives; -// smaller windows lose context. 3 is a balance. -const RECENT_TURN_WINDOW = 3 - -export function matchesGoAhead(text: string): boolean { - const trimmed = text.trim() - if (!trimmed) { - return false - } - for (let i = 0, { length } = GO_AHEAD_PATTERNS; i < length; i += 1) { - const re = GO_AHEAD_PATTERNS[i]! - if (re.test(trimmed)) { - return true - } - } - return false -} - -export function readRecentUserTurns( - transcriptPath: string, - window: number, -): string[] { - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return [] - } - const turns: string[] = [] - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) { - continue - } - let entry: unknown - try { - entry = JSON.parse(line) - } catch { - continue - } - if (entry === null || typeof entry !== 'object') { - continue - } - if ((entry as { type?: string | undefined }).type !== 'user') { - continue - } - const msg = ( - entry as { message?: { content?: unknown | undefined } | undefined } - ).message - if (!msg) { - continue - } - const c = msg.content - if (typeof c === 'string') { - turns.push(c) - } else if (Array.isArray(c)) { - // Newer format — content is an array of segments. - const text = c - .map(seg => - typeof seg === 'string' - ? seg - : typeof (seg as { text?: unknown | undefined }).text === 'string' - ? (seg as { text: string }).text - : '', - ) - .join('\n') - turns.push(text) - } - } - return turns.slice(-window) -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'AskUserQuestion') { - process.exit(0) - } - - if (!payload.transcript_path) { - process.exit(0) - } - - const turns = readRecentUserTurns(payload.transcript_path, RECENT_TURN_WINDOW) - if (turns.length === 0) { - process.exit(0) - } - - // Find the most recent user turn that matches the go-ahead pattern. - let matched: string | undefined - for (let i = turns.length - 1; i >= 0; i -= 1) { - if (matchesGoAhead(turns[i]!)) { - matched = turns[i] - break - } - } - if (!matched) { - process.exit(0) - } - - // Reminder-only — exit 0, write to stderr. Claude Code surfaces the - // stderr text to the assistant without blocking the tool call. - process.stderr.write( - [ - '[ask-suppression-reminder] AskUserQuestion with recent go-ahead directive', - '', - ` Recent user turn: "${matched.trim().slice(0, 80)}"`, - '', - ' The user has given you explicit permission to proceed. Reconsider', - ' whether the question is genuinely scoping (a real ambiguity you', - ' cannot resolve from context) or whether you should pick the', - ' obvious default and execute.', - '', - ' Per CLAUDE.md Judgment & self-evaluation: skip AskUserQuestion', - ' when intent is clear; pick the obvious default and execute.', - '', - ' Disable this reminder: set SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED=1.', - '', - ].join('\n'), - ) - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[ask-suppression-reminder] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/ask-suppression-reminder/package.json b/.claude/hooks/ask-suppression-reminder/package.json deleted file mode 100644 index 835fcb84f..000000000 --- a/.claude/hooks/ask-suppression-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-ask-suppression-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/ask-suppression-reminder/test/index.test.mts b/.claude/hooks/ask-suppression-reminder/test/index.test.mts deleted file mode 100644 index ab8c93921..000000000 --- a/.claude/hooks/ask-suppression-reminder/test/index.test.mts +++ /dev/null @@ -1,131 +0,0 @@ -// node --test specs for the ask-suppression-reminder hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function writeTranscript(userTurns: string[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'ask-suppress-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = userTurns.map(t => - JSON.stringify({ type: 'user', message: { content: t } }), - ) - writeFileSync(transcriptPath, lines.join('\n') + '\n') - return transcriptPath -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-AskUserQuestion passes silently', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo hi' }, - transcript_path: writeTranscript(['yes']), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('AskUserQuestion with no recent directive — no reminder', async () => { - const r = await runHook({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript([ - 'Can you investigate the bug?', - 'I think it is in the parser.', - ]), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('AskUserQuestion with recent "do it" — reminder fires', async () => { - const r = await runHook({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript(['First find them.', 'do it']), - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('go-ahead directive')) -}) - -test('AskUserQuestion with "yes" — reminder fires', async () => { - const r = await runHook({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript(['yes']), - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('go-ahead directive')) -}) - -test('AskUserQuestion with "yes" buried in paragraph — no reminder', async () => { - const r = await runHook({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript([ - 'yes, but only after you read the docs and report what you find', - ]), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('digit-only directive ("1") fires reminder', async () => { - const r = await runHook({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript(['Pick one of these:', '1']), - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('go-ahead directive')) -}) - -test('disabled via env var', async () => { - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { - ...process.env, - SOCKET_ASK_SUPPRESSION_REMINDER_DISABLED: '1', - }, - }) - child.stdin!.end( - JSON.stringify({ - tool_name: 'AskUserQuestion', - transcript_path: writeTranscript(['do it']), - }), - ) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) - assert.strictEqual(stderr, '') -}) diff --git a/.claude/hooks/ask-suppression-reminder/tsconfig.json b/.claude/hooks/ask-suppression-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/ask-suppression-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/auth-rotation-reminder/README.md b/.claude/hooks/auth-rotation-reminder/README.md deleted file mode 100644 index 2b74b1f3c..000000000 --- a/.claude/hooks/auth-rotation-reminder/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# auth-rotation-reminder - -A **Claude Code hook** that runs at the _end_ of every Claude turn, -notices when you've been logged into a CLI for "too long," and -automatically logs you out so stale long-lived tokens don't sit in -your dotfiles or keychain for days. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `Stop` hook like -> this one fires _after_ Claude finishes a turn. Stop hooks are a -> good place for periodic maintenance — they have access to your -> shell environment but don't gate any tool calls. - -## Why automatic logout - -Long-lived auth tokens live in well-known files: `~/.npmrc`, -`~/.config/gh/hosts.yml`, `~/.config/gcloud/`, `~/.docker/config.json`, -your OS keychain. A compromised dev workstation has a wide blast -radius on those files. Periodic auto-revocation tightens the window -where a stolen token is useful, and forces explicit re-authentication -— which is itself a small phishing-defense moment ("did I really -mean to publish?"). - -## Defaults - -- **Interval**: 1 hour. Set `SOCKET_AUTH_ROTATION_INTERVAL_HOURS=4` to - loosen, `=0` to run on every Stop event. -- **Mode**: auto-logout (the hook _acts_, not just warns). -- **Default skip-list**: `gh` is skipped because Claude Code itself - uses `gh` for `gh pr edit` etc. — auto-revoking it would break the - agent. -- **CI**: hook short-circuits when `CI` env var is set. - -## What's swept - -| id | display name | detect | logout | -| ------- | --------------- | ------------------------------- | -------------------------------------- | -| npm | npm | `npm whoami` | `npm logout` | -| pnpm | pnpm | `pnpm whoami` | `pnpm logout` | -| yarn | yarn | `yarn --version` | `yarn npm logout` | -| gcloud | gcloud | `gcloud auth list ... ACTIVE` | `gcloud auth revoke --all --quiet` | -| aws-sso | aws (sso) | `aws sts get-caller-identity` | `aws sso logout` | -| gh | gh (GitHub CLI) | `gh auth status` | `gh auth logout --hostname github.com` | -| vault | vault | `vault token lookup` | `vault token revoke -self` | -| docker | docker | `docker info \| grep Username:` | `docker logout` | -| socket | socket | `socket whoami` | `socket logout` | - -The hook never reads, prints, or compares any token value. Detection -is exit-code only; logout commands' output is suppressed except for -non-zero exit codes which surface as "logout failed" lines. - -## Snoozing - -Need to keep your auth alive for the next few hours (e.g. mid-publish)? -Drop a `.snooze` file with an ISO 8601 expiry on line 1. - -```bash -# Snooze for 4 hours, project-local -date -ud "+4 hours" +"%Y-%m-%dT%H:%M:%SZ" > .claude/auth-rotation.snooze - -# Snooze globally for 8 hours (applies to every repo) -mkdir -p ~/.claude/hooks/auth-rotation -date -ud "+8 hours" +"%Y-%m-%dT%H:%M:%SZ" > ~/.claude/hooks/auth-rotation/snooze -``` - -The hook **automatically deletes the file** once the timestamp is -reached. No manual cleanup needed. - -Snoozes that are malformed, empty, or unreadable are also auto-deleted -on the next run — fail-safe so a corrupted file can't permanently -disable rotation. - -`.claude/*.snooze` is gitignored; project-local snoozes never leak into -commits. - -## Skip-list - -Permanently skip a service: - -```bash -# Per-user: applies to every repo -mkdir -p ~/.claude/hooks/auth-rotation -echo gcloud >> ~/.claude/hooks/auth-rotation/services-skip - -# Per-repo: applies just to this checkout -echo vault >> .claude/auth-rotation.services-skip -``` - -One id per line. Lines starting with `#` are comments. Service ids -are stable — see the table above. - -## Disable temporarily - -```bash -SOCKET_AUTH_ROTATION_DISABLED=1 # any non-empty value -``` - -For pairing sessions, demos, etc. The hook short-circuits before -doing any work. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/auth-rotation-reminder/index.mts" - } - ] - } - ] - } -} -``` - -## Tests - -```bash -cd .claude/hooks/auth-rotation-reminder -node --test test/*.test.mts -``` - -## Reusing the snooze convention - -Other hooks can adopt the same `.snooze` pattern. The convention: - -- Filename: `.claude/.snooze` (project) or - `~/.claude/hooks//snooze` (global). -- Format: ISO 8601 expiry on line 1. Optional further lines ignored. -- `.gitignore`: `.claude/*.snooze`. -- Cleanup: hook auto-deletes expired files via `safeDelete` from - `@socketsecurity/lib-stable/fs`. -- The `checkSnoozes` helper in `index.mts` is easy to copy into a - sibling hook. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/auth-rotation-reminder) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/auth-rotation-reminder/index.mts b/.claude/hooks/auth-rotation-reminder/index.mts deleted file mode 100644 index 1391422f4..000000000 --- a/.claude/hooks/auth-rotation-reminder/index.mts +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — auth-rotation-reminder. -// -// Periodically logs you out of authenticated CLIs (npm, pnpm, gcloud, -// vault, aws sso, docker, socket, …) so stale long-lived tokens don't -// sit in dotfiles or keychains for days. -// -// Behavior on each Stop event: -// -// 1. Drain stdin (Stop hook delivers a JSON payload we don't need). -// 2. Skip if running in CI (CI auth has its own lifecycle). -// 3. Read both global + project-local `.snooze` files. Each carries -// an ISO 8601 expiry on line 1; if past, the file is auto-cleaned -// and the hook proceeds. If unexpired, the hook honors the snooze -// and exits silently. -// 4. Throttle via a state file: if the last successful run was within -// the configured interval (default 1h), exit silently. -// 5. For each service in services.mts: -// a. Skip if the binary is missing and `optional: true`. -// b. Run detectCmd. Skip if not authenticated. -// c. Run logoutCmd. Log to stderr via lib's logger. -// 6. Update the state file's mtime. -// -// The hook NEVER reads, prints, or compares any token value. Detection -// is exit-code only; logout commands' output is suppressed except for -// non-zero exit codes which surface as "logout failed" lines. -// -// Snooze file format (ISO 8601 timestamp on line 1): -// -// $ date -ud '+4 hours' +"%Y-%m-%dT%H:%M:%SZ" > .claude/auth-rotation.snooze -// -// Removed automatically once the timestamp is reached. -// -// Configuration env vars (all optional): -// -// SOCKET_AUTH_ROTATION_INTERVAL_HOURS default: 1 -// How long between actual auth-rotation runs (state-file throttle). -// Set to 0 to run on every Stop event (verbose). -// -// SOCKET_AUTH_ROTATION_DISABLED default: unset -// If set to a truthy value, skip the hook entirely. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { - existsSync, - mkdirSync, - readFileSync, - statSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - readLastAssistantText, - stripCodeFences, -} from '../_shared/transcript.mts' - -import { DEFAULT_SKIP_IDS, SERVICES } from './services.mts' -import type { Service } from './services.mts' - -const logger = getDefaultLogger() -const PREFIX = '[auth-rotation-reminder]' - -// ── Paths ─────────────────────────────────────────────────────────── - -const STATE_DIR = path.join(os.homedir(), '.claude', 'hooks', 'auth-rotation') -const STATE_FILE = path.join(STATE_DIR, 'last-run') -const GLOBAL_SNOOZE = path.join(STATE_DIR, 'snooze') -const GLOBAL_SKIP_LIST = path.join(STATE_DIR, 'services-skip') - -// Project-local files live at the repo root next to .claude/. Use -// CLAUDE_PROJECT_DIR (Claude Code injects this on every hook run) so -// the paths stay correct regardless of session cwd — process.cwd() -// drifts when the user navigates into a subpackage. -const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() -const PROJECT_SNOOZE = path.join(PROJECT_DIR, '.claude', 'auth-rotation.snooze') -const PROJECT_SKIP_LIST = path.join( - PROJECT_DIR, - '.claude', - 'auth-rotation.services-skip', -) - -// ── Snooze handling ───────────────────────────────────────────────── - -interface SnoozeStatus { - active: boolean - cleaned: string[] -} - -export async function checkSnoozes(): Promise { - const status: SnoozeStatus = { active: false, cleaned: [] } - const cleanFile = async (file: string, reason: string): Promise => { - try { - await safeDelete(file) - status.cleaned.push(file) - } catch (e) { - logger.error( - `${PREFIX} safeDelete(${path.basename(file)}) failed (${reason}): ${errorMessage(e)}`, - ) - } - } - for (const file of [GLOBAL_SNOOZE, PROJECT_SNOOZE]) { - if (!existsSync(file)) { - continue - } - let content = '' - try { - content = readFileSync(file, 'utf8').trim() - } catch { - await cleanFile(file, 'unreadable') - continue - } - // Empty content = legacy form, no expiry. Treat as expired now. - if (content.length === 0) { - await cleanFile(file, 'legacy (no expiry)') - continue - } - const firstLine = content.split('\n')[0]!.trim() - const expiry = Date.parse(firstLine) - if (Number.isNaN(expiry)) { - await cleanFile(file, 'malformed expiry') - continue - } - if (Date.now() >= expiry) { - await cleanFile(file, 'expired') - continue - } - // Unexpired snooze. Honor it. - status.active = true - return status - } - return status -} - -// ── Skip-list ─────────────────────────────────────────────────────── - -export function loadSkipIds(): Set { - const skipIds = new Set(DEFAULT_SKIP_IDS) - for (const file of [GLOBAL_SKIP_LIST, PROJECT_SKIP_LIST]) { - if (!existsSync(file)) { - continue - } - try { - const content = readFileSync(file, 'utf8') - for (const raw of content.split('\n')) { - const trimmed = raw.trim() - if (trimmed && !trimmed.startsWith('#')) { - skipIds.add(trimmed) - } - } - } catch { - // Ignore unreadable skip-list — better to over-rotate than fail closed. - } - } - return skipIds -} - -// ── Leak detection ────────────────────────────────────────────────── - -// Patterns that signal the assistant just announced a token leak in -// its own output. Bypass the throttle when any of these fire so the -// rotation happens immediately, not in the next 1h tick. -// -// The patterns target the WARNING text (i.e., what the assistant -// said about a leak), not the token value itself. token-guard handles -// pre-leak blocking; this is "the leak happened, surface it now." -const LEAK_WARNING_PATTERNS: readonly RegExp[] = [ - /\brotate the token\b/i, - /\brotate (?:the )?(?:api )?key\b/i, - /\bleaked into (?:the )?transcript\b/i, - /\btoken (?:value )?(?:was )?(?:briefly )?visible (?:to me )?(?:at one point )?(?:in )?(?:the )?(?:tool output|transcript|context)\b/i, - // Bright-red rotation banner shape the security-incident block uses. - /(?:⚠️|⚠|!)+\s*Rotate the token\b/i, - // "appears in transcript" / "in conversation transcript" - /\b(?:appeared|exposed|present) in (?:the )?(?:conversation )?transcript\b/i, - // "security incident notice" — used by my Token-Hygiene memory - // template when surfacing a leak. - /\bsecurity incident notice\b/i, -] - -interface LeakDetection { - triggered: boolean - matchedPattern: string | undefined -} - -/** - * Scan the most-recent assistant turn (from the Stop-hook JSON payload's - * transcript_path) for a leak-warning marker. Returns `triggered: true` when - * any pattern hits — caller bypasses the throttle and runs rotation - * immediately. - * - * Caller passes in the raw stdin payload because `main()` already captured it - * (Node's stdin is single-use). - */ -export function detectLeakWarning(stdinPayload: string): LeakDetection { - if (!stdinPayload) { - return { triggered: false, matchedPattern: undefined } - } - let payload: { transcript_path?: string | undefined } - try { - payload = JSON.parse(stdinPayload) as { - transcript_path?: string | undefined - } - } catch { - return { triggered: false, matchedPattern: undefined } - } - let text: string - try { - text = readLastAssistantText(payload.transcript_path) ?? '' - } catch { - return { triggered: false, matchedPattern: undefined } - } - if (!text) { - return { triggered: false, matchedPattern: undefined } - } - // Strip code fences so a regex matching inside an example block - // doesn't fire (those are docs / show-don't-tell, not incidents). - const stripped = stripCodeFences(text) - for (let i = 0, { length } = LEAK_WARNING_PATTERNS; i < length; i += 1) { - const pat = LEAK_WARNING_PATTERNS[i]! - const m = stripped.match(pat) - if (m) { - return { triggered: true, matchedPattern: m[0] } - } - } - return { triggered: false, matchedPattern: undefined } -} - -// ── Throttle ──────────────────────────────────────────────────────── - -export function intervalMs(): number { - const raw = process.env['SOCKET_AUTH_ROTATION_INTERVAL_HOURS'] - const hours = raw === undefined ? 1 : Number.parseFloat(raw) - if (!Number.isFinite(hours) || hours < 0) { - return 60 * 60 * 1000 - } - return Math.round(hours * 60 * 60 * 1000) -} - -export function withinThrottle(): boolean { - const interval = intervalMs() - if (interval === 0) { - return false - } - if (!existsSync(STATE_FILE)) { - return false - } - try { - const { mtimeMs } = statSync(STATE_FILE) - return Date.now() - mtimeMs < interval - } catch { - return false - } -} - -export function touchStateFile(): void { - try { - mkdirSync(STATE_DIR, { recursive: true }) - if (!existsSync(STATE_FILE)) { - writeFileSync(STATE_FILE, '') - } - const now = new Date() - utimesSync(STATE_FILE, now, now) - } catch { - // Throttle is best-effort. Loss = hook runs more often than configured; - // not worth surfacing. - } -} - -// ── Service detection + logout ────────────────────────────────────── - -interface RotationResult { - loggedOut: string[] - failed: Array<{ service: string; reason: string }> - skippedMissing: string[] -} - -export function isOnPath(binary: string): boolean { - // `command -v` is portable across sh/bash/zsh and exits 0 if found. - const r = spawnSync('sh', ['-c', `command -v ${binary} >/dev/null 2>&1`], { - stdio: 'ignore', - }) - return r.status === 0 -} - -export function isAuthenticated(s: Service): boolean { - const r = spawnSync(s.detectCmd[0]!, s.detectCmd.slice(1) as string[], { - stdio: 'ignore', - timeout: 5000, - }) - return r.status === 0 -} - -export function runLogout(s: Service): { - ok: boolean - reason?: string | undefined -} { - const r = spawnSync(s.logoutCmd[0]!, s.logoutCmd.slice(1) as string[], { - stdio: 'ignore', - timeout: 10_000, - }) - if (r.status === 0) { - return { ok: true } - } - if (r.error) { - return { ok: false, reason: r.error.message } - } - return { ok: false, reason: `exit code ${r.status}` } -} - -export function rotateAll(skipIds: Set): RotationResult { - const result: RotationResult = { - loggedOut: [], - failed: [], - skippedMissing: [], - } - for (let i = 0, { length } = SERVICES; i < length; i += 1) { - const service = SERVICES[i]! - if (skipIds.has(service.id)) { - continue - } - if (!isOnPath(service.detectCmd[0]!)) { - if (!service.optional) { - result.skippedMissing.push(service.name) - } - continue - } - if (!isAuthenticated(service)) { - continue - } - const out = runLogout(service) - if (out.ok) { - result.loggedOut.push(service.name) - } else { - result.failed.push({ - service: service.name, - reason: out.reason ?? 'unknown', - }) - } - } - return result -} - -// ── Output ────────────────────────────────────────────────────────── - -export function reportSnoozeCleaned(cleaned: string[]): void { - for (let i = 0, { length } = cleaned; i < length; i += 1) { - const file = cleaned[i]! - logger.error(`${PREFIX} cleared expired snooze: ${file}`) - } -} - -export function reportRotation(result: RotationResult): void { - const parts: string[] = [] - if (result.loggedOut.length > 0) { - parts.push( - `logged out of ${result.loggedOut.length} CLI(s): ${result.loggedOut.join(', ')}`, - ) - } - if (result.failed.length > 0) { - const failed = result.failed - .map(f => `${f.service} (${f.reason})`) - .join(', ') - parts.push(`logout failed: ${failed}`) - } - if (result.skippedMissing.length > 0) { - parts.push(`expected-but-missing: ${result.skippedMissing.join(', ')}`) - } - if (parts.length === 0) { - return - } - logger.error(`${PREFIX} ${parts.join('; ')}`) - logger.error( - ` Snooze for next 4h: date -ud "+4 hours" +"%Y-%m-%dT%H:%M:%SZ" > .claude/auth-rotation.snooze`, - ) -} - -// ── Main ──────────────────────────────────────────────────────────── - -export async function run(stdinPayload: string): Promise { - if (process.env['CI']) { - return - } - if (process.env['SOCKET_AUTH_ROTATION_DISABLED']) { - return - } - const snooze = await checkSnoozes() - reportSnoozeCleaned(snooze.cleaned) - if (snooze.active) { - return - } - // Inspect the most-recent assistant turn for a leak-warning marker. - // When the assistant just said "rotate the token" / "leaked into - // transcript", bypass the throttle so rotation runs immediately - // instead of waiting for the next 1h tick. - const leak = detectLeakWarning(stdinPayload) - if (leak.triggered) { - logger.error( - `${PREFIX} leak warning detected in assistant output ("${leak.matchedPattern}"); bypassing throttle`, - ) - } else if (withinThrottle()) { - return - } - const skipIds = loadSkipIds() - const result = rotateAll(skipIds) - reportRotation(result) - touchStateFile() -} - -function main(): void { - // Capture stdin so detectLeakWarning() can scan the Stop-hook - // payload (JSON with transcript_path). We previously drained - // without reading; now we accumulate and pass to run(). - let stdinPayload = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - stdinPayload += chunk - }) - process.stdin.on('end', () => { - run(stdinPayload) - .catch(e => { - logger.error(`${PREFIX} unexpected error: ${errorMessage(e)}`) - }) - .finally(() => { - process.exit(0) - }) - }) - if (process.stdin.readable === false) { - run('') - .catch(e => { - logger.error(`${PREFIX} unexpected error: ${errorMessage(e)}`) - }) - .finally(() => { - process.exit(0) - }) - } -} - -main() diff --git a/.claude/hooks/auth-rotation-reminder/package.json b/.claude/hooks/auth-rotation-reminder/package.json deleted file mode 100644 index e67eec83e..000000000 --- a/.claude/hooks/auth-rotation-reminder/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-auth-rotation-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/auth-rotation-reminder/services.mts b/.claude/hooks/auth-rotation-reminder/services.mts deleted file mode 100644 index 9c36338e9..000000000 --- a/.claude/hooks/auth-rotation-reminder/services.mts +++ /dev/null @@ -1,138 +0,0 @@ -// Service catalog for auth-rotation-reminder. -// -// Each entry tells the hook how to detect whether a CLI is currently -// authenticated and how to log it out. `optional: true` means the hook -// silently skips the service if the binary isn't on PATH (most are -// optional — most devs have a subset of these installed). -// -// Detection commands MUST exit 0 when authenticated and non-zero when -// not. Output goes to /dev/null; the hook reads only the exit code. -// -// Logout commands run unconditionally when the hook is in auto-logout -// mode. They should be idempotent — re-running them on an already -// logged-out CLI is fine. - -export interface Service { - // Stable id used in skip-list files and error messages. Never rename - // without a deprecation cycle — devs encode these in their personal - // `.skip` lists. - id: string - // Display name for output. - name: string - // Command + args that exit 0 if logged in, non-zero otherwise. - detectCmd: readonly string[] - // Command + args that performs the logout. Must be idempotent. - logoutCmd: readonly string[] - // Skip silently when the binary isn't on PATH. False means the - // hook reports "binary missing" as a finding (rare — only for - // first-class fleet CLIs we expect every dev to have). - optional: boolean - // Optional human-readable doc URL surfaced when the hook reports the - // logout. Empty when no canonical doc page exists. - docUrl?: string | undefined -} - -// Default skip-list seeds. Devs can extend via the per-user -// `~/.claude/hooks/auth-rotation/services-skip` (one id per line) -// or per-repo `.claude/auth-rotation.services-skip` files. -// -// `gh` is seeded because Claude Code itself uses `gh` for `gh pr edit` -// etc. — auto-revoking it mid-session would break the agent. -export const DEFAULT_SKIP_IDS = ['gh'] as const - -export const SERVICES: readonly Service[] = [ - { - id: 'npm', - name: 'npm', - detectCmd: ['npm', 'whoami'], - logoutCmd: ['npm', 'logout'], - optional: true, - docUrl: 'https://docs.npmjs.com/cli/v11/commands/npm-logout', - }, - { - id: 'pnpm', - name: 'pnpm', - detectCmd: ['pnpm', 'whoami'], - logoutCmd: ['pnpm', 'logout'], - optional: false, - docUrl: 'https://pnpm.io/id/11.x/cli/logout', - }, - { - id: 'yarn', - name: 'yarn', - // Yarn Berry's logout lives under `npm` namespace; Yarn Classic's - // is bare. We try Berry first (the modern default), fall back to - // Classic. Detection is the same: `npm whoami` from inside a - // yarn-managed registry. Yarn doesn't expose a portable whoami, - // so we approximate by checking for a yarn auth token in - // `~/.yarnrc.yml` via grep — too fragile to ship; use logout-only - // (idempotent: clears nothing if nothing's there). - detectCmd: ['yarn', '--version'], - logoutCmd: ['yarn', 'npm', 'logout'], - optional: true, - }, - { - id: 'gcloud', - name: 'gcloud', - // `gcloud auth list` exits 0 always; we check whether any non-empty - // active account is reported. Wrap with sh -c to chain. - detectCmd: [ - 'sh', - '-c', - 'gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | grep -q .', - ], - logoutCmd: ['gcloud', 'auth', 'revoke', '--all', '--quiet'], - optional: true, - docUrl: 'https://cloud.google.com/sdk/gcloud/reference/auth/revoke', - }, - { - id: 'aws-sso', - name: 'aws (sso)', - // `aws sts get-caller-identity` succeeds when authenticated. - // sts is the universal probe across all AWS auth flavors. - detectCmd: ['aws', 'sts', 'get-caller-identity'], - // `aws sso logout` only clears SSO cache. For non-SSO creds, the - // dev would have to remove `~/.aws/credentials` themselves; we - // don't touch that file because it might hold long-lived keys - // intentionally. SSO-only is the conservative default. - logoutCmd: ['aws', 'sso', 'logout'], - optional: true, - }, - { - id: 'gh', - name: 'gh (GitHub CLI)', - detectCmd: ['gh', 'auth', 'status'], - logoutCmd: ['gh', 'auth', 'logout', '--hostname', 'github.com'], - optional: true, - docUrl: 'https://cli.github.com/manual/gh_auth_logout', - }, - { - id: 'vault', - name: 'vault', - detectCmd: ['vault', 'token', 'lookup'], - // `token revoke -self` revokes the active token; survives the - // logout safely (re-auth via `vault login` next session). - logoutCmd: ['vault', 'token', 'revoke', '-self'], - optional: true, - }, - { - id: 'docker', - name: 'docker', - // No portable "am I logged in" — `docker info` returns mixed data. - // Approximate via `docker system info` filter. - detectCmd: ['sh', '-c', 'docker info 2>/dev/null | grep -q "^ Username:"'], - // Without a registry arg, `docker logout` clears the default index. - logoutCmd: ['docker', 'logout'], - optional: true, - }, - { - id: 'socket', - name: 'socket', - // `socket whoami` (when present in the cli) is the canonical probe. - // The cli emits exit 0 when authenticated. - detectCmd: ['socket', 'whoami'], - // `socket logout` clears the local API token from settings. - logoutCmd: ['socket', 'logout'], - optional: true, - }, -] as const diff --git a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts b/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts deleted file mode 100644 index 0adf2fa7e..000000000 --- a/.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts +++ /dev/null @@ -1,174 +0,0 @@ -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { test } from 'node:test' -import assert from 'node:assert/strict' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Env { - [key: string]: string -} - -function runHook( - opts: { - cwd?: string | undefined - env?: Env | undefined - } = {}, -): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - cwd: opts.cwd ?? process.cwd(), - stdio: ['pipe', 'ignore', 'pipe'], - env: { - // Default to a sentinel CI value the hook short-circuits on, - // unless the caller overrides. Most tests want the early-exit - // path so they don't actually run logout commands. - ...process.env, - ...opts.env, - }, - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end('{}\n') - }) -} - -function makeRepo(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'auth-rotation-test-')) - mkdirSync(path.join(dir, '.claude'), { recursive: true }) - return dir -} - -test('exits 0 silently when CI env var is set', async () => { - const repo = makeRepo() - try { - const { code, stderr } = await runHook({ - cwd: repo, - env: { CI: '1' }, - }) - assert.equal(code, 0) - assert.equal(stderr, '', `expected no output in CI; got: ${stderr}`) - } finally { - await safeDelete(repo) - } -}) - -test('exits 0 silently when SOCKET_AUTH_ROTATION_DISABLED is set', async () => { - const repo = makeRepo() - try { - const { code, stderr } = await runHook({ - cwd: repo, - env: { - CI: '', - SOCKET_AUTH_ROTATION_DISABLED: '1', - }, - }) - assert.equal(code, 0) - assert.equal(stderr, '') - } finally { - await safeDelete(repo) - } -}) - -test('honors a project-local snooze with future expiry', async () => { - const repo = makeRepo() - try { - const expiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() - writeFileSync(path.join(repo, '.claude', 'auth-rotation.snooze'), expiry) - const { code, stderr } = await runHook({ - cwd: repo, - env: { CI: '' }, - }) - assert.equal(code, 0) - // Hook should NOT report cleanup of an unexpired snooze. - assert.ok( - !stderr.includes('cleared expired snooze'), - `hook cleared a fresh snooze: ${stderr}`, - ) - } finally { - await safeDelete(repo) - } -}) - -test('auto-cleans expired project-local snooze and proceeds', async () => { - const repo = makeRepo() - const snoozeFile = path.join(repo, '.claude', 'auth-rotation.snooze') - try { - const expiry = new Date(Date.now() - 60 * 60 * 1000).toISOString() - writeFileSync(snoozeFile, expiry) - const { code } = await runHook({ - cwd: repo, - // Force CI so the hook short-circuits AFTER snooze handling - // (which is what we're testing). - env: { CI: '' }, - }) - assert.equal(code, 0) - // We can't easily assert on snooze cleanup messaging without - // also forcing the hook to do real auth detection. The strong - // assertion is that the file is gone afterward. - assert.ok( - !existsSync(snoozeFile), - 'expired snooze file should have been deleted', - ) - } finally { - await safeDelete(repo) - } -}) - -test('auto-cleans malformed snooze content', async () => { - const repo = makeRepo() - const snoozeFile = path.join(repo, '.claude', 'auth-rotation.snooze') - try { - writeFileSync(snoozeFile, 'not-an-iso-timestamp\n') - const { code } = await runHook({ - cwd: repo, - env: { CI: '' }, - }) - assert.equal(code, 0) - assert.ok( - !existsSync(snoozeFile), - 'malformed snooze file should have been deleted', - ) - } finally { - await safeDelete(repo) - } -}) - -test('auto-cleans empty (legacy) snooze file', async () => { - const repo = makeRepo() - const snoozeFile = path.join(repo, '.claude', 'auth-rotation.snooze') - try { - writeFileSync(snoozeFile, '') - const { code } = await runHook({ - cwd: repo, - env: { CI: '' }, - }) - assert.equal(code, 0) - assert.ok( - !existsSync(snoozeFile), - 'empty (legacy) snooze file should have been deleted', - ) - } finally { - await safeDelete(repo) - } -}) diff --git a/.claude/hooks/auth-rotation-reminder/tsconfig.json b/.claude/hooks/auth-rotation-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/auth-rotation-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/check-new-deps/README.md b/.claude/hooks/check-new-deps/README.md deleted file mode 100644 index e513cfcc7..000000000 --- a/.claude/hooks/check-new-deps/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# check-new-deps - -A **Claude Code hook** that runs whenever Claude tries to edit or -create a dependency manifest (`package.json`, `requirements.txt`, -`Cargo.toml`, and 14+ other ecosystems). It extracts the -_newly added_ dependencies, asks [Socket.dev](https://socket.dev) if -any of them are known malware or have critical security alerts, and -**blocks** the edit if so. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool (here, `Edit` or -> `Write`). It can either **prime** (write to stderr, exit 0, model -> carries on) or **block** (exit 2, edit never happens). This one -> blocks for malware/critical findings and primes for low-quality -> warnings. - -## What it does, step by step - -1. Claude tries to edit `package.json` (or any other supported - manifest). -2. The hook reads the proposed edit from stdin. -3. It detects the file type and extracts dependency names from the - new content. -4. For an `Edit` (not a `Write`), it diffs new content vs. old, so - only _newly added_ dependencies get checked — existing deps - aren't re-scanned every time you bump an unrelated version. -5. It builds a [Package URL (PURL)](https://github.com/package-url/purl-spec) - for each new dep and calls Socket.dev's `checkMalware` API. -6. Three outcomes: - - **Malware or critical alert** → exit `2`. Edit is blocked, - Claude reads the alert reason from stderr and either picks a - different package or asks the user. - - **Low quality score** → exit `0` with a warning. Edit proceeds. - - **Clean (or file isn't a manifest)** → exit `0` silently. Edit - proceeds. - -## Flow diagram - -``` -Claude wants to edit package.json - │ - ▼ -Hook receives the edit via stdin (JSON) - │ - ▼ -Extract new deps from new_string -Diff against old_string (if Edit, not Write) - │ - ▼ -Build Package URLs (PURLs) for each dep - │ - ▼ -Call sdk.checkMalware(components) - - ≤5 deps: parallel firewall API (fast, full data) - - >5 deps: batch PURL API (efficient) - │ - ├── Malware/critical alert → EXIT 2 (blocked) - ├── Low score → warn, EXIT 0 (allowed) - └── Clean → EXIT 0 (allowed) -``` - -## Supported ecosystems - -| File pattern | Ecosystem | Example | -| -------------------------------------------------- | -------------- | ------------------------------------ | -| `package.json` | npm | `"express": "^4.19"` | -| `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock` | npm | lockfile entries | -| `requirements.txt`, `pyproject.toml`, `setup.py` | PyPI | `flask>=3.0` | -| `Cargo.toml`, `Cargo.lock` | Cargo (Rust) | `serde = "1.0"` | -| `go.mod`, `go.sum` | Go | `github.com/gin-gonic/gin v1.9` | -| `Gemfile`, `Gemfile.lock` | RubyGems | `gem 'rails'` | -| `composer.json`, `composer.lock` | Composer (PHP) | `"vendor/package": "^3.0"` | -| `pom.xml`, `build.gradle` | Maven (Java) | `commons` | -| `pubspec.yaml`, `pubspec.lock` | Pub (Dart) | `flutter_bloc: ^8.1` | -| `.csproj` | NuGet (.NET) | `` | -| `mix.exs` | Hex (Elixir) | `{:phoenix, "~> 1.7"}` | -| `Package.swift` | Swift PM | `.package(url: "...", from: "4.0")` | -| `*.tf` | Terraform | `source = "hashicorp/aws"` | -| `Brewfile` | Homebrew | `brew "git"` | -| `conanfile.*` | Conan (C/C++) | `boost/1.83.0` | -| `flake.nix` | Nix | `github:owner/repo` | -| `.github/workflows/*.yml` | GitHub Actions | `uses: owner/repo@ref` | - -## Caching - -API responses are cached in-memory for 5 minutes (max 500 entries) -to avoid redundant network calls when Claude touches the same -manifest a few times in one session. - -## Slopsquatting defense (Threat 2.2) - -AI agents sometimes hallucinate package names that don't exist — -attackers register those names and wait. This hook detects every -"not found" response from the Socket.dev firewall API and counts it -in a persistent cacache-backed TTL cache (7-day window, keyed by -`{ecosystem}/{namespace?}/{name}` — version stripped so a burst of -fake `@1`/`@2`/`@3` requests counts as one). After three attempts on -the same nonexistent name, the hook surfaces a warning to stderr with -a "did you mean" hint when the typo is close to a known package. - -The cache survives across sessions and processes — an attacker can't -shake the counter by triggering a new Claude session. - -## Audit log - -Every invocation appends one JSONL record per checked dependency to -`~/.claude/audit/check-new-deps.jsonl`. Each record has: - -- `ts` — timestamp (ms) -- `repo` — basename of `process.cwd()` -- `type` — ecosystem (`npm`, `pypi`, `cargo`, …) -- `name` — package name -- `namespace?` — scope/group when present -- `version?` — version range when present in the manifest -- `verdict` — one of `allow` / `block` / `notfound` / `unknown` -- `reason?` — block reason (only set when `verdict === 'block'`) -- `session?` — Claude session id (derived from `transcript_path`) - -The log is **LOCAL ONLY**. Nothing in this file leaves the -developer's machine via this hook — no outbound channel is added. -Private package names already pass through the Socket.dev API call -(unchanged from the original behavior); the audit log just records -locally what was checked. - -## Wiring - -The hook is registered in `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/check-new-deps/index.mts" - } - ] - } - ] - } -} -``` - -## Dependencies - -All dependencies use `catalog:` references from the workspace root -(`pnpm-workspace.yaml`): - -- `@socketsecurity/sdk-stable` — Socket.dev SDK v4, exposes `checkMalware()`. -- `@socketsecurity/lib-stable` — shared constants and path utilities. -- `@socketregistry/packageurl-js-stable` — Package URL (PURL) parsing. - -## Exit codes - -| Code | Meaning | What Claude does next | -| ---- | ------- | ------------------------------------------------------------------ | -| `0` | Allow | Edit/Write proceeds normally. | -| `2` | Block | Edit/Write is rejected; Claude reads the block reason from stderr. | - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/check-new-deps) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -## Files - -- `index.mts` — main hook (dep extraction + Socket.dev API check) -- `audit.mts` — slopsquatting tracking + audit log -- `types.mts` — shared type definitions -- `package.json` / `tsconfig.json` — workspace and TS config -- `test/extract-deps.test.mts` — unit + integration tests diff --git a/.claude/hooks/check-new-deps/audit.mts b/.claude/hooks/check-new-deps/audit.mts deleted file mode 100644 index eeeb1693d..000000000 --- a/.claude/hooks/check-new-deps/audit.mts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Audit logging + slopsquatting (Threat 2.2) tracking for the check-new-deps - * hook. - * - * Two responsibilities, co-located because they share end-of-hook timing: - * - * 1. Audit log — append one JSONL record per checked package to - * `~/.claude/audit/check-new-deps.jsonl`. The log is LOCAL ONLY: no outbound - * channel, no network. Private package names never leave the developer's - * machine via this log. - * 2. 404 tracking — when a PURL returns "not found" from the firewall API, bump a - * persistent cacache-backed TTL counter. After NOT_FOUND_THRESHOLD attempts - * on the same nonexistent package, surface a warning with a "did you mean" - * suggestion. The cache survives across sessions and processes so attackers - * can't shake the counter by triggering a new session. - * - * Failure mode: everything here is best-effort. A disk-full / EACCES audit-log - * failure or a corrupt cacache entry must NEVER change the verdict the hook - * returns. All write paths are wrapped in try/catch that logs to stderr and - * continues. - */ - -import { promises as fsp } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { stringify } from '@socketregistry/packageurl-js-stable' -import type { PackageURL } from '@socketregistry/packageurl-js-stable' -import { createTtlCache } from '@socketsecurity/lib-stable/cache/ttl/store' -import type { TtlCache } from '@socketsecurity/lib-stable/cache/ttl/types' -import { errorMessage } from '@socketsecurity/lib-stable/errors' - -import type { - AuditRecord, - BatchOutcome, - CheckResult, - Dep, - HookInput, - NotFoundEntry, - Verdict, -} from './types.mts' - -// How long (ms) we remember that a package didn't exist (7 days). -// Long enough to survive a typical AI hallucination cycle; short enough -// that a newly-registered legitimate name eventually clears. -const NOT_FOUND_CACHE_TTL = 7 * 24 * 60 * 60 * 1_000 -// Repeated 404s on the same package before we surface a slopsquatting -// warning. One miss is a typo; three is a pattern worth flagging. -const NOT_FOUND_THRESHOLD = 3 -// Where the audit log lives. Single file, append-only JSONL. Local -// only — never read by the hook, only written. -const AUDIT_LOG_DIR = path.join(os.homedir(), '.claude', 'audit') -const AUDIT_LOG_FILE = path.join(AUDIT_LOG_DIR, 'check-new-deps.jsonl') - -// Persistent 404 counter — keyed by canonical PURL identity -// (`{type}/{namespace?}/{name}`, version stripped so attackers can't -// shake the counter by appending random version specifiers). Lazily -// built because createTtlCache touches cacache on disk and we don't -// want that work in the hot path when no 404s occur. -let notFoundCache: TtlCache | undefined -function getNotFoundCache(): TtlCache { - if (!notFoundCache) { - notFoundCache = createTtlCache({ - prefix: 'check-new-deps-404', - ttl: NOT_FOUND_CACHE_TTL, - }) - } - return notFoundCache -} - -// Compute the canonical "{type}/{namespace?}{name}" identity. Version -// is dropped on purpose: an attacker can request the same fake name -// at a hundred bogus versions and we want one warning, not a hundred. -function depIdentity(dep: Dep): string { - return dep.namespace - ? `${dep.type}/${dep.namespace}/${dep.name}` - : `${dep.type}/${dep.name}` -} - -// Inverse of depIdentity for purposes of resolving a PURL back to a -// `{type, namespace, name}` triple. We need this when we have to -// surface a 404 warning and the only thing we kept around is the PURL. -function depFromPurl( - purl: string, -): { type: string; namespace?: string | undefined; name: string } | undefined { - // PURL shape: pkg:type/[namespace/]name[@version] - if (!purl.startsWith('pkg:')) { - return undefined - } - const noScheme = purl.slice(4) - const atIdx = noScheme.indexOf('@') - const versionless = atIdx === -1 ? noScheme : noScheme.slice(0, atIdx) - const slashIdx = versionless.indexOf('/') - if (slashIdx === -1) { - return undefined - } - const type = versionless.slice(0, slashIdx) - const rest = versionless.slice(slashIdx + 1) - const lastSlash = rest.lastIndexOf('/') - if (lastSlash === -1) { - return { type, name: rest } - } - return { - type, - namespace: rest.slice(0, lastSlash), - name: rest.slice(lastSlash + 1), - } -} - -// Pull the session id from Claude Code's transcript_path. The basename -// is a UUID like "abc1234.jsonl"; we strip the extension so audit -// consumers can join across hook invocations on a clean session id. -function deriveSessionId(hook: HookInput): string | undefined { - if (hook.session_id) { - return hook.session_id - } - if (!hook.transcript_path) { - return undefined - } - const base = path.basename(hook.transcript_path) - const dotIdx = base.lastIndexOf('.') - return dotIdx === -1 ? base : base.slice(0, dotIdx) -} - -// One audit record per dep, written before we surface 404 warnings so -// the log is the source of truth even when the cache write below fails. -function buildAuditRecords( - hook: HookInput, - deps: Dep[], - outcome: BatchOutcome, -): AuditRecord[] { - const session = deriveSessionId(hook) - const repo = path.basename(process.cwd()) - const ts = Date.now() - const blockedByPurl = new Map() - for (const b of outcome.blocked) { - blockedByPurl.set(b.purl, b) - } - - const records: AuditRecord[] = [] - for (let i = 0, { length } = deps; i < length; i += 1) { - const dep = deps[i]! - const purl = stringify(dep as unknown as PackageURL) - const blockedHit = blockedByPurl.get(purl) - let verdict: Verdict - let reason: string | undefined - if (blockedHit) { - verdict = 'block' - reason = blockedHit.reason - } else if (outcome.notFound.has(purl)) { - verdict = 'notfound' - } else if (outcome.ok.has(purl)) { - verdict = 'allow' - } else { - // API failed, dep wasn't in the response at all — record as - // 'unknown' rather than fabricating an allow. - verdict = 'unknown' - } - records.push({ - ts, - repo, - type: dep.type, - name: dep.name, - namespace: dep.namespace, - version: dep.version, - verdict, - reason, - session, - }) - } - return records -} - -// Append every record as one JSONL line. On POSIX `fs.appendFile` is -// atomic for writes < PIPE_BUF (4 KiB) — our records are well under -// that. The whole function is wrapped to swallow disk-full / EACCES. -async function appendAuditRecords(records: AuditRecord[]): Promise { - if (!records.length) { - return - } - try { - await fsp.mkdir(AUDIT_LOG_DIR, { recursive: true }) - // Join into one write so the OS only sees one append syscall per - // hook invocation. (Multiple appendFile calls would each be - // atomic individually but they can interleave with other agents.) - const body = records.map(r => JSON.stringify(r)).join('\n') + '\n' - await fsp.appendFile(AUDIT_LOG_FILE, body, { encoding: 'utf8' }) - } catch (e) { - // Audit is best-effort. Don't ever break the verdict over a log - // write failure. - process.stderr.write( - `[check-new-deps] audit log write failed: ${errorMessage(e)}\n`, - ) - } -} - -// Bump the persistent 404 counter for every PURL that came back as -// "not found". Surfaces a warning when a single fake package has been -// requested NOT_FOUND_THRESHOLD or more times. Returns the list of -// PURLs that crossed the threshold this call — the caller writes -// the warning to stderr. -async function bumpNotFoundCounters(notFound: Set): Promise { - if (!notFound.size) { - return [] - } - const crossed: string[] = [] - let cache: TtlCache - try { - cache = getNotFoundCache() - } catch (e) { - process.stderr.write( - `[check-new-deps] 404-cache init failed: ${errorMessage(e)}\n`, - ) - return [] - } - for (const purl of notFound) { - const dep = depFromPurl(purl) - if (!dep) { - continue - } - const key = depIdentity({ - type: dep.type, - name: dep.name, - namespace: dep.namespace, - }) - try { - const prev = await cache.get(key) - const now = Date.now() - const next: NotFoundEntry = prev - ? { - count: prev.count + 1, - firstSeenAt: prev.firstSeenAt, - lastSeenAt: now, - } - : { count: 1, firstSeenAt: now, lastSeenAt: now } - await cache.set(key, next) - // First-time-over-threshold check: we want one warning per - // crossing, not one per request after. - const wasUnderThreshold = - prev === undefined || prev.count < NOT_FOUND_THRESHOLD - if (next.count >= NOT_FOUND_THRESHOLD && wasUnderThreshold) { - crossed.push(purl) - } - } catch (e) { - // Per-key failure shouldn't kill the rest of the batch. - process.stderr.write( - `[check-new-deps] 404-cache write failed for ${key}: ${errorMessage(e)}\n`, - ) - } - } - return crossed -} - -// Short, curated "did you mean" hint for common ecosystems where AI -// agents tend to hallucinate names. Levenshtein distance against a -// small allowlist — no external dep, no network. The list is -// deliberately narrow: better to give one strong suggestion or none -// than a noisy fuzzy match. Add new entries when a repeat 404 lands. -const KNOWN_GOOD_NAMES: Record = { - __proto__: null as unknown as string[], - npm: [ - 'react', - 'react-dom', - 'next', - 'vite', - 'webpack', - 'rollup', - 'esbuild', - 'typescript', - 'lodash', - 'express', - 'fastify', - 'koa', - 'axios', - // socket-hook: allow eslint-biome-ref -- popular-package allowlist entry, not a config ref. - 'eslint', - 'prettier', - 'vitest', - 'jest', - 'mocha', - 'chai', - 'sinon', - 'zod', - 'yup', - 'commander', - 'yargs', - 'chalk', - 'debug', - 'glob', - ], - pypi: [ - 'requests', - 'urllib3', - 'numpy', - 'pandas', - 'scipy', - 'matplotlib', - 'flask', - 'django', - 'fastapi', - 'pydantic', - 'sqlalchemy', - 'celery', - 'pytest', - 'tox', - 'black', - 'ruff', - 'mypy', - 'click', - 'rich', - ], - cargo: [ - 'serde', - 'serde_json', - 'tokio', - 'reqwest', - 'clap', - 'anyhow', - 'thiserror', - 'tracing', - 'rayon', - 'regex', - ], - gem: ['rails', 'rspec', 'sinatra', 'puma', 'rake', 'devise', 'sidekiq'], -} - -// Suggest the nearest known-good name for `bad` within `ecosystem`, -// or undefined if nothing is close enough. Distance <= 2 is the -// heuristic — that catches "expres" → "express" and "loadash" → -// "lodash" without firing on "totally-fake". -function suggestSimilarName( - ecosystem: string, - bad: string, -): string | undefined { - const candidates = KNOWN_GOOD_NAMES[ecosystem] - if (!candidates) { - return undefined - } - const target = bad.toLowerCase() - let best: { name: string; dist: number } | undefined - for (let i = 0, { length } = candidates; i < length; i += 1) { - const c = candidates[i]! - const d = levenshtein(target, c.toLowerCase()) - if (d <= 2 && (!best || d < best.dist)) { - best = { name: c, dist: d } - } - } - return best?.name -} - -// Iterative Levenshtein with a single rolling row. We bail early -// once the running min in the row exceeds 2, since that's our cap. -function levenshtein(a: string, b: string): number { - if (a === b) { - return 0 - } - if (!a.length) { - return b.length - } - if (!b.length) { - return a.length - } - const aLen = a.length - const bLen = b.length - // Eager length-difference prune: if |a|-|b| > 2 the answer is > 2. - if (Math.abs(aLen - bLen) > 2) { - return Math.abs(aLen - bLen) - } - let prev: number[] = Array.from({ length: bLen + 1 }, () => 0) - let curr: number[] = Array.from({ length: bLen + 1 }, () => 0) - for (let j = 0; j <= bLen; j++) { - prev[j] = j - } - for (let i = 1; i <= aLen; i++) { - curr[0] = i - let rowMin = curr[0]! - const ai = a.charCodeAt(i - 1) - for (let j = 1; j <= bLen; j++) { - const cost = ai === b.charCodeAt(j - 1) ? 0 : 1 - const del = prev[j]! + 1 - const ins = curr[j - 1]! + 1 - const sub = prev[j - 1]! + cost - const v = del < ins ? (del < sub ? del : sub) : ins < sub ? ins : sub - curr[j] = v - if (v < rowMin) { - rowMin = v - } - } - if (rowMin > 2) { - return rowMin - } - const tmp = prev - prev = curr - curr = tmp - } - return prev[bLen]! -} - -// End-of-hook accounting: write the audit log, bump the persistent -// 404 cache, and surface a slopsquatting warning when any PURL has -// crossed the threshold on this invocation. -async function recordCheckOutcome( - hook: HookInput, - deps: Dep[], - outcome: BatchOutcome, -): Promise { - try { - const records = buildAuditRecords(hook, deps, outcome) - await appendAuditRecords(records) - } catch (e) { - // Build / append both wrapped; the outer catch is defense in - // depth against a bug in buildAuditRecords itself. - process.stderr.write( - `[check-new-deps] audit record build failed: ${errorMessage(e)}\n`, - ) - } - try { - const crossed = await bumpNotFoundCounters(outcome.notFound) - for (let i = 0, { length } = crossed; i < length; i += 1) { - const purl = crossed[i]! - const dep = depFromPurl(purl) - if (!dep) { - continue - } - const suggestion = suggestSimilarName(dep.type, dep.name) - const hint = suggestion ? ` (did you mean "${suggestion}"?)` : '' - process.stderr.write( - `[check-new-deps] warning: package "${dep.name}" ` + - `(${dep.type}) has been requested ${NOT_FOUND_THRESHOLD}+ ` + - `times and does not exist on the Socket.dev registry — ` + - `possible AI-hallucinated name${hint}.\n`, - ) - } - } catch (e) { - process.stderr.write( - `[check-new-deps] 404 accounting failed: ${errorMessage(e)}\n`, - ) - } -} - -export { - AUDIT_LOG_FILE, - appendAuditRecords, - buildAuditRecords, - bumpNotFoundCounters, - depFromPurl, - depIdentity, - deriveSessionId, - getNotFoundCache, - levenshtein, - NOT_FOUND_THRESHOLD, - recordCheckOutcome, - suggestSimilarName, -} diff --git a/.claude/hooks/check-new-deps/index.mts b/.claude/hooks/check-new-deps/index.mts deleted file mode 100644 index 57b0420c3..000000000 --- a/.claude/hooks/check-new-deps/index.mts +++ /dev/null @@ -1,782 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — Socket.dev dependency firewall. -// -// Intercepts Edit/Write tool calls to dependency manifest files across -// 17+ package ecosystems. Extracts newly-added dependencies, builds -// Package URLs (PURLs), and checks them against the Socket.dev API -// using the SDK v4 checkMalware() method. -// -// Diff-aware: when old_string is present (Edit), only deps that -// appear in new_string but NOT in old_string are checked. -// -// In-process caching: API responses are cached in-process with a TTL -// to avoid redundant network calls when the same dep is checked -// repeatedly. The cache auto-evicts expired entries and caps at -// MAX_CACHE_SIZE. -// -// Slopsquatting defense + audit log live in `./audit.mts` — see that -// module's file-header comment for the Threat 2.2 mitigation. -// -// Exit codes: -// 0 = allow (no new deps, all clean, or non-dep file) -// 2 = block (malware detected by Socket.dev) - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - parseNpmSpecifier, - stringify, -} from '@socketregistry/packageurl-js-stable' -import type { PackageURL } from '@socketregistry/packageurl-js-stable' -import { SOCKET_PUBLIC_API_TOKEN } from '@socketsecurity/lib-stable/constants/socket' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { SocketSdk } from '@socketsecurity/sdk-stable' -import type { MalwareCheckPackage } from '@socketsecurity/sdk-stable' - -import { recordCheckOutcome } from './audit.mts' -import type { BatchOutcome, CheckResult, Dep, HookInput } from './types.mts' - -const logger = getDefaultLogger() - -// Per-request timeout (ms) to avoid blocking the hook on slow responses. -const API_TIMEOUT = 5_000 -// Max PURLs per batch request (API limit is 1024). -const MAX_BATCH_SIZE = 1024 -// How long (ms) to cache a successful API response (5 minutes). -const CACHE_TTL = 5 * 60 * 1_000 -// Maximum cache entries before forced eviction of oldest. -const MAX_CACHE_SIZE = 500 - -// SDK instance using the public API token (no user config needed). -const sdk = new SocketSdk(SOCKET_PUBLIC_API_TOKEN, { - timeout: API_TIMEOUT, -}) - -// A cached API lookup result with expiration timestamp. -interface CacheEntry { - result: CheckResult | undefined - expiresAt: number -} - -// Function that extracts deps from file content. -type Extractor = (content: string) => Dep[] - -// --- cache --- - -// Simple TTL + max-size cache for API responses. -// Prevents redundant network calls when the same dep is checked -// multiple times in a session. Evicts expired entries on every -// get/set, and drops oldest entries if the cache exceeds MAX_CACHE_SIZE. -const cache = new Map() - -function cacheGet(key: string): CacheEntry | undefined { - const entry = cache.get(key) - if (!entry) { - return - } - if (Date.now() > entry.expiresAt) { - cache.delete(key) - return - } - return entry -} - -function cacheSet(key: string, result: CheckResult | undefined): void { - // Evict expired entries before inserting. - if (cache.size >= MAX_CACHE_SIZE) { - const now = Date.now() - for (const [k, v] of cache) { - if (now > v.expiresAt) { - cache.delete(k) - } - } - } - // If still over capacity, drop the oldest entries (FIFO). - if (cache.size >= MAX_CACHE_SIZE) { - const excess = cache.size - MAX_CACHE_SIZE + 1 - let dropped = 0 - for (const k of cache.keys()) { - if (dropped >= excess) { - break - } - cache.delete(k) - dropped++ - } - } - cache.set(key, { - result, - expiresAt: Date.now() + CACHE_TTL, - }) -} - -// Manifest file suffix → extractor function. -// __proto__: null prevents prototype-pollution on lookups. -const extractors: Record = { - __proto__: null as unknown as Extractor, - '.csproj': extract( - // .NET: - /PackageReference\s+Include="([^"]+)"/g, - (m): Dep => ({ type: 'nuget', name: m[1]! }), - ), - '.tf': extractTerraform, - Brewfile: extractBrewfile, - 'build.gradle': extractMaven, - 'build.gradle.kts': extractMaven, - 'Cargo.lock': extract( - // Rust lockfile: [[package]]\nname = "serde"\nversion = "1.0.0" - /name\s*=\s*"([\w][\w-]*)"/gm, - (m): Dep => ({ type: 'cargo', name: m[1]! }), - ), - 'Cargo.toml': (content: string): Dep[] => { - // Rust: extract crate names from dep lines. - // - // Two-mode strategy because the hook receives either a full - // Cargo.toml (Write) or a fragment (Edit's new_string, often just - // the added line with no section header): - // - // Full file — scan only [dependencies] / [dev-dependencies] / - // [build-dependencies] (incl. target-specific - // [target.*.dependencies] via the `.` suffix) - // and skip [package], [features], [profile], etc. - // Fragment — no section headers at all → treat the whole - // content as an implicit [dependencies] body and - // match any `name = "..."` or `name = { version = "..." }`. - // - // The lineRe requires the value to look like a version spec - // (string or table with a `version` key), so `[features]`-style - // `key = ["derive"]` array values don't match even in fragment mode. - const deps: Dep[] = [] - const depSectionRe = - /^\[(?:(?:build-|dev-)?dependencies(?:\.[^\]]+)?|target\.[^\]]+\.(?:build-|dev-)?dependencies(?:\.[^\]]+)?)\]\s*$/gm - const anySectionRe = /^\[/gm - const lineRe = - /^(\w[\w-]*)\s*=\s*(?:\{[^}]*version\s*=\s*"[^"]*"|\s*"[^"]*")/gm - const push = (section: string) => { - let m - while ((m = lineRe.exec(section)) !== null) { - deps.push({ type: 'cargo', name: m[1]! }) - } - lineRe.lastIndex = 0 - } - const hasAnySection = /^\[/m.test(content) - if (!hasAnySection) { - push(content) - return deps - } - let sectionMatch - while ((sectionMatch = depSectionRe.exec(content)) !== null) { - const sectionStart = sectionMatch.index + sectionMatch[0].length - anySectionRe.lastIndex = sectionStart - const nextSection = anySectionRe.exec(content) - const sectionEnd = nextSection ? nextSection.index : content.length - push(content.slice(sectionStart, sectionEnd)) - } - return deps - }, - 'conanfile.py': extractConan, - 'conanfile.txt': extractConan, - 'composer.lock': extract( - // PHP lockfile: "name": "vendor/package" - /"name":\s*"([a-z][\w-]*)\/([a-z][\w-]*)"/g, - (m): Dep => ({ - type: 'composer', - namespace: m[1]!, - name: m[2]!, - }), - ), - 'composer.json': extract( - // PHP: "vendor/package": "^3.0" - /"([a-z][\w-]*)\/([a-z][\w-]*)":\s*"/g, - (m): Dep => ({ - type: 'composer', - namespace: m[1]!, - name: m[2]!, - }), - ), - 'flake.nix': extractNixFlake, - 'Gemfile.lock': extract( - // Ruby lockfile: indented gem names under GEM > specs - /^\s{4}(\w[\w-]*)\s+\(/gm, - (m): Dep => ({ type: 'gem', name: m[1]! }), - ), - Gemfile: extract( - // Ruby: gem 'rails', '~> 7.0' - /gem\s+['"]([^'"]+)['"]/g, - (m): Dep => ({ type: 'gem', name: m[1]! }), - ), - 'go.sum': extract( - // Go checksum file: module/path v1.2.3 h1:hash= - /([\w./-]+)\s+v[\d.]+/gm, - (m): Dep => { - const parts = m[1]!.split('/') - return { - type: 'golang', - name: parts.pop()!, - namespace: parts.join('/') || undefined, - } - }, - ), - 'go.mod': extract( - // Go: github.com/gin-gonic/gin v1.9.1 - /([\w./-]+)\s+v[\d.]+/gm, - (m): Dep => { - const parts = m[1]!.split('/') - return { - type: 'golang', - name: parts.pop()!, - namespace: parts.join('/') || undefined, - } - }, - ), - 'mix.exs': extract( - // Elixir: {:phoenix, "~> 1.7"} - /\{:(\w+),/g, - (m): Dep => ({ type: 'hex', name: m[1]! }), - ), - 'package-lock.json': extractNpmLockfile, - 'package.json': extractNpm, - 'Package.swift': extract( - // Swift: .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0") - /\.package\s*\(\s*url:\s*"https:\/\/github\.com\/([^/]+)\/([^"]+)".*?from:\s*"([^"]+)"/gs, - (m): Dep => ({ - type: 'swift', - namespace: `github.com/${m[1]!}`, - name: m[2]!.replace(/\.git$/, ''), - version: m[3]!, - }), - ), - 'Pipfile.lock': extractPipfileLock, - 'pnpm-lock.yaml': extractNpmLockfile, - 'poetry.lock': extract( - // Python poetry lockfile: [[package]]\nname = "flask" - /name\s*=\s*"([a-zA-Z][\w.-]*)"/gm, - (m): Dep => ({ type: 'pypi', name: m[1]! }), - ), - 'pom.xml': extractMaven, - 'Project.toml': extract( - // Julia: JSON3 = "uuid-string" - /^(\w[\w.-]*)\s*=\s*"/gm, - (m): Dep => ({ type: 'julia', name: m[1]! }), - ), - 'pubspec.lock': extract( - // Dart lockfile: top-level package names at column 2 - /^ (\w[\w_-]*):/gm, - (m): Dep => ({ type: 'pub', name: m[1]! }), - ), - 'pubspec.yaml': extract( - // Dart: flutter_bloc: ^8.1.3 (2-space indented under dependencies:) - /^\s{2}(\w[\w_-]*):\s/gm, - (m): Dep => ({ type: 'pub', name: m[1]! }), - ), - 'pyproject.toml': extractPypi, - 'requirements.txt': extractPypi, - 'setup.py': extractPypi, - 'yarn.lock': extractNpmLockfile, -} - -// --- core --- - -// Orchestrates the full check: extract deps, diff against old, query API. -export async function check(hook: HookInput): Promise { - // Normalize backslashes and collapse segments for cross-platform paths. - const filePath = normalizePath(hook.tool_input?.file_path || '') - - // GitHub Actions workflows live under .github/workflows/*.yml - const isWorkflow = /\.github\/workflows\/.*\.ya?ml$/.test(filePath) - const extractor = isWorkflow ? extractGitHubActions : findExtractor(filePath) - if (!extractor) { - return 0 - } - - // Edit provides new_string; Write provides content. - const newContent = - hook.tool_input?.new_string ?? hook.tool_input?.content ?? '' - const oldContent = hook.tool_input?.old_string ?? '' - - const newDeps = extractor(newContent) - if (newDeps.length === 0) { - return 0 - } - - // Diff-aware: only check deps added in this edit, not pre-existing. - const deps = oldContent ? diffDeps(newDeps, extractor(oldContent)) : newDeps - if (deps.length === 0) { - return 0 - } - - // Check all deps via SDK checkMalware(). - const { blocked, notFound, ok } = await checkDepsBatch(deps) - - // Fire-and-forget audit + slopsquatting accounting. Failures here - // must not change the verdict, so swallow everything. - await recordCheckOutcome(hook, deps, { blocked, notFound, ok }) - - if (blocked.length > 0) { - logger.error(`Socket: blocked ${blocked.length} dep(s):`) - for (let i = 0, { length } = blocked; i < length; i += 1) { - const b = blocked[i]! - logger.error(` ${b.purl}: ${b.reason}`) - } - return 2 - } - return 0 -} - -// Check deps against Socket.dev using SDK v4 checkMalware(). -// Deps already in cache are skipped; results are cached after lookup. -async function checkDepsBatch(deps: Dep[]): Promise { - const blocked: CheckResult[] = [] - const notFound = new Set() - const ok = new Set() - - // Partition deps into cached vs uncached. - const uncached: Array<{ dep: Dep; purl: string }> = [] - for (let i = 0, { length } = deps; i < length; i += 1) { - const dep = deps[i]! - const purl = stringify(dep as unknown as PackageURL) - const cached = cacheGet(purl) - if (cached) { - if (cached.result?.blocked) { - blocked.push(cached.result) - } else { - ok.add(purl) - } - continue - } - uncached.push({ dep, purl }) - } - - if (!uncached.length) { - return { blocked, notFound, ok } - } - - try { - // Process in chunks to respect API batch size limit. - for (let i = 0; i < uncached.length; i += MAX_BATCH_SIZE) { - const batch = uncached.slice(i, i + MAX_BATCH_SIZE) - const components = batch.map(({ purl }) => ({ purl })) - - const result = await sdk.checkMalware(components) - - if (!result.success) { - // Whole-API failure — log and don't infer 404s. Returning - // everything-as-ok would taint the audit log; instead leave - // the batch as unknown and the caller emits 'unknown'. - logger.warn(`Socket: API returned ${result.status}, allowing all`) - return { blocked, notFound, ok } - } - - // Build lookup keyed by full PURL (includes namespace + version). - const purlByKey = new Map() - const requestedKeys = new Set() - for (const { dep, purl } of batch) { - const ns = dep.namespace ? `${dep.namespace}/` : '' - const key = `${dep.type}:${ns}${dep.name}` - purlByKey.set(key, purl) - requestedKeys.add(key) - } - - const seenKeys = new Set() - const pkgs: MalwareCheckPackage[] = result.data - for (let i = 0, { length } = pkgs; i < length; i += 1) { - const pkg = pkgs[i]! - const ns = pkg.namespace ? `${pkg.namespace}/` : '' - const key = `${pkg.type}:${ns}${pkg.name}` - const purl = purlByKey.get(key) - if (!purl) { - continue - } - seenKeys.add(key) - - // Check for malware alerts. - const malware = pkg.alerts.find( - a => a.severity === 'critical' || a.type === 'malware', - ) - if (malware) { - const cr: CheckResult = { - purl, - blocked: true, - reason: `${malware.type} — ${malware.severity ?? 'critical'}`, - } - cacheSet(purl, cr) - blocked.push(cr) - continue - } - - // No malware alerts — clean dep. - cacheSet(purl, undefined) - ok.add(purl) - } - - // Anything we requested but didn't see in the response is a - // 404 from the firewall API (the SDK drops them silently). - // Slopsquatting tip-off lives here. - for (const key of requestedKeys) { - if (seenKeys.has(key)) { - continue - } - const purl = purlByKey.get(key) - if (purl) { - notFound.add(purl) - } - } - } - } catch (e) { - // Network failure — log and allow all deps through. - logger.warn(`Socket: network error (${errorMessage(e)}), allowing all`) - } - - return { blocked, notFound, ok } -} - -// Return deps in `newDeps` that don't appear in `oldDeps` (by PURL). -function diffDeps(newDeps: Dep[], oldDeps: Dep[]): Dep[] { - const old = new Set(oldDeps.map(d => stringify(d as unknown as PackageURL))) - return newDeps.filter(d => !old.has(stringify(d as unknown as PackageURL))) -} - -// Match file path suffix against the extractors map. -function findExtractor(filePath: string): Extractor | undefined { - for (const [suffix, fn] of Object.entries(extractors)) { - if (filePath.endsWith(suffix)) { - return fn - } - } -} - -// --- extractor factory --- - -// Higher-order function: takes a regex and a match→Dep transform, -// returns an Extractor that applies matchAll and collects results. -export function extract( - re: RegExp, - transform: (m: RegExpExecArray) => Dep | undefined, -): Extractor { - return (content: string): Dep[] => { - const deps: Dep[] = [] - for (const m of content.matchAll(re)) { - const dep = transform(m as RegExpExecArray) - if (dep) { - deps.push(dep) - } - } - return deps - } -} - -// --- ecosystem extractors (alphabetic) --- - -// Homebrew (Brewfile): brew "package" or tap "owner/repo". -function extractBrewfile(content: string): Dep[] { - const deps: Dep[] = [] - // brew "git", cask "firefox", tap "homebrew/cask" - for (const m of content.matchAll(/(?:brew|cask)\s+['"]([^'"]+)['"]/g)) { - deps.push({ type: 'brew', name: m[1]! }) - } - return deps -} - -// Conan (C/C++): "boost/1.83.0" in conanfile.txt, -// or requires = "zlib/1.3.0" in conanfile.py. -function extractConan(content: string): Dep[] { - const deps: Dep[] = [] - for (const m of content.matchAll(/([a-z][\w.-]+)\/[\d.]+/gm)) { - deps.push({ type: 'conan', name: m[1]! }) - } - return deps -} - -// GitHub Actions: "uses: owner/repo@ref" in workflow YAML. -// Handles subpaths like "org/repo/subpath@v1". -function extractGitHubActions(content: string): Dep[] { - const deps: Dep[] = [] - for (const m of content.matchAll(/uses:\s*['"]?([^@\s'"]+)@([^\s'"]+)/g)) { - const parts = m[1]!.split('/') - if (parts.length >= 2) { - deps.push({ - type: 'github', - namespace: parts[0]!, - name: parts.slice(1).join('/'), - }) - } - } - return deps -} - -// Maven/Gradle (Java/Kotlin): -// pom.xml: org.apachecommons -// build.gradle(.kts): implementation 'group:artifact:version' -function extractMaven(content: string): Dep[] { - const deps: Dep[] = [] - // XML-style Maven POM declarations. - for (const m of content.matchAll( - /([^<]+)<\/groupId>\s*([^<]+)<\/artifactId>/g, - )) { - deps.push({ - type: 'maven', - namespace: m[1]!, - name: m[2]!, - }) - } - // Gradle shorthand: implementation/api/compile 'group:artifact:ver' - for (const m of content.matchAll( - /(?:api|compile|implementation)\s+['"]([^:'"]+):([^:'"]+)(?::[^'"]*)?['"]/g, - )) { - deps.push({ - type: 'maven', - namespace: m[1]!, - name: m[2]!, - }) - } - return deps -} - -// Convenience entry point for testing: route any file path -// through the correct extractor and return all deps found. -function extractNewDeps(rawFilePath: string, content: string): Dep[] { - // Normalize backslashes and collapse segments for cross-platform. - const filePath = normalizePath(rawFilePath) - const isWorkflow = /\.github\/workflows\/.*\.ya?ml$/.test(filePath) - const extractor = isWorkflow ? extractGitHubActions : findExtractor(filePath) - return extractor ? extractor(content) : [] -} - -// Nix flakes (flake.nix): inputs.name.url = "github:owner/repo" -// or inputs.name = { url = "github:owner/repo"; }; -function extractNixFlake(content: string): Dep[] { - const deps: Dep[] = [] - // Match github:owner/repo patterns in flake inputs. - for (const m of content.matchAll(/github:([^/\s"]+)\/([^/\s"]+)/g)) { - deps.push({ - type: 'github', - namespace: m[1]!, - name: m[2]!.replace(/\/.*$/, ''), - }) - } - return deps -} - -// npm lockfiles (package-lock.json, pnpm-lock.yaml, yarn.lock): -// Each format references packages differently: -// package-lock.json: "node_modules/@scope/name" or "node_modules/name" -// pnpm-lock.yaml: /@scope/name@version or /name@version -// yarn.lock: "@scope/name@version" or name@version -function extractNpmLockfile(content: string): Dep[] { - const deps: Dep[] = [] - const seen = new Set() - - // package-lock.json: "node_modules/name" or "node_modules/@scope/name" - for (const m of content.matchAll( - /node_modules\/((?:@[\w.-]+\/)?[\w][\w.-]*)/g, - )) { - addNpmDep(m[1]!, deps, seen) - } - // pnpm-lock.yaml: '/name@ver' or '/@scope/name@ver' - // yarn.lock: "name@ver" or "@scope/name@ver" - for (const m of content.matchAll(/['"/]((?:@[\w.-]+\/)?[\w][\w.-]*)@/gm)) { - addNpmDep(m[1]!, deps, seen) - } - return deps -} - -// Deduplicated npm dep insertion using parseNpmSpecifier. -export function addNpmDep(raw: string, deps: Dep[], seen: Set): void { - if (seen.has(raw)) { - return - } - seen.add(raw) - if (raw.startsWith('.') || raw.startsWith('/')) { - return - } - if (raw.startsWith('@') || /^[a-z]/.test(raw)) { - const { namespace, name } = parseNpmSpecifier(raw) - if (name) { - deps.push({ type: 'npm', namespace, name }) - } - } -} - -// npm (package.json): "name": "version" or "@scope/name": "ver". -// Only matches entries where the value looks like a version/range/specifier, -// not arbitrary string values like scripts or config. -function extractNpm(content: string): Dep[] { - const deps: Dep[] = [] - for (const m of content.matchAll(/"(@?[^"]+)":\s*"([^"]*)"/g)) { - const raw = m[1]! - const val = m[2]! - // Skip builtins, relative, and absolute paths. - if (raw.startsWith('node:') || raw.startsWith('.') || raw.startsWith('/')) { - continue - } - // Value must look like a version specifier: semver, range, workspace:, - // catalog:, npm:, *, latest, or starts with ^~><=. - if (!/^[\^~><=*]|^\d|^workspace:|^catalog:|^npm:|^latest$/.test(val)) { - continue - } - // Only lowercase or scoped names are real deps. - // Exclude known package.json metadata fields that look like deps. - if (PACKAGE_JSON_METADATA_KEYS.has(raw)) { - continue - } - if (raw.startsWith('@') || /^[a-z]/.test(raw)) { - const { namespace, name } = parseNpmSpecifier(raw) - if (name) { - deps.push({ type: 'npm', namespace, name }) - } - } - } - return deps -} - -// package.json metadata fields that match the "key": "value" dep pattern but aren't deps. -const PACKAGE_JSON_METADATA_KEYS = new Set([ - 'access', - 'author', - 'browser', - 'bugs', - 'cpu', - 'description', - 'engines', - 'exports', - 'homepage', - 'jsdelivr', - 'license', - 'main', - 'module', - 'name', - 'os', - 'publishConfig', - 'repository', - 'sideEffects', - 'type', - 'types', - 'typings', - 'unpkg', - 'version', -]) - -// Pipfile.lock: JSON with "default" and "develop" sections keyed by package name. -export function extractPipfileLock(content: string): Dep[] { - const deps: Dep[] = [] - try { - const lock = JSON.parse(content) as Record> - for (const section of ['default', 'develop']) { - const packages = lock[section] - if (packages && typeof packages === 'object') { - for (const name of Object.keys(packages)) { - deps.push({ type: 'pypi', name }) - } - } - } - } catch { - // JSON.parse fails on partial content (e.g. Edit new_string fragments). - // Fall back to regex matching package name keys in Pipfile.lock JSON. - for (const m of content.matchAll(/"([a-zA-Z][\w.-]*)"\s*:\s*\{/g)) { - deps.push({ type: 'pypi', name: m[1]! }) - } - } - return deps -} - -// PyPI (requirements.txt, pyproject.toml, setup.py): -// requirements.txt: package>=1.0 or package==1.0 at line start -// pyproject.toml: "package>=1.0" in dependencies arrays -// setup.py: "package>=1.0" in install_requires lists -function extractPypi(content: string): Dep[] { - const deps: Dep[] = [] - const seen = new Set() - // requirements.txt style: package name at line start, followed by - // version specifier, extras bracket, or end of line. - for (const m of content.matchAll(/^([a-zA-Z][\w.-]+)\s*(?:[>===18.20.8", - "pnpm": ">=11.0.0-rc.0" - } - }, - "node_modules/@socketsecurity/lib-stable": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/@socketsecurity/lib-stable/-/lib-5.18.2.tgz", - "integrity": "sha512-h6aGfphQ9jdVjUMGIKJcsIvT6BmzBo0OD20HzeK+6KQJi2HupfCUzIH26vDPxf+aYVmrX0/hKJDYI5sXfTGx9A==", - "license": "MIT", - "engines": { - "node": ">=22", - "pnpm": ">=11.0.0-rc.0" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@socketsecurity/sdk-stable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@socketsecurity/sdk-stable/-/sdk-4.0.1.tgz", - "integrity": "sha512-fe3DQp2dFwhc0G6Za36GIMSV+QaPAP5L96K3ZOtywt9nhbwxc9IQwqzdOVztdn5Rbez3t9EHU9Esj24/hWdP0g==", - "license": "MIT", - "engines": { - "node": ">=18.20.8", - "pnpm": ">=11.0.0-rc.0" - } - }, - "node_modules/@types/node": { - "version": "24.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", - "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/.claude/hooks/check-new-deps/package.json b/.claude/hooks/check-new-deps/package.json deleted file mode 100644 index 5f0288727..000000000 --- a/.claude/hooks/check-new-deps/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "hook-check-new-deps", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketregistry/packageurl-js-stable": "catalog:", - "@socketsecurity/lib-stable": "catalog:", - "@socketsecurity/sdk-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/check-new-deps/test/extract-deps.test.mts b/.claude/hooks/check-new-deps/test/extract-deps.test.mts deleted file mode 100644 index cfa73bddd..000000000 --- a/.claude/hooks/check-new-deps/test/extract-deps.test.mts +++ /dev/null @@ -1,1050 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -// node:child_process.spawnSync is used here directly (not lib's -// spawnSync wrapper) because the wrapper's types don't expose the -// `input` field — we need to pipe the hook payload through stdin. -// The wrapper isn't adding any security here: nodeBin comes from -// whichSync (validated path) and the only arg is hookScript (a -// path we control). Same shape Node's native API has. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, mkdtempSync, promises as fsp, rmSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { whichSync } from '@socketsecurity/lib-stable/bin/which' - -import { - buildAuditRecords, - depFromPurl, - depIdentity, - deriveSessionId, - levenshtein, - suggestSimilarName, -} from '../audit.mts' -import { - cache, - cacheGet, - cacheSet, - diffDeps, - extractBrewfile, - extractNewDeps, - extractNixFlake, - extractNpmLockfile, - extractTerraform, -} from '../index.mts' - -const hookScript = new URL('../index.mts', import.meta.url).pathname -const nodeBinRaw = whichSync('node') -if (!nodeBinRaw) { - throw new Error('"node" not found on PATH') -} -// whichSync can return string | string[]; the first hit is canonical. -const nodeBin: string = Array.isArray(nodeBinRaw) ? nodeBinRaw[0]! : nodeBinRaw - -interface RunHookOptions { - // Override HOME/USERPROFILE so the audit log + 404 cache don't - // leak into the developer's real ~/.claude. - home?: string | undefined - transcript_path?: string | undefined - session_id?: string | undefined -} - -// Helper: run the full hook as a subprocess. -// Uses spawnSync because we need to pipe stdin content (the hook reads JSON from stdin). -function runHook( - toolInput: Record, - toolName = 'Edit', - options: RunHookOptions = {}, -): { code: number | null; stdout: string; stderr: string } { - const payload: Record = { - tool_name: toolName, - tool_input: toolInput, - } - if (options.transcript_path) { - payload['transcript_path'] = options.transcript_path - } - if (options.session_id) { - payload['session_id'] = options.session_id - } - const input = JSON.stringify(payload) - // Inherit the parent env (so PATH / NODE / etc. work) and only - // override HOME/USERPROFILE when the test wants an isolated $HOME. - const env: NodeJS.ProcessEnv = { ...process.env } - if (options.home) { - env['HOME'] = options.home - env['USERPROFILE'] = options.home - } - const result = spawnSync(nodeBin, [hookScript], { - input, - timeout: 15_000, - stdio: ['pipe', 'pipe', 'pipe'], - env, - }) - return { - code: result.status ?? 1, - stdout: - typeof result.stdout === 'string' - ? result.stdout - : result.stdout.toString(), - stderr: - typeof result.stderr === 'string' - ? result.stderr - : result.stderr.toString(), - } -} - -// Allocate a throwaway $HOME for each test that touches persistent -// state. Cleaned up via a finally block so failing tests don't pile -// up junk in $TMPDIR. -function makeTempHome(): string { - return mkdtempSync(path.join(os.tmpdir(), 'check-new-deps-test-')) -} - -function removeTempHome(home: string): void { - if (existsSync(home)) { - rmSync(home, { recursive: true, force: true }) - } -} - -// ============================================================================ -// Unit tests: extractNewDeps per ecosystem -// ============================================================================ - -describe('extractNewDeps', () => { - // npm - describe('npm', () => { - it('unscoped', () => { - const d = extractNewDeps('package.json', '"lodash": "^4.17.21"') - assert.equal(d.length, 1) - assert.equal(d[0].type, 'npm') - assert.equal(d[0].name, 'lodash') - assert.equal(d[0].namespace, undefined) - }) - it('scoped', () => { - const d = extractNewDeps('package.json', '"@types/node": "^20.0.0"') - assert.equal(d[0].namespace, '@types') - assert.equal(d[0].name, 'node') - }) - it('multiple', () => { - const d = extractNewDeps( - 'package.json', - '"a": "1", "@b/c": "2", "d": "3"', - ) - assert.equal(d.length, 3) - }) - it('ignores node: builtins', () => { - assert.equal(extractNewDeps('package.json', '"node:fs": "1"').length, 0) - }) - it('ignores relative', () => { - assert.equal(extractNewDeps('package.json', '"./foo": "1"').length, 0) - }) - it('ignores absolute', () => { - assert.equal(extractNewDeps('package.json', '"/foo": "1"').length, 0) - }) - it('ignores capitalized keys', () => { - assert.equal( - extractNewDeps('package.json', '"Name": "my-project"').length, - 0, - ) - }) - it('handles workspace protocol', () => { - const d = extractNewDeps('package.json', '"my-lib": "workspace:*"') - assert.equal(d.length, 1) - }) - }) - - // cargo - describe('cargo', () => { - it('inline version', () => { - const d = extractNewDeps('Cargo.toml', 'serde = "1.0"') - assert.deepEqual(d[0], { type: 'cargo', name: 'serde' }) - }) - it('table version', () => { - const d = extractNewDeps( - 'Cargo.toml', - 'serde = { version = "1.0", features = ["derive"] }', - ) - assert.equal(d[0].name, 'serde') - }) - it('hyphenated name', () => { - assert.equal( - extractNewDeps('Cargo.toml', 'simd-json = "0.17"')[0].name, - 'simd-json', - ) - }) - it('multiple', () => { - assert.equal( - extractNewDeps('Cargo.toml', 'a = "1"\nb = { version = "2" }').length, - 2, - ) - }) - }) - - // golang - describe('golang', () => { - it('with namespace', () => { - const d = extractNewDeps('go.mod', 'github.com/gin-gonic/gin v1.9.1') - assert.equal(d[0].namespace, 'github.com/gin-gonic') - assert.equal(d[0].name, 'gin') - }) - it('stdlib extension', () => { - const d = extractNewDeps('go.mod', 'golang.org/x/sync v0.7.0') - assert.equal(d[0].namespace, 'golang.org/x') - assert.equal(d[0].name, 'sync') - }) - }) - - // pypi - describe('pypi', () => { - it('requirements.txt', () => { - const d = extractNewDeps('requirements.txt', 'flask>=2.0\nrequests==2.31') - assert.ok(d.some(x => x.name === 'flask')) - assert.ok(d.some(x => x.name === 'requests')) - }) - it('pyproject.toml', () => { - assert.ok( - extractNewDeps('pyproject.toml', '"django>=4.2"').some( - x => x.name === 'django', - ), - ) - }) - it('setup.py', () => { - assert.ok( - extractNewDeps('setup.py', '"numpy>=1.24"').some( - x => x.name === 'numpy', - ), - ) - }) - }) - - // gem - describe('gem', () => { - it('single-quoted', () => { - assert.equal(extractNewDeps('Gemfile', "gem 'rails'")[0].name, 'rails') - }) - it('double-quoted with version', () => { - assert.equal( - extractNewDeps('Gemfile', 'gem "sinatra", "~> 3.0"')[0].name, - 'sinatra', - ) - }) - }) - - // maven - describe('maven', () => { - it('pom.xml', () => { - const d = extractNewDeps( - 'pom.xml', - 'org.apachecommons-lang3', - ) - assert.equal(d[0].namespace, 'org.apache') - assert.equal(d[0].name, 'commons-lang3') - }) - it('build.gradle', () => { - const d = extractNewDeps( - 'build.gradle', - "implementation 'com.google.guava:guava:32.1'", - ) - assert.equal(d[0].namespace, 'com.google.guava') - assert.equal(d[0].name, 'guava') - }) - it('build.gradle.kts', () => { - const d = extractNewDeps( - 'build.gradle.kts', - "implementation 'org.jetbrains:annotations:24.0'", - ) - assert.equal(d[0].name, 'annotations') - }) - }) - - // swift - describe('swift', () => { - it('github package', () => { - const d = extractNewDeps( - 'Package.swift', - '.package(url: "https://github.com/vapor/vapor", from: "4.0.0")', - ) - assert.equal(d[0].type, 'swift') - assert.equal(d[0].name, 'vapor') - }) - }) - - // pub - describe('pub', () => { - it('dart package', () => { - assert.equal( - extractNewDeps('pubspec.yaml', ' flutter_bloc: ^8.1')[0].name, - 'flutter_bloc', - ) - }) - }) - - // hex - describe('hex', () => { - it('elixir dep', () => { - assert.equal( - extractNewDeps('mix.exs', '{:phoenix, "~> 1.7"}')[0].name, - 'phoenix', - ) - }) - }) - - // composer - describe('composer', () => { - it('vendor/package', () => { - const d = extractNewDeps('composer.json', '"monolog/monolog": "^3.0"') - assert.equal(d[0].namespace, 'monolog') - assert.equal(d[0].name, 'monolog') - }) - }) - - // nuget - describe('nuget', () => { - it('.csproj PackageReference', () => { - assert.equal( - extractNewDeps( - 'test.csproj', - '', - )[0].name, - 'Newtonsoft.Json', - ) - }) - }) - - // julia - describe('julia', () => { - it('Project.toml', () => { - assert.equal( - extractNewDeps('Project.toml', 'JSON3 = "0a1fb500"')[0].name, - 'JSON3', - ) - }) - }) - - // conan - describe('conan', () => { - it('conanfile.txt', () => { - assert.equal( - extractNewDeps('conanfile.txt', 'boost/1.83.0')[0].name, - 'boost', - ) - }) - it('conanfile.py', () => { - assert.equal( - extractNewDeps('conanfile.py', 'requires = "zlib/1.3.0"')[0].name, - 'zlib', - ) - }) - }) - - // github actions - describe('github actions', () => { - it('extracts action with version', () => { - const d = extractNewDeps( - '.github/workflows/ci.yml', - 'uses: actions/checkout@v4', - ) - assert.equal(d[0].type, 'github') - assert.equal(d[0].namespace, 'actions') - assert.equal(d[0].name, 'checkout') - }) - it('extracts action with SHA', () => { - const d = extractNewDeps( - '.github/workflows/ci.yml', - 'uses: actions/setup-node@abc123def', - ) - assert.equal(d[0].name, 'setup-node') - }) - it('extracts action with subpath', () => { - const d = extractNewDeps( - '.github/workflows/ci.yml', - 'uses: org/repo/subpath@v1', - ) - assert.equal(d[0].namespace, 'org') - assert.equal(d[0].name, 'repo/subpath') - }) - it('multiple actions', () => { - const d = extractNewDeps( - '.github/workflows/ci.yml', - 'uses: a/b@v1\n uses: c/d@v2', - ) - assert.equal(d.length, 2) - }) - }) - - // terraform - describe('terraform', () => { - it('registry module source', () => { - const d = extractTerraform('source = "hashicorp/consul/aws"') - assert.equal(d[0].type, 'terraform') - assert.equal(d[0].namespace, 'hashicorp') - assert.equal(d[0].name, 'consul') - }) - it('via extractNewDeps', () => { - const d = extractNewDeps( - 'main.tf', - 'source = "cloudflare/dns/cloudflare"', - ) - assert.equal(d.length, 1) - assert.equal(d[0].namespace, 'cloudflare') - }) - }) - - // nix flakes - describe('nix flakes', () => { - it('github input', () => { - const d = extractNixFlake('inputs.nixpkgs.url = "github:NixOS/nixpkgs"') - assert.equal(d[0].type, 'github') - assert.equal(d[0].namespace, 'NixOS') - assert.equal(d[0].name, 'nixpkgs') - }) - it('via extractNewDeps', () => { - const d = extractNewDeps( - 'flake.nix', - 'url = "github:nix-community/home-manager"', - ) - assert.equal(d.length, 1) - assert.equal(d[0].name, 'home-manager') - }) - }) - - // homebrew - describe('homebrew', () => { - it('brew formula', () => { - const d = extractBrewfile('brew "git"') - assert.equal(d[0].type, 'brew') - assert.equal(d[0].name, 'git') - }) - it('cask', () => { - const d = extractBrewfile('cask "firefox"') - assert.equal(d[0].name, 'firefox') - }) - it('via extractNewDeps', () => { - const d = extractNewDeps('Brewfile', 'brew "wget"\ncask "iterm2"') - assert.equal(d.length, 2) - }) - }) - - // lockfiles - describe('lockfiles', () => { - it('package-lock.json', () => { - const d = extractNpmLockfile( - '"node_modules/lodash": { "version": "4.17.21" }', - ) - assert.ok(d.some(x => x.name === 'lodash')) - }) - it('pnpm-lock.yaml', () => { - const d = extractNewDeps( - 'pnpm-lock.yaml', - "'/lodash@4.17.21':\n resolution:", - ) - assert.ok(d.some(x => x.name === 'lodash')) - }) - it('yarn.lock', () => { - const d = extractNewDeps('yarn.lock', '"lodash@^4.17.21":\n version:') - assert.ok(d.some(x => x.name === 'lodash')) - }) - it('Cargo.lock', () => { - const d = extractNewDeps( - 'Cargo.lock', - 'name = "serde"\nversion = "1.0.210"', - ) - assert.equal(d[0].type, 'cargo') - assert.equal(d[0].name, 'serde') - }) - it('go.sum', () => { - const d = extractNewDeps( - 'go.sum', - 'github.com/gin-gonic/gin v1.9.1 h1:abc=', - ) - assert.equal(d[0].type, 'golang') - assert.equal(d[0].name, 'gin') - }) - it('Gemfile.lock', () => { - const d = extractNewDeps( - 'Gemfile.lock', - ' rails (7.1.0)\n activerecord (7.1.0)', - ) - assert.ok(d.some(x => x.name === 'rails')) - }) - it('composer.lock', () => { - const d = extractNewDeps('composer.lock', '"name": "monolog/monolog"') - assert.equal(d[0].namespace, 'monolog') - assert.equal(d[0].name, 'monolog') - }) - it('poetry.lock', () => { - const d = extractNewDeps( - 'poetry.lock', - 'name = "flask"\nversion = "3.0.0"', - ) - assert.ok(d.some(x => x.name === 'flask')) - }) - it('pubspec.lock', () => { - const d = extractNewDeps( - 'pubspec.lock', - ' flutter_bloc:\n dependency: direct', - ) - assert.ok(d.some(x => x.name === 'flutter_bloc')) - }) - }) - - // windows paths - describe('windows paths', () => { - it('handles backslash in package.json path', () => { - const d = extractNewDeps( - 'C:\\Users\\foo\\project\\package.json', - '"lodash": "^4"', - ) - assert.equal(d.length, 1) - assert.equal(d[0].name, 'lodash') - }) - it('handles backslash in workflow path', () => { - const d = extractNewDeps( - '.github\\workflows\\ci.yml', - 'uses: actions/checkout@v4', - ) - assert.equal(d.length, 1) - assert.equal(d[0].name, 'checkout') - }) - it('handles backslash in Cargo.toml path', () => { - const d = extractNewDeps('src\\parser\\Cargo.toml', 'serde = "1.0"') - assert.equal(d.length, 1) - }) - }) - - // pass-through - describe('unsupported files', () => { - it('returns empty for .rs', () => { - assert.equal(extractNewDeps('main.rs', 'fn main(){}').length, 0) - }) - it('returns empty for .js', () => { - assert.equal(extractNewDeps('index.js', 'x').length, 0) - }) - it('returns empty for .md', () => { - assert.equal(extractNewDeps('README.md', '# hi').length, 0) - }) - }) -}) - -// ============================================================================ -// Unit tests: diffDeps -// ============================================================================ - -describe('diffDeps', () => { - it('returns only new deps', () => { - const newDeps = [ - { type: 'npm', name: 'a' }, - { type: 'npm', name: 'b' }, - ] - const oldDeps = [{ type: 'npm', name: 'a' }] - const result = diffDeps(newDeps, oldDeps) - assert.equal(result.length, 1) - assert.equal(result[0].name, 'b') - }) - it('returns empty when no new deps', () => { - const deps = [{ type: 'npm', name: 'a' }] - assert.equal(diffDeps(deps, deps).length, 0) - }) - it('returns all when old is empty', () => { - const deps = [ - { type: 'npm', name: 'a' }, - { type: 'npm', name: 'b' }, - ] - assert.equal(diffDeps(deps, []).length, 2) - }) -}) - -// ============================================================================ -// Unit tests: cache -// ============================================================================ - -describe('cache', () => { - it('stores and retrieves entries', () => { - cache.clear() - cacheSet('pkg:npm/test', { purl: 'pkg:npm/test', blocked: true }) - const entry = cacheGet('pkg:npm/test') - assert.ok(entry) - assert.equal(entry!.result?.blocked, true) - }) - it('returns undefined for missing keys', () => { - cache.clear() - assert.equal(cacheGet('pkg:npm/missing'), undefined) - }) - it('evicts expired entries on get', () => { - cache.clear() - // Manually insert an expired entry. - cache.set('pkg:npm/expired', { - result: undefined, - expiresAt: Date.now() - 1000, - }) - assert.equal(cacheGet('pkg:npm/expired'), undefined) - assert.equal(cache.has('pkg:npm/expired'), false) - }) - it('caches undefined for clean deps', () => { - cache.clear() - cacheSet('pkg:npm/clean', undefined) - const entry = cacheGet('pkg:npm/clean') - assert.ok(entry) - assert.equal(entry!.result, undefined) - }) -}) - -// ============================================================================ -// Integration tests: full hook subprocess -// ============================================================================ - -describe('hook integration', () => { - // Blocking - it('blocks malware (npm)', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - new_string: '"bradleymeck": "^1.0.0"', - }) - assert.equal(r.code, 2) - assert.ok(r.stderr.includes('blocked')) - }) - - // Allowing - it('allows clean npm package', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - new_string: '"lodash": "^4.17.21"', - }) - assert.equal(r.code, 0) - }) - it('allows scoped npm package', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - new_string: '"@types/node": "^20"', - }) - assert.equal(r.code, 0) - }) - it('allows cargo crate', async () => { - const r = await runHook({ - file_path: '/tmp/Cargo.toml', - new_string: 'serde = "1.0"', - }) - assert.equal(r.code, 0) - }) - it('allows go module', async () => { - const r = await runHook({ - file_path: '/tmp/go.mod', - new_string: 'golang.org/x/sync v0.7.0', - }) - assert.equal(r.code, 0) - }) - it('allows pypi package', async () => { - const r = await runHook({ - file_path: '/tmp/requirements.txt', - new_string: 'flask>=2.0', - }) - assert.equal(r.code, 0) - }) - it('allows ruby gem', async () => { - const r = await runHook({ - file_path: '/tmp/Gemfile', - new_string: "gem 'rails'", - }) - assert.equal(r.code, 0) - }) - it('allows maven dep', async () => { - const r = await runHook({ - file_path: '/tmp/build.gradle', - new_string: "implementation 'com.google.guava:guava:32.1'", - }) - assert.equal(r.code, 0) - }) - it('allows nuget package', async () => { - const r = await runHook({ - file_path: '/tmp/test.csproj', - new_string: - '', - }) - assert.equal(r.code, 0) - }) - it('allows github action', async () => { - const r = await runHook({ - file_path: '/tmp/.github/workflows/ci.yml', - new_string: 'uses: actions/checkout@v4', - }) - assert.equal(r.code, 0) - }) - - // Pass-through - it('passes non-dep files', async () => { - const r = await runHook({ - file_path: '/tmp/main.rs', - new_string: 'fn main(){}', - }) - assert.equal(r.code, 0) - }) - it('passes non-Edit tools', async () => { - const r = await runHook({ file_path: '/tmp/package.json' }, 'Read') - assert.equal(r.code, 0) - }) - - // Diff-aware - it('skips pre-existing deps in old_string', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - old_string: '"lodash": "^4.17.21"', - new_string: '"lodash": "^4.17.21"', - }) - assert.equal(r.code, 0) - }) - it('checks only NEW deps when old_string present', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - old_string: '"lodash": "^4.17.21"', - new_string: '"lodash": "^4.17.21", "bradleymeck": "^1.0.0"', - }) - assert.equal(r.code, 2) - }) - - // Batch (multiple deps in one request) - it('checks multiple deps in batch (fast)', async () => { - const start = Date.now() - const r = await runHook({ - file_path: '/tmp/package.json', - new_string: '"express": "^4", "lodash": "^4", "debug": "^4"', - }) - assert.equal(r.code, 0) - assert.ok(Date.now() - start < 5000, 'batch should be fast') - }) - - // Write tool - it('works with Write tool', async () => { - const r = await runHook( - { file_path: '/tmp/package.json', content: '"lodash": "^4"' }, - 'Write', - ) - assert.equal(r.code, 0) - }) - - // Empty content - it('handles empty content', async () => { - const r = await runHook({ - file_path: '/tmp/package.json', - new_string: '', - }) - assert.equal(r.code, 0) - }) - - // Lockfile monitoring - it('checks lockfile deps (Cargo.lock)', async () => { - const r = await runHook({ - file_path: '/tmp/Cargo.lock', - new_string: 'name = "serde"\nversion = "1.0.210"', - }) - assert.equal(r.code, 0) - }) - - // Terraform - it('checks terraform module', async () => { - const r = await runHook({ - file_path: '/tmp/main.tf', - new_string: 'source = "hashicorp/consul/aws"', - }) - assert.equal(r.code, 0) - }) -}) - -// ============================================================================ -// Unit tests: PURL <-> identity helpers -// ============================================================================ - -describe('depIdentity / depFromPurl', () => { - it('round-trips an unscoped npm dep', () => { - const id = depIdentity({ type: 'npm', name: 'lodash' }) - assert.equal(id, 'npm/lodash') - }) - it('round-trips a scoped npm dep', () => { - const id = depIdentity({ - type: 'npm', - name: 'node', - namespace: '@types', - }) - assert.equal(id, 'npm/@types/node') - }) - it('parses unscoped purl', () => { - const d = depFromPurl('pkg:npm/lodash@4.17.21') - assert.equal(d?.type, 'npm') - assert.equal(d?.name, 'lodash') - assert.equal(d?.namespace, undefined) - }) - it('parses scoped purl (url-encoded @)', () => { - // socket-sdk PURLs url-encode @ to %40 — the parser does not need - // to round-trip-decode, just to peel off `{type}/{namespace}/{name}`. - const d = depFromPurl('pkg:npm/%40types/node@20') - assert.equal(d?.type, 'npm') - assert.equal(d?.namespace, '%40types') - assert.equal(d?.name, 'node') - }) - it('parses purl without version', () => { - const d = depFromPurl('pkg:cargo/serde') - assert.equal(d?.type, 'cargo') - assert.equal(d?.name, 'serde') - }) - it('returns undefined for non-purl', () => { - assert.equal(depFromPurl('lodash@4'), undefined) - assert.equal(depFromPurl('pkg:'), undefined) - }) -}) - -// ============================================================================ -// Unit tests: levenshtein + suggestSimilarName -// ============================================================================ - -describe('levenshtein', () => { - it('returns 0 for identical strings', () => { - assert.equal(levenshtein('foo', 'foo'), 0) - }) - it('returns length when one side is empty', () => { - assert.equal(levenshtein('', 'foo'), 3) - assert.equal(levenshtein('foo', ''), 3) - }) - it('handles a single substitution', () => { - assert.equal(levenshtein('cat', 'bat'), 1) - }) - it('handles a single insertion', () => { - assert.equal(levenshtein('cat', 'cats'), 1) - }) - it('handles a transposition (two edits)', () => { - assert.equal(levenshtein('expres', 'express'), 1) - }) - it('returns difference for very-different strings', () => { - // Bailout path: returns rowMin once it exceeds 2. - const d = levenshtein('totally-fake', 'lodash') - assert.ok(d > 2) - }) -}) - -describe('suggestSimilarName', () => { - it('suggests express for expres', () => { - assert.equal(suggestSimilarName('npm', 'expres'), 'express') - }) - it('suggests lodash for loadash', () => { - assert.equal(suggestSimilarName('npm', 'loadash'), 'lodash') - }) - it('returns undefined for nothing close enough', () => { - assert.equal(suggestSimilarName('npm', 'totally-fake'), undefined) - }) - it('returns undefined for unknown ecosystem', () => { - assert.equal(suggestSimilarName('made-up', 'lodash'), undefined) - }) - it('suggests requests for requets (pypi)', () => { - assert.equal(suggestSimilarName('pypi', 'requets'), 'requests') - }) -}) - -// ============================================================================ -// Unit tests: deriveSessionId -// ============================================================================ - -describe('deriveSessionId', () => { - it('prefers explicit session_id over transcript_path', () => { - const id = deriveSessionId({ - tool_name: 'Edit', - session_id: 'sess-abc', - transcript_path: '/foo/sess-zzz.jsonl', - }) - assert.equal(id, 'sess-abc') - }) - it('strips .jsonl from transcript path basename', () => { - const id = deriveSessionId({ - tool_name: 'Edit', - transcript_path: '/path/to/abc-1234.jsonl', - }) - assert.equal(id, 'abc-1234') - }) - it('returns undefined when neither is set', () => { - assert.equal(deriveSessionId({ tool_name: 'Edit' }), undefined) - }) -}) - -// ============================================================================ -// Unit tests: buildAuditRecords -// ============================================================================ - -describe('buildAuditRecords', () => { - it('emits one record per dep with correct verdict mix', () => { - const deps = [ - { type: 'npm', name: 'lodash' }, - { type: 'npm', name: 'evil-pkg' }, - { type: 'npm', name: 'ghost-pkg' }, - { type: 'npm', name: 'mystery-pkg' }, - ] - const records = buildAuditRecords( - { - tool_name: 'Edit', - session_id: 'sess-1', - }, - deps, - { - blocked: [ - { - purl: 'pkg:npm/evil-pkg', - blocked: true, - reason: 'malware — critical', - }, - ], - notFound: new Set(['pkg:npm/ghost-pkg']), - ok: new Set(['pkg:npm/lodash']), - }, - ) - assert.equal(records.length, 4) - const byName = new Map(records.map(r => [r.name, r])) - assert.equal(byName.get('lodash')?.verdict, 'allow') - assert.equal(byName.get('evil-pkg')?.verdict, 'block') - assert.equal(byName.get('evil-pkg')?.reason, 'malware — critical') - assert.equal(byName.get('ghost-pkg')?.verdict, 'notfound') - assert.equal(byName.get('mystery-pkg')?.verdict, 'unknown') - // Session id flows through. - for (let i = 0, { length } = records; i < length; i += 1) { - const r = records[i]! - assert.equal(r.session, 'sess-1') - } - // Repo basename is the cwd basename (which varies by where tests run). - for (let i = 0, { length } = records; i < length; i += 1) { - const r = records[i]! - assert.ok(typeof r.repo === 'string' && r.repo.length > 0) - } - }) -}) - -// ============================================================================ -// Integration tests: audit log + 404 tracking -// ============================================================================ - -describe('audit log integration', () => { - it('writes one jsonl record per checked dep', async () => { - const home = makeTempHome() - try { - const r = runHook( - { - file_path: '/tmp/package.json', - new_string: '"lodash": "^4.17.21"', - }, - 'Edit', - { home, session_id: 'sess-audit-1' }, - ) - assert.equal(r.code, 0) - const log = path.join(home, '.claude', 'audit', 'check-new-deps.jsonl') - assert.ok(existsSync(log), 'audit log file should exist') - const body = await fsp.readFile(log, 'utf8') - const lines = body.trim().split('\n') - assert.equal(lines.length, 1) - const record = JSON.parse(lines[0]!) as Record - assert.equal(record['name'], 'lodash') - assert.equal(record['type'], 'npm') - assert.equal(record['session'], 'sess-audit-1') - assert.equal(record['verdict'], 'allow') - assert.equal(typeof record['ts'], 'number') - assert.equal(typeof record['repo'], 'string') - } finally { - removeTempHome(home) - } - }) - - it('appends records across multiple invocations', async () => { - const home = makeTempHome() - try { - runHook( - { - file_path: '/tmp/package.json', - new_string: '"lodash": "^4"', - }, - 'Edit', - { home, session_id: 'sess-1' }, - ) - runHook( - { - file_path: '/tmp/package.json', - new_string: '"express": "^4"', - }, - 'Edit', - { home, session_id: 'sess-2' }, - ) - const log = path.join(home, '.claude', 'audit', 'check-new-deps.jsonl') - const body = await fsp.readFile(log, 'utf8') - const lines = body.trim().split('\n').filter(Boolean) - assert.equal(lines.length, 2) - const records = lines.map(l => JSON.parse(l) as Record) - const names = records.map(r => r['name']).toSorted() - assert.deepEqual(names, ['express', 'lodash']) - } finally { - removeTempHome(home) - } - }) - - it('records block verdict on malware hit', async () => { - const home = makeTempHome() - try { - const r = runHook( - { - file_path: '/tmp/package.json', - new_string: '"bradleymeck": "^1.0.0"', - }, - 'Edit', - { home }, - ) - assert.equal(r.code, 2) - const log = path.join(home, '.claude', 'audit', 'check-new-deps.jsonl') - const body = await fsp.readFile(log, 'utf8') - const lines = body.trim().split('\n').filter(Boolean) - const blocked = lines - .map(l => JSON.parse(l) as Record) - .find(r => r['verdict'] === 'block') - assert.ok(blocked, 'should have a block record') - assert.equal(blocked!['name'], 'bradleymeck') - assert.ok( - typeof blocked!['reason'] === 'string' && - (blocked!['reason'] as string).length > 0, - ) - } finally { - removeTempHome(home) - } - }) - - it('writes nothing for non-manifest files', async () => { - const home = makeTempHome() - try { - const r = runHook( - { - file_path: '/tmp/main.rs', - new_string: 'fn main(){}', - }, - 'Edit', - { home }, - ) - assert.equal(r.code, 0) - const log = path.join(home, '.claude', 'audit', 'check-new-deps.jsonl') - assert.equal(existsSync(log), false) - } finally { - removeTempHome(home) - } - }) - - it('does not crash when audit dir is unwritable', async () => { - // Point HOME at a path that already exists as a regular file so - // mkdir of $HOME/.claude/audit fails with ENOTDIR. Hook must - // still return its real verdict (allow for lodash) — exit 0. - const home = path.join(os.tmpdir(), `check-new-deps-bad-home-${Date.now()}`) - await fsp.writeFile(home, 'blocking file', 'utf8') - try { - const r = runHook( - { - file_path: '/tmp/package.json', - new_string: '"lodash": "^4"', - }, - 'Edit', - { home }, - ) - assert.equal(r.code, 0, 'unwritable audit must not fail the hook') - } finally { - if (existsSync(home)) { - rmSync(home, { force: true }) - } - } - }) -}) diff --git a/.claude/hooks/check-new-deps/tsconfig.json b/.claude/hooks/check-new-deps/tsconfig.json deleted file mode 100644 index 53c5c8475..000000000 --- a/.claude/hooks/check-new-deps/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/check-new-deps/types.mts b/.claude/hooks/check-new-deps/types.mts deleted file mode 100644 index b0bbff2bd..000000000 --- a/.claude/hooks/check-new-deps/types.mts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Shared types for the check-new-deps hook. Pure type definitions — no runtime - * side effects, so both index.mts and audit.mts can import without circularity - * concerns. - */ - -// Extracted dependency with ecosystem type, name, and optional scope. -export interface Dep { - type: string - name: string - namespace?: string | undefined - version?: string | undefined -} - -// Shape of the JSON blob Claude Code pipes to the hook via stdin. -export interface HookInput { - tool_name: string - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - old_string?: string | undefined - content?: string | undefined - } - | undefined - // Optional context Claude Code passes when invoking a hook. We only - // read the basename of transcript_path to scope the audit log to - // session; the file itself is never opened. - transcript_path?: string | undefined - session_id?: string | undefined -} - -// Verdict recorded for each checked dep in the audit log. Kept narrow -// so an external tail-the-jsonl process can switch on it directly. -export type Verdict = 'allow' | 'block' | 'notfound' | 'unknown' - -// Result of checking a single dep against the Socket.dev API. -export interface CheckResult { - purl: string - blocked?: boolean | undefined - reason?: string | undefined -} - -// Per-batch outcome breakdown so the caller can route into audit -// logging + slopsquatting accounting without re-deriving anything. -export interface BatchOutcome { - blocked: CheckResult[] - // PURLs the API didn't recognize. The firewall path silently drops - // 404s, the batch path returns them with `score === undefined`; we - // detect both shapes by diffing requested PURLs vs returned ones. - notFound: Set - // PURLs the API confirmed exist and are clean. Anything in this - // set is recorded as `verdict: 'allow'`. - ok: Set -} - -// Persistent shape stored in the 404 TTL cache. We track count + -// first/last timestamps so a future tool can surface "this dep has -// been requested N times across M sessions" without a separate -// counter. -export interface NotFoundEntry { - count: number - firstSeenAt: number - lastSeenAt: number -} - -// Single record written to the audit log. The shape is intentionally -// flat so each line greps cleanly. session/range may be undefined -// when the corresponding Claude Code field wasn't piped through. -export interface AuditRecord { - ts: number - repo: string - type: string - name: string - namespace?: string | undefined - version?: string | undefined - verdict: Verdict - reason?: string | undefined - session?: string | undefined -} diff --git a/.claude/hooks/claude-md-section-size-guard/README.md b/.claude/hooks/claude-md-section-size-guard/README.md deleted file mode 100644 index 54c4b979b..000000000 --- a/.claude/hooks/claude-md-section-size-guard/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# claude-md-section-size-guard - -PreToolUse hook that caps the body length of individual `### ` sections inside the CLAUDE.md fleet-canonical block. - -## What it does - -Complements `claude-md-size-guard` (48KB byte cap on the whole block) by enforcing a per-section line cap inside the block. Each `### Section heading` inside the `` markers gets at most **8 body lines** (configurable via `CLAUDE_MD_FLEET_SECTION_MAX_LINES`). - -Sections that exceed 8 lines should have a long-form companion at `docs/claude.md/fleet/.md` and the inline body should shrink to 1-2 sentences plus a link. The cap was 20 initially (during the bootstrap when several fleet sections were 12-19 lines); it tightened to 8 once those sections were outsourced. - -Blank lines don't count. Code-fence content does count. - -When a section exceeds the cap, the hook prints: - -- Which section was too long. -- How many lines over. -- The canonical fix: move the long form to `docs/claude.md/fleet/.md` and leave a 1-sentence summary + link. - -## What's not enforced - -- Per-repo CLAUDE.md content (outside the markers) — uncapped. -- Sections at `##` or `#` level — only `### ` sections are checked, because that's where fleet rules live. -- Long lines — readability is a separate concern. - -## Why a per-section cap, not just the byte cap - -The failure mode this hook addresses: an operator can grow a single rule from 2 lines to 60 lines of detailed prose without ever tripping the 48KB byte cap — until enough other sections accrete that an unrelated 1-line addition breaks the build. The per-section cap catches this directly, at the moment the long content is written, when the operator has the long-form text in hand and can immediately drop it into a `docs/claude.md/fleet/.md` companion. - -## Override - -`CLAUDE_MD_FLEET_SECTION_MAX_LINES=12 # default 8` - -No bypass phrase — the override env-var is the documented escape valve. If you find yourself reaching for it, that's a strong signal the rule should be outsourced. - -## Reading - -- CLAUDE.md → opening fleet-canonical note (cap is cited there). -- `.claude/hooks/claude-md-size-guard/` — the companion byte-cap hook. diff --git a/.claude/hooks/claude-md-section-size-guard/index.mts b/.claude/hooks/claude-md-section-size-guard/index.mts deleted file mode 100644 index 08096fa1b..000000000 --- a/.claude/hooks/claude-md-section-size-guard/index.mts +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — claude-md-section-size-guard. -// -// Complements `claude-md-size-guard` (40KB byte cap on the whole -// fleet block) by enforcing a per-section LINE cap inside the block. -// Without this, an Edit can grow a single rule from 2 lines into -// 20 paragraphs without ever tripping the byte cap — until enough -// other sections accrete that one tries to add 1 byte and breaks. -// The section cap forces the "outsource to docs/claude.md/fleet/.md" -// pattern at the moment a section is written, when the operator has -// the long-form text in hand. -// -// What the hook does: -// 1. Fires only on Edit/Write tool calls targeting a CLAUDE.md. -// 2. Materializes the post-edit content (full content for Write; -// diff-applied for Edit; the new_string itself for partial Edit -// when the file isn't readable). -// 3. Extracts the fleet block (between BEGIN/END markers). -// 4. Walks the fleet block by `### ` heading boundaries. -// 5. For each section, counts the body lines (lines after the -// heading, up to the next `### ` or `END FLEET-CANONICAL` marker, -// excluding blank lines at the very top of the section). -// 6. If any section's body exceeds the cap, exits 2 with a stderr -// message naming the section + the cap + the canonical fix -// (outsource to `docs/claude.md/fleet/.md` and replace -// the section body with a one-sentence summary + link). -// -// Cap policy: -// - Default: 8 body lines per `### ` section. (8 ≈ a tight rule -// with 2 short paragraphs OR a rule + a "Why:" + a "How:" line.) -// - Override via env `CLAUDE_MD_FLEET_SECTION_MAX_LINES`. -// - Headings only inside the fleet block are checked. Per-repo -// content (outside the markers) is uncapped — repo-specific -// sections can be as long as they need to be. -// -// What counts as a "body line": -// - Any non-blank line below the `### ` heading. -// - Code-block lines (between ``` fences) count too. A long code -// example pushes the section into the "outsource" regime same -// as long prose. -// -// What's NOT a line: -// - Blank lines (`\n` only, or whitespace-only). -// - The heading itself. -// -// Why a section-level cap, not a hook on long lines: -// The failure mode is "I wrote a 60-line rule because it's -// conceptually one rule and the byte budget tolerated it." Per- -// section line count catches this directly. Long lines are a -// separate question (readability) and aren't constrained here. -// -// Hook contract: -// - Reads PreToolUse JSON from stdin. -// - Exits 0 (allowed), 2 (blocked + stderr explanation), or 0 -// with stderr log (fail-open on hook bugs). - -import { existsSync, readFileSync } from 'node:fs' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -// Default cap: 8 body lines. Sections above this should have a -// long-form companion under docs/claude.md/fleet/ and the inline body -// should shrink to 1-2 sentences plus a link. Catches the failure -// mode where a single section grows to 30+ lines while leaving room -// for short rules to stay self-contained. -const DEFAULT_MAX_BODY_LINES = 8 -const FLEET_BEGIN_MARKER = '\n\n` -const EPILOG = `\n\n\nAfter the block.\n` - -function buildClaudeMd( - sections: Array<{ heading: string; body: string }>, -): string { - const body = sections.map(s => `### ${s.heading}\n\n${s.body}\n`).join('\n') - return PROLOG + body + EPILOG -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-CLAUDE.md targets pass through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/README.md', - content: - '# README\n\n\n### s1\n' + - 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('allows short sections under the default cap', async () => { - const content = buildClaudeMd([ - { heading: 'Tooling', body: 'Use pnpm.\n\nNever use npx.' }, - { heading: 'Token hygiene', body: 'Redact tokens. Always.' }, - ]) - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('blocks a section that exceeds the default 8-line cap', async () => { - const longBody = Array(12).fill('one detail line').join('\n') - const content = buildClaudeMd([{ heading: 'Long rule', body: longBody }]) - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Long rule/) - assert.match(result.stderr, /12 body lines/) -}) - -test('blank lines do not count toward the cap', async () => { - // 8 non-blank lines with blanks between — exactly at cap, should pass. - const lines: string[] = [] - for (let i = 1; i <= 8; i++) { - lines.push(`line ${i}`) - lines.push('') - } - const body = lines.join('\n').trimEnd() - const content = buildClaudeMd([{ heading: 'Right at cap', body }]) - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('code-fence lines do count toward the cap', async () => { - // 1 prose + 9 code lines = 10 non-blank > 8 cap. Should block. - const codeLines: string[] = [] - codeLines.push('```ts') - for (let i = 0; i < 7; i++) { - codeLines.push(`const v${i} = ${i}`) - } - codeLines.push('```') - const body = ['Use this pattern:', '', ...codeLines].join('\n') - const content = buildClaudeMd([{ heading: 'Has code block', body }]) - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('reports MULTIPLE too-long sections in one error message', async () => { - const longBody = Array(30).fill('detail').join('\n') - const content = buildClaudeMd([ - { heading: 'Section A', body: longBody }, - { heading: 'Section B', body: 'short' }, - { heading: 'Section C', body: longBody }, - ]) - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Section A/) - assert.match(result.stderr, /Section C/) - assert.doesNotMatch(result.stderr, /Section B/) -}) - -test('only checks ### sections, not ## or #', async () => { - // ## sections are uncapped; should pass even with 30 body lines. - const longBody = Array(30).fill('detail').join('\n') - const content = - PROLOG + - `## Top-level section\n\n${longBody}\n\n### Subsection\n\nshort\n` + - EPILOG - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('content OUTSIDE the fleet markers is uncapped', async () => { - const longBody = Array(50).fill('per-repo detail').join('\n') - const content = - `# Repo CLAUDE.md\n\n### Repo-specific rule\n\n${longBody}\n\n` + - PROLOG + - `### Fleet rule\n\nshort.\n` + - EPILOG + - `\n### Another repo section\n\n${longBody}` - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('respects CLAUDE_MD_FLEET_SECTION_MAX_LINES env override', async () => { - const body = Array(35).fill('line').join('\n') - const content = buildClaudeMd([{ heading: 'Bigger section', body }]) - const result = await runHook( - { - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }, - { CLAUDE_MD_FLEET_SECTION_MAX_LINES: '40' }, - ) - // Cap raised to 40; 35 lines is fine. - assert.strictEqual(result.code, 0) -}) - -test('passes through when fleet markers are absent', async () => { - const content = - '# No fleet block\n\n### Rule\n\n' + Array(100).fill('line').join('\n') - const result = await runHook({ - tool_input: { file_path: '/x/CLAUDE.md', content }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('Edit: when on-disk file is unreadable, falls back to new_string', async () => { - // The /nonexistent path will cause applyEditToFile to return - // undefined; the hook then scans new_string alone. - const longSection = - `\n### overgrown\n\n` + - Array(30).fill('x').join('\n') + - `\n` - const result = await runHook({ - tool_input: { - file_path: '/nonexistent/CLAUDE.md', - old_string: 'a', - new_string: longSection, - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /overgrown/) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not valid json') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) - assert.match(stderr, /fail-open/) -}) - -test('fails open on empty stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('') - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) -}) diff --git a/.claude/hooks/claude-md-section-size-guard/tsconfig.json b/.claude/hooks/claude-md-section-size-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/claude-md-section-size-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/claude-md-size-guard/README.md b/.claude/hooks/claude-md-size-guard/README.md deleted file mode 100644 index 910c72276..000000000 --- a/.claude/hooks/claude-md-size-guard/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# claude-md-size-guard - -PreToolUse Edit/Write hook that blocks CLAUDE.md edits which would push the **fleet-canonical block** (between `` / `` markers) above 48 KB. - -## Why - -The fleet block is byte-identical across every `socket-*` repo. Every byte added there costs N copies of in-context tokens fleet-wide. Per-repo content outside the markers is paid once. Capping the fleet block at 48 KB: - -- Forces new fleet rules to be **terse + reference-deferred** (link to `docs/references/.md`). -- Leaves headroom for per-repo content. Per-repo CLAUDE.md additions are NOT capped here. -- Catches accidental size growth at edit time, before the bytes propagate via `sync-scaffolding`. - -## How - -The hook fires on Edit/Write tool calls. For Write, it inspects `content`. For Edit, it splices `old_string` → `new_string` against the on-disk file and measures the post-edit fleet block. If the block exceeds the cap, exits 2 with stderr explaining the overage and the canonical remediation (move details into `docs/references/.md`). - -## Cap - -- **Default:** 48 KB (49 152 bytes). Sized to leave per-repo CLAUDE.md additions ample room outside the fleet block. -- **Override:** set `CLAUDE_MD_FLEET_BLOCK_BYTES=` in env (rarely needed; bumping the cap should be a deliberate fleet-wide decision). - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't brick the session. The trade-off: a bug means the cap silently doesn't apply for that edit. Acceptable because the alternative (hook crash blocks unrelated edits) is worse. - -## How to add a fleet rule that fits - -1. Write the rule as a single paragraph (3-5 lines max) in the fleet block. -2. Move the expanded explanation to `docs/references/.md` (cascaded fleet-wide via `SHARED_SKILL_FILES` in `sync-scaffolding/manifest.mts`). -3. Link from the rule body: `[Full details](docs/references/.md)`. - -The `bypass-phrases` reference (`docs/references/bypass-phrases.md` ↔ the "Hook bypasses require the canonical phrase" CLAUDE.md rule) is the canonical shape. diff --git a/.claude/hooks/claude-md-size-guard/index.mts b/.claude/hooks/claude-md-size-guard/index.mts deleted file mode 100644 index ad263c92e..000000000 --- a/.claude/hooks/claude-md-size-guard/index.mts +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — claude-md-size-guard. -// -// Blocks Edit/Write tool calls that would push CLAUDE.md above the -// 40KB whole-file size cap. The cap measures the ENTIRE post-edit -// file, not just the fleet-canonical block — fleet content + per-repo -// content both count. -// -// Why a whole-file cap: every byte in CLAUDE.md is load-bearing -// in-context tokens for every Claude session opened in the repo, AND -// fleet content is duplicated across ~12 socket-* repos. The 40KB -// ceiling forces ruthless reference-deferral: each rule states the -// invariant + a one-line "Why" + a link to docs/claude.md/fleet/.md -// for the full pattern catalog. Detail goes in the linked doc. -// -// What the hook does: -// 1. Fires only on Edit/Write tool calls targeting a CLAUDE.md. -// 2. Computes the post-edit text (Write: content; Edit: splice). -// 3. If the whole file exceeds the cap, exits 2 with a stderr message -// naming the size, the cap, and the canonical remediation. -// -// Cap policy: -// - Default: 40 KB (40_960 bytes). Override per-repo via env -// `CLAUDE_MD_BYTES`. Legacy `CLAUDE_MD_FLEET_BLOCK_BYTES` is read -// as a fallback so existing per-repo overrides don't break. -// -// Hook contract: -// - Reads Claude Code's PreToolUse JSON from stdin. -// - Operates on `tool_input.new_string` (Edit) or `tool_input.content` -// (Write). When an Edit is a partial replacement we read the on- -// disk file and apply the diff in-memory. If we can't reliably -// compute (ambiguous Edit), we fail open. - -import { existsSync, readFileSync } from 'node:fs' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -const DEFAULT_CAP_BYTES = 40 * 1024 - -/** - * Compute the post-edit text. For Write, that's just `content`. For Edit, - * splice the on-disk file: replace `old_string` with `new_string` once. If the - * on-disk file isn't readable or `old_string` doesn't match exactly, return - * undefined (caller fails open). - */ -export function computePostEditText( - toolName: string, - filePath: string, - newString: string | undefined, - oldString: string | undefined, - content: string | undefined, -): string | undefined { - if (toolName === 'Write') { - return content - } - if (toolName !== 'Edit') { - return undefined - } - if (!existsSync(filePath)) { - // First Edit on a new file is essentially a Write; treat - // new_string as the full content. - return newString - } - if (oldString === undefined || newString === undefined) { - return undefined - } - let raw: string - try { - raw = readFileSync(filePath, 'utf8') - } catch { - return undefined - } - const idx = raw.indexOf(oldString) - if (idx === -1) { - return undefined - } - return raw.slice(0, idx) + newString + raw.slice(idx + oldString.length) -} - -export function emitBlock( - filePath: string, - fileBytes: number, - capBytes: number, -): void { - const lines: string[] = [] - lines.push('[claude-md-size-guard] Blocked: CLAUDE.md too large.') - lines.push(` File: ${filePath}`) - lines.push(` File size: ${fileBytes} bytes`) - lines.push(` Cap: ${capBytes} bytes (whole file)`) - lines.push(` Over by: ${fileBytes - capBytes} bytes`) - lines.push('') - lines.push(' CLAUDE.md is load-bearing in-context for every session, and') - lines.push(' the fleet block is duplicated across ~12 socket-* repos. The') - lines.push(' 40KB ceiling forces ruthless reference-deferral:') - lines.push('') - lines.push(' 1. State the invariant + one-line "Why" inline.') - lines.push(' 2. Move detail to `docs/claude.md/fleet/.md`.') - lines.push(' 3. Link from the rule: `[Full details](docs/claude.md/...)`.') - lines.push('') - lines.push(' See `docs/claude.md/fleet/bypass-phrases.md` for an example') - lines.push(' of the one-paragraph + reference shape.') - process.stderr.write(lines.join('\n') + '\n') -} - -export function getCap(): number { - const env = - process.env['CLAUDE_MD_BYTES'] ?? process.env['CLAUDE_MD_FLEET_BLOCK_BYTES'] - if (!env) { - return DEFAULT_CAP_BYTES - } - const n = Number.parseInt(env, 10) - if (!Number.isFinite(n) || n <= 0) { - return DEFAULT_CAP_BYTES - } - return n -} - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - old_string?: string | undefined - } - | undefined - tool_name?: string | undefined -} - -export function isClaudeMd(filePath: string | undefined): boolean { - if (!filePath) { - return false - } - const base = filePath.split('/').pop() ?? '' - return base === 'CLAUDE.md' -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!isClaudeMd(filePath)) { - return - } - const postEdit = computePostEditText( - payload.tool_name, - filePath, - payload.tool_input?.new_string, - payload.tool_input?.old_string, - payload.tool_input?.content, - ) - if (postEdit === undefined) { - // Fail open — couldn't compute post-edit text reliably. - return - } - const cap = getCap() - const size = Buffer.byteLength(postEdit, 'utf8') - if (size <= cap) { - return - } - emitBlock(filePath, size, cap) - process.exitCode = 2 -} - -main().catch(e => { - process.stderr.write( - `[claude-md-size-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/claude-md-size-guard/package.json b/.claude/hooks/claude-md-size-guard/package.json deleted file mode 100644 index 6af1514f0..000000000 --- a/.claude/hooks/claude-md-size-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-claude-md-size-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/claude-md-size-guard/test/index.test.mts b/.claude/hooks/claude-md-size-guard/test/index.test.mts deleted file mode 100644 index 9da49a8e3..000000000 --- a/.claude/hooks/claude-md-size-guard/test/index.test.mts +++ /dev/null @@ -1,130 +0,0 @@ -// node --test specs for the claude-md-size-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook( - payload: Record, - envOverride?: Record, -): Promise { - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { ...process.env, ...envOverride }, - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-CLAUDE.md targets are ignored', async () => { - const result = await runHook({ - tool_input: { content: 'x'.repeat(100_000), file_path: 'README.md' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('Write of small file is allowed', async () => { - const result = await runHook({ - tool_input: { content: 'x'.repeat(1_000), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('Write of file at exactly 40KB is allowed', async () => { - const result = await runHook({ - tool_input: { content: 'x'.repeat(40 * 1024), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('Write of file over 40KB is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'x'.repeat(45 * 1024), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /claude-md-size-guard/) - assert.match(result.stderr, /too large/) - assert.match(result.stderr, /docs\/claude\.md\/fleet\//) -}) - -test('cap override via env var', async () => { - const result = await runHook( - { - tool_input: { content: 'x'.repeat(2_000), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }, - { CLAUDE_MD_BYTES: '1024' }, - ) - assert.strictEqual(result.code, 2) -}) - -test('legacy CLAUDE_MD_FLEET_BLOCK_BYTES env still works as fallback', async () => { - const result = await runHook( - { - tool_input: { content: 'x'.repeat(2_000), file_path: 'CLAUDE.md' }, - tool_name: 'Write', - }, - { CLAUDE_MD_FLEET_BLOCK_BYTES: '1024' }, - ) - assert.strictEqual(result.code, 2) -}) - -test('Edit splice that grows file over cap is blocked', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'claude-md-size-guard-')) - const file = path.join(dir, 'CLAUDE.md') - writeFileSync(file, 'base\n') - const result = await runHook({ - tool_input: { - file_path: file, - new_string: 'y'.repeat(45 * 1024), - old_string: 'base\n', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /too large/) -}) - -test('Edit splice that keeps file under cap is allowed', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'claude-md-size-guard-')) - const file = path.join(dir, 'CLAUDE.md') - writeFileSync(file, 'base\n') - const result = await runHook({ - tool_input: { - file_path: file, - new_string: 'z'.repeat(2_000), - old_string: 'base\n', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/claude-md-size-guard/tsconfig.json b/.claude/hooks/claude-md-size-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/claude-md-size-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/codex-no-write-guard/README.md b/.claude/hooks/codex-no-write-guard/README.md deleted file mode 100644 index 153bcf60e..000000000 --- a/.claude/hooks/codex-no-write-guard/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# codex-no-write-guard - -PreToolUse Bash/Agent hook that blocks Codex invocations with code-change -intent. Fleet-wide: only fires when `codex` appears in a command, so it's -a no-op in repos that don't use Codex. - -## Why - -Dense perf-critical code (parser internals, native bindings) is sensitive -to subtle edits. Codex output is excellent for diagnosis and review but -tends to introduce micro-regressions when used to generate code changes. -The 5ms inline-asm-prefetch incident is the canonical example. - -The rule: use Codex for advice; do the edits yourself based on the advice. - -## What it blocks - -| Pattern | Block? | -| --------------------------------------------------------------------- | ------ | -| Bash `codex --write ...` / `codex -w ...` | yes | -| Bash `codex "implement X" ...` / `codex "add Y" ...` / etc. | yes | -| Bash `codex "explain X"` / `codex "diagnose Y"` / `codex "review"` | no | -| Agent `subagent_type: codex:codex-rescue` w/ prompt "implement / fix" | yes | -| Agent `subagent_type: codex:codex-rescue` w/ prompt "diagnose / why" | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow codex-write bypass - -Use sparingly — the regression risk is real. - -## Wiring - -Wired into the fleet's default `template/.claude/settings.json` PreToolUse -chain. The hook short-circuits to exit 0 unless `codex` appears in the -command, so it costs ~nothing in repos that never invoke Codex. diff --git a/.claude/hooks/codex-no-write-guard/index.mts b/.claude/hooks/codex-no-write-guard/index.mts deleted file mode 100644 index 5a1530ee6..000000000 --- a/.claude/hooks/codex-no-write-guard/index.mts +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — codex-no-write-guard. -// -// Per "Codex Usage" rule in opt-in repos (ultrathink today, others future): -// Codex is for advice and assessment ONLY, never code changes. Blocks: -// -// 1. Bash invocations of the `codex` CLI when `--write` or `-w` is passed, -// or when the prompt contains implementation-intent verbs. -// 2. Agent invocations with `subagent_type: codex:codex-rescue` (or other -// `codex:*` subagents) when the prompt contains implementation-intent -// verbs. -// -// Prior incident: Codex added inline asm prefetch causing a 5ms perf -// regression. Codex's output is well-suited for diagnosis but not for code -// changes — the regression patterns are subtle (perf, semantic edge cases) -// that human review catches but Codex doesn't. -// -// Bypass: `Allow codex-write bypass` typed verbatim in a recent user turn. -// -// This hook ships in the wheelhouse template (cascaded everywhere) but is -// wired into `.claude/settings.json` only in opt-in repos. Where unwired, -// it has zero effect. - -import process from 'node:process' - -import { commandsFor, invocationHasFlag } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly command?: string | undefined - readonly subagent_type?: string | undefined - readonly prompt?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow codex-write bypass' - -// Implementation-intent verb pattern. Conservative — matches verbs that -// signal "make code changes" rather than "diagnose / explain / review". -const WRITE_INTENT_VERBS = [ - 'implement', - 'apply', - 'write', - 'add', - 'create', - 'fix', - 'patch', - 'change', - 'edit', - 'rewrite', - 'refactor', - 'update', - 'modify', -] - -export function hasWriteIntent(text: string): string | undefined { - const lower = text.toLowerCase() - for (let i = 0, { length } = WRITE_INTENT_VERBS; i < length; i += 1) { - const verb = WRITE_INTENT_VERBS[i]! - const re = new RegExp(`\\b${verb}(?:s|ing|ed)?\\b`) - if (re.test(lower)) { - return verb - } - } - return undefined -} - -export function isCodexBashCommand(command: string): boolean { - // Parser-based: the binary at a command position is exactly `codex`. - // Rejects `codex-no-write-guard` (a path/identifier, not the binary) and - // a quoted "codex …" inside an arg to another command — both of which - // the old `codex\b` regex wrongly matched. - return commandsFor(command, 'codex').length > 0 -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - const input = payload.tool_input - if (!input) { - process.exit(0) - } - - let blocked: { kind: 'bash' | 'agent'; reason: string } | undefined - - if (payload.tool_name === 'Bash') { - const command = input.command ?? '' - const codexCommands = commandsFor(command, 'codex') - if (codexCommands.length > 0) { - if (invocationHasFlag(command, 'codex', ['--write', '-w'])) { - blocked = { kind: 'bash', reason: '--write / -w flag' } - } else { - // Check write-intent verbs only in the codex command's OWN args - // (the prompt), not the whole shell line — so a sibling command - // or a path containing a verb word doesn't trip the guard. - const codexArgText = codexCommands - .flatMap(c => c.args) - .join(' ') - const verb = hasWriteIntent(codexArgText) - if (verb) { - blocked = { kind: 'bash', reason: `write-intent verb "${verb}"` } - } - } - } - } else if (payload.tool_name === 'Agent') { - const subagent = input.subagent_type ?? '' - if (/^codex(?::|$)/.test(subagent)) { - const prompt = input.prompt ?? '' - const verb = hasWriteIntent(prompt) - if (verb) { - blocked = { kind: 'agent', reason: `write-intent verb "${verb}"` } - } - } - } - - if (!blocked) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[codex-no-write-guard] Blocked: Codex used for code changes', - '', - ` Mode: ${blocked.kind} (${blocked.reason})`, - '', - ' Per "Codex Usage" rule: Codex is for advice and assessment ONLY,', - ' never code changes. Prior incident: Codex added inline asm prefetch', - ' causing a 5ms perf regression — subtle perf bugs that human review', - ' catches but Codex misses.', - '', - ' Use Codex for:', - ' - Diagnosis ("why is X slow / failing?")', - ' - Review ("is this design sound?")', - ' - Explanation ("walk me through this code")', - '', - ' Do NOT use Codex for:', - ' - "Implement / write / add / fix / patch / refactor X"', - ' - Anything with `--write` or `-w` flags', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[codex-no-write-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/codex-no-write-guard/package.json b/.claude/hooks/codex-no-write-guard/package.json deleted file mode 100644 index 03a50b550..000000000 --- a/.claude/hooks/codex-no-write-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-codex-no-write-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/codex-no-write-guard/test/index.test.mts b/.claude/hooks/codex-no-write-guard/test/index.test.mts deleted file mode 100644 index f83b5d0b5..000000000 --- a/.claude/hooks/codex-no-write-guard/test/index.test.mts +++ /dev/null @@ -1,166 +0,0 @@ -// node --test specs for the codex-no-write-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-codex Bash passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'ls -la' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('command mentioning the guard name (codex-no-write-guard) is NOT a codex invocation', async () => { - // Regression: the old `codex\b` regex matched `codex-no-write-guard` and - // the word "write" in it → false block. The parser sees the binary is - // `for`/`ls`/`grep`, not `codex`. - const r = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'grep -n "write" template/.claude/hooks/codex-no-write-guard/index.mts', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('quoted "codex --write" inside an echo is NOT a codex invocation', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo "run codex --write to apply"' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('real codex --write in a chain is still blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'cd /x && codex --write "do it"' }, - }) - assert.strictEqual(r.code, 2) -}) - -test('codex with --write blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex --write "do something"' }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('--write / -w flag')) -}) - -test('codex -w blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex -w "patch this"' }, - }) - assert.strictEqual(r.code, 2) -}) - -test('codex with "implement" verb blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex "implement the bloom filter"' }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('implement')) -}) - -test('codex with "diagnose" passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex "diagnose this performance regression"' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('codex with "explain" passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex "explain what this does"' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Agent codex:codex-rescue with implementation intent blocked', async () => { - const r = await runHook({ - tool_name: 'Agent', - tool_input: { - subagent_type: 'codex:codex-rescue', - prompt: 'implement the SIMD whitespace scanner', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('Agent codex:codex-rescue with diagnosis passes', async () => { - const r = await runHook({ - tool_name: 'Agent', - tool_input: { - subagent_type: 'codex:codex-rescue', - prompt: 'diagnose why this benchmark regressed', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Agent for non-codex subagent passes', async () => { - const r = await runHook({ - tool_name: 'Agent', - tool_input: { - subagent_type: 'general-purpose', - prompt: 'implement the bloom filter', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('bypass phrase passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'codex-guard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow codex-write bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'codex --write "fix this"' }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/codex-no-write-guard/tsconfig.json b/.claude/hooks/codex-no-write-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/codex-no-write-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/comment-tone-reminder/README.md b/.claude/hooks/comment-tone-reminder/README.md deleted file mode 100644 index 55fb05057..000000000 --- a/.claude/hooks/comment-tone-reminder/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# comment-tone-reminder - -Stop hook that scans the assistant's most recent turn for teacher-tone phrases that would read condescendingly if written into a code comment. - -## Why - -CLAUDE.md's "Code style → Comments" rule: comments default to none; when written, the audience is a junior dev — explain the constraint, the hidden invariant, the "why this and not the obvious thing." No teacher-tone preamble. - -The patterns this hook flags are predictable shapes: "First, we will...", "Note that...", "It's important to...", "As you can see...", "Remember that...", "In order to...". - -## What it catches - -| Phrase | Why it's flagged | -| ------------------------------------- | ------------------------------------------------------- | -| `first, we (will\|are\|need\|should)` | Step-by-step narration — drop the framing. | -| `note that` | Tutorial filler. State the load-bearing point directly. | -| `it's important to` | Don't announce importance — state the constraint. | -| `as you can see` | Presupposes reader engagement. Drop. | -| `remember (that\|to)` | Reader doesn't need reminding — state the rule. | -| `in order to` | Wordy. "To X" suffices unless contrasting paths. | - -## Why it doesn't block - -Stop hooks fire after the assistant has produced its response. Blocking would truncate the message. The warning surfaces to stderr alongside the response so the user reads both and can push back in the next turn. - -## Configuration - -`SOCKET_COMMENT_TONE_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/comment-tone-reminder/index.mts b/.claude/hooks/comment-tone-reminder/index.mts deleted file mode 100644 index 3af6fba43..000000000 --- a/.claude/hooks/comment-tone-reminder/index.mts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — comment-tone-reminder. -// -// Flags teacher-tone phrases in the most-recent assistant turn that -// suggest comments written in code edits will read condescendingly. -// CLAUDE.md "Code style → Comments" says: audience is a junior dev, -// explain the constraint, not the obvious. No "First, we'll …" / -// "Note that …" / "It's important …" / "As you can see …" tone. -// -// Fires informationally to stderr; never blocks. -// -// Disable via SOCKET_COMMENT_TONE_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'comment-tone-reminder', - disabledEnvVar: 'SOCKET_COMMENT_TONE_REMINDER_DISABLED', - patterns: [ - { - label: 'first, we (will|are)', - regex: /\bfirst,? we (?:are|need|should|will)\b/i, - why: 'Teacher-tone narration. Drop the step-by-step framing in comments.', - }, - { - label: 'note that', - regex: /\bnote that\b/i, - why: 'Tutorial filler. If the note is load-bearing, state it directly without the preamble.', - }, - { - label: "it['’]?s important to", - regex: /\bit'?s important to\b/i, - why: "Teacher-tone. State the constraint, don't announce that it's important.", - }, - { - label: 'as you can see', - regex: /\bas you can see\b/i, - why: 'Presupposes reader engagement. Drop the phrase.', - }, - { - label: 'remember that', - regex: /\bremember (?:that|to)\b/i, - why: "Teacher-tone. The reader doesn't need to be reminded — state the rule.", - }, - { - label: 'in order to', - regex: /\bin order to\b/i, - why: 'Wordy. "To X" is sufficient unless contrasting with another path.', - }, - ], - closingHint: - 'These phrases in code comments age into noise. Per CLAUDE.md "Comments": audience is a junior dev — explain the constraint, the hidden invariant. Default to no comment.', -}) diff --git a/.claude/hooks/comment-tone-reminder/package.json b/.claude/hooks/comment-tone-reminder/package.json deleted file mode 100644 index 5a01b7052..000000000 --- a/.claude/hooks/comment-tone-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-comment-tone-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/comment-tone-reminder/test/index.test.mts b/.claude/hooks/comment-tone-reminder/test/index.test.mts deleted file mode 100644 index 85d59a30e..000000000 --- a/.claude/hooks/comment-tone-reminder/test/index.test.mts +++ /dev/null @@ -1,117 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'comment-tone-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { - stdout: string - stderr: string - exitCode: number -} { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - exitCode: result.status ?? -1, - } -} - -test('flags "first, we will" teacher-tone preamble', () => { - const { path: p, cleanup } = makeTranscript('First, we will parse the input.') - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /comment-tone-reminder/) - assert.match(stderr, /first, we/) - } finally { - cleanup() - } -}) - -test('flags "note that" tutorial filler', () => { - const { path: p, cleanup } = makeTranscript( - 'Note that the parser caches results.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /note that/) - } finally { - cleanup() - } -}) - -test('flags "in order to" wordiness', () => { - const { path: p, cleanup } = makeTranscript( - 'We use a cache in order to avoid recomputation.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /in order to/) - } finally { - cleanup() - } -}) - -test('does not flag plain prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores parsed results keyed by input.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Plain output here.\n```\nnote that this is in code\n```\nMore prose.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript('Note that we should skip this.') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_COMMENT_TONE_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/comment-tone-reminder/tsconfig.json b/.claude/hooks/comment-tone-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/comment-tone-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/commit-author-guard/README.md b/.claude/hooks/commit-author-guard/README.md deleted file mode 100644 index d9a09d3a2..000000000 --- a/.claude/hooks/commit-author-guard/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# commit-author-guard - -PreToolUse hook that blocks `git commit` invocations where the effective author email doesn't match the user's canonical GitHub identity. - -## Why - -The assistant sometimes commits as the wrong identity — for example signing as `jdalton@socket.dev` (a work email) when the user's canonical GitHub identity is `john.david.dalton@gmail.com`. The wrong identity: - -- Misattributes commits in `git log` / GitHub history -- Breaks DCO / signed-commit verification if the wrong GPG key signs -- Mixes personal and work identities in a single repo's history - -This hook catches the failure before the commit lands. - -## What it catches - -Three failure modes: - -1. **`--author=` override**: - - ``` - git commit --author="Wrong " -m "..." - ``` - -2. **`-c user.email=` override**: - - ``` - git commit -c user.email=wrong@example.com -m "..." - ``` - -3. **Wrong local checkout config**: the assistant edited `.git/config` to point at a different identity, then issues a plain `git commit` that inherits the wrong defaults. - -## Canonical identity sources - -In order of preference: - -### `~/.claude/git-authors.json` - -Explicit allowlist, the source of truth when present: - -```json -{ - "canonical": { - "name": "jdalton", - "email": "john.david.dalton@gmail.com" - }, - "aliases": [{ "name": "jdalton", "email": "jdalton@socket.dev" }] -} -``` - -The `canonical` identity is the default. `aliases` are additional emails accepted as legitimate (e.g., when work email is intentional in socket-internal repos). - -### `git config --global user.email` - -Fallback when the JSON config is absent. Reads the user's real identity from their global gitconfig. - -## What it does NOT catch - -- Environment-variable overrides (`GIT_AUTHOR_EMAIL=...`) — those are runtime state, not visible to a static command check. The hook can only see the command text. -- Commits already in the history — only catches new ones. - -## Bypass - -For legitimate cases where a different identity is needed (e.g., committing to a third-party repo where the work email is correct): - -- Add the email to `aliases[]` in `~/.claude/git-authors.json` (persistent), or -- Type `Allow commit-author bypass` (or `Allow commit author bypass` / `Allow commitauthor bypass`) in a recent user message (one-shot), or -- Set `SOCKET_COMMIT_AUTHOR_GUARD_DISABLED=1` to turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/commit-author-guard/index.mts b/.claude/hooks/commit-author-guard/index.mts deleted file mode 100644 index d7025086d..000000000 --- a/.claude/hooks/commit-author-guard/index.mts +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — commit-author-guard. -// -// Blocks `git commit` invocations that would author the commit as -// someone other than the user's canonical GitHub identity. Catches: -// -// 1. Wrong --author override: -// git commit --author="Wrong " -m "..." -// -// 2. Wrong -c user.email override: -// git commit -c user.email=wrong@example.com -m "..." -// -// 3. Local checkout user.email differs from canonical (e.g. an -// assistant edited .git/config to point at a Socket work email -// instead of the personal GitHub email). The commit itself -// doesn't override but the checkout config is wrong. -// -// Canonical identity sources, in order: -// (a) ~/.claude/git-authors.json — explicit allowlist, the source -// of truth when present. Shape: -// { -// "canonical": { -// "name": "jdalton", -// "email": "john.david.dalton@gmail.com" -// }, -// "aliases": [ -// { "name": "jdalton", "email": "jdalton@socket.dev" } -// ] -// } -// Canonical is the default; aliases are also allowed (for cases -// where work email is intentional, e.g. socket-internal repos). -// -// (b) `git config --global user.email` + `--global user.name` — the -// user's real identity, fallback when the config file is absent. -// -// Bypass: type "Allow commit-author bypass" in a recent user message, -// or set SOCKET_COMMIT_AUTHOR_GUARD_DISABLED=1. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, readFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -interface GitAuthor { - readonly name?: string | undefined - readonly email?: string | undefined -} - -interface AllowedAuthors { - readonly canonical: GitAuthor - readonly aliases: readonly GitAuthor[] -} - -const ENV_DISABLE = 'SOCKET_COMMIT_AUTHOR_GUARD_DISABLED' -const BYPASS_PHRASES = [ - 'Allow commit-author bypass', - 'Allow commit author bypass', - 'Allow commitauthor bypass', -] as const - -export function isAllowedAuthor( - candidate: GitAuthor, - allowed: AllowedAuthors, -): boolean { - const candidateEmail = candidate.email?.toLowerCase() - if (!candidateEmail) { - // No email in candidate; can't compare. Treat as ok — git will - // fail on its own if no identity is configured. - return true - } - if (allowed.canonical.email?.toLowerCase() === candidateEmail) { - return true - } - for (let i = 0, { length } = allowed.aliases; i < length; i += 1) { - if (allowed.aliases[i]!.email?.toLowerCase() === candidateEmail) { - return true - } - } - return false -} - -// Detect whether the command is `git commit ...` (not push, not log). -// Also returns true for `git -c ... commit ...` and other forms with -// flags before the subcommand. -export function isGitCommit(command: string): boolean { - // Match `git` (optionally with -c flags between) followed by `commit`. - // Negative lookahead avoids `git config commit.gpgsign`. - return /\bgit\b(?:\s+-c\s+[^\s]+)*\s+commit(?:\s|$)/.test(command) -} - -// Parse a `git commit ...` command for explicit author overrides. -// Three forms we recognize: -// -// --author="Name " -// --author "Name " -// -c user.email=email@example -c user.name=Name -// -// Returns the override author if any, otherwise undefined. -export function parseAuthorOverride(command: string): GitAuthor | undefined { - // --author="Name " or --author='Name ' - const authorEq = /--author=(['"]?)([^'"<>]+)\s*<([^>]+)>\1/i.exec(command) - if (authorEq) { - return { name: authorEq[2]!.trim(), email: authorEq[3]!.trim() } - } - // --author "Name " - const authorSpace = /--author\s+(['"])([^'"<>]+)\s*<([^>]+)>\1/i.exec(command) - if (authorSpace) { - return { name: authorSpace[2]!.trim(), email: authorSpace[3]!.trim() } - } - // -c user.email=... - const cEmail = /-c\s+user\.email=([^\s'"]+)/i.exec(command) - const cName = /-c\s+user\.name=(?:(['"])([^'"]+)\1|([^\s]+))/i.exec(command) - if (cEmail || cName) { - return { - email: cEmail?.[1], - name: cName ? (cName[2] ?? cName[3]) : undefined, - } - } - return undefined -} - -export function readAllowedAuthors(): AllowedAuthors { - // Source (a): ~/.claude/git-authors.json - const configPath = path.join(os.homedir(), '.claude', 'git-authors.json') - if (existsSync(configPath)) { - try { - const raw = JSON.parse(readFileSync(configPath, 'utf8')) as { - canonical?: GitAuthor | undefined - aliases?: GitAuthor[] | undefined - } - const canonical = raw.canonical ?? {} - const aliases = Array.isArray(raw.aliases) ? raw.aliases : [] - return { canonical, aliases } - } catch { - // Fall through to git-config fallback. - } - } - // Source (b): global git config - let email: string | undefined - let name: string | undefined - const emailResult = spawnSync('git', ['config', '--global', 'user.email']) - if (emailResult.status === 0) { - email = String(emailResult.stdout).trim() || undefined - } - const nameResult = spawnSync('git', ['config', '--global', 'user.name']) - if (nameResult.status === 0) { - name = String(nameResult.stdout).trim() || undefined - } - return { canonical: { name, email }, aliases: [] } -} - -// Read the local checkout's user.email + user.name. Falls through to -// undefined on failure. Used when the command has no explicit override -// — we need to know what git would use by default. -export function readCheckoutAuthor(cwd: string | undefined): GitAuthor { - let email: string | undefined - let name: string | undefined - const opts = cwd ? { cwd } : {} - const emailResult = spawnSync('git', ['config', 'user.email'], opts) - if (emailResult.status === 0) { - email = String(emailResult.stdout).trim() || undefined - } - const nameResult = spawnSync('git', ['config', 'user.name'], opts) - if (nameResult.status === 0) { - name = String(nameResult.stdout).trim() || undefined - } - return { name, email } -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const payloadRaw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(payloadRaw) as PreToolUsePayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.['command'] - if (typeof command !== 'string') { - process.exit(0) - } - if (!isGitCommit(command)) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - - const allowed = readAllowedAuthors() - // If we don't have a canonical email configured anywhere, fail open — - // the hook can't enforce something it doesn't know. - if (!allowed.canonical.email) { - process.exit(0) - } - - // Determine the effective author for this commit. - const override = parseAuthorOverride(command) - const effective = override ?? readCheckoutAuthor(payload.cwd) - - if (isAllowedAuthor(effective, allowed)) { - process.exit(0) - } - - const lines = [ - '[commit-author-guard] Commit author does not match canonical identity.', - '', - ` Effective author : ${effective.name ?? '(unset)'} <${effective.email ?? '(unset)'}>`, - ` Canonical author : ${allowed.canonical.name ?? '(unset)'} <${allowed.canonical.email}>`, - ] - if (allowed.aliases.length > 0) { - lines.push(' Allowed aliases :') - for (let i = 0, { length } = allowed.aliases; i < length; i += 1) { - const a = allowed.aliases[i]! - lines.push(` - ${a.name ?? '(any)'} <${a.email ?? '(any)'}>`) - } - } - lines.push('') - lines.push(' Fix one of these before committing:') - lines.push('') - lines.push(' # Use the canonical identity for this commit:') - lines.push(` git -c user.email=${allowed.canonical.email} commit ...`) - lines.push('') - lines.push(' # Or correct the local checkout config:') - lines.push(` git config user.email ${allowed.canonical.email}`) - lines.push( - ` git config user.name "${allowed.canonical.name ?? 'jdalton'}"`, - ) - lines.push('') - lines.push(' Allowed-author list: ~/.claude/git-authors.json') - lines.push(' (falls back to `git config --global user.email` when absent)') - lines.push('') - lines.push(' Bypass: type "Allow commit-author bypass" in a recent message.') - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/commit-author-guard/package.json b/.claude/hooks/commit-author-guard/package.json deleted file mode 100644 index 8bdde464d..000000000 --- a/.claude/hooks/commit-author-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-commit-author-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/commit-author-guard/test/index.test.mts b/.claude/hooks/commit-author-guard/test/index.test.mts deleted file mode 100644 index 8dfcbd361..000000000 --- a/.claude/hooks/commit-author-guard/test/index.test.mts +++ /dev/null @@ -1,348 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface FakeRepo { - readonly root: string - readonly home: string - readonly authorsJsonPath: string - cleanup(): void -} - -function makeFakeRepo( - canonicalEmail = 'john.david.dalton@gmail.com', -): FakeRepo { - const root = mkdtempSync(path.join(os.tmpdir(), 'authorguard-')) - const home = path.join(root, 'home') - mkdirSync(path.join(home, '.claude'), { recursive: true }) - // Init a git repo so `git config user.email` calls don't error out. - const repo = path.join(root, 'repo') - mkdirSync(repo, { recursive: true }) - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', canonicalEmail], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'jdalton'], { cwd: repo }) - const authorsJsonPath = path.join(home, '.claude', 'git-authors.json') - writeFileSync( - authorsJsonPath, - JSON.stringify({ - canonical: { name: 'jdalton', email: canonicalEmail }, - aliases: [{ name: 'jdalton', email: 'jdalton@socket.dev' }], - }), - ) - return { - root, - home, - authorsJsonPath, - cleanup: () => rmSync(root, { recursive: true, force: true }), - } -} - -function makeTranscript(dir: string, bypassPhrase?: string): string { - const transcriptPath = path.join(dir, 'session.jsonl') - const userContent = bypassPhrase ?? 'normal message' - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userContent }), - ) - return transcriptPath -} - -function runHook( - payload: object, - home: string, - extraEnv: Record = {}, -): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - env: { ...process.env, HOME: home, ...extraEnv }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('BLOCKS --author override with wrong email', () => { - const repo = makeFakeRepo() - try { - const { stderr, exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: 'git commit --author="Wrong " -m "fix"', - }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 2) - assert.match(stderr, /commit-author-guard/) - assert.match(stderr, /wrong@example\.com/) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS --author override with canonical email', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: - 'git commit --author="jdalton " -m "fix"', - }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS --author override with allowlisted alias email', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: - 'git commit --author="jdalton " -m "fix"', - }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('BLOCKS -c user.email override with wrong email', () => { - const repo = makeFakeRepo() - try { - const { stderr, exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: 'git -c user.email=imposter@example.com commit -m "fix"', - }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 2) - assert.match(stderr, /imposter@example\.com/) - } finally { - repo.cleanup() - } -}) - -test('BLOCKS when local checkout has wrong user.email and no override', () => { - const repo = makeFakeRepo() - try { - // Reset the repo's user.email to a wrong value, simulating a corrupted - // local checkout config. - spawnSync('git', ['config', 'user.email', 'imposter@example.com'], { - cwd: path.join(repo.root, 'repo'), - }) - const { stderr, exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { command: 'git commit -m "fix"' }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 2) - assert.match(stderr, /imposter@example\.com/) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS plain git commit when local checkout is canonical', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { command: 'git commit -m "fix"' }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES non-Bash tools', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Write', - tool_input: { command: 'git commit --author="Wrong " -m "x"' }, - transcript_path: makeTranscript(repo.root), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES git commands that are not commit', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { command: 'git log --author=anyone' }, - transcript_path: makeTranscript(repo.root), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES git config commit.gpgsign (must not match commit subcommand)', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { command: 'git config commit.gpgsign true' }, - transcript_path: makeTranscript(repo.root), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS with "Allow commit-author bypass" phrase', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: 'git commit --author="Wrong " -m "fix"', - }, - transcript_path: makeTranscript( - repo.root, - 'Allow commit-author bypass', - ), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS with hyphenless variant "Allow commit author bypass"', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: 'git commit --author="Wrong " -m "fix"', - }, - transcript_path: makeTranscript( - repo.root, - 'Allow commit author bypass', - ), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const repo = makeFakeRepo() - try { - const { exitCode } = runHook( - { - tool_name: 'Bash', - tool_input: { - command: 'git commit --author="Wrong " -m "fix"', - }, - transcript_path: makeTranscript(repo.root), - cwd: path.join(repo.root, 'repo'), - }, - repo.home, - { SOCKET_COMMIT_AUTHOR_GUARD_DISABLED: '1' }, - ) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('fails open when no canonical email is configured anywhere', () => { - // Delete the git-authors.json AND clear global git config email - // path is checked separately — here we just ensure the JSON path - // missing means we use the global config (which may or may not be set). - // The hook should not block when it has no canonical to enforce. - const root = mkdtempSync(path.join(os.tmpdir(), 'authorguard-empty-')) - const home = path.join(root, 'home') - mkdirSync(path.join(home, '.claude'), { recursive: true }) - const repo = path.join(root, 'repo') - mkdirSync(repo, { recursive: true }) - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'whoever@example.com'], { - cwd: repo, - }) - try { - // The hook will fall back to the user's REAL global git config. Since - // we can't safely unset that, we just verify the hook doesn't crash on - // a missing git-authors.json. If global config is also unset, the hook - // fails open; if it's set to the user's real email, this test's - // imposter email gets blocked. Either way, the hook should not crash. - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Bash', - tool_input: { command: 'git commit -m "fix"' }, - cwd: repo, - }), - env: { ...process.env, HOME: home }, - }) - // Exit code is either 0 (fail open) or 2 (real global config caught it); - // never -1 (crash). - assert.ok(result.status === 0 || result.status === 2) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/commit-author-guard/tsconfig.json b/.claude/hooks/commit-author-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/commit-author-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/commit-message-format-guard/README.md b/.claude/hooks/commit-message-format-guard/README.md deleted file mode 100644 index 240de618c..000000000 --- a/.claude/hooks/commit-message-format-guard/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# commit-message-format-guard - -PreToolUse hook that blocks `git commit -m ` invocations whose message doesn't follow [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/), or that include AI-attribution markers. - -## Why - -A `git log` is the canonical history of a repo. Two failure modes pollute it: - -1. **Format drift** — free-form titles ("update stuff", "fix typo", "WIP") make CHANGELOG generation impossible and obscure intent. -2. **AI attribution** — "Generated with Claude", `Co-Authored-By: Claude`, robot-emoji tag lines, and `` footers leak the authorship model into history. - -The fleet bans both. This hook is the commit-time gate; `commit-pr-reminder` is the Stop-time draft check (defense in depth). - -## What it catches - -Block examples: - -- `git commit -m "update stuff"` — no type, blocked. -- `git commit -m "feat:"` — empty description, blocked. -- `git commit -m "FEAT: parser"` — uppercase type, blocked. -- `git commit -m "feature(parser): X"` — `feature` not in the allowed list, blocked. -- `git commit -m "fix: bug - - Co-Authored-By: Claude"` — AI-attribution footer, blocked. - -- `git commit -m "feat: thing - - 🤖 Generated with Claude"` — robot-emoji tag, blocked. - -Allow examples: - -- `git commit -m "feat(parser): add ability to parse arrays"` -- `git commit -m "fix: array parsing issue when multiple spaces"` -- `git commit -m "chore!: drop support for Node 14"` -- `git commit -m "refactor(api)!: drop legacy /v1 routes"` - -## Allowed types - -`feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `revert`. - -## How to bypass - -Per the fleet's `Allow bypass` convention: - -- `Allow commit-format bypass` — type/format issue (e.g. bringing in a fixup commit with a pre-existing message). -- `Allow ai-attribution bypass` — for the AI-attribution check specifically. Use sparingly — only when a commit legitimately documents the forbidden strings (e.g. a CLAUDE.md edit that quotes them). - -Type the canonical phrase verbatim in a recent user message; the hook then allows the next matching commit. - -## How to disable in tests - -Set `SOCKET_COMMIT_MESSAGE_FORMAT_GUARD_DISABLED=1` to short-circuit the hook entirely. Used only by the hook's own test suite — never set in operator config. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/commit-message-format-guard/index.mts b/.claude/hooks/commit-message-format-guard/index.mts deleted file mode 100644 index b220d8a7c..000000000 --- a/.claude/hooks/commit-message-format-guard/index.mts +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — commit-message-format-guard. -// -// Validates `git commit -m ` (and `--message=`) invocations -// against the Conventional Commits 1.0 spec. Two checks: -// -// 1. The first line of the message follows -// [(scope)][!]: -// where type ∈ { feat, fix, chore, docs, style, refactor, perf, -// test, build, ci, revert }, type is lowercase, the colon-space -// separator is required, and the description is non-empty. -// -// 2. No AI-attribution markers anywhere in the message body -// ("Generated with Claude", "Co-Authored-By: Claude", 🤖 tag -// lines, ). The Stop-hook companion -// commit-pr-reminder catches these at draft time; this is the -// commit-time defense in depth. -// -// Spec: https://www.conventionalcommits.org/en/v1.0.0/ -// -// Bypass phrases (one phrase = one commit): -// - "Allow commit-format bypass" — type/format issue -// - "Allow ai-attribution bypass" — explicit AI-attribution override -// (rare; mostly for commits that legitimately document the -// forbidden strings, e.g. a CLAUDE.md edit that quotes them as -// examples). -// -// Env disable (testing only): SOCKET_COMMIT_MESSAGE_FORMAT_GUARD_DISABLED=1. -// -// Hook contract: -// - Reads PreToolUse JSON from stdin. -// - Exits 0 (allow) or 2 (block + stderr explanation). -// - Fails open on any internal error so the hook never wedges the -// operator's flow. - -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_COMMIT_MESSAGE_FORMAT_GUARD_DISABLED' -const BYPASS_FORMAT = 'Allow commit-format bypass' -const BYPASS_AI = 'Allow ai-attribution bypass' - -const ALLOWED_TYPES = [ - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'revert', - 'style', - 'test', -] as const - -const ALLOWED_TYPE_SET: ReadonlySet = new Set(ALLOWED_TYPES) - -// Header form: [(scope)][!]: -// - type: lowercase letters -// - optional (scope) in parens -// - optional `!` breaking-change marker -// - `: ` separator (colon + space) -// - non-empty description -const HEADER_RE = /^([a-z]+)(\([^)]+\))?(!)?: (.+)$/ - -// AI-attribution patterns. These match anywhere in the message body — -// header or footer. Patterns mirror commit-pr-reminder. -const AI_ATTRIBUTION_PATTERNS: ReadonlyArray<{ - readonly label: string - readonly regex: RegExp -}> = [ - { - label: 'Generated with Claude/Anthropic', - regex: /generated with (?:anthropic|claude)/i, - }, - { - label: 'Co-Authored-By: Claude', - regex: /co-authored-by:?\s*claude/i, - }, - { - label: 'Robot emoji (🤖) tag line', - regex: /🤖/, - }, - { - label: 'noreply@anthropic.com footer', - regex: //i, - }, -] - -/** - * True when the command is a `git commit ...` invocation. Tolerates leading - * `git -c k=v` flags before the subcommand. - */ -export function isGitCommit(command: string): boolean { - return /\bgit\b(?:\s+-c\s+\S+)*\s+commit(?:\s|$)/.test(command) -} - -/** - * Extract the inline message text from `git commit -m …` / `--message=…` forms. - * Returns undefined when the command has no inline message (e.g. uses `-F - * file`, `-e` to open the editor, or neither) — we don't block those forms; the - * operator's editor or file is responsible. - * - * Multiple `-m` flags concatenate with blank-line separators (matching git's - * behavior); the first line of the joined result is the header. - */ -export function extractCommitMessage(command: string): string | undefined { - const matches = [ - ...command.matchAll( - /(?:^|\s)-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+))/g, - ), - ...command.matchAll( - /--message(?:\s+|=)(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+))/g, - ), - ] - if (matches.length === 0) { - return undefined - } - const pieces = matches.map(m => m[1] ?? m[2] ?? m[3] ?? '') - return pieces.join('\n\n') -} - -/** - * Result of validating a single message header. - * - * - Kind: 'ok' — header passes - * - Kind: 'no-type' — first line has no `: ` prefix at all - * - Kind: 'bad-type' — first line has a `: ` prefix but word isn't - * lowercase / not in the type set - * - Kind: 'uppercase-type' — type letters are present but include uppercase - * - Kind: 'empty-description' — header has `: ` but description is - * empty/whitespace - */ -export type HeaderCheck = - | { kind: 'ok' } - | { kind: 'no-type'; line: string } - | { kind: 'bad-type'; line: string; type: string } - | { kind: 'uppercase-type'; line: string; type: string } - | { kind: 'empty-description'; line: string; type: string } - -export function validateHeader(line: string): HeaderCheck { - // Quick pre-check: does the line look like a Conventional header at all? - // We accept any leading word-token before `: ` for diagnosis even if the - // case is wrong; the strict HEADER_RE then refines. - const looseMatch = /^([A-Za-z]+)(\([^)]+\))?(!)?:\s*(.*)$/.exec(line) - if (!looseMatch) { - return { kind: 'no-type', line } - } - const type = looseMatch[1]! - const desc = looseMatch[4]! - // Type must be all-lowercase. - if (type !== type.toLowerCase()) { - return { kind: 'uppercase-type', line, type } - } - // Type must be in the allowed set. - if (!ALLOWED_TYPE_SET.has(type)) { - return { kind: 'bad-type', line, type } - } - // Strict format check (catches "feat:description" without space, etc.). - const strictMatch = HEADER_RE.exec(line) - if (!strictMatch) { - // The loose pattern matched but the strict one didn't — that means - // either the `: ` separator is missing the space, or the description - // is empty. - if (!desc.trim()) { - return { kind: 'empty-description', line, type } - } - return { kind: 'no-type', line } - } - const description = strictMatch[4]! - if (!description.trim()) { - return { kind: 'empty-description', line, type } - } - return { kind: 'ok' } -} - -/** - * Scan the full message body for AI-attribution markers. Returns the first - * matching label, or undefined when the message is clean. - */ -export function findAiAttribution(message: string): string | undefined { - for (let i = 0, { length } = AI_ATTRIBUTION_PATTERNS; i < length; i += 1) { - const p = AI_ATTRIBUTION_PATTERNS[i]! - if (p.regex.test(message)) { - return p.label - } - } - return undefined -} - -/** - * Build a context-appropriate suggestion for an invalid header. We look at the - * user's input and propose ONE example of a valid replacement based on what - * they typed. - */ -export function suggestReplacement(check: HeaderCheck): string { - if (check.kind === 'ok') { - return '' - } - const text = check.line.trim() - // Lowercase variant: try to recover the intent. - if (check.kind === 'uppercase-type') { - return `${check.type.toLowerCase()}: ${text.slice(text.indexOf(':') + 1).trim()}` - } - if (check.kind === 'bad-type') { - // Suggest 'feat' as a generic recoverable type, keep the rest. - const rest = - text.slice(text.indexOf(':') + 1).trim() || 'describe the change' - return `feat: ${rest}` - } - if (check.kind === 'empty-description') { - return `${check.type}: describe the change` - } - // no-type: try to fold whatever the user typed into a feat header. - const words = text.split(/\s+/).filter(Boolean) - const first = (words[0] ?? '').toLowerCase() - // If the first word looks like a noun (e.g. "parser", "extension"), use it - // as a scope and keep the rest as the description. - if (words.length >= 2 && /^[a-z][a-z0-9-]*$/.test(first)) { - const rest = words.slice(1).join(' ') - return `feat(${first}): ${rest}` - } - return `feat: ${text || 'describe the change'}` -} - -function emitBlock(reason: string, body: string): never { - process.stderr.write(`[commit-message-format-guard] ${reason}\n\n${body}\n`) - process.exit(2) -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const raw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(raw) as PreToolUsePayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.['command'] - if (typeof command !== 'string') { - process.exit(0) - } - if (!isGitCommit(command)) { - process.exit(0) - } - const message = extractCommitMessage(command) - if (message === undefined) { - // No inline message — operator may be using -F file or editor; not our - // call to enforce here. - process.exit(0) - } - - // Header check first. - const firstLine = message.split('\n')[0] ?? '' - const header = validateHeader(firstLine) - if (header.kind !== 'ok') { - if (bypassPhrasePresent(payload.transcript_path, BYPASS_FORMAT)) { - // Operator authorized this commit. Still fall through to AI check - // separately — bypass-format does not authorize AI attribution. - } else { - const suggestion = suggestReplacement(header) - const lines: string[] = [] - if (header.kind === 'no-type') { - lines.push(` Missing Conventional Commits header in: "${header.line}"`) - } else if (header.kind === 'bad-type') { - lines.push( - ` Unknown type "${header.type}" in: "${header.line}"`, - ` Allowed types: ${ALLOWED_TYPES.join(', ')}`, - ) - } else if (header.kind === 'uppercase-type') { - lines.push( - ` Type must be lowercase. Got "${header.type}" in: "${header.line}"`, - ) - } else if (header.kind === 'empty-description') { - lines.push(` Empty description after "${header.type}:" header.`) - } - lines.push('') - lines.push(` Required format: [(scope)][!]: `) - lines.push(` Allowed types : ${ALLOWED_TYPES.join(', ')}`) - lines.push( - ` Spec : https://www.conventionalcommits.org/en/v1.0.0/`, - ) - lines.push('') - lines.push(` Suggested fix : ${suggestion}`) - lines.push('') - lines.push(` Bypass: type "${BYPASS_FORMAT}" in a recent message.`) - emitBlock( - 'Commit message does not match Conventional Commits 1.0.', - lines.join('\n'), - ) - } - } - - // AI-attribution check (independent of the format bypass). - const aiLabel = findAiAttribution(message) - if (aiLabel) { - if (bypassPhrasePresent(payload.transcript_path, BYPASS_AI)) { - process.exit(0) - } - const lines: string[] = [] - lines.push(` AI-attribution marker found: ${aiLabel}`) - lines.push('') - lines.push(' The fleet forbids AI attribution in commit messages and PR') - lines.push(' descriptions. Remove the offending line(s) and retry.') - lines.push('') - lines.push(' Patterns blocked:') - lines.push(' - "Generated with Claude" / "Generated with Anthropic"') - lines.push(' - "Co-Authored-By: Claude"') - lines.push(' - Robot emoji (🤖) tag lines') - lines.push(' - footer') - lines.push('') - lines.push(` Bypass (rare): type "${BYPASS_AI}" in a recent message.`) - lines.push(' Use only when a commit legitimately documents the strings') - lines.push(' (e.g. CLAUDE.md edits that quote them as examples).') - emitBlock( - 'AI-attribution markers are forbidden in commit messages.', - lines.join('\n'), - ) - } - - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/commit-message-format-guard/package.json b/.claude/hooks/commit-message-format-guard/package.json deleted file mode 100644 index b02979e42..000000000 --- a/.claude/hooks/commit-message-format-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-commit-message-format-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/commit-message-format-guard/test/format.test.mts b/.claude/hooks/commit-message-format-guard/test/format.test.mts deleted file mode 100644 index 04cc4546a..000000000 --- a/.claude/hooks/commit-message-format-guard/test/format.test.mts +++ /dev/null @@ -1,296 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly stderr: string - readonly exitCode: number -} - -function makeTranscript(bypassPhrase?: string): { - readonly transcriptPath: string - cleanup(): void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fmtguard-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const userContent = bypassPhrase ?? 'normal message' - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userContent }), - ) - return { - transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook( - command: string, - options: { - readonly bypassPhrase?: string | undefined - readonly env?: Record | undefined - } = {}, -): RunResult { - const t = makeTranscript(options.bypassPhrase) - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Bash', - tool_input: { command }, - transcript_path: t.transcriptPath, - }), - env: { ...process.env, ...(options.env ?? {}) }, - encoding: 'utf8', - }) - return { - stderr: String(result.stderr ?? ''), - exitCode: result.status ?? -1, - } - } finally { - t.cleanup() - } -} - -// Sanity / valid cases - -test('ALLOWS feat: simple', () => { - const { exitCode } = runHook('git commit -m "feat: add thing"') - assert.equal(exitCode, 0) -}) - -test('ALLOWS feat(scope): with scope', () => { - const { exitCode } = runHook( - 'git commit -m "feat(parser): add ability to parse arrays"', - ) - assert.equal(exitCode, 0) -}) - -test('ALLOWS chore!: breaking change', () => { - const { exitCode } = runHook( - 'git commit -m "chore!: drop support for Node 14"', - ) - assert.equal(exitCode, 0) -}) - -test('ALLOWS refactor(api)!: scoped breaking change', () => { - const { exitCode } = runHook( - 'git commit -m "refactor(api)!: drop legacy /v1 routes"', - ) - assert.equal(exitCode, 0) -}) - -test('ALLOWS fix: with no scope and longer description', () => { - const { exitCode } = runHook( - 'git commit -m "fix: array parsing issue when multiple spaces"', - ) - assert.equal(exitCode, 0) -}) - -test('ALLOWS multiple -m flags (header on first)', () => { - const { exitCode } = runHook( - 'git commit -m "feat: add thing" -m "Body paragraph explaining."', - ) - assert.equal(exitCode, 0) -}) - -// Type/format blocks - -test('BLOCKS missing type (no colon)', () => { - const { stderr, exitCode } = runHook('git commit -m "update stuff"') - assert.equal(exitCode, 2) - assert.match(stderr, /commit-message-format-guard/) - assert.match(stderr, /Conventional Commits/) -}) - -test('BLOCKS empty description', () => { - const { stderr, exitCode } = runHook('git commit -m "feat:"') - assert.equal(exitCode, 2) - assert.match(stderr, /Empty description|empty/i) -}) - -test('BLOCKS empty description with whitespace-only', () => { - const { stderr, exitCode } = runHook('git commit -m "feat: "') - assert.equal(exitCode, 2) - assert.match(stderr, /Empty description|empty/i) -}) - -test('BLOCKS uppercase type', () => { - const { stderr, exitCode } = runHook('git commit -m "FEAT: parser"') - assert.equal(exitCode, 2) - assert.match(stderr, /lowercase|uppercase/i) -}) - -test('BLOCKS unknown type (feature)', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feature(parser): add arrays"', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /Unknown type|feature/i) -}) - -test('BLOCKS unknown type (chores)', () => { - const { stderr, exitCode } = runHook('git commit -m "chores: update deps"') - assert.equal(exitCode, 2) - assert.match(stderr, /Unknown type|chores/i) -}) - -test('Block message includes spec URL', () => { - const { stderr } = runHook('git commit -m "update stuff"') - assert.match(stderr, /conventionalcommits\.org\/en\/v1\.0\.0/) -}) - -test('Block message includes a suggestion', () => { - const { stderr } = runHook('git commit -m "update parser"') - assert.match(stderr, /Suggested fix/) -}) - -// AI-attribution blocks - -test('BLOCKS Generated with Claude', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feat: add thing" -m "Generated with Claude"', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -test('BLOCKS Generated with Anthropic', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feat: add thing" -m "Generated with Anthropic"', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -test('BLOCKS Co-Authored-By Claude', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feat: add thing" -m "Co-Authored-By: Claude "', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -test('BLOCKS robot emoji tag', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feat: add thing" -m "🤖 Generated"', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -test('BLOCKS noreply@anthropic.com', () => { - const { stderr, exitCode } = runHook( - 'git commit -m "feat: add thing" -m "Authored by "', - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -// Bypass phrases - -test('ALLOWS with "Allow commit-format bypass" phrase', () => { - const { exitCode } = runHook('git commit -m "update stuff"', { - bypassPhrase: 'Allow commit-format bypass', - }) - assert.equal(exitCode, 0) -}) - -test('Format bypass does NOT authorize AI attribution', () => { - // Both rules trip; format bypass should let format pass but AI - // attribution should still block. - const { stderr, exitCode } = runHook( - 'git commit -m "update stuff" -m "Co-Authored-By: Claude"', - { bypassPhrase: 'Allow commit-format bypass' }, - ) - assert.equal(exitCode, 2) - assert.match(stderr, /AI-attribution/) -}) - -test('ALLOWS with "Allow ai-attribution bypass" phrase', () => { - const { exitCode } = runHook( - 'git commit -m "docs: document forbidden strings" -m "We forbid Co-Authored-By: Claude trailers."', - { bypassPhrase: 'Allow ai-attribution bypass' }, - ) - assert.equal(exitCode, 0) -}) - -test('AI bypass alone does NOT authorize format errors', () => { - const { stderr, exitCode } = runHook('git commit -m "update stuff"', { - bypassPhrase: 'Allow ai-attribution bypass', - }) - assert.equal(exitCode, 2) - assert.match(stderr, /Conventional Commits/) -}) - -// Env-var disable - -test('disabled env var short-circuits', () => { - const { exitCode } = runHook( - 'git commit -m "totally invalid 🤖 Generated with Claude"', - { env: { SOCKET_COMMIT_MESSAGE_FORMAT_GUARD_DISABLED: '1' } }, - ) - assert.equal(exitCode, 0) -}) - -// Ignore non-commit / non-Bash - -test('IGNORES non-Bash tool', () => { - const t = makeTranscript() - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { command: 'git commit -m "update stuff"' }, - transcript_path: t.transcriptPath, - }), - encoding: 'utf8', - }) - assert.equal(result.status, 0) - } finally { - t.cleanup() - } -}) - -test('IGNORES non-commit git commands', () => { - const { exitCode } = runHook('git log --oneline -m "anything"') - assert.equal(exitCode, 0) -}) - -test('IGNORES git commit with no inline message (likely -F or editor)', () => { - const { exitCode } = runHook('git commit -F /tmp/msg.txt') - assert.equal(exitCode, 0) -}) - -test('IGNORES git config commit.* (subcommand is config, not commit)', () => { - const { exitCode } = runHook('git config commit.gpgsign true') - assert.equal(exitCode, 0) -}) - -// Quote variants - -test('ALLOWS single-quoted message', () => { - const { exitCode } = runHook("git commit -m 'feat: add thing'") - assert.equal(exitCode, 0) -}) - -test('BLOCKS single-quoted invalid message', () => { - const { exitCode } = runHook("git commit -m 'update stuff'") - assert.equal(exitCode, 2) -}) - -test('ALLOWS --message= form', () => { - const { exitCode } = runHook('git commit --message="feat: add thing"') - assert.equal(exitCode, 0) -}) - -test('BLOCKS --message= form with invalid header', () => { - const { exitCode } = runHook('git commit --message="update stuff"') - assert.equal(exitCode, 2) -}) diff --git a/.claude/hooks/commit-message-format-guard/tsconfig.json b/.claude/hooks/commit-message-format-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/commit-message-format-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/commit-pr-reminder/README.md b/.claude/hooks/commit-pr-reminder/README.md deleted file mode 100644 index a8712fd39..000000000 --- a/.claude/hooks/commit-pr-reminder/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# commit-pr-reminder - -Stop hook that flags assistant turns drafting commit messages or PR bodies missing fleet conventions. - -## What it catches - -- **AI attribution** — "Generated with Claude", "Co-Authored-By: Claude", `🤖 Generated`. The fleet's Commits & PRs rule forbids these. - -The companion guards that actually block `git commit` / `gh pr create` invocations live separately. This hook only nudges when drafted text shows the antipatterns in the assistant turn. - -## Bypass - -- `SOCKET_COMMIT_PR_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/commit-pr-reminder/index.mts b/.claude/hooks/commit-pr-reminder/index.mts deleted file mode 100644 index 578db48ef..000000000 --- a/.claude/hooks/commit-pr-reminder/index.mts +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — commit-pr-reminder. -// -// Flags assistant turns that drafted a commit message or PR body -// missing the fleet's required structure: -// -// - Conventional Commits header (`(): `). -// Anti-pattern: free-form sentences as the commit title. -// -// - AI attribution lines ("Generated with Claude", "Co-Authored-By: -// Claude", "🤖" tag lines). The fleet forbids these. -// -// - PR body missing a Summary section (PRs that paste a commit log -// without a 1-3 bullet summary). -// -// This hook only flags drafted text in the assistant turn — it doesn't -// inspect real git/gh invocations. The git/PR ones live in their own -// PreToolUse guards. -// -// Disable via SOCKET_COMMIT_PR_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'commit-pr-reminder', - disabledEnvVar: 'SOCKET_COMMIT_PR_REMINDER_DISABLED', - patterns: [ - { - label: 'AI attribution: Generated with Claude', - regex: /generated with (?:anthropic|claude)/i, - why: 'The fleet forbids AI attribution in commit/PR text. Remove the line.', - }, - { - label: 'AI attribution: Co-Authored-By Claude', - regex: /co-authored-by:?\s*claude/i, - why: 'Co-Authored-By Claude is forbidden in commit/PR trailers.', - }, - { - label: 'AI attribution: robot emoji tag line', - regex: /^.*🤖.*generated/im, - why: 'Remove the robot-emoji + "Generated" attribution line.', - }, - ], - closingHint: - 'Commits/PRs must use Conventional Commits (`(): `) with no AI attribution. PR bodies need a Summary section. See CLAUDE.md "Commits & PRs".', -}) diff --git a/.claude/hooks/commit-pr-reminder/package.json b/.claude/hooks/commit-pr-reminder/package.json deleted file mode 100644 index a00faf1e7..000000000 --- a/.claude/hooks/commit-pr-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-commit-pr-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/commit-pr-reminder/test/index.test.mts b/.claude/hooks/commit-pr-reminder/test/index.test.mts deleted file mode 100644 index d3cf75d8f..000000000 --- a/.claude/hooks/commit-pr-reminder/test/index.test.mts +++ /dev/null @@ -1,79 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-pr-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: 'do it' }) + - '\n' + - JSON.stringify({ role: 'assistant', content: assistantText }), - ) - return transcriptPath -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS "Generated with Claude"', () => { - const t = makeTranscript('Commit body:\n\nGenerated with Claude Code') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /commit-pr-reminder/) - assert.match(stderr, /generated with claude/i) -}) - -test('FLAGS "Co-Authored-By: Claude"', () => { - const t = makeTranscript( - 'Trailer:\nCo-Authored-By: Claude ', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /co-authored-by/i) -}) - -test('FLAGS robot emoji generated tag', () => { - const t = makeTranscript('PR body:\n🤖 Generated with assistance') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /robot emoji/i) -}) - -test('does NOT fire on plain Conventional Commit text', () => { - const t = makeTranscript( - 'feat(api): add new endpoint\n\nDetails about the change.', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire on the word "generated" without "claude" nearby', () => { - const t = makeTranscript('The build artifacts are generated by tsc.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const t = makeTranscript('Generated with Claude Code') - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: t }), - env: { ...process.env, SOCKET_COMMIT_PR_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') -}) diff --git a/.claude/hooks/commit-pr-reminder/tsconfig.json b/.claude/hooks/commit-pr-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/commit-pr-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/compound-lessons-reminder/README.md b/.claude/hooks/compound-lessons-reminder/README.md deleted file mode 100644 index c06ce5ac8..000000000 --- a/.claude/hooks/compound-lessons-reminder/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# compound-lessons-reminder - -Stop hook that flags repeat-finding language in the assistant's most-recent turn that isn't accompanied by rule promotion. - -## Why - -CLAUDE.md "Compound lessons into rules": - -> When the same kind of finding fires twice — across two runs, two PRs, or two fleet repos — **promote it to a rule** instead of fixing it again. Land it in CLAUDE.md, a `.claude/hooks/*` block, or a skill prompt — pick the lowest-friction surface. Always cite the original incident in a `**Why:**` line. - -This hook catches the failure mode where the assistant notices a recurring bug class but fixes it again instead of writing the rule that would prevent the next occurrence. - -## What it catches - -Repeat-finding language in the assistant's prose: - -| Pattern | Example | -| ------------------------------ | --------------------------------------------------- | -| `again` / `once more` | "Hitting the same lockfile issue again" | -| `second/third time` | "This is the second time we've seen this regex bug" | -| `same X as before` | "Same monthCode handling bug as we saw earlier" | -| `we've seen this before` | "We've seen this pattern before" | -| `recurring`, `keeps happening` | "Recurring CI failure on the same line" | - -Code fences are stripped first so quoted phrases don't false-positive. - -If a repeat-finding mention is found, the hook then checks the same turn's tool-use events for evidence of rule promotion: - -- Edit/Write to `CLAUDE.md` -- Edit/Write to `.claude/hooks/*` -- Edit/Write to `.claude/skills/*` -- A `**Why:**` line anywhere in the written content (canonical citation shape) - -If any of those is present, the hook is satisfied — the rule got written. - -## Why it doesn't block - -Stop hooks fire after the turn. Blocking would just truncate the assistant's response. The warning prompts the next turn to write the rule. - -## Configuration - -`SOCKET_COMPOUND_LESSONS_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/compound-lessons-reminder/index.mts b/.claude/hooks/compound-lessons-reminder/index.mts deleted file mode 100644 index 3d1ca9125..000000000 --- a/.claude/hooks/compound-lessons-reminder/index.mts +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — compound-lessons-reminder. -// -// Flags assistant text that shows a repeat-finding pattern without -// evidence of promoting it to a rule. CLAUDE.md "Compound lessons -// into rules": -// -// When the same kind of finding fires twice — across two runs, -// two PRs, or two fleet repos — promote it to a rule instead of -// fixing it again. Land it in CLAUDE.md, a `.claude/hooks/*` -// block, or a skill prompt — pick the lowest-friction surface. -// Always cite the original incident in a `**Why:**` line. -// -// Detection: -// -// 1. Scan the assistant's prose for repeat-finding language: "again", -// "second time", "same X as before", "we've seen this before", -// "this is the third time", etc. -// -// 2. Inspect the same turn's tool-use events for evidence of -// rule promotion: Edit/Write to CLAUDE.md, hooks/, or skills/. -// Or for a `**Why:**` line in any written content (the canonical -// shape for citing the original incident). -// -// 3. If a repeat-finding mention exists but no rule promotion -// followed, warn. -// -// Disable via SOCKET_COMPOUND_LESSONS_REMINDER_DISABLED. - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { - readLastAssistantText, - readLastAssistantToolUses, - readStdin, - stripCodeFences, -} from '../_shared/transcript.mts' - -// Probe common sibling locations for a wheelhouse checkout. Order is -// preference: socket-wheelhouse first (canonical), then aliases that -// appeared in the fleet historically. Returns the absolute path to -// template/CLAUDE.md if found, otherwise undefined. -export function findWheelhouseClaudeMd(cwd: string): string | undefined { - const candidates = [ - 'socket-wheelhouse', - 'socket-repo-template', // legacy alias - ] - // Walk up from cwd: try ..//template/CLAUDE.md at each parent. - let dir = cwd - for (let i = 0; i < 4; i += 1) { - const parent = path.dirname(dir) - if (parent === dir) { - break - } - for (let j = 0, { length } = candidates; j < length; j += 1) { - const probe = path.join(parent, candidates[j]!, 'template', 'CLAUDE.md') - if (existsSync(probe)) { - return probe - } - } - dir = parent - } - return undefined -} - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -const REPEAT_FINDING_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = - [ - { - label: 'again', - regex: /\b(hit this )?again\b|\bonce more\b/i, - }, - { - label: 'second/third time', - regex: /\b(fifth|fourth|n-th|nth|second|third) time\b/i, - }, - { - label: 'same X as before / before in this session', - // Up to ~40 chars between "same" and "as/we saw" so we can match - // "same monthCode resolution bug as we saw before" (multi-word X) - // but not entire sentences. - regex: - /\bsame\s+[^.?!\n]{1,40}?\s+(as|we saw)\s+(before|earlier|previously|last time)\b/i, - }, - { - label: "we've seen this before", - regex: - /\b(we'?ve|i'?ve|we have|i have)\s+seen\s+this\s+(already|before)\b/i, - }, - { - label: 'recurring / keeps happening', - regex: - /\b(recurring|keeps happening|kept happening|repeated|repeating)\b/i, - }, - ] - -// Paths that signal rule promotion when edited in the same turn. -const RULE_SURFACE_PATTERNS: readonly RegExp[] = [ - /\bCLAUDE\.md\b/, - /\/\.claude\/hooks\//, - /\/\.claude\/skills\//, - /\/template\/CLAUDE\.md\b/, -] - -interface RepeatFindingHit { - readonly label: string - readonly snippet: string -} - -export function detectRepeatFindings(text: string): RepeatFindingHit[] { - const stripped = stripCodeFences(text) - const found: RepeatFindingHit[] = [] - for (let i = 0, { length } = REPEAT_FINDING_PATTERNS; i < length; i += 1) { - const pattern = REPEAT_FINDING_PATTERNS[i]! - const match = pattern.regex.exec(stripped) - if (!match) { - continue - } - const start = Math.max(0, match.index - 25) - const end = Math.min(stripped.length, match.index + match[0].length + 40) - const snippet = stripped.slice(start, end).replace(/\s+/g, ' ').trim() - found.push({ label: pattern.label, snippet }) - } - return found -} - -export function hasRulePromotionEvidence( - toolUses: ReturnType, - text: string, -): boolean { - // Check 1: any Edit/Write to a rule surface. - for (let i = 0, { length } = toolUses; i < length; i += 1) { - const event = toolUses[i]! - if (event.name !== 'Edit' && event.name !== 'Write') { - continue - } - const filePath = event.input['file_path'] - if (typeof filePath !== 'string') { - continue - } - for ( - let j = 0, { length: pLen } = RULE_SURFACE_PATTERNS; - j < pLen; - j += 1 - ) { - if (RULE_SURFACE_PATTERNS[j]!.test(filePath)) { - return true - } - } - } - // Check 2: a `**Why:**` line in the assistant text (canonical citation - // shape for new rules / memory entries). - if (/\*\*Why:\*\*/.test(text)) { - return true - } - return false -} - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_COMPOUND_LESSONS_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const text = readLastAssistantText(payload.transcript_path) - if (!text) { - process.exit(0) - } - const repeats = detectRepeatFindings(text) - if (repeats.length === 0) { - process.exit(0) - } - const toolUses = readLastAssistantToolUses(payload.transcript_path) - if (hasRulePromotionEvidence(toolUses, text)) { - process.exit(0) - } - - const lines = [ - '[compound-lessons-reminder] Repeat finding detected without rule promotion:', - '', - ] - for (let i = 0, { length } = repeats; i < length; i += 1) { - const hit = repeats[i]! - lines.push(` • "${hit.label}" — …${hit.snippet}…`) - } - lines.push('') - lines.push(' CLAUDE.md "Compound lessons into rules": when the same kind of') - lines.push( - ' finding fires twice, promote it to a rule. Land it in CLAUDE.md,', - ) - lines.push( - ' a `.claude/hooks/*` block, or a skill prompt — pick the lowest-', - ) - lines.push(' friction surface. Always cite the original incident in a') - lines.push(' `**Why:**` line.') - lines.push('') - // If the rule is fleet-wide (not just this repo), it belongs in - // socket-wheelhouse/template/. Help the user find the right path - // — or fall back to the PR link if the wheelhouse isn't local. - const wheelhouseMd = findWheelhouseClaudeMd(process.cwd()) - if (wheelhouseMd) { - lines.push(` Fleet rule? Edit: ${wheelhouseMd}`) - lines.push( - ' (Then re-cascade via `socket-wheelhouse/scripts/sync-scaffolding.mts`.)', - ) - } else { - lines.push(' Fleet rule? Wheelhouse not found locally. Open a PR at') - lines.push(' https://github.com/SocketDev/socket-wheelhouse') - lines.push(' editing `template/CLAUDE.md` (or `template/.claude/hooks/`).') - } - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/compound-lessons-reminder/package.json b/.claude/hooks/compound-lessons-reminder/package.json deleted file mode 100644 index 0be2d4924..000000000 --- a/.claude/hooks/compound-lessons-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-compound-lessons-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/compound-lessons-reminder/test/index.test.mts b/.claude/hooks/compound-lessons-reminder/test/index.test.mts deleted file mode 100644 index aa5a7c5af..000000000 --- a/.claude/hooks/compound-lessons-reminder/test/index.test.mts +++ /dev/null @@ -1,192 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface ToolUse { - name: string - input: Record -} - -function makeTranscript( - assistantText: string, - toolUses: readonly ToolUse[] = [], -): { path: string; cleanup: () => void } { - const dir = mkdtempSync(path.join(os.tmpdir(), 'compound-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const content: object[] = [{ type: 'text', text: assistantText }] - for (let i = 0, { length } = toolUses; i < length; i += 1) { - content.push({ - type: 'tool_use', - name: toolUses[i]!.name, - input: toolUses[i]!.input, - }) - } - writeFileSync( - transcriptPath, - [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', content }, - }), - ].join('\n'), - ) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags "again" repeat-finding', () => { - const { path: p, cleanup } = makeTranscript( - 'Hitting the same regex bug again. Fixed it.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /compound-lessons-reminder/) - assert.match(stderr, /again/) - } finally { - cleanup() - } -}) - -test('flags "second time" repeat-finding', () => { - const { path: p, cleanup } = makeTranscript( - 'This is the second time we have seen this regex bug.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /second/i) - } finally { - cleanup() - } -}) - -test('flags "same X as before"', () => { - const { path: p, cleanup } = makeTranscript( - 'Same monthCode resolution bug as we saw before — patched.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /same/i) - } finally { - cleanup() - } -}) - -test('flags "we have seen this before"', () => { - const { path: p, cleanup } = makeTranscript( - 'We have seen this before in the temporal_rs port.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /seen/i) - } finally { - cleanup() - } -}) - -test('does NOT flag when CLAUDE.md was edited (rule promotion)', () => { - const { path: p, cleanup } = makeTranscript( - 'Hitting the same regex bug again. Promoting to a rule.', - [ - { - name: 'Edit', - input: { file_path: '/repo/template/CLAUDE.md', new_string: '...' }, - }, - ], - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag when a new hook is added', () => { - const { path: p, cleanup } = makeTranscript( - 'Second time hitting this. Adding a hook for it.', - [ - { - name: 'Write', - input: { - file_path: '/repo/template/.claude/hooks/new-rule/index.mts', - content: '...', - }, - }, - ], - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag when **Why:** citation is present', () => { - const { path: p, cleanup } = makeTranscript( - 'Same bug as before. New rule:\n\n**Why:** prior incident in commit abc123 where mock test masked prod failure.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag plain prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores parsed results keyed by file path.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT false-positive on "again" inside code fence', () => { - const { path: p, cleanup } = makeTranscript( - 'Code:\n```\nrun again to verify\n```\nMoved on.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript('Hitting this again.') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_COMPOUND_LESSONS_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/compound-lessons-reminder/tsconfig.json b/.claude/hooks/compound-lessons-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/compound-lessons-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/concurrent-cargo-build-guard/README.md b/.claude/hooks/concurrent-cargo-build-guard/README.md deleted file mode 100644 index 1dd80a7f3..000000000 --- a/.claude/hooks/concurrent-cargo-build-guard/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# concurrent-cargo-build-guard - -PreToolUse Bash hook that blocks a second `cargo build --release` (or known -fleet build-prod alias) while one is in flight. Fleet-wide: only fires on -cargo / build-prod commands, so a no-op in non-cargo repos. - -## Why - -Cargo release builds spawn 8 LLVM threads each, using 8-22GB RAM per build. -Two concurrent release builds reliably OOM-kill on typical dev machines. -Cargo dev builds + cargo check are fast (~1-2s) and parallel-safe — those -are exempt. - -## What it blocks - -| Pattern | Block when in-flight? | -| ------------------------------------------ | --------------------- | -| `cargo build --release` / `cargo build -r` | yes | -| `cargo b --release` / `cargo b -r` | yes | -| `pnpm build:prod` (fleet alias) | yes | -| `node scripts/build.mts --prod` | yes | -| `cargo build` (no --release) | no | -| `cargo check` | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow concurrent-cargo-build bypass - -Use sparingly — OOM consequences are real and abrupt. - -## Detection - -Uses `pgrep -f ` to count in-flight processes matching the same -build shape. If count ≥ 1, blocks. Times out the pgrep call at 5s to -guarantee the hook itself doesn't hang. diff --git a/.claude/hooks/concurrent-cargo-build-guard/index.mts b/.claude/hooks/concurrent-cargo-build-guard/index.mts deleted file mode 100644 index dda34ece0..000000000 --- a/.claude/hooks/concurrent-cargo-build-guard/index.mts +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — concurrent-cargo-build-guard. -// -// Blocks Bash invocations of `cargo build --release` (or known fleet -// build-prod aliases) when another release build is already in flight. -// Each cargo release build spawns 8 LLVM threads using 8-22GB RAM; -// concurrent builds OOM-kill on typical dev machines. -// -// Detection model: -// - Fires on Bash invocations of `cargo build --release` / `cargo build -r` -// / `cargo b --release` / `pnpm build:prod` / `node scripts/build.mts --prod` -// (extend the pattern list when more aliases land). -// - Probes for an in-flight build via `pgrep -f` on the same patterns. If -// count ≥ 1, block. -// - Cargo `check` / dev builds are explicitly exempt (fast + parallel-safe). -// -// Bypass: `Allow concurrent-cargo-build bypass` typed verbatim in a recent -// user turn. -// -// Fires only on cargo / build-prod commands, so a no-op in repos that -// don't use cargo. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow concurrent-cargo-build bypass' - -// Patterns that identify a release build invocation. Each entry is a regex -// matched against the command string AND a separate regex used by pgrep -f -// to find in-flight builds. The two can differ — the cmdline regex is more -// permissive (e.g. captures `pnpm` wrappers) while the pgrep regex targets -// the actual long-running cargo / linker process. -interface BuildPattern { - readonly label: string - // Parser-based matcher: true when `command` invokes this release build. - readonly matches: (command: string) => boolean - // pgrep -f pattern (string, not RegExp — pgrep uses POSIX ERE). - readonly pgrepPattern: string -} - -const BUILD_PATTERNS: BuildPattern[] = [ - { - label: 'cargo build --release', - // `cargo` (or `cargo b`/`build`) with a release flag, as a real - // command — not the words appearing in a quoted string or a sibling. - matches: command => - commandsFor(command, 'cargo').some( - c => - (c.args.includes('build') || c.args.includes('b')) && - (c.args.includes('--release') || c.args.includes('-r')), - ), - pgrepPattern: 'cargo (build|b).*(--release|-r)', - }, - { - label: 'pnpm build:prod', - // `pnpm build:prod` or `pnpm run build:prod` — the script token shows - // up as an arg either way. - matches: command => - commandsFor(command, 'pnpm').some(c => c.args.includes('build:prod')), - pgrepPattern: 'pnpm.*build:prod', - }, - { - label: 'node scripts/build.mts --prod', - // `node …/scripts/build.mts --prod` — the script path is an arg ending - // in scripts/build.mts and --prod is a flag on the same node command. - matches: command => - commandsFor(command, 'node').some( - c => - c.args.some(a => /(?:^|\/)scripts\/build\.mts$/.test(a)) && - c.args.includes('--prod'), - ), - pgrepPattern: 'node.*scripts/build\\.mts.*--prod', - }, -] - -export function commandMatchesBuild(command: string): BuildPattern | undefined { - for (let i = 0, { length } = BUILD_PATTERNS; i < length; i += 1) { - const p = BUILD_PATTERNS[i]! - if (p.matches(command)) { - return p - } - } - return undefined -} - -export function countInFlight(pgrepPattern: string): number { - const r = spawnSync('pgrep', ['-f', pgrepPattern], { - timeout: 5_000, - }) - if (r.status !== 0) { - return 0 - } - return String(r.stdout).split('\n').filter(Boolean).length -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!command) { - process.exit(0) - } - - const matched = commandMatchesBuild(command) - if (!matched) { - process.exit(0) - } - - const inFlight = countInFlight(matched.pgrepPattern) - if (inFlight === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[concurrent-cargo-build-guard] Blocked: release build already in flight', - '', - ` Requested: ${matched.label}`, - ` In-flight: ${inFlight} matching process(es) via pgrep -f '${matched.pgrepPattern}'`, - '', - ' Each release build spawns 8 LLVM threads using 8-22GB RAM.', - ' Running two simultaneously OOM-kills on typical dev machines.', - '', - ' Options:', - ' - Wait for the in-flight build to finish.', - ' - Run a dev build instead: `cargo build` (no --release) is', - ' fast (~1-2s) and parallel-safe.', - ` - Bypass: type "${BYPASS_PHRASE}" in a new message, then retry`, - ' (use sparingly; OOM consequences are real).', - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[concurrent-cargo-build-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/concurrent-cargo-build-guard/package.json b/.claude/hooks/concurrent-cargo-build-guard/package.json deleted file mode 100644 index bb8de8bdc..000000000 --- a/.claude/hooks/concurrent-cargo-build-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-concurrent-cargo-build-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts b/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts deleted file mode 100644 index a297b8268..000000000 --- a/.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts +++ /dev/null @@ -1,103 +0,0 @@ -// node --test specs for the concurrent-cargo-build-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { fileURLToPath } from 'node:url' -import path from 'node:path' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -// Note: real concurrency-detection tests require spawning a fake long-running -// process and pgrep'ing for it, which is platform-fragile in CI. The -// happy-path tests below cover the deterministic surfaces (command-pattern -// matching, exempt commands, bypass) and rely on the no-in-flight default -// for the "passes when nothing is running" case. - -test('non-Bash tool passes', async () => { - const r = await runHook({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/x.txt', new_string: 'hi' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('cargo check passes (exempt)', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'cargo check' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('cargo build (no --release) passes (exempt)', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'cargo build' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('cargo build --release passes when nothing else is in flight', async () => { - // pgrep should find no other cargo release builds in test env. - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'cargo build --release' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('cargo b -r matches the pattern', async () => { - // Same as above — no in-flight build expected in test env. - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'cd packages/acorn/lang/rust && cargo b -r' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('pnpm build:prod matches the pattern', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'pnpm build:prod' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('unrelated Bash command passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'echo "cargo build --release is a string, not a call"', - }, - }) - // The hook treats the command string as-is — `cargo build --release` - // inside an echo IS the pattern match. The block fires only when an - // actual in-flight build is detected; in the test env, there is none. - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/concurrent-cargo-build-guard/tsconfig.json b/.claude/hooks/concurrent-cargo-build-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/concurrent-cargo-build-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/consumer-grep-reminder/README.md b/.claude/hooks/consumer-grep-reminder/README.md deleted file mode 100644 index e509c092d..000000000 --- a/.claude/hooks/consumer-grep-reminder/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# consumer-grep-reminder - -PreToolUse Edit hook (reminder, NOT a block) that fires when an edit -removes a CSS class, HTML attribute, or named export AND the repo has -consumer-bearing subtrees (`upstream/`, `vendor/`, `third_party/`, -`external/`, `deps/`, `additions/source-patched/`). - -## Why - -Past incident: an agent stripped a CSS class because repo-root grep -found 0 hits. The project's upstream bundle (in `upstream/`) hydrated -from that class — the rendered page went blank in production. - -Repo-root grep doesn't see code in `upstream/` / `vendor/` / etc. when -those are gitignored or submodules. This hook surfaces the reminder to -grep those subtrees BEFORE relying on a "0 consumers" finding. - -## What it surfaces - -| Edit pattern | Reminder? | -| -------------------------------------------------------- | --------- | -| Removes `.my-class-name` (hyphenated CSS class) | yes | -| Removes `data-foo` / `aria-bar` (HTML attribute literal) | yes | -| Removes `export const foo` / `export function foo` | yes | -| Removes any of the above when NO consumer subtree exists | no | -| Pure additions (no removals) | no | -| Non-Edit tools | no | - -## Not a block - -False-positive surface is real — not every CSS class removal is a -hydration target. The reminder lets the agent verify with a grep -against the listed subtrees, then continue. The user can also ignore -the reminder if they've already verified. - -## Suggested response - -When this fires, run something like: - -```bash -rg -nF '.removed-class' upstream/ vendor/ third_party/ -``` - -If the grep finds hits, the removal needs coordination with the -upstream bundle. If 0 hits, proceed. diff --git a/.claude/hooks/consumer-grep-reminder/index.mts b/.claude/hooks/consumer-grep-reminder/index.mts deleted file mode 100644 index 935bbbf7a..000000000 --- a/.claude/hooks/consumer-grep-reminder/index.mts +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — consumer-grep-reminder. -// -// Reminder (not blocker) on Edit/Write operations that DELETE a CSS -// class, HTML attribute, element selector, or named export. The -// concern: when the repo has `upstream/`, `vendor/`, `third_party/`, or -// `external/` submodules / vendored trees, repo-root grep for "is -// anyone using this?" misses consumers that live inside the -// upstream/vendored bundle. Past incident: an agent stripped a CSS -// class because the repo-root grep found 0 hits; the project's upstream -// bundle hydrated from that class and the rendered output went blank. -// -// Reminder shape: -// - Detect a removal of a class/attribute/selector pattern in the -// Edit's old_string that doesn't reappear in new_string. -// - Check whether the repo has any of the canonical "consumer-bearing" -// submodule / vendored directories. -// - If yes, emit a stderr reminder pointing at the dirs to grep -// BEFORE deleting. Exit 0 (no block). -// -// This is reminder-only because the false-positive surface is real: -// not every CSS class removal is a hydration-target removal. The -// stderr message gives the agent the signal to verify; the agent's -// correct response is to grep before continuing, not to abort. - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - } - | undefined - readonly cwd?: string | undefined -} - -// Dirs that signal "this repo has consumers outside the repo root." -// Match the same set as the untracked-by-default rule. -const CONSUMER_DIRS = [ - 'upstream', - 'vendor', - 'third_party', - 'external', - 'deps', - 'additions/source-patched', -] - -// Patterns whose removal triggers the reminder. Conservative — only -// signals when the removed token is unambiguous (a quoted selector, -// a class/attribute literal, an exported name). -const REMOVAL_PATTERNS: Array<{ name: string; re: RegExp }> = [ - // CSS class selector: `.foo-bar` (with hyphen — bare `.foo` matches - // too many things) - { name: 'CSS class', re: /\.[a-z][a-zA-Z0-9-]*-[a-zA-Z0-9-]+/g }, - // HTML attribute literal: `data-foo`, `aria-bar` - { name: 'HTML attribute', re: /\b(?:aria|data)-[a-zA-Z0-9-]+/g }, - // Named export: `export const foo = ...` / `export function foo` - { - name: 'named export', - re: /\bexport\s+(?:class|const|function|let|var)\s+(\w+)/g, - }, -] - -export function findConsumerDirs(repoRoot: string): string[] { - const found: string[] = [] - for (let i = 0, { length } = CONSUMER_DIRS; i < length; i += 1) { - const dir = CONSUMER_DIRS[i]! - if (existsSync(path.join(repoRoot, dir))) { - found.push(dir) - } - } - return found -} - -export function findRemovedTokens( - oldStr: string, - newStr: string, -): Map { - const removed = new Map() - for (const { name, re } of REMOVAL_PATTERNS) { - re.lastIndex = 0 - const oldMatches = new Set() - let m: RegExpExecArray | null - while ((m = re.exec(oldStr)) !== null) { - oldMatches.add(m[0]) - } - re.lastIndex = 0 - const newMatches = new Set() - while ((m = re.exec(newStr)) !== null) { - newMatches.add(m[0]) - } - const gone: string[] = [] - for (const v of oldMatches) { - if (!newMatches.has(v)) { - gone.push(v) - } - } - if (gone.length > 0) { - removed.set(name, gone) - } - } - return removed -} - -export function findRepoRoot( - filePath: string, - cwd: string | undefined, -): string { - // Walk up from filePath until we find a .git directory; fall back to cwd. - let dir = path.dirname(filePath) - for (let depth = 0; depth < 10; depth += 1) { - if (existsSync(path.join(dir, '.git'))) { - return dir - } - const parent = path.dirname(dir) - if (parent === dir) { - break - } - dir = parent - } - return cwd ?? path.dirname(filePath) -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit') { - // Only fires on Edit — Write is "create new file" semantically, - // not "delete things." - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath) { - process.exit(0) - } - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr || oldStr === newStr) { - process.exit(0) - } - - const removed = findRemovedTokens(oldStr, newStr) - if (removed.size === 0) { - process.exit(0) - } - - const repoRoot = findRepoRoot(filePath, payload.cwd) - const dirs = findConsumerDirs(repoRoot) - if (dirs.length === 0) { - process.exit(0) - } - - const lines: string[] = [] - lines.push( - '[consumer-grep-reminder] removed tokens — grep upstream consumers before relying on the change:', - ) - lines.push('') - for (const [name, tokens] of removed) { - lines.push( - ` ${name}: ${tokens - .slice(0, 5) - .map(t => `\`${t}\``) - .join( - ', ', - )}${tokens.length > 5 ? ` (+${tokens.length - 5} more)` : ''}`, - ) - } - lines.push('') - lines.push(' Repo has consumer-bearing subtree(s):') - for (let i = 0, { length } = dirs; i < length; i += 1) { - const d = dirs[i]! - lines.push(` ${d}/`) - } - lines.push('') - lines.push( - ' Past incident: agent stripped a CSS class because repo-root grep', - ) - lines.push(' found 0 hits; an upstream bundle hydrated from it and the page') - lines.push(' went blank. Grep every consumer subtree before continuing:') - lines.push('') - for (let i = 0, { length } = dirs; i < length; i += 1) { - const d = dirs[i]! - lines.push( - ` rg -nF '${[...removed.values()].flat()[0] ?? ''}' ${d}/`, - ) - } - lines.push('') - lines.push(' Reminder-only; not a block.') - lines.push('') - - process.stderr.write(lines.join('\n')) - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[consumer-grep-reminder] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/consumer-grep-reminder/package.json b/.claude/hooks/consumer-grep-reminder/package.json deleted file mode 100644 index a3abc8cf4..000000000 --- a/.claude/hooks/consumer-grep-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-consumer-grep-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/consumer-grep-reminder/test/index.test.mts b/.claude/hooks/consumer-grep-reminder/test/index.test.mts deleted file mode 100644 index cc4cff97d..000000000 --- a/.claude/hooks/consumer-grep-reminder/test/index.test.mts +++ /dev/null @@ -1,126 +0,0 @@ -// node --test specs for the consumer-grep-reminder hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function mkRepo(opts: { consumerDirs?: string[] | undefined } = {}): string { - const repo = mkdtempSync(path.join(os.tmpdir(), 'consumer-grep-test-')) - mkdirSync(path.join(repo, '.git'), { recursive: true }) - for (const d of opts.consumerDirs ?? []) { - mkdirSync(path.join(repo, d), { recursive: true }) - } - return repo -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit passes silently', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/foo.css', content: '.x {}' }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('Edit with no removals — no reminder', async () => { - const repo = mkRepo({ consumerDirs: ['upstream'] }) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(repo, 'app.css'), - old_string: '.foo-bar { color: red }\n', - new_string: '.foo-bar { color: red }\n.baz-qux { color: blue }\n', - }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('Edit removing CSS class in repo WITH upstream/ — reminder fires', async () => { - const repo = mkRepo({ consumerDirs: ['upstream'] }) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(repo, 'app.css'), - old_string: '.foo-bar { color: red }\n.keep-me { color: blue }\n', - new_string: '.keep-me { color: blue }\n', - }, - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('consumer-grep-reminder')) - assert.ok(String(r.stderr).includes('foo-bar')) -}) - -test('Edit removing CSS class in repo WITHOUT consumer subtree — no reminder', async () => { - const repo = mkRepo() - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(repo, 'app.css'), - old_string: '.foo-bar {}\n', - new_string: '', - }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('Edit removing data-attribute in repo with vendor/ — reminder fires', async () => { - const repo = mkRepo({ consumerDirs: ['vendor'] }) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(repo, 'page.html'), - old_string: '
x
', - new_string: '
x
', - }, - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('data-hydrate-target')) -}) - -test('Edit removing a named export with third_party/ — reminder fires', async () => { - const repo = mkRepo({ consumerDirs: ['third_party'] }) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(repo, 'index.ts'), - old_string: - 'export const oldApi = () => 1\nexport const kept = () => 2\n', - new_string: 'export const kept = () => 2\n', - }, - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('oldApi')) -}) diff --git a/.claude/hooks/consumer-grep-reminder/tsconfig.json b/.claude/hooks/consumer-grep-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/consumer-grep-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/cross-repo-guard/README.md b/.claude/hooks/cross-repo-guard/README.md deleted file mode 100644 index eed74eb62..000000000 --- a/.claude/hooks/cross-repo-guard/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# cross-repo-guard - -A **Claude Code hook** that runs before `Edit` or `Write` tool calls -and **blocks** edits that introduce a path reference from one fleet -repo into another. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, edit never happens). This one blocks. - -## What it catches - -Two forbidden shapes — both name another fleet repo by path: - -| Form | Example | Why it's bad | -| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Cross-repo relative | `require('../../socket-lib/dist/effects/text-shimmer.js')` | Assumes `ultrathink/` and `socket-lib/` are sibling clones. Breaks in CI sandboxes, fresh checkouts, and any non-standard layout. | -| Cross-repo absolute | `require('/Users/jdalton/projects/socket-lib/dist/effects/ultra.js')` | Leaks the author's local directory layout into the committed tree. Same brittleness. | - -## What to do instead - -Import via the published npm package — every fleet repo is a real -workspace dep: - -```ts -// ✗ WRONG (cross-repo relative) -import { applyShimmer } from '../../socket-lib/dist/effects/text-shimmer.js' - -// ✗ WRONG (cross-repo absolute) -import { applyShimmer } from '/Users//projects/socket-lib/dist/effects/text-shimmer.js' - -// ✓ RIGHT -import { applyShimmer } from '@socketsecurity/lib-stable/effects/text-shimmer' -``` - -If the package isn't published or the version mismatches, vendor the -code into the consuming repo. Never bridge with a path-based -require/import that escapes the repo. - -## Scope - -- **Fires** on `Edit` and `Write` calls. -- **Exempts**: this hook's own source, the git-side scanner - (`.git-hooks/_helpers.mts`), the canonical `CLAUDE.md` fleet block - (which documents fleet repos by name), `.gitmodules`, lockfiles, and - Claude memory files. -- **Exempts** lines tagged `// socket-hook: allow cross-repo` (or `#` - / `/*` for non-TS files). The bare `// socket-hook: allow` form also - works for blanket suppression. - -## Fleet repo list - -The hook recognizes these names as fleet repos: - -``` -claude-code -socket-addon -socket-btm -socket-cli -socket-lib -socket-packageurl-js -socket-registry -socket-wheelhouse -socket-sdk-js -socket-sdxgen -socket-stuie -ultrathink -``` - -To add a new fleet repo, update the list in `index.mts` AND in the -companion git-side scanner in `.git-hooks/_helpers.mts` (`FLEET_REPO_NAMES`) -— keep the two in sync. - -## Wiring - -`.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/cross-repo-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/cross-repo-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/cross-repo-guard/index.mts b/.claude/hooks/cross-repo-guard/index.mts deleted file mode 100644 index a74d92919..000000000 --- a/.claude/hooks/cross-repo-guard/index.mts +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — cross-repo guard. -// -// Blocks Edit/Write tool calls that would introduce a path reference -// to another fleet repo. Two forbidden forms: -// -// 1. `..//…` — relative path that escapes the current -// repo into a sibling clone. Hardcodes the -// assumption that both repos live as -// siblings under the same projects root; -// breaks in CI / fresh clones / non- -// standard layouts. -// 2. `…/projects//…` — absolute or env-rooted path -// that targets another fleet -// repo. Same brittleness, plus -// leaks the author's directory -// layout into source. -// -// The right form is to import via the published npm package: -// `@socketsecurity/lib-stable/`, `@socketsecurity/registry-stable/`, -// etc. Workspace deps are real, declared, and work regardless of clone -// layout. -// -// Exit code 2 makes Claude Code refuse the edit so the diff never -// lands. Doc lines that legitimately need to mention a path can carry -// the canonical opt-out marker `// socket-hook: allow cross-repo` -// (`#`/`/*` accepted). -// -// Scope: -// - Fires only on `Edit` and `Write` tool calls. -// - Inspects all text-shaped file extensions; fleet-repo names in -// pnpm-lock.yaml / pnpm-workspace.yaml / CLAUDE.md / .gitmodules / -// this hook itself are exempt by path. -// -// Fails open on hook bugs (exit code 0 + logger.error). -// -// Companion to the git-side `scanCrossRepoPaths` scanner in -// `.git-hooks/_helpers.mts` — same regex shape, same semantics. Keep -// the two regexes in sync. - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { FLEET_REPO_NAMES } from '../_shared/fleet-repos.mts' -import { readStdin } from '../_shared/transcript.mts' - -const logger = getDefaultLogger() - -const FLEET_RE_FRAGMENT = FLEET_REPO_NAMES.join('|') - -// `..//…` and deeper variants like `../..//…`. Boundary -// chars in front prevent matching e.g. `socketdev-../socket-cli/`. -const CROSS_REPO_RELATIVE_RE = new RegExp( - String.raw`(?:^|[\s'"\`(=,])\.\.(?:/\.\.)*/(?:${FLEET_RE_FRAGMENT})\b`, -) -// `…/projects//…` — absolute or env-rooted variant. -const CROSS_REPO_ABSOLUTE_RE = new RegExp( - String.raw`/projects/(?:${FLEET_RE_FRAGMENT})\b`, -) -const CROSS_REPO_ANY_RE = new RegExp( - `${CROSS_REPO_RELATIVE_RE.source}|${CROSS_REPO_ABSOLUTE_RE.source}`, -) - -// Files exempt from the rule. Comments explain why each is excluded. -const EXEMPT_PATH_PATTERNS: RegExp[] = [ - // The hook itself names every fleet repo by necessity. - /\.claude\/hooks\/cross-repo-guard\//, - // The git-side scanner does the same. - /\.git-hooks\/_helpers\.mts$/, - // The fleet's canonical CLAUDE.md documents fleet repo relationships. - /(?:^|\/)CLAUDE\.md$/, - // Submodule index — fleet repos point at each other by URL. - /(?:^|\/)\.gitmodules$/, - // Lockfiles / workspace config name fleet packages. - /(?:^|\/)pnpm-lock\.yaml$/, - /(?:^|\/)pnpm-workspace\.yaml$/, - // Memory files in `.claude/projects/...` may legitimately quote past - // mistakes verbatim. - /\.claude\/projects\/.*\/memory\//, -] - -const SOCKET_HOOK_MARKER_RE = - /(?:#|\/\/|\/\*)\s*socket-hook:\s*allow(?:\s+([\w-]+))?/ - -interface ToolInput { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -export function emitBlock(filePath: string, hits: Hit[]): void { - const lines: string[] = [] - lines.push('[cross-repo-guard] Blocked: cross-repo path reference found') - lines.push( - ' Use `@socketsecurity/lib-stable/` or `@socketsecurity/registry-stable/`', - ) - lines.push( - ' imports instead. Path-based references break in CI / fresh clones.', - ) - lines.push(` File: ${filePath}`) - for (const h of hits.slice(0, 3)) { - lines.push(` Line ${h.lineNumber}: ${h.line.trim()}`) - lines.push(` Match: ${h.matched.trim()}`) - } - if (hits.length > 3) { - lines.push(` …and ${hits.length - 3} more.`) - } - lines.push( - ' Opt-out for one line (rare): append `// socket-hook: allow cross-repo`.', - ) - logger.error(lines.join('\n')) -} - -export function isInScope(filePath: string): boolean { - if (!filePath) { - return false - } - for (let i = 0, { length } = EXEMPT_PATH_PATTERNS; i < length; i += 1) { - const re = EXEMPT_PATH_PATTERNS[i]! - if (re.test(filePath)) { - return false - } - } - return true -} - -export function isMarkerSuppressed(line: string): boolean { - const m = line.match(SOCKET_HOOK_MARKER_RE) - if (!m) { - return false - } - return !m[1] || m[1] === 'cross-repo' -} - -export function repoNameFromPath(filePath: string): string | undefined { - // `/Users//projects/socket-lib/src/foo.ts` → `socket-lib`. - // Best-effort: take the segment after `/projects/` if present. - const m = filePath.match(/\/projects\/([^/]+)/) - return m?.[1] -} - -interface Hit { - lineNumber: number - line: string - matched: string -} - -export function scan(source: string, currentRepoName?: string): Hit[] { - const hits: Hit[] = [] - const lines = source.split('\n') - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - const m = line.match(CROSS_REPO_ANY_RE) - if (!m) { - continue - } - // A repo's own paths are fine — only flag escapes. - const matched = m[0] - if (currentRepoName && matched.includes(`/${currentRepoName}`)) { - continue - } - if (isMarkerSuppressed(line)) { - continue - } - hits.push({ lineNumber: i + 1, line, matched }) - } - return hits -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!isInScope(filePath)) { - return - } - const source = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!source) { - return - } - const hits = scan(source, repoNameFromPath(filePath)) - if (hits.length === 0) { - return - } - emitBlock(filePath, hits) - process.exitCode = 2 -} - -main().catch(e => { - // Fail open on hook bugs. - logger.error( - `[cross-repo-guard] hook error (continuing): ${(e as Error).message}`, - ) -}) diff --git a/.claude/hooks/cross-repo-guard/package.json b/.claude/hooks/cross-repo-guard/package.json deleted file mode 100644 index e5e6cee3e..000000000 --- a/.claude/hooks/cross-repo-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-cross-repo-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts b/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts deleted file mode 100644 index 3a3d74539..000000000 --- a/.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts +++ /dev/null @@ -1,136 +0,0 @@ -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { test } from 'node:test' -import assert from 'node:assert/strict' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Payload { - tool_name: 'Edit' | 'Write' | string - tool_input: { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } -} - -function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end(JSON.stringify(payload)) - }) -} - -test('blocks ../socket-lib/ relative reference', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/Users//projects/ultrathink/assets/x.mjs', - content: `const f = require('../../socket-lib/dist/effects/x.js')`, - }, - }) - assert.equal(code, 2, `expected exit 2; got ${code}; stderr=${stderr}`) - assert.ok(stderr.includes('cross-repo-guard')) -}) - -test('blocks /Users//projects// absolute reference', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/Users//projects/ultrathink/assets/x.mjs', - content: `const f = require('/Users//projects/socket-lib/dist/effects/x.js')`, - }, - }) - assert.equal(code, 2, `expected exit 2; got ${code}; stderr=${stderr}`) - assert.ok(stderr.includes('/projects/socket-lib')) -}) - -test('does not block @socketsecurity/lib-stable package import', async () => { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: 'src/foo.ts', - content: `import { applyShimmer } from '@socketsecurity/lib-stable/effects/shimmer'`, - }, - }) - assert.equal(code, 0) -}) - -test('does not block own-repo paths (socket-lib editing socket-lib paths)', async () => { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/Users//projects/socket-lib/scripts/foo.mts', - content: `// path: /Users//projects/socket-lib/dist/effects/x.js`, - }, - }) - assert.equal(code, 0) -}) - -test('respects // socket-hook: allow cross-repo marker', async () => { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: 'src/foo.ts', - content: `const p = '../../socket-cli/x' // socket-hook: allow cross-repo`, - }, - }) - assert.equal(code, 0) -}) - -test('respects bare // socket-hook: allow marker', async () => { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: 'src/foo.ts', - content: `const p = '../../socket-cli/x' // socket-hook: allow`, - }, - }) - assert.equal(code, 0) -}) - -test('skips files outside scope (CLAUDE.md, .gitmodules)', async () => { - for (const filePath of [ - 'CLAUDE.md', - '.gitmodules', - '.git-hooks/_helpers.mts', - 'pnpm-lock.yaml', - ]) { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: `mention of ../../socket-lib/ here`, - }, - }) - assert.equal(code, 0, `unexpected block on ${filePath}`) - } -}) - -test('does not fire on non-Edit/Write tools', async () => { - const { code } = await runHook({ - tool_name: 'Bash', - tool_input: { content: '' }, - }) - assert.equal(code, 0) -}) diff --git a/.claude/hooks/cross-repo-guard/tsconfig.json b/.claude/hooks/cross-repo-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/cross-repo-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/default-branch-guard/README.md b/.claude/hooks/default-branch-guard/README.md deleted file mode 100644 index 929143fb8..000000000 --- a/.claude/hooks/default-branch-guard/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# default-branch-guard - -PreToolUse hook that blocks Bash invocations hard-coding `main` or `master` in scripting contexts where the fleet's "Default branch fallback" rule requires a `git symbolic-ref` lookup. - -## Why - -Fleet repos are mostly on `main`, but legacy/vendored repos still use `master`. Scripts that hard-code one name silently no-op on the other. The canonical pattern looks up `refs/remotes/origin/HEAD`, falls back to `main`, then `master`, never just assumes. - -## What it catches - -- `BASE=main` / `BASE=master` literal assignments -- `--base=main` / `--base main` flag values -- `DEFAULT_BRANCH=main` / `MAIN_BRANCH=master` -- Heredoc / `cat > file.sh` writes containing `main..HEAD` / `master...HEAD` literals - -## What it does NOT catch - -- Interactive one-offs: `git checkout main`, `git pull origin main`, `gh pr create --base main` are allowed (the user is operating on a known repo). -- Mentions of "main" / "master" in non-scripting commands (`echo`, comments, etc.). - -## Bypass - -- Type `Allow default-branch bypass` in a recent user message (also accepts `Allow default branch bypass` / `Allow defaultbranch bypass`), or -- Set `SOCKET_DEFAULT_BRANCH_GUARD_DISABLED=1`. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/default-branch-guard/index.mts b/.claude/hooks/default-branch-guard/index.mts deleted file mode 100644 index 0b3c80277..000000000 --- a/.claude/hooks/default-branch-guard/index.mts +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — default-branch-guard. -// -// Blocks Bash invocations that hard-code `main` or `master` as the -// default branch in places where the fleet's "Default branch fallback" -// rule says to use a `git symbolic-ref refs/remotes/origin/HEAD` -// lookup with main→master fallback. -// -// What it catches (Bash commands that look like a script body, not a -// one-off): -// -// - Hard-coded `git diff main...HEAD` / `git rev-list main..HEAD` -// when the user is constructing a script (BASE=, default branch -// resolution, scripting context). -// -// - `BASE=main` / `BASE=master` literal assignments. -// -// - `--base main` / `--base=main` literal flag values (for `gh pr`, -// etc.) in scripting context. -// -// The heuristic is generous: a plain `git checkout main` or `git pull -// origin main` is allowed (those are interactive one-offs). The hook -// fires when the command shape implies a reusable script. -// -// Bypass: "Allow default-branch bypass" in a recent user turn, or set -// SOCKET_DEFAULT_BRANCH_GUARD_DISABLED=1. - -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASES = [ - 'Allow default-branch bypass', - 'Allow default branch bypass', - 'Allow defaultbranch bypass', -] as const - -// Patterns we consider "script context" (not interactive one-off): -// -// BASE=main — variable assignment defaulting to main -// --base=main — flag value -// --base main — flag value (space-separated) -// -// Each pattern's regex must include enough context to distinguish -// scripting from interactive use. -const SCRIPT_CONTEXT_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = - [ - { - label: 'BASE=main / BASE=master literal assignment', - regex: /\bBASE\s*=\s*(["']?)(?:main|master)\1\b/, - }, - { - label: '--base main / --base=main literal value', - regex: /--base[\s=](["']?)(?:main|master)\1\b/, - }, - { - label: 'DEFAULT_BRANCH=main literal assignment', - regex: - /\b(?:DEFAULT_BRANCH|MAIN_BRANCH)\s*=\s*(["']?)(?:main|master)\1\b/, - }, - ] - -// Heredoc / file-write detection: when the command writes a script -// (e.g. via cat > file.sh, tee, redirect), be stricter — any reference -// to `main..HEAD` / `main...HEAD` inside the writeable body counts as -// scripting context. -const SCRIPT_WRITE_RE = - /(?:cat\s*>\s*|tee\s+|>\s*)\S+\.(?:bash|fish|js|mjs|mts|sh|ts|zsh)\b/ - -const TRIPLE_DOT_BRANCH_RE = /\b(?:main|master)\.{2,3}HEAD\b/ - -async function main(): Promise { - if (process.env['SOCKET_DEFAULT_BRANCH_GUARD_DISABLED']) { - process.exit(0) - } - const payloadRaw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(payloadRaw) as PreToolUsePayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.['command'] - if (typeof command !== 'string') { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - - const hits: string[] = [] - for (let i = 0, { length } = SCRIPT_CONTEXT_PATTERNS; i < length; i += 1) { - const pattern = SCRIPT_CONTEXT_PATTERNS[i]! - if (pattern.regex.test(command)) { - hits.push(pattern.label) - } - } - if (SCRIPT_WRITE_RE.test(command) && TRIPLE_DOT_BRANCH_RE.test(command)) { - hits.push( - 'writing a script file with `main..HEAD` / `master..HEAD` literal — ' + - 'resolve BASE via `git symbolic-ref` instead', - ) - } - if (hits.length === 0) { - process.exit(0) - } - - const lines = [ - '[default-branch-guard] Command hard-codes a default branch name in scripting context:', - '', - ] - for (let i = 0, { length } = hits; i < length; i += 1) { - lines.push(` • ${hits[i]}`) - } - lines.push('') - lines.push( - ' Per CLAUDE.md "Default branch fallback", scripts must look up the', - ) - lines.push(" remote's HEAD and fall back main → master, not hard-code one:") - lines.push('') - lines.push( - " BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')", - ) - lines.push( - ' [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/main && BASE=main', - ) - lines.push( - ' [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/master && BASE=master', - ) - lines.push(' BASE="${BASE:-main}"') - lines.push('') - lines.push( - ' Bypass: type "Allow default-branch bypass" in a recent message.', - ) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/default-branch-guard/package.json b/.claude/hooks/default-branch-guard/package.json deleted file mode 100644 index 5e09b9a8f..000000000 --- a/.claude/hooks/default-branch-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-default-branch-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/default-branch-guard/test/index.test.mts b/.claude/hooks/default-branch-guard/test/index.test.mts deleted file mode 100644 index 3ee2a32cc..000000000 --- a/.claude/hooks/default-branch-guard/test/index.test.mts +++ /dev/null @@ -1,110 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(userText?: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'defbranch-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userText ?? 'do it' }), - ) - return transcriptPath -} - -function runHook( - command: string, - transcriptPath?: string, - extraEnv: Record = {}, -): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Bash', - tool_input: { command }, - transcript_path: transcriptPath, - }), - env: { ...process.env, ...extraEnv }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('BLOCKS BASE=main literal assignment', () => { - const { stderr, exitCode } = runHook('BASE=main && git diff $BASE..HEAD') - assert.equal(exitCode, 2) - assert.match(stderr, /default-branch-guard/) - assert.match(stderr, /BASE=main/) -}) - -test('BLOCKS BASE=master literal assignment', () => { - const { exitCode } = runHook('BASE=master\ngit diff $BASE..HEAD') - assert.equal(exitCode, 2) -}) - -test('BLOCKS --base main flag in gh pr create-like script', () => { - const { exitCode } = runHook('gh pr create --base main --title foo') - assert.equal(exitCode, 2) -}) - -test('BLOCKS --base=main', () => { - const { exitCode } = runHook('gh pr create --base=main --title foo') - assert.equal(exitCode, 2) -}) - -test('BLOCKS DEFAULT_BRANCH=main', () => { - const { exitCode } = runHook( - 'DEFAULT_BRANCH=main\ngit diff $DEFAULT_BRANCH..HEAD', - ) - assert.equal(exitCode, 2) -}) - -test('BLOCKS script-file write with main..HEAD literal', () => { - const { exitCode } = runHook('cat > script.sh < { - const { exitCode } = runHook('git checkout main') - assert.equal(exitCode, 0) -}) - -test('ALLOWS plain git pull origin main', () => { - const { exitCode } = runHook('git pull origin main') - assert.equal(exitCode, 0) -}) - -test('ALLOWS the canonical lookup pattern', () => { - const { exitCode } = runHook( - 'BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed s@^refs/remotes/origin/@@)', - ) - assert.equal(exitCode, 0) -}) - -test('IGNORES non-Bash tools', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { command: 'BASE=main' }, - }), - }) - assert.equal(result.status, 0) -}) - -test('ALLOWS with "Allow default-branch bypass" phrase', () => { - const t = makeTranscript('Allow default-branch bypass') - const { exitCode } = runHook('BASE=main && git diff $BASE..HEAD', t) - assert.equal(exitCode, 0) -}) - -test('disabled env var short-circuits', () => { - const { exitCode } = runHook('BASE=main', undefined, { - SOCKET_DEFAULT_BRANCH_GUARD_DISABLED: '1', - }) - assert.equal(exitCode, 0) -}) diff --git a/.claude/hooks/default-branch-guard/tsconfig.json b/.claude/hooks/default-branch-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/default-branch-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/README.md b/.claude/hooks/dirty-worktree-on-stop-reminder/README.md deleted file mode 100644 index 5afeee672..000000000 --- a/.claude/hooks/dirty-worktree-on-stop-reminder/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# dirty-worktree-on-stop-reminder - -Stop hook that emits a stderr reminder at turn-end if `git status ---porcelain` shows any modified, untracked, or staged-uncommitted -files in the harness project dir. - -## Why - -CLAUDE.md "Don't leave the worktree dirty" already states the rule: -finish a code change → commit it. The complementary -`no-orphaned-staging` hook catches only staged-but-uncommitted index -entries; this hook closes the broader gap — **unstaged modifications -and untracked files** that the agent left behind because they came -from a `pnpm run format` sweep, a script side-effect, or -"I'll get to it later." - -Past failure: an agent committed surgical work (T1, T2) but left 28 -formatter-touched files dirty because they came from an earlier -`pnpm run format` sweep. The agent announced "intentional pause" -in the turn summary instead of resolving the state. The next session -inherited a 28-file diff with no clear ownership. - -## What it does - -Runs `git status --porcelain` in `$CLAUDE_PROJECT_DIR`. Filters out -untracked-by-default trees (`vendor/`, `third_party/`, `upstream/`, -`additions/source-patched/`, `deps/`, `external/`, `pkg-node/`, -`*-bundled/`, `*-vendored/`) so vendor drops don't trip the reminder. -Reports the remaining dirty paths plus a 3-option remediation menu: -commit / revert / explicitly announce. - -Never blocks. Informational stderr only — the Stop event has no tool -call to refuse. - -## Disable - -```bash -SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED=1 -``` - -## Related - -- `no-orphaned-staging` — Stop hook for staged-but-uncommitted hunks -- `node-modules-staging-guard` — PreToolUse block for `git add -f` of - `node_modules/` (bypass: `Allow node-modules-staging bypass`) -- `overeager-staging-guard` — PreToolUse block for `git add -A` / - `git add .` (bypass: `Allow add-all bypass`) -- Fleet doc: [`docs/claude.md/fleet/worktree-hygiene.md`](../../docs/claude.md/fleet/worktree-hygiene.md) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts b/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts deleted file mode 100644 index 6bfd20eb0..000000000 --- a/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — dirty-worktree-on-stop-reminder. -// -// Fires at turn-end. Checks `git status --porcelain` in the harness -// project dir. If anything is modified, untracked, or staged but -// uncommitted, emits a stderr reminder listing the dirty paths. -// -// The fleet rule (CLAUDE.md "Don't leave the worktree dirty"): -// -// Finish a code change → commit it. Never end a turn with -// uncommitted edits, untracked files, or staged-but-uncommitted -// hunks. If you can't commit yet (mid-refactor, failing tests, -// waiting on user), announce it in the turn summary — silent -// dirty worktrees are the failure mode. -// -// Why a reminder, not a block: Stop hooks fire AFTER the turn ended; -// there's no tool call to refuse. The reminder makes dirty state -// visible at the very turn that created it, so the agent can resolve -// it (commit / revert / explicitly announce) before the next turn. -// -// Complements `no-orphaned-staging` which only catches index entries. -// This hook catches the broader dirty-worktree case: unstaged -// modifications and untracked files. -// -// Untracked-by-default directories (vendor/, third_party/, upstream/, -// additions/source-patched/) are filtered out — they're under -// .gitignore rules and not the failure mode this hook targets. -// -// Exit codes: -// 0 — always. Informational; never blocks. -// -// Disabled via `SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED=1`. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -export async function drainStdin(): Promise { - await new Promise(resolve => { - let chunks = '' - process.stdin.on('data', d => { - chunks += d.toString('utf8') - }) - process.stdin.on('end', () => resolve()) - process.stdin.on('error', () => resolve()) - setTimeout(() => resolve(), 200) - void chunks - }) -} - -export function getProjectDir(): string | undefined { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -interface DirtyEntry { - readonly status: string - readonly path: string -} - -// Untracked-by-default path prefixes — match the CLAUDE.md -// "Untracked-by-default for vendored / build-copied trees" list. -const UNTRACKED_BY_DEFAULT_PREFIXES = [ - 'additions/source-patched/', - 'vendor/', - 'third_party/', - 'external/', - 'upstream/', - 'deps/', - 'pkg-node/', -] - -export function isUntrackedByDefault(p: string): boolean { - for ( - let i = 0, { length } = UNTRACKED_BY_DEFAULT_PREFIXES; - i < length; - i += 1 - ) { - const prefix = UNTRACKED_BY_DEFAULT_PREFIXES[i]! - if (p.startsWith(prefix)) { - return true - } - } - if (/(?:^|\/)[^/]+-(?:bundled|vendored)(?:\/|$)/.test(p)) { - return true - } - return false -} - -export function parsePorcelain(out: string): DirtyEntry[] { - const entries: DirtyEntry[] = [] - for (const line of out.split('\n')) { - if (!line) { - continue - } - const status = line.slice(0, 2) - const rest = line.slice(3) - const arrow = rest.indexOf(' -> ') - const filePath = arrow === -1 ? rest : rest.slice(arrow + 4) - if (isUntrackedByDefault(filePath)) { - continue - } - entries.push({ status, path: filePath }) - } - return entries -} - -export function listDirtyEntries(repoDir: string): DirtyEntry[] { - const r = spawnSync('git', ['status', '--porcelain'], { - cwd: repoDir, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return parsePorcelain(String(r.stdout)) -} - -async function main(): Promise { - if (process.env['SOCKET_DIRTY_WORKTREE_REMINDER_DISABLED']) { - return - } - await drainStdin() - - const repoDir = getProjectDir() - if (!repoDir) { - return - } - - const dirty = listDirtyEntries(repoDir) - if (dirty.length === 0) { - return - } - - process.stderr.write( - `[dirty-worktree-on-stop-reminder] Turn ended with ${dirty.length} dirty path(s):\n`, - ) - for (const e of dirty.slice(0, 10)) { - process.stderr.write(` ${e.status} ${e.path}\n`) - } - if (dirty.length > 10) { - process.stderr.write(` ... and ${dirty.length - 10} more\n`) - } - process.stderr.write( - "\nFleet rule: end-of-turn worktree must match the user's mental\n" + - "model of where the work is. 'Done' means committed. Options:\n" + - ' • Commit the dirty paths (surgical: explicit file args).\n' + - ' • Revert paths you did not author this session.\n' + - ' • If pause is intentional (mid-refactor, waiting on user),\n' + - ' announce it explicitly in the turn summary.\n' + - '\nSilent dirty worktrees break the next session. See:\n' + - ' CLAUDE.md → "Don\'t leave the worktree dirty"\n' + - ' docs/claude.md/fleet/worktree-hygiene.md\n', - ) -} - -main().catch(e => { - process.stderr.write( - `[dirty-worktree-on-stop-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/package.json b/.claude/hooks/dirty-worktree-on-stop-reminder/package.json deleted file mode 100644 index 6b836acb0..000000000 --- a/.claude/hooks/dirty-worktree-on-stop-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-dirty-worktree-on-stop-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts b/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts deleted file mode 100644 index c01a3dcfe..000000000 --- a/.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts +++ /dev/null @@ -1,94 +0,0 @@ -// node --test specs for the dirty-worktree-on-stop-reminder hook. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { isUntrackedByDefault, parsePorcelain } from '../index.mts' - -test('isUntrackedByDefault: vendor/ prefix', () => { - assert.strictEqual(isUntrackedByDefault('vendor/foo.cc'), true) -}) - -test('isUntrackedByDefault: third_party/ prefix', () => { - assert.strictEqual(isUntrackedByDefault('third_party/lib/x.h'), true) -}) - -test('isUntrackedByDefault: upstream/ prefix', () => { - assert.strictEqual(isUntrackedByDefault('upstream/node/src/foo.cc'), true) -}) - -test('isUntrackedByDefault: additions/source-patched/ prefix', () => { - assert.strictEqual( - isUntrackedByDefault('additions/source-patched/bin-infra/main.js'), - true, - ) -}) - -test('isUntrackedByDefault: deps/ prefix', () => { - assert.strictEqual(isUntrackedByDefault('deps/curl/src.c'), true) -}) - -test('isUntrackedByDefault: pkg-node/ prefix', () => { - assert.strictEqual(isUntrackedByDefault('pkg-node/foo.js'), true) -}) - -test('isUntrackedByDefault: *-bundled component', () => { - assert.strictEqual(isUntrackedByDefault('something-bundled/x.js'), true) - assert.strictEqual(isUntrackedByDefault('packages/foo-bundled/a.ts'), true) -}) - -test('isUntrackedByDefault: *-vendored component', () => { - assert.strictEqual(isUntrackedByDefault('node-vendored/file.cc'), true) -}) - -test('isUntrackedByDefault: ordinary tracked path', () => { - assert.strictEqual(isUntrackedByDefault('src/index.ts'), false) - assert.strictEqual(isUntrackedByDefault('packages/foo/lib/x.ts'), false) - assert.strictEqual( - isUntrackedByDefault('.github/workflows/release.yml'), - false, - ) -}) - -test('parsePorcelain: modified + untracked + staged', () => { - const out = [ - ' M src/index.ts', - '?? new-file.md', - 'M staged.ts', - 'A added.ts', - '', - ].join('\n') - const entries = parsePorcelain(out) - assert.strictEqual(entries.length, 4) - assert.deepStrictEqual(entries.map(e => e.path).toSorted(), [ - 'added.ts', - 'new-file.md', - 'src/index.ts', - 'staged.ts', - ]) -}) - -test('parsePorcelain: rename uses destination', () => { - const out = 'R old/path.ts -> new/path.ts\n' - const entries = parsePorcelain(out) - assert.strictEqual(entries.length, 1) - assert.strictEqual(entries[0]!.path, 'new/path.ts') -}) - -test('parsePorcelain: filters vendor/upstream', () => { - const out = [ - ' M src/real.ts', - ' M vendor/skip.cc', - ' M upstream/node/skip.cc', - '?? third_party/skip.h', - '', - ].join('\n') - const entries = parsePorcelain(out) - assert.strictEqual(entries.length, 1) - assert.strictEqual(entries[0]!.path, 'src/real.ts') -}) - -test('parsePorcelain: empty input', () => { - assert.deepStrictEqual(parsePorcelain(''), []) - assert.deepStrictEqual(parsePorcelain('\n\n'), []) -}) diff --git a/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json b/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/dont-blame-user-reminder/README.md b/.claude/hooks/dont-blame-user-reminder/README.md deleted file mode 100644 index a297956cd..000000000 --- a/.claude/hooks/dont-blame-user-reminder/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# dont-blame-user-reminder - -Claude Code `Stop` hook that scans the assistant's most recent turn for phrases that blame the user (or "the linter") for state the assistant's own scripts most likely produced. - -## Why - -CLAUDE.md's _"Fix it, don't defer"_ block has a rule: don't blame the user (or "the linter") when your own edits get reverted between turns. The cause is almost always the assistant's own machinery — pre-commit autofix, sync-cascade from `template/`, `oxlint --fix`, `oxfmt`. Attributing the change to the user instead of investigating those scripts is a deferral: it lets the assistant stop debugging without finding the actual cause. - -Past incident: the assistant repeatedly claimed "the user reverted my edits" / "the linter stripped my assertions" / "the user prefers state with no assertions" when the strips were actually produced by template-canonical sources + the sync-cascade. - -## What it catches - -| Phrase shape | Why it's flagged | -| --- | --- | -| `the user/linter/formatter reverted/stripped/removed/rewrote …` | Attributes state to the user/tool as the cause, with no investigation. | -| `user's intentional/preferred/preserved state` | Same — assumes intent the assistant hasn't evidenced. | -| `removed/reverted/stripped by the user/linter/formatter` | Same. | -| `the user/linter wants/chose to keep/strip/remove …` | Same. | - -Quoted spans are stripped before matching, so the hook doesn't self-fire when the assistant *describes* these phrases (e.g. paraphrasing this doc in a turn summary). - -## Why it blocks - -Unlike most `Stop` reminders, this one runs in **blocking** mode: the assistant must continue the turn and either (a) prove the blame with hard evidence — a quoted user message, a `git reflog` entry, a commit hash — or (b) keep investigating which script produced the reverted state (`git log -S`, run pre-commit phases in isolation, diff `template/` canonical sources). `stop_hook_active` suppresses it after the first fire, so it triggers at most once per stop chain. - -## Configuration - -`SOCKET_DONT_BLAME_USER_DISABLED=1` — turn the hook off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/dont-blame-user-reminder/index.mts b/.claude/hooks/dont-blame-user-reminder/index.mts deleted file mode 100644 index 80af7fe7e..000000000 --- a/.claude/hooks/dont-blame-user-reminder/index.mts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — dont-blame-user-reminder. -// -// Scans the assistant's most recent turn for phrases that blame the -// user (or "the linter") for state that was actually produced by the -// assistant's own scripts: pre-commit autofix, sync-scaffolding -// cascades, lint --fix passes, format-on-save. -// -// Why this exists: jdalton repeatedly saw the assistant claim "the -// user reverted my edits" / "the linter stripped my !s" / "user's -// preferred state has no assertions" when in fact the strips were -// produced by the assistant's own template canonical sources + -// sync-cascade scripts. Blaming the user instead of investigating -// the assistant's own scripts is a deferral pattern: it lets the -// assistant stop debugging without finding the actual cause. -// -// Runs in BLOCKING mode so the assistant must continue the turn and -// either (a) prove the blame is correct with evidence (a commit -// hash, a hook output, etc.) or (b) keep investigating the actual -// script that produced the reverted state. The block is suppressed -// when stop_hook_active is set, so it can fire at most once per -// stop chain. -// -// Disabled via SOCKET_DONT_BLAME_USER_DISABLED env var. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'dont-blame-user-reminder', - disabledEnvVar: 'SOCKET_DONT_BLAME_USER_DISABLED', - blocking: true, - // Strip quoted spans so the hook doesn't self-fire when the - // assistant *describes* the phrases it detects (e.g. when this - // doc-comment is itself paraphrased in a turn summary). - stripQuotedSpans: true, - patterns: [ - { - label: 'blaming user/linter for revert without evidence', - // Matches phrases that attribute state to the user / linter - // *as the cause*, with no investigation attached. The shape: - // "user reverted X" / "linter stripped Y" / "user prefers Z". - // These are deferral phrases when said about state produced - // by the assistant's own scripts (sync-cascade, pre-commit - // autofix, oxlint --fix, oxfmt). - regex: - /\b(?:the\s+)?(?:formatter|linter|user)\s+(?:reverted|stripped|removed|undid|reformatted|rewrote|preserves?|prefers?|keeps?)\b|\buser['']s\s+(?:intentional|preferred|preserved)\s+state\b|\b(?:removed|reverted|stripped)\s+by\s+(?:the\s+)?(?:formatter|linter|user)\b|\b(?:the\s+)?(?:user|linter)\s+(?:wants|chose|picked)\s+(?:to\s+keep|to\s+strip|to\s+remove)\b/i, - why: 'Don\'t blame the user or "the linter" for state that may have been produced by your own scripts (sync-cascade, pre-commit autofix, oxlint --fix, oxfmt, template canonical sources). Investigate WHICH script produced the state — `git log -S` the change, run pre-commit phases in isolation, check `template/` canonical sources. Only attribute the change to the user with direct evidence (a quoted user message, a `git reflog` entry).', - }, - ], - closingHint: - 'If you have hard evidence the user reverted the change (a quoted user message, a manual `git reflog` entry), restate the evidence inline. Otherwise resume the investigation into the actual script that produced the state.', -}) diff --git a/.claude/hooks/dont-blame-user-reminder/package.json b/.claude/hooks/dont-blame-user-reminder/package.json deleted file mode 100644 index 58b889601..000000000 --- a/.claude/hooks/dont-blame-user-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-dont-blame-user-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/dont-blame-user-reminder/test/index.test.mts b/.claude/hooks/dont-blame-user-reminder/test/index.test.mts deleted file mode 100644 index 78833e0d2..000000000 --- a/.claude/hooks/dont-blame-user-reminder/test/index.test.mts +++ /dev/null @@ -1,212 +0,0 @@ -// node --test specs for the dont-blame-user-reminder hook. -// -// Spawns the hook as a subprocess (matches the production runtime), -// writes a fake transcript to a temp dir, passes its path on stdin, -// captures stdout/stderr + exit code. The hook runs in BLOCKING mode: -// on a hit it writes a `{decision:'block'}` JSON to stdout and nothing -// to stderr; stop_hook_active suppresses it. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -interface Result { - readonly code: number - readonly stderr: string - readonly stdout: string -} - -interface TranscriptEntry { - readonly type: 'user' | 'assistant' - readonly content: string -} - -interface RunHookOptions { - readonly stopHookActive?: boolean | undefined -} - -function setupTranscript(rawContent: string): { - readonly dir: string - readonly transcriptPath: string - readonly cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'dont-blame-user-test-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, rawContent) - return { - dir, - transcriptPath, - cleanup: () => { - rmSync(dir, { recursive: true, force: true }) - }, - } -} - -async function runHook( - entries: TranscriptEntry[], - options: RunHookOptions = {}, -): Promise { - const rawContent = - entries - .map(e => - JSON.stringify({ type: e.type, message: { content: e.content } }), - ) - .join('\n') + '\n' - const transcript = setupTranscript(rawContent) - try { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - void child.catch(() => undefined) - const payload: Record = { - transcript_path: transcript.transcriptPath, - } - if (options.stopHookActive) { - payload['stop_hook_active'] = true - } - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - return await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - } finally { - transcript.cleanup() - } -} - -// In blocking mode the hook writes a `{decision:'block'}` JSON to -// stdout and nothing to stderr. -function assertBlock(result: Result, pattern: RegExp): void { - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.match(result.stdout, pattern) - const parsed = JSON.parse(result.stdout) as { - decision?: string | undefined - reason?: string | undefined - } - assert.strictEqual(parsed.decision, 'block') -} - -test('no transcript path: exits clean', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end(JSON.stringify({})) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stdout, '') -}) - -test('clean assistant turn: no block', async () => { - const result = await runHook([ - { type: 'user', content: 'do the work' }, - { - type: 'assistant', - content: 'Investigated the cascade; the strip came from oxfmt. Fixed.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stdout, '') -}) - -test('blocks "the user reverted my edits"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'It looks like the user reverted my edits between turns.', - }, - ]) - assertBlock(result, /dont-blame-user-reminder/) -}) - -test('blocks "the linter stripped" my assertions', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'The linter stripped the non-null assertions I added.', - }, - ]) - assertBlock(result, /dont-blame-user-reminder/) -}) - -test('blocks "the formatter rewrote"', async () => { - const result = await runHook([ - { type: 'assistant', content: 'The formatter rewrote the file again.' }, - ]) - assertBlock(result, /dont-blame-user-reminder/) -}) - -test('blocks "user\'s preferred state"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: "This must be the user's preferred state with no assertions.", - }, - ]) - assertBlock(result, /dont-blame-user-reminder/) -}) - -test('blocks "the user chose to strip"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'Presumably the user chose to strip those checks.', - }, - ]) - assertBlock(result, /dont-blame-user-reminder/) -}) - -test('stop_hook_active suppresses the block', async () => { - const result = await runHook( - [ - { - type: 'assistant', - content: 'The user reverted my edits.', - }, - ], - { stopHookActive: true }, - ) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stdout, '') -}) - -test('quoted span describing the phrase does not self-fire', async () => { - // The hook strips quoted spans, so describing what it detects (in - // double quotes) is not itself a blame. - const result = await runHook([ - { - type: 'assistant', - content: - 'The hook fires on phrases like "the user reverted" — I avoided those.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stdout, '') -}) diff --git a/.claude/hooks/dont-blame-user-reminder/tsconfig.json b/.claude/hooks/dont-blame-user-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/dont-blame-user-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/README.md b/.claude/hooks/dont-stop-mid-queue-reminder/README.md deleted file mode 100644 index 1e79c73e2..000000000 --- a/.claude/hooks/dont-stop-mid-queue-reminder/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# dont-stop-mid-queue-reminder - -Stop hook that flags assistant turns announcing "I'm stopping here" / "what's next?" / "honest stopping point" when the user gave a continuous-work directive ("complete each one", "hammer it out", "100%", "do them all") and never authorized a stop. - -## Why - -The failure mode: the assistant finishes ONE item from a queue the user authorized as a batch, posts a status summary listing what's left, and stops — instead of continuing to the next item. The user has to re-issue "keep going" every time. That re-litigates intent the user already gave and burns the user's time on coordination instead of work. - -## What it catches - -Stopping-announcement phrases in the last assistant turn: - -- "stopping here" / "I'll stop here" / "I'm stopping" -- "honest stopping point" / "natural stopping point" / "clean stopping point" / "good stopping point" -- "pausing here" / "I'm pausing" -- "want me to continue?" / "should I keep going?" / "shall I continue?" -- "what's next?" -- "pick a/the next item/task/one" -- "stop for this session" / "stopping for this session" -- "session totals" / "final session state" / "session summary" -- "remaining queue:" followed by a bulleted list - -Code fences are stripped before matching — `// stopping here` inside a code block does not fire. - -## Short-circuit: user-authorized stops - -If any of the 3 most recent user turns contains an explicit stop signal — "stop", "pause", "hold", "halt", "wait", "we're done", "that's enough", "enough for now/today", "let's stop", "let's pause" — the hook exits 0. In those cases the assistant is just acknowledging. - -## What it does NOT catch - -- Genuine blockers ("the build needs to run for 2 hours") — those announce a wait, not a stop. -- Final turns of a single-item request (no queue → nothing to mid-queue-stop). -- The assistant deciding mid-task that it needs user input ("which option do you prefer?") — that's a clarification, not a stop. - -## Bypass - -- `SOCKET_DONT_STOP_MID_QUEUE_REMINDER_DISABLED=1` — turn off entirely. - -This hook is a soft reminder (exit 0 with stderr message), not a blocker (exit 2). The Stop event runs _after_ the turn is over; blocking would be too late to be useful. Instead, the next assistant turn sees the reminder in its context. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/index.mts b/.claude/hooks/dont-stop-mid-queue-reminder/index.mts deleted file mode 100644 index cfe8fc34c..000000000 --- a/.claude/hooks/dont-stop-mid-queue-reminder/index.mts +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — dont-stop-mid-queue-reminder. -// -// Flags assistant text that announces stopping or end-of-session when -// the conversation has a non-empty queue of remaining work. Catches -// the failure mode where the assistant finishes ONE item, summarizes -// what's left, and stops — instead of continuing through the queue -// the user already authorized. -// -// What this hook catches (regex on code-fence-stripped text): -// -// - "Stopping here" / "I'll stop here" -// - "Honest stopping point" / "natural stopping point" -// - "Pausing here" / "I'm pausing" -// - "Session is at a clean stopping point" -// - "Want me to continue?" / "Should I keep going?" -// - "What's next?" / "Pick a [next/specific] [item/one]" -// - "Stopping for this session" / "stop for this session" -// - "Final session state" / "Session totals" -// - "Remaining queue:" followed by a non-empty list -// -// Exception: if the user explicitly said "stop" / "pause" / "we're -// done" in a recent message, the assistant is just acknowledging. -// The hook reads recent user turns and skips if any contains those -// signals. -// -// Disable via SOCKET_DONT_STOP_MID_QUEUE_REMINDER_DISABLED. - -import process from 'node:process' - -import { - readLastAssistantText, - readStdin, - readUserText, - stripCodeFences, -} from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -const STOP_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ - { - label: 'stopping here / stop here', - regex: /\b(stopping here|i'?ll\s+stop\s+here|i'?m\s+stopping)\b/i, - }, - { - label: 'honest/natural/clean stopping point', - regex: /\b(clean|good|honest|natural)\s+stopping\s+point\b/i, - }, - { - label: 'pausing here', - regex: /\b(pausing\s+here|i'?m\s+pausing)\b/i, - }, - { - label: 'holding here / holding for / holding off', - // "Holding here." / "Holding for next direction." / "Holding pending - // your call." — the queue equivalent of "I'll wait for you to say - // what's next." Pick the next item instead. - regex: - /\b(holding\s+(for|here|off|pending|until)|i'?m\s+holding|i'?ll\s+hold|will\s+hold)\b/i, - }, - { - label: 'waiting for direction / next direction', - regex: - /\b(waiting\s+(for|on)\s+(next|the|your)\s+(call|decision|direction|go-ahead|input|signal|word)|wait(ing)?\s+for\s+(you|your)\s+to\s+(choose|decide|direct|pick|say|tell))\b/i, - }, - { - label: 'ready when you (are) / let me know when', - regex: - /\b(ready\s+when\s+you('re|\s+are)|let\s+me\s+know\s+when|standing\s+by)\b/i, - }, - { - label: 'want me to continue / should I keep going', - regex: - /\b(want\s+me\s+to\s+continue|should\s+i\s+keep\s+going|shall\s+i\s+continue)\??/i, - }, - { - label: "what's next?", - regex: /\bwhat'?s\s+next\??/i, - }, - { - label: 'pick a/the next item', - regex: - /\bpick\s+(a|one|specific|the|which)\b[^.?!\n]{0,30}(item|one|task)/i, - }, - { - label: 'want me to pick / take them in order', - regex: - /\b(want\s+me\s+to\s+pick|take\s+(them|these|those)\s+in\s+order|which\s+(item|one|task)\s+(first|next)|should\s+i\s+start\s+with)\b/i, - }, - { - label: 'pick one and continue / one or in order menu', - regex: /\bpick\s+(a|one|the)\s+and\s+continue\b/i, - }, - { - label: 'or take them in order', - regex: /\bor\s+take\s+(all|them|these)\s+in\s+order\??/i, - }, - { - label: 'stop(ping) for this session', - regex: - /\b(stop(ping)?|stopping\s+work)\s+(for\s+(the|this)|in\s+this)\s+session\b/i, - }, - { - label: 'session totals / final session state', - regex: /\b(session\s+totals|final\s+session\s+state|session\s+summary)\b/i, - }, - { - label: 'remaining queue / open queue (followed by a list)', - regex: /\b(open|remaining)\s+queue\b[^.?!\n]{0,30}:\s*\n?\s*[-*•]/i, - }, - { - label: 'turn ends with menu question after listing pending items', - // Heuristic: the turn contains a bulleted list under a header like - // "pending", "remaining", "left", "still pending" (signals an - // open queue), AND the turn's LAST non-empty line is a question. - // The most common failure: enumerate what's left, then ask the - // user which one to pick instead of just picking the next item. - regex: - /\b(still\s+pending|what'?s\s+left|remaining|still\s+to\s+do|outstanding|pending:)\b[\s\S]{0,800}\?\s*$/im, - }, -] - -// Signals from the user that genuinely authorize stopping. If any -// recent user turn matches, the hook short-circuits. -const USER_STOP_AUTHORIZATION_RE = - /\b(stop|pause|hold|halt|wait|we'?re\s+done|that'?s\s+enough|enough\s+for\s+(now|today)|let'?s\s+stop|let'?s\s+pause)\b/i - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_DONT_STOP_MID_QUEUE_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const rawText = readLastAssistantText(payload.transcript_path) - if (!rawText) { - process.exit(0) - } - const text = stripCodeFences(rawText) - - // Check if any STOP pattern fires. - const hits: Array<{ label: string; snippet: string }> = [] - for (let i = 0, { length } = STOP_PATTERNS; i < length; i += 1) { - const pattern = STOP_PATTERNS[i]! - const match = pattern.regex.exec(text) - if (!match) { - continue - } - const start = Math.max(0, match.index - 20) - const end = Math.min(text.length, match.index + match[0].length + 40) - hits.push({ - label: pattern.label, - snippet: text.slice(start, end).replace(/\s+/g, ' ').trim(), - }) - } - if (hits.length === 0) { - process.exit(0) - } - - // Check if the user authorized stopping. Look at the 3 most recent - // user turns — if any contains a stop signal, the assistant is - // just acknowledging. - const recentUserText = readUserText(payload.transcript_path, 3) - if (USER_STOP_AUTHORIZATION_RE.test(recentUserText)) { - process.exit(0) - } - - const lines = [ - '[dont-stop-mid-queue-reminder] Assistant turn announces stopping or asks a menu question without user authorization:', - '', - ] - for (let i = 0, { length } = hits; i < length; i += 1) { - const hit = hits[i]! - lines.push(` • "${hit.label}" — …${hit.snippet}…`) - } - lines.push('') - lines.push( - ' ⚠ Action for the NEXT turn: do NOT wait for the user to answer.', - ) - lines.push(' Identify the next item in the queue (or, if the queue is') - lines.push( - ' unclear, pick the highest-value remaining item and SAY which', - ) - lines.push(" one you're picking), then START WORK on it immediately.") - lines.push('') - lines.push( - ' Why: the user gave you a queue ("complete each one," "keep going,"', - ) - lines.push( - ' "do them all," "100%," "hammer it out") and asking "what\'s next?"', - ) - lines.push( - ' / "pick one or in order?" re-litigates intent already given. Pick', - ) - lines.push(' and execute; the user can redirect mid-turn if needed.') - lines.push('') - lines.push( - ' Legitimate stops: the user said "stop," "pause," "we\'re done,"', - ) - lines.push( - ' "enough for now," or similar. Or you hit a genuine blocker (off-', - ) - lines.push( - ' machine action needed, build cycle measured in hours, etc.) and', - ) - lines.push(' named it concretely.') - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/package.json b/.claude/hooks/dont-stop-mid-queue-reminder/package.json deleted file mode 100644 index fc49c8023..000000000 --- a/.claude/hooks/dont-stop-mid-queue-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-dont-stop-mid-queue-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts b/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts deleted file mode 100644 index 1bf1051e4..000000000 --- a/.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts +++ /dev/null @@ -1,359 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface Turn { - readonly role: 'user' | 'assistant' - readonly text: string -} - -function makeTranscript(turns: readonly Turn[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'stopguard-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines: string[] = [] - for (let i = 0, { length } = turns; i < length; i += 1) { - const t = turns[i]! - lines.push(JSON.stringify({ role: t.role, content: t.text })) - } - writeFileSync(transcriptPath, lines.join('\n')) - return transcriptPath -} - -function runHook( - transcriptPath: string, - extraEnv: Record = {}, -): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - env: { ...process.env, ...extraEnv }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS "stopping here" without user authorization', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'complete each one in the queue' }, - { role: 'assistant', text: 'Done with item 1. Stopping here for now.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /dont-stop-mid-queue-reminder/) - assert.match(stderr, /stopping here/i) -}) - -test('FLAGS "honest stopping point"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'hammer through the list' }, - { - role: 'assistant', - text: 'Item 3 complete. This feels like an honest stopping point.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /honest.*stopping point/i) -}) - -test('FLAGS "what\'s next?"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'work the queue' }, - { role: 'assistant', text: "Done with task A. What's next?" }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /what's next/i) -}) - -test('FLAGS "want me to continue?"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'do them all' }, - { - role: 'assistant', - text: 'Hook built. Want me to continue with the next one?', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /want me to continue/i) -}) - -test('FLAGS "pick a next item"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'go through the list' }, - { - role: 'assistant', - text: "I've finished hook 5. Pick the next item from the queue.", - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /pick.*next.*item/i) -}) - -test('FLAGS "Want me to pick one and continue, or take them in order?"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'what is left' }, - { - role: 'assistant', - text: 'Three items left:\n- A\n- B\n- C\n\nWant me to pick one and continue, or take them in order?', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /pick.*continue|take.*order/i) -}) - -test('FLAGS "which one first?" menu', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'fix the bugs' }, - { - role: 'assistant', - text: 'Three bugs found. Which one first?', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /which.*first/i) -}) - -test('FLAGS "should I start with X?"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'do them all' }, - { - role: 'assistant', - text: 'A list of items: foo, bar, baz. Should I start with foo?', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /should.*start/i) -}) - -test('FLAGS pending-list followed by trailing question', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'hammer it out' }, - { - role: 'assistant', - text: 'Still pending:\n- item A\n- item B\n- item C\n\nWhere should I focus?', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /menu question|still pending/i) -}) - -test('FLAGS "session totals"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: '100%, all of them' }, - { - role: 'assistant', - text: 'Session totals: 7 hooks landed, 3 remaining. Done for now.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /session totals/i) -}) - -test('FLAGS "remaining queue:" with bulleted list', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'complete each' }, - { - role: 'assistant', - text: 'Hook 7 done.\n\nRemaining queue:\n- hook 8\n- hook 9\n- hook 10', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /remaining queue/i) -}) - -test('FLAGS "Holding here." trailing turn', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'continue through the queue' }, - { - role: 'assistant', - text: 'Hook 4 landed and tests pass. Holding here.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /holding/i) -}) - -test('FLAGS "Holding for next direction."', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'work the list' }, - { - role: 'assistant', - text: 'Item complete. Holding for next direction.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /holding/i) -}) - -test('FLAGS "I\'ll hold pending your call"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'do them all' }, - { - role: 'assistant', - text: "Tier 2 work complete. I'll hold pending your call on which one is next.", - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /holding|hold/i) -}) - -test('FLAGS "Waiting for your direction"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'hammer through' }, - { - role: 'assistant', - text: 'Done with patch 17. Waiting for your direction on the next item.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /waiting.*direction/i) -}) - -test('FLAGS "Ready when you are."', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'complete each one' }, - { - role: 'assistant', - text: 'All three hooks landed. Ready when you are.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /ready when you/i) -}) - -test('FLAGS "standing by"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: '100% complete' }, - { - role: 'assistant', - text: 'Phase landed. Standing by.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.match(stderr, /standing by/i) -}) - -test('does NOT fire on technical "holding the lock"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'explain how this mutex works' }, - { - role: 'assistant', - text: 'The worker is holding the lock during write, releasing on drop.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS when user said "stop" in recent turn', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: "okay let's stop here for today" }, - { role: 'assistant', text: 'Stopping here. Session summary follows.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS when user said "pause"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'pause for a sec, I need to check something' }, - { role: 'assistant', text: 'Sure, pausing here.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS when user said "we\'re done"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: "okay we're done for today" }, - { role: 'assistant', text: 'Got it. Final session state below.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS when user said "enough for now"', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: "that's enough for now" }, - { role: 'assistant', text: 'Understood. Stopping here.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire on innocuous text', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'list the files' }, - { - role: 'assistant', - text: 'Here are the files in the directory: a.ts, b.ts.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ignores stopping phrases INSIDE code fences', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'help me' }, - { - role: 'assistant', - text: 'Here is the docs:\n```\n// Stopping here is the natural stopping point.\n```\nDone.', - }, - ]) - const { stderr, exitCode } = runHook(transcriptPath) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const transcriptPath = makeTranscript([ - { role: 'user', text: 'complete each one' }, - { role: 'assistant', text: 'Item 1 done. Stopping here.' }, - ]) - const { stderr, exitCode } = runHook(transcriptPath, { - SOCKET_DONT_STOP_MID_QUEUE_REMINDER_DISABLED: '1', - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does not crash on missing transcript_path', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({}), - }) - assert.equal(result.status, 0) -}) - -test('does not crash on malformed payload', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: 'not-json', - }) - assert.equal(result.status, 0) -}) diff --git a/.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json b/.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/drift-check-reminder/README.md b/.claude/hooks/drift-check-reminder/README.md deleted file mode 100644 index 31e6c17a9..000000000 --- a/.claude/hooks/drift-check-reminder/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# drift-check-reminder - -Stop hook that nudges when an assistant turn edits a fleet-canonical surface (CLAUDE.md, hooks/, external-tools.json, .github/actions/, lockstep.json, cache-versions.json, .gitmodules) without mentioning a cascade / drift check / sync. - -## Why - -Fleet repos drift fast when one repo bumps a shared resource and the others aren't updated. CLAUDE.md's "Drift watch" rule requires: edit in repo A, reconcile in repos B/C/D in the same PR or open a `chore(wheelhouse): cascade …` follow-up. - -## What it catches - -Assistant turn that: - -1. Mentions a drift surface — `external-tools.json`, `template/CLAUDE.md`, `template/.claude/hooks/`, `.github/actions/`, `lockstep.json`, `setup-and-install`, `cache-versions.json`, `.gitmodules`. -2. AND uses an edit verb (`updated`, `edited`, `bumped`, `added`, `removed`, `landed`, etc.). -3. AND does NOT mention `cascade` / `sync` / `drift` / `fleet` / `other repos` / `downstream` / `chore(wheelhouse)` / `re-cascade`. - -## Bypass - -- `SOCKET_DRIFT_CHECK_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/drift-check-reminder/index.mts b/.claude/hooks/drift-check-reminder/index.mts deleted file mode 100644 index 9c5189527..000000000 --- a/.claude/hooks/drift-check-reminder/index.mts +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — drift-check-reminder. -// -// Flags assistant turns that edited a fleet-canonical surface in ONE -// repo without mentioning a drift check / cascade to the other fleet -// repos. The fleet's "Drift watch" rule says: when you bump a shared -// resource (tool SHA, action SHA, CLAUDE.md fleet block, hook code), -// either reconcile in the same PR or open a `chore(wheelhouse): cascade …` -// follow-up. -// -// What this hook catches: -// -// Assistant turn mentions edits to a known drift surface — e.g. -// `external-tools.json`, `template/CLAUDE.md`, `template/.claude/ -// hooks/`, `.github/actions/`, `lockstep.json`, `.gitmodules` — -// AND does NOT mention "cascade" / "sync" / "fleet" / "drift" / -// "other repos" in the same turn. -// -// Heuristic; false positives expected. Soft reminder. -// -// Disable via SOCKET_DRIFT_CHECK_REMINDER_DISABLED. - -import process from 'node:process' - -import { - readLastAssistantText, - readStdin, - stripCodeFences, -} from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -// Drift-prone surfaces (fleet-canonical). Mention of any of these -// triggers the check. We avoid `\b` boundaries because some surfaces -// (e.g. `.gitmodules`) start with `.` and `\b` between two non-word -// chars never matches. Instead we look for a non-word boundary OR -// start-of-string before, and non-word OR end-of-string after. -const DRIFT_SURFACE_RE = - /(^|\W)(external-tools\.json|template\/CLAUDE\.md|template\/\.claude\/hooks\/|\.github\/actions\/|lockstep\.json|\.gitmodules|setup-and-install|cache-versions\.json)(?=$|\W)/ - -// Cascade-acknowledgement phrases. Any of these in the same turn -// satisfies the check. -const CASCADE_ACK_RE = - /\b(cascade|sync-scaffolding|drift|fleet|other repos?|downstream|chore\(wheelhouse\)|re-cascade|recascade|wheelhouse)\b/i - -// We want this to fire only when an EDIT actually happened, not just -// a passing mention. The simplest proxy: look for verbs that imply -// "I just changed this" in the assistant turn. -const EDIT_VERB_RE = - /\b(added|bumped|cascaded|changed|committed|edited|landed|modified|removed|updated)\b/i - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_DRIFT_CHECK_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const rawText = readLastAssistantText(payload.transcript_path) - if (!rawText) { - process.exit(0) - } - const text = stripCodeFences(rawText) - - const surfaceMatch = DRIFT_SURFACE_RE.exec(text) - if (!surfaceMatch) { - process.exit(0) - } - if (!EDIT_VERB_RE.test(text)) { - process.exit(0) - } - if (CASCADE_ACK_RE.test(text)) { - process.exit(0) - } - - const surfaceName = surfaceMatch[2]! - const surfaceIdx = surfaceMatch.index + (surfaceMatch[1]?.length ?? 0) - const start = Math.max(0, surfaceIdx - 30) - const end = Math.min(text.length, surfaceIdx + surfaceName.length + 30) - const snippet = text.slice(start, end).replace(/\s+/g, ' ').trim() - - const lines = [ - '[drift-check-reminder] Edited a fleet-canonical surface without mentioning cascade/sync:', - '', - ` • surface: "${surfaceName}" — …${snippet}…`, - '', - ' Per CLAUDE.md "Drift watch": when you edit one of these in repo A,', - ' either reconcile the other fleet repos in the same PR or open a', - ' `chore(wheelhouse): cascade from ` follow-up.', - '', - ' Drift surfaces include: external-tools.json, template/CLAUDE.md,', - ' template/.claude/hooks/, .github/actions/, lockstep.json,', - ' cache-versions.json, .gitmodules.', - '', - ] - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/drift-check-reminder/package.json b/.claude/hooks/drift-check-reminder/package.json deleted file mode 100644 index 7b279099d..000000000 --- a/.claude/hooks/drift-check-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-drift-check-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/drift-check-reminder/test/index.test.mts b/.claude/hooks/drift-check-reminder/test/index.test.mts deleted file mode 100644 index d350bef5b..000000000 --- a/.claude/hooks/drift-check-reminder/test/index.test.mts +++ /dev/null @@ -1,98 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'drift-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: 'bump it' }) + - '\n' + - JSON.stringify({ role: 'assistant', content: assistantText }), - ) - return transcriptPath -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS edited external-tools.json without cascade mention', () => { - const t = makeTranscript('Updated external-tools.json to bump zizmor.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /drift-check-reminder/) - assert.match(stderr, /external-tools\.json/) -}) - -test('FLAGS edited template/CLAUDE.md without cascade mention', () => { - const t = makeTranscript('Added a new rule to template/CLAUDE.md.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /template\/CLAUDE\.md/) -}) - -test('does NOT fire when cascade is mentioned', () => { - const t = makeTranscript( - 'Updated external-tools.json. Cascade to other fleet repos will follow.', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire when "sync" / "fleet" appears', () => { - const t = makeTranscript('Bumped external-tools.json — sync to fleet next.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire when surface is mentioned in passing (no edit verb)', () => { - const t = makeTranscript('See external-tools.json for the current SHA pins.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('FLAGS lockstep.json edit', () => { - const t = makeTranscript('Modified lockstep.json to add a new row.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /lockstep\.json/) -}) - -test('FLAGS .gitmodules edit', () => { - const t = makeTranscript('Added a submodule entry to .gitmodules.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /gitmodules/) -}) - -test('does NOT fire on non-drift edits', () => { - const t = makeTranscript('Updated src/foo.ts to fix the off-by-one bug.') - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const t = makeTranscript('Bumped external-tools.json.') - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: t }), - env: { ...process.env, SOCKET_DRIFT_CHECK_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') -}) diff --git a/.claude/hooks/drift-check-reminder/tsconfig.json b/.claude/hooks/drift-check-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/drift-check-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/enterprise-push-property-reminder/README.md b/.claude/hooks/enterprise-push-property-reminder/README.md deleted file mode 100644 index c841f95f7..000000000 --- a/.claude/hooks/enterprise-push-property-reminder/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# enterprise-push-property-reminder - -A **Claude Code PostToolUse hook** that fires after a `git push` rejected by the Socket enterprise ruleset, and surfaces the canonical bypass: the repo's `temporarily-doesnt-touch-customers` custom property. - -## Why this exists - -Some SocketDev repos sit under an enterprise-level GitHub ruleset on `refs/heads/main` that rejects direct pushes with: - -``` -remote: - Required workflow '' is not satisfied -remote: - Changes must be made through a pull request. -``` - -These rules sit ABOVE per-repo admin. The fleet escape hatch — the wheelhouse-canonical mechanism — is the per-repo custom property `temporarily-doesnt-touch-customers === "true"`. When set, `canSkipReviewGate()` in `socket-wheelhouse/scripts/_shared/repo-properties.mts` allows direct push for routine cascade work. - -The hook makes this discoverable. Without it, the rejection error leaves the operator (or the next assistant turn) guessing which of "open a PR / `gh pr merge --admin` / disable the ruleset / something else" is right. The property is the actual answer for routine work. - -## What it does - -1. PostToolUse on every `Bash` call. -2. Filters to commands matching `\bgit\s+push\b`. -3. Inspects `tool_response` for the enterprise-ruleset rejection pattern (both `Repository rule violations found` AND `Changes must be made through a pull request` must be present — single-match would false-fire on generic push errors). -4. On match: writes a stderr reminder to Claude with: - - The property name + required literal-string value (`"true"`) - - The current property value (queried via `gh api repos/{owner}/{repo}/properties/values`) - - A link to the repo's properties page in the GitHub UI - - A pointer to `docs/claude.md/fleet/push-policy.md` for full rationale - -The hook **does not** modify the property or retry the push. The operator decides whether the bypass is appropriate for the current change set. - -## Exit semantics - -- Exit 0 with stderr message on match (informational, doesn't block). -- Exit 0 silent on any non-match path (wrong tool, wrong command, no ruleset error). -- Exit 0 silent on any internal error (fail-open — a bad hook deploy can't suppress legitimate push errors). - -## When NOT to expect a reminder - -- The push succeeded. -- The push failed for a non-ruleset reason (auth, conflict, signature mismatch). -- The push wasn't actually `git push` (e.g. `gh push` or `git-lfs push`). -- The repo isn't under the Socket enterprise ruleset. - -The pattern requires both error lines for a tight match — generic "permission denied" or "branch protection" failures don't trip it. - -## See also - -- `docs/claude.md/fleet/push-policy.md` — full rationale + operator flow. -- `scripts/_shared/repo-properties.mts` — `canSkipReviewGate()` implementation used by the cascade. -- `.claude/hooks/pr-vs-push-default-reminder/` — sibling hook for the reverse case (Claude opening a PR when direct push would have worked). diff --git a/.claude/hooks/enterprise-push-property-reminder/index.mts b/.claude/hooks/enterprise-push-property-reminder/index.mts deleted file mode 100644 index fe3a40ce7..000000000 --- a/.claude/hooks/enterprise-push-property-reminder/index.mts +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — enterprise-push-property-reminder. -// -// After a Bash `git push` fails with the enterprise-ruleset error -// pattern, surface the canonical bypass: the repo's -// `temporarily-doesnt-touch-customers` custom property. -// -// Fleet context: some SocketDev repos sit under a Socket-enterprise -// ruleset on refs/heads/main that requires PRs + a specific Audit -// workflow. The escape hatch (per cascade convention in -// `socket-wheelhouse/scripts/_shared/repo-properties.mts`) is the -// per-repo custom property `temporarily-doesnt-touch-customers === -// 'true'`. When set, `canSkipReviewGate()` returns true and direct -// push is allowed. -// -// This hook detects: -// 1. Bash tool calls -// 2. Containing `git push` (or `git push --no-verify`, etc.) -// 3. Whose output contains the enterprise ruleset rejection pattern -// -// On match, it writes a stderr reminder to Claude with: -// - The property name + required value (`"true"` literal string) -// - The current value of that property (via `gh api`) -// - A link to docs/claude.md/fleet/push-policy.md -// -// The hook does NOT modify the property or retry the push — the -// operator decides whether the bypass is appropriate. -// -// PostToolUse, not PreToolUse: we react to the rejection, we don't -// try to predict it. Server-side rulesets are the ground truth. -// -// Fail-open on hook bugs: exit 0 + silent log so a bad deploy -// can't suppress legitimate push errors. - -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { findInvocation } from '../_shared/shell-command.mts' - -interface Payload { - readonly hook_event_name?: string | undefined - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly tool_response?: unknown | undefined -} - -// Patterns that identify the enterprise-ruleset rejection. Both must -// be present in the push output to fire — we don't want false -// positives from generic push failures (auth, conflict, etc.). -const RULESET_ERROR_PATTERNS: readonly RegExp[] = [ - /Repository rule violations found/, - /Changes must be made through a pull request/, -] - -// Detects `git push` invocations via the shell parser (sees through -// chains / `$(…)`; ignores a quoted "git push" in a message). The hook -// scopes to push commands only — pulls/fetches/commits don't trip the -// enterprise ruleset. -function isGitPush(command: string): boolean { - return findInvocation(command, { binary: 'git', subcommand: 'push' }) -} - -// Read the tool_response into a string for pattern matching. Bash's -// tool_response shape is typically `{ stdout: string, stderr: string, -// interrupted: boolean, isImage: boolean }` but harness variants may -// pass it as a bare string. Walk both shapes. -export function extractOutput(value: unknown): string { - if (typeof value === 'string') { - return value - } - if (value !== null && typeof value === 'object') { - const obj = value as Record - const parts: string[] = [] - for (const key of ['stdout', 'stderr', 'output', 'content']) { - const v = obj[key] - if (typeof v === 'string') { - parts.push(v) - } - } - return parts.join('\n') - } - return '' -} - -export function isEnterpriseRulesetFailure(output: string): boolean { - for (let i = 0, { length } = RULESET_ERROR_PATTERNS; i < length; i += 1) { - if (!RULESET_ERROR_PATTERNS[i]!.test(output)) { - return false - } - } - return true -} - -// Read `owner/repo` from the current `git remote get-url origin` -// output. Returns undefined if the URL isn't a recognized -// SSH/HTTPS GitHub shape — the hook just won't surface the -// per-repo property state in that case. -export function getCurrentRepoSlug(): string | undefined { - const r = spawnSync('git', ['remote', 'get-url', 'origin'], { - encoding: 'utf8', - timeout: 2_000, - }) - if (r.status !== 0 || typeof r.stdout !== 'string') { - return undefined - } - const url = r.stdout.trim() - // SSH form: git@github.com:owner/repo.git - // HTTPS form: https://github.com/owner/repo(.git)? - const sshMatch = /git@github\.com:([^/]+)\/([^/.]+)/.exec(url) - if (sshMatch) { - return `${sshMatch[1]}/${sshMatch[2]}` - } - const httpsMatch = /github\.com\/([^/]+)\/([^/.]+)/.exec(url) - if (httpsMatch) { - return `${httpsMatch[1]}/${httpsMatch[2]}` - } - return undefined -} - -// Query the current state of the `temporarily-doesnt-touch-customers` -// property via `gh api`. Returns the value string or undefined on -// any failure (no auth, API blocked by firewall, property not set, -// etc.). The reminder treats undefined as "unknown, instruct the -// operator to set it explicitly". -export function getPropertyValue( - slug: string, - propertyName: string, -): string | undefined { - const r = spawnSync( - 'gh', - [ - 'api', - `repos/${slug}/properties/values`, - '--jq', - `.[] | select(.property_name == "${propertyName}") | .value`, - ], - { - encoding: 'utf8', - timeout: 5_000, - }, - ) - if (r.status !== 0) { - return undefined - } - const value = String(r.stdout ?? '').trim() - return value.length > 0 ? value : undefined -} - -export function formatReminder( - slug: string | undefined, - currentValue: string | undefined, -): string { - const lines: string[] = [] - lines.push('') - lines.push('🚨 enterprise-push-property-reminder') - lines.push('') - lines.push('The `git push` was rejected by the Socket enterprise ruleset on') - lines.push('refs/heads/main:') - lines.push('') - lines.push(' - Required workflow ... is not satisfied') - lines.push(' - Changes must be made through a pull request') - lines.push('') - lines.push('Canonical bypass for routine cascade work: set the repo') - lines.push( - '`temporarily-doesnt-touch-customers` custom property to the LITERAL', - ) - lines.push('string `"true"` (not `true`, not `True`).') - if (slug) { - lines.push('') - lines.push(`Repo: ${slug}`) - if (currentValue === undefined) { - lines.push(' current value: ') - } else { - lines.push(` current value: "${currentValue}"`) - } - lines.push(` GitHub UI: https://github.com/${slug}/settings/properties`) - } - lines.push('') - lines.push('After flipping the property:') - lines.push(' git push origin main') - lines.push('') - lines.push( - 'After the in-flight remediation window closes, flip it back to "false"', - ) - lines.push('(re-engages the ruleset).') - lines.push('') - lines.push( - 'Full rationale: docs/claude.md/fleet/push-policy.md (Enterprise-ruleset', - ) - lines.push('escape hatch section).') - lines.push('') - return lines.join('\n') -} - -async function readStdin(): Promise { - let raw = '' - for await (const chunk of process.stdin) { - raw += chunk - } - return raw -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: Payload - try { - payload = JSON.parse(raw) as Payload - } catch { - process.exit(0) - } - if (payload.hook_event_name !== 'PostToolUse') { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command - if (typeof command !== 'string' || !isGitPush(command)) { - process.exit(0) - } - const output = extractOutput(payload.tool_response) - if (!isEnterpriseRulesetFailure(output)) { - process.exit(0) - } - const slug = getCurrentRepoSlug() - const currentValue = slug - ? getPropertyValue(slug, 'temporarily-doesnt-touch-customers') - : undefined - process.stderr.write(formatReminder(slug, currentValue)) - // Exit 0 — informational only. The push already failed; we're - // just adding context for the next assistant turn. - process.exit(0) -} - -main().catch(() => { - // Fail-open. - process.exit(0) -}) diff --git a/.claude/hooks/enterprise-push-property-reminder/package.json b/.claude/hooks/enterprise-push-property-reminder/package.json deleted file mode 100644 index 61ae449e9..000000000 --- a/.claude/hooks/enterprise-push-property-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-enterprise-push-property-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts b/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts deleted file mode 100644 index 5fb366806..000000000 --- a/.claude/hooks/enterprise-push-property-reminder/test/index.test.mts +++ /dev/null @@ -1,164 +0,0 @@ -// node --test specs for the enterprise-push-property-reminder hook. - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -interface Result { - code: number - stderr: string -} - -async function runHook(payload: Record): Promise { - return new Promise(resolve => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - child.stdin.end(JSON.stringify(payload)) - }) -} - -const ENTERPRISE_ERROR_OUTPUT = [ - 'remote: error: GH013: Repository rule violations found for refs/heads/main.', - 'remote: Review all repository rules at https://github.com/.../rules?ref=refs%2Fheads%2Fmain', - 'remote: ', - "remote: - Required workflow 'Audit GHA Workflows, Audit GHA Workflows' is not satisfied", - 'remote: ', - 'remote: - Changes must be made through a pull request.', - 'To github.com:SocketDev/socket-btm.git', - ' ! [remote rejected] main -> main (push declined due to repository rule violations)', - 'error: failed to push some refs to ...', -].join('\n') - -test('non-Bash tool passes silently', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Edit', - tool_input: { file_path: '/tmp/foo.ts' }, - tool_response: 'whatever', - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('Bash non-git-push command passes silently', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls -la' }, - tool_response: ENTERPRISE_ERROR_OUTPUT, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git push WITHOUT enterprise error passes silently', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push origin main' }, - tool_response: 'Everything up-to-date', - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git push WITH enterprise error fires reminder', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push origin main' }, - tool_response: ENTERPRISE_ERROR_OUTPUT, - }) - assert.equal(r.code, 0) - assert.match(r.stderr, /enterprise-push-property-reminder/) - assert.match(r.stderr, /temporarily-doesnt-touch-customers/) - assert.match(r.stderr, /"true"/) -}) - -test('git push WITH --no-verify + enterprise error still fires', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push --no-verify origin main' }, - tool_response: ENTERPRISE_ERROR_OUTPUT, - }) - assert.equal(r.code, 0) - assert.match(r.stderr, /enterprise-push-property-reminder/) -}) - -test('tool_response shaped as object with stderr field is read', async () => { - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push origin main' }, - tool_response: { - stdout: '', - stderr: ENTERPRISE_ERROR_OUTPUT, - interrupted: false, - }, - }) - assert.equal(r.code, 0) - assert.match(r.stderr, /enterprise-push-property-reminder/) -}) - -test('partial error pattern (one line only) does NOT fire', async () => { - // Only "Repository rule violations" — missing "must be made through a PR" - const r = await runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push origin main' }, - tool_response: 'remote: error: Repository rule violations found', - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('non-PostToolUse event passes silently', async () => { - const r = await runHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'git push origin main' }, - tool_response: ENTERPRISE_ERROR_OUTPUT, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('malformed JSON input passes silently (fail-open)', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.stdin.end('not valid json') - const code: number = await new Promise(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.equal(code, 0) - assert.equal(stderr, '') -}) - -test('empty stdin passes silently', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.stdin.end('') - const code: number = await new Promise(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.equal(code, 0) - assert.equal(stderr, '') -}) diff --git a/.claude/hooks/enterprise-push-property-reminder/tsconfig.json b/.claude/hooks/enterprise-push-property-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/enterprise-push-property-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/error-message-quality-reminder/README.md b/.claude/hooks/error-message-quality-reminder/README.md deleted file mode 100644 index 9e73dfe4b..000000000 --- a/.claude/hooks/error-message-quality-reminder/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# error-message-quality-reminder - -Stop hook that inspects code blocks the assistant wrote for low-quality error message strings — `throw new Error("invalid")`, `throw new RangeError("failed")`, etc. - -## Why - -CLAUDE.md "Error messages": - -> An error message is UI. The reader should fix the problem from the message alone. Four ingredients in order: -> -> 1. **What** — the rule, not the fallout (`must be lowercase`, not `invalid`) -> 2. **Where** — exact file/line/key/field/flag -> 3. **Saw vs. wanted** — bad value and the allowed shape/set -> 4. **Fix** — one imperative action (`rename the key to …`) - -This hook catches the trivial-vague case: a `throw new Error(...)` whose entire message is a single vague word or short phrase with no field, no value, no rule. - -## What it catches - -| Pattern | Example | Hint | -| ----------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- | -| Bare `invalid` | `throw new Error("invalid")` | "Invalid" is the fallout. State the rule: "must be lowercase", "must match /^[a-z]+$/". | -| Bare `failed` | `throw new Error("failed")` | Name what was attempted: "could not write \: ENOENT". | -| Bare `error occurred` | `throw new Error("an error occurred")` | Says nothing actionable. State rule, location, bad value. | -| `something went wrong` | `throw new Error("something went wrong")` | Pure filler. | -| `unable to X` / `could not X` | `throw new Error("unable to read")` | Add object + reason: "could not read \: \". | -| `not found` | `throw new Error("not found")` | Missing what? Where? "config file not found: \". | -| `bad` / `wrong` / `incorrect` | `throw new Error("bad value")` | Describe the rule the value violated, not how you feel about it. | - -## What it does NOT catch - -The check is intentionally conservative — only the trivially-vague cases. Skipped: - -- Messages containing `:` (signals a field-path prefix like `"user.email: must be lowercase"`) -- Messages containing quoted values (`"`, `` ` ``) — suggests "saw vs. wanted" content -- Messages longer than 40 chars (likely have the four ingredients spread across the sentence) -- Dynamic templates with `${...}` (the static check can't know the interpolated content) - -Conservative by design: the goal is to flag the cases that are 100% definitely wrong, not to grade every message. The user reads the warning and decides if there are deeper quality issues to address. - -## Why it doesn't block - -Stop hooks fire after the assistant produced the code. The vague-error is already in the diff. The warning prompts the next turn to revise. - -## Configuration - -`SOCKET_ERROR_MESSAGE_QUALITY_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/error-message-quality-reminder/index.mts b/.claude/hooks/error-message-quality-reminder/index.mts deleted file mode 100644 index 110b13473..000000000 --- a/.claude/hooks/error-message-quality-reminder/index.mts +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — error-message-quality-reminder. -// -// Inspects code blocks the assistant wrote for low-quality error -// message strings. CLAUDE.md "Error messages" rule: -// -// An error message is UI. The reader should fix the problem from -// the message alone. Four ingredients in order: -// -// 1. What — the rule, not the fallout -// 2. Where — exact file/line/key/field/flag -// 3. Saw vs. wanted — the bad value and the allowed shape -// 4. Fix — one imperative action -// -// What this hook catches: throw statements where the message string -// is only a vague verb/noun without the "what" rule or a specific -// field. E.g. `throw new Error("invalid")` — no rule, no field, -// no fix. -// -// What this hook DOES NOT catch: high-quality messages that happen -// to contain a flagged word as part of a longer message. The check -// is "is the message ONLY this vague phrase" rather than "does it -// contain this word." -// -// Pattern: extract every `throw new Error("…")` or `throw new -// Error(`…`)` from the assistant's code fences, inspect the -// message string, flag if it's <40 chars AND matches a vague-only -// shape. -// -// Disable via SOCKET_ERROR_MESSAGE_QUALITY_REMINDER_DISABLED. - -import process from 'node:process' - -import { findThrowNew } from '../_shared/acorn/index.mts' -import { - extractCodeFences, - readLastAssistantText, - readStdin, -} from '../_shared/transcript.mts' -import type { CodeFence } from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -// Vague-only error messages — too short to contain "what / where / -// saw vs. wanted / fix" content. Each pattern matches the WHOLE -// message string (anchored), so longer messages containing these -// words but also a field path or rule are not flagged. -// -// The shape is: a verb or adjective + optional generic noun, with -// no colon (a colon usually signals a field-path prefix like -// "user.email: must be lowercase"), no period sentences, no quoted -// values. -const VAGUE_MESSAGE_PATTERNS: ReadonlyArray<{ - label: string - regex: RegExp - hint: string -}> = [ - { - label: 'bare "invalid"', - regex: - /^(?:invalid|invalid value|invalid input|invalid argument|invalid format)\.?$/i, - hint: '"Invalid" describes the fallout, not the rule. Say what shape was expected: "must be lowercase", "must match /^[a-z]+$/", "must be one of X / Y / Z".', - }, - { - label: 'bare "failed"', - regex: - /^(?:failed|failure|operation failed|request failed|action failed)\.?$/i, - hint: '"Failed" describes the symptom. Name what was attempted and what blocked it: "could not write : ENOENT", "fetch returned 503".', - }, - { - label: 'bare "error occurred"', - regex: /^(?:an? )?error(?:\s+occurred)?\.?$/i, - hint: 'The message says nothing the reader can act on. State the rule, the location, the bad value.', - }, - { - label: 'bare "something went wrong"', - regex: /^something went wrong\.?$/i, - hint: 'Pure filler. CLAUDE.md "Error messages": the reader should fix the problem from the message alone.', - }, - { - label: 'bare "unable to X" / "could not X" (verb-only)', - regex: /^(?:unable to|could not|cannot|can'?t)\s+\w+\.?$/i, - hint: 'No object / no reason. "Unable to read" → "could not read : ".', - }, - { - label: 'bare "not found"', - regex: /^(?:not found|not\s+exist|does not exist|missing)\.?$/i, - hint: 'Missing what? Where? Say "config file not found: " with the specific path.', - }, - { - label: 'bare "bad" / "wrong" / "incorrect"', - regex: - /^(?:bad|wrong|incorrect|invalid format)(?:\s+(?:argument|data|format|input|value))?\.?$/i, - hint: 'Same as "invalid" — describe the rule the value violated, not how you feel about it.', - }, -] - -// AST-based detector — walks every `throw new (...)` via the -// shared acorn helper. The previous version had to parse the throw -// shape with a single complex regex that: -// 1. Couldn't handle interpolated template literals (it relied on -// the body containing no quote characters at all). -// 2. Could false-positive on string literals containing the literal -// text `throw new Error("...")`. -// -// AST eliminates both. We accept any constructor matching `/Error$/` -// or the literal `TemporalError`, then inspect the first argument; if -// it's a string Literal, we grade it. - -// Match any Error-suffixed class plus the legacy TemporalError name. -const ERROR_CLASS_RE = /(?:Error|TemporalError)$/ - -interface MessageFinding { - readonly errorClass: string - readonly message: string - readonly label: string - readonly hint: string -} - -export function gradeMessages( - codeBlocks: readonly CodeFence[], -): MessageFinding[] { - const findings: MessageFinding[] = [] - for ( - let bi = 0, { length: blocksLen } = codeBlocks; - bi < blocksLen; - bi += 1 - ) { - const block = codeBlocks[bi]!.body - const throwSites = findThrowNew(block, ERROR_CLASS_RE) - for (let i = 0, { length } = throwSites; i < length; i += 1) { - const site = throwSites[i]! - const message = (site.message ?? '').trim() - if (message.length === 0) { - // Non-string-Literal first arg (template literal with - // interpolation, an identifier, etc.) — out of scope; this - // hook only grades static-string violations. - continue - } - // Skip messages that contain a colon (suggests field-path prefix) - // or a quoted value (suggests "saw vs. wanted" present). - if ( - message.includes(':') || - message.includes('"') || - message.includes('`') - ) { - continue - } - if (message.length > 40) { - continue - } - for ( - let pi = 0, { length: patternsLen } = VAGUE_MESSAGE_PATTERNS; - pi < patternsLen; - pi += 1 - ) { - const pattern = VAGUE_MESSAGE_PATTERNS[pi]! - if (pattern.regex.test(message)) { - findings.push({ - errorClass: site.ctorName, - message, - label: pattern.label, - hint: pattern.hint, - }) - break - } - } - } - } - return findings -} - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_ERROR_MESSAGE_QUALITY_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const text = readLastAssistantText(payload.transcript_path) - if (!text) { - process.exit(0) - } - const codeBlocks = extractCodeFences(text) - if (codeBlocks.length === 0) { - process.exit(0) - } - const findings = gradeMessages(codeBlocks) - if (findings.length === 0) { - process.exit(0) - } - - const lines = [ - '[error-message-quality-reminder] Vague error messages found:', - '', - ] - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - lines.push(` • throw new ${f.errorClass}("${f.message}")`) - lines.push(` Vague: ${f.label}`) - lines.push(` ${f.hint}`) - lines.push('') - } - lines.push( - ' CLAUDE.md "Error messages": (1) What — the rule, not the fallout.', - ) - lines.push( - ' (2) Where — exact file/line/key/field. (3) Saw vs. wanted — bad', - ) - lines.push(' value + allowed shape. (4) Fix — one imperative action. Full') - lines.push(' guidance: docs/claude.md/error-messages.md.') - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/error-message-quality-reminder/package.json b/.claude/hooks/error-message-quality-reminder/package.json deleted file mode 100644 index 5a58a9693..000000000 --- a/.claude/hooks/error-message-quality-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-error-message-quality-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/error-message-quality-reminder/test/index.test.mts b/.claude/hooks/error-message-quality-reminder/test/index.test.mts deleted file mode 100644 index 5b1e9665b..000000000 --- a/.claude/hooks/error-message-quality-reminder/test/index.test.mts +++ /dev/null @@ -1,178 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'errmsg-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags bare "invalid" in code block', () => { - const { path: p, cleanup } = makeTranscript( - 'Here is the change:\n```ts\nthrow new Error("invalid")\n```', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /error-message-quality-reminder/) - assert.match(stderr, /invalid/) - } finally { - cleanup() - } -}) - -test('flags bare "failed"', () => { - const { path: p, cleanup } = makeTranscript( - '```ts\nthrow new TypeError("failed")\n```', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /failed/) - } finally { - cleanup() - } -}) - -test('flags "something went wrong"', () => { - const { path: p, cleanup } = makeTranscript( - '```\nthrow new Error("something went wrong")\n```', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /something went wrong/) - } finally { - cleanup() - } -}) - -test('flags "unable to X" verb-only', () => { - const { path: p, cleanup } = makeTranscript( - '```\nthrow new Error("unable to read")\n```', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /unable to/i) - } finally { - cleanup() - } -}) - -test('does NOT flag good messages with field-path prefix', () => { - const { path: p, cleanup } = makeTranscript( - '```ts\nthrow new RangeError("user.email: must be lowercase")\n```', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag good messages with quoted value', () => { - const { path: p, cleanup } = makeTranscript( - '```\nthrow new Error(`config file not found: ${path}`)\n```', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag long messages (>40 chars)', () => { - const { path: p, cleanup } = makeTranscript( - '```\nthrow new Error("the configuration file could not be parsed because of a syntax error")\n```', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag throws in plain prose (not in code fence)', () => { - const { path: p, cleanup } = makeTranscript( - 'I will throw new Error("invalid") if that case happens.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('handles multiple throws in same code block', () => { - const { path: p, cleanup } = makeTranscript( - '```\nif (x) throw new Error("invalid")\nif (y) throw new Error("failed")\n```', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /invalid/) - assert.match(stderr, /failed/) - } finally { - cleanup() - } -}) - -test('handles multiple code blocks', () => { - const { path: p, cleanup } = makeTranscript( - 'First:\n```\nthrow new Error("invalid")\n```\nSecond:\n```\nthrow new TypeError("bad")\n```', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /invalid/) - assert.match(stderr, /bad/) - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript( - '```\nthrow new Error("invalid")\n```', - ) - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { - ...process.env, - SOCKET_ERROR_MESSAGE_QUALITY_REMINDER_DISABLED: '1', - }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/error-message-quality-reminder/tsconfig.json b/.claude/hooks/error-message-quality-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/error-message-quality-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/excuse-detector/README.md b/.claude/hooks/excuse-detector/README.md deleted file mode 100644 index a8bf0a631..000000000 --- a/.claude/hooks/excuse-detector/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# excuse-detector - -Claude Code `Stop` hook that scans the assistant's most recent turn for excuse-shaped phrases and warns to stderr at end-of-turn. - -## Why - -CLAUDE.md has two rules the assistant routinely tries to wriggle out of: - -- **No "pre-existing" excuse** — fix lint/type/test errors you see in your reading window. Don't label them "pre-existing" and walk past. -- **Unrelated issues are critical** — an adjacent bug is exactly the bug nobody is currently looking for. Don't defer. - -The phrases that precede those deferrals are predictable: "pre-existing", "not related to my X", "unrelated to the task", "out of scope", "separate concern", "leave it for later", "not my issue". This hook scans the transcript for them. - -## What it catches - -| Phrase | Why it's flagged | -| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `pre-existing` / `preexisting` | Bare rationalization; CLAUDE.md bans the label. | -| `not related to my ` | Scoping out a fix. CLAUDE.md says fix it. | -| `unrelated to the task` | Same. | -| `out of scope` | Same. The genuine exception (large refactor) requires asking, not silent deferral. | -| `separate concern` | Same. | -| `leave it for later` | Deferral marker. CLAUDE.md "Completion" bans deferrals. | -| `not my issue` / `not my problem` | Scoping out. | -| `which direction do you want` | Fix-vs-defer choice menu. Pick the fix path. | -| `should I implement … or accept` | Same — choice-architecture masquerading as a question. | -| `accept … as (a known) gap/drift/limitation` | Gap-acceptance is the rationalization branch. Fix it. | -| `two/three paths/options: fix … or …` | Collapsing the menu — pick fix, start the first sub-step. | -| `document(ed) as a known gap/drift/limitation` | Deferral euphemism. Fix instead. | -| `want me to fix … or skip/defer/document/treat/leave` | Re-litigating a fix the user already said yes to. | - -**Codewords that override gap-acceptance:** "fix it", "build it", "do it all", "100%", "keep going", "implement X", "make it work". When any appears in a recent user turn, the only legitimate response to a failure is another fix attempt. - -**Legitimate exceptions:** the user introduced the dichotomy themselves, or the fix requires off-machine action (publish, infra, creds). Name the off-machine step concretely; don't frame it as "accept the gap." - -## Why it doesn't block - -Stop hooks fire _after_ the assistant has produced its response. Blocking at that point would just truncate the message — the rationalization is already out. The warning surfaces alongside the response so the user reads both, and can push back in the next turn. - -The right enforcement is layered: - -- **CLAUDE.md rule** documents the policy. -- **This hook** surfaces violations at end-of-turn. -- **The user** demands the fix in the next turn. - -## Configuration - -`SOCKET_EXCUSE_DETECTOR_DISABLED=1` — turn the hook off entirely. Useful for sessions where the policy genuinely doesn't apply (e.g. running a long-form review that intentionally calls out scope boundaries). - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/excuse-detector/index.mts b/.claude/hooks/excuse-detector/index.mts deleted file mode 100644 index 23e1338ed..000000000 --- a/.claude/hooks/excuse-detector/index.mts +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — excuse-detector. -// -// Scans the assistant's most recent turn for excuse-shaped phrases -// that violate CLAUDE.md's "No 'pre-existing' excuse" and "Fix > defer" -// rules. -// -// Runs in BLOCKING mode: when a match is found, the hook emits a -// Stop-hook block decision so Claude must continue the turn and -// address the matched phrase (e.g. fix the "pre-existing" TS errors) -// rather than ending the turn on the excuse. The block is suppressed -// when `stop_hook_active` is set, so this can fire at most once per -// stop chain — Claude is given one forced chance to fix or to state -// the trade-off explicitly. -// -// Disabled via SOCKET_EXCUSE_DETECTOR_DISABLED env var. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -// Deferral-verb fragment shared by every bare-phrase pattern that -// the assistant might quote descriptively in a summary. Phrases -// like "out of scope" or "unrelated to the task" appear in -// "rule docs describe X" prose just as often as in actual -// deferrals; pairing them with a co-located deferral verb in -// the regex eliminates the false positive at the cost of -// missing some legitimate excuses that don't say `skip` / -// `leave` / `defer` in the same sentence. Worth it: false -// positives erode trust in the hook faster than false negatives. -const DEFER = String.raw`(skip|skipping|skipped|leave|leaving|left|defer|deferring|deferred|ignore|ignoring|ignored|won't|wont|cannot|can't|cant|not (going|gonna) to (fix|address|touch))` - -/** - * Build a regex that fires when `phraseRe` appears within ~60 chars (either - * side) of a deferral verb. Use for bare phrases whose surface form alone is - * ambiguous (descriptive vs. deferral). - */ -export function withDeferralVerb(phraseRe: string): RegExp { - return new RegExp( - `${phraseRe}[^.?!\\n]{0,60}\\b${DEFER}\\b|\\b${DEFER}\\b[^.?!\\n]{0,60}${phraseRe}`, - 'i', - ) -} - -await runStopReminder({ - name: 'excuse-detector', - disabledEnvVar: 'SOCKET_EXCUSE_DETECTOR_DISABLED', - blocking: true, - // Strip quoted spans so the hook doesn't self-fire when the - // assistant *describes* the phrases it detects (e.g. a summary - // saying "when Claude says 'pre-existing', the hook blocks"). - // Quoted phrases are *referenced* not *asserted*, so they should - // not count as deferrals. - stripQuotedSpans: true, - patterns: [ - { - label: 'pre-existing (deferral shape)', - // Bare "pre-existing" matches both "this is pre-existing, skipping it" - // (deferral) and "pre-existing test-fixture bugs were fixed" - // (descriptive). Require a deferral verb in range. - regex: withDeferralVerb(String.raw`\bpre[- ]?existing\b`), - why: 'CLAUDE.md "No pre-existing excuse": if you see a lint error, type error, test failure, broken comment, or stale comment anywhere in your reading window — fix it. (Only fires when paired with a deferral verb in range.)', - }, - { - label: 'not related to my (deferral shape)', - // Without a deferral verb in range this fires on descriptive - // prose ("the fix is not related to my prior changes — it's - // its own commit"). Require a verb. - regex: withDeferralVerb(String.raw`\b(not |un)?related to my\b`), - why: 'CLAUDE.md "Unrelated issues are critical": an unrelated bug is not a reason to defer — fix it immediately. (Only fires when paired with a deferral verb in range.)', - }, - { - label: 'unrelated to the task (deferral shape)', - regex: withDeferralVerb(String.raw`\bunrelated to (the |this )?task\b`), - why: 'CLAUDE.md "Unrelated issues are critical": same as above. (Only fires when paired with a deferral verb in range.)', - }, - { - label: 'out of scope (deferral shape)', - // Common descriptive shape: "the rule's out-of-scope handling - // is X". Require a deferral verb so we don't fire on docs. - regex: withDeferralVerb( - String.raw`\b(out of|outside)( (the|this))? scope\b`, - ), - why: 'CLAUDE.md "No pre-existing excuse": the only exceptions are genuinely large refactors (state the trade-off and ask). (Only fires when paired with a deferral verb in range.)', - }, - { - label: 'separate concern (deferral shape)', - regex: withDeferralVerb(String.raw`\bseparate concern\b`), - why: 'CLAUDE.md "Unrelated issues are critical": fix the unrelated bug first, in its own commit, then resume the original task. (Only fires when paired with a deferral verb in range.)', - }, - { - label: 'leave it for later', - // Already deferral-shaped by construction ("leave" is the verb); - // no extra DEFER pairing needed. - regex: /\bleave (?:it|that|this) for later\b/i, - why: 'CLAUDE.md "Completion": never leave TODO/FIXME/XXX/shims/stubs/placeholders — finish 100%.', - }, - { - label: 'not my issue', - // Already deferral-shaped; "not my X" is the surface form of - // the deferral itself. - regex: /\bnot my (?:bug|issue|problem)\b/i, - why: 'CLAUDE.md "Unrelated issues are critical": same as "unrelated".', - }, - { - label: 'which direction do you want', - regex: /\bwhich direction do you want\b/i, - why: 'CLAUDE.md "Fix > defer": don\'t offer fix-vs-defer as a binary choice. Pick the fix path and execute.', - }, - { - label: 'should I implement … or accept', - regex: - /\bshould (?:i|we) (?:build|do|fix|implement) [^.?!\n]+(?:or|,)\s+(?:accept|defer|document|leave|skip|treat)\b/i, - why: 'CLAUDE.md "Fix > defer": this is a choice-architecture masquerading as a question. Fix it.', - }, - { - label: 'accept … as (a) (known )?gap', - regex: - /\baccept (?:this|it|that|[^.?!\n]{1,40}) as (?:a |an )?(?:known |documented |expected )?(?:drift|gap|limitation)\b/i, - why: 'CLAUDE.md "Fix > defer": gap-acceptance is the rationalization branch. The fix is the answer unless the user explicitly asked for the trade-off.', - }, - { - label: 'two paths/options: fix … or', - regex: - /\b(?:three|two) (?:choices|options|paths)[^.?!\n]{0,40}(?:fix|implement)[^.?!\n]{0,80}(?:or|,)\s+(?:accept|defer|document|leave|skip|treat)\b/i, - why: 'CLAUDE.md "Fix > defer": collapsing the menu — pick the fix path, start the first sub-step.', - }, - { - label: 'document(ed)? (it )?as a known (gap|drift|limitation)', - regex: - /\bdocument(?:ed)?\b[^.?!\n]{0,40}\bas a known (?:drift|gap|limitation)\b/i, - why: 'CLAUDE.md "Fix > defer": "document as known gap" is the deferral euphemism. Fix it instead.', - }, - { - label: 'want me to fix … or', - regex: - /\bwant me to (?:address|build|do|fix|implement) [^.?!\n]+(?:or|,)\s+(?:skip|defer|document|treat|accept|leave|move on)\b/i, - why: 'CLAUDE.md "Fix > defer": same pattern — re-litigating the fix decision. The user already said yes by virtue of asking.', - }, - ], - closingHint: - "These phrases usually precede a deferral. The Stop hook will block once so Claude must act on the matched item — either fix it now, or state the trade-off explicitly with the user's constraint.", -}) diff --git a/.claude/hooks/excuse-detector/package.json b/.claude/hooks/excuse-detector/package.json deleted file mode 100644 index a04838885..000000000 --- a/.claude/hooks/excuse-detector/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-excuse-detector", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/excuse-detector/test/index.test.mts b/.claude/hooks/excuse-detector/test/index.test.mts deleted file mode 100644 index 1145ba5e6..000000000 --- a/.claude/hooks/excuse-detector/test/index.test.mts +++ /dev/null @@ -1,459 +0,0 @@ -// node --test specs for the excuse-detector hook. -// -// Spawns the hook as a subprocess (matches the production runtime), -// writes a fake transcript to a temp dir, passes its path on stdin, -// captures stderr + exit code. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -interface Result { - readonly code: number - readonly stderr: string - readonly stdout: string -} - -interface TranscriptEntry { - readonly type: 'user' | 'assistant' - readonly content: string -} - -interface RunHookOptions { - readonly stopHookActive?: boolean | undefined -} - -// Single source of truth for the tmp transcript location used by every -// test (1 path, 1 reference). `setupTranscript` constructs the dir + -// file once and returns both, along with the cleanup callback. -function setupTranscript(rawContent: string): { - readonly dir: string - readonly transcriptPath: string - readonly cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'excuse-detector-test-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, rawContent) - return { - dir, - transcriptPath, - cleanup: () => { - rmSync(dir, { recursive: true, force: true }) - }, - } -} - -async function runHook( - entries: TranscriptEntry[], - options: RunHookOptions = {}, -): Promise { - const rawContent = - entries - .map(e => - JSON.stringify({ type: e.type, message: { content: e.content } }), - ) - .join('\n') + '\n' - const transcript = setupTranscript(rawContent) - try { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - const payload: Record = { - transcript_path: transcript.transcriptPath, - } - if (options.stopHookActive) { - payload['stop_hook_active'] = true - } - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - return await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - } finally { - transcript.cleanup() - } -} - -test('no transcript path: exits clean', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end(JSON.stringify({})) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -// Helper: assert a hit ended up in stdout as a Stop-hook block JSON. -// In blocking mode the hook writes JSON to stdout and nothing to stderr. -function assertBlock(result: Result, pattern: RegExp): void { - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.match(result.stdout, pattern) - const parsed = JSON.parse(result.stdout) as { - decision?: string | undefined - reason?: string | undefined - } - assert.strictEqual(parsed.decision, 'block') - assert.match(parsed.reason ?? '', pattern) -} - -test('clean assistant turn: no warning', async () => { - const result = await runHook([ - { type: 'user', content: 'do the work' }, - { - type: 'assistant', - content: 'Done. Tests pass and the diff is committed.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('detects "pre-existing"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'The lint error is pre-existing so I skipped it.', - }, - ]) - assertBlock(result, /pre-existing/) - assert.match(result.stdout, /excuse-detector/) -}) - -test('detects "preexisting" (no hyphen)', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'These are preexisting failures, leaving them.', - }, - ]) - assertBlock(result, /pre-existing/) -}) - -test('detects "not related to my rename"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - "Pre-existing test bugs from the null→undefined autofix, skipping — not related to my rename, I'll defer them.", - }, - ]) - // Should hit BOTH patterns (each paired with a deferral verb). - assertBlock(result, /pre-existing/) - assert.match(result.stdout, /related to my/) -}) - -test('detects "unrelated to the task"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'This typo is unrelated to the task, skipping.', - }, - ]) - assertBlock(result, /unrelated to the task/) -}) - -test('detects "out of scope"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: "Refactoring that module is out of scope — I'll skip it.", - }, - ]) - assertBlock(result, /out of scope/) -}) - -test('detects "separate concern"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: "That's a separate concern, leaving it for the next pass.", - }, - ]) - assertBlock(result, /separate concern/) -}) - -test('detects "leave it for later"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: "I'll leave it for later.", - }, - ]) - assertBlock(result, /leave it for later/) -}) - -test('detects "not my issue"', async () => { - const result = await runHook([ - { - type: 'assistant', - content: 'The CI failure is not my issue.', - }, - ]) - assertBlock(result, /not my issue/) -}) - -test('scans only the LAST assistant turn', async () => { - const result = await runHook([ - { type: 'user', content: 'first' }, - { - type: 'assistant', - content: 'I noticed a pre-existing bug and fixed it.', - }, - { type: 'user', content: 'next' }, - { type: 'assistant', content: 'Tests pass, diff is clean.' }, - ]) - // The first assistant turn mentions "pre-existing" but the LAST one - // is clean — the hook should not warn or block. - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('stop_hook_active: true falls back to informational stderr (no block)', async () => { - const result = await runHook( - [ - { - type: 'assistant', - content: 'The lint error is pre-existing so I skipped it.', - }, - ], - { stopHookActive: true }, - ) - assert.strictEqual(result.code, 0) - // No block JSON on stdout — we already gave Claude one chance. - assert.strictEqual(result.stdout, '') - // Still surface the warning informationally. - assert.match(result.stderr, /pre-existing/) - assert.match(result.stderr, /excuse-detector/) -}) - -test('does not fire on phrases inside ASCII double quotes (meta-discussion)', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - 'When Claude says "pre-existing" or "out of scope", the hook now blocks. Implementation done.', - }, - ]) - // Quoted = referenced, not asserted. No block, no warning. - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('does not fire on phrases inside ASCII single quotes', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - "The phrase 'leave it for later' is one of the patterns. Implementation done.", - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('does not fire on phrases inside smart double quotes', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - 'The summary mentions “unrelated to the task” as one excuse phrase.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('still fires on phrases asserted in plain prose (not quoted)', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - "I noticed a lint error but it is pre-existing — I won't fix it; the typo is out of scope for this task.", - }, - ]) - // Two trigger phrases: "pre-existing" paired with "won't fix" - // (deferral verb in range) and "out of scope" (bare phrase). - assertBlock(result, /pre-existing/) - assert.match(result.stdout, /out of scope/) -}) - -test('does NOT fire on descriptive "out of scope" (no deferral verb)', async () => { - // Pure description of what the rule docs say — no skip / leave / - // defer verb in range. Should not fire. - const result = await runHook([ - { - type: 'assistant', - content: - 'The rule documents an out of scope branch for files belonging to another session. Summary done.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('does NOT fire on descriptive "unrelated to the task" (no deferral verb)', async () => { - const result = await runHook([ - { - type: 'assistant', - content: - 'The test fixture appears unrelated to the task on its surface, so I rewrote it to match.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('does NOT fire on descriptive "pre-existing X was fixed"', async () => { - // The deferral-shape regex requires a deferral verb near - // "pre-existing" (skip / leave / defer / can't / won't / etc.). - // Plain descriptive uses where the assistant is reporting work - // ("pre-existing bugs were fixed", "the pre-existing TS error is - // now resolved") must not fire — they're describing fixes, not - // deferring them. - const result = await runHook([ - { - type: 'assistant', - content: - 'Summary: 8 pre-existing test-fixture bugs fixed. The pre-existing RuleTester bug that affected every rule is resolved.', - }, - ]) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') -}) - -test('respects SOCKET_EXCUSE_DETECTOR_DISABLED', async () => { - const transcript = setupTranscript( - JSON.stringify({ - type: 'assistant', - message: { content: 'this is pre-existing.' }, - }) + '\n', - ) - try { - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { ...process.env, SOCKET_EXCUSE_DETECTOR_DISABLED: '1' }, - }) - child.stdin!.end( - JSON.stringify({ transcript_path: transcript.transcriptPath }), - ) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') - assert.strictEqual(result.stdout, '') - } finally { - transcript.cleanup() - } -}) - -test('handles array-of-blocks content shape', async () => { - const transcript = setupTranscript( - JSON.stringify({ - type: 'assistant', - message: { - content: [ - { type: 'text', text: 'first block' }, - { - type: 'text', - text: 'second block: the lint error is pre-existing so I skipped it', - }, - ], - }, - }) + '\n', - ) - try { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end( - JSON.stringify({ transcript_path: transcript.transcriptPath }), - ) - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - assertBlock(result, /pre-existing/) - } finally { - transcript.cleanup() - } -}) - -test('fails open on malformed payload', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not valid json') - let stderr = '' - let stdout = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.process.stdout!.on('data', chunk => { - stdout += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr, stdout }) - }) - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/excuse-detector/tsconfig.json b/.claude/hooks/excuse-detector/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/excuse-detector/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/extension-build-current-guard/README.md b/.claude/hooks/extension-build-current-guard/README.md deleted file mode 100644 index de1c2e9ba..000000000 --- a/.claude/hooks/extension-build-current-guard/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# extension-build-current-guard - -PostToolUse hook that auto-rebuilds the trusted-publisher extension whenever a file under `tools/trusted-publisher-extension/src/` is edited. - -## Why - -The extension is loaded unpacked from disk during local development. Without this hook, an operator edits `src/popup.mts`, forgets to run `pnpm build`, hits Chrome's reload button — and sees stale behavior. The hook closes that loop automatically: every src/ edit triggers a fresh build so `dist/` is always current. - -`dist/` is gitignored — we keep build artifacts off git, but the hook ensures they exist locally. - -## What it does - -After any `Edit` or `Write` to a path under `tools/trusted-publisher-extension/src/`: - -1. Locate the wheelhouse repo root from cwd -2. Run `pnpm --filter @socketsecurity/trusted-publisher-extension build` -3. Print build failures to stderr (but always exit 0 — PostToolUse can't reject the prior call) - -Build time is ~15ms with rolldown; no perceptible delay. - -## Failure mode - -If the build fails, you'll see the error tail in stderr. The hook still exits 0 (PostToolUse hooks can't reject what already happened). Fix the build error, then re-run: - -```sh -pnpm --filter @socketsecurity/trusted-publisher-extension build -``` - -## How to disable in tests - -`SOCKET_EXTENSION_BUILD_CURRENT_GUARD_DISABLED=1`. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/extension-build-current-guard/index.mts b/.claude/hooks/extension-build-current-guard/index.mts deleted file mode 100644 index 066b884b5..000000000 --- a/.claude/hooks/extension-build-current-guard/index.mts +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — extension-build-current-guard. -// -// Fires after Edit/Write operations. When the edited path is under -// `tools/trusted-publisher-extension/src/`, the hook runs -// `pnpm --filter @socketsecurity/trusted-publisher-extension build` -// in the background to keep dist/ in sync with src/. -// -// The hook is FIRE-AND-FORGET — it never blocks (PostToolUse can't -// reject the prior tool call anyway). Its purpose is to ensure -// local Chrome loads of the unpacked extension always see the -// latest src/ behavior without the operator having to remember to -// run the build manually. -// -// Build failures are surfaced to stderr so the operator sees them, -// but the hook still exits 0. -// -// Env disable (testing only): SOCKET_EXTENSION_BUILD_CURRENT_GUARD_DISABLED=1. - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { readStdin } from '../_shared/transcript.mts' - -interface PostToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly file_path?: unknown | undefined } | undefined - readonly cwd?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_EXTENSION_BUILD_CURRENT_GUARD_DISABLED' -const EXTENSION_SRC_PREFIX = 'tools/trusted-publisher-extension/src/' -const EXTENSION_FILTER = '@socketsecurity/trusted-publisher-extension' - -/** - * Returns true when filePath is under the extension's src/ tree. - */ -export function isExtensionSrcPath(filePath: string): boolean { - return filePath.includes(EXTENSION_SRC_PREFIX) -} - -/** - * Walks up from `start` looking for a directory that contains both - * `package.json` AND `tools/trusted-publisher-extension/`. Returns the path or - * undefined. - */ -export function findRepoRoot(start: string): string | undefined { - let cur = start - for (let i = 0; i < 10; i++) { - if ( - existsSync(path.join(cur, 'package.json')) && - existsSync(path.join(cur, 'tools', 'trusted-publisher-extension')) - ) { - return cur - } - const parent = path.dirname(cur) - if (parent === cur) { - return undefined - } - cur = parent - } - return undefined -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - let payload: PostToolUsePayload - try { - const raw = await readStdin() - payload = JSON.parse(raw) as PostToolUsePayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = - typeof payload.tool_input?.file_path === 'string' - ? payload.tool_input.file_path - : '' - if (!filePath || !isExtensionSrcPath(filePath)) { - process.exit(0) - } - const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.cwd() - const repoRoot = findRepoRoot(cwd) - if (!repoRoot) { - process.exit(0) - } - // Run build synchronously so the operator sees the result before - // they reach for Chrome's reload button. Rolldown finishes in - // ~15ms for this extension; no real cost. - const r = spawnSync('pnpm', ['--filter', EXTENSION_FILTER, 'build'], { - cwd: repoRoot, - encoding: 'utf8', - }) - if (r.status !== 0) { - const output = `${typeof r.stdout === 'string' ? r.stdout : ''}${typeof r.stderr === 'string' ? r.stderr : ''}` - const lines = [ - '[extension-build-current-guard] Build failed after src/ edit.', - '', - ' Output tail:', - ...output - .split('\n') - .slice(-10) - .map(l => ` ${l}`), - '', - ' Fix the error then re-run:', - ` pnpm --filter ${EXTENSION_FILTER} build`, - ] - process.stderr.write(lines.join('\n') + '\n') - // Still exit 0 — PostToolUse hooks can't reject the prior call, - // and we don't want to confuse the operator with a non-zero - // exit that has no actionable effect. - process.exit(0) - } - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/extension-build-current-guard/package.json b/.claude/hooks/extension-build-current-guard/package.json deleted file mode 100644 index e7f508da1..000000000 --- a/.claude/hooks/extension-build-current-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-extension-build-current-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/extension-build-current-guard/test/index.test.mts b/.claude/hooks/extension-build-current-guard/test/index.test.mts deleted file mode 100644 index ce9b4b69e..000000000 --- a/.claude/hooks/extension-build-current-guard/test/index.test.mts +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly stderr: string - readonly exitCode: number -} - -function runHook( - payload: Record, - env: Record = {}, -): RunResult { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - env: { ...process.env, ...env }, - encoding: 'utf8', - }) - return { - stderr: String(result.stderr ?? ''), - exitCode: result.status ?? -1, - } -} - -// Sanity: non-Edit/Write tools no-op - -test('ALLOWS non-Edit/Write tools', () => { - const { exitCode } = runHook({ - tool_name: 'Bash', - tool_input: { command: 'ls' }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS Edit to a non-extension file', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { file_path: '/repo/scripts/foo.mts' }, - }) - assert.equal(exitCode, 0) -}) - -// Env disable short-circuits - -test('ALLOWS with env disable', () => { - const { exitCode } = runHook( - { - tool_name: 'Edit', - tool_input: { - file_path: '/repo/tools/trusted-publisher-extension/src/popup.mts', - }, - }, - { SOCKET_EXTENSION_BUILD_CURRENT_GUARD_DISABLED: '1' }, - ) - assert.equal(exitCode, 0) -}) - -// repoRoot not found: hook exits 0 (fail-open) - -test('ALLOWS when repo root cannot be located', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'ebcg-')) - try { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: `${dir}/tools/trusted-publisher-extension/src/popup.mts`, - }, - cwd: dir, - }) - assert.equal(exitCode, 0) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -// PostToolUse exits 0 even on build failure (can't reject the prior call) - -test('Returns 0 even when build would fail (PostToolUse contract)', () => { - // Use a tempdir that LOOKS like a repo root but where pnpm build - // will fail (no actual extension to build). - const dir = mkdtempSync(path.join(os.tmpdir(), 'ebcg-')) - try { - writeFileSync(path.join(dir, 'package.json'), '{}') - const toolsDir = path.join(dir, 'tools', 'trusted-publisher-extension') - const srcDir = path.join(toolsDir, 'src') - mkdirSync(srcDir, { recursive: true }) - writeFileSync(path.join(srcDir, 'popup.mts'), '') - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(srcDir, 'popup.mts'), - }, - cwd: dir, - }) - // Build will fail (no pnpm filter target) — but we still exit 0. - assert.equal(exitCode, 0) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/extension-build-current-guard/tsconfig.json b/.claude/hooks/extension-build-current-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/extension-build-current-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/file-size-reminder/README.md b/.claude/hooks/file-size-reminder/README.md deleted file mode 100644 index 28a7b2423..000000000 --- a/.claude/hooks/file-size-reminder/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# file-size-reminder - -Stop hook that warns when an assistant turn's Write / Edit / NotebookEdit tool calls push a file past the 500-line soft cap or 1000-line hard cap. - -## Why - -CLAUDE.md "File size" rule: - -> Soft cap **500 lines**, hard cap **1000 lines** per source file. Past those, split along natural seams — group by domain, not line count; name files for what's in them; co-locate helpers with consumers. Exceptions: a single function that legitimately needs the space (note it inline), or a generated artifact. - -The intent is to catch the slide where a file gradually accumulates 600, then 700, then 1200 lines because nobody noticed each individual edit pushing it over. The hook surfaces the count alongside the edit so the next turn can act on it. - -## What it catches - -After each assistant turn, the hook walks the most recent assistant's tool-use events, finds calls to: - -- `Write` (creating a new file or full rewrite) -- `Edit` (modifying a file in place) -- `NotebookEdit` (Jupyter cell modifications) - -For each target `file_path`, it reads the current on-disk state (post-edit, since the hook fires after the tool ran), counts lines, and warns if the count is past either cap. - -| Cap | Threshold | Action | -| ---- | -------------- | ---------------------------------- | -| Soft | 501-1000 lines | Warning — start planning the split | -| Hard | 1001+ lines | Stronger warning — split now | - -## Exempt paths - -Generated / vendored / build-output paths are skipped to avoid noise: - -- `node_modules/`, `.cache/`, `coverage/`, `coverage-isolated/` -- `dist/`, `build/`, `external/`, `vendor/`, `upstream/` -- `.git/`, `test/fixtures/`, `test/packages/` -- `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `Cargo.lock` -- `*.d.ts`, `*.d.ts.map`, `*.tsbuildinfo`, `*.map` - -The skip list errs on the side of suppressing false positives — genuine in-scope files past the cap will still surface. - -## Why it doesn't block - -Stop hooks fire after the tool has run. Blocking would just truncate the warning. The size violation is in the diff already; the warning prompts the next turn to address it. - -## Configuration - -`SOCKET_FILE_SIZE_REMINDER_DISABLED=1` — turn off entirely. Useful for sessions intentionally working on a generated-file context the skip list doesn't recognize. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/file-size-reminder/index.mts b/.claude/hooks/file-size-reminder/index.mts deleted file mode 100644 index 583140af9..000000000 --- a/.claude/hooks/file-size-reminder/index.mts +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — file-size-reminder. -// -// Surfaces file-size violations after Write / Edit / NotebookEdit -// tool calls. CLAUDE.md "File size": -// -// Soft cap 500 lines, hard cap 1000 lines per source file. Past -// those, split along natural seams — group by domain, not line -// count; name files for what's in them; co-locate helpers with -// consumers. -// -// Exceptions (also from CLAUDE.md / docs/claude.md/file-size.md): -// -// - A single function that legitimately needs the space (the user -// notes this inline at the top of the function). -// - Generated artifacts (lockfiles, schema dumps, vendored data). -// -// The hook walks the most-recent assistant turn's tool-use events, -// finds Write/Edit/NotebookEdit calls, reads each target file from -// disk (post-edit state, since the hook fires after the tool ran), -// counts lines, and flags any file past either cap. -// -// Skips paths matching the generated-artifact heuristic — anything -// under common vendor / generated / dist / build / coverage paths. -// The skip list errs on the side of suppressing false positives; -// genuine in-scope files past the cap will still surface. -// -// Disable via SOCKET_FILE_SIZE_REMINDER_DISABLED. - -import { existsSync, readFileSync, statSync } from 'node:fs' -import process from 'node:process' - -import { readLastAssistantToolUses, readStdin } from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -const SOFT_CAP_LINES = 500 -const HARD_CAP_LINES = 1000 - -// Tool names that write or modify file content. Read / Glob / Grep -// don't change a file, so they don't trigger this hook. -const FILE_WRITING_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) - -// Path patterns we skip — generated, vendored, or otherwise -// exempt from the cap. Tested as substring matches against the -// absolute file_path; a hit anywhere in the path skips the file. -// -// Each entry is intentionally generous: false-positives in the -// skip list are recoverable (the user can disable the hook or -// reduce the list), but false-positives in the *flagging* list -// would noise up every turn that touches a vendored file. -const SKIP_PATH_SUBSTRINGS: readonly string[] = [ - '/node_modules/', - '/.cache/', - '/coverage/', - '/coverage-isolated/', - '/dist/', - '/build/', - '/external/', - '/vendor/', - '/upstream/', - '/.git/', - '/test/fixtures/', - '/test/packages/', - // Lockfiles + manifests - 'pnpm-lock.yaml', - 'package-lock.json', - 'yarn.lock', - 'Cargo.lock', - // Type declarations (often generated) - '.d.ts', - '.d.ts.map', - '.tsbuildinfo', - // Map files - '.map', -] - -export function collectHits( - events: ReadonlyArray<{ name: string; input: Record }>, -): SizeHit[] { - const seen = new Set() - const hits: SizeHit[] = [] - for (let i = 0, { length } = events; i < length; i += 1) { - const event = events[i]! - if (!FILE_WRITING_TOOLS.has(event.name)) { - continue - } - const pathField = event.input['file_path'] ?? event.input['notebook_path'] - if (typeof pathField !== 'string') { - continue - } - if (seen.has(pathField)) { - continue - } - seen.add(pathField) - if (isExempt(pathField)) { - continue - } - const lines = countLines(pathField) - if (lines === undefined) { - continue - } - if (lines > HARD_CAP_LINES) { - hits.push({ path: pathField, lines, cap: 'hard' }) - } else if (lines > SOFT_CAP_LINES) { - hits.push({ path: pathField, lines, cap: 'soft' }) - } - } - return hits -} - -export function countLines(absPath: string): number | undefined { - try { - if (!existsSync(absPath)) { - return undefined - } - const stat = statSync(absPath) - if (!stat.isFile()) { - return undefined - } - // Use byte-count fast-path for very large files: if the file is - // over ~256 KB it's almost certainly past the cap unless every - // line is one byte (unrealistic). Otherwise read + count newlines. - const content = readFileSync(absPath, 'utf8') - // Count newlines + 1 unless the file is empty. This matches the - // canonical `wc -l` convention (which counts newlines, off-by-one - // for files without trailing newline) closely enough — exact - // boundary cases at the cap edge don't matter, the cap is a - // judgement guideline not a hard machine check. - if (content.length === 0) { - return 0 - } - let count = 0 - for (let i = 0, { length } = content; i < length; i += 1) { - if (content.charCodeAt(i) === 10) { - count += 1 - } - } - // Add 1 for the final line if it doesn't end in a newline. - if (content.charCodeAt(content.length - 1) !== 10) { - count += 1 - } - return count - } catch { - return undefined - } -} - -interface SizeHit { - readonly path: string - readonly lines: number - readonly cap: 'soft' | 'hard' -} - -export function isExempt(absPath: string): boolean { - for (let i = 0, { length } = SKIP_PATH_SUBSTRINGS; i < length; i += 1) { - if (absPath.includes(SKIP_PATH_SUBSTRINGS[i]!)) { - return true - } - } - return false -} - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_FILE_SIZE_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - - const events = readLastAssistantToolUses(payload.transcript_path) - if (events.length === 0) { - process.exit(0) - } - const hits = collectHits(events) - if (hits.length === 0) { - process.exit(0) - } - - const lines = ['[file-size-reminder] File-size cap exceeded:', ''] - for (let i = 0, { length } = hits; i < length; i += 1) { - const hit = hits[i]! - const capLabel = - hit.cap === 'hard' - ? `HARD CAP (${HARD_CAP_LINES} lines)` - : `soft cap (${SOFT_CAP_LINES} lines)` - lines.push(` • ${hit.path}`) - lines.push(` ${hit.lines} lines — past ${capLabel}`) - } - lines.push('') - lines.push( - ' CLAUDE.md "File size": split along natural seams — group by domain,', - ) - lines.push( - " name files for what's in them, co-locate helpers with consumers.", - ) - lines.push( - ' Exceptions (single legitimate large function / generated artifact)', - ) - lines.push( - ' should be stated inline. Full playbook: docs/claude.md/file-size.md.', - ) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - // Fail-open on any hook bug. - process.exit(0) -}) diff --git a/.claude/hooks/file-size-reminder/package.json b/.claude/hooks/file-size-reminder/package.json deleted file mode 100644 index b76df77d0..000000000 --- a/.claude/hooks/file-size-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-file-size-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/file-size-reminder/test/index.test.mts b/.claude/hooks/file-size-reminder/test/index.test.mts deleted file mode 100644 index 2e9365913..000000000 --- a/.claude/hooks/file-size-reminder/test/index.test.mts +++ /dev/null @@ -1,196 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface ToolUseEvent { - readonly name: string - readonly input: Record -} - -function makeTranscript( - dir: string, - toolUses: readonly ToolUseEvent[], -): string { - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'doing the thing' }, - ...toolUses.map(tu => ({ - type: 'tool_use', - name: tu.name, - input: tu.input, - })), - ], - }, - }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return transcriptPath -} - -function writeLines(filePath: string, n: number): void { - const content = Array.from({ length: n }, (_, i) => `line ${i + 1}`).join( - '\n', - ) - writeFileSync(filePath, content) -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags soft-cap violation (501-1000 lines)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 750) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.match(stderr, /file-size-reminder/) - assert.match(stderr, /soft cap/) - assert.match(stderr, /750 lines/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('flags hard-cap violation (>1000 lines)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'huge.mts') - writeLines(target, 1500) - const transcript = makeTranscript(dir, [ - { name: 'Write', input: { file_path: target, content: '...' } }, - ]) - const { stderr } = runHook(transcript) - assert.match(stderr, /HARD CAP/) - assert.match(stderr, /1500 lines/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('does not flag files at or under soft cap', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'small.mts') - writeLines(target, 500) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('skips node_modules paths', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const realDir = path.join(dir, 'node_modules', 'pkg') - mkdirSync(realDir, { recursive: true }) - const realTarget = path.join(realDir, 'big.mts') - writeLines(realTarget, 2000) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: realTarget, new_string: 'x' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('skips Read / Glob tool uses', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 2000) - const transcript = makeTranscript(dir, [ - { name: 'Read', input: { file_path: target } }, - { name: 'Glob', input: { pattern: '**/*.mts' } }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - // Read/Glob don't write, so no flag even though file is over cap - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('handles missing file gracefully (no crash)', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const transcript = makeTranscript(dir, [ - { - name: 'Edit', - input: { file_path: '/tmp/does-not-exist-xyz.mts', new_string: 'x' }, - }, - ]) - const { stderr, exitCode } = runHook(transcript) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('deduplicates multiple edits to the same file', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'multi.mts') - writeLines(target, 600) - const transcript = makeTranscript(dir, [ - { name: 'Edit', input: { file_path: target, new_string: 'a' } }, - { name: 'Edit', input: { file_path: target, new_string: 'b' } }, - { name: 'Edit', input: { file_path: target, new_string: 'c' } }, - ]) - const { stderr } = runHook(transcript) - // Only one warning for the file, not three. - const matches = stderr.match(/600 lines/g) ?? [] - assert.equal(matches.length, 1) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('disabled env var short-circuits', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fsize-')) - try { - const target = path.join(dir, 'big.mts') - writeLines(target, 1500) - const transcript = makeTranscript(dir, [ - { name: 'Write', input: { file_path: target, content: '...' } }, - ]) - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcript }), - env: { ...process.env, SOCKET_FILE_SIZE_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/file-size-reminder/tsconfig.json b/.claude/hooks/file-size-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/file-size-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/follow-direct-imperative-reminder/README.md b/.claude/hooks/follow-direct-imperative-reminder/README.md deleted file mode 100644 index e2da1e377..000000000 --- a/.claude/hooks/follow-direct-imperative-reminder/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# follow-direct-imperative-reminder - -Stop hook that flags assistant turns which respond to a bare imperative user command with hedging or re-litigation before the tool call. - -## Why - -CLAUDE.md "Judgment & self-evaluation" rule: - -> Direct imperatives → execute, don't litigate. When the user issues a bare command ("use nvm 26.2.0", "cancel the build", "do it", "kill it"), the response is the tool call, not a paragraph weighing trade-offs. - -Past incident (the trigger for this hook): user typed "use nvm use 26.2.0". Assistant responded with a paragraph explaining why it wouldn't help the in-flight build, instead of switching Node. Same turn the user typed "cancel the build right now". Assistant kept narrating build phases instead of killing the process. User asked for a hook to stop the behavior. - -The failure mode is analysis-before-action when the command was unambiguous. The user already weighed the trade-off. Re-litigating wastes a turn and signals the directive was optional. It wasn't. - -## Detection - -Two-signal rule, both must hit: - -1. **Previous user turn is a bare imperative.** Single short sentence (≤ 8 words), starts with an action verb (`cancel`, `kill`, `use`, `run`, `commit`, `push`, `do`, `continue`, etc.) or common imperative phrase (`let's`, `just`, `please`). No question mark (questions invite analysis). -2. **Assistant turn contains hedge / re-litigation markers**: - - `doesn't help` / `won't help` - - `before I do that` / `let me explain` / `let me first` - - `to be clear` / `worth noting` / `that said` / `actually` - - `the in-flight X` (re-litigating in-flight state) - - `caveat:` / `note:` / `important:` - -Both signals fire: stderr reminder lands in the next turn's context. - -## What it does NOT catch - -- Questions from the user ("should I use Node 26?"). Analysis is invited. -- Long contextual user messages. Those carry their own framing. -- Assistant turns that hedge after the tool call. Post-action qualification is fine. - -## Disable - -```bash -SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1 -``` - -## Related - -- `dont-stop-mid-queue-reminder`: Stop hook for premature "what's next?" after authorized continuous-work directives. -- `ask-suppression-reminder`: Stop hook for AskUserQuestion when recent transcript already authorized the obvious default. diff --git a/.claude/hooks/follow-direct-imperative-reminder/index.mts b/.claude/hooks/follow-direct-imperative-reminder/index.mts deleted file mode 100644 index f708b8b18..000000000 --- a/.claude/hooks/follow-direct-imperative-reminder/index.mts +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — follow-direct-imperative-reminder. -// -// Fires at turn-end. If the immediately-preceding user turn was a bare -// imperative command (short, action-verb-led) AND the just-emitted -// assistant text contains hedge / re-litigation patterns BEFORE any -// tool call, emit a stderr reminder pointing at the failure mode. -// -// The fleet rule (CLAUDE.md "Judgment & self-evaluation"): -// -// Direct imperatives → execute, don't litigate. When the user -// issues a bare command ("use nvm 26.2.0", "cancel the build", -// "do it", "kill it"), the response is the tool call, not a -// paragraph weighing trade-offs. -// -// Past incident: user typed "use nvm use 26.2.0"; assistant responded -// with a paragraph explaining why it wouldn't help the in-flight -// build instead of running the command. Same turn the user typed -// "cancel the build right now" — assistant continued narrating -// build phases instead of killing the process. The user explicitly -// asked for a hook to stop this. -// -// Detection: -// - Last user turn is a single short imperative (≤ 8 words, -// starts with an action verb or a known imperative form). -// - Last assistant turn (just emitted) contains hedge openers -// OR a leading analysis paragraph that precedes any tool call. -// -// Why a reminder, not a block: Stop hooks fire AFTER the turn ended. -// The reminder lands in the next turn's context so the agent sees -// the pattern it just exhibited. -// -// Exit codes: -// 0 — always. Informational; never blocks. -// -// Disabled via `SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED=1`. - -import { readFileSync } from 'node:fs' -import process from 'node:process' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -interface TranscriptEntry { - readonly type?: string | undefined - readonly role?: string | undefined - readonly message?: - | { - readonly content?: unknown | undefined - readonly role?: string | undefined - } - | undefined - readonly content?: unknown | undefined -} - -export async function drainStdinJson(): Promise { - return await new Promise(resolve => { - let raw = '' - process.stdin.on('data', d => { - raw += d.toString('utf8') - }) - process.stdin.on('end', () => { - try { - resolve(raw ? (JSON.parse(raw) as StopPayload) : {}) - } catch { - resolve({}) - } - }) - process.stdin.on('error', () => resolve({})) - setTimeout(() => resolve({}), 200) - }) -} - -// Read the last N entries from a JSONL transcript file. The harness -// uses one JSON object per line. -export function readTranscriptTail( - path: string, - count: number, -): TranscriptEntry[] { - let text: string - try { - text = readFileSync(path, 'utf8') - } catch { - return [] - } - const lines = text.split('\n').filter(Boolean) - const tail = lines.slice(-count) - const out: TranscriptEntry[] = [] - for (const line of tail) { - try { - out.push(JSON.parse(line) as TranscriptEntry) - } catch { - // ignore malformed - } - } - return out -} - -// Flatten content (string | content-block-array) into one string. -export function flattenContent(content: unknown): string { - if (typeof content === 'string') { - return content - } - if (Array.isArray(content)) { - const parts: string[] = [] - for (const block of content) { - if (block && typeof block === 'object') { - const b = block as { - type?: string | undefined - text?: string | undefined - } - if (b.type === 'text' && typeof b.text === 'string') { - parts.push(b.text) - } - } - } - return parts.join('\n') - } - return '' -} - -// Role detection across the two shapes the transcript uses. -export function entryRole(e: TranscriptEntry): string | undefined { - return e.role ?? e.message?.role ?? e.type -} - -export function entryText(e: TranscriptEntry): string { - return flattenContent(e.message?.content ?? e.content ?? '') -} - -// Imperative-command opening verbs/forms. Kept conservative — -// over-matching would trigger the reminder on normal conversation. -const IMPERATIVE_OPENERS = [ - // Single-verb commands. - 'cancel', - 'kill', - 'stop', - 'abort', - 'do', - 'use', - 'run', - 'commit', - 'push', - 'fix', - 'try', - 'continue', - 'restart', - 'rerun', - 'redo', - 'execute', - 'go', - 'land', - 'merge', - 'rebase', - 'reset', - 'add', - 'remove', - 'delete', - 'install', - 'switch', - 'check', - 'show', - 'list', - 'open', - 'close', - 'undo', - 'revert', - 'apply', - 'build', - 'test', - 'deploy', - 'finish', - 'follow', - 'now', - // Common imperative phrases. - "let's", - 'just', - 'please', -] - -// Returns true when the text looks like a bare imperative directive -// (short, action-verb-led, no question mark, no long context). -export function looksLikeImperative(text: string): boolean { - const trimmed = text.trim().toLowerCase() - if (!trimmed) { - return false - } - // Strip leading punctuation. - const body = trimmed.replace(/^[!,.\s]+/, '') - // Skip questions entirely — questions invite analysis. - if (body.includes('?')) { - return false - } - // Bounded length: long contextual messages are not bare imperatives. - const wordCount = body.split(/\s+/).filter(Boolean).length - if (wordCount > 8) { - return false - } - // Pull the first word. - const firstWord = body.split(/\s+/)[0] ?? '' - return IMPERATIVE_OPENERS.includes(firstWord) -} - -// Hedge / re-litigation markers in the assistant's text. The goal is -// to catch paragraphs that explain WHY the command might not help -// before the tool call lands. -const HEDGE_MARKERS = [ - /\bdoesn't help\b/i, - /\bwon't help\b/i, - /\bbefore (?:i|we) (?:do that|run|kick|switch|cancel)\b/i, - /\blet me (?:explain|first|note)\b/i, - /\b(?:to be clear|just so we'?re clear)\b/i, - /\bworth (?:checking|confirming|noting)\b/i, - /\bone thing to (?:note|flag)\b/i, - /\bthat said\b/i, - /\bactually,?\s+/i, - /\b(?:however|but),?\s+(?:that|the|this)\b/i, - // "the in-flight X is past Y" — re-litigation of in-flight state. - /\bthe in-?flight\b/i, - // Heavy throat-clearing. - /\b(?:caveat|note|important):/i, -] - -export function hasHedge(text: string): boolean { - for (let i = 0, { length } = HEDGE_MARKERS; i < length; i += 1) { - const re = HEDGE_MARKERS[i]! - if (re.test(text)) { - return true - } - } - return false -} - -async function main(): Promise { - if (process.env['SOCKET_FOLLOW_DIRECT_IMPERATIVE_DISABLED']) { - return - } - const payload = await drainStdinJson() - const transcriptPath = payload.transcript_path - if (!transcriptPath) { - return - } - // Pull the last ~6 entries — usually covers the last user + last - // assistant turn plus any tool result entries between them. - const tail = readTranscriptTail(transcriptPath, 8) - if (tail.length === 0) { - return - } - - // Find the last assistant entry (what we just emitted) and the - // last user entry BEFORE it. - let lastAssistantIdx = -1 - for (let i = tail.length - 1; i >= 0; i -= 1) { - if (entryRole(tail[i]!) === 'assistant') { - lastAssistantIdx = i - break - } - } - if (lastAssistantIdx === -1) { - return - } - let lastUserIdx = -1 - for (let i = lastAssistantIdx - 1; i >= 0; i -= 1) { - if (entryRole(tail[i]!) === 'user') { - lastUserIdx = i - break - } - } - if (lastUserIdx === -1) { - return - } - - const userText = entryText(tail[lastUserIdx]!) - const assistantText = entryText(tail[lastAssistantIdx]!) - if (!userText || !assistantText) { - return - } - if (!looksLikeImperative(userText)) { - return - } - if (!hasHedge(assistantText)) { - return - } - - const userPreview = userText.trim().slice(0, 60) - process.stderr.write( - [ - '[follow-direct-imperative-reminder] You hedged before executing a direct imperative.', - '', - ` User said: "${userPreview}"`, - '', - ' The response to a bare command should be the tool call,', - ' not a paragraph weighing trade-offs. Hedge openers ("That', - ' won\'t help…", "Let me explain…", "Before I do that…") +', - ' analysis-before-action when the command was unambiguous', - ' are the failure mode the rule targets.', - '', - ' Fix: state the intent in one short sentence at most, then', - ' run the command. If you genuinely think the directive is', - " wrong, run it AFTER raising the concern — don't refuse to act.", - '', - " CLAUDE.md → 'Judgment & self-evaluation' → Direct imperatives.", - '', - ].join('\n'), - ) -} - -main().catch(e => { - process.stderr.write( - `[follow-direct-imperative-reminder] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/follow-direct-imperative-reminder/package.json b/.claude/hooks/follow-direct-imperative-reminder/package.json deleted file mode 100644 index fe86e4b49..000000000 --- a/.claude/hooks/follow-direct-imperative-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-follow-direct-imperative-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts b/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts deleted file mode 100644 index fe0f1cd46..000000000 --- a/.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -// node --test specs for follow-direct-imperative-reminder. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { flattenContent, hasHedge, looksLikeImperative } from '../index.mts' - -test('looksLikeImperative: "use nvm 26.2.0"', () => { - assert.strictEqual(looksLikeImperative('use nvm 26.2.0'), true) -}) - -test('looksLikeImperative: "cancel the build right now"', () => { - assert.strictEqual(looksLikeImperative('cancel the build right now'), true) -}) - -test('looksLikeImperative: "kill it"', () => { - assert.strictEqual(looksLikeImperative('kill it'), true) -}) - -test('looksLikeImperative: "do what I said"', () => { - assert.strictEqual(looksLikeImperative('do what I said'), true) -}) - -test('looksLikeImperative: "continue"', () => { - assert.strictEqual(looksLikeImperative('continue'), true) -}) - -test('looksLikeImperative: rejects questions', () => { - assert.strictEqual(looksLikeImperative('should I use 26?'), false) -}) - -test('looksLikeImperative: rejects long context', () => { - assert.strictEqual( - looksLikeImperative( - 'use nvm to switch to Node 26.2.0 so the build runs with the right engines', - ), - false, - ) -}) - -test('looksLikeImperative: rejects non-verb opener', () => { - assert.strictEqual(looksLikeImperative('hey there friend'), false) - assert.strictEqual(looksLikeImperative('thanks for that'), false) -}) - -test('looksLikeImperative: empty', () => { - assert.strictEqual(looksLikeImperative(''), false) - assert.strictEqual(looksLikeImperative(' '), false) -}) - -test('hasHedge: "doesn\'t help"', () => { - assert.strictEqual( - hasHedge( - "Switching the shell's Node to 26.2.0 doesn't help the build that's already running", - ), - true, - ) -}) - -test('hasHedge: "Before I do that"', () => { - assert.strictEqual( - hasHedge('Before I do that, the in-flight build is at 37%.'), - true, - ) -}) - -test('hasHedge: "Let me explain"', () => { - assert.strictEqual(hasHedge('Let me explain why this fails.'), true) -}) - -test('hasHedge: "actually,"', () => { - assert.strictEqual(hasHedge('actually, the dependency graph shows…'), true) -}) - -test('hasHedge: clean status update', () => { - assert.strictEqual(hasHedge('Switched. Now on Node 26.2.0.'), false) -}) - -test('hasHedge: tool result narration', () => { - assert.strictEqual(hasHedge('Build cancelled. No processes remain.'), false) -}) - -test('flattenContent: string', () => { - assert.strictEqual(flattenContent('hi'), 'hi') -}) - -test('flattenContent: text blocks', () => { - assert.strictEqual( - flattenContent([ - { type: 'text', text: 'one' }, - { type: 'text', text: 'two' }, - ]), - 'one\ntwo', - ) -}) - -test('flattenContent: ignores non-text blocks', () => { - assert.strictEqual( - flattenContent([ - { type: 'tool_use', name: 'Bash' }, - { type: 'text', text: 'survives' }, - ]), - 'survives', - ) -}) - -test('flattenContent: empty/garbage', () => { - assert.strictEqual(flattenContent(undefined), '') - assert.strictEqual(flattenContent(42), '') - assert.strictEqual(flattenContent(undefined), '') -}) diff --git a/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json b/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/follow-direct-imperative-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/gh-token-hygiene-guard/README.md b/.claude/hooks/gh-token-hygiene-guard/README.md deleted file mode 100644 index 2602d1719..000000000 --- a/.claude/hooks/gh-token-hygiene-guard/README.md +++ /dev/null @@ -1,237 +0,0 @@ -# gh-token-hygiene-guard - -PreToolUse hook on Bash commands invoking `gh`. Enforces four -invariants motivated by the May 2026 Nx Console supply-chain -compromise (a malicious npm package read `~/.config/gh/hosts.yml` and -used the token against the GitHub API within 74 seconds of install). - -1. **Keychain storage.** Token must live in the OS keychain - (`gh auth status` reports `(keyring)`). On-disk - `~/.config/gh/hosts.yml` is rejected; no bypass. Detection is - **per-host**: the hook isolates the `github.com` block from - `gh auth status` before checking, so a keyring-backed - `github.enterprise.com` login can't mask a file-backed - `github.com` token. -2. **8-hour token age cap.** The hook stamps a local timestamp on - `gh auth login` / `gh auth refresh` and blocks every non-auth `gh` - command after 8 hours. Self-recovery: `gh auth refresh -h -github.com` is always allowed (re-stamps the file). This cap lives - in THIS hook, not `auth-rotation-reminder` (which handles non-gh - CLIs like npm / pnpm / gcloud / docker / vault). -3. **`workflow` scope is on-demand, single-use, physical-presence-gated.** - Recommended default scopes: `read:org, repo` (the hook does not - enforce a scope allowlist; gh forces `gist` as a minimum, so the - practical floor is `read:org, repo, gist`). To add the scope: - - Type `Allow workflow-scope bypass` in chat. **The phrase alone is - not enough** — an attacker who forges the chat-typed slot still - can't proceed without your physical presence. - - The hook runs **OS physical-presence authentication** (Touch ID / - YubiKey / fingerprint — see "Physical-presence auth" below). - - On success, `gh auth refresh -h github.com -s workflow` is let - through and the hook records a **session-bound** grant at - `~/.claude/gh-workflow-grant` (body = `\n`). - - The next `gh workflow run` verifies the grant's `session_id` - matches the dispatching session, then consumes it (deletes the - file). A grant planted by another process or a stale session is - rejected. - - A second dispatch requires a fresh bypass + auth cycle. -4. **Workflow scope revoke is always allowed** without bypass or auth - (`gh auth refresh -r workflow`), so users can clean up after a - dispatch. - -The dispatch gate also covers the API shape -(`gh api .../actions/workflows/.../dispatches`), not just -`gh workflow run` / `gh workflow dispatch`. - -## Operational state - -Two files under `~/.claude/`: - -- `gh-token-issued-at` — local timestamp of the last `gh auth login` / - `gh auth refresh`. Drives the 8h age check. First run stamps "now" - and treats the token as fresh (so the hook ships without forcing - every dev to re-auth on upgrade). -- `gh-workflow-grant` — **session-bound** marker for an unconsumed - workflow-dispatch authorization. Body is `\n`. - Presence alone is insufficient — the dispatch step cross-checks the - recorded `session_id` against the current Claude session. Deleted as - soon as a dispatch is let through. - -## Threat model & design choices - -- **Session-bound grants (not presence-only).** A presence-only marker - could be pre-created by a malicious postinstall (`touch -~/.claude/gh-workflow-grant`) before Claude even launches. Binding - the grant to the `session_id` the harness provides means a planted - grant from another process / session is rejected — the attacker - can't guess a session id the hook will later receive. -- **Physical presence on top of the chat phrase.** The single most - dangerous capability (dispatching workflows with access to all repo - secrets incl. npm publish tokens) is gated by a per-use biometric / - hardware-key check, not just a chat phrase that an injected agent - could emit. -- **Absolute `/usr/bin/` paths for sudo / dscl / osascript.** Defeats - PATH-hijack — a postinstall that drops `~/.local/bin/sudo` can't - intercept the auth call. (`gh` itself stays PATH-resolved; there's - no single canonical path across Homebrew / Intel / Linux.) -- **Known gaps** (documented in - [`docs/claude.md/fleet/security-stack.md`](../../../docs/claude.md/fleet/security-stack.md)): - the transcript JSONL the bypass-phrase check reads is - unauthenticated (needs harness HMAC), and `containsGhInvocation` is - regex-based, not AST-based (shell-variable / eval evasion possible). - -## Escape hatches - -None. The hook is failsafe-deny on its core invariants and -fail-closed on the auth path (no working physical-presence method → -block, never silently pass). There is **no test-only env-var -override** — `SOCKET_GH_HYGIENE_TEST_AUTH` was removed 2026-05-26 -because an attacker who planted it in a shell rc / `.envrc` / VS Code -terminal env would have bypassed Touch ID. The OS-auth path is -intentionally unreachable in unit tests and is exercised by manual -smoke-testing instead. - -## Physical-presence auth (cross-platform) - -The workflow-scope bypass (invariant 3) requires biometric / hardware -confirmation after the chat phrase. What works per platform: - -| Platform | Path | Notes | -| ---------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| **macOS + Touch ID** | `pam_tid.so` on sudo | Best. Setup below. | -| **macOS + osascript, no MDM** | password dialog → `dscl -authonly` | Fallback when Touch ID isn't configured. | -| **macOS + MDM (iru/Jamf/Mosyle/Kandji)** | Touch ID only | osascript is blocked by org policy; the hook detects the MDM install on disk and skips osascript (no "Process Blocked" toast). | -| **Linux + YubiKey** | `pam_u2f.so` on sudo | FIDO2 device. | -| **Linux + fingerprint reader** | `pam_fprintd.so` on sudo | ThinkPad / Framework / some Dells. | -| **Linux, no biometric/key** | — | `unsupported` → block. Error gives setup recipes. | -| **Windows** | — | No reachable equivalent (Windows Hello needs a UWP context). Dispatch from a macOS/Linux host or the GitHub web UI. | - -**MDM detection is filesystem-only.** The hook checks for known -blocker install paths (`/Library/Application Support/iru`, -`/usr/local/jamf/bin/jamf`, `/Library/Mosyle`, `/Library/Kandji`, …) -with `existsSync` — it never invokes osascript to probe, because the -probe itself triggers the block toast. - -### Linux setup (one-time) - -YubiKey (or any FIDO2 device): - -```sh -sudo apt install libpam-u2f # Debian/Ubuntu -sudo dnf install pam-u2f # Fedora/RHEL -pamu2fcfg | sudo tee -a /etc/u2f_mappings -# Add to /etc/pam.d/sudo, above `@include common-auth`: -# auth sufficient pam_u2f.so authfile=/etc/u2f_mappings -``` - -Laptop fingerprint reader: - -```sh -sudo apt install libpam-fprintd fprintd # Debian/Ubuntu -sudo dnf install fprintd-pam # Fedora/RHEL -fprintd-enroll -# Add to /etc/pam.d/sudo, above `@include common-auth`: -# auth sufficient pam_fprintd.so -``` - -Verify either with `sudo -k && sudo -n true` — a silent exit 0 means -the hook will recognize it as a physical-presence success. - -## macOS Touch ID setup (one time, recommended on Sonoma+) - -The hook prints these instructions on first use if Touch ID isn't -configured. Run once to enable Touch ID as a sudo auth method (sudo -falls back to the password prompt if Touch ID is unavailable — -declined, no fingerprint enrolled, lid closed): - -```sh -sudo tee /etc/pam.d/sudo_local <<'EOF' -auth sufficient pam_tid.so -EOF -``` - -> **Copy-paste verbatim.** The closing `EOF` must start at column 0 -> (no leading whitespace) or the heredoc will not terminate and -> your shell will hang waiting for input. Same constraint applies -> to the body lines — they're sent to `tee` as-is. If you indented -> this block when transcribing it, strip the indent. - -After this, every bypass-authorized refresh pops a Touch ID dialog -(no password typing required). - -### What the command does, line by line - -- **`sudo tee /etc/pam.d/sudo_local`** — writes to `/etc/pam.d/sudo_local`, which requires root; `sudo tee` is the canonical "write a file as root from a normal shell" pattern. `tee` reads stdin and writes the file; `sudo` elevates `tee`. Plain `> /etc/pam.d/sudo_local` redirection wouldn't work because the redirect happens in your unprivileged shell BEFORE sudo runs. This first sudo invocation prompts for your password the conventional way (since Touch ID isn't set up yet); every sudo after this point gets the Touch ID option. - -- **`/etc/pam.d/sudo_local`** — the official macOS PAM extension point introduced in macOS Sonoma (14). Apple created it so users can layer auth methods on sudo without modifying `/etc/pam.d/sudo`, which is replaced on every macOS update. `/etc/pam.d/sudo`'s first line is `auth include sudo_local`, which pulls in whatever you put here. The file doesn't exist by default; creating it is what activates the extension. - -- **`<<'EOF' ... EOF`** — a [heredoc](https://en.wikipedia.org/wiki/Here_document). Everything between the markers becomes stdin for `tee`. The single quotes around the opening `'EOF'` disable shell variable / backtick expansion inside the body — `$foo` and `` ` `` stay literal. Conservative default for config files. - -- **`auth sufficient pam_tid.so`** — the PAM directive. Three fields: - - **`auth`** — the module-type. PAM stacks split into `auth`, `account`, `password`, and `session`; only `auth` modules participate in the "prove who you are" phase that sudo cares about. - - **`sufficient`** — the control flag. PAM evaluates auth modules top-to-bottom; `sufficient` means "if this succeeds, the whole stack succeeds; if it fails, ignore and try the next module". So Touch ID is given first chance, and if you decline the dialog or no fingerprint is enrolled, sudo silently falls through to the password prompt. - - **`pam_tid.so`** — Apple's Touch ID PAM module shipped at `/usr/lib/pam/pam_tid.so.2`. Pops the system Touch ID dialog and reports success / failure to PAM. Requires Touch ID hardware (M-series MacBook, Touch ID Magic Keyboard, or unlocked Apple Watch). - -### Why `sufficient` and not `required`? - -The four PAM control flags: - -- **`required`** — must succeed; failure recorded but stack keeps evaluating -- **`requisite`** — must succeed; failure short-circuits immediately -- **`sufficient`** — succeeds the whole stack on success; failure ignored, falls through -- **`optional`** — result ignored - -We use `sufficient` because Touch ID should be an **alternative** to typing the password, not a precondition. Lid closed, no fingerprint enrolled, declined dialog, broken sensor → sudo silently moves to the password path. No friction, no lockout. - -### Why not edit `/etc/pam.d/sudo` directly? - -You can; it's a text file. But macOS updates replace it on every system upgrade — your edit silently disappears after the next macOS minor release. `sudo_local` is preserved across upgrades; that's its whole purpose. - -### Verifying it works - -```sh -sudo -k # invalidate any cached auth -sudo -v # next sudo should pop the Touch ID dialog -``` - -If Touch ID dialog appears → good. If you see a password prompt → Touch ID isn't enrolled, or you're on hardware without Touch ID, or the file path / content is wrong. Re-run the setup and double-check. - -### Undoing it - -```sh -sudo rm /etc/pam.d/sudo_local -``` - -Back to default. On a non-MDM Mac the osascript password dialog still -works (slower). On an MDM-managed Mac, removing Touch ID leaves **no** -working path — re-enable it or dispatch from elsewhere. - -## Tests - -Run `node --test test/index.test.mts` (the `pnpm test` wrapper goes -through a workspace install that currently has unrelated drift). - -14 cases cover: - -- non-`gh` Bash command → pass -- on-disk storage → block -- keyring storage + non-dispatch `gh` command → pass -- workflow dispatch + no scope → block -- workflow dispatch + scope + unconsumed grant → pass -- workflow dispatch consumes the grant (single-use) → grant deleted -- workflow dispatch + scope + missing grant → block -- workflow dispatch + **attacker-planted grant (wrong session)** → block -- `gh auth refresh -s workflow` + no bypass → block -- `gh auth refresh -s workflow` + bypass → reaches the auth path - (outcome is environment-dependent; the test asserts it does NOT hit - the bypass-missing branch) -- `gh auth refresh -r workflow` (revoke) → pass without bypass -- `gh api .../dispatches` (api shape) → block -- token >8h old → block -- token >8h old + `gh auth refresh` → pass (self-recovery) - -The OS physical-presence path (Touch ID / pam_u2f / pam_fprintd / -osascript) and the MDM-blocker filesystem detection are **not** unit -tested — they're OS-specific and were removed from the test surface -when the `SOCKET_GH_HYGIENE_TEST_AUTH` override was deleted. Verify -manually on the target machine. diff --git a/.claude/hooks/gh-token-hygiene-guard/index.mts b/.claude/hooks/gh-token-hygiene-guard/index.mts deleted file mode 100644 index 092fefa32..000000000 --- a/.claude/hooks/gh-token-hygiene-guard/index.mts +++ /dev/null @@ -1,843 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — gh-token-hygiene-guard. -// -// Four invariants on `gh` invocations, motivated by the May 2026 Nx -// Console supply-chain compromise (malicious npm package exfiltrated -// ~/.config/gh/hosts.yml and used the token against the GitHub API in -// <74 seconds): -// -// 1. KEYRING STORAGE. `gh auth status` must report `(keyring)`. The -// on-disk default at `~/.config/gh/hosts.yml` is exactly what the -// Nx malware exfiltrated. No bypass — move the token off disk. -// Fix: `gh auth logout && gh auth login` (keychain is the default -// since gh 2.40; `--secure-storage` does not exist — the only flag -// is `--insecure-storage` for opting out, which this hook rejects). -// Detection is PER-HOST: extractHostBlock() isolates the -// github.com block before checking, so a keyring-backed -// github.enterprise.com login can't mask a file-backed github.com. -// -// 2. 8-HOUR TOKEN AGE CAP. The hook stamps ~/.claude/gh-token-issued-at -// on `gh auth login` / `gh auth refresh` and blocks every non-auth -// `gh` command once the token is >8h old. Self-recovery: -// `gh auth refresh -h github.com` is always allowed (re-stamps). -// -// 3. WORKFLOW SCOPE ON-DEMAND, SINGLE-USE, PHYSICAL-PRESENCE-GATED. -// The `workflow` scope grants dispatch power over every workflow -// including publish / release. Recommended default scope set: -// `read:org, repo` (the hook does not enforce a scope allowlist; -// gh itself forces `gist` as a minimum, so the practical floor is -// `read:org, repo, gist`). To add the scope: -// a. User types `Allow workflow-scope bypass` in chat. -// b. Hook runs OS physical-presence auth (see -// requireUserAuthentication below) — the chat phrase ALONE is -// insufficient. An attacker who forges the chat-typed slot -// still can't proceed without your fingerprint / hardware key. -// c. On success, the hook records a SESSION-BOUND grant -// (~/.claude/gh-workflow-grant = `\n`). -// d. The next `gh workflow run` verifies the grant's session_id -// matches the dispatching session, then consumes it (deletes -// the file). A grant planted by another process / session is -// rejected. Any further dispatch needs a fresh phrase + auth. -// e. User manually re-revokes scope via -// `gh auth refresh -r workflow` when done (revoke needs no -// bypass). -// -// 4. KEYCHAIN-CLI READ DETECTION. Routing through the existing -// `no-blind-keychain-read-guard` handles `security -// find-generic-password` etc. — not duplicated here. -// -// Physical-presence auth (invariant 3, step b) is cross-platform: -// - macOS: Touch ID via pam_tid.so on sudo. osascript password -// dialog as fallback — UNLESS an MDM blocker (iru / Jamf / Mosyle / -// Kandji) is detected on disk, in which case osascript is skipped -// (invoking it would surface a "Process Blocked" toast). -// - Linux: pam_u2f (YubiKey / FIDO2) or pam_fprintd (laptop -// fingerprint) on sudo. resolveSudoBin() handles NixOS path. -// - Windows: no reachable path → 'unsupported' (fails closed). -// -// Exit codes: -// - 0: pass (not a gh command, or all checks satisfied) -// - 2: block (one of the invariants violated; stderr explains) -// -// Fail-open on hook bugs: main().catch() exits 0 so a bad deploy can't -// brick every gh command. Fail-CLOSED on auth (unsupported/denied → 2) -// because a missing physical-presence check must not silently pass. -// -// No test-only env override (removed 2026-05-26 as a supply-chain -// hardening measure — an attacker who planted SOCKET_GH_HYGIENE_TEST_AUTH -// in a shell rc / .envrc would have bypassed Touch ID). The OS-auth -// path is exercised by manual smoke-testing. -// -// Reads a PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." }, -// "transcript_path": "...", "session_id": "..." } - -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { findInvocation, parseCommands } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -// Absolute paths for OS-auth binaries. PATH-hijack defense — a -// malicious npm postinstall that drops ~/.local/bin/sudo, ~/.local/bin/dscl, -// or ~/.local/bin/osascript cannot intercept these calls because spawnSync -// is given the absolute path. -// -// dscl + osascript are macOS-only and live at /usr/bin/. sudo varies: -// - macOS: /usr/bin/sudo -// - Linux: /usr/bin/sudo (most distros) or /run/wrappers/bin/sudo (NixOS) -// - Windows: no equivalent — Windows has no physical-presence path that -// can be invoked from a Node child process. Hook fails closed -// on win32. -// resolveSudoBin() checks the candidates and returns the first that -// exists, or undefined if none. Calls fail-closed via ENOENT if the -// returned path becomes unavailable between resolve and spawn (TOCTOU -// is non-exploitable here because the candidates are all system paths -// outside user writability). -const DSCL_BIN = '/usr/bin/dscl' -const OSASCRIPT_BIN = '/usr/bin/osascript' -const SUDO_CANDIDATES = [ - '/usr/bin/sudo', - '/usr/local/bin/sudo', - '/run/wrappers/bin/sudo', -] as const -function resolveSudoBin(): string | undefined { - for (let i = 0; i < SUDO_CANDIDATES.length; i += 1) { - if (existsSync(SUDO_CANDIDATES[i]!)) { - return SUDO_CANDIDATES[i] - } - } - return undefined -} - -const BYPASS_PHRASE = 'Allow workflow-scope bypass' -// One bypass phrase authorizes ONE workflow dispatch. The grant file's -// presence = unconsumed. The hook deletes the file immediately after -// letting the dispatch through, so a second dispatch (chain attack or -// genuine re-use) requires a fresh phrase. Token-age (8h) is the -// time-based check; the dispatch gate is single-use. -const WORKFLOW_GRANT_FILE = path.join( - os.homedir(), - '.claude', - 'gh-workflow-grant', -) -const TOKEN_ISSUED_AT_FILE = path.join( - os.homedir(), - '.claude', - 'gh-token-issued-at', -) -const TOKEN_TTL_MS = 8 * 60 * 60 * 1000 // 8 hours - -interface PreToolUsePayload { - tool_name?: string | undefined - tool_input?: { command?: string | undefined } | undefined - transcript_path?: string | undefined - session_id?: string | undefined -} - -interface GhAuthStatus { - storage: 'keyring' | 'file' | 'unknown' - scopes: readonly string[] -} - -async function main(): Promise { - const raw = await readStdin() - let payload: PreToolUsePayload - try { - payload = raw ? JSON.parse(raw) : {} - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!command) { - process.exit(0) - } - // Cheap pre-filter: only inspect commands that mention `gh`. - if (!containsGhInvocation(command)) { - process.exit(0) - } - // The auth-status read is the slow path (~50ms). Skip it when the - // gh command is a known read-only shape that doesn't touch tokens. - // For now, run on every gh command — paranoid by default. - let status: GhAuthStatus - try { - status = readGhAuthStatus() - } catch (e) { - // gh not installed, or no active auth — let the command run and - // gh itself will report. Don't double-block. - process.exit(0) - } - // Invariant 1: keyring storage. - if (status.storage === 'file') { - fail( - 'gh-token-hygiene-guard: gh token is stored on disk', - [ - 'Your gh CLI token lives at ~/.config/gh/hosts.yml. Any local', - 'process can read it (this is exactly the path the Nx Console', - 'supply-chain malware exfiltrated in May 2026).', - '', - 'Fix:', - ' gh auth logout', - ' gh auth login # keychain is the default', - ' gh auth status # confirms "(keyring)"', - '', - 'No bypass — moving the token off disk is non-negotiable.', - ].join('\n'), - ) - } - // Invariant 4 (checked early so the user can self-recover by - // running `gh auth refresh -h github.com` even when expired). - if (!isAuthMaintenanceCommand(command) && !isTokenFresh()) { - fail( - 'gh-token-hygiene-guard: gh token is >8h old', - [ - 'The fleet enforces an 8-hour cap on gh token age. Refresh:', - ' gh auth refresh -h github.com', - '', - '(Once refreshed, the hook stamps a local timestamp and', - 'gh commands flow normally again.)', - ].join('\n'), - ) - } - // Stamp the token-issued-at file on ANY auth-refresh / login flow. - // The actual refresh runs after this hook; stamping pre-emptively is - // fine because a failed refresh leaves the old token in place (and - // the next successful refresh re-stamps). Parser-confirmed `gh auth - // login|refresh` so a quoted mention doesn't spuriously re-stamp. - if ( - parseCommands(command).some( - c => - c.binary === 'gh' && - c.args.includes('auth') && - (c.args.includes('login') || c.args.includes('refresh')), - ) - ) { - recordTokenIssuedAt() - } - // Invariant 2: workflow scope on-demand. - const isWorkflowDispatch = - isWorkflowDispatchCommand(command) || isWorkflowApiDispatch(command) - const isWorkflowRefresh = isWorkflowScopeRefresh(command) - const hasWorkflowScope = status.scopes.includes('workflow') - if (isWorkflowRefresh) { - // Revoke is always allowed (no bypass needed). - if (isWorkflowScopeRevoke(command)) { - process.exit(0) - } - // Refresh-add: chat-bypass phrase + Touch ID sudo prompt both - // required. The phrase alone isn't sufficient — an attacker who - // exfiltrates the bypass-typed slot still can't proceed without - // your physical presence. - if (!bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - fail( - 'gh-token-hygiene-guard: adding workflow scope requires bypass', - [ - `Type \`${BYPASS_PHRASE}\` in chat before running:`, - ` ${command}`, - '', - 'After the phrase, Touch ID will prompt for physical confirmation.', - ].join('\n'), - ) - } - const authResult = requireUserAuthentication() - if (authResult === 'denied') { - fail( - 'gh-token-hygiene-guard: physical-presence check failed', - [ - 'Authentication was cancelled or password did not match.', - 'Re-run your command and approve the Touch ID / password prompt.', - ].join('\n'), - ) - } - if (authResult === 'unsupported') { - const platformGuidance = platformAuthGuidance() - fail( - 'gh-token-hygiene-guard: no physical-presence auth available', - [ - 'The workflow-scope bypass requires biometric / hardware-key', - 'confirmation. Nothing was reachable in this environment.', - '', - ...platformGuidance, - ].join('\n'), - ) - } - recordWorkflowGrant(payload.session_id) - process.exit(0) - } - if (isWorkflowDispatch) { - // Block if scope is absent — nothing to dispatch with. - if (!hasWorkflowScope) { - fail( - 'gh-token-hygiene-guard: workflow dispatch requires workflow scope', - [ - 'Token does not have the `workflow` scope. To dispatch:', - ` 1. Type \`${BYPASS_PHRASE}\` in chat.`, - ' 2. Run: gh auth refresh -h github.com -s workflow', - ' 3. Re-run your dispatch command.', - ' 4. Scope auto-revokes after one dispatch.', - ].join('\n'), - ) - } - // One bypass phrase = one dispatch. Grant file must exist AND - // bind to the current session_id. Pre-creation attack (attacker - // touches the file from a different process) is rejected because - // the recorded session_id won't match the dispatch session. - if (!verifyWorkflowGrant(payload.session_id)) { - fail( - 'gh-token-hygiene-guard: workflow dispatch grant is missing, expired, or session-mismatched', - [ - 'Token has `workflow` scope, but no valid dispatch grant for', - 'this Claude session was found.', - '', - 'Each bypass phrase authorizes ONE dispatch in the SAME', - 'session it was typed. A grant from a different session, or', - 'a grant file planted by another process, will not match.', - '', - 'To dispatch:', - ' 1. Run: gh auth refresh -h github.com -r workflow', - ` 2. Type \`${BYPASS_PHRASE}\` in chat (this session).`, - ' 3. Run: gh auth refresh -h github.com -s workflow', - ' 4. Re-run your dispatch command in the SAME session.', - ].join('\n'), - ) - } - consumeWorkflowGrant() - } - process.exit(0) -} - -// True when any command segment actually invokes the `gh` binary. Uses -// the shell parser, not regex: a regex on `gh` over-matched (a path or a -// quoted string containing "gh" tripped it — see the false positives this -// hook used to throw on `grep gh`) AND under-matched (missed indirection). -// The parser reads the real binary at each segment, so `echo "gh ..."` -// (quoted, not a command) is correctly ignored and `cmd1 && gh ...` -// (chained) is caught. -function containsGhInvocation(command: string): boolean { - return findInvocation(command, { binary: 'gh' }) -} - -// A `gh` segment whose args contain `workflow` then `run`/`dispatch`. -// Parser-confirmed `gh` binary + structured arg check (the args list, -// not a raw-string regex, so a quoted "workflow run" can't trip it). -function isWorkflowDispatchCommand(command: string): boolean { - return parseCommands(command).some( - c => - c.binary === 'gh' && - c.args.includes('workflow') && - (c.args.includes('run') || c.args.includes('dispatch')), - ) -} - -// `gh api …/actions/workflows//dispatches`. Parser-confirms the `gh` -// binary, then checks the args for the dispatches API path. -function isWorkflowApiDispatch(command: string): boolean { - return parseCommands(command).some( - c => - c.binary === 'gh' && - c.args.includes('api') && - c.args.some(a => /\/actions\/workflows\/[^/\s]+\/dispatches\b/.test(a)), - ) -} - -// `gh auth refresh` with a scope flag (`-s`/`--scopes` add, `-r`/ -// `--remove-scopes` remove) referencing `workflow`. Parser-confirms the -// `gh auth refresh` shape; the scope value can be `workflow` or a -// comma-list containing it (`-s repo,workflow`), so test each arg. -function isWorkflowScopeRefresh(command: string): boolean { - return parseCommands(command).some(c => { - if ( - c.binary !== 'gh' || - !c.args.includes('auth') || - !c.args.includes('refresh') - ) { - return false - } - // Find a scope flag, then look at the value token(s) for `workflow`. - for (let i = 0; i < c.args.length; i += 1) { - const a = c.args[i]! - const isScopeFlag = /^(?:-s|-r|--scopes|--remove-scopes)$/.test(a) - // Inline form: `--scopes=workflow` or `-sworkflow`. - if (/^(?:-s|-r|--scopes|--remove-scopes)\b.*workflow\b/.test(a)) { - return true - } - if (isScopeFlag) { - const value = c.args[i + 1] - if (value && /\bworkflow\b/.test(value)) { - return true - } - } - } - return false - }) -} - -function isWorkflowScopeRevoke(command: string): boolean { - return ( - /\bgh\s+auth\s+refresh\b/.test(command) && - /(?:^|\s)(?:-r|--remove-scopes)\b[^|;&]*\bworkflow\b/.test(command) - ) -} - -function isAuthMaintenanceCommand(command: string): boolean { - // Self-recovery commands that must run even when the age-block - // is active. Otherwise the user is locked out. - return /\bgh\s+auth\s+(?:login|logout|refresh|status)\b/.test(command) -} - -function isTokenFresh(): boolean { - if (!existsSync(TOKEN_ISSUED_AT_FILE)) { - // First run: stamp now and treat as fresh. This makes the hook - // ship-able without forcing every developer to re-auth on first - // upgrade — the 8h clock starts from the moment the hook first - // observes them. - recordTokenIssuedAt() - return true - } - try { - const recorded = Number(readFileSync(TOKEN_ISSUED_AT_FILE, 'utf8')) - if (!Number.isFinite(recorded)) { - return false - } - return Date.now() - recorded < TOKEN_TTL_MS - } catch { - return false - } -} - -function recordTokenIssuedAt(): void { - try { - mkdirSync(path.dirname(TOKEN_ISSUED_AT_FILE), { recursive: true }) - writeFileSync(TOKEN_ISSUED_AT_FILE, String(Date.now()), 'utf8') - } catch { - // best-effort - } -} - -function readGhAuthStatus(): GhAuthStatus { - const r = spawnSync('gh', ['auth', 'status'], { - stdio: 'pipe', - stdioString: true, - timeout: 5000, - }) - const text = String(r.stdout ?? '') + String(r.stderr ?? '') - if (!text) { - throw new Error('gh auth status: no output') - } - // Per-host parse. `gh auth status` lists every host the user is logged - // in to, each as its own block. We care about github.com specifically. - // Substring-matching the entire blob for `(keyring)` was a vuln: if the - // user is logged in to both github.com (file-backed) AND - // github.enterprise.com (keyring-backed), the regex sees `(keyring)` - // anywhere and concludes the github.com token is safe. - const githubComBlock = extractHostBlock(text, 'github.com') - let storage: GhAuthStatus['storage'] = 'unknown' - if (githubComBlock) { - if (/\(keyring\)|stored in:\s*keychain/i.test(githubComBlock)) { - storage = 'keyring' - } else if (/Logged in to github\.com/i.test(githubComBlock)) { - storage = 'file' - } - } - // Scopes are still parsed from the github.com block. - const scopesText = githubComBlock ?? text - const scopesMatch = scopesText.match(/Token scopes:\s*(.+)/i) - const scopes = scopesMatch - ? scopesMatch[1]!.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) - : [] - return { storage, scopes } -} - -// Extract a single host's block from `gh auth status` output. -// Block boundaries: from the line containing the host header -// (typically `github.com` or `github.enterprise.com` as the FIRST -// non-blank chars on its own line, optionally followed by `:`) to -// the next host header OR EOF. -function extractHostBlock(text: string, host: string): string | undefined { - const lines = text.split('\n') - // Match the host header — a line starting with the host name (with - // optional `:` suffix) at zero or low indent. - const headerRe = /^\S+/ - let start = -1 - let end = lines.length - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - if (!headerRe.test(line)) { - continue - } - const trimmed = line.trim().replace(/:$/, '') - if (start === -1) { - if (trimmed === host) { - start = i - } - } else { - // Already inside our block — next header line ends it. - end = i - break - } - } - if (start === -1) { - return undefined - } - return lines.slice(start, end).join('\n') -} - -// Grant body is `\n`. The session_id binds the -// grant to the Claude session that authorized it — an attacker who -// pre-creates the file (postinstall, .envrc) cannot guess a session_id -// the hook would later receive on dispatch. Presence-only was vulnerable -// to pre-creation; session-binding closes that gap. -function recordWorkflowGrant(sessionId: string | undefined): void { - if (!sessionId) { - // No session_id from harness — refuse to record. The dispatch - // step would have no way to verify; failing closed here is safer - // than recording an unverifiable grant. - return - } - try { - mkdirSync(path.dirname(WORKFLOW_GRANT_FILE), { recursive: true }) - writeFileSync(WORKFLOW_GRANT_FILE, `${sessionId}\n${Date.now()}`, 'utf8') - } catch { - // best-effort; if we can't write, the next dispatch will still - // require a fresh bypass phrase, so no security regression. - } -} - -// Returns true iff the grant file exists AND its session_id matches -// the current session. An attacker-planted grant from a different -// (or no) session is rejected. -function verifyWorkflowGrant(sessionId: string | undefined): boolean { - if (!sessionId) { - return false - } - if (!existsSync(WORKFLOW_GRANT_FILE)) { - return false - } - try { - const body = readFileSync(WORKFLOW_GRANT_FILE, 'utf8') - const recordedSessionId = body.split('\n')[0]?.trim() ?? '' - return recordedSessionId === sessionId - } catch { - return false - } -} - -function consumeWorkflowGrant(): void { - try { - rmSync(WORKFLOW_GRANT_FILE, { force: true }) - } catch { - // best-effort - } -} - -// Detect MDM-managed Macs (iru / Jamf / Mosyle / Kandji) where -// osascript is likely intercepted by org policy. **Filesystem-only -// detection** — we MUST NOT probe osascript itself, because the probe -// invocation triggers the same "Process Blocked" toast we're trying -// to avoid. Past variant: a `osascript -e 'return "probe"'` healthcheck -// surfaced the iru block toast on every hook invocation. -// -// Detection signals (presence of any known MDM-blocker install path): -// * iru: /Library/Application Support/iru -// * Jamf: /usr/local/jamf/bin/jamf or /Library/Application Support/JAMF -// * Mosyle: /usr/local/bin/mosyle or /Library/Mosyle -// * Kandji: /Library/Kandji -// -// False-positive cost: hook returns 'unsupported' for a working -// osascript, user gets pointed at Touch ID — recoverable. -// False-negative cost: hook tries osascript, user sees ONE toast per -// bypass (acceptable, much better than ONE PER HOOK INVOCATION). -// -// Result is cached for the lifetime of this hook invocation. -let mdmBlockerDetectedCache: boolean | undefined -function isOsascriptBlocked(): boolean { - if (mdmBlockerDetectedCache !== undefined) { - return mdmBlockerDetectedCache - } - // osascript missing entirely (non-darwin or stripped install). - if (!existsSync(OSASCRIPT_BIN)) { - mdmBlockerDetectedCache = true - return true - } - const mdmPaths = [ - '/Library/Application Support/iru', - '/usr/local/jamf/bin/jamf', - '/Library/Application Support/JAMF', - '/usr/local/bin/mosyle', - '/Library/Mosyle', - '/Library/Kandji', - ] - for (let i = 0; i < mdmPaths.length; i += 1) { - if (existsSync(mdmPaths[i]!)) { - mdmBlockerDetectedCache = true - return true - } - } - mdmBlockerDetectedCache = false - return false -} - -// Platform-specific setup guidance for the 'no auth method' error. -// Tailored to which paths actually work on each OS: -// - macOS: Touch ID via pam_tid.so (best). osascript fallback if no -// MDM blocker is present. -// - Linux: pam_u2f (YubiKey / FIDO2) or pam_fprintd (laptop -// fingerprint reader) — both layered onto sudo via PAM. -// - Windows: no clean path. Run releases from a macOS / Linux host. -function platformAuthGuidance(): readonly string[] { - if (process.platform === 'win32') { - return [ - 'Windows has no equivalent to Touch ID / pam_u2f reachable from', - 'a Node child process. Options:', - ' * Run gh workflow dispatches from a macOS or Linux machine.', - ' * Use the GitHub web UI (Actions → Run workflow) instead.', - ] - } - if (process.platform === 'darwin') { - const osBlocked = isOsascriptBlocked() - const mdmNote = osBlocked - ? [ - 'An MDM (iru / Jamf / Mosyle / Kandji) is intercepting', - 'osascript on this machine, so the password-dialog fallback', - 'is unusable. Touch ID is the only working path.', - '', - ] - : [] - return [ - ...mdmNote, - 'Enable Touch ID for sudo (copy-paste verbatim — `EOF` MUST be', - 'at column 0, no leading whitespace, or the heredoc will hang):', - '', - "sudo tee /etc/pam.d/sudo_local <<'EOF'", - 'auth sufficient pam_tid.so', - 'EOF', - '', - 'Then re-run your gh command — Touch ID will prompt.', - 'Mac without Touch ID hardware + MDM-blocked osascript = no path;', - 'use the GitHub web UI to dispatch instead.', - ] - } - // Linux / BSD / other POSIX. - return [ - 'Layer a biometric / hardware-key onto sudo via PAM. Two common', - 'options — pick the one matching your hardware:', - '', - ' YubiKey (or any FIDO2 device):', - ' sudo apt install libpam-u2f # Debian/Ubuntu', - ' sudo dnf install pam-u2f # Fedora/RHEL', - ' pamu2fcfg | sudo tee -a /etc/u2f_mappings', - ' # Then add to /etc/pam.d/sudo (above @include common-auth):', - ' # auth sufficient pam_u2f.so authfile=/etc/u2f_mappings', - '', - ' Laptop fingerprint reader (ThinkPad / Framework / some Dells):', - ' sudo apt install libpam-fprintd fprintd # Debian/Ubuntu', - ' sudo dnf install fprintd-pam # Fedora/RHEL', - ' fprintd-enroll', - ' # Then add to /etc/pam.d/sudo (above @include common-auth):', - ' # auth sufficient pam_fprintd.so', - '', - 'Test with `sudo -k && sudo -n true` — if it returns 0 silently,', - 'the hook will recognize it as a physical-presence success.', - ] -} - -type AuthResult = 'authenticated' | 'denied' | 'unsupported' - -/** - * Verify physical presence via the OS. Tries Touch ID (if sudo is configured - * with pam_tid.so) first; falls back to an osascript password dialog validated - * against the user's account. - * - * Returns: 'authenticated' — user proved presence 'denied' — user cancelled or - * password did not match 'unsupported' — neither path available (non-macOS, no - * osascript) - */ -function requireUserAuthentication(): AuthResult { - // Windows: no equivalent path. Windows Hello requires a UWP context - // (UserConsentVerifier) not reachable from a regular Node child. - // runas + UAC is a click, not physical presence. - if (process.platform === 'win32') { - return 'unsupported' - } - // Path 1: physical-presence via PAM-backed sudo. - // macOS: pam_tid.so (Touch ID). - // Linux: pam_u2f.so (YubiKey / FIDO2) OR pam_fprintd.so (fingerprint - // reader on supported laptops). - // If PAM is configured to make these "sufficient" auth methods, then - // `sudo -n true` (non-interactive) succeeds silently after physical - // confirmation. If PAM falls through to password, `-n` blocks and - // we fall through here. - const sudoBin = resolveSudoBin() - if (sudoBin) { - // Invalidate any cached sudo timestamp so the user can't accidentally - // skip the prompt. -k is silent and always exits 0. - spawnSync(sudoBin, ['-k'], { stdio: 'ignore', timeout: 2000 }) - // -n suppresses the TTY password prompt. If pam_tid.so / pam_u2f / - // pam_fprintd is configured "sufficient" in the auth stack, sudo - // presents the system biometric dialog (no TTY needed) and -n - // still allows it to succeed. - const touchIdResult = spawnSync(sudoBin, ['-n', 'true'], { - stdio: 'ignore', - timeout: 30_000, - }) - if (touchIdResult.status === 0) { - return 'authenticated' - } - } - // Path 2: macOS-only — osascript password prompt + dscl validation. - // Linux/BSD: no GUI-portable fallback that works across distros - // without assuming a specific desktop (zenity/kdialog/gum all have - // packaging caveats). Falls back to 'unsupported' on non-darwin. - // macOS-with-MDM-blocker: skipped via isOsascriptBlocked() to avoid - // surfacing the "Process Blocked" toast. - if (process.platform !== 'darwin') { - return 'unsupported' - } - if (isOsascriptBlocked()) { - return 'unsupported' - } - // `display dialog` runs in osascript's own UI process — it does NOT - // require Automation / System Events permissions (which Claude Code - // typically doesn't have). Bare `display dialog` works without any - // privacy prompt the first time. - const dialogScript = - 'display dialog ' + - '"Authenticate to authorize workflow scope bypass.\\n\\n' + - 'This step is required even after the chat bypass phrase." ' + - 'default answer "" with hidden answer with title "gh-token-hygiene-guard" ' + - 'buttons {"Cancel", "Authenticate"} default button "Authenticate" with icon caution\n' + - 'return text returned of result' - const dialog = spawnSync(OSASCRIPT_BIN, ['-e', dialogScript], { - stdio: ['ignore', 'pipe', 'pipe'], - stdioString: true, - timeout: 120_000, - }) - if (dialog.status !== 0) { - // Reached only when isOsascriptBlocked() returned false (no MDM - // signal on disk) but the dialog still errored. Most common cause: - // user clicked Cancel. Treat as 'denied' (cancellation message). - return 'denied' - } - const password = String(dialog.stdout ?? '').replace(/\n$/, '') - if (!password) { - return 'denied' - } - // Validate against the user's account via dscl. -authonly returns - // exit 0 on match, non-zero otherwise. The password never touches - // disk; it flows through stdin only. - const user = process.env['USER'] ?? '' - if (!user) { - return 'unsupported' - } - const dscl = spawnSync(DSCL_BIN, ['.', '-authonly', user], { - stdio: ['pipe', 'ignore', 'ignore'], - input: password, - stdioString: true, - timeout: 10_000, - }) - if (dscl.status === 0) { - // Password fallback worked. If Touch ID isn't configured for sudo, - // surface a one-time educational nudge so the user can set it up - // and skip the password dialog on future bypasses. - maybePrintTouchIdSetupNudge() - return 'authenticated' - } - return 'denied' -} - -const TOUCH_ID_NUDGED_FILE = path.join( - os.homedir(), - '.claude', - 'gh-touch-id-setup-nudged', -) - -function maybePrintTouchIdSetupNudge(): void { - // Already configured → no nudge needed. - if (isTouchIdSudoConfigured()) { - return - } - // Already shown the nudge → don't repeat. - if (existsSync(TOUCH_ID_NUDGED_FILE)) { - return - } - try { - mkdirSync(path.dirname(TOUCH_ID_NUDGED_FILE), { recursive: true }) - writeFileSync(TOUCH_ID_NUDGED_FILE, String(Date.now()), 'utf8') - } catch { - // best-effort; if we can't write the sentinel, the nudge prints - // again next time — minor annoyance, no security impact. - } - process.stderr.write( - [ - '', - 'TIP — skip the password dialog next time: enable Touch ID for sudo.', - '', - 'Run this once (copy-paste verbatim; `EOF` must be at column 0,', - 'no leading whitespace, or the heredoc will hang):', - '', - "sudo tee /etc/pam.d/sudo_local <<'EOF'", - 'auth sufficient pam_tid.so', - 'EOF', - '', - 'What this does:', - " /etc/pam.d/sudo_local is macOS Sonoma+'s sudo PAM extension", - " point (Apple's officially-supported way to layer auth methods).", - ' The line adds pam_tid.so as a `sufficient` auth method — meaning', - ' sudo tries Touch ID first and falls back to your password if', - ' Touch ID is unavailable (lid closed, no fingerprint enrolled,', - ' declined). The file is preserved across macOS updates, unlike', - ' /etc/pam.d/sudo which is replaced on every system upgrade.', - '', - "After the one-time setup, this hook's bypass-auth step pops a", - 'Touch ID dialog instead of asking for your password.', - '', - 'This tip is shown once. Full doc:', - ' docs/claude.md/fleet/gh-token-hygiene.md', - '', - ].join('\n'), - ) -} - -function isTouchIdSudoConfigured(): boolean { - // pam_tid.so can be in either /etc/pam.d/sudo_local (Sonoma+ preferred - // location) or directly in /etc/pam.d/sudo (older systems / manual - // edits). Either is "configured". - for (const f of ['/etc/pam.d/sudo_local', '/etc/pam.d/sudo']) { - try { - if (existsSync(f)) { - const content = readFileSync(f, 'utf8') - // Detect lines like `auth ... pam_tid.so` (whitespace-flexible). - if (/^\s*auth\b.*\bpam_tid\.so\b/m.test(content)) { - return true - } - } - } catch { - // Unreadable → assume not configured. - } - } - return false -} - -function fail(headline: string, body: string): never { - process.stderr.write(`\n${headline}\n\n${body}\n\n`) - process.exit(2) -} - -main().catch(() => { - // Fail open on internal errors — don't break Claude Code's tool - // pipeline if our hook itself crashes. - process.exit(0) -}) diff --git a/.claude/hooks/gh-token-hygiene-guard/package.json b/.claude/hooks/gh-token-hygiene-guard/package.json deleted file mode 100644 index f006ca496..000000000 --- a/.claude/hooks/gh-token-hygiene-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-gh-token-hygiene-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts b/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts deleted file mode 100644 index c60e5d081..000000000 --- a/.claude/hooks/gh-token-hygiene-guard/test/index.test.mts +++ /dev/null @@ -1,384 +0,0 @@ -// node --test specs for the gh-token-hygiene-guard hook. -// -// The hook shells out to `gh auth status`. To make tests deterministic -// we stage a fake `gh` binary on PATH that prints scripted output, and -// point the timestamp-file env override at a tmpdir so grant state -// doesn't bleed between tests. - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -interface RunOptions { - // What the fake `gh auth status` should print. - ghStatusOutput?: string | undefined - // Pretend a transcript with this body exists. Path passed as - // transcript_path to the hook. - transcriptText?: string | undefined - // The Bash command to feed via tool_input.command. - command: string - // Pre-create the workflow-grant file body. Use a string to set the - // body content (e.g. a session_id for a valid grant, or 'wrong-session' - // for a mismatch test). Set to `true` to record with the same - // session_id the hook sees ('test-session-id'). Omit for no grant. - hasGrant?: boolean | string | undefined - // session_id passed to the hook (defaults to 'test-session-id'). - sessionId?: string | undefined -} - -const TEST_SESSION_ID = 'test-session-id' - -async function runHook( - opts: RunOptions, -): Promise { - const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-hyg-')) - // Fake gh binary: prints scripted output to stdout, exits 0. - const fakeGh = path.join(tmp, 'gh') - const body = (opts.ghStatusOutput ?? '').replace(/'/g, "'\\''") - writeFileSync(fakeGh, `#!/bin/sh\nprintf '%s\\n' '${body}'\n`) - chmodSync(fakeGh, 0o755) - // Fake HOME so the grant file lands in tmpdir. - const fakeHome = path.join(tmp, 'home') - mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) - const grantFile = path.join(fakeHome, '.claude', 'gh-workflow-grant') - if (opts.hasGrant === true) { - // Valid grant: bind to the test session id. - writeFileSync(grantFile, `${TEST_SESSION_ID}\n${Date.now()}`) - } else if (typeof opts.hasGrant === 'string') { - // Caller-specified body (e.g. 'wrong-session' to simulate mismatch). - writeFileSync(grantFile, `${opts.hasGrant}\n${Date.now()}`) - } - let transcriptPath: string | undefined - if (opts.transcriptText !== undefined) { - transcriptPath = path.join(tmp, 'transcript.jsonl') - // Minimal transcript line shape: { role: 'user', content: '...' } - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: opts.transcriptText }, - }) + '\n', - ) - } - const env: NodeJS.ProcessEnv = { - ...process.env, - PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, - HOME: fakeHome, - } - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe', env }) - void child.catch(() => undefined) - child.stdin!.end( - JSON.stringify({ - tool_name: 'Bash', - tool_input: { command: opts.command }, - transcript_path: transcriptPath, - session_id: opts.sessionId ?? TEST_SESSION_ID, - }), - ) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - // Inspect grant file BEFORE cleanup - let grantStillExists = false - try { - grantStillExists = existsSync(grantFile) - } catch {} - try { - rmSync(tmp, { recursive: true, force: true }) - } catch {} - resolve({ code: code ?? 0, stderr, grantStillExists }) - }) - }) -} - -const KEYRING_OUTPUT_NO_WORKFLOW = [ - 'github.com', - ' ✓ Logged in to github.com account jdalton (keyring)', - " - Token scopes: 'read:org', 'repo'", -].join('\n') - -const KEYRING_OUTPUT_WITH_WORKFLOW = [ - 'github.com', - ' ✓ Logged in to github.com account jdalton (keyring)', - " - Token scopes: 'read:org', 'repo', 'workflow'", -].join('\n') - -const FILE_STORAGE_OUTPUT = [ - 'github.com', - ' ✓ Logged in to github.com account jdalton', - " - Token scopes: 'read:org', 'repo'", -].join('\n') - -test('non-gh Bash passes', async () => { - const r = await runHook({ - command: 'ls -la', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - }) - assert.strictEqual(r.code, 0) -}) - -test('grep that mentions gh as a search string is NOT a gh invocation', async () => { - // Regression: the old regex matched `gh ` anywhere, so a grep for - // "gh workflow" tripped the guard. The parser reads the real binary - // (grep), so this passes regardless of gh storage state. - const r = await runHook({ - command: 'grep -n "gh workflow run" some-file.mts', - ghStatusOutput: FILE_STORAGE_OUTPUT, - }) - assert.strictEqual(r.code, 0) -}) - -test('echo of a quoted gh command is NOT a gh invocation', async () => { - const r = await runHook({ - command: 'echo "run gh auth login to fix"', - ghStatusOutput: FILE_STORAGE_OUTPUT, - }) - assert.strictEqual(r.code, 0) -}) - -test('chained real gh invocation is still caught', async () => { - // The parser must still SEE a real gh command in a chain. - const r = await runHook({ - command: 'echo start && gh pr list', - ghStatusOutput: FILE_STORAGE_OUTPUT, - }) - assert.strictEqual(r.code, 2) -}) - -test('on-disk gh storage is blocked', async () => { - const r = await runHook({ - command: 'gh pr list', - ghStatusOutput: FILE_STORAGE_OUTPUT, - }) - assert.strictEqual(r.code, 2) - assert.match(r.stderr, /stored on disk/) -}) - -test('keyring storage + non-dispatch gh command passes', async () => { - const r = await runHook({ - command: 'gh pr list', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow dispatch without workflow scope is blocked', async () => { - const r = await runHook({ - command: 'gh workflow run publish.yml', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - }) - assert.strictEqual(r.code, 2) - assert.match(r.stderr, /workflow scope/i) -}) - -test('workflow dispatch with scope + unconsumed grant passes', async () => { - const r = await runHook({ - command: 'gh workflow run publish.yml', - ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, - hasGrant: true, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow dispatch consumes the grant (single-use)', async () => { - const r = await runHook({ - command: 'gh workflow run publish.yml', - ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, - hasGrant: true, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual( - r.grantStillExists, - false, - 'grant file should be deleted after a single dispatch', - ) -}) - -test('workflow dispatch with scope + missing grant is blocked', async () => { - const r = await runHook({ - command: 'gh workflow run publish.yml', - ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, - }) - assert.strictEqual(r.code, 2) - assert.match(r.stderr, /missing, expired, or session-mismatched/) -}) - -test('workflow dispatch with attacker-planted grant (wrong session) blocked', async () => { - // Simulates the pre-creation attack: a malicious postinstall writes - // ~/.claude/gh-workflow-grant with some arbitrary content (or a - // session_id from a previous, legitimate session). The hook MUST - // reject because the recorded session_id doesn't match the current - // session_id. - const r = await runHook({ - command: 'gh workflow run publish.yml', - ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, - hasGrant: 'attacker-planted-session-xxx', - }) - assert.strictEqual(r.code, 2) - assert.match(r.stderr, /session-mismatched/) -}) - -test('refresh -s workflow without bypass is blocked', async () => { - const r = await runHook({ - command: 'gh auth refresh -h github.com -s workflow', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - }) - assert.strictEqual(r.code, 2) - assert.match(r.stderr, /requires bypass/) -}) - -// Bypass-phrase normalization (hyphen vs space, em-dashes, etc.) is -// unit-tested directly in _shared/transcript.test.mts. End-to-end -// here only verifies block/allow behavior at the hook boundary; -// the OS-auth path (sudo + dscl + osascript on absolute /usr/bin/ -// paths) is intentionally unreachable in unit tests — testing it -// would require either an env-var bypass (rejected on security -// grounds) or a /usr/bin/ overlay (rejected as fragile / dangerous). -// The auth path is exercised by manual smoke-testing on the -// developer's machine when the hook ships. - -test('refresh -s workflow with bypass phrase passes the bypass-detect gate', async () => { - // With the bypass phrase present, the hook proceeds past the - // bypass-detect gate and runs OS-auth. The OS-auth outcome is - // environment-dependent — on a Touch-ID-configured developer - // machine `sudo -n true` succeeds silently and the hook records - // the grant; in CI / on a fresh box, `sudo -n` errors and the - // hook falls through to the osascript dialog (which is denied - // without a TTY). Both are acceptable outcomes — what this test - // verifies is that the bypass-MISSING error is NOT what we get. - const r = await runHook({ - command: 'gh auth refresh -h github.com -s workflow', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - transcriptText: 'Allow workflow-scope bypass', - }) - // Must NOT be the bypass-missing branch (which would say "requires bypass"). - assert.doesNotMatch(r.stderr, /requires bypass/) - // Exit code is 0 (auth succeeded, grant recorded) OR 2 (auth denied). - assert.ok( - r.code === 0 || r.code === 2, - `unexpected exit code ${r.code} (stderr: ${r.stderr})`, - ) -}) - -test('refresh -r workflow (revoke) passes without bypass', async () => { - const r = await runHook({ - command: 'gh auth refresh -h github.com -r workflow', - ghStatusOutput: KEYRING_OUTPUT_WITH_WORKFLOW, - }) - assert.strictEqual(r.code, 0) -}) - -test('gh api workflow dispatch shape is also blocked', async () => { - const r = await runHook({ - command: - 'gh api -X POST repos/foo/bar/actions/workflows/publish.yml/dispatches -f ref=main', - ghStatusOutput: KEYRING_OUTPUT_NO_WORKFLOW, - }) - assert.strictEqual(r.code, 2) -}) - -test('expired token age (>8h) blocks non-auth commands', async () => { - // Pre-stamp the issued-at file with an old timestamp by running - // through the hook with HOME pointing at our tmpdir. - const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-age-')) - const fakeHome = path.join(tmp, 'home') - mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) - writeFileSync( - path.join(fakeHome, '.claude', 'gh-token-issued-at'), - String(Date.now() - 9 * 60 * 60 * 1000), // 9h ago - ) - const fakeGh = path.join(tmp, 'gh') - writeFileSync( - fakeGh, - `#!/bin/sh\nprintf '%s\\n' '${KEYRING_OUTPUT_NO_WORKFLOW.replace(/'/g, "'\\''")}'\n`, - ) - chmodSync(fakeGh, 0o755) - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { - ...process.env, - PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, - HOME: fakeHome, - }, - }) - void child.catch(() => undefined) - child.stdin!.end( - JSON.stringify({ - tool_name: 'Bash', - tool_input: { command: 'gh pr list' }, - }), - ) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code = await new Promise(resolve => { - child.process.on('exit', c => { - try { - rmSync(tmp, { recursive: true, force: true }) - } catch {} - resolve(c ?? 0) - }) - }) - assert.strictEqual(code, 2) - assert.match(stderr, />8h old/) -}) - -test('expired token age allows gh auth refresh (self-recovery)', async () => { - const tmp = mkdtempSync(path.join(os.tmpdir(), 'gh-age-r-')) - const fakeHome = path.join(tmp, 'home') - mkdirSync(path.join(fakeHome, '.claude'), { recursive: true }) - writeFileSync( - path.join(fakeHome, '.claude', 'gh-token-issued-at'), - String(Date.now() - 9 * 60 * 60 * 1000), - ) - const fakeGh = path.join(tmp, 'gh') - writeFileSync( - fakeGh, - `#!/bin/sh\nprintf '%s\\n' '${KEYRING_OUTPUT_NO_WORKFLOW.replace(/'/g, "'\\''")}'\n`, - ) - chmodSync(fakeGh, 0o755) - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { - ...process.env, - PATH: `${tmp}${path.delimiter}${process.env['PATH'] ?? ''}`, - HOME: fakeHome, - }, - }) - void child.catch(() => undefined) - child.stdin!.end( - JSON.stringify({ - tool_name: 'Bash', - tool_input: { command: 'gh auth refresh -h github.com' }, - }), - ) - const code = await new Promise(resolve => { - child.process.on('exit', c => { - try { - rmSync(tmp, { recursive: true, force: true }) - } catch {} - resolve(c ?? 0) - }) - }) - assert.strictEqual(code, 0) -}) diff --git a/.claude/hooks/gh-token-hygiene-guard/tsconfig.json b/.claude/hooks/gh-token-hygiene-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/gh-token-hygiene-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/gitmodules-comment-guard/README.md b/.claude/hooks/gitmodules-comment-guard/README.md deleted file mode 100644 index 746b54c37..000000000 --- a/.claude/hooks/gitmodules-comment-guard/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# gitmodules-comment-guard - -A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls -which would land a `[submodule "..."]` section in `.gitmodules` -without the canonical `# -` comment immediately above -it. - -## Why this rule - -The Socket fleet's lockstep harness uses the `# slug-version` annotation -to surface upstream version drift in its update reports. Without it, -`pnpm run lockstep` can't tell whether a submodule pin reflects v1.0 or -v3.5 of the upstream — the report is meaningless. Adding the comment -costs one line; missing it silently breaks the drift surface. - -## Conventional shape - -```gitmodules -# semver-7.7.4 -[submodule "packages/node-smol-builder/upstream/semver"] - path = packages/node-smol-builder/upstream/semver - url = https://github.com/npm/node-semver.git - ignore = dirty -``` - -The slug is short (no path); the version is whatever upstream tags -(`v25.9.0`, `1.7.19`, `liburing-2.14`, `epochs/three_hourly/2026-02-24_21H`). - -## What's enforced - -- Every `[submodule "PATH"]` line must be preceded _immediately_ (no - blank line) by `# -`. -- The slug pattern is permissive: `[a-z0-9]([a-z0-9-]*[a-z0-9])?`. -- The version is anything non-whitespace after the first hyphen. - -## What's not enforced - -- `ignore = dirty` — conventional but not blocked here. (It's a - parallel-Claude-sessions concern, not a build break.) -- Repository URL format / branch — those don't affect lockstep. - -## Override marker - -For a legitimate one-off where the comment doesn't apply: - -```gitmodules -[submodule "..."] # socket-hook: allow gitmodules-no-comment -``` - -Don't reach for this — fix the comment instead. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/gitmodules-comment-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This hook lives in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/gitmodules-comment-guard) -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/gitmodules-comment-guard/index.mts b/.claude/hooks/gitmodules-comment-guard/index.mts deleted file mode 100644 index 16c3d43d1..000000000 --- a/.claude/hooks/gitmodules-comment-guard/index.mts +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — gitmodules-comment-guard. -// -// Blocks Edit/Write tool calls that introduce a `[submodule "..."]` -// section into `.gitmodules` without the canonical `# -` -// comment immediately above it. Without that comment, the harness -// can't surface upstream version drift in the `lockstep` reports — the -// fleet relies on this annotation to know what version each pinned -// submodule represents. -// -// What's enforced: -// - Every `[submodule "PATH"]` line must be preceded (immediately, -// no blank line) by `# -` where matches -// `[a-z0-9]([a-z0-9-]*[a-z0-9])?` and is whatever the -// upstream uses (`v25.9.0`, `0.1.0`, `1.7.19`, `liburing-2.14`, -// `epochs/three_hourly/2026-02-24_21H`, etc.). The version is -// the part after the FIRST hyphen — we don't try to parse it -// beyond "non-empty". -// - `ignore = dirty` is conventional but not enforced here (it's a -// parallel-Claude-sessions concern; submodule add without it is -// not a build break). -// -// Scope: -// - Fires on Edit and Write tool calls. -// - Only inspects `.gitmodules` at the repo root. -// - Lines marked `# socket-hook: allow gitmodules-no-comment` are -// exempt for one-off legitimate cases. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import process from 'node:process' - -const ALLOW_MARKER = '# socket-hook: allow gitmodules-no-comment' - -// Match `[submodule "PATH"]` with PATH captured. Tolerant of -// whitespace and quoting variations. -const SUBMODULE_RE = /^\s*\[submodule\s+"([^"]+)"\s*\]\s*$/ - -// Match `# -` where the version is whatever follows -// the first hyphen. We only require: starts with `# `, contains a -// hyphen, has non-empty version part. -const COMMENT_RE = /^#\s+[a-z0-9]+([a-z0-9-]*[a-z0-9])?-[^\s]/ - -interface Hook { - // tool_name and tool_input shape — keeping it loose because the - // PreToolUse payload schema isn't versioned beyond JSON-with-body. - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -// Read newline-separated lines for analysis. -export function findOrphanSubmoduleSections(text: string): string[] { - const lines = text.split('\n') - const orphans: string[] = [] - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (!line) { - continue - } - const match = SUBMODULE_RE.exec(line) - if (!match) { - continue - } - // Allow marker on the [submodule] line or the line above is - // a one-off escape hatch. - if (line.includes(ALLOW_MARKER)) { - continue - } - if (i > 0 && lines[i - 1]?.includes(ALLOW_MARKER)) { - continue - } - // The previous line must be a comment matching `# -`. - const prev = i > 0 ? lines[i - 1] : '' - if (!prev || !COMMENT_RE.test(prev)) { - orphans.push(match[1] ?? line) - } - } - return orphans -} - -function main() { - let stdin = '' - process.stdin.on('data', chunk => { - stdin += chunk - }) - process.stdin.on('end', () => { - // Fail OPEN on any internal bug. The JSON.parse below already has - // its own try/catch (bad payloads exit 0), but unexpected throws - // in the regex/stderr path would otherwise become unhandled - // rejections → exit 1 → block. Per CLAUDE.md, hooks must not - // brick the session on their own crash. - try { - let payload: Hook - try { - payload = JSON.parse(stdin) as Hook - } catch { - // Bad payload — fail open. - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !filePath.endsWith('/.gitmodules')) { - process.exit(0) - } - // Edit gives us new_string (the replacement); Write gives us - // content (the full new file). Either way, we scan the proposed - // text for the orphan condition. For Edit calls the new_string - // may be a fragment that doesn't contain a [submodule] header — - // that's fine, the check passes. - const proposed = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - const orphans = findOrphanSubmoduleSections(proposed) - if (orphans.length === 0) { - process.exit(0) - } - // Block the tool call. Exit code 2 makes Claude Code refuse and - // surface the stderr to the model so it can retry. - process.stderr.write( - `[gitmodules-comment-guard] refusing edit: ${orphans.length} ` + - `submodule section(s) lack the canonical ` + - `# - comment immediately above:\n` + - orphans.map(o => ` [submodule "${o}"]`).join('\n') + - '\n\nFix: prepend a comment line on the line BEFORE each\n' + - '[submodule "..."] section. Example:\n' + - '\n # semver-7.7.4\n [submodule "packages/.../upstream/semver"]\n' + - '\nThe slug should be a short name (no path); the version is\n' + - 'whatever the upstream tags (v25.9.0, 1.7.19, liburing-2.14, etc.).\n' + - '\nOne-off override: append `# socket-hook: allow gitmodules-no-comment`\n' + - 'to the [submodule] line.\n', - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[gitmodules-comment-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) - // If stdin is closed before any data, treat as empty payload. - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/gitmodules-comment-guard/package.json b/.claude/hooks/gitmodules-comment-guard/package.json deleted file mode 100644 index b5286e81f..000000000 --- a/.claude/hooks/gitmodules-comment-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-gitmodules-comment-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts b/.claude/hooks/gitmodules-comment-guard/test/index.test.mts deleted file mode 100644 index e07e5f7f2..000000000 --- a/.claude/hooks/gitmodules-comment-guard/test/index.test.mts +++ /dev/null @@ -1,135 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function runHook(payload: object): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('BLOCKS [submodule] without leading comment', () => { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: - '[submodule "vendor/foo"]\n\tpath = vendor/foo\n\turl = https://example.com/foo\n', - }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /gitmodules-comment-guard/) - assert.match(stderr, /vendor\/foo/) -}) - -test('ALLOWS [submodule] with canonical # name-version comment', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: - '# semver-7.7.4\n[submodule "vendor/semver"]\n\tpath = vendor/semver\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS multi-hyphen version (liburing-2.14)', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: '# liburing-2.14\n[submodule "vendor/liburing"]\n\tpath = x\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS v-prefixed version (v25.9.0)', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: '# node-v25.9.0\n[submodule "vendor/node"]\n\tpath = x\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('BLOCKS [submodule] when blank line separates from comment', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: - '# semver-7.7.4\n\n[submodule "vendor/semver"]\n\tpath = vendor/semver\n', - }, - }) - assert.equal(exitCode, 2) -}) - -test('ALLOWS with one-off override marker on [submodule] line', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: - '[submodule "vendor/foo"] # socket-hook: allow gitmodules-no-comment\n\tpath = x\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('IGNORES non-.gitmodules files', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitignore', - content: '[submodule "foo"]\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('IGNORES tools other than Edit/Write', () => { - const { exitCode } = runHook({ - tool_name: 'Read', - tool_input: { - file_path: '/repo/.gitmodules', - content: '[submodule "x"]', - }, - }) - assert.equal(exitCode, 0) -}) - -test('handles multiple submodules, blocks only the orphan', () => { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.gitmodules', - content: - '# a-1.0\n[submodule "a"]\n\tpath = a\n' + - '\n' + - '[submodule "b"]\n\tpath = b\n' + - '\n' + - '# c-3.0\n[submodule "c"]\n\tpath = c\n', - }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /submodule "b"/) - assert.doesNotMatch(stderr, /submodule "a"/) - assert.doesNotMatch(stderr, /submodule "c"/) -}) - -test('fails open on malformed JSON', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: 'not-json', - }) - assert.equal(result.status, 0) -}) diff --git a/.claude/hooks/gitmodules-comment-guard/tsconfig.json b/.claude/hooks/gitmodules-comment-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/gitmodules-comment-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/identifying-users-reminder/README.md b/.claude/hooks/identifying-users-reminder/README.md deleted file mode 100644 index e220fec27..000000000 --- a/.claude/hooks/identifying-users-reminder/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# identifying-users-reminder - -Stop hook that flags generic "the user" / "this user" / "the developer" references in the assistant's most-recent turn where naming or "you" would be more appropriate. - -## Why - -CLAUDE.md "Identifying users": - -> Identify users by git credentials and use their actual name. Use "you/your" when speaking directly; use names when referencing contributions. - -The failure mode this catches: the assistant says "the user wants X" instead of either: - -- "you want X" (if speaking directly), or -- "jdalton wants X" (if referencing what someone did) - -"The user" reads as bureaucratic distance — like the assistant is filing a ticket about the person rather than working with them. - -## What it catches - -| Pattern | Example | -| ---------------------------------------------- | ---------------------------------- | -| `the user wants/needs/asked/said` | "the user wants this fixed" | -| `this user` (singular reference) | "this user prefers concise output" | -| `someone wants/needs/asked` (sentence-initial) | "Someone asked about X earlier" | -| `the developer/engineer wants/needs` | "the developer prefers tabs" | - -## What it does NOT catch - -- `you` / `your` — direct address, the right shape -- `users` (plural) — talking about user populations -- `the user can` / `if a user types` — generic API/UX description (the verb list is intentionally narrow to exclude these) - -## Why it doesn't block - -Stop hooks fire after the turn. Blocking would just truncate. The warning prompts the next turn to revise the framing. - -## Configuration - -`SOCKET_IDENTIFYING_USERS_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/identifying-users-reminder/index.mts b/.claude/hooks/identifying-users-reminder/index.mts deleted file mode 100644 index ef603e129..000000000 --- a/.claude/hooks/identifying-users-reminder/index.mts +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — identifying-users-reminder. -// -// Flags assistant text that refers to the user as "the user" instead -// of by name. CLAUDE.md "Identifying users": -// -// Identify users by git credentials and use their actual name. -// Use "you/your" when speaking directly; use names when referencing -// contributions. -// -// What this hook catches: -// -// - "The user" / "this user" / "user wants" in non-quoted context. -// These are markers that the assistant is talking ABOUT the user -// rather than TO them, which usually means a missed name lookup. -// -// - "Someone" / "the developer" / "the engineer" as a generic -// third-party reference where naming would be appropriate. -// -// What this hook does NOT catch: -// -// - "you" / "your" — those are direct address, the right shape. -// - "users" (plural) — talking about user populations, not a specific -// person. -// - "the user can" / "if a user types" — generic API/UX description. -// -// The distinction: "the user wants X" (singular, definite, about a -// specific person) gets flagged; "if a user types X" (singular, -// indefinite, generic role) does not. -// -// Disable via SOCKET_IDENTIFYING_USERS_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' -import type { RuleViolation } from '../_shared/stop-reminder.mts' - -const PATTERNS: readonly RuleViolation[] = [ - { - label: 'the user wants/needs/asked/said', - // Match `the user` followed by an action verb that implies a - // specific person's intent. The verb-list is intentionally narrow - // — generic API docs say "the user can call X" which is fine. - regex: - /\b[Tt]he\s+user\s+(?:asked|chose|decided|likes|needs|picked|prefers|requested|said|wants|wrote)\b/i, - why: 'Refers to a specific person\'s intent. Use their name from `git config user.name`, or "you" if speaking directly.', - }, - { - label: 'this user (singular reference)', - regex: /\b[Tt]his\s+user\b/i, - why: 'Same — naming or "you" is the right shape.', - }, - { - label: 'someone (singular human reference)', - regex: /^Someone\s+(?:asked|needs|prefers|requested|said|wants|wrote)\b/im, - why: '"Someone" hedges around naming. If you have access to git config, use the name.', - }, - { - label: 'the developer / the engineer (third-party framing)', - regex: - /\b[Tt]he\s+(?:developer|engineer)\s+(?:asked|needs|prefers|said|wants|wrote)\b/i, - why: 'Same — name them if known, "you" if direct.', - }, -] - -await runStopReminder({ - name: 'identifying-users-reminder', - disabledEnvVar: 'SOCKET_IDENTIFYING_USERS_REMINDER_DISABLED', - patterns: PATTERNS, - closingHint: - 'CLAUDE.md "Identifying users": use the name from `git config user.name` when referencing what someone did or wants. Use "you/your" when speaking directly. "The user" reads as bureaucratic distance.', -}) diff --git a/.claude/hooks/identifying-users-reminder/package.json b/.claude/hooks/identifying-users-reminder/package.json deleted file mode 100644 index 1cbb5b709..000000000 --- a/.claude/hooks/identifying-users-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-identifying-users-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/identifying-users-reminder/test/index.test.mts b/.claude/hooks/identifying-users-reminder/test/index.test.mts deleted file mode 100644 index 736975e36..000000000 --- a/.claude/hooks/identifying-users-reminder/test/index.test.mts +++ /dev/null @@ -1,164 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'identify-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n'), - ) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags "the user wants" framing', () => { - const { path: p, cleanup } = makeTranscript( - 'The user wants this fixed before the deadline.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /identifying-users-reminder/) - assert.match(stderr, /the user/i) - } finally { - cleanup() - } -}) - -test('flags "the user asked"', () => { - const { path: p, cleanup } = makeTranscript( - 'Earlier the user asked about the cache implementation.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /the user/i) - } finally { - cleanup() - } -}) - -test('flags "this user prefers"', () => { - const { path: p, cleanup } = makeTranscript( - 'This user prefers concise output.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /this user/i) - } finally { - cleanup() - } -}) - -test('flags "the developer wrote"', () => { - const { path: p, cleanup } = makeTranscript( - 'The developer wrote this in haste.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /developer/i) - } finally { - cleanup() - } -}) - -test('flags sentence-initial "Someone asked"', () => { - const { path: p, cleanup } = makeTranscript( - 'Someone asked about this earlier.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /someone/i) - } finally { - cleanup() - } -}) - -test('does NOT flag "you want" (direct address)', () => { - const { path: p, cleanup } = makeTranscript( - 'You want this fixed before the deadline.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag "the user can call X" (generic API description)', () => { - const { path: p, cleanup } = makeTranscript( - 'The user can call X to get the result. The user must pass an object.', - ) - try { - const { stderr } = runHook(p) - // "can call" / "must pass" aren't in the verb list — these are - // generic API descriptions, not specific-intent references. - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag "users" plural', () => { - const { path: p, cleanup } = makeTranscript( - 'Users wants different things. Most users wants speed.', - ) - try { - const { stderr } = runHook(p) - // "users" plural doesn't match `the user` regex. - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Example:\n```\nthe user wants validation\n```\nPlain output here.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript('The user wants this.') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_IDENTIFYING_USERS_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/identifying-users-reminder/tsconfig.json b/.claude/hooks/identifying-users-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/identifying-users-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/immutable-release-pattern-guard/README.md b/.claude/hooks/immutable-release-pattern-guard/README.md deleted file mode 100644 index 95ca98c1e..000000000 --- a/.claude/hooks/immutable-release-pattern-guard/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# immutable-release-pattern-guard - -PreToolUse Edit/Write hook that blocks introducing a single-call -`gh release create ` into a workflow YAML file. - -## Why - -GitHub immutable releases ([GA 2025-10-28](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/)) -auto-generate a Sigstore-bundle release attestation at publish-time over -the locked asset set. The single-call `gh release create` form combines -create + upload + publish into one action, which can race the -attestation hash before all assets land — the resulting release may -publish without a verifiable attestation. - -The fleet rule is the 3-step pattern: - -```bash -gh release create "$TAG" --draft --title "$TITLE" --notes "$NOTES" -gh release upload "$TAG" -gh release edit "$TAG" --draft=false -``` - -The `--draft` flag on `gh release create` is the marker. The publish -step is `gh release edit ... --draft=false` (a different verb). - -## What it blocks - -| Pattern | Block? | -| -------------------------------------------------------------- | ------ | -| `gh release create "$TAG" --draft --title ... --notes ...` | no | -| `gh release create "$TAG" --draft=true ...` | no | -| `gh release create "$TAG" --title ... --notes ... file.tar.gz` | yes | -| `gh release create "$TAG" file.tar.gz` (drive-by) | yes | -| `gh release edit "$TAG" --draft=false` | no | -| Same pattern outside `.github/workflows/*.y*ml` | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow immutable-release-pattern bypass - -Use sparingly — releases without verifiable attestations defeat the -supply-chain audit trail downstream consumers rely on. - -## Detection - -Regex over the after-edit text: find each `gh release create` opener, -walk to the next unescaped newline (respecting backslash line -continuations), check whether the captured call includes the `--draft` -flag. Any non-draft call is a violation. - -## Related - -- Fleet doc: [`docs/claude.md/fleet/immutable-releases.md`](../../docs/claude.md/fleet/immutable-releases.md) -- Fleet doc: [`docs/claude.md/fleet/version-bumps.md`](../../docs/claude.md/fleet/version-bumps.md) -- Memory: `feedback_immutable_releases_three_step.md` diff --git a/.claude/hooks/immutable-release-pattern-guard/index.mts b/.claude/hooks/immutable-release-pattern-guard/index.mts deleted file mode 100644 index d83c7ecdc..000000000 --- a/.claude/hooks/immutable-release-pattern-guard/index.mts +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — immutable-release-pattern-guard. -// -// Blocks Edit/Write to `.github/workflows/*.y*ml` files that introduce a -// single-call `gh release create [...flags] ` pattern. -// -// GitHub immutable releases (GA 2025-10-28) attach a Sigstore-bundle -// release attestation at publish-time over the locked asset set. The -// single-call form combines create + upload + publish into one action, -// which can race the attestation hash before all assets land. The fleet -// rule is the 3-step pattern: -// -// gh release create "$TAG" --draft --title ... --notes ... -// gh release upload "$TAG" -// gh release edit "$TAG" --draft=false -// -// Detection: scan after-edit text for `gh release create` calls that do -// NOT include `--draft`. Skip when the call is followed by a `gh release -// upload` + `gh release edit ... --draft=false` pair (3-step pattern -// spread across multiple shell lines but the same workflow file). -// -// Bypass: `Allow immutable-release-pattern bypass` typed verbatim in a -// recent user turn. - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow immutable-release-pattern bypass' - -// Match a `gh release create` invocation up to the next newline that isn't -// continued by a backslash. The capture is the full call (incl. continued -// lines). Subsequent analysis decides whether it's the 3-step or single-call -// form. -export function findReleaseCreateCalls(text: string): string[] { - const calls: string[] = [] - // Find each `gh release create` opener. - const opener = /gh\s+release\s+create\b/g - let m: RegExpExecArray | null - while ((m = opener.exec(text)) !== null) { - const start = m.index - // Walk forward, collecting until an unescaped newline. - let i = start - let prevWasBackslash = false - while (i < text.length) { - const c = text[i] - if (c === '\n' && !prevWasBackslash) { - break - } - prevWasBackslash = c === '\\' - i += 1 - } - calls.push(text.slice(start, i)) - } - return calls -} - -// A single `gh release create` call is "safe" if it includes the `--draft` -// flag — that marks it as the first step of the 3-step pattern. -export function callIsDraft(call: string): boolean { - // Match `--draft` as a standalone flag (not e.g. `--draft=false`, which - // is the publish step using `gh release edit`, not `create`). - return /(?:^|\s)--draft(?:\s|$|=true)/.test(call) -} - -export function isWorkflowYaml(filePath: string): boolean { - return /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(filePath) -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -// Return the first offending (non-draft) `gh release create` call, or -// undefined if all calls in the text are draft-form. -export function findUnsafeCall(text: string): string | undefined { - for (const call of findReleaseCreateCalls(text)) { - if (!callIsDraft(call)) { - return call - } - } - return undefined -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath || !isWorkflowYaml(filePath)) { - process.exit(0) - } - - let afterText: string - if (payload.tool_name === 'Write') { - afterText = input?.content ?? input?.new_string ?? '' - } else { - const currentText = readFileSafe(filePath) - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr || !currentText.includes(oldStr)) { - process.exit(0) - } - afterText = currentText.replace(oldStr, newStr) - } - - const unsafe = findUnsafeCall(afterText) - if (!unsafe) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - const preview = unsafe.replace(/\s+/g, ' ').slice(0, 90) - process.stderr.write( - [ - '[immutable-release-pattern-guard] Blocked: single-call `gh release create` in workflow YAML', - '', - ` File: ${path.basename(filePath)}`, - ` Call: ${preview}...`, - '', - ' GitHub immutable releases (GA 2025-10-28) auto-generate a Sigstore', - ' release attestation at publish-time over the locked asset set. The', - ' single-call `gh release create ` form combines create', - ' + upload + publish into one action and can race the attestation', - ' hash before all assets land.', - '', - ' Fix — use the 3-step pattern:', - '', - ' gh release create "$TAG" \\', - ' --draft \\', - ' --title "$TITLE" \\', - ' --notes "$NOTES"', - ' gh release upload "$TAG" release/*.tar.gz release/checksums.txt', - ' gh release edit "$TAG" --draft=false', - '', - ' Detail: docs/claude.md/fleet/immutable-releases.md', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[immutable-release-pattern-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/immutable-release-pattern-guard/package.json b/.claude/hooks/immutable-release-pattern-guard/package.json deleted file mode 100644 index 14e0196a2..000000000 --- a/.claude/hooks/immutable-release-pattern-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-immutable-release-pattern-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts b/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts deleted file mode 100644 index 45178b9cd..000000000 --- a/.claude/hooks/immutable-release-pattern-guard/test/index.test.mts +++ /dev/null @@ -1,152 +0,0 @@ -// node --test specs for the immutable-release-pattern-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function tmpWorkflow(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'imm-rel-test-')) - const wfDir = path.join(dir, '.github', 'workflows') - mkdirSync(wfDir, { recursive: true }) - const p = path.join(wfDir, 'release.yml') - writeFileSync(p, content) - return p -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-workflow file passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/foo.md', - content: 'gh release create v1.0.0 file.tar.gz\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow without gh release create passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: 'jobs:\n x:\n steps:\n - run: echo hi\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('3-step pattern passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" --draft --title "$TITLE" --notes "$NOTES"\n gh release upload "$TAG" release/*.tar.gz\n gh release edit "$TAG" --draft=false\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('3-step with --draft=true also passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" --draft=true --title "$TITLE"\n gh release upload "$TAG" file.tar.gz\n gh release edit "$TAG" --draft=false\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('multi-line draft form with backslash continuations passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: |\n gh release create "$TAG" \\\n --draft \\\n --title "$TITLE" \\\n --notes "$NOTES"\n gh release upload "$TAG" file.tar.gz\n gh release edit "$TAG" --draft=false\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('single-call form (no --draft) is blocked', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: gh release create "$TAG" --title "$TITLE" --notes "$NOTES" file.tar.gz\n', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('drive-by single-call form (just files) is blocked', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: gh release create v1.0.0 file.tar.gz checksums.txt\n', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase passes', async () => { - const filePath = tmpWorkflow('') - const txDir = mkdtempSync(path.join(os.tmpdir(), 'imm-rel-tx-')) - const transcriptPath = path.join(txDir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow immutable-release-pattern bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n release:\n steps:\n - run: gh release create "$TAG" file.tar.gz\n', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/immutable-release-pattern-guard/tsconfig.json b/.claude/hooks/immutable-release-pattern-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/immutable-release-pattern-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/inline-script-defer-guard/README.md b/.claude/hooks/inline-script-defer-guard/README.md deleted file mode 100644 index e8f2bb445..000000000 --- a/.claude/hooks/inline-script-defer-guard/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# inline-script-defer-guard - -PreToolUse Edit/Write hook that blocks introducing ` -``` - -Or, for code that genuinely belongs in an external file: - -```html - -``` - -## What it covers - -| File extension | Checked? | -| -------------------------------------------------------- | --------------- | -| `.html` / `.htm` | full text | -| `.njk` / `.ejs` / `.hbs` / `.handlebars` | full text | -| `.svelte` / `.vue` / `.astro` | full text | -| `.ts` / `.tsx` / `.mts` / `.cts` / `.js` / `.jsx` / etc. | new_string only | -| anything else | not checked | - -## Bypass - -Type the canonical phrase in a new message: - - Allow inline-defer bypass - -Use sparingly — the bug is silent in production. - -## Companion: oxlint rule - -`socket/no-inline-defer-async` catches the same shape at commit time -even when edits happened outside Claude. diff --git a/.claude/hooks/inline-script-defer-guard/index.mts b/.claude/hooks/inline-script-defer-guard/index.mts deleted file mode 100644 index 95bef9fa2..000000000 --- a/.claude/hooks/inline-script-defer-guard/index.mts +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — inline-script-defer-guard. -// -// Blocks Edit/Write operations that add ` -// -// Files covered: `*.html` / `*.htm` / `*.njk` / `*.ejs` / `*.hbs` / -// `*.handlebars` / `*.svelte` / `*.vue` / `*.astro`. Also fires on TS/JS -// source files that contain HTML string literals matching the pattern — -// SSR / static-gen code paths. -// -// Bypass: `Allow inline-defer bypass` typed verbatim in a recent user turn. - -import { readFileSync } from 'node:fs' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow inline-defer bypass' - -// File extensions where we check the full text content. For other -// extensions, only the new_string is checked (template strings embedded -// in TS/JS source). -const HTML_EXT_RE = /\.(astro|ejs|handlebars|hbs|htm|html|njk|svelte|vue)$/i - -const SOURCE_EXT_RE = /\.(m?[jt]sx?|cts|cjs)$/i - -// Match each `', - '', - ' Or — if the script DOES belong in an external file:', - '', - ' ', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[inline-script-defer-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/inline-script-defer-guard/package.json b/.claude/hooks/inline-script-defer-guard/package.json deleted file mode 100644 index 43b2da593..000000000 --- a/.claude/hooks/inline-script-defer-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-inline-script-defer-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/inline-script-defer-guard/test/index.test.mts b/.claude/hooks/inline-script-defer-guard/test/index.test.mts deleted file mode 100644 index fb3bba841..000000000 --- a/.claude/hooks/inline-script-defer-guard/test/index.test.mts +++ /dev/null @@ -1,134 +0,0 @@ -// node --test specs for the inline-script-defer-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-HTML / non-source file passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/note.txt', - content: '', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('inline ', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('inline ', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('inline ', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'idef-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow inline-defer bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/page.html', - content: '', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/inline-script-defer-guard/tsconfig.json b/.claude/hooks/inline-script-defer-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/inline-script-defer-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/judgment-reminder/README.md b/.claude/hooks/judgment-reminder/README.md deleted file mode 100644 index 311836620..000000000 --- a/.claude/hooks/judgment-reminder/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# judgment-reminder - -Stop hook that flags hedging language in the assistant's most-recent turn. Two-layer detection: regex for fixed phrases, compromise.js for modal-verb judgment hedges. - -## Why - -CLAUDE.md "Judgment & self-evaluation": - -- "Default to perfectionist when you have latitude." -- "If a fix fails twice: stop, re-read top-down, state where the mental model was wrong, try something fundamentally different." - -Hedging undermines those rules — it offloads judgment back to the user instead of executing the perfectionist default. - -## What it catches - -### Fixed-phrase regex layer - -| Phrase | Why it's flagged | -| -------------------------------------------- | -------------------------------------------------------- | -| `I'm not sure` / `I am not sure` | Hedge; state a recommendation with rationale instead. | -| `you decide` / `your call` / `up to you` | Offloads judgment. Pick the recommended path. | -| `either approach works` / `either way works` | False-equivalence hedging. Pick one. | -| `let me know` / `your preference` | Hand-off phrasing. Ask one specific question or execute. | -| `maybe X` / `perhaps X` (sentence-initial) | Front-loaded uncertainty user didn't ask for. | - -### Modal-verb NLP layer (compromise.js) - -Flags first-person modals in judgment contexts: - -- `I could go either way` -- `we might want to consider` -- `I may pick the simpler approach` - -The compromise.js library tags verbs with POS so we can distinguish judgment hedges ("I could go") from technical conditionals ("the parser could throw") — regex alone would false-positive on the latter. - -**Fail-open**: if compromise.js fails to load, the hook degrades to a regex-only fallback that catches the most common shape but misses some context. - -## Why it doesn't block - -Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn. - -## Configuration - -`SOCKET_JUDGMENT_REMINDER_DISABLED=1` — turn off entirely. - -## Relationship to other reminders - -- `excuse-detector` — catches fix-vs-defer choice menus -- `perfectionist-reminder` — catches speed-vs-depth choice menus -- `judgment-reminder` (this) — catches hedging within a single position - -All three address the same underlying anti-pattern: offloading judgment the assistant should have made. - -## Dependencies - -- `compromise@14.15.0` — NLP library for POS-tagged modal-verb detection. Lazy-loaded; optional. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/judgment-reminder/index.mts b/.claude/hooks/judgment-reminder/index.mts deleted file mode 100644 index 62eeebcab..000000000 --- a/.claude/hooks/judgment-reminder/index.mts +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — judgment-reminder. -// -// Flags hedging language in the assistant's most recent turn. -// CLAUDE.md "Judgment & self-evaluation": -// - "If the request is based on a misconception, say so..." -// - "Default to perfectionist when you have latitude." -// - "If a fix fails twice: stop, re-read top-down..." -// -// Hedging ("I'm not sure", "you decide", "either approach works", -// modal "might/could/may") undermines those rules — it offloads the -// judgment back onto the user instead of executing the perfectionist -// default. -// -// What this catches: -// -// - Fixed phrases (regex): "I'm not sure", "you decide", "either -// approach works", "your call", "up to you", "let me know", etc. -// - Modal verbs (compromise.js POS): could / might / may / perhaps / -// maybe, when used as judgment hedges rather than technical -// conditionals. -// -// The compromise.js NLP layer is what makes modal detection useful: -// "this could throw" (technical conditional, OK) vs "I could go either -// way" (judgment hedge, flag). The library tags each token with POS -// and lets us inspect the verb context. Regex alone gets too many -// false positives on the technical use. -// -// Fail-open contract: if compromise.js fails to load (or its data -// initializer throws), fall back to regex-only detection — the hook -// still flags fixed phrases, just misses the modal-verb signal. -// -// Disable via SOCKET_JUDGMENT_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' -import type { ReminderHit, RuleViolation } from '../_shared/stop-reminder.mts' - -// Try-require compromise.js for modal-verb detection. Lazy + optional -// because the dep is heavy (~2.5 MB unpacked) and the fixed-phrase -// regex catches the most common hedging patterns without it. Modal -// detection is an enhancement, not a requirement — if compromise is -// missing (e.g. downstream repo didn't pnpm install the hook's deps -// yet), the hook degrades gracefully to regex-only. -interface NlpDoc { - readonly verbs: () => { - readonly out: (mode: 'array') => readonly string[] - } - readonly sentences: () => { - readonly out: (mode: 'array') => readonly string[] - } -} -type NlpFn = (text: string) => NlpDoc - -let cachedNlp: NlpFn | undefined -export async function loadCompromise(): Promise { - if (cachedNlp !== undefined) { - return cachedNlp - } - try { - const mod = await import('compromise') - const candidate = (mod as { default?: unknown | undefined }).default ?? mod - cachedNlp = - typeof candidate === 'function' ? (candidate as NlpFn) : undefined - } catch { - cachedNlp = undefined - } - return cachedNlp -} - -// Sentence-starting hedge modals — "I could go either way", "this -// might be the better path", "perhaps we should..." These read as -// the assistant deferring judgment rather than stating a position. -// -// We filter to hedge contexts (first-person subject + modal + judgment -// verb) so technical conditionals like "the parser could throw if X" -// don't false-positive. The compromise pattern matches: -// - (i|we) + (could|might|may) -// - sentence-initial perhaps/maybe + we/I/it -const HEDGE_VERB_REGEX = - /\b(i|we)\s+(could|may|might)\s+(approach|choose|consider|do|go|pick|try|use)\b/i - -export async function detectModalHedges( - text: string, -): Promise { - const nlp = await loadCompromise() - if (!nlp) { - // Fallback: regex-only. We still catch the most common shape. - const match = HEDGE_VERB_REGEX.exec(text) - if (!match) { - return [] - } - return [ - { - label: 'modal-verb hedge (regex fallback)', - why: "Modal verbs (could/might/may) used in first-person judgment context. State the position; don't hedge.", - snippet: extractSnippet(text, match.index, match[0].length), - }, - ] - } - - // Compromise.js path: walk sentences, flag any that contain a - // first-person modal in a judgment context. The library tags each - // verb with POS; we check sentence-by-sentence so the snippet is - // useful (a single sentence rather than the whole turn). - const doc = nlp(text) - const sentences = doc.sentences().out('array') - const hits: ReminderHit[] = [] - for (let i = 0, { length } = sentences; i < length; i += 1) { - const sentence = sentences[i]! - if (!HEDGE_VERB_REGEX.test(sentence)) { - continue - } - // Compromise gives us POS-aware verb detection; we use it to - // confirm the modal isn't part of a code-shape conditional like - // "could throw" / "might return" (technical, not judgment). - const sentenceDoc = nlp(sentence) - const verbs = sentenceDoc.verbs().out('array') - const hasJudgmentVerb = verbs.some(v => - /\b(approach|choose|consider|do|go|pick|try|use)\b/i.test(v), - ) - if (!hasJudgmentVerb) { - continue - } - hits.push({ - label: 'modal-verb hedge', - why: "First-person modal (could/might/may) used in judgment context. State the position; don't hedge.", - snippet: sentence.length > 80 ? sentence.slice(0, 77) + '…' : sentence, - }) - // One hit per turn is enough — flag and move on. - break - } - return hits -} - -export function extractSnippet( - text: string, - index: number, - length: number, -): string { - const start = Math.max(0, index - 30) - const end = Math.min(text.length, index + length + 30) - const prefix = start > 0 ? '…' : '' - const suffix = end < text.length ? '…' : '' - return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix -} - -const FIXED_HEDGE_PATTERNS: readonly RuleViolation[] = [ - { - label: "I'm not sure / I am not sure", - regex: /\bi['’]?m\s+not\s+sure\b|\bi\s+am\s+not\s+sure\b/i, - why: 'Hedging. State a recommendation with rationale, or say "I need to verify X" and then do it.', - }, - { - label: 'you decide / your call / up to you', - regex: /\b(you\s+decide|your\s+call|up\s+to\s+you)\b/i, - why: 'Offloads judgment. Default-perfectionist: pick the recommended path and execute.', - }, - { - label: 'either approach works / either way works', - regex: - /\b(either\s+(approach|option|path|way)\s+works|either\s+is\s+fine)\b/i, - why: 'False-equivalence hedging. Even when paths are close, name the one with the smaller blast radius and pick it.', - }, - { - label: 'let me know / your preference', - regex: /\b(let\s+me\s+know|your\s+preference|tell\s+me\s+what)\b/i, - why: 'Hand-off phrasing. If the user already gave intent, execute; if not, ask one specific question, not "let me know."', - }, - { - label: 'maybe / perhaps as judgment hedge', - regex: /^(maybe|perhaps)\s+/im, - why: 'Sentence-initial hedge. State the position; "maybe" at the front signals uncertainty the user didn\'t ask for.', - }, -] - -await runStopReminder({ - name: 'judgment-reminder', - disabledEnvVar: 'SOCKET_JUDGMENT_REMINDER_DISABLED', - patterns: FIXED_HEDGE_PATTERNS, - extraCheck: detectModalHedges, - closingHint: - 'CLAUDE.md "Judgment & self-evaluation": default to perfectionist; state the recommendation, name the trade-off, then execute. Hedging asks the user to think for you.', -}) diff --git a/.claude/hooks/judgment-reminder/package.json b/.claude/hooks/judgment-reminder/package.json deleted file mode 100644 index 0ff5c0271..000000000 --- a/.claude/hooks/judgment-reminder/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-judgment-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "compromise": "14.15.0" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/judgment-reminder/test/index.test.mts b/.claude/hooks/judgment-reminder/test/index.test.mts deleted file mode 100644 index cf19b44f0..000000000 --- a/.claude/hooks/judgment-reminder/test/index.test.mts +++ /dev/null @@ -1,140 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'judgment-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags "I\'m not sure" hedge', () => { - const { path: p, cleanup } = makeTranscript( - "I'm not sure which approach is better.", - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /judgment-reminder/) - assert.match(stderr, /I'm not sure|not sure/i) - } finally { - cleanup() - } -}) - -test('flags "you decide" offload', () => { - const { path: p, cleanup } = makeTranscript( - 'Want me to do A or B? You decide.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /you decide/i) - } finally { - cleanup() - } -}) - -test('flags "either approach works" false-equivalence', () => { - const { path: p, cleanup } = makeTranscript( - 'Either approach works for this case.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /either/i) - } finally { - cleanup() - } -}) - -test('flags first-person modal hedge ("I could go either way")', () => { - const { path: p, cleanup } = makeTranscript( - 'I could go either way on this design.', - ) - try { - const { stderr } = runHook(p) - // Either the modal-hedge match OR the "either way" fixed phrase - // (both correctly flag the same sentence; we accept either) - assert.match(stderr, /modal-verb hedge|either/i) - } finally { - cleanup() - } -}) - -test('does NOT flag technical conditional ("could throw")', () => { - const { path: p, cleanup } = makeTranscript( - 'The parser could throw if the input is malformed.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - // The "could throw" use is a technical conditional, not a judgment - // hedge — the regex pattern requires first-person subject + judgment - // verb, so it should not match. - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not flag plain prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores results keyed by file path.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Output:\n```\nI am not sure (in code)\n```\nPlain prose here.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript("I'm not sure which approach.") - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_JUDGMENT_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/judgment-reminder/tsconfig.json b/.claude/hooks/judgment-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/judgment-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/lock-step-ref-guard/README.md b/.claude/hooks/lock-step-ref-guard/README.md deleted file mode 100644 index ba3a7ed20..000000000 --- a/.claude/hooks/lock-step-ref-guard/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# lock-step-ref-guard - -PreToolUse hook (informational; never blocks) that flags malformed and stale `Lock-step` comments at the moment they land in a file. - -## Why - -Per CLAUDE.md's _Code style → Cross-port files_ rule, files that ship in multiple language implementations use a `Lock-step` comment convention to cross-reference the canonical impl. The full forms live in [`docs/claude.md/fleet/parser-comments.md`](../../../docs/claude.md/fleet/parser-comments.md) §5–6. - -The CI gate (`scripts/check-lock-step-refs.mts`) catches stale `` references at commit time, but two classes of bugs slip past it: - -1. **Typos in the `Lock-step` shape itself** — `lockstep`, `Lock step`, `Lock-step Rust:` (missing `with`/`from`), `Lock-step with: ` (missing ``). The CI regex doesn't match these, so they silently rot forever as illegitimate comments. -2. **Same-keystroke staleness** — a porter typing `// Lock-step with Rust: crates/parser-stmt/src/foo.rs` after `parser-stmt/` was renamed last week. The CI gate catches it at commit; the hook catches it at the keystroke so the porter sees the breadcrumb before committing. - -## What it catches - -**Malformed:** - -```rust -// lockstep with Go: parser.go:42 // wrong: hyphen missing -// Lock step with Go: parser.go:42 // wrong: hyphen missing -// Lock-step Rust: src/foo.rs // wrong: missing with/from -// Lock-step with: src/foo.rs // wrong: missing -// Lock-step with Go, parser.go // wrong: comma instead of colon -``` - -**Stale (when `.config/lock-step-refs.json` is present):** - -```rust -// Lock-step with Rust: crates/parser-stmt/src/foo.rs // crate doesn't exist -//! Lock-step from Go: src/parser-old/class.go // dir was renamed -``` - -**Accepted:** - -```rust -//! Lock-step with Go: src/parser/class.go -//! Lock-step from Rust: crates/parser/src/class.rs -// Lock-step with Go: parser.go:6450-6457 -// Lock-step note: reshaped for borrowck — Zig's `defer s.restore()` ... -``` - -## Scope - -- Source-file extensions: `.rs`, `.go`, `.cpp`, `.hpp`, `.h`, `.ts`, `.mts`, `.cts`, `.tsx`, `.py`, `.zig`, `.js`, `.mjs`, `.cjs`, `.jsx`. -- Skips `test/` directories and `*.test.*` files — illustrative example refs are common in tests and don't represent real port-tracking claims. -- Stale-path checking is **opt-in per repo**: requires `.config/lock-step-refs.json` to declare `` → impl-root mappings. Without the config, only malformed-shape detection runs. -- Malformed-shape detection always runs, regardless of opt-in. Typos are typos. - -## Behavior - -- Exit code 0 in all cases. Hook is informational; the next turn sees the stderr breadcrumb and can fix. -- The blocking layer is the CI gate `scripts/check-lock-step-refs.mts`, run by `pnpm check`. - -## Bypass - -- Type `Allow lock-step bypass` in a recent user message (also accepts `Allow lockstep bypass` / `Allow lock step bypass`), or -- Set `SOCKET_LOCK_STEP_REF_GUARD_DISABLED=1`. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/lock-step-ref-guard/index.mts b/.claude/hooks/lock-step-ref-guard/index.mts deleted file mode 100644 index 56fe561e3..000000000 --- a/.claude/hooks/lock-step-ref-guard/index.mts +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — lock-step-ref-guard. -// -// Flags two failure modes in `Lock-step` comments at the moment they -// land in a file, before they reach CI (which is gated separately by -// `scripts/check-lock-step-refs.mts`): -// -// 1. STALE — the comment names a path that no longer exists in the -// target impl. The CI gate also catches this; the hook catches it -// one keystroke earlier so the porter can fix as they type. -// 2. MALFORMED — the comment uses an almost-right shape (`lockstep`, -// `Lock step`, `Lock-step Go:` missing `with`/`from`, missing the -// `: ` separator). These wouldn't be matched by the -// CI scanner at all — they'd silently rot forever. The hook is -// the only place that catches the typo class. -// -// Convention spec: `docs/claude.md/fleet/parser-comments.md` §5–6. -// Recognized forms: -// -// //! Lock-step with : (canonical side) -// //! Lock-step from : (port side) -// // Lock-step with : [:] (inline cross-ref) -// // Lock-step note: (rationale; not validated) -// -// Behavior: -// - Exits 0 in all cases. Hook is informational; the breadcrumb in -// stderr is the next-turn nudge. The blocking layer is the CI -// gate in `pnpm check`. -// - Opt-in per repo: when `.config/lock-step-refs.json` is absent, -// STALE checks are skipped (the gate is disabled at the repo -// level). MALFORMED checks always run — they detect typos -// regardless of whether the repo has opted into validation. -// - Only fires for the new content the edit introduces. Comments -// that were already in the file but unchanged aren't re-flagged. -// -// Scope: -// - Source-file extensions: .rs, .go, .cpp, .hpp, .h, .ts, .mts, -// .cts, .tsx, .py, .zig, .js, .mjs, .cjs, .jsx. -// - Skips test/ directories and *.test.* files — illustrative -// example refs are common in tests. -// -// Bypass: type `Allow lock-step bypass` in a recent user message, -// or set SOCKET_LOCK_STEP_REF_GUARD_DISABLED=1. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: unknown | undefined - readonly content?: unknown | undefined - readonly new_string?: unknown | undefined - } - | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -interface LockStepConfig { - readonly roots: Readonly> - readonly scan: readonly string[] - readonly extensions: readonly string[] -} - -const ENV_DISABLE = 'SOCKET_LOCK_STEP_REF_GUARD_DISABLED' -const BYPASS_PHRASES = [ - 'Allow lock-step bypass', - 'Allow lockstep bypass', - 'Allow lock step bypass', -] as const - -const SOURCE_EXT_RE = - /\.(?:cjs|cpp|cts|go|h|hh|hpp|js|jsx|mjs|mts|py|rs|ts|tsx|zig)$/ - -// Canonical form: `Lock-step (with|from) : [:]`. -// Path must contain `.` or `/` so prose like "Lock-step with Go: JSON -// parser" doesn't false-positive. -const CANONICAL_RE = - /Lock-step (from|with) ([A-Za-z][A-Za-z0-9+#-]*): ([^\s:,]*[./][^\s:,]*)(?::(\d+(?:-\d+)?))?/g - -// Note form is rationale-only; we accept it but don't validate. -const NOTE_RE = /Lock-step note:/ - -// Common typos / near-misses we catch as MALFORMED. Each pattern is a -// shape that LOOKS like a lock-step comment but isn't quite right. -// -// 1. Lowercased / unhyphenated: `lockstep`, `lock step`, `Lockstep`. -// 2. Missing `with`/`from`/`note` discriminator: `Lock-step Rust: …`. -// 3. Hyphen-in-Lang gone wrong: `Lock-step with: …` (no lang). -// 4. Comma instead of colon: `Lock-step with Rust, src/foo.rs`. -const MALFORMED_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly hint: string -}> = [ - { - re: /\blockstep\b/i, - hint: - 'spell it "Lock-step" with a hyphen — the canonical form ' + - 'matches `grep -r "Lock-step"`', - }, - { - re: /\bLock[ _]step\b/, - hint: - 'use a hyphen — write "Lock-step" not "Lock step" or "Lock_step" ' + - 'so the audit grep is uniform', - }, - { - re: /Lock-step (?!(?:from|note|with)\b)[A-Z]/, - hint: - 'missing discriminator — write "Lock-step with :" or ' + - '"Lock-step from :" or "Lock-step note:"', - }, - { - re: /Lock-step (?:from|with) :/, - hint: - 'missing token — write "Lock-step with Go: " ' + - 'not "Lock-step with : "', - }, - { - re: /Lock-step (?:from|with) [A-Za-z][A-Za-z0-9+#-]*,\s/, - hint: - 'use ":" between and , not "," — ' + - '"Lock-step with Go: parser.go" not "Lock-step with Go, parser.go"', - }, -] - -export function checkStale( - refs: readonly MatchedRef[], - config: LockStepConfig, - repoRoot: string, -): StaleHit[] { - const hits: StaleHit[] = [] - for (let i = 0, { length } = refs; i < length; i += 1) { - const ref = refs[i]! - const roots = config.roots[ref.lang] - if (!roots || !roots.length) { - hits.push({ - lineNumber: ref.lineNumber, - preview: ref.preview, - reason: 'unknown-lang', - lang: ref.lang, - refPath: ref.refPath, - }) - continue - } - let found = false - if (existsSync(path.join(repoRoot, ref.refPath))) { - found = true - } else { - for (let r = 0, { length: rLen } = roots; r < rLen; r += 1) { - if (existsSync(path.join(repoRoot, roots[r]!, ref.refPath))) { - found = true - break - } - } - } - if (!found) { - hits.push({ - lineNumber: ref.lineNumber, - preview: ref.preview, - reason: 'path-not-found', - lang: ref.lang, - refPath: ref.refPath, - }) - } - } - return hits -} - -interface MatchedRef { - readonly form: 'with' | 'from' - readonly lang: string - readonly refPath: string - readonly lineNumber: number - readonly preview: string -} - -interface MalformedHit { - readonly lineNumber: number - readonly preview: string - readonly hint: string -} - -interface StaleHit { - readonly lineNumber: number - readonly preview: string - readonly reason: 'unknown-lang' | 'path-not-found' - readonly lang: string - readonly refPath: string -} - -export function findCanonicalRefs(content: string): MatchedRef[] { - const hits: MatchedRef[] = [] - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - CANONICAL_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = CANONICAL_RE.exec(line)) !== null) { - hits.push({ - form: match[1] as 'with' | 'from', - lang: match[2]!, - refPath: match[3]!, - lineNumber: i + 1, - preview: line.trim().slice(0, 100), - }) - } - } - return hits -} - -export function findMalformed( - content: string, - canonical: readonly MatchedRef[], - noteLines: ReadonlySet, -): MalformedHit[] { - const canonicalLines = new Set(canonical.map(h => h.lineNumber)) - const hits: MalformedHit[] = [] - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const lineNumber = i + 1 - // If a line already contains a canonical ref or a Lock-step note, - // don't also flag it as malformed. Heuristic: a single line can - // have BOTH a canonical ref and a typo elsewhere, but in practice - // the typos we catch are alternative spellings on the SAME phrase - // — flagging both would be noise. - if (canonicalLines.has(lineNumber) || noteLines.has(lineNumber)) { - continue - } - const line = lines[i]! - for (let p = 0, { length: pLen } = MALFORMED_PATTERNS; p < pLen; p += 1) { - const { re, hint } = MALFORMED_PATTERNS[p]! - if (re.test(line)) { - hits.push({ - lineNumber, - preview: line.trim().slice(0, 100), - hint, - }) - break - } - } - } - return hits -} - -export function findNoteLines(content: string): Set { - const out = new Set() - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - if (NOTE_RE.test(lines[i]!)) { - out.add(i + 1) - } - } - return out -} - -export function loadConfig(repoRoot: string): LockStepConfig | undefined { - const configFile = path.join(repoRoot, '.config', 'lock-step-refs.json') - if (!existsSync(configFile)) { - return undefined - } - let raw: string - try { - raw = readFileSync(configFile, 'utf8') - } catch { - return undefined - } - try { - const parsed = JSON.parse(raw) as unknown - if ( - parsed && - typeof parsed === 'object' && - 'roots' in parsed && - 'scan' in parsed && - 'extensions' in parsed - ) { - return parsed as LockStepConfig - } - } catch { - // Malformed config — let the CI gate report it; hook stays silent. - } - return undefined -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const payloadRaw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(payloadRaw) as PreToolUsePayload - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.['file_path'] - if (typeof filePath !== 'string') { - process.exit(0) - } - if (!SOURCE_EXT_RE.test(filePath)) { - process.exit(0) - } - // Skip tests — illustrative example refs are common. - if (/(^|\/)test\//.test(filePath) || /\.test\.[a-z]+$/.test(filePath)) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - const content = - typeof payload.tool_input?.['content'] === 'string' - ? (payload.tool_input!['content'] as string) - : typeof payload.tool_input?.['new_string'] === 'string' - ? (payload.tool_input!['new_string'] as string) - : '' - if (!content) { - process.exit(0) - } - const refs = findCanonicalRefs(content) - const noteLines = findNoteLines(content) - const malformed = findMalformed(content, refs, noteLines) - - const repoRoot = payload.cwd ?? process.cwd() - const config = loadConfig(repoRoot) - const stale = config ? checkStale(refs, config, repoRoot) : [] - - if (malformed.length === 0 && stale.length === 0) { - process.exit(0) - } - - const out: string[] = [`[lock-step-ref-guard] ${filePath}:`, ''] - if (malformed.length > 0) { - out.push(' Malformed Lock-step comment(s) — fix the shape:') - for (let i = 0, { length } = malformed; i < length; i += 1) { - const h = malformed[i]! - out.push(` • line ${h.lineNumber}: "${h.preview}"`) - out.push(` → ${h.hint}`) - } - out.push('') - } - if (stale.length > 0) { - out.push(' Stale Lock-step reference(s) — fix or remove:') - for (let i = 0, { length } = stale; i < length; i += 1) { - const h = stale[i]! - const tag = - h.reason === 'unknown-lang' - ? `unknown "${h.lang}" (add to .config/lock-step-refs.json roots)` - : `path not found: ${h.refPath}` - out.push(` • line ${h.lineNumber}: ${tag}`) - out.push(` "${h.preview}"`) - } - out.push('') - } - out.push(' Spec: docs/claude.md/fleet/parser-comments.md §5–6.') - out.push( - ' CI gate: scripts/check-lock-step-refs.mts (run via `pnpm check`).', - ) - out.push(' Bypass: "Allow lock-step bypass" in a recent user message, or') - out.push(` ${ENV_DISABLE}=1.`) - out.push('') - process.stderr.write(out.join('\n') + '\n') - // Informational — exit 0. The CI gate is the blocking layer. - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/lock-step-ref-guard/package.json b/.claude/hooks/lock-step-ref-guard/package.json deleted file mode 100644 index 0729c8eb9..000000000 --- a/.claude/hooks/lock-step-ref-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-lock-step-ref-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/lock-step-ref-guard/test/index.test.mts b/.claude/hooks/lock-step-ref-guard/test/index.test.mts deleted file mode 100644 index c0793b454..000000000 --- a/.claude/hooks/lock-step-ref-guard/test/index.test.mts +++ /dev/null @@ -1,294 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(userText?: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'lsrg-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userText ?? 'normal message' }), - ) - return transcriptPath -} - -function makeRepo( - opts: { - configContent?: string | undefined - existingFiles?: readonly string[] | undefined - } = {}, -): string { - const root = mkdtempSync(path.join(os.tmpdir(), 'lsrg-repo-')) - if (opts.configContent !== undefined) { - mkdirSync(path.join(root, '.config'), { recursive: true }) - writeFileSync( - path.join(root, '.config', 'lock-step-refs.json'), - opts.configContent, - ) - } - for (const rel of opts.existingFiles ?? []) { - const full = path.join(root, rel) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, '') - } - return root -} - -function runHook( - tool: 'Edit' | 'Write' | 'Read', - filePath: string, - content: string, - options: { - transcriptPath?: string | undefined - env?: Record | undefined - cwd?: string | undefined - } = {}, -): { stderr: string; exitCode: number } { - const payload: Record = { - tool_name: tool, - tool_input: { file_path: filePath, content, new_string: content }, - } - if (options.transcriptPath) { - payload['transcript_path'] = options.transcriptPath - } - if (options.cwd) { - payload['cwd'] = options.cwd - } - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - env: { ...process.env, ...(options.env ?? {}) }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -// MALFORMED — always fires, regardless of config presence - -test('FLAGS lowercase "lockstep with Go: parser.go"', () => { - const content = '// lockstep with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - assert.match(stderr, /lock-step-ref-guard/) - assert.match(stderr, /Lock-step.*hyphen|Lock-step.*Lock step/) -}) - -test('FLAGS unhyphenated "Lock step with Go: parser.go"', () => { - const content = '// Lock step with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - assert.match(stderr, /hyphen/) -}) - -test('FLAGS missing discriminator "Lock-step Rust: src/foo.rs"', () => { - const content = '// Lock-step Rust: src/foo.rs\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.go', content) - assert.equal(exitCode, 0) - assert.match(stderr, /discriminator/) -}) - -test('FLAGS missing "Lock-step with : src/foo.rs"', () => { - const content = '// Lock-step with : src/foo.rs\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.go', content) - assert.equal(exitCode, 0) - assert.match(stderr, //) -}) - -test('FLAGS comma-instead-of-colon "Lock-step with Go, parser.go"', () => { - const content = '// Lock-step with Go, parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - assert.match(stderr, /":".*","|"," instead of ":"/) -}) - -// CANONICAL forms — accepted - -test('ACCEPTS canonical "Lock-step with Go: parser.go" (no config)', () => { - const content = '// Lock-step with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - // Without a config, no stale-check runs; the canonical form passes silently. - assert.equal(stderr, '') -}) - -test('ACCEPTS file-level "//! Lock-step from Rust: crates/parser/src/class.rs"', () => { - const content = - '//! Lock-step from Rust: crates/parser/src/class.rs\npackage parser' - const { stderr, exitCode } = runHook( - 'Write', - '/repo/src/parser/class.go', - content, - ) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS "Lock-step note: " without flagging', () => { - const content = [ - '// Lock-step note: reshaped for borrowck — Zig used a raw pointer here.', - 'const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -// STALE — fires only when config is present - -test('FLAGS stale path when config opts in', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - }) - const content = - '// Lock-step with Rust: crates/parser-stmt/src/foo.rs\nconst x = 1' - const { stderr, exitCode } = runHook( - 'Write', - path.join(repo, 'src/foo.go'), - content, - { cwd: repo }, - ) - assert.equal(exitCode, 0) - assert.match(stderr, /Stale Lock-step reference/) - assert.match(stderr, /path not found/) -}) - -test('ACCEPTS stale path when config absent (opt-in disabled)', () => { - const repo = makeRepo() // no config - const content = - '// Lock-step with Rust: crates/parser-stmt/src/foo.rs\nconst x = 1' - const { stderr, exitCode } = runHook( - 'Write', - path.join(repo, 'src/foo.go'), - content, - { cwd: repo }, - ) - assert.equal(exitCode, 0) - // Stale-check disabled; the canonical form is shape-correct so no malformed flag. - assert.equal(stderr, '') -}) - -test('ACCEPTS resolvable path when config opts in', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - existingFiles: ['crates/parser/src/class.rs'], - }) - const content = '// Lock-step with Rust: parser/src/class.rs\nconst x = 1' - const { stderr, exitCode } = runHook( - 'Write', - path.join(repo, 'src/foo.go'), - content, - { cwd: repo }, - ) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('FLAGS unknown when config opts in', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - }) - const content = '// Lock-step with Bash: scripts/x.sh\nconst x = 1' - const { stderr, exitCode } = runHook( - 'Write', - path.join(repo, 'src/foo.go'), - content, - { cwd: repo }, - ) - assert.equal(exitCode, 0) - assert.match(stderr, /unknown /) -}) - -// FALSE-POSITIVE GUARD — prose with "Lock-step with Go: JSON" - -test('does NOT match prose "Lock-step with Go: JSON parser semantics"', () => { - const content = [ - '// Lock-step with Go: JSON parser semantics here are tricky.', - 'const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - // The path regex requires `.` or `/`. "JSON" has neither, so no canonical - // match fires. The shape is also not a recognized malformed pattern. - assert.equal(stderr, '') -}) - -// SCOPE — skip non-source files - -test('SKIPS Markdown files', () => { - const content = '// lockstep with Go: parser.go\nsome prose' - const { stderr, exitCode } = runHook('Write', '/repo/README.md', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS test files', () => { - const content = '// lockstep with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook( - 'Write', - '/repo/test/parser.test.ts', - content, - ) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('SKIPS Read tool calls', () => { - const content = '// lockstep with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Read', '/repo/src/foo.rs', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -// BYPASS - -test('BYPASS via "Allow lock-step bypass" user message', () => { - const transcriptPath = makeTranscript('Allow lock-step bypass') - const content = '// lockstep with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content, { - transcriptPath, - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('BYPASS via SOCKET_LOCK_STEP_REF_GUARD_DISABLED=1', () => { - const content = '// lockstep with Go: parser.go\nconst x = 1' - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.rs', content, { - env: { SOCKET_LOCK_STEP_REF_GUARD_DISABLED: '1' }, - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -// HARDENING — bad payloads don't crash - -test('exits 0 on invalid JSON payload', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: 'not-json', - }) - assert.equal(result.status, 0) -}) - -test('exits 0 on missing tool_input', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ tool_name: 'Write' }), - }) - assert.equal(result.status, 0) -}) diff --git a/.claude/hooks/lock-step-ref-guard/tsconfig.json b/.claude/hooks/lock-step-ref-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/lock-step-ref-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/logger-guard/README.md b/.claude/hooks/logger-guard/README.md deleted file mode 100644 index f5966a680..000000000 --- a/.claude/hooks/logger-guard/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# logger-guard - -A **Claude Code hook** that runs before `Edit` or `Write` tool calls -on TypeScript source files and **blocks** edits that introduce direct -stream writes — `process.stderr.write`, `process.stdout.write`, -`console.log` / `error` / `warn` / `info` / `debug` — into source -code that's supposed to use a logger. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, edit never happens). This one blocks. - -## Why a logger and not console.log - -Source code in this fleet uses `getDefaultLogger()` from -`@socketsecurity/lib-stable/logger` for all output. That logger handles: - -- **Color and theme.** Terminal colors honor the user's environment - (no-color, light/dark, etc.). Direct `console.log` doesn't. -- **Indentation tracking.** Nested operations indent their output. - Direct writes don't, so you get unaligned messages. -- **Stream redirection in tests.** Vitest captures and asserts on - logger output. Direct writes go to the real stdout/stderr and - pollute test reports. -- **Layout-sensitive features.** Spinners, progress bars, and footer - rendering all increment counters the logger maintains. Bypassing - the logger leaves those counters wrong, which produces visual - artifacts (a spinner that doesn't clear, a footer that - duplicates). - -The block is what keeps the logger as the single source of truth. -If even one file directly writes to stdout, the next person on a -related file sees the precedent and follows it; the convention -erodes. - -## Scope - -The hook is intentionally narrow: - -- **Fires** on `Edit` and `Write` calls. -- **Inspects** files matching `*.{ts,mts,tsx,cts}` under repo source. -- **Exempts** `.claude/hooks/`, `.git-hooks/`, `scripts/`, tests, - fixtures, and external/vendored code — those have legitimate - reasons to write directly. -- **Exempts** lines tagged `# socket-hook: allow console` (canonical - per-line opt-out — names the construct being allowed, not the - recommended replacement). The bare form `# socket-hook: allow` - also works for blanket suppression. Legacy `allow logger` is - accepted as an alias for one deprecation cycle. -- **Exempts** lines that look like documentation: lines starting - with `*`, `//`, or `#`; JSDoc tags; fully-backticked code spans. - -## Suggested replacements - -When the hook blocks, it surfaces a concrete rewrite per hit so the -agent can apply it directly: - -| Direct call | Logger equivalent | -| ------------------------- | ------------------- | -| `process.stderr.write(s)` | `logger.error(s)` | -| `process.stdout.write(s)` | `logger.info(s)` | -| `console.error(...)` | `logger.error(...)` | -| `console.warn(...)` | `logger.warn(...)` | -| `console.info(...)` | `logger.info(...)` | -| `console.debug(...)` | `logger.debug(...)` | -| `console.log(...)` | `logger.info(...)` | - -## Wiring - -`.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/logger-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Testing - -```bash -cd .claude/hooks/logger-guard -node --test test/*.test.mts -``` - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/logger-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/logger-guard/index.mts b/.claude/hooks/logger-guard/index.mts deleted file mode 100644 index c53c10c1f..000000000 --- a/.claude/hooks/logger-guard/index.mts +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — logger-guard. -// -// Blocks Edit/Write tool calls that would introduce direct calls to -// `process.stderr.write`, `process.stdout.write`, `console.log`, -// `console.error`, `console.warn`, `console.info`, or `console.debug` -// in source files. Exit code 2 makes Claude Code refuse the tool call -// so the diff never lands. The model sees the rejection reason on -// stderr and retries using the lib's logger. -// -// Why this rule: -// -// The fleet's source code uses `getDefaultLogger()` from -// `@socketsecurity/lib-stable/logger/default` for every output. Direct stream -// writes bypass color/theme handling, indentation tracking, stream -// redirection in tests, and spinner-counter increments — producing -// inconsistent output that breaks layout-sensitive workflows. -// -// Scope: -// -// - Fires only on `Edit` and `Write` tool calls. -// - Only inspects `.ts` / `.mts` / `.cts` / `.tsx` source files. -// Hooks, git-hooks, scripts, tests, fixtures, external/vendored -// code are exempt — see EXEMPT_PATH_PATTERNS. -// - Lines marked `// socket-hook: allow console` are exempt. -// -// AST-based detector (vendored acorn-wasm in `../_shared/acorn/`). -// Replaced the regex implementation that had to compensate for -// string-literal / comment / template-literal false positives via -// `looksLikeDocumentation` heuristics — the parser handles all of -// that intrinsically because it only reaches CallExpression nodes -// for actual calls, not text-shapes that look like calls. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import process from 'node:process' - -import { findMemberCalls } from '../_shared/acorn/index.mts' -import type { MemberCallSite } from '../_shared/acorn/index.mts' -import { lineIsSuppressed } from '../_shared/markers.mts' -import { readStdin } from '../_shared/transcript.mts' - -const EXEMPT_PATH_PATTERNS: RegExp[] = [ - /\.claude\/hooks\//, - /\.git-hooks\//, - /(?:^|\/)scripts\//, - /\.(?:spec|test)\.(?:m?[jt]s|tsx?|cts|mts)$/, - /(?:^|\/)tests?\//, - /(?:^|\/)fixtures\//, - /(?:^|\/)external\//, - /(?:^|\/)vendor\//, - /(?:^|\/)upstream\//, - // The logger is its own owner — these files implement the Logger - // class + its browser shim and must call console.* directly. - /(?:^|\/)src\/logger\//, -] - -// The forbidden calls and the canonical logger replacement for each. -// Two-segment chains (`console.log`) and three-segment chains -// (`process.stderr.write`) — `findMemberCalls` handles both. -const FORBIDDEN_CALLS: Array<{ - object: string - property: string - replacement: string -}> = [ - { object: 'console', property: 'log', replacement: 'logger.info' }, - { object: 'console', property: 'error', replacement: 'logger.error' }, - { object: 'console', property: 'warn', replacement: 'logger.warn' }, - { object: 'console', property: 'info', replacement: 'logger.info' }, - { object: 'console', property: 'debug', replacement: 'logger.debug' }, - { object: 'process.stderr', property: 'write', replacement: 'logger.error' }, - { object: 'process.stdout', property: 'write', replacement: 'logger.info' }, -] - -interface ToolInput { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -export function emitBlock(filePath: string, hits: Hit[]): void { - const out: string[] = [] - out.push('') - out.push('[logger-guard] Blocked: direct stream write found') - out.push( - ' Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default` instead.', - ) - out.push(` File: ${filePath}`) - for (const h of hits.slice(0, 3)) { - out.push(` Line ${h.line}: ${h.text}`) - out.push( - ` Fix: replace \`${h.fullCall}(\` with \`${h.replacement}(\``, - ) - } - if (hits.length > 3) { - out.push(` …and ${hits.length - 3} more.`) - } - out.push( - ' Opt-out for one line (rare): append `// socket-hook: allow console`.', - ) - out.push('') - process.stderr.write(out.join('\n')) -} - -interface Hit { - line: number - text: string - fullCall: string - replacement: string -} - -export function isInScope(filePath: string): boolean { - if (!filePath) { - return false - } - if (!/\.(?:m?ts|tsx|cts)$/.test(filePath)) { - return false - } - for (let i = 0, { length } = EXEMPT_PATH_PATTERNS; i < length; i += 1) { - const re = EXEMPT_PATH_PATTERNS[i]! - if (re.test(filePath)) { - return false - } - } - return true -} - -export function scan(source: string): Hit[] { - const hits: Hit[] = [] - const lines = source.split('\n') - for (let i = 0, { length } = FORBIDDEN_CALLS; i < length; i += 1) { - const spec = FORBIDDEN_CALLS[i]! - const matches: MemberCallSite[] = findMemberCalls( - source, - spec.object, - spec.property, - ) - for (let i = 0, { length } = matches; i < length; i += 1) { - const m = matches[i]! - // Per-line allow marker: `// socket-hook: allow console`. The - // marker has to appear on the same source line as the call. - const sourceLine = lines[m.line - 1] ?? '' - if (lineIsSuppressed(sourceLine, 'console')) { - continue - } - hits.push({ - line: m.line, - text: m.text, - fullCall: `${spec.object}.${spec.property}`, - replacement: spec.replacement, - }) - } - } - // Multiple FORBIDDEN_CALLS iterations may produce out-of-order - // results when several different calls land on different lines. - // Sort by line for readable output. - hits.sort((a, b) => a.line - b.line) - return hits -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!isInScope(filePath)) { - return - } - const source = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!source) { - return - } - const hits = scan(source) - if (hits.length === 0) { - return - } - emitBlock(filePath, hits) - process.exitCode = 2 -} - -main().catch(e => { - process.stderr.write( - `[logger-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/logger-guard/package.json b/.claude/hooks/logger-guard/package.json deleted file mode 100644 index 1b522cde6..000000000 --- a/.claude/hooks/logger-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-logger-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/logger-guard/test/logger-guard.test.mts b/.claude/hooks/logger-guard/test/logger-guard.test.mts deleted file mode 100644 index 3573d0897..000000000 --- a/.claude/hooks/logger-guard/test/logger-guard.test.mts +++ /dev/null @@ -1,214 +0,0 @@ -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { test } from 'node:test' -import assert from 'node:assert/strict' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Payload { - tool_name: 'Edit' | 'Write' | string - tool_input: { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } -} - -function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end(JSON.stringify(payload)) - }) -} - -test('blocks console.log in src/ .ts files', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: 'src/foo.ts', - content: 'export function foo() { console.log("hi") }', - }, - }) - assert.equal(code, 2, `expected exit 2; got ${code}; stderr=${stderr}`) - assert.ok(stderr.includes('logger-guard')) - assert.ok(stderr.includes('Fix:')) - assert.ok(stderr.includes('logger.info')) -}) - -test('blocks process.stderr.write in src/ .mts files', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/utils/output.mts', - new_string: 'process.stderr.write("oops\\n")', - }, - }) - assert.equal(code, 2) - assert.ok(stderr.includes('logger.error(')) -}) - -test('allows hooks themselves to use process.stderr.write', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '.claude/hooks/some-hook/index.mts', - new_string: 'process.stderr.write("ok\\n")', - }, - }) - assert.equal(code, 0, `expected exit 0; got ${code}; stderr=${stderr}`) -}) - -test('allows scripts/ to use console.log', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'scripts/build.mts', - new_string: 'console.log("build complete")', - }, - }) - assert.equal(code, 0) -}) - -test('allows tests to use console.log', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/utils/foo.test.mts', - new_string: 'console.log("debug")', - }, - }) - assert.equal(code, 0) -}) - -test('respects # socket-hook: allow console marker', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: - 'const x = 1; console.error("a") // # socket-hook: allow console', - }, - }) - assert.equal(code, 0) -}) - -// Legacy spelling — accepted as alias for one deprecation cycle. -test('respects # socket-hook: allow logger marker (legacy alias)', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: - 'const x = 1; console.error("legacy") // # socket-hook: allow logger', - }, - }) - assert.equal(code, 0) -}) - -test('respects bare # socket-hook: allow marker', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: 'console.warn("a") // # socket-hook: allow', - }, - }) - assert.equal(code, 0) -}) - -test('respects // socket-hook: allow console marker (slash-slash prefix)', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: 'process.stderr.write(buf) // socket-hook: allow console', - }, - }) - assert.equal(code, 0) -}) - -test('respects /* socket-hook: allow console */ marker (block-comment prefix)', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: 'console.error("a") /* socket-hook: allow console */', - }, - }) - assert.equal(code, 0) -}) - -test('does not flag JSDoc examples', async () => { - const { code } = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: 'src/foo.ts', - content: - '/**\n * @example\n * console.log("usage")\n */\nexport const foo = 1', - }, - }) - assert.equal(code, 0) -}) - -test('does not flag comment lines', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - new_string: '// previously: console.log("debug")', - }, - }) - assert.equal(code, 0) -}) - -test('does not flag content fully inside a single backtick span', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.ts', - // Single-line markdown-style backtick span — the inner content - // is documentation, not real code. - new_string: 'const note = `use logger.info() not console.log()`', - }, - }) - assert.equal(code, 0) -}) - -test('does not run on non-Edit/Write tools', async () => { - const { code } = await runHook({ - tool_name: 'Bash', - tool_input: { content: 'console.log("nope")' }, - }) - assert.equal(code, 0) -}) - -test('does not run on .js files (out of scope)', async () => { - const { code } = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: 'src/foo.js', - new_string: 'console.log("legacy")', - }, - }) - assert.equal(code, 0) -}) diff --git a/.claude/hooks/logger-guard/tsconfig.json b/.claude/hooks/logger-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/logger-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/markdown-filename-guard/README.md b/.claude/hooks/markdown-filename-guard/README.md deleted file mode 100644 index ce1345979..000000000 --- a/.claude/hooks/markdown-filename-guard/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# markdown-filename-guard - -PreToolUse Edit/Write hook that blocks markdown files with non-canonical filenames. - -## What it enforces - -| Filename shape | Allowed at | Notes | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------- | -| `README.md`, `LICENSE` | anywhere | Special-cased by GitHub. | -| `AUTHORS.md`, `CHANGELOG.md`, `CITATION.md`, `CLAUDE.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `CONTRIBUTORS.md`, `COPYING`, `CREDITS.md`, `GOVERNANCE.md`, `MAINTAINERS.md`, `NOTICE.md`, `SECURITY.md`, `SUPPORT.md`, `TRADEMARK.md` | repo root, `docs/` (top level), or `.claude/` (top level) | The SCREAMING_CASE allowlist. GitHub renders these specially. | -| `lowercase-with-hyphens.md` | inside `docs/` or `.claude/` (any depth) | All other docs. | - -Blocked: - -- Custom SCREAMING_CASE filenames (`NOTES.md`, `MY_DESIGN.md`, etc.) — rename to `notes.md` / `my-design.md`. -- `.MD` extension — use `.md`. -- `camelCase.md` / `snake_case.md` / `Spaces In Filename.md` — convert to lowercase-with-hyphens. -- Lowercase-hyphenated docs at repo root — move to `docs/` or `.claude/`. - -## Why - -SCREAMING_CASE doc filenames optimize for "noticeable in a repo root" but read as shouty + opaque inside body text and TOC links. Lowercase-with-hyphens reads naturally and matches the rest of the fleet's slug-style identifiers (URLs, CSS classes, CLI flags, package names). The narrow SCREAMING_CASE allowlist is the set GitHub renders specially — adding more dilutes the signal. - -The fleet's `scripts/validate/markdown-filenames.mts` does the same check at commit time (per repo, not template-canonical); this hook catches it earlier, at edit time, so the model gets immediate feedback when it picks a wrong name. - -## Companion files - -- `index.mts` — the hook itself. -- `test/index.test.mts` — node:test specs (15 cases). -- `package.json` — workspace declaration so `taze` can see the hook's deps. -- `tsconfig.json` — fleet-canonical TS config. - -## Adding a new allowed filename - -If GitHub adds a new specially-rendered file (e.g. `FUNDING.md`), update `ALLOWED_SCREAMING_CASE` in `index.mts` and the table above. Don't add custom project-specific SCREAMING_CASE filenames here — those break the convention. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. The `scripts/validate/markdown-filenames.mts` gate at commit time is the second line of defense. diff --git a/.claude/hooks/markdown-filename-guard/index.mts b/.claude/hooks/markdown-filename-guard/index.mts deleted file mode 100644 index d2ddbf731..000000000 --- a/.claude/hooks/markdown-filename-guard/index.mts +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — markdown-filename-guard. -// -// Blocks Edit/Write tool calls that would create a markdown file -// with a non-canonical filename. Per the fleet's docs convention: -// -// - Allowed everywhere: README.md, LICENSE. -// - Allowed at root, docs/, or .claude/ (top level only): the -// conventional SCREAMING_CASE set (AUTHORS, CHANGELOG, CLAUDE, -// CODE_OF_CONDUCT, CONTRIBUTING, GOVERNANCE, MAINTAINERS, -// NOTICE, SECURITY, SUPPORT, etc.). -// - Everything else must be lowercase-with-hyphens AND placed -// under `docs/` or `.claude/` (at any depth). -// -// Why: SCREAMING_CASE doc filenames optimize for "noticeable in a -// repo root" but read as shouty + opaque inside body text and TOC -// links. Hyphenated lowercase reads naturally and matches every -// other slug-style identifier the fleet uses (URLs, CSS classes, -// CLI flags, package names). The narrow SCREAMING_CASE allowlist is -// the set GitHub renders specially — adding more would dilute the -// signal. -// -// The fleet's `scripts/validate/markdown-filenames.mts` does the -// same check at commit time; this hook catches it earlier, at edit -// time, so the model gets immediate feedback when it picks a wrong -// name. -// -// Exit code 2 makes Claude Code refuse the tool call. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import path from 'node:path' -import process from 'node:process' - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined -} - -// SCREAMING_CASE files allowed at root / docs/ / .claude/ (top level). -const ALLOWED_SCREAMING_CASE: ReadonlySet = new Set([ - 'AUTHORS', - 'CHANGELOG', - 'CITATION', - 'CLAUDE', - 'CODE_OF_CONDUCT', - 'CONTRIBUTING', - 'CONTRIBUTORS', - 'COPYING', - 'CREDITS', - 'GOVERNANCE', - 'LICENSE', - 'MAINTAINERS', - 'NOTICE', - 'README', - 'SECURITY', - 'SUPPORT', - 'TRADEMARK', -]) - -type Verdict = { - ok: boolean - message?: string | undefined - suggestion?: string | undefined -} - -export function classifyMarkdownPath(absPath: string): Verdict { - const filename = path.basename(absPath) - if (!/\.(MD|markdown|md)$/.test(filename)) { - return { ok: true } - } - - // Anything under a `.claude/` segment is off-limits to doc-filename - // rules: that tree is owned by Claude Code (auto-memory, skills, - // hooks, settings) and each tool inside picks its own filename - // convention. The hook's job is to keep human-facing docs canonical, - // not police runtime/tooling artifacts. - // - // Cheap-substring pre-check: if the path doesn't even contain the - // literal `.claude` token, skip the normalize call. Saves the - // normalization on the overwhelmingly-common non-`.claude` path. - if (absPath.includes('.claude')) { - const normalized = normalizePath(absPath) - if (normalized.includes('/.claude/') || normalized.endsWith('/.claude')) { - return { ok: true } - } - } - - const relPath = normalizePath(toRepoRelative(absPath)) - // For docs that describe a specific code file (e.g. `smol-ffi.js.md`), - // strip the source-file hint before validating the stem. - const nameWithoutExt = stripCodeFileHintExt( - filename.replace(/\.(MD|markdown|md)$/, ''), - ) - - // README / LICENSE — anywhere. - if (nameWithoutExt === 'LICENSE' || nameWithoutExt === 'README') { - return { ok: true } - } - - // SCREAMING_CASE allowlist. - if (ALLOWED_SCREAMING_CASE.has(nameWithoutExt)) { - if (isAtAllowedScreamingLocation(relPath)) { - return { ok: true } - } - const lowered = filename.toLowerCase().replace(/_/g, '-') - return { - ok: false, - message: `${filename} (SCREAMING_CASE) is allowed only at the repo root, docs/, or .claude/. This path puts it deeper.`, - suggestion: `Either move to root / docs/ / .claude/, or rename to ${lowered}.`, - } - } - - // Wrong-case extension `.MD`. - if (filename.endsWith('.MD')) { - return { - ok: false, - message: `Extension is .MD; the fleet uses .md.`, - suggestion: filename.replace(/\.MD$/, '.md'), - } - } - - // SCREAMING_CASE not in the allowlist — never allowed. - if (isScreamingCase(nameWithoutExt)) { - return { - ok: false, - message: `${filename}: SCREAMING_CASE markdown filenames are limited to the canonical allowlist (AUTHORS, CHANGELOG, CLAUDE, README, SECURITY, etc.). Custom doc names should be lowercase-with-hyphens.`, - suggestion: filename.toLowerCase().replace(/_/g, '-'), - } - } - - // Must be lowercase-with-hyphens. - if (!isLowercaseHyphenated(nameWithoutExt)) { - const suggested = nameWithoutExt - .toLowerCase() - .replace(/[_\s]+/g, '-') - .replace(/[^a-z0-9-]/g, '') - return { - ok: false, - message: `${filename}: doc filenames must be lowercase-with-hyphens (no underscores, no camelCase, no spaces).`, - suggestion: `${suggested}.md`, - } - } - - // Lowercase-hyphenated docs must live under docs/ or .claude/. - if (!isAtAllowedRegularLocation(relPath)) { - return { - ok: false, - message: `${filename}: per-repo docs live under docs/ or .claude/, not at ${path.posix.dirname(relPath) || '.'}.`, - suggestion: `Move to docs/${filename} or .claude/${filename}.`, - } - } - - return { ok: true } -} - -export function emitBlock(filePath: string, verdict: Verdict): void { - const lines: string[] = [] - lines.push('[markdown-filename-guard] Blocked: non-canonical doc filename.') - lines.push(` File: ${filePath}`) - if (verdict.message) { - lines.push(` Issue: ${verdict.message}`) - } - if (verdict.suggestion) { - lines.push(` Suggestion: ${verdict.suggestion}`) - } - lines.push('') - lines.push(' Fleet doc-filename rules:') - lines.push(' - README.md / LICENSE — allowed anywhere.') - lines.push( - ' - SCREAMING_CASE allowlist (AUTHORS, CHANGELOG, CLAUDE, CONTRIBUTING,', - ) - lines.push( - ' GOVERNANCE, MAINTAINERS, NOTICE, README, SECURITY, SUPPORT, …) —', - ) - lines.push(' allowed at root / docs/ / .claude/ (top level only).') - lines.push( - ' - Everything else: lowercase-with-hyphens, in docs/ or .claude/.', - ) - process.stderr.write(lines.join('\n') + '\n') -} - -export function isAtAllowedRegularLocation(relPath: string): boolean { - const dir = path.posix.dirname(relPath) - if (dir === '.claude' || dir.startsWith('.claude/')) { - return true - } - // Accept any path segment named `docs` so per-package doc trees like - // `packages//docs/.md` and - // `packages//lang//docs/.md` resolve to the same "in - // a docs/ directory" rule as repo-root docs/. Segment-equality (not - // substring) so `foo-docs/`, `docs-old/`, `.docs/` don't match. - const segments = dir.split('/') - return segments.includes('docs') -} - -export function isAtAllowedScreamingLocation(relPath: string): boolean { - const dir = path.posix.dirname(relPath) - return dir === '.' || dir === '.claude' || dir === 'docs' -} - -export function isLowercaseHyphenated(nameWithoutExt: string): boolean { - return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(nameWithoutExt) -} - -export function isScreamingCase(nameWithoutExt: string): boolean { - return /^[A-Z0-9_]+$/.test(nameWithoutExt) && /[A-Z]/.test(nameWithoutExt) -} - -/** - * Strip a single trailing "source-file extension" hint from a doc-filename - * stem. Canonical fleet pattern for docs describing a specific code file is - * `.md` (e.g. `smol-ffi.js.md` describes `smol-ffi.js`). Without this - * strip, `smol-ffi.js.md` is parsed as stem `smol-ffi.js` which fails - * `isLowercaseHyphenated` on the embedded `.`. The accepted hint extensions - * match the language set the fleet documents code in. - */ -export function stripCodeFileHintExt(stem: string): string { - return stem.replace( - /\.(?:[cm]?[jt]sx?|json|ya?ml|toml|sh|py|rs|go|cc|cpp|h|hpp)$/, - '', - ) -} - -/** - * Strip a leading repo-absolute prefix (anything up through and including a - * `/` segment) so we get the in-repo relative path. Falls back to - * the input if no recognizable prefix. - * - * Special case: socket-wheelhouse keeps the fleet-canonical doc tree under - * `template/`, which acts as the "repo root" from the fleet perspective. Strip - * that extra prefix so doc-location rules apply the same way as in a downstream - * repo (where the docs live at actual root). Without this carve-out, every - * SCREAMING_CASE doc in `template/` (CLAUDE.md, README.md at template root) - * would trip the SCREAMING_CASE-only-at-repo-root rule. - */ -export function toRepoRelative(filePath: string): string { - // PreToolUse passes absolute paths. Strip up through `/projects//`. - const m = filePath.match(/\/projects\/[^/]+\/(.+)$/) - if (!m) { - return filePath - } - let rel = m[1]! - // socket-wheelhouse: treat template/ as the effective repo root. - if (rel.startsWith('template/')) { - rel = rel.slice('template/'.length) - } - return rel -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath) { - return - } - const verdict = classifyMarkdownPath(filePath) - if (verdict.ok) { - return - } - emitBlock(filePath, verdict) - process.exitCode = 2 -} - -main().catch(e => { - process.stderr.write( - `[markdown-filename-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/markdown-filename-guard/package.json b/.claude/hooks/markdown-filename-guard/package.json deleted file mode 100644 index 35a1a1add..000000000 --- a/.claude/hooks/markdown-filename-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-markdown-filename-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/markdown-filename-guard/test/index.test.mts b/.claude/hooks/markdown-filename-guard/test/index.test.mts deleted file mode 100644 index d9a0edd0c..000000000 --- a/.claude/hooks/markdown-filename-guard/test/index.test.mts +++ /dev/null @@ -1,338 +0,0 @@ -// node --test specs for the markdown-filename-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-markdown files pass through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/src/SHOUTY.ts', - new_string: 'export const X = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('README.md anywhere is allowed', async () => { - for (const p of [ - '/Users/x/projects/foo/README.md', - '/Users/x/projects/foo/packages/bar/README.md', - '/Users/x/projects/foo/docs/sub/README.md', - ]) { - const result = await runHook({ - tool_input: { content: 'hi', file_path: p }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, p) - } -}) - -test('LICENSE anywhere is allowed', async () => { - const result = await runHook({ - tool_input: { content: 'MIT', file_path: '/Users/x/projects/foo/LICENSE' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('CLAUDE.md at root is allowed', async () => { - const result = await runHook({ - tool_input: { - content: '# CLAUDE.md', - file_path: '/Users/x/projects/foo/CLAUDE.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('CLAUDE.md under socket-wheelhouse/template/ is allowed (template-as-root carve-out)', async () => { - const result = await runHook({ - tool_input: { - content: '# CLAUDE.md', - file_path: '/Users/x/projects/socket-wheelhouse/template/CLAUDE.md', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('CLAUDE.md under template/docs/ is allowed (template-as-root + docs/)', async () => { - const result = await runHook({ - tool_input: { - content: '# CLAUDE.md', - file_path: '/Users/x/projects/socket-wheelhouse/template/docs/CLAUDE.md', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('CLAUDE.md deeper under template/ (template/packages/foo/) is still blocked', async () => { - const result = await runHook({ - tool_input: { - content: '# CLAUDE.md', - file_path: - '/Users/x/projects/socket-wheelhouse/template/packages/foo/CLAUDE.md', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /SCREAMING_CASE/) -}) - -test('CONTRIBUTING.md at root is allowed', async () => { - const result = await runHook({ - tool_input: { - content: 'how to contribute', - file_path: '/Users/x/projects/foo/CONTRIBUTING.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('CONTRIBUTING.md in docs/ is allowed', async () => { - const result = await runHook({ - tool_input: { - content: 'how to contribute', - file_path: '/Users/x/projects/foo/docs/CONTRIBUTING.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('CONTRIBUTING.md in docs/sub/ is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'how to contribute', - file_path: '/Users/x/projects/foo/docs/sub/CONTRIBUTING.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /SCREAMING_CASE/) -}) - -test('NOTES.md (non-allowlisted SCREAMING_CASE) is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'notes', - file_path: '/Users/x/projects/foo/docs/NOTES.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /SCREAMING_CASE markdown filenames/) - assert.match(result.stderr, /notes\.md/) -}) - -test('MY_DESIGN.md (custom SCREAMING_CASE) is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'design', - file_path: '/Users/x/projects/foo/docs/MY_DESIGN.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /my-design\.md/) -}) - -test('lowercase-with-hyphens in docs/ is allowed', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/release-notes.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('lowercase-with-hyphens in packages//docs/ is allowed', async () => { - for (const p of [ - '/Users/x/projects/foo/packages/acorn/docs/perf/journey.md', - '/Users/x/projects/foo/packages/acorn/docs/architecture.md', - '/Users/x/projects/foo/packages/acorn/lang/rust/docs/performance.md', - '/Users/x/projects/foo/packages/acorn/lang/typescript/docs/building.md', - ]) { - const result = await runHook({ - tool_input: { content: 'doc', file_path: p }, - tool_name: 'Write', - }) - assert.strictEqual( - result.code, - 0, - `${p} should be allowed (got code ${result.code}: ${result.stderr})`, - ) - } -}) - -test('docs-lookalike segments (foo-docs, docs-old, .docs) are blocked', async () => { - for (const p of [ - '/Users/x/projects/foo/packages/acorn/foo-docs/notes.md', - '/Users/x/projects/foo/docs-old/notes.md', - '/Users/x/projects/foo/.docs/notes.md', - ]) { - const result = await runHook({ - tool_input: { content: 'doc', file_path: p }, - tool_name: 'Write', - }) - assert.strictEqual( - result.code, - 2, - `${p} should be blocked (got code ${result.code})`, - ) - } -}) - -test('lowercase-with-hyphens at root is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/release-notes.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /docs\/ or \.claude\//) -}) - -test('camelCase markdown filename is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/myDoc.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /lowercase-with-hyphens/) -}) - -test('underscore in lowercase doc is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/my_doc.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('.MD extension is blocked', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/release-notes.MD', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /\.md/) -}) - -test('.md (code-file hint) is allowed under docs/', async () => { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/additions/lib/smol-ffi.js.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('.md works for .mts / .cc / .py too', async () => { - for (const filename of ['parser.mts.md', 'binding.cc.md', 'tool.py.md']) { - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: `/Users/x/projects/foo/docs/${filename}`, - }, - tool_name: 'Write', - }) - assert.strictEqual( - result.code, - 0, - `${filename} should be allowed (got code ${result.code}: ${result.stderr})`, - ) - } -}) - -test('.md with non-hyphenated stem is still blocked', async () => { - // `bad_name.js.md` strips to `bad_name`, which is not lowercase-hyphenated. - const result = await runHook({ - tool_input: { - content: 'doc', - file_path: '/Users/x/projects/foo/docs/bad_name.js.md', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /lowercase-with-hyphens/) -}) - -test('anything under .claude/ at any depth bypasses the rules', async () => { - for (const filename of [ - // Auto-memory: snake_case + outside the repo (under $HOME). - '/Users/x/.claude/projects/-Users-x-projects-foo/memory/user_role.md', - '/Users/x/.claude/projects/-Users-x-projects-foo/memory/MEMORY.md', - // Skill / hook subdirs inside a repo's .claude/ — any name works. - '/Users/x/projects/foo/.claude/skills/some_skill/SOMETHING.md', - '/Users/x/projects/foo/.claude/hooks/foo/notes_in_camelCase.md', - '/Users/x/projects/foo/.claude/whateverYouWant.md', - ]) { - const result = await runHook({ - tool_input: { content: 'doc', file_path: filename }, - tool_name: 'Write', - }) - assert.strictEqual( - result.code, - 0, - `${filename} should be allowed under .claude/ (got code ${result.code}: ${result.stderr})`, - ) - } -}) diff --git a/.claude/hooks/markdown-filename-guard/tsconfig.json b/.claude/hooks/markdown-filename-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/markdown-filename-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/marketplace-comment-guard/README.md b/.claude/hooks/marketplace-comment-guard/README.md deleted file mode 100644 index 8264fdc24..000000000 --- a/.claude/hooks/marketplace-comment-guard/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# marketplace-comment-guard - -A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls -which would land a `.claude-plugin/marketplace.json` or sibling -`.claude-plugin/README.md` in an inconsistent state — every plugin -pinned in marketplace.json must have a row in the README's pin table -with matching `version` (= `source.ref`), matching `sha`, and an -ISO-8601 `date`. - -## Why this rule - -JSON has no comments, so marketplace.json can't carry the human-readable -pin metadata (pin date, pinner, free-form notes) that the GHA `uses:` -SHA-pin convention puts inline. The fleet handles this by putting the -machine-readable pin in `marketplace.json` and the human metadata in a -sibling README, then enforcing consistency at edit time. - -Without the guard the two surfaces drift: someone bumps `sha` in JSON -but forgets the README, or the README's `date` rots while pretending -the pin is fresh. Same failure mode the workflow `uses:` rule guards -against — opaque pins look fine and stay broken for months. - -## Conventional shape - -```jsonc -// .claude-plugin/marketplace.json -{ - "plugins": [ - { - "name": "codex", - "source": { - "source": "git-subdir", - "url": "https://github.com/openai/codex-plugin-cc.git", - "ref": "v1.0.1", - "sha": "9cb4fe4099195b2587c402117a3efce6ab5aac78", - }, - }, - ], -} -``` - -```markdown - - -| plugin | version | sha | date | notes | -| ------ | ------- | ---------------------------------------- | ---------- | ------------------------------- | -| codex | v1.0.1 | 9cb4fe4099195b2587c402117a3efce6ab5aac78 | 2026-05-18 | upstream openai/codex-plugin-cc | -``` - -The first four columns are required and inspected. Any trailing column -(e.g. free-form `notes`) is accepted but not validated. `git blame` is the -authoritative record of _who_ bumped a pin, so a `by` column is deliberately -absent — duplicating personal identifiers into fleet-canonical files is a -public-surface-hygiene mistake. - -## What's enforced - -- Every `plugins[].source.sha` in marketplace.json has a row in the - README table keyed by plugin name. -- The row's `version` cell matches `source.ref`. -- The row's `sha` cell matches `source.sha`. -- The row's `date` cell matches ISO-8601 `YYYY-MM-DD`. -- Either file edited without the sibling existing blocks — the pair - must be created and maintained together. - -## What's not enforced - -- The accuracy of `date` — that's a human-review concern (same as the - GHA `uses:` rule). -- Any trailing `notes` column — free-form metadata. -- Source types other than `git-subdir` carrying a `ref` field — if you - add a new source type that doesn't have `ref`, the guard skips that - entry rather than blocking. Add explicit support if the new type - warrants it. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/marketplace-comment-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This hook lives in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/marketplace-comment-guard) -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/marketplace-comment-guard/index.mts b/.claude/hooks/marketplace-comment-guard/index.mts deleted file mode 100644 index 9bc72be8a..000000000 --- a/.claude/hooks/marketplace-comment-guard/index.mts +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — marketplace-comment-guard. -// -// Enforces consistency between `.claude-plugin/marketplace.json` and its -// sibling `.claude-plugin/README.md`. Every plugin pinned in -// marketplace.json must have a row in the README's pin table with a -// matching `version` (= `source.ref`) AND `sha`, plus an ISO-8601 `date`. -// -// JSON can't carry comments and Claude Code's marketplace.json parser -// would reject them anyway, so the human-readable pin metadata (pin -// date, pinner, notes) lives in the README. The guard keeps the two -// files honest — same shape as the GHA `uses:` SHA-pin comment rule, -// which uses an inline `# v6.4.0 (YYYY-MM-DD)` to carry the staleness -// signal. -// -// Scope: -// - Fires on Edit and Write tool calls. -// - Only inspects paths ending in `.claude-plugin/marketplace.json` -// or `.claude-plugin/README.md`. -// - When marketplace.json is being edited, the post-edit JSON is -// reconstructed from disk + the proposed change and checked against -// the on-disk README. -// - When README is being edited, the post-edit README is reconstructed -// and checked against the on-disk marketplace.json. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -interface Hook { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - old_string?: string | undefined - content?: string | undefined - } - | undefined -} - -interface PluginPin { - name: string - ref: string - sha: string -} - -interface BadPin { - name: string - expected: PluginPin - reason: string -} - -const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ - -export function extractPluginPins( - marketplaceJson: string, -): PluginPin[] | undefined { - let parsed: unknown - try { - parsed = JSON.parse(marketplaceJson) - } catch { - return undefined - } - if (!parsed || typeof parsed !== 'object') { - return undefined - } - const plugins = (parsed as { plugins?: unknown | undefined }).plugins - if (!Array.isArray(plugins)) { - return [] - } - const pins: PluginPin[] = [] - for (let i = 0, { length } = plugins; i < length; i += 1) { - const entry = plugins[i]! - if (!entry || typeof entry !== 'object') { - continue - } - const e = entry as Record - const name = typeof e['name'] === 'string' ? e['name'] : undefined - const src = e['source'] - if (!src || typeof src !== 'object') { - continue - } - const s = src as Record - const ref = typeof s['ref'] === 'string' ? s['ref'] : undefined - const sha = typeof s['sha'] === 'string' ? s['sha'] : undefined - if (name && ref && sha) { - pins.push({ name, ref, sha }) - } - } - return pins -} - -interface ReadmeRow { - plugin: string - version: string - sha: string - date: string -} - -// Parse the README's markdown pin table. We look for any line matching -// the pipe-separated table shape with at least 4 columns; the first -// four are plugin / version / sha / date. Trailing columns (by, notes) -// are ignored by the guard. -export function extractReadmeRows(readme: string): ReadmeRow[] { - const rows: ReadmeRow[] = [] - for (const rawLine of readme.split('\n')) { - const line = rawLine.trim() - if (!line.startsWith('|') || !line.endsWith('|')) { - continue - } - // Strip leading + trailing | and split. - const cells = line - .slice(1, -1) - .split('|') - .map(c => c.trim()) - if (cells.length < 4) { - continue - } - const [plugin, version, sha, date] = cells - if (!plugin || !version || !sha || !date) { - continue - } - // Skip header row and divider row. - if (plugin === 'plugin' || /^-+$/.test(plugin.replace(/[\s:-]/g, '-'))) { - continue - } - rows.push({ plugin, version, sha, date }) - } - return rows -} - -export function isGuardedPath( - p: string, -): { kind: 'json' | 'readme' } | undefined { - if (p.endsWith('/.claude-plugin/marketplace.json')) { - return { kind: 'json' } - } - if (p.endsWith('/.claude-plugin/README.md')) { - return { kind: 'readme' } - } - return undefined -} - -export function reconstructAfterEdit( - filePath: string, - tool: 'Edit' | 'Write', - input: Hook['tool_input'], -): string | undefined { - if (tool === 'Write') { - return input?.content ?? '' - } - // Edit: apply old_string → new_string to the current on-disk content. - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - let current: string - try { - current = readFileSync(filePath, 'utf8') - } catch { - return undefined - } - const idx = current.indexOf(oldStr) - if (idx === -1) { - return undefined - } - return current.slice(0, idx) + newStr + current.slice(idx + oldStr.length) -} - -export function siblingPath(filePath: string, kind: 'json' | 'readme'): string { - const dir = path.dirname(filePath) - return kind === 'json' - ? path.join(dir, 'README.md') - : path.join(dir, 'marketplace.json') -} - -export function validate(pins: PluginPin[], rows: ReadmeRow[]): BadPin[] { - const bad: BadPin[] = [] - const byPlugin = new Map() - for (let i = 0, { length } = rows; i < length; i += 1) { - const row = rows[i]! - byPlugin.set(row.plugin, row) - } - for (let i = 0, { length } = pins; i < length; i += 1) { - const pin = pins[i]! - const row = byPlugin.get(pin.name) - if (!row) { - bad.push({ - name: pin.name, - expected: pin, - reason: `no row in README pin table for plugin "${pin.name}"`, - }) - continue - } - if (row.version !== pin.ref) { - bad.push({ - name: pin.name, - expected: pin, - reason: `README version "${row.version}" does not match marketplace.json source.ref "${pin.ref}"`, - }) - } - if (row.sha !== pin.sha) { - bad.push({ - name: pin.name, - expected: pin, - reason: `README sha "${row.sha}" does not match marketplace.json source.sha "${pin.sha}"`, - }) - } - if (!ISO_DATE_RE.test(row.date)) { - bad.push({ - name: pin.name, - expected: pin, - reason: `README date "${row.date}" is not ISO-8601 YYYY-MM-DD`, - }) - } - } - return bad -} - -function main() { - let stdin = '' - process.stdin.on('data', chunk => { - stdin += chunk - }) - process.stdin.on('end', () => { - try { - let payload: Hook - try { - payload = JSON.parse(stdin) as Hook - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath) { - process.exit(0) - } - const kind = isGuardedPath(filePath) - if (!kind) { - process.exit(0) - } - - const reconstructed = reconstructAfterEdit( - filePath, - tool, - payload.tool_input, - ) - if (reconstructed === undefined) { - process.exit(0) - } - - const sibling = siblingPath(filePath, kind.kind) - let siblingContent: string - try { - siblingContent = readFileSync(sibling, 'utf8') - } catch { - // Sibling missing — block, the pair must exist together. - process.stderr.write( - `[marketplace-comment-guard] refusing edit: sibling file missing.\n` + - ` Edited: ${filePath}\n` + - ` Missing: ${sibling}\n\n` + - `marketplace.json and its sibling README.md must exist together.\n` + - `Create the missing file before editing the other.\n`, - ) - process.exit(2) - } - - const marketplaceJson = - kind.kind === 'json' ? reconstructed : siblingContent - const readme = kind.kind === 'readme' ? reconstructed : siblingContent - - const pins = extractPluginPins(marketplaceJson) - if (pins === undefined) { - process.stderr.write( - `[marketplace-comment-guard] refusing edit: marketplace.json is not parseable JSON.\n` + - ` File: ${kind.kind === 'json' ? filePath : sibling}\n\n` + - `Fix the JSON syntax before editing either side of the pair.\n`, - ) - process.exit(2) - } - - const rows = extractReadmeRows(readme) - const bad = validate(pins, rows) - if (bad.length === 0) { - process.exit(0) - } - - process.stderr.write( - `[marketplace-comment-guard] refusing edit: ` + - `${bad.length} plugin pin(s) drift between marketplace.json and README.md.\n` + - bad - .map( - b => - ` ${b.name}: ${b.reason}\n` + - ` expected row: | ${b.expected.name} | ${b.expected.ref} | ${b.expected.sha} | YYYY-MM-DD | ... |`, - ) - .join('\n') + - '\n\nFix: update the README pin table so every plugin in marketplace.json\n' + - 'has a row with matching version + sha + an ISO-8601 date.\n' + - 'Bump the SHA → bump the row. Same discipline as the GHA `uses:`\n' + - 'SHA-pin comments — the date column is the staleness signal.\n', - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[marketplace-comment-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/marketplace-comment-guard/package.json b/.claude/hooks/marketplace-comment-guard/package.json deleted file mode 100644 index f03d43b3c..000000000 --- a/.claude/hooks/marketplace-comment-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-marketplace-comment-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/marketplace-comment-guard/test/index.test.mts b/.claude/hooks/marketplace-comment-guard/test/index.test.mts deleted file mode 100644 index 3a03fcc81..000000000 --- a/.claude/hooks/marketplace-comment-guard/test/index.test.mts +++ /dev/null @@ -1,248 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: sync-required — test flow is sync. -// prefer-spawn-over-execsync: required — uses encoding/input options -// not exposed on the lib spawnSync wrapper. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function runHook(payload: object): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -function makeFixture( - marketplaceJson: string | undefined, - readme: string | undefined, -): { dir: string; jsonPath: string; readmePath: string } { - const dir = mkdtempSync(path.join(os.tmpdir(), 'mc-guard-')) - const pluginDir = path.join(dir, '.claude-plugin') - mkdirSync(pluginDir, { recursive: true }) - const jsonPath = path.join(pluginDir, 'marketplace.json') - const readmePath = path.join(pluginDir, 'README.md') - if (marketplaceJson !== undefined) { - writeFileSync(jsonPath, marketplaceJson) - } - if (readme !== undefined) { - writeFileSync(readmePath, readme) - } - return { dir, jsonPath, readmePath } -} - -const SHA = '9cb4fe4099195b2587c402117a3efce6ab5aac78' -const SHA_OTHER = 'cf6f8515d898ecb921c2da23d08235144fb16601' - -const VALID_JSON = JSON.stringify( - { - name: 'test', - plugins: [ - { - name: 'codex', - source: { - source: 'git-subdir', - url: 'https://github.com/openai/codex-plugin-cc.git', - path: 'plugins/codex', - ref: 'v1.0.1', - sha: SHA, - }, - }, - ], - }, - null, - 2, -) - -const VALID_README = `# marketplace - -| plugin | version | sha | date | notes | -|--------|---------|------------------------------------------|------------|-------| -| codex | v1.0.1 | ${SHA} | 2026-05-18 | test | -` - -test('SKIPS non-marketplace paths', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/some/other/file.json', - content: '{}', - }, - }) - assert.equal(exitCode, 0) -}) - -test('SKIPS non-Edit/Write tools', () => { - const { exitCode } = runHook({ - tool_name: 'Read', - tool_input: { - file_path: '/repo/.claude-plugin/marketplace.json', - }, - }) - assert.equal(exitCode, 0) -}) - -test('BLOCKS Write of marketplace.json when sibling README is missing', () => { - const { dir, jsonPath } = makeFixture(undefined, undefined) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /sibling file missing/) - } finally { - safeDeleteSync(dir) - } -}) - -test('ALLOWS Write of consistent marketplace.json + on-disk README', () => { - const { dir, jsonPath } = makeFixture(undefined, VALID_README) - try { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 0) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Write of marketplace.json when README sha is stale', () => { - const staleReadme = VALID_README.replace(SHA, SHA_OTHER) - const { dir, jsonPath } = makeFixture(undefined, staleReadme) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /sha .* does not match/) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Write of marketplace.json when README version is stale', () => { - const staleReadme = VALID_README.replace('v1.0.1', 'v1.0.0') - const { dir, jsonPath } = makeFixture(undefined, staleReadme) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /version .* does not match/) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Write of marketplace.json when README has no row for a plugin', () => { - const noRowReadme = `# marketplace - -| plugin | version | sha | date | notes | -|--------|---------|-----|------|-------| -` - const { dir, jsonPath } = makeFixture(undefined, noRowReadme) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /no row in README pin table/) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Write of marketplace.json when README date is malformed', () => { - const badDateReadme = VALID_README.replace('2026-05-18', 'May 18 2026') - const { dir, jsonPath } = makeFixture(undefined, badDateReadme) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: VALID_JSON }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /not ISO-8601/) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Write of malformed marketplace.json', () => { - const { dir, jsonPath } = makeFixture(undefined, VALID_README) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: jsonPath, content: '{not json' }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /not parseable JSON/) - } finally { - safeDeleteSync(dir) - } -}) - -test('ALLOWS Write of README with consistent on-disk marketplace.json', () => { - const { dir, readmePath } = makeFixture(VALID_JSON, undefined) - try { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: readmePath, content: VALID_README }, - }) - assert.equal(exitCode, 0) - } finally { - safeDeleteSync(dir) - } -}) - -test('BLOCKS Edit of README that removes a plugin row', () => { - const { dir, readmePath } = makeFixture(VALID_JSON, VALID_README) - try { - const { stderr, exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: readmePath, - old_string: `| codex | v1.0.1 | ${SHA} | 2026-05-18 | test |\n`, - new_string: '', - }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /no row in README pin table for plugin "codex"/) - } finally { - safeDeleteSync(dir) - } -}) - -test('ALLOWS Edit of README that bumps a row in sync with a JSON bump (simulated by also having the JSON match)', () => { - // For this case the README is being edited while marketplace.json on - // disk is already at the new sha. The edit is allowed because the - // post-edit README + on-disk JSON are consistent. - const { dir, readmePath } = makeFixture(VALID_JSON, VALID_README) - try { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: readmePath, - // No-op edit (replacing a string with itself) — content stays - // consistent with on-disk JSON. - old_string: 'test', - new_string: 'test', - }, - }) - assert.equal(exitCode, 0) - } finally { - safeDeleteSync(dir) - } -}) diff --git a/.claude/hooks/marketplace-comment-guard/tsconfig.json b/.claude/hooks/marketplace-comment-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/marketplace-comment-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/minify-mcp-output/README.md b/.claude/hooks/minify-mcp-output/README.md deleted file mode 100644 index 52af7c5aa..000000000 --- a/.claude/hooks/minify-mcp-output/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# minify-mcp-output - -A **Claude Code PostToolUse hook** that compresses MCP-tool output text -losslessly before it enters Claude's context. Pairs with the wire-level -proxy [`@socketsecurity/token-minifier`](../../packages/socket-token-minifier/) -for built-in tools (Read, Bash, Edit, etc.) — those have no PostToolUse -rewrite channel, so they only benefit from wire-level compression. - -## Why this rule - -MCP tools (declared via `.mcp.json`) can produce verbose output: JSON -arrays, nested objects, long text fields with whitespace and line -prefixes. Stage compression saves tokens **both** on the wire AND in -context (because Claude reads the compressed version going forward). - -Built-in tool results don't go through this hook — Claude Code's hook -runtime accepts `updatedMCPToolOutput` only when `tool_name` starts -with `mcp__`. For built-in tools, use the proxy instead. - -## Stages (identical to socket-token-minifier) - -| Stage | What it does | -| ------------- | ------------------------------------------------------- | -| `minify` | `JSON.stringify` without indent on JSON-shaped strings. | -| `strip-lines` | Removes ` 42\t` cat -n style line prefixes. | -| `whitespace` | Collapses 3+ blank lines to a single blank line. | - -All are deterministic, information-preserving transforms. No semantic -compression, no ML, no Python. - -## What's enforced - -- Hook fires only on `PostToolUse`. -- Hook activates only when `tool_name` starts with `mcp__`. -- Stages applied to all text content in the MCP `tool_response`, - including string-shaped responses, `{type:"text", text:"..."}` blocks, - and arrays thereof. -- Non-text content (images, structured data) passes through unchanged. -- The hook fails **open** on any internal error (exit 0 with no output) - so a bad deploy can't break tool delivery. - -## What's not enforced - -- Built-in tools (Read, Bash, Edit, Write, etc.) — Claude Code's - runtime does not accept `updatedMCPToolOutput` for them. Use the - proxy for wire-level compression. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "mcp__.*", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" - } - ] - } - ] - } -} -``` - -The matcher `mcp__.*` is a belt-and-suspenders narrowing — the hook -itself also checks `tool_name` startsWith `mcp__` and exits 0 if it -doesn't match. - -## Cross-fleet sync - -This hook lives in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/minify-mcp-output) -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -The compression-stage logic is intentionally **inlined** here rather -than imported from `packages/socket-token-minifier/` — that package -lives only in wheelhouse, while this hook cascades fleet-wide. -Inlining keeps the dependency-resolution graph trivial for downstream -repos. diff --git a/.claude/hooks/minify-mcp-output/index.mts b/.claude/hooks/minify-mcp-output/index.mts deleted file mode 100644 index 291f573f8..000000000 --- a/.claude/hooks/minify-mcp-output/index.mts +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -// Claude Code PostToolUse hook — minify-mcp-output. -// -// Applies lossless minification stages (minify / strip-lines / -// whitespace) to MCP-tool output text and returns the result via -// `hookSpecificOutput.updatedMCPToolOutput` — the only documented -// rewrite channel for PostToolUse, verified empirically. -// -// Scope: -// - PostToolUse only. -// - tool_name starts with `mcp__` (Claude Code's MCP tool naming -// convention: mcp____). -// - Other tool names (built-in: Read/Bash/Edit/etc.) pass through -// untouched — those have no PostToolUse rewrite channel; use the -// wire-level proxy (socket-token-minifier) instead. -// -// The hook fails OPEN on its own errors (exit 0 with no output) so a -// bad deploy can't break tool result delivery. -// -// Stages here are inlined (not imported from packages/socket-token- -// minifier/) because this hook cascades into every fleet repo via -// sync-scaffolding, while packages/socket-token-minifier/ lives only -// in wheelhouse. The stage logic is small enough that inlining is -// cleaner than orchestrating a workspace dependency that downstream -// repos don't have. - -import process from 'node:process' - -interface Payload { - hook_event_name?: string | undefined - tool_name?: string | undefined - tool_response?: unknown | undefined - // Plus session_id, cwd, etc. — we don't care. -} - -// ---------- Inlined stages (synced with packages/socket-token-minifier/src/stages/) ---------- - -export function minify(text: string): string { - const trimmed = text.trimStart() - if (trimmed.length === 0) { - return text - } - const first = trimmed.charCodeAt(0) - if (first !== 0x7b && first !== 0x5b) { - return text - } - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - return text - } - return JSON.stringify(parsed) -} - -const LINE_PREFIX_RE = /^[ \t]*\d+\t/gm -export function stripLines(text: string): string { - return text.replace(LINE_PREFIX_RE, '') -} - -const BLANK_RUN_RE = /\n(?:[ \t]*\n){2,}/g -export function whitespace(text: string): string { - return text.replace(BLANK_RUN_RE, '\n\n') -} - -export function applyStages(text: string): string { - return whitespace(stripLines(minify(text))) -} - -// ---------- Tool-response walker ---------- - -/** - * Walk an MCP tool_response value and compress text content in place. Returns - * the same structure with strings minified. Non-text content (images, - * structured data we don't recognize) passes through unchanged. - * - * Shapes we handle: - * - * - String → minified string. - * - { type: "text", text: string } → minified text. - * - { content: } - * - { type: "text", text: string }[] (typical MCP shape). - * - Other → passes through. - */ -export function compressMCPOutput(value: unknown): unknown { - if (typeof value === 'string') { - return applyStages(value) - } - if (Array.isArray(value)) { - return value.map(compressMCPOutput) - } - if (value !== null && typeof value === 'object') { - const obj = value as Record - const out: Record = { ...obj } - if (typeof obj['text'] === 'string') { - out['text'] = applyStages(obj['text']) - } - if (obj['content'] !== undefined) { - out['content'] = compressMCPOutput(obj['content']) - } - return out - } - return value -} - -// ---------- Hook IO ---------- - -export function isMCPToolName(name: string | undefined): boolean { - return typeof name === 'string' && name.startsWith('mcp__') -} - -function main() { - let stdin = '' - process.stdin.on('data', chunk => { - stdin += chunk - }) - process.stdin.on('end', () => { - try { - let payload: Payload - try { - payload = JSON.parse(stdin) as Payload - } catch { - process.exit(0) - } - if (payload.hook_event_name !== 'PostToolUse') { - process.exit(0) - } - if (!isMCPToolName(payload.tool_name)) { - process.exit(0) - } - const original = payload.tool_response - if (original === undefined) { - process.exit(0) - } - const compressed = compressMCPOutput(original) - const out = { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - updatedMCPToolOutput: compressed, - }, - } - process.stdout.write(JSON.stringify(out)) - process.exit(0) - } catch { - // Fail-open: silently exit 0 so Claude Code uses the original. - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/minify-mcp-output/package.json b/.claude/hooks/minify-mcp-output/package.json deleted file mode 100644 index 492493d1b..000000000 --- a/.claude/hooks/minify-mcp-output/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-minify-mcp-output", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/minify-mcp-output/test/index.test.mts b/.claude/hooks/minify-mcp-output/test/index.test.mts deleted file mode 100644 index ce83b7e9f..000000000 --- a/.claude/hooks/minify-mcp-output/test/index.test.mts +++ /dev/null @@ -1,164 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { compressMCPOutput, isMCPToolName } from '../index.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function runHook(payload: object): { - stdout: string - exitCode: number -} { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stdout: String(result.stdout), exitCode: result.status ?? -1 } -} - -// ---------- isMCPToolName ---------- - -test('isMCPToolName: accepts mcp__ prefix', () => { - assert.equal(isMCPToolName('mcp__github__list_repos'), true) - assert.equal(isMCPToolName('mcp__playwright__navigate'), true) -}) - -test('isMCPToolName: rejects built-in tool names', () => { - for (const name of ['Read', 'Bash', 'Edit', 'Write', 'Grep']) { - assert.equal(isMCPToolName(name), false) - } -}) - -test('isMCPToolName: rejects undefined / wrong type', () => { - assert.equal(isMCPToolName(undefined), false) - assert.equal(isMCPToolName(''), false) -}) - -// ---------- compressMCPOutput ---------- - -test('compressMCPOutput: minifies string-shaped response', () => { - const got = compressMCPOutput(' 1\thello\n 2\tworld\n') - assert.equal(got, 'hello\nworld\n') -}) - -test('compressMCPOutput: minifies text block in object', () => { - const got = compressMCPOutput({ - type: 'text', - text: '\n\n\n\nfoo\n', - }) - assert.deepEqual(got, { type: 'text', text: '\n\nfoo\n' }) -}) - -test('compressMCPOutput: minifies text blocks in arrays', () => { - const got = compressMCPOutput([ - { type: 'text', text: ' 1\tline a\n' }, - { type: 'text', text: ' 2\tline b\n' }, - ]) - assert.deepEqual(got, [ - { type: 'text', text: 'line a\n' }, - { type: 'text', text: 'line b\n' }, - ]) -}) - -test('compressMCPOutput: walks into nested content fields', () => { - const got = compressMCPOutput({ - content: [{ type: 'text', text: ' 1\tfoo\n' }], - }) - assert.deepEqual(got, { - content: [{ type: 'text', text: 'foo\n' }], - }) -}) - -test('compressMCPOutput: passes through non-text blocks', () => { - const input = { - type: 'image', - source: { data: 'abc', media_type: 'image/png' }, - } - assert.deepEqual(compressMCPOutput(input), input) -}) - -test('compressMCPOutput: passes through primitives that aren’t strings', () => { - assert.equal(compressMCPOutput(42), 42) - assert.equal(compressMCPOutput(true), true) - assert.equal(compressMCPOutput(undefined), null) -}) - -test('compressMCPOutput: minifies JSON-shaped strings', () => { - const got = compressMCPOutput('{\n "a": 1,\n "b": 2\n}') - assert.equal(got, '{"a":1,"b":2}') -}) - -// ---------- hook IO ---------- - -test('hook: SKIPS non-PostToolUse events', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PreToolUse', - tool_name: 'mcp__x__y', - tool_response: 'whatever', - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: SKIPS built-in tools', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Read', - tool_response: { content: 'whatever' }, - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: SKIPS when tool_response is absent', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__x__y', - }) - assert.equal(exitCode, 0) - assert.equal(stdout.trim(), '') -}) - -test('hook: emits updatedMCPToolOutput for MCP tool with text content', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__github__list_repos', - tool_response: [{ type: 'text', text: ' 1\tfoo\n 2\tbar\n' }], - }) - assert.equal(exitCode, 0) - const parsed = JSON.parse(stdout) as { - hookSpecificOutput: { - hookEventName: string - updatedMCPToolOutput: Array<{ text: string }> - } - } - assert.equal(parsed.hookSpecificOutput.hookEventName, 'PostToolUse') - assert.equal( - parsed.hookSpecificOutput.updatedMCPToolOutput[0]!.text, - 'foo\nbar\n', - ) -}) - -test('hook: emits updatedMCPToolOutput for MCP tool with string-shaped response', () => { - const { stdout, exitCode } = runHook({ - hook_event_name: 'PostToolUse', - tool_name: 'mcp__custom__tool', - tool_response: '{\n "x": 1\n}', - }) - assert.equal(exitCode, 0) - const parsed = JSON.parse(stdout) as { - hookSpecificOutput: { updatedMCPToolOutput: string } - } - assert.equal(parsed.hookSpecificOutput.updatedMCPToolOutput, '{"x":1}') -}) - -test('hook: fails open on malformed stdin', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: '{not json', - }) - assert.equal(result.status, 0) - assert.equal(String(result.stdout).trim(), '') -}) diff --git a/.claude/hooks/minify-mcp-output/tsconfig.json b/.claude/hooks/minify-mcp-output/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/minify-mcp-output/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/minimum-release-age-guard/README.md b/.claude/hooks/minimum-release-age-guard/README.md deleted file mode 100644 index d642eef48..000000000 --- a/.claude/hooks/minimum-release-age-guard/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# minimum-release-age-guard - -PreToolUse Edit/Write hook that blocks additions to `pnpm-workspace.yaml` -`minimumReleaseAge.exclude[]`. - -## Why - -`pnpm`'s `minimumReleaseAge` (typically set to `7d`) refuses to install -packages whose npm publish date is younger than the cap. The cap is -malware-soak protection: packages published within the last week are still -in the suspicion window for typosquats, postinstall malware, and supply-chain -attacks that haven't yet been caught by Socket / npm / community signal. - -`minimumReleaseAge.exclude[]` opts specific packages OUT of the soak. Every -entry is a malware-protection hole — and most attempts to add to it are -quick-fix shortcuts to install a package that just published, not legitimate -emergency CVE patches. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------------------- | ------ | -| Edit/Write that adds a name to `minimumReleaseAge.exclude[]` | yes | -| Edit/Write that removes a name from `minimumReleaseAge.exclude[]` | no | -| Edit/Write touching `pnpm-workspace.yaml` but not the exclude array | no | -| Edit/Write to any other file | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow minimumReleaseAge bypass - -Use sparingly. The legitimate cases are: - -- Emergency CVE patch published in the last 7 days. -- First-party package you control (lower attack-surface risk). - -## Detection - -The hook parses both the current file contents and the after-edit contents -as YAML (permissive, narrow to the `minimumReleaseAge.exclude` block), then -computes the set difference. Names added → block. Names removed or unchanged -→ pass. - -Fails open on YAML parse errors — better to under-block than to brick edits -when the file is in a transient bad state. diff --git a/.claude/hooks/minimum-release-age-guard/index.mts b/.claude/hooks/minimum-release-age-guard/index.mts deleted file mode 100644 index eddd5740d..000000000 --- a/.claude/hooks/minimum-release-age-guard/index.mts +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — minimum-release-age-guard. -// -// Blocks Edit/Write operations that add entries to a `pnpm-workspace.yaml` -// file's `minimumReleaseAge.exclude[]` array. The 7-day soak is intentional -// malware-soak protection — packages on npm <7 days are still in the -// suspicion window for typosquats / postinstall-script malware / etc. -// Adding to the exclude list bypasses that protection. -// -// Detection model: -// - Fires only on Edit / Write to files named `pnpm-workspace.yaml`. -// - For Edit: applies new_string-over-old_string to current file contents, -// parses before+after as YAML, computes the set difference of the -// `minimumReleaseAge.exclude` array. New names → block. -// - For Write: compares against current contents (absent file = empty -// exclude array). -// -// Bypass: `Allow minimumReleaseAge bypass` typed verbatim in a recent user -// turn — for emergency CVE patches where a legitimately-published-yesterday -// fix must be installed before the 7-day window closes. -// -// Fails open on parse errors (better to under-block than to brick edits -// when the file isn't parseable YAML). - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow minimumReleaseAge bypass' - -// Permissive YAML extraction tailored to the `minimumReleaseAge.exclude` -// block. We don't pull in a full YAML library — the block shape is narrow: -// -// minimumReleaseAge: -// exclude: -// - pkg-a -// - "@scope/pkg-b" -// -// Returns the set of `- ` entries under the exclude list. Empty set -// when the block isn't present. -export function extractExcludeNames(yamlText: string): Set { - const lines = yamlText.split(/\r?\n/) - const out = new Set() - let inMra = false - let mraIndent = -1 - let inExclude = false - let excludeIndent = -1 - for (let i = 0, { length } = lines; i < length; i += 1) { - const raw = lines[i]! - const line = raw.replace(/\s+#.*$/, '') - const trimmed = line.trim() - if (!trimmed) { - continue - } - const indent = line.length - line.trimStart().length - - if (!inMra) { - if (/^minimumReleaseAge\s*:\s*$/.test(trimmed)) { - inMra = true - mraIndent = indent - } - continue - } - - if (indent <= mraIndent && trimmed.length > 0) { - inMra = false - inExclude = false - continue - } - - if (!inExclude) { - if (/^exclude\s*:\s*$/.test(trimmed)) { - inExclude = true - excludeIndent = indent - } - continue - } - - if (indent <= excludeIndent && trimmed.length > 0) { - inExclude = false - continue - } - - const itemMatch = /^-\s+(.+)$/.exec(trimmed) - if (!itemMatch) { - continue - } - let name = itemMatch[1]!.trim() - name = name.replace(/^["']|["']$/g, '') - if (name) { - out.add(name) - } - } - return out -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath || path.basename(filePath) !== 'pnpm-workspace.yaml') { - process.exit(0) - } - - const currentText = readFileSafe(filePath) - let afterText: string - if (payload.tool_name === 'Write') { - afterText = input?.content ?? input?.new_string ?? '' - } else { - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr) { - process.exit(0) - } - if (!currentText.includes(oldStr)) { - process.exit(0) - } - afterText = currentText.replace(oldStr, newStr) - } - - let beforeNames: Set - let afterNames: Set - try { - beforeNames = extractExcludeNames(currentText) - afterNames = extractExcludeNames(afterText) - } catch (e) { - process.stderr.write( - `[minimum-release-age-guard] parse error (allowing): ${e}\n`, - ) - process.exit(0) - } - - const added: string[] = [] - for (const name of afterNames) { - if (!beforeNames.has(name)) { - added.push(name) - } - } - if (added.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - added.sort() - process.stderr.write( - [ - '[minimum-release-age-guard] Blocked: minimumReleaseAge.exclude additions', - '', - ` File: ${filePath}`, - ` New entries: ${added.map(n => `\`${n}\``).join(', ')}`, - '', - ' The 7-day `minimumReleaseAge` soak is intentional malware-soak', - ' protection. Packages on npm < 7 days are still in the typosquat /', - ' postinstall-malware suspicion window. Adding to `exclude[]`', - ' bypasses that protection for the listed packages.', - '', - ' Legitimate cases (rare):', - ' - Emergency CVE patch published < 7 days ago.', - ' - First-party package you control.', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[minimum-release-age-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/minimum-release-age-guard/package.json b/.claude/hooks/minimum-release-age-guard/package.json deleted file mode 100644 index addbcabc1..000000000 --- a/.claude/hooks/minimum-release-age-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-minimum-release-age-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/minimum-release-age-guard/test/index.test.mts b/.claude/hooks/minimum-release-age-guard/test/index.test.mts deleted file mode 100644 index e18e0e3de..000000000 --- a/.claude/hooks/minimum-release-age-guard/test/index.test.mts +++ /dev/null @@ -1,133 +0,0 @@ -// node --test specs for the minimum-release-age-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function tmpYaml(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'mra-guard-test-')) - const p = path.join(dir, 'pnpm-workspace.yaml') - writeFileSync(p, content) - return p -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo hi' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit to a non-workspace file passes', async () => { - const filePath = tmpYaml('foo: bar\n').replace( - /pnpm-workspace\.yaml$/, - 'package.json', - ) - writeFileSync(filePath, '{"foo": "bar"}') - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '"bar"', - new_string: '"baz"', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit removes an exclude entry — passes', async () => { - const filePath = tmpYaml( - 'minimumReleaseAge:\n exclude:\n - pkg-a\n - pkg-b\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: ' - pkg-a\n - pkg-b\n', - new_string: ' - pkg-a\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit adds a new exclude entry — blocked', async () => { - const filePath = tmpYaml('minimumReleaseAge:\n exclude:\n - pkg-a\n') - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: ' - pkg-a\n', - new_string: ' - pkg-a\n - pkg-b\n', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('pkg-b')) -}) - -test('Write adds a fresh exclude — blocked', async () => { - const filePath = tmpYaml('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: 'minimumReleaseAge:\n exclude:\n - sketchy-pkg\n', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('sketchy-pkg')) -}) - -test('Edit with bypass phrase in transcript — passes', async () => { - const filePath = tmpYaml('minimumReleaseAge:\n exclude:\n - pkg-a\n') - const dir = mkdtempSync(path.join(os.tmpdir(), 'mra-guard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow minimumReleaseAge bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: ' - pkg-a\n', - new_string: ' - pkg-a\n - pkg-b\n', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/minimum-release-age-guard/tsconfig.json b/.claude/hooks/minimum-release-age-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/minimum-release-age-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/new-hook-claude-md-guard/README.md b/.claude/hooks/new-hook-claude-md-guard/README.md deleted file mode 100644 index e506a3200..000000000 --- a/.claude/hooks/new-hook-claude-md-guard/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# new-hook-claude-md-guard - -**Wheelhouse-only** PreToolUse hook. Blocks `Write` / `Edit` to a hook's `index.mts` unless `template/CLAUDE.md` contains an `(enforced by `.claude/hooks//`)` reference for that hook. - -## Why - -Fleet repos read `template/CLAUDE.md` as the source of truth for behavioral rules. A hook without a corresponding CLAUDE.md entry is policy that exists in code but not on paper — users get blocked by a rule they never read. - -This hook closes that drift the moment it would land. Without the CLAUDE.md entry, the hook commit is refused. - -## What it requires - -Adding a new hook (`template/.claude/hooks/my-rule/index.mts`) must be accompanied by an entry in `template/CLAUDE.md`: - -```markdown -🚨 Never do bad thing X — explanation here (enforced by `.claude/hooks/my-rule/`). -``` - -The pattern: **one minimal line, attached to the rule it enforces**, with the parenthetical hook reference in `(enforced by `.claude/hooks//`)` form. Don't add prose; the hook's README carries the detail. - -Accepted variants: - -- ``(enforced by `.claude/hooks/my-rule/`)`` — preferred -- ``(enforced by `.claude/hooks/my-rule`)`` — trailing slash optional -- `` enforced by `.claude/hooks/my-rule/` `` — without parens (less common but accepted) - -## Why wheelhouse-only - -Downstream fleet repos receive their CLAUDE.md and hook code via `sync-scaffolding`. They consume the canonical version; they shouldn't be re-policing the source-of-truth mapping. This hook lives in `template/.claude/hooks/new-hook-claude-md-guard/` but is **NOT** listed in `scripts/sync-scaffolding/manifest.mts`'s `IDENTICAL_FILES`, so the cascade skips it. - -## Skipped paths - -- `template/.claude/hooks/_shared/...` — helpers, not hooks -- `test/*.test.mts` — test files -- `new-hook-claude-md-guard` itself — chicken-and-egg -- Any hook listed in `WHEELHOUSE_ONLY_HOOKS` in index.mts - -## Bypass - -For follow-up commits on the same PR where the CLAUDE.md entry lands separately, type any of these in a user message: - -- `Allow new-hook bypass` -- `Allow new hook bypass` -- `Allow newhook bypass` - -Or set `SOCKET_NEW_HOOK_CLAUDE_MD_GUARD_DISABLED=1` to turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/new-hook-claude-md-guard/index.mts b/.claude/hooks/new-hook-claude-md-guard/index.mts deleted file mode 100644 index d5736424b..000000000 --- a/.claude/hooks/new-hook-claude-md-guard/index.mts +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — new-hook-claude-md-guard. -// -// Blocks Write/Edit operations that create or modify a hook's -// `index.mts` unless the relevant CLAUDE.md contains an -// `(enforced by `.claude/hooks//`)` reference. -// -// Two-mode behavior: -// -// 1. In socket-wheelhouse (path matches `template/.claude/hooks/`): -// checks `template/CLAUDE.md` — the fleet-canonical source. -// Forces any new hook to land alongside a documented rule. -// -// 2. In every fleet repo (path matches `.claude/hooks/` at repo -// root): checks the repo's `CLAUDE.md`. Catches downstream -// forks — if someone adds a hook locally (against the -// no-fleet-fork rule), the missing citation in the cascaded -// fleet block blocks the edit. Defense in depth on top of -// no-fleet-fork-guard. -// -// Fires on: -// - Write to `/template/.claude/hooks//index.mts` (wheelhouse) -// - Edit to `/template/.claude/hooks//index.mts` (wheelhouse) -// - Write/Edit to `/.claude/hooks//index.mts` (any fleet repo) -// -// Skips: -// - `_shared/` (not a hook, just helpers) -// - Test files (`test/*.test.mts`) -// - This hook itself (chicken-and-egg) -// -// Disable: `Allow new-hook bypass` in a recent user turn, or set -// SOCKET_NEW_HOOK_CLAUDE_MD_GUARD_DISABLED=1. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly file_path?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_NEW_HOOK_CLAUDE_MD_GUARD_DISABLED' -const BYPASS_PHRASES = [ - 'Allow new-hook bypass', - 'Allow new hook bypass', - 'Allow newhook bypass', -] as const - -// Match either: -// /template/.claude/hooks//index.mts (wheelhouse) -// /.claude/hooks//index.mts (any fleet repo) -// -// Captures the hook name in group 1. The optional `template/` segment -// covers the wheelhouse path; the rest is identical. -const HOOK_INDEX_PATH_RE = - /.*?(?:\/template)?\/\.claude\/hooks\/([^/]+)\/index\.mts$/ - -// Hooks that are themselves wheelhouse-only — they don't need a -// CLAUDE.md entry because they're internal tooling, not policy rules -// the fleet should know about. Update when adding more. -const WHEELHOUSE_ONLY_HOOKS: ReadonlySet = new Set([ - 'new-hook-claude-md-guard', -]) - -export function findCanonicalClaudeMd( - filePath: string, - cwd: string | undefined, -): string | undefined { - // Wheelhouse mode: `/template/.claude/hooks//index.mts` - // → check `/template/CLAUDE.md` (the fleet-canonical source). - const tplIdx = filePath.indexOf('/template/.claude/hooks/') - if (tplIdx >= 0) { - return filePath.slice(0, tplIdx) + '/template/CLAUDE.md' - } - // Downstream mode: `/.claude/hooks//index.mts` - // → check `/CLAUDE.md` (the cascaded fleet block lives here). - const repoIdx = filePath.indexOf('/.claude/hooks/') - if (repoIdx >= 0) { - return filePath.slice(0, repoIdx) + '/CLAUDE.md' - } - // Fallback: try cwd-relative. Prefer template/ if present, else - // fall back to repo-root CLAUDE.md. - if (cwd) { - const tplCandidate = path.join(cwd, 'template', 'CLAUDE.md') - if (existsSync(tplCandidate)) { - return tplCandidate - } - const rootCandidate = path.join(cwd, 'CLAUDE.md') - if (existsSync(rootCandidate)) { - return rootCandidate - } - } - return undefined -} - -export function readPayload(raw: string): PreToolUsePayload | undefined { - try { - return JSON.parse(raw) as PreToolUsePayload - } catch { - return undefined - } -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const payloadRaw = await readStdin() - const payload = readPayload(payloadRaw) - if (!payload) { - process.exit(0) - } - const toolName = payload.tool_name - if (toolName !== 'Edit' && toolName !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.['file_path'] - if (typeof filePath !== 'string') { - process.exit(0) - } - const match = HOOK_INDEX_PATH_RE.exec(filePath) - if (!match) { - process.exit(0) - } - const hookName = match[1]! - // Skip _shared (helpers, not a hook) and wheelhouse-only hooks. - if (hookName === '_shared' || WHEELHOUSE_ONLY_HOOKS.has(hookName)) { - process.exit(0) - } - // Bypass via canonical user phrase. - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - const claudeMdPath = findCanonicalClaudeMd(filePath, payload.cwd) - if (!claudeMdPath || !existsSync(claudeMdPath)) { - // Can't find CLAUDE.md; fail-open rather than blocking on - // infrastructure problems. - process.exit(0) - } - let content: string - try { - content = readFileSync(claudeMdPath, 'utf8') - } catch { - process.exit(0) - } - // The required form is `(enforced by `.claude/hooks//`)`. - // We accept either backtick-quoted or plain-text variants of the - // path — the existing fleet uses backticks consistently, but a - // trailing slash is also optional. - const expectedRefs = [ - `(enforced by \`.claude/hooks/${hookName}/\`)`, - `(enforced by \`.claude/hooks/${hookName}\`)`, - `enforced by \`.claude/hooks/${hookName}/\``, - `enforced by \`.claude/hooks/${hookName}\``, - ] - let found = false - for (let i = 0, { length } = expectedRefs; i < length; i += 1) { - if (content.includes(expectedRefs[i]!)) { - found = true - break - } - } - if (found) { - process.exit(0) - } - - const lines = [ - `[new-hook-claude-md-guard] Hook "${hookName}" missing CLAUDE.md reference.`, - '', - ` ${toolName} blocked: template/CLAUDE.md must contain a one-line`, - ` reference to the hook before it lands. Expected form (inline,`, - ` attached to the rule the hook enforces):`, - '', - ` (enforced by \`.claude/hooks/${hookName}/\`)`, - '', - ' Why: fleet repos read CLAUDE.md as the source of truth. A hook', - " without a CLAUDE.md entry is policy that doesn't exist on paper —", - " users won't know why they got blocked. Keep the entry minimal,", - ' attached to an existing rule whenever possible.', - '', - ' Bypass (use sparingly, e.g. when adding the CLAUDE.md entry in', - ' a follow-up commit on the same PR): type "Allow new-hook bypass"', - ' in a recent message.', - '', - ] - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/new-hook-claude-md-guard/package.json b/.claude/hooks/new-hook-claude-md-guard/package.json deleted file mode 100644 index 86de1824e..000000000 --- a/.claude/hooks/new-hook-claude-md-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-new-hook-claude-md-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts b/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts deleted file mode 100644 index 02226412a..000000000 --- a/.claude/hooks/new-hook-claude-md-guard/test/index.test.mts +++ /dev/null @@ -1,234 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface FakeRepo { - readonly root: string - readonly templatePath: string - readonly claudeMdPath: string - readonly hookIndexPath: (hookName: string) => string - cleanup(): void -} - -function makeFakeRepo(claudeMdContent: string): FakeRepo { - const root = mkdtempSync(path.join(os.tmpdir(), 'newhook-')) - const templatePath = path.join(root, 'template') - mkdirSync(path.join(templatePath, '.claude', 'hooks'), { recursive: true }) - const claudeMdPath = path.join(templatePath, 'CLAUDE.md') - writeFileSync(claudeMdPath, claudeMdContent) - return { - root, - templatePath, - claudeMdPath, - hookIndexPath: hookName => - path.join(templatePath, '.claude', 'hooks', hookName, 'index.mts'), - cleanup: () => rmSync(root, { recursive: true, force: true }), - } -} - -function makeTranscript(dir: string, bypassPhrase?: string): string { - const transcriptPath = path.join(dir, 'session.jsonl') - const userContent = bypassPhrase ?? 'normal message' - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userContent }), - ) - return transcriptPath -} - -function runHook(payload: object): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('BLOCKS when adding a new hook without CLAUDE.md reference', () => { - const repo = makeFakeRepo('# CLAUDE.md\n\nNo references at all here.\n') - try { - const filePath = repo.hookIndexPath('my-new-hook') - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: filePath }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 2) - assert.match(stderr, /new-hook-claude-md-guard/) - assert.match(stderr, /my-new-hook/) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS when CLAUDE.md has the canonical reference', () => { - const repo = makeFakeRepo( - '# CLAUDE.md\n\nA rule sentence (enforced by `.claude/hooks/my-new-hook/`).\n', - ) - try { - const filePath = repo.hookIndexPath('my-new-hook') - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: filePath }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - repo.cleanup() - } -}) - -test('ALLOWS when CLAUDE.md uses trailing-slash-omitted variant', () => { - const repo = makeFakeRepo('(enforced by `.claude/hooks/my-new-hook`)') - try { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS for _shared/ helper edits', () => { - const repo = makeFakeRepo('# nothing here') - try { - const filePath = path.join( - repo.templatePath, - '.claude', - 'hooks', - '_shared', - 'index.mts', - ) - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: filePath }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS for self (new-hook-claude-md-guard) — chicken-and-egg', () => { - const repo = makeFakeRepo('# nothing here') - try { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { file_path: repo.hookIndexPath('new-hook-claude-md-guard') }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS with "Allow new-hook bypass" phrase', () => { - const repo = makeFakeRepo('# no reference') - try { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, - transcript_path: makeTranscript(repo.root, 'Allow new-hook bypass'), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS with hyphen variant "Allow new hook bypass"', () => { - const repo = makeFakeRepo('# no reference') - try { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, - transcript_path: makeTranscript(repo.root, 'Allow new hook bypass'), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES tools other than Write/Edit', () => { - const repo = makeFakeRepo('# no reference') - try { - const { exitCode } = runHook({ - tool_name: 'Read', - tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES files outside template/.claude/hooks/*/index.mts', () => { - const repo = makeFakeRepo('# no reference') - try { - const filePath = path.join(repo.templatePath, 'random-other-file.mts') - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: filePath }, - transcript_path: makeTranscript(repo.root), - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES test files inside hook dirs', () => { - const repo = makeFakeRepo('# no reference') - try { - const filePath = path.join( - repo.templatePath, - '.claude', - 'hooks', - 'my-new-hook', - 'test', - 'index.test.mts', - ) - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { file_path: filePath }, - transcript_path: makeTranscript(repo.root), - }) - // test/ files don't match HOOK_INDEX_PATH_RE (path doesn't end - // with //index.mts — it ends with /test/index.test.mts). - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const repo = makeFakeRepo('# no reference') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { file_path: repo.hookIndexPath('my-new-hook') }, - transcript_path: makeTranscript(repo.root), - }), - env: { ...process.env, SOCKET_NEW_HOOK_CLAUDE_MD_GUARD_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - repo.cleanup() - } -}) diff --git a/.claude/hooks/new-hook-claude-md-guard/tsconfig.json b/.claude/hooks/new-hook-claude-md-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/new-hook-claude-md-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-blind-keychain-read-guard/README.md b/.claude/hooks/no-blind-keychain-read-guard/README.md deleted file mode 100644 index 22a1796f9..000000000 --- a/.claude/hooks/no-blind-keychain-read-guard/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# no-blind-keychain-read-guard - -`PreToolUse(Bash)` blocker that refuses direct keychain READ calls -from Bash. The keychain APIs surface a UI auth prompt per call; -reading three times costs three prompts. The fleet's canonical -in-process resolver (`api-token.mts.findApiToken()`) caches the -value module-scoped after the first hit, so subsequent code paths -should never need to re-read the keychain. - -## Detected reads - -| Platform | Pattern | -| -------------- | ---------------------------------------------- | -| macOS | `security find-{generic,internet}-password` | -| Linux | `secret-tool lookup` / `secret-tool search` | -| Windows | `Get-StoredCredential` | -| Windows | `Get-Credential … \| ConvertFrom-SecureString` | -| cross-platform | `keyring get` | - -## Allowed (not flagged) - -Writes and deletes — these only happen during operator-driven -setup / rotation, never on hot paths: - -- `security add-generic-password` / `security delete-generic-password` -- `secret-tool store` / `secret-tool clear` -- `New-StoredCredential` / `Remove-StoredCredential` -- `keyring set` / `keyring del` - -## Bypass - -Type the canonical phrase verbatim in your next user turn: - -``` -Allow blind-keychain-read bypass -``` - -Use when you genuinely need a fresh keychain read — operator-invoked -diagnostics, verifying an entry exists, etc. - -## Why - -`security find-generic-password` on macOS prompts the user every call -unless the calling process is on the entry's ACL. Claude Code's Bash -tool spawns a fresh process per call, so each `security` invocation -re-prompts. The same shape exists on Linux (`secret-tool` against -gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against -the CredentialManager UI). - -The right answer is to read the cached value from process state: - -```ts -import { findApiToken } from '../setup-security-tools/lib/api-token.mts' -const { token } = findApiToken() // module-cached after first call -``` - -Or from a child process spawned by hooks: - -```bash -echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge -``` - -The bridge writes the token to `~/.zshenv` (or platform equivalent) -so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` -without a keychain read. diff --git a/.claude/hooks/no-blind-keychain-read-guard/index.mts b/.claude/hooks/no-blind-keychain-read-guard/index.mts deleted file mode 100644 index bdb109425..000000000 --- a/.claude/hooks/no-blind-keychain-read-guard/index.mts +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-blind-keychain-read-guard. -// -// Blocks Bash invocations that READ a credential from the OS -// keychain. Reading via the platform CLI surfaces a per-call UI auth -// prompt on the user's screen ("this app wants to access your -// keychain"), and the prompt fires once per call — a hook chain that -// reads the keychain three times costs three prompts. Tokens are -// already cached in process memory after the first resolution; the -// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits -// the cache, then env, then keychain, in that order. Bash callers -// that go straight to `security find-generic-password` skip all of -// that and re-prompt the user every time. -// -// Detects (case-sensitive, structural — not just substring): -// -// macOS: -// security find-generic-password -// security find-internet-password -// -// Linux: -// secret-tool lookup -// secret-tool search -// -// Windows (PowerShell): -// Get-StoredCredential (CredentialManager module) -// Get-Credential (when piping to ConvertFrom-SecureString) -// -// Cross-platform (Python keyring CLI): -// keyring get -// -// Allowed (writes / deletes — necessary for operator-driven setup / -// rotation, never on hot paths): -// -// security add-generic-password security delete-generic-password -// secret-tool store secret-tool clear -// New-StoredCredential Remove-StoredCredential -// keyring set keyring del -// -// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. -// Use when you genuinely need to verify a keychain entry exists -// (e.g. operator-invoked diagnostics). -// -// Exit codes: -// 0 — pass. -// 2 — block. -// -// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's -// hook contract. - -import process from 'node:process' - -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly command?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -interface Hit { - readonly tool: string - readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' - readonly snippet: string -} - -const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' - -// Token-bearing read patterns. Each entry: the literal verb that -// surfaces a UI prompt + a label for the error message. Writes / -// deletes are intentionally absent from this list. -const READ_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly tool: string - readonly platform: Hit['platform'] -}> = [ - // macOS — `security(1)`. The `-w` flag prints the password to - // stdout, but even the metadata-only form triggers the ACL prompt. - { - re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, - tool: 'security find-*-password', - platform: 'macos', - }, - // Linux — `secret-tool`. `lookup` returns the password; `search` - // lists matches (also surfaces the libsecret prompt). - { - re: /\bsecret-tool\s+(?:lookup|search)\b/, - tool: 'secret-tool lookup/search', - platform: 'linux', - }, - // Windows PowerShell — CredentialManager module. The - // `Get-StoredCredential` cmdlet returns a PSCredential; reading - // `.Password | ConvertFrom-SecureString` is the read pattern. - { - re: /\bGet-StoredCredential\b/, - tool: 'Get-StoredCredential', - platform: 'windows', - }, - // PowerShell `Get-Credential -Credential` piped to - // `ConvertFrom-SecureString -AsPlainText` is the readback shape. - // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user - // flow and not the issue here — match only the readback pipe. - { - re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, - tool: 'Get-Credential | ConvertFrom-SecureString', - platform: 'windows', - }, - // Python `keyring` CLI — `keyring get `. - { - re: /\bkeyring\s+get\b/, - tool: 'keyring get', - platform: 'cross-platform', - }, -] - -/** - * Scan a Bash command string for keychain READ patterns. Returns one hit per - * matching subcommand so the error message can name them all (a `&&`-chained - * command might have multiple). - */ -export function findKeychainReads(command: string): Hit[] { - const hits: Hit[] = [] - for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { - const entry = READ_PATTERNS[i]! - const m = entry.re.exec(command) - if (!m) { - continue - } - // Pull a short snippet around the match (up to 80 chars) so the - // operator can see the context. Centered on the match start. - const start = Math.max(0, m.index - 10) - const end = Math.min(command.length, m.index + m[0].length + 50) - const snippet = command.slice(start, end) - hits.push({ - tool: entry.tool, - platform: entry.platform, - snippet: snippet.length < command.length ? `…${snippet}…` : snippet, - }) - } - return hits -} - -function handlePayload(payloadRaw: string): number { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - return 0 - } - if (payload.tool_name !== 'Bash') { - return 0 - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return 0 - } - const hits = findKeychainReads(command) - if (hits.length === 0) { - return 0 - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - return 0 - } - const lines: string[] = [] - lines.push( - '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', - ) - lines.push('') - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) - lines.push(` Saw: ${h.snippet}`) - } - lines.push('') - lines.push(' Reading the keychain via the platform CLI surfaces a UI auth') - lines.push(" prompt on the user's screen — and the prompt fires once per") - lines.push(' call. A hook chain that reads three times costs three prompts.') - lines.push('') - lines.push(' The token is almost certainly already available without a') - lines.push(' keychain read:') - lines.push('') - lines.push(' - In-process: call findApiToken() from setup-security-tools/') - lines.push(' lib/api-token.mts. It returns the module-cached value from') - lines.push(' the first call onward, then env, then keychain.') - lines.push('') - lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') - lines.push( - ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', - ) - lines.push(' exports both for every new shell session.') - lines.push('') - lines.push(' Writes / deletes (security add-generic-password / secret-tool') - lines.push(' store / New-StoredCredential / etc.) are allowed — they only') - lines.push(' happen during operator-driven setup / rotation.') - lines.push('') - lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') - lines.push(' keychain read):') - lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) - process.stderr.write(lines.join('\n') + '\n') - return 2 -} - -export { handlePayload } - -// CLI entrypoint — only fires when this file is the main module. -// During tests the importer pulls `findKeychainReads` without triggering -// the stdin reader (which would never see an `end` event in test env -// and hang the process). -if (process.argv[1] && process.argv[1].endsWith('index.mts')) { - let payloadRaw = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - payloadRaw += chunk - }) - process.stdin.on('end', () => { - try { - process.exit(handlePayload(payloadRaw)) - } catch (e) { - process.stderr.write( - `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) -} diff --git a/.claude/hooks/no-blind-keychain-read-guard/package.json b/.claude/hooks/no-blind-keychain-read-guard/package.json deleted file mode 100644 index 819429bd2..000000000 --- a/.claude/hooks/no-blind-keychain-read-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-blind-keychain-read-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts deleted file mode 100644 index 8567a3b85..000000000 --- a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @file Unit tests for findKeychainReads — the structural matcher that - * classifies a Bash command string into keychain READ hits (vs writes, - * deletes, and unrelated commands). - */ - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { findKeychainReads } from '../index.mts' - -test('macOS find-generic-password is flagged', () => { - const hits = findKeychainReads( - 'security find-generic-password -s socket-cli -a SOCKET_API_KEY -w', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'macos') -}) - -test('macOS find-internet-password is flagged', () => { - const hits = findKeychainReads( - 'security find-internet-password -s example.com -a user', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'macos') -}) - -test('macOS add-generic-password is NOT flagged (write)', () => { - const hits = findKeychainReads( - 'security add-generic-password -U -s socket-cli -a SOCKET_API_KEY -w xxx', - ) - assert.equal(hits.length, 0) -}) - -test('macOS delete-generic-password is NOT flagged (delete)', () => { - const hits = findKeychainReads( - 'security delete-generic-password -s socket-cli -a SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Linux secret-tool lookup is flagged', () => { - const hits = findKeychainReads( - 'secret-tool lookup service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'linux') -}) - -test('Linux secret-tool search is flagged', () => { - const hits = findKeychainReads('secret-tool search service socket-cli') - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'linux') -}) - -test('Linux secret-tool store is NOT flagged (write)', () => { - const hits = findKeychainReads( - 'secret-tool store --label="Socket API token" service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Linux secret-tool clear is NOT flagged (delete)', () => { - const hits = findKeychainReads( - 'secret-tool clear service socket-cli user SOCKET_API_KEY', - ) - assert.equal(hits.length, 0) -}) - -test('Windows Get-StoredCredential is flagged', () => { - const hits = findKeychainReads( - 'powershell -Command "(Get-StoredCredential -Target \'socket-cli:SOCKET_API_KEY\').Password"', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'windows') -}) - -test('Windows Get-Credential | ConvertFrom-SecureString is flagged', () => { - const hits = findKeychainReads( - 'Get-Credential -Credential admin | ConvertFrom-SecureString -AsPlainText', - ) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'windows') -}) - -test('Windows Get-Credential WITHOUT pipe is NOT flagged (fresh prompt)', () => { - // Bare Get-Credential is an interactive fresh-prompt flow, not a - // readback of a stored credential. Don't block. - const hits = findKeychainReads('$cred = Get-Credential -Credential admin') - assert.equal(hits.length, 0) -}) - -test('Windows New-StoredCredential is NOT flagged (write)', () => { - const hits = findKeychainReads( - "New-StoredCredential -Target 'socket-cli:SOCKET_API_KEY' -UserName x -SecurePassword $s", - ) - assert.equal(hits.length, 0) -}) - -test('keyring get is flagged', () => { - const hits = findKeychainReads('keyring get socket-cli SOCKET_API_KEY') - assert.equal(hits.length, 1) - assert.equal(hits[0]!.platform, 'cross-platform') -}) - -test('keyring set is NOT flagged (write)', () => { - const hits = findKeychainReads('keyring set socket-cli SOCKET_API_KEY') - assert.equal(hits.length, 0) -}) - -test('chained reads count separately', () => { - // && chain with two reads - const hits = findKeychainReads( - 'security find-generic-password -s a -a b -w && secret-tool lookup service a user b', - ) - assert.equal(hits.length, 2) -}) - -test('unrelated commands are not flagged', () => { - for (const cmd of [ - 'ls -la', - 'git log --oneline -5', - 'echo $SOCKET_API_KEY', - 'pnpm install', - 'grep security file.txt', - 'security delete-keychain ~/Library/Keychains/foo.keychain', - ]) { - const hits = findKeychainReads(cmd) - assert.equal(hits.length, 0, `should not flag: ${cmd}`) - } -}) - -test('command substitution wrapping is still flagged', () => { - // The structural matcher is intentionally a regex, not an AST. This - // catches the common subshell shape — verifying the inner verb is - // detected even inside `$(...)`. AST-based parsing is overkill for - // a non-security-critical reminder hook. - const hits = findKeychainReads( - 'TOKEN="$(security find-generic-password -s socket-cli -a SOCKET_API_KEY -w)" && echo done', - ) - assert.equal(hits.length, 1) -}) diff --git a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-disable-lint-rule-guard/README.md b/.claude/hooks/no-disable-lint-rule-guard/README.md deleted file mode 100644 index 35308dbd3..000000000 --- a/.claude/hooks/no-disable-lint-rule-guard/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# no-disable-lint-rule-guard - -PreToolUse hook that blocks Edit/Write operations adding `"some-rule": "off"` (or `"warn"`) to any oxlint or `.eslintrc` config file. - -## Why - -Lint rules catch real classes of bug or style drift. Disabling a rule globally weakens the gate for every file matching its selector — and the disabled rule becomes invisible to future readers. The fleet rule: **fix the underlying code**, not the config. - -## What it catches - -Block examples: - -- Adding `"socket/foo": "off"` to `.config/oxlintrc.json` -- Adding `"no-console": "warn"` to `.eslintrc.json` -- Writing a new lint config file that already contains rule disables - -Allow examples: - -- Editing a lint config to add new rules -- Editing a lint config to REMOVE a rule disable (i.e. re-enabling) -- Edits to any non-config file -- Per-line `oxlint-disable-next-line -- ` comments (those live in source files, not config) - -## How to bypass - -`Allow disable-lint-rule bypass` typed verbatim in a recent message. Use sparingly — the right answer is almost always to fix the code or use a per-line exemption with a reason. - -## How to disable in tests - -`SOCKET_NO_DISABLE_LINT_RULE_GUARD_DISABLED=1` short-circuits the hook. Only used by the hook's own test suite. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/no-disable-lint-rule-guard/index.mts b/.claude/hooks/no-disable-lint-rule-guard/index.mts deleted file mode 100644 index 20cda3fb0..000000000 --- a/.claude/hooks/no-disable-lint-rule-guard/index.mts +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-disable-lint-rule-guard. -// -// Blocks Edit/Write operations that ADD a `"rule-name": "off"` (or -// "warn") entry to any oxlint or .eslintrc config file. The fleet -// rule is: fix the underlying code, don't weaken the gate. Genuine -// single-call-site exemptions belong in a `oxlint-disable-next-line -// -- ` comment on the violating line. -// -// Trigger surface (filename match, anywhere in the path): -// - oxlintrc.json -// - oxlintrc.dogfood.json -// - any *oxlintrc*.json -// - .eslintrc, .eslintrc.json, .eslintrc.js, eslint.config.* -// -// Detection: compare old vs new content. If new_string adds a string -// matching /"": "off"/ (or "warn") that wasn't in -// old_string, block. The check is text-based — works for both Edit -// (old_string + new_string fields) and Write (full file content). -// -// Bypass: `Allow disable-lint-rule bypass` typed verbatim in a -// recent user message. -// Env disable (testing only): SOCKET_NO_DISABLE_LINT_RULE_GUARD_DISABLED=1. -// -// Hook contract: -// - Reads PreToolUse JSON from stdin. -// - Exits 0 (allow) or 2 (block + stderr explanation). -// - Fails open on any internal error. - -import { existsSync, readFileSync } from 'node:fs' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: unknown | undefined - readonly old_string?: unknown | undefined - readonly new_string?: unknown | undefined - readonly content?: unknown | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_NO_DISABLE_LINT_RULE_GUARD_DISABLED' -const BYPASS_PHRASE = 'Allow disable-lint-rule bypass' - -// Matches: ESLint configs and oxlint configs by filename, anywhere in path. -const CONFIG_FILE_RE = - /(?:^|\/)(?:[^/]*oxlintrc[^/]*\.json|\.eslintrc(?:\.[a-z]+)?|eslint\.config\.[a-z]+)$/i - -// Matches a rule-off (or rule-warn) entry. Captures the rule name. -const RULE_DISABLE_RE = /"([a-z][a-z0-9/-]+)":\s*"(?:off|warn)"/gi - -/** - * Returns true if `filePath` looks like an oxlint/.eslintrc config file. - */ -export function isLintConfigPath(filePath: string): boolean { - return CONFIG_FILE_RE.test(filePath) -} - -/** - * Returns the set of rules disabled in `content` (any rule mapped to "off" or - * "warn"). - */ -export function extractDisabledRules(content: string): Set { - const out = new Set() - for (const m of content.matchAll(RULE_DISABLE_RE)) { - const rule = m[1] - if (rule) { - out.add(rule) - } - } - return out -} - -interface BlockReason { - readonly addedRules: readonly string[] - readonly filePath: string -} - -/** - * Given the old and new file content, returns the rules newly mapped to - * "off"/"warn" in new that weren't in old. Empty array means no weakening was - * added. - */ -export function newlyDisabledRules( - oldContent: string, - newContent: string, -): string[] { - const oldRules = extractDisabledRules(oldContent) - const newRules = extractDisabledRules(newContent) - const added: string[] = [] - for (const rule of newRules) { - if (!oldRules.has(rule)) { - added.push(rule) - } - } - return added.toSorted() -} - -function getOldNewContent( - payload: PreToolUsePayload, -): { readonly old: string; readonly next: string } | undefined { - const input = payload.tool_input - if (!input) { - return undefined - } - const filePath = typeof input.file_path === 'string' ? input.file_path : '' - if (payload.tool_name === 'Edit') { - const oldString = - typeof input.old_string === 'string' ? input.old_string : '' - const newString = - typeof input.new_string === 'string' ? input.new_string : '' - return { old: oldString, next: newString } - } - if (payload.tool_name === 'Write') { - const next = typeof input.content === 'string' ? input.content : '' - let old = '' - if (filePath && existsSync(filePath)) { - try { - old = readFileSync(filePath, 'utf8') - } catch { - old = '' - } - } - return { old, next } - } - return undefined -} - -function reportBlock(reason: BlockReason): void { - const ruleList = reason.addedRules.map(r => ` - ${r}`).join('\n') - const lines = [ - '[no-disable-lint-rule-guard] Edit weakens lint policy.', - '', - ` File: ${reason.filePath}`, - ` New disables:`, - ruleList, - '', - " Don't disable rules globally. Fix the underlying code, or use a", - ' per-line exemption with a reason:', - '', - ' // oxlint-disable-next-line -- ', - '', - ' See docs/claude.md/fleet/no-disable-lint-rule.md for the full', - ' rationale + scoped-override recipe.', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a recent message.`, - ] - process.stderr.write(lines.join('\n') + '\n') -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - let payload: PreToolUsePayload - try { - const raw = await readStdin() - payload = JSON.parse(raw) as PreToolUsePayload - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - - const input = payload.tool_input - if (!input) { - process.exit(0) - } - const filePath = typeof input.file_path === 'string' ? input.file_path : '' - if (!filePath || !isLintConfigPath(filePath)) { - process.exit(0) - } - - const contents = getOldNewContent(payload) - if (!contents) { - process.exit(0) - } - - const added = newlyDisabledRules(contents.old, contents.next) - if (added.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - reportBlock({ addedRules: added, filePath }) - process.exit(2) -} - -main().catch(() => { - // Fail open — never wedge operator flow on internal hook errors. - process.exit(0) -}) diff --git a/.claude/hooks/no-disable-lint-rule-guard/package.json b/.claude/hooks/no-disable-lint-rule-guard/package.json deleted file mode 100644 index a3662f58f..000000000 --- a/.claude/hooks/no-disable-lint-rule-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-disable-lint-rule-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts b/.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts deleted file mode 100644 index 1ecc047c8..000000000 --- a/.claude/hooks/no-disable-lint-rule-guard/test/index.test.mts +++ /dev/null @@ -1,215 +0,0 @@ -import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly stderr: string - readonly exitCode: number -} - -function makeTranscript(bypassPhrase?: string): { - readonly transcriptPath: string - cleanup(): void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'nodlrg-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const userContent = bypassPhrase ?? 'normal message' - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userContent }), - ) - return { - transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook( - payload: Record, - options: { - readonly bypassPhrase?: string | undefined - readonly env?: Record | undefined - } = {}, -): RunResult { - const t = makeTranscript(options.bypassPhrase) - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - ...payload, - transcript_path: t.transcriptPath, - }), - env: { ...process.env, ...(options.env ?? {}) }, - encoding: 'utf8', - }) - return { - stderr: String(result.stderr ?? ''), - exitCode: result.status ?? -1, - } - } finally { - t.cleanup() - } -} - -// Sanity: non-config files don't trigger - -test('ALLOWS edit to non-config file', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/src/index.mts', - old_string: 'foo', - new_string: 'bar', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS non-Edit/Write tools', () => { - const { exitCode } = runHook({ - tool_name: 'Bash', - tool_input: { command: 'ls' }, - }) - assert.equal(exitCode, 0) -}) - -// Allow: edits to lint configs that DON'T add rule disables - -test('ALLOWS oxlintrc edit that does not add disables', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"rules": {\n "foo": "error"\n}', - new_string: '"rules": {\n "foo": "error",\n "bar": "error"\n}', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS oxlintrc edit that removes a rule-off entry', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"some-rule": "off"', - new_string: '"some-rule": "error"', - }, - }) - assert.equal(exitCode, 0) -}) - -// Block: edits that add a rule-off - -test('BLOCKS oxlintrc Edit that adds a rule-off', () => { - const { exitCode, stderr } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/foo": "off"\n}', - }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /socket\/foo/) -}) - -test('BLOCKS oxlintrc Edit that adds a rule-warn', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/foo": "warn"\n}', - }, - }) - assert.equal(exitCode, 2) -}) - -test('BLOCKS dogfood oxlintrc Edit that adds disables', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.dogfood.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/bar": "off"\n}', - }, - }) - assert.equal(exitCode, 2) -}) - -test('BLOCKS template oxlintrc Edit that adds disables', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/template/.config/oxlintrc.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/bar": "off"\n}', - }, - }) - assert.equal(exitCode, 2) -}) - -test('BLOCKS .eslintrc.json Edit that adds disables', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.eslintrc.json', - old_string: '"rules": {}', - new_string: '"rules": { "no-console": "off" }', - }, - }) - assert.equal(exitCode, 2) -}) - -// Bypass - -test('ALLOWS with bypass phrase', () => { - const { exitCode } = runHook( - { - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/foo": "off"\n}', - }, - }, - { bypassPhrase: 'Allow disable-lint-rule bypass' }, - ) - assert.equal(exitCode, 0) -}) - -test('ALLOWS with env disable', () => { - const { exitCode } = runHook( - { - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.config/oxlintrc.json', - old_string: '"rules": {}', - new_string: '"rules": {\n "socket/foo": "off"\n}', - }, - }, - { env: { SOCKET_NO_DISABLE_LINT_RULE_GUARD_DISABLED: '1' } }, - ) - assert.equal(exitCode, 0) -}) - -// Write tool: file doesn't exist yet -> baseline = empty - -test('BLOCKS Write of new lint config with rule-off', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/nonexistent/.config/oxlintrc.json', - content: '{"rules": {"some-rule": "off"}}', - }, - }) - assert.equal(exitCode, 2) -}) diff --git a/.claude/hooks/no-disable-lint-rule-guard/tsconfig.json b/.claude/hooks/no-disable-lint-rule-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-disable-lint-rule-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-empty-commit-guard/README.md b/.claude/hooks/no-empty-commit-guard/README.md deleted file mode 100644 index c43b041dc..000000000 --- a/.claude/hooks/no-empty-commit-guard/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# no-empty-commit-guard - -PreToolUse hook that blocks two empty-commit shapes the fleet bans -(see CLAUDE.md "Commits & PRs → No empty commits"): - -1. `git commit --allow-empty` (with or without `-m`, also covers - `--allow-empty-message`). -2. `git cherry-pick --allow-empty` / `--keep-redundant-commits` — - replaying a no-content commit forward. - -## Why blocking - -Empty commits pollute `git log`, break CHANGELOG generators (which -expect each commit to carry a diff), and hide intent: a future -reader can't tell whether the author meant to amend the previous -commit, anchor a tag, or something else. - -The canonical way to anchor a release tag forward is -`git tag -f vX.Y.Z ` against an actual content -commit, not a fake "anchor" commit with no diff. Force-moving the -tag is a cleaner mechanism than synthesising history. - -## Bypass - -Type `Allow empty-commit bypass` verbatim in a recent user turn, -then retry. The phrase authorises the next blocked `git commit` -or `git cherry-pick` invocation within the conversation window. - -## Skipped silently - -- `tool_name !== 'Bash'`. -- Commands that don't contain `git commit` or `git cherry-pick`. -- `--allow-empty` appearing inside a quoted string (e.g. inside a - `-m` commit-message body that mentions the flag). - -## Failure mode - -Fails open: any internal error logs to stderr and exits 0. The hook -is a quality gate, not a hard dependency — it never wedges the -operator's flow. diff --git a/.claude/hooks/no-empty-commit-guard/index.mts b/.claude/hooks/no-empty-commit-guard/index.mts deleted file mode 100644 index 93ad6e1ba..000000000 --- a/.claude/hooks/no-empty-commit-guard/index.mts +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-empty-commit-guard. -// -// Blocks two empty-commit shapes the fleet bans (see CLAUDE.md -// "Commits & PRs → No empty commits"): -// -// 1. `git commit --allow-empty` (with or without `-m`). -// 2. `git cherry-pick --allow-empty` / `--keep-redundant-commits` -// against a ref whose patch is empty relative to HEAD. -// -// Why blocking, not reminder: empty commits pollute `git log`, break -// CHANGELOG generators (which expect each commit to carry a diff), -// and hide intent ("did the author mean to anchor a tag? amend a -// previous commit? something else?"). The canonical way to anchor -// a release tag forward is `git tag -f vX.Y.Z` against the actual -// content commit, not a fake "anchor" commit with no diff. -// -// Skipped silently: -// - tool_name !== 'Bash'. -// - Command doesn't contain `git commit` or `git cherry-pick`. -// - Bypass phrase present in recent transcript turns. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/path/to/jsonl", // optional -// ... } -// -// Exit codes: -// 0 — allow. -// 2 — block. Stderr carries the operator-facing message. -// -// Fails open on any internal error (exit 0 + stderr log) so the -// hook never wedges the operator's flow. - -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow empty-commit bypass' - -/** - * Detect `git commit --allow-empty` (and `--allow-empty-message`, which is the - * same antipattern — both produce a no-op commit). Parser-based: the flag must - * belong to a real `git commit` invocation, so a literal `--allow-empty` in a - * commit-message body or a sibling command doesn't false-positive. - */ -export function isAllowEmptyCommit(command: string): boolean { - return commandsFor(command, 'git').some( - c => - c.args.includes('commit') && - c.args.some(a => a === '--allow-empty' || a === '--allow-empty-message'), - ) -} - -/** - * Detect `git cherry-pick --allow-empty` or `--keep-redundant-commits` — both - * replay a no-content commit forward into the current branch, which is exactly - * the empty-commit pattern the rule bans. - */ -export function isCherryPickAllowEmpty(command: string): boolean { - return commandsFor(command, 'git').some( - c => - c.args.includes('cherry-pick') && - c.args.some( - a => a === '--allow-empty' || a === '--keep-redundant-commits', - ), - ) -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!command) { - process.exit(0) - } - - const allowEmptyCommit = isAllowEmptyCommit(command) - const allowEmptyCherryPick = isCherryPickAllowEmpty(command) - if (!allowEmptyCommit && !allowEmptyCherryPick) { - process.exit(0) - } - - // Operator bypass — `Allow empty-commit bypass` in a recent turn. - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - process.exit(0) - } - - const flag = allowEmptyCommit - ? '--allow-empty (or --allow-empty-message)' - : '--allow-empty / --keep-redundant-commits' - process.stderr.write( - [ - `[no-empty-commit-guard] Blocked: git ${allowEmptyCommit ? 'commit' : 'cherry-pick'} ${flag}`, - '', - ' Empty commits pollute `git log`, break CHANGELOG generators', - ' (which expect each commit to carry a diff), and hide intent.', - '', - ' If you are anchoring a release tag forward, use:', - ' git tag -f vX.Y.Z ', - ' git push origin --force-with-lease vX.Y.Z', - '', - ' If you genuinely need to record a no-content waypoint, type', - ` "${BYPASS_PHRASE}" in chat, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-empty-commit-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/no-empty-commit-guard/package.json b/.claude/hooks/no-empty-commit-guard/package.json deleted file mode 100644 index b78fb046c..000000000 --- a/.claude/hooks/no-empty-commit-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-empty-commit-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-empty-commit-guard/test/index.test.mts b/.claude/hooks/no-empty-commit-guard/test/index.test.mts deleted file mode 100644 index 8e0618869..000000000 --- a/.claude/hooks/no-empty-commit-guard/test/index.test.mts +++ /dev/null @@ -1,134 +0,0 @@ -// node --test specs for the no-empty-commit-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Bash tool calls pass through silently', async () => { - const result = await runHook({ - tool_input: { file_path: 'foo.ts', new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('plain git commit passes through silently', async () => { - const result = await runHook({ - tool_input: { command: 'git commit -m "real change"' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('git commit --allow-empty is blocked', async () => { - const result = await runHook({ - tool_input: { - command: 'git commit --allow-empty -m "anchor v1.0.0 tag"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) -}) - -test('git commit --allow-empty-message is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git commit --allow-empty-message -m ""' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) -}) - -test('git cherry-pick --allow-empty is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git cherry-pick --allow-empty abc1234' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) -}) - -test('git cherry-pick --keep-redundant-commits is blocked', async () => { - const result = await runHook({ - tool_input: { - command: 'git cherry-pick --keep-redundant-commits abc1234', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-empty-commit-guard.*Blocked/) -}) - -test('plain git cherry-pick passes through silently', async () => { - const result = await runHook({ - tool_input: { command: 'git cherry-pick abc1234' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('commit message bodies mentioning --allow-empty are skipped (quote-aware)', async () => { - const result = await runHook({ - tool_input: { - command: `git commit -m "docs: forbid git commit --allow-empty in fleet"`, - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('--allow-empty in a SEPARATE chained command is not attributed to git commit', async () => { - // Parser scopes the flag to the invocation that owns it: here the - // commit is plain and `--allow-empty` is just an echo arg. The old - // substring approach would have wrongly blocked this. - const result = await runHook({ - tool_input: { - command: 'git commit -m "real change" && echo "next: --allow-empty"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('git commit --allow-empty chained after another command is still blocked', async () => { - const result = await runHook({ - tool_input: { command: 'cd /x && git commit --allow-empty -m anchor' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) diff --git a/.claude/hooks/no-empty-commit-guard/tsconfig.json b/.claude/hooks/no-empty-commit-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-empty-commit-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-experimental-strip-types-guard/README.md b/.claude/hooks/no-experimental-strip-types-guard/README.md deleted file mode 100644 index 92818e1b9..000000000 --- a/.claude/hooks/no-experimental-strip-types-guard/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# no-experimental-strip-types-guard - -PreToolUse Bash hook that blocks commands passing `--experimental-strip-types` to Node. - -## Why - -The `--experimental-strip-types` flag became: - -- **Stable** in Node 22.6 (renamed to `--strip-types`, flag still accepted as alias). -- **Default-on** in Node 24+. - -The fleet runs Node 22.6+ everywhere. Passing the flag is dead weight — it's a no-op on every supported runtime, emits a deprecation warning on some, and usually signals a stale copy-pasted invocation that was lifted from a Node 22.0–22.5 era guide. - -## What it blocks - -| Pattern | Why | -| ----------------------------------------------- | ------------------------------------------------------- | -| `node --experimental-strip-types foo.ts` | Strip is stable/default; flag is a no-op. | -| `NODE_OPTIONS='--experimental-strip-types' ...` | Same. Captured by the same regex (word-boundary match). | -| `pnpm exec node --experimental-strip-types ...` | Same. | - -## How - -The hook reads the Claude Code PreToolUse JSON payload from stdin, inspects `tool_input.command` for a word-boundary match against `--experimental-strip-types`, and exits 2 (block) with a stderr message identifying the current Node version. Fails open on malformed input (exit 0). - -## Bypass - -None. If a tool genuinely needs the flag (e.g. you're testing Node behavior on a stale runtime), invoke node directly without going through Bash, or pin a specific older Node version in the script. There is no allowlist — every fleet repo runs Node 22.6+. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/no-experimental-strip-types-guard/index.mts b/.claude/hooks/no-experimental-strip-types-guard/index.mts deleted file mode 100644 index f15f407dc..000000000 --- a/.claude/hooks/no-experimental-strip-types-guard/index.mts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-experimental-strip-types-guard. -// -// Blocks Bash commands that pass `--experimental-strip-types` to Node. -// The flag became unnecessary in Node 22.6 (when --experimental-strip-types -// went stable) and is a no-op since Node 24+, which strips TS types by -// default. The fleet runs Node 26+; passing the flag is dead weight and -// usually signals stale copy-pasted invocations. -// -// On block, emits stderr identifying the current Node version so the -// reader can see why the flag isn't needed here. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// ... } -// -// Exit codes: -// 0 — pass (not a Bash tool, or command doesn't pass the flag). -// 2 — block (command passes --experimental-strip-types). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import process from 'node:process' - -import { parseCommands } from '../_shared/shell-command.mts' - -interface ToolInput { - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly tool_name?: string | undefined -} - -const FLAG = '--experimental-strip-types' - -// True when any parsed command passes `--experimental-strip-types` as a -// real argument, or carries it inside a `NODE_OPTIONS=…` env assignment -// (Node parses that value as args at startup, so it's live even when the -// assignment value is quoted). The parser scopes the flag to an actual -// invocation, so a quoted mention inside an `echo`/`-m` body is ignored. -function passesStripTypesFlag(command: string): boolean { - for (const c of parseCommands(command)) { - if (c.args.some(a => a === FLAG || a.startsWith(`${FLAG}=`))) { - return true - } - for (const a of c.assignments) { - if (a.startsWith('NODE_OPTIONS=') && a.includes(FLAG)) { - return true - } - } - } - return false -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - // Fail OPEN on any internal bug. The JSON.parse below already has - // its own try/catch (bad payloads exit 0), but unexpected throws in - // the regex/stderr path would otherwise become unhandled rejections - // → exit 1 → block. Per CLAUDE.md, hooks must not brick the session - // on their own crash. - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - // Fail open on malformed payload. - process.exit(0) - } - - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - - // Fire only when the flag is a real argument to a parsed command, or - // lives in a NODE_OPTIONS env assignment — never on a quoted mention - // inside an `echo`/`-m` message body. - if (!passesStripTypesFlag(command)) { - process.exit(0) - } - - process.stderr.write( - [ - '[no-experimental-strip-types-guard] Blocked: --experimental-strip-types', - '', - ` Current Node: ${process.version}`, - ' The fleet runs Node 22.6+ / 24+ / 26+, where TypeScript type stripping', - ' is either stable (no flag needed) or default-on. Passing the flag is', - ' a no-op and usually signals a stale copy-pasted invocation.', - '', - ' Fix: remove `--experimental-strip-types` from the command.', - '', - ].join('\n'), - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-experimental-strip-types-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/no-experimental-strip-types-guard/package.json b/.claude/hooks/no-experimental-strip-types-guard/package.json deleted file mode 100644 index a80205dd3..000000000 --- a/.claude/hooks/no-experimental-strip-types-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-experimental-strip-types-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts b/.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts deleted file mode 100644 index 0e35fbf29..000000000 --- a/.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts +++ /dev/null @@ -1,170 +0,0 @@ -// node --test specs for the no-experimental-strip-types-guard hook. -// -// Spawns the hook as a subprocess (matches the production runtime), -// pipes a JSON payload on stdin, captures stderr + exit code. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -interface Result { - readonly code: number - readonly stderr: string -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Bash tool calls pass through untouched', async () => { - const result = await runHook({ - tool_input: { file_path: 'foo.ts', new_string: 'const x = 1' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('benign bash commands pass through', async () => { - const result = await runHook({ - tool_input: { command: 'node foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('blocks --experimental-strip-types as a node arg', async () => { - const result = await runHook({ - tool_input: { command: 'node --experimental-strip-types foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-experimental-strip-types-guard/) - assert.match(result.stderr, /Current Node/) -}) - -test('blocks --experimental-strip-types via NODE_OPTIONS', async () => { - const result = await runHook({ - tool_input: { - command: 'NODE_OPTIONS="--experimental-strip-types" node foo.ts', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks --experimental-strip-types via pnpm exec', async () => { - const result = await runHook({ - tool_input: { - command: 'pnpm exec node --experimental-strip-types foo.ts', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('does not match a substring that is not the flag', async () => { - // Word-boundary check: --experimental-strip-types-foo should not match. - // But the regex uses \b which treats `-` as a word boundary too, so - // anything appearing after the flag word ends at any non-word char. - // The flag literally ending with another `--foo` after it should still - // match `--experimental-strip-types\b`. We document this with a positive - // test: bare flag matches even with trailing args. - const result = await runHook({ - tool_input: { - command: 'node --experimental-strip-types --some-other foo.ts', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('does not match an unrelated string containing experimental', async () => { - const result = await runHook({ - tool_input: { - command: 'node --experimental-vm-modules foo.ts', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('does not match flag mentioned inside a single-quoted string', async () => { - const result = await runHook({ - tool_input: { - command: "echo 'tip: drop --experimental-strip-types from your script'", - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('does not match flag mentioned inside a double-quoted string', async () => { - const result = await runHook({ - tool_input: { - command: 'echo "tip: drop --experimental-strip-types from your script"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('does not match flag mentioned inside a heredoc body', async () => { - const result = await runHook({ - tool_input: { - command: - 'git commit -m "$(cat <<\'EOF\'\nthe --experimental-strip-types flag is dead\nEOF\n)"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('still blocks flag passed as a real arg even when other quoted args mention it', async () => { - const result = await runHook({ - tool_input: { - command: "echo 'reminder' && node --experimental-strip-types foo.ts", - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('fails open on malformed payload', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not valid json') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/no-experimental-strip-types-guard/tsconfig.json b/.claude/hooks/no-experimental-strip-types-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-experimental-strip-types-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-external-issue-ref-guard/README.md b/.claude/hooks/no-external-issue-ref-guard/README.md deleted file mode 100644 index 33eb38d08..000000000 --- a/.claude/hooks/no-external-issue-ref-guard/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# no-external-issue-ref-guard - -PreToolUse Bash hook. Blocks `git commit` / `gh pr create|edit|comment|review` -/ `gh issue create|edit|comment` / `gh release create|edit` invocations -whose message body contains a GitHub issue/PR reference to a non-SocketDev -repo. - -## What it catches - -The leak is GitHub's auto-link behavior: any `/#` token -or `https://github.com///(issues|pull)/` URL in a commit -message posts an `added N commits that reference this issue` event back to -the target issue. A fleet-wide cascade with one such ref in the message ends -up pinging the upstream maintainer N times. - -## Allowed - -- Bare `#123` — resolves against the current repo, no cross-repo leak. -- `SocketDev/#` — same org, fine to ping (case-insensitive). -- `https://github.com/SocketDev/...` — same org. - -## Blocked - -- `spencermountain/compromise#1203` (or any other non-SocketDev `owner/repo#num`) -- `https://github.com/spencermountain/compromise/issues/1203` - -## Bypass - -`Allow external-issue-ref bypass` (verbatim, in a recent user turn). - -## Fix path the hook suggests - -- **Commit messages**: remove the ref. Move it to the PR description - prose; PR bodies don't backref from commits. -- **PR/issue bodies**: rewrite to masked-link form, e.g. - `[#1203](https://github.com/owner/repo/issues/1203)`. GitHub doesn't - backref markdown links the same way. - -## Cited from CLAUDE.md - -Under _Public-surface hygiene_: "No external issue/PR refs in commit -messages or PR bodies" bullet. diff --git a/.claude/hooks/no-external-issue-ref-guard/index.mts b/.claude/hooks/no-external-issue-ref-guard/index.mts deleted file mode 100644 index c05be1404..000000000 --- a/.claude/hooks/no-external-issue-ref-guard/index.mts +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-external-issue-ref-guard. -// -// Blocks `git commit` / `gh pr create` / `gh pr edit` / `gh issue create` -// / `gh issue comment` invocations whose message body references an -// issue or PR in a GitHub repo NOT owned by SocketDev. -// -// The leak: GitHub auto-links any `/#` token in a -// commit message and posts an `added N commits that reference this -// issue` event back to the target issue. When the fleet does a -// 12-repo cascade and every commit cites `spencermountain/compromise -// #1203`, the maintainer's issue gets spammed with 12 backrefs. -// -// Allowed: -// - bare `#123` (resolves against the current repo — no cross-repo leak) -// - `SocketDev/#` (same org — fine to ping) -// - `https://github.com/SocketDev/...` (same org) -// -// Blocked: -// - `/#` -// - `https://github.com///issues/` -// - `https://github.com///pull/` -// -// Fix path the hook suggests: -// - In commit messages: omit the ref. Put the link in the PR -// description prose instead (PR bodies don't backref from commits). -// - In PR/issue bodies: rewrite the bare `/#` token -// to a masked-link form `[#](https://github.com/...)` — GitHub -// doesn't backref markdown links the same way. -// -// Bypass: `Allow external-issue-ref bypass`. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." } } - -import { errorMessage } from '@socketsecurity/lib-stable/errors' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_name?: string | undefined - tool_input?: { command?: string | undefined } | undefined - transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow external-issue-ref bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -// Commands whose -m / --body / -F arguments end up on a public surface -// where GitHub will auto-link an issue token. -const PUBLIC_MESSAGE_COMMANDS: RegExp[] = [ - /\bgit\s+commit\b/, - /\bgh\s+pr\s+(comment|create|edit|review)\b/, - /\bgh\s+issue\s+(comment|create|edit)\b/, - /\bgh\s+release\s+(create|edit)\b/, -] - -// Org allowlist — case-insensitive, but kept lowercase for comparison. -// GitHub treats orgs case-insensitively in URLs and refs, so `socketdev`, -// `SocketDev`, `SOCKETDEV` all resolve to the same org. Storing -// canonical-case here keeps the hook honest about what it accepts. -const ALLOWED_ORGS = new Set(['socketdev']) - -// Detect `/#` token. Owner and repo names follow -// GitHub's rules: alphanumerics, dashes, underscores, dots (no -// leading dot/dash). We're permissive on the boundaries since we're -// pattern-matching prose, not validating canonical refs. -// -// (^|\s|\() — anchor at start, whitespace, or open paren. Prevents -// matching URL fragments that already contain the form -// (those are matched separately by the URL regex below). -// ([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?) — owner -// / -// ([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?) — repo -// # -// (\d+) — issue/PR number -// (?=\b|[\s.,;:)\]]|$) — terminate cleanly -const OWNER_REPO_REF_RE = - /(?:^|\s|\()([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)#(\d+)(?=\b|[\s.,;:)\]]|$)/g - -// Detect full GitHub issue/PR URLs to non-SocketDev orgs. -const GITHUB_URL_RE = - /https?:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\/(?:issues|pull)\/(\d+)/g - -/** - * Extract the textual message body from a shell command. Covers the three - * common forms: - * - * - `-m "..."` / `-m '...'` (one or more times — git supports it) - * - `--message=...` / `--message ...` - * - `--body=...` / `--body ...` - * - `--body-file=` is NOT inspected (we'd have to read the file; out of - * scope, we only check args-as-text) - * - HEREDOC bodies: `... -m "$(cat <<'EOF' ... EOF\n)"`. We parse the literal - * HEREDOC body when present in the command string. - * - * Returns all extracted message bodies joined by newlines so the caller can run - * one regex pass over the combined text. - */ -export function extractMessageBodies(command: string): string { - const out: string[] = [] - - // Match -m or --message and capture the following quoted or - // unquoted token. We have to be tolerant — quoting is shell- - // sensitive but the hook isn't a shell parser. - // - // Patterns: - // -m "text with spaces" - // -m 'text' - // -m text - // --message="text" - // --message text - // --body "..." - const flagRe = - /(?:^|\s)(?:--body|--body-text|--message|-m)(?:\s+|=)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)/g - let match: RegExpExecArray | null - while ((match = flagRe.exec(command)) !== null) { - const raw = match[1]! - out.push(unquoteShell(raw)) - } - - // HEREDOC bodies. Match `<<'TAG' ... TAG` (single-quoted tag = no - // shell interpolation, which is the conventional safe form used by - // the fleet's commit-message HEREDOCs). - const heredocRe = /<<\s*'([A-Z][A-Z0-9_]*)'([\s\S]*?)^\s*\1\s*$/gm - while ((match = heredocRe.exec(command)) !== null) { - out.push(match[2]!) - } - // Same for unquoted HEREDOC tags (still common). - const heredocUnquotedRe = /<<\s*([A-Z][A-Z0-9_]*)\b([\s\S]*?)^\s*\1\s*$/gm - while ((match = heredocUnquotedRe.exec(command)) !== null) { - out.push(match[2]!) - } - - return out.join('\n') -} - -/** - * Walk the message text and collect every external-org reference. Returns an - * empty array when the text only references same-repo (`#123`) or - * SocketDev-owned (`SocketDev/socket-lib#42`) issues. - */ -export function findExternalRefs(text: string): ExternalRef[] { - const out: ExternalRef[] = [] - - let m: RegExpExecArray | null - // Reset regex lastIndex (the regexes are module-scoped /g globals). - OWNER_REPO_REF_RE.lastIndex = 0 - while ((m = OWNER_REPO_REF_RE.exec(text)) !== null) { - const owner = m[1]! - const repo = m[2]! - const num = m[3]! - if (!ALLOWED_ORGS.has(owner.toLowerCase())) { - out.push({ - kind: 'token', - owner, - repo, - num, - raw: `${owner}/${repo}#${num}`, - }) - } - } - - GITHUB_URL_RE.lastIndex = 0 - while ((m = GITHUB_URL_RE.exec(text)) !== null) { - const owner = m[1]! - const repo = m[2]! - const num = m[3]! - if (!ALLOWED_ORGS.has(owner.toLowerCase())) { - out.push({ - kind: 'url', - owner, - repo, - num, - raw: m[0]!, - }) - } - } - - return out -} - -interface ExternalRef { - kind: 'token' | 'url' - owner: string - repo: string - num: string - raw: string -} - -export function isPublicMessageCommand(command: string): boolean { - const normalized = command.replace(/\s+/g, ' ') - return PUBLIC_MESSAGE_COMMANDS.some(re => re.test(normalized)) -} - -/** - * Strip a single layer of shell quoting from a token. Handles single quotes, - * double quotes, and unquoted text. We don't attempt full shell-quote - * unescaping — for the leak we're guarding against, the literal content is what - * GitHub sees, and any escaped char that's inside `/#` would - * prevent the auto-link anyway. - */ -export function unquoteShell(token: string): string { - if (token.length >= 2) { - const first = token[0] - const last = token[token.length - 1] - if (first === '"' && last === '"') { - return token.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') - } - if (first === "'" && last === "'") { - return token.slice(1, -1) - } - } - return token -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'no-external-issue-ref-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - if (payload.tool_name !== 'Bash') { - return 0 - } - const command = payload.tool_input?.command - if (!command || typeof command !== 'string') { - return 0 - } - if (!isPublicMessageCommand(command)) { - return 0 - } - - const body = extractMessageBodies(command) - if (!body) { - return 0 - } - - const refs = findExternalRefs(body) - if (refs.length === 0) { - return 0 - } - - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - // Build the user-facing block message. Group by ref so a single - // ref repeated three times in a HEREDOC body doesn't print three - // times. - const dedup = new Map() - for (let i = 0, { length } = refs; i < length; i += 1) { - const r = refs[i]! - if (!dedup.has(r.raw)) { - dedup.set(r.raw, r) - } - } - const lines: string[] = [ - '🚨 no-external-issue-ref-guard: blocked commit/PR/issue message ' + - 'referencing a non-SocketDev GitHub issue or PR.', - '', - 'Why this matters: GitHub auto-links these tokens and posts an', - "'added N commits that reference this issue' event back to the", - 'target. A fleet cascade of N commits = N pings to the maintainer.', - '', - 'Refs found:', - ] - for (const r of dedup.values()) { - lines.push(` - ${r.raw}`) - } - lines.push('') - lines.push('Fix one of:') - lines.push(' • Remove the ref from the commit message. Move it to') - lines.push(' the PR description prose, which does NOT backref.') - lines.push(' • Rewrite to masked-link form (does NOT auto-link):') - lines.push(' [#1203](https://github.com/owner/repo/issues/1203)') - lines.push(' • If the ref IS to a SocketDev-owned repo, write it as') - lines.push(' `SocketDev/#` (case-insensitive).') - lines.push('') - lines.push( - `Bypass (the user must type verbatim in a recent turn): \`${BYPASS_PHRASE}\``, - ) - process.stderr.write(lines.join('\n') + '\n') - return 2 -} - -main().then( - code => process.exit(code), - e => { - process.stderr.write( - `no-external-issue-ref-guard: hook bug — fail-open. ${errorMessage(e)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/no-external-issue-ref-guard/package.json b/.claude/hooks/no-external-issue-ref-guard/package.json deleted file mode 100644 index ace930b4c..000000000 --- a/.claude/hooks/no-external-issue-ref-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-external-issue-ref-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts b/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts deleted file mode 100644 index 4091f77fe..000000000 --- a/.claude/hooks/no-external-issue-ref-guard/test/index.test.mts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @file Unit tests for no-external-issue-ref-guard. Test strategy: spawn the - * hook with a JSON payload on stdin and assert the exit code + stderr. - * Mirrors the test shape used by the no-revert-guard / no-meta-comments-guard - * test suites. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(payload: object): RunResult { - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function commit(command: string, transcriptPath?: string): RunResult { - const payload: Record = { - tool_name: 'Bash', - tool_input: { command }, - } - if (transcriptPath) { - payload['transcript_path'] = transcriptPath - } - return runHook(payload) -} - -describe('no-external-issue-ref-guard', () => { - test('allows non-Bash tools', () => { - const r = runHook({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/foo.ts' }, - }) - assert.equal(r.code, 0) - }) - - test('allows git commit with no external refs', () => { - const r = commit('git commit -m "fix(foo): bug in bar"') - assert.equal(r.code, 0) - assert.equal(r.stderr, '') - }) - - test('allows bare #123 (same-repo, no cross-repo leak)', () => { - const r = commit('git commit -m "fix(foo): close #123"') - assert.equal(r.code, 0) - }) - - test('allows SocketDev/#', () => { - const r = commit( - 'git commit -m "chore: cascade SocketDev/socket-wheelhouse#42"', - ) - assert.equal(r.code, 0) - }) - - test('allows SocketDev URL', () => { - const r = commit( - 'git commit -m "fix: see https://github.com/SocketDev/socket-cli/issues/9"', - ) - assert.equal(r.code, 0) - }) - - test('allows socketdev (lowercase) URL — case-insensitive', () => { - const r = commit( - 'git commit -m "see https://github.com/socketdev/socket-lib/pull/100"', - ) - assert.equal(r.code, 0) - }) - - test('blocks external owner/repo#num token in -m', () => { - const r = commit( - 'git commit -m "chore(deps): trustPolicyExclude spencermountain/compromise#1203"', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /no-external-issue-ref-guard/) - assert.match(r.stderr, /spencermountain\/compromise#1203/) - }) - - test('blocks external GitHub issue URL', () => { - const r = commit( - 'git commit -m "see https://github.com/spencermountain/compromise/issues/1203"', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /spencermountain\/compromise\/issues\/1203/) - }) - - test('blocks external GitHub pull URL', () => { - const r = commit('git commit -m "fixes https://github.com/foo/bar/pull/7"') - assert.equal(r.code, 2) - }) - - test('blocks ref inside HEREDOC body', () => { - const cmd = `git commit -m "$(cat <<'EOF' -chore(deps): trustPolicyExclude compromise@14.15.0 - -Maintainer issue: spencermountain/compromise#1203. -EOF -)"` - const r = commit(cmd) - assert.equal(r.code, 2) - assert.match(r.stderr, /spencermountain\/compromise#1203/) - }) - - test('blocks ref in gh pr create --body', () => { - const r = commit( - 'gh pr create --title "x" --body "fixes spencermountain/compromise#1203"', - ) - assert.equal(r.code, 2) - }) - - test('blocks ref in gh issue comment --body', () => { - const r = commit('gh issue comment 1 --body "see torvalds/linux#999 too"') - assert.equal(r.code, 2) - assert.match(r.stderr, /torvalds\/linux#999/) - }) - - test('does not trigger on non-message commands', () => { - // `git push` doesn't have a message arg, even if "spencermountain - // /compromise#1203" appeared somewhere in env vars or output. - const r = commit('git push origin main') - assert.equal(r.code, 0) - }) - - test('does not block when message text only has a SocketDev ref', () => { - const r = commit( - 'git commit -m "chore: pick up SocketDev/socket-lib#42 fix"', - ) - assert.equal(r.code, 0) - }) - - test('deduplicates repeated refs in stderr', () => { - const r = commit( - 'git commit -m "spencermountain/compromise#1203 ' + - 'and again spencermountain/compromise#1203"', - ) - assert.equal(r.code, 2) - const matches = - String(r.stderr).match(/spencermountain\/compromise#1203/g) || [] - // Ref appears in 'Refs found:' bullet — one bullet, not two. - // (May also appear in narrative text once.) - assert.ok(matches.length <= 2, `expected dedup; saw ${matches.length}`) - }) - - test('fails open on invalid JSON', () => { - const r = spawnSync('node', [HOOK], { - input: 'not json', - }) - assert.equal(r.status, 0) - }) - - test('fails open on empty stdin', () => { - const r = spawnSync('node', [HOOK], { - input: '', - }) - assert.equal(r.status, 0) - }) -}) diff --git a/.claude/hooks/no-external-issue-ref-guard/tsconfig.json b/.claude/hooks/no-external-issue-ref-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-external-issue-ref-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/README.md b/.claude/hooks/no-file-scope-oxlint-disable-guard/README.md deleted file mode 100644 index 95b6f9e75..000000000 --- a/.claude/hooks/no-file-scope-oxlint-disable-guard/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# no-file-scope-oxlint-disable-guard - -PreToolUse hook that blocks Edit/Write tool calls introducing a file-scope `oxlint-disable ` comment. - -File-scope disables (without `-next-line`) silently exempt every line of the file from a fleet rule — including lines added later by editors who never saw the disable. Inline `oxlint-disable-next-line -- ` per call site forces a fresh justification next to each banned usage. - -## Allowed - -- `// oxlint-disable-next-line -- ` -- `/* oxlint-disable-next-line */` -- `/* oxlint-enable */` (re-enables; pairs with disables) - -## Blocked - -- `/* oxlint-disable */` at file scope -- `// oxlint-disable ` at file scope - -## Exemptions - -Files under `.config/oxlint-plugin/rules/` and `.config/oxlint-plugin/test/` may file-scope-disable their own rule (the banned shape is lookup-table data in the rule definition or test fixture). - -## Disabling - -Set `SOCKET_NO_FILE_SCOPE_OXLINT_DISABLE_GUARD_DISABLED=1` to bypass. diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts b/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts deleted file mode 100644 index 2c8d2141f..000000000 --- a/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-file-scope-oxlint-disable-guard. -// -// Blocks Edit/Write tool calls that introduce a file-scope -// `oxlint-disable ` comment. Always force inline -// `oxlint-disable-next-line -- ` per call site so the -// exemption is independently justified next to the code it covers. -// -// Why: a file-scope `/* oxlint-disable socket/no-console-prefer-logger */` -// at the top of a file silently exempts every line of that file from -// a fleet rule — including lines added later by editors who never -// saw the disable. Inline `-next-line` forces a fresh justification -// per call site, which surfaces in code review + `git blame`. -// -// Recognized banned shapes: -// /* oxlint-disable */ (no -next-line suffix) -// // oxlint-disable (line comment, no -next-line) -// -// Allowed shapes (passes through): -// /* oxlint-disable-next-line */ (block, per call) -// // oxlint-disable-next-line (line, per call) -// /* oxlint-enable */ (re-enables; pairs with disables) -// -// Exemption: files under `.config/oxlint-plugin/rules/` and -// `.config/oxlint-plugin/test/` are allowed to file-scope-disable -// their own rule (the banned shape is lookup-table data in the rule -// definition or in test fixtures). -// -// Reads PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Exit codes: -// 0 — pass. -// 2 — block (at least one file-scope oxlint-disable found). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import process from 'node:process' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined -} - -const FILE_SCOPE_DISABLE_RE = - /^[ \t]*(?:\/\*|\/\/)[ \t]*oxlint-disable(?!-next-line)[ \t]+/ - -// Plugin-internal rule + test files are exempt — the banned shape is -// lookup-table data in the rule definition or test fixture. -const EXEMPT_PATH_SUFFIXES: readonly string[] = [ - '.config/oxlint-plugin/rules/', - '.config/oxlint-plugin/test/', -] - -interface Finding { - readonly line: number - readonly text: string -} - -export function findFileScopeDisables(text: string): Finding[] { - const findings: Finding[] = [] - const lines = text.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - if (FILE_SCOPE_DISABLE_RE.test(line)) { - findings.push({ line: i + 1, text: line.trim() }) - } - } - return findings -} - -export function isExemptPath(filePath: string): boolean { - for (let i = 0, { length } = EXEMPT_PATH_SUFFIXES; i < length; i += 1) { - if (filePath.includes(EXEMPT_PATH_SUFFIXES[i]!)) { - return true - } - } - return false -} - -export async function readStdin(): Promise { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer) - } - return Buffer.concat(chunks).toString('utf8') -} - -async function main(): Promise { - if (process.env['SOCKET_NO_FILE_SCOPE_OXLINT_DISABLE_GUARD_DISABLED']) { - process.exit(0) - } - let raw: string - try { - raw = await readStdin() - } catch (e) { - process.stderr.write( - `[no-file-scope-oxlint-disable-guard] stdin read failed: ${ - e instanceof Error ? e.message : String(e) - }\n`, - ) - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch (e) { - process.stderr.write( - `[no-file-scope-oxlint-disable-guard] payload parse failed: ${ - e instanceof Error ? e.message : String(e) - }\n`, - ) - process.exit(0) - } - const toolName = payload.tool_name - if (toolName !== 'Edit' && toolName !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path || '' - if (isExemptPath(filePath)) { - process.exit(0) - } - const newContent = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - const findings = findFileScopeDisables(newContent) - if (findings.length === 0) { - process.exit(0) - } - const lines: string[] = [] - lines.push( - '🚨 no-file-scope-oxlint-disable-guard: blocked Edit/Write — file-scope `oxlint-disable` is forbidden.', - ) - lines.push('') - lines.push(`File: ${filePath || ''}`) - lines.push('') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - lines.push(` Line ${f.line}: ${f.text}`) - } - lines.push('') - lines.push( - 'Fix: move each disable to `oxlint-disable-next-line -- `', - ) - lines.push( - ' on the specific line that needs it. Each exemption must carry its own', - ) - lines.push(' justification next to the code it covers.') - lines.push('') - lines.push( - "If the entire file legitimately can't comply, the file needs a refactor", - ) - lines.push('— not a blanket exemption.') - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) -} - -main().catch((e: unknown) => { - process.stderr.write( - `[no-file-scope-oxlint-disable-guard] unexpected: ${ - e instanceof Error ? e.message : String(e) - }\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/package.json b/.claude/hooks/no-file-scope-oxlint-disable-guard/package.json deleted file mode 100644 index 1a1ef3276..000000000 --- a/.claude/hooks/no-file-scope-oxlint-disable-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-file-scope-oxlint-disable-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json b/.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-fleet-fork-guard/README.md b/.claude/hooks/no-fleet-fork-guard/README.md deleted file mode 100644 index 14f5ed847..000000000 --- a/.claude/hooks/no-fleet-fork-guard/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# no-fleet-fork-guard - -PreToolUse Edit/Write hook that blocks edits to fleet-canonical files inside downstream fleet repos. - -## What it enforces - -The fleet rule "Never fork fleet-canonical files locally" (CLAUDE.md fleet block, full reference at [`docs/claude.md/no-local-fork-canonical.md`](../../../docs/claude.md/no-local-fork-canonical.md)). - -Fleet-canonical surfaces (anything tracked by `socket-wheelhouse/scripts/sync-scaffolding/manifest.mts`): - -- `.config/oxlint-plugin/` — oxlint plugin index + rules -- `.git-hooks/` — commit-msg / pre-commit / pre-push entry shims + .mts helpers (git invokes the shims when `core.hooksPath` is set to this directory) -- `.claude/hooks/` — PreToolUse / PostToolUse hooks -- `.claude/skills/_shared/` — shared skill helpers -- `docs/claude.md/` — CLAUDE.md offshoot references - -When Claude tries to Edit/Write a file under one of these prefixes in a fleet member (any repo with `CLAUDE.md` containing the `BEGIN FLEET-CANONICAL` marker, except `socket-wheelhouse/template/`), the hook exits 2 with a stderr message that: - -1. States the rule. -2. Names the canonical file path inside `socket-wheelhouse/template/...`. -3. Provides the exact `sync-scaffolding` command to cascade. -4. Documents the bypass phrase. - -Edits inside `socket-wheelhouse/template/` are ALLOWED — that IS the canonical home. - -## Bypass - -Reverting / overriding the block requires the user to type **`Allow fleet-fork bypass`** verbatim in a recent user turn. The phrase is scoped to the current conversation; it does NOT carry across sessions. Per the broader bypass-phrase contract enforced by `no-revert-guard` and the fleet CLAUDE.md "Hook bypasses" rule. - -## Why a hook + a rule + a memory - -- The CLAUDE.md fleet block documents the policy (visible at every prompt). -- A user-memory entry keeps the assistant honest across sessions. -- This hook is the actual enforcement at edit time. - -The hook catches the failure mode where Claude reaches for a "quick fix" in a downstream repo's canonical file (typically because the local copy has a known bug and the user is in a hurry to land something else). The block flips the workflow back to "fix-in-template, cascade out" where it belongs. - -## Detection - -For each Edit/Write/MultiEdit call: - -1. Resolve `tool_input.file_path` to an absolute path. -2. Check if the path contains `/socket-wheelhouse/template/` — if yes, allow. -3. Walk up directories looking for a fleet repo root: `package.json` AND `CLAUDE.md` containing the `BEGIN FLEET-CANONICAL` marker. -4. If no fleet repo root is found (the file is outside any fleet repo), allow. -5. Compute the file path relative to the repo root. -6. If the relative path matches one of the canonical prefixes, check the bypass phrase. -7. No bypass → exit 2 with the explanation. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. The CLAUDE.md rule + memory still document the policy as a backstop. - -## Companion files - -- `index.mts` — the hook itself. -- `test/index.test.mts` — node:test specs. -- `package.json` — workspace declaration so `taze` can see the hook's deps. -- `tsconfig.json` — fleet-canonical TS config. - -## Adding a new canonical surface - -When a new directory becomes fleet-canonical (cascades via sync-scaffolding): - -1. Add it to `CANONICAL_PREFIXES` in `index.mts`. -2. Add it to the bullet list in this README. -3. Add it to the bullet list in `docs/claude.md/no-local-fork-canonical.md`. -4. Add the surface to the sync manifest. diff --git a/.claude/hooks/no-fleet-fork-guard/index.mts b/.claude/hooks/no-fleet-fork-guard/index.mts deleted file mode 100644 index fcfbe67e7..000000000 --- a/.claude/hooks/no-fleet-fork-guard/index.mts +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-fleet-fork-guard. -// -// Blocks Edit/Write tool calls that target a fleet-canonical file -// path inside a downstream fleet repo. The fleet rule -// ("Never fork fleet-canonical files locally") says these files -// MUST be edited in socket-wheelhouse/template/... and cascaded -// out via sync-scaffolding — never branched locally in a downstream -// repo. Local forks turn into "drift to preserve" hacks that block -// fleet-wide improvements from reaching the forked repo. -// -// The hook detects a fleet-canonical edit by: -// 1. Resolving the absolute file path of the Edit/Write target. -// 2. Checking if the path is INSIDE socket-wheelhouse/template/ -// → allow (this IS the canonical home). -// 3. Otherwise, checking if the path matches a fleet-canonical -// surface prefix: -// - .config/oxlint-plugin/ -// - .git-hooks/ -// - .claude/hooks/ -// - .claude/skills/_shared/ -// - docs/claude.md/ -// → block. -// -// The bypass phrase: `Allow fleet-fork bypass`. Reading the recent -// user turns from the transcript follows the same pattern as the -// no-revert-guard hook. -// -// Why a hook on top of the CLAUDE.md rule + memory: the rule -// documents the policy, the memory keeps the assistant honest across -// sessions, the hook is the actual enforcement at edit time. Catches -// the failure mode where Claude reaches for a "quick fix" in a -// downstream repo's canonical file (typically because the local -// version has a known bug and the user is in a hurry to land -// something else). The block flips the workflow back to -// "fix-in-template, cascade out" where it belongs. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit" | "Write" | "MultiEdit", -// "tool_input": { "file_path": "...", ... }, -// "transcript_path": "/.../session.jsonl" } -// -// Exits: -// 0 — allowed (not a fleet-canonical edit, OR target is the template, -// OR bypass phrase present). -// 2 — blocked (with a stderr message that explains the rule + the -// canonical fix path + the bypass phrase). -// 0 (with stderr log) — fail-open on hook bugs so a bad deploy can't -// brick the session. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: { file_path?: string | undefined } | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -// Fleet-canonical directory prefixes. Matches relative-to-repo-root. -// Order matters for nested prefixes (more-specific first), but these -// are all leaves — no nesting between them. -const CANONICAL_PREFIXES = [ - '.config/oxlint-plugin/', - '.git-hooks/', - '.claude/hooks/', - '.claude/skills/_shared/', - 'docs/claude.md/', -] - -// Carve-out: paths under a CANONICAL_PREFIXES dir that are explicitly -// per-repo (not cascaded). `docs/claude.md/repo/` is the per-repo -// analog of `docs/claude.md/fleet/` — host repos drop architecture / -// commands / build-pipeline detail here to keep CLAUDE.md under the -// whole-file size cap. -const PER_REPO_PREFIXES = ['docs/claude.md/repo/'] - -// Fleet-canonical individual files (not under one of the prefix -// dirs). Matches relative-to-repo-root. -const CANONICAL_FILES: string[] = [ - // Add specific files here when needed. Most canonical content lives - // under the prefix dirs above. -] - -const BYPASS_PHRASE = 'Allow fleet-fork bypass' - -// How many recent user turns to scan for the bypass phrase. Matches -// the no-revert-guard hook's window. -const BYPASS_LOOKBACK_USER_TURNS = 8 - -// File-path tokens that identify the socket-wheelhouse canonical -// home. If the resolved absolute path contains one of these, we're -// editing the source of truth — allow. -// -// `socket-wheelhouse/template/` covers the standard checkout shape -// (e.g. /Users//projects/socket-wheelhouse/template/...). -// `repo-template/template/` covers any rename / mirror / fork that -// keeps the trailing component. -const TEMPLATE_PATH_TOKENS = [ - '/socket-wheelhouse/template/', - '/repo-template/template/', -] - -/** - * Find the fleet repo root for an absolute file path by walking up until we hit - * a directory that has package.json AND a CLAUDE.md containing the - * FLEET-CANONICAL marker. Returns the repo root path or undefined if the file - * is outside a fleet repo. - */ -export function findFleetRepoRoot(filePath: string): string | undefined { - let cur = path.dirname(filePath) - const root = path.parse(cur).root - while (cur && cur !== root) { - const pkgPath = path.join(cur, 'package.json') - const claudePath = path.join(cur, 'CLAUDE.md') - if (existsSync(pkgPath) && existsSync(claudePath)) { - try { - const claudeContent = readFileSync(claudePath, 'utf8') - if (claudeContent.includes('BEGIN FLEET-CANONICAL')) { - return cur - } - } catch { - // unreadable — skip and continue walking up - } - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - return undefined -} - -export function isCanonicalRelativePath(rel: string): boolean { - const normalized = rel.replace(/\\/g, '/') - // Per-repo carve-outs take precedence over the canonical prefixes - // (they're more specific). Edits under these paths are intentionally - // per-repo and don't go through the fleet cascade. - for (let i = 0, { length } = PER_REPO_PREFIXES; i < length; i += 1) { - const prefix = PER_REPO_PREFIXES[i]! - if (normalized.startsWith(prefix)) { - return false - } - } - for (let i = 0, { length } = CANONICAL_PREFIXES; i < length; i += 1) { - const prefix = CANONICAL_PREFIXES[i]! - if (normalized.startsWith(prefix)) { - return true - } - } - return CANONICAL_FILES.includes(normalized) -} - -export function isInsideTemplate(filePath: string): boolean { - const normalized = filePath.replace(/\\/g, '/') - return TEMPLATE_PATH_TOKENS.some(token => normalized.includes(token)) -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'no-fleet-fork-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return 0 - } - - const filePath = payload.tool_input?.file_path - if (!filePath) { - return 0 - } - - const absPath = path.resolve(filePath) - - // The canonical home is allowed. - if (isInsideTemplate(absPath)) { - return 0 - } - - // Walk up to find the fleet repo root. If the file isn't inside a - // fleet repo at all, this hook doesn't apply — let it through. - const repoRoot = findFleetRepoRoot(absPath) - if (!repoRoot) { - return 0 - } - - const relToRepo = path.relative(repoRoot, absPath) - - if (!isCanonicalRelativePath(relToRepo)) { - return 0 - } - - // Bypass-phrase check. - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - process.stderr.write( - [ - `🚨 no-fleet-fork-guard: blocked Edit/Write to fleet-canonical path.`, - ``, - `File: ${relToRepo}`, - `Repo: ${path.basename(repoRoot)}`, - ``, - `Fleet-canonical files (anything tracked by`, - `socket-wheelhouse/scripts/sync-scaffolding/manifest.mts) MUST`, - `be edited in socket-wheelhouse/template/${relToRepo} and`, - `cascaded out — never branched locally in a downstream fleet repo.`, - ``, - `Fix path:`, - ` 1. Edit socket-wheelhouse/template/${relToRepo}`, - ` 2. Commit + push template`, - ` 3. Cascade with: node scripts/sync-scaffolding/cli.mts \\`, - ` --target ${repoRoot} --fix`, - ``, - `If you genuinely need to bypass (e.g. emergency hotfix that`, - `can't wait for cascade), the user must type \`${BYPASS_PHRASE}\``, - `verbatim in a recent user turn. Reference:`, - `docs/claude.md/no-local-fork-canonical.md`, - ``, - ].join('\n'), - ) - return 2 -} - -main().then( - code => process.exit(code), - e => { - process.stderr.write( - `no-fleet-fork-guard: hook bug — fail-open. ${errorMessage(e)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/no-fleet-fork-guard/package.json b/.claude/hooks/no-fleet-fork-guard/package.json deleted file mode 100644 index 8d7dc1e5c..000000000 --- a/.claude/hooks/no-fleet-fork-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-no-fleet-fork-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts b/.claude/hooks/no-fleet-fork-guard/test/index.test.mts deleted file mode 100644 index 265361bae..000000000 --- a/.claude/hooks/no-fleet-fork-guard/test/index.test.mts +++ /dev/null @@ -1,349 +0,0 @@ -// node --test specs for the no-fleet-fork-guard hook. -// -// Spawns the hook as a subprocess (matches production runtime), pipes -// a JSON payload on stdin, captures stderr + exit code. -// -// Tests use a temp git-style repo skeleton — empty package.json plus -// a CLAUDE.md with or without the FLEET-CANONICAL marker — so we can -// exercise the "is this a fleet repo?" walk-up logic without -// depending on actual fleet-repo checkouts. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook( - payload: Record, - transcript?: string, -): Promise { - let transcriptPath: string | undefined - if (transcript !== undefined) { - const dir = mkdtempSync(path.join(os.tmpdir(), 'no-fleet-fork-test-')) - transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, transcript) - payload['transcript_path'] = transcriptPath - } - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -function userTurn(text: string): string { - return ( - JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + - '\n' - ) -} - -interface RepoSetup { - hasFleetCanonical: boolean -} - -/** - * Create a temp dir that looks like a fleet repo. - */ -function makeFakeFleetRepo( - setup: RepoSetup = { hasFleetCanonical: true }, -): string { - const repo = mkdtempSync(path.join(os.tmpdir(), 'fake-fleet-repo-')) - writeFileSync(path.join(repo, 'package.json'), '{"name":"fake-fleet"}\n') - const claudeMarker = setup.hasFleetCanonical - ? '\nrules go here\n\n' - : '# Just a regular project README-style markdown\n' - writeFileSync(path.join(repo, 'CLAUDE.md'), claudeMarker) - return repo -} - -function makeCanonicalFile(repo: string, relPath: string): string { - const full = path.join(repo, relPath) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, '// existing content\n') - return full -} - -test('non-Edit/Write tool calls pass through untouched', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('Edit on a non-canonical path inside a fleet repo passes', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, 'src/foo.ts') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Edit on a canonical path outside a fleet repo passes', async () => { - // Tmp dir without CLAUDE.md → the walk-up never finds a fleet root. - const dir = mkdtempSync(path.join(os.tmpdir(), 'non-fleet-')) - try { - const file = path.join(dir, '.config/oxlint-plugin/rules/foo.mts') - mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, '// content\n') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('Edit on .config/oxlint-plugin/rules/* in a fleet repo is BLOCKED', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile( - repo, - '.config/oxlint-plugin/rules/example.mts', - ) - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-fleet-fork-guard/) - assert.match(result.stderr, /\.config\/oxlint-plugin\/rules\/example\.mts/) - assert.match(result.stderr, /Allow fleet-fork bypass/) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Edit on .git-hooks/* in a fleet repo is BLOCKED', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, '.git-hooks/_helpers.mts') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /\.git-hooks\/_helpers\.mts/) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Edit on .claude/hooks/* in a fleet repo is BLOCKED', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, '.claude/hooks/some-hook/index.mts') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Edit on docs/claude.md/* in a fleet repo is BLOCKED', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, 'docs/claude.md/sorting.md') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Edit on docs/claude.md/repo/* in a fleet repo is ALLOWED (per-repo carve-out)', async () => { - // The repo/ subdirectory is the per-repo analog of fleet/. Host repos - // drop architecture/commands/build detail here to fit the whole-file - // size cap without cascading the content fleet-wide. - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, 'docs/claude.md/repo/architecture.md') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('Write tool also blocked, not just Edit', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile( - repo, - '.config/oxlint-plugin/rules/new-rule.mts', - ) - const result = await runHook({ - tool_input: { file_path: file, content: 'export default {}' }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('MultiEdit tool also blocked', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, '.config/oxlint-plugin/rules/foo.mts') - const result = await runHook({ - tool_input: { file_path: file, edits: [] }, - tool_name: 'MultiEdit', - }) - assert.strictEqual(result.code, 2) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('repo without FLEET-CANONICAL marker passes through', async () => { - // Project that has CLAUDE.md but is NOT a fleet member — the walk-up - // sees CLAUDE.md but no marker, so the path doesn't qualify. - const repo = makeFakeFleetRepo({ hasFleetCanonical: false }) - try { - const file = makeCanonicalFile(repo, '.config/oxlint-plugin/rules/x.mts') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('bypass phrase in recent user turn allows the edit', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, '.git-hooks/pre-push.mts') - const result = await runHook( - { - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }, - userTurn('please do this Allow fleet-fork bypass thanks'), - ) - assert.strictEqual(result.code, 0) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('bypass phrase variants do NOT count', async () => { - const repo = makeFakeFleetRepo() - try { - const file = makeCanonicalFile(repo, '.git-hooks/pre-push.mts') - // Each of these should NOT bypass — phrase must be exact. - for (const variant of [ - 'allow fleet-fork bypass', // lowercase - 'Allow fleet fork bypass', // space instead of hyphen - 'Allow fleet-fork', // no "bypass" - 'fleet-fork bypass', // no "Allow" - ]) { - const result = await runHook( - { - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }, - userTurn(variant), - ) - assert.strictEqual( - result.code, - 2, - `variant should not bypass: ${variant}`, - ) - } - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('paths under socket-wheelhouse/template/ always pass', async () => { - // Even if Claude tries to spell out a path that would otherwise - // match a canonical prefix, anything under .../socket-wheelhouse/ - // template/ is allowed since that IS the canonical home. - const repo = mkdtempSync(path.join(os.tmpdir(), 'fake-srt-')) - try { - const file = path.join( - repo, - 'socket-wheelhouse/template/.git-hooks/_helpers.mts', - ) - mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, '// canonical home\n') - const result = await runHook({ - tool_input: { file_path: file, new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - } finally { - rmSync(repo, { force: true, recursive: true }) - } -}) - -test('malformed JSON payload fails open with stderr log', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not-json{{{') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => resolve({ code: code ?? 0, stderr })) - }) - assert.strictEqual(result.code, 0) - assert.match(result.stderr, /fail-open/) -}) - -test('empty stdin passes through', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const result = await new Promise(resolve => { - child.process.on('exit', code => resolve({ code: code ?? 0, stderr })) - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/no-fleet-fork-guard/tsconfig.json b/.claude/hooks/no-fleet-fork-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-fleet-fork-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-meta-comments-guard/README.md b/.claude/hooks/no-meta-comments-guard/README.md deleted file mode 100644 index 909dd21da..000000000 --- a/.claude/hooks/no-meta-comments-guard/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# no-meta-comments-guard - -`PreToolUse(Edit|Write)` hook. Blocks source-file edits that introduce a comment which either: - -1. **References the current task / plan / user request** rather than the code's runtime semantics — e.g. `// Plan: use the cache here` / `// Task: rename foo to bar` / `// Per the task instructions, swap to async` / `// As requested, add retry`. - -2. **Describes code that was removed** rather than code that exists — e.g. `// removed: old behavior used a Map here` / `// previously called X` / `// used to be sync, made async in 6.0`. - -Per CLAUDE.md "Code style → Comments": comments default to none; when written, they explain the **constraint** or the **hidden invariant**, not the development context. Development context (the plan, the task, the user request, removed code) goes in commit messages and PR descriptions, not source comments. - -## The comment is usually useful — it's the prefix that's noise - -When the hook fires on a `Plan:` / `Task:` style comment, the suggested fix **strips the meta prefix and keeps the underlying explanation**: - -``` -Saw: // Plan: use the cache to avoid re-resolving -Suggest: // Use the cache to avoid re-resolving -``` - -The agent gets to keep the useful "why" — drop the meta-label. - -For removed-code references the suggestion is to delete entirely (the info lives in git history). - -## File scope - -Only matches source files: `.{m,c,}{j,t}sx?`, `.cc`, `.cpp`, `.h`, `.hpp`, `.rs`, `.go`, `.py`, `.sh`. Markdown / JSON / YAML aren't checked — those file types use `#` / `//` / `*` as legitimate body content, not as comment markers. - -## Bypass - -There's no canonical bypass phrase. The fix is to rewrite the comment per the suggestion. If you genuinely need the comment to read as-is (rare — usually means the explanation is missing important context), the hook can be temporarily disabled via `SOCKET_NO_META_COMMENTS_DISABLED=1` for the session. - -## Source of truth - -The rule itself lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Code style → Comments". This hook enforces it at edit time. diff --git a/.claude/hooks/no-meta-comments-guard/index.mts b/.claude/hooks/no-meta-comments-guard/index.mts deleted file mode 100644 index 895d831be..000000000 --- a/.claude/hooks/no-meta-comments-guard/index.mts +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-meta-comments-guard. -// -// Blocks Edit/Write tool calls that introduce a comment which: -// -// (a) References the current task / plan / user request rather -// than the code's runtime semantics: -// // Plan: use the cache here -// // Task: rename foo to bar -// // Per the task instructions, swap to async -// // As requested, add retry -// // TODO from the brief: handle Win32 -// -// (b) Describes code that was removed rather than code that -// exists: -// // removed: old behavior used a Map here -// // previously called X; now Y -// // used to be sync, made async in 6.0 -// // no longer using fetch — see commit abc1234 -// -// Per CLAUDE.md "Code style → Comments": comments default to none; -// when written, audience is a junior dev — explain the CONSTRAINT -// or the hidden invariant, not the development context (commit -// messages and PR descriptions are where development context goes). -// -// On block, emits a stderr suggestion stripping the meta prefix so -// the agent can keep the explanation if it's actually useful and -// just drop the noise. Example transform: -// -// // Plan: use the cache to avoid re-resolving → // Use the cache to avoid re-resolving -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Exit codes: -// 0 — pass (not Edit/Write, no meta comments). -// 2 — block (at least one meta-comment pattern found). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import process from 'node:process' - -import { splitLines, walkComments } from '../_shared/acorn/index.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined -} - -interface MetaCommentFinding { - readonly kind: 'task' | 'removed-code' - readonly line: number - readonly snippet: string - readonly suggestion: string -} - -// Task / plan / user-request references. -// -// Patterns are anchored on `// `, `/* `, `# `, ` * `, ` - ` (markdown -// bullet inside comment) so we don't false-positive on identifiers -// or string literals containing the words. -// -// `Plan:` / `Task:` are case-insensitive leading labels. The free- -// form phrases (`per the task`, `as requested`) match anywhere in -// the comment body — those are the dead-give-away tells, not the -// rest of the sentence. -const TASK_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly stripPrefix?: RegExp | undefined -}> = [ - // `// Plan: ...` / `// Task: ...` / `// Note from plan: ...` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, - stripPrefix: - /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, - }, - // `// Per the task ...` / `// Per the plan ...` / `// As requested ...` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, - }, - // `// TODO from the brief` / `// FIXME per plan` - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, - }, - // Phase / tier / step markers — `// Tier 1 ...`, `// Phase 10a: - // ...`, `// Step 3 - ...`. These leak the roadmap shape into source - // and rot when the roadmap shifts. Catch as bare labels (followed - // by whitespace + number) OR as `Phase NNN:` / `Step NNN -` colon / - // dash labels. - { - re: /(^|\n)\s*(?:\/\/|\/\*|\*|#|-)\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, - stripPrefix: - /^(\s*(?:\/\/|\/\*|\*|#|-)\s*)(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, - }, -] - -// Removed-code references. -const REMOVED_CODE_PATTERNS: readonly RegExp[] = [ - // `// removed X` / `// removed: X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*removed\b/i, - // `// previously X` / `// previously called X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*previously\b/i, - // `// used to X` / `// used to be X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*used\s+to\b/i, - // `// no longer X` / `// no longer needed` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*no\s+longer\b/i, - // `// formerly X` - /(^|\n)\s*(?:\/\/|\/\*|\*|#)\s*formerly\b/i, -] - -/** - * Uppercase the first alphabetic character that follows the comment marker, so - * a stripped `// plan: use the cache` reads as `// Use the cache`. Skips the - * comment marker tokens so they don't count as "first letter". - */ -export function uppercaseFirstLetterAfterMarker(line: string): string { - const m = line.match(/^(\s*(?:\/\/|\/\*|\*|#|-)\s*)([a-zA-Z])/) - if (!m) { - return line - } - const prefix = m[1]! - const firstChar = m[2]! - return prefix + firstChar.toUpperCase() + line.slice(prefix.length + 1) -} - -// Body-only versions of the patterns (no comment-marker prefix — -// the AST walker already gives us the body text). The same TASK_PATTERNS -// and REMOVED_CODE_PATTERNS above retain the marker-prefixed form so the -// non-JS lexical path below can still use them. -const TASK_BODY_PATTERNS: ReadonlyArray<{ - readonly re: RegExp - readonly stripBody?: RegExp | undefined -}> = [ - { - re: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:/i, - stripBody: /^\s*(?:plan|task|note from (?:brief|plan|task))\s*:\s*/i, - }, - { - re: /^\s*(?:per the (?:brief|plan|request|spec|task|user)|as requested|per the user('s)? request)\b/i, - }, - { - re: /^\s*(?:FIXME|TODO|XXX)\s+(?:from|per)\s+(?:the\s+)?(?:brief|plan|request|spec|task|user)\b/i, - }, - { - re: /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\b/i, - stripBody: - /^\s*(?:iteration|milestone|phase|sprint|step|tier)\s+(?:[0-9]+[a-z]*|i{1,3}|iv|v|vi{0,3}|ix|x)\s*[:.-]?\s*/i, - }, -] - -const REMOVED_CODE_BODY_PATTERNS: readonly RegExp[] = [ - /^\s*removed\b/i, - /^\s*previously\b/i, - /^\s*used\s+to\b/i, - /^\s*no\s+longer\b/i, - /^\s*formerly\b/i, -] - -/** - * AST-based detector for JS/TS/JSX/TSX source. Uses `walkComments` from the - * shared acorn helper to walk just the comment tokens — string-literal mentions - * of `Plan:` / `Task:` etc. don't trigger. - */ -export function findMetaCommentsAst(text: string): MetaCommentFinding[] { - const findings: MetaCommentFinding[] = [] - const lines = splitLines(text) - for (const c of walkComments(text, { comments: true })) { - // Block comments may have multiple meaningful lines; check each - // line of the body individually so the suggestion can name the - // exact offending line. - const bodyLines = splitLines(c.value) - for (let li = 0; li < bodyLines.length; li += 1) { - const body = bodyLines[li]! - // Strip leading ` *` / `*` decorators that JSDoc-style blocks use. - const cleaned = body.replace(/^\s*\*\s?/, '') - const lineNum = c.line + li - const sourceLine = (lines[lineNum - 1] ?? '').trim() - let matched = false - for (const { re, stripBody } of TASK_BODY_PATTERNS) { - if (!re.test(cleaned)) { - continue - } - const stripped = stripBody - ? cleaned.replace(stripBody, '').trim() - : cleaned.trim() - const suggestion = uppercaseFirstLetterAfterMarker( - c.kind === 'Line' ? `// ${stripped}` : `* ${stripped}`, - ) - findings.push({ - kind: 'task', - line: lineNum, - snippet: sourceLine, - suggestion: - suggestion || - '(remove the comment entirely — it has no runtime content)', - }) - matched = true - break - } - if (matched) { - continue - } - for ( - let i = 0, { length } = REMOVED_CODE_BODY_PATTERNS; - i < length; - i += 1 - ) { - const re = REMOVED_CODE_BODY_PATTERNS[i]! - if (!re.test(cleaned)) { - continue - } - findings.push({ - kind: 'removed-code', - line: lineNum, - snippet: sourceLine, - suggestion: - '(remove the comment — code that no longer exists is git-history territory, not source comments)', - }) - break - } - } - } - return findings -} - -/** - * Lexical-regex fallback for non-JS sources (C++, Rust, Go, Python, shell). The - * acorn-wasm parser only understands JS/TS, so for those languages we keep the - * marker-anchored regex scan. False-positives on string-literal mentions of `// - * Plan:` etc. are possible but rare in practice for those language - * conventions. - */ -export function findMetaCommentsLexical(text: string): MetaCommentFinding[] { - const findings: MetaCommentFinding[] = [] - const lines = splitLines(text) - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - for (const { re, stripPrefix } of TASK_PATTERNS) { - if (!re.test(`\n${line}`)) { - continue - } - const stripped = stripPrefix - ? line.replace(stripPrefix, '$1').replace(/\s+/g, ' ').trim() - : line - .trim() - .replace(/^[\s/*#-]+/, '') - .trim() - const suggestion = uppercaseFirstLetterAfterMarker(stripped) - findings.push({ - kind: 'task', - line: i + 1, - snippet: line.trim(), - suggestion: - suggestion || - '(remove the comment entirely — it has no runtime content)', - }) - break - } - for (let i = 0, { length } = REMOVED_CODE_PATTERNS; i < length; i += 1) { - const re = REMOVED_CODE_PATTERNS[i]! - if (!re.test(`\n${line}`)) { - continue - } - findings.push({ - kind: 'removed-code', - line: i + 1, - snippet: line.trim(), - suggestion: - '(remove the comment — code that no longer exists is git-history territory, not source comments)', - }) - break - } - } - return findings -} - -const JS_TS_FILE_RE = /\.(?:[cm]?[jt]sx?)$/ - -export function findMetaComments( - text: string, - filePath: string, -): MetaCommentFinding[] { - return JS_TS_FILE_RE.test(filePath) - ? findMetaCommentsAst(text) - : findMetaCommentsLexical(text) -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path ?? '' - // Only check source files. Markdown / json / yaml don't have - // "code comments" in the relevant sense — those file types use - // the same prefix tokens (`#`, `//`, `*`) as legitimate body - // content, not as comment markers. - if (!/\.(?:[cm]?[jt]sx?|cc|cpp|h|hpp|rs|go|py|sh)$/.test(filePath)) { - process.exit(0) - } - const text = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!text) { - process.exit(0) - } - - const findings = findMetaComments(text, filePath) - if (findings.length === 0) { - process.exit(0) - } - - const lines: string[] = [] - lines.push('[no-meta-comments-guard] Blocked: meta-comment(s) in source.') - lines.push(` File: ${filePath}`) - lines.push('') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - lines.push(` Line ${f.line} (${f.kind}):`) - lines.push(` Saw: ${f.snippet}`) - lines.push(` Suggest: ${f.suggestion}`) - lines.push('') - } - lines.push(' Per CLAUDE.md "Code style → Comments": comments describe the') - lines.push(' CONSTRAINT or the hidden invariant. Development context') - lines.push( - ' (the plan, the task, the user request, removed code) lives in', - ) - lines.push(' commit messages and PR descriptions, not source comments.') - lines.push('') - lines.push(' Rewrite or delete the comment, then retry the Edit/Write.') - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-meta-comments-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/no-meta-comments-guard/package.json b/.claude/hooks/no-meta-comments-guard/package.json deleted file mode 100644 index 8c1e7e4d8..000000000 --- a/.claude/hooks/no-meta-comments-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-meta-comments-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-meta-comments-guard/test/index.test.mts b/.claude/hooks/no-meta-comments-guard/test/index.test.mts deleted file mode 100644 index 82aeb4e69..000000000 --- a/.claude/hooks/no-meta-comments-guard/test/index.test.mts +++ /dev/null @@ -1,261 +0,0 @@ -// node --test specs for the no-meta-comments-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'echo // Plan: do thing' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('non-source files pass through (markdown / json / yaml)', async () => { - for (const file_path of [ - '/x/docs/readme.md', - '/x/package.json', - '/x/.github/workflows/ci.yml', - ]) { - const result = await runHook({ - tool_input: { - file_path, - new_string: '// Plan: do the thing\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0, file_path) - } -}) - -test('// Plan: prefix is blocked with strip-prefix suggestion', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - 'const x = 1\n// Plan: use the cache to avoid re-resolving\nconst y = 2', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Plan/) - assert.match(result.stderr, /Use the cache to avoid re-resolving/) -}) - -test('// Task: prefix is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.mts', - new_string: '// Task: rename foo to bar\nconst bar = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Per the task instructions ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Per the task instructions, swap to async\nawait foo()', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Per the task/i) -}) - -test('// As requested ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// As requested, add retry\nawait retry(foo)', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// removed X is blocked (removed-code pattern)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// removed: old behavior used a Map here\nconst data = new Set()', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /removed-code/) -}) - -test('// previously called X is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// previously called fooSync; now async\nasync function foo() {}', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// used to be sync, made async in 6.0 is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// used to be sync, made async in 6.0\nasync function foo() {}', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// no longer needed because X is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// no longer needed because Node 26 ships this natively\nlet polyfill: unknown', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Tier 1 implementation. is blocked (phase marker)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.cc', - new_string: '// Tier 1 implementation. Mirrors upstream X.\nint x = 1;', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Tier 1/) -}) - -test('// Tier 2 surface — mirrors ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.hpp', - new_string: '// Tier 2 surface — mirrors OpenTUI.\nclass Foo {};', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Phase 10a: temporal_rs shim ... is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Phase 10a: temporal_rs shim Instant\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Step 3 - parser rejection is blocked', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.go', - new_string: '// Step 3 - parser rejection\nx := 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// Milestone V achievable is blocked (Roman numeral phase)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: '// Milestone V achievable now\nconst x = 1', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('// "tier" inside content (not a phase marker) passes through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// Cache tier selection happens in resolveTier()\nconst t = 0', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`) -}) - -test('normal explanatory comments pass through', async () => { - for (const text of [ - '// Use the cache to avoid re-resolving on every call.\nconst cache = new Map()', - "// Falls back to the JS impl when smol-versions isn't available.\nconst v = getSmol()", - '// V8 inlines this when the call site is monomorphic.\nfunction hot() {}', - '/* Multi-line block comments describing the invariant\n are also fine. */\nfunction f() {}', - ]) { - const result = await runHook({ - tool_input: { file_path: '/x/src/foo.ts', new_string: text }, - tool_name: 'Edit', - }) - assert.strictEqual( - result.code, - 0, - `Expected pass for: ${text.slice(0, 60)}…\n stderr: ${result.stderr}`, - ) - } -}) - -test('multiple findings in one file are all surfaced', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: - '// Plan: use the cache\nconst x = 1\n// removed: old impl was sync\nconst y = 2', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Plan/) - assert.match(result.stderr, /removed-code/) - // Both line numbers should appear in the output. - assert.match(result.stderr, /Line 1/) - assert.match(result.stderr, /Line 3/) -}) diff --git a/.claude/hooks/no-meta-comments-guard/tsconfig.json b/.claude/hooks/no-meta-comments-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-meta-comments-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-non-fleet-push-guard/README.md b/.claude/hooks/no-non-fleet-push-guard/README.md deleted file mode 100644 index 332dccbd9..000000000 --- a/.claude/hooks/no-non-fleet-push-guard/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# no-non-fleet-push-guard - -PreToolUse(Bash) hook that blocks `git push` to a repository outside the -fleet. - -## Why - -The fleet's git-side pre-push hook only exists in repos that installed -the fleet hook chain. A non-fleet repo (a personal checkout, a sibling -project like `depot`) has no such hook, so a stray `cd /…/depot && git -push` sails straight through. The block has to live agent-side, before -the command runs, and resolve the target repo against the fleet roster. - -Past incident: an agent `cd`-ed into `depot` (not a fleet repo) and -pushed a fleet-convention change to its `main`. The push succeeded -because depot has no fleet pre-push hook. This guard is the response. - -## What it blocks - -| Command shape | Resolves target via | Block? | -| ------------------------------------------ | ------------------- | ------ | -| `git push` (in a fleet repo cwd) | process cwd | no | -| `git push` (in a non-fleet repo cwd) | process cwd | yes | -| `cd /path/to/depot && git push` | leading `cd` | yes | -| `git -C /path/to/depot push` | `-C` flag | yes | -| `echo "git push"` / commit msg saying push | (not a push) | no | -| `git push` where `origin` is unresolvable | (fail open) | no | - -Fleet membership is the broad set in -[`_shared/fleet-repos.mts`](../_shared/fleet-repos.mts) (`FLEET_REPO_NAMES`), -which includes `ultrathink` and other members the narrower cascade -roster (`cascading-fleet/lib/fleet-repos.json`) omits. Gating on the -broad set is deliberate: a fleet member is pushable even if it isn't a -cascade target. - -## Target-directory resolution - -In priority order: - -1. `git -C push …` — the explicit `-C` dir. -2. A leading `cd ` in the command chain (`cd X && git push`), - resolved against the process cwd for relative paths. -3. The hook's process cwd. - -Then `git -C remote get-url origin` → slug via `slugFromRemoteUrl` -→ `isFleetRepo(slug)`. - -## Fail-open - -Any resolution ambiguity (no `git push` found, dir unreadable, no -`origin`, unparseable remote URL) → allow. Under-blocking is recoverable -(the operator reverts a stray push); a false block wedges a valid -workflow. The guard only fires when it can positively identify a -non-fleet origin slug. - -## Bypass - -Type the canonical phrase in a new message: - - Allow non-fleet-push bypass - -Use for a genuine push to a personal / non-fleet repo you own. - -## Detection: shell parser, not regex - -`git push` detection goes through the shared shell parser -([`_shared/shell-command.mts`](../_shared/shell-command.mts), which wraps -`shell-quote`), not a regex. The parser splits the command line into -segments and reads the binary + subcommand at each position, so it sees -through: - -- `&&` / `||` / `;` / `|` chains (`cd /x && git push`) -- `$(…)` command substitution (`git push $(echo origin)`) -- quoted bodies (`git commit -m "git push later"` is NOT a push) -- global options before the subcommand (`git -C /x push`) - -Remaining limits of any static parser (shared with -`gh-token-hygiene-guard`): a binary fully sourced from a variable -(`g=git; $g push`) can't be statically resolved to `git` — the parser -FLAGS it as opaque (`hasOpaqueInvocation`) but this guard doesn't act on -that today; and an alias or wrapper script that pushes is out of scope. diff --git a/.claude/hooks/no-non-fleet-push-guard/index.mts b/.claude/hooks/no-non-fleet-push-guard/index.mts deleted file mode 100644 index 1bb753a17..000000000 --- a/.claude/hooks/no-non-fleet-push-guard/index.mts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-non-fleet-push-guard. -// -// Blocks `git push` to a repository that is NOT a fleet member. The -// fleet's git-side pre-push hook can't catch this: a non-fleet repo -// never has the fleet hook chain installed (that's exactly how a stray -// push to e.g. `depot` slips through). So the guard lives agent-side, -// inspecting the Bash command before it runs, and resolves the target -// repo's origin remote against the canonical fleet roster. -// -// Detection model: -// - Fires only on Bash commands containing `git push` at an -// executable position (not inside quotes / heredoc bodies — a -// commit message that says "git push" is not a push). -// - Resolves the TARGET directory, in priority order: -// 1. `git -C push …` (explicit -C) -// 2. a leading `cd && …` (the `cd /…/depot && git push` -// shape that bypasses the session cwd) -// 3. the hook's process cwd -// - Reads `git -C remote get-url origin`, extracts the repo -// slug, and blocks when the slug is not in FLEET_REPO_NAMES. -// -// Bypass: `Allow non-fleet-push bypass` typed verbatim in a recent user -// turn — for the rare legitimate push to a personal / non-fleet repo. -// -// Fails OPEN on any resolution ambiguity (can't find the command, the -// dir, or the remote): better to under-block than to wedge a valid -// push when the shape is unfamiliar. The cost of a missed block is one -// `Allow … bypass`-free push the operator can revert; the cost of a -// false block is a bricked workflow. - -import path from 'node:path' -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { isFleetRepo, slugFromRemoteUrl } from '../_shared/fleet-repos.mts' -import { findInvocation } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow non-fleet-push bypass' - -// `git -C …` — capture the dir (quoted or bare). Still a regex -// because we only need the -C VALUE, not command structure; the push -// DETECTION (which needs structure) goes through the shell parser. -const GIT_DASH_C_RE = /\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/ - -// A leading `cd ` before the push, e.g. `cd /x/depot && git push`. -// Only the FIRST cd in the chain matters for where git runs. -const LEADING_CD_RE = /(?:^|[;&|]|&&)\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/ - -export function extractGitCwd(command: string): string { - // Priority 1: explicit `git -C `. - const dashC = GIT_DASH_C_RE.exec(command) - if (dashC) { - return dashC[2] ?? dashC[3] ?? dashC[4] ?? process.cwd() - } - // Priority 2: a leading `cd ` in the chain. - const cd = LEADING_CD_RE.exec(command) - if (cd) { - const dir = cd[2] ?? cd[3] ?? cd[4] - if (dir) { - // Resolve against process cwd so a relative `cd ../foo` works. - return path.resolve(process.cwd(), dir) - } - } - // Priority 3: the hook's own cwd. - return process.cwd() -} - -export function originSlug(dir: string): string | undefined { - let out: string - try { - const r = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { - encoding: 'utf8', - }) - if (r.status !== 0) { - return undefined - } - out = String(r.stdout ?? '').trim() - } catch { - return undefined - } - return slugFromRemoteUrl(out) -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command - if (!command) { - process.exit(0) - } - - // Detect `git push` via the shell parser (not regex): it splits the - // command line into segments, sees through `&&`/`|`/`;` chains and - // `$(…)` substitution, and ignores `push` inside a quoted commit - // message — so `git commit -m "git push later"` is correctly NOT a - // push, while `cd /x && git push` and `git -C /x push` are. - if (!findInvocation(command, { binary: 'git', subcommand: 'push' })) { - process.exit(0) - } - - const dir = extractGitCwd(command) - const slug = originSlug(dir) - - // Fail open: no resolvable origin slug → can't classify, allow. - if (!slug) { - process.exit(0) - } - if (isFleetRepo(slug)) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[no-non-fleet-push-guard] Blocked: push to a non-fleet repository', - '', - ` Target dir: ${dir}`, - ` origin repo: ${slug}`, - '', - ` \`${slug}\` is not in the fleet roster, and fleet tooling must`, - ' not push to repos outside the fleet. A non-fleet repo has no', - ' fleet hook chain, so this agent-side guard is the only check', - ' standing between you and a stray push to someone else’s repo.', - '', - ' If this push is wrong: you probably `cd`-ed into the wrong repo', - ' or have the wrong `origin`. Verify with:', - ` git -C ${dir} remote get-url origin`, - '', - ` If the push is genuinely intended (a personal / non-fleet repo`, - ` you own), type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[no-non-fleet-push-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/no-non-fleet-push-guard/package.json b/.claude/hooks/no-non-fleet-push-guard/package.json deleted file mode 100644 index 4f2d28dc6..000000000 --- a/.claude/hooks/no-non-fleet-push-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-non-fleet-push-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts b/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts deleted file mode 100644 index 9371ea645..000000000 --- a/.claude/hooks/no-non-fleet-push-guard/test/index.test.mts +++ /dev/null @@ -1,171 +0,0 @@ -// node --test specs for the no-non-fleet-push-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns the hook -// subprocess and pipes stdin/stdout/stderr. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -// prefer-spawn-over-execsync: required -- test asserts the hook's behavior under a synchronous execFileSync call path. -import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -// Make a throwaway git repo with the given origin URL, return its path. -function gitRepoWithOrigin(originUrl: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'nfp-guard-')) - const run = (...args: string[]) => - execFileSync('git', ['-C', dir, ...args], { stdio: 'ignore' }) - run('init', '-q') - run('remote', 'add', 'origin', originUrl) - return dir -} - -// A dir that is NOT a git repo (no origin) — for the fail-open case. -function nonGitDir(): string { - return mkdtempSync(path.join(os.tmpdir(), 'nfp-nongit-')) -} - -async function runHook( - payload: Record, - cwd?: string, -): Promise { - const child = spawn(process.execPath, [HOOK], { cwd, stdio: 'pipe' }) - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const bash = (command: string) => ({ - tool_name: 'Bash', - tool_input: { command }, -}) - -test('non-Bash tool passes', async () => { - const r = await runHook({ tool_name: 'Edit', tool_input: { command: 'x' } }) - assert.strictEqual(r.code, 0) -}) - -test('Bash without git push passes', async () => { - const r = await runHook(bash('ls -la && echo hi')) - assert.strictEqual(r.code, 0) -}) - -test('fleet repo via cwd — git push allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/socket-cli.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 0) -}) - -test('non-fleet repo via cwd — git push BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('non-fleet repo via leading cd — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - // cwd is a fleet repo; the cd redirects git into the non-fleet one. - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook(bash(`cd ${dir} && git push origin main`), fleetCwd) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('non-fleet repo via git -C — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook(bash(`git -C ${dir} push origin main`), fleetCwd) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('ultrathink (fleet member, not in cascade roster) — allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/ultrathink.git') - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('HTTPS remote, non-fleet — BLOCKED', async () => { - const dir = gitRepoWithOrigin('https://github.com/SocketDev/depot.git') - const r = await runHook(bash('git push origin main'), dir) - assert.strictEqual(r.code, 2) -}) - -test('fork under another owner of a fleet name — allowed (slug matches)', async () => { - // slug is keyed on repo name; a socket-cli fork still resolves to a - // fleet slug. (Owner-level gating is out of scope; the name is the key.) - const dir = gitRepoWithOrigin('git@github.com:someuser/socket-cli.git') - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('git push mentioned only in a quoted commit message — not a push', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook( - bash(`git commit -m "remember to git push later"`), - dir, - ) - assert.strictEqual(r.code, 0) -}) - -test('non-git dir (no origin) — fail open, allowed', async () => { - const dir = nonGitDir() - const r = await runHook(bash('git push'), dir) - assert.strictEqual(r.code, 0) -}) - -test('substitution: git $(printf push) to a non-fleet repo — BLOCKED', async () => { - // The shell parser surfaces `git push` even when the subcommand is - // produced by a $(…) substitution — a form the old regex missed. - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const r = await runHook(bash('git push $(echo origin) main'), dir) - assert.strictEqual(r.code, 2) - assert.ok(r.stderr.includes('depot')) -}) - -test('pipe/chain push to non-fleet repo — BLOCKED', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const fleetCwd = gitRepoWithOrigin('git@github.com:SocketDev/socket-lib.git') - const r = await runHook( - bash(`echo start && cd ${dir} && git push origin main`), - fleetCwd, - ) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase in transcript — non-fleet push allowed', async () => { - const dir = gitRepoWithOrigin('git@github.com:SocketDev/depot.git') - const txDir = mkdtempSync(path.join(os.tmpdir(), 'nfp-tx-')) - const transcriptPath = path.join(txDir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow non-fleet-push bypass' }, - }) + '\n', - ) - const r = await runHook( - { - ...bash('git push origin main'), - transcript_path: transcriptPath, - }, - dir, - ) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/no-non-fleet-push-guard/tsconfig.json b/.claude/hooks/no-non-fleet-push-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-non-fleet-push-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-orphaned-staging/README.md b/.claude/hooks/no-orphaned-staging/README.md deleted file mode 100644 index f12eb5f61..000000000 --- a/.claude/hooks/no-orphaned-staging/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# no-orphaned-staging - -Stop hook. Fires at turn-end and lists any files that are staged -(`git diff --cached --name-only`) but not yet committed. - -## Why - -Fleet rule from CLAUDE.md ("Don't leave the worktree dirty"): - -> Stage only when you're about to commit. `git add` and `git commit` -> belong on the same line (chained with `&&`) OR in the same Bash -> call. Don't stage as a side-effect of "preparing" — staging is a -> commit-time action. - -A turn that ends with staged-but-uncommitted hunks is the failure -mode the rule warns against. Common causes: - -1. The agent ran `git add` but forgot the `git commit`. -2. A pre-commit hook failed and left the index half-cooked. -3. The agent staged "for later" — exactly what this rule forbids. - -All three look identical to the next session: a populated index of -unknown provenance. The reminder makes the dangling state visible -at the turn that created it. - -## Output - -Stderr only. Exit code always 0 — informational, never blocks -(Stop hooks can't refuse anything anyway; the turn already ended). - -``` -[no-orphaned-staging] Turn ended with staged-but-uncommitted files: - - scripts/foo.mts - - template/CLAUDE.md - ... and 3 more - -Fleet rule: stage only when about to commit. Either: - • Run `git commit` to finish the work, OR - • Run `git reset` to unstage (keep changes in working tree). - -CLAUDE.md → "Don't leave the worktree dirty" → "Stage only when -you're about to commit". -``` - -## Disable - -`SOCKET_NO_ORPHANED_STAGING_DISABLED=1` in the env. Use during -intentional mid-refactor pauses or worktree migrations where staged -state is the work-product. diff --git a/.claude/hooks/no-orphaned-staging/index.mts b/.claude/hooks/no-orphaned-staging/index.mts deleted file mode 100644 index 7fab1d6be..000000000 --- a/.claude/hooks/no-orphaned-staging/index.mts +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — no-orphaned-staging. -// -// Fires at turn-end. Checks `git diff --cached --name-only` in -// $CLAUDE_PROJECT_DIR. If anything is staged but uncommitted, emits -// a stderr warning listing the orphaned paths. -// -// The fleet rule (CLAUDE.md "Don't leave the worktree dirty"): -// -// Stage only when you're about to commit. `git add` and `git -// commit` belong on the same line (chained with `&&`) OR in the -// same Bash call. Don't stage as a side-effect of "preparing" -// — staging is a commit-time action. -// -// A turn that ends with staged-but-uncommitted hunks tends to be -// either: -// (a) the agent forgot the commit half of `git add && git commit`, -// (b) a failed pre-commit hook unstuck the index, or -// (c) the agent staged "for later" — exactly what this rule -// forbids. -// -// All three are the same failure mode: the next session sees an -// already-staged index and has to figure out the intent. The -// reminder makes the dangling state visible at the very turn that -// created it. -// -// Why a reminder, not a block: Stop hooks fire AFTER the turn ended; -// there's no tool call to refuse. The signal goes to stderr so the -// next message includes the warning. The agent can then either -// commit or explicitly explain why the staged state is intentional. -// -// Exit codes: -// 0 — always. This is informational; never blocks. -// -// Disabled via `SOCKET_NO_ORPHANED_STAGING_DISABLED=1`. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -export async function drainStdin(): Promise { - // Stop payloads carry transcript_path; this hook doesn't need it, - // but the stdin must be drained so the harness doesn't pipe-stall. - await new Promise(resolve => { - let chunks = '' - process.stdin.on('data', d => { - chunks += d.toString('utf8') - }) - process.stdin.on('end', () => resolve()) - process.stdin.on('error', () => resolve()) - setTimeout(() => resolve(), 200) - void chunks - }) -} - -export function getProjectDir(): string | undefined { - // Prefer the harness-supplied env (correct even when cwd has been - // chdir'd by a tool). Fall back to cwd. - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -export function listStagedFiles(repoDir: string): string[] { - const r = spawnSync('git', ['diff', '--cached', '--name-only'], { - cwd: repoDir, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - -async function main(): Promise { - if (process.env['SOCKET_NO_ORPHANED_STAGING_DISABLED']) { - return - } - await drainStdin() - - const repoDir = getProjectDir() - if (!repoDir) { - return - } - - const staged = listStagedFiles(repoDir) - if (staged.length === 0) { - return - } - - process.stderr.write( - '[no-orphaned-staging] Turn ended with staged-but-uncommitted files:\n', - ) - for (const f of staged.slice(0, 10)) { - process.stderr.write(` - ${f}\n`) - } - if (staged.length > 10) { - process.stderr.write(` ... and ${staged.length - 10} more\n`) - } - process.stderr.write( - '\nFleet rule: stage only when about to commit. Either:\n' + - ' • Run `git commit` to finish the work, OR\n' + - ' • Run `git reset` to unstage (keep changes in working tree).\n' + - '\nCLAUDE.md → "Don\'t leave the worktree dirty" → "Stage only when ' + - 'you\'re about to commit".\n', - ) -} - -main().catch(e => { - process.stderr.write( - `[no-orphaned-staging] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) -}) diff --git a/.claude/hooks/no-orphaned-staging/package.json b/.claude/hooks/no-orphaned-staging/package.json deleted file mode 100644 index 898f67466..000000000 --- a/.claude/hooks/no-orphaned-staging/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-orphaned-staging", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-orphaned-staging/test/index.test.mts b/.claude/hooks/no-orphaned-staging/test/index.test.mts deleted file mode 100644 index 8b55414d4..000000000 --- a/.claude/hooks/no-orphaned-staging/test/index.test.mts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Unit tests for no-orphaned-staging hook. Test strategy: create a temp - * git repo, stage a file (or not), spawn the hook with CLAUDE_PROJECT_DIR - * pointed at the temp repo, and inspect stderr. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(env: Record): RunResult { - const r = spawnSync('node', [HOOK], { - input: '{}', - env: { ...process.env, ...env }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function git(repoDir: string, args: string[]): void { - const r = spawnSync('git', args, { cwd: repoDir }) - if (r.status !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`) - } -} - -let tmpRepo: string - -beforeEach(() => { - tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'no-orphaned-staging-')) - git(tmpRepo, ['init', '-q']) - git(tmpRepo, ['config', 'user.email', 'test@example.com']) - git(tmpRepo, ['config', 'user.name', 'Test']) - writeFileSync(path.join(tmpRepo, 'README.md'), '# test\n') - git(tmpRepo, ['add', 'README.md']) - git(tmpRepo, ['commit', '-q', '-m', 'initial']) -}) - -afterEach(() => { - rmSync(tmpRepo, { recursive: true, force: true }) -}) - -describe('no-orphaned-staging', () => { - test('clean index → silent', () => { - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') - }) - - test('staged file → warning', () => { - writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') - git(tmpRepo, ['add', 'foo.txt']) - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - assert.match(r.stderr, /no-orphaned-staging/) - assert.match(r.stderr, /foo\.txt/) - }) - - test('multiple staged files listed', () => { - for (const name of ['a.txt', 'b.txt', 'c.txt']) { - writeFileSync(path.join(tmpRepo, name), `${name}\n`) - git(tmpRepo, ['add', name]) - } - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.equal(r.code, 0) - for (const name of ['a.txt', 'b.txt', 'c.txt']) { - assert.match(r.stderr, new RegExp(name)) - } - }) - - test('disabled via env → silent even when staged', () => { - writeFileSync(path.join(tmpRepo, 'foo.txt'), 'staged content\n') - git(tmpRepo, ['add', 'foo.txt']) - const r = runHook({ - CLAUDE_PROJECT_DIR: tmpRepo, - SOCKET_NO_ORPHANED_STAGING_DISABLED: '1', - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') - }) - - test('non-repo dir → silent (not a git repo)', () => { - const nonRepo = mkdtempSync(path.join(os.tmpdir(), 'not-a-repo-')) - try { - const r = runHook({ CLAUDE_PROJECT_DIR: nonRepo }) - assert.equal(r.code, 0) - // git returns non-zero exit + the helper returns empty list. - assert.equal(r.stderr, '') - } finally { - rmSync(nonRepo, { recursive: true, force: true }) - } - }) - - test('truncates listing past 10 files', () => { - for (let i = 0; i < 15; i += 1) { - const name = `f${i}.txt` - writeFileSync(path.join(tmpRepo, name), `${name}\n`) - git(tmpRepo, ['add', name]) - } - const r = runHook({ CLAUDE_PROJECT_DIR: tmpRepo }) - assert.match(r.stderr, /and 5 more/) - }) - - test('fail-open on hook bug', () => { - // Empty stdin would normally drain; verifying the hook doesn't - // crash on missing-env-vars or other edge cases. - const r = spawnSync('node', [HOOK], { - input: '', - env: { ...process.env, CLAUDE_PROJECT_DIR: '/nonexistent/path' }, - }) - assert.equal(r.status, 0) - }) -}) diff --git a/.claude/hooks/no-orphaned-staging/tsconfig.json b/.claude/hooks/no-orphaned-staging/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-orphaned-staging/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md b/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md deleted file mode 100644 index acffb604f..000000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# no-package-json-pnpm-overrides-guard - -PreToolUse Edit/Write hook that blocks adding (or expanding) a -`pnpm.overrides` block in any `package.json`. - -## Why - -pnpm reads dependency overrides from two places: `pnpm.overrides` in -`package.json`, or the top-level `overrides:` map in `pnpm-workspace.yaml`. -The fleet standardizes on the workspace file as the single override surface. - -A `pnpm.overrides` block in package.json splits the source of truth: a -reviewer auditing pins now has to check two files, and the workspace file's -`trustPolicy: no-downgrade` only governs the overrides declared there. An -override hiding in a package.json can silently downgrade a transitive dep -past the trust policy. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------------------ | ------ | -| Edit/Write that adds a key under `pnpm.overrides` in package.json | yes | -| Edit/Write that removes a key from `pnpm.overrides` | no | -| Edit/Write touching package.json but not `pnpm.overrides` | no | -| Edit/Write to `pnpm-workspace.yaml` `overrides:` (the right place) | no | -| Edit/Write to any other file | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow package-json-overrides bypass - -Rare legitimate case: a published package that ships its own -`pnpm.overrides` you're vendoring verbatim and must not rewrite. - -## Detection - -The hook parses both the current package.json and the after-edit contents -as JSON, reads `pnpm.overrides`, and computes the set difference of override -keys. Keys added → block. Keys removed or unchanged → pass. - -Fails open on JSON parse errors: better to under-block than to brick edits -when the file is in a transient bad state. - -## Fix - -Move the override to the top-level `overrides:` map in `pnpm-workspace.yaml`, -then `pnpm install`: - -```yaml -# pnpm-workspace.yaml -overrides: - some-dep: '>=1.2.3' -``` diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts deleted file mode 100644 index 85cb6bf84..000000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-package-json-pnpm-overrides-guard. -// -// Blocks Edit/Write operations that add (or expand) a `pnpm.overrides` -// block in any `package.json`. The fleet keeps dependency overrides in -// `pnpm-workspace.yaml` `overrides:` as the single source of truth. A -// `pnpm.overrides` block in package.json splits that surface and sits -// outside the workspace file's `trustPolicy: no-downgrade` governance. -// -// Detection model: -// - Fires only on Edit / Write to files named `package.json`. -// - Parses before + after JSON. Reports the override keys that are -// present in the after-state but absent (or fewer) in the before. -// - New / expanded `pnpm.overrides` → block. -// -// Bypass: `Allow package-json-overrides bypass` typed verbatim in a -// recent user turn. -// -// Fails open on parse errors (better to under-block than to brick edits -// when the file isn't parseable JSON). - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow package-json-overrides bypass' - -// Extract the set of override keys declared under `pnpm.overrides` in a -// package.json text. Returns an empty set when the block is absent, the -// text isn't valid JSON, or `pnpm.overrides` isn't an object. pnpm reads -// overrides from `pnpm.overrides` (package.json) or top-level `overrides` -// (pnpm-workspace.yaml); this guard targets the package.json form only. -export function extractOverrideKeys(jsonText: string): Set { - const out = new Set() - let parsed: unknown - try { - parsed = JSON.parse(jsonText) - } catch { - return out - } - if (!parsed || typeof parsed !== 'object') { - return out - } - const pnpm = (parsed as { pnpm?: unknown | undefined }).pnpm - if (!pnpm || typeof pnpm !== 'object') { - return out - } - const overrides = (pnpm as { overrides?: unknown | undefined }).overrides - if (!overrides || typeof overrides !== 'object') { - return out - } - for (const key of Object.keys(overrides as Record)) { - out.add(key) - } - return out -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath || path.basename(filePath) !== 'package.json') { - process.exit(0) - } - - const currentText = readFileSafe(filePath) - let afterText: string - if (payload.tool_name === 'Write') { - afterText = input?.content ?? input?.new_string ?? '' - } else { - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr) { - process.exit(0) - } - if (!currentText.includes(oldStr)) { - process.exit(0) - } - afterText = currentText.replace(oldStr, newStr) - } - - let beforeKeys: Set - let afterKeys: Set - try { - beforeKeys = extractOverrideKeys(currentText) - afterKeys = extractOverrideKeys(afterText) - } catch (e) { - process.stderr.write( - `[no-package-json-pnpm-overrides-guard] parse error (allowing): ${e}\n`, - ) - process.exit(0) - } - - const added: string[] = [] - for (const key of afterKeys) { - if (!beforeKeys.has(key)) { - added.push(key) - } - } - if (added.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - added.sort() - process.stderr.write( - [ - '[no-package-json-pnpm-overrides-guard] Blocked: package.json pnpm.overrides additions', - '', - ` File: ${filePath}`, - ` New entries: ${added.map(k => `\`${k}\``).join(', ')}`, - '', - ' The fleet keeps dependency overrides in `pnpm-workspace.yaml`', - ' `overrides:`, the single override surface. A `pnpm.overrides`', - ' block in package.json splits the source of truth and sits', - ' outside the workspace file’s `trustPolicy: no-downgrade`.', - '', - ' Fix: move the override to the top-level `overrides:` map in', - ' `pnpm-workspace.yaml`, then `pnpm install`.', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[no-package-json-pnpm-overrides-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json deleted file mode 100644 index eeb28c3b8..000000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-package-json-pnpm-overrides-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts b/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts deleted file mode 100644 index 616ff545b..000000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/test/index.test.mts +++ /dev/null @@ -1,147 +0,0 @@ -// node --test specs for the no-package-json-pnpm-overrides-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function tmpPackageJson(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-test-')) - const p = path.join(dir, 'package.json') - writeFileSync(p, content) - return p -} - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'echo hi' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit to a non-package.json file passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-other-')) - const filePath = path.join(dir, 'pnpm-workspace.yaml') - writeFileSync(filePath, 'overrides:\n foo: 1.0.0\n') - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: 'foo: 1.0.0', - new_string: 'foo: 2.0.0', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit that does not touch pnpm.overrides passes', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "version": "1.0.0"\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '"1.0.0"', - new_string: '"1.0.1"', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit removes a pnpm.overrides key — passes', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1", "b": "2" } }\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '{ "a": "1", "b": "2" }', - new_string: '{ "a": "1" }', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('Edit adds a new pnpm.overrides key — blocked', async () => { - const filePath = tmpPackageJson( - '{\n "name": "x",\n "pnpm": { "overrides": { "a": "1" } }\n}\n', - ) - const r = await runHook({ - tool_name: 'Edit', - tool_input: { - file_path: filePath, - old_string: '{ "a": "1" }', - new_string: '{ "a": "1", "b": "2" }', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('`b`')) -}) - -test('Write adds a fresh pnpm.overrides — blocked', async () => { - const filePath = tmpPackageJson('{ "name": "x" }') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: '{ "name": "x", "pnpm": { "overrides": { "sketchy": "9" } } }', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('sketchy')) -}) - -test('Edit with bypass phrase in transcript — passes', async () => { - const filePath = tmpPackageJson('{ "name": "x" }') - const dir = mkdtempSync(path.join(os.tmpdir(), 'pj-overrides-guard-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow package-json-overrides bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: '{ "name": "x", "pnpm": { "overrides": { "b": "2" } } }', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json b/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-package-json-pnpm-overrides-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-revert-guard/README.md b/.claude/hooks/no-revert-guard/README.md deleted file mode 100644 index 157b4c3ac..000000000 --- a/.claude/hooks/no-revert-guard/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# no-revert-guard - -PreToolUse Bash hook that blocks destructive git commands and hook bypasses unless the user has authorized them with the canonical phrase `Allow bypass`. - -## What it blocks - -| Pattern | Bypass phrase | -| ----------------------------------------------------------- | ------------------------- | -| `git checkout -- ` / `git checkout -- ` | `Allow revert bypass` | -| `git restore ` (without `--staged`) | `Allow revert bypass` | -| `git reset --hard` | `Allow revert bypass` | -| `git stash drop` / `git stash pop` / `git stash clear` | `Allow revert bypass` | -| `git clean -f` (and variants) | `Allow revert bypass` | -| `git rm -r{f,}` | `Allow revert bypass` | -| `--no-verify` | `Allow no-verify bypass` | -| `--no-gpg-sign` / `commit.gpgsign=false` | `Allow gpg bypass` | -| `DISABLE_PRECOMMIT_LINT=1` | `Allow lint bypass` | -| `DISABLE_PRECOMMIT_TEST=1` | `Allow test bypass` | -| `git push --force` / `-f` | `Allow force-push bypass` | - -## How the bypass works - -The hook reads the conversation transcript (path passed in the PreToolUse JSON payload) and searches the concatenated user-turn text for the exact phrase. The match is **case-sensitive** and **substring-based** — a paraphrase like "go ahead and revert" does not count. - -A phrase from a previous session does not carry over: the transcript only includes the current session's turns. - -## Why hook + memory + CLAUDE.md rule - -Defense in depth: - -- **CLAUDE.md** documents the policy so a reviewer reading the canonical fleet rules sees the rule. -- **Memory** keeps the assistant honest across sessions even before the hook fires. -- **Hook** is the actual enforcement: when Claude tries the destructive command, this hook checks the transcript, finds no matching authorization phrase, and exits 2 with a stderr message telling Claude exactly what the user needs to type. - -The user then makes a deliberate choice instead of Claude inferring intent from context. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy of the hook can't brick the session. The trade-off: a buggy hook silently allows the destructive command. Acceptable because the alternative (hook crashes wedge the session) is worse for development velocity, and bug reports surface quickly. - -## Companion files - -- `index.mts` — the hook itself -- `package.json` — declares the hook as a workspace package (taze sees it via `pnpm-workspace.yaml`'s `packages: ['.claude/hooks/*']`) -- `tsconfig.json` — fleet-canonical TS config for hooks -- `test/` — node:test runner specs (run via `pnpm exec --filter hook-no-revert-guard test` or `node --test test/*.test.mts`) diff --git a/.claude/hooks/no-revert-guard/index.mts b/.claude/hooks/no-revert-guard/index.mts deleted file mode 100644 index 0be74a668..000000000 --- a/.claude/hooks/no-revert-guard/index.mts +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-revert-guard. -// -// Blocks Bash commands that would revert tracked changes, bypass the -// git-hook chain (.git-hooks/ wired in via `core.hooksPath`), or -// otherwise destroy work in flight, unless the conversation has -// authorized the bypass via the canonical phrase -// `Allow bypass` (case-sensitive, exact match). -// -// The bypass-phrase contract: -// - Revert (git checkout/restore/reset/stash drop/stash pop/clean) → -// user must type "Allow revert bypass" in a recent user turn. -// - Hook bypass (--no-verify, DISABLE_PRECOMMIT_*, --no-gpg-sign) → -// user must type "Allow bypass" where matches the flag -// (e.g. "Allow no-verify bypass", "Allow lint bypass", -// "Allow gpg bypass"). -// - Force push (--force / -f to push or push-with-lease) → -// user must type "Allow force-push bypass". -// -// Phrase scoping: the hook reads the recent user turns from the -// transcript (most recent N user messages). A phrase from a prior -// session does NOT carry over — only the current conversation counts. -// -// Why a hook + a memory + a CLAUDE.md rule: the rule documents the -// policy, the memory keeps the assistant honest across sessions, the -// hook is the actual enforcement at edit time. When Claude tries the -// destructive command, this hook checks the transcript, finds no -// matching authorization phrase, and exits 2 with a stderr message -// telling Claude exactly what the user needs to type. The user then -// makes a deliberate choice instead of Claude inferring intent. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/.../session.jsonl" } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: { command?: string | undefined } | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -type GuardCheck = { - // Canonical phrase the user must type to bypass. - readonly bypassPhrase: string - // Human-readable label for the rule (logged on rejection). - readonly label: string - // Detector. Exactly one of `pattern` / `matches` is set: - // - `pattern`: a regex matched anywhere in the command. Correct for - // flag / env-var rules (`--no-verify`, `DISABLE_PRECOMMIT_LINT=1`) - // that apply regardless of which binary they sit on. - // - `matches`: a parser-based detector for command-STRUCTURE rules - // (which git subcommand runs). Returns the offending substring for - // the log, or undefined when no match. Sees through chains / `$(…)` - // / quotes, where a regex would over- or under-match. - readonly pattern?: RegExp | undefined - readonly matches?: (command: string) => string | undefined -} - -const CHECKS: readonly GuardCheck[] = [ - { - bypassPhrase: 'Allow revert bypass', - label: 'git revert (checkout/restore/reset/stash/clean)', - // Parser-based: inspect each real `git` command's args for a - // destructive subcommand shape. Sees through chains / quotes so a - // quoted "git reset --hard" in a commit message isn't a match. - matches: command => matchDestructiveGit(command), - }, - { - bypassPhrase: 'Allow no-verify bypass', - label: 'git --no-verify (skips .git-hooks/ chain)', - pattern: /(?:^|\s)--no-verify\b/, - }, - { - bypassPhrase: 'Allow gpg bypass', - label: 'git --no-gpg-sign / commit.gpgsign=false', - pattern: /(?:--no-gpg-sign|commit\.gpgsign\s*=\s*false)\b/, - }, - { - bypassPhrase: 'Allow lint bypass', - label: 'DISABLE_PRECOMMIT_LINT=1 (skips lint step in pre-commit hook)', - pattern: /\bDISABLE_PRECOMMIT_LINT\s*=\s*[1-9]/, - }, - { - bypassPhrase: 'Allow test bypass', - label: 'DISABLE_PRECOMMIT_TEST=1 (skips test step in pre-commit hook)', - pattern: /\bDISABLE_PRECOMMIT_TEST\s*=\s*[1-9]/, - }, - { - // SKIP_ASSET_DOWNLOAD is a documented degraded-mode flag in - // socket-cli's download-assets.mts (use cached assets when - // offline/rate-limited). It becomes a *bypass* when used to push - // past pre-commit by short-circuiting the build's network step. - // Treat as a bypass so agents can't unilaterally trade build - // completeness for commit speed. - bypassPhrase: 'Allow asset-download bypass', - label: 'SKIP_ASSET_DOWNLOAD=1 (skips release-asset fetch in build)', - pattern: /\bSKIP_ASSET_DOWNLOAD\s*=\s*[1-9]/, - }, - { - // `git stash` (in any form: bare, push, save, --keep-index) is - // forbidden in the primary checkout under the parallel-Claude - // rule. The stash store is shared across sessions — another agent - // can `git stash pop` yours and destroy work. CLAUDE.md says use - // worktrees instead. This catches the *initial* stash (the - // existing revert pattern below catches drop/pop/clear, which is - // a separate destruction surface). - // - // Observed violation pattern: agents instinctively reach for - // `git stash` when they want to test in a clean tree without - // their changes interfering. Reflex of SWE muscle memory; the - // worktree pattern is less familiar. Block the reflex; the - // bypass phrase exists for single-session contexts where the - // user knows no other Claude session is active. - bypassPhrase: 'Allow stash bypass', - label: 'git stash (primary-checkout parallel-Claude hazard)', - // Any `git stash` (bare, or push/save/--keep-index/etc.) — but NOT - // `git stash pop/drop/clear`, which the destructive-git check above - // already owns (it's a different destruction surface). - matches: command => - commandsFor(command, 'git').some(c => { - if (c.args[0] !== 'stash') { - return false - } - const sub = c.args[1] - return sub !== 'clear' && sub !== 'drop' && sub !== 'pop' - }) - ? 'git stash' - : undefined, - }, - { - // Bash file-write surfaces agents reach for when an Edit/Write - // hook blocks them. Catches the "go around" pattern: agent tries - // Edit, gets blocked by markdown-filename-guard / path-guard / - // no-fleet-fork-guard / etc., then switches to `python3 -c` - // (or `sed -i` / heredoc / printf >) to write the same content - // via Bash where the Edit-layer hooks don't fire. - // - // The contract: when an Edit/Write hook blocks, the path forward - // is (a) move the file to a canonical location, (b) refactor the - // change so the rule no longer triggers, or (c) get the canonical - // bypass phrase for the original hook. Switching tools to dodge - // the hook is not a path. - // - // Observed 2026-05-12: agent used `python3 -c '...write(...)'` - // to rename a markdown file after markdown-filename-guard blocked - // Edit on it. - // - // Patterns matched: - // - python -c '...' with open(...,'w') or .write_text( - // - sed -i (in-place edit) - // - heredoc redirected to file (cat << EOF > file) - // - tee writing to a non-tmp file - // - dd of= - // - // Carve-outs intentionally NOT matched: plain `>` / `>>` (too - // broad — every build/log/test invocation uses these), `mv` / `cp` - // (file moves, not content writes), tools that write their own - // output (`tsc`, `pnpm build`, etc. — they don't use Bash write - // primitives directly). - bypassPhrase: 'Allow bash-write bypass', - label: 'Bash file-write (likely dodging an Edit/Write hook)', - pattern: - /(?:^|[\s;&|(`])(?:python3?\s+-c\b.*(?:open\([^)]*['"]w['"]?|\.write_text\(|\.write\([^)]*\)\s*$)|sed\s+-i\b|cat\s+<<-?\s*['"]?[A-Z_]+['"]?\b[^|;`]*>\s*[^/]|tee\s+(?!-)\S*\.(?:m?[jt]sx?|json|md|ya?ml|toml|sh|py|rs|go|css)\b|\bdd\s+[^|;`]*\bof=)/, - }, - { - bypassPhrase: 'Allow force-push bypass', - label: 'git push --force / -f', - matches: command => - commandsFor(command, 'git').some( - c => - c.args.includes('push') && - (c.args.includes('--force') || - c.args.includes('-f') || - c.args.some(a => a.startsWith('--force-with-lease'))), - ) - ? 'git push --force' - : undefined, - }, -] - -// Destructive `git` subcommands the revert rule blocks. Operates on a -// parsed git command's args (a1 = first arg = subcommand, rest = flags). -// Mirrors the old regex's surface: -// checkout … -- (discards working-tree changes) -// restore (but NOT `restore --staged`, which only unstages) -// reset --hard -// stash clear|drop|pop -// clean -f / -xf / -df … -// rm -f / -rf -export function matchDestructiveGit(command: string): string | undefined { - for (const c of commandsFor(command, 'git')) { - const [sub, ...rest] = c.args - if (!sub) { - continue - } - if (sub === 'checkout' && rest.includes('--')) { - return 'git checkout -- ' - } - if (sub === 'restore' && !rest.includes('--staged')) { - return 'git restore' - } - if (sub === 'reset' && rest.includes('--hard')) { - return 'git reset --hard' - } - if ( - sub === 'stash' && - (rest[0] === 'clear' || rest[0] === 'drop' || rest[0] === 'pop') - ) { - return `git stash ${rest[0]}` - } - if (sub === 'clean' && rest.some(a => /^-[a-z]*f/.test(a))) { - return 'git clean -f' - } - if (sub === 'rm' && rest.some(a => /^-r?f?$/.test(a) && a.includes('f'))) { - return 'git rm -f' - } - } - return undefined -} - -export function emitBlock( - command: string, - match: GuardCheck, - matchedSubstring: string, -): void { - const lines: string[] = [] - lines.push('[no-revert-guard] Blocked: destructive / hook-bypass command.') - lines.push(` Rule: ${match.label}`) - lines.push(` Match: ${matchedSubstring}`) - lines.push(` Command: ${command}`) - lines.push('') - lines.push(' This operation either reverts tracked changes or bypasses the') - lines.push(' fleet hook chain. Both destroy work or skip safety checks.') - lines.push('') - lines.push( - ` To proceed, the user must type the EXACT phrase in a new message:`, - ) - lines.push(` ${match.bypassPhrase}`) - lines.push('') - lines.push( - ' The phrase is case-sensitive. Inferring intent from a paraphrase', - ) - lines.push(' ("go ahead", "skip the hook", "fine") does NOT count.') - process.stderr.write(lines.join('\n') + '\n') -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Bash') { - return - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return - } - - // Allowlist: fleet-sync cascade commands run in batches across every - // repo and would otherwise need a fresh bypass phrase per repo. The - // caller marks intent by setting `FLEET_SYNC=1` inline (the same way - // CI=true is set inline). The sentinel is opt-in per command — no - // global env-var poisoning — and only allows the two operations the - // cascade actually needs: - // - // 1. `git commit --no-verify -m "chore(wheelhouse): cascade template@"` - // — the commit message MUST start with `chore(wheelhouse): cascade template@`. - // 2. `git push --no-verify origin ` — any branch / direct push. - // - // Anything else with `FLEET_SYNC=1` still falls through to the normal - // checks below, so the sentinel can't be used as a blanket bypass for - // unrelated destructive work. - if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { - const isCascadeCommit = - /\bgit\s+commit\b/.test(command) && - /chore\(wheelhouse\):\s*cascade\s+template@/.test(command) - const isCascadePush = /\bgit\s+push\b/.test(command) - if (isCascadeCommit || isCascadePush) { - return - } - } - - // Find the first matching destructive pattern. A check is either a - // regex (`pattern`, matched anywhere — flags / env vars) or a parser - // detector (`matches`, command-structure — git subcommands). - let triggered: { check: GuardCheck; matchedSubstring: string } | undefined - for (let i = 0, { length } = CHECKS; i < length; i += 1) { - const check = CHECKS[i]! - if (check.matches) { - const hit = check.matches(command) - if (hit) { - triggered = { check, matchedSubstring: hit } - break - } - } else if (check.pattern) { - const m = command.match(check.pattern) - if (m) { - triggered = { check, matchedSubstring: m[0].trim() } - break - } - } - } - if (!triggered) { - return - } - - // Look for the canonical bypass phrase in user turns. The match is - // case-sensitive and substring-based — a paraphrase doesn't count. - if ( - bypassPhrasePresent(payload.transcript_path, triggered.check.bypassPhrase) - ) { - return - } - - emitBlock(command, triggered.check, triggered.matchedSubstring) - process.exitCode = 2 -} - -main().catch(e => { - // Fail open on hook bugs. - process.stderr.write( - `[no-revert-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/no-revert-guard/package.json b/.claude/hooks/no-revert-guard/package.json deleted file mode 100644 index d51e8f7d2..000000000 --- a/.claude/hooks/no-revert-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-revert-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-revert-guard/test/index.test.mts b/.claude/hooks/no-revert-guard/test/index.test.mts deleted file mode 100644 index 186235142..000000000 --- a/.claude/hooks/no-revert-guard/test/index.test.mts +++ /dev/null @@ -1,573 +0,0 @@ -// node --test specs for the no-revert-guard hook. -// -// Spawns the hook as a subprocess (matches the production runtime), -// pipes a JSON payload on stdin, captures stderr + exit code. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook( - payload: Record, - transcript?: string, -): Promise { - let transcriptPath: string | undefined - if (transcript !== undefined) { - const dir = mkdtempSync(path.join(os.tmpdir(), 'no-revert-guard-test-')) - transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, transcript) - payload['transcript_path'] = transcriptPath - } - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -function userTurn(text: string): string { - return JSON.stringify({ type: 'user', message: { content: text } }) + '\n' -} - -test('non-Bash tool calls pass through untouched', async () => { - const result = await runHook({ - tool_input: { file_path: 'foo.ts', new_string: 'export const x = 1' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('benign git command (status) passes through', async () => { - const result = await runHook({ - tool_input: { command: 'git status --short' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git checkout -- is blocked without phrase', async () => { - const result = await runHook({ - tool_input: { command: 'git checkout -- src/foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /no-revert-guard/) - assert.match(result.stderr, /Allow revert bypass/) -}) - -test('git checkout -- is allowed with phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'git checkout -- src/foo.ts' }, - tool_name: 'Bash', - }, - userTurn('Allow revert bypass — please revert that one file'), - ) - assert.strictEqual(result.code, 0) -}) - -test('git reset --hard is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git reset --hard HEAD~1' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow revert bypass/) -}) - -test('git restore is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git restore src/foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git restore --staged is allowed (unstages, no revert)', async () => { - const result = await runHook({ - tool_input: { command: 'git restore --staged src/foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git stash drop is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git stash drop' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('--no-verify is blocked without its specific phrase', async () => { - const result = await runHook({ - tool_input: { command: 'git commit -m "foo" --no-verify' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow no-verify bypass/) -}) - -test('--no-verify is allowed with its phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'git commit -m "foo" --no-verify' }, - tool_name: 'Bash', - }, - userTurn('Allow no-verify bypass for the next commit'), - ) - assert.strictEqual(result.code, 0) -}) - -test('DISABLE_PRECOMMIT_LINT=1 is blocked without phrase', async () => { - const result = await runHook({ - tool_input: { command: 'DISABLE_PRECOMMIT_LINT=1 git commit -m "foo"' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow lint bypass/) -}) - -test('DISABLE_PRECOMMIT_LINT=1 allowed with phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'DISABLE_PRECOMMIT_LINT=1 git commit -m "foo"' }, - tool_name: 'Bash', - }, - userTurn('Allow lint bypass — manual cleanup follows'), - ) - assert.strictEqual(result.code, 0) -}) - -test('SKIP_ASSET_DOWNLOAD=1 is blocked without phrase', async () => { - const result = await runHook({ - tool_input: { command: 'SKIP_ASSET_DOWNLOAD=1 pnpm run build' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow asset-download bypass/) -}) - -test('SKIP_ASSET_DOWNLOAD=1 allowed with phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'SKIP_ASSET_DOWNLOAD=1 pnpm run build' }, - tool_name: 'Bash', - }, - userTurn('Allow asset-download bypass — GitHub releases rate-limited'), - ) - assert.strictEqual(result.code, 0) -}) - -test('bare git stash is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git stash' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow stash bypass/) -}) - -test('git stash --keep-index is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git stash --keep-index' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow stash bypass/) -}) - -test('git stash push is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git stash push -m "test"' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow stash bypass/) -}) - -test('git stash is allowed with phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'git stash --keep-index' }, - tool_name: 'Bash', - }, - userTurn('Allow stash bypass — single Claude session, safe'), - ) - assert.strictEqual(result.code, 0) -}) - -test('git stash drop is blocked by the revert check, not the stash check', async () => { - const result = await runHook({ - tool_input: { command: 'git stash drop stash@{0}' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow revert bypass/) -}) - -test('python -c with open(...,"w") is blocked', async () => { - const result = await runHook({ - tool_input: { - command: `python3 -c 'open("docs/file.md","w").write("content")'`, - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('python -c with .write_text is blocked', async () => { - const result = await runHook({ - tool_input: { - command: `python3 -c 'import pathlib; pathlib.Path("foo.md").write_text("x")'`, - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('sed -i is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'sed -i "s/foo/bar/g" src/file.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('heredoc redirected to source file is blocked', async () => { - const result = await runHook({ - tool_input: { - command: `cat << EOF > src/foo.ts\nexport const x = 1\nEOF`, - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('dd of= is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'dd if=/dev/zero of=src/blob.bin bs=1024 count=1' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('tee writing to a source file is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'echo "x" | tee src/foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow bash-write bypass/) -}) - -test('bash-write is allowed with phrase', async () => { - const result = await runHook( - { - tool_input: { command: 'sed -i "s/foo/bar/g" build/generated.json' }, - tool_name: 'Bash', - }, - userTurn('Allow bash-write bypass — generated file, no Edit hook needed'), - ) - assert.strictEqual(result.code, 0) -}) - -test('mv is NOT a bash-write (file move, not content write)', async () => { - const result = await runHook({ - tool_input: { command: 'mv src/old.ts src/new.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('cp is NOT a bash-write', async () => { - const result = await runHook({ - tool_input: { command: 'cp template/x.json downstream/x.json' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('python -c without file write is NOT blocked', async () => { - const result = await runHook({ - tool_input: { command: `python3 -c 'print("hello")'` }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git push --force is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git push --force origin main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow force-push bypass/) -}) - -test('paraphrase does not count', async () => { - const result = await runHook( - { - tool_input: { command: 'git checkout -- src/foo.ts' }, - tool_name: 'Bash', - }, - userTurn('go ahead and revert that file'), - ) - assert.strictEqual(result.code, 2) -}) - -test('case mismatch does not count', async () => { - const result = await runHook( - { - tool_input: { command: 'git checkout -- src/foo.ts' }, - tool_name: 'Bash', - }, - userTurn('allow revert bypass'), - ) - assert.strictEqual(result.code, 2) -}) - -test('multi-line user turn with phrase embedded works', async () => { - const result = await runHook( - { - tool_input: { command: 'git checkout -- src/foo.ts' }, - tool_name: 'Bash', - }, - userTurn( - 'I want to drop my last edit.\nAllow revert bypass\nThat one specifically.', - ), - ) - assert.strictEqual(result.code, 0) -}) - -// ── FLEET_SYNC=1 cascade allowlist ────────────────────────────────── - -test('FLEET_SYNC=1 allows the cascade commit without bypass phrase', async () => { - const result = await runHook({ - tool_input: { - command: - 'FLEET_SYNC=1 git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('FLEET_SYNC=1 allows the cascade push without bypass phrase', async () => { - const result = await runHook({ - tool_input: { - command: 'FLEET_SYNC=1 git push --no-verify origin HEAD:main', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('FLEET_SYNC=1 with a non-cascade commit message is still blocked', async () => { - const result = await runHook({ - tool_input: { - command: 'FLEET_SYNC=1 git commit --no-verify -m "feat: sneak this past"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.ok(String(result.stderr).includes('Allow no-verify bypass')) -}) - -test('FLEET_SYNC=1 does NOT relax non-git destructive ops (e.g. stash)', async () => { - const result = await runHook({ - tool_input: { command: 'FLEET_SYNC=1 git stash' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.ok(String(result.stderr).includes('Allow stash bypass')) -}) - -test('FLEET_SYNC=1 does NOT relax git reset --hard', async () => { - const result = await runHook({ - tool_input: { command: 'FLEET_SYNC=1 git reset --hard HEAD~1' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.ok(String(result.stderr).includes('Allow revert bypass')) -}) - -test('no FLEET_SYNC sentinel: cascade commit still requires the bypass phrase', async () => { - const result = await runHook({ - tool_input: { - command: - 'git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.ok(String(result.stderr).includes('Allow no-verify bypass')) -}) - -test('FLEET_SYNC=0 (explicit off) does NOT activate the allowlist', async () => { - const result = await runHook({ - tool_input: { - command: - 'FLEET_SYNC=0 git commit --no-verify -m "chore(wheelhouse): cascade template@abc1234"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.ok(String(result.stderr).includes('Allow no-verify bypass')) -}) - -// ── Parser-enabled coverage (added with the shell-quote migration) ── - -test('destructive git in an && chain is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'echo backup && git reset --hard origin/main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('destructive git after a cd is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'cd /repo; git clean -fdx' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('quoted "git reset --hard" in a commit message is NOT a revert', async () => { - const result = await runHook({ - tool_input: { - command: 'git commit -m "document why git reset --hard is dangerous"', - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('quoted "git push --force" in an echo is NOT a force-push', async () => { - const result = await runHook({ - tool_input: { command: 'echo "never git push --force to main"' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git clean -f is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git clean -f' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git clean -xdf (bundled flags) is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git clean -xdf' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git rm -rf is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git rm -rf old-dir' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git checkout -- is blocked (ref form)', async () => { - const result = await runHook({ - tool_input: { command: 'git checkout HEAD~1 -- src/foo.ts' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git push --force-with-lease is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git push --force-with-lease origin main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('git push -f is blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git push -f origin main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) -}) - -test('plain git push (no force) is NOT blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git push origin main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git checkout (switch, no --) is NOT a revert', async () => { - const result = await runHook({ - tool_input: { command: 'git checkout main' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git reset (soft, default) is NOT blocked', async () => { - const result = await runHook({ - tool_input: { command: 'git reset HEAD~1' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('git stash pop attributed to the revert rule (not stash rule)', async () => { - const result = await runHook({ - tool_input: { command: 'git stash pop' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Allow revert bypass/) -}) - -test('a word ending in "git" is not a git command (e.g. legit)', async () => { - const result = await runHook({ - tool_input: { command: 'echo legit && ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/no-revert-guard/tsconfig.json b/.claude/hooks/no-revert-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-revert-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/README.md b/.claude/hooks/no-structured-clone-prefer-json-guard/README.md deleted file mode 100644 index 4b0d96261..000000000 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# no-structured-clone-prefer-json-guard - -A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls -introducing a bare `structuredClone(...)` call into a code file -without the canonical per-line opt-out comment. - -## Why this rule - -For the JSON-roundtrippable subset — anything that came from -`JSON.parse`, anything you'd happily round-trip through -`JSON.stringify` and back — `JSON.parse(JSON.stringify(x))` is -**3-5× faster** than `structuredClone(x)`. The browser/Node -`structuredClone` runs the full HTML structured-clone algorithm: -type tagging, transferable handling, prototype preservation, cycle -detection. None of those apply to JSON data. The JSON round-trip -goes straight through V8's tight C++ JSON path with no type dispatch. - -For caches, hot read-paths, and defensive-copy wrappers, the -constant-factor difference is meaningful at scale. - -## Conventional shape - -```ts -// Wrong — bare structuredClone on JSON-shaped data: -const copy = structuredClone(parsedJson) - -// Right — JSON round-trip: -const copy = JSON.parse(JSON.stringify(parsedJson)) - -// Right — primordial-safe form for socket-lib internals: -import { JSONParse, JSONStringify } from '@socketsecurity/lib/primordials/json' -const copy = JSONParse(JSONStringify(parsedJson)) -``` - -## When `structuredClone` IS the right tool - -The value genuinely contains shapes JSON can't round-trip: - -- `Date` instances (JSON → ISO string, not Date) -- `Map` / `Set` (JSON → `{}` / `[]`) -- `RegExp` (JSON → `{}`) -- `ArrayBuffer` / typed arrays (JSON → `{}` / array of numbers) -- `Error` instances (JSON → `{}`) -- Circular references (JSON throws) - -For those, opt back in per-line with a rationale: - -```ts -// oxlint-disable-next-line socket/no-structured-clone-prefer-json -- value contains Date / Map; JSON round-trip would corrupt. -const copy = structuredClone(value) -``` - -## What's enforced - -- Any line containing `structuredClone(` inside a code file - (`.ts` / `.mts` / `.cts` / `.js` / `.mjs` / `.cjs`). -- The immediately-preceding line must contain - `oxlint-disable-next-line socket/no-structured-clone-prefer-json`. -- Lines marked `// socket-hook: allow structured-clone` are also - exempt for one-off pre-rule legacy cases. - -## What's exempt - -- Declaration files (`.d.ts`, `.d.mts`). -- Comment lines that happen to mention `structuredClone` (docstrings, - rationale comments). -- Markdown, JSON, YAML, and any non-code file. - -## Override marker - -For a legitimate one-off: - -```ts -const copy = structuredClone(value) // socket-hook: allow structured-clone -``` - -Don't reach for this — add the `oxlint-disable-next-line` with a -rationale instead, so the lint rule keeps the per-callsite gate. - -## Bypass phrase - -If the user genuinely needs to bypass the whole hook for one session, -they must type `Allow no-structured-clone-prefer-json bypass` -verbatim in a recent user turn. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/no-structured-clone-prefer-json-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This hook lives in `socket-wheelhouse/template/.claude/hooks/no-structured-clone-prefer-json-guard` -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts b/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts deleted file mode 100644 index 0394c6f13..000000000 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-structured-clone-prefer-json-guard. -// -// Blocks Edit/Write tool calls that introduce a bare `structuredClone(...)` -// call into a `.ts` / `.mts` / `.cts` / `.js` / `.mjs` / `.cjs` file -// without the canonical per-line opt-out comment. The fleet rule: for -// the JSON-roundtrippable subset (anything coming from `JSON.parse`), -// `JSON.parse(JSON.stringify(x))` is 3-5x faster than `structuredClone` -// because it skips the full HTML structured-clone algorithm (type -// tagging, transferable handling, prototype preservation, cycle -// detection — none of which the JSON subset needs). -// -// When the value genuinely needs `Date` / `Map` / `Set` / `RegExp` / -// `ArrayBuffer` / typed-array preservation, opt back in with: -// -// // oxlint-disable-next-line socket/no-structured-clone-prefer-json -- -// const copy = structuredClone(value) -// -// What's enforced: -// - Any `structuredClone(...)` CALL EXPRESSION (AST-parsed via the -// vendored acorn-wasm in `_shared/acorn/`). Member-call methods -// (`obj.structuredClone(...)`) are correctly NOT flagged because -// they're MemberExpression nodes, not bare Identifier calls. -// - String-literal mentions, comment mentions, and TypeScript type -// references are skipped — they're not CallExpression nodes. -// - The IMMEDIATELY-PRECEDING line must contain -// `oxlint-disable-next-line socket/no-structured-clone-prefer-json`. -// - Lines marked `// socket-hook: allow structured-clone` are also -// exempt for one-off legitimate cases. -// -// Bypass phrase: `Allow no-structured-clone-prefer-json bypass`. -// -// Fragment tolerance: Edit's `new_string` is a snippet that may not -// parse standalone. `tryParse` returns undefined on parse failure; -// `findBareCallsTo` returns an empty array. Hook stays fail-open on -// any parser issue. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import process from 'node:process' - -import { findBareCallsTo } from '../_shared/acorn/index.mts' - -const ALLOW_MARKER = '// socket-hook: allow structured-clone' - -// File extensions where the rule applies. Markdown / JSON / YAML / -// generated `.d.ts` etc. are exempt. -const APPLICABLE_EXTS = new Set(['.cjs', '.cts', '.js', '.mjs', '.mts', '.ts']) - -/** - * Apply the secondary per-line allow marker filter. The AST helper already - * strips calls preceded by an `oxlint-disable-next-line` comment; this catches - * the older `// socket-hook: allow structured-clone` shape (same-line or - * preceding-line). - */ -export function applyAllowMarkerFilter( - source: string, - candidates: Array<{ line: number; text: string }>, -): Offense[] { - const lines = source.split('\n') - const out: Offense[] = [] - for (let i = 0, { length } = candidates; i < length; i += 1) { - const c = candidates[i]! - const line = lines[c.line - 1] ?? '' - if (line.includes(ALLOW_MARKER)) { - continue - } - const prev = c.line >= 2 ? (lines[c.line - 2] ?? '') : '' - if (prev.includes(ALLOW_MARKER)) { - continue - } - out.push({ line: c.line, text: c.text }) - } - return out -} - -interface Hook { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -interface Offense { - line: number - text: string -} - -export function isApplicable(filePath: string): boolean { - if (filePath.endsWith('.d.ts') || filePath.endsWith('.d.mts')) { - return false - } - const dot = filePath.lastIndexOf('.') - if (dot === -1) { - return false - } - const ext = filePath.slice(dot) - return APPLICABLE_EXTS.has(ext) -} - -function main(): void { - let stdin = '' - process.stdin.on('data', (chunk: Buffer) => { - stdin += chunk.toString() - }) - process.stdin.on('end', () => { - try { - let payload: Hook - try { - payload = JSON.parse(stdin) as Hook - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !isApplicable(filePath)) { - process.exit(0) - } - const proposed = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - const candidates = findBareCallsTo(proposed, 'structuredClone', { - oxlintRuleName: 'socket/no-structured-clone-prefer-json', - }) - const offenses = applyAllowMarkerFilter(proposed, candidates) - if (offenses.length === 0) { - process.exit(0) - } - process.stderr.write( - `[no-structured-clone-prefer-json-guard] refusing edit: ` + - `${offenses.length} bare \`structuredClone(\` call${offenses.length === 1 ? '' : 's'} ` + - `without the canonical per-line opt-out comment:\n` + - offenses.map(o => ` line ${o.line}: ${o.text}`).join('\n') + - '\n\n' + - 'For JSON-roundtrippable data (anything from `JSON.parse`), use\n' + - '`JSON.parse(JSON.stringify(x))` or `JSONParse(JSONStringify(x))` from\n' + - '`@socketsecurity/lib/primordials/json`. It is 3-5x faster than\n' + - '`structuredClone(...)` because it skips the full HTML structured-clone\n' + - 'algorithm (type tagging, transferable handling, prototype preservation,\n' + - 'cycle detection — none of which the JSON subset needs).\n' + - '\n' + - 'When the value genuinely contains Date / Map / Set / RegExp /\n' + - 'ArrayBuffer / typed-array shapes that JSON would corrupt, opt back\n' + - 'in with a per-line disable + rationale:\n' + - '\n' + - ' // oxlint-disable-next-line socket/no-structured-clone-prefer-json -- \n' + - ' const copy = structuredClone(value)\n' + - '\n' + - 'One-off override: append `// socket-hook: allow structured-clone`\n' + - 'to the line. Whole-session bypass requires the user to type\n' + - '`Allow no-structured-clone-prefer-json bypass` verbatim.\n', - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-structured-clone-prefer-json-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/package.json b/.claude/hooks/no-structured-clone-prefer-json-guard/package.json deleted file mode 100644 index 25e447269..000000000 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-no-structured-clone-prefer-json-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts b/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts deleted file mode 100644 index cbc133e3d..000000000 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts +++ /dev/null @@ -1,149 +0,0 @@ -// Tests for no-structured-clone-prefer-json-guard. - -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { describe, test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(payload: object): Promise { - return new Promise((resolve, reject) => { - const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('close', code => { - resolve({ code: code ?? 0, stderr }) - }) - child.stdin!.write(JSON.stringify(payload)) - child.stdin!.end() - }) -} - -const BARE_USE = `export function clone(v: unknown) { - return structuredClone(v) -} -` - -const WITH_DISABLE = `export function clone(v: unknown) { - // oxlint-disable-next-line socket/no-structured-clone-prefer-json -- value contains Date instances; JSON would corrupt. - return structuredClone(v) -} -` - -const WITH_HOOK_ALLOW = `export function clone(v: unknown) { - return structuredClone(v) // socket-hook: allow structured-clone -} -` - -// Member-access call on a user object — `o.structuredClone()` must NOT -// trigger the hook. The hook's regex uses a negative-lookbehind to skip -// `.structuredClone(` shapes. -const MEMBER_CALL = `export function clone(o: any) { - return o.structuredClone() -} -` - -const COMMENT_ONLY = `// docstring mentioning structuredClone(x) but not calling it -export const x = 1 -` - -describe('no-structured-clone-prefer-json-guard', () => { - test('blocks bare structuredClone call', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.ts', content: BARE_USE }, - }) - assert.equal(result.code, 2) - assert.match(result.stderr, /structuredClone/) - assert.match(result.stderr, /JSON\.parse/) - }) - - test('passes when oxlint-disable-next-line comment is present', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.ts', content: WITH_DISABLE }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('passes when socket-hook allow marker is present', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.ts', content: WITH_HOOK_ALLOW }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores member-call user methods named structuredClone', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.ts', content: MEMBER_CALL }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores comment-only references', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.ts', content: COMMENT_ONLY }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores non-code files', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.md', content: BARE_USE }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores .d.ts declaration files', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/example.d.ts', content: BARE_USE }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores non-Edit/Write tool calls', async () => { - const result = await runHook({ - tool_name: 'Read', - tool_input: { file_path: '/tmp/example.ts', content: BARE_USE }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('fails open on malformed payload', async () => { - const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) - let exitCode = 0 - child.stdin!.write('not-json') - child.stdin!.end() - await new Promise(resolve => { - child.process.on('close', code => { - exitCode = code ?? 0 - resolve() - }) - }) - assert.equal(exitCode, 0) - }) -}) diff --git a/.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json b/.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-token-in-dotenv-guard/README.md b/.claude/hooks/no-token-in-dotenv-guard/README.md deleted file mode 100644 index 4b37ec695..000000000 --- a/.claude/hooks/no-token-in-dotenv-guard/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# no-token-in-dotenv-guard - -`PreToolUse(Edit|Write)` blocker that refuses writing a real API token / secret into a `.env` / `.env.local` / `.env.` / `.envrc` dotfile. - -## Why - -Dotfiles leak. They: - -- Get accidentally committed despite `.gitignore` (one careless `git add -A` and the file's in history). -- Get read by every dev tool that walks the project dir. -- Get swept by file-indexer / backup / log-scraper clients (Spotlight, Time Machine, Dropbox, etc.). -- End up in shell-history dotfile dumps that the operator shares. - -Tokens belong in **env vars** (CI) or the **OS keychain** (dev local). Never in a file. - -## Detection - -A hit requires all of: - -1. **File path** ends in `.env`, `.env.local`, `.env.development`, `.env.production`, `.env.`, or `.envrc`. -2. **A line** of the form `=` where `` matches either a known token-bearing name (sourced from [`_shared/token-patterns.mts`](../_shared/token-patterns.mts)) or the generic `*_(?:TOKEN|KEY|SECRET)` suffix shape. -3. **The value is non-empty** and isn't a known placeholder (``, `xxx`, `TODO`, `REPLACE-ME`, `${SECRET}`, `$(...)`). - -The shared catalog covers Socket fleet, LLM providers (Anthropic, OpenAI, Gemini, etc.), VCS (GitHub, GitLab), product tracking (Linear, Notion, Jira, Asana, Trello), chat (Slack, Discord, Telegram, Twilio), cloud (AWS, GCP, Azure, DO, Cloudflare, Fly, Heroku), package registries, payments (Stripe, Square, PayPal), email (SendGrid, Mailgun, etc.), and observability (Datadog, Sentry, etc.). - -## Bypass - -`Allow dotenv-token bypass` in a recent user turn. Use case: seeding a test fixture's `.env` with a known-junk token that's structurally valid but not authoritative. - -## Source of truth - -The rule lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Token hygiene". This hook enforces it at edit time alongside [`token-guard`](../token-guard/) (which enforces the same rule at Bash time). diff --git a/.claude/hooks/no-token-in-dotenv-guard/index.mts b/.claude/hooks/no-token-in-dotenv-guard/index.mts deleted file mode 100644 index fb1d77f40..000000000 --- a/.claude/hooks/no-token-in-dotenv-guard/index.mts +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-token-in-dotenv-guard. -// -// Blocks Edit/Write that would put a Socket API token (or any other -// long-lived secret pattern) into a `.env` / `.env.local` / similar -// dotfile. Tokens belong in the OS keychain (macOS Keychain / Linux -// libsecret / Windows CredentialManager — wired via setup-security- -// tools/install.mts) or in CI env, not in files that: -// -// - Get accidentally committed (despite .gitignore, on dirty repos). -// - Get read by every dev tool that walks the project dir. -// - End up in shell-history dotfile dumps. -// - Get swept by log-scraper / file-indexer tools (Spotlight, -// Apple Backup, file-sync clients). -// -// Detection: -// -// - File path ends with `.env`, `.env.local`, `.env.development`, -// `.env.production`, `.env.`, `.envrc`, etc. -// - Content has a line like `=` where KEY matches a -// known token-bearing name (SOCKET_API_TOKEN, SOCKET_API_KEY, -// SOCKET_CLI_API_TOKEN, SOCKET_SECURITY_API_TOKEN, plus the -// generic GITHUB_TOKEN / OPENAI_API_KEY / ANTHROPIC_API_KEY -// patterns — same shape, same leak). -// - The value is non-empty (a `KEY=` empty placeholder is a -// template scaffold, not a leak). -// - The value isn't an obvious placeholder (``, -// `xxx`, `TODO`, `replace-me`, `${SECRET}`, `$(...)`). -// -// Bypass: `Allow dotenv-token bypass` in a recent user turn. The -// canonical phrase tells the assistant the operator has a specific -// reason (e.g. seeding a test fixture's `.env` with a known-junk -// token that's structurally valid but not authoritative). -// -// Exit codes: -// 0 — pass. -// 2 — block. -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import path from 'node:path' -import process from 'node:process' - -import { - GENERIC_TOKEN_SUFFIX_RE, - isTokenKey, -} from '../_shared/token-patterns.mts' -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -// Dotfile shapes that carry env-style KEY=VALUE content. -const DOTENV_BASENAME_RE = /^\.env(?:\..+)?$|^\.envrc$/ - -// Token-bearing key names live in `_shared/token-patterns.mts` so -// every hook that scans for secret leaks (this one + token-guard) -// shares one catalog. We use both the named-vendor list and the -// generic-suffix fallback here because a dotenv file is the worst -// place for ANY shape of secret — false positives are acceptable. - -// Placeholders that mean "the human will fill this in" — these -// don't trip the guard because they're scaffold content, not real -// secrets. Tight allowlist; anything else fires. -const PLACEHOLDER_RE = - /^(?:|<[^>]+>|x{3,}|TODO|REPLACE[_-]?ME|your[_-]?token|your[_-]?key|\$\{[A-Z_][A-Z0-9_]*\}|\$\([^)]+\))$/i - -const BYPASS_PHRASE = 'Allow dotenv-token bypass' - -/** - * Scan a dotenv body for `=` patterns. Returns one hit - * per offending line so the error message can name them all (the operator might - * have multiple leaks in one paste). - */ -export function findTokenLeaks(content: string): Hit[] { - const hits: Hit[] = [] - const lines = content.split('\n') - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - continue - } - const eqIdx = trimmed.indexOf('=') - if (eqIdx < 0) { - continue - } - // Optional `export ` prefix per POSIX shells. - const rawKey = trimmed - .slice(0, eqIdx) - .trim() - .replace(/^export\s+/, '') - if (!isLeakyTokenKey(rawKey)) { - continue - } - const rawValue = trimmed.slice(eqIdx + 1) - if (isPlaceholder(rawValue)) { - continue - } - hits.push({ - key: rawKey, - line: i + 1, - snippet: trimmed.length > 80 ? trimmed.slice(0, 77) + '…' : trimmed, - }) - } - return hits -} - -interface Hit { - readonly line: number - readonly key: string - readonly snippet: string -} - -export function isDotenvPath(filePath: string): boolean { - return DOTENV_BASENAME_RE.test(path.basename(filePath)) -} - -/** - * Match either a known token-bearing vendor key OR a generic - * `_(?:TOKEN|KEY|SECRET)` suffix. A dotenv is the most leak-prone place a - * secret can live, so both passes apply here even though elsewhere - * (token-guard) we prefer the named-vendor list alone. - */ -export function isLeakyTokenKey(key: string): boolean { - return isTokenKey(key) || GENERIC_TOKEN_SUFFIX_RE.test(key) -} - -export function isPlaceholder(value: string): boolean { - const stripped = value.replace(/^["']|["']$/g, '').trim() - return PLACEHOLDER_RE.test(stripped) -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath || !isDotenvPath(filePath)) { - process.exit(0) - } - const content = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!content) { - process.exit(0) - } - const hits = findTokenLeaks(content) - if (hits.length === 0) { - process.exit(0) - } - // Bypass check. - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - process.exit(0) - } - const lines: string[] = [] - lines.push( - '[no-token-in-dotenv-guard] Blocked: token-bearing key in dotenv.', - ) - lines.push(` File: ${filePath}`) - lines.push('') - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push(` Line ${h.line}: ${h.snippet}`) - lines.push(` Key: ${h.key}`) - } - lines.push('') - lines.push( - ' Dotfiles leak — .env / .env.local accidentally get committed,', - ) - lines.push(' read by every dev tool that walks the project dir, swept by') - lines.push(" log-scraper / file-indexer / backup clients. Tokens don't") - lines.push(' belong here.') - lines.push('') - lines.push(' Right places to store a Socket API token:') - lines.push( - ' - OS keychain (canonical): run `node .claude/hooks/' + - 'setup-security-tools/install.mts` — it prompts securely and persists', - ) - lines.push( - ' to macOS Keychain / Linux libsecret / Windows CredentialManager.', - ) - lines.push( - ' - CI env: set as a secret in your CI provider, not in a file.', - ) - lines.push('') - lines.push( - ' Bypass (e.g. seeding a test fixture with a known-junk value):', - ) - lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) - } catch (e) { - process.stderr.write( - `[no-token-in-dotenv-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/no-token-in-dotenv-guard/package.json b/.claude/hooks/no-token-in-dotenv-guard/package.json deleted file mode 100644 index 42ec26481..000000000 --- a/.claude/hooks/no-token-in-dotenv-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-token-in-dotenv-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts b/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts deleted file mode 100644 index d8a819b3b..000000000 --- a/.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts +++ /dev/null @@ -1,254 +0,0 @@ -// node --test specs for the no-token-in-dotenv-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tools pass through', async () => { - const result = await runHook({ - tool_input: { command: 'echo SOCKET_API_TOKEN=abc123' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-dotenv files pass through (even with token-like content)', async () => { - for (const file_path of [ - '/x/docs/example.md', - '/x/config/secrets.json', - '/x/scripts/setup.sh', - ]) { - const result = await runHook({ - tool_input: { - file_path, - new_string: 'SOCKET_API_TOKEN=real-looking-token-value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, file_path) - } -}) - -test('blocks SOCKET_API_TOKEN in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: - 'NODE_ENV=development\nSOCKET_API_TOKEN=sktsec_abc123def456\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /SOCKET_API_TOKEN/) - assert.match(result.stderr, /OS keychain/) -}) - -test('blocks SOCKET_API_KEY (legacy) in .env.local', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env.local', - new_string: 'SOCKET_API_KEY=sktsec_legacy_value\n', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks ANTHROPIC_API_KEY in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'ANTHROPIC_API_KEY=sk-ant-real-key-value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /ANTHROPIC_API_KEY/) -}) - -test('blocks OPENAI_API_KEY in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'OPENAI_API_KEY=sk-real-openai-value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks LINEAR_API_KEY in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'LINEAR_API_KEY=lin_api_real_value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks NOTION_TOKEN in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'NOTION_TOKEN=secret_real_value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks GITHUB_TOKEN in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'GITHUB_TOKEN=ghp_real_token_value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('blocks generic *_API_TOKEN suffix in .env', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'CUSTOM_VENDOR_API_TOKEN=real-value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('allows empty token placeholder (scaffold)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'SOCKET_API_TOKEN=\nANTHROPIC_API_KEY=\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('allows placeholder', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'SOCKET_API_TOKEN=\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('allows xxx / TODO / REPLACE_ME placeholders', async () => { - for (const placeholder of [ - 'xxx', - 'XXX', - 'TODO', - 'REPLACE_ME', - 'REPLACE-ME', - 'your-key', - ]) { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: `SOCKET_API_TOKEN=${placeholder}\n`, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, placeholder) - } -}) - -test('allows ${VARNAME} substitution placeholder', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: 'SOCKET_API_TOKEN=${SOCKET_TOKEN_FROM_KEYCHAIN}\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('allows comments and unrelated keys', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: `# Configuration\nNODE_ENV=development\nPORT=3000\nDEBUG=true\nLOG_LEVEL=info\n`, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('handles export KEY=VALUE form', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.envrc', - new_string: 'export SOCKET_API_TOKEN=real-value\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('handles quoted values', async () => { - for (const quoted of ['"real-value"', "'real-value'"]) { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: `SOCKET_API_TOKEN=${quoted}\n`, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2, quoted) - } -}) - -test('multiple leaks in one file: all are surfaced', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/proj/.env', - new_string: - 'SOCKET_API_TOKEN=real-1\nGITHUB_TOKEN=real-2\nANTHROPIC_API_KEY=real-3\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /Line 1/) - assert.match(result.stderr, /Line 2/) - assert.match(result.stderr, /Line 3/) -}) diff --git a/.claude/hooks/no-token-in-dotenv-guard/tsconfig.json b/.claude/hooks/no-token-in-dotenv-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-token-in-dotenv-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/no-underscore-identifier-guard/README.md b/.claude/hooks/no-underscore-identifier-guard/README.md deleted file mode 100644 index 43c452119..000000000 --- a/.claude/hooks/no-underscore-identifier-guard/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# no-underscore-identifier-guard - -PreToolUse hook that blocks `Edit` / `Write` operations introducing a new -underscore-prefixed **identifier** (`_resetX`, `_internal`, `_cache`, etc.). - -## Why - -Privacy in TypeScript is handled by module boundaries (not exporting) or by -the `_internal/` _directory_ pattern — not by leading underscores on symbol -names. The underscore-as-internal-marker convention is borrowed from other -languages where it has runtime meaning; in TS it's purely decorative and -adds noise to `git blame` and IDE autocomplete. - -## What's banned - -| Form | Example | -| ---------- | -------------------------- | -| Variable | `const _cache = new Map()` | -| Function | `function _doResolve() {}` | -| Class | `class _Helper {}` | -| Interface | `interface _Options {}` | -| Type alias | `type _Internal = ...` | -| Re-export | `export { _foo }` | - -## What's allowed - -- **`_internal/` directories** — the canonical way to signal module-private - files. The rule is about identifiers inside files, not folder layout. -- **Bare `_` throwaway** — `for (const _ of arr)`, destructuring rest, etc. -- **Generated output** under `dist/` / `build/` / `node_modules/`. -- **Bypass:** type `Allow underscore-identifier bypass` verbatim in a recent - user turn. - -## See also - -- CLAUDE.md → "No underscore-prefixed identifiers" -- `.config/oxlint-plugin/rules/no-underscore-identifier.mts` (commit-time - partner of this edit-time hook) diff --git a/.claude/hooks/no-underscore-identifier-guard/index.mts b/.claude/hooks/no-underscore-identifier-guard/index.mts deleted file mode 100644 index 92ff414ec..000000000 --- a/.claude/hooks/no-underscore-identifier-guard/index.mts +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — no-underscore-identifier-guard. -// -// Blocks Edit/Write tool calls that introduce a new underscore-prefixed -// *identifier* (function, variable, type, export). Privacy in TypeScript -// is handled by module boundaries (not exporting) or by `_internal/` -// *directory* layout — not by leading underscores on symbol names. The -// underscore-as-internal-marker convention from other languages adds -// noise without enforcement: TS doesn't treat `_foo` as private, so -// the underscore is decorative. -// -// Banned identifier shapes (recognized at edit time): -// const _foo = ... -// let _foo = ... -// var _foo = ... -// function _foo(...) -// class _Foo {...} -// interface _Foo {...} -// type _Foo = ... -// export function _foo(...) -// export const _foo = ... -// export { _foo } -// -// Allowed (passes through): -// - `_internal/` directory paths — the canonical way to signal -// module-private files. The rule is about identifiers inside -// files, not folder layout. -// - `_` as a single-character throwaway (`for (const _ of arr)`, -// destructuring `({ a: _, ...rest })`) — universally understood -// "I don't care about this value." -// - `_$$_` / `_$` style names from generated code (rollup, swc -// temporaries) inside files under `dist/` or `build/`. -// - Bypass phrase `Allow underscore-identifier bypass` typed -// verbatim in a recent user turn. -// -// Reads PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Exit codes: -// 0 — pass. -// 2 — block (at least one banned identifier found). -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import process from 'node:process' - -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -// Match declarations that introduce a leading-underscore identifier. -// We don't try to AST-parse; the regex set covers the surface forms -// that show up in TS/JS files in practice. False positives are tolerable -// here (we'd rather catch + show the line than miss it), and the -// allowlist covers the canonical exceptions. -// -// Each regex captures the offending identifier in group 1 for the -// error message. We intentionally require at least one alpha char -// AFTER the underscore — bare `_` is allowed (throwaway). -const BANNED_DECL_PATTERNS: readonly RegExp[] = [ - // const/let/var _foo - /\b(?:const|let|var)\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, - // function _foo / async function _foo - /\b(?:async\s+)?function\s*\*?\s+(_[A-Za-z][A-Za-z0-9_]*)\s*\(/g, - // class _Foo - /\bclass\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, - // interface _Foo - /\binterface\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, - // type _Foo = - /\btype\s+(_[A-Za-z][A-Za-z0-9_]*)\s*[=<]/g, - // export { _foo, ... } - /\bexport\s*\{[^}]*?\b(_[A-Za-z][A-Za-z0-9_]*)\b/g, -] - -const BYPASS_PHRASE = 'Allow underscore-identifier bypass' - -export function findBannedIdentifiers(text: string): Finding[] { - const findings: Finding[] = [] - const lines = text.split('\n') - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - for (let i = 0, { length } = BANNED_DECL_PATTERNS; i < length; i += 1) { - const pattern = BANNED_DECL_PATTERNS[i]! - pattern.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = pattern.exec(line)) !== null) { - findings.push({ - line: i + 1, - identifier: match[1]!, - text: line.trimEnd(), - }) - } - } - } - return findings -} - -export function hasRecentBypass(transcriptPath: string | undefined): boolean { - // Delegates to the shared transcript reader. Reads the JSONL the harness - // points at; `normalizeBypassText` handles hyphen/em-dash/whitespace - // normalization. Previous version checked process.env['CLAUDE_RECENT_USER_TURNS'], - // which no harness sets — bypass channel was effectively dead. - return bypassPhrasePresent(transcriptPath, BYPASS_PHRASE) -} - -export function isGeneratedPath(filePath: string): boolean { - return ( - filePath.includes('/dist/') || - filePath.includes('/build/') || - filePath.includes('/node_modules/') - ) -} - -interface Finding { - readonly line: number - readonly identifier: string - readonly text: string -} - -export function isInternalDirPath(filePath: string): boolean { - return filePath.includes('/_internal/') -} - -// Hook/lint test files and oxlint-plugin rule files legitimately contain -// banned identifier *strings* as fixture data. Exempt them so the rule -// can have its own tests without bypass phrases. -export function isPluginOrHookTestPath(filePath: string): boolean { - return ( - filePath.includes('/.claude/hooks/no-underscore-identifier-guard/') || - filePath.includes( - '/.config/oxlint-plugin/rules/no-underscore-identifier.', - ) || - filePath.includes('/.config/oxlint-plugin/test/no-underscore-identifier') - ) -} - -export async function readStdin(): Promise { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) - } - return Buffer.concat(chunks).toString('utf8') -} - -async function main(): Promise { - let payload: ToolInput - try { - const raw = await readStdin() - payload = JSON.parse(raw) as ToolInput - } catch (err) { - // Malformed payload — fail open. - process.stderr.write( - `no-underscore-identifier-guard: payload parse failed (${(err as Error).message})\n`, - ) - process.exit(0) - } - - const toolName = payload.tool_name - if (toolName !== 'Edit' && toolName !== 'Write') { - process.exit(0) - } - - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath) { - process.exit(0) - } - - // Allowlist: _internal/ dirs, generated output, this rule's own - // test/lint fixtures. - if ( - isInternalDirPath(filePath) || - isGeneratedPath(filePath) || - isPluginOrHookTestPath(filePath) - ) { - process.exit(0) - } - - // Only police TS/JS source. - if (!/\.(?:c|m)?[jt]sx?$/.test(filePath)) { - process.exit(0) - } - - const text = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - if (!text) { - process.exit(0) - } - - const findings = findBannedIdentifiers(text) - if (findings.length === 0) { - process.exit(0) - } - - if (hasRecentBypass(payload.transcript_path)) { - process.stderr.write( - `no-underscore-identifier-guard: ${findings.length} underscore identifier(s) — bypassed via "${BYPASS_PHRASE}"\n`, - ) - process.exit(0) - } - - const lines = findings - .map(f => ` ${filePath}:${f.line} ${f.identifier}\n ${f.text}`) - .join('\n') - process.stderr.write( - `no-underscore-identifier-guard: refusing to introduce underscore-prefixed identifier(s).\n` + - `\n` + - `${lines}\n` + - `\n` + - `Drop the leading underscore. Privacy in TypeScript is handled by:\n` + - ` - not exporting the symbol (module boundary), or\n` + - ` - placing the file under a "_internal/" directory.\n` + - `\n` + - `Bypass: type "${BYPASS_PHRASE}" in a recent message.\n`, - ) - process.exit(2) -} - -main().catch((err: unknown) => { - process.stderr.write( - `no-underscore-identifier-guard: unexpected error (${(err as Error).message})\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/no-underscore-identifier-guard/package.json b/.claude/hooks/no-underscore-identifier-guard/package.json deleted file mode 100644 index fd53068d7..000000000 --- a/.claude/hooks/no-underscore-identifier-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-no-underscore-identifier-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts b/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts deleted file mode 100644 index a396e9617..000000000 --- a/.claude/hooks/no-underscore-identifier-guard/test/index.test.mts +++ /dev/null @@ -1,340 +0,0 @@ -// node --test specs for the no-underscore-identifier-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const F = '/Users/x/projects/foo/src/mod.ts' - -// ─── Pass-through cases ────────────────────────────────────────── - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('missing file_path passes through', async () => { - const result = await runHook({ - tool_input: { new_string: 'const _foo = 1' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-source extensions pass through (md, json, txt)', async () => { - for (const p of [ - '/Users/x/projects/foo/README.md', - '/Users/x/projects/foo/package.json', - '/Users/x/projects/foo/notes.txt', - ]) { - const result = await runHook({ - tool_input: { content: 'const _x = 1', file_path: p }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, p) - } -}) - -test('TS/JS extensions are policed (ts/tsx/js/jsx/mts/cts)', async () => { - for (const ext of ['ts', 'tsx', 'js', 'jsx', 'mts', 'cts']) { - const result = await runHook({ - tool_input: { - content: 'const _foo = 1', - file_path: `/Users/x/projects/foo/src/mod.${ext}`, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2, `${ext} should be policed`) - } -}) - -// ─── Allowlist cases ───────────────────────────────────────────── - -test('_internal/ directory passes through', async () => { - const result = await runHook({ - tool_input: { - content: 'const _resolutionCache = new Map()', - file_path: '/Users/x/projects/foo/src/external-tools/_internal/cache.ts', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('dist/ generated paths pass through', async () => { - const result = await runHook({ - tool_input: { - content: 'const _temp = 1', - file_path: '/Users/x/projects/foo/dist/bundle.js', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('build/ generated paths pass through', async () => { - const result = await runHook({ - tool_input: { - content: 'const _temp = 1', - file_path: '/Users/x/projects/foo/build/out.js', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('node_modules paths pass through', async () => { - const result = await runHook({ - tool_input: { - content: 'const _vendored = 1', - file_path: '/Users/x/projects/foo/node_modules/some-dep/index.js', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('bare _ as throwaway is allowed', async () => { - const result = await runHook({ - tool_input: { - content: 'for (const _ of arr) { count++ }', - file_path: F, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('destructuring rest with _ as ignore is allowed', async () => { - const result = await runHook({ - tool_input: { - content: 'const { foo, ...rest } = obj\nconst { a: _, b } = pair', - file_path: F, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('regular identifiers without underscore prefix pass', async () => { - const result = await runHook({ - tool_input: { - content: ` - const resolutionCache = new Map() - function doResolveX() {} - export class Helper {} - export interface Options {} - export type Internal = string - `, - file_path: F, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -// ─── Banned cases ──────────────────────────────────────────────── - -test('const _foo is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'const _foo = 1', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /_foo/) -}) - -test('let _bar is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'let _bar = 1', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('var _baz is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'var _baz = 1', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('function _doFoo() is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'function _doFoo() {}', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /_doFoo/) -}) - -test('async function _doFoo() is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'async function _doFoo() {}', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('export function _resetX() is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'export function _resetX() {}', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /_resetX/) -}) - -test('class _Helper is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'class _Helper {}', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('interface _Options is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'interface _Options {}', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('type _Internal = ... is blocked', async () => { - const result = await runHook({ - tool_input: { content: 'type _Internal = string', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('export { _foo } re-export is blocked', async () => { - const result = await runHook({ - tool_input: { - content: "import { _foo } from 'mod'\nexport { _foo }", - file_path: F, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('multiple offenders are all listed in the error', async () => { - const result = await runHook({ - tool_input: { - content: ` - const _cache = new Map() - function _doWork() {} - class _Helper {} - `, - file_path: F, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /_cache/) - assert.match(result.stderr, /_doWork/) - assert.match(result.stderr, /_Helper/) -}) - -test('error message points at the file and line', async () => { - const result = await runHook({ - tool_input: { content: 'const _foo = 1', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /mod\.ts:1/) -}) - -test('error message mentions _internal/ exception + bypass phrase', async () => { - const result = await runHook({ - tool_input: { content: 'const _foo = 1', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /_internal\//) - assert.match(result.stderr, /Allow underscore-identifier bypass/) -}) - -// ─── Bypass case ───────────────────────────────────────────────── - -test('bypass phrase in CLAUDE_RECENT_USER_TURNS env allows the edit', async () => { - const child = spawn(process.execPath, [HOOK], { - stdio: 'pipe', - env: { - ...process.env, - CLAUDE_RECENT_USER_TURNS: 'Allow underscore-identifier bypass', - }, - }) - child.stdin!.end( - JSON.stringify({ - tool_input: { content: 'const _foo = 1', file_path: F }, - tool_name: 'Write', - }), - ) - const code = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) -}) - -// ─── Edge cases ────────────────────────────────────────────────── - -test('malformed JSON fails open (exit 0)', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not-json{') - const code = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) -}) - -test('empty content passes through', async () => { - const result = await runHook({ - tool_input: { content: '', file_path: F }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('Edit with new_string (not content) is checked', async () => { - const result = await runHook({ - tool_input: { file_path: F, new_string: 'const _foo = 1' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) diff --git a/.claude/hooks/no-underscore-identifier-guard/tsconfig.json b/.claude/hooks/no-underscore-identifier-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/no-underscore-identifier-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/node-modules-staging-guard/README.md b/.claude/hooks/node-modules-staging-guard/README.md deleted file mode 100644 index 4ca4a6a38..000000000 --- a/.claude/hooks/node-modules-staging-guard/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# node-modules-staging-guard - -PreToolUse Bash hook that blocks `git add -f` / `git add --force` for -paths containing `node_modules/` or `package-lock.json` under -`.claude/hooks/*/` or `.claude/skills/*/`. - -## Why - -`-f` overrides `.gitignore`. Past incident: an agent ran -`git add -f .claude/hooks/check-new-deps/node_modules/` to "fix" what -looked like a missing dir in a commit. The directory landed in 6 fleet -repos via cascade. Removing it required either a history rewrite -(`git filter-branch` / `git filter-repo`) + force-push, or living with -the bloat forever. Neither is acceptable. - -Each hook + skill ships with a small `package.json` (devDeps only). -Consumers run their own `pnpm install` to materialize `node_modules`. -Committing the dir is never the right answer. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------------------ | ------ | -| `git add -f .claude/hooks/foo/node_modules/` | yes | -| `git add --force packages/bar/node_modules/baz` | yes | -| `git add -f .claude/hooks/foo/package-lock.json` | yes | -| `git add -f some-other-gitignored-file` | no | -| `git add .claude/hooks/foo/index.mts` (no `-f`) | no | -| `git add node_modules/...` (no `-f` — gitignore catches it anyway) | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow node-modules-staging bypass - -Use sparingly. Legitimate force-stages of node_modules are vanishingly -rare; if you're tempted, you're probably about to do the bad thing. - -## Detection - -Tokenize the Bash command on whitespace + `&&` / `||` / `;` / `|`, -respect leading env-var assignments (`FOO=bar git add ...`), match -`git add ... -f` / `... --force`, then walk every path argument -checking for `/node_modules/` segments or -`.claude/{hooks,skills}//package-lock.json`. diff --git a/.claude/hooks/node-modules-staging-guard/index.mts b/.claude/hooks/node-modules-staging-guard/index.mts deleted file mode 100644 index 5b87967c3..000000000 --- a/.claude/hooks/node-modules-staging-guard/index.mts +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — node-modules-staging-guard. -// -// Blocks `git add -f` / `git add --force` invocations targeting paths -// that contain `/node_modules/` or that point at a `package-lock.json` -// under `.claude/hooks/*/` or `.claude/skills/*/`. Past incident: a -// cascading agent used `git add -f` to commit `.claude/hooks/check-new- -// deps/node_modules/` into 6 fleet repos. Removing it required force- -// push (which is itself a hazard) or filter-branch/filter-repo. -// -// The `-f` (force) flag exists for the rare case where a gitignored -// file legitimately needs to be staged. It should never be used for -// node_modules or hook/skill package-lock.json files — those are -// gitignored intentionally because each consumer runs its own install. -// -// Detection: parse the Bash command, look for `git add -f` (or -// `--force`), then check every path argument. If any path contains -// `node_modules/` (anywhere in the path) OR points at a -// `package-lock.json` under `.claude/hooks//` / -// `.claude/skills//`, block. -// -// Bypass: `Allow node-modules-staging bypass` typed verbatim in a recent -// user turn. Use sparingly — legitimate force-stages of node_modules -// are vanishingly rare. - -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow node-modules-staging bypass' - -// Tokenize the command on whitespace; split on `&&`/`||`/`;`/`|` so we -// don't merge chained commands. The git invocation may be wrapped by -// env-var assignments (`FOO=bar git add ...`). -export function findGitAddForceInvocations(command: string): string[][] { - const out: string[][] = [] - const segments = command.split(/(?:&&|\|\||;|\n)/) - for (let i = 0, { length } = segments; i < length; i += 1) { - const segment = segments[i]! - const tokens = segment.trim().split(/\s+/) - // `j` for the inner cursor — outer loop already owns `i`. - let j = 0 - while (j < tokens.length && tokens[j]!.includes('=')) { - j += 1 - } - if (tokens[j] !== 'git') { - continue - } - if (tokens[j + 1] !== 'add') { - continue - } - const rest = tokens.slice(j + 2) - const hasForce = rest.some(arg => arg === '--force' || arg === '-f') - if (!hasForce) { - continue - } - out.push(rest) - } - return out -} - -export function isForbiddenPath(arg: string): boolean { - // `-f` / `--force` are flag-only, not paths. - if (arg.startsWith('-')) { - return false - } - // Strip quotes. - const stripped = arg.replace(/^["']|["']$/g, '') - // Any `/node_modules/` segment OR a top-level `node_modules` / - // `node_modules/...`. - if ( - /(?:^|\/)node_modules(?:\/|$)/.test(stripped) || - /[\\]node_modules(?:[\\]|$)/.test(stripped) - ) { - return true - } - // `package-lock.json` under `.claude/hooks//` or - // `.claude/skills//`. - if ( - /(?:^|\/)\.claude\/(?:hooks|skills)\/[^/]+\/(?:package-lock\.json|pnpm-lock\.yaml)$/.test( - stripped, - ) - ) { - return true - } - return false -} - -async function main(): Promise { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!command) { - process.exit(0) - } - - const forced = findGitAddForceInvocations(command) - if (forced.length === 0) { - process.exit(0) - } - - const blockedArgs: string[] = [] - for (let i = 0, { length } = forced; i < length; i += 1) { - const restArgs = forced[i]! - for (let i = 0, { length } = restArgs; i < length; i += 1) { - const arg = restArgs[i]! - if (isForbiddenPath(arg)) { - blockedArgs.push(arg) - } - } - } - if (blockedArgs.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[node-modules-staging-guard] Blocked: `git add -f` of node_modules / hook lockfile', - '', - ' Forbidden paths in the command:', - ...blockedArgs.map(a => ` ${a}`), - '', - ' Past incident: a cascading agent committed', - ' `.claude/hooks/check-new-deps/node_modules/` into 6 fleet repos.', - ' Removing it required force-push (itself a hazard) or filter-branch.', - '', - ' `node_modules/` and hook `package-lock.json` files are gitignored', - ' INTENTIONALLY. Each consumer runs its own `pnpm install` against', - ' the package.json that did land in the commit.', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[node-modules-staging-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/node-modules-staging-guard/package.json b/.claude/hooks/node-modules-staging-guard/package.json deleted file mode 100644 index 4e6af34a0..000000000 --- a/.claude/hooks/node-modules-staging-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-node-modules-staging-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/node-modules-staging-guard/test/index.test.mts b/.claude/hooks/node-modules-staging-guard/test/index.test.mts deleted file mode 100644 index ac078eb5e..000000000 --- a/.claude/hooks/node-modules-staging-guard/test/index.test.mts +++ /dev/null @@ -1,118 +0,0 @@ -// node --test specs for the node-modules-staging-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record): Promise { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Bash passes', async () => { - const r = await runHook({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/foo' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('git add (no -f) passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git add .claude/hooks/foo/index.mts' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('git add -f of non-node_modules file passes', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git add -f dist/generated-but-ignored.json' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('git add -f node_modules path blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git add -f .claude/hooks/check-new-deps/node_modules/', - }, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('node_modules')) -}) - -test('git add --force node_modules path blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git add --force packages/foo/node_modules/some-pkg', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('git add -f hook package-lock.json blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git add -f .claude/hooks/foo/package-lock.json' }, - }) - assert.strictEqual(r.code, 2) -}) - -test('chained: legitimate add followed by force-add of node_modules blocked', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { - command: - 'git add src/foo.ts && git add -f .claude/hooks/bar/node_modules/', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase passes', async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'nm-stage-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow node-modules-staging bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git add -f .claude/hooks/foo/node_modules/' }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/node-modules-staging-guard/tsconfig.json b/.claude/hooks/node-modules-staging-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/node-modules-staging-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/overeager-staging-guard/index.mts b/.claude/hooks/overeager-staging-guard/index.mts deleted file mode 100644 index 03f4ef33c..000000000 --- a/.claude/hooks/overeager-staging-guard/index.mts +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — overeager-staging-guard. -// -// Catches the failure mode where an agent's `git commit` sweeps in -// files it didn't author — usually another Claude session's work -// that was already staged when this session opened the repo. Two -// enforcement layers: -// -// 1. BLOCK `git add -A` / `git add .` / `git add --all` / `git add -u` -// / `git add --update`. These sweep everything in the working -// tree into the index, which is hostile to parallel-session -// repos: another agent's unstaged edits get staged into your -// next commit. Per CLAUDE.md: "surgical `git add `. -// Never `-A` / `.`." -// -// 2. WARN on `git commit` when the index contains files the agent -// has NOT touched this session (via Edit / Write / `git add -// ` / `git rm `). Exits 0 — informational, not a -// block — but emits a stderr summary listing every unfamiliar -// staged file so the agent has a chance to spot parallel-session -// work before the commit goes through. -// -// Detection heuristic: list staged files, compare against tool- -// use history in the transcript. Files staged but never touched -// this session surface as suspicious entries. -// -// Both layers fail open on hook bugs (exit 0 + stderr log). -// -// Bypass: -// - `Allow add-all bypass` in a recent user turn (case-sensitive, -// exact match) — disables layer 1 for the next add. -// - `SOCKET_OVEREAGER_STAGING_GUARD_DISABLED=1` — disables both. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// "transcript_path": "/.../session.jsonl" } - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { commandsFor, findInvocation } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined -} - -const ENV_DISABLE = 'SOCKET_OVEREAGER_STAGING_GUARD_DISABLED' -const BYPASS_PHRASES = ['Allow add-all bypass'] as const - -export function addTouchedFromBash( - command: string, - touched: Set, -): void { - const segments = command.split(/(?:&&|\|\||;|\n)/) - for (let i = 0, { length } = segments; i < length; i += 1) { - const segment = segments[i]! - const tokens = segment.trim().split(/\s+/) - let j = 0 - while (j < tokens.length && tokens[j]!.includes('=')) { - j += 1 - } - if (tokens[j] !== 'git') { - continue - } - const verb = tokens[j + 1] - if (verb !== 'add' && verb !== 'mv' && verb !== 'rm') { - continue - } - // Everything after the verb that isn't a flag is a path. - for (const arg of tokens.slice(j + 2)) { - if (arg.startsWith('-')) { - continue - } - // Skip the "." / glob forms; those weren't surgical adds. - if (arg === '.') { - continue - } - touched.add(path.resolve(arg)) - } - } -} - -// Detects `git add` invocations that sweep the working tree. Parses -// the command with the shared shell tokenizer so chains, quoting, and -// leading env-var assignments (`GIT_AUTHOR_NAME=x git add …`) are all -// handled — and a quoted "git add ." inside a message can't false-fire. -// `git add ./path` (a legitimate surgical add of a dotfile) is NOT -// confused with `git add .` (the broad sweep) because the parser -// preserves the exact arg. -export function detectBroadGitAdd(command: string): string | undefined { - for (const c of commandsFor(command, 'git')) { - // The `add` subcommand can sit after global flags (`git -C x add .`), - // so scan all args rather than assuming position. - if (!c.args.includes('add')) { - continue - } - for (let k = 0, { length } = c.args; k < length; k += 1) { - const arg = c.args[k]! - if (arg === '--all' || arg === '-A') { - return `git add ${arg}` - } - if (arg === '--update' || arg === '-u') { - return `git add ${arg}` - } - if (arg === '.') { - return 'git add .' - } - } - } - return undefined -} - -export function getRepoDir(): string { - return process.env['CLAUDE_PROJECT_DIR'] || process.cwd() -} - -export function isGitCommit(command: string): boolean { - return findInvocation(command, { binary: 'git', subcommand: 'commit' }) -} - -export function listStagedFiles(repoDir: string): string[] { - const r = spawnSync('git', ['diff', '--cached', '--name-only'], { - cwd: repoDir, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - -// Read tool-use history from the transcript. Return the set of file -// paths the agent has Edit/Write'd, plus any `git rm ` targets -// the agent has staged this session. -export function readTouchedPaths( - transcriptPath: string | undefined, -): Set { - const touched = new Set() - if (!transcriptPath) { - return touched - } - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return touched - } - for (const line of raw.split('\n')) { - if (!line.trim()) { - continue - } - let entry: unknown - try { - entry = JSON.parse(line) - } catch { - continue - } - if (entry === null || typeof entry !== 'object') { - continue - } - const msg = (entry as { message?: unknown | undefined }).message - if (msg === null || typeof msg !== 'object') { - continue - } - const content = (msg as { content?: unknown | undefined }).content - if (!Array.isArray(content)) { - continue - } - for (let i = 0, { length } = content; i < length; i += 1) { - const part = content[i]! - if (part === null || typeof part !== 'object') { - continue - } - const toolName = (part as { name?: unknown | undefined }).name - const toolInput = (part as { input?: unknown | undefined }).input - if (typeof toolName !== 'string') { - continue - } - if (toolInput === null || typeof toolInput !== 'object') { - continue - } - const filePath = (toolInput as { file_path?: unknown | undefined }) - .file_path - if (typeof filePath === 'string' && filePath) { - // Edit / Write / Read carry file_path; only Edit and Write - // modify, but tracking Read'd-but-not-edited files as touched - // is harmless for this heuristic. - if (toolName === 'Edit' || toolName === 'Write') { - touched.add(path.resolve(filePath)) - } - } - // Bash commands with `git rm ` / `git add ` also - // count as touched. Parse the command tokens. - const command = (toolInput as { command?: unknown | undefined }).command - if (toolName === 'Bash' && typeof command === 'string') { - addTouchedFromBash(command, touched) - } - } - } - return touched -} - -async function main(): Promise { - if (process.env[ENV_DISABLE]) { - process.exit(0) - } - const raw = await readStdin() - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = ( - payload.tool_input as { command?: unknown | undefined } | undefined - )?.command - if (typeof command !== 'string' || !command.trim()) { - process.exit(0) - } - - const repoDir = getRepoDir() - const transcriptPath = payload.transcript_path - - // ── Layer 1: block `git add -A` / `.` / `-u` ───────────────────── - const broad = detectBroadGitAdd(command) - if (broad) { - // Fleet-sync sentinel: cascade scripts run `git add -u` inside a - // worktree they just created off origin/main — no parallel-session - // hazard because the worktree is empty otherwise. Same opt-in - // sentinel the no-revert-guard recognizes (`FLEET_SYNC=1` prefix). - if (/(?:^|\s)FLEET_SYNC\s*=\s*1\b/.test(command)) { - process.exit(0) - } - if ( - transcriptPath && - bypassPhrasePresent(transcriptPath, BYPASS_PHRASES, 3) - ) { - process.exit(0) - } - process.stderr.write( - [ - `[overeager-staging-guard] Blocked: ${broad}`, - '', - ' This sweeps the entire working tree into the index.', - " In a parallel-session repo, that pulls in another agent's", - ' unstaged edits and they get swept into your next commit.', - '', - ' Fix: stage by explicit path.', - ' git add path/to/file.ts path/to/other.ts', - '', - ' Bypass (only if you genuinely need a sweep):', - ' user types "Allow add-all bypass" in chat, then retry.', - ].join('\n') + '\n', - ) - process.exit(2) - } - - // ── Layer 2: warn on `git commit` if index has unfamiliar files ── - if (isGitCommit(command)) { - const staged = listStagedFiles(repoDir) - if (staged.length === 0) { - process.exit(0) - } - const touched = readTouchedPaths(transcriptPath) - const unfamiliar: string[] = [] - for (let i = 0, { length } = staged; i < length; i += 1) { - const f = staged[i]! - const abs = path.resolve(repoDir, f) - if (!touched.has(abs)) { - unfamiliar.push(f) - } - } - if (unfamiliar.length === 0) { - process.exit(0) - } - // Don't block — commits with pre-staged content can be legitimate. - // Just print a loud stderr warning so the agent inspects before - // proceeding (and humans reviewing the session can spot the slip). - process.stderr.write( - [ - '[overeager-staging-guard] ⚠ git commit about to sweep in files this session has not touched:', - '', - ...unfamiliar.slice(0, 20).map(f => ` ${f}`), - ...(unfamiliar.length > 20 - ? [` ... and ${unfamiliar.length - 20} more`] - : []), - '', - ' Likely cause: a parallel Claude session staged these. The', - ' commit will include them under your authorship.', - '', - ' If unintended, abort and run:', - ' git restore --staged # to drop one file', - ' git reset HEAD # to drop everything', - '', - ' If intended, proceed — this is informational, not a block.', - ].join('\n') + '\n', - ) - process.exit(0) - } - - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[overeager-staging-guard] hook bug — fail-open. ${e instanceof Error ? e.message : String(e)}\n`, - ) - process.exit(0) -}) diff --git a/.claude/hooks/overeager-staging-guard/package.json b/.claude/hooks/overeager-staging-guard/package.json deleted file mode 100644 index 6d10817d3..000000000 --- a/.claude/hooks/overeager-staging-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-overeager-staging-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/overeager-staging-guard/test/index.test.mts b/.claude/hooks/overeager-staging-guard/test/index.test.mts deleted file mode 100644 index 1fd9a97ee..000000000 --- a/.claude/hooks/overeager-staging-guard/test/index.test.mts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * @file Unit tests for overeager-staging-guard hook. Two layers under test: - * - * 1. Layer 1 — block `git add -A` / `.` / `-u` (exit 2). - * 2. Layer 2 — informational warning on `git commit` when index contains files - * not touched by this session (exit 0 + stderr). - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - readonly code: number - readonly stderr: string -} - -function runHook( - command: string, - options: { - cwd?: string | undefined - transcriptPath?: string | undefined - env?: Record | undefined - } = {}, -): RunResult { - const payload = { - tool_name: 'Bash', - tool_input: { command }, - transcript_path: options.transcriptPath, - } - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - env: { - ...process.env, - ...(options.cwd ? { CLAUDE_PROJECT_DIR: options.cwd } : {}), - ...(options.env ?? {}), - }, - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -function gitInit(repo: string): void { - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { - cwd: repo, - }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) -} - -function gitAdd(repo: string, files: string[]): void { - spawnSync('git', ['add', ...files], { cwd: repo }) -} - -function writeTranscript(entries: object[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'overeager-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync(transcriptPath, entries.map(e => JSON.stringify(e)).join('\n')) - return transcriptPath -} - -let tmpRepo: string - -beforeEach(() => { - tmpRepo = mkdtempSync(path.join(os.tmpdir(), 'overeager-repo-')) - gitInit(tmpRepo) -}) - -afterEach(() => { - rmSync(tmpRepo, { recursive: true, force: true }) -}) - -// ─── Layer 1: broad git-add blocking ────────────────────────────── - -test('blocks `git add -A`', () => { - const r = runHook('git add -A', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add -A/) - assert.match(r.stderr, /Blocked/) -}) - -test('blocks `git add --all`', () => { - const r = runHook('git add --all', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add --all/) -}) - -test('blocks `git add .`', () => { - const r = runHook('git add .', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add \./) -}) - -test('blocks `git add -u`', () => { - const r = runHook('git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add -u/) -}) - -test('blocks `git add --update`', () => { - const r = runHook('git add --update', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) - -test('blocks broad add chained after another command', () => { - const r = runHook('echo hi && git add -A && git commit -m x', { - cwd: tmpRepo, - }) - assert.equal(r.code, 2) -}) - -test('blocks broad add when env vars are set on the command', () => { - const r = runHook('GIT_AUTHOR_NAME=foo git add .', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) - -test('blocks `git -C path add .` (subcommand after a global flag)', () => { - const r = runHook(`git -C ${tmpRepo} add .`, { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /git add \./) -}) - -test('quoted "git add ." inside a message is NOT a broad add', () => { - // Regression: the parser distinguishes a real invocation from the - // same words sitting inside a quoted commit-message argument. - const r = runHook('git commit -m "stop using git add ."', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add path/to/file.ts`', () => { - const r = runHook('git add src/foo.ts', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add ./relative-path.ts` (not a broad sweep)', () => { - const r = runHook('git add ./src/foo.ts', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows `git add multiple specific files`', () => { - const r = runHook('git add src/a.ts src/b.ts test/c.test.ts', { - cwd: tmpRepo, - }) - assert.equal(r.code, 0) -}) - -test('allows `git commit -m`', () => { - const r = runHook('git commit -m "fix: thing"', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -test('allows non-git Bash commands', () => { - const r = runHook('ls -la', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('bypass: `Allow add-all bypass` in transcript allows broad add', () => { - const transcriptPath = writeTranscript([ - { - type: 'user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Allow add-all bypass' }], - }, - }, - ]) - const r = runHook('git add -A', { cwd: tmpRepo, transcriptPath }) - assert.equal(r.code, 0) -}) - -test('env disable short-circuits', () => { - const r = runHook('git add -A', { - cwd: tmpRepo, - env: { SOCKET_OVEREAGER_STAGING_GUARD_DISABLED: '1' }, - }) - assert.equal(r.code, 0) -}) - -// ─── Layer 2: warn on git commit with unfamiliar staged files ───── - -test('git commit with empty index passes silently', () => { - const r = runHook('git commit -m "x"', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git commit warns when index has files not touched this session', () => { - writeFileSync(path.join(tmpRepo, 'parallel.ts'), '// other agent') - gitAdd(tmpRepo, ['parallel.ts']) - // Empty transcript — agent touched nothing. - const transcriptPath = writeTranscript([]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - // Layer 2 is informational — exit 0 with stderr warning. - assert.equal(r.code, 0) - assert.match(r.stderr, /parallel\.ts/) - assert.match(r.stderr, /not touched/) -}) - -test('git commit silent when index files match transcript Edit history', () => { - const myFile = path.join(tmpRepo, 'mine.ts') - writeFileSync(myFile, '// mine') - gitAdd(tmpRepo, ['mine.ts']) - const transcriptPath = writeTranscript([ - { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - name: 'Edit', - input: { file_path: myFile }, - }, - ], - }, - }, - ]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('git commit silent when index files match transcript git-add history', () => { - const myFile = path.join(tmpRepo, 'mine.ts') - writeFileSync(myFile, '// mine') - gitAdd(tmpRepo, ['mine.ts']) - const transcriptPath = writeTranscript([ - { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - name: 'Bash', - input: { command: `git add ${myFile}` }, - }, - ], - }, - }, - ]) - const r = runHook('git commit -m "mine"', { - cwd: tmpRepo, - transcriptPath, - }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -// ─── Misc edge cases ────────────────────────────────────────────── - -test('non-Bash tool_name is ignored', () => { - const r = spawnSync('node', [HOOK], { - input: JSON.stringify({ - tool_name: 'Edit', - tool_input: { file_path: '/tmp/foo' }, - }), - }) - assert.equal(r.status, 0) -}) - -test('malformed payload is ignored (fail-open)', () => { - const r = spawnSync('node', [HOOK], { - input: 'not-json', - }) - assert.equal(r.status, 0) -}) - -test('empty command is ignored', () => { - const r = runHook('', { cwd: tmpRepo }) - assert.equal(r.code, 0) -}) - -// ─── FLEET_SYNC=1 sentinel ──────────────────────────────────────── - -test('FLEET_SYNC=1 allows `git add -u`', () => { - const r = runHook('FLEET_SYNC=1 git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('FLEET_SYNC=1 allows `git add -A`', () => { - const r = runHook('FLEET_SYNC=1 git add -A', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('FLEET_SYNC=1 allows `git add .`', () => { - const r = runHook('FLEET_SYNC=1 git add .', { cwd: tmpRepo }) - assert.equal(r.code, 0) - assert.equal(r.stderr, '') -}) - -test('no FLEET_SYNC: `git add -u` still blocked', () => { - const r = runHook('git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) - assert.match(r.stderr, /Blocked: git add -u/) -}) - -test('FLEET_SYNC=0 (explicit off): `git add -u` still blocked', () => { - const r = runHook('FLEET_SYNC=0 git add -u', { cwd: tmpRepo }) - assert.equal(r.code, 2) -}) diff --git a/.claude/hooks/overeager-staging-guard/tsconfig.json b/.claude/hooks/overeager-staging-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/overeager-staging-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/path-guard/README.md b/.claude/hooks/path-guard/README.md deleted file mode 100644 index bec03c11b..000000000 --- a/.claude/hooks/path-guard/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# path-guard - -A **Claude Code hook** that runs before `Edit` or `Write` tool calls -on `.mts` or `.cts` files and **blocks** edits that would build a -multi-segment build/output path inline. The fleet's rule, in one -sentence: - -> 1 path, 1 reference. Construct a path _once_ in a canonical -> `paths.mts` (or a build-infra helper); reference the computed value -> everywhere else. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, edit never happens). This one blocks. - -## Why this rule exists - -Build outputs typically nest deep — `build///out/Final/`. -If three different scripts all `path.join(...)` their own version of -that path, a refactor that changes the layout breaks one or two of -them silently. Centralizing the construction in a single `paths.mts` -per package means a refactor is a one-file diff, and divergence -becomes impossible because every consumer imports the same value. - -The companion `scripts/check-paths.mts` runs a deeper whole-repo -scan at `pnpm check` time, catching anything this hook missed. - -## What it blocks - -| Rule | Example that gets blocked | Fix | -| ------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A** — multi-stage path constructed inline | `path.join(PKG, 'build', mode, 'out', 'Final', name)` | Move the construction into the package's `scripts/paths.mts` (or use `getFinalBinaryPath` from `build-infra/lib/paths`); import the computed value here. | -| **B** — cross-package path traversal | `path.join(PKG, '..', 'lief-builder', 'build', ...)` | Add `lief-builder: workspace:*` as a dependency; import its `paths.mts` via the workspace `exports` field. | - -The hook fires on `Edit` and `Write` tool calls when the target path -ends in `.mts` or `.cts`. Other extensions (`.ts`, `.mjs`, `.js`, -`.yml`, `.json`, `.md`) pass through — TS path code lives in `.mts` -per fleet convention, and other file types are covered by the -`scripts/check-paths.mts` gate at commit time. - -## What it allows - -- Edits to a `paths.mts` (the canonical constructor). -- Edits to `scripts/check-paths.mts` (the gate itself, which - legitimately enumerates patterns). -- Edits to this hook's own files (the test suite has to enumerate - the same patterns). -- `path.join` calls with a single stage segment, e.g. - `path.join(packageRoot, 'build', 'temp')` — that's a one-off - helper path, not a multi-stage build output. -- `path.join` calls with no stage segments at all (most - general-purpose joins). -- Any string concatenation that doesn't go through `path.join` — - the hook is regex-based and intentionally narrow. - -## Stage segments the hook recognizes - -These come from `build-infra/lib/constants.mts:BUILD_STAGES` plus the -lowercase directory-name siblings used by some builders: - -`Final`, `Release`, `Stripped`, `Compressed`, `Optimized`, `Synced`, -`wasm`, `downloaded` - -Two or more in the same `path.join` call — or one stage segment plus -one of `'build'`/`'out'` plus one mode (`'dev'`/`'prod'`) — triggers -Rule A. - -## Known sibling packages (for Rule B) - -The hook recognizes Rule B traversals only when the next segment -after `..` is a known fleet package name: - -`binflate`, `binject`, `binpress`, `bin-infra`, `build-infra`, -`codet5-models-builder`, `curl-builder`, `libpq-builder`, -`lief-builder`, `minilm-builder`, `models`, `napi-go`, -`node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, -`stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder` - -When a new package joins the workspace, add it to -`KNOWN_SIBLING_PACKAGES` in `index.mts`. - -## Fail-open on hook bugs - -If the hook itself crashes, it writes a log line and exits `0` — -i.e. _the edit is allowed_. A buggy security hook that blocks -everything is worse than one that temporarily lets things through. -The companion `scripts/check-paths.mts` gate at commit time catches -anything the hook missed. - -## Testing - -```bash -pnpm --filter hook-path-guard test -``` - -Adding a new detection pattern: update `STAGE_SEGMENTS` (or -`KNOWN_SIBLING_PACKAGES`) in `index.mts`, then add a positive and a -negative test in `test/path-guard.test.mts`. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/path-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -To propagate a change from the template to every fleet repo: - -```bash -node scripts/sync-scaffolding.mts --all --fix -``` diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/path-guard/index.mts deleted file mode 100644 index 33f730d1d..000000000 --- a/.claude/hooks/path-guard/index.mts +++ /dev/null @@ -1,351 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — path-guard firewall. -// -// Mantra: 1 path, 1 reference. -// -// Blocks Edit/Write tool calls that would *construct* a multi-segment -// build/output path inline in a `.mts` or `.cts` file, instead of -// importing the constructed value from the canonical `paths.mts` (or a -// build-infra helper). This fires BEFORE the write lands; exit code 2 -// makes Claude Code refuse the tool call so the diff never touches the -// repo. The model sees the rejection reason on stderr and retries with -// an import-based approach. -// -// What the hook checks (subset of the gate's rules — diff-local only): -// -// Rule A — Multi-stage path construction: a `path.join(...)` / -// `path.resolve(...)` call or string-template that stitches together -// two or more "stage" segments together with build / out / mode / -// platform-arch context. Outside a `paths.mts` file this is a -// violation: the construction belongs in a helper, every consumer -// imports the computed value. -// -// Rule B — Cross-package traversal: `path.join(*, '..', '', 'build', ...)` reaches into a sibling's build output -// without going through its `exports`. Forces consumers to declare a -// workspace dep and import the sibling's `paths.mts`. -// -// What the hook does NOT check (the gate handles repo-wide concerns): -// -// Rule C — workflow YAML repetition (gate scans .yml files). -// Rule D — comment-encoded paths (gate scans comments + JSDoc). -// Rule F — same path reconstructed in multiple files. -// Rule G — Makefile / Dockerfile / shell-script paths. -// -// AST-based detector (vendored acorn-wasm). Replaces the prior -// regex+paren-balance string scanner that the previous file's -// `extractPathCalls` had to roll by hand because regex couldn't -// handle nested parens in argument lists like -// `path.join(getDir(x), 'Final')`. The AST visitor sees those calls -// natively, with arguments resolved as Literal / NewExpression / -// CallExpression / TemplateLiteral nodes; we only treat string-Literal -// arguments as path segments (every other shape is a computed value -// that doesn't participate in the rule). -// -// Scope: -// - Fires only on `Edit` and `Write` tool calls. -// - Only `.mts` / `.cts` source files. -// - Skips `paths.mts` itself (canonical constructor) and the gate / -// hook implementations that enumerate stage tokens. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log). - -import process from 'node:process' - -import { findTemplateLiterals } from '../_shared/acorn/index.mts' -import type { TemplateLiteralSite } from '../_shared/acorn/index.mts' -import { - BUILD_ROOT_SEGMENTS, - KNOWN_SIBLING_PACKAGES, - MODE_SEGMENTS, - STAGE_SEGMENTS, -} from './segments.mts' - -const EXEMPT_FILE_PATTERNS: RegExp[] = [ - /(?:^|\/)paths\.(?:cts|mts)$/, - /scripts\/check-paths\.mts$/, - /scripts\/check-paths\//, - /\.claude\/hooks\/path-guard\/index\.(?:cts|mts)$/, - /\.claude\/hooks\/path-guard\/test\//, - /scripts\/check-consistency\.mts$/, -] - -class BlockError extends Error { - public readonly rule: string - public readonly suggestion: string - public readonly snippet: string - constructor(rule: string, suggestion: string, snippet: string) { - super(rule) - this.name = 'BlockError' - this.rule = rule - this.suggestion = suggestion - this.snippet = snippet.slice(0, 240) + (snippet.length > 240 ? '…' : '') - } -} - -interface ToolInput { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -export function stdin(): Promise { - return new Promise(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => (buf += chunk)) - process.stdin.on('end', () => resolve(buf)) - }) -} - -export function isInScope(filePath: string) { - if (!filePath) { - return false - } - if (!filePath.endsWith('.mts') && !filePath.endsWith('.cts')) { - return false - } - return !EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) -} - -/** - * Collect string-literal arguments from each `path.join` / `path.resolve` call. - * We deliberately only consume the `firstStringArg` + the - * `allStringLiteralArgs` flag from the AST helper's MemberCallSite, then walk - * the call again at the source level only as a fallback for displaying the - * snippet — we never parse arguments by hand. - * - * To get ALL string-literal args (not just the first), we re-parse the - * arguments via `findMemberCalls`'s nature: it visits one CallExpression at a - * time. Since the public surface returns only `firstStringArg`, here we walk - * again with a custom visitor that inspects each argument. This keeps the - * public helper API narrow while letting path-guard get the full literal list - * it needs. - */ -import { walkSimple } from '../_shared/acorn/index.mts' -import type { AcornNode } from '../_shared/acorn/index.mts' - -interface PathCall { - /** - * All string-Literal arguments in source order. - */ - literals: string[] - /** - * Whether ANY argument was a non-string node (Identifier / CallExpression / - * etc.). - */ - hasComputedArg: boolean - /** - * Source snippet around the call for the block message. - */ - snippet: string - /** - * 1-based line of the call. - */ - line: number -} - -export function collectPathCalls(source: string): PathCall[] { - const lines = source.split('\n') - const out: PathCall[] = [] - // Match both `path.join(...)` and `path.resolve(...)` via two passes. - for (const property of ['join', 'resolve']) { - walkSimple(source, { - CallExpression(node: AcornNode) { - const callee = node['callee'] as AcornNode | undefined - if (!callee || callee.type !== 'MemberExpression') { - return - } - const obj = callee['object'] as AcornNode | undefined - if ( - !obj || - obj.type !== 'Identifier' || - (obj['name'] as string) !== 'path' - ) { - return - } - const prop = callee['property'] as AcornNode | undefined - if ( - !prop || - prop.type !== 'Identifier' || - (prop['name'] as string) !== property - ) { - return - } - const args = (node['arguments'] as AcornNode[] | undefined) ?? [] - const literals: string[] = [] - let hasComputedArg = false - for (let i = 0, { length } = args; i < length; i += 1) { - const a = args[i]! - if (a.type === 'Literal' && typeof a['value'] === 'string') { - literals.push(a['value'] as string) - } else { - hasComputedArg = true - } - } - const start = node['start'] as number | undefined - const end = node['end'] as number | undefined - if (typeof start !== 'number' || typeof end !== 'number') { - return - } - const line = source.slice(0, start).split('\n').length /* 1-based */ - const snippet = source.slice(start, end) - const trimmedLine = lines[line - 1]?.trim() ?? '' - out.push({ - literals, - hasComputedArg, - // Prefer the single-line text when the call fits on one - // line; otherwise show the slice (truncated by BlockError). - snippet: snippet.includes('\n') ? snippet : trimmedLine, - line, - }) - }, - }) - } - return out -} - -export function checkRuleA(calls: PathCall[]) { - for (let i = 0, { length } = calls; i < length; i += 1) { - const call = calls[i]! - const stages = call.literals.filter(l => STAGE_SEGMENTS.has(l)) - const buildRoots = call.literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) - const modes = call.literals.filter(l => MODE_SEGMENTS.has(l)) - const twoStages = stages.length >= 2 - const stagePlusContext = - stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1 - if (twoStages || stagePlusContext) { - throw new BlockError( - 'A — multi-stage path constructed inline', - 'Construct this path in the owning `paths.mts` (or a build-infra helper like `getFinalBinaryPath`) and import the computed value here. 1 path, 1 reference.', - call.snippet, - ) - } - } -} - -export function checkRuleB(calls: PathCall[]) { - for (let i = 0, { length } = calls; i < length; i += 1) { - const call = calls[i]! - const hasBuildContext = call.literals.some( - l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), - ) - if (!hasBuildContext) { - continue - } - for (let i = 0; i < call.literals.length - 1; i++) { - if ( - call.literals[i] === '..' && - KNOWN_SIBLING_PACKAGES.has(call.literals[i + 1]!) - ) { - const sibling = call.literals[i + 1]! - throw new BlockError( - 'B — cross-package path traversal', - `Don't reach into '${sibling}'s build output via \`..\`. Add \`${sibling}: workspace:*\` as a dep and import its \`paths.mts\` via the \`exports\` field. 1 path, 1 reference.`, - call.snippet, - ) - } - } - } -} - -export function checkRuleATemplate(templates: TemplateLiteralSite[]) { - for (let i = 0, { length } = templates; i < length; i += 1) { - const tpl = templates[i]! - // Skip templates with no `/` separator — they can't be path-shaped. - if (!tpl.segments.includes('/')) { - continue - } - // Replace `\0` expression sentinels with empty (they don't - // contribute path segments); split on `/`; filter empty. - const segments = tpl.segments - .replace(/\x00/g, '') - .split('/') - .filter(s => s.length > 0) - const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) - const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) - const modes = segments.filter(s => MODE_SEGMENTS.has(s)) - const hasBuildAndOut = - buildRoots.includes('build') && buildRoots.includes('out') - const hasOut = buildRoots.includes('out') - const hasBuild = buildRoots.includes('build') - const triggers = - (hasBuildAndOut && stages.length >= 1) || - (stages.length >= 2 && hasOut) || - (hasBuild && stages.length >= 1 && modes.length >= 1) - if (triggers) { - throw new BlockError( - 'A — multi-stage path constructed inline via template literal', - 'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.', - tpl.text, - ) - } - } -} - -export function check(source: string) { - const calls = collectPathCalls(source) - if (calls.length > 0) { - checkRuleA(calls) - checkRuleB(calls) - } - const templates = findTemplateLiterals(source) - if (templates.length > 0) { - checkRuleATemplate(templates) - } -} - -export function emitBlock(filePath: string, err: BlockError) { - process.stderr.write( - `\n[path-guard] Blocked: ${err.rule}\n` + - ` Mantra: 1 path, 1 reference\n` + - ` File: ${filePath}\n` + - ` Snippet: ${err.snippet}\n` + - ` Fix: ${err.suggestion}\n\n`, - ) -} - -async function main() { - const raw = await stdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!isInScope(filePath)) { - return - } - const source = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!source) { - return - } - try { - check(source) - } catch (e) { - if (e instanceof BlockError) { - emitBlock(filePath, e) - process.exitCode = 2 - return - } - throw e - } -} - -main().catch(e => { - process.stderr.write(`[path-guard] hook error (allowing): ${e}\n`) - process.exitCode = 0 -}) diff --git a/.claude/hooks/path-guard/package.json b/.claude/hooks/path-guard/package.json deleted file mode 100644 index a7cb5039a..000000000 --- a/.claude/hooks/path-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-path-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/path-guard/segments.mts deleted file mode 100644 index c4eb78e6d..000000000 --- a/.claude/hooks/path-guard/segments.mts +++ /dev/null @@ -1,74 +0,0 @@ -// Canonical path-segment vocabulary shared by the path-guard hook -// (.claude/hooks/path-guard/index.mts) and gate (scripts/check-paths.mts). -// -// Mantra: 1 path, 1 reference. This module is the *one* place stage, -// build-root, mode, and sibling-package vocabulary is defined. Both -// consumers import from here so they can never drift apart. -// -// Synced byte-identically across the Socket fleet via -// socket-wheelhouse/scripts/sync-scaffolding.mts (IDENTICAL_FILES). -// When adding a new stage/build-root/mode/sibling, edit this file in -// the template and re-sync. - -// "Stage" segments — Rule A core. Two of these spread via `path.join` -// or interpolated into a template literal is a finding outside a -// canonical `paths.mts`. Sourced from build-infra/lib/constants.mts -// `BUILD_STAGES` plus their lowercase directory-name siblings used by -// some builders. -export const STAGE_SEGMENTS = new Set([ - 'Compressed', - 'Final', - 'Optimized', - 'Release', - 'Stripped', - 'Synced', - 'downloaded', - 'wasm', -]) - -// "Build-root" segments — at least one must be present together with -// a stage segment to confirm we're constructing a build output path -// rather than something coincidental. Example: a join that yields -// `//` doesn't fire if no build-root segment is -// present; `/build//out/` does. -export const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) - -// Build-mode segments — a stage segment plus one of these is also a -// finding (`build///out/` is the canonical shape). -export const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When a -// new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mts --all --fix` from -// socket-wheelhouse. -export const KNOWN_SIBLING_PACKAGES = new Set([ - 'acorn', - 'bin-infra', - 'binflate', - 'binject', - 'binpress', - 'build-infra', - 'cli', - 'codet5-models-builder', - 'core', - 'curl-builder', - 'libpq-builder', - 'lief-builder', - 'minilm-builder', - 'models', - 'napi-go', - 'node-smol-builder', - 'npm', - 'onnxruntime-builder', - 'opentui-builder', - 'package-builder', - 'react', - 'renderer', - 'stubs-builder', - 'ultraviolet', - 'ultraviolet-builder', - 'yoga', - 'yoga-layout-builder', -]) diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/path-guard/test/path-guard.test.mts deleted file mode 100644 index 12e35b92b..000000000 --- a/.claude/hooks/path-guard/test/path-guard.test.mts +++ /dev/null @@ -1,311 +0,0 @@ -// Tests for the path-guard hook. Each `node:test` block writes a -// mock PreToolUse payload to the hook's stdin and asserts on its exit -// code + stderr. Exit 2 = blocked; exit 0 = allowed. -// -// Run: pnpm --filter hook-path-guard test -// (or directly: node --test test/*.test.mts) - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -const runHook = ( - toolName: string, - filePath: string, - source: string, -): { code: number; stderr: string } => { - const payload = JSON.stringify({ - tool_name: toolName, - tool_input: - toolName === 'Edit' - ? { file_path: filePath, new_string: source } - : { file_path: filePath, content: source }, - }) - const result = spawnSync(process.execPath, [HOOK], { - input: payload, - }) - return { - code: result.status ?? -1, - stderr: String(result.stderr), - } -} - -describe('path-guard — Rule A (multi-stage construction)', () => { - it('blocks two stage segments in path.join', () => { - const source = ` - const p = path.join(PACKAGE_ROOT, 'wasm', 'out', 'Final', 'bin') - ` - const { code, stderr } = runHook( - 'Write', - 'packages/foo/scripts/build.mts', - source, - ) - assert.equal(code, 2) - assert.match(stderr, /Blocked: A/) - assert.match(stderr, /1 path, 1 reference/) - }) - - it('blocks build + mode + stage', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'binary') - ` - const { code } = runHook('Edit', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('blocks Release + Stripped together', () => { - const source = ` - const p = path.join(buildDir, 'Release', 'Stripped') - ` - const { code } = runHook( - 'Write', - 'packages/foo/scripts/release.mts', - source, - ) - assert.equal(code, 2) - }) - - it('allows single stage segment with one build root', () => { - // 'build' + 'temp' → no stage segment at all → pass - const source = ` - const tmp = path.join(packageRoot, 'build', 'temp') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows path.join with no stage segments', () => { - const source = ` - const cfg = path.join(packageRoot, 'config', 'settings.json') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — Rule B (cross-package traversal)', () => { - it('blocks .. + sibling package + build context', () => { - const source = ` - const lief = path.join(PKG, '..', 'lief-builder', 'build', 'Final') - ` - const { code, stderr } = runHook( - 'Write', - 'packages/binject/scripts/build.mts', - source, - ) - assert.equal(code, 2) - assert.match(stderr, /Blocked: B/) - assert.match(stderr, /lief-builder/) - }) - - it('allows .. + sibling without build context', () => { - // Reaching into a sibling for a non-build asset is allowed; the - // gate may still flag it but the hook is scoped to build paths. - const source = ` - const cfg = path.join(PKG, '..', 'lief-builder', 'config.json') - ` - const { code } = runHook( - 'Write', - 'packages/binject/scripts/build.mts', - source, - ) - assert.equal(code, 0) - }) - - it('does not fire on traversal to unknown directory', () => { - const source = ` - const x = path.join(PKG, '..', 'fixtures', 'build', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/test/test.mts', source) - assert.equal(code, 0) - }) - - it('does not fire when .. and sibling are non-adjacent (regression)', () => { - // Earlier regex ran with sticky sawDotDot — once it saw `..` it - // would flag any later sibling-named segment. The fix requires - // the sibling to appear *immediately* after `..`. - const source = ` - const x = path.join(PKG, '..', 'cache', 'lief-builder', 'config.json') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — paren-balance correctness', () => { - it('detects A through nested function-call args (regression)', () => { - // Old regex used \\([^()]*\\) which only handled one nesting - // level — `path.join(getDir(child(x)), 'build', 'dev', 'Final')` - // silently slipped through. The paren-balancing scanner catches it. - const source = ` - const p = path.join(getDir(child(x)), 'build', 'dev', 'out', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('detects A in path.resolve() too', () => { - const source = ` - const p = path.resolve(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) -}) - -describe('path-guard — template literals', () => { - it('detects A in fully-literal template path', () => { - const source = '\n const p = `build/dev/out/Final/binary`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('detects A in template with placeholders', () => { - const source = - '\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('allows template with single non-stage segment', () => { - const source = '\n const url = `https://example.com/path`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows template with no stage segments', () => { - const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) - - it('allows template that is purely interpolation', () => { - // `${a}/${b}/${c}` has no literal stage segments. - const source = '\n const p = `${a}/${b}/${c}`\n ' - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — file-type filter', () => { - it('skips .ts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/src/index.ts', source) - assert.equal(code, 0) - }) - - it('skips .mjs files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'additions/foo.mjs', source) - assert.equal(code, 0) - }) - - it('skips .yml files', () => { - const source = ` - run: | - FINAL="build/\${MODE}/\${ARCH}/out/Final" - ` - const { code } = runHook('Write', '.github/workflows/foo.yml', source) - assert.equal(code, 0) - }) - - it('inspects .mts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 2) - }) - - it('inspects .cts files', () => { - const source = ` - const p = path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin') - ` - const { code } = runHook('Write', 'packages/foo/scripts/build.cts', source) - assert.equal(code, 2) - }) -}) - -describe('path-guard — exempt files', () => { - it('allows edits to paths.mts', () => { - const source = ` - export const FINAL_DIR = path.join(PKG, 'build', 'dev', 'out', 'Final') - ` - const { code } = runHook('Write', 'packages/foo/scripts/paths.mts', source) - assert.equal(code, 0) - }) - - it('allows edits to check-paths.mts (the gate)', () => { - const source = ` - const PATTERNS = [path.join('build', 'Final', 'wasm')] - ` - const { code } = runHook('Write', 'scripts/check-paths.mts', source) - assert.equal(code, 0) - }) - - it('allows edits to the path-guard hook itself', () => { - const source = ` - const STAGES = ['Final', 'Release', 'Stripped'] - ` - const { code } = runHook( - 'Write', - '.claude/hooks/path-guard/index.mts', - source, - ) - assert.equal(code, 0) - }) - - it('allows edits to path-guard tests', () => { - const source = ` - const fixture = path.join('build', 'dev', 'out', 'Final') - ` - const { code } = runHook( - 'Write', - '.claude/hooks/path-guard/test/path-guard.test.mts', - source, - ) - assert.equal(code, 0) - }) -}) - -describe('path-guard — tool-name filter', () => { - it('skips Bash', () => { - const source = `path.join(PKG, 'build', 'dev', 'out', 'Final', 'bin')` - const { code } = runHook('Bash', '', source) - assert.equal(code, 0) - }) - - it('skips Read', () => { - const source = '' - const { code } = runHook('Read', 'packages/foo/scripts/build.mts', source) - assert.equal(code, 0) - }) -}) - -describe('path-guard — bug-tolerance (fails open)', () => { - it('passes through invalid JSON payload', () => { - const result = spawnSync(process.execPath, [HOOK], { - input: 'not json at all', - }) - assert.equal(result.status, 0) - }) - - it('passes through empty stdin', () => { - const result = spawnSync(process.execPath, [HOOK], { - input: '', - }) - assert.equal(result.status, 0) - }) -}) diff --git a/.claude/hooks/path-guard/tsconfig.json b/.claude/hooks/path-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/path-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/path-regex-normalize-reminder/README.md b/.claude/hooks/path-regex-normalize-reminder/README.md deleted file mode 100644 index ad4134743..000000000 --- a/.claude/hooks/path-regex-normalize-reminder/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# path-regex-normalize-reminder - -Claude Code Stop hook. Inspects code blocks the assistant wrote for regex -literals or `new RegExp(...)` calls that try to match both path separators -inline — patterns like `[/\\]`, `[\\\\/]`, or `\\\\` in a regex that also -mentions path-flavored segments (`.cache`, `node_modules`, `build`, etc.). - -Suggests normalizing the path first with `normalizePath` (or `toUnixPath`) -from `@socketsecurity/lib-stable/paths/normalize`, then writing the regex against -`/` only. - -## Why - -Dual-separator patterns are easy to miss in some branches, slower to read, -and they multiply when escaped Windows separators (`\\\\`) get mixed in. -The fleet's `normalizePath` helper converts backslashes to forward slashes -plus does segment collapsing — one normalized representation across -`darwin` / `linux` / `win32`. Lint rules and runtime code both benefit -from a single-separator regex against normalized input. - -## Trigger - -The hook is a **reminder**, not a blocker. It writes to stderr at the end -of a turn if it sees a suspect pattern in the last assistant message's -code fences. Exit code is always 0. - -## Bypass - -Type `Allow path-regex-normalize bypass` verbatim in a recent user turn. -(Reminders don't strictly need bypasses since they don't block; the phrase -is for consistency with other fleet hooks.) - -## Disable - -Set `SOCKET_PATH_REGEX_NORMALIZE_REMINDER_DISABLED=1` in the env. diff --git a/.claude/hooks/path-regex-normalize-reminder/index.mts b/.claude/hooks/path-regex-normalize-reminder/index.mts deleted file mode 100644 index 4853c58db..000000000 --- a/.claude/hooks/path-regex-normalize-reminder/index.mts +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — path-regex-normalize-reminder. -// -// Spots regex patterns that try to match both path separators inline -// (`[/\\]`, `[\\\\/]`, escaped backslashes inside a path-flavored regex) -// and reminds the author to use `normalizePath` from -// `@socketsecurity/lib-stable/paths/normalize` instead, then write the regex -// against `/` only. -// -// AST-based detector — uses `findRegexLiterals` from the vendored -// acorn-wasm to walk the AST and inspect each `Literal { regex }` -// node's `pattern` directly. The previous regex-driven scanner had to -// reconstruct the regex-literal grammar by hand (a regex matching -// regex literals is famously hard) and false-positived on `//` inside -// comments and `/.../` in string literals. AST-walk skips all of that -// intrinsically. -// -// For `new RegExp("...")` constructor calls, walks CallExpression -// nodes whose callee is `Identifier(RegExp)` (via the AST helper's -// CallExpression visitor). -// -// Scope: TypeScript / JavaScript source code blocks in the last -// assistant message. Markdown / READMEs / docs are skipped because -// example regexes there are illustrative, not run. -// -// Disable via SOCKET_PATH_REGEX_NORMALIZE_REMINDER_DISABLED. - -import process from 'node:process' - -import { findRegexLiterals, walkSimple } from '../_shared/acorn/index.mts' -import type { AcornNode } from '../_shared/acorn/index.mts' -import { - bypassPhrasePresent, - extractCodeFences, - readLastAssistantText, - readStdin, -} from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -interface Finding { - pattern: string - reason: string -} - -const BYPASS_PHRASE = 'Allow path-regex-normalize bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -const CODE_LANGS = new Set([ - '', - 'cjs', - 'cts', - 'js', - 'jsx', - 'mjs', - 'mts', - 'ts', - 'tsx', -]) - -// Three forms of a dual-separator character class inside a regex -// pattern. The patterns are matched against the RAW regex source -// (what the AST helper reports as `pattern`), not against JS string -// escaping. -const DUAL_SEP_RE_PATTERNS: readonly RegExp[] = [ - /\[\\?\/\]/, // `[/]` or `[\/]` alone (rare; included for completeness) - /\[\/\\\\\]/, // `[/\\]` — slash + escaped backslash - /\[\\\\\/\]/, // `[\\/]` — escaped backslash + slash -] - -// Path-flavored token signal — if any of these appear in the same -// code block as the dual-sep regex, we trigger. Otherwise the regex -// is probably matching something else (HTTP path, URL, etc.). -const PATH_FLAVOR_RE = - /(\.cache|node_modules|\/build\/|\bpaths?\.|os\.homedir|process\.cwd|fileURLToPath|path\.join|path\.resolve|path\.sep|normalize)/ - -export function findFindings(code: string): Finding[] { - const findings: Finding[] = [] - - // Quick early-out: if the block contains no path-flavored token at - // all, no point parsing. - if (!PATH_FLAVOR_RE.test(code)) { - return findings - } - - // Regex literals via AST. - const regexLiterals = findRegexLiterals(code) - for (let i = 0, { length } = regexLiterals; i < length; i += 1) { - const r = regexLiterals[i]! - if (!isDualSeparator(r.pattern)) { - continue - } - findings.push({ - pattern: `/${r.pattern}/${r.flags}`, - reason: - 'Dual path-separator regex. Normalize the input with `normalizePath` from `@socketsecurity/lib-stable/paths/normalize` first, then match `/` only.', - }) - } - - // `new RegExp("...")` constructor — walk CallExpression / NewExpression - // with callee = Identifier(RegExp). The first arg is the pattern - // string; the second (optional) is flags. - walkSimple(code, { - NewExpression(node: AcornNode) { - const callee = node['callee'] as AcornNode | undefined - if ( - !callee || - callee.type !== 'Identifier' || - (callee['name'] as string) !== 'RegExp' - ) { - return - } - const args = (node['arguments'] as AcornNode[] | undefined) ?? [] - const first = args[0] - if ( - !first || - first.type !== 'Literal' || - typeof first['value'] !== 'string' - ) { - return - } - const pattern = first['value'] as string - // The constructor takes the pattern as a STRING — backslash - // escapes are JS-string escapes, so `"[/\\\\]"` in source - // becomes `"[/\\]"` as the value, then `[/\\]` as the regex. - // We test against the value (already one level of unescaping). - if (!isDualSeparator(pattern)) { - return - } - findings.push({ - pattern: `new RegExp(${JSON.stringify(pattern)})`, - reason: - '`new RegExp(...)` with both separators in the pattern string. Normalize the input first; the regex stays single-separator.', - }) - }, - }) - - return findings -} - -export function isDualSeparator(pattern: string): boolean { - for (let i = 0, { length } = DUAL_SEP_RE_PATTERNS; i < length; i += 1) { - const p = DUAL_SEP_RE_PATTERNS[i]! - if (p.test(pattern)) { - return true - } - } - return false -} - -async function main(): Promise { - const payloadRaw = await readStdin() - if (process.env['SOCKET_PATH_REGEX_NORMALIZE_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - process.exit(0) - } - const text = readLastAssistantText(payload.transcript_path) - if (!text) { - process.exit(0) - } - const codeBlocks = extractCodeFences(text) - if (codeBlocks.length === 0) { - process.exit(0) - } - - const aggregate: Finding[] = [] - for (let i = 0, { length } = codeBlocks; i < length; i += 1) { - const block = codeBlocks[i]! - if (!CODE_LANGS.has((block.lang ?? '').toLowerCase())) { - continue - } - const findings = findFindings(block.body) - for (let fi = 0, { length: flen } = findings; fi < flen; fi += 1) { - aggregate.push(findings[fi]!) - } - } - if (aggregate.length === 0) { - process.exit(0) - } - - const lines = [ - '[path-regex-normalize-reminder] Regex matching path separators inline:', - '', - ] - for (let i = 0, { length } = aggregate; i < length; i += 1) { - const f = aggregate[i]! - lines.push(` • ${f.pattern}`) - lines.push(` ${f.reason}`) - lines.push('') - } - lines.push( - " Use `import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'`,", - ) - lines.push( - ' then write a single-separator regex against `normalizePath(input)`.', - ) - lines.push(` Bypass: type "${BYPASS_PHRASE}" verbatim in a recent message.`) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') // socket-hook: allow console - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/path-regex-normalize-reminder/package.json b/.claude/hooks/path-regex-normalize-reminder/package.json deleted file mode 100644 index a6383bde3..000000000 --- a/.claude/hooks/path-regex-normalize-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-path-regex-normalize-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/path-regex-normalize-reminder/tsconfig.json b/.claude/hooks/path-regex-normalize-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/path-regex-normalize-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/paths-mts-inherit-guard/README.md b/.claude/hooks/paths-mts-inherit-guard/README.md deleted file mode 100644 index b149fdc45..000000000 --- a/.claude/hooks/paths-mts-inherit-guard/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# paths-mts-inherit-guard - -PreToolUse Edit/Write hook. Blocks landing a sub-package -`scripts/paths.mts` (or `paths.cts`) whose content doesn't inherit -from the nearest ancestor `paths.mts` via `export *`. - -## Why - -`paths.mts` is per-package — like `package.json`, every package that -has a `scripts/` dir has its own. Sub-packages must `export *` from -the nearest ancestor so `REPO_ROOT`, `CONFIG_DIR`, -`NODE_MODULES_CACHE_DIR`, etc. aren't re-derived (and don't drift). - -The fleet rule from CLAUDE.md (1 path, 1 reference): - -> Sub-packages inherit: a sub-package's `paths.mts` `export * from -'/paths.mts'` from the nearest ancestor and adds local -> overrides below the re-export. Don't re-derive `REPO_ROOT` / -> `CONFIG_DIR` / `NODE_MODULES_CACHE_DIR`. - -## Allowed shapes - -Repo-root `scripts/paths.mts` — no ancestor exists; nothing to -inherit from. Skipped. - -Sub-package `packages/foo/scripts/paths.mts`: - -```ts -export * from '../../../scripts/paths.mts' - -// Local overrides below — package-specific paths. -import path from 'node:path' -import { REPO_ROOT } from '../../../scripts/paths.mts' -export const FOO_DIST = path.join(REPO_ROOT, 'packages', 'foo', 'dist') -``` - -## Blocked shapes - -A sub-package `paths.mts` that re-derives `REPO_ROOT` instead of -inheriting: - -```ts -// BLOCKED — should re-export from the ancestor -const REPO_ROOT = fileURLToPath(import.meta.url).split('/scripts/')[0] -``` - -## Bypass - -`Allow paths-mts-inherit bypass` (verbatim, in a recent user turn). -Use when a sub-package's paths.mts genuinely needs to be self- -contained — but this is rare; if you're tempted, double-check the -inheritance pattern. - -## Cited from CLAUDE.md - -Under _1 path, 1 reference_: "Sub-packages inherit" bullet. diff --git a/.claude/hooks/paths-mts-inherit-guard/index.mts b/.claude/hooks/paths-mts-inherit-guard/index.mts deleted file mode 100644 index f77746ce1..000000000 --- a/.claude/hooks/paths-mts-inherit-guard/index.mts +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — paths-mts-inherit-guard. -// -// Mantra: 1 path, 1 reference (per-package). -// -// `scripts/paths.mts` is the canonical per-package paths module — -// like `package.json`, every package gets its own. Sub-packages -// inherit from the nearest ancestor's paths.mts via: -// -// export * from '/paths.mts' -// -// The hook blocks Edit/Write tool calls that would land a sub-package -// `paths.mts` (or `paths.cts`) whose final content lacks the -// `export *` re-export from an ancestor. -// -// What counts as a "sub-package paths.mts": -// - File path matches `/scripts/paths.{mts,cts}` -// - There exists an ancestor `scripts/paths.{mts,cts}` higher in -// the directory tree (and not the same file). -// -// What counts as proper inheritance: -// - The final content contains a line matching -// `^export \* from ['"][^'"]*paths\.m?ts['"]` -// where the target is a path that resolves to an ancestor's -// paths.mts. The hook checks the textual `export *` line; it -// doesn't resolve the target to verify the ancestor exists -// on disk (the ancestor may also be a fresh Edit in the same -// diff — we trust the consumer's intent). -// -// Repo-root scripts/paths.mts is exempt — there's no ancestor to -// inherit from. We detect "is repo root" by checking whether any -// parent dir between the file and the filesystem root contains -// another scripts/paths.{mts,cts}. -// -// Bypass: `Allow paths-mts-inherit bypass` typed verbatim by the -// user in a recent conversation turn. -// -// Fails open on every error (exit 0 + log) so a buggy hook can't -// brick the session. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit" | "Write" | "MultiEdit", -// "tool_input": { "file_path": "...", "new_string"?: "...", -// "content"?: "..." }, -// "transcript_path": "/.../session.jsonl" } -// -// Exits: -// 0 — allowed (not a sub-package paths.mts, repo-root paths.mts, -// inheritance present, or bypass phrase recent). -// 2 — blocked (with stderr explanation + the inheritance pattern -// the maintainer should paste). -// 0 with stderr log — fail-open on hook bugs. - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow paths-mts-inherit bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -const PATHS_MTS_RE = /(?:^|\/)paths\.(?:cts|mts)$/ -const EXPORT_STAR_RE = - /^\s*export\s+\*\s+from\s+['"](?:[^'"]+\/paths\.m?ts)['"];?\s*$/m - -/** - * Walk up from `filePath` looking for an ancestor `scripts/paths.mts` or - * `scripts/paths.cts`. Returns the absolute path of the nearest one, or - * `undefined` if there's no ancestor (i.e. this IS the repo- root paths.mts). - * - * Stops at the first ancestor found OR at the filesystem root. - */ -export function findAncestorPathsMts(filePath: string): string | undefined { - const fileDir = path.dirname(path.resolve(filePath)) - // Skip the current file's own dir — we want a STRICT ancestor. - let cur = path.dirname(fileDir) - const root = path.parse(cur).root - while (cur && cur !== root) { - for (const ext of ['mts', 'cts']) { - const candidate = path.join(cur, 'scripts', `paths.${ext}`) - if (existsSync(candidate) && candidate !== path.resolve(filePath)) { - return candidate - } - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - return undefined -} - -async function main(): Promise { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'paths-mts-inherit-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return 0 - } - - const filePath = payload.tool_input?.file_path - if (!filePath) { - return 0 - } - if (!PATHS_MTS_RE.test(filePath)) { - return 0 - } - - // Only enforce on `<...>/scripts/paths.{mts,cts}` (the canonical - // location). A `paths.mts` outside a `scripts/` dir is some other - // file with the same name; not our concern. - if (!/\/scripts\/paths\.(?:cts|mts)$/.test(filePath)) { - return 0 - } - - // Repo-root paths.mts has no ancestor — exempt. - const ancestor = findAncestorPathsMts(filePath) - if (!ancestor) { - return 0 - } - - // The new content we're about to write. Edit uses `new_string` - // (a fragment); Write uses `content` (the full file). For Edit, - // we can't see the surrounding file without reading it, so we - // approximate: if the fragment itself contains an `export *`, - // accept; otherwise check the on-disk file. MultiEdit follows - // the same shape as Edit at the payload level (Claude Code - // serializes the merged result). - const fragment = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - if (EXPORT_STAR_RE.test(fragment)) { - return 0 - } - - // For Edit-shaped writes, the existing file may already carry the - // export *. Read it as a best-effort check before blocking — we - // don't want to false-positive when the Edit is touching some - // OTHER line and the inheritance is already present. - if (tool === 'Edit' || tool === 'MultiEdit') { - try { - const { readFileSync } = await import('node:fs') - const existing = readFileSync(filePath, 'utf8') - if (EXPORT_STAR_RE.test(existing)) { - return 0 - } - } catch { - // File may not exist yet (new file via Edit, unusual but - // possible). Fall through to the block path. - } - } - - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - const relAncestor = path.relative(path.dirname(filePath), ancestor) - process.stderr.write( - [ - `🚨 paths-mts-inherit-guard: blocked Edit/Write to a sub-package`, - `paths.mts that doesn't inherit from the nearest ancestor.`, - ``, - `File: ${filePath}`, - `Ancestor: ${ancestor}`, - ``, - `Mantra: 1 path, 1 reference.`, - ``, - `A sub-package's paths.mts must \`export *\` from the nearest`, - `ancestor paths.mts so REPO_ROOT, CONFIG_DIR, NODE_MODULES_CACHE_DIR,`, - `etc. aren't re-derived (and don't drift). Add this as the first`, - `line of the file:`, - ``, - ` export * from '${relAncestor}'`, - ``, - `Then add this package's own overrides below.`, - ``, - `Bypass: type \`${BYPASS_PHRASE}\` verbatim in a recent message`, - `if this paths.mts genuinely needs to be self-contained.`, - ``, - ].join('\n'), - ) - return 2 -} - -main().then( - code => process.exit(code), - e => { - process.stderr.write( - `paths-mts-inherit-guard: hook bug — fail-open. ${errorMessage(e)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/paths-mts-inherit-guard/package.json b/.claude/hooks/paths-mts-inherit-guard/package.json deleted file mode 100644 index ebaa9eef0..000000000 --- a/.claude/hooks/paths-mts-inherit-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-paths-mts-inherit-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts b/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts deleted file mode 100644 index 3d5bc3f59..000000000 --- a/.claude/hooks/paths-mts-inherit-guard/test/index.test.mts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @file Unit tests for paths-mts-inherit-guard. Test strategy: spawn the hook - * with a JSON payload on stdin and assert the exit code + stderr. Mirrors the - * shape used by the no-revert-guard / no-external-issue-ref-guard tests. - */ - -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, test } from 'node:test' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(payload: object): RunResult { - const r = spawnSync('node', [HOOK], { - input: JSON.stringify(payload), - }) - return { - code: typeof r.status === 'number' ? r.status : 0, - stderr: String(r.stderr || ''), - } -} - -let tmpRoot: string - -beforeEach(() => { - tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'paths-mts-inherit-guard-')) - // Repo-root scripts/paths.mts — ancestor exists for sub-packages. - mkdirSync(path.join(tmpRoot, 'scripts'), { recursive: true }) - writeFileSync( - path.join(tmpRoot, 'scripts', 'paths.mts'), - "export const REPO_ROOT = '/tmp/fake'\n", - ) -}) - -afterEach(() => { - rmSync(tmpRoot, { recursive: true, force: true }) -}) - -describe('paths-mts-inherit-guard', () => { - test('allows non-Edit/Write tools', () => { - const r = runHook({ - tool_name: 'Bash', - tool_input: { command: 'ls' }, - }) - assert.equal(r.code, 0) - }) - - test('allows Edit/Write to non-paths.mts files', () => { - const r = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: path.join(tmpRoot, 'scripts', 'foo.mts'), - new_string: '// whatever', - }, - }) - assert.equal(r.code, 0) - }) - - test('allows repo-root scripts/paths.mts (no ancestor)', () => { - const r = runHook({ - tool_name: 'Write', - tool_input: { - file_path: path.join(tmpRoot, 'scripts', 'paths.mts'), - content: "export const X = 'no inheritance needed at root'\n", - }, - }) - assert.equal(r.code, 0) - }) - - test('blocks sub-package paths.mts without export *', () => { - mkdirSync(path.join(tmpRoot, 'packages', 'foo', 'scripts'), { - recursive: true, - }) - const r = runHook({ - tool_name: 'Write', - tool_input: { - file_path: path.join( - tmpRoot, - 'packages', - 'foo', - 'scripts', - 'paths.mts', - ), - content: "export const REDERIVED = 'wrong'\n", - }, - }) - assert.equal(r.code, 2) - assert.match(r.stderr, /paths-mts-inherit-guard/) - assert.match(r.stderr, /export \* from/) - }) - - test('allows sub-package paths.mts WITH export *', () => { - mkdirSync(path.join(tmpRoot, 'packages', 'foo', 'scripts'), { - recursive: true, - }) - const r = runHook({ - tool_name: 'Write', - tool_input: { - file_path: path.join( - tmpRoot, - 'packages', - 'foo', - 'scripts', - 'paths.mts', - ), - content: - "export * from '../../../scripts/paths.mts'\nexport const FOO_DIST = '/x'\n", - }, - }) - assert.equal(r.code, 0) - }) - - test('allows Edit when existing file already has export *', () => { - mkdirSync(path.join(tmpRoot, 'packages', 'bar', 'scripts'), { - recursive: true, - }) - const subPath = path.join( - tmpRoot, - 'packages', - 'bar', - 'scripts', - 'paths.mts', - ) - writeFileSync( - subPath, - "export * from '../../../scripts/paths.mts'\nexport const OLD = '/x'\n", - ) - const r = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: subPath, - // The diff doesn't touch the export * line, just adds an - // additional const below it. - new_string: "export const BAR_DIST = '/y'\n", - }, - }) - assert.equal(r.code, 0) - }) - - test('allows paths.cts variant', () => { - mkdirSync(path.join(tmpRoot, 'packages', 'cjs', 'scripts'), { - recursive: true, - }) - const r = runHook({ - tool_name: 'Write', - tool_input: { - file_path: path.join( - tmpRoot, - 'packages', - 'cjs', - 'scripts', - 'paths.cts', - ), - content: "export * from '../../../scripts/paths.mts'\n", - }, - }) - assert.equal(r.code, 0) - }) - - test('fails open on invalid JSON', () => { - const r = spawnSync('node', [HOOK], { - input: 'not json', - }) - assert.equal(r.status, 0) - }) - - test('fails open on empty stdin', () => { - const r = spawnSync('node', [HOOK], { - input: '', - }) - assert.equal(r.status, 0) - }) - - test('ignores file paths outside a scripts/ dir', () => { - // A `paths.mts` not under `scripts/` is some other file with the - // same name; not our concern. - mkdirSync(path.join(tmpRoot, 'lib'), { recursive: true }) - const r = runHook({ - tool_name: 'Write', - tool_input: { - file_path: path.join(tmpRoot, 'lib', 'paths.mts'), - content: "export const X = 'not a scripts paths.mts'\n", - }, - }) - assert.equal(r.code, 0) - }) -}) diff --git a/.claude/hooks/paths-mts-inherit-guard/tsconfig.json b/.claude/hooks/paths-mts-inherit-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/paths-mts-inherit-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/perfectionist-reminder/README.md b/.claude/hooks/perfectionist-reminder/README.md deleted file mode 100644 index d91517049..000000000 --- a/.claude/hooks/perfectionist-reminder/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# perfectionist-reminder - -Stop hook that scans the assistant's most recent turn for speed-vs-depth choice menus where the perfectionist path is the obvious right answer. - -## Why - -CLAUDE.md "Judgment & self-evaluation" says: - -> Default to perfectionist when you have latitude. "Works now" ≠ "right." Before calling done: perfectionist vs. pragmatist views. Default perfectionist absent a signal. - -Sister rule from "Fix > defer" already catches "implement vs accept-as-gap" via `excuse-detector`. The speed-vs-depth menu is a different but related failure pattern: offering "Option A (do it right) / Option B (ship fast)" as a binary choice when the user already signaled they want correctness (asked the right question, requested a thorough audit, said "do it properly", etc.). - -The assistant's job is to internalize the perfectionist default and execute, not re-litigate the velocity tradeoff every time the work is non-trivial. - -## What it catches - -| Phrase pattern | Why it's flagged | -| --------------------------------------------------- | ---------------------------------------------------- | -| `Option A (depth)… Option B (speed)` | Binary choice menu offloading judgment. Pick depth. | -| `maximally useful vs maximally shipped` | Same framing — execute the perfectionist path. | -| `ship-it precision`, `ship-it-now` | Velocity euphemism. Use only when user time-boxed. | -| `depth over breadth?` / `breadth over depth?` | The default IS depth (perfectionist). | -| `speed vs depth`, `fast vs right`, `now vs correct` | Speed-vs-quality framing. Perfectionist is default. | -| `if you say A … if you say B` | Binary choice architecture pretending to be helpful. | -| `plow through vs do it right` | Same pattern — velocity vs care. | - -## Legitimate exceptions - -The hook can't tell from text alone whether the trade-off is real: - -- **User explicitly asked** "is this worth doing fully?" — they introduced the dichotomy. -- **Time-boxed engagement** — the user said "we have 1 hour" and the work needs more. -- **Off-machine action required** — the perfectionist path needs gh dispatch / npm publish / infra access. - -In all three cases, the menu is genuinely useful framing. The hook still flags it; the user reads the warning and decides. - -## Why it doesn't block - -Stop hooks fire after the assistant has produced its response. Blocking would truncate. The warning surfaces alongside the response so the user reads both and can push back next turn. - -## Configuration - -`SOCKET_PERFECTIONIST_REMINDER_DISABLED=1` — turn off entirely. - -## Relationship to excuse-detector - -`excuse-detector` catches **fix vs defer** ("should I implement X or accept as gap?"). This hook catches **depth vs speed** ("should I do it properly or ship a quick version?"). Different failure modes, same underlying anti-pattern: a choice menu where the user already picked. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/perfectionist-reminder/index.mts b/.claude/hooks/perfectionist-reminder/index.mts deleted file mode 100644 index 135a46153..000000000 --- a/.claude/hooks/perfectionist-reminder/index.mts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — perfectionist-reminder. -// -// Flags speed-vs-depth choice menus in the assistant's most recent -// turn. CLAUDE.md "Judgment & self-evaluation" says "Default to -// perfectionist when you have latitude" — so when the assistant -// presents a choice between "speed" and "depth" / "correctness" -// without the user having asked for the trade-off, it's the same -// failure pattern as the excuse-detector's fix-vs-defer menu: -// offloading a decision the assistant should have made. -// -// What this catches (regex on code-fence-stripped text): -// -// - "Option A (depth): ... Option B (speed): ..." -// - "Maximally useful vs maximally shipped" -// - "Ship-it precision" / "ship-it-now" -// - "Depth over breadth?" / "breadth over depth?" -// - "Speed vs depth" / "speed vs correctness" / "fast vs right" -// - "If you say A I'll ... if you say B I'll ..." (binary choice -// architecture) -// -// Exceptions: the user explicitly asked which approach to take, or -// the trade-off is genuinely irreducible (time-boxed engagement, -// off-machine action required). The hook can't tell from text alone; -// it just flags the pattern. The user reads the warning and decides -// if it's legitimate or pushback-worthy. -// -// Disable via SOCKET_PERFECTIONIST_REMINDER_DISABLED. - -import { runStopReminder } from '../_shared/stop-reminder.mts' - -await runStopReminder({ - name: 'perfectionist-reminder', - disabledEnvVar: 'SOCKET_PERFECTIONIST_REMINDER_DISABLED', - patterns: [ - { - label: 'option A (depth/correctness) … option B (speed/shipped)', - regex: - /\boption\s+a\b[^.?!\n]{0,80}\b(?:correctness|depth|proper|thorough)\b[\s\S]{0,200}\boption\s+b\b[^.?!\n]{0,80}\b(?:breadth|fast|ship|speed)\b/i, - why: 'Speed-vs-depth choice menu. Per CLAUDE.md "Default to perfectionist when you have latitude" — pick depth and execute.', - }, - { - label: 'maximally useful vs maximally shipped', - regex: - /\bmaximally\s+(?:correct|thorough|useful)\b[\s\S]{0,80}\bmaximally\s+(?:fast|quick|shipped)\b/i, - why: 'Same pattern — re-litigating perfectionist-vs-velocity. User already chose perfectionist.', - }, - { - label: 'ship-it precision / ship-it-now', - regex: /\bship[- ]it[- ]?(?:fast|now|precision|version)\b/i, - why: 'Velocity-framed; CLAUDE.md says perfectionist default. Use unless user explicitly time-boxed.', - }, - { - label: 'depth over breadth / breadth over depth', - regex: /\b(?:depth\s+over\s+breadth|breadth\s+over\s+depth)\?/i, - why: 'The CLAUDE.md default is depth (perfectionist). Pick it.', - }, - { - label: 'speed vs depth / fast vs right / now vs correct', - regex: - /\b(?:fast|now|quick|speed)\s+vs\.?\s+(?:correct|depth|proper|right|thorough)\b/i, - why: 'Same speed-vs-quality framing; perfectionist is the default unless user opted out.', - }, - { - label: 'if you say A … if you say B', - regex: /\bif\s+you\s+say\s+a\b[\s\S]{0,200}\bif\s+you\s+say\s+b\b/i, - why: 'Binary choice architecture — masquerades as helpful framing but offloads judgment to user.', - }, - { - label: 'plow through vs do it right', - regex: - /\bplow\s+(?:ahead|through)\b[\s\S]{0,80}\b(?:carefully|correctly|properly|right)\b/i, - why: 'Same pattern (velocity vs care). Default perfectionist.', - }, - ], - closingHint: - 'CLAUDE.md "Judgment & self-evaluation": "Default to perfectionist when you have latitude." If the user already gave perfectionist signals (asked for correctness, asked for depth, said "do it right"), do not re-present the choice — execute the perfectionist path.', -}) diff --git a/.claude/hooks/perfectionist-reminder/package.json b/.claude/hooks/perfectionist-reminder/package.json deleted file mode 100644 index 3583aecd0..000000000 --- a/.claude/hooks/perfectionist-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-perfectionist-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/perfectionist-reminder/test/index.test.mts b/.claude/hooks/perfectionist-reminder/test/index.test.mts deleted file mode 100644 index 9b320fd3f..000000000 --- a/.claude/hooks/perfectionist-reminder/test/index.test.mts +++ /dev/null @@ -1,137 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): { - path: string - cleanup: () => void -} { - const dir = mkdtempSync(path.join(os.tmpdir(), 'perfectionist-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const lines = [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ role: 'assistant', content: assistantText }), - ].join('\n') - writeFileSync(transcriptPath, lines) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags Option A / Option B depth-vs-speed menu', () => { - const { path: p, cleanup } = makeTranscript( - 'Option A (depth): I do 4-5 hooks well. Option B (speed): I ship all 12 with regex-only.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /perfectionist-reminder/) - assert.match(stderr, /option/i) - } finally { - cleanup() - } -}) - -test('flags maximally useful vs maximally shipped', () => { - const { path: p, cleanup } = makeTranscript( - 'Should I go for maximally useful (proper) or maximally shipped (fast)?', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /maximally/) - } finally { - cleanup() - } -}) - -test('flags ship-it precision framing', () => { - const { path: p, cleanup } = makeTranscript( - 'I could do this with ship-it precision and iterate later.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /ship-it/) - } finally { - cleanup() - } -}) - -test('flags speed vs depth phrasing', () => { - const { path: p, cleanup } = makeTranscript( - 'This is a speed vs depth question — which way?', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /speed/i) - } finally { - cleanup() - } -}) - -test('flags "if you say A / if you say B" binary choice', () => { - const { path: p, cleanup } = makeTranscript( - 'If you say A I will do all 12 properly. If you say B I will ship regex-only.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /if you say/i) - } finally { - cleanup() - } -}) - -test('does not flag plain technical prose', () => { - const { path: p, cleanup } = makeTranscript( - 'The cache stores parsed results keyed by file path. Each entry expires after 10 minutes.', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does not false-positive on phrases inside code fences', () => { - const { path: p, cleanup } = makeTranscript( - 'Plain output here.\n```\nspeed vs depth (this is in code)\n```\nMore prose.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript( - 'Option A (depth) or Option B (speed)?', - ) - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_PERFECTIONIST_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/perfectionist-reminder/tsconfig.json b/.claude/hooks/perfectionist-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/perfectionist-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/plan-location-guard/README.md b/.claude/hooks/plan-location-guard/README.md deleted file mode 100644 index d1f1f9e8a..000000000 --- a/.claude/hooks/plan-location-guard/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# plan-location-guard - -PreToolUse hook that blocks plan-shaped `.md` writes to tracked locations. - -## What it blocks - -Edit / Write / MultiEdit on a markdown file is blocked when: - -1. The target path lives under `docs/plans/` (at any depth), OR -2. The target path lives under a sub-package `.claude/plans/` (i.e. - any `.claude/plans/` that is NOT at the repo root — detected by - the presence of a `packages/`, `apps/`, or `crates/` segment in - the path prefix, OR by finding a second `.claude/plans/` deeper - than the first). - -AND the doc looks like a plan, per a narrow heuristic: - -- Filename stem contains one of: `plan`, `roadmap`, `migration`, - `design`, `next-steps`, `dispatcher-plan`. -- OR the first heading of the content contains one of: `plan`, - `roadmap`, `migration plan`, `design doc`. - -Both conditions must be true to block — paths that look like plan -_locations_ but don't have plan-shaped content are pass-through. This -keeps the hook narrow; the goal is to catch the specific failure -mode where a design doc gets dropped into `docs/plans/`. - -## What it allows - -- `/.claude/plans/.md` — the canonical home (untracked). -- Random `.md` writes outside `docs/plans/` and `.claude/plans/`. -- Markdown writes that don't look like plans (e.g. a `README.md` that - happens to live under `docs/plans/`). -- Bash / Read / non-Edit tool calls. - -## Bypass phrase - -`Allow plan-location bypass` — the user types this verbatim in a -recent (last 8 user turns) message. The hook reads the transcript via -the `_shared/transcript.mts` helper. - -## Why a hook on top of the CLAUDE.md rule - -The CLAUDE.md rule documents the convention. The hook is the actual -enforcement at edit time. The recurring failure mode this rule was -written to address: socket-btm grew three parallel `docs/plans/` -directories (root, package-level, `.claude/plans/`) — same content -type, all tracked, all drifting. Without an edit-time guard, that -failure mode recurs every session a new agent reaches for "the -obvious place" to put a plan. - -## Reading - -- `docs/claude.md/fleet/plan-storage.md` — full rule + migration playbook. -- CLAUDE.md → `### Plan storage` — inline summary. diff --git a/.claude/hooks/plan-location-guard/index.mts b/.claude/hooks/plan-location-guard/index.mts deleted file mode 100644 index 7e1eb6226..000000000 --- a/.claude/hooks/plan-location-guard/index.mts +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — plan-location-guard. -// -// Blocks Edit/Write/MultiEdit operations that try to land a -// design/implementation/migration *plan* document at a tracked -// location instead of `/.claude/plans/.md`. Per the -// fleet "Plan storage" rule, plans are working notes and must not be -// tracked by version control. -// -// Blocked target paths (case-insensitive on the `plans/` segment, -// any depth from repo root): -// -// - `**/docs/plans/**/*.md` -// The classic "I wrote a design doc somewhere visible" failure -// mode. Covers root `docs/plans/` and any package-level -// `/docs/plans/`. -// -// - `**//.claude/plans/**/*.md` (i.e. .claude/plans/ that is -// NOT at the repo root) -// Sub-package .claude/ trees are not part of the operator's -// session-level .claude/ — the canonical operator dir is the -// repo root. -// -// Allowed: -// - `/.claude/plans/**/*.md` — the canonical home. -// - Any `.md` whose filename, headings, and content do NOT look -// like a plan (we only block when filename + content match the -// plan-shape heuristic; other docs are out of scope). -// -// Heuristic for "looks like a plan" — at least one of: -// - Filename contains `plan`, `roadmap`, `migration`, `dispatcher-plan`, -// `design`, `next-steps`, or `*-plan-*.md` shape. -// - File content (the `new_string` / `content` payload from -// Edit/Write) opens with a `# ` heading whose words -// include "plan", "roadmap", "migration plan", or "design doc". -// -// The heuristic is intentionally narrow: this hook is not trying to -// classify every .md file in the fleet — it's catching the specific -// failure mode where someone writes a design doc into `docs/plans/` -// because that's what "feels right." Random `.md` writes outside -// `docs/plans/` and `.claude/plans/` are pass-through. -// -// Bypass phrase: `Allow plan-location bypass`. Reading recent user -// turns follows the same pattern as no-revert-guard / -// no-fleet-fork-guard. -// -// Why a hook on top of the CLAUDE.md rule: the rule documents the -// convention; the hook is the actual enforcement at edit time. -// Catches the recurring failure mode where Claude or a parallel -// session writes a design doc into `docs/plans/` because that's the -// historical convention (see the socket-btm migration that triggered -// this rule — three parallel `docs/plans/` directories drifted). -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit" | "Write" | "MultiEdit", -// "tool_input": { "file_path": "...", -// "content"?: "...", -// "new_string"?: "..." }, -// "transcript_path": "/.../session.jsonl" } -// -// Exits: -// 0 — allowed. -// 2 — blocked (with stderr message that explains rule + fix + -// bypass phrase). -// 0 (with stderr log) — fail-open on hook bugs. - -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow plan-location bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -// Filename-stem tokens that mark a doc as "plan-shaped." The check -// is on the base name (extension stripped, lowercased). -const PLAN_FILENAME_TOKENS = [ - 'plan', - 'roadmap', - 'migration', - 'design', - 'next-steps', - 'dispatcher-plan', -] - -// First-heading tokens that mark a doc as "plan-shaped." Checked -// against the first non-blank line of the new content if the -// filename heuristic didn't fire. -const PLAN_HEADING_TOKENS = ['plan', 'roadmap', 'migration plan', 'design doc'] - -/** - * Lowercased filename without extension. Returns empty string for paths without - * a basename. - */ -export function basenameStem(filePath: string): string { - const base = path.basename(filePath) - const dot = base.lastIndexOf('.') - const stem = dot > 0 ? base.slice(0, dot) : base - return stem.toLowerCase() -} - -/** - * Classify the target path. Returns: - * - * - 'allowed-root-claude-plans' — under <something>/.claude/plans/ - * - 'blocked-docs-plans' — under <something>/docs/plans/ - * - 'blocked-sub-claude-plans' — under <something>/<sub>/.claude/plans/ (i.e. not - * at the first .claude/ segment) - * - 'irrelevant' — none of the above - * - * The classification is purely lexical on the resolved path. It does NOT walk - * for a repo root, since the fleet rule applies to any docs/plans/ regardless - * of repo context — including the case where a script under /tmp tries to write - * into a project tree. - */ -export function classifyPath(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/') - const segs = normalized.split('/') - - // Find the FIRST `.claude/plans/` segment pair vs any DEEPER one. - // The "first" one nearest the root is the canonical operator dir; - // anything deeper (i.e. `<pkg>/.claude/plans/`) is a sub-package - // plans dir and is forbidden. - let firstClaudeIdx = -1 - for (let i = 0; i < segs.length - 1; i++) { - if (segs[i] === '.claude' && segs[i + 1] === 'plans') { - firstClaudeIdx = i - break - } - } - - if (firstClaudeIdx !== -1) { - // Look for a SECOND `.claude/plans/` deeper than the first. - for (let i = firstClaudeIdx + 2; i < segs.length - 1; i++) { - if (segs[i] === '.claude' && segs[i + 1] === 'plans') { - return 'blocked-sub-claude-plans' - } - } - // Check whether the first `.claude/plans/` is itself nested under - // another package directory (heuristic: preceded by `packages/`, - // `apps/`, or `crates/` in the parent path). - const prefix = segs.slice(0, firstClaudeIdx).join('/') - if ( - prefix.includes('/packages/') || - prefix.includes('/apps/') || - prefix.includes('/crates/') - ) { - return 'blocked-sub-claude-plans' - } - return 'allowed-root-claude-plans' - } - - // Look for any `docs/plans/` segment pair. - for (let i = 0; i < segs.length - 1; i++) { - if (segs[i] === 'docs' && segs[i + 1] === 'plans') { - return 'blocked-docs-plans' - } - } - - return 'irrelevant' -} - -export function contentLooksLikePlan(content: string | undefined): boolean { - if (!content) { - return false - } - // First non-blank line. - let firstLine = '' - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (trimmed) { - firstLine = trimmed.toLowerCase() - break - } - } - if (!firstLine.startsWith('#')) { - return false - } - return PLAN_HEADING_TOKENS.some(token => firstLine.includes(token)) -} - -export function filenameLooksLikePlan(filePath: string): boolean { - const stem = basenameStem(filePath) - if (!stem) { - return false - } - return PLAN_FILENAME_TOKENS.some(token => stem.includes(token)) -} - -async function main(): Promise<number> { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'plan-location-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return 0 - } - - const filePath = payload.tool_input?.file_path - if (!filePath) { - return 0 - } - - // Only target markdown files. - if (!filePath.toLowerCase().endsWith('.md')) { - return 0 - } - - const classification = classifyPath(filePath) - if ( - classification !== 'blocked-docs-plans' && - classification !== 'blocked-sub-claude-plans' - ) { - return 0 - } - - // Apply the plan-shape heuristic. If the doc clearly looks like a - // plan (filename OR opening heading), block. If neither fires, this - // is probably a coincidence (e.g. an unrelated doc that happened - // to live under docs/plans/ for historical reasons) — let it through - // and let the human decide. - const content = payload.tool_input?.new_string ?? payload.tool_input?.content - const looksLikePlan = - filenameLooksLikePlan(filePath) || contentLooksLikePlan(content) - if (!looksLikePlan) { - return 0 - } - - // Bypass-phrase check. - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - const suggestion = - classification === 'blocked-docs-plans' - ? 'Move the plan to <repo-root>/.claude/plans/<lowercase-hyphenated>.md (untracked by default).' - : 'Move the plan to the REPO-ROOT .claude/plans/ — sub-package .claude/plans/ is not the canonical home.' - - process.stderr.write( - [ - `🚨 plan-location-guard: blocked plan-shaped .md write at a tracked location.`, - ``, - `File: ${filePath}`, - `Classification: ${classification}`, - ``, - `Per the fleet "Plan storage" rule (CLAUDE.md → Plan storage),`, - `plans live at <repo-root>/.claude/plans/<name>.md and must NOT`, - `be tracked by version control. The fleet .gitignore excludes`, - `/.claude/* and intentionally omits plans/ from the allowlist —`, - `so a plan written to the canonical path is untracked by default.`, - ``, - `Fix:`, - ` ${suggestion}`, - ``, - `Background reading:`, - ` docs/claude.md/fleet/plan-storage.md`, - ``, - `One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim`, - `in a recent message.`, - ``, - ].join('\n'), - ) - return 2 -} - -main().then( - code => process.exit(code), - err => { - process.stderr.write( - `plan-location-guard: hook error — fail-open: ${String(err)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/plan-location-guard/package.json b/.claude/hooks/plan-location-guard/package.json deleted file mode 100644 index 3f32f24ce..000000000 --- a/.claude/hooks/plan-location-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-plan-location-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/plan-location-guard/test/index.test.mts b/.claude/hooks/plan-location-guard/test/index.test.mts deleted file mode 100644 index 9c7c1e927..000000000 --- a/.claude/hooks/plan-location-guard/test/index.test.mts +++ /dev/null @@ -1,216 +0,0 @@ -// node --test specs for the plan-location-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-markdown files pass through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/script.ts', - content: '// not a markdown file', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('blocks plan-shaped doc under docs/plans/ at repo root', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - content: '# Migration plan\n\nSteps:\n\n1. ...', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /plan-location-guard: blocked/) - assert.match(result.stderr, /docs-plans/) -}) - -test('blocks plan-shaped doc under package-level docs/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: - '/Users/x/projects/foo/packages/bar/docs/plans/refactor-plan.md', - content: '# Refactor plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /docs-plans/) -}) - -test('allows plan under repo-root .claude/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/.claude/plans/my-plan.md', - content: '# My plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('blocks plan under sub-package .claude/plans/', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/packages/bar/.claude/plans/sub-plan.md', - content: '# Sub-package plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /sub-claude-plans/) -}) - -test('blocks plan under a SECOND .claude/plans/ deeper than the first', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/.claude/plans/outer/.claude/plans/inner.md', - content: '# Inner plan', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /sub-claude-plans/) -}) - -test('blocks README.md whose heading mentions "plans" (heading heuristic)', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/README.md', - content: - '# Plans directory\n\nThis directory holds historical plan archives.', - }, - tool_name: 'Write', - }) - // Filename ("readme") is benign but the heading "# Plans directory" - // contains a plan-shape token. The heuristic is intentionally - // OR-shaped — either signal blocks. - assert.strictEqual(result.code, 2) -}) - -test("allows truly-unrelated doc under docs/plans/ that doesn't look like a plan", async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/index.md', - content: '# Archive index\n\nLinks to historical artifacts.', - }, - tool_name: 'Write', - }) - // Neither filename ("index") nor heading ("Archive index") contains - // a plan-shape token. Pass-through. - assert.strictEqual(result.code, 0) -}) - -test('blocks Edit (not just Write) to plan-shaped path', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - new_string: 'updated # Migration plan content', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('detects plan via filename when content is missing', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/roadmap.md', - }, - tool_name: 'Write', - }) - // Filename contains 'roadmap' — plan-shaped. Block. - assert.strictEqual(result.code, 2) -}) - -test('respects bypass phrase in recent user turn', async t => { - // Build a transcript file containing the bypass phrase. - const { writeFile, mkdtemp, rm } = await import('node:fs/promises') - const os = await import('node:os') - const tmp = await mkdtemp(path.join(os.tmpdir(), 'plan-location-test-')) - const transcriptPath = path.join(tmp, 'session.jsonl') - const turn = { - type: 'user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Allow plan-location bypass' }], - }, - } - await writeFile(transcriptPath, JSON.stringify(turn) + '\n', 'utf8') - t.after(async () => { - await rm(tmp, { recursive: true, force: true }) - }) - - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/docs/plans/migration-plan.md', - content: '# Migration plan', - }, - tool_name: 'Write', - transcript_path: transcriptPath, - }) - assert.strictEqual(result.code, 0) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('not valid json') - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) - assert.match(stderr, /fail-open/) -}) - -test('fails open on empty stdin', async () => { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - child.stdin!.end('') - const code: number = await new Promise(resolve => { - child.process.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0) -}) diff --git a/.claude/hooks/plan-location-guard/tsconfig.json b/.claude/hooks/plan-location-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/plan-location-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/plan-review-reminder/README.md b/.claude/hooks/plan-review-reminder/README.md deleted file mode 100644 index d93fb09d1..000000000 --- a/.claude/hooks/plan-review-reminder/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# plan-review-reminder - -Stop hook that nudges when an assistant turn proposes a plan in prose without the structured shape the fleet's "Plan review before approval" rule requires. - -## What it catches - -- **Plan phrase without numbered list** — "Here's the plan:" / "My plan is" / "Steps:" / "Approach:" / "I will:" / "Step 1" followed by paragraph prose and no `1.` / `1)` line within 800 characters. -- **Fleet-shared edits without second-opinion invite** — when the plan mentions `CLAUDE.md` / `.claude/hooks/` / `_shared/` / `template/CLAUDE.md` / `sync-scaffolding` / `scripts/fleet` but does not invite a "second opinion" / "review the plan" / "sanity check" / "pair review" pass. - -## Bypass - -- `SOCKET_PLAN_REVIEW_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/plan-review-reminder/index.mts b/.claude/hooks/plan-review-reminder/index.mts deleted file mode 100644 index 4e340d516..000000000 --- a/.claude/hooks/plan-review-reminder/index.mts +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — plan-review-reminder. -// -// Flags assistant turns that propose a multi-step plan in prose form -// without the structured shape the fleet's "Plan review before -// approval" rule requires: numbered steps, named files, named rules. -// -// What this hook catches: -// -// - Phrases that announce a plan ("Here's the plan:", "My plan is", -// "I will:", "Steps:", "Approach:") followed by paragraph prose -// and NO numbered list within ~20 lines after. -// -// - Plans that announce fleet-shared edits (CLAUDE.md, hooks/, -// _shared/) without inviting a second-opinion pass. -// -// Heuristic: this is a soft reminder, not a blocker. False positives -// (a quick informal "my plan: just do X") are expected; the cost is -// a single stderr block that the next turn can ignore. -// -// Disable via SOCKET_PLAN_REVIEW_REMINDER_DISABLED. - -import process from 'node:process' - -import { - readLastAssistantText, - readStdin, - stripCodeFences, -} from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -// Plan-announcement phrases. Each fires only if the announcement is -// NOT followed (within a window of text) by a numbered list. -const PLAN_PHRASE_RE = - /\b(?:here'?s the plan|my plan is|i will:|approach:|steps:|step 1)\b/i - -// Numbered-list shape: "1." or "1)" at line start. -const NUMBERED_LIST_RE = /^\s*1\s*[.)]\s+\S/m - -// Fleet-shared resources whose edits should invite a second-opinion pass. -const FLEET_SHARED_RE = - /\b(?:CLAUDE\.md|\.claude\/hooks\/|_shared\/|template\/CLAUDE\.md|sync-scaffolding|scripts\/fleet)\b/ - -// Second-opinion-invitation phrases. -const SECOND_OPINION_RE = - /\b(?:second[- ]opinion|review (?:the|this) plan|sanity[- ]check|pair[- ]review|invite a review)\b/i - -async function main(): Promise<void> { - const payloadRaw = await readStdin() - if (process.env['SOCKET_PLAN_REVIEW_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const rawText = readLastAssistantText(payload.transcript_path) - if (!rawText) { - process.exit(0) - } - const text = stripCodeFences(rawText) - - const hits: string[] = [] - - // Check 1: plan announcement without numbered list. - const planMatch = PLAN_PHRASE_RE.exec(text) - if (planMatch) { - const afterPlan = text.slice(planMatch.index, planMatch.index + 800) - if (!NUMBERED_LIST_RE.test(afterPlan)) { - hits.push( - 'plan announced but no numbered list within 800 chars — ' + - 'per "Plan review before approval", list steps numerically, ' + - "name files you'll touch, name rules you'll honor", - ) - } - } - - // Check 2: fleet-shared edits without second-opinion invite. The - // fleet-shared scan runs on rawText, not the code-fence-stripped - // copy — paths like `template/CLAUDE.md` are usually quoted in - // backticks and would be stripped otherwise. - if (FLEET_SHARED_RE.test(rawText) && !SECOND_OPINION_RE.test(text)) { - // Only fire if it really looks like a plan (rather than just a - // mention of a fleet path in passing). Check both the raw text - // (which keeps the I'll context) and the stripped text. - if ( - PLAN_PHRASE_RE.test(text) || - /\b(?:I'?ll|I will|I'm going to)\b/i.test(rawText) - ) { - hits.push( - 'plan touches fleet-shared resources (CLAUDE.md / .claude/hooks/ / ' + - '_shared/) but does not invite a second-opinion pass — per ' + - 'CLAUDE.md "Plan review before approval", invite review before code', - ) - } - } - - if (hits.length === 0) { - process.exit(0) - } - - const lines = ['[plan-review-reminder] Plan structure check:', ''] - for (let i = 0, { length } = hits; i < length; i += 1) { - lines.push(` • ${hits[i]}`) - } - lines.push('') - lines.push( - ' See CLAUDE.md "Plan review before approval" — the plan itself is a deliverable.', - ) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/plan-review-reminder/package.json b/.claude/hooks/plan-review-reminder/package.json deleted file mode 100644 index f3c52761c..000000000 --- a/.claude/hooks/plan-review-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-plan-review-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/plan-review-reminder/test/index.test.mts b/.claude/hooks/plan-review-reminder/test/index.test.mts deleted file mode 100644 index f130b91b4..000000000 --- a/.claude/hooks/plan-review-reminder/test/index.test.mts +++ /dev/null @@ -1,85 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(assistantText: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'planreview-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: 'plan this' }) + - '\n' + - JSON.stringify({ role: 'assistant', content: assistantText }), - ) - return transcriptPath -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS "Here\'s the plan" without numbered list', () => { - const t = makeTranscript( - "Here's the plan: I'll touch a few files, fix the bug, run tests. Done.", - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /plan-review-reminder/) - assert.match(stderr, /numbered list/) -}) - -test('does NOT fire when plan has numbered list', () => { - const t = makeTranscript( - "Here's the plan:\n\n1. Read file foo.ts\n2. Apply Edit\n3. Run pnpm test", - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('FLAGS fleet-shared mention without second-opinion invite', () => { - const t = makeTranscript( - "I'll edit `template/CLAUDE.md` to add a new rule, then update `.claude/hooks/foo/`.", - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.match(stderr, /fleet-shared/) -}) - -test('does NOT fire when fleet-shared edit has second-opinion invite', () => { - const t = makeTranscript( - "Here's the plan:\n\n1. Edit `template/CLAUDE.md`\n2. Invite a second-opinion pass before code.", - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does NOT fire on plain non-plan prose', () => { - const t = makeTranscript( - 'I fixed the bug by removing the stale assertion in foo.ts:42.', - ) - const { stderr, exitCode } = runHook(t) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const t = makeTranscript("Here's the plan: do stuff.") - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: t }), - env: { ...process.env, SOCKET_PLAN_REVIEW_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') -}) diff --git a/.claude/hooks/plan-review-reminder/tsconfig.json b/.claude/hooks/plan-review-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/plan-review-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/plugin-patch-format-guard/README.md b/.claude/hooks/plugin-patch-format-guard/README.md deleted file mode 100644 index c58c4b1a4..000000000 --- a/.claude/hooks/plugin-patch-format-guard/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# plugin-patch-format-guard - -PreToolUse Edit/Write hook that blocks malformed plugin-cache patches under `scripts/plugin-patches/`. - -## What it enforces - -The runtime consumer is `scripts/install-claude-plugins.mts` — its `reapplyPluginPatches()` parses each patch filename, strips the `# @key:` header, and feeds the body to `patch -p1`. A patch that doesn't match the convention is skipped (or fails to apply) at reconcile time. This hook catches the mistake at edit time instead. Rules: - -1. **Filename** matches `<plugin>-<version>-<slug>.patch` — lowercase-kebab plugin, dotted semver version, lowercase-kebab slug (e.g. `codex-1.0.1-stdin-eagain.patch`). -2. **Header** carries all four provenance keys as line-start comments: `# @plugin:`, `# @plugin-version:`, `# @sha:`, `# @description:` (`# @upstream:` is recommended but not required). -3. **Plain unified diff body** — must contain a `--- ` line, and must NOT contain git-diff markers: `diff --git`, `index <hash>..<hash>`, `new file mode`. `patch -p1` doesn't expect git markers; they break the apply. -4. **Version cross-check** — the `# @plugin-version:` value must match the version embedded in the filename (they map to the same plugin-cache dir). - -## Scope - -Fires only when the target `file_path` resolves under `scripts/plugin-patches/` and ends in `.patch` (normalized to `/`-separators first). Everything else passes through untouched. - -`Write` carries the whole file in `tool_input.content`, so it's fully validated. `Edit` only carries a `new_string` fragment — the hook can't see the surrounding file, so an `Edit` without `content` is skipped (the next `Write` or commit-time path catches it). - -## Why - -A plugin-cache patch is replayed over a cache Claude Code regenerates on every install. The format is load-bearing: the filename maps to the cache dir, the header carries provenance, and the body must be a tool-`patch`-compatible plain diff. Git-diff output (`git diff` / `git format-patch`) injects `index`/`mode` markers that bare `patch` rejects — a classic foot-gun this gate closes. Full spec: [`docs/claude.md/fleet/plugin-cache-patches.md`](../../../docs/claude.md/fleet/plugin-cache-patches.md). Regenerate stale patches via the `regenerating-plugin-patches` skill. - -## No bypass - -This is a pure format gate, not a policy gate — there's no `Allow … bypass` phrase. A malformed patch is always wrong; fix the patch. - -## Companion files - -- `index.mts` — the hook (exports `classifyPluginPatch`, `isPluginPatchPath`, `emitBlock`). -- `test/index.test.mts` — node:test specs. -- `package.json` — workspace declaration so `taze` can see the hook's deps. -- `tsconfig.json` — fleet-canonical TS config. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. diff --git a/.claude/hooks/plugin-patch-format-guard/index.mts b/.claude/hooks/plugin-patch-format-guard/index.mts deleted file mode 100644 index 0501c839a..000000000 --- a/.claude/hooks/plugin-patch-format-guard/index.mts +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — plugin-patch-format-guard. -// -// Blocks Edit/Write tool calls that would write a plugin-cache patch -// (`scripts/plugin-patches/*.patch`) in a non-canonical shape. The -// runtime consumer is `install-claude-plugins.mts`'s -// `reapplyPluginPatches()`, which: parses the filename via -// `parsePatchFileName`, strips the `# @key: value` header via -// `stripPatchHeader`, then feeds the body to `patch -p1`. A patch that -// doesn't match the convention is silently skipped (or worse, fails to -// apply) at reconcile time — this hook catches the mistake at edit time. -// -// What it enforces (full spec: docs/claude.md/fleet/plugin-cache-patches.md): -// -// 1. Filename `<plugin>-<version>-<slug>.patch` — lowercase-kebab -// plugin, dotted semver version, lowercase-kebab slug. -// 2. Four required `# @key:` header lines: @plugin, @plugin-version, -// @sha, @description. -// 3. A PLAIN `diff -u` body: must have a `--- ` line, must NOT carry -// git-diff markers (`diff --git`, `index ab..cd`, `new file mode`). -// `patch` doesn't expect git markers; they break the apply. -// 4. The `# @plugin-version:` value must match the version embedded in -// the filename (best-effort cross-check). -// -// Validation needs the WHOLE file content. Write passes it as -// `tool_input.content`. Edit only passes a `new_string` fragment — we -// can't see the surrounding file, so an Edit without `content` is -// skipped (documented limitation; the commit-time path / the next Write -// catch it). No bypass — this is a pure format gate, not a policy gate. -// -// Exit code 2 makes Claude Code refuse the tool call. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit"|"Write", -// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } -// -// Fails open on hook bugs (exit 0 + stderr log). - -import process from 'node:process' - -import { - isAbsolute, - normalizePath, -} from '@socketsecurity/lib-stable/paths/normalize' - -import { readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - } - | undefined - tool_name?: string | undefined -} - -// <plugin>-<version>-<slug>.patch — lowercase-kebab plugin, dotted -// semver version, lowercase-kebab slug. Mirrors `PATCH_FILE_NAME` in -// scripts/install-claude-plugins.mts so the hook and the consumer agree. -const PATCH_FILE_NAME = /^[a-z0-9-]+-(\d+\.\d+\.\d+)-[a-z0-9-]+\.patch$/ - -// The four header keys the consumer's provenance block requires. -const REQUIRED_HEADER_KEYS = [ - '@plugin', - '@plugin-version', - '@sha', - '@description', -] as const - -// Line-start `# @plugin-version: <semver>` — used to cross-check the -// header version against the filename version. -const HEADER_PLUGIN_VERSION = /^# @plugin-version:\s*(\d+\.\d+\.\d+)\s*$/m - -type Verdict = { ok: true } | { ok: false; reason: string } - -/** - * Is the target file path a plugin-cache patch under - * `scripts/plugin-patches/`? Normalizes to `/`-separators first so the - * check is cross-platform (per the fleet path-regex-normalize rule), then - * matches the canonical dir + `.patch` extension. - */ -export function isPluginPatchPath(filePath: string): boolean { - const normalized = normalizePath(filePath) - // Match the dir segment with or without a leading slash so a (malformed) - // relative path is still recognized as a plugin patch — the caller then - // flags the non-absolute path rather than letting it slip past as "not a - // patch". `/scripts/plugin-patches/` (mid-path) and `scripts/plugin-patches/` - // (path start) both count. - return ( - /(?:^|\/)scripts\/plugin-patches\//.test(normalized) && - normalized.endsWith('.patch') - ) -} - -/** - * Pure classifier: given a patch filename + its full content, return a - * verdict. Exported for unit tests. Mirrors the runtime contract of - * `install-claude-plugins.mts` (filename → cache dir, header → provenance, - * plain `diff -u` body → `patch -p1`). - */ -export function classifyPluginPatch( - fileName: string, - content: string, -): Verdict { - // (1) Filename shape. - const nameMatch = PATCH_FILE_NAME.exec(fileName) - if (!nameMatch) { - return { - ok: false, - reason: - `Filename "${fileName}" must match <plugin>-<version>-<slug>.patch ` + - '(lowercase-kebab plugin, dotted semver version, lowercase-kebab ' + - 'slug). Example: codex-1.0.1-stdin-eagain.patch.', - } - } - const fileVersion = nameMatch[1]! - - // (2) Required header keys, each as a line-start `# @key:` comment. - const missing: string[] = [] - for (let i = 0, { length } = REQUIRED_HEADER_KEYS; i < length; i += 1) { - const key = REQUIRED_HEADER_KEYS[i]! - const re = new RegExp(`^# ${key}:`, 'm') - if (!re.test(content)) { - missing.push(`# ${key}:`) - } - } - if (missing.length) { - return { - ok: false, - reason: - `Missing required header line(s): ${missing.join(', ')}. Every ` + - 'plugin patch needs a `# @plugin:` / `# @plugin-version:` / ' + - '`# @sha:` / `# @description:` provenance header above the diff.', - } - } - - // (3) Plain unified diff body — must have a `--- ` line. - if (!/^--- /m.test(content)) { - return { - ok: false, - reason: - 'No `--- ` line found. The body must be a plain unified diff ' + - '(`diff -u` output) — `reapplyPluginPatches()` strips everything ' + - 'before the first `--- ` line and feeds the rest to `patch -p1`.', - } - } - - // (3b) Reject git-diff markers — `patch` doesn't expect them. - const lines = content.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - if (/^diff --git /.test(line)) { - return { - ok: false, - reason: - 'Body is a `git diff` (found `diff --git`). Use a plain ' + - '`diff -u a/file b/file` instead — git markers break `patch -p1`. ' + - 'Regenerate via the regenerating-plugin-patches skill.', - } - } - if (/^index [0-9a-f]+\.\./.test(line)) { - return { - ok: false, - reason: - 'Body has a git `index <hash>..<hash>` line. Use a plain ' + - '`diff -u` body (no git markers); regenerate via the ' + - 'regenerating-plugin-patches skill.', - } - } - if (/^new file mode /.test(line)) { - return { - ok: false, - reason: - 'Body has a git `new file mode` line. Use a plain `diff -u` ' + - 'body (no git markers); regenerate via the ' + - 'regenerating-plugin-patches skill.', - } - } - } - - // (4) Cross-check the header version against the filename version. - const headerMatch = HEADER_PLUGIN_VERSION.exec(content) - if (headerMatch) { - const headerVersion = headerMatch[1]! - if (headerVersion !== fileVersion) { - return { - ok: false, - reason: - `Version mismatch: filename says ${fileVersion}, ` + - `\`# @plugin-version:\` says ${headerVersion}. They map to the ` + - 'same plugin-cache dir, so they must agree. Fix one to match.', - } - } - } - - return { ok: true } -} - -export function emitBlock(filePath: string, reason: string): void { - const lines: string[] = [] - lines.push('[plugin-patch-format-guard] Blocked: malformed plugin patch.') - lines.push(` File: ${filePath}`) - lines.push(` Issue: ${reason}`) - lines.push('') - lines.push(' A plugin-cache patch must be:') - lines.push(' - named <plugin>-<version>-<slug>.patch (dotted semver),') - lines.push( - ' - headed by # @plugin: / # @plugin-version: / # @sha: / # @description:,', - ) - lines.push( - ' - a plain `diff -u` body (a/… b/…, NO `diff --git`/`index`/`mode`).', - ) - lines.push(' Spec: docs/claude.md/fleet/plugin-cache-patches.md') - process.stderr.write(lines.join('\n') + '\n') -} - -async function main(): Promise<void> { - const raw = await readStdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - return - } - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath || !isPluginPatchPath(filePath)) { - return - } - // PreToolUse always hands hooks an absolute file_path. A relative one is - // anomalous — the path-match + filename-derivation below assume an absolute - // path, so flag it rather than silently mis-derive the cache mapping. - if (!isAbsolute(filePath)) { - process.stderr.write( - `[plugin-patch-format-guard] Blocked: file_path must be absolute.\n` + - ` Where: tool_input.file_path = "${filePath}"\n` + - ` Saw: a relative path; wanted an absolute path (PreToolUse ` + - `always passes one).\n` + - ` Fix: pass the absolute path to the patch under ` + - `scripts/plugin-patches/.\n`, - ) - process.exitCode = 2 - return - } - // Validation needs the whole file. Write carries it in `content`; an - // Edit only carries a `new_string` fragment, so we can't see the full - // file — skip the Edit-without-content case rather than guess. - const content = payload.tool_input?.content - if (typeof content !== 'string') { - return - } - const fileName = normalizePath(filePath).split('/').pop() ?? '' - const verdict = classifyPluginPatch(fileName, content) - if (verdict.ok) { - return - } - emitBlock(filePath, verdict.reason) - process.exitCode = 2 -} - -main().catch(e => { - process.stderr.write( - `[plugin-patch-format-guard] hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/plugin-patch-format-guard/package.json b/.claude/hooks/plugin-patch-format-guard/package.json deleted file mode 100644 index 49f8d3096..000000000 --- a/.claude/hooks/plugin-patch-format-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-plugin-patch-format-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts b/.claude/hooks/plugin-patch-format-guard/test/index.test.mts deleted file mode 100644 index 0a6c124c3..000000000 --- a/.claude/hooks/plugin-patch-format-guard/test/index.test.mts +++ /dev/null @@ -1,251 +0,0 @@ -// node --test specs for the plugin-patch-format-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { classifyPluginPatch, isPluginPatchPath } from '../index.mts' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const PATCH_PATH = - '/Users/x/projects/foo/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch' - -const VALID_PATCH = `# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @upstream: https://github.com/openai/codex-plugin-cc -# @description: Fix EAGAIN on stdin read -# ---- a/scripts/lib/fs.mjs -+++ b/scripts/lib/fs.mjs -@@ -32,9 +32,39 @@ - context --old -+new - context -` - -// --- Unit tests for the pure classifier. --- - -test('classifyPluginPatch: valid patch passes', () => { - const verdict = classifyPluginPatch( - 'codex-1.0.1-stdin-eagain.patch', - VALID_PATCH, - ) - assert.deepStrictEqual(verdict, { ok: true }) -}) - -test('classifyPluginPatch: bad filename blocks', () => { - for (const name of [ - 'codex-1.0-x.patch', // version not dotted-semver - 'Codex-1.0.1-x.patch', // uppercase plugin - 'codex-1.0.1-X.patch', // uppercase slug - 'codex-1.0.1.patch', // missing slug - 'codex-1.0.1-x.diff', // wrong extension - ]) { - const verdict = classifyPluginPatch(name, VALID_PATCH) - assert.strictEqual(verdict.ok, false, `${name} should be blocked`) - if (!verdict.ok) { - assert.match(verdict.reason, /<plugin>-<version>-<slug>\.patch/) - } - } -}) - -test('classifyPluginPatch: missing each required header key blocks', () => { - const keys = ['@plugin', '@plugin-version', '@sha', '@description'] as const - for (const key of keys) { - // Drop just the line for `key`. Use a per-key version match for - // @plugin-version so the cross-check doesn't pre-empt the header check. - const content = VALID_PATCH.split('\n') - .filter(line => !line.startsWith(`# ${key}:`)) - .join('\n') - const verdict = classifyPluginPatch('codex-1.0.1-x.patch', content) - assert.strictEqual(verdict.ok, false, `missing ${key} should block`) - if (!verdict.ok) { - assert.match(verdict.reason, /header/i) - } - } -}) - -test('classifyPluginPatch: git-diff markers block', () => { - const gitDiffGit = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const v1 = classifyPluginPatch('codex-1.0.1-x.patch', gitDiffGit) - assert.strictEqual(v1.ok, false) - if (!v1.ok) { - assert.match(v1.reason, /diff --git/) - } - - const gitIndex = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'index ab12cd34..ef56ab78 100644\n--- a/scripts/lib/fs.mjs', - ) - const v2 = classifyPluginPatch('codex-1.0.1-x.patch', gitIndex) - assert.strictEqual(v2.ok, false) - if (!v2.ok) { - assert.match(v2.reason, /index/) - } - - const gitNewFile = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'new file mode 100644\n--- a/scripts/lib/fs.mjs', - ) - const v3 = classifyPluginPatch('codex-1.0.1-x.patch', gitNewFile) - assert.strictEqual(v3.ok, false) - if (!v3.ok) { - assert.match(v3.reason, /new file mode/) - } -}) - -test('classifyPluginPatch: missing diff body blocks', () => { - const headerOnly = `# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @description: no diff body -# -` - const verdict = classifyPluginPatch('codex-1.0.1-x.patch', headerOnly) - assert.strictEqual(verdict.ok, false) - if (!verdict.ok) { - assert.match(verdict.reason, /--- /) - } -}) - -test('classifyPluginPatch: version/filename mismatch blocks', () => { - // Filename says 2.0.0, header says 1.0.1. - const verdict = classifyPluginPatch('codex-2.0.0-x.patch', VALID_PATCH) - assert.strictEqual(verdict.ok, false) - if (!verdict.ok) { - assert.match(verdict.reason, /mismatch/i) - } -}) - -test('isPluginPatchPath: matches only scripts/plugin-patches/*.patch', () => { - assert.strictEqual(isPluginPatchPath(PATCH_PATH), true) - assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/other/codex-1.0.1-x.patch'), - false, - ) - assert.strictEqual( - isPluginPatchPath('/Users/x/projects/foo/scripts/plugin-patches/notes.md'), - false, - ) -}) - -// --- Integration tests through the hook subprocess. --- - -test('hook: non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: non-patch files pass through', async () => { - const result = await runHook({ - tool_input: { - content: 'export const X = 1', - file_path: '/Users/x/projects/foo/src/index.mts', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: valid patch via Write passes', async () => { - const result = await runHook({ - tool_input: { content: VALID_PATCH, file_path: PATCH_PATH }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0, result.stderr) -}) - -test('hook: git-diff body via Write blocks', async () => { - const gitDiff = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const result = await runHook({ - tool_input: { content: gitDiff, file_path: PATCH_PATH }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /plugin-patch-format-guard/) - assert.match(result.stderr, /diff --git/) -}) - -test('hook: bad filename via Write blocks', async () => { - const result = await runHook({ - tool_input: { - content: VALID_PATCH, - file_path: - '/Users/x/projects/foo/scripts/plugin-patches/Codex-1.0-bad.patch', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /<plugin>-<version>-<slug>\.patch/) -}) - -test('hook: Edit without content is skipped (cannot see whole file)', async () => { - const result = await runHook({ - tool_input: { file_path: PATCH_PATH, new_string: 'diff --git oops' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('hook: Edit WITH content is validated', async () => { - const gitDiff = VALID_PATCH.replace( - '--- a/scripts/lib/fs.mjs', - 'diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs\n--- a/scripts/lib/fs.mjs', - ) - const result = await runHook({ - tool_input: { content: gitDiff, file_path: PATCH_PATH }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 2) -}) - -test('hook: relative plugin-patch path blocks (PreToolUse always passes absolute)', async () => { - const result = await runHook({ - tool_input: { - content: VALID_PATCH, - file_path: 'scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /must be absolute/) -}) diff --git a/.claude/hooks/plugin-patch-format-guard/tsconfig.json b/.claude/hooks/plugin-patch-format-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/plugin-patch-format-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/pointer-comment-guard/README.md b/.claude/hooks/pointer-comment-guard/README.md deleted file mode 100644 index 3f22729f5..000000000 --- a/.claude/hooks/pointer-comment-guard/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# pointer-comment-guard - -PreToolUse hook (informational; never blocks) that flags pointer-style comments missing the one-line claim that should accompany them. - -## Why - -Per CLAUDE.md's "Code style → Pointer comments" rule: - -> Pointer comments are acceptable when (a) the destination actually carries the load-bearing explanation, AND (b) the inline form carries the one-line claim so a reader who never follows the pointer still walks away with the _why_. A pointer with neither is dead weight; a pointer with only (a) fails CLAUDE.md's "the reader should fix the problem from the comment alone" test. - -This hook verifies (b) syntactically. (a) requires following the pointer and assessing destination quality, which a static check can't do. - -## What it catches - -A comment that opens with a pointer phrase — `see X` / `see X for details` / `full rationale in Y` / `documented in Z` / `defined in W` / `described in V` / `specified in U` / `reference in T` — and contains no detectable claim shape in the rest of the comment. - -**Flagged:** - -```ts -// See the @fileoverview JSDoc above. - -// Full rationale in the fileoverview. - -// See X for details. -``` - -**Accepted:** - -```ts -// Why uncurried, not Fast-API'd: see the fileoverview JSDoc above. -// V8's existing hot path beats trampoline overhead. - -// Searches stay uncurried — V8's hot path beats any Fast API -// binding here. Full rationale in the @fileoverview JSDoc above. -``` - -## Scope - -- Source-file extensions only: `.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`, `.tsx`, `.jsx`. -- Skips `test/` directories and `*.test.*` files — illustrative pointer-only comments are common there and not the failure mode this hook targets. - -## Behavior - -- Exit code 0 in all cases. Hook writes a stderr breadcrumb when a violation is detected; the next turn sees it and can fix. -- Markdown, configs, and anything outside the source-file extensions are skipped. - -## Bypass - -- Type `Allow pointer-comment bypass` in a recent user message (also accepts `Allow pointer comment bypass` / `Allow pointercomment bypass`), or -- Set `SOCKET_POINTER_COMMENT_GUARD_DISABLED=1`. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/pointer-comment-guard/index.mts b/.claude/hooks/pointer-comment-guard/index.mts deleted file mode 100644 index cc9cefe9e..000000000 --- a/.claude/hooks/pointer-comment-guard/index.mts +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — pointer-comment-guard. -// -// Flags pointer-style comments ("see X", "see X for details", "full -// rationale in Y", "documented in Z", "see the @fileoverview JSDoc -// above") that DON'T also carry a one-line claim explaining the -// decision. Per CLAUDE.md "Code style → Pointer comments": -// -// Pointer comments are acceptable when (a) the destination -// actually carries the load-bearing explanation, AND (b) the -// inline form carries the one-line claim so a reader who never -// follows the pointer still walks away with the *why*. A pointer -// with neither is dead weight; a pointer with only (a) fails the -// "the reader should fix the problem from the comment alone" test. -// -// This hook can verify (b) syntactically (claim present in the same -// comment block). It can't verify (a) — that would require following -// the pointer and assessing destination quality. -// -// What we accept (passing comments): -// -// // Why uncurried, not Fast-API'd: see the fileoverview JSDoc -// // above. V8's existing hot path beats trampoline overhead. -// -// // Searches stay uncurried — V8's hot path beats any Fast API -// // binding here. Full rationale in the @fileoverview JSDoc above. -// -// // See https://example.com for details about the X-Y-Z header -// // shape; that spec also dictates the ordering used below. -// -// What we flag (bare pointers, no claim): -// -// // See the @fileoverview JSDoc above. -// -// // Full rationale in the fileoverview. -// -// // See X for details. -// -// Scope: -// - Source files only (.ts / .mts / .cts / .js / .mjs / .cjs / .tsx -// / .jsx). Markdown, configs, and tests are skipped. -// - Only applies to comments that begin with a pointer phrase. A -// comment that has the claim FIRST and the pointer second always -// passes (the bug we're guarding against is pointer-without-why). -// -// Bypass: "Allow pointer-comment bypass" in a recent user turn, or -// SOCKET_POINTER_COMMENT_GUARD_DISABLED=1. - -import process from 'node:process' - -import { splitLines, walkComments } from '../_shared/acorn/index.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: unknown | undefined - readonly content?: unknown | undefined - readonly new_string?: unknown | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASES = [ - 'Allow pointer-comment bypass', - 'Allow pointer comment bypass', - 'Allow pointercomment bypass', -] as const - -const SOURCE_EXT_RE = /\.(?:c|m)?[jt]sx?$/ - -// A line is a "comment" line if it starts (after optional whitespace -// and `*` for block-comment continuation) with `//` or is inside a -// `/* … */` block. We normalize comment groups before scanning. -// -// A pointer phrase opens with one of these patterns. They are the -// canonical "see X" / "rationale in Y" shapes — narrow enough to -// avoid false positives on prose like "I'll see if this works." -const POINTER_OPENERS_RE = - /^(?:see\b|full rationale in\b|rationale in\b|details in\b|documented in\b|defined in\b|described in\b|specified in\b|reference[sd]? in\b)/i - -// A pointer-only comment is one where, after stripping the pointer -// phrase + its target, no claim text remains. We detect the boundary -// by looking for a continuation that doesn't itself start with another -// pointer phrase and contains an active verb / claim shape. -// -// Claim shapes (any of these in the SAME comment passes the check): -// - "X beats / wins / wraps / replaces / avoids / prevents / forces -// / requires / blocks / matches / fails / throws Y" -// - "because / since / due to / so that / to <verb>" -// - "X is Y" / "X are Y" (assertion shape) -// - "X — Y" / "X: Y" / "X; Y" (em-dash / colon / semicolon claim) -// - "X — Y" with Y being a sentence (verb present) -// -// This is heuristic, not parser-accurate; we err on the side of -// passing comments to keep false-positive cost low. The flag only -// fires on the unambiguous case: a bare pointer with nothing else. -const CLAIM_SHAPE_RE = - /\b(?:beats|wins|wraps|replaces|avoids|prevents|forces|requires|blocks|matches|fails|throws|returns|does|doesn'?t|will|won'?t|is|are|was|were|because|since|so that|to\s+\w+|since\s+\w+|due to)\b/i - -interface Comment { - readonly text: string - readonly lineNumber: number -} - -// Split source into comment blocks via the AST walker. A "block" is -// one logical comment: a `/* … */` span (one CommentSite from the -// walker), or a run of consecutive `//` lines (we merge those here -// since the walker reports each line-comment separately). -// -// The previous hand-rolled lexer walked the source line-by-line -// tracking `/*` / `*/` state and `//` runs. The AST walker does the -// state-tracking for us (it knows about string-literal regions, so a -// `//` inside a string doesn't get mistaken for a comment opener). -export function extractCommentBlocks(source: string): Comment[] { - const all = walkComments(source, { comments: true }) - const blocks: Comment[] = [] - let lineRunStartLine: number | undefined - let lineRunStartOffset: number | undefined - let lineRunEnd: number | undefined - let lineRunBuf: string[] = [] - const flushLineRun = (): void => { - if (lineRunStartLine === undefined || lineRunBuf.length === 0) { - return - } - blocks.push({ - text: lineRunBuf.join('\n').trim(), - lineNumber: lineRunStartLine, - }) - lineRunStartLine = undefined - lineRunStartOffset = undefined - lineRunEnd = undefined - lineRunBuf = [] - } - for (let i = 0; i < all.length; i += 1) { - const c = all[i]! - if (c.kind === 'Line') { - // Contiguous if there's no significant content between the prior - // line-comment's end and this one's start. We approximate by - // checking the prior end is followed only by whitespace + a - // single newline, and the next non-whitespace position is `//`. - const adjacent = - lineRunEnd !== undefined && - /^[\t \r]*\n[\t ]*\/\//.test(source.slice(lineRunEnd, c.start + 2)) - if (!adjacent) { - flushLineRun() - } - if (lineRunStartLine === undefined) { - lineRunStartLine = c.line - lineRunStartOffset = c.start - } - lineRunBuf.push(c.value.trimStart()) - lineRunEnd = c.end - continue - } - // Block comment — flush any pending line-run first, then add the - // block as its own entry with leading `*` decorators stripped per - // line. - flushLineRun() - const cleaned = splitLines(c.value) - .map(l => l.replace(/^\s*\*\s?/, '')) - .join('\n') - .trim() - if (cleaned) { - blocks.push({ text: cleaned, lineNumber: c.line }) - } - } - flushLineRun() - // lineRunStartOffset is kept for symmetry with the line-run merge - // window; we don't currently expose it on Comment. - void lineRunStartOffset - return blocks -} - -interface Hit { - readonly lineNumber: number - readonly preview: string -} - -export function findPointerOnlyComments(blocks: readonly Comment[]): Hit[] { - const hits: Hit[] = [] - for (let i = 0, { length } = blocks; i < length; i += 1) { - const block = blocks[i]! - const text = block.text.trim() - if (text.length === 0) { - continue - } - if (!POINTER_OPENERS_RE.test(text)) { - continue - } - // Block opens with a pointer phrase. Check whether the WHOLE block - // ALSO carries a claim shape. If it does, we pass. - if (CLAIM_SHAPE_RE.test(text)) { - continue - } - // Pointer-only. Flag. - const preview = text.replace(/\s+/g, ' ').slice(0, 100) - hits.push({ lineNumber: block.lineNumber, preview }) - } - return hits -} - -async function main(): Promise<void> { - if (process.env['SOCKET_POINTER_COMMENT_GUARD_DISABLED']) { - process.exit(0) - } - const payloadRaw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(payloadRaw) as PreToolUsePayload - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.['file_path'] - if (typeof filePath !== 'string') { - process.exit(0) - } - if (!SOURCE_EXT_RE.test(filePath)) { - process.exit(0) - } - // Skip tests — they often have illustrative pointer-only comments. - if (/(?:^|\/)test\//.test(filePath) || /\.test\.[jt]sx?$/.test(filePath)) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - const content = - typeof payload.tool_input?.['content'] === 'string' - ? (payload.tool_input!['content'] as string) - : typeof payload.tool_input?.['new_string'] === 'string' - ? (payload.tool_input!['new_string'] as string) - : '' - if (!content) { - process.exit(0) - } - const blocks = extractCommentBlocks(content) - const hits = findPointerOnlyComments(blocks) - if (hits.length === 0) { - process.exit(0) - } - - const lines = [ - `[pointer-comment-guard] Pointer-only comment(s) detected in ${filePath}:`, - '', - ] - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push( - ` • line ${h.lineNumber}: "${h.preview}${h.preview.length === 100 ? '…' : ''}"`, - ) - } - lines.push('') - lines.push( - ' Per CLAUDE.md "Code style → Pointer comments": a pointer comment', - ) - lines.push( - ' must carry a one-line claim explaining the decision, so a reader', - ) - lines.push(' who never follows the pointer still walks away with the *why*.') - lines.push('') - lines.push(' Bad:') - lines.push(' // See the @fileoverview JSDoc above.') - lines.push('') - lines.push(' Good:') - lines.push(' // See the @fileoverview JSDoc above.') - lines.push(" // V8's existing hot path beats trampoline overhead here.") - lines.push('') - lines.push( - ' Bypass: "Allow pointer-comment bypass" in a recent user message,', - ) - lines.push(' or SOCKET_POINTER_COMMENT_GUARD_DISABLED=1.') - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - // Informational — exit 0. The hook leaves the breadcrumb in stderr - // for the next turn to read; it doesn't block the edit. - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/pointer-comment-guard/package.json b/.claude/hooks/pointer-comment-guard/package.json deleted file mode 100644 index 9bae83284..000000000 --- a/.claude/hooks/pointer-comment-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-pointer-comment-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/pointer-comment-guard/test/index.test.mts b/.claude/hooks/pointer-comment-guard/test/index.test.mts deleted file mode 100644 index e87810940..000000000 --- a/.claude/hooks/pointer-comment-guard/test/index.test.mts +++ /dev/null @@ -1,211 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function makeTranscript(userText?: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pcg-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userText ?? 'normal message' }), - ) - return transcriptPath -} - -function runHook( - tool: 'Edit' | 'Write' | 'Read', - filePath: string, - content: string, - options: { - transcriptPath?: string | undefined - env?: Record<string, string> | undefined - } = {}, -): { stderr: string; exitCode: number } { - const payload: Record<string, unknown> = { - tool_name: tool, - tool_input: { file_path: filePath, content, new_string: content }, - } - if (options.transcriptPath) { - payload['transcript_path'] = options.transcriptPath - } - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - env: { ...process.env, ...(options.env ?? {}) }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('FLAGS bare "See the @fileoverview JSDoc above."', () => { - const content = [ - 'export const x = 1', - '// See the @fileoverview JSDoc above.', - 'export const StringPrototypeEndsWith = uncurry()', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/foo.ts', content) - assert.equal(exitCode, 0) - assert.match(stderr, /pointer-comment-guard/) - assert.match(stderr, /See the @fileoverview/) -}) - -test('FLAGS bare "Full rationale in the fileoverview."', () => { - const content = [ - '// Full rationale in the fileoverview.', - 'export const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/bar.ts', content) - assert.equal(exitCode, 0) - assert.match(stderr, /Full rationale/) -}) - -test('FLAGS bare "See X for details."', () => { - const content = ['// See X for details.', 'export const x = 1'].join('\n') - const { exitCode } = runHook('Write', '/repo/src/baz.ts', content) - assert.equal(exitCode, 0) -}) - -test('ACCEPTS pointer + claim form (current breadcrumb shape)', () => { - const content = [ - "// Why uncurried, not Fast-API'd: see the fileoverview JSDoc above.", - "// V8's existing hot path beats trampoline overhead on these.", - 'export const StringPrototypeEndsWith = uncurry()', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/string.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS claim-first-then-pointer form (alternate)', () => { - const content = [ - "// Searches stay uncurried — V8's hot path beats any Fast API", - '// binding here. Full rationale in the @fileoverview JSDoc above.', - 'export const StringPrototypeEndsWith = uncurry()', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/string.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS pointer with claim via "because"', () => { - const content = [ - '// See the upstream spec for details, because the ordering matters here.', - 'export const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/x.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS plain non-pointer comments', () => { - const content = [ - '// This is a regular comment about the constraint.', - 'export const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/x.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS prose containing "see" not as a pointer opener', () => { - // "see" inside a sentence, not opening the comment. - const content = [ - "// I'll see if this works in practice — it doesn't on Node 18.", - 'export const x = 1', - ].join('\n') - const { stderr, exitCode } = runHook('Write', '/repo/src/x.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('IGNORES non-source extensions (markdown, json)', () => { - const content = ['// See the @fileoverview JSDoc above.'].join('\n') - const md = runHook('Write', '/repo/docs/foo.md', content) - const json = runHook('Write', '/repo/data.json', content) - assert.equal(md.exitCode, 0) - assert.equal(md.stderr, '') - assert.equal(json.exitCode, 0) - assert.equal(json.stderr, '') -}) - -test('IGNORES test files (illustrative pointer-only comments are fine there)', () => { - const content = ['// See X for details.', 'export const x = 1'].join('\n') - const testDir = runHook('Write', '/repo/test/foo.ts', content) - const testFile = runHook('Write', '/repo/src/foo.test.ts', content) - assert.equal(testDir.exitCode, 0) - assert.equal(testDir.stderr, '') - assert.equal(testFile.exitCode, 0) - assert.equal(testFile.stderr, '') -}) - -test('IGNORES non-Edit/Write tools', () => { - const content = '// See X for details.' - const { exitCode, stderr } = runHook('Read', '/repo/src/foo.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('ACCEPTS with "Allow pointer-comment bypass" phrase', () => { - const t = makeTranscript('Allow pointer-comment bypass') - const content = '// See the @fileoverview JSDoc above.' - const { exitCode, stderr } = runHook('Write', '/repo/src/foo.ts', content, { - transcriptPath: t, - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('disabled env var short-circuits', () => { - const content = '// See the @fileoverview JSDoc above.' - const { exitCode, stderr } = runHook('Write', '/repo/src/foo.ts', content, { - env: { SOCKET_POINTER_COMMENT_GUARD_DISABLED: '1' }, - }) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('handles block comments — bare pointer in /* … */ is flagged', () => { - const content = [ - '/**', - ' * See the @fileoverview JSDoc above.', - ' */', - 'export const x = 1', - ].join('\n') - const { exitCode, stderr } = runHook('Write', '/repo/src/foo.ts', content) - assert.equal(exitCode, 0) - assert.match(stderr, /See the @fileoverview/) -}) - -test('handles block comments — pointer + claim in /* … */ passes', () => { - const content = [ - '/**', - ' * See the @fileoverview JSDoc above. The hot path beats the trampoline.', - ' */', - 'export const x = 1', - ].join('\n') - const { exitCode, stderr } = runHook('Write', '/repo/src/foo.ts', content) - assert.equal(exitCode, 0) - assert.equal(stderr, '') -}) - -test('does not crash on malformed payload', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: 'not-json', - }) - assert.equal(result.status, 0) -}) - -test('does not crash when content is missing', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { file_path: '/repo/src/foo.ts' }, - }), - }) - assert.equal(result.status, 0) -}) diff --git a/.claude/hooks/pointer-comment-guard/tsconfig.json b/.claude/hooks/pointer-comment-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/pointer-comment-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/pr-vs-push-default-reminder/README.md b/.claude/hooks/pr-vs-push-default-reminder/README.md deleted file mode 100644 index 4fff186c4..000000000 --- a/.claude/hooks/pr-vs-push-default-reminder/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# pr-vs-push-default-reminder - -PreToolUse Bash hook (reminder, NOT a block) that fires on `gh pr create` -when the current branch is `main` / `master` AND no recent user turn -contains an explicit PR directive. - -## Why - -Per CLAUDE.md "Push policy: push, fall back to PR" — direct `git push` -is the fleet default. The PR-fallback is for the cases where the push -is rejected (branch protection, conflicts, identity rejection). - -Past pattern: agents opened PRs speculatively when a direct push would -have worked. The user then has to close each PR. This hook gives the -agent a nudge to try the direct push first. - -## PR directive patterns - -Any of the following in a recent user turn passes the check: - -- "open a PR" / "open the PR" / "open a pr" -- "PR this" / "pr this" -- "make a PR" / "make the PR" -- "create a PR" / "send a PR" -- "pull request" - -## Not a block - -Reminder-only. The agent can still proceed with `gh pr create` if it's -the correct action (e.g. the push truly will be rejected). The -reminder just surfaces the alternative. - -## Skipped scenarios - -- Current branch is NOT main/master (feature branches always PR). -- The PR command has the directive in a recent user turn. -- The transcript can't be read (failed gracefully). diff --git a/.claude/hooks/pr-vs-push-default-reminder/index.mts b/.claude/hooks/pr-vs-push-default-reminder/index.mts deleted file mode 100644 index 87cd24bb0..000000000 --- a/.claude/hooks/pr-vs-push-default-reminder/index.mts +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — pr-vs-push-default-reminder. -// -// Reminder (NOT a block) on `gh pr create` invocations when the current -// branch is `main` / `master` AND the recent transcript doesn't carry -// an explicit PR directive ("open a PR", "PR this", "make a PR", -// "make a pr"). -// -// Per CLAUDE.md "Push policy: push, fall back to PR" — direct push is -// the fleet default; PR is the explicit opt-in. The reminder surfaces -// when the agent is about to open a PR without user-asked-for-PR -// signal, in case `git push` would actually work and a PR is wasted -// work (the user will then have to close the PR). -// -// Skipped when the branch isn't main/master (feature branches always -// PR via the wheelhouse push-fallback policy). - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { readFileSync } from 'node:fs' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -// Patterns that signal "I want a PR." Match against the FULL trimmed -// text of any of the last N user turns. -const PR_DIRECTIVE_PATTERNS = [ - /\bopen (?:a |the )?pr\b/i, - /\bpr this\b/i, - /\bmake (?:a |the )?pr\b/i, - /\bcreate (?:a |the )?pr\b/i, - /\bsend (?:a |the )?pr\b/i, - /\bpull request\b/i, -] - -// Recent user-turn window. -const TURN_WINDOW = 6 - -export function currentBranch(cwd: string): string | undefined { - const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { - cwd, - timeout: 5_000, - }) - if (r.status !== 0) { - return undefined - } - return String(r.stdout).trim() -} - -export function hasPrDirective(turns: string[]): boolean { - for (let i = 0, { length } = turns; i < length; i += 1) { - const text = turns[i]! - for (let i = 0, { length } = PR_DIRECTIVE_PATTERNS; i < length; i += 1) { - const re = PR_DIRECTIVE_PATTERNS[i]! - if (re.test(text)) { - return true - } - } - } - return false -} - -export function isGhPrCreate(command: string): boolean { - return /\bgh\s+pr\s+create\b/.test(command) -} - -interface TranscriptEntry { - type?: string | undefined - message?: { content?: unknown | undefined } | undefined -} - -export function readRecentUserTurnTexts( - transcriptPath: string, - window: number, -): string[] { - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return [] - } - const turns: string[] = [] - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) { - continue - } - let entry: TranscriptEntry - try { - entry = JSON.parse(line) as TranscriptEntry - } catch { - continue - } - if (entry.type !== 'user') { - continue - } - const c = entry.message?.content - if (typeof c === 'string') { - turns.push(c) - } else if (Array.isArray(c)) { - turns.push( - c - .map(seg => - typeof seg === 'string' - ? seg - : typeof (seg as { text?: unknown | undefined }).text === 'string' - ? (seg as { text: string }).text - : '', - ) - .join('\n'), - ) - } - } - return turns.slice(-window) -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!isGhPrCreate(command)) { - process.exit(0) - } - - const cwd = payload.cwd ?? process.cwd() - const branch = currentBranch(cwd) - if (!branch || (branch !== 'main' && branch !== 'master')) { - process.exit(0) - } - - // On main/master — check whether the user asked for a PR. - if (!payload.transcript_path) { - process.exit(0) - } - const turns = readRecentUserTurnTexts(payload.transcript_path, TURN_WINDOW) - if (hasPrDirective(turns)) { - process.exit(0) - } - - process.stderr.write( - [ - '[pr-vs-push-default-reminder] About to open a PR from main', - '', - ` Current branch: ${branch}`, - ' Recent user turns do not contain an explicit PR directive', - ' ("open a PR", "PR this", "make a PR", "pull request").', - '', - ' Per CLAUDE.md "Push policy: push, fall back to PR" — direct', - ' `git push origin <branch>` is the fleet default. PRs are the', - ' opt-in. If you opened this PR speculatively, the user will', - ' have to close it; that wastes their time.', - '', - ' Try the direct push first:', - '', - ` git push origin ${branch}`, - '', - ' Fall back to `gh pr create` only when the push is rejected.', - '', - ' Reminder-only; not a block.', - '', - ].join('\n'), - ) - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[pr-vs-push-default-reminder] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/pr-vs-push-default-reminder/package.json b/.claude/hooks/pr-vs-push-default-reminder/package.json deleted file mode 100644 index 8e07641da..000000000 --- a/.claude/hooks/pr-vs-push-default-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-pr-vs-push-default-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts b/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts deleted file mode 100644 index 91a75f7fb..000000000 --- a/.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts +++ /dev/null @@ -1,126 +0,0 @@ -// node --test specs for the pr-vs-push-default-reminder hook. - -import { - spawn, - spawnSync, -} from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function mkRepoOnBranch(branch: string): string { - const repo = mkdtempSync(path.join(os.tmpdir(), 'pr-vs-push-test-')) - spawnSync('git', ['init', '-q', '-b', branch], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) - writeFileSync(path.join(repo, 'README.md'), 'x') - spawnSync('git', ['add', '.'], { cwd: repo }) - spawnSync('git', ['commit', '-q', '-m', 'init'], { cwd: repo }) - return repo -} - -function mkTranscript(userTurns: string[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pr-vs-push-tx-')) - const p = path.join(dir, 'session.jsonl') - const lines = userTurns.map(t => - JSON.stringify({ type: 'user', message: { content: t } }), - ) - writeFileSync(p, lines.join('\n') + '\n') - return p -} - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-gh-pr-create Bash passes silently', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git status' }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('gh pr create on feature branch — no reminder', async () => { - const repo = mkRepoOnBranch('feat/x') - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'gh pr create --title "x"' }, - cwd: repo, - transcript_path: mkTranscript(['fix this']), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('gh pr create on main with no PR directive — reminder fires', async () => { - const repo = mkRepoOnBranch('main') - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'gh pr create --title "x"' }, - cwd: repo, - transcript_path: mkTranscript(['fix this']), - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('About to open a PR from main')) -}) - -test('gh pr create on main with "open a PR" directive — no reminder', async () => { - const repo = mkRepoOnBranch('main') - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'gh pr create --title "x"' }, - cwd: repo, - transcript_path: mkTranscript(['open a PR for this']), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('gh pr create on main with "pull request" directive — no reminder', async () => { - const repo = mkRepoOnBranch('main') - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'gh pr create --title "x"' }, - cwd: repo, - transcript_path: mkTranscript(['fix this', 'send a pull request']), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('gh pr create on master (legacy) without directive — reminder fires', async () => { - const repo = mkRepoOnBranch('master') - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'gh pr create --title "x"' }, - cwd: repo, - transcript_path: mkTranscript(['ship it']), - }) - assert.strictEqual(r.code, 0) - assert.ok(String(r.stderr).includes('About to open a PR')) -}) diff --git a/.claude/hooks/pr-vs-push-default-reminder/tsconfig.json b/.claude/hooks/pr-vs-push-default-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/pr-vs-push-default-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/README.md b/.claude/hooks/prefer-rebase-over-revert-guard/README.md deleted file mode 100644 index 05cbe6c43..000000000 --- a/.claude/hooks/prefer-rebase-over-revert-guard/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# prefer-rebase-over-revert-guard - -`PreToolUse(Bash)` reminder hook. Fires when a `git revert <ref>` command targets a commit that's still local-only (not yet on `origin/<current-branch>`). - -For unpushed commits, `git reset --soft HEAD~N` or `git rebase -i HEAD~N` cleanly drops the commit. A revert commit just adds a noisy `Revert "..."` entry to local history that gets pushed along with everything else. Revert commits are the right call **only** when the change is already on the remote — you can't rewrite shared history there. - -## Behavior - -- **Always exits 0.** This is a reminder, not a block. -- Writes a stderr nudge before the tool call so the operator sees it. -- Probes `git merge-base --is-ancestor <ref> @{upstream}` to decide pushed-ness. - - Pushed → silent. Revert is correct. - - Unpushed → fire the reminder. - - No upstream (e.g. new branch) → silent. Avoids false-positives. - -## Skipped silently - -- `tool_name !== 'Bash'`. -- Command doesn't contain `git revert` outside quoted strings. -- Command has `--no-commit` or `--no-edit` (advanced workflows). -- Target ref can't be resolved (defensive — never false-positive on weird shapes). - -## Why a reminder, not a block - -There are legitimate reasons to revert an unpushed commit (e.g. emitting a clean "this got rolled back" entry for traceability before a force-push). Blocking would be too aggressive. A stderr nudge gives the operator the information; they decide. - -## Source of truth - -The rule itself lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Commits & PRs" → "Backing out an unpushed commit". This hook enforces it at edit time. diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts b/.claude/hooks/prefer-rebase-over-revert-guard/index.mts deleted file mode 100644 index 072344de1..000000000 --- a/.claude/hooks/prefer-rebase-over-revert-guard/index.mts +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — prefer-rebase-over-revert-guard. -// -// Reminder hook (never blocks) that fires when a Bash command runs -// `git revert <ref>` against a ref that's still local-only (not yet -// on origin). For unpushed commits, `git reset --soft HEAD~N` or -// `git rebase -i HEAD~N` cleanly drops the commit; a revert commit -// just pollutes local history with a "Revert ..." noise commit. -// -// For already-pushed commits a revert commit is correct — don't -// rewrite shared history. So the hook only nudges when the target -// is provably unpushed. -// -// Always exits 0 (reminder, not enforcer). Writes the suggestion -// to stderr so the operator sees it before approving the tool call. -// -// Skipped silently: -// - tool_name !== 'Bash'. -// - Command doesn't contain `git revert` outside quoted strings. -// - Command has `--no-edit` or `--no-commit` (advanced workflows). -// - Target ref can't be parsed (defensive — never false-positive). -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", -// "tool_input": { "command": "..." }, -// ... } -// -// Exit codes: -// 0 — always. This is a reminder, not a block. -// -// Fails open on any internal error (exit 0 + stderr log). - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' - -interface ToolInput { - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly tool_name?: string | undefined -} - -/** - * Pull the first argument that looks like a ref out of a `git revert` command. - * Returns undefined when nothing parsable is found — better to skip the - * reminder than to false-positive on a complex command. - * - * Handles common shapes: git revert HEAD git revert HEAD~3 git revert abc1234 - * git revert <sha>..<sha> git revert --no-commit HEAD. - */ -export function extractRef(command: string): string | undefined { - for (const c of commandsFor(command, 'git')) { - const revertIdx = c.args.indexOf('revert') - if (revertIdx === -1) { - continue - } - // First non-flag token after `revert` is the target ref. - for (let i = revertIdx + 1, { length } = c.args; i < length; i += 1) { - const tok = c.args[i]! - if (!tok.startsWith('-') && tok.length > 0) { - return tok - } - } - } - return undefined -} - -function isGitRevert(command: string): boolean { - return commandsFor(command, 'git').some(c => c.args.includes('revert')) -} - -/** - * Probe `git` for whether `ref` is reachable on `origin/<current-branch>`. If - * the local branch has no upstream we can't tell, so return undefined (= "don't - * fire the reminder, we'd false-positive on a brand-new branch"). - */ -export function isRefPushed(ref: string): boolean | undefined { - // Run all probes in the current working directory — same dir the - // user's `git revert` would run in. - const opts = { encoding: 'utf8' as const, stdio: 'pipe' as const } - - // 1. Resolve the symbolic upstream. Empty = no upstream (new branch). - const upstream = spawnSync( - 'git', - ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], - opts, - ) - if (upstream.status !== 0) { - return undefined - } - const upstreamRef = String(upstream.stdout).trim() - if (!upstreamRef) { - return undefined - } - - // 2. Resolve the target ref to a SHA. Bad refs → undefined. - const targetSha = spawnSync( - 'git', - ['rev-parse', '--verify', `${ref}^{commit}`], - opts, - ) - if (targetSha.status !== 0) { - return undefined - } - const sha = String(targetSha.stdout).trim() - if (!sha) { - return undefined - } - - // 3. Is the SHA an ancestor of the upstream branch? - // `git merge-base --is-ancestor` exits 0 if yes, 1 if no. - const isAncestor = spawnSync( - 'git', - ['merge-base', '--is-ancestor', sha, upstreamRef], - opts, - ) - if (isAncestor.status === 0) { - return true - } - if (isAncestor.status === 1) { - return false - } - // Any other exit code (rare; e.g. corrupted refs) — bail. - return undefined -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!command) { - process.exit(0) - } - - // Only fire on real `git revert` invocations (parser sees through - // chains / `$(…)`; a quoted "git revert" in a message is ignored). - if (!isGitRevert(command)) { - process.exit(0) - } - - // Skip advanced workflows. `--no-commit` / `--no-edit` mean the - // operator is mid-merge or scripting; the rebase suggestion - // doesn't apply cleanly. - if (/--no-(?:commit|edit)\b/.test(command)) { - process.exit(0) - } - - const ref = extractRef(command) - if (!ref) { - process.exit(0) - } - - const pushed = isRefPushed(ref) - if (pushed !== false) { - // Pushed (= revert is correct), or unknowable (= don't false- - // positive on a brand-new branch with no upstream). - process.exit(0) - } - - process.stderr.write( - [ - '[prefer-rebase-over-revert-guard] Reminder: this commit looks unpushed.', - '', - ` Target ref: ${ref}`, - '', - ' For unpushed commits, `git reset --soft HEAD~N` (or `git rebase -i HEAD~N`)', - ' cleanly drops the commit — no "Revert ..." noise in history. Revert commits', - ' are correct for changes already on origin.', - '', - ' Proceed if intentional; this is a reminder, not a block.', - '', - ].join('\n'), - ) - // Always exit 0. The hook is a nudge, not an enforcer. - process.exit(0) - } catch (e) { - process.stderr.write( - `[prefer-rebase-over-revert-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/package.json b/.claude/hooks/prefer-rebase-over-revert-guard/package.json deleted file mode 100644 index ba5578d63..000000000 --- a/.claude/hooks/prefer-rebase-over-revert-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-prefer-rebase-over-revert-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts b/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts deleted file mode 100644 index 7d26db0ba..000000000 --- a/.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts +++ /dev/null @@ -1,124 +0,0 @@ -// node --test specs for the prefer-rebase-over-revert-guard hook. -// -// The hook probes `git` at runtime to decide pushed-ness — these -// tests verify the surface behavior (always exit 0, stderr matches -// on the should-fire cases) rather than the upstream-detection -// internals. The git probe is invoked in whatever cwd the test -// runs in; in this test suite that's the wheelhouse repo, which has -// an upstream, so we exercise both the "skip silently" and "would -// fire if the SHA were unpushed" paths via input shape. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-Bash tool calls pass through silently', async () => { - const result = await runHook({ - tool_input: { file_path: 'foo.ts', new_string: 'x' }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('non-revert Bash commands pass through silently', async () => { - const result = await runHook({ - tool_input: { command: 'git status' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('commit message bodies mentioning git revert are skipped (quote-aware)', async () => { - const result = await runHook({ - tool_input: { - command: `git commit -m "reminder: use git revert later if needed"`, - }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('git revert chained after another command is still detected', async () => { - // Parser sees through the `&&` chain — the old regex matched on the - // raw substring; the parser confirms a real `git revert` invocation. - const result = await runHook({ - tool_input: { command: 'cd /tmp && git revert this-ref-does-not-exist' }, - tool_name: 'Bash', - }) - // Bogus ref → defensive exit 0; the point is the hook didn't bail at - // the detection gate (it reached the ref-resolution probe). - assert.strictEqual(result.code, 0) -}) - -test('git revert with --no-commit is skipped (advanced workflow)', async () => { - const result = await runHook({ - tool_input: { command: 'git revert --no-commit HEAD' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('git revert with --no-edit is skipped (advanced workflow)', async () => { - const result = await runHook({ - tool_input: { command: 'git revert --no-edit abc1234' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('git revert against a bogus ref exits 0 with no stderr (defensive)', async () => { - // `git rev-parse` will fail on the bogus ref; the hook bails to - // exit 0 + empty stderr rather than firing a false positive. - const result = await runHook({ - tool_input: { command: 'git revert this-ref-does-not-exist-anywhere' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) - assert.strictEqual(result.stderr, '') -}) - -test('always exits 0 — reminder hook never blocks', async () => { - // Hook is non-blocking by design. Verify on a shape that WOULD - // fire the reminder if the SHA were locally-unpushed: HEAD is - // always pushed on a clean checkout (no local commits), so this - // should silently skip. Either way, exit 0. - const result = await runHook({ - tool_input: { command: 'git revert HEAD' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) diff --git a/.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json b/.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/private-name-guard/README.md b/.claude/hooks/private-name-guard/README.md deleted file mode 100644 index 1b4d1a2e3..000000000 --- a/.claude/hooks/private-name-guard/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# private-name-guard - -A **Claude Code hook** that runs before any Bash command Claude is -about to execute and reminds the model not to publish private repo -names or internal project codenames to public surfaces. It never -blocks — its job is to keep that rule top-of-mind right when Claude -is about to commit, push, or comment on a public-facing PR/issue. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool (here, the Bash -> tool). It can either **prime** (write to stderr, exit 0, model -> carries on) or **block** (exit 2). This one only primes. - -## The rule - -> No private repos or internal project names in public surfaces. Omit -> the reference entirely — don't substitute a placeholder. The -> placeholder itself is a tell. - -This is the close sibling of [`public-surface-reminder`](../public-surface-reminder/), -which covers customer/company names and internal work-item IDs. The -two hooks **compose** — both fire on the same public-surface -commands, each priming a distinct slice of the rule set. - -## What counts as "public surface" - -- `git commit` (including `--amend`) -- `git push` -- `gh pr (create|edit|comment|review)` -- `gh issue (create|edit|comment)` -- `gh api -X POST|PATCH|PUT` -- `gh release (create|edit)` - -Any other Bash command passes through silently. - -## Why no denylist - -A list of internal project names is itself a leak. A file named -`private-projects.txt` enumerating "these are our internal repos" is -worse than no list at all — anyone who finds it gets the org's full -internal map for free. Recognition happens at write time, every time, -by the model reading what it's about to send. The hook just makes -sure that read happens. - -## Wiring - -`.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/private-name-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Exit code - -Always `0`. The hook never blocks; it only prints to stderr. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/private-name-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/private-name-guard/index.mts b/.claude/hooks/private-name-guard/index.mts deleted file mode 100644 index 2d8795f88..000000000 --- a/.claude/hooks/private-name-guard/index.mts +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — private-name guard. -// -// Never blocks. On every Bash command that would publish text to a public -// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write), -// writes a short reminder to stderr so the model re-reads the command with -// the rule freshly in mind: -// -// No private repos or internal project names in public surfaces. -// Omit the reference entirely — don't substitute a placeholder. -// -// Exit code is always 0. This is attention priming, not enforcement. The -// model is responsible for applying the rule — the hook just makes sure -// the rule is in the active context at the moment the command is about -// to fire. -// -// Deliberately carries no enumerated denylist. Recognition and replacement -// happen at write time, not via a list of names. A denylist is itself a -// leak — a file named `private-projects.txt` would be the very thing it -// tries to prevent. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." } } - -import { readFileSync } from 'node:fs' - -type ToolInput = { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -// Commands that can publish content outside the local machine. -// Keep broad — better to remind on an extra read than miss a write. -const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ - /\bgit\s+commit\b/, - /\bgit\s+push\b/, - /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, - /\bgh\s+issue\s+(?:comment|create|edit)\b/, - /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, - /\bgh\s+release\s+(?:create|edit)\b/, -] - -export function isPublicSurface(command: string): boolean { - const normalized = command.replace(/\s+/g, ' ') - return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) -} - -function main(): void { - let raw = '' - try { - raw = readFileSync(0, 'utf8') - } catch { - return - } - - let input: ToolInput - try { - input = JSON.parse(raw) - } catch { - return - } - - if (input.tool_name !== 'Bash') { - return - } - const command = input.tool_input?.command - if (!command || typeof command !== 'string') { - return - } - if (!isPublicSurface(command)) { - return - } - - const lines = [ - '[private-name-guard] This command writes to a public Git/GitHub surface.', - ' • Re-read the commit message / PR body / comment BEFORE it sends.', - ' • No private repo names. No internal project codenames. No unreleased', - ' product names. No internal-only tooling repos absent from the public', - ' org page. No customer/partner names.', - ' • Omit the reference entirely. Do not substitute a placeholder — the', - ' placeholder itself is a tell.', - ' • If you spot one, cancel and rewrite the text first.', - ] - process.stderr.write(lines.join('\n') + '\n') -} - -main() diff --git a/.claude/hooks/private-name-guard/package.json b/.claude/hooks/private-name-guard/package.json deleted file mode 100644 index 5ffc1e888..000000000 --- a/.claude/hooks/private-name-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-private-name-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts b/.claude/hooks/private-name-guard/test/private-name-guard.test.mts deleted file mode 100644 index e4c1854da..000000000 --- a/.claude/hooks/private-name-guard/test/private-name-guard.test.mts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Payload { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end(JSON.stringify(payload)) - }) -} - -test('reminds (exit 0 + stderr) on git commit', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git commit -m "ship feature"', - }, - }) - assert.equal(code, 0, `expected exit 0 (reminder, not block); got ${code}`) - assert.ok( - stderr.toLowerCase().includes('private') || - stderr.toLowerCase().includes('internal') || - stderr.toLowerCase().includes('reminder'), - `expected reminder text in stderr; got: ${stderr}`, - ) -}) - -test('reminds on gh pr create', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'gh pr create --title "x" --body "y"', - }, - }) - assert.equal(code, 0) - assert.ok(stderr.length > 0, `expected reminder text; got empty stderr`) -}) - -test('stays silent on non-public-surface commands', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'ls -la', - }, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0, `expected no reminder; got: ${stderr}`) -}) - -test('stays silent on non-Bash tool', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Edit', - tool_input: { command: 'git commit' }, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - child.stdin!.end('not json at all {{{') - const code = await new Promise<number>(resolve => { - child.process.on('exit', c => resolve(c ?? -1)) - }) - assert.equal(code, 0, 'malformed stdin must NOT block the tool call') -}) diff --git a/.claude/hooks/private-name-guard/tsconfig.json b/.claude/hooks/private-name-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/private-name-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/public-surface-reminder/README.md b/.claude/hooks/public-surface-reminder/README.md deleted file mode 100644 index 01fde025d..000000000 --- a/.claude/hooks/public-surface-reminder/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# public-surface-reminder - -A **Claude Code hook** that runs before any Bash command Claude is -about to execute and prints a quick reminder about two writing rules -to stderr. It never blocks — its job is just to make sure those rules -are top-of-mind right when Claude is about to commit, push, comment -on a PR, or otherwise publish text somewhere public. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool (here, the Bash -> tool). The hook can either **prime** the model (write to stderr, -> exit 0, model carries on) or **block** the call (exit 2). This one -> only primes. - -## The two rules - -1. **No real customer or company names.** Use a placeholder like - `Acme Inc`. No exceptions. -2. **No internal work-item IDs or tracker URLs.** No `SOC-123` / - `ENG-456` / `ASK-789` / similar; no `linear.app` / `sentry.io` / - internal Jira links. - -## What counts as "public surface" - -The hook only primes for commands that publish text outward: - -- `git commit` (including `--amend`) -- `git push` -- `gh pr (create|edit|comment|review)` -- `gh issue (create|edit|comment)` -- `gh api -X POST|PATCH|PUT` -- `gh release (create|edit)` - -Any other Bash command passes through silently. - -## Why no denylist - -You might ask: why doesn't the hook just have a list of customer -names to scan for? Because **the list itself is the leak**. A file -named `customers.txt` enumerating "these are our customers" is worse -than the bug it tries to prevent — anyone who finds it gets the org's -full customer map for free. Recognition has to happen at write time, -done by the model reading what it's about to send. The hook just -makes sure that read happens. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/public-surface-reminder/index.mts" - } - ] - } - ] - } -} -``` - -## Exit code - -Always `0`. The hook prints a reminder and steps aside. - -## Sibling hooks - -- [`private-name-guard`](../private-name-guard/) — primes on private - repo / project names. -- [`token-guard`](../token-guard/) — _blocks_ Bash calls that would - leak literal secrets to stdout. (The blocking sibling, contrasted - with this priming one.) - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/public-surface-reminder) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/public-surface-reminder/index.mts b/.claude/hooks/public-surface-reminder/index.mts deleted file mode 100644 index 0856652a4..000000000 --- a/.claude/hooks/public-surface-reminder/index.mts +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — public-surface reminder. -// -// Never blocks. On every Bash command that would publish text to a public -// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write), -// writes a short reminder to stderr so the model re-reads the command with -// the two rules freshly in mind: -// -// 1. No real customer/company names — ever. Use `Acme Inc` instead. -// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`, -// `ASK-789`, `linear.app`, `sentry.io`, etc. -// -// Exit code is always 0. This is attention priming, not enforcement. The -// model is responsible for actually applying the rule — the hook just makes -// sure the rule is in the active context at the moment the command is about -// to fire. -// -// Deliberately carries no list of customer names. Recognition and -// replacement happen at write time, not via enumeration. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." } } - -import { readFileSync } from 'node:fs' - -type ToolInput = { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -// Commands that can publish content outside the local machine. -// Keep broad — better to remind on an extra read than miss a write. -const PUBLIC_SURFACE_PATTERNS: RegExp[] = [ - /\bgit\s+commit\b/, - /\bgit\s+push\b/, - /\bgh\s+pr\s+(?:comment|create|edit|review)\b/, - /\bgh\s+issue\s+(?:comment|create|edit)\b/, - /\bgh\s+api\b[^|]*-X\s*(?:PATCH|POST|PUT)\b/i, - /\bgh\s+release\s+(?:create|edit)\b/, -] - -export function isPublicSurface(command: string): boolean { - const normalized = command.replace(/\s+/g, ' ') - return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized)) -} - -function main(): void { - let raw = '' - try { - raw = readFileSync(0, 'utf8') - } catch { - return - } - - let input: ToolInput - try { - input = JSON.parse(raw) - } catch { - return - } - - if (input.tool_name !== 'Bash') { - return - } - const command = input.tool_input?.command - if (!command || typeof command !== 'string') { - return - } - if (!isPublicSurface(command)) { - return - } - - const lines = [ - '[public-surface-reminder] This command writes to a public Git/GitHub surface.', - ' • Re-read the commit message / PR body / comment BEFORE it sends.', - ' • No real customer or company names — use `Acme Inc`. No exceptions.', - ' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).', - ' • If you spot one, cancel and rewrite the text first.', - ] - process.stderr.write(lines.join('\n') + '\n') -} - -main() diff --git a/.claude/hooks/public-surface-reminder/package.json b/.claude/hooks/public-surface-reminder/package.json deleted file mode 100644 index 334643237..000000000 --- a/.claude/hooks/public-surface-reminder/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-public-surface-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts b/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts deleted file mode 100644 index 8240de471..000000000 --- a/.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts +++ /dev/null @@ -1,95 +0,0 @@ -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -interface Payload { - tool_name?: string | undefined - tool_input?: - | { - command?: string | undefined - } - | undefined -} - -function runHook(payload: Payload): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - child.stdin!.end(JSON.stringify(payload)) - }) -} - -test('reminds on git commit (exit 0 + stderr)', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git commit -m "feat: x"', - }, - }) - assert.equal(code, 0, `expected reminder, not block; got exit ${code}`) - assert.ok(stderr.length > 0, 'expected reminder text on stderr') -}) - -test('reminds on gh release create', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'gh release create v1.0.0 --notes "release"', - }, - }) - assert.equal(code, 0) - assert.ok(stderr.length > 0) -}) - -test('stays silent on non-public-surface commands', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Bash', - tool_input: { - command: 'git status', - }, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0) -}) - -test('stays silent on non-Bash tool', async () => { - const { code, stderr } = await runHook({ - tool_name: 'Read', - tool_input: {}, - }) - assert.equal(code, 0) - assert.equal(stderr.length, 0) -}) - -test('fails open on malformed stdin', async () => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - child.stdin!.end('}}}invalid') - const code = await new Promise<number>(resolve => { - child.process.on('exit', c => resolve(c ?? -1)) - }) - assert.equal(code, 0) -}) diff --git a/.claude/hooks/public-surface-reminder/tsconfig.json b/.claude/hooks/public-surface-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/public-surface-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/pull-request-target-guard/README.md b/.claude/hooks/pull-request-target-guard/README.md deleted file mode 100644 index e56b15217..000000000 --- a/.claude/hooks/pull-request-target-guard/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# pull-request-target-guard - -`PreToolUse(Edit|Write)` blocker for `.github/workflows/*.yml` that combines the three high-risk patterns: - -1. `on: pull_request_target` — runs in the BASE repo's context with secrets. -2. `actions/checkout` with `ref: ${{ github.event.pull_request.head.* }}` — checks out the FORK's code (attacker-controlled). -3. Subsequent execute-fork-code step (`pnpm i`, `npm i`, `yarn`, `bun i`, `pip install`, `cargo build`, `go build`, `make`, etc.). - -When all three are present, a fork PR can exfiltrate the base repo's secrets via a malicious `prepare` / `postinstall` script or build step. `--ignore-scripts` neutralizes installs but not builds — the hook only treats install-script-bypassed installs as safe; build steps still trip. - -## Coverage relative to zizmor - -[zizmor](https://docs.zizmor.sh/audits/) already flags `pull_request_target` use via `dangerous-triggers` (High, default-on) plus several collateral audits (`bot-conditions`, `github-env`, `template-injection`, `overprovisioned-secrets`, `artipacked`). - -This hook adds the **specific exploitation path**: not "you used a dangerous trigger" but "you used the dangerous trigger AND did the exact thing that exfiltrates secrets." Surfaces the issue at edit time before zizmor would catch it at commit/CI time. - -## Bypass - -`Allow pr-target-execution bypass` in a recent user turn. Rare — the safer patterns (split workflows, `labeled`-gated triggers, never check out fork code in privileged context) cover ~all legitimate use cases. - -## Reference - -The threat write-up that prompted this hook: <https://bsky.app/profile/43081j.com/post/3mlnme43qnc2e> - -The rule lives in [`CLAUDE.md`](../../../CLAUDE.md) under "Public-surface hygiene". diff --git a/.claude/hooks/pull-request-target-guard/index.mts b/.claude/hooks/pull-request-target-guard/index.mts deleted file mode 100644 index 281373c25..000000000 --- a/.claude/hooks/pull-request-target-guard/index.mts +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — pull-request-target-guard. -// -// Blocks Edit/Write to `.github/workflows/*.yml` that combines the -// dangerous patterns the GitHub Actions threat model is allergic to: -// -// `pull_request_target` trigger -// + `actions/checkout` of the fork's HEAD (the PR head SHA, -// `pull_request.head.sha`, or `pull_request.head.ref`) -// + a subsequent `run:` step that EXECUTES the checked-out -// fork code (`pnpm i`, `npm i`, `yarn`, `bun i`, `pip install`, -// `cargo build`, `go build`, `make`, build scripts, etc.) -// -// `pull_request_target` runs in the BASE repo's context, with access -// to secrets. By default `actions/checkout` checks out the base — -// safe. When the workflow explicitly checks out the FORK's HEAD AND -// then runs install / build / arbitrary commands on it, the fork's -// authors can exfiltrate the base repo's secrets (e.g. via a `prepare` -// install script). -// -// Reference threat write-up: -// https://bsky.app/profile/43081j.com/post/3mlnme43qnc2e -// -// What zizmor already covers (we don't duplicate): -// - `dangerous-triggers`: flags ANY `pull_request_target` use. -// - `bot-conditions`, `github-env`, `template-injection`, -// `overprovisioned-secrets`, `artipacked`: collateral patterns. -// -// What zizmor doesn't directly catch and this hook adds: -// - The exact "fork-checkout + execute-fork-code" combo. Zizmor -// flags the trigger as dangerous; this hook flags the specific -// exploitation path so the operator can't miss it at edit time. -// -// Bypass: `Allow pr-target-execution bypass` in a recent user turn. -// Use case: a workflow that genuinely needs to execute fork code in -// the privileged context (rare, reviewer-acknowledged trade-off). -// -// Exit codes: -// 0 — pass (not a workflow file, not the dangerous combo, or all -// execute steps use --ignore-scripts and similar guards). -// 2 — block. -// -// Fails open on parse errors (exit 0 + stderr log). - -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly content?: string | undefined - readonly file_path?: string | undefined - readonly new_string?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow pr-target-execution bypass' - -// Workflow-file shape. -export function isWorkflowPath(filePath: string): boolean { - return /\/\.github\/workflows\/[^/]+\.ya?ml$/.test(filePath) -} - -// 1. `on:` block declares `pull_request_target`. Match in three -// shapes: -// on: pull_request_target -// on: [pull_request_target, ...] -// on: -// pull_request_target: -// types: [...] -const TRIGGER_RE = /^\s*on\s*:[\s\S]*?\bpull_request_target\b/m - -// 2. `actions/checkout` with a ref pointing at the fork's HEAD. -// Common shapes in YAML: -// ref: ${{ github.event.pull_request.head.sha }} -// ref: ${{ github.event.pull_request.head.ref }} -// ref: ${{ github.event.pull_request.head.repo.full_name }} -// -// The `head.*` selector is the smoking-gun pattern — base.* -// checkouts are safe, head.* on pull_request_target is the exact -// privileged-fork-checkout shape. -const FORK_CHECKOUT_RE = - /uses\s*:\s*[^\n]*actions\/checkout[^\n]*[\s\S]{0,500}?\bref\s*:\s*[^\n]*\bgithub\.event\.pull_request\.head\b/ - -// 3. Subsequent `run:` that executes fork code. The list is the -// common set; not exhaustive (a workflow can `bash <(curl ...)`). -// Intentional false-positive risk on benign uses (e.g. running a -// linter that doesn't execute project scripts) — operators can -// bypass when needed. -// -// Each pattern matches the COMMAND TOKEN as it appears at run-time; -// we deliberately don't try to parse YAML steps. A coarse scan that -// flags too much is preferable to a fine scan that misses a leak. -const EXECUTE_PATTERNS: ReadonlyArray<{ - re: RegExp - cmd: string - safeIf?: RegExp | undefined -}> = [ - // Node package managers — `prepare`/`postinstall` scripts run by - // default. --ignore-scripts neutralizes the install-script vector - // but a build step on the next line can still execute fork code. - { - re: /\b(?:bun|npm|pnpm|yarn)\s+(?:add|ci|i|install)\b/, - cmd: 'package-manager install', - safeIf: /--ignore-scripts\b/, - }, - // Node build steps (no install-script bypass; the build itself - // runs fork-controlled code). - { - re: /\b(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?build\b/, - cmd: 'node build', - }, - // Generic `npm test` / `pnpm test` etc. - { - re: /\b(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?test\b/, - cmd: 'node test', - }, - // Python. - { - re: /\bpip\s+install\b/, - cmd: 'pip install', - }, - { - re: /\b(?:python|python3)\s+setup\.py\b/, - cmd: 'python setup.py', - }, - { - re: /\bpoetry\s+(?:build|install)\b/, - cmd: 'poetry install/build', - }, - // Ruby. - { - re: /\bbundle\s+install\b/, - cmd: 'bundle install', - }, - // Rust. - { - re: /\bcargo\s+(?:build|install|run|test)\b/, - cmd: 'cargo build/test/run/install', - }, - // Go. - { - re: /\bgo\s+(?:build|generate|install|run|test)\b/, - cmd: 'go build/test/run/install', - }, - // Make / generic build runners. - { - re: /\b(?:gmake|just|make|ninja|task)\s+\w*/, - cmd: 'make / build runner', - }, - // `bash <(curl ...)` and `sh -c "$(curl ...)"` install patterns. - { - re: /\b(?:bash|sh|zsh)\b[^\n]*\$\(\s*curl\b/, - cmd: 'shell pipe from curl', - }, - { - re: /\b(?:bash|sh|zsh)\b[^\n]*<\(\s*curl\b/, - cmd: 'shell process-sub from curl', - }, -] - -interface Finding { - readonly line: number - readonly cmd: string - readonly snippet: string -} - -/** - * Scan a workflow body and return findings. Returns empty when the dangerous - * combo isn't present. - * - * Three preconditions must hold for ANY finding to fire: - * - * 1. On: pull_request_target - * 2. Actions/checkout with a fork-HEAD ref - * 3. One or more execute-fork-code steps - * - * If only (1) and (2) hold, zizmor's `dangerous-triggers` already surfaces it. - * The execute-fork-code step is what this hook adds. - */ -export function findUnsafeForkExecution(content: string): Finding[] { - if (!TRIGGER_RE.test(content)) { - return [] - } - if (!FORK_CHECKOUT_RE.test(content)) { - return [] - } - const findings: Finding[] = [] - const lines = content.split('\n') - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - // Only inspect `run:` lines (and block-scalar continuations). - // A coarse signal — when a `run:` step contains the pattern, - // count it as an execute. Multi-line `run: |` blocks with the - // pattern on a later line also hit because we're scanning every - // line. - const runHit = /^\s*-?\s*run\s*:\s*(.*)/.exec(line) - const bodyLine = runHit ? runHit[1]! : line - for (let i = 0, { length } = EXECUTE_PATTERNS; i < length; i += 1) { - const ep = EXECUTE_PATTERNS[i]! - if (!ep.re.test(bodyLine)) { - continue - } - // Safe-if clause (e.g. --ignore-scripts on install). - if (ep.safeIf?.test(bodyLine)) { - continue - } - findings.push({ - cmd: ep.cmd, - line: i + 1, - snippet: - bodyLine.trim().length > 90 - ? bodyLine.trim().slice(0, 87) + '…' - : bodyLine.trim(), - }) - break - } - } - return findings -} - -let payloadRaw = '' -process.stdin.setEncoding('utf8') -process.stdin.on('data', chunk => { - payloadRaw += chunk -}) -process.stdin.on('end', () => { - try { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path ?? '' - if (!filePath || !isWorkflowPath(filePath)) { - process.exit(0) - } - const content = - payload.tool_input?.new_string ?? payload.tool_input?.content ?? '' - if (!content) { - process.exit(0) - } - const findings = findUnsafeForkExecution(content) - if (findings.length === 0) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - process.exit(0) - } - const lines: string[] = [] - lines.push( - '[pull-request-target-guard] Blocked: fork-execution in pull_request_target workflow.', - ) - lines.push(` File: ${path.basename(filePath)}`) - lines.push('') - lines.push(' Workflow combines all three high-risk patterns:') - lines.push( - ' 1. on: pull_request_target (runs in BASE repo context with secrets)', - ) - lines.push( - ' 2. actions/checkout with ref: ${{ github.event.pull_request.head.* }}', - ) - lines.push(' (checks out the FORK code — attacker-controlled)') - lines.push(' 3. Subsequent execute-fork-code step(s):') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - lines.push(` Line ${f.line} (${f.cmd}): ${f.snippet}`) - } - lines.push('') - lines.push(' Why this is dangerous:') - lines.push( - ' The fork can declare a `prepare` / `postinstall` script (or a build', - ) - lines.push( - " step) that exfiltrates the base repo's secrets. Even `--ignore-scripts`", - ) - lines.push( - ' only stops install-time execution — a build still runs fork code.', - ) - lines.push('') - lines.push(' Safer patterns:') - lines.push( - ' a. Split: run build in `on: pull_request` (no secrets), publish an', - ) - lines.push( - ' artifact, then a separate `workflow_run` consumes it and posts the', - ) - lines.push(' comment with the privileged token.') - lines.push( - ' b. Gate the pull_request_target trigger on `labeled` so only maintainers', - ) - lines.push( - ' can run it: `on: pull_request_target: types: [labeled]`.', - ) - lines.push( - ' c. Never check out the fork in pull_request_target context.', - ) - lines.push('') - lines.push( - ' Reference: https://bsky.app/profile/43081j.com/post/3mlnme43qnc2e', - ) - lines.push('') - lines.push( - ` Bypass (rare; requires a deliberate review trade-off): type "${BYPASS_PHRASE}".`, - ) - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) - } catch (e) { - process.stderr.write( - `[pull-request-target-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } -}) diff --git a/.claude/hooks/pull-request-target-guard/package.json b/.claude/hooks/pull-request-target-guard/package.json deleted file mode 100644 index a0771a703..000000000 --- a/.claude/hooks/pull-request-target-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-pull-request-target-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/pull-request-target-guard/test/index.test.mts b/.claude/hooks/pull-request-target-guard/test/index.test.mts deleted file mode 100644 index 9a908ac4c..000000000 --- a/.claude/hooks/pull-request-target-guard/test/index.test.mts +++ /dev/null @@ -1,342 +0,0 @@ -// node --test specs for the pull-request-target-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-workflow files pass through', async () => { - const result = await runHook({ - tool_input: { - file_path: '/x/src/foo.ts', - new_string: 'on: pull_request_target\nactions/checkout\npnpm install\n', - }, - tool_name: 'Edit', - }) - assert.strictEqual(result.code, 0) -}) - -test('non-Edit/Write tools pass through', async () => { - const result = await runHook({ - tool_input: { command: 'echo pull_request_target' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('safe: pull_request_target without fork checkout', async () => { - // pull_request_target trigger, but checkout pulls the BASE (default). - const yaml = `name: PR check -on: pull_request_target -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: pnpm install - - run: pnpm test -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/pr.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('safe: fork checkout without pull_request_target', async () => { - // Same checkout shape but trigger is pull_request — no secrets in - // scope, so executing fork code is fine. - const yaml = `name: PR check -on: pull_request -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install - - run: pnpm test -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/pr.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('safe: pull_request_target + fork checkout but no execute step', async () => { - // Workflow checks out the fork but only inspects metadata (e.g. - // posts a comment). No execute. Zizmor's `dangerous-triggers` - // would still flag the shape, but this hook is satisfied. - const yaml = `name: comment -on: pull_request_target -jobs: - comment: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({...}) -` - const result = await runHook({ - tool_input: { - file_path: '/x/.github/workflows/comment.yml', - new_string: yaml, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('BLOCKS: pull_request_target + fork checkout + pnpm install', async () => { - const yaml = `name: PR check -on: pull_request_target -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install - - run: pnpm test -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/pr.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /pull_request_target/) - assert.match(result.stderr, /package-manager install/) -}) - -test('BLOCKS: pull_request_target + fork checkout + npm i', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.ref }} - - run: npm i -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: pull_request_target + fork checkout + build', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm run build -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /node build/) -}) - -test('BLOCKS: pull_request_target + fork checkout + cargo build', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: cargo build --release -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: pull_request_target + fork checkout + pip install', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pip install -r requirements.txt -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: pull_request_target + fork checkout + make', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: make all -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('safe: pnpm install --ignore-scripts is allowed', async () => { - // --ignore-scripts neutralizes the install-script vector. The - // hook treats install-with-ignore-scripts as safe; a build step - // on a subsequent line would still trip. - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install --ignore-scripts -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('BLOCKS: pnpm install --ignore-scripts + then pnpm build (build still fork code)', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install --ignore-scripts - - run: pnpm build -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: array trigger form on: [pull_request, pull_request_target]', async () => { - const yaml = `on: [pull_request, pull_request_target] -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: pull_request_target with types in block-mapping form', async () => { - const yaml = `on: - pull_request_target: - types: [opened, synchronize] -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('BLOCKS: shell-piped curl install', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: bash -c "$(curl -sL https://example.com/install.sh)" -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) -}) - -test('error message names all three risk components', async () => { - const yaml = `on: pull_request_target -jobs: - j: - steps: - - uses: actions/checkout@v4 - with: - ref: \${{ github.event.pull_request.head.sha }} - - run: pnpm install -` - const result = await runHook({ - tool_input: { file_path: '/x/.github/workflows/x.yml', new_string: yaml }, - tool_name: 'Write', - }) - assert.match(result.stderr, /pull_request_target/) - assert.match(result.stderr, /head\./) - assert.match(result.stderr, /package-manager install/) - assert.match(result.stderr, /Safer patterns/) - assert.match(result.stderr, /labeled/) -}) diff --git a/.claude/hooks/pull-request-target-guard/tsconfig.json b/.claude/hooks/pull-request-target-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/pull-request-target-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/readme-fleet-shape-guard/README.md b/.claude/hooks/readme-fleet-shape-guard/README.md deleted file mode 100644 index 20f19c6bb..000000000 --- a/.claude/hooks/readme-fleet-shape-guard/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# readme-fleet-shape-guard - -PreToolUse Edit/Write hook that blocks edits to the **root `README.md`** when the resulting content violates the canonical fleet skeleton. - -## Why - -Root READMEs across fleet repos drift in three predictable ways: (a) the canonical 5-section structure gets reordered or partially missing, (b) `socket-wheelhouse` (a private repo) leaks into prose or links, (c) commands invoke sibling-repo relative paths (`node ../socket-foo/scripts/...`) that outside readers can't follow. All three are public-facing failure modes. - -The fleet has matching surfaces at three layers: - -- **Lint-time** — `template/.config/markdownlint-rules/socket-{readme-required-sections, no-private-wheelhouse-leak, no-relative-sibling-script}.mjs`. -- **Sync-time** — `scripts/sync-scaffolding/checks/readme-skeleton-drift.mts` (report-only; no autofix because README content is contextual). -- **Edit-time** — this hook. Fires at the earliest surface, before the drift can be committed or pushed. - -## How - -On `Edit` / `MultiEdit` / `Write` whose `file_path` resolves to the repo-root `README.md`, the hook: - -1. Reconstructs the post-edit text (Write → `content`; Edit → splice `old_string` → `new_string` against the on-disk file). -2. Runs three checks: section list (5 required, in order); `socket-wheelhouse` mention (outside fenced code blocks); sibling-repo relative path patterns. -3. If any check fires AND the user hasn't typed the bypass phrase, exits 2 with a stderr explaining which rule was hit, the canonical fix, and the bypass instructions. - -Nested READMEs (`packages/*/README.md`, `docs/*/README.md`, etc.) are silently ignored — they're scoped docs with their own shape. - -## Bypass - -User types **`Allow readme-fleet-shape bypass`** verbatim in a recent message (within the last 8 user turns). Case-sensitive; paraphrases don't count. - -## Failing open - -The hook fails open on its own bugs (exit 0 + stderr log) so a buggy hook can't brick the session. The trade-off: a bug means the check silently doesn't apply for that edit. The sync-time check and the lint-time check still catch the drift later. - -## Related - -- `.claude/hooks/no-meta-comments-guard/` — structural template; same `_shared/transcript.mts` bypass pattern. -- `.claude/hooks/plan-location-guard/` — same PreToolUse + bypass shape, blocking on file-path classification. diff --git a/.claude/hooks/readme-fleet-shape-guard/index.mts b/.claude/hooks/readme-fleet-shape-guard/index.mts deleted file mode 100644 index dccc7c282..000000000 --- a/.claude/hooks/readme-fleet-shape-guard/index.mts +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — readme-fleet-shape-guard. -// -// Blocks Edit/Write of the root README.md when the resulting content -// violates the canonical fleet skeleton: -// -// (a) Missing or out-of-order canonical section. The 5 level-2 -// sections must appear in this order: -// Why this repo exists / Install / Usage / Development / License -// -// (b) Mentions `socket-wheelhouse` outside fenced code blocks. -// socket-wheelhouse is a private repo; the link 404s for outside -// readers. -// -// (c) Invokes a command against a sibling-repo relative path. -// `node ../socket-foo/scripts/...` and similar shapes assume the -// reader has the sibling repo checked out at exactly the right -// relative level — almost never true for an outside user. -// -// Only fires on the REPO-ROOT README.md (basename === 'README.md' AND -// directory is repo root). Nested READMEs (packages/, docs/, .claude/, -// etc.) are scoped docs with their own shape; this hook is silent for -// them. -// -// Bypass phrase: `Allow readme-fleet-shape bypass`. Reading recent user -// turns follows the same pattern as no-revert-guard, plan-location-guard. -// -// Companion to: -// - scripts/sync-scaffolding/checks/readme-skeleton-drift.mts -// (sync-time check, no autofix) -// - template/.config/markdownlint-rules/socket-{readme-required-sections, -// no-private-wheelhouse-leak, no-relative-sibling-script}.mjs -// (lint-time check) -// -// This hook is the edit-time enforcement — it fires when the README is -// being written, catching the failure mode at its earliest surface. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Edit" | "MultiEdit" | "Write", -// "tool_input": { "file_path": "...", -// "content"?: "...", -// "new_string"?: "...", -// "old_string"?: "..." }, -// "transcript_path": "/.../session.jsonl" } -// -// Exits: -// 0 — allowed. -// 2 — blocked (with stderr message that explains rule + fix + bypass). -// 0 (with stderr log) — fail-open on hook bugs. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - content?: string | undefined - file_path?: string | undefined - new_string?: string | undefined - old_string?: string | undefined - } - | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow readme-fleet-shape bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -const REQUIRED_SECTIONS = [ - 'Why this repo exists', - 'Install', - 'Usage', - 'Development', - 'License', -] as const - -const WHEELHOUSE_LEAK_RE = /socket-wheelhouse/i -const SIBLING_PATH_RES: readonly RegExp[] = [ - /\b(?:bun|deno|node|npm|pnpm|yarn)\s+\.\.\/[\w@-]+\//, - // socket-hook: allow regex-alternation-order - /(?:^|\s)\.\.\/socket-[\w-]+\//i, - // socket-hook: allow regex-alternation-order - /(?:^|\s)\.\.\/sdxgen\//, - // socket-hook: allow regex-alternation-order - /(?:^|\s)\.\.\/stuie\//, -] - -/** - * Repo-root README detection. The hook only fires on the root README.md, not - * nested READMEs. The check is path-shape only — basename match + parent - * directory ≠ another README's parent. - */ -export function isRootReadme(filePath: string): boolean { - const normalized = filePath.replace(/\\/g, '/') - if (path.basename(normalized) !== 'README.md') { - return false - } - const dir = path.dirname(normalized) - // Nested-README markers: any path segment that says "this is a - // scoped doc, not the repo root." - const segments = dir.split('/').filter(Boolean) - const SCOPED_PARENTS = new Set([ - '.claude', - 'apps', - 'crates', - 'docs', - 'examples', - 'packages', - 'pkg-node', - 'scripts', - 'template', - 'test', - 'tools', - ]) - for (const seg of segments) { - if (SCOPED_PARENTS.has(seg)) { - return false - } - } - return true -} - -/** - * Compute the post-edit text for an Edit (splice old_string → new_string - * against the on-disk file) or a Write (just `content`). Returns undefined when - * the post-edit text can't be reliably computed (Edit against a file that - * doesn't exist, or old_string not found). - */ -export function computePostEditText( - toolName: string, - filePath: string, - newString: string | undefined, - oldString: string | undefined, - content: string | undefined, -): string | undefined { - if (toolName === 'Write') { - return content - } - if (toolName === 'Edit' || toolName === 'MultiEdit') { - if (!existsSync(filePath)) { - // Edit against a non-existent file is unusual; let it through. - return undefined - } - let onDisk: string - try { - onDisk = readFileSync(filePath, 'utf8') - } catch { - return undefined - } - if (oldString === undefined || newString === undefined) { - return undefined - } - const idx = onDisk.indexOf(oldString) - if (idx === -1) { - return undefined - } - return ( - onDisk.slice(0, idx) + newString + onDisk.slice(idx + oldString.length) - ) - } - return undefined -} - -interface ShapeFinding { - kind: 'missing-section' | 'wheelhouse-leak' | 'relative-sibling' - detail: string -} - -export function findShapeViolations(text: string): ShapeFinding[] { - const lines = text.split('\n') - const findings: ShapeFinding[] = [] - - const headings: string[] = [] - for (let i = 0, { length } = lines; i < length; i += 1) { - const m = /^##\s+(.+?)\s*$/.exec(lines[i] ?? '') - if (m && m[1]) { - headings.push(m[1]) - } - } - let cursor = 0 - for (let r = 0, { length } = REQUIRED_SECTIONS; r < length; r += 1) { - const want = REQUIRED_SECTIONS[r] - let found = -1 - for (let h = cursor; h < headings.length; h += 1) { - if (headings[h] === want) { - found = h - break - } - } - if (found === -1) { - findings.push({ - kind: 'missing-section', - detail: `Missing canonical section "## ${want}" (or out of order)`, - }) - break - } - cursor = found + 1 - } - - let inFence = false - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i] ?? '' - if (/^\s*(?:```|~~~)/.test(line)) { - inFence = !inFence - continue - } - if (inFence) { - continue - } - if (WHEELHOUSE_LEAK_RE.test(line)) { - findings.push({ - kind: 'wheelhouse-leak', - detail: `Line ${i + 1} mentions socket-wheelhouse: ${line.trim().slice(0, 120)}`, - }) - break - } - } - - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i] ?? '' - let matched = false - for (let j = 0, jl = SIBLING_PATH_RES.length; j < jl; j += 1) { - if (SIBLING_PATH_RES[j]!.test(line)) { - matched = true - break - } - } - if (matched) { - findings.push({ - kind: 'relative-sibling', - detail: `Line ${i + 1} invokes a sibling-relative path: ${line.trim().slice(0, 120)}`, - }) - break - } - } - - return findings -} - -async function main(): Promise<number> { - const raw = await readStdin() - if (!raw.trim()) { - return 0 - } - - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.stderr.write( - 'readme-fleet-shape-guard: failed to parse stdin payload — fail-open\n', - ) - return 0 - } - - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'Write') { - return 0 - } - - const filePath = payload.tool_input?.file_path - if (!filePath || !isRootReadme(filePath)) { - return 0 - } - - const postEdit = computePostEditText( - tool, - filePath, - payload.tool_input?.new_string, - payload.tool_input?.old_string, - payload.tool_input?.content, - ) - if (postEdit === undefined) { - return 0 - } - - const findings = findShapeViolations(postEdit) - if (findings.length === 0) { - return 0 - } - - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return 0 - } - - const lines: string[] = [ - `🚨 readme-fleet-shape-guard: blocked Edit/Write of root README.md.`, - ``, - `File: ${filePath}`, - ``, - `Violations:`, - ] - for (let i = 0, { length } = findings; i < length; i += 1) { - lines.push(` - ${findings[i]!.detail}`) - } - lines.push(``) - lines.push( - `Per the fleet "Canonical README" rule (CLAUDE.md → Canonical README),`, - ) - lines.push(`root README.md must follow the skeleton at:`) - lines.push(` socket-wheelhouse/template/README.md`) - lines.push(``) - lines.push(`Required sections in order:`) - for (let i = 0, { length } = REQUIRED_SECTIONS; i < length; i += 1) { - lines.push(` ${i + 1}. ## ${REQUIRED_SECTIONS[i]}`) - } - lines.push(``) - lines.push( - `One-shot bypass (rare): user types "${BYPASS_PHRASE}" verbatim in a recent message.`, - ) - lines.push(``) - process.stderr.write(`${lines.join('\n')}`) - return 2 -} - -main().then( - code => process.exit(code), - err => { - process.stderr.write( - `readme-fleet-shape-guard: hook error — fail-open: ${String(err)}\n`, - ) - process.exit(0) - }, -) diff --git a/.claude/hooks/readme-fleet-shape-guard/package.json b/.claude/hooks/readme-fleet-shape-guard/package.json deleted file mode 100644 index 5aa420611..000000000 --- a/.claude/hooks/readme-fleet-shape-guard/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-readme-fleet-shape-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts b/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts deleted file mode 100644 index 76ab7c113..000000000 --- a/.claude/hooks/readme-fleet-shape-guard/test/index.test.mts +++ /dev/null @@ -1,139 +0,0 @@ -// node --test specs for the readme-fleet-shape-guard hook. - -import test from 'node:test' -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -const CANONICAL_README = [ - '# foo', - '', - '## Why this repo exists', - '', - 'A thing.', - '', - '## Install', - '', - '```sh', - 'npm install foo', - '```', - '', - '## Usage', - '', - '```js', - 'const foo = require("foo")', - '```', - '', - '## Development', - '', - 'pnpm install', - '', - '## License', - '', - 'MIT', - '', -].join('\n') - -test('non-Edit/Write tool calls pass through', async () => { - const result = await runHook({ - tool_input: { command: 'ls' }, - tool_name: 'Bash', - }) - assert.strictEqual(result.code, 0) -}) - -test('nested README is ignored', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/packages/bar/README.md', - content: '# bar\n\nNo canonical sections at all.\n', - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('canonical root README passes', async () => { - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/README.md', - content: CANONICAL_README, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 0) -}) - -test('missing canonical section is blocked', async () => { - const broken = CANONICAL_README.replace('## Install', '## Setup') - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/README.md', - content: broken, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /readme-fleet-shape-guard/) - assert.match(result.stderr, /Missing canonical section "## Install"/) -}) - -test('socket-wheelhouse mention is blocked', async () => { - const leaky = CANONICAL_README.replace( - 'A thing.', - 'A thing. See socket-wheelhouse for details.', - ) - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/README.md', - content: leaky, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /socket-wheelhouse/) -}) - -test('relative sibling script is blocked', async () => { - const sibling = CANONICAL_README.replace( - 'pnpm install', - 'node ../socket-bar/scripts/foo.mts', - ) - const result = await runHook({ - tool_input: { - file_path: '/Users/x/projects/foo/README.md', - content: sibling, - }, - tool_name: 'Write', - }) - assert.strictEqual(result.code, 2) - assert.match(result.stderr, /sibling-relative path/) -}) diff --git a/.claude/hooks/readme-fleet-shape-guard/tsconfig.json b/.claude/hooks/readme-fleet-shape-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/readme-fleet-shape-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/release-workflow-guard/README.md b/.claude/hooks/release-workflow-guard/README.md deleted file mode 100644 index 5be9f04ef..000000000 --- a/.claude/hooks/release-workflow-guard/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# release-workflow-guard - -A **Claude Code hook** that runs before every Bash command and -**blocks** any attempt to dispatch a GitHub Actions workflow. The -model never gets to fire those commands; the human running Claude has -to do it themselves. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, command never runs). This one blocks. - -## Why this is so strict - -Workflow dispatches are **irrevocable**: - -- Publish workflows push npm versions. Once published, an npm version - is unpublishable after 24 hours. -- Build/Release workflows cut GitHub releases pinned to a specific - SHA. Releases can be edited, but the SHA + the moment they were - cut are forever. -- Container workflows push immutable image tags. -- Even workflows that _advertise_ a `dry_run` input still treat the - dispatch itself as a prod trigger — the workflow runs and counts - for downstream CI gating; only specific steps may be skipped. - -The cost of blocking a legitimate dispatch is one re-prompt — the -user types the command in their own terminal. The cost of letting -through a wrong dispatch is irreversible. So the hook errs strict. - -## What gets blocked - -- `gh workflow run <id>` -- `gh workflow dispatch <id>` (alias of `run`) -- `gh api .../actions/workflows/<id>/dispatches` (POST or PUT) - -Any other Bash command passes through silently. - -## Why no per-workflow allowlist - -Because allowlists drift. A "benign" CI dispatch today becomes a -prod-touching dispatch tomorrow when someone wires a publish step -behind it, and nobody remembers to update the allowlist. Block all -dispatches; let the user judge case-by-case. - -## Override - -There is no opt-out. If a real workflow id needs dispatching during -a Claude session: - -- The user runs it from a plain shell outside Claude, or -- Triggers it via the GitHub Actions UI, or -- Types `! gh workflow run ...` at a Claude prompt — the leading - `!` runs the command in the user's session, where this hook - doesn't fire. - -## Wiring - -`.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/release-workflow-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Exit codes - -- `0` — command is not a workflow dispatch; pass through. -- `2` — command is a workflow dispatch; block + write the reason to - stderr. - -## Sibling hooks - -The "blocking, not priming" pattern is shared across three hooks: - -- [`token-guard`](../token-guard/) — blocks Bash calls that would - leak literal secrets to stdout. -- [`path-guard`](../path-guard/) — blocks Edit/Write calls that - build inline multi-stage paths. -- `release-workflow-guard` (this one). - -The other public-surface hooks ([`private-name-guard`](../private-name-guard/), -[`public-surface-reminder`](../public-surface-reminder/)) only -**prime** — they exit 0 after writing a reminder. The shared rule -for which side of the fence a hook lands on: block when the harm of -a wrong fire is irreversible; prime when it's recoverable. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/release-workflow-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/release-workflow-guard/index.mts b/.claude/hooks/release-workflow-guard/index.mts deleted file mode 100644 index 4c313a921..000000000 --- a/.claude/hooks/release-workflow-guard/index.mts +++ /dev/null @@ -1,751 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — release-workflow-guard. -// -// Risk-tiered policy on Bash commands that would dispatch a GitHub -// Actions workflow. The risk that matters is reversibility: -// -// - npm publish: irreversible after the 24h unpublish window. The -// package version is locked forever. Block always. -// - GitHub release: reversible via `gh release delete <tag> -// --cleanup-tag`. The downstream blast radius is bounded by who -// pulled the release before deletion. Allowable. -// - Container image push: effectively irreversible (registries -// conventionally treat image tags as immutable). Block. -// -// Hook decision tree, in order: -// -// 1. Verifiable dry-run? (`-f dry-run=true` + workflow declares -// `dry-run:` input + no force-prod override) → ALLOW. -// 2. GitHub-release-only workflow? (workflow YAML never calls -// `npm/pnpm/yarn publish`, does call `gh release create` / -// release action, and command has no force-prod override) -// → ALLOW. -// 3. Anything else (npm-publishing workflow, force-prod override, -// unclassifiable workflow, `gh api .../dispatches` shape) → BLOCK. -// -// The npm-publish detector triggers on `npm publish`, `pnpm publish`, -// `yarn publish`, and `JS-DevTools/npm-publish` action references in -// the workflow YAML. The GH-release detector triggers on -// `gh release create`, `softprops/action-gh-release`, and -// `ncipollo/release-action`. Both run with whitespace tolerance. -// -// Force-prod overrides keep blocking even for GH-only workflows: -// `-f release=true`, `-f publish=true`, `-f prod=true`, -// `-f production=true`. These flip a workflow back into "do the prod -// thing" — a GH-release-only workflow that takes `publish=true` may -// be wired to also npm-publish in that branch. -// -// Recovery (when a wrong release lands): -// - `gh release delete <tag> --cleanup-tag --yes` -// (drops the GH release and the git tag in one command) -// -// Exit code 2 with a clear stderr message stops the tool call. The -// model never gets to fire the command. The user re-runs it from -// their own terminal (or via the GitHub Actions UI) when ready. -// -// Blocked patterns: -// - `gh workflow run <id>` -// - `gh workflow dispatch <id>` (alias of `run`) -// - `gh api ... actions/workflows/<id>/dispatches` POST/PUT -// (the gh-api shape never bypasses; it takes inputs as a JSON -// body which is harder to verify safely. Route through user.) -// -// Operational rules paired with the SKILL ("updating-node" Phase 5): -// - Cap of 2 live releases per artifact in flight. Before -// dispatching a 3rd, delete the oldest tag+release. Keeps one -// prior release as a validation safety net. -// - Before dispatching a release workflow, bump the corresponding -// `.github/cache-versions.json` entry. Otherwise the workflow -// hits a stale cache and re-publishes a stale binary. -// -// The hook recognizes only kebab-case `dry-run` as the input name — -// see CLAUDE.md "Workflow input naming" for the rule. If a workflow -// declares `dry_run` (snake) or any other shape, the verification -// fails and the bypass doesn't apply. Fix the workflow. -// -// This hook is the enforcement layer paired with the CLAUDE.md -// rule. The rule documents the policy; the hook makes it -// mechanical so the model can't accidentally dispatch a workflow -// even when reasoning about urgent release work. -// -// Reads a Claude Code PreToolUse JSON payload from stdin: -// { "tool_name": "Bash", "tool_input": { "command": "..." } } - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { commandsFor, parseCommands } from '../_shared/shell-command.mts' -import { bypassPhraseRemaining } from '../_shared/transcript.mts' - -type ToolInput = { - tool_input?: - | { - command?: string | undefined - } - | undefined - tool_name?: string | undefined - transcript_path?: string | undefined -} - -// Bypass phrase: `Allow workflow-dispatch bypass: <workflow>`. -// Authorizes EXACTLY ONE dispatch of the named workflow when the -// user types the phrase verbatim in a recent turn. Re-dispatching -// the same workflow needs a fresh phrase. Dispatching a different -// workflow needs its own phrase. -// -// Why per-workflow + per-trigger: an earlier shape just matched the -// bare string `Allow workflow-dispatch bypass`, which authorized -// every dispatch in the next 8 user turns. That was too permissive -// — one phrase shouldn't open the door for an unrelated workflow -// later in the session. The colon-suffix form names the workflow -// being authorized so each phrase consumes one specific dispatch. -// -// `<workflow>` is the literal token passed to `gh workflow run` — -// either the workflow filename (`publish.yml`), the basename -// (`publish`), or the workflow ID (`12345`). The matcher accepts -// any of those three shapes for the same workflow because the user -// might write whichever feels natural. -// -// Use cases that need the bypass (the dry-run path doesn't cover): -// - Workflows that don't accept a `dry-run` input by design -// (e.g. node-smol's main build, which has 30-minute side effects -// but no inverse). -// - One-off recovery dispatches after a stuck job. -// - Re-dispatches after a transient infra failure (cache miss, -// runner timeout) where the user has already verified the -// previous run's intent. -// -// Once-and-done: once the hook authorizes a dispatch against a -// phrase, that exact phrase doesn't authorize a second dispatch. -// Implementation note: we don't write to disk to track consumption — -// instead the test "is this phrase present AFTER my last dispatch -// of this workflow" answers it. See `findUnclaimedBypassPhrase`. -const BYPASS_PHRASE_PREFIX = 'Allow workflow-dispatch bypass:' -const BYPASS_LOOKBACK_USER_TURNS = 8 - -/** - * Build the canonical phrase variants that authorize ONE dispatch of - * `workflow`. The user can name the workflow in any of three shapes — the - * filename, the basename (drop `.yml` / `.yaml`), or the numeric workflow id — - * and any of them counts. - */ -export function buildAcceptedPhrases(workflow: string): readonly string[] { - const stripped = workflow.replace(/\.(?:yaml|yml)$/i, '') - // De-duplicate when filename and basename collapse to the same - // string (the workflow target was already stripped). - const tokens = stripped === workflow ? [workflow] : [workflow, stripped] - return tokens.map(token => `${BYPASS_PHRASE_PREFIX} ${token}`) -} - -/** - * Count past `gh workflow run/dispatch` invocations targeting `workflow` in the - * assistant tool-use history. Each prior dispatch consumes one bypass phrase, - * so the per-trigger guard requires `phraseCount > priorDispatchCount`. - * - * Walks the transcript JSONL directly — `_shared/transcript.mts` exposes - * `readLastAssistantToolUses` for the most-recent turn only, but here we need - * the full history. Best-effort: malformed lines are skipped silently. - */ -export function countPriorDispatches( - transcriptPath: string | undefined, - workflow: string, -): number { - if (!transcriptPath || !workflow) { - return 0 - } - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return 0 - } - const accepted = new Set([workflow, workflow.replace(/\.(?:yaml|yml)$/i, '')]) - let count = 0 - const lines = raw.split('\n') - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - if (!line) { - continue - } - let evt: unknown - try { - evt = JSON.parse(line) - } catch { - continue - } - // Look at assistant tool-use blocks only — the user's Bash - // calls (if any) don't count, and our own future calls are - // not yet in the transcript when this hook runs. - if ( - !evt || - typeof evt !== 'object' || - (evt as Record<string, unknown>)['type'] !== 'assistant' - ) { - continue - } - const message = (evt as Record<string, unknown>)['message'] - if (!message || typeof message !== 'object') { - continue - } - const content = (message as Record<string, unknown>)['content'] - if (!Array.isArray(content)) { - continue - } - for (let j = 0, blocksLen = content.length; j < blocksLen; j += 1) { - const block = content[j] - if (!block || typeof block !== 'object') { - continue - } - const b = block as Record<string, unknown> - if (b['type'] !== 'tool_use' || b['name'] !== 'Bash') { - continue - } - const cmd = (b['input'] as Record<string, unknown> | undefined)?.[ - 'command' - ] - if (typeof cmd !== 'string') { - continue - } - const dispatch = detectDispatch(cmd) - if (dispatch.workflow && accepted.has(dispatch.workflow)) { - count += 1 - } - } - } - return count -} - -// Flags on `gh workflow run/dispatch` that take a value argument — so -// the value isn't mistaken for the workflow target. `gh workflow run -// publish.yml -f dry-run=true --ref main` → target is `publish.yml`. -const GH_WORKFLOW_VALUE_FLAGS = new Set([ - '--field', - '--json', - '--raw-field', - '--ref', - '--repo', - '-F', - '-R', - '-f', - '-r', -]) - -// `gh api` path that names a workflow dispatch endpoint: -// `.../actions/workflows/<id>/dispatches`. The path component implies -// dispatch — no need to also inspect -X. -const GH_API_DISPATCH_PATH_RE = /\/actions\/workflows\/([^/\s]+)\/dispatches\b/ - -// Dry-run input detection. The fleet standardized on `dry-run` -// (kebab-case) — see socket-registry's shared actions and every -// `*.yml` workflow that takes a dispatch input. Match values -// "true"/"1"/"yes" as truthy and "false"/"0"/"no" as falsy. Quote- -// mask handling lives in detectDispatch; these regexes scan the -// same masked range as the dispatch detector. -const DRY_RUN_TRUE_RE = /-f\s+dry-run\s*=\s*['"]?(?:1|true|yes)['"]?/i -const DRY_RUN_FALSE_RE = /-f\s+dry-run\s*=\s*['"]?(?:0|false|no)['"]?/i - -// Inputs that flip a workflow back into "do the prod thing." Even -// with dry-run=true, if any of these are explicitly set the dispatch -// is no longer benign — block. Order matters: this runs after -// dry-run detection, so an explicit publish=true overrides. -const FORCE_PROD_INPUTS_RE = - /-f\s+(?:prod|production|publish|release)\s*=\s*['"]?(?:1|true|yes)['"]?/i - -// Workflow YAML input declaration. Match the canonical -// `dry-run:` line under `inputs:` — used to verify a workflow -// actually accepts a dry-run override before allowing a dispatch -// that claims to use it. Tolerates leading whitespace (any -// indentation) since YAML nesting depth varies by file. -const WORKFLOW_DRY_RUN_INPUT_RE = /^\s+dry-run:\s*$/m - -// npm-publish detector. A workflow that contains any of these tokens -// publishes to npm — irreversible after the 24h unpublish window. -// Always block these dispatches unless the user runs them themselves. -// - `npm publish` / `pnpm publish` / `yarn publish` (CLI) -// - `JS-DevTools/npm-publish` (popular publish action) -// The whitespace tolerance handles `pnpm publish` and `npm publish` -// found in real workflow YAML. -const WORKFLOW_NPM_PUBLISH_RE = - /\b(?:npm|pnpm|yarn)\s+publish\b|JS-DevTools\/npm-publish/i - -// GitHub-release detector. A workflow that creates a GH release but -// never npm-publishes is allowed live (dispatch can be re-run, prior -// releases can be deleted via `gh release delete`). Recognize both -// the `gh release create` CLI and the standard release actions. -const WORKFLOW_GH_RELEASE_RE = - /\bgh\s+release\s+create\b|softprops\/action-gh-release|ncipollo\/release-action/i - -// `--repo <owner>/<name>` parser. Captures the repo name (after the -// slash). Used to gate the dry-run bypass: a dispatch targeting a -// repo other than the current $CLAUDE_PROJECT_DIR can't be verified -// from disk, so we conservatively block it. -const GH_REPO_FLAG_RE = /\s--repo\s+\S*?\/([^\s/]+)/ - -// Inline `cd <path> && …` parser. Captures the destination path so -// the search-roots resolver can include it. Claude Code's Bash tool -// invokes PreToolUse hooks with cwd = the session's project dir -// (not the cwd the chained command will switch to), so without this -// parse the hook can't locate a workflow YAML that lives in the -// sibling clone the user is targeting via `cd`. The path may be -// quoted ("..." or '...'); strip the quotes for the resolver. -const INLINE_CD_RE = /(?:^|[;&])\s*cd\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*&&/ -// (Use a single capture in the consumer by checking groups 1..3 — the -// regex syntax requires three alternation groups; the resolver picks -// the first non-undefined.) - -type DispatchResult = { - // When `blocked` is false, populated with the reason the dispatch - // was allowed through. Surfaced in the hook's "allowed" log line so - // the user can see exactly why the guard let it pass. - allowedReason?: string | undefined - blocked: boolean - shape?: string | undefined - workflow?: string | undefined -} - -// Resolve the workflow file path and verify it actually declares a -// `dry-run` input. The path is resolved relative to -// `$CLAUDE_PROJECT_DIR/.github/workflows/<workflow>` since the hook -// runs from arbitrary cwds; falls back to ".github/workflows/<wf>" -// when the env var is unset (e.g. the hook invoked outside Claude -// Code). The check is intentionally permissive: any unparseable -// workflow file is treated as "no dry-run input" (block-the-default). -// -// `searchRoots` is the list of project directories to probe. The -// caller picks exactly one based on the dispatch shape: -// - same-repo (no --repo, or --repo names the current project): -// just the current project dir. -// - cross-repo (--repo names a different project): just the -// sibling clone at <parent-of-project-dir>/<name>. The current -// project is intentionally excluded so a same-named workflow in -// the current checkout can't false-positive a cross-repo dispatch. -// Classify a workflow file by its release shape: -// - 'npm' — runs `npm/pnpm/yarn publish` somewhere; irreversible -// - 'gh' — only creates GitHub releases (reversible via -// `gh release delete`) -// - 'unknown' — no detected release shape (file unreadable, or -// workflow does something the classifier can't see) -// -// The hook treats 'gh' as eligible for live dispatch (after other -// gates pass) and treats 'npm' / 'unknown' as block-the-default. -export function classifyWorkflow( - workflow: string, - searchRoots: readonly string[], -): 'npm' | 'gh' | 'unknown' { - if (!/\.(?:yaml|yml)$/i.test(workflow)) { - return 'unknown' - } - const filename = path.basename(workflow) - for (let i = 0, { length } = searchRoots; i < length; i += 1) { - const root = searchRoots[i]! - const fullPath = path.join(root, '.github', 'workflows', filename) - if (!existsSync(fullPath)) { - continue - } - try { - const yaml = readFileSync(fullPath, 'utf8') - // npm-publish wins if both signals appear — a workflow that - // both creates a GH release AND publishes to npm is still - // irreversible at the npm step. - if (WORKFLOW_NPM_PUBLISH_RE.test(yaml)) { - return 'npm' - } - if (WORKFLOW_GH_RELEASE_RE.test(yaml)) { - return 'gh' - } - // File exists but neither signal — fall through to next root. - } catch { - // Read error — try next root. - } - } - return 'unknown' -} - -export function workflowDeclaresDryRunInput( - workflow: string, - searchRoots: readonly string[], -): boolean { - // Workflow arg can be "id.yml", "name.yaml", a numeric ID, or a path. - // Numeric IDs and paths-without-extension can't be resolved without - // hitting GitHub's API — for those, conservatively return false. - if (!/\.(?:yaml|yml)$/i.test(workflow)) { - return false - } - // Strip any leading directory prefix the user passed (e.g. they - // typed the path explicitly). The bare filename is what - // .github/workflows/ holds. - const filename = path.basename(workflow) - for (let i = 0, { length } = searchRoots; i < length; i += 1) { - const root = searchRoots[i]! - const fullPath = path.join(root, '.github', 'workflows', filename) - if (!existsSync(fullPath)) { - continue - } - try { - const yaml = readFileSync(fullPath, 'utf8') - if (WORKFLOW_DRY_RUN_INPUT_RE.test(yaml)) { - return true - } - // File exists but no dry-run input — fall through to next root. - // (Same-name workflow may exist in multiple sibling repos with - // different shapes; only one needs to satisfy the verification.) - } catch { - // Read error — try next root. - } - } - return false -} - -// Decide whether a dispatch on `workflow` should be allowed because -// it's a verifiable dry-run. All four conditions must hold: -// 1. `-f dry-run=true|1|yes` is explicitly present in the command -// 2. `-f dry-run=false|0|no` is NOT present (user didn't override) -// 3. No force-prod input is present (release/publish/prod=true) -// 4. The target workflow YAML declares a `dry-run:` input under -// its `workflow_dispatch.inputs` block — without that, the gh -// CLI silently accepts the flag but the workflow ignores it. -// -// The workflow lookup probes the current project first, then any -// sibling clone implied by `--repo owner/<name>`. Sibling clones -// follow the fleet convention: `<projects-dir>/<repo-name>` next to -// the current project. If the file isn't readable from any local -// checkout, the bypass denies — same posture as a missing file. -// Resolve the workflow file's search roots based on the command's -// --repo flag. Used by both bypasses (dry-run + GH-release-only). -// - same-repo (no --repo, or --repo names the current project): -// the current project dir, plus `process.cwd()` when it differs. -// The cwd fallback covers the cross-session case where one Claude -// session has CLAUDE_PROJECT_DIR pointing at repo A, but the user -// `cd`-ed into sibling repo B before invoking `gh workflow run` -// against a workflow that lives in B. Without the cwd fallback -// the hook would block the bypass because A's YAML doesn't -// declare the dry-run input that B's does. -// - cross-repo (--repo names a different project): just the sibling -// clone at <parent-of-project-dir>/<name>. The current project is -// intentionally excluded so a same-named workflow in the current -// checkout can't false-positive a cross-repo dispatch. -export function resolveSearchRoots(command: string): string[] { - // Resolution order: $CLAUDE_PROJECT_DIR (Claude Code sets this when - // it remembers to) → derive from this hook script's path (the hook - // lives at <project>/.claude/hooks/release-workflow-guard/index.mts, - // so go three levels up from __dirname) → $PWD as last resort. - // The script-path derivation is the most robust because it doesn't - // depend on the runner exporting env vars correctly. - let projectDir = process.env['CLAUDE_PROJECT_DIR'] - if (!projectDir) { - // process.argv[1] is the absolute path of this hook script when - // invoked via `node <path>`. Walk up to the repo root. - const scriptPath = process.argv[1] - if (scriptPath) { - // .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo - const candidate = path.resolve(scriptPath, '..', '..', '..', '..') - if (existsSync(path.join(candidate, '.github', 'workflows'))) { - projectDir = candidate - } - } - } - if (!projectDir) { - projectDir = process.cwd() - } - const repoMatch = GH_REPO_FLAG_RE.exec(command) - if (repoMatch && path.basename(projectDir) !== repoMatch[1]!) { - // Cross-repo dispatch: only look in the sibling clone. Excluding - // projectDir keeps a same-name workflow in the current checkout - // from false-positiving the verification. - return [path.join(path.dirname(projectDir), repoMatch[1]!)] - } - // Same-repo (no --repo, or --repo names the current project): add - // process.cwd() when it differs from projectDir AND any inline - // `cd <path> &&` prefix in the command itself. Claude Code's Bash - // tool runs PreToolUse hooks with cwd = the session's project dir, - // not the cwd that the chained command will switch to — so the - // inline-cd parsing is the only way for the hook to find the - // workflow YAML when the user types `cd ../sibling && gh workflow - // run ...` from a session pinned to a different project. - const roots: string[] = [projectDir] - const cwd = process.cwd() - if ( - cwd !== projectDir && - existsSync(path.join(cwd, '.github', 'workflows')) - ) { - roots.push(cwd) - } - const inlineCd = INLINE_CD_RE.exec(command) - if (inlineCd) { - // `cd path && gh workflow run ...` — resolve path relative to - // projectDir (most common: a sibling clone). Absolute paths are - // honored as-is; `~` is left literal because the hook can't - // expand the user's $HOME safely. The capture-group pick handles - // single-quoted / double-quoted / bare forms via three - // alternation groups in INLINE_CD_RE. - const cdPath = inlineCd[1] ?? inlineCd[2] ?? inlineCd[3] - if (cdPath) { - const resolved = path.isAbsolute(cdPath) - ? cdPath - : path.resolve(projectDir, cdPath) - if ( - !roots.includes(resolved) && - existsSync(path.join(resolved, '.github', 'workflows')) - ) { - roots.push(resolved) - } - } - } - return roots -} - -export function isVerifiableDryRun( - command: string, - workflow: string | undefined, -): boolean { - if (!workflow) { - return false - } - if (!DRY_RUN_TRUE_RE.test(command)) { - return false - } - if (DRY_RUN_FALSE_RE.test(command)) { - return false - } - if (FORCE_PROD_INPUTS_RE.test(command)) { - return false - } - return workflowDeclaresDryRunInput(workflow, resolveSearchRoots(command)) -} - -// Decide whether a live (non-dry-run) dispatch is safe because the -// target workflow only releases to GitHub — never to npm. -// Conditions: -// 1. Workflow YAML contains no `npm/pnpm/yarn publish` reference. -// 2. Workflow YAML contains a GH-release indicator -// (`gh release create`, softprops/action-gh-release, etc.). -// 3. No force-prod input (`-f publish=true` etc.) is set on the -// command — those re-enable destructive steps that even an -// otherwise-GH workflow may guard behind a flag. -// -// Recovery from a bad GH release is `gh release delete <tag> -// --cleanup-tag` — single command, undoes both tag and release. That -// shape is acceptable risk; npm publish is not. -export function isGhReleaseOnly( - command: string, - workflow: string | undefined, -): boolean { - if (!workflow) { - return false - } - if (FORCE_PROD_INPUTS_RE.test(command)) { - return false - } - return classifyWorkflow(workflow, resolveSearchRoots(command)) === 'gh' -} - -// Pull the workflow target token out of a parsed `gh workflow -// run/dispatch` arg list. Skips the `workflow` + `run`/`dispatch` -// subcommand words and any value-taking flag + its value; the first -// remaining bare positional is the target (`publish.yml`, `publish`, -// or a numeric id). -function extractWorkflowTarget(args: readonly string[]): string | undefined { - // Locate the run/dispatch subcommand index after the `workflow` word. - const wfIdx = args.indexOf('workflow') - if (wfIdx === -1) { - return undefined - } - let i = wfIdx + 1 - // The subcommand may be `run` or `dispatch`; skip exactly one. - if (args[i] === 'dispatch' || args[i] === 'run') { - i += 1 - } else { - return undefined - } - for (const { length } = args; i < length; i += 1) { - const arg = args[i]! - // `--flag=value` form consumes its own value. - if (arg.startsWith('--') && arg.includes('=')) { - continue - } - if (GH_WORKFLOW_VALUE_FLAGS.has(arg)) { - // Skip the flag's value token too. - i += 1 - continue - } - if (arg.startsWith('-')) { - // A bare flag with no value (rare here) — skip just the flag. - continue - } - return arg - } - return undefined -} - -export function detectDispatch(command: string): DispatchResult { - // Parser-based: each real `gh` invocation is inspected on its own - // args, so a quoted "gh workflow run" in a message body or a sibling - // command's string can't false-trigger, and `$(…)` / chains are seen - // through. No module-scoped /g-regex `lastIndex` state to manage. - // - // Obfuscation guard: when `gh` is produced by a command substitution - // (`$(echo gh) workflow run …`), shell-quote strands `workflow` as - // the command's binary. Treat that shape as a dispatch too — a - // security guard should block-the-default on an obfuscated `gh` - // rather than wave it through. - const ghCommands = commandsFor(command, 'gh') - const obfuscatedWorkflowCommands = parseCommands(command).filter( - c => - c.binary === 'workflow' && - (c.args[0] === 'dispatch' || c.args[0] === 'run'), - ) - for (const c of [...ghCommands, ...obfuscatedWorkflowCommands]) { - // Normalize: gh commands carry `workflow` in args; the obfuscated - // shape carries it as the binary with run/dispatch in args[0]. Build - // a uniform arg list that always starts at `workflow`. - const wfArgs = c.binary === 'workflow' ? ['workflow', ...c.args] : c.args - if (wfArgs.includes('workflow')) { - const workflow = extractWorkflowTarget(wfArgs) - if (workflow) { - if (isVerifiableDryRun(command, workflow)) { - return { - allowedReason: - 'verifiable dry-run (-f dry-run=true + workflow declares dry-run input)', - blocked: false, - shape: 'gh workflow run/dispatch', - workflow, - } - } - if (isGhReleaseOnly(command, workflow)) { - return { - allowedReason: - 'GitHub-release-only workflow (no npm publish; reversible via `gh release delete --cleanup-tag`)', - blocked: false, - shape: 'gh workflow run/dispatch', - workflow, - } - } - return { - blocked: true, - shape: 'gh workflow run/dispatch', - workflow, - } - } - } - // `gh api .../actions/workflows/<id>/dispatches`. The dry-run - // bypass intentionally doesn't apply — that path takes inputs as a - // JSON body, harder to verify; route those through the user. - if (c.args.includes('api')) { - for (let i = 0, { length } = c.args; i < length; i += 1) { - const m = GH_API_DISPATCH_PATH_RE.exec(c.args[i]!) - if (m) { - return { - blocked: true, - shape: 'gh api .../dispatches', - workflow: m[1], - } - } - } - } - } - - return { blocked: false } -} - -function main(): void { - let raw = '' - try { - raw = readFileSync(0, 'utf8') - } catch { - return - } - - let input: ToolInput - try { - input = JSON.parse(raw) - } catch { - return - } - - if (input.tool_name !== 'Bash') { - return - } - const command = input.tool_input?.command - if (!command || typeof command !== 'string') { - return - } - - const { allowedReason, blocked, shape, workflow } = detectDispatch(command) - if (!blocked) { - if (allowedReason) { - // Transparently log the bypass so the user sees why the guard - // let it through. Stderr only — no exit-code change, hook - // behaves as if it never fired. - process.stderr.write( - // socket-hook: allow console - `[release-workflow-guard] ALLOWED: ${shape} on ${workflow ?? '<unknown>'} — ${allowedReason}\n`, - ) - } - return - } - - // Per-trigger phrase bypass. The user types - // `Allow workflow-dispatch bypass: <workflow>` verbatim — one - // phrase authorizes exactly one dispatch of that workflow. A - // second dispatch of the same workflow needs a fresh phrase. - // - // Implementation: count the matching phrases the user has typed - // and subtract the number of prior dispatches against the same - // workflow already in the transcript. If anything's left, this - // dispatch consumes one slot and is allowed. - if (workflow) { - const acceptedPhrases = buildAcceptedPhrases(workflow) - const priorDispatches = countPriorDispatches( - input.transcript_path, - workflow, - ) - const remaining = bypassPhraseRemaining( - input.transcript_path, - acceptedPhrases, - priorDispatches, - BYPASS_LOOKBACK_USER_TURNS, - ) - if (remaining > 0) { - process.stderr.write( - // socket-hook: allow console - `[release-workflow-guard] ALLOWED: ${shape} on ${workflow} — bypass phrase consumed (${remaining - 1} remaining for this workflow)\n`, - ) - return - } - } - - const phraseExample = workflow - ? `${BYPASS_PHRASE_PREFIX} ${workflow.replace(/\.(?:yaml|yml)$/i, '')}` - : `${BYPASS_PHRASE_PREFIX} <workflow>` - const lines = [ - '[release-workflow-guard] BLOCKED: this command would dispatch a', - ` GitHub Actions workflow (${shape}, target: ${workflow ?? '<unknown>'}).`, - '', - ' Workflow dispatches often have irreversible prod side effects:', - ' - Publish workflows push npm versions (unpublishable after 24h).', - ' - Build/Release workflows create GitHub releases pinned by SHA.', - ' - Container workflows push immutable image tags.', - '', - ' Bypass options:', - ' (a) Verifiable dry-run:', - ' - Pass `-f dry-run=true` explicitly, AND', - ' - The workflow YAML must declare a `dry-run:` input under', - ' its workflow_dispatch.inputs block.', - ' - No force-prod overrides may be set', - ' (e.g. -f release=true / -f publish=true).', - ` (b) Per-trigger phrase bypass: the user types`, - ` \`${phraseExample}\``, - ' verbatim in a recent message. ONE phrase authorizes ONE', - ' dispatch of that exact workflow. A second dispatch (or a', - ' different workflow) needs its own phrase.', - '', - ' Without a bypass, the user runs workflow_dispatch jobs', - ' manually. Tell the user to run the command in their own', - ' terminal (or via the GitHub Actions UI), then resume.', - ] - process.stderr.write(lines.join('\n') + '\n') // socket-hook: allow console - process.exitCode = 2 -} - -main() diff --git a/.claude/hooks/release-workflow-guard/package.json b/.claude/hooks/release-workflow-guard/package.json deleted file mode 100644 index 19b0f2080..000000000 --- a/.claude/hooks/release-workflow-guard/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-release-workflow-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts b/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts deleted file mode 100644 index e9b25f62c..000000000 --- a/.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts +++ /dev/null @@ -1,1139 +0,0 @@ -/** - * @file Tests for the release-workflow-guard hook. Runs the hook as a - * subprocess (node --test), piping a tool-use payload on stdin and asserting - * on the exit code + stderr. Exit 2 means the hook refused the command; exit - * 0 means it passed it through. The dry-run bypass tests need a fixture - * workflow on disk because the hook verifies the named workflow declares a - * `dry-run:` input. Each test that exercises the bypass writes a tmpDir + - * workflow fixture and points the hook at it via CLAUDE_PROJECT_DIR. - */ - -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process, { execPath } from 'node:process' -import { afterEach, describe, it } from 'node:test' -import assert from 'node:assert/strict' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { isSpawnError } from '@socketsecurity/lib-stable/process/spawn/errors' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const hookScript = new URL('../index.mts', import.meta.url).pathname - -async function runHook( - command: string, - toolName = 'Bash', - env?: Record<string, string>, - cwd?: string, - transcriptPath?: string, -): Promise<{ code: number | null; stdout: string; stderr: string }> { - const payload = JSON.stringify({ - tool_name: toolName, - tool_input: { command }, - ...(transcriptPath ? { transcript_path: transcriptPath } : {}), - }) - return runChild(payload, env, cwd) -} - -/** - * Make a tmp transcript file containing one user-turn message with the given - * text. Used to exercise the phrase-bypass path. - */ -/** - * Build a synthetic transcript with a single user turn (text) and an optional - * assistant turn (tool-use blocks). The assistant turn is appended after the - * user turn so the per-trigger "prior-dispatch" counter sees historical Bash - * invocations. - */ -async function makeTranscript( - text: string, - assistantBlocks?: ReadonlyArray<{ - type: 'tool_use' - name: string - input: Record<string, unknown> - }>, -): Promise<{ - transcriptPath: string - cleanup: () => Promise<void> -}> { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-transcript-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const userTurn = JSON.stringify({ - type: 'user', - message: { role: 'user', content: text }, - }) - const lines = [userTurn] - if (assistantBlocks && assistantBlocks.length > 0) { - const assistantTurn = JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', content: assistantBlocks }, - }) - lines.push(assistantTurn) - } - await fs.writeFile(transcriptPath, lines.join('\n') + '\n', 'utf8') - return { - transcriptPath, - cleanup: async () => { - await safeDelete(dir, { force: true }) - }, - } -} - -/** - * Make a tmp project root with a `.github/workflows/<name>.yml` fixture - * containing the given workflow body. Returns the project dir + a cleanup - * function. Pass the project dir as CLAUDE_PROJECT_DIR to runHook so the - * dry-run verification reads the fixture. - */ -async function makeWorkflowFixture( - filename: string, - body: string, -): Promise<{ projectDir: string; cleanup: () => Promise<void> }> { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-fixture-')) - const wfDir = path.join(projectDir, '.github', 'workflows') - await fs.mkdir(wfDir, { recursive: true }) - await fs.writeFile(path.join(wfDir, filename), body, 'utf8') - return { - projectDir, - cleanup: async () => { - await safeDelete(projectDir, { force: true }) - }, - } -} - -// Async @socketsecurity/lib-stable/spawn — preferred over child_process -// spawnSync (see CLAUDE.md "Async spawn preferred"). Hooks are -// small, but async tests run in parallel under node --test, so -// even short subprocess waits compound when sync. spawn returns -// `{ stdin, stdout, stderr, process }` synchronously plus a thenable -// for the result; write the payload to stdin and await the result. -// On non-zero exit it throws a SpawnError — catch and lift fields -// back out so tests can assert on code (the hook's exit-2 path is -// the primary thing we test). -async function runChild( - payload: string, - env?: Record<string, string>, - cwd?: string, -): Promise<{ code: number | null; stdout: string; stderr: string }> { - const child = spawn(execPath, [hookScript], { - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - ...(env ? { env: { ...process.env, ...env } } : {}), - ...(cwd ? { cwd } : {}), - }) - child.stdin?.end(payload) - try { - const result = await child - return { - code: result.code, - stdout: (result.stdout || '').toString(), - stderr: (result.stderr || '').toString(), - } - } catch (e) { - if (isSpawnError(e)) { - return { - code: e.code, - stdout: (e.stdout || '').toString(), - stderr: (e.stderr || '').toString(), - } - } - throw e - } -} - -describe('release-workflow-guard hook', () => { - describe('blocks dispatching commands', () => { - it('gh workflow run', async () => { - const r = await runHook('gh workflow run release.yml') - assert.equal(r.code, 2) - assert.match(r.stderr, /BLOCKED/) - assert.match(r.stderr, /release\.yml/) - }) - - it('gh workflow dispatch', async () => { - const r = await runHook('gh workflow dispatch publish.yml') - assert.equal(r.code, 2) - assert.match(r.stderr, /publish\.yml/) - }) - - it('gh workflow run with -f flags', async () => { - const r = await runHook( - 'gh workflow run build.yml -f mode=prod -f arch=arm64', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /build\.yml/) - }) - - it('gh api .../dispatches', async () => { - const r = await runHook( - 'gh api repos/foo/bar/actions/workflows/42/dispatches -X POST', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /42/) - }) - - it('gh workflow run after a chained &&', async () => { - const r = await runHook('git fetch && gh workflow run release.yml') - assert.equal(r.code, 2) - }) - - it('gh workflow run with value flags BEFORE the target', async () => { - // Parser skips each value-taking flag + its value, so the target - // is found wherever it sits in the arg list. - const r = await runHook( - 'gh workflow run --ref main -f mode=prod release.yml', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /release\.yml/) - }) - - it('blocks an obfuscated `$(echo gh) workflow run` dispatch', async () => { - // shell-quote strands `workflow` as the binary when `gh` is - // produced by a substitution. The guard treats that shape as a - // dispatch too — a security guard must block-the-default on an - // obfuscated `gh`, not wave it through. - const r = await runHook('$(echo gh) workflow run release.yml') - assert.equal(r.code, 2) - assert.match(r.stderr, /release\.yml/) - }) - }) - - describe('allows benign commands', () => { - it('plain echo', async () => { - assert.equal((await runHook('echo hello')).code, 0) - }) - - it('git status', async () => { - assert.equal((await runHook('git status --short')).code, 0) - }) - - it('gh pr list (not a dispatch)', async () => { - assert.equal((await runHook('gh pr list --state open')).code, 0) - }) - - it('gh workflow list (read-only, no dispatch)', async () => { - assert.equal((await runHook('gh workflow list')).code, 0) - }) - - it('gh api repos/.../workflows (no /dispatches)', async () => { - assert.equal( - (await runHook('gh api repos/foo/bar/actions/workflows')).code, - 0, - ) - }) - }) - - describe('does not match inside quoted argument bodies', () => { - it('git commit -m with double-quoted body mentioning gh workflow run', async () => { - const r = await runHook( - 'git commit -m "chore: blocks dispatching gh workflow run jobs"', - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - }) - - it('git commit -m with heredoc body mentioning gh workflow run', async () => { - const r = await runHook( - `git commit -m "$(cat <<'EOF'\nchore: never gh workflow run anything\nEOF\n)"`, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - }) - - it('echo of a doc string mentioning gh api .../dispatches', async () => { - const r = await runHook( - 'echo "see also: gh api repos/x/y/actions/workflows/1/dispatches"', - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - }) - - it('single-quoted body protects against dispatch substring', async () => { - const r = await runHook( - "echo 'pretend command: gh workflow dispatch foo.yml'", - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - }) - }) - - describe('dry-run bypass', () => { - // Workflow body that declares a `dry-run:` input. The hook's - // verification looks for the line ` dry-run:` (any indent) under - // a `workflow_dispatch.inputs:` block — the body below is the - // minimal shape that matches. - const WF_WITH_DRY_RUN = [ - 'name: Build', - 'on:', - ' workflow_dispatch:', - ' inputs:', - ' dry-run:', - ' type: boolean', - ' default: true', - 'jobs:', - ' build:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - run: echo build', - ].join('\n') - - // Same workflow without the dry-run input — bypass shouldn't apply. - const WF_WITHOUT_DRY_RUN = [ - 'name: Publish', - 'on:', - ' workflow_dispatch: {}', - 'jobs:', - ' publish:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - run: echo publish', - ].join('\n') - - let projectDir: string - let cleanup: (() => Promise<void>) | undefined - - afterEach(async () => { - if (cleanup) { - await cleanup() - cleanup = undefined - } - }) - - it('allows -f dry-run=true on a workflow that declares the input', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook( - 'gh workflow run build.yml -f dry-run=true', - 'Bash', - { - CLAUDE_PROJECT_DIR: projectDir, - }, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - assert.match(r.stderr, /verifiable dry-run/) - }) - - it('blocks -f dry-run=true when workflow does NOT declare the input', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'publish.yml', - WF_WITHOUT_DRY_RUN, - )) - const r = await runHook( - 'gh workflow run publish.yml -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /BLOCKED/) - }) - - it('blocks -f dry-run=true when workflow file does not exist', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'real.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook( - 'gh workflow run does-not-exist.yml -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - }) - - it('blocks when -f dry-run=false overrides', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook( - 'gh workflow run build.yml -f dry-run=true -f dry-run=false', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - }) - - it('blocks when force-prod input is set alongside dry-run=true', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - for (const forceArg of [ - '-f release=true', - '-f publish=true', - '-f prod=true', - '-f production=true', - ]) { - // eslint-disable-next-line no-await-in-loop - const r = await runHook( - `gh workflow run build.yml -f dry-run=true ${forceArg}`, - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal( - r.code, - 2, - `expected blocked with ${forceArg} but got ${r.code}: ${r.stderr}`, - ) - } - }) - - it('blocks when -f dry-run is omitted (default-true is not enough)', async () => { - // The workflow defaults dry-run to true, but the hook requires - // explicit -f dry-run=true so a future default flip can't - // silently turn a benign-looking command into a prod dispatch. - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook('gh workflow run build.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 2) - }) - - it('snake_case dry_run input does NOT trigger the bypass', async () => { - // Fleet convention is kebab-case dry-run only. A workflow - // declaring snake_case must be normalized; the hook - // intentionally fails the verification rather than guessing. - const wf = WF_WITH_DRY_RUN.replace('dry-run:', 'dry_run:') - ;({ projectDir, cleanup } = await makeWorkflowFixture('build.yml', wf)) - const r = await runHook( - 'gh workflow run build.yml -f dry-run=true', - 'Bash', - { - CLAUDE_PROJECT_DIR: projectDir, - }, - ) - assert.equal(r.code, 2) - }) - - it('allows --repo when its basename matches the project dir', async () => { - // Make a fixture project whose dirname matches the --repo arg's - // basename. That's the "user runs the dispatch from inside the - // checkout" common case — the file is locally readable. - const targetProjectDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'rwg-fixture-target-'), - ) - const matchingName = path.basename(targetProjectDir) - const wfDir = path.join(targetProjectDir, '.github', 'workflows') - await fs.mkdir(wfDir, { recursive: true }) - await fs.writeFile(path.join(wfDir, 'build.yml'), WF_WITH_DRY_RUN, 'utf8') - try { - const r = await runHook( - `gh workflow run build.yml --repo SocketDev/${matchingName} -f dry-run=true`, - 'Bash', - { CLAUDE_PROJECT_DIR: targetProjectDir }, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - } finally { - await safeDelete(targetProjectDir, { force: true }) - } - }) - - it('allows --repo when the sibling clone has the workflow', async () => { - // Setup: parent dir contains two siblings — the current - // project (where the hook is "rooted") and a target repo with - // the workflow file. Cross-repo dispatch should resolve via - // the sibling-clone fallback. - const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-fleet-')) - const currentProject = path.join(parentDir, 'current') - const siblingProject = path.join(parentDir, 'sibling-target') - await fs.mkdir(currentProject, { recursive: true }) - await fs.mkdir(path.join(siblingProject, '.github', 'workflows'), { - recursive: true, - }) - await fs.writeFile( - path.join(siblingProject, '.github', 'workflows', 'build.yml'), - WF_WITH_DRY_RUN, - 'utf8', - ) - try { - const r = await runHook( - 'gh workflow run build.yml --repo SocketDev/sibling-target -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: currentProject }, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - } finally { - await safeDelete(parentDir, { force: true }) - } - }) - - it('allows when inline `cd <path> &&` prefix points at a sibling clone with the workflow', async () => { - // Setup: two siblings under a parent. CLAUDE_PROJECT_DIR points - // at A (no workflow). The command is `cd ../B && gh workflow - // run ...` — A's hook process never has cwd=B (the chained - // shell does, but the hook runs before that), so resolution - // must parse the inline cd to find B. - const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-cd-')) - const projectA = path.join(parentDir, 'project-a') - const projectB = path.join(parentDir, 'project-b') - await fs.mkdir(projectA, { recursive: true }) - await fs.mkdir(path.join(projectB, '.github', 'workflows'), { - recursive: true, - }) - await fs.writeFile( - path.join(projectB, '.github', 'workflows', 'build.yml'), - WF_WITH_DRY_RUN, - 'utf8', - ) - try { - // The cd path is relative to A (the projectDir resolver root). - const r = await runHook( - 'cd ../project-b && gh workflow run build.yml -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectA }, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - } finally { - await safeDelete(parentDir, { force: true }) - } - }) - - it('allows when cwd holds the workflow but CLAUDE_PROJECT_DIR points elsewhere', async () => { - // Setup: two sibling projects under a parent. CLAUDE_PROJECT_DIR - // is set to project A (no workflow), but the child is spawned - // with cwd=B (has workflow). No --repo flag in the command, so - // the hook should fall through to the cwd-derived root and find - // the YAML there. This matches the cross-session scenario where - // one Claude session has CLAUDE_PROJECT_DIR pinned but the user - // `cd`-ed into a sibling clone before dispatching. - const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rwg-cwd-')) - const projectA = path.join(parentDir, 'project-a') - const projectB = path.join(parentDir, 'project-b') - await fs.mkdir(projectA, { recursive: true }) - await fs.mkdir(path.join(projectB, '.github', 'workflows'), { - recursive: true, - }) - await fs.writeFile( - path.join(projectB, '.github', 'workflows', 'build.yml'), - WF_WITH_DRY_RUN, - 'utf8', - ) - try { - const r = await runHook( - 'gh workflow run build.yml -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectA }, - projectB, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - } finally { - await safeDelete(parentDir, { force: true }) - } - }) - - it('blocks --repo when no sibling clone exists', async () => { - // The current project has no sibling named after the --repo - // target — verification fails (workflow file not readable), - // bypass denied. - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook( - 'gh workflow run build.yml --repo SocketDev/no-such-sibling -f dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - }) - - it('bypass does not apply to gh api .../dispatches', async () => { - ;({ projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - WF_WITH_DRY_RUN, - )) - const r = await runHook( - 'gh api repos/x/y/actions/workflows/build.yml/dispatches -X POST -f inputs.dry-run=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - }) - }) - - describe('GH-release-only bypass', () => { - let cleanups: Array<() => Promise<void>> = [] - - afterEach(async () => { - for (let i = 0, { length } = cleanups; i < length; i += 1) { - const cleanup = cleanups[i]! - await cleanup() - } - cleanups = [] - }) - - it('allows live dispatch of a workflow that only creates GH releases', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'stubs.yml', - [ - 'name: stubs', - 'on:', - ' workflow_dispatch:', - 'jobs:', - ' release:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - run: gh release create stubs-20260506-abc1234 ./release/*.tar.gz', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook('gh workflow run stubs.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 0) - assert.match(r.stderr, /ALLOWED/) - assert.match(r.stderr, /GitHub-release-only/) - }) - - it('allows live dispatch when softprops/action-gh-release is used', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'curl.yml', - [ - 'name: curl', - 'on:', - ' workflow_dispatch:', - 'jobs:', - ' release:', - ' steps:', - ' - uses: softprops/action-gh-release@v2', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook('gh workflow run curl.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 0) - }) - - it('blocks workflows that npm publish even if they also gh-release', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'release-and-publish.yml', - [ - 'name: release-and-publish', - 'on:', - ' workflow_dispatch:', - 'jobs:', - ' publish:', - ' steps:', - ' - run: gh release create vX.Y.Z', - ' - run: npm publish', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook( - 'gh workflow run release-and-publish.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /BLOCKED/) - }) - - it('blocks pnpm publish workflows', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'publish.yml', - [ - 'name: publish', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' publish:', - ' steps:', - ' - run: pnpm publish --access public', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook('gh workflow run publish.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 2) - }) - - it('blocks JS-DevTools/npm-publish action workflows', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'auto-publish.yml', - [ - 'name: auto-publish', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' publish:', - ' steps:', - ' - uses: JS-DevTools/npm-publish@v3', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook('gh workflow run auto-publish.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 2) - }) - - it('blocks workflows with no detectable release shape', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'mystery.yml', - [ - 'name: mystery', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' do:', - ' steps:', - ' - run: ./run-the-thing.sh', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook('gh workflow run mystery.yml', 'Bash', { - CLAUDE_PROJECT_DIR: projectDir, - }) - assert.equal(r.code, 2) - }) - - it('blocks GH-release-only workflow when force-prod input is set', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'stubs.yml', - [ - 'name: stubs', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' release:', - ' steps:', - ' - run: gh release create x', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const r = await runHook( - 'gh workflow run stubs.yml -f publish=true', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - ) - assert.equal(r.code, 2) - }) - - it('allows --repo when sibling clone has GH-release-only workflow', async () => { - // Create a sibling project named "socket-other" alongside the - // primary fixture; place a stubs.yml in the sibling. The hook - // must read the sibling, not the primary. - const projectsRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'rwg-roots-'), - ) - const primaryDir = path.join(projectsRoot, 'socket-btm') - const siblingDir = path.join(projectsRoot, 'socket-other') - await fs.mkdir(path.join(primaryDir, '.github', 'workflows'), { - recursive: true, - }) - await fs.mkdir(path.join(siblingDir, '.github', 'workflows'), { - recursive: true, - }) - await fs.writeFile( - path.join(siblingDir, '.github', 'workflows', 'stubs.yml'), - 'jobs:\n r:\n steps:\n - run: gh release create x\n', - 'utf8', - ) - cleanups.push(async () => { - await safeDelete(projectsRoot, { force: true }) - }) - const r = await runHook( - 'gh workflow run stubs.yml --repo SocketDev/socket-other', - 'Bash', - { CLAUDE_PROJECT_DIR: primaryDir }, - ) - assert.equal(r.code, 0) - assert.match(r.stderr, /GitHub-release-only/) - }) - }) - - describe('workflow-dispatch phrase bypass', () => { - let cleanups: Array<() => Promise<void>> = [] - - afterEach(async () => { - for (let i = 0, { length } = cleanups; i < length; i += 1) { - const cleanup = cleanups[i]! - await cleanup() - } - cleanups = [] - }) - - it('blocks dispatch when transcript lacks the bypass phrase', async () => { - // Sanity check: without a transcript, the canonical block path - // still fires for a workflow that has neither dry-run nor a - // GH-release-only shape. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'publish.yml', - [ - 'name: publish', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' publish:', - ' steps:', - ' - run: npm publish', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'just a regular message with no bypass phrase here', - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run publish.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /BLOCKED/) - }) - - it('allows dispatch when transcript contains the per-workflow bypass phrase (filename form)', async () => { - // The classic node-smol case: workflow has no dry-run input, - // isn't a pure GH-release shape, but the user has typed the - // canonical per-workflow phrase in a recent turn — bypass - // authorizes ONE dispatch of THIS exact workflow. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./scripts/build.sh', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'Allow workflow-dispatch bypass: build.yml — kicking off the smol build', - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - assert.match(r.stderr, /bypass phrase consumed/) - }) - - it('basename form (no .yml suffix) also matches', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript('Allow workflow-dispatch bypass: build') - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - }) - - it('blocks when the phrase names a DIFFERENT workflow', async () => { - // User authorized `publish.yml` but is running `build.yml` — - // the phrase is workflow-scoped, so the wrong target rejects. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript('Allow workflow-dispatch bypass: publish.yml') - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /BLOCKED/) - }) - - it('legacy bare phrase (no colon-suffix) does NOT bypass', async () => { - // Older sessions might still type `Allow workflow-dispatch - // bypass` without naming a workflow. That used to authorize - // anything for the next 8 turns; the per-trigger shape no - // longer accepts the bare form. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript('Allow workflow-dispatch bypass') - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - }) - - it('phrase match is case-sensitive (lowercased phrase does NOT bypass)', async () => { - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./scripts/build.sh', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'allow workflow-dispatch bypass: build.yml — wrong case', - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - }) - - it('paraphrased intent does NOT bypass', async () => { - // Per fleet rule: only the exact phrase counts; "go ahead" or - // "ship it" inferring intent must not unlock the dispatch. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./scripts/build.sh', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'go ahead and dispatch the workflow, skip the guard', - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - }) - - it('phrase also bypasses `gh api .../dispatches` shape (id form)', async () => { - // The dry-run bypass intentionally doesn't apply to gh-api, but - // the explicit per-workflow phrase bypass does. The workflow - // is identified by the path-component id (`42` here), so the - // phrase names the id, not a filename. - const { transcriptPath, cleanup } = await makeTranscript( - 'Allow workflow-dispatch bypass: 42', - ) - cleanups.push(cleanup) - const r = await runHook( - 'gh api repos/foo/bar/actions/workflows/42/dispatches -X POST', - 'Bash', - undefined, - undefined, - transcriptPath, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - }) - - it('phrase on its own line in a multi-line user message bypasses', async () => { - // The fleet rule explicitly allows the phrase to appear on its - // own line in a multi-line message — the transcript helper - // matches by substring on the concatenated user turns. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'here is some preamble\nAllow workflow-dispatch bypass: build.yml\nand some trailing text', - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - }) - - it('one phrase = one dispatch (a re-dispatch of the same workflow blocks)', async () => { - // Per-trigger semantics: the phrase budget for `build.yml` is - // 1 because the user typed the phrase once. The transcript - // also contains a prior assistant tool-use dispatching the - // same workflow, which consumes the slot. The current - // dispatch (the second) finds remaining=0 and blocks. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - // Build a transcript with: user phrase, then assistant Bash - // tool-use dispatching the workflow. - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript('Allow workflow-dispatch bypass: build.yml', [ - { - type: 'tool_use', - name: 'Bash', - input: { command: 'gh workflow run build.yml' }, - }, - ]) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 2, `Expected 2 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /BLOCKED/) - }) - - it('two phrases = two dispatches allowed', async () => { - // The user typed the phrase twice in the transcript, the - // assistant already dispatched once, so 2 - 1 = 1 remaining - // — this dispatch consumes the last slot. - const { projectDir, cleanup } = await makeWorkflowFixture( - 'build.yml', - [ - 'name: build', - 'on: { workflow_dispatch: {} }', - 'jobs:', - ' build:', - ' steps:', - ' - run: ./build', - '', - ].join('\n'), - ) - cleanups.push(cleanup) - const { transcriptPath, cleanup: cleanupTranscript } = - await makeTranscript( - 'Allow workflow-dispatch bypass: build.yml\nAllow workflow-dispatch bypass: build.yml', - [ - { - type: 'tool_use', - name: 'Bash', - input: { command: 'gh workflow run build.yml' }, - }, - ], - ) - cleanups.push(cleanupTranscript) - const r = await runHook( - 'gh workflow run build.yml', - 'Bash', - { CLAUDE_PROJECT_DIR: projectDir }, - undefined, - transcriptPath, - ) - assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`) - assert.match(r.stderr, /ALLOWED/) - }) - }) - - describe('payload edge cases', () => { - it('non-Bash tool is ignored', async () => { - assert.equal( - (await runHook('gh workflow run release.yml', 'Read')).code, - 0, - ) - }) - - it('empty command is ignored', async () => { - assert.equal((await runHook('')).code, 0) - }) - - it('invalid JSON on stdin returns 0 (silent)', async () => { - // Hook intentionally returns 0 on bad JSON (don't punish the - // model for unparseable payloads — pass them through). - const r = await runChild('not json') - assert.equal(r.code, 0) - }) - }) -}) diff --git a/.claude/hooks/release-workflow-guard/tsconfig.json b/.claude/hooks/release-workflow-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/release-workflow-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/scan-label-in-commit-guard/README.md b/.claude/hooks/scan-label-in-commit-guard/README.md deleted file mode 100644 index 0af75b65b..000000000 --- a/.claude/hooks/scan-label-in-commit-guard/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# scan-label-in-commit-guard - -`PreToolUse(Bash)` blocker that refuses `git commit` invocations -whose message body contains scan-report-internal labels (`B1`, `M9`, -`H3`, `L4`). - -## Why - -`/scanning-quality` and `/scanning-security` assign scratch-pad IDs -like `B5` ("Blocker #5") or `M9` ("Medium #9") to findings inside a -review session. The label has meaning **only within the report** — -a future reader of `git log` doesn't have the report and cannot -decode "fix B5" or "addresses M9". - -The right shape inlines the actual finding text: - -``` -✗ fix(http-request): B5 download truncation race -✓ fix(http-request/download): settle on fileStream finish, not res end -``` - -## Detection - -Case-sensitive `\b[BMHL]\d+\b` as a standalone word. The hook -extracts the message body from: - -- `git commit -m "<msg>"` (single or repeated `-m`) -- `git commit --message=<msg>` / `--message <msg>` -- `git commit -F <file>` / `--file=<file>` / `--file <file>` - -`git commit` without `-m`/`-F` opens the editor — those messages are -reviewed by the operator, so the hook doesn't fire. - -Fenced code blocks (` ``` `) are stripped before scanning so -labels inside log output / quoted fixtures don't trigger the rule. - -## What's not flagged - -- Lowercase: `b1`, `m9` are not report labels -- 5+ digit IDs: `B12345` is too long to be a report label -- `GHSA-B1-xyz`-style identifiers (label is part of a larger token) -- Anything inside ` ``` ` fences - -## Bypass - -Type the canonical phrase verbatim in your next user turn: - -``` -Allow scan-label-in-commit bypass -``` - -Use when the label is genuinely meaningful in the message (e.g. citing -a real internal advisory ID that happens to match the shape). diff --git a/.claude/hooks/scan-label-in-commit-guard/index.mts b/.claude/hooks/scan-label-in-commit-guard/index.mts deleted file mode 100644 index 04e077f22..000000000 --- a/.claude/hooks/scan-label-in-commit-guard/index.mts +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — scan-label-in-commit-guard. -// -// Blocks `git commit` invocations whose message body contains -// scan-report-internal labels (B1, B2, …, M3, H5, L7). These are -// the scratch-pad IDs the `/scanning-quality` and `/scanning-security` -// skills assign to findings inside a single review session. They have -// no meaning outside that session — a future reader of `git log` who -// doesn't have the original report can't decode "fix B5" or -// "addresses M9". -// -// The right shape is to inline the actual finding text: -// -// ✗ fix(http-request): B5 download truncation race -// ✓ fix(http-request/download): settle on fileStream finish, not res end -// -// Detection — the message is sourced from one of: -// - `git commit -m "<msg>"` (single -m or repeated) -// - `git commit --message=<msg>` -// - `git commit -F <file>` / `git commit --file=<file>` — read file -// -// Pattern: case-sensitive `\b[BMHL]\d+\b` as a standalone word. -// - B1, M9, H3, L4 → flag -// - 'B' alone, 'B12345' (5+ digits = likely a real ID), 'GHSA-…' → don't flag -// - Inside fenced code blocks (``` … ```) → don't flag (the operator -// is quoting test output / SQL / etc.) -// -// Bypass: type "Allow scan-label-in-commit bypass" in a recent user -// message. Use when the label is genuinely meaningful (e.g. citing a -// specific advisory ID that happens to match the shape). -// -// Exit codes: -// 0 — pass. -// 2 — block. -// -// Fails open on malformed payloads (exit 0 + stderr log). - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_input?: - | { - readonly command?: string | undefined - } - | undefined - readonly tool_name?: string | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -interface Hit { - readonly label: string - readonly line: number - readonly snippet: string -} - -const BYPASS_PHRASE = 'Allow scan-label-in-commit bypass' - -// Match standalone scan-report-internal IDs: B/M/H/L (Blocker / -// Medium / High / Low) followed by 1–4 digits. The lookbehind / -// lookahead pair excludes `B12345` (5+ digits) and `GHSA-B1-…` / -// `branch-B12` shapes where a hyphen sits next to the label. -// Case-sensitive — lowercase `b1` is not a report label. -const LABEL_RE = /(?<![A-Za-z0-9_-])[BMHL][0-9]{1,4}(?![A-Za-z0-9_-])/g - -/** - * Strip fenced code blocks from a multi-line message body so we don't flag - * labels that appear inside quoted log output. Triple-backtick fences only - * (`````); we don't try to handle indented code blocks. - */ -export function stripFencedCode(body: string): string { - return body.replace(/```[\s\S]*?```/g, '') -} - -/** - * Find scan-label matches in a commit message body. Returns one hit per unique - * (line, label) pair so the error message can name them all. - */ -export function findScanLabels(body: string): Hit[] { - const stripped = stripFencedCode(body) - const hits: Hit[] = [] - const lines = stripped.split('\n') - const seen = new Set<string>() - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]! - let m: RegExpExecArray | null - LABEL_RE.lastIndex = 0 - while ((m = LABEL_RE.exec(line)) !== null) { - const label = m[0] - const key = `${i}:${label}` - if (seen.has(key)) { - continue - } - seen.add(key) - hits.push({ - label, - line: i + 1, - snippet: line.length > 80 ? line.slice(0, 77) + '…' : line, - }) - } - } - return hits -} - -/** - * Pull the commit message from a `git commit …` command line. Returns the - * message text or `undefined` if the command doesn't carry an inline message - * (e.g. uses `-e` to open the editor — those messages are reviewed by the - * operator, no need to flag). - * - * Handles `-m "msg"`, `-m msg`, `--message=msg`, `--message msg`, `-F file`, - * `--file=file`. For file-form invocations, reads the file relative to `cwd`. - */ -export function extractCommitMessage( - command: string, - cwd: string, -): string | undefined { - // Inspect each real `git commit` invocation. The parser strips quotes - // and scopes args to the command that owns them, so a `-m` inside a - // sibling command or a quoted body can't leak in. - for (const c of commandsFor(command, 'git')) { - if (!c.args.includes('commit')) { - continue - } - const { args } = c - // Collect every inline message: `-m <msg>`, `--message <msg>`, - // `--message=<msg>` (repeated -m forms join with a blank line, the - // same way git concatenates multiple -m paragraphs). - const messages: string[] = [] - let fileArg: string | undefined - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]! - if (arg === '--message' || arg === '-m') { - const next = args[i + 1] - if (next !== undefined) { - messages.push(next) - i += 1 - } - continue - } - if (arg.startsWith('--message=')) { - messages.push(arg.slice('--message='.length)) - continue - } - if (arg === '--file' || arg === '-F') { - const next = args[i + 1] - if (next !== undefined) { - fileArg = next - i += 1 - } - continue - } - if (arg.startsWith('--file=')) { - fileArg = arg.slice('--file='.length) - continue - } - } - if (messages.length > 0) { - return messages.join('\n\n') - } - if (fileArg !== undefined) { - const filePath = path.isAbsolute(fileArg) - ? fileArg - : path.join(cwd, fileArg) - if (existsSync(filePath)) { - try { - return readFileSync(filePath, 'utf8') - } catch { - return undefined - } - } - } - } - return undefined -} - -function handlePayload(payloadRaw: string): number { - let payload: ToolInput - try { - payload = JSON.parse(payloadRaw) as ToolInput - } catch { - return 0 - } - if (payload.tool_name !== 'Bash') { - return 0 - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return 0 - } - const cwd = payload.cwd ?? process.cwd() - const body = extractCommitMessage(command, cwd) - if (!body) { - return 0 - } - const hits = findScanLabels(body) - if (hits.length === 0) { - return 0 - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - return 0 - } - const lines: string[] = [] - lines.push( - '[scan-label-in-commit-guard] Blocked: scan-report-internal label in commit message.', - ) - lines.push('') - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - lines.push(` Line ${h.line}: ${h.label} — "${h.snippet}"`) - } - lines.push('') - lines.push(' Labels like B1 / M9 / H3 / L4 come from /scanning-quality and') - lines.push(' /scanning-security reports. They are scratch-pad IDs that mean') - lines.push(' nothing outside the original session — a future reader of') - lines.push(' `git log` who does not have the report cannot decode them.') - lines.push('') - lines.push(' Rewrite the message to inline the actual finding text:') - lines.push(' ✗ fix(http-request): B5 download truncation race') - lines.push( - ' ✓ fix(http-request/download): settle on fileStream finish, not res end', - ) - lines.push('') - lines.push(' Bypass (e.g. citing a real advisory ID that happens to match):') - lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) - process.stderr.write(lines.join('\n') + '\n') - return 2 -} - -export { handlePayload } - -// CLI entrypoint — only fires when this file is the main module. Tests -// import `findScanLabels` / `extractCommitMessage` directly without -// triggering the stdin reader (which would never see an `end` event -// in test env and hang the process). -if (process.argv[1] && process.argv[1].endsWith('index.mts')) { - let payloadRaw = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - payloadRaw += chunk - }) - process.stdin.on('end', () => { - try { - process.exit(handlePayload(payloadRaw)) - } catch (e) { - process.stderr.write( - `[scan-label-in-commit-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) -} diff --git a/.claude/hooks/scan-label-in-commit-guard/package.json b/.claude/hooks/scan-label-in-commit-guard/package.json deleted file mode 100644 index bdf2b3382..000000000 --- a/.claude/hooks/scan-label-in-commit-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-scan-label-in-commit-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts b/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts deleted file mode 100644 index ffe1010c5..000000000 --- a/.claude/hooks/scan-label-in-commit-guard/test/index.test.mts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @file Unit tests for findScanLabels + extractCommitMessage. - */ - -import test from 'node:test' -import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { extractCommitMessage, findScanLabels } from '../index.mts' - -// ── findScanLabels ── - -test('flags single B-label in prose', () => { - const hits = findScanLabels('fix(http): B5 download truncation race') - assert.equal(hits.length, 1) - assert.equal(hits[0]!.label, 'B5') -}) - -test('flags multiple labels across lines', () => { - const body = `fix(security): land B1 + M9 fixes - -Also addresses H3 (rc file mode).` - const hits = findScanLabels(body) - assert.equal(hits.length, 3) - const labels = hits.map(h => h.label).toSorted() - assert.deepEqual(labels, ['B1', 'H3', 'M9']) -}) - -test('does not flag lowercase', () => { - const hits = findScanLabels('fix b1 bug') - assert.equal(hits.length, 0) -}) - -test('does not flag 5+ digit IDs', () => { - const hits = findScanLabels('Refs B12345 (a real internal ID)') - assert.equal(hits.length, 0) -}) - -test('does not flag GHSA-style identifiers', () => { - const hits = findScanLabels('Bump for GHSA-B1-xyz advisory') - assert.equal(hits.length, 0) -}) - -test('does not flag inside fenced code block', () => { - const body = `chore: pin pnpm - -Output for reference: -\`\`\` -B1 = expected -M9 = expected -\`\`\` - -No real labels here.` - const hits = findScanLabels(body) - assert.equal(hits.length, 0) -}) - -test('flags label before fenced block', () => { - const body = `fix B5 issue - -\`\`\` -log content -\`\`\`` - const hits = findScanLabels(body) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.label, 'B5') -}) - -test('flags label after fenced block', () => { - const body = `\`\`\` -output -\`\`\` - -Closes M3.` - const hits = findScanLabels(body) - assert.equal(hits.length, 1) - assert.equal(hits[0]!.label, 'M3') -}) - -test('deduplicates same label same line', () => { - // Same label twice on one line dedups to a single hit (the dedup key - // is `${line}:${label}` so the operator gets one entry per offending - // line, not one per character offset). - const hits = findScanLabels('fix B1 and B1 again') - assert.equal(hits.length, 1) -}) - -// ── extractCommitMessage ── - -test('extracts -m "msg"', () => { - const msg = extractCommitMessage('git commit -m "fix B5 issue"', '/tmp') - assert.equal(msg, 'fix B5 issue') -}) - -test("extracts -m 'msg' (single quotes)", () => { - const msg = extractCommitMessage("git commit -m 'fix M9 issue'", '/tmp') - assert.equal(msg, 'fix M9 issue') -}) - -test('extracts --message=msg', () => { - const msg = extractCommitMessage( - 'git commit --message="addresses H3"', - '/tmp', - ) - assert.equal(msg, 'addresses H3') -}) - -test('returns undefined for non-commit command', () => { - assert.equal(extractCommitMessage('git push origin main', '/tmp'), undefined) - assert.equal(extractCommitMessage('ls -la', '/tmp'), undefined) -}) - -test('returns undefined for `git commit` with no -m/-F (editor mode)', () => { - assert.equal(extractCommitMessage('git commit', '/tmp'), undefined) - assert.equal(extractCommitMessage('git commit --amend', '/tmp'), undefined) -}) - -test('extracts -F file content', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-msg-test-')) - try { - const file = path.join(dir, 'msg.txt') - writeFileSync(file, 'fix(http): B5 + M9 issues') - const msg = extractCommitMessage(`git commit -F ${file}`, dir) - assert.equal(msg, 'fix(http): B5 + M9 issues') - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('extracts --file= file content', () => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-msg-test-')) - try { - const file = path.join(dir, 'msg.txt') - writeFileSync(file, 'fix L7') - const msg = extractCommitMessage(`git commit --file=${file}`, dir) - assert.equal(msg, 'fix L7') - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('returns undefined if -F file does not exist', () => { - const msg = extractCommitMessage( - 'git commit -F /nonexistent-path-for-test', - '/tmp', - ) - assert.equal(msg, undefined) -}) - -test('multiple -m flags concatenate', () => { - const msg = extractCommitMessage( - 'git commit -m "title B1" -m "body M9"', - '/tmp', - ) - assert.match(msg!, /B1/) - assert.match(msg!, /M9/) -}) - -test('extracts commit message from a chained command', () => { - // Parser sees through `cd … &&` — the commit message is read from the - // git invocation, not the chain prefix. - const msg = extractCommitMessage('cd /repo && git commit -m "fix B5"', '/tmp') - assert.equal(msg, 'fix B5') -}) - -test('does not read a -m from a SEPARATE sibling command', () => { - // The `-m` belongs to the preceding `mail` command, not `git commit`. - // The parser scopes args per-invocation, so the commit message is - // empty (editor mode) and nothing leaks across the chain. - const msg = extractCommitMessage( - 'mail -m "B5 in subject" && git commit', - '/tmp', - ) - assert.equal(msg, undefined) -}) diff --git a/.claude/hooks/scan-label-in-commit-guard/tsconfig.json b/.claude/hooks/scan-label-in-commit-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/scan-label-in-commit-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-basics-tools/README.md b/.claude/hooks/setup-basics-tools/README.md deleted file mode 100644 index 99cabef19..000000000 --- a/.claude/hooks/setup-basics-tools/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# setup-basics-tools - -Operator-invoked installer for the **socket-basics workflow stack**: -TruffleHog, Trivy, OpenGrep, and uv. Slim leaf of the -`setup-security-tools` umbrella. - -## When to use - -```sh -node .claude/hooks/setup-basics-tools/install.mts -``` - -For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. - -## What gets installed - -| Tool | Source | Purpose | -| ---------- | ----------------------------------- | ------------------------------------------------------------------- | -| TruffleHog | `github:trufflesecurity/trufflehog` | Secrets scanner | -| Trivy | `github:aquasecurity/trivy` | Container / IaC / SBOM vuln scanner | -| OpenGrep | `github:opengrep/opengrep` | SAST (semgrep fork) | -| uv | `github:astral-sh/uv` | Python package manager (used by socket-basics for Python bootstrap) | diff --git a/.claude/hooks/setup-basics-tools/install.mts b/.claude/hooks/setup-basics-tools/install.mts deleted file mode 100644 index 8aa57da51..000000000 --- a/.claude/hooks/setup-basics-tools/install.mts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for the socket-basics workflow stack: - * TruffleHog (secrets scanner), Trivy (vuln/SBOM scanner), OpenGrep (SAST), - * and uv (Python package manager bootstrap). Slim leaf of the - * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-basics-tools/install.mts For the full setup (firewall + - * scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -async function main(): Promise<void> { - logger.log('socket-basics tools — install / verify') - logger.log('') - - const { setupTrufflehog, setupTrivy, setupOpengrep, setupUv } = - (await import('../setup-security-tools/lib/installers.mts')) as { - setupTrufflehog: () => Promise<boolean> - setupTrivy: () => Promise<boolean> - setupOpengrep: () => Promise<boolean> - setupUv: () => Promise<boolean> - } - - const [trufflehogOk, trivyOk, opengrepOk, uvOk] = await Promise.all([ - setupTrufflehog(), - setupTrivy(), - setupOpengrep(), - setupUv(), - ]) - logger.log('') - - logger.log('=== Summary ===') - logger.log(`OpenGrep: ${opengrepOk ? 'ready' : 'FAILED'}`) - logger.log(`Trivy: ${trivyOk ? 'ready' : 'FAILED'}`) - logger.log(`TruffleHog: ${trufflehogOk ? 'ready' : 'FAILED'}`) - logger.log(`uv: ${uvOk ? 'ready' : 'FAILED'}`) - - if (!(opengrepOk && trivyOk && trufflehogOk && uvOk)) { - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-basics-tools install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-basics-tools/package.json b/.claude/hooks/setup-basics-tools/package.json deleted file mode 100644 index 139639b77..000000000 --- a/.claude/hooks/setup-basics-tools/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-setup-basics-tools", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/setup-basics-tools/tsconfig.json b/.claude/hooks/setup-basics-tools/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-basics-tools/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-claude-scanners/README.md b/.claude/hooks/setup-claude-scanners/README.md deleted file mode 100644 index 4fe2f4a72..000000000 --- a/.claude/hooks/setup-claude-scanners/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# setup-claude-scanners - -Operator-invoked installer for **AgentShield** + **zizmor** — the two -claude-config / GitHub-Actions scanners. Slim leaf of the -`setup-security-tools` umbrella. - -## When to use - -- You want to install or refresh ONLY the scanner surface - (AgentShield + zizmor) without re-running the firewall / - socket-basics / misc installers. -- You're onboarding a fresh worktree where the only thing you need - scanning right now is claude-config + workflow YAML. - -```sh -node .claude/hooks/setup-claude-scanners/install.mts -``` - -For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. - -## Relationship to setup-security-tools - -The umbrella `setup-security-tools/install.mts` does everything this -leaf does PLUS sfw (firewall) + socket-basics tools (TruffleHog, -Trivy, OpenGrep, uv) + misc tools (cdxgen, synp, janus). - -This leaf is a thin re-entry point that imports `setupAgentShield` - -- `setupZizmor` from the umbrella's `lib/installers.mts` and runs - ONLY those. No token resolution / keychain / shell-rc plumbing is - involved — the two scanners are auth-free. - -## What gets installed - -| Tool | Source | Purpose | -| ----------- | --------------------------------------- | ------------------------------------------------------------- | -| AgentShield | `pkg:npm/ecc-agentshield@1.4.0` via dlx | Claude AI config security scanner (prompt injection, secrets) | -| zizmor | `github:zizmorcore/zizmor` GH-release | GitHub Actions security scanner | diff --git a/.claude/hooks/setup-claude-scanners/install.mts b/.claude/hooks/setup-claude-scanners/install.mts deleted file mode 100644 index 02081e989..000000000 --- a/.claude/hooks/setup-claude-scanners/install.mts +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for AgentShield + zizmor — the two - * claude-config / GitHub-Actions scanners. Slim leaf of the - * `setup-security-tools` umbrella. Run via: node - * .claude/hooks/setup-claude-scanners/install.mts For the full setup - * (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -async function main(): Promise<void> { - logger.log('Claude scanners — install / verify') - logger.log('') - - const { setupAgentShield, setupZizmor } = - (await import('../setup-security-tools/lib/installers.mts')) as { - setupAgentShield: () => Promise<boolean> - setupZizmor: () => Promise<boolean> - } - - const agentshieldOk = await setupAgentShield() - logger.log('') - const zizmorOk = await setupZizmor() - logger.log('') - - logger.log('=== Summary ===') - logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) - logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) - - if (!(agentshieldOk && zizmorOk)) { - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-claude-scanners install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-claude-scanners/package.json b/.claude/hooks/setup-claude-scanners/package.json deleted file mode 100644 index c8e535991..000000000 --- a/.claude/hooks/setup-claude-scanners/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-setup-claude-scanners", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/setup-claude-scanners/tsconfig.json b/.claude/hooks/setup-claude-scanners/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-claude-scanners/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-firewall/README.md b/.claude/hooks/setup-firewall/README.md deleted file mode 100644 index 2e09edcae..000000000 --- a/.claude/hooks/setup-firewall/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# setup-firewall - -Operator-invoked installer for **Socket Firewall** (sfw enterprise + -free). Slim leaf of the `setup-security-tools` umbrella. - -## When to use - -- You want to install or refresh ONLY the firewall surface without - re-running the AgentShield / zizmor / socket-basics tool - installers. -- You're rotating `SOCKET_API_KEY` and want sfw to re-resolve - enterprise vs free without touching everything else. - -```sh -# Install / verify -node .claude/hooks/setup-firewall/install.mts - -# Rotate the API token (re-prompts; overwrites keychain) -node .claude/hooks/setup-firewall/install.mts --rotate -``` - -## Relationship to setup-security-tools - -The umbrella `setup-security-tools/install.mts` does everything this -leaf does PLUS AgentShield + zizmor + socket-basics tools (TruffleHog, -Trivy, OpenGrep, uv) + a few misc tools (cdxgen, synp, janus). - -This leaf is a thin re-entry point that imports from the umbrella's -`lib/installers.mts` and runs ONLY the firewall installer. The token -resolution / keychain / shell-rc bridge / --rotate prompt all use the -umbrella's exported helpers — single source of truth. - -## What gets installed - -| Surface | Source | -| ------------------------------------------------------------------ | ------------------------------------------------------------------- | -| sfw binary (enterprise or free, depending on token) | github:SocketDev/firewall-release (enterprise) / SocketDev/sfw-free | -| PATH shims for npm / pnpm / yarn / pip / uv / cargo / etc. | `~/.socket/sfw/shims/` | -| Shell-rc env block (`~/.zshenv` on macOS) | `setup-security-tools/lib/shell-rc-bridge.mts` | -| OS keychain entry (macOS Keychain / libsecret / CredentialManager) | `setup-security-tools/lib/token-storage.mts` | diff --git a/.claude/hooks/setup-firewall/install.mts b/.claude/hooks/setup-firewall/install.mts deleted file mode 100644 index 23691813f..000000000 --- a/.claude/hooks/setup-firewall/install.mts +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for Socket Firewall (sfw enterprise + free). - * Slim leaf of the setup-security-tools umbrella — for operators who want to - * install / refresh ONLY the firewall surface without re-running the - * AgentShield / zizmor / socket-basics tool installers. The actual installer - * code lives in `../setup-security-tools/lib/installers.mts`. This entry - * point exists so operators can scope their setup precisely: node - * .claude/hooks/setup-firewall/install.mts For the full setup, use `node - * .claude/hooks/setup-security-tools/install.mts` which sequences this leaf - * alongside the others. --rotate is honored here too — re-prompts for - * SOCKET_API_KEY and overwrites the OS keychain entry, just like the - * umbrella's --rotate path. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { findApiToken } from '../setup-security-tools/lib/api-token.mts' -import { - offerTokenPrompt, - parseArgs, - promptAndPersist, - wireBridgeIntoShellRc, -} from '../setup-security-tools/lib/operator-prompts.mts' - -const logger = getDefaultLogger() - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - logger.log('Socket Firewall — install / verify') - logger.log('') - - let apiToken: string | undefined - if (args.rotate) { - const fresh = await promptAndPersist(logger, 'rotate') - if (fresh) { - apiToken = fresh - } else { - const lookup = findApiToken() - apiToken = lookup.token - if (apiToken && lookup.source) { - logger.log(`Keeping existing SOCKET_API_KEY (via ${lookup.source}).`) - } - } - } else { - const lookup = findApiToken() - apiToken = lookup.token - if (apiToken && lookup.source) { - logger.log(`SOCKET_API_KEY: found via ${lookup.source}.`) - } else { - apiToken = await offerTokenPrompt(logger) - } - } - - if (apiToken) { - wireBridgeIntoShellRc(logger, apiToken) - } - - const { setupSfw } = - (await import('../setup-security-tools/lib/installers.mts')) as { - setupSfw: (apiToken: string | undefined) => Promise<boolean> - } - - const sfwOk = await setupSfw(apiToken) - logger.log('') - logger.log('=== Summary ===') - logger.log(`SFW: ${sfwOk ? 'ready' : 'FAILED'}`) - if (!sfwOk) { - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-firewall install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-firewall/package.json b/.claude/hooks/setup-firewall/package.json deleted file mode 100644 index cdfc9e359..000000000 --- a/.claude/hooks/setup-firewall/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-setup-firewall", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/setup-firewall/tsconfig.json b/.claude/hooks/setup-firewall/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-firewall/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-misc-tools/README.md b/.claude/hooks/setup-misc-tools/README.md deleted file mode 100644 index 287134b19..000000000 --- a/.claude/hooks/setup-misc-tools/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# setup-misc-tools - -Operator-invoked installer for one-off tools: **cdxgen**, **synp**, -and **janus**. Slim leaf of the `setup-security-tools` umbrella. - -## When to use - -```sh -node .claude/hooks/setup-misc-tools/install.mts -``` - -For the full setup (firewall + scanners + socket-basics + misc), use -`node .claude/hooks/setup-security-tools/install.mts`. - -## What gets installed - -| Tool | Source | Purpose | -| ------ | ------------------------------------------ | ---------------------------------------------------------- | -| cdxgen | `github:CycloneDX/cdxgen` (slim SEA) | CycloneDX SBOM generator (used by `socket scan sbom`) | -| synp | `pkg:npm/synp@1.9.14` via dlx | yarn.lock ↔ package-lock.json converter (cross-PM interop) | -| janus | `github:divmain/janus` (darwin-arm64 only) | Tool that some Socket workflows opt into | diff --git a/.claude/hooks/setup-misc-tools/install.mts b/.claude/hooks/setup-misc-tools/install.mts deleted file mode 100644 index 3a4f524f8..000000000 --- a/.claude/hooks/setup-misc-tools/install.mts +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for one-off tools: cdxgen (SBOM), synp - * (lockfile interop), and janus. Slim leaf of the `setup-security-tools` - * umbrella. Run via: node .claude/hooks/setup-misc-tools/install.mts For the - * full setup (firewall + scanners + socket-basics + misc), use `node - * .claude/hooks/setup-security-tools/install.mts`. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -async function main(): Promise<void> { - logger.log('misc tools — install / verify') - logger.log('') - - const { setupCdxgen, setupSynp, setupJanus } = - (await import('../setup-security-tools/lib/installers.mts')) as { - setupCdxgen: () => Promise<boolean> - setupSynp: () => Promise<boolean> - setupJanus: () => Promise<boolean> - } - - const [cdxgenOk, synpOk, janusOk] = await Promise.all([ - setupCdxgen(), - setupSynp(), - setupJanus(), - ]) - logger.log('') - - logger.log('=== Summary ===') - logger.log(`cdxgen: ${cdxgenOk ? 'ready' : 'FAILED'}`) - logger.log(`janus: ${janusOk ? 'ready' : 'FAILED'}`) - logger.log(`synp: ${synpOk ? 'ready' : 'FAILED'}`) - - if (!(cdxgenOk && janusOk && synpOk)) { - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-misc-tools install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-misc-tools/package.json b/.claude/hooks/setup-misc-tools/package.json deleted file mode 100644 index 692682322..000000000 --- a/.claude/hooks/setup-misc-tools/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "hook-setup-misc-tools", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/setup-misc-tools/tsconfig.json b/.claude/hooks/setup-misc-tools/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-misc-tools/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-security-tools/README.md b/.claude/hooks/setup-security-tools/README.md deleted file mode 100644 index 62908155e..000000000 --- a/.claude/hooks/setup-security-tools/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# setup-security-tools - -A one-command setup helper that downloads and verifies Socket's three -local security tools — **AgentShield**, **zizmor**, and **SFW (Socket -Firewall)** — and wires them into your shell's PATH. Run it once per -machine and you're set. - -> Despite living under `.claude/hooks/`, this isn't a Claude Code -> _lifecycle_ hook (it doesn't fire on `PreToolUse` / `Stop` / etc.). -> It's just a shared setup script that any fleet repo can invoke as -> `pnpm run setup`. It lives here because it's tightly coupled to the -> claude config it sets up alongside. - -## What gets installed - -### 1. AgentShield - -Scans your Claude Code configuration (`.claude/` directory) for -security issues — prompt injection patterns, leaked secrets, -overly-permissive tool permissions. - -**How it's installed**: as an npm package, downloaded via the Socket -dlx system (a pinned-version + integrity-hash cache that lives at -`~/.socket/_dlx/`). The pin is read from `external-tools.json` so -every fleet repo agrees on a version. Subsequent runs reuse the -cache. There's no `devDependencies` entry in the consumer repo. - -### 2. zizmor - -Static analysis for GitHub Actions workflows. Catches unpinned -actions, secret exposure, template injection, and permission issues. - -**How it's installed**: as a native binary, downloaded from -[zizmor's GitHub Releases](https://github.com/zizmorcore/zizmor/releases), -SHA-256 verified against the pinned hash in `external-tools.json`, -cached at `~/.socket/_dlx/`. If you already have zizmor installed -via Homebrew, the download is skipped — but the script still uses -its pinned version, not your system one. - -### 3. SFW — Socket Firewall - -Intercepts package manager commands (`npm install`, `pnpm add`, etc.) -and scans the resolved packages against Socket.dev's malware database -_before_ the install runs. Catches malware that landed in the -registry between your last `pnpm install` and now. - -**How it's installed**: as a native binary, downloaded from GitHub, -SHA-256 verified, cached at `~/.socket/_dlx/`. The script also writes -small wrapper scripts ("shims") at `~/.socket/_wheelhouse/shims/` — one per -package manager — that transparently route commands through the -firewall. You make sure that directory is at the front of your PATH; -nothing else changes about how you use the tools. - -**Free vs. Enterprise**: if `SOCKET_API_KEY` is set in your env, -`.env`, or `.env.local`, the script installs the enterprise SFW -build (which adds gem, bundler, nuget, and go support). Otherwise -it installs the free build (npm, yarn, pnpm, pip, pip3, uv, cargo). -`SOCKET_API_KEY` is the primary slot because every Socket tool -reads it without a fallback chain. `SOCKET_API_TOKEN` (the -forward-canonical name used in fleet docs / workflow inputs) is -accepted as a secondary read — pass either and the bootstrap -resolves it. - -## How to use - -```sh -pnpm run setup -``` - -(That's wired in `package.json` to `node .claude/hooks/setup-security-tools/index.mts`.) - -The script will detect whether you have a `SOCKET_API_KEY` (or the -forward-canonical `SOCKET_API_TOKEN` alternative), ask if unsure, -then download whatever isn't already cached. - -## Where each tool lands - -| Tool | Location | Persists across repos? | -| ----------- | --------------------------------------- | ---------------------- | -| AgentShield | `~/.socket/_dlx/<hash>/agentshield` | Yes | -| zizmor | `~/.socket/_dlx/<hash>/zizmor` | Yes | -| SFW binary | `~/.socket/_dlx/<hash>/sfw` | Yes | -| SFW shims | `~/.socket/_wheelhouse/shims/npm`, etc. | Yes | - -`<hash>` in `_dlx/<hash>/` is a content-addressed directory keyed off -the pinned version + sha256, so multiple versions can coexist -without colliding. - -## Pre-push integration - -The `.git-hooks/pre-push` hook (also in this repo) runs -**AgentShield** and **zizmor** automatically before every `git push`. -A failed scan blocks the push. This means you don't have to remember -to run `pnpm run security` manually — every push gets the check. - -SFW doesn't run from pre-push (it runs at install time instead — see -the shims). - -## Re-running - -Safe to run multiple times: - -- AgentShield skips the download if the cached binary matches the - pinned version. -- zizmor skips the download if the cached binary matches the pinned - version. -- SFW skips the download if cached, and only rewrites the shims if - the shim contents changed. - -## Adopting in a new fleet repo - -The hook is self-contained but has three workspace dependencies. To -add it to a new Socket repo: - -1. Copy `.claude/hooks/setup-security-tools/` and - `.claude/commands/setup-security-tools.md`. -2. Make sure the consumer repo's catalog (or `dependencies`) provides - `@socketsecurity/lib-stable`, `@socketregistry/packageurl-js-stable`, and - `@sinclair/typebox`. -3. Make sure `.claude/hooks/` isn't gitignored — add - `!/.claude/hooks/` to `.gitignore` if needed. -4. Add a `setup` script to `package.json`: - `"setup": "node .claude/hooks/setup-security-tools/index.mts"`. -5. Run `pnpm install` so the hook's workspace deps resolve. - -## Troubleshooting - -**"AgentShield install failed"** — Check that your machine can reach -the npm registry. The dlx system caches at `~/.socket/_dlx/`. Clear -the cache (`safe-delete ~/.socket/_dlx/`) to force a fresh download. - -**"zizmor found but wrong version"** — The script intentionally -downloads the pinned version into the dlx cache, ignoring whatever -version you have via Homebrew. The pin lives in `external-tools.json`. - -**"No supported package managers found"** — SFW only creates shims -for package managers found on your `PATH` at install time. Install -npm/pnpm/whatever first, then re-run setup. - -**SFW shims not intercepting** — Make sure `~/.socket/_wheelhouse/shims` is -at the _front_ of your `PATH`. Run `which npm` — it should point at -the shim under `~/.socket/_wheelhouse/shims/`, not the real binary. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/setup-security-tools) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/setup-security-tools/external-tools.json b/.claude/hooks/setup-security-tools/external-tools.json deleted file mode 100644 index 896694659..000000000 --- a/.claude/hooks/setup-security-tools/external-tools.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "description": "Security tools for Claude Code hooks (self-contained, no external deps)", - "tools": { - "agentshield": { - "description": "Claude AI config security scanner (prompt injection, secrets)", - "purl": "pkg:npm/ecc-agentshield@1.4.0", - "integrity": "sha512-R98OO1Ujyk2lezDLb+iQmMhF6FwTJCHajy3G4FCB6x7wkSTqR9f8+eAelC5KDzYDsGSbc0sOZvjXOOPRBtMpDg==" - }, - "cdxgen": { - "description": "CycloneDX SBOM generator — slim SEA binary (no bundled bun/deno; smaller + faster than the npm flavor). Consumed by `socket scan sbom`.", - "version": "12.4.1", - "repository": "github:CycloneDX/cdxgen", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "cdxgen-darwin-arm64-slim", - "sha256": "0505e99b41aafd058f7f4d374c8cc6efbb74fc64cdb1abdb57ea404889df9039" - }, - "darwin-x64": { - "asset": "cdxgen-darwin-amd64-slim", - "sha256": "bd1fb6c6025ebe17ae285a1b0bbf7b8e75a527196c31b4af1920d054312dca2b" - }, - "linux-arm64": { - "asset": "cdxgen-linux-arm64-slim", - "sha256": "4ccfef914c899b11b253804092f347f641eb81f7b38a70bec588d329764d63fe" - }, - "linux-arm64-musl": { - "asset": "cdxgen-linux-arm64-musl-slim", - "sha256": "4f46b4b13c2237bb1155ac736fd0ecedb2d746bee28560bf0e53033bce07a0e0" - }, - "linux-x64": { - "asset": "cdxgen-linux-amd64-slim", - "sha256": "7a01b6214982fdcd05547226fadc1ccd768ed0e179ec37443431fe855779b7c0" - }, - "linux-x64-musl": { - "asset": "cdxgen-linux-amd64-musl-slim", - "sha256": "37fb567f2ac3dd281e9a5d8d040d73f9da0f5bfff6fe059e07d7f2f942de69c8" - }, - "win-arm64": { - "asset": "cdxgen-windows-arm64-slim.exe", - "sha256": "82ce353118cfc20bac972c0c5f34bfa4fb31d05e0391ffdd964335392b1c17c1" - }, - "win-x64": { - "asset": "cdxgen-windows-amd64-slim.exe", - "sha256": "3378eadfbf1e6463c5dbe4ff7d1ad160a4866c04d91e61ff43482fe83bc9118c" - } - } - }, - "synp": { - "description": "yarn.lock <-> package-lock.json converter (cross-PM interop)", - "purl": "pkg:npm/synp@1.9.14", - "integrity": "sha512-0e4u7KtrCrMqvuXvDN4nnHSEQbPlONtJuoolRWzut0PfuT2mEOvIFnYFHEpn5YPIOv7S5Ubher0b04jmYRQOzQ==" - }, - "zizmor": { - "description": "GitHub Actions security scanner", - "version": "1.23.1", - "repository": "github:zizmorcore/zizmor", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "zizmor-aarch64-apple-darwin.tar.gz", - "sha256": "2632561b974c69f952258c1ab4b7432d5c7f92e555704155c3ac28a2910bd717" - }, - "darwin-x64": { - "asset": "zizmor-x86_64-apple-darwin.tar.gz", - "sha256": "89d5ed42081dd9d0433a10b7545fac42b35f1f030885c278b9712b32c66f2597" - }, - "linux-arm64": { - "asset": "zizmor-aarch64-unknown-linux-gnu.tar.gz", - "sha256": "3725d7cd7102e4d70827186389f7d5930b6878232930d0a3eb058d7e5b47e658" - }, - "linux-x64": { - "asset": "zizmor-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "67a8df0a14352dd81882e14876653d097b99b0f4f6b6fe798edc0320cff27aff" - }, - "win-x64": { - "asset": "zizmor-x86_64-pc-windows-msvc.zip", - "sha256": "33c2293ff02834720dd7cd8b47348aafb2e95a19bdc993c0ecaca9c804ade92a" - } - } - }, - "sfw-free": { - "description": "Socket Firewall (free tier)", - "version": "v1.6.1", - "repository": "github:SocketDev/sfw-free", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "sfw-free-macos-arm64", - "sha256": "bf1616fc44ac49f1cb2067fedfa127a3ae65d6ec6d634efbb3098cfa355e5555" - }, - "darwin-x64": { - "asset": "sfw-free-macos-x86_64", - "sha256": "724ccea19d847b79db8cc8e38f5f18ce2dd32336007f42b11bed7d2e5f4a2566" - }, - "linux-arm64": { - "asset": "sfw-free-linux-arm64", - "sha256": "df2eedb2daf2572eee047adb8bfd81c9069edcb200fc7d3710fca98ec3ca81a1" - }, - "linux-x64": { - "asset": "sfw-free-linux-x86_64", - "sha256": "4a1e8b65e90fce7d5fd066cf0af6c93d512065fa4222a475c8d959a6bc14b9ff" - }, - "win-x64": { - "asset": "sfw-free-windows-x86_64.exe", - "sha256": "c953e62ad7928d4d8f2302f5737884ea1a757babc26bed6a42b9b6b68a5d54af" - } - }, - "ecosystems": ["npm", "yarn", "pnpm", "pip", "pip3", "uv", "cargo"] - }, - "sfw-enterprise": { - "description": "Socket Firewall (enterprise tier)", - "version": "v1.6.1", - "repository": "github:SocketDev/firewall-release", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "sfw-macos-arm64", - "sha256": "acad0b517601bb7408e2e611c9226f47dcccbd83333d7fc5157f1d32ed2b953d" - }, - "darwin-x64": { - "asset": "sfw-macos-x86_64", - "sha256": "01d64d40effda35c31f8d8ee1fed1388aac0a11aba40d47fba8a36024b77500c" - }, - "linux-arm64": { - "asset": "sfw-linux-arm64", - "sha256": "671270231617142404a1564e52672f79b806f9df3f232fcc7606329c0246da55" - }, - "linux-x64": { - "asset": "sfw-linux-x86_64", - "sha256": "9115b4ca8021eb173eb9e9c3627deb7f1066f8debd48c5c9d9f3caabb2a26a4b" - }, - "win-x64": { - "asset": "sfw-windows-x86_64.exe", - "sha256": "9a50e1ddaf038138c3f85418dc5df0113bbe6fc884f5abe158beaa9aea18d70a" - } - }, - "ecosystems": [ - "npm", - "yarn", - "pnpm", - "pip", - "pip3", - "uv", - "cargo", - "gem", - "bundler", - "nuget" - ] - }, - "trufflehog": { - "description": "TruffleHog — secrets scanner used by socket-basics SAST workflow.", - "version": "3.93.8", - "repository": "github:trufflesecurity/trufflehog", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "trufflehog_3.93.8_darwin_arm64.tar.gz", - "sha256": "f6eb3ae49c653e1ec8474ee3d4161666548e895d5051dad509ec8baa5b1fb89e" - }, - "darwin-x64": { - "asset": "trufflehog_3.93.8_darwin_amd64.tar.gz", - "sha256": "32e94de8572cdb014a261ee04b86d45ee5f2bb08b40aee1a054076284a3c2396" - }, - "linux-arm64": { - "asset": "trufflehog_3.93.8_linux_arm64.tar.gz", - "sha256": "9d51b703515502ee5a7be0ac48719a8f13c33544cecb5abaedcaaf6ad8238537" - }, - "linux-x64": { - "asset": "trufflehog_3.93.8_linux_amd64.tar.gz", - "sha256": "b965dd2a4106dc3c194dfcaa93931fe0a93571261e3e1f46f2d1728b6612e019" - }, - "win-x64": { - "asset": "trufflehog_3.93.8_windows_amd64.tar.gz", - "sha256": "1a563dbf559b566cd9eee3ec310099f5978f2b2a800b019e2c3fa027931fcc85" - } - } - }, - "trivy": { - "description": "Trivy — container/IaC/SBOM vuln scanner used by socket-basics.", - "version": "0.69.3", - "repository": "github:aquasecurity/trivy", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "trivy_0.69.3_macOS-ARM64.tar.gz", - "sha256": "a2f2179afd4f8bb265ca3c7aefb56a666bc4a9a411663bc0f22c3549fbc643a5" - }, - "darwin-x64": { - "asset": "trivy_0.69.3_macOS-64bit.tar.gz", - "sha256": "fec4a9f7569b624dd9d044fca019e5da69e032700edbb1d7318972c448ec2f4e" - }, - "linux-arm64": { - "asset": "trivy_0.69.3_Linux-ARM64.tar.gz", - "sha256": "7e3924a974e912e57b4a99f65ece7931f8079584dae12eb7845024f97087bdfd" - }, - "linux-x64": { - "asset": "trivy_0.69.3_Linux-64bit.tar.gz", - "sha256": "1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75" - }, - "win-x64": { - "asset": "trivy_0.69.3_windows-64bit.zip", - "sha256": "74362dc711383255308230ecbeb587eb1e4e83a8d332be5b0259afac6e0c2224" - } - } - }, - "opengrep": { - "description": "OpenGrep — semgrep fork used by socket-basics SAST workflow.", - "version": "1.16.5", - "repository": "github:opengrep/opengrep", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "opengrep_osx_arm64", - "sha256": "52b2f71b5663b5c3ce9d8070cdf6c815981286e7b1fd2e7031e910f1e2fd6958" - }, - "darwin-x64": { - "asset": "opengrep_osx_x86", - "sha256": "d018d1eb1a2ab627437f3db46d4d74237e739b0b85f02b3d81a9e625b1cc831f" - }, - "linux-arm64": { - "asset": "opengrep_manylinux_aarch64", - "sha256": "6b9efb7b82dbd947be472ef9623bb55c920c447a03010f2d7a1db3a9e5f96024" - }, - "linux-x64": { - "asset": "opengrep_manylinux_x86", - "sha256": "feb9983a339b0f8ed4d38979e75a3d5828d3a44993f5db9d1ad9c3bacb328d57" - }, - "win-x64": { - "asset": "opengrep-core_windows_x86.zip", - "sha256": "df43bf06d2f4ec87be9c7f4b49657f4d1ca30f714c748c911062978d245d0156" - } - } - }, - "uv": { - "description": "uv — Python package manager (Astral). Used by socket-basics for Python project bootstrap.", - "version": "0.10.11", - "repository": "github:astral-sh/uv", - "release": "asset", - "checksums": { - "darwin-arm64": { - "asset": "uv-aarch64-apple-darwin.tar.gz", - "sha256": "437a7d498dd6564d5bf986074249ba1fc600e73da55ae04d7bd4c24d5f149b95" - }, - "darwin-x64": { - "asset": "uv-x86_64-apple-darwin.tar.gz", - "sha256": "ff90020b554cf02ef8008535c9aab6ef27bb7be6b075359300dec79c361df897" - }, - "linux-arm64": { - "asset": "uv-aarch64-unknown-linux-gnu.tar.gz", - "sha256": "23003df007937dd607409c8ddf010baa82bad2673e60e254632ca5b04edcce13" - }, - "linux-x64": { - "asset": "uv-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "5a360b0de092ddf4131f5313d0411b48c4e95e8107e40c3f8f2e9fcb636b3583" - }, - "win-x64": { - "asset": "uv-x86_64-pc-windows-msvc.zip", - "sha256": "9ee74df98582f37fdd6069e1caac80d2616f9a489f5dbb2b1c152f30be69c58e" - } - } - }, - "janus": { - "description": "janus — divmain/janus single-binary tool. Installed under the shared Socket Wheelhouse dir so every fleet member sees the same binary. Currently darwin-arm64 only; other platforms will be added as upstream ships builds.", - "version": "1.22.0", - "repository": "github:divmain/janus", - "release": "asset", - "installDir": "wheelhouse", - "checksums": { - "darwin-arm64": { - "asset": "janus-aarch64-apple-darwin.tar.gz", - "sha256": "bb00f2b8b97e612fc42688e369529f453f3701c7bb4abcf6b0fd7024c38da521" - } - } - } - } -} diff --git a/.claude/hooks/setup-security-tools/index.mts b/.claude/hooks/setup-security-tools/index.mts deleted file mode 100644 index 60c63974f..000000000 --- a/.claude/hooks/setup-security-tools/index.mts +++ /dev/null @@ -1,359 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — setup-security-tools health-check. -// -// Read-only diagnostic that fires at turn-end and surfaces problems -// with the Socket security tools (AgentShield, Zizmor, SFW). Never -// auto-downloads — the heavy lifting (network calls, keychain prompts, -// shim rewrites) lives in `install.mts` and is operator-invoked. -// -// What it checks: -// -// 1. SFW shim integrity. Walks `~/.socket/_wheelhouse/shims/*` and reports -// shims whose dlx-cached binary target no longer exists on disk. -// Cache eviction (manifest rebuild, manual cleanup) leaves -// shims pointing at vanished hashes — every `pnpm` / `npm` / -// etc. call then fails with "No such file or directory" until -// the shims are rewritten. -// -// 2. Token / SFW edition consistency. If a SOCKET_API_TOKEN is -// available (env or OS keychain) but the SFW shim is the free -// build, the operator is paying for enterprise scanning they -// aren't getting. The reverse — no token but enterprise shim — -// is rarer but equally inconsistent. -// -// 3. Stale / expired token detection. Reads the last assistant turn -// from the Stop payload's transcript_path and looks for the -// Socket API "SOCKET_API_KEY validation got status of 401" error -// surfaced by sfw / agentshield / the SDK. When it fires, the -// remediation is `install.mts --rotate` (overwrites the keychain -// entry with a fresh token), not the plain `install.mts` invocation. -// -// Output: stderr lines starting with `[setup-security-tools]`. Each -// finding ends with the exact remediation command: -// -// node .claude/hooks/setup-security-tools/install.mts -// -// Disabled via `SOCKET_SETUP_SECURITY_TOOLS_DISABLED=1`. -// -// Fails open on every error (exit 0 + stderr log). The hook must -// not block the conversation on its own bugs. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { getSocketAppDir } from '@socketsecurity/lib-stable/paths/socket' - -interface Finding { - readonly kind: - | 'broken-shim' - | 'edition-mismatch' - | 'auto-repaired' - | 'token-401' - readonly message: string -} - -/** - * Regex for the Socket API 401-validation error message. The exact text is - * emitted by every Socket-tool client (sfw, agentshield, socket-cli, the JS - * SDK) when the configured token is rejected at upstream. We match a loose - * shape so a future variant of the sentence (newline-wrapped, prefixed with - * file-path, etc.) still trips the rule. - * - * Why: the SDK + sfw render this same error to stderr / stdout, but the - * operator usually scrolls past it and the next tool call also 401s. The right - * remediation is to rotate the token, not to retry. - * - * Recognized today: - * - * - "SOCKET_API_KEY validation got status of 401 from the Socket API" - * - "SOCKET_API_TOKEN validation got status of 401 from the Socket API" - * (forward-looking, in case the fleet env-var rename reaches the upstream SDK - * error path) - */ -const TOKEN_401_RE = - /SOCKET_API_(?:KEY|TOKEN) validation got status of 401 from the Socket API/ - -export function checkEdition(): Finding[] { - const shimPath = path.join(getSocketAppDir('wheelhouse'), 'shims', 'pnpm') - if (!existsSync(shimPath)) { - return [] - } - let content = '' - try { - content = require('node:fs').readFileSync(shimPath, 'utf8') as string - } catch { - return [] - } - const isFree = content.includes('sfw-free') - const isEnt = content.includes('sfw-enterprise') - // Setup tooling detects whether a token is present in the raw env; the - // keychain-fallback getter would defeat that "is it wired up yet?" check. - // socket-api-token-getter: allow direct-env - const apiKeyInEnv = !!process.env['SOCKET_API_KEY'] - // socket-api-token-getter: allow direct-env - const apiTokenInEnv = !!process.env['SOCKET_API_TOKEN'] - const tokenPresent = apiKeyInEnv || apiTokenInEnv - if (isFree && tokenPresent) { - return [ - { - kind: 'edition-mismatch', - message: - 'SOCKET_API_KEY is set but the SFW shim is the free build. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts` to ' + - 'switch to sfw-enterprise (org-aware malware scanning + private ' + - 'package data).', - }, - ] - } - // No findings for the enterprise-without-token shape — having an - // enterprise shim provisioned ahead of token setup is common during - // onboarding and the operator will fix it when their key arrives. - // Listing it as a "finding" would just create noise. - void isEnt - return [] -} - -export async function checkShims(): Promise<Finding[]> { - const shimsDir = path.join(getSocketAppDir('wheelhouse'), 'shims') - if (!existsSync(shimsDir)) { - return [] - } - let entries: string[] - try { - entries = await fs.readdir(shimsDir) - } catch { - return [] - } - const broken: string[] = [] - for (let i = 0, { length } = entries; i < length; i += 1) { - const name = entries[i]! - const shimPath = path.join(shimsDir, name) - let content: string - try { - content = await fs.readFile(shimPath, 'utf8') - } catch { - continue - } - const m = content.match(/"([^"]*\/_dlx\/[^"]+\/sfw-(?:enterprise|free))"/) - if (!m) { - continue - } - if (!existsSync(m[1]!)) { - broken.push(name) - } - } - if (broken.length === 0) { - return [] - } - return [ - { - kind: 'broken-shim', - message: - `SFW shim${broken.length === 1 ? '' : 's'} point to a missing dlx ` + - `target: ${broken.join(', ')}. The dlx cache evicted the binary ` + - `(manifest rebuild, manual delete, or cache rotation). Every ` + - `command through ${broken.length === 1 ? 'that shim' : 'those shims'} ` + - `currently fails with "No such file or directory." Run ` + - `\`node .claude/hooks/setup-security-tools/install.mts\` to ` + - `re-download SFW and rewrite the shims.`, - }, - ] -} - -/** - * Scan the most recent assistant turn for the Socket API 401- validation error. - * The transcript path comes from the Stop payload piped to the hook; if it's - * missing or unreadable we return no findings — never throw, never block. - * - * Reads the whole JSONL one line at a time (the transcript is usually < 1 MB - * but can grow); we walk in reverse so we stop at the last assistant turn - * instead of dragging through old context. - */ -export async function checkToken401( - transcriptPath: string, -): Promise<Finding[]> { - if (!existsSync(transcriptPath)) { - return [] - } - let raw: string - try { - raw = await fs.readFile(transcriptPath, 'utf8') - } catch { - return [] - } - const lines = raw.split('\n') - // Walk backwards — only the most recent assistant turn matters. - // Stop at the *second* assistant boundary so prior 401s don't - // re-trigger after a successful rotation. - let assistantTurnsSeen = 0 - for (let i = lines.length - 1; i >= 0; i -= 1) { - const line = lines[i] - if (!line) { - continue - } - let entry: { - type?: string | undefined - message?: { content?: unknown | undefined } | undefined - } - try { - entry = JSON.parse(line) - } catch { - continue - } - if (entry.type !== 'assistant') { - continue - } - assistantTurnsSeen += 1 - if (assistantTurnsSeen > 1) { - break - } - // The `message.content` field is an array of blocks; the text - // blocks have `{ type: 'text', text: '...' }`. Tool-use blocks - // carry the actual error string in their `text` rendering, so - // stringify the whole content and grep — cheaper than walking - // the schema and catches every shape upstream might use. - const haystack = JSON.stringify(entry.message?.content ?? '') - if (TOKEN_401_RE.test(haystack)) { - return [ - { - kind: 'token-401', - message: - 'Socket API returned 401 — the configured SOCKET_API_KEY ' + - 'is invalid, expired, or lacks the required permissions. ' + - 'Run `node .claude/hooks/setup-security-tools/install.mts ' + - '--rotate` to re-prompt and overwrite the keychain entry.', - }, - ] - } - } - return [] -} - -/** - * Silently auto-repair an empty/missing SFW shims directory when the SFW binary - * + the regenerate script are both present. This handles the common failure - * shape where shims got renamed/moved (`shims.broken-backup/`) and the operator - * forgot to re-run the regenerator. Returns a single 'auto-repaired' finding on - * success (so the user sees one tidy notice instead of nothing) — or nothing if - * the repair conditions weren't met / the script failed. - */ -export function repairShims(home: string): Finding[] { - // Use the lib-stable helper for cross-platform consistency and to - // honor the canonical "_wheelhouse" umbrella. The home arg is - // accepted for backwards-compat with the existing call site but - // ignored in favor of the lib-stable resolution. - void home - const sfwDir = getSocketAppDir('wheelhouse') - const shimsDir = path.join(sfwDir, 'shims') - const sfwBin = path.join(sfwDir, 'bin', 'sfw') - const regen = path.join(sfwDir, 'regenerate-shims.sh') - - // Both the binary and the regen script must exist. If either is - // missing the repair can't run; the diagnostic path will surface - // the install command instead. - if (!existsSync(sfwBin) || !existsSync(regen)) { - return [] - } - - // Repair triggers when shims/ is missing OR empty. A populated - // shims/ dir is handled by checkShims() (which reports broken - // individual shims). - let isEmpty = true - if (existsSync(shimsDir)) { - try { - const entries = require('node:fs').readdirSync(shimsDir) as string[] - isEmpty = entries.length === 0 - } catch { - // Unreadable dir — treat as broken; let regen recreate it. - isEmpty = true - } - } - if (!isEmpty) { - return [] - } - - const r = spawnSync('bash', [regen], {}) - if (r.status !== 0) { - // Failed — fall through to checkShims() which will report the - // missing/broken state and the install command. Don't double- - // report here. - return [] - } - - return [ - { - kind: 'auto-repaired', - message: - 'SFW shims were missing/empty — auto-repaired via ' + - `${regen}. ${String(r.stdout).trim().split('\n').pop() ?? ''}`.trim(), - }, - ] -} - -async function main(): Promise<void> { - if (process.env['SOCKET_SETUP_SECURITY_TOOLS_DISABLED']) { - return - } - // Read the Stop payload from stdin. We use `transcript_path` to - // scan the most recent assistant turn for the 401 error signature. - // Drain even if we can't parse so the pipe doesn't buffer-stall. - let payloadRaw = '' - await new Promise<void>(resolve => { - process.stdin.on('data', d => { - payloadRaw += d.toString('utf8') - }) - process.stdin.on('end', () => resolve()) - process.stdin.on('error', () => resolve()) - // Short timeout so we don't hang on stdin that never closes. - setTimeout(() => resolve(), 200) - }) - let transcriptPath: string | undefined - if (payloadRaw) { - try { - const payload = JSON.parse(payloadRaw) as { - transcript_path?: string | undefined - } - if (typeof payload.transcript_path === 'string') { - transcriptPath = payload.transcript_path - } - } catch { - // Malformed payload — skip the 401 scan but still run the - // shim/edition checks. - } - } - - const findings: Finding[] = [] - - // Auto-repair pass first. If shims/ is empty AND we have the binary - // + regen script, rebuild silently — this covers the common "moved - // to .broken-backup/" failure shape. After repair, checkShims() - // sees a populated shims/ dir and stays quiet, so the operator - // gets one notice line instead of a wall of diagnostics. - const home = process.env['HOME'] - if (home) { - findings.push(...repairShims(home)) - } - - findings.push(...(await checkShims())) - findings.push(...checkEdition()) - if (transcriptPath) { - findings.push(...(await checkToken401(transcriptPath))) - } - - if (findings.length === 0) { - return - } - process.stderr.write('[setup-security-tools] Health check:\n') - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - process.stderr.write(` • ${f.message}\n`) - } -} - -main().catch(e => { - process.stderr.write( - `[setup-security-tools] health-check error (allowing): ${e}\n`, - ) -}) diff --git a/.claude/hooks/setup-security-tools/install.mts b/.claude/hooks/setup-security-tools/install.mts deleted file mode 100644 index cac5d0aaf..000000000 --- a/.claude/hooks/setup-security-tools/install.mts +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -/** - * @file User-invoked installer / health-fixer for the Socket security tools - * (AgentShield, Zizmor, SFW). Runs interactively. Differs from `index.mts` - * (the Stop hook): - * - * - This script PROMPTS for missing config (e.g. SOCKET_API_KEY) and persists - * to the OS keychain. - * - It DOWNLOADS missing or stale binaries. - * - It REPAIRS broken SFW shims (entries pointing to dlx-cache hashes that no - * longer exist on disk). The Stop hook only DETECTS and REPORTS. - * Auto-prompting / auto- downloading from a Stop hook would surprise the - * operator with network calls + interactive flows mid-conversation. Skips - * the interactive prompt path when: - * - Running in CI (`getCI()` from @socketsecurity/lib-stable/env/ci). - * - Stdin isn't a TTY (`!process.stdin.isTTY`). In those skip cases, the script - * falls back to sfw-free (the auth- free SFW build) and continues without - * persisting a token. Invocation: node - * .claude/hooks/setup-security-tools/install.mts node - * .claude/hooks/setup-security-tools/install.mts --rotate Flags: --rotate - * Re-prompt for SOCKET_API_KEY and overwrite the keychain entry, ignoring - * env/.env/keychain lookup. Use to rotate a leaked or expired token without - * manually clearing the keychain first. --update-token Alias for --rotate. - * Exit codes: 0 — all tools installed + verified. 1 — at least one tool - * failed; details on stderr. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { findApiToken } from './lib/api-token.mts' -import { - offerTokenPrompt, - parseArgs, - promptAndPersist, - wireBridgeIntoShellRc, -} from './lib/operator-prompts.mts' - -const logger = getDefaultLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -/** - * Walk an existing SFW shim and report whether its dlx-cached binary target - * still exists. A shim is "broken" when the dlx cache has been evicted (cleanup - * script, manual delete, manifest rebuild) and the shim points at a path that - * no longer resolves. - */ -export async function findBrokenShims(): Promise<string[]> { - const shimsDir = path.join( - process.env['HOME'] ?? '', - '.socket', - 'sfw', - 'shims', - ) - if (!existsSync(shimsDir)) { - return [] - } - const broken: string[] = [] - const entries = await fs.readdir(shimsDir) - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const shimPath = path.join(shimsDir, entry) - let content: string - try { - content = await fs.readFile(shimPath, 'utf8') - } catch { - continue - } - // Each shim has the form: exec "<dlx-path>/sfw-{free,enterprise}" ... - // Pull out the dlx target and check existsSync. - const m = content.match(/"([^"]*\/_dlx\/[^"]+\/sfw-(?:enterprise|free))"/) - if (!m) { - continue - } - const target = m[1]! - if (!existsSync(target)) { - broken.push(entry) - } - } - return broken -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - logger.log('Socket security tools — install / verify') - logger.log('') - - let apiToken: string | undefined - if (args.rotate) { - // Rotation path: skip the lookup so a stale env/.env doesn't - // short-circuit the re-prompt, and overwrite the keychain entry - // unconditionally. If the user presses Enter without typing, the - // existing keychain value stays in place — we fall through to the - // normal lookup below so downstream installers still get the - // pre-rotation token. - const fresh = await promptAndPersist(logger, 'rotate') - if (fresh) { - apiToken = fresh - } else { - const lookup = findApiToken() - apiToken = lookup.token - if (apiToken && lookup.source) { - logger.log(`Keeping existing SOCKET_API_KEY (via ${lookup.source}).`) - } - } - } else { - // Existing token state — env > .env > keychain. - const lookup = findApiToken() - apiToken = lookup.token - if (apiToken && lookup.source) { - logger.log(`SOCKET_API_KEY: found via ${lookup.source}.`) - } else { - apiToken = await offerTokenPrompt(logger) - } - } - - // Wire the literal token into the shell rc unconditionally. The - // token may have come from env/keychain (no prompt fired) — - // without this block, every NEW shell session launches with an - // empty SOCKET_API_KEY and Socket tools return 401. We embed the - // token VALUE directly in the rc instead of calling `security - // find-generic-password` from the shell, because the latter - // triggers a macOS Keychain auth prompt on every new shell - // (Claude Code's Bash tool spawns one per command — see the - // 2026-05-15 incident memory). Idempotent: same-value re-run is - // outcome=unchanged. Rotate writes a fresh block. - if (apiToken) { - wireBridgeIntoShellRc(logger, apiToken) - } - - // Broken-shim detection. When the dlx cache rotates (cleanup, manifest - // rebuild, manual deletion), shims keep pointing at the old hash and - // every shimmed command fails with "No such file or directory." - // Repair = reinstall SFW, which rewrites the shims at the new hash. - const broken = await findBrokenShims() - if (broken.length > 0) { - logger.warn( - `Found ${broken.length} broken SFW shim(s): ${broken.join(', ')}. ` + - 'These point to a dlx-cache target that no longer exists. ' + - 'Reinstalling SFW will rewrite the shims.', - ) - } - - const installers = (await import('./lib/installers.mts')) as { - setupAgentShield: () => Promise<boolean> - setupZizmor: () => Promise<boolean> - setupSfw: (apiToken: string | undefined) => Promise<boolean> - setupTrufflehog: () => Promise<boolean> - setupTrivy: () => Promise<boolean> - setupOpengrep: () => Promise<boolean> - setupUv: () => Promise<boolean> - setupJanus: () => Promise<boolean> - setupCdxgen: () => Promise<boolean> - setupSynp: () => Promise<boolean> - } - - const agentshieldOk = await installers.setupAgentShield() - logger.log('') - const zizmorOk = await installers.setupZizmor() - logger.log('') - const sfwOk = await installers.setupSfw(apiToken) - logger.log('') - const [trufflehogOk, trivyOk, opengrepOk, uvOk, janusOk, cdxgenOk, synpOk] = - await Promise.all([ - installers.setupTrufflehog(), - installers.setupTrivy(), - installers.setupOpengrep(), - installers.setupUv(), - installers.setupJanus(), - installers.setupCdxgen(), - installers.setupSynp(), - ]) - logger.log('') - - logger.log('=== Summary ===') - logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) - logger.log(`cdxgen: ${cdxgenOk ? 'ready' : 'FAILED'}`) - logger.log(`janus: ${janusOk ? 'ready' : 'FAILED'}`) - logger.log(`OpenGrep: ${opengrepOk ? 'ready' : 'FAILED'}`) - logger.log(`SFW: ${sfwOk ? 'ready' : 'FAILED'}`) - logger.log(`synp: ${synpOk ? 'ready' : 'FAILED'}`) - logger.log(`Trivy: ${trivyOk ? 'ready' : 'FAILED'}`) - logger.log(`TruffleHog: ${trufflehogOk ? 'ready' : 'FAILED'}`) - logger.log(`uv: ${uvOk ? 'ready' : 'FAILED'}`) - logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) - - const allOk = - agentshieldOk && - cdxgenOk && - janusOk && - opengrepOk && - sfwOk && - synpOk && - trivyOk && - trufflehogOk && - uvOk && - zizmorOk - if (allOk) { - logger.log('') - logger.log('All security tools ready.') - } else { - logger.error('') - logger.warn('Some tools not available. See above.') - process.exitCode = 1 - } -} - -void __dirname - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`setup-security-tools install: ${msg}`) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-security-tools/lib/api-token.mts b/.claude/hooks/setup-security-tools/lib/api-token.mts deleted file mode 100644 index a8caff981..000000000 --- a/.claude/hooks/setup-security-tools/lib/api-token.mts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @file Single source of truth for "what's the Socket API token?" Resolution - * order (first hit wins): env → keychain. External fleet docs / workflow - * inputs / .env.example use SOCKET_API_TOKEN (the promoted name); internally - * we read both SOCKET_API_TOKEN and SOCKET_API_KEY because every Socket tool - * supports SOCKET_API_KEY (CLI, SDK, sfw, fleet scripts). Returns `undefined` - * when no token is found. Never throws — callers decide how to react (use - * free SFW, skip auth-gated install, prompt). **No `.env` / `.env.local` - * reads.** Dotfiles leak — they get accidentally committed, read by every dev - * tool that walks the project dir, swept into log scrapers. Tokens belong in - * env (for CI) or in the OS keychain (for dev local). **Module- scope - * cache.** Each successful resolution is memoized for the lifetime of the - * process. Reason: every `security find-generic-password` call on macOS - * triggers a fresh Keychain ACL check, which surfaces the "this app wants to - * access your keychain" dialog. A pre-commit hook + commit-msg hook + - * post-commit invocation can fire three keychain reads in 200ms — each one - * its own prompt. The cache collapses N reads per process to 1. Also - * propagates the resolved token into both env names so child processes - * inherit it regardless of which name they read. - */ - -import { readTokenFromKeychain } from './token-storage.mts' - -// Both names are checked at read time — first env hit wins. Storage layer -// (token-storage.mts) writes ONLY SOCKET_API_KEY to keep macOS Keychain -// rotation to a single auth prompt. -const ENV_NAMES = ['SOCKET_API_TOKEN', 'SOCKET_API_KEY'] as const - -export interface TokenLookup { - readonly token: string | undefined - readonly source: 'env' | 'keychain' | undefined -} - -// Module-scope cache: the result of the FIRST findApiToken() call is -// reused for every subsequent call in the same process. A `null` -// sentinel means "we already looked and found nothing" — distinct -// from `undefined` which means "not yet looked." Otherwise a -// not-found case would re-hit the keychain on every call. -let cached: TokenLookup | null | undefined - -/** - * Clear the module cache. Test-only escape hatch — production code should never - * call this. Used by `--rotate` flows that need to re-prompt after wiping the - * keychain entry. - */ -export function resetApiTokenCacheForTesting(): void { - cached = undefined -} - -export function findApiToken(): TokenLookup { - if (cached !== undefined) { - return cached === null ? { token: undefined, source: undefined } : cached - } - - for (let i = 0, { length } = ENV_NAMES; i < length; i += 1) { - const name = ENV_NAMES[i]! - const value = process.env[name] - if (value) { - propagateToEnv(value) - cached = { token: value, source: 'env' } - return cached - } - } - - const fromKeychain = readTokenFromKeychain() - if (fromKeychain) { - propagateToEnv(fromKeychain) - cached = { token: fromKeychain, source: 'keychain' } - return cached - } - - cached = undefined - return { token: undefined, source: undefined } -} - -/** - * Populate both SOCKET_API_TOKEN and SOCKET_API_KEY in `process.env` so any - * spawned child resolves a value under whichever name it reads. Idempotent — - * already-set values are left alone (so the user's explicit env value isn't - * clobbered by a keychain read). - */ -export function propagateToEnv(token: string): void { - for (let i = 0, { length } = ENV_NAMES; i < length; i += 1) { - const name = ENV_NAMES[i]! - if (!process.env[name]) { - process.env[name] = token - } - } -} diff --git a/.claude/hooks/setup-security-tools/lib/installers.mts b/.claude/hooks/setup-security-tools/lib/installers.mts deleted file mode 100644 index 597abab74..000000000 --- a/.claude/hooks/setup-security-tools/lib/installers.mts +++ /dev/null @@ -1,891 +0,0 @@ -#!/usr/bin/env node -// Setup script for Socket security tools. -// -// Configures three tools: -// 1. AgentShield — scans Claude AI config for prompt injection / secrets. -// Downloaded as npm package via dlx (pinned version, cached). -// 2. Zizmor — static analysis for GitHub Actions workflows. Downloads the -// correct binary, verifies SHA-256, cached via the dlx system. -// 3. SFW (Socket Firewall) — intercepts package manager commands to scan -// for malware. Downloads binary, verifies SHA-256, creates PATH shims. -// Enterprise vs free determined by SOCKET_API_KEY (primary; universally -// supported) or SOCKET_API_TOKEN (forward-canonical; accepted as secondary) -// in env / .env / .env.local. - -import { existsSync, promises as fs, readFileSync } from 'node:fs' - -import { findApiToken as findApiTokenCanonical } from './api-token.mts' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { PackageURL } from '@socketregistry/packageurl-js-stable' -import { Type } from '@sinclair/typebox' - -import { whichSync } from '@socketsecurity/lib-stable/bin/which' -import { downloadBinary } from '@socketsecurity/lib-stable/dlx/binary' -import { downloadPackage } from '@socketsecurity/lib-stable/dlx/package' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { getSocketHomePath } from '@socketsecurity/lib-stable/paths/socket' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { parseSchema } from '@socketsecurity/lib-stable/schema/parse' - -const logger = getDefaultLogger() - -// ── Tool config loaded from external-tools.json (self-contained) ── - -const checksumEntrySchema = Type.Object({ - asset: Type.String(), - sha256: Type.String(), -}) - -const toolSchema = Type.Object({ - description: Type.Optional(Type.String()), - version: Type.Optional(Type.String()), - purl: Type.Optional(Type.String()), - integrity: Type.Optional(Type.String()), - repository: Type.Optional(Type.String()), - release: Type.Optional(Type.String()), - checksums: Type.Optional(Type.Record(Type.String(), checksumEntrySchema)), - ecosystems: Type.Optional(Type.Array(Type.String())), -}) - -const configSchema = Type.Object({ - description: Type.Optional(Type.String()), - tools: Type.Record(Type.String(), toolSchema), -}) - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// external-tools.json lives one level up at the hook root -// (.claude/hooks/setup-security-tools/external-tools.json) — keep it -// out of `lib/` so it's discoverable as a top-level config file rather -// than buried as an implementation detail. Fall back to a sibling path -// so an early-installed copy in lib/ still resolves during onboarding. -const configPath = (() => { - const parentPath = path.join(__dirname, '..', 'external-tools.json') - if (existsSync(parentPath)) { - return parentPath - } - return path.join(__dirname, 'external-tools.json') -})() -const rawConfig = JSON.parse(readFileSync(configPath, 'utf8')) -const config = parseSchema(configSchema, rawConfig) - -const AGENTSHIELD = config.tools['agentshield']! -const CDXGEN = config.tools['cdxgen']! -const SYNP = config.tools['synp']! -const ZIZMOR = config.tools['zizmor']! -const SFW_FREE = config.tools['sfw-free']! -const SFW_ENTERPRISE = config.tools['sfw-enterprise']! -const TRUFFLEHOG = config.tools['trufflehog']! -const TRIVY = config.tools['trivy']! -const OPENGREP = config.tools['opengrep']! -const UV = config.tools['uv']! -const JANUS = config.tools['janus']! - -// ── Shared helpers ── - -export async function checkZizmorVersion(binPath: string): Promise<boolean> { - try { - const result = await spawn(binPath, ['--version'], { stdio: 'pipe' }) - const output = String(result.stdout).trim() - return ZIZMOR.version ? output.includes(ZIZMOR.version) : false - } catch { - return false - } -} - -/** - * Resolve the Socket API token from env → keychain. Re-exported from - * `lib/api-token.mts` so call sites can keep importing `findApiToken` from - * `installers.mts` (back-compat) while the canonical resolver stays a single - * source of truth. - * - * The previous in-file implementation read `.env` / `.env.local` which is a - * CLAUDE.md token-hygiene violation (dotfiles leak; tokens belong in env or the - * OS keychain). It also skipped the keychain entirely, which caused - * sfw-enterprise → sfw-free silent downgrades when the token was only in the - * macOS Keychain. - */ -export function findApiToken(): string | undefined { - return findApiTokenCanonical().token -} - -type ToolEntry = (typeof config.tools)[string] - -interface InstallGitHubToolOptions { - /** - * Logical tool name (used for log banner + cache key). - */ - name: string - /** - * Display name for human-readable logs. - */ - displayName: string - /** - * Tool config entry from external-tools.json. - */ - tool: ToolEntry - /** - * Name of the binary inside the archive (without extension). For bare-binary - * assets (no archive), pass the same string used as the asset name — the - * helper detects and skips extraction. - */ - binaryNameInArchive: string - /** - * Final binary name on disk (without extension). Usually same as - * `binaryNameInArchive`. - */ - finalBinaryName: string - /** - * Optional path within the archive where the binary lives. Defaults to the - * archive root. - */ - pathInArchive?: string | undefined - /** - * Optional absolute directory to install the final binary into. When set, the - * binary is copied here (creating parent dirs as needed) instead of landing - * alongside the dlx-cached archive. Use for shared cross-fleet locations - * (e.g. `~/.socket/_wheelhouse/<tool>/`) so multiple consumers reuse the same - * install. - */ - installDir?: string | undefined -} - -/** - * Common path for tools downloaded from GitHub Releases: PATH check → download - * + sha256-verify → cache hit / extract → chmod 0o755. - * - * Handles three archive shapes: - `.tar.gz` / `.tgz` → tar xzf - `.zip` → - * PowerShell Expand-Archive (Windows) or unzip - bare binary → copy as-is (used - * by opengrep manylinux/osx assets) - */ -export async function installGitHubReleaseTool( - options: InstallGitHubToolOptions, -): Promise<boolean> { - const opts = { __proto__: null, ...options } as InstallGitHubToolOptions - const { binaryNameInArchive, displayName, finalBinaryName, name, tool } = opts - logger.log(`=== ${displayName} ===`) - - // Check PATH first (e.g. brew install). - const systemBin = whichSync(finalBinaryName, { nothrow: true }) - if (systemBin && typeof systemBin === 'string') { - logger.log(`Found on PATH: ${systemBin}`) - return true - } - - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - const platformEntry = tool.checksums?.[platformKey] - if (!platformEntry) { - logger.warn(`${displayName}: unsupported platform ${platformKey}`) - return false - } - const { asset, sha256: expectedSha } = platformEntry - const repo = tool.repository?.replace(/^[^:]+:/, '') ?? '' - // Most GitHub release URLs use a `v` prefix on the tag (`v1.2.3`); a - // few projects don't (`uv` uses `0.10.11`). The tool config's - // `version` field is the bare semver — prepend `v` unless it already - // starts with one. astral-sh/uv is the lone exception and is handled - // by setupUv() passing the literal tag. - const tagPrefix = tool.version?.startsWith('v') ? '' : 'v' - const tag = `${tagPrefix}${tool.version}` - const url = `https://github.com/${repo}/releases/download/${tag}/${asset}` - - logger.log(`Downloading ${displayName} v${tool.version} (${asset})...`) - const { binaryPath: downloadPath, downloaded } = await downloadBinary({ - url, - name: `${name}-${tool.version}-${asset}`, - sha256: expectedSha, - }) - logger.log( - downloaded - ? 'Download complete, checksum verified.' - : `Using cached: ${downloadPath}`, - ) - - const ext = process.platform === 'win32' ? '.exe' : '' - const finalDir = opts.installDir ?? path.dirname(downloadPath) - await fs.mkdir(finalDir, { recursive: true }) - const finalBinPath = path.join(finalDir, `${finalBinaryName}${ext}`) - if (existsSync(finalBinPath)) { - logger.log(`Cached: ${finalBinPath}`) - return true - } - - const isTar = asset.endsWith('.tar.gz') || asset.endsWith('.tgz') - const isZip = asset.endsWith('.zip') - // Bare-binary assets (opengrep's manylinux/osx variants) — the asset - // IS the binary, no extraction needed. Copy + chmod and exit. - if (!isTar && !isZip) { - await fs.copyFile(downloadPath, finalBinPath) - await fs.chmod(finalBinPath, 0o755) - logger.log(`Installed to ${finalBinPath}`) - return true - } - - const extractDir = await fs.mkdtemp( - path.join(os.tmpdir(), `${name}-extract-`), - ) - try { - if (isZip) { - if (process.platform === 'win32') { - await spawn( - 'powershell', - [ - '-NoProfile', - '-Command', - `Expand-Archive -Path '${downloadPath}' -DestinationPath '${extractDir}' -Force`, - ], - { stdio: 'pipe' }, - ) - } else { - await spawn('unzip', ['-q', downloadPath, '-d', extractDir], { - stdio: 'pipe', - }) - } - } else { - await spawn('tar', ['xzf', downloadPath, '-C', extractDir], { - stdio: 'pipe', - }) - } - const extractedRel = opts.pathInArchive - ? path.join(opts.pathInArchive, `${binaryNameInArchive}${ext}`) - : `${binaryNameInArchive}${ext}` - const extractedBin = path.join(extractDir, extractedRel) - if (!existsSync(extractedBin)) { - throw new Error(`Binary not found after extraction: ${extractedBin}`) - } - await fs.copyFile(extractedBin, finalBinPath) - await fs.chmod(finalBinPath, 0o755) - } finally { - await safeDelete(extractDir).catch(e => { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`cleanup of extract dir failed (${extractDir}): ${msg}`) - }) - } - - logger.log(`Installed to ${finalBinPath}`) - return true -} - -/** - * Variant of `installGitHubReleaseTool` for projects that don't tag with a `v` - * prefix (astral-sh/uv). Takes an explicit `tag` field instead of synthesizing - * one from `tool.version`. - */ -export async function installGitHubReleaseToolWithTag( - options: InstallGitHubToolOptions & { tag: string }, -): Promise<boolean> { - const opts = { __proto__: null, ...options } as InstallGitHubToolOptions & { - tag: string - } - const { binaryNameInArchive, displayName, finalBinaryName, name, tag, tool } = - opts - logger.log(`=== ${displayName} ===`) - - const systemBin = whichSync(finalBinaryName, { nothrow: true }) - if (systemBin && typeof systemBin === 'string') { - logger.log(`Found on PATH: ${systemBin}`) - return true - } - - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - const platformEntry = tool.checksums?.[platformKey] - if (!platformEntry) { - logger.warn(`${displayName}: unsupported platform ${platformKey}`) - return false - } - const { asset, sha256: expectedSha } = platformEntry - const repo = tool.repository?.replace(/^[^:]+:/, '') ?? '' - const url = `https://github.com/${repo}/releases/download/${tag}/${asset}` - - logger.log(`Downloading ${displayName} ${tag} (${asset})...`) - const { binaryPath: downloadPath, downloaded } = await downloadBinary({ - url, - name: `${name}-${tag}-${asset}`, - sha256: expectedSha, - }) - logger.log( - downloaded - ? 'Download complete, checksum verified.' - : `Using cached: ${downloadPath}`, - ) - - const ext = process.platform === 'win32' ? '.exe' : '' - const finalBinPath = path.join( - path.dirname(downloadPath), - `${finalBinaryName}${ext}`, - ) - if (existsSync(finalBinPath)) { - logger.log(`Cached: ${finalBinPath}`) - return true - } - - const isZip = asset.endsWith('.zip') - const extractDir = await fs.mkdtemp( - path.join(os.tmpdir(), `${name}-extract-`), - ) - try { - if (isZip) { - if (process.platform === 'win32') { - await spawn( - 'powershell', - [ - '-NoProfile', - '-Command', - `Expand-Archive -Path '${downloadPath}' -DestinationPath '${extractDir}' -Force`, - ], - { stdio: 'pipe' }, - ) - } else { - await spawn('unzip', ['-q', downloadPath, '-d', extractDir], { - stdio: 'pipe', - }) - } - } else { - await spawn('tar', ['xzf', downloadPath, '-C', extractDir], { - stdio: 'pipe', - }) - } - const extractedRel = opts.pathInArchive - ? path.join(opts.pathInArchive, `${binaryNameInArchive}${ext}`) - : `${binaryNameInArchive}${ext}` - const extractedBin = path.join(extractDir, extractedRel) - if (!existsSync(extractedBin)) { - throw new Error(`Binary not found after extraction: ${extractedBin}`) - } - await fs.copyFile(extractedBin, finalBinPath) - await fs.chmod(finalBinPath, 0o755) - } finally { - await safeDelete(extractDir).catch(e => { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`cleanup of extract dir failed (${extractDir}): ${msg}`) - }) - } - - logger.log(`Installed to ${finalBinPath}`) - return true -} - -export async function setupAgentShield(): Promise<boolean> { - logger.log('=== AgentShield ===') - const purl = PackageURL.fromString(AGENTSHIELD.purl!) - if (purl.type !== 'npm') { - throw new Error( - `Unsupported PURL type "${purl.type}" — only npm is supported`, - ) - } - const npmPackage = purl.namespace - ? `${purl.namespace}/${purl.name}` - : purl.name! - const version = AGENTSHIELD.version ?? purl.version - const packageSpec = version ? `${npmPackage}@${version}` : npmPackage - - logger.log(`Installing ${packageSpec} via dlx…`) - const { binaryPath, installed } = await downloadPackage({ - package: packageSpec, - binaryName: 'agentshield', - }) - - // Verify the installed package matches the pinned version. - // - // Don't trust the binary's --version self-report: ecc-agentshield's - // compiled bundle has a hardcoded version string that has drifted - // from the published package.json (e.g. binary reports "1.5.0" - // while npm latest + published package.json both say "1.4.0"). - // That's an upstream packaging issue; the authoritative answer - // is the dlx-cached package.json, which is what npm actually - // delivered after integrity-hash verification. - if (version) { - const pkgJsonPath = path.join( - path.dirname(binaryPath), - '..', - 'ecc-agentshield', - 'package.json', - ) - let installedVersion: string | undefined - try { - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { - version?: unknown | undefined - } - if (typeof pkgJson.version === 'string') { - installedVersion = pkgJson.version - } - } catch { - // Fall through — treat as unverifiable rather than fail. - } - if (installedVersion && installedVersion !== version) { - logger.warn( - `Version mismatch: pinned ${version}, installed ${installedVersion}`, - ) - return false - } - const reportedVersion = installedVersion ?? version - logger.log( - installed - ? `Installed: ${binaryPath} (${reportedVersion})` - : `Cached: ${binaryPath} (${reportedVersion})`, - ) - } else { - logger.log(installed ? `Installed: ${binaryPath}` : `Cached: ${binaryPath}`) - } - return true -} - -export async function setupCdxgen(): Promise<boolean> { - // cdxgen ships per-platform SEA binaries (slim variant by default — - // no bundled bun/deno runtimes, ~3× smaller than the full flavor). - // Falls through to the generic GitHub-release-tool helper. Platforms - // that aren't in the asset map quietly skip via the helper's - // "unsupported platform" warning path — none today (the slim matrix - // covers all 8 fleet targets). - return installGitHubReleaseTool({ - name: 'cdxgen', - displayName: 'cdxgen', - tool: CDXGEN, - binaryNameInArchive: 'cdxgen', - finalBinaryName: 'cdxgen', - }) -} - -export async function setupJanus(): Promise<boolean> { - // janus ships darwin-arm64 only at v1.22.0. On every other platform, - // skip the install with a quiet log rather than emitting a warning — - // janus isn't a fleet-critical dependency, just a tool some Socket - // workflows opt into. Install lands in the shared - // ~/.socket/_wheelhouse/janus/<version>/ dir so every fleet member's - // hook reuses the same binary. - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - if (!JANUS.checksums?.[platformKey]) { - logger.log('=== janus ===') - logger.log(`Skipped: no janus build for ${platformKey} (mac-arm64 only)`) - return true - } - const installDir = path.join( - getSocketHomePath(), - '_wheelhouse', - 'janus', - JANUS.version!, - platformKey, - ) - return installGitHubReleaseTool({ - name: 'janus', - displayName: 'janus', - tool: JANUS, - binaryNameInArchive: 'janus', - finalBinaryName: 'janus', - installDir, - }) -} - -interface NpmToolInstallOptions { - /** - * Logical tool name (used for log banner + bin name). - */ - readonly name: string - /** - * Human-readable display name for log output. - */ - readonly displayName: string - /** - * Tool config entry from external-tools.json (must carry `purl`). - */ - readonly tool: (typeof config.tools)[string] -} - -/** - * Install an npm-only tool via dlx. Mirrors the upper half of - * `setupAgentShield()` — purl → package spec → `downloadPackage`. No - * version-mismatch verification: the dlx layer SRI-verifies the tarball against - * the `integrity` from external-tools.json, which is the authoritative answer - * (binary --version self-reports can drift from package.json — see the - * AgentShield comment for the documented case). - */ -export async function setupNpmTool( - opts: NpmToolInstallOptions, -): Promise<boolean> { - const { displayName, name, tool } = opts - logger.log(`=== ${displayName} ===`) - if (!tool.purl) { - logger.warn(`${displayName}: missing purl in external-tools.json`) - return false - } - const purl = PackageURL.fromString(tool.purl) - if (purl.type !== 'npm') { - throw new Error( - `${displayName}: unsupported PURL type "${purl.type}" — only npm is supported`, - ) - } - const npmPackage = purl.namespace - ? `${purl.namespace}/${purl.name}` - : purl.name! - const version = tool.version ?? purl.version - const packageSpec = version ? `${npmPackage}@${version}` : npmPackage - logger.log(`Installing ${packageSpec} via dlx…`) - const { binaryPath, installed } = await downloadPackage({ - package: packageSpec, - binaryName: name, - }) - logger.log( - installed - ? `Installed: ${binaryPath}${version ? ` (${version})` : ''}` - : `Cached: ${binaryPath}${version ? ` (${version})` : ''}`, - ) - return true -} - -export async function setupOpengrep(): Promise<boolean> { - // OpenGrep ships bare-binary assets for Linux/macOS (e.g. - // `opengrep_manylinux_x86`) and a zipped binary for Windows (named - // `opengrep-core_windows_x86.zip` containing `opengrep-core.exe`). - // The bare-binary case is auto-detected by extension; we just need - // the right `binaryNameInArchive` for the Windows zip case. - const isWindows = process.platform === 'win32' - return installGitHubReleaseTool({ - name: 'opengrep', - displayName: 'OpenGrep', - tool: OPENGREP, - binaryNameInArchive: isWindows ? 'opengrep-core' : 'opengrep', - finalBinaryName: 'opengrep', - }) -} - -export async function setupSfw(apiToken: string | undefined): Promise<boolean> { - const isEnterprise = !!apiToken - const sfwConfig = isEnterprise ? SFW_ENTERPRISE : SFW_FREE - logger.log( - `=== Socket Firewall (${isEnterprise ? 'enterprise' : 'free'}) ===`, - ) - - // Platform. - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - const platformEntry = sfwConfig.checksums?.[platformKey] - if (!platformEntry) { - throw new Error(`Unsupported platform: ${platformKey}`) - } - - // Checksum + asset. - const { asset, sha256 } = platformEntry - const repo = sfwConfig.repository?.replace(/^[^:]+:/, '') ?? '' - const url = `https://github.com/${repo}/releases/download/${sfwConfig.version}/${asset}` - const binaryName = isEnterprise ? 'sfw' : 'sfw-free' - - // Download (with cache + checksum). - const { binaryPath, downloaded } = await downloadBinary({ - url, - name: binaryName, - sha256, - }) - logger.log( - downloaded ? `Downloaded to ${binaryPath}` : `Cached at ${binaryPath}`, - ) - - // Create shims. - const isWindows = process.platform === 'win32' - - const shimDir = path.join(getSocketHomePath(), 'sfw', 'shims') - await fs.mkdir(shimDir, { recursive: true }) - const ecosystems = [...(sfwConfig.ecosystems ?? [])] - if (isEnterprise && process.platform === 'linux') { - ecosystems.push('go') - } - const cleanPath = (process.env['PATH'] ?? '') - .split(path.delimiter) - .filter(p => p !== shimDir) - .join(path.delimiter) - const sfwBin = normalizePath(binaryPath) - const created: string[] = [] - for (let i = 0, { length } = ecosystems; i < length; i += 1) { - const cmd = ecosystems[i]! - let realBin = whichSync(cmd, { nothrow: true, path: cleanPath }) - if (!realBin || typeof realBin !== 'string') { - continue - } - realBin = normalizePath(realBin) - - // Bash shim (macOS/Linux/Windows Git Bash). - const bashLines = [ - '#!/bin/bash', - `export PATH="$(echo "$PATH" | tr ':' '\\n' | grep -vxF '${shimDir}' | paste -sd: -)"`, - ] - if (isEnterprise) { - // Read API token from env at runtime — never embed secrets in - // scripts. Either SOCKET_API_KEY or SOCKET_API_TOKEN is accepted; - // whichever is set gets exported under both so downstream tools - // see the value regardless of which name they read. - // - // Dotfile fallback (`.env` / `.env.local`) is intentionally NOT - // checked here per CLAUDE.md token-hygiene: tokens belong in env - // (CI) or the OS keychain (dev local), never in dotfiles. The - // shell-rc bridge installed by setup-security-tools writes the - // export line into ~/.zshenv so every new shell already has the - // env var set. - bashLines.push( - 'if [ -z "$SOCKET_API_KEY" ] && [ -n "$SOCKET_API_TOKEN" ]; then', - ' SOCKET_API_KEY="$SOCKET_API_TOKEN"', - 'fi', - 'if [ -n "$SOCKET_API_KEY" ]; then', - ' export SOCKET_API_KEY', - ' SOCKET_API_TOKEN="$SOCKET_API_KEY"', - ' export SOCKET_API_TOKEN', - 'fi', - ) - } - bashLines.push(`exec "${sfwBin}" "${realBin}" "$@"`) - const bashContent = bashLines.join('\n') + '\n' - const bashPath = path.join(shimDir, cmd) - if ( - !existsSync(bashPath) || - (await fs.readFile(bashPath, 'utf8').catch(() => '')) !== bashContent - ) { - await fs.writeFile(bashPath, bashContent, { mode: 0o755 }) - } - created.push(cmd) - - // Windows .cmd shim (strips shim dir from PATH, then execs through sfw). - if (isWindows) { - let cmdApiTokenBlock = '' - if (isEnterprise) { - // Mirror the bash-shim env-only resolution. Dotfile fallback - // (`.env` / `.env.local`) is intentionally not read here — see - // the bash-shim comment for the token-hygiene rationale. The - // Windows CredentialManager shell-rc bridge installed by - // setup-security-tools writes the env var for every new - // session. - cmdApiTokenBlock = - `if not defined SOCKET_API_KEY (\r\n` + - ` if defined SOCKET_API_TOKEN set "SOCKET_API_KEY=%SOCKET_API_TOKEN%"\r\n` + - `)\r\n` + - `if defined SOCKET_API_KEY set "SOCKET_API_TOKEN=%SOCKET_API_KEY%"\r\n` - } - const cmdContent = - `@echo off\r\n` + - `set "PATH=;%PATH%;"\r\n` + - `set "PATH=%PATH:;${shimDir};=%"\r\n` + - `set "PATH=%PATH:~1,-1%"\r\n` + - cmdApiTokenBlock + - `"${sfwBin}" "${realBin}" %*\r\n` - const cmdPath = path.join(shimDir, `${cmd}.cmd`) - if ( - !existsSync(cmdPath) || - (await fs.readFile(cmdPath, 'utf8').catch(() => '')) !== cmdContent - ) { - await fs.writeFile(cmdPath, cmdContent) - } - } - } - - if (created.length) { - logger.log(`Shims: ${created.join(', ')}`) - logger.log(`Shim dir: ${shimDir}`) - logger.log(`Activate: export PATH="${shimDir}:$PATH"`) - } else { - logger.warn('No supported package managers found on PATH.') - } - return !!created.length -} - -export async function setupSynp(): Promise<boolean> { - return setupNpmTool({ - name: 'synp', - displayName: 'synp', - tool: SYNP, - }) -} - -export async function setupTrivy(): Promise<boolean> { - return installGitHubReleaseTool({ - name: 'trivy', - displayName: 'Trivy', - tool: TRIVY, - binaryNameInArchive: 'trivy', - finalBinaryName: 'trivy', - }) -} - -export async function setupTrufflehog(): Promise<boolean> { - return installGitHubReleaseTool({ - name: 'trufflehog', - displayName: 'TruffleHog', - tool: TRUFFLEHOG, - binaryNameInArchive: 'trufflehog', - finalBinaryName: 'trufflehog', - }) -} - -export async function setupUv(): Promise<boolean> { - // astral-sh/uv tags releases without a `v` prefix (`0.10.11`, not - // `v0.10.11`), so the generic helper's `v`-prepend would 404. The - // tarball also wraps the binary one level deep: e.g. - // `uv-x86_64-apple-darwin/uv`. Pin the tag literally and tell the - // helper which subdirectory holds the binary. - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - const platformEntry = UV.checksums?.[platformKey] - const pathInArchive = platformEntry?.asset.replace(/\.(tar\.gz|zip)$/, '') - return installGitHubReleaseToolWithTag({ - name: 'uv', - displayName: 'uv (Python package manager)', - tool: UV, - binaryNameInArchive: 'uv', - finalBinaryName: 'uv', - pathInArchive, - tag: UV.version!, - }) -} - -export async function setupZizmor(): Promise<boolean> { - logger.log('=== Zizmor ===') - - // Check PATH first (e.g. brew install). - const systemBin = whichSync('zizmor', { nothrow: true }) - if (systemBin && typeof systemBin === 'string') { - if (await checkZizmorVersion(systemBin)) { - logger.log(`Found on PATH: ${systemBin} (v${ZIZMOR.version})`) - return true - } - logger.log(`Found on PATH but wrong version (need v${ZIZMOR.version})`) - } - - // Download archive via dlx (handles caching + checksum). - const platformKey = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` - const platformEntry = ZIZMOR.checksums?.[platformKey] - if (!platformEntry) { - throw new Error(`Unsupported platform: ${platformKey}`) - } - const { asset, sha256: expectedSha } = platformEntry - const repo = ZIZMOR.repository?.replace(/^[^:]+:/, '') ?? '' - const url = `https://github.com/${repo}/releases/download/v${ZIZMOR.version}/${asset}` - - logger.log(`Downloading zizmor v${ZIZMOR.version} (${asset})...`) - const { binaryPath: archivePath, downloaded } = await downloadBinary({ - url, - name: `zizmor-${ZIZMOR.version}-${asset}`, - sha256: expectedSha, - }) - logger.log( - downloaded - ? 'Download complete, checksum verified.' - : `Using cached archive: ${archivePath}`, - ) - - // Extract binary from the cached archive. - const ext = process.platform === 'win32' ? '.exe' : '' - const binPath = path.join(path.dirname(archivePath), `zizmor${ext}`) - if (existsSync(binPath) && (await checkZizmorVersion(binPath))) { - logger.log(`Cached: ${binPath} (v${ZIZMOR.version})`) - return true - } - - const isZip = asset.endsWith('.zip') - // mkdtemp is collision-safe, unlike Date.now()-only naming. - const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zizmor-extract-')) - try { - if (isZip) { - await spawn( - 'powershell', - [ - '-NoProfile', - '-Command', - `Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force`, - ], - { stdio: 'pipe' }, - ) - } else { - await spawn('tar', ['xzf', archivePath, '-C', extractDir], { - stdio: 'pipe', - }) - } - const extractedBin = path.join(extractDir, `zizmor${ext}`) - if (!existsSync(extractedBin)) { - throw new Error(`Binary not found after extraction: ${extractedBin}`) - } - await fs.copyFile(extractedBin, binPath) - await fs.chmod(binPath, 0o755) - } finally { - // Cleanup is fail-open by design — a tempdir we couldn't delete - // (EPERM / EBUSY / ENOTEMPTY) shouldn't prevent the install from - // reporting success — but the silent swallow loses the signal, - // and orphaned tempdirs accumulate on the user's machine. Log - // and continue. - await safeDelete(extractDir).catch(e => { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`cleanup of extract dir failed (${extractDir}): ${msg}`) - }) - } - - logger.log(`Installed to ${binPath}`) - return true -} - -async function main(): Promise<void> { - logger.log('Setting up Socket security tools…') - logger.log('') - - const apiToken = findApiToken() - - const agentshieldOk = await setupAgentShield() - logger.log('') - const zizmorOk = await setupZizmor() - logger.log('') - const sfwOk = await setupSfw(apiToken) - logger.log('') - // socket-basics SAST + secrets stack + janus (shared wheelhouse) + - // npm-only tools (cdxgen, synp) — non-fatal if any individual tool - // fails (the basics workflow degrades cleanly when a scanner is - // absent; janus is opt-in and mac-only; cdxgen + synp are consumed - // by socket-cli scan/lockfile codepaths). Install in parallel since - // they don't share state. - const [cdxgenOk, janusOk, opengrepOk, synpOk, trivyOk, trufflehogOk, uvOk] = - await Promise.all([ - setupCdxgen(), - setupJanus(), - setupOpengrep(), - setupSynp(), - setupTrivy(), - setupTrufflehog(), - setupUv(), - ]) - logger.log('') - - logger.log('=== Summary ===') - logger.log(`AgentShield: ${agentshieldOk ? 'ready' : 'NOT AVAILABLE'}`) - logger.log(`cdxgen: ${cdxgenOk ? 'ready' : 'FAILED'}`) - logger.log(`janus: ${janusOk ? 'ready' : 'FAILED'}`) - logger.log(`OpenGrep: ${opengrepOk ? 'ready' : 'FAILED'}`) - logger.log(`SFW: ${sfwOk ? 'ready' : 'FAILED'}`) - logger.log(`synp: ${synpOk ? 'ready' : 'FAILED'}`) - logger.log(`Trivy: ${trivyOk ? 'ready' : 'FAILED'}`) - logger.log(`TruffleHog: ${trufflehogOk ? 'ready' : 'FAILED'}`) - logger.log(`uv: ${uvOk ? 'ready' : 'FAILED'}`) - logger.log(`Zizmor: ${zizmorOk ? 'ready' : 'FAILED'}`) - - const allOk = - agentshieldOk && - cdxgenOk && - janusOk && - opengrepOk && - sfwOk && - synpOk && - trivyOk && - trufflehogOk && - uvOk && - zizmorOk - if (allOk) { - logger.log('') - logger.log('All security tools ready.') - } else { - logger.error('') - logger.warn('Some tools not available. See above.') - } -} - -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - main().catch((e: unknown) => { - logger.error(errorMessage(e)) - process.exitCode = 1 - }) -} diff --git a/.claude/hooks/setup-security-tools/lib/operator-prompts.mts b/.claude/hooks/setup-security-tools/lib/operator-prompts.mts deleted file mode 100644 index a4e8b058d..000000000 --- a/.claude/hooks/setup-security-tools/lib/operator-prompts.mts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * @file Operator-prompt helpers shared between the setup-security-tools - * umbrella's install.mts and the scoped leaves (setup-firewall, etc.). Each - * helper here is library-shaped: no top-level side effects, no process.exit, - * no implicit logger ownership. Callers pass their own logger so each - * entrypoint can label its prompts/outputs differently. What's intentionally - * NOT here: - * - * - `findBrokenShims()` — only used by the umbrella to print a pre-install - * warning. Stays in install.mts. - * - `main()` — orchestration, not a helper. - */ - -import process from 'node:process' -import readline from 'node:readline' - -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import type { Logger } from '@socketsecurity/lib-stable/logger/logger' - -import { installShellRcBridge } from './shell-rc-bridge.mts' -import type { BridgeWriteResult } from './shell-rc-bridge.mts' -import { keychainAvailable, writeTokenToKeychain } from './token-storage.mts' - -export interface CliArgs { - readonly rotate: boolean -} - -export function parseArgs(argv: readonly string[]): CliArgs { - let rotate = false - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i]! - if (arg === '--rotate' || arg === '--update-token') { - rotate = true - } - } - return { rotate } -} - -/** - * Read a secret from the TTY without echoing it. Wraps node:readline with - * custom output muting — typed characters never appear on screen and never end - * up in shell history. - * - * Caller must verify `process.stdin.isTTY` before invoking. - */ -export async function promptSecret(prompt: string): Promise<string> { - // Custom output stream that swallows everything written to stdout - // during the prompt — that's how readline echoes typed characters, - // and we want them invisible. - const muted = new (class extends (await import('node:stream')).Writable { - override _write(_chunk: unknown, _enc: unknown, cb: () => void): void { - cb() - } - })() - const rl = readline.createInterface({ - input: process.stdin, - output: muted, - terminal: true, - }) - // The prompt itself is written directly to stderr so it shows up - // even though readline's echo is muted. - process.stderr.write(prompt) - try { - return await new Promise<string>(resolve => { - rl.question('', answer => { - process.stderr.write('\n') - resolve(answer.trim()) - }) - }) - } finally { - rl.close() - } -} - -/** - * Shared prompt-and-persist body used by both the "no token found" and the - * explicit `--rotate` paths. The `reason` strings differ but the gating + the - * prompt + the keychain write are identical. - */ -export async function promptAndPersist( - logger: Logger, - reason: 'missing' | 'rotate', -): Promise<string | undefined> { - if (getCI()) { - logger.log( - 'CI environment detected — skipping the SOCKET_API_KEY prompt. ' + - 'Falling back to sfw-free.', - ) - return undefined - } - if (!process.stdin.isTTY) { - logger.log( - 'No TTY — skipping the SOCKET_API_KEY prompt. ' + - 'Falling back to sfw-free. Set SOCKET_API_KEY in env or run ' + - 'this script interactively to persist it to the OS keychain.', - ) - return undefined - } - const kc = keychainAvailable() - if (!kc.available) { - logger.warn( - `OS keychain tool '${kc.toolName}' is not available. ${ - kc.installHint ?? '' - }`, - ) - logger.log('Falling back to sfw-free.') - return undefined - } - logger.log('') - if (reason === 'rotate') { - logger.log( - `Rotating SOCKET_API_KEY — the keychain entry will be overwritten ` + - `via ${kc.toolName}.`, - ) - } else { - logger.log('Socket API token not found in env, .env, or the OS keychain.') - logger.log( - 'A token unlocks sfw-enterprise (org-aware malware scanning). ' + - `It will be stored securely via ${kc.toolName}.`, - ) - } - logger.log( - 'Get a token at https://socket.dev/dashboard or press Enter to skip' + - (reason === 'rotate' - ? ' (the existing keychain entry stays in place).' - : ' and use sfw-free.'), - ) - logger.log('') - const answer = await promptSecret('SOCKET_API_KEY (input hidden): ') - if (!answer) { - if (reason === 'rotate') { - logger.log('No token entered. Keychain unchanged.') - } else { - logger.log('No token entered. Falling back to sfw-free.') - } - return undefined - } - try { - writeTokenToKeychain(answer) - if (reason === 'rotate') { - logger.success(`SOCKET_API_KEY rotated and persisted via ${kc.toolName}.`) - } - } catch (e) { - logger.error( - `Failed to persist token to keychain: ${(e as Error).message}. ` + - 'Continuing with the value for this session only — it will not ' + - 'persist across runs until the keychain tool is available.', - ) - } - return answer -} - -/** - * Thin alias for the "no token found" prompt path. Same shape as - * `promptAndPersist(logger, 'missing')` but reads better at call sites that are - * only ever in the missing-token branch. - */ -export async function offerTokenPrompt( - logger: Logger, -): Promise<string | undefined> { - return promptAndPersist(logger, 'missing') -} - -/** - * Print a one-paragraph summary of what the shell-rc bridge did (or didn't do), - * with a copy-pasteable next step. - */ -export function reportBridgeOutcome( - logger: Logger, - bridge: BridgeWriteResult | undefined, -): void { - if (!bridge) { - // Non-macOS or no rc detectable — fall through to a manual line - // the user can paste. We hand the user a literal-export template - // (not a keychain-read) because re-reading the keychain on every - // shell triggers an auth prompt on macOS. - logger.log('') - logger.log( - 'Add this to your shell rc / .zshenv so SOCKET_API_KEY is exported ' + - 'each session (every Socket tool reads it without a fallback chain):', - ) - logger.log(" export SOCKET_API_KEY='<your-token>'") - return - } - if (bridge.outcome === 'unchanged') { - logger.log( - `Shell-rc env block already canonical at ${bridge.rcPath} — no change.`, - ) - } else if (bridge.outcome === 'updated') { - logger.success( - `Updated the shell-rc env block at ${bridge.rcPath}. ` + - 'Run `source ' + - bridge.rcPath + - '` (or open a new shell) so SOCKET_API_KEY gets exported.', - ) - } else { - logger.success( - `Wrote the shell-rc env block to ${bridge.rcPath}. ` + - 'Run `source ' + - bridge.rcPath + - '` (or open a new shell) so SOCKET_API_KEY gets exported.', - ) - } -} - -/** - * Write (or refresh) the keychain → shell-env bridge block in the user's shell - * rc. Idempotent: re-running on an already-wired rc is a no-op. - */ -export function wireBridgeIntoShellRc(logger: Logger, token: string): void { - try { - const bridge = installShellRcBridge(token) - reportBridgeOutcome(logger, bridge) - } catch (e) { - logger.warn( - `Failed to write the shell-rc env block: ${(e as Error).message}. ` + - 'You will need to export SOCKET_API_KEY manually for Socket tools to pick it up.', - ) - } -} diff --git a/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts b/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts deleted file mode 100644 index c12c52c5e..000000000 --- a/.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @file Wire a keychain → environment bridge into the user's shell rc file so - * every new shell session exports `SOCKET_API_KEY` from the OS keychain. - * `SOCKET_API_KEY` is universally supported across Socket tools (CLI, SDK, - * sfw, fleet scripts) — one env var covers the whole surface with no fallback - * chain. Why a shell-rc block instead of a wrapper script: sfw and other - * Socket clients read their token from `process.env`, but the OS keychain - * (macOS Keychain, Linux libsecret, Windows CredentialManager) only hands the - * token out on explicit request. Nothing bridges the two automatically — so - * unless the user manually exports the value from the keychain each session, - * every Socket tool launches with an empty token and the API returns 401. The - * block is delimited by canonical sentinels so re-running the install script - * updates the block in place (no duplicate appends). The block is small - * enough that the user can read it before sourcing. macOS only for now — zsh - * and bash. Linux's `secret-tool` works the same way but the rc-detection on - * Linux distros varies more (system vs user profile, multiple bash variants). - * Windows uses PowerShell profiles; the equivalent is - * `$PROFILE.CurrentUserAllHosts`. Both are tractable but out of scope for - * this baseline. Read paths are silent (best-effort). Write paths surface - * clear errors so the install script can tell the user when the rc file - * couldn't be touched (read-only home dir, immutable rc, etc.). - */ - -import { - appendFileSync, - existsSync, - readFileSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -// Sentinels are intentionally simple — no env-var names in the -// BEGIN/END lines so user search-replace on a token name can't -// accidentally orphan the block. -const BLOCK_BEGIN = '# BEGIN socket-cli env (managed)' -const BLOCK_END = '# END socket-cli env' - -/** - * Build the managed block body. Takes the literal token value so the shell - * never calls `security find-generic-password` (which prompts for the user's - * macOS login password on every new shell — see the 2026-05-15 incident in - * memory: feedback_keychain_prompts.md). - * - * The exports use single-quotes for safe POSIX-shell escaping. - */ -export function buildBlockBody(token: string): string { - const quoted = shellSingleQuote(token) - return `# Token persisted by setup-security-tools install.mts. -# Rotate via: node .claude/hooks/setup-security-tools/install.mts --rotate -# Keychain copy still lives at: security find-generic-password -s socket-cli -a SOCKET_API_KEY -# SOCKET_API_KEY is universally supported across Socket tools (CLI, SDK, sfw, -# fleet scripts) — one env var covers the whole surface with no fallback chain. -export SOCKET_API_KEY=${quoted}` -} - -/** - * Escape characters that have special meaning in a JavaScript regex. Used for - * the sentinel-matching regex above — the sentinels contain literal parens and - * `→` which both round-trip safely, but a future sentinel rename might add a - * regex metachar so the escape is here to prevent that from breaking the - * matcher silently. - */ -export function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -export interface BridgeWriteResult { - rcPath: string - // 'inserted' = fresh block appended; 'updated' = existing block - // body rewritten in place; 'unchanged' = block already canonical. - outcome: 'inserted' | 'updated' | 'unchanged' -} - -/** - * Insert / update the env-var block in the user's shell rc. macOS only — Linux - * + Windows return `undefined` (the install script falls back to a one-line - * instruction the user can paste). - * - * Takes the literal token value and embeds it as a static `export - * SOCKET_API_KEY='...'` in the managed block. NO keychain lookup runs from the - * shell — every shell startup would otherwise hit a macOS Keychain auth prompt, - * and Claude Code's Bash tool spawns a fresh shell per command, so the user - * gets a continuous prompt stream until they revoke. (Incident memory: - * feedback_keychain_prompts.md, 2026-05-15.) - * - * The keychain is still the canonical store — the rc block is a one-time - * materialization. Next rotate writes a new block. - * - * Idempotent: a second call with the same token rewrites the block in place - * rather than appending a duplicate. Different tokens trigger a rewrite. The - * block is matched by BLOCK_BEGIN / BLOCK_END sentinels so it's safe to share - * an rc with other managed blocks (homebrew, nvm, etc.). - */ -export function installShellRcBridge( - token: string, -): BridgeWriteResult | undefined { - if (!token || typeof token !== 'string') { - throw new TypeError( - 'installShellRcBridge: token must be a non-empty string', - ) - } - if (os.platform() !== 'darwin') { - return undefined - } - const rcPath = pickRcFile() - if (!rcPath) { - return undefined - } - - const desiredBlock = `${BLOCK_BEGIN}\n${buildBlockBody(token)}\n${BLOCK_END}` - - let existing = '' - if (existsSync(rcPath)) { - existing = readFileSync(rcPath, 'utf8') - } - - // First sweep: strip any legacy block written by an earlier install - // version. The legacy block called `security find-generic-password` - // from the shell, which triggers a macOS Keychain auth prompt on - // every new shell — Claude Code's Bash tool spawns one per command, - // so the user gets a continuous prompt stream. Removing the legacy - // block before writing the new one closes that loop without - // double-appending. - const legacyRe = - /\n*# BEGIN socket-cli keychain bridge \(managed\)[\s\S]*?# END socket-cli keychain bridge\n?/g - existing = existing.replace(legacyRe, '\n') - - // Look for an existing canonical block. Capture the BEGIN line, - // anything up to the END line, and the END line itself. - const blockRe = new RegExp( - `${escapeRegExp(BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}`, - ) - const match = blockRe.exec(existing) - - if (match) { - if (match[0] === desiredBlock) { - return { rcPath, outcome: 'unchanged' } - } - const rewritten = - existing.slice(0, match.index) + - desiredBlock + - existing.slice(match.index + match[0].length) - writeFileSync(rcPath, rewritten) - return { rcPath, outcome: 'updated' } - } - - // No existing block — append. Prefix with a blank line if the file - // doesn't already end with one, so the block reads cleanly against - // whatever the previous user content was. - const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n\n') - const prefix = needsLeadingNewline - ? existing.endsWith('\n') - ? '\n' - : '\n\n' - : '' - appendFileSync(rcPath, `${prefix}${desiredBlock}\n`) - return { rcPath, outcome: 'inserted' } -} - -/** - * Pick the shell rc file to edit. Honors $SHELL when set; defaults to the most - * common file for the active user's shell. - * - * Why .zshenv (not .zshrc) for zsh: ~/.zshrc is only sourced for interactive - * shells. Tools that spawn zsh non-interactively (Claude Code's Bash tool, IDE - * integrations, CI runners) skip .zshrc and therefore miss the bridge. - * ~/.zshenv runs for every zsh invocation regardless of interactive / login - * state, which is what an env-var export actually wants. The only downside is - * the file runs on more shells than strictly needed — but a keychain lookup of - * a single string is cheap (~5ms) and any consumer that doesn't care just - * ignores the var. - * - * For bash: ~/.bashrc is interactive, ~/.bash_profile is login. Bash's BASH_ENV - * is the closest analog to .zshenv but it requires the env var to be set ahead - * of time, which doesn't help us. Settle for ~/.bashrc when present, fall back - * to ~/.bash_profile. Non-interactive bash callers still need a wrapper script - * for now. - * - * Returns `undefined` when no rc file is sensible — caller falls through to - * "tell the user what to add manually." - */ -export function pickRcFile(): string | undefined { - const home = os.homedir() - const shell = process.env['SHELL'] ?? '' - if (shell.endsWith('zsh')) { - return path.join(home, '.zshenv') - } - if (shell.endsWith('bash')) { - const bashrc = path.join(home, '.bashrc') - if (existsSync(bashrc)) { - return bashrc - } - const bashProfile = path.join(home, '.bash_profile') - if (existsSync(bashProfile)) { - return bashProfile - } - return bashrc - } - return undefined -} - -/** - * Single-quote a value for safe inclusion in a POSIX shell `export` statement. - * The token is a base64-ish opaque string in practice but single-quoting also - * handles any future format that includes dollar-signs, backticks, or - * backslashes without surprise expansion. - * - * POSIX single-quoted strings can contain anything except a single quote. To - * embed a literal single quote, close the quoted span, insert an escaped quote, - * and reopen: `it's` → `'it'\''s'`. - */ -export function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -/** - * Remove the keychain-bridge block from the user's shell rc. Used by a future - * `--unbridge` path; not wired into install.mts yet. Returns `true` when a - * block was removed, `false` when no block was present. - */ -export function uninstallShellRcBridge(): boolean { - if (os.platform() !== 'darwin') { - return false - } - const rcPath = pickRcFile() - if (!rcPath || !existsSync(rcPath)) { - return false - } - const existing = readFileSync(rcPath, 'utf8') - const blockRe = new RegExp( - `\\n*${escapeRegExp(BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(BLOCK_END)}\\n?`, - ) - const match = blockRe.exec(existing) - if (!match) { - return false - } - writeFileSync( - rcPath, - existing.slice(0, match.index) + - existing.slice(match.index + match[0].length), - ) - return true -} diff --git a/.claude/hooks/setup-security-tools/lib/token-storage.mts b/.claude/hooks/setup-security-tools/lib/token-storage.mts deleted file mode 100644 index c42d1871c..000000000 --- a/.claude/hooks/setup-security-tools/lib/token-storage.mts +++ /dev/null @@ -1,439 +0,0 @@ -/** - * @file Cross-platform secure storage for the Socket API token. Wraps each OS's - * native credential store: macOS → `security add-generic-password` / - * `find-generic-password` (Keychain Access). Linux → `secret-tool store` / - * `secret-tool lookup` (libsecret). Windows → `cmdkey /add` plus PowerShell - * readback via `Get-StoredCredential` (CredentialManager module). Falls back - * to `DPAPI`-encrypted file under `%APPDATA%\\socket-cli\\token.enc` when - * neither CredentialManager module nor cmdkey-readback is available. The - * token is stored under service name `socket-cli` with account - * `SOCKET_API_KEY` so it co-exists with other Socket credentials (e.g. - * CLI-managed publish tokens) without collision. **Never read from or write - * to a plain file.** The point of this module is to keep the token off the - * filesystem entirely. The fallback DPAPI file on Windows is encrypted under - * the user's machine key — still not plaintext. Returned values are the raw - * token string or `undefined`. Errors during read are silent (returns - * undefined); errors during write throw so the caller can surface why - * persistence failed. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -const SERVICE = 'socket-cli' - -// Keychain account names. SOCKET_API_KEY is the primary slot we read + write -// (universally supported across Socket tools — CLI, SDK, sfw, fleet scripts). -// SOCKET_API_TOKEN appears in DELETE_SLOTS only, to purge stale entries from -// older hook versions that mirrored the value to both slots. Each -// `security add-generic-password` call on macOS triggers a Keychain auth -// prompt, so we write one slot to keep rotation to a single prompt. -const WRITE_SLOTS = ['SOCKET_API_KEY'] as const -const READ_SLOTS = ['SOCKET_API_KEY'] as const -const DELETE_SLOTS = ['SOCKET_API_KEY', 'SOCKET_API_TOKEN'] as const - -export function deleteLinux(account: string): void { - spawnSync('secret-tool', ['clear', 'service', SERVICE, 'user', account], { - stdio: 'ignore', - }) -} - -export function deleteMacOS(account: string): void { - // Exit code 44 = entry not found, which is fine. Any other non- - // zero is an error worth surfacing — but since delete is best- - // effort we swallow it (a stale entry is annoying but not blocking). - spawnSync( - 'security', - ['delete-generic-password', '-s', SERVICE, '-a', account], - { stdio: 'ignore' }, - ) -} - -/** - * Remove the token from the platform's secure store. Idempotent — succeeds - * whether the entry exists or not. Clears both the primary account - * (`SOCKET_API_KEY`) and the forward-canonical mirror (`SOCKET_API_TOKEN`), so - * a rotate/wipe purges stale entries left by older versions of this hook that - * mirrored to both slots. - */ -export function deleteTokenFromKeychain(): void { - const platform_ = detectPlatform() - for (let i = 0, { length } = DELETE_SLOTS; i < length; i += 1) { - const slot = DELETE_SLOTS[i]! - switch (platform_) { - case 'darwin': - deleteMacOS(slot) - break - case 'linux': - deleteLinux(slot) - break - case 'win32': - deleteWindows(slot) - break - default: - return - } - } -} - -export function deleteWindows(account: string): void { - // Try the PowerShell removal first, ignore failures. - spawnSync( - 'powershell', - [ - '-NoProfile', - '-Command', - `try { Remove-StoredCredential -Target '${SERVICE}:${account}' } catch {}`, - ], - { stdio: 'ignore' }, - ) - // Also remove the DPAPI file if present. - const filePath = getWindowsDpapiFilePath() - if (existsSync(filePath)) { - try { - rmSync(filePath, { force: true }) - } catch { - // best-effort - } - } -} - -type Platform = 'darwin' | 'linux' | 'win32' | 'other' - -export function detectPlatform(): Platform { - const p = os.platform() - if (p === 'darwin' || p === 'linux' || p === 'win32') { - return p - } - return 'other' -} - -export function getWindowsDpapiFilePath(): string { - const appData = - process.env['APPDATA'] ?? path.join(os.homedir(), 'AppData', 'Roaming') - return path.join(appData, 'socket-cli', 'token.enc') -} - -/** - * Diagnostic: report whether the platform's keychain tool is available. Used by - * the install script to tell the operator upfront if - * libsecret/CredentialManager need installing before the prompt. - */ -export function keychainAvailable(): { - available: boolean - toolName: string - installHint: string | undefined -} { - const p = detectPlatform() - switch (p) { - case 'darwin': { - // security(1) ships with macOS — always present. - return { - available: true, - toolName: 'security(1)', - installHint: undefined, - } - } - case 'linux': { - const r = spawnSync('secret-tool', ['--version'], { stdio: 'ignore' }) - return r.status === 0 - ? { available: true, toolName: 'secret-tool', installHint: undefined } - : { - available: false, - toolName: 'secret-tool', - installHint: - 'apt install libsecret-tools (Debian/Ubuntu) | ' + - 'dnf install libsecret (Fedora/RHEL)', - } - } - case 'win32': { - // PowerShell is always present on Windows 10+. - return { - available: true, - toolName: 'PowerShell (CredentialManager / DPAPI)', - installHint: undefined, - } - } - default: - return { - available: false, - toolName: 'n/a', - installHint: `Platform ${os.platform()} is not supported. Set SOCKET_API_KEY in your shell rc.`, - } - } -} - -export function readLinux(account: string): string | undefined { - const r = spawnSync( - 'secret-tool', - ['lookup', 'service', SERVICE, 'user', account], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - if (r.status !== 0) { - // secret-tool exits 1 when the entry doesn't exist AND when the - // command isn't on PATH — both map to "no token here, try the - // next source." Don't try to distinguish. - return undefined - } - const out = String(r.stdout).trim() - return out || undefined -} - -export function readMacOS(account: string): string | undefined { - // `-s service -a account -w` prints the password to stdout. - // Non-zero exit when the entry doesn't exist. - const r = spawnSync( - 'security', - ['find-generic-password', '-s', SERVICE, '-a', account, '-w'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - if (r.status !== 0) { - return undefined - } - const out = String(r.stdout).trim() - return out || undefined -} - -/** - * Read the token from the platform's secure store. Returns undefined when the - * entry doesn't exist OR when the underlying tool isn't on PATH — read paths - * never throw, so callers can fall through to the next source (env, .env, - * prompt) cleanly. - * - * Reads the primary `SOCKET_API_KEY` slot only. One stored slot, one read, one - * macOS Keychain ACL check. - */ -export function readTokenFromKeychain(): string | undefined { - const platform_ = detectPlatform() - for (let i = 0, { length } = READ_SLOTS; i < length; i += 1) { - const slot = READ_SLOTS[i]! - let value: string | undefined - switch (platform_) { - case 'darwin': - value = readMacOS(slot) - break - case 'linux': - value = readLinux(slot) - break - case 'win32': - value = readWindows(slot) - break - default: - return undefined - } - if (value) { - return value - } - } - return undefined -} - -export function readWindows(account: string): string | undefined { - // Try the CredentialManager PowerShell module first (clean - // structured read). Falls back to the DPAPI file if the module - // isn't installed. - const ps = spawnSync( - 'powershell', - [ - '-NoProfile', - '-Command', - `try { (Get-StoredCredential -Target '${SERVICE}:${account}').Password | ConvertFrom-SecureString -AsPlainText } catch { exit 1 }`, - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - if (ps.status === 0) { - const out = String(ps.stdout).trim() - if (out) { - return out - } - } - // Fallback: DPAPI-encrypted file (encrypted under the current - // user's machine key — readable only by this user on this machine). - // The DPAPI file uses one filename regardless of slot; we only fall - // back when the CredentialManager read missed entirely, so a single - // file is enough. - return readWindowsDpapiFile() -} - -export function readWindowsDpapiFile(): string | undefined { - const filePath = getWindowsDpapiFilePath() - if (!existsSync(filePath)) { - return undefined - } - // Decrypt via DPAPI (System.Security.Cryptography.ProtectedData). - // The file holds base64(DPAPI-protected UTF8(token)). - const psScript = ` - $bytes = [Convert]::FromBase64String((Get-Content -Raw '${filePath.replace(/'/g, "''")}')) - $plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'CurrentUser') - [System.Text.Encoding]::UTF8.GetString($plain) - ` - const ps = spawnSync('powershell', ['-NoProfile', '-Command', psScript], { - stdio: ['ignore', 'pipe', 'pipe'], - }) - if (ps.status !== 0) { - return undefined - } - const out = String(ps.stdout).trim() - return out || undefined -} - -export function writeLinux(token: string, account: string): void { - // secret-tool reads the token from stdin so it never appears in - // `ps` / `/proc/<pid>/cmdline`. - const r = spawnSync( - 'secret-tool', - ['store', '--label=Socket API token', 'service', SERVICE, 'user', account], - { - input: token, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - if (r.status !== 0) { - throw new Error( - `secret-tool store failed (exit ${r.status}, user=${account}): ${String(r.stderr).trim()}. ` + - 'Install libsecret-tools (apt install libsecret-tools / dnf install libsecret) ' + - 'or ensure a Secret Service provider (gnome-keyring, kwallet) is running.', - ) - } -} - -export function writeMacOS(token: string, account: string): void { - // `-U` updates the entry if it already exists; without -U a second - // `add-generic-password` call would error. - const r = spawnSync( - 'security', - [ - 'add-generic-password', - '-U', - '-s', - SERVICE, - '-a', - account, - '-w', - token, - '-T', - '', // -T '' allows any app to read; we don't want a per-app ACL - '-D', - 'Socket API token', - '-l', - 'Socket API token', - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - if (r.status !== 0) { - throw new Error( - `security(1) add-generic-password failed (exit ${r.status}, account=${account}): ${String(r.stderr).trim()}`, - ) - } -} - -/** - * Persist the token to the platform's secure store. Throws on write failure — - * the caller is in a user-initiated setup flow and should see why persistence - * failed, not silently continue. - * - * Writes the token under the primary account (`SOCKET_API_KEY`) only. Every - * Socket tool reads SOCKET_API_KEY without a fallback chain, so one stored slot - * covers the whole surface — and one slot keeps macOS rotation to a single - * Keychain auth prompt. - */ -export function writeTokenToKeychain(token: string): void { - if (!token || typeof token !== 'string') { - throw new TypeError( - 'writeTokenToKeychain: token must be a non-empty string', - ) - } - const platform_ = detectPlatform() - if (platform_ === 'other') { - throw new Error( - `Unsupported platform: ${os.platform()}. ` + - 'Token storage requires macOS, Linux, or Windows.', - ) - } - for (let i = 0, { length } = WRITE_SLOTS; i < length; i += 1) { - const slot = WRITE_SLOTS[i]! - switch (platform_) { - case 'darwin': - writeMacOS(token, slot) - break - case 'linux': - writeLinux(token, slot) - break - case 'win32': - writeWindows(token, slot) - break - } - } -} - -export function writeWindows(token: string, account: string): void { - // Prefer CredentialManager PowerShell module — most idiomatic. - // The token is passed via stdin to avoid leaking into command - // history / ps output. - const psScript = ` - $token = $input | Out-String - $token = $token.Trim() - $secure = ConvertTo-SecureString $token -AsPlainText -Force - try { - New-StoredCredential -Target '${SERVICE}:${account}' -UserName '${account}' -SecurePassword $secure -Persist LocalMachine | Out-Null - exit 0 - } catch { exit 1 } - ` - const ps = spawnSync('powershell', ['-NoProfile', '-Command', psScript], { - input: token, - stdio: ['pipe', 'pipe', 'pipe'], - }) - if (ps.status === 0) { - return - } - // Fallback: DPAPI-encrypted file. Used when the CredentialManager - // module isn't installed (common on bare Windows; `Install-Module - // CredentialManager` requires admin or a user-scope install). The - // file is written once on the canonical slot's pass; the legacy - // slot's pass also calls this but writeWindowsDpapiFile rewrites - // the same file with the same value, so the second call is a no-op - // in effect. - writeWindowsDpapiFile(token) -} - -export function writeWindowsDpapiFile(token: string): void { - const filePath = getWindowsDpapiFilePath() - const dir = path.dirname(filePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const psScript = ` - $token = $input | Out-String - $token = $token.Trim() - $bytes = [System.Text.Encoding]::UTF8.GetBytes($token) - $protected = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser') - [Convert]::ToBase64String($protected) | Set-Content -Path '${filePath.replace(/'/g, "''")}' -NoNewline - ` - const ps = spawnSync('powershell', ['-NoProfile', '-Command', psScript], { - input: token, - stdio: ['pipe', 'pipe', 'pipe'], - }) - if (ps.status !== 0) { - throw new Error( - `DPAPI file write failed: ${String(ps.stderr).trim()}. ` + - 'Install the CredentialManager PowerShell module (' + - '`Install-Module CredentialManager -Scope CurrentUser`) for a cleaner storage path.', - ) - } - // chmod-equivalent: NTFS ACLs default to user-only for AppData files - // created this way, so no extra step needed. -} - -// Hide unused-import lint when readFileSync / writeFileSync aren't -// used (Windows-only fallback path). Reference them once at module -// scope so the bundler still tree-shakes correctly on non-Windows. -void readFileSync -void writeFileSync diff --git a/.claude/hooks/setup-security-tools/package-lock.json b/.claude/hooks/setup-security-tools/package-lock.json deleted file mode 100644 index 98cd9b7ec..000000000 --- a/.claude/hooks/setup-security-tools/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@socketsecurity/hook-setup-security-tools", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@socketsecurity/hook-setup-security-tools", - "dependencies": { - "@socketsecurity/lib-stable": "5.18.2" - } - }, - "node_modules/@socketsecurity/lib-stable": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/@socketsecurity/lib-stable/-/lib-5.18.2.tgz", - "integrity": "sha512-h6aGfphQ9jdVjUMGIKJcsIvT6BmzBo0OD20HzeK+6KQJi2HupfCUzIH26vDPxf+aYVmrX0/hKJDYI5sXfTGx9A==", - "license": "MIT", - "engines": { - "node": ">=22", - "pnpm": ">=11.0.0-rc.0" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - } - } -} diff --git a/.claude/hooks/setup-security-tools/package.json b/.claude/hooks/setup-security-tools/package.json deleted file mode 100644 index 5f083c9f6..000000000 --- a/.claude/hooks/setup-security-tools/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "hook-setup-security-tools", - "private": true, - "type": "module", - "main": "./index.mts", - "dependencies": { - "@sinclair/typebox": "catalog:", - "@socketregistry/packageurl-js-stable": "catalog:", - "@socketsecurity/lib-stable": "catalog:" - } -} diff --git a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts b/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts deleted file mode 100644 index e235d973b..000000000 --- a/.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import os from 'node:os' -import path from 'node:path' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const SCRIPT = path.resolve(__dirname, '..', 'index.mts') - -// setup-security-tools is a setup script, not a Claude Code hook — -// it doesn't read stdin, doesn't have a tool_input contract, and the -// `main()` body downloads binaries on every invocation. The -// meaningful test surface is "the script parses without syntax -// errors" — full integration coverage lives in -// .github/workflows/setup-security-tools.yml, where the script -// actually runs against the network. - -test('parses without syntax errors (node --check)', async () => { - const code = await new Promise<number>((resolve, reject) => { - const child = spawn(process.execPath, ['--check', SCRIPT], { - stdio: ['ignore', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', c => { - if (c !== 0) { - reject(new Error(`node --check exited ${c}; stderr=${stderr}`)) - return - } - resolve(c ?? -1) - }) - }) - assert.equal(code, 0) -}) - -test('module imports without throwing (does NOT invoke main)', async () => { - // The script auto-runs `main()` at module load, so we can't just - // `import(SCRIPT)`. Instead, spawn a child node process that - // imports the module under a `DRY_RUN=1` guard… but the script - // doesn't honor such a guard. Document the gap here and leave the - // syntax check above as the primary surface — full coverage - // requires either (a) refactoring index.mts to export main() and - // gate the auto-invocation behind `import.meta.main`, or (b) a - // mock harness that traps the lib imports. Both are scope-creep - // for this baseline test. - // - // Once the module is refactored to gate auto-invocation, replace - // this test with a real import + export-shape assertion. - assert.ok(true, 'placeholder — see comment above') -}) - -test('surfaces token-401 finding when transcript contains the Socket API 401 error', async () => { - const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs') - const dir = mkdtempSync(path.join(os.tmpdir(), 'setup-security-tools-test-')) - try { - const transcriptPath = path.join(dir, 'transcript.jsonl') - // Synthetic Claude Code transcript: a single assistant turn - // whose tool_use output carries the canonical 401 error string. - const assistantTurn = { - type: 'assistant', - message: { - content: [ - { - type: 'text', - text: - 'I tried to run sfw and got:\n\nConfiguration Error\n ' + - '- SOCKET_API_KEY validation got status of 401 from the ' + - 'Socket API, please ensure the key is valid and has the ' + - 'correct permissions.', - }, - ], - }, - } - writeFileSync(transcriptPath, JSON.stringify(assistantTurn) + '\n') - const stopPayload = JSON.stringify({ transcript_path: transcriptPath }) - - const { code, stderr } = await new Promise<{ - code: number - stderr: string - }>((resolve, reject) => { - const child = spawn(process.execPath, [SCRIPT], { - stdio: ['pipe', 'ignore', 'pipe'], - // The hook's other checks (broken shims, edition mismatch) - // need $HOME to fire; the 401 check only needs the transcript - // path, so a missing HOME just keeps those checks quiet — - // exactly what we want for an isolated 401-detection test. - env: { ...process.env, HOME: '' }, - }) - let stderrChunks = '' - child.process.stderr!.on('data', d => { - stderrChunks += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', c => - resolve({ code: c ?? -1, stderr: stderrChunks }), - ) - child.stdin!.write(stopPayload) - child.stdin!.end() - }) - - assert.equal(code, 0, `hook should exit 0, got ${code}; stderr=${stderr}`) - assert.match(stderr, /token.*401|--rotate/i) - assert.match(stderr, /install\.mts --rotate/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) - -test('stays quiet when the transcript has no 401 error', async () => { - const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs') - const dir = mkdtempSync(path.join(os.tmpdir(), 'setup-security-tools-test-')) - try { - const transcriptPath = path.join(dir, 'transcript.jsonl') - const assistantTurn = { - type: 'assistant', - message: { - content: [{ type: 'text', text: 'Nothing of interest here.' }], - }, - } - writeFileSync(transcriptPath, JSON.stringify(assistantTurn) + '\n') - const stopPayload = JSON.stringify({ transcript_path: transcriptPath }) - - const { stderr } = await new Promise<{ stderr: string }>( - (resolve, reject) => { - const child = spawn(process.execPath, [SCRIPT], { - stdio: ['pipe', 'ignore', 'pipe'], - env: { ...process.env, HOME: '' }, - }) - let stderrChunks = '' - child.process.stderr!.on('data', d => { - stderrChunks += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', () => resolve({ stderr: stderrChunks })) - child.stdin!.write(stopPayload) - child.stdin!.end() - }, - ) - - // No 401 line means no finding from checkToken401. Other checks - // are gated on HOME (cleared above) so they stay quiet too. - assert.doesNotMatch(stderr, /token.*401|--rotate/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts b/.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts deleted file mode 100644 index 5e90fead3..000000000 --- a/.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @file Tests for the shell-rc env-var block writer. Drives - * installShellRcBridge / uninstallShellRcBridge against a temp HOME so the - * real `~/.zshenv` never gets touched. macOS-only (matches the implementation - * gate); on non-macOS hosts the functions return `undefined` / `false` and - * the assertions skip the rewrite-shape checks. - */ - -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -const IS_MACOS = os.platform() === 'darwin' - -const FAKE_TOKEN = 'sk-test-aaaabbbbccccddddeeeeffff' - -function withFakeHome( - fn: (rcPath: string) => Promise<void> | void, -): () => Promise<void> { - return async () => { - const fake = mkdtempSync(path.join(os.tmpdir(), 'shell-rc-bridge-test-')) - const prevHome = process.env['HOME'] - const prevShell = process.env['SHELL'] - process.env['HOME'] = fake - process.env['SHELL'] = '/bin/zsh' - try { - // zsh target is .zshenv. - const rcPath = path.join(fake, '.zshenv') - await fn(rcPath) - } finally { - if (prevHome === undefined) { - delete process.env['HOME'] - } else { - process.env['HOME'] = prevHome - } - if (prevShell === undefined) { - delete process.env['SHELL'] - } else { - process.env['SHELL'] = prevShell - } - rmSync(fake, { recursive: true, force: true }) - } - } -} - -test( - 'installShellRcBridge inserts the block with a literal token export', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '# existing\nexport PATH=$PATH:/foo\n') - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - const r = installShellRcBridge(FAKE_TOKEN) - assert.ok(r) - assert.equal(r.outcome, 'inserted') - const content = readFileSync(rcPath, 'utf8') - assert.match(content, /BEGIN socket-cli env/) - assert.match(content, /END socket-cli env/) - // Token literal exported as the primary universally-supported var. - assert.match(content, new RegExp(`export SOCKET_API_KEY='${FAKE_TOKEN}'`)) - // The forward-canonical name is NOT exported — every Socket tool reads - // SOCKET_API_KEY directly, so one export covers the whole surface. - assert.doesNotMatch(content, /export SOCKET_API_TOKEN=/) - // NO live keychain CALL — `security find-generic-password` may - // appear in a `#` doc comment that points the user at the - // canonical store, but it must NOT be inside a `$(...)` or - // backtick command substitution that would actually run on - // every shell startup. - assert.doesNotMatch(content, /\$\([^)]*security find-generic-password/) - assert.doesNotMatch(content, /`[^`]*security find-generic-password/) - // Preserves existing content. - assert.match(content, /existing/) - assert.match(content, /export PATH/) - }), -) - -test( - 'second run with same token returns outcome=unchanged', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '') - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - installShellRcBridge(FAKE_TOKEN) - const r = installShellRcBridge(FAKE_TOKEN) - assert.ok(r) - assert.equal(r.outcome, 'unchanged') - }), -) - -test( - 'second run with a different token rewrites the block (rotation)', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '') - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - installShellRcBridge(FAKE_TOKEN) - const rotated = `${FAKE_TOKEN}-rotated` - const r = installShellRcBridge(rotated) - assert.ok(r) - assert.equal(r.outcome, 'updated') - const content = readFileSync(rcPath, 'utf8') - // Only one block. - const beginCount = (content.match(/BEGIN socket-cli env/g) || []).length - assert.equal(beginCount, 1) - // New token is present; old is gone. - assert.match(content, new RegExp(`export SOCKET_API_KEY='${rotated}'`)) - assert.doesNotMatch( - content, - new RegExp(`export SOCKET_API_KEY='${FAKE_TOKEN}'(?!-rotated)`), - ) - }), -) - -test( - 'tampered block body is rewritten in place (no duplicate append)', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '') - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - installShellRcBridge(FAKE_TOKEN) - const tampered = readFileSync(rcPath, 'utf8').replace( - `export SOCKET_API_KEY='${FAKE_TOKEN}'`, - "export SOCKET_API_KEY='junk'", - ) - writeFileSync(rcPath, tampered) - const r = installShellRcBridge(FAKE_TOKEN) - assert.ok(r) - assert.equal(r.outcome, 'updated') - const content = readFileSync(rcPath, 'utf8') - const beginCount = (content.match(/BEGIN socket-cli env/g) || []).length - assert.equal(beginCount, 1) - assert.match(content, new RegExp(`export SOCKET_API_KEY='${FAKE_TOKEN}'`)) - assert.doesNotMatch(content, /export SOCKET_API_KEY='junk'/) - }), -) - -test( - 'tokens with single quotes are escaped safely', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '') - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - // Hypothetical token with a single quote in it. Not a real shape, - // but the escape logic should survive any byte sequence. - const weird = "sk-test-with'quote" - installShellRcBridge(weird) - const content = readFileSync(rcPath, 'utf8') - // Single-quote-close, escaped-quote, single-quote-reopen. - assert.match(content, /export SOCKET_API_KEY='sk-test-with'\\''quote'/) - }), -) - -test( - 'rejects empty / non-string token', - withFakeHome(async () => { - if (!IS_MACOS) { - return - } - const { installShellRcBridge } = await import('../lib/shell-rc-bridge.mts') - assert.throws(() => installShellRcBridge(''), /non-empty string/) - assert.throws( - // @ts-expect-error: deliberately wrong type - () => installShellRcBridge(undefined), - /non-empty string/, - ) - }), -) - -test( - 'uninstallShellRcBridge removes the block and preserves surrounding content', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '# before\nexport PATH=$PATH:/foo\n') - const { installShellRcBridge, uninstallShellRcBridge } = - await import('../lib/shell-rc-bridge.mts') - installShellRcBridge(FAKE_TOKEN) - const removed = uninstallShellRcBridge() - assert.equal(removed, true) - const content = readFileSync(rcPath, 'utf8') - assert.doesNotMatch(content, /BEGIN socket-cli env/) - assert.match(content, /# before/) - assert.match(content, /export PATH/) - }), -) - -test( - 'uninstallShellRcBridge returns false when no block is present', - withFakeHome(async rcPath => { - if (!IS_MACOS) { - return - } - writeFileSync(rcPath, '# nothing here\n') - const { uninstallShellRcBridge } = - await import('../lib/shell-rc-bridge.mts') - assert.equal(uninstallShellRcBridge(), false) - assert.ok(existsSync(rcPath)) - }), -) diff --git a/.claude/hooks/setup-security-tools/tsconfig.json b/.claude/hooks/setup-security-tools/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-security-tools/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/setup-security-tools/update.mts b/.claude/hooks/setup-security-tools/update.mts deleted file mode 100644 index f327facbc..000000000 --- a/.claude/hooks/setup-security-tools/update.mts +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env node -// Update script for Socket security tools. -// -// Checks for new releases of zizmor and sfw, respecting the pnpm -// minimumReleaseAge cooldown (read from pnpm-workspace.yaml) for third-party tools. -// Socket-owned tools (sfw) are excluded from cooldown. -// -// Updates external-tools.json when new versions or checksums are found. - -import { createHash } from 'node:crypto' -import { existsSync, readFileSync, promises as fs } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - httpDownload, - httpRequest, -} from '@socketsecurity/lib-stable/http-request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CONFIG_FILE = path.join(__dirname, 'external-tools.json') - -const MS_PER_MINUTE = 60_000 -const DEFAULT_COOLDOWN_MINUTES = 10_080 - -// Read minimumReleaseAge from pnpm-workspace.yaml (minutes → ms). -function readCooldownMs(): number { - let dir = __dirname - for (let i = 0; i < 10; i += 1) { - const candidate = path.join(dir, 'pnpm-workspace.yaml') - if (existsSync(candidate)) { - try { - const content = readFileSync(candidate, 'utf8') - const match = /^minimumReleaseAge:\s*(\d+)/m.exec(content) - if (match) return Number(match[1]) * MS_PER_MINUTE - } catch { - // Read error. - } - logger.warn( - `Could not read minimumReleaseAge from ${candidate}, defaulting to ${DEFAULT_COOLDOWN_MINUTES} minutes`, - ) - return DEFAULT_COOLDOWN_MINUTES * MS_PER_MINUTE - } - const parent = path.dirname(dir) - if (parent === dir) break - dir = parent - } - logger.warn( - `pnpm-workspace.yaml not found, defaulting cooldown to ${DEFAULT_COOLDOWN_MINUTES} minutes`, - ) - return DEFAULT_COOLDOWN_MINUTES * MS_PER_MINUTE -} - -const COOLDOWN_MS = readCooldownMs() - -// ── GitHub API helpers ── - -interface GhRelease { - assets: GhAsset[] - published_at: string - tag_name: string -} - -interface GhAsset { - browser_download_url: string - name: string -} - -async function ghApiLatestRelease(repo: string): Promise<GhRelease> { - const result = await spawn( - 'gh', - ['api', `repos/${repo}/releases/latest`, '--cache', '1h'], - { stdio: 'pipe' }, - ) - const stdout = - typeof result.stdout === 'string' ? result.stdout : result.stdout.toString() - return JSON.parse(stdout) as GhRelease -} - -function isOlderThanCooldown(publishedAt: string): boolean { - const published = new Date(publishedAt).getTime() - return Date.now() - published >= COOLDOWN_MS -} - -function versionFromTag(tag: string): string { - return tag.replace(/^v/, '') -} - -// ── Config file I/O ── - -interface ToolConfig { - description?: string - version: string - repository?: string - assets?: Record<string, string> - platforms?: Record<string, string> - checksums?: Record<string, string> - ecosystems?: string[] -} - -interface Config { - description?: string - tools: Record<string, ToolConfig> -} - -function readConfig(): Config { - return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) as Config -} - -async function writeConfig(config: Config): Promise<void> { - await fs.writeFile( - CONFIG_FILE, - JSON.stringify(config, undefined, 2) + '\n', - 'utf8', - ) -} - -// ── Checksum computation ── - -async function computeSha256(filePath: string): Promise<string> { - const content = await fs.readFile(filePath) - return createHash('sha256').update(content).digest('hex') -} - -async function downloadAndHash(url: string): Promise<string> { - const tmpFile = path.join( - tmpdir(), - `security-tools-update-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ) - try { - await httpDownload(url, tmpFile, { retries: 2 }) - return await computeSha256(tmpFile) - } finally { - await fs.unlink(tmpFile).catch(() => {}) - } -} - -// ── Zizmor update ── - -interface UpdateResult { - reason: string - skipped: boolean - tool: string - updated: boolean -} - -async function updateZizmor(config: Config): Promise<UpdateResult> { - const tool = 'zizmor' - logger.log(`=== Checking ${tool} ===`) - - const toolConfig = config.tools[tool] - if (!toolConfig) { - return { tool, skipped: true, updated: false, reason: 'not in config' } - } - - const repo = - toolConfig.repository?.replace(/^[^:]+:/, '') ?? 'zizmorcore/zizmor' - - let release: GhRelease - try { - release = await ghApiLatestRelease(repo) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`Failed to fetch zizmor releases: ${msg}`) - return { tool, skipped: true, updated: false, reason: `API error: ${msg}` } - } - - const latestVersion = versionFromTag(release.tag_name) - const currentVersion = toolConfig.version - - logger.log(`Current: v${currentVersion}, Latest: v${latestVersion}`) - - if (latestVersion === currentVersion) { - logger.log('Already current.') - return { tool, skipped: false, updated: false, reason: 'already current' } - } - - // Respect cooldown for third-party tools. - if (!isOlderThanCooldown(release.published_at)) { - const daysOld = ( - (Date.now() - new Date(release.published_at).getTime()) / - 86_400_000 - ).toFixed(1) - const cooldownDays = (COOLDOWN_MS / 86_400_000).toFixed(0) - logger.log( - `v${latestVersion} is only ${daysOld} days old (need ${cooldownDays}). Skipping.`, - ) - return { - tool, - skipped: true, - updated: false, - reason: `too new (${daysOld} days, need ${cooldownDays})`, - } - } - - logger.log(`Updating to v${latestVersion}...`) - - // Try to get checksums from the release's checksums.txt asset first. - let checksumMap: Record<string, string> | undefined - const checksumsAsset = release.assets.find(a => a.name === 'checksums.txt') - if (checksumsAsset) { - try { - const resp = await httpRequest(checksumsAsset.browser_download_url) - if (resp.ok) { - checksumMap = { __proto__: null } as unknown as Record<string, string> - for (const line of resp.text().split('\n')) { - const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim()) - if (match) { - checksumMap[match[2]!] = match[1]! - } - } - } - } catch { - // Fall through to per-asset download. - } - } - - // Compute checksums for each asset in the config. - const currentChecksums = toolConfig.checksums ?? {} - const newChecksums: Record<string, string> = { - __proto__: null, - } as unknown as Record<string, string> - let allFound = true - - for (const assetName of Object.keys(currentChecksums)) { - let newHash: string | undefined - - // Try checksums.txt first. - if (checksumMap?.[assetName]) { - newHash = checksumMap[assetName] - } else { - // Download and compute. - const asset = release.assets.find(a => a.name === assetName) - if (!asset) { - logger.warn(` Asset not found in release: ${assetName}`) - allFound = false - continue - } - logger.log(` Computing checksum for ${assetName}...`) - try { - newHash = await downloadAndHash(asset.browser_download_url) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(` Failed to download ${assetName}: ${msg}`) - allFound = false - continue - } - } - - if (!newHash) { - allFound = false - continue - } - - newChecksums[assetName] = newHash - const oldHash = currentChecksums[assetName] - if (oldHash && oldHash !== newHash) { - logger.log( - ` ${assetName}: ${oldHash.slice(0, 12)}... -> ${newHash.slice(0, 12)}...`, - ) - } else if (oldHash === newHash) { - logger.log(` ${assetName}: unchanged`) - } - } - - if (!allFound) { - logger.warn('Some assets could not be verified. Skipping version bump.') - return { - tool, - skipped: true, - updated: false, - reason: 'incomplete asset checksums', - } - } - - // Update config. - toolConfig.version = latestVersion - toolConfig.checksums = newChecksums - logger.log(`Updated zizmor: ${currentVersion} -> ${latestVersion}`) - - return { - tool, - skipped: false, - updated: true, - reason: `${currentVersion} -> ${latestVersion}`, - } -} - -// ── SFW update ── - -async function updateSfwTool( - config: Config, - toolName: string, -): Promise<UpdateResult> { - const toolConfig = config.tools[toolName] - if (!toolConfig) { - return { - tool: toolName, - skipped: true, - updated: false, - reason: 'not in config', - } - } - - const repo = toolConfig.repository?.replace(/^[^:]+:/, '') - if (!repo) { - return { - tool: toolName, - skipped: true, - updated: false, - reason: 'no repository', - } - } - - let release: GhRelease - try { - release = await ghApiLatestRelease(repo) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(`Failed to fetch ${toolName} releases: ${msg}`) - return { - tool: toolName, - skipped: true, - updated: false, - reason: `API error: ${msg}`, - } - } - - logger.log( - ` ${toolName}: latest ${release.tag_name} (published ${release.published_at.slice(0, 10)})`, - ) - - const currentChecksums = toolConfig.checksums ?? {} - const platforms = toolConfig.platforms ?? {} - const prefix = toolName === 'sfw-enterprise' ? 'sfw' : 'sfw-free' - const newChecksums: Record<string, string> = { - __proto__: null, - } as unknown as Record<string, string> - let changed = false - let allFound = true - - for (const { 0: _, 1: sfwPlatform } of Object.entries(platforms)) { - const suffix = sfwPlatform.startsWith('windows') ? '.exe' : '' - const assetName = `${prefix}-${sfwPlatform}${suffix}` - const asset = release.assets.find(a => a.name === assetName) - const url = asset - ? asset.browser_download_url - : `https://github.com/${repo}/releases/download/${release.tag_name}/${assetName}` - logger.log(` Computing checksum for ${assetName}...`) - try { - const hash = await downloadAndHash(url) - newChecksums[sfwPlatform] = hash - if (currentChecksums[sfwPlatform] !== hash) { - logger.log( - ` ${sfwPlatform}: ${(currentChecksums[sfwPlatform] ?? '').slice(0, 12)}... -> ${hash.slice(0, 12)}...`, - ) - changed = true - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - logger.warn(` Failed to download ${assetName}: ${msg}`) - allFound = false - } - } - - if (!allFound) { - logger.warn( - ` Some ${toolName} assets could not be downloaded. Skipping update.`, - ) - return { - tool: toolName, - skipped: true, - updated: false, - reason: 'incomplete downloads', - } - } - - if (changed) { - toolConfig.version = release.tag_name - toolConfig.checksums = newChecksums - return { - tool: toolName, - skipped: false, - updated: true, - reason: 'checksums updated', - } - } - - return { - tool: toolName, - skipped: false, - updated: false, - reason: 'already current', - } -} - -async function updateSfw(config: Config): Promise<UpdateResult[]> { - logger.log('=== Checking SFW ===') - logger.log('Socket-owned tool: cooldown excluded.') - - const results: UpdateResult[] = [] - - logger.log('') - results.push(await updateSfwTool(config, 'sfw-free')) - - logger.log('') - results.push(await updateSfwTool(config, 'sfw-enterprise')) - - return results -} - -// ── Main ── - -async function main(): Promise<void> { - logger.log('Checking for security tool updates...\n') - - const config = readConfig() - const allResults: UpdateResult[] = [] - - // 1. Check zizmor (third-party, respects cooldown). - allResults.push(await updateZizmor(config)) - logger.log('') - - // 2. Check sfw (Socket-owned, no cooldown). - const sfwResults = await updateSfw(config) - allResults.push(...sfwResults) - logger.log('') - - // Write updated config if anything changed. - const anyUpdated = allResults.some(r => r.updated) - if (anyUpdated) { - await writeConfig(config) - logger.log('Updated external-tools.json.\n') - } - - // Report. - logger.log('=== Summary ===') - for (const r of allResults) { - const status = r.updated ? 'UPDATED' : r.skipped ? 'SKIPPED' : 'CURRENT' - logger.log(` ${r.tool}: ${status} (${r.reason})`) - } - - if (!anyUpdated) { - logger.log('\nNo updates needed.') - } -} - -main().catch((e: unknown) => { - logger.error(e instanceof Error ? e.message : String(e)) - process.exitCode = 1 -}) diff --git a/.claude/hooks/setup-signing/README.md b/.claude/hooks/setup-signing/README.md deleted file mode 100644 index 7645fd1c9..000000000 --- a/.claude/hooks/setup-signing/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# setup-signing - -Install-only helper that configures git commit signing. Paired with -the pre-commit signing-config gate and pre-push signed-commits -enforcement — those hooks REQUIRE signing; this helper makes the -one-time setup mechanical. - -## Usage - -```sh -node .claude/hooks/setup-signing/install.mts # detect + configure -node .claude/hooks/setup-signing/install.mts --check # report status; exit 0 if configured, 1 if not -node .claude/hooks/setup-signing/install.mts --force # overwrite existing config -``` - -## Detection order - -The helper picks the FIRST available signing method in this order: - -1. **1Password SSH agent** — checks the agent socket and queries - `ssh-add -L`. Recommended path: keys never touch disk, biometric - unlock on use. -2. **SSH key on disk** — `~/.ssh/id_ed25519.pub` (preferred), then - `id_ecdsa.pub`, then `id_rsa.pub`. Sets `user.signingkey` to the - `.pub` path (git's documented convention for SSH signing). -3. **GPG secret key** — `gpg --list-secret-keys --with-colons` first - `sec:` entry. Sets `user.signingkey` to the long key ID and - `gpg.format=openpgp`. - -If none of these are detected, the helper prints setup instructions -for each path and exits 1. - -## What it sets - -For SSH: - -``` -git config --global commit.gpgsign true -git config --global user.signingkey <pub-key-or-path> -git config --global gpg.format ssh -# If 1Password path on macOS: -git config --global gpg.ssh.program /Applications/1Password.app/Contents/MacOS/op-ssh-sign -``` - -For GPG: - -``` -git config --global commit.gpgsign true -git config --global user.signingkey <KEYID> -git config --global gpg.format openpgp -``` - -## What it does NOT do - -- **Never generates keys.** Key creation is the user's call. -- **Never uploads keys to GitHub.** The user uploads the public key as - a Signing Key at https://github.com/settings/keys to get the - "Verified" badge on commits. -- **Never disables an existing config.** Without `--force`, the - helper exits early if signing is already configured. diff --git a/.claude/hooks/setup-signing/install.mts b/.claude/hooks/setup-signing/install.mts deleted file mode 100644 index 1ac7f83d6..000000000 --- a/.claude/hooks/setup-signing/install.mts +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install-only entry point for commit-signing setup. Detects which - * signing method is locally available (SSH keys via 1Password / agent / - * ~/.ssh, GPG via gpg-agent, plain GPG key), and walks the user through `git - * config user.signingkey` + `git config commit.gpgsign true` + `git config - * gpg.format` (ssh|openpgp). Paired with the pre-commit signing-config gate - * and the pre-push signed-commits enforcement. Without signing set up, those - * hooks block commits / pushes; this helper makes the one-time setup - * mechanical. Usage: node .claude/hooks/setup-signing/install.mts node - * .claude/hooks/setup-signing/install.mts --check # report only node - * .claude/hooks/setup-signing/install.mts --force # overwrite existing config - * Auto-detection order (first hit wins): - * - * 1. 1Password SSH agent (SOCK at ~/Library/Group Containers/.../agent.sock). If - * present + has keys, recommend SSH signing routed through 1Password. - * Pros: keys never touch disk; biometric unlock on use. - * 2. ssh-agent or running gpg-agent with loaded keys. SSH preferred over GPG - * when both exist (simpler keyring, no expiry headaches). - * 3. ~/.ssh/id_ed25519.pub (or id_rsa.pub) on disk. Recommend SSH signing using - * that key. - * 4. `gpg --list-secret-keys` produces output. Recommend GPG signing with the - * first secret key. - * 5. Nothing found. Print the setup choices and exit. The helper NEVER generates - * new keys. Key creation is the user's call — the helper only configures - * git to USE keys the user already has. - */ - -import { existsSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -interface CliArgs { - check: boolean - force: boolean -} - -function parseArgs(argv: readonly string[]): CliArgs { - return { - check: argv.includes('--check'), - force: argv.includes('--force'), - } -} - -type SigningFormat = 'ssh' | 'openpgp' - -interface CurrentConfig { - gpgsign: string - signingkey: string - format: string -} - -function readCurrentConfig(): CurrentConfig { - const get = (key: string): string => { - const r = spawnSync('git', ['config', '--global', '--get', key], { - stdio: 'pipe', - stdioString: true, - }) - return r.status === 0 ? String(r.stdout ?? '').trim() : '' - } - return { - gpgsign: get('commit.gpgsign'), - signingkey: get('user.signingkey'), - format: get('gpg.format') || 'openpgp', // git's default - } -} - -interface DetectedSigner { - format: SigningFormat - // The literal `user.signingkey` value to set. - key: string - // Human-readable origin (1Password, ssh-agent, ~/.ssh/id_ed25519.pub, gpg). - source: string -} - -function detect1PasswordSshAgent(): DetectedSigner | undefined { - // macOS: ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock - // Linux: ~/.1password/agent.sock - // Windows: \\\\.\\pipe\\openssh-ssh-agent (different mechanism, skip detection) - let sock: string | undefined - if (os.platform() === 'darwin') { - sock = path.join( - os.homedir(), - 'Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock', - ) - } else if (os.platform() === 'linux') { - sock = path.join(os.homedir(), '.1password/agent.sock') - } - if (!sock || !existsSync(sock)) { - return undefined - } - // Ask the agent what keys it has. SSH_AUTH_SOCK pointed at 1Password's sock. - const r = spawnSync('ssh-add', ['-L'], { - stdio: 'pipe', - stdioString: true, - env: { ...process.env, SSH_AUTH_SOCK: sock }, - timeout: 5_000, - }) - if (r.status !== 0) { - return undefined - } - // First public-key line is the one to use. - const line = String(r.stdout ?? '') - .split('\n') - .find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-')) - if (!line) { - return undefined - } - return { - format: 'ssh', - // For SSH signing, user.signingkey is the public key string itself - // (or a path to a .pub file). Inline is simpler. - key: line.trim(), - source: '1Password SSH agent', - } -} - -function detectSshKeyOnDisk(): DetectedSigner | undefined { - // Prefer ed25519 over rsa. - const candidates = ['id_ed25519.pub', 'id_ecdsa.pub', 'id_rsa.pub'] - for (let i = 0, { length } = candidates; i < length; i += 1) { - const name = candidates[i]! - const p = path.join(os.homedir(), '.ssh', name) - if (existsSync(p)) { - return { - format: 'ssh', - // Pointing user.signingkey at the .pub file is the documented git - // convention for SSH signing (git reads the public key from the - // file at sign time). - key: p, - source: `~/.ssh/${name}`, - } - } - } - return undefined -} - -function detectGpgKey(): DetectedSigner | undefined { - const r = spawnSync( - 'gpg', - ['--list-secret-keys', '--keyid-format=long', '--with-colons'], - { - stdio: 'pipe', - stdioString: true, - timeout: 5_000, - }, - ) - if (r.status !== 0) { - return undefined - } - // Parse `--with-colons` machine output. Lines starting with "sec:" are - // secret keys; field 5 is the keygrip / long ID. - const lines = String(r.stdout ?? '').split('\n') - for (const line of lines) { - if (line.startsWith('sec:')) { - const fields = line.split(':') - const keyId = fields[4] - if (keyId) { - return { format: 'openpgp', key: keyId, source: 'gpg secret key' } - } - } - } - return undefined -} - -function detectSigner(): DetectedSigner | undefined { - return detect1PasswordSshAgent() ?? detectSshKeyOnDisk() ?? detectGpgKey() -} - -function configure(signer: DetectedSigner): void { - const set = (key: string, value: string): void => { - spawnSync('git', ['config', '--global', key, value], { stdio: 'inherit' }) - } - set('commit.gpgsign', 'true') - set('user.signingkey', signer.key) - set('gpg.format', signer.format) - if (signer.format === 'ssh' && signer.source === '1Password SSH agent') { - // SSH signing additionally needs a program that can verify signatures - // (op-ssh-sign for 1Password). git uses gpg.ssh.program for signing - // operations. - if (os.platform() === 'darwin') { - const opSign = '/Applications/1Password.app/Contents/MacOS/op-ssh-sign' - if (existsSync(opSign)) { - set('gpg.ssh.program', opSign) - } - } - } -} - -function reportConfig(c: CurrentConfig): void { - logger.log(` commit.gpgsign: ${c.gpgsign || '(unset)'}`) - logger.log(` user.signingkey: ${c.signingkey || '(unset)'}`) - logger.log(` gpg.format: ${c.format}`) -} - -function reportManualSteps(): void { - logger.log('No usable signing key detected. Choose one:') - logger.log('') - logger.log('Option A — 1Password SSH signing (recommended)') - logger.log(' 1. Open 1Password → Settings → Developer → enable SSH agent') - logger.log( - ' 2. Add SOCK to your shell: export SSH_AUTH_SOCK=~/Library/Group\\ Containers/2BUA8C4S2C.com.1password/t/agent.sock', - ) - logger.log( - ' 3. Create or import an SSH key in 1Password → run this helper again', - ) - logger.log('') - logger.log('Option B — Existing SSH key on disk') - logger.log(' 1. Confirm ~/.ssh/id_ed25519.pub exists') - logger.log(' 2. Run this helper again') - logger.log('') - logger.log('Option C — GPG') - logger.log( - ' 1. Generate: gpg --full-generate-key (RSA 4096 or Ed25519, no expiry preferred for personal use)', - ) - logger.log(' 2. Upload public key to GitHub → Settings → SSH and GPG keys') - logger.log(' 3. Run this helper again') - logger.log('') - logger.log('GitHub-side note: upload the corresponding PUBLIC key as a') - logger.log( - 'Signing Key at https://github.com/settings/keys for "Verified" badges', - ) - logger.log('on web-rendered commits.') -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - logger.log('Commit signing — install / verify') - logger.log('') - - const before = readCurrentConfig() - logger.log('Current git config:') - reportConfig(before) - logger.log('') - - const alreadyConfigured = - before.gpgsign.toLowerCase() === 'true' && Boolean(before.signingkey) - if (alreadyConfigured && !args.force) { - logger.log( - 'Signing is already configured. Pass --force to re-detect and overwrite.', - ) - if (args.check) { - process.exit(0) - } - process.exit(0) - } - - if (args.check) { - logger.log('Signing is NOT configured (or partial).') - process.exit(1) - } - - const signer = detectSigner() - if (!signer) { - reportManualSteps() - process.exit(1) - } - - logger.log(`Detected signer: ${signer.source} (${signer.format})`) - logger.log(`Setting user.signingkey to:`) - logger.log(` ${signer.key}`) - logger.log('') - configure(signer) - - const after = readCurrentConfig() - logger.log('Updated git config:') - reportConfig(after) - logger.log('') - logger.log( - 'Done. The next commit will be signed automatically. Pre-commit and', - ) - logger.log('pre-push gates will accept it.') - logger.log('') - logger.log('GitHub-side: upload the public key as a Signing Key at') - logger.log(' https://github.com/settings/keys') - logger.log('so commits show as "Verified" in the GitHub UI.') -} - -main().catch(err => { - logger.error(String(err?.message ?? err)) - process.exit(1) -}) diff --git a/.claude/hooks/setup-signing/package.json b/.claude/hooks/setup-signing/package.json deleted file mode 100644 index 256f60e89..000000000 --- a/.claude/hooks/setup-signing/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-setup-signing", - "private": true, - "type": "module", - "main": "./install.mts", - "exports": { - ".": "./install.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/setup-signing/tsconfig.json b/.claude/hooks/setup-signing/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/setup-signing/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/README.md b/.claude/hooks/soak-exclude-date-annotation-guard/README.md deleted file mode 100644 index 71afb4343..000000000 --- a/.claude/hooks/soak-exclude-date-annotation-guard/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# soak-exclude-date-annotation-guard - -A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls -which would land a per-package `minimumReleaseAgeExclude` entry in -`pnpm-workspace.yaml` without the canonical -`# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation directly -above the bullet. - -## Why this rule - -Soak-bypass entries are temporary by design — they exist because a -fresh release was needed faster than the 7-day soak window allows. -Without a documented removable-on date, entries accumulate and -nobody knows when they can safely be removed. The standard -annotation lets a periodic sweep (`grep -E 'removable: 2026-04' -pnpm-workspace.yaml`) find candidates whose natural soak has long -since cleared. - -## Conventional shape - -```yaml -minimumReleaseAgeExclude: - # vite 8.0.13 ships rolldown natively (no esbuild transitive). ... - # published: 2026-05-14 | removable: 2026-05-21 - - 'vite@8.0.13' -``` - -The annotation must be the **last comment line** above the bullet — -contiguous, no blank line between them. `published` is the version's -npm publish date (`npm view pkg@1.2.3 time` → look up the version-row -date). `removable` is `published + 7d`, the natural soak-clear date. - -## What's enforced - -- Every ` - 'pkg@1.2.3'` bullet inside the `minimumReleaseAgeExclude:` - block must be preceded by a comment line matching: - ``` - # published: YYYY-MM-DD | removable: YYYY-MM-DD - ``` -- The annotation must be the **immediately-preceding** line (last - `#` line above the bullet). - -## What's exempt - -- **Scope-glob entries** (`'@socketsecurity/*'`, `'@socketregistry/*'`, - etc.) — persistent fleet policy, not a time-bound bypass. -- **Bare-name entries** without `@version` (also persistent). -- Lines marked `# socket-hook: allow soak-exclude-no-date-annotation`. - -## Override marker - -For a legitimate one-off where the annotation truly doesn't apply: - -```yaml -- 'pkg@1.2.3' # socket-hook: allow soak-exclude-no-date-annotation -``` - -Don't reach for this — add the annotation instead. - -## Bypass phrase - -If the user genuinely needs to bypass the whole hook for one session, -they must type `Allow soak-exclude-no-date-annotation bypass` verbatim -in a recent user turn. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/soak-exclude-date-annotation-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This hook lives in `socket-wheelhouse/template/.claude/hooks/soak-exclude-date-annotation-guard` -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts b/.claude/hooks/soak-exclude-date-annotation-guard/index.mts deleted file mode 100644 index 577039f6d..000000000 --- a/.claude/hooks/soak-exclude-date-annotation-guard/index.mts +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — soak-exclude-date-annotation-guard. -// -// Blocks Edit/Write tool calls on `pnpm-workspace.yaml` that introduce -// a per-package `minimumReleaseAgeExclude` entry without the canonical -// `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation as the -// LAST comment line above the bullet. -// -// Why: soak-bypass entries are temporary by design — they exist because -// a fresh release was needed faster than the 7-day soak window. Without -// a documented removable-on date, entries pile up and nobody knows when -// they can be removed. The standard format lets a periodic sweep -// (manual or scripted) grep for `removable: <past-date>` to find -// candidates for cleanup. -// -// What's enforced (inside `minimumReleaseAgeExclude:` blocks only): -// - Each ` - 'NAME@VERSION'` line (exact-pin form) must be preceded by -// a comment line matching: -// # published: YYYY-MM-DD | removable: YYYY-MM-DD -// The annotation must be the IMMEDIATELY-PRECEDING comment line (the -// last `#` line above the bullet, no intervening blank line). -// -// What's exempt: -// - Scope-glob entries (`@socketsecurity/*`, `@socketregistry/*`, etc.) — -// persistent fleet policy, not a time-bound bypass. -// - Bare-name entries without `@version` (also persistent). -// - Lines marked `# socket-hook: allow soak-exclude-no-date-annotation`. -// -// Bypass: `Allow soak-exclude-no-date-annotation bypass` (typed verbatim -// by the user) for one-off legitimate cases. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import process from 'node:process' - -import { bypassPhrasePresent } from '../_shared/transcript.mts' - -const ALLOW_MARKER = '# socket-hook: allow soak-exclude-no-date-annotation' -const BYPASS_PHRASE = 'Allow soak-exclude-no-date-annotation bypass' - -// Matches the section header for the soak-exclude block. -const SECTION_HEADER = /^minimumReleaseAgeExclude:\s*$/ - -// Matches a top-level YAML key that ENDS the soak-exclude block. -const ANY_TOP_LEVEL_KEY = /^[A-Za-z_][\w-]*:\s*(\S.*)?$/ - -// Matches a per-package exact-pin entry inside the block: -// - 'name@1.2.3' -// - 'name@1.2.3-pre.0' -// - '@scope/name@1.2.3' -// - "name@1.2.3" (double-quoted) -// - name@1.2.3 (unquoted) -// Captures: 1=name, 2=version -const ENTRY_RE = - /^\s*-\s*['"]?((?:@[^@/'"\s]+\/)?[^@'"\s]+)@([^'"\s]+)['"]?\s*$/ - -// Glob entries (scope-wide, exempt). -const GLOB_ENTRY_RE = /^\s*-\s*['"]?[^'"\s]*\*[^'"\s]*['"]?\s*$/ - -// Bare name entries (no @version, exempt — persistent policy). -const BARE_NAME_ENTRY_RE = /^\s*-\s*['"]?[^@'"\s]+['"]?\s*$/ - -// The canonical annotation form. The two YYYY-MM-DD slots must be -// present, in this exact order, separated by ` | `. -const ANNOTATION_RE = - /^\s*#\s+published:\s+(\d{4}-\d{2}-\d{2})\s+\|\s+removable:\s+(\d{4}-\d{2}-\d{2})\s*$/ - -interface Hook { - tool_name?: string | undefined - transcript_path?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -interface OrphanReport { - line: number - name: string - version: string -} - -/** - * Walk the proposed file content and find every per-package exact-pin entry - * inside the soak-exclude block that lacks the canonical `# published: ... | - * removable: ...` annotation immediately above it. - */ -export function findOrphanEntries(text: string): OrphanReport[] { - const lines = text.split('\n') - const orphans: OrphanReport[] = [] - let inBlock = false - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] ?? '' - if (SECTION_HEADER.test(line)) { - inBlock = true - continue - } - if (!inBlock) { - continue - } - // A top-level key (non-indented `foo:`) ends the block. - if (ANY_TOP_LEVEL_KEY.test(line) && !line.startsWith(' ')) { - inBlock = false - continue - } - const m = ENTRY_RE.exec(line) - if (!m) { - continue - } - // Per-line allow marker. - if (line.includes(ALLOW_MARKER)) { - continue - } - // Scope-glob / bare-name entries are exempt — checked here so the - // regex order doesn't matter. - if (GLOB_ENTRY_RE.test(line) || BARE_NAME_ENTRY_RE.test(line)) { - continue - } - // Walk upward to find the IMMEDIATELY-PRECEDING comment line. Skip - // intervening blank lines? No — the canonical form requires the - // annotation to be the LAST comment above the bullet, contiguous. - const prev = i > 0 ? (lines[i - 1] ?? '') : '' - if (!ANNOTATION_RE.test(prev)) { - orphans.push({ - line: i + 1, - name: m[1] ?? '<unknown>', - version: m[2] ?? '<unknown>', - }) - } - } - return orphans -} - -function main(): void { - let stdin = '' - process.stdin.on('data', (chunk: Buffer) => { - stdin += chunk.toString() - }) - process.stdin.on('end', () => { - try { - let payload: Hook - try { - payload = JSON.parse(stdin) as Hook - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !filePath.endsWith('/pnpm-workspace.yaml')) { - process.exit(0) - } - const proposed = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - const orphans = findOrphanEntries(proposed) - if (orphans.length === 0) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { - process.exit(0) - } - const today = new Date().toISOString().slice(0, 10) - const exampleRemovable = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10) - process.stderr.write( - `[soak-exclude-date-annotation-guard] refusing edit: ` + - `${orphans.length} minimumReleaseAgeExclude entr${orphans.length === 1 ? 'y' : 'ies'} ` + - `lack the canonical date annotation:\n` + - orphans - .map(o => ` line ${o.line}: ${o.name}@${o.version}`) - .join('\n') + - '\n\n' + - "Fix: prepend a comment line directly above each `- '<pkg>@<version>'` bullet:\n" + - '\n' + - ' # published: <YYYY-MM-DD> | removable: <YYYY-MM-DD>\n' + - " - 'pkg@1.2.3'\n" + - '\n' + - "`published` is the version's npm publish date (`npm view pkg@1.2.3 time`).\n" + - '`removable` is `published + 7d` — the natural soak-clear date.\n' + - `\nExample for an entry added today (${today}):\n` + - ` # published: ${today} | removable: ${exampleRemovable}\n` + - " - 'pkg@1.2.3'\n" + - '\n' + - 'One-off override: append `# socket-hook: allow soak-exclude-no-date-annotation`\n' + - 'to the bullet line.\n', - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[soak-exclude-date-annotation-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/package.json b/.claude/hooks/soak-exclude-date-annotation-guard/package.json deleted file mode 100644 index 58752ae50..000000000 --- a/.claude/hooks/soak-exclude-date-annotation-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-soak-exclude-date-annotation-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts b/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts deleted file mode 100644 index e84477c91..000000000 --- a/.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts +++ /dev/null @@ -1,139 +0,0 @@ -// Tests for soak-exclude-date-annotation-guard. - -import assert from 'node:assert/strict' -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { describe, test } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(__dirname, '..', 'index.mts') - -interface RunResult { - code: number - stderr: string -} - -function runHook(payload: object): Promise<RunResult> { - return new Promise((resolve, reject) => { - const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('close', code => { - resolve({ code: code ?? 0, stderr }) - }) - child.stdin!.write(JSON.stringify(payload)) - child.stdin!.end() - }) -} - -const ANNOTATED = `minimumReleaseAgeExclude: - - '@socketsecurity/*' - # vite 8.0.13 ships rolldown natively. - # published: 2026-05-14 | removable: 2026-05-21 - - 'vite@8.0.13' -` - -const UNANNOTATED = `minimumReleaseAgeExclude: - - '@socketsecurity/*' - # vite 8.0.13 ships rolldown natively. - - 'vite@8.0.13' -` - -const ONLY_GLOBS = `minimumReleaseAgeExclude: - - '@socketaddon/*' - - '@socketbin/*' - - '@socketregistry/*' - - '@socketsecurity/*' -` - -describe('soak-exclude-date-annotation-guard', () => { - test('passes when annotation is present', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/pnpm-workspace.yaml', content: ANNOTATED }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('blocks when annotation is missing on an exact-pin entry', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/pnpm-workspace.yaml', - content: UNANNOTATED, - }, - }) - assert.equal(result.code, 2) - assert.match(result.stderr, /vite@8\.0\.13/) - assert.match(result.stderr, /published:/) - assert.match(result.stderr, /removable:/) - }) - - test('passes for glob-only soak-exclude block', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/pnpm-workspace.yaml', - content: ONLY_GLOBS, - }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('ignores non-pnpm-workspace.yaml files', async () => { - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/package.json', content: UNANNOTATED }, - }) - assert.equal(result.code, 0) - }) - - test('ignores non-Edit/Write tool calls', async () => { - const result = await runHook({ - tool_name: 'Read', - tool_input: { - file_path: '/tmp/pnpm-workspace.yaml', - content: UNANNOTATED, - }, - }) - assert.equal(result.code, 0) - }) - - test('respects per-line allow marker', async () => { - const content = `minimumReleaseAgeExclude: - # no annotation here - - 'pkg@1.0.0' # socket-hook: allow soak-exclude-no-date-annotation -` - const result = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/pnpm-workspace.yaml', content }, - }) - assert.equal(result.code, 0, result.stderr) - }) - - test('fails open on a malformed payload', async () => { - const child = spawn('node', [HOOK], { stdio: ['pipe', 'pipe', 'pipe'] }) - let exitCode = 0 - child.process.on('close', code => { - exitCode = code ?? 0 - }) - child.stdin!.write('not-json') - child.stdin!.end() - await new Promise<void>(resolve => - child.process.on('close', () => resolve()), - ) - assert.equal(exitCode, 0) - }) -}) diff --git a/.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json b/.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/socket-token-minifier-start/README.md b/.claude/hooks/socket-token-minifier-start/README.md deleted file mode 100644 index 91e176769..000000000 --- a/.claude/hooks/socket-token-minifier-start/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# socket-token-minifier-start - -**Claude Code SessionStart hook.** Auto-starts the socket-token-minifier -proxy if installed and not already running. Writes -`export ANTHROPIC_BASE_URL=http://localhost:7779` to `$CLAUDE_ENV_FILE` -**only** if the proxy is verified healthy. - -## Why fail-closed matters - -Setting `ANTHROPIC_BASE_URL` unconditionally (via `template/.claude/settings.json:env`) -would break every session whose proxy is down — including CI runners that -weekly-update workflows invoke `claude` from. This hook gates the env-var -write on a live `/health` probe, so the worst-case path is "no compression, -direct to api.anthropic.com" — never a 502. - -## Flow - -1. **Probe** `localhost:7779/health` (250ms timeout). -2. If **healthy**: write env var, exit 0. -3. If **port returned a non-2xx status**: something else is listening; skip - (don't clobber an unrelated process on this port). -4. If **binary not installed**: emit context, exit 0 without env-var write. -5. If **connection refused**: spawn the proxy detached, poll /health every - 100ms up to 2.5s total. If healthy in time, write env var. Else - fail-closed (no env var). - -Total time budget: ~3s worst case, ~0ms when proxy already healthy. - -## Install dependency - -This hook is a no-op until the proxy binary exists at -`~/.socket/_wheelhouse/bin/socket-token-minifier`. Install it via -`pnpm run install-token-minifier` from any fleet repo. The install script -sets up a self-contained pnpm workspace at -`~/.socket/_wheelhouse/socket-token-minifier/` and writes the bin shim. - -## Wiring (template settings.json) - -Inserted under `hooks.SessionStart`: - -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", - "timeout": 5 - } - ] - } - ] - } -} -``` - -5-second timeout — generous enough for the 3s startup budget plus a buffer. - -## Cross-fleet sync - -This hook lives in `socket-wheelhouse/template/.claude/hooks/` and is -required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/socket-token-minifier-start/index.mts b/.claude/hooks/socket-token-minifier-start/index.mts deleted file mode 100644 index 9778d12ef..000000000 --- a/.claude/hooks/socket-token-minifier-start/index.mts +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env node -// Claude Code SessionStart hook — socket-token-minifier auto-start. -// -// Probes localhost:7779 for a healthy socket-token-minifier proxy. -// If absent, spawns the installed binary in the background and waits -// for /health to respond. Only writes `export ANTHROPIC_BASE_URL=…` -// to $CLAUDE_ENV_FILE if the proxy is verified healthy. -// -// **Fail-closed**: if the binary isn't installed, the port is taken -// by something else, or the spawn fails to come up healthy in the -// time budget, the hook exits 0 with no env-var write. Claude Code -// then routes direct to api.anthropic.com — no compression, no -// breakage. The only failure mode this hook prevents is the worse -// one: setting ANTHROPIC_BASE_URL unconditionally and breaking -// every session whose proxy isn't running. -// -// Time budget: ~3 seconds total. Anything slower than that holds the -// SessionStart hook chain and the user feels it. - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { appendFileSync, existsSync } from 'node:fs' -import http from 'node:http' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getSocketAppDir } from '@socketsecurity/lib-stable/paths/socket' - -const logger = getDefaultLogger() - -const PROXY_PORT = 7779 -const HEALTH_URL = `http://localhost:${PROXY_PORT}/health` -const BIN_PATH = path.join( - getSocketAppDir('wheelhouse'), - 'bin', - 'socket-token-minifier', -) -const ANTHROPIC_BASE_URL = `http://localhost:${PROXY_PORT}` - -const PROBE_TIMEOUT_MS = 250 -const SPAWN_WAIT_BUDGET_MS = 2500 -const SPAWN_POLL_INTERVAL_MS = 100 - -/** - * Emit additionalContext (visible in the transcript) so a user skimming the - * session log sees what the hook did. Optional — Claude Code reads it as - * informational text, not as an action. - */ -export function emitSessionStartContext(message: string): void { - const out = { - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext: `[socket-token-minifier] ${message}`, - }, - } - process.stdout.write(JSON.stringify(out)) -} - -interface ProbeOutcome { - healthy: boolean - /** - * Undefined when probe couldn't connect (proxy absent); defined when - * something else returned). - */ - status?: number | undefined -} - -/** - * One-shot HTTP GET to /health. Resolves to {healthy: true} only on 2xx — - * anything else (connection refused, timeout, wrong content, non-2xx status) is - * treated as not-healthy. Fail-closed at this layer keeps the env-var write - * conditional on actual liveness. - */ -export function probeHealth(): Promise<ProbeOutcome> { - return new Promise(resolve => { - const req = http.get(HEALTH_URL, { timeout: PROBE_TIMEOUT_MS }, res => { - const status = res.statusCode ?? 0 - // Drain body so the socket can be reused / closed cleanly. - res.resume() - resolve({ healthy: status >= 200 && status < 300, status }) - }) - req.on('error', () => resolve({ healthy: false })) - req.on('timeout', () => { - req.destroy() - resolve({ healthy: false }) - }) - }) -} - -export function sleep(ms: number): Promise<void> { - return new Promise(r => setTimeout(r, ms)) -} - -/** - * Spawn the proxy detached so it survives this hook exit. stdio disconnected so - * any startup logs don't leak into Claude Code's session output. - */ -export function spawnDetached(): void { - // The lib's spawn returns a thenable-with-extras shape: it has the - // promise interface AND a `process: ChildProcess` field. We don't - // await — we just unref() the underlying ChildProcess so SIGTERM / - // exit signals don't cascade into the proxy. - const result = spawn(BIN_PATH, [], { - detached: true, - stdio: 'ignore', - }) - result.process.unref() -} - -/** - * Append `export ANTHROPIC_BASE_URL=...` to CLAUDE_ENV_FILE so the session env - * picks it up. Claude Code reads the file when assembling its child-process env - * (per claude-code/src/utils/sessionEnvironment.ts). - * - * If the file isn't set OR isn't writable, fail-closed silently — the env var - * stays unset and Claude Code falls back to direct api.anthropic.com. - */ -export function writeAnthropicBaseUrlToEnvFile(): void { - const envFile = process.env['CLAUDE_ENV_FILE'] - if (!envFile) { - return - } - // Quote single-quoted POSIX style. The value is a known-safe URL, - // but quote anyway for consistency with hooks that take user input. - const line = `export ANTHROPIC_BASE_URL='${ANTHROPIC_BASE_URL}'\n` - try { - // Append, don't overwrite — other hooks may also be writing. - // Use sync fs since this is a small write on a hot path (hook - // runtime is part of session-start latency). - appendFileSync(envFile, line, 'utf8') - } catch { - // Fail-closed: if we can't write, don't set the env var. Session - // goes direct. - } -} - -async function main(): Promise<void> { - // (1) Already running? - const initial = await probeHealth() - if (initial.healthy) { - writeAnthropicBaseUrlToEnvFile() - emitSessionStartContext( - `proxy already healthy on :${PROXY_PORT}; ANTHROPIC_BASE_URL set.`, - ) - return - } - - // (2) Port taken by something we don't recognize? If so, fail-closed — - // we don't want to clobber whatever is listening. - if (initial.status !== undefined) { - emitSessionStartContext( - `port ${PROXY_PORT} responded with status ${initial.status} (not our proxy); skipping.`, - ) - return - } - - // (3) Binary installed? - if (!existsSync(BIN_PATH)) { - emitSessionStartContext( - `binary not found at ${BIN_PATH}; run \`pnpm run install-token-minifier\`. ` + - `Continuing with direct api.anthropic.com.`, - ) - return - } - - // (4) Start it + wait for health. - spawnDetached() - const deadline = Date.now() + SPAWN_WAIT_BUDGET_MS - while (Date.now() < deadline) { - await sleep(SPAWN_POLL_INTERVAL_MS) - const probe = await probeHealth() - if (probe.healthy) { - writeAnthropicBaseUrlToEnvFile() - emitSessionStartContext( - `started proxy on :${PROXY_PORT}; ANTHROPIC_BASE_URL set.`, - ) - return - } - } - - // Spawn fired but didn't come healthy in the budget. Fail-closed. - emitSessionStartContext( - `proxy failed to become healthy within ${SPAWN_WAIT_BUDGET_MS}ms; ` + - `continuing with direct api.anthropic.com.`, - ) -} - -main().catch(e => { - // Internal-error fail-closed: never block session start. Log to - // stderr so a noisy install issue is at least visible. - logger.fail(`socket-token-minifier-start hook error: ${String(e)}`) - process.exit(0) -}) diff --git a/.claude/hooks/socket-token-minifier-start/package.json b/.claude/hooks/socket-token-minifier-start/package.json deleted file mode 100644 index 786bff3f0..000000000 --- a/.claude/hooks/socket-token-minifier-start/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-socket-token-minifier-start", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - } -} diff --git a/.claude/hooks/socket-token-minifier-start/tsconfig.json b/.claude/hooks/socket-token-minifier-start/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/socket-token-minifier-start/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/squash-history-reminder/README.md b/.claude/hooks/squash-history-reminder/README.md deleted file mode 100644 index 22d6140a7..000000000 --- a/.claude/hooks/squash-history-reminder/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# squash-history-reminder - -Stop hook that nudges the operator toward the `squashing-history` skill when an opted-in fleet repo's default branch has grown beyond a configurable commit threshold. - -## Why - -A subset of fleet repos (currently `socket-addon`, `socket-bin`, `socket-btm`, `sdxgen`, `stuie`) periodically squash the default branch to a single "Initial commit" — the convention exists for repos where deep history is more confusing than useful (binary publishing forwards, scratchpad tooling, etc.). The opt-in is declared centrally in `template/.claude/skills/cascading-fleet/lib/fleet-repos.json` under each repo's `optIns: ['squash-history']` array. - -The hook is a soft reminder, not a blocker. It fires at end-of-turn when all three are true: - -1. The current repo is on the opt-in list. -2. The current branch is the repo's default branch (`main` / `master` — resolved per the fleet's _Default branch fallback_ rule). -3. The default branch has > `SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD` commits (default 50). - -When all three fire, stderr emits a one-paragraph reminder pointing at the `squashing-history` skill. - -## Bypass - -User types **`Allow squash-history-reminder bypass`** verbatim in a recent message (within the last 8 user turns). Case-sensitive; paraphrases don't count. - -Or set `SOCKET_SQUASH_HISTORY_REMINDER_DISABLED=1` in the env to disable entirely. - -## Configuration - -- `SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD` — integer; default 50. Below this count, the hook stays silent. -- `SOCKET_SQUASH_HISTORY_REMINDER_DISABLED` — any truthy value short-circuits the hook. - -## Failing open - -The hook fails open on its own bugs (the catch in `main()`). A buggy hook can never block the session. - -## Related - -- `.claude/skills/squashing-history/SKILL.md` — the canonical squash-history skill (does the actual work). -- `.claude/skills/cascading-fleet/lib/fleet-repos.json` — the roster + opt-in declarations. -- `.claude/hooks/default-branch-guard/` — sibling hook that enforces `main → master` fallback wherever the default branch is hard-coded. diff --git a/.claude/hooks/squash-history-reminder/index.mts b/.claude/hooks/squash-history-reminder/index.mts deleted file mode 100644 index 07b463110..000000000 --- a/.claude/hooks/squash-history-reminder/index.mts +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — squash-history-reminder. -// -// Reminds the operator about the `squashing-history` skill when: -// 1. The current repo's `name` (from the local git remote OR -// basename of the working tree) is listed in the fleet -// roster's `optIns: ['squash-history']` set. -// 2. The current branch is the repo's default branch (per the -// fleet's _Default branch fallback_ rule — main → master). -// 3. The default branch has more than HISTORY_COMMIT_THRESHOLD -// commits (default 50). Tunable via env. -// -// The reminder is a soft one-liner; pairs with the -// `template/.claude/skills/squashing-history/SKILL.md` skill that -// does the actual squash. -// -// Bypass phrase: `Allow squash-history-reminder bypass`. Disable -// entirely via SOCKET_SQUASH_HISTORY_REMINDER_DISABLED. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -// prefer-async-spawn: sync-required — hook fires synchronously at -// turn-end and must finish before stdin/stdout close. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -const BYPASS_PHRASE = 'Allow squash-history-reminder bypass' -const BYPASS_LOOKBACK_USER_TURNS = 8 -const HISTORY_COMMIT_THRESHOLD = Number.parseInt( - process.env['SOCKET_SQUASH_HISTORY_COMMIT_THRESHOLD'] ?? '50', - 10, -) - -interface StopPayload { - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -interface FleetRepo { - readonly name: string - readonly optIns?: readonly string[] | undefined -} - -interface FleetRoster { - readonly repos: readonly FleetRepo[] -} - -function gitSafe(cwd: string, args: string[]): string | undefined { - const r = spawnSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - if (r.status !== 0 || typeof r.stdout !== 'string') { - return undefined - } - return r.stdout.trim() -} - -/** - * Identify the canonical repo name. Prefer the GitHub remote (handles checkout - * dir renames like `socket-cli-fix-foo`); fall back to the working-tree - * basename. - */ -export function resolveRepoName(cwd: string): string | undefined { - const remote = gitSafe(cwd, ['config', '--get', 'remote.origin.url']) - if (remote) { - // git@github.com:Org/repo.git OR https://github.com/Org/repo(.git)? - const m = /[/:]([^/:]+?)(?:\.git)?$/.exec(remote) - if (m && m[1]) { - return m[1] - } - } - const base = path.basename(cwd) - return base || undefined -} - -export function readRoster(rosterPath: string): FleetRoster | undefined { - if (!existsSync(rosterPath)) { - return undefined - } - try { - const raw = readFileSync(rosterPath, 'utf8') - return JSON.parse(raw) as FleetRoster - } catch { - return undefined - } -} - -export function isOptedIn( - roster: FleetRoster, - repoName: string, - optIn: string, -): boolean { - for (let i = 0, { length } = roster.repos; i < length; i += 1) { - const r = roster.repos[i]! - if (r.name === repoName) { - return (r.optIns ?? []).includes(optIn) - } - } - return false -} - -function defaultBranch(cwd: string): string { - const sym = gitSafe(cwd, ['symbolic-ref', 'refs/remotes/origin/HEAD']) - if (sym) { - return sym.replace(/^refs\/remotes\/origin\//, '') - } - for (const candidate of ['main', 'master']) { - if ( - gitSafe(cwd, [ - 'show-ref', - '--verify', - '--quiet', - `refs/remotes/origin/${candidate}`, - ]) !== undefined - ) { - return candidate - } - } - return 'main' -} - -function currentBranch(cwd: string): string | undefined { - return gitSafe(cwd, ['branch', '--show-current']) -} - -function commitCount(cwd: string, ref: string): number { - const out = gitSafe(cwd, ['rev-list', '--count', ref]) - if (out === undefined) { - return 0 - } - const n = Number.parseInt(out, 10) - return Number.isFinite(n) ? n : 0 -} - -async function main(): Promise<void> { - if (process.env['SOCKET_SQUASH_HISTORY_REMINDER_DISABLED']) { - return - } - const raw = await readStdin() - if (!raw.trim()) { - return - } - let payload: StopPayload - try { - payload = JSON.parse(raw) as StopPayload - } catch { - return - } - const cwd = payload.cwd ?? process.cwd() - - const repoRoot = gitSafe(cwd, ['rev-parse', '--show-toplevel']) ?? cwd - const rosterCandidates = [ - path.join( - repoRoot, - 'template/.claude/skills/cascading-fleet/lib/fleet-repos.json', - ), - path.join(repoRoot, '.claude/skills/cascading-fleet/lib/fleet-repos.json'), - ] - let roster: FleetRoster | undefined - for (let i = 0, { length } = rosterCandidates; i < length; i += 1) { - roster = readRoster(rosterCandidates[i]!) - if (roster) { - break - } - } - if (!roster) { - return - } - - const repoName = resolveRepoName(repoRoot) - if (!repoName) { - return - } - if (!isOptedIn(roster, repoName, 'squash-history')) { - return - } - - const branch = currentBranch(repoRoot) - const base = defaultBranch(repoRoot) - if (branch !== base) { - return - } - - const count = commitCount(repoRoot, branch) - if (count <= HISTORY_COMMIT_THRESHOLD) { - return - } - - if ( - bypassPhrasePresent( - payload.transcript_path, - BYPASS_PHRASE, - BYPASS_LOOKBACK_USER_TURNS, - ) - ) { - return - } - - process.stderr.write( - [ - `💡 squash-history-reminder: ${repoName} is opted into the squash-history convention.`, - ` The default branch \`${branch}\` has ${count} commits (threshold ${HISTORY_COMMIT_THRESHOLD}).`, - ` Consider running the \`squashing-history\` skill to collapse to a single Initial commit.`, - ` Skill: .claude/skills/squashing-history/SKILL.md`, - ` Suppress for this session: type "${BYPASS_PHRASE}" verbatim, or set`, - ` SOCKET_SQUASH_HISTORY_REMINDER_DISABLED=1 to disable entirely.`, - '', - ].join('\n'), - ) -} - -main().catch(e => { - process.stderr.write( - `squash-history-reminder: hook error (continuing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/squash-history-reminder/package.json b/.claude/hooks/squash-history-reminder/package.json deleted file mode 100644 index e3f4af7f7..000000000 --- a/.claude/hooks/squash-history-reminder/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "hook-squash-history-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "dependencies": { - "@socketsecurity/lib-stable": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/squash-history-reminder/test/index.test.mts b/.claude/hooks/squash-history-reminder/test/index.test.mts deleted file mode 100644 index 189593c68..000000000 --- a/.claude/hooks/squash-history-reminder/test/index.test.mts +++ /dev/null @@ -1,51 +0,0 @@ -// node --test specs for squash-history-reminder hook helpers. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { isOptedIn, resolveRepoName } from '../index.mts' - -test('isOptedIn returns true for an opted-in repo', () => { - const roster = { - repos: [ - { name: 'socket-btm', optIns: ['squash-history'] }, - { name: 'socket-cli' }, - ], - } - assert.strictEqual(isOptedIn(roster, 'socket-btm', 'squash-history'), true) -}) - -test('isOptedIn returns false for a non-opted-in repo', () => { - const roster = { - repos: [ - { name: 'socket-btm', optIns: ['squash-history'] }, - { name: 'socket-cli' }, - ], - } - assert.strictEqual(isOptedIn(roster, 'socket-cli', 'squash-history'), false) -}) - -test('isOptedIn returns false for a repo missing from the roster', () => { - const roster = { - repos: [{ name: 'socket-btm', optIns: ['squash-history'] }], - } - assert.strictEqual(isOptedIn(roster, 'unknown-repo', 'squash-history'), false) -}) - -test('isOptedIn returns false for a different opt-in name', () => { - const roster = { - repos: [{ name: 'socket-btm', optIns: ['squash-history'] }], - } - assert.strictEqual(isOptedIn(roster, 'socket-btm', 'other-opt-in'), false) -}) - -test('resolveRepoName falls back to cwd basename if no git remote', () => { - // Use a real path to verify basename extraction; the function tries - // git first but will silently fail in /tmp (no remote configured). - const result = resolveRepoName('/tmp/socket-imaginary') - // Result is either the basename OR a real remote name if /tmp happens - // to be inside a git checkout (unlikely). Both are valid; the - // important thing is the function returns *something* string-shaped. - assert.strictEqual(typeof result, 'string') - assert.ok((result?.length ?? 0) > 0) -}) diff --git a/.claude/hooks/squash-history-reminder/tsconfig.json b/.claude/hooks/squash-history-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/squash-history-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/stale-process-sweeper/README.md b/.claude/hooks/stale-process-sweeper/README.md deleted file mode 100644 index 875bb76c0..000000000 --- a/.claude/hooks/stale-process-sweeper/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# stale-process-sweeper - -A **Claude Code hook** that runs at the _end_ of every Claude turn -and sweeps stale Node test/build worker processes that lost their -parent. Without this, abandoned workers accumulate across turns and -gradually exhaust system memory. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `Stop` hook like -> this one fires _after_ Claude finishes a turn (a unit of work that -> ends with the model handing the conversation back to the user). -> Stop hooks can do cleanup, log diagnostics, or — like this one — -> reap orphans. - -## Why orphans pile up - -Vitest's `forks` pool spawns one Node worker per CPU. When the parent -runner exits abnormally — a `Bash` tool timeout, a `SIGINT` from the -user, a pre-commit hook crash — the workers stay alive holding -roughly 80–100 MB of RSS each. Tools like `tsgo` and `esbuild` have -similar long-lived service processes that can outlive their parent. - -After a few interrupted runs, you can have several gigabytes of -abandoned processes sitting around. The sweeper finds them by -matching their command line against a known pattern list, confirms -their parent process has died (so we don't kill workers belonging to -a _real_ in-progress run), and sends them `SIGTERM`. - -## What's swept - -| Pattern | What it matches | -| -------------------------------------- | -------------------------------- | -| `vitest/dist/workers/(forks\|threads)` | Vitest worker pool processes | -| `vitest/dist/(cli\|node).[mc]?js` | Orphaned Vitest parent runners | -| `\btsgo\b` | TypeScript Go-based type checker | -| `type-coverage/bin/type-coverage` | Type coverage tool | -| `esbuild/(bin\|lib)/.*\bservice\b` | esbuild's daemon service | - -## What's not swept - -- Anything spawned by a still-living shell (parent process alive). - Those are part of an in-progress run; killing them would break - legitimate work. -- The Claude Code process itself or its parent terminal. -- Anything outside the pattern list. The sweeper is conservative — - if a stuck process isn't pattern-matched, it survives. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/stale-process-sweeper/index.mts" - } - ] - } - ] - } -} -``` - -## Output - -Silent on the happy path (no orphans found). When something is -reaped: - -``` -[stale-process-sweeper] reaped 14 stale worker(s), ~1120MB freed: -vitest-worker=29240(95MB), vitest-worker=33278(93MB), … -``` - -The line goes to stderr. Stop-hook output is shown to the user, not -the model — useful diagnostic, doesn't pollute Claude's context. - -## Testing - -```bash -cd .claude/hooks/stale-process-sweeper -node --test test/*.test.mts -``` - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/stale-process-sweeper) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/stale-process-sweeper/index.mts b/.claude/hooks/stale-process-sweeper/index.mts deleted file mode 100644 index 9b367e474..000000000 --- a/.claude/hooks/stale-process-sweeper/index.mts +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — stale-process-sweeper. -// -// Fires at turn-end. Finds Node test/build worker processes that the -// session left behind (test runner crashed mid-run, hook timed out, -// user interrupted `Bash`, etc.) and kills them so they don't pile up -// across turns and exhaust system memory. -// -// What's swept: -// - vitest workers (`vitest/dist/workers/forks` and the threads pool) -// - vitest itself (orphan parent runners that survived a SIGINT) -// - tsgo / tsc type-check daemons -// - type-coverage workers -// - esbuild service processes -// - Socket Firewall wrappers (`~/.socket/_wheelhouse/bin/sfw`) — each pnpm / -// yarn invocation goes through one, and the wrapper sometimes -// outlives its pnpm child. On a busy day this can pile up to -// hundreds of orphans holding ~200MB RSS each (20+GB total). -// Only orphans are reaped (parent dead or init) — live-parent -// wrappers might be tied to an in-progress install. -// -// What's NOT swept: -// - Anything spawned by a still-living shell (PPID alive) -// - Anything matching the user's editors / IDEs / terminals -// - The Claude Code process itself -// -// The hook is fast (one `ps` call + a few regex matches + a couple of -// `kill -0` probes) and silent on the happy path. It only writes to -// stderr when it actually killed something — that's a useful signal. -// -// Stop hooks receive JSON on stdin (we don't read it; the body -// shape is irrelevant to our work) and exit code is advisory. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -// Process-name patterns that indicate a stale test/build worker. -// Must be specific enough that real user processes (a normal `node` -// invocation, an editor's language server) don't match. -const STALE_PATTERNS: Array<{ name: string; rx: RegExp }> = [ - // Vitest worker pools — both `forks` (process-per-worker) and the - // path the threads pool uses when isolation is requested. The - // canonical leak: Vitest spawns N workers, parent crashes/SIGINTs, - // workers stay alive holding 80–100MB each. - { - name: 'vitest-worker', - rx: /vitest\/dist\/workers\/(forks|threads)/, - }, - // Vitest parent runner that survived its own children's exit. - // Matches both shapes: - // - `node ... vitest/dist/cli ... run` (older entry point) - // - `node ... vitest/dist/node.mjs ... run` (alternate entry point) - // - `node node_modules/.bin/../vitest/vitest.mjs run` (current shape - // spawned by `pnpm test` / `vitest run`) - { - name: 'vitest-runner', - rx: /vitest\/(dist\/(cli|node)\.[mc]?js|vitest\.[mc]?js)\b/, - }, - // tsgo / tsc daemons. `tsgo` is the new Go-based type checker; - // `tsc --watch` daemons can also linger. - { - name: 'tsgo', - rx: /\btsgo\b/, - }, - // type-coverage runs as a separate process and sometimes outlives - // its CI step. - { - name: 'type-coverage', - rx: /type-coverage\/bin\/type-coverage/, - }, - // esbuild's daemon service helper. - { - name: 'esbuild-service', - rx: /esbuild\/(bin|lib)\/.*\bservice\b/, - }, - // Socket Firewall command wrappers. Three deployment layouts: - // - ~/.socket/_wheelhouse/bin/sfw[-<version>] (current dev install) - // - ~/.socket/_dlx/<hash>/sfw (planned: dlxBinary cache) - // - ${RUNNER_TEMP}/sfw-bin/sfw[.exe] (CI runner install) - // Path component is invariant across home prefixes (/Users/<user>/ vs - // /home/<user>/). The CI path uses RUNNER_TEMP which varies per OS but - // the trailing `/sfw-bin/sfw` is stable. - // - // Orphan-only (the parent-alive branch in sweep()) — a live-parent - // sfw is likely a mid-flight pnpm/yarn install. - { - name: 'sfw-wrapper', - rx: /(?:\.socket\/(?:_dlx\/[0-9a-f]+|sfw\/bin)|sfw-bin)\/sfw(?:-[\w.]+)?(?:\.exe)?\b/, - }, -] - -interface ProcRow { - command: string - // Elapsed seconds since process started. - elapsedSec: number - pcpu: number - pid: number - ppid: number - rss: number -} - -// Convert ps `etime` field ([dd-]hh:mm:ss or mm:ss) to seconds. -// Examples: "05:23" → 323, "1:02:30" → 3750, "2-04:00:00" → 187200. -export function parseEtime(etime: string): number { - let rest = etime - let days = 0 - const dashIdx = rest.indexOf('-') - if (dashIdx !== -1) { - days = Number.parseInt(rest.slice(0, dashIdx), 10) || 0 - rest = rest.slice(dashIdx + 1) - } - const parts = rest.split(':').map(p => Number.parseInt(p, 10) || 0) - let hours = 0 - let mins = 0 - let secs = 0 - if (parts.length === 3) { - ;[hours, mins, secs] = parts as [number, number, number] - } else if (parts.length === 2) { - ;[mins, secs] = parts as [number, number] - } else if (parts.length === 1) { - secs = parts[0] ?? 0 - } - return days * 86400 + hours * 3600 + mins * 60 + secs -} - -export function listProcesses(): ProcRow[] { - // -A: all processes, -o: custom format, no truncation. macOS + Linux - // both support `pcpu` (instantaneous CPU%) and `etime` (elapsed time). - // Windows isn't supported (Stop hook is unix-only in practice). - const result = spawnSync( - 'ps', - ['-A', '-o', 'pid=,ppid=,rss=,pcpu=,etime=,command='], - {}, - ) - if (result.status !== 0 || !result.stdout) { - return [] - } - const rows: ProcRow[] = [] - // `ps -A` is unix-only (see comment above), so the output uses LF - // line endings — no CRLF normalization needed here. - for (const line of String(result.stdout).split('\n')) { - if (!line.trim()) { - continue - } - // Split into [pid, ppid, rss, pcpu, etime, ...command]. `command` - // may contain arbitrary spaces, so re-join after the first five - // fields. `pcpu` and `etime` are well-formed (no embedded space). - const parts = line.trim().split(/\s+/) - if (parts.length < 6) { - continue - } - const pid = Number.parseInt(parts[0]!, 10) - const ppid = Number.parseInt(parts[1]!, 10) - const rss = Number.parseInt(parts[2]!, 10) - const pcpu = Number.parseFloat(parts[3]!) - const elapsedSec = parseEtime(parts[4]!) - if (!Number.isFinite(pid) || !Number.isFinite(ppid)) { - continue - } - const command = parts.slice(5).join(' ') - rows.push({ - pid, - ppid, - rss, - pcpu: Number.isFinite(pcpu) ? pcpu : 0, - elapsedSec, - command, - }) - } - return rows -} - -export function isAlive(pid: number): boolean { - if (pid <= 1) { - // PID 0 / 1 are the kernel / init — if our parent is one of those, - // we're definitely an orphan, but `kill -0 1` would mislead. - return false - } - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -export function classify(row: ProcRow): string | undefined { - for (const { name, rx } of STALE_PATTERNS) { - if (rx.test(row.command)) { - return name - } - } - return undefined -} - -// Two reasons a matched worker should be reaped: -// 1. ORPHAN — parent is gone or is init (PID 1). Classic case: vitest -// SIGINT'd, parent exited, workers re-parented to init. -// 2. STUCK — parent is alive but the worker has been running for a -// long time, holding lots of memory, and burning CPU. Classic case: -// vitest run timed out from inside Claude Code; the parent CLI -// process is technically alive but unproductive, and its workers -// spin forever consuming gigabytes. We sweep these even though the -// parent's still around. -// -// Stuck-worker thresholds — conservative on purpose. A real, productive -// worker doesn't simultaneously hit all three: 5+ minutes of wallclock -// AND >50% CPU sustained AND >500MB RSS. Healthy parallel test runs -// finish well under 5 minutes per worker; CI workers that legitimately -// take longer don't run inside Claude Code's hook environment anyway. -const STUCK_MIN_ELAPSED_SEC = 300 -const STUCK_MIN_PCPU = 50 -const STUCK_MIN_RSS_KB = 500 * 1024 - -export function sweep(): { - killed: Array<{ - name: string - pid: number - reason: 'orphan' | 'stuck' - rssMb: number - }> - skipped: number -} { - const rows = listProcesses() - const myPid = process.pid - const myPpid = process.ppid - const killed: Array<{ - name: string - pid: number - reason: 'orphan' | 'stuck' - rssMb: number - }> = [] - let skipped = 0 - - for (let i = 0, { length } = rows; i < length; i += 1) { - const row = rows[i]! - // Never touch ourselves or our parent (Claude Code). - if (row.pid === myPid || row.pid === myPpid) { - continue - } - const name = classify(row) - if (!name) { - continue - } - let reason: 'orphan' | 'stuck' | undefined - if (row.ppid === 1 || !isAlive(row.ppid)) { - reason = 'orphan' - } else if ( - row.elapsedSec >= STUCK_MIN_ELAPSED_SEC && - row.pcpu >= STUCK_MIN_PCPU && - row.rss >= STUCK_MIN_RSS_KB - ) { - // Worker is matched, has a live parent, but is wedged: long - // elapsed time + spinning CPU + heavy memory. This is the - // user-reported case where vitest workers hung at 100% CPU / - // 1+GB RSS while their parent CLI was technically alive. - reason = 'stuck' - } - if (reason === undefined) { - skipped += 1 - continue - } - try { - // SIGTERM first — give the worker a chance to flush. We don't - // wait for it; the next sweep (next turn) will SIGKILL anything - // that ignored SIGTERM. Keeping the hook fast matters more than - // squeezing every last byte. - process.kill(row.pid, 'SIGTERM') - killed.push({ - name, - pid: row.pid, - reason, - rssMb: Math.round(row.rss / 1024), - }) - } catch { - // Already gone, or we lack permission — nothing to do. - } - } - return { killed, skipped } -} - -function main() { - // Drain stdin (Stop hook delivers a JSON payload). We don't need - // the body, but Node will keep the event loop alive if we don't - // consume it. - process.stdin.resume() - process.stdin.on('data', () => {}) - process.stdin.on('end', runSweep) - // If stdin is already closed (some hook runners don't pipe input), - // run immediately. - if (process.stdin.readable === false) { - runSweep() - } -} - -export function runSweep() { - let result: ReturnType<typeof sweep> - try { - result = sweep() - } catch (e) { - // Hooks must never crash a Claude turn. Log and exit clean. - process.stderr.write( - `[stale-process-sweeper] unexpected error: ${(e as Error).message}\n`, - ) - process.exit(0) - } - if (result.killed.length > 0) { - const totalMb = result.killed.reduce((sum, k) => sum + k.rssMb, 0) - const breakdown = result.killed - .map(k => `${k.name}=${k.pid}(${k.rssMb}MB,${k.reason})`) - .join(', ') - process.stderr.write( - `[stale-process-sweeper] reaped ${result.killed.length} stale ` + - `worker(s), ~${totalMb}MB freed: ${breakdown}\n`, - ) - } - process.exit(0) -} - -main() diff --git a/.claude/hooks/stale-process-sweeper/package.json b/.claude/hooks/stale-process-sweeper/package.json deleted file mode 100644 index 1a0f6de11..000000000 --- a/.claude/hooks/stale-process-sweeper/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-stale-process-sweeper", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts b/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts deleted file mode 100644 index bdcd52084..000000000 --- a/.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts +++ /dev/null @@ -1,92 +0,0 @@ -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { fileURLToPath } from 'node:url' -import path from 'node:path' -import { test } from 'node:test' -import assert from 'node:assert/strict' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.resolve(__dirname, '..', 'index.mts') - -// Run the hook with an empty stdin payload (Stop hook delivers JSON, -// but the body is unused). Captures stderr + exit code. -function runHook(): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [HOOK], { - stdio: ['pipe', 'ignore', 'pipe'], - }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - let stderr = '' - child.process.stderr!.on('data', d => { - stderr += d.toString() - }) - child.process.on('error', reject) - child.process.on('exit', code => { - resolve({ code: code ?? -1, stderr }) - }) - // Stop hooks receive a JSON payload on stdin. Send an empty object - // so the hook's drain logic completes. - child.stdin!.end('{}\n') - }) -} - -test('stale-process-sweeper: exits 0 when nothing to sweep', async () => { - const { code, stderr } = await runHook() - assert.equal(code, 0, `hook should exit 0; stderr=${stderr}`) - // On a clean host the hook should be silent. - assert.equal( - stderr, - '', - `hook should be silent when no orphans exist; got: ${stderr}`, - ) -}) - -test('stale-process-sweeper: ignores live-parent test workers', async () => { - // Spawn a fake "vitest worker" whose parent is still alive. The - // sweeper must not touch it. We use a script path that matches the - // worker regex; the actual command runs `node -e 'setTimeout(...)'` - // long enough to outlive the hook invocation. - // - // Note: matching the regex `vitest/dist/workers/forks` requires a - // command line that contains that substring. We can't easily forge - // a real vitest binary, so we approximate by passing the path as an - // argv string — `ps -o command=` reflects argv, and the regex sees - // it. - const fakeWorker = spawn( - process.execPath, - [ - '-e', - 'setTimeout(() => {}, 5000)', - // This dummy arg is what `ps` will report; the sweeper's regex - // picks it up. The worker still has a live parent (this test - // process), so the sweeper should NOT kill it. - '/fake/vitest/dist/workers/forks.js', - ], - { stdio: 'ignore', detached: false }, - ) - // Give the OS a moment to register the child. - await new Promise(r => setTimeout(r, 100)) - try { - const { code, stderr } = await runHook() - assert.equal(code, 0) - // Should NOT have reaped the fake worker — its parent (us) is - // alive. If the hook killed it, the message would mention it. - assert.ok( - !stderr.includes('reaped'), - `hook reaped a live-parent worker: ${stderr}`, - ) - // Verify the worker is still alive. - assert.ok( - !fakeWorker.process.killed && fakeWorker.process.exitCode === null, - 'fake worker should still be running', - ) - } finally { - fakeWorker.process.kill('SIGKILL') - } -}) diff --git a/.claude/hooks/stale-process-sweeper/tsconfig.json b/.claude/hooks/stale-process-sweeper/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/stale-process-sweeper/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/sweep-ds-store/README.md b/.claude/hooks/sweep-ds-store/README.md deleted file mode 100644 index eb8fdd552..000000000 --- a/.claude/hooks/sweep-ds-store/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# sweep-ds-store - -Stop hook that sweeps `.DS_Store` files at turn-end. Excludes `.git/` -and `node_modules/`. Silent on the happy path; logs sweep count when -files are found. - -## Why - -`.DS_Store` is gitignored fleet-wide, but the files still exist on -disk. They surface in: - -- `find` output, polluting search results -- `git status --ignored` reports -- non-git tooling (rsync, tar, zip artifacts) -- Spotlight indexing churn - -The right fix is to delete them, not just ignore them. The hook runs -at every turn-end (the same time `stale-process-sweeper` runs), so -files Finder created mid-session are gone before the next turn. - -## Behavior - -- Walks the worktree starting at `$CLAUDE_PROJECT_DIR` (or `cwd` as - fallback) -- Skips `.git/` and `node_modules/` subtrees -- Doesn't follow symlinks -- Max depth: 12 (defense against pathological symlink loops) -- Per-file delete errors are logged but never block the hook - -## Output - -Silent unless files were found. Output goes to stderr: - -``` -[sweep-ds-store] swept 3 .DS_Store file(s): - .DS_Store - src/.DS_Store - test/fixtures/.DS_Store -``` - -## Bypass - -None — `.DS_Store` is never wanted in a repo. If you have a reason -to keep one (very rare; testing macOS-specific tooling), name it -`.DS_Store.fixture` and adjust the test. diff --git a/.claude/hooks/sweep-ds-store/index.mts b/.claude/hooks/sweep-ds-store/index.mts deleted file mode 100644 index c5632e6b1..000000000 --- a/.claude/hooks/sweep-ds-store/index.mts +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — sweep-ds-store. -// -// Fires at turn-end. Walks the worktree (current working directory) -// and deletes any `.DS_Store` files Finder created mid-session. -// Excludes `.git/` and `node_modules/` so we don't churn through -// directories full of vendor noise. -// -// Why a hook instead of `.gitignore` alone: -// `.DS_Store` is gitignored fleet-wide, but the FILES themselves -// still exist on disk. They surface in: -// - `find` output, polluting search results -// - `git status --ignored` reports -// - non-git tooling (rsync, tar, zip) -// - Spotlight indexing churn -// The right fix is to delete them, not just ignore them. -// -// Silent on the happy path. When files are found, logs: -// -// [sweep-ds-store] swept N .DS_Store file(s): -// ./path/to/.DS_Store -// ... -// -// No bypass — `.DS_Store` is never wanted in a repo. If you have a -// reason to keep one (very rare — testing macOS-specific code), use -// a name like `.DS_Store.fixture` and adjust the test fixture. -// -// Stop hooks receive a JSON payload on stdin but the body shape is -// irrelevant here; we ignore it. Drains the pipe so the upstream -// doesn't buffer-stall. - -import { existsSync, promises as fs } from 'node:fs' -import type { Dirent } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -const TARGET = '.DS_Store' -const EXCLUDE_DIRS = new Set(['.git', 'node_modules']) -const MAX_DEPTH = 12 - -interface SweepResult { - readonly swept: readonly string[] - readonly errors: readonly string[] -} - -/** - * Recursively walk `root`, deleting every `.DS_Store` found. Returns the list - * of deleted paths (relative to `root`) and any per-file delete errors. Never - * throws — Stop hooks must not block the conversation on their own bugs. - * - * `MAX_DEPTH` is a defense against pathological symlink loops; the worktrees we - * run on don't nest anywhere near that deep. - */ -export async function sweepDsStore(root: string): Promise<SweepResult> { - const swept: string[] = [] - const errors: string[] = [] - await walk(root, root, 0, swept, errors) - return { swept, errors } -} - -async function walk( - root: string, - dir: string, - depth: number, - swept: string[], - errors: string[], -): Promise<void> { - if (depth > MAX_DEPTH) { - return - } - let entries: Dirent[] - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - // Permission denied, race with another process, etc. Skip the - // dir; never block the hook. - return - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const name = entry.name - const full = path.join(dir, name) - if (entry.isDirectory()) { - if (EXCLUDE_DIRS.has(name)) { - continue - } - // Avoid following symlinks — keeps the walk to the working - // tree, not whatever a symlink points at. - if (entry.isSymbolicLink()) { - continue - } - await walk(root, full, depth + 1, swept, errors) - continue - } - if (name === TARGET) { - try { - await safeDelete(full) - swept.push(path.relative(root, full)) - } catch (e) { - errors.push(`${path.relative(root, full)}: ${(e as Error).message}`) - } - } - } -} - -async function main(): Promise<void> { - // Drain stdin so the upstream pipe doesn't buffer-stall, but ignore - // the body — Stop hooks pass a JSON payload that we don't need. - process.stdin.resume() - process.stdin.on('data', () => {}) - // Short timeout — if stdin never closes we still want to run. - await new Promise<void>(resolve => { - process.stdin.on('end', () => resolve()) - setTimeout(() => resolve(), 100) - }) - - const root = process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd() - if (!existsSync(root)) { - return - } - const { swept, errors } = await sweepDsStore(root) - if (swept.length === 0 && errors.length === 0) { - return - } - const lines: string[] = [] - if (swept.length > 0) { - lines.push(`[sweep-ds-store] swept ${swept.length} .DS_Store file(s):`) - for (let i = 0, { length } = swept; i < length; i += 1) { - lines.push(` ${swept[i]!}`) - } - } - if (errors.length > 0) { - lines.push(`[sweep-ds-store] ${errors.length} delete error(s):`) - for (let i = 0, { length } = errors; i < length; i += 1) { - lines.push(` ${errors[i]!}`) - } - } - process.stderr.write(lines.join(os.EOL) + os.EOL) -} - -// CLI entrypoint — only fires when this file is the main module so -// the test importer can pull `sweepDsStore` without triggering the -// stdin reader. -if (process.argv[1] && process.argv[1].endsWith('index.mts')) { - main().catch(e => { - process.stderr.write( - `[sweep-ds-store] hook error (allowing): ${(e as Error).message}${os.EOL}`, - ) - }) -} diff --git a/.claude/hooks/sweep-ds-store/package.json b/.claude/hooks/sweep-ds-store/package.json deleted file mode 100644 index cdfcfeb1c..000000000 --- a/.claude/hooks/sweep-ds-store/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-sweep-ds-store", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/sweep-ds-store/test/index.test.mts b/.claude/hooks/sweep-ds-store/test/index.test.mts deleted file mode 100644 index 005bdd12d..000000000 --- a/.claude/hooks/sweep-ds-store/test/index.test.mts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * @file Unit tests for sweepDsStore — the recursive .DS_Store remover used by - * the Stop hook. Uses real temp dirs (cheap, < 50ms total) rather than - * mocks. - */ - -import test from 'node:test' -import assert from 'node:assert/strict' -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { rmSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { sweepDsStore } from '../index.mts' - -function setup(): string { - return mkdtempSync(path.join(os.tmpdir(), 'sweep-ds-store-test-')) -} - -function cleanup(dir: string): void { - try { - rmSync(dir, { force: true, recursive: true }) - } catch { - // best-effort - } -} - -test('sweeps a top-level .DS_Store', async () => { - const root = setup() - try { - writeFileSync(path.join(root, '.DS_Store'), 'binary-junk') - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 1) - assert.equal(result.swept[0], '.DS_Store') - assert.equal(existsSync(path.join(root, '.DS_Store')), false) - } finally { - cleanup(root) - } -}) - -test('sweeps nested .DS_Store files', async () => { - const root = setup() - try { - mkdirSync(path.join(root, 'a', 'b'), { recursive: true }) - writeFileSync(path.join(root, '.DS_Store'), 'x') - writeFileSync(path.join(root, 'a', '.DS_Store'), 'x') - writeFileSync(path.join(root, 'a', 'b', '.DS_Store'), 'x') - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 3) - assert.equal(existsSync(path.join(root, 'a', 'b', '.DS_Store')), false) - } finally { - cleanup(root) - } -}) - -test('skips .git/', async () => { - const root = setup() - try { - mkdirSync(path.join(root, '.git'), { recursive: true }) - writeFileSync(path.join(root, '.git', '.DS_Store'), 'x') - writeFileSync(path.join(root, '.DS_Store'), 'x') - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 1) - assert.equal(result.swept[0], '.DS_Store') - // .git/.DS_Store still exists - assert.equal(existsSync(path.join(root, '.git', '.DS_Store')), true) - } finally { - cleanup(root) - } -}) - -test('skips node_modules/', async () => { - const root = setup() - try { - mkdirSync(path.join(root, 'node_modules', 'foo'), { recursive: true }) - writeFileSync(path.join(root, 'node_modules', 'foo', '.DS_Store'), 'x') - writeFileSync(path.join(root, '.DS_Store'), 'x') - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 1) - assert.equal(result.swept[0], '.DS_Store') - } finally { - cleanup(root) - } -}) - -test('ignores other files with similar names', async () => { - const root = setup() - try { - writeFileSync(path.join(root, '.DS_Store.fixture'), 'x') - writeFileSync(path.join(root, '_DS_Store'), 'x') - writeFileSync(path.join(root, '.ds_store'), 'x') - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 0) - assert.equal(existsSync(path.join(root, '.DS_Store.fixture')), true) - } finally { - cleanup(root) - } -}) - -test('empty directory tree returns empty result', async () => { - const root = setup() - try { - const result = await sweepDsStore(root) - assert.equal(result.swept.length, 0) - assert.equal(result.errors.length, 0) - } finally { - cleanup(root) - } -}) - -test('non-existent root does not throw', async () => { - const result = await sweepDsStore('/nonexistent-path-for-test') - assert.equal(result.swept.length, 0) - assert.equal(result.errors.length, 0) -}) diff --git a/.claude/hooks/sweep-ds-store/tsconfig.json b/.claude/hooks/sweep-ds-store/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/sweep-ds-store/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/token-guard/README.md b/.claude/hooks/token-guard/README.md deleted file mode 100644 index ab713a21a..000000000 --- a/.claude/hooks/token-guard/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# token-guard - -A **Claude Code hook** that runs before every Bash command and -**blocks** the call if the command shape would leak a secret (an API -key, an OAuth token, a JWT, etc.) into Claude's view of stdout. - -> If you haven't worked with Claude Code hooks before: hooks are tiny -> scripts that run at specific lifecycle points. A `PreToolUse` hook -> like this one fires _before_ Claude calls a tool. It can either -> **prime** (write to stderr, exit 0, model carries on) or **block** -> (exit 2, command never runs). This one blocks. The model then sees -> the block reason and rewrites the command. - -## Why this exists - -Claude reads tool output back into its context. If a `cat .env` -prints `STRIPE_KEY=sk_live_…`, the secret is now in conversation -history and could be echoed into a commit, a PR, or a chat reply -later. The cleanest fix is to never print the value at all. - -## What it blocks - -| Rule | Example that gets blocked | What to do instead | -| ------------------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Literal token in the command itself | `echo vtwn_abc123…` | Rotate the exposed token; read tokens from `.env.local` at spawn time, never inline them. | -| `env` / `printenv` / `export -p` / `set` printing everything | `env \| grep FOO` (the grep doesn't redact the value) | `env \| sed 's/=.*/=<redacted>/'`, or filter specific keys you know aren't secret. | -| `.env*` read without a redactor | `cat .env.local` | `sed 's/=.*/=<redacted>/' .env.local`, or just print key names: `grep -v '^#' .env.local \| cut -d= -f1`. | -| `curl -H "Authorization:"` with unfiltered stdout | `curl -H "Authorization: Bearer $TOKEN" api.example.com` | Redirect output (`> file`, `> /dev/null`), or pipe through `jq` / `grep` / `head` / `wc` / `cut` / `awk` so the response body is processed before it hits Claude's stdout. | -| Sensitive env var name in an `echo` / `printf` to stdout | `echo $API_KEY` | Same as above — redirect or pipe. | - -## What it allows - -- Any write to a file (`>`, `>>`, `tee`). -- Any pipe through `jq`, `grep`, `head`, `tail`, `wc`, `cut`, `awk`, - `sed s/=.*/=<redacted>/`, `python3 -m json.tool`. -- Legitimate `git` / `pnpm` / `npm` / `node` / `tsc` / `oxfmt` / - `oxlint` invocations that happen to reference env var names but - don't echo values. -- Any `curl` call that does not carry an `Authorization:` header. - -## Detected token shapes - -If a literal value matching one of these prefixes appears in a Bash -command, it gets blocked outright (the assumption being that a value -this shape is not idle text): - -| Provider | Prefix | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Val Town | `vtwn_` | -| Linear | `lin_api_` | -| OpenAI / Anthropic | `sk-` (20+ chars) | -| Stripe | `sk_live_`, `sk_test_`, `pk_live_`, `rk_live_` | -| GitHub | `ghp_`, `gho_`, `ghs_`, `ghu_`, `ghr_`, `github_pat_` (`ghs_`/`ghu_` match both classic opaque + new JWT format per the 2026-05-15 token-format rollout) | -| GitLab | `glpat-` | -| AWS | `AKIA…` | -| Slack | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` | -| Google | `AIza…` | -| JWTs | three-segment `eyJ…` | - -## Fail-open on hook bugs - -If the hook itself crashes (a parse error, a missing dep, a typo in -a regex), it writes a log line and exits `0` — i.e. _the command is -allowed_. The reasoning: a buggy security hook that blocks -everything is a worse outcome than a buggy security hook that -temporarily lets things through. The companion enforcement layers -(`pre-push` git hook, secret scanners in CI) catch what slips past. - -## Testing - -```bash -pnpm --filter hook-token-guard test -``` - -Adding a new token-shape detection: add an entry to -`LITERAL_TOKEN_PATTERNS` in `index.mts`, then add a positive and a -negative test in `test/token-guard.test.mts`. - -## Cross-fleet sync - -This README and the hook itself live in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/token-guard) -and are required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. - -To propagate a change from the template to every fleet repo: - -```bash -node scripts/sync-scaffolding.mts --all --fix -``` diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/token-guard/index.mts deleted file mode 100644 index cec194c06..000000000 --- a/.claude/hooks/token-guard/index.mts +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — token-guard firewall. -// -// Blocks Bash commands that would echo token-bearing env vars into -// tool output. This fires BEFORE the command runs; exit code 2 makes -// Claude Code refuse the tool call. The model sees the rejection -// reason on stderr and retries with a redacted formulation. -// -// Blocked patterns: -// - Literal token shapes in the command string (vtwn_, lin_api_, -// sk-, ghp_, AKIA, xox, AIza, JWT, etc.) — hardest block, logs -// a redacted message and urges rotation -// - `env`, `printenv`, `export -p`, `set` with no filter pipeline -// - `cat` / `head` / `tail` / `less` / `more` of .env* files -// without a redaction step -// - `curl -H "Authorization: ..."` with output going to unfiltered -// stdout (not /dev/null, not a file, not piped to jq/grep/etc.) -// - Commands referencing a sensitive env var name (*TOKEN*, -// *SECRET*, *PASSWORD*, *API_KEY*, *SIGNING_KEY*, *PRIVATE_KEY*, -// *AUTH*, *CREDENTIAL*) that write to stdout without redaction -// -// Control flow uses a `BlockError` thrown from check helpers so every -// short-circuit path goes through a single `process.exitCode = 2` -// drop at the top-level catch — no scattered `process.exit(2)` that -// can race with buffered stderr. - -import process from 'node:process' - -import { SENSITIVE_NAME_FRAGMENTS } from '../_shared/token-patterns.mts' - -// Name fragments matched case-insensitively against the command. -// Sourced from the shared catalog in `_shared/token-patterns.mts` so -// every hook that scans for secret-bearing names uses one list. -const SENSITIVE_ENV_NAMES = SENSITIVE_NAME_FRAGMENTS - -// Pipelines that "launder" earlier-stage secrets into safe output. -// The first two patterns match `sed 's/.../redact.../'` and -// `sed 's/.../FOO=*****/'` regardless of which delimiter sed uses -// (`/`, `#`, `|`). `[\s\S]*?` reaches across the delimiter between -// the search and replacement parts (the previous `[^/|#]*` couldn't -// cross `/` and so missed the canonical `sed 's/=.*/=<redacted>/'` -// — the very command the token-guard error message suggests). -const REDACTION_MARKERS = [ - /\bsed\b[^|]*s[/|#][\s\S]*?<?redact/i, - /\bsed\b[^|]*s[/|#][\s\S]*?[A-Z_]+=[\s\S]*?\*{3,}/i, - /\|\s*cut\b[^|]*-d['"]?=['"]?\s*-f\s*1/i, - /\|\s*awk\b[^|]*-F\s*['"]?=['"]?/i, - />\s*\/dev\/null/, - />>\s*[^|]/, - />\s*[^|]/, -] - -// Commands that dump all env vars to stdout with no filter. -const ALWAYS_DANGEROUS = [ - /^\s*env\s*(?:\||&&|;|$)/, - /^\s*env\s*$/, - /^\s*printenv\s*(?:\||&&|;|$)/, - /^\s*printenv\s*$/, - /^\s*export\s+-p\s*(?:\||&&|;|$)/, - /^\s*set\s*(?:\||&&|;|$)/, -] - -// Plain reads of .env files that would dump values to stdout. -const ENV_FILE_READ = /\b(?:bat|cat|head|less|more|tail)\b[^|]*\.env[^/\s|]*/ - -// curl calls that include an Authorization header. -const CURL_WITH_AUTH = - /\bcurl\b(?:[^|]|\|(?!\s*(?:grep|head|jq|sed|tail)))*(?:--header|-H)\s*['"]?Authorization:/i - -// Literal token-shape patterns — if any match in the command string, -// a real token has been pasted somewhere it shouldn't have been. -const LITERAL_TOKEN_PATTERNS: Array<[RegExp, string]> = [ - [/\bvtwn_[A-Za-z0-9_-]{8,}/, 'Val Town token (vtwn_)'], - [/\blin_api_[A-Za-z0-9_-]{8,}/, 'Linear API token (lin_api_)'], - [/\bsk-[A-Za-z0-9_-]{20,}/, 'OpenAI/Anthropic-style secret key (sk-)'], - [/\bsk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live secret (sk_live_)'], - [/\bsk_test_[A-Za-z0-9_-]{16,}/, 'Stripe test secret (sk_test_)'], - [/\bpk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live publishable (pk_live_)'], - [/\brk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live restricted (rk_live_)'], - [/\bghp_[A-Za-z0-9]{30,}/, 'GitHub personal access token (ghp_)'], - [/\bgho_[A-Za-z0-9]{30,}/, 'GitHub OAuth token (gho_)'], - // ghs_ and ghu_ char classes include `.` and `_` to match both the - // classic opaque format AND the new stateless JWT format GitHub is - // rolling out (announced 2026-04, opt-in via X-GitHub-Stateless-S2S-Token - // header per 2026-05-15 changelog). JWT-format tokens are ~520 chars - // and contain two dots; classic opaque tokens are short and have no - // dots. The recommended regex from GitHub's docs is - // `ghs_[A-Za-z0-9\._]{36,}` — 36 is the minimum for both formats. - // Same applies to ghu_ prophylactically since user-to-server tokens - // are scheduled for the same format change (timing TBD per changelog). - [/\bghs_[A-Za-z0-9._]{36,}/, 'GitHub app server token (ghs_)'], - [/\bghu_[A-Za-z0-9._]{36,}/, 'GitHub user access token (ghu_)'], - [/\bghr_[A-Za-z0-9]{30,}/, 'GitHub refresh token (ghr_)'], - [/\bgithub_pat_[A-Za-z0-9_]{20,}/, 'GitHub fine-grained PAT'], - [/\bglpat-[A-Za-z0-9_-]{16,}/, 'GitLab PAT (glpat-)'], - [/\bAKIA[0-9A-Z]{16}/, 'AWS access key ID (AKIA)'], - [/\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token (xox_-)'], - [/\bAIza[0-9A-Za-z_-]{35}/, 'Google API key (AIza)'], - [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, 'JWT'], -] - -class BlockError extends Error { - public readonly rule: string - public readonly suggestion: string - public readonly showCommand: boolean - constructor(rule: string, suggestion: string, showCommand = true) { - super(rule) - this.name = 'BlockError' - this.rule = rule - this.suggestion = suggestion - this.showCommand = showCommand - } -} - -export function stdin(): Promise<string> { - return new Promise<string>(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => (buf += chunk)) - process.stdin.on('end', () => resolve(buf)) - }) -} - -type ToolInput = { - tool_name?: string | undefined - tool_input?: { command?: string | undefined } | undefined -} - -export function hasRedaction(command: string) { - return REDACTION_MARKERS.some(re => re.test(command)) -} - -// Env-var-context match: only fire when a sensitive keyword appears -// in a position that ACTUALLY references an env var. Possible contexts: -// - `$TOKEN` / `${TOKEN}` / `${TOKEN:-default}` -// - `TOKEN=value` / `export TOKEN=value` -// - `env TOKEN` / `printenv TOKEN` / `unset TOKEN` -// - `ENV['TOKEN']` / `ENV["TOKEN"]` / `ENV.fetch('TOKEN')` (Ruby) -// -// The previous version matched the fragment as a SUBSTRING of the -// env-var name (`[A-Z0-9_]*FRAG[A-Z0-9_]*`). That tripped `$AUTHOR_NAME` -// on `AUTH` (because AUTH is a prefix of AUTHOR) and `$PASSAGE_TIME` -// on `PASS`. -// -// Env-var names are conventionally underscore-segmented tokens -// (`ACCESS_TOKEN`, `API_KEY`). For a fragment to be sensitive it -// must occupy one or more WHOLE underscore-delimited tokens — not a -// substring of a single token. Boundary chars inside the name are -// therefore `^`, `$`, or `_`; letters/digits adjacent to the fragment -// mean it's part of a larger word (`AUTH` inside `AUTHOR`) so it -// doesn't count. -// -// Plain-prose occurrences ("tests pass") still don't trigger because -// the env-var sigils (`$`, `${`, `=`, `env`/`printenv`/etc., `ENV[`) -// gate every match. -const NAME_BODY = String.raw`(?:[A-Z0-9_]*_)?` // optional leading tokens -const NAME_TAIL = String.raw`(?:_[A-Z0-9_]*)?` // optional trailing tokens -const sensitiveEnvBoundaryRes = SENSITIVE_ENV_NAMES.map(frag => { - const NAME = `${NAME_BODY}${frag}${NAME_TAIL}` - return new RegExp( - String.raw`(?:` + - // $NAME or ${NAME} or ${NAME:-...} or ${NAME:=...} etc. - String.raw`\$\{?${NAME}(?:[:}\W]|$)` + - // NAME= (assignment; whitespace allowed before =). - String.raw`|(?:^|\s|;|&|\|)${NAME}\s*=` + - // env NAME / printenv NAME / unset NAME / export NAME - String.raw`|\b(?:env|printenv|unset|export)\s+${NAME}\b` + - // Ruby ENV[...] / ENV.fetch(...) with the name in single or - // double quotes: ENV['ACCESS_TOKEN'], ENV["TOKEN"], etc. - String.raw`|\bENV(?:\.FETCH)?\s*[\[(]\s*['"]${NAME}['"]` + - String.raw`)`, - ) -}) -export function referencesSensitiveEnv(command: string) { - const upper = command.toUpperCase() - return sensitiveEnvBoundaryRes.some(re => re.test(upper)) -} - -export function matchesAlwaysDangerous(command: string) { - for (let i = 0, { length } = ALWAYS_DANGEROUS; i < length; i += 1) { - const re = ALWAYS_DANGEROUS[i]! - if (re.test(command)) { - return re - } - } - return undefined -} - -export function check(command: string) { - // 0. Literal token-shape in the command string — hardest block. - // A real token value already landed in the command, which itself is - // logged. We refuse to echo it further and urge rotation. - for (const [pattern, label] of LITERAL_TOKEN_PATTERNS) { - if (pattern.test(command)) { - throw new BlockError( - `literal ${label} found in command string`, - 'Rotate the exposed token immediately. Never paste tokens into commands; read them from .env.local or a keychain at subprocess spawn time.', - false, - ) - } - } - - // 1. Always-dangerous patterns. Skip when the command already has a - // redaction pipeline — the suggested fix here is `env | sed ...`, - // which would itself match ALWAYS_DANGEROUS without this guard. - const dangerous = matchesAlwaysDangerous(command) - if (dangerous && !hasRedaction(command)) { - throw new BlockError( - `\`${dangerous.source}\` dumps env to stdout`, - 'Pipe through redaction, e.g. `env | sed "s/=.*/=<redacted>/"` or filter specific keys.', - ) - } - - // 2. .env file reads without redaction. - if (ENV_FILE_READ.test(command) && !hasRedaction(command)) { - throw new BlockError( - '.env file read without a redaction pipeline', - 'Use `sed "s/=.*/=<redacted>/" .env.local` or `grep -v "^#" .env.local | cut -d= -f1` for key names only.', - ) - } - - // 3. curl with Authorization header and unsanitized stdout. - const curlHasAuth = CURL_WITH_AUTH.test(command) - const curlOutputSafe = - />\s*\/dev\/null|>\s*[^|&]/.test(command) || - /\|\s*(?:jq|grep|head|tail|wc|cut|awk|python3?\s+-m\s+json\.tool)\b/.test( - command, - ) - if (curlHasAuth && !curlOutputSafe) { - throw new BlockError( - 'curl with Authorization header and unsanitized stdout', - 'Redirect response to /dev/null, pipe to jq/grep/head, or save to a file.', - ) - } - - // 4. References a sensitive env var name and writes to stdout - // without a redaction step. Skip when curl-with-auth passed — that - // rule already evaluated the same pipeline. - if ( - !curlHasAuth && - referencesSensitiveEnv(command) && - !hasRedaction(command) - ) { - const isPureWrite = /^\s*(?:git|node|npm|oxfmt|oxlint|pnpm|tsc)\b/.test( - command, - ) - if (!isPureWrite) { - throw new BlockError( - 'command references sensitive env var name and writes to stdout without redaction', - 'Redirect to a file, pipe through `sed "s/=.*/=<redacted>/"`, or ensure only key names (not values) are printed.', - ) - } - } -} - -export function emitBlock(command: string, err: BlockError) { - const safeCommand = err.showCommand - ? command.slice(0, 200) + (command.length > 200 ? '…' : '') - : '<command suppressed to avoid re-logging the literal token>' - process.stderr.write( - `\n[token-guard] Blocked: ${err.rule}\n` + - ` Command: ${safeCommand}\n` + - ` Fix: ${err.suggestion}\n\n`, - ) -} - -async function main() { - const raw = await stdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Bash') { - return - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return - } - - try { - check(command) - } catch (e) { - if (e instanceof BlockError) { - emitBlock(command, e) - process.exitCode = 2 - return - } - throw e - } -} - -main().catch(e => { - // Never block a tool call due to a bug in the hook itself. Log it - // so we notice, but fail open. - process.stderr.write(`[token-guard] hook error (allowing): ${e}\n`) - process.exitCode = 0 -}) diff --git a/.claude/hooks/token-guard/package.json b/.claude/hooks/token-guard/package.json deleted file mode 100644 index fc68951d8..000000000 --- a/.claude/hooks/token-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-token-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/token-guard/test/token-guard.test.mts deleted file mode 100644 index 475cafbfa..000000000 --- a/.claude/hooks/token-guard/test/token-guard.test.mts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @file Tests for the token-guard hook. Runs the hook as a subprocess (node - * --test), piping a tool-use payload on stdin and asserting on the exit code - * + stderr. Exit 2 means the hook refused the command; exit 0 means it passed - * it through. - */ - -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -import { whichSync } from '@socketsecurity/lib-stable/bin/which' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const hookScript = new URL('../index.mts', import.meta.url).pathname -const nodeBinRaw = whichSync('node') -if (!nodeBinRaw || typeof nodeBinRaw !== 'string') { - throw new Error('"node" not found on PATH') -} -const nodeBin: string = nodeBinRaw - -function runHook( - command: string, - toolName = 'Bash', -): { - code: number | null - stdout: string - stderr: string -} { - const input = JSON.stringify({ - tool_name: toolName, - tool_input: { command }, - }) - const result = spawnSync(nodeBin, [hookScript], { - input, - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - return { - code: result.status, - stdout: (result.stdout || '').toString(), - stderr: (result.stderr || '').toString(), - } -} - -describe('token-guard hook', () => { - describe('allows safe commands', () => { - it('plain echo', () => { - assert.equal(runHook('echo hello').code, 0) - }) - it('git log', () => { - assert.equal(runHook('git log -1 --oneline').code, 0) - }) - it('pnpm install', () => { - assert.equal(runHook('pnpm install').code, 0) - }) - it('node script', () => { - assert.equal(runHook('node scripts/build.mts').code, 0) - }) - it('sed with redaction on .env', () => { - assert.equal(runHook("sed 's/=.*/=<redacted>/' .env.local").code, 0) - }) - it('grep key-names-only on .env', () => { - assert.equal(runHook("grep -v '^#' .env.local | cut -d= -f1").code, 0) - }) - it('curl without Authorization header', () => { - assert.equal(runHook('curl -sS https://api.example.com').code, 0) - }) - it('curl with auth piped to jq', () => { - assert.equal( - runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com | jq .name', - ).code, - 0, - ) - }) - it('curl with auth redirected to file', () => { - assert.equal( - runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com > out.json', - ).code, - 0, - ) - }) - it('non-Bash tool is always allowed', () => { - assert.equal(runHook('env', 'Edit').code, 0) - }) - }) - - describe('blocks literal token shapes', () => { - it('Val Town token', () => { - const r = runHook('echo vtwn_ABCDEFGHIJKL') - assert.equal(r.code, 2) - assert.match(r.stderr, /Val Town token/) - }) - it('Linear API token', () => { - const r = runHook('echo lin_api_ABCDEFGHIJKLMNOP') - assert.equal(r.code, 2) - assert.match(r.stderr, /Linear API token/) - }) - it('GitHub PAT', () => { - const r = runHook('echo ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcd1234') - assert.equal(r.code, 2) - assert.match(r.stderr, /GitHub personal access token/) - }) - it('GitHub app server token (ghs_) — classic opaque format', () => { - // Classic format: opaque string, no dots, no underscores. Real - // `ghs_` server tokens are 36+ chars after the prefix; the - // minimum-length floor in the regex matches both classic and - // new JWT-format tokens. - const r = runHook('echo ghs_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij') - assert.equal(r.code, 2) - assert.match(r.stderr, /GitHub app server token/) - }) - it('GitHub app server token (ghs_) — new JWT format with dots', () => { - // New stateless JWT format (2026 rollout): ghs_ prefix + JWT body - // with two dots. Recommended detection regex per GitHub docs is - // `ghs_[A-Za-z0-9\._]{36,}`. Real JWTs are ~520 chars; this fixture - // is a shorter synthetic that still hits both characteristics - // (length >= 36, contains dots). - const r = runHook( - 'echo ghs_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_part_abcdef123456', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /GitHub app server token/) - }) - it('GitHub user access token (ghu_) — JWT format prophylactic', () => { - // User-to-server tokens are scheduled for the same JWT format - // change per the 2026-05-15 changelog (timing TBD). The ghu_ - // pattern uses the same char class so the future rollout is - // covered when it ships. - const r = runHook( - 'echo ghu_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature_part_abcdef123456', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /GitHub user access token/) - }) - it('AWS access key', () => { - const r = runHook('echo AKIAIOSFODNN7EXAMPLE') - assert.equal(r.code, 2) - assert.match(r.stderr, /AWS access key/) - }) - it('Stripe test secret', () => { - const r = runHook('echo sk_test_ABCDEFGHIJKLMNOP') - assert.equal(r.code, 2) - assert.match(r.stderr, /Stripe test secret/) - }) - it('JWT', () => { - const r = runHook( - 'echo eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /JWT/) - }) - it('redacts the command in stderr so the literal token is not re-logged', () => { - const r = runHook('echo vtwn_SECRETVALUE') - assert.equal(r.code, 2) - assert.doesNotMatch(r.stderr, /SECRETVALUE/) - assert.match(r.stderr, /suppressed/) - }) - }) - - describe('blocks env/printenv dumps', () => { - it('bare env', () => { - assert.equal(runHook('env').code, 2) - }) - it('env piped without redactor', () => { - assert.equal(runHook('env | grep FOO').code, 2) - }) - it('printenv', () => { - assert.equal(runHook('printenv').code, 2) - }) - it('export -p', () => { - assert.equal(runHook('export -p').code, 2) - }) - }) - - describe('blocks .env reads without redaction', () => { - it('cat .env.local', () => { - assert.equal(runHook('cat .env.local').code, 2) - }) - it('head .env', () => { - assert.equal(runHook('head .env').code, 2) - }) - it('less .env.production', () => { - assert.equal(runHook('less .env.production').code, 2) - }) - }) - - describe('blocks curl with auth to unfiltered stdout', () => { - it('plain curl -H Authorization', () => { - const r = runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /Authorization header and unsanitized stdout/) - }) - }) - - describe('blocks sensitive-env-name references without redaction', () => { - it('echoing $API_KEY', () => { - assert.equal(runHook('echo $API_KEY').code, 2) - }) - it('ruby -e with $TOKEN', () => { - assert.equal(runHook('ruby -e "puts ENV[\'ACCESS_TOKEN\']"').code, 2) - }) - }) - - describe('does not false-positive on substring of sensitive name', () => { - // Regression: `PATHS-ALLOWLIST.YML` toUpperCase()d contains `PASS` - // as a substring, which the pre-fix unbounded match treated as - // a sensitive env reference. Word-boundary fix means `PASS` must - // be a standalone token (or at a `_`/`-`/`.`/`/` boundary). - it('paths-allowlist.yml does not trip PASS', () => { - assert.equal(runHook('cat .github/paths-allowlist.yml').code, 0) - }) - it('AUTHOR_NAME does not trip AUTH', () => { - // AUTHOR ends with R; the boundary-after match correctly skips - // it because the next char is `_`, but `AUTH` followed by `O` - // (alphanumeric) is not a token boundary. - assert.equal(runHook('echo $AUTHOR_NAME').code, 0) - }) - it('PASSAGE_TIME does not trip PASS', () => { - assert.equal(runHook('echo $PASSAGE_TIME').code, 0) - }) - }) - - describe('fails open on malformed input', () => { - it('empty stdin', () => { - const r = spawnSync(nodeBin, [hookScript], { - input: '', - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - assert.equal(r.status, 0) - }) - it('non-JSON stdin', () => { - const r = spawnSync(nodeBin, [hookScript], { - input: 'not json', - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - assert.equal(r.status, 0) - }) - it('empty command', () => { - assert.equal(runHook('').code, 0) - }) - }) -}) diff --git a/.claude/hooks/token-guard/tsconfig.json b/.claude/hooks/token-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/token-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/token-hygiene/README.md b/.claude/hooks/token-hygiene/README.md deleted file mode 100644 index f963f198b..000000000 --- a/.claude/hooks/token-hygiene/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# token-hygiene - -Claude Code `PreToolUse` hook that refuses Bash tool calls that would leak secrets to tool output. Mandatory across the Socket fleet — every repo ships this file byte-for-byte via `scripts/sync-scaffolding.mjs`. - -## What it blocks - -| Rule | Example | Fix | -| -------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Literal token in command | `echo vtwn_abc123…` | Rotate the exposed token; read tokens from `.env.local` at spawn time, never inline them | -| `env`/`printenv`/`export -p`/`set` dumping everything | `env \| grep FOO` (unredacted) | `env \| sed 's/=.*/=<redacted>/'` or filter specific keys | -| `.env*` read without redactor | `cat .env.local` | `sed 's/=.*/=<redacted>/' .env.local` or `grep -v '^#' .env.local \| cut -d= -f1` | -| `curl -H "Authorization:"` with unfiltered stdout | `curl -H "Authorization: Bearer $TOKEN" api.example.com` | Redirect to file/`/dev/null`, or pipe to `jq`/`grep`/`head`/`wc`/`cut`/`awk` | -| References sensitive env var name writing unredacted to stdout | `echo $API_KEY` | Same as above | - -## What it allows - -- Any write to a file (`>`, `>>`, `tee`) -- Any pipe through `jq`, `grep`, `head`, `tail`, `wc`, `cut`, `awk`, `sed s/=.*/=<redacted>/`, `python3 -m json.tool` -- Legitimate `git`/`pnpm`/`npm`/`node`/`tsc`/`oxfmt`/`oxlint` invocations that happen to reference env var names but don't echo values -- Any curl call that does not carry an `Authorization:` header - -## Detected token shapes - -Literal value patterns caught in-command: - -- Val Town — `vtwn_` -- Linear — `lin_api_` -- OpenAI / Anthropic — `sk-` (20+ chars) -- Stripe — `sk_live_`, `sk_test_`, `pk_live_`, `rk_live_` -- GitHub — `ghp_`, `gho_`, `ghs_`, `ghu_`, `ghr_`, `github_pat_` -- GitLab — `glpat-` -- AWS — `AKIA…` -- Slack — `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` -- Google — `AIza…` -- JWTs — three-segment `eyJ…` - -## Control flow - -The hook reads the tool-use payload from stdin, type-checks `tool_name === 'Bash'`, and runs `check(command)`. Any rule violation `throw`s a typed `BlockError`; a single top-level `try/catch` in `main()` writes the block message to stderr and sets `process.exitCode = 2`. Hook bugs fail **open** — a crash in the hook writes a log line and returns exit 0 so legitimate work isn't blocked on a bad deploy. - -## Testing - -```bash -pnpm --filter @socketsecurity/hook-token-hygiene test -``` - -Adding new token-shape detections: update `LITERAL_TOKEN_PATTERNS` in `index.mts`, add a positive and negative test in `test/token-hygiene.test.mts`. - -## Updating across the fleet - -This file is in `IDENTICAL_FILES` in `scripts/sync-scaffolding.mjs`. After editing, run from `socket-wheelhouse`: - -```bash -node scripts/sync-scaffolding.mjs --all --fix -``` - -to propagate the change to every fleet repo. diff --git a/.claude/hooks/token-hygiene/index.mts b/.claude/hooks/token-hygiene/index.mts deleted file mode 100644 index f9260547f..000000000 --- a/.claude/hooks/token-hygiene/index.mts +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — token-hygiene firewall. -// -// Blocks Bash commands that would echo token-bearing env vars into -// tool output. This fires BEFORE the command runs; exit code 2 makes -// Claude Code refuse the tool call. The model sees the rejection -// reason on stderr and retries with a redacted formulation. -// -// Blocked patterns: -// - Literal token shapes in the command string (vtwn_, lin_api_, -// sk-, ghp_, AKIA, xox, AIza, JWT, etc.) — hardest block, logs -// a redacted message and urges rotation -// - `env`, `printenv`, `export -p`, `set` with no filter pipeline -// - `cat` / `head` / `tail` / `less` / `more` of .env* files -// without a redaction step -// - `curl -H "Authorization: ..."` with output going to unfiltered -// stdout (not /dev/null, not a file, not piped to jq/grep/etc.) -// - Commands referencing a sensitive env var name (*TOKEN*, -// *SECRET*, *PASSWORD*, *API_KEY*, *SIGNING_KEY*, *PRIVATE_KEY*, -// *AUTH*, *CREDENTIAL*) that write to stdout without redaction -// -// Control flow uses a `BlockError` thrown from check helpers so every -// short-circuit path goes through a single `process.exitCode = 2` -// drop at the top-level catch — no scattered `process.exit(2)` that -// can race with buffered stderr. - -import process from 'node:process' - -// Name fragments matched case-insensitively against the command. -const SENSITIVE_ENV_NAMES = [ - 'TOKEN', - 'SECRET', - 'PASSWORD', - 'PASS', - 'API_KEY', - 'APIKEY', - 'SIGNING_KEY', - 'PRIVATE_KEY', - 'AUTH', - 'CREDENTIAL', -] - -// Pipelines that "launder" earlier-stage secrets into safe output. -const REDACTION_MARKERS = [ - /\bsed\b[^|]*s[/|#][^/|#]*=[^/|#]*<?redact/i, - /\bsed\b[^|]*s[/|#][^/|#]*[A-Z_]+=[^/|#]*\*+/i, - /\|\s*cut\b[^|]*-d['"]?=['"]?\s*-f\s*1/i, - /\|\s*awk\b[^|]*-F\s*['"]?=['"]?/i, - />\s*\/dev\/null/, - />>\s*[^|]/, - />\s*[^|]/, -] - -// Commands that dump all env vars to stdout with no filter. -const ALWAYS_DANGEROUS = [ - /^\s*env\s*(?:\||&&|;|$)/, - /^\s*env\s*$/, - /^\s*printenv\s*(?:\||&&|;|$)/, - /^\s*printenv\s*$/, - /^\s*export\s+-p\s*(?:\||&&|;|$)/, - /^\s*set\s*(?:\||&&|;|$)/, -] - -// Plain reads of .env files that would dump values to stdout. -const ENV_FILE_READ = /\b(?:cat|head|tail|less|more|bat)\b[^|]*\.env[^/\s|]*/ - -// curl calls that include an Authorization header. -const CURL_WITH_AUTH = - /\bcurl\b(?:[^|]|\|(?!\s*(?:sed|grep|head|tail|jq)))*(?:-H|--header)\s*['"]?Authorization:/i - -// Literal token-shape patterns — if any match in the command string, -// a real token has been pasted somewhere it shouldn't have been. -const LITERAL_TOKEN_PATTERNS: Array<[RegExp, string]> = [ - [/\bvtwn_[A-Za-z0-9_-]{8,}/, 'Val Town token (vtwn_)'], - [/\blin_api_[A-Za-z0-9_-]{8,}/, 'Linear API token (lin_api_)'], - [/\bsk-[A-Za-z0-9_-]{20,}/, 'OpenAI/Anthropic-style secret key (sk-)'], - [/\bsk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live secret (sk_live_)'], - [/\bsk_test_[A-Za-z0-9_-]{16,}/, 'Stripe test secret (sk_test_)'], - [/\bpk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live publishable (pk_live_)'], - [/\brk_live_[A-Za-z0-9_-]{16,}/, 'Stripe live restricted (rk_live_)'], - [/\bghp_[A-Za-z0-9]{30,}/, 'GitHub personal access token (ghp_)'], - [/\bgho_[A-Za-z0-9]{30,}/, 'GitHub OAuth token (gho_)'], - [/\bghs_[A-Za-z0-9]{30,}/, 'GitHub app server token (ghs_)'], - [/\bghu_[A-Za-z0-9]{30,}/, 'GitHub user access token (ghu_)'], - [/\bghr_[A-Za-z0-9]{30,}/, 'GitHub refresh token (ghr_)'], - [/\bgithub_pat_[A-Za-z0-9_]{20,}/, 'GitHub fine-grained PAT'], - [/\bglpat-[A-Za-z0-9_-]{16,}/, 'GitLab PAT (glpat-)'], - [/\bAKIA[0-9A-Z]{16}/, 'AWS access key ID (AKIA)'], - [/\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token (xox_-)'], - [/\bAIza[0-9A-Za-z_-]{35}/, 'Google API key (AIza)'], - [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, 'JWT'], -] - -class BlockError extends Error { - public readonly rule: string - public readonly suggestion: string - public readonly showCommand: boolean - constructor(rule: string, suggestion: string, showCommand = true) { - super(rule) - this.name = 'BlockError' - this.rule = rule - this.suggestion = suggestion - this.showCommand = showCommand - } -} - -const stdin = (): Promise<string> => - new Promise(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => (buf += chunk)) - process.stdin.on('end', () => resolve(buf)) - }) - -type ToolInput = { - tool_name?: string - tool_input?: { command?: string } -} - -const hasRedaction = (command: string): boolean => - REDACTION_MARKERS.some(re => re.test(command)) - -const referencesSensitiveEnv = (command: string): boolean => { - const upper = command.toUpperCase() - return SENSITIVE_ENV_NAMES.some(frag => upper.includes(frag)) -} - -const matchesAlwaysDangerous = (command: string): RegExp | null => { - for (const re of ALWAYS_DANGEROUS) { - if (re.test(command)) { - return re - } - } - return null -} - -const check = (command: string): void => { - // 0. Literal token-shape in the command string — hardest block. - // A real token value already landed in the command, which itself is - // logged. We refuse to echo it further and urge rotation. - for (const [pattern, label] of LITERAL_TOKEN_PATTERNS) { - if (pattern.test(command)) { - throw new BlockError( - `literal ${label} found in command string`, - 'Rotate the exposed token immediately. Never paste tokens into commands; read them from .env.local or a keychain at subprocess spawn time.', - false, - ) - } - } - - // 1. Always-dangerous patterns. - const dangerous = matchesAlwaysDangerous(command) - if (dangerous) { - throw new BlockError( - `\`${dangerous.source}\` dumps env to stdout`, - 'Pipe through redaction, e.g. `env | sed "s/=.*/=<redacted>/"` or filter specific keys.', - ) - } - - // 2. .env file reads without redaction. - if (ENV_FILE_READ.test(command) && !hasRedaction(command)) { - throw new BlockError( - '.env file read without a redaction pipeline', - 'Use `sed "s/=.*/=<redacted>/" .env.local` or `grep -v "^#" .env.local | cut -d= -f1` for key names only.', - ) - } - - // 3. curl with Authorization header and unsanitized stdout. - const curlHasAuth = CURL_WITH_AUTH.test(command) - const curlOutputSafe = - />\s*\/dev\/null|>\s*[^|&]/.test(command) || - /\|\s*(?:jq|grep|head|tail|wc|cut|awk|python3?\s+-m\s+json\.tool)\b/.test( - command, - ) - if (curlHasAuth && !curlOutputSafe) { - throw new BlockError( - 'curl with Authorization header and unsanitized stdout', - 'Redirect response to /dev/null, pipe to jq/grep/head, or save to a file.', - ) - } - - // 4. References a sensitive env var name and writes to stdout - // without a redaction step. Skip when curl-with-auth passed — that - // rule already evaluated the same pipeline. - if ( - !curlHasAuth && - referencesSensitiveEnv(command) && - !hasRedaction(command) - ) { - const isPureWrite = /^\s*(?:git|pnpm|npm|node|tsc|oxfmt|oxlint)\b/.test( - command, - ) - if (!isPureWrite) { - throw new BlockError( - 'command references sensitive env var name and writes to stdout without redaction', - 'Redirect to a file, pipe through `sed "s/=.*/=<redacted>/"`, or ensure only key names (not values) are printed.', - ) - } - } -} - -const emitBlock = (command: string, err: BlockError): void => { - const safeCommand = err.showCommand - ? command.slice(0, 200) + (command.length > 200 ? '…' : '') - : '<command suppressed to avoid re-logging the literal token>' - process.stderr.write( - `\n[token-hygiene] Blocked: ${err.rule}\n` + - ` Command: ${safeCommand}\n` + - ` Fix: ${err.suggestion}\n\n`, - ) -} - -const main = async (): Promise<void> => { - const raw = await stdin() - if (!raw) { - return - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - return - } - if (payload.tool_name !== 'Bash') { - return - } - const command = payload.tool_input?.command ?? '' - if (!command) { - return - } - - try { - check(command) - } catch (e) { - if (e instanceof BlockError) { - emitBlock(command, e) - process.exitCode = 2 - return - } - throw e - } -} - -main().catch(e => { - // Never block a tool call due to a bug in the hook itself. Log it - // so we notice, but fail open. - process.stderr.write(`[token-hygiene] hook error (allowing): ${e}\n`) - process.exitCode = 0 -}) diff --git a/.claude/hooks/token-hygiene/package.json b/.claude/hooks/token-hygiene/package.json deleted file mode 100644 index e09731457..000000000 --- a/.claude/hooks/token-hygiene/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@socketsecurity/hook-token-hygiene", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@socketsecurity/lib-stable": "catalog:", - "@types/node": "24.9.2" - } -} diff --git a/.claude/hooks/token-hygiene/test/token-hygiene.test.mts b/.claude/hooks/token-hygiene/test/token-hygiene.test.mts deleted file mode 100644 index 473121f14..000000000 --- a/.claude/hooks/token-hygiene/test/token-hygiene.test.mts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file Tests for the token-hygiene hook. Runs the hook as a subprocess (node - * --test), piping a tool-use payload on stdin and asserting on the exit code - * + stderr. Exit 2 means the hook refused the command; exit 0 means it passed - * it through. - */ - -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -import { whichSync } from '@socketsecurity/lib-stable/bin/which' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const hookScript = new URL('../index.mts', import.meta.url).pathname -const nodeBin = whichSync('node') -if (!nodeBin) { - throw new Error('"node" not found on PATH') -} - -function runHook( - command: string, - toolName = 'Bash', -): { - code: number | null - stdout: string - stderr: string -} { - const input = JSON.stringify({ - tool_name: toolName, - tool_input: { command }, - }) - const result = spawnSync(nodeBin, [hookScript], { - input, - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - return { - code: result.status, - stdout: (result.stdout || '').toString(), - stderr: (result.stderr || '').toString(), - } -} - -describe('token-hygiene hook', () => { - describe('allows safe commands', () => { - it('plain echo', () => { - assert.equal(runHook('echo hello').code, 0) - }) - it('git log', () => { - assert.equal(runHook('git log -1 --oneline').code, 0) - }) - it('pnpm install', () => { - assert.equal(runHook('pnpm install').code, 0) - }) - it('node script', () => { - assert.equal(runHook('node scripts/build.mts').code, 0) - }) - it('sed with redaction on .env', () => { - assert.equal(runHook("sed 's/=.*/=<redacted>/' .env.local").code, 0) - }) - it('grep key-names-only on .env', () => { - assert.equal(runHook("grep -v '^#' .env.local | cut -d= -f1").code, 0) - }) - it('curl without Authorization header', () => { - assert.equal(runHook('curl -sS https://api.example.com').code, 0) - }) - it('curl with auth piped to jq', () => { - assert.equal( - runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com | jq .name', - ).code, - 0, - ) - }) - it('curl with auth redirected to file', () => { - assert.equal( - runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com > out.json', - ).code, - 0, - ) - }) - it('non-Bash tool is always allowed', () => { - assert.equal(runHook('env', 'Edit').code, 0) - }) - }) - - describe('blocks literal token shapes', () => { - it('Val Town token', () => { - const r = runHook('echo vtwn_ABCDEFGHIJKL') - assert.equal(r.code, 2) - assert.match(r.stderr, /Val Town token/) - }) - it('Linear API token', () => { - const r = runHook('echo lin_api_ABCDEFGHIJKLMNOP') - assert.equal(r.code, 2) - assert.match(r.stderr, /Linear API token/) - }) - it('GitHub PAT', () => { - const r = runHook('echo ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcd1234') - assert.equal(r.code, 2) - assert.match(r.stderr, /GitHub personal access token/) - }) - it('AWS access key', () => { - const r = runHook('echo AKIAIOSFODNN7EXAMPLE') - assert.equal(r.code, 2) - assert.match(r.stderr, /AWS access key/) - }) - it('Stripe test secret', () => { - const r = runHook('echo sk_test_ABCDEFGHIJKLMNOP') - assert.equal(r.code, 2) - assert.match(r.stderr, /Stripe test secret/) - }) - it('JWT', () => { - const r = runHook( - 'echo eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /JWT/) - }) - it('redacts the command in stderr so the literal token is not re-logged', () => { - const r = runHook('echo vtwn_SECRETVALUE') - assert.equal(r.code, 2) - assert.doesNotMatch(r.stderr, /SECRETVALUE/) - assert.match(r.stderr, /suppressed/) - }) - }) - - describe('blocks env/printenv dumps', () => { - it('bare env', () => { - assert.equal(runHook('env').code, 2) - }) - it('env piped without redactor', () => { - assert.equal(runHook('env | grep FOO').code, 2) - }) - it('printenv', () => { - assert.equal(runHook('printenv').code, 2) - }) - it('export -p', () => { - assert.equal(runHook('export -p').code, 2) - }) - }) - - describe('blocks .env reads without redaction', () => { - it('cat .env.local', () => { - assert.equal(runHook('cat .env.local').code, 2) - }) - it('head .env', () => { - assert.equal(runHook('head .env').code, 2) - }) - it('less .env.production', () => { - assert.equal(runHook('less .env.production').code, 2) - }) - }) - - describe('blocks curl with auth to unfiltered stdout', () => { - it('plain curl -H Authorization', () => { - const r = runHook( - 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com', - ) - assert.equal(r.code, 2) - assert.match(r.stderr, /Authorization header and unsanitized stdout/) - }) - }) - - describe('blocks sensitive-env-name references without redaction', () => { - it('echoing $API_KEY', () => { - assert.equal(runHook('echo $API_KEY').code, 2) - }) - it('ruby -e with $TOKEN', () => { - assert.equal(runHook('ruby -e "puts ENV[\'ACCESS_TOKEN\']"').code, 2) - }) - }) - - describe('fails open on malformed input', () => { - it('empty stdin', () => { - const r = spawnSync(nodeBin, [hookScript], { - input: '', - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - assert.equal(r.status, 0) - }) - it('non-JSON stdin', () => { - const r = spawnSync(nodeBin, [hookScript], { - input: 'not json', - timeout: 5_000, - stdio: ['pipe', 'pipe', 'pipe'], - }) - assert.equal(r.status, 0) - }) - it('empty command', () => { - assert.equal(runHook('').code, 0) - }) - }) -}) diff --git a/.claude/hooks/token-hygiene/tsconfig.json b/.claude/hooks/token-hygiene/tsconfig.json deleted file mode 100644 index 53c5c8475..000000000 --- a/.claude/hooks/token-hygiene/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/variant-analysis-reminder/README.md b/.claude/hooks/variant-analysis-reminder/README.md deleted file mode 100644 index 563490509..000000000 --- a/.claude/hooks/variant-analysis-reminder/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# variant-analysis-reminder - -Stop hook that flags High/Critical severity mentions in the assistant's most-recent turn that aren't followed by variant-search tool calls. - -## Why - -CLAUDE.md "Variant analysis on every High/Critical finding": - -> When a finding lands at severity High or Critical, search the rest of the repo for the same shape before closing it. Bugs cluster — same mental model, same antipattern. Three searches: same file, sibling files, cross-package. - -This hook catches the failure mode where the assistant identifies a High/Critical issue, fixes the one instance, and moves on — without checking whether the same shape exists elsewhere in the repo. - -## What it catches - -The hook scans the assistant's prose for severity labels in finding-shaped contexts: - -- `Critical:` / `High:` -- `Severity: Critical` / `Severity: High` -- `● Critical` / `● High` (bullet-shaped findings) -- `CRITICAL(` / `HIGH(` / `CRITICAL:` / `HIGH:` (callout shape) - -Code fences are stripped first so a quoted phrase doesn't false-positive (e.g., a code example mentioning a "High" enum value). - -If a severity mention is found, the hook then inspects the same turn's tool-use events. If **at least one** Grep / Glob / Read / Agent call ran in the turn, the hook is satisfied — the assistant did some kind of search. If zero searches ran, the warning surfaces. - -This is intentionally lenient: the hook can't tell whether the search was for variants of the right thing, so it only flags the case where no search at all happened. The user reads the warning and decides if the variant analysis was sufficient. - -## Why it doesn't block - -Stop hooks fire after the turn. Blocking would just truncate the findings. The warning prompts the next turn to do the search. - -## Configuration - -`SOCKET_VARIANT_ANALYSIS_REMINDER_DISABLED=1` — turn off entirely. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/variant-analysis-reminder/index.mts b/.claude/hooks/variant-analysis-reminder/index.mts deleted file mode 100644 index b43572359..000000000 --- a/.claude/hooks/variant-analysis-reminder/index.mts +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -// Claude Code Stop hook — variant-analysis-reminder. -// -// Flags High/Critical severity findings in the assistant's most-recent -// turn without subsequent evidence of grep/Glob/Read tool calls in -// the same turn. CLAUDE.md "Variant analysis on every High/Critical -// finding": -// -// When a finding lands at severity High or Critical, search the -// rest of the repo for the same shape before closing it. Bugs -// cluster — same mental model, same antipattern. Three searches: -// same file, sibling files, cross-package. -// -// Detection: -// -// 1. Scan the assistant's prose for "Critical"/"High" severity -// mentions in finding-shaped context ("Critical: ...", -// "Severity: High", "● High", etc.). -// -// 2. Inspect the same turn's tool-use events for evidence of -// variant search: Grep, Glob, or Read calls. If at least one -// search-shaped call ran AFTER the severity mention, the hook -// is satisfied. -// -// 3. If a severity mention exists but no search followed, warn. -// -// This is a Stop hook so the user reads the warning alongside the -// turn's findings — next turn does the variant analysis. -// -// Disable via SOCKET_VARIANT_ANALYSIS_REMINDER_DISABLED. - -import process from 'node:process' - -import { - readLastAssistantText, - readLastAssistantToolUses, - readStdin, - stripCodeFences, -} from '../_shared/transcript.mts' - -interface StopPayload { - readonly transcript_path?: string | undefined -} - -// Severity mentions worth flagging. Each pattern matches a context -// where Critical/High is the finding's severity, not just a passing -// adjective. Case-sensitive on the severity word but tolerant of -// surrounding punctuation. -const SEVERITY_PATTERNS: ReadonlyArray<{ label: string; regex: RegExp }> = [ - { - label: 'Critical/High severity label', - regex: /\b(?:severity[:\s]+|grade[:\s]+|●\s*)?(Critical|High)\b(?=[:\s,])/g, - }, - { - label: 'CRITICAL/HIGH callout', - regex: /(?<![A-Z])(CRITICAL|HIGH)(?![A-Z])\s*[:(]/g, - }, -] - -// Tool-use names that count as "variant search." -const VARIANT_SEARCH_TOOLS: ReadonlySet<string> = new Set([ - 'Agent', - 'Glob', - 'Grep', - 'Read', -]) - -interface DetectedSeverity { - readonly term: string - readonly snippet: string -} - -export function detectSeverityMentions(text: string): DetectedSeverity[] { - const stripped = stripCodeFences(text) - const found: DetectedSeverity[] = [] - for (let i = 0, { length } = SEVERITY_PATTERNS; i < length; i += 1) { - const pattern = SEVERITY_PATTERNS[i]! - pattern.regex.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = pattern.regex.exec(stripped)) !== null) { - const term = match[1]! - const start = Math.max(0, match.index - 20) - const end = Math.min(stripped.length, match.index + match[0].length + 40) - const snippet = stripped.slice(start, end).replace(/\s+/g, ' ').trim() - found.push({ term, snippet }) - // Limit per pattern to avoid spam if every line says "High". - if (found.length >= 3) { - return found - } - } - } - return found -} - -async function main(): Promise<void> { - const payloadRaw = await readStdin() - if (process.env['SOCKET_VARIANT_ANALYSIS_REMINDER_DISABLED']) { - process.exit(0) - } - let payload: StopPayload - try { - payload = JSON.parse(payloadRaw) as StopPayload - } catch { - process.exit(0) - } - const text = readLastAssistantText(payload.transcript_path) - if (!text) { - process.exit(0) - } - const severityHits = detectSeverityMentions(text) - if (severityHits.length === 0) { - process.exit(0) - } - // Check the same turn's tool-uses for variant-search activity. - const toolUses = readLastAssistantToolUses(payload.transcript_path) - let searchCount = 0 - for (let i = 0, { length } = toolUses; i < length; i += 1) { - if (VARIANT_SEARCH_TOOLS.has(toolUses[i]!.name)) { - searchCount += 1 - } - } - if (searchCount >= 1) { - // At least one variant search ran. We don't try to verify it was - // about the right thing — that's the user's call. Hook satisfied. - process.exit(0) - } - - const lines = [ - '[variant-analysis-reminder] High/Critical severity flagged without follow-up search:', - '', - ] - for (let i = 0, { length } = severityHits; i < length; i += 1) { - const hit = severityHits[i]! - lines.push(` • ${hit.term}: …${hit.snippet}…`) - } - lines.push('') - lines.push(' CLAUDE.md "Variant analysis on every High/Critical finding":') - lines.push( - ' Bugs cluster — same mental model, same antipattern. Three searches', - ) - lines.push( - ' before closing a High/Critical finding: same file, sibling files,', - ) - lines.push( - ' cross-package. The hook saw no Grep/Glob/Read/Agent in this turn.', - ) - lines.push('') - process.stderr.write(lines.join('\n') + '\n') - process.exit(0) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/variant-analysis-reminder/package.json b/.claude/hooks/variant-analysis-reminder/package.json deleted file mode 100644 index c04832a03..000000000 --- a/.claude/hooks/variant-analysis-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-variant-analysis-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/variant-analysis-reminder/test/index.test.mts b/.claude/hooks/variant-analysis-reminder/test/index.test.mts deleted file mode 100644 index cbffdd188..000000000 --- a/.claude/hooks/variant-analysis-reminder/test/index.test.mts +++ /dev/null @@ -1,182 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface ToolUse { - name: string - input: Record<string, unknown> -} - -function makeTranscript( - assistantText: string, - toolUses: readonly ToolUse[] = [], -): { path: string; cleanup: () => void } { - const dir = mkdtempSync(path.join(os.tmpdir(), 'variant-')) - const transcriptPath = path.join(dir, 'session.jsonl') - const content: object[] = [{ type: 'text', text: assistantText }] - for (let i = 0, { length } = toolUses; i < length; i += 1) { - content.push({ - type: 'tool_use', - name: toolUses[i]!.name, - input: toolUses[i]!.input, - }) - } - writeFileSync( - transcriptPath, - [ - JSON.stringify({ role: 'user', content: 'hi' }), - JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', content }, - }), - ].join('\n'), - ) - return { - path: transcriptPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }), - } -} - -function runHook(transcriptPath: string): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: transcriptPath }), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('flags "Critical:" severity without variant search', () => { - const { path: p, cleanup } = makeTranscript( - 'Found a Critical: prompt injection in agents/foo.md', - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.match(stderr, /variant-analysis-reminder/) - assert.match(stderr, /Critical/) - } finally { - cleanup() - } -}) - -test('flags ● High bullet shape', () => { - const { path: p, cleanup } = makeTranscript( - 'Findings:\n● High: missing validation on user input', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /High/) - } finally { - cleanup() - } -}) - -test('flags CRITICAL callout shape', () => { - const { path: p, cleanup } = makeTranscript( - '● CRITICAL (1)\n Some critical issue here.', - ) - try { - const { stderr } = runHook(p) - assert.match(stderr, /CRITICAL/) - } finally { - cleanup() - } -}) - -test('does NOT flag when Grep ran in same turn', () => { - const { path: p, cleanup } = makeTranscript( - 'Critical: prompt injection found', - [{ name: 'Grep', input: { pattern: 'ignore previous' } }], - ) - try { - const { stderr, exitCode } = runHook(p) - assert.equal(exitCode, 0) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag when Glob ran in same turn', () => { - const { path: p, cleanup } = makeTranscript( - 'High severity: unbound variable', - [{ name: 'Glob', input: { pattern: '**/*.mts' } }], - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag when Agent (delegated search) ran', () => { - const { path: p, cleanup } = makeTranscript( - 'Critical: SQL injection vector', - [{ name: 'Agent', input: { prompt: 'find variants' } }], - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT flag plain prose without severity labels', () => { - const { path: p, cleanup } = makeTranscript( - 'I implemented the feature and ran the tests. No issues found.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT false-positive on "Critical" inside code fence', () => { - const { path: p, cleanup } = makeTranscript( - 'Output:\n```\nCritical: some log message\n```\nMoving on.', - ) - try { - const { stderr } = runHook(p) - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('does NOT false-positive on "high quality" / "high-performance"', () => { - const { path: p, cleanup } = makeTranscript( - 'This is a high-performance hashmap and the result is high quality.', - ) - try { - const { stderr } = runHook(p) - // "high" not followed by `:` or `,` shouldn't match — the regex - // requires lookahead for [:\s,] after the severity word. - assert.equal(stderr, '') - } finally { - cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const { path: p, cleanup } = makeTranscript('Critical: bug found') - try { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ transcript_path: p }), - env: { ...process.env, SOCKET_VARIANT_ANALYSIS_REMINDER_DISABLED: '1' }, - }) - assert.equal(result.status, 0) - assert.equal(result.stderr, '') - } finally { - cleanup() - } -}) diff --git a/.claude/hooks/variant-analysis-reminder/tsconfig.json b/.claude/hooks/variant-analysis-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/variant-analysis-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/README.md b/.claude/hooks/verify-rendered-output-before-commit-reminder/README.md deleted file mode 100644 index 33807849a..000000000 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# verify-rendered-output-before-commit-reminder - -PreToolUse Bash hook (reminder, NOT a block) that fires on `git commit` -when: - -1. Staged files include UI/render shapes (`*.html` / `*.css` / etc.). -2. The transcript shows a build invocation since the last user - verification signal. -3. No user signal ("looks good" / "ship it" / "verified" / "push") - has appeared since the build. - -## Why - -Past pattern: agents committed UI changes (CSS, HTML, build outputs) -before checking the rendered artifact. Wasted commits piled up per -session — the user paraphrase was "rebuild before you fucking commit." - -This hook surfaces the reminder so the agent pauses to verify the -artifact before committing. - -## What it covers - -| Staged files | Recent build? | User verify since build? | Reminder? | -| ------------------- | ------------- | ------------------------ | --------- | -| Pure source (`.ts`) | — | — | no | -| UI files (`.html`) | no | — | no | -| UI files (`.html`) | yes | yes | no | -| UI files (`.html`) | yes | no | yes | - -## User verify patterns - -- "looks good", "ship it", "verified", "confirmed" -- "rebuild looks correct", "build is correct", "render looks right" -- "push" (terminal directive) - -## Not a block - -False-positive surface is real (sometimes the build output is -self-evident in the diff). The reminder lets the agent pause; the user -can also override by typing a verify signal before retrying. diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts b/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts deleted file mode 100644 index 7f1590320..000000000 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — verify-rendered-output-before-commit-reminder. -// -// Reminder on `git commit` when: -// 1. The staged file set contains UI/render-shape files -// (`*.html`, `*.css`, `scripts/tour.mts`-shape build inputs), AND -// 2. The transcript shows a recent build invocation that affected -// those files (e.g. `pnpm run build`, `node scripts/tour.mts`, -// `pnpm tour`, etc.), AND -// 3. There's no explicit "looks good" / "ship it" / "push" / -// "verified" / "confirmed" / "rebuild looks correct" from the user -// since that build ran. -// -// Surfaces a stderr reminder asking the agent to verify the rebuilt -// output BEFORE committing. Past pattern: multiple wasted commits per -// session ("rebuild before you fucking commit"). Reporting-only — never -// blocks; the verification step is the agent's call. -// -// No-op when the staged set is purely non-UI source. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { readFileSync } from 'node:fs' -import process from 'node:process' - -import { readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: string | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -// Files whose changes likely affect rendered output. -const UI_FILE_RE = - /\.(?:astro|css|ejs|handlebars|hbs|htm|html|less|njk|sass|scss|svelte|vue)$/i - -// Build-script patterns. Conservative — match the common fleet shapes: -// `pnpm run build`, `pnpm build`, `node scripts/<name>.mts`, `pnpm tour`, -// `pnpm site`, `pnpm docs:build`. -const BUILD_COMMAND_RES = [ - /\bpnpm\s+(?:run\s+)?(?:build|docs:build|docs:dev|render|site|tour)\b/, - /\bnode\s+(?:[^&;|]*\/)?scripts\/(?:build|emit-html|generate-site|render|tour)/, -] - -// User signals that mean "the build is verified, go ahead and commit." -const VERIFY_PATTERNS = [ - /\blooks good\b/i, - /\bship it\b/i, - /\bverified\b/i, - /\bconfirmed\b/i, - /\brebuild looks (?:correct|good|right)\b/i, - /\bbuild is (?:correct|good)\b/i, - /\brender(?:ed)? (?:looks )?(?:correct|good|right)\b/i, - /\bpush(?:\s|$|\.)/i, -] - -interface Analysis { - buildCommand: string | undefined - buildIndex: number - verifyIndex: number -} - -export function analyzeTranscript(entries: TranscriptEntry[]): Analysis { - let buildCommand: string | undefined - let buildIndex = -1 - let verifyIndex = -1 - for (let i = 0; i < entries.length; i += 1) { - const e = entries[i]! - const msg = e.message - if (!msg) { - continue - } - const content = msg.content - // Build invocation — find in assistant tool_use Bash calls. - if (Array.isArray(content)) { - for (let i = 0, { length } = content; i < length; i += 1) { - const part = content[i]! - if (part === null || typeof part !== 'object') { - continue - } - const name = (part as { name?: unknown | undefined }).name - const input = (part as { input?: unknown | undefined }).input - if ( - name === 'Bash' && - input && - typeof input === 'object' && - typeof (input as { command?: unknown | undefined }).command === - 'string' - ) { - const cmd = (input as { command: string }).command - for (let i = 0, { length } = BUILD_COMMAND_RES; i < length; i += 1) { - const re = BUILD_COMMAND_RES[i]! - if (re.test(cmd)) { - buildCommand = cmd - buildIndex = i - break - } - } - } - } - } - // User verify signal — string content of user turn. - if (e.type === 'user') { - let text = '' - if (typeof content === 'string') { - text = content - } else if (Array.isArray(content)) { - text = content - .map(seg => - typeof seg === 'string' - ? seg - : typeof (seg as { text?: unknown | undefined }).text === 'string' - ? (seg as { text: string }).text - : '', - ) - .join('\n') - } - for (let i = 0, { length } = VERIFY_PATTERNS; i < length; i += 1) { - const re = VERIFY_PATTERNS[i]! - if (re.test(text)) { - verifyIndex = i - break - } - } - } - } - return { buildCommand, buildIndex, verifyIndex } -} - -export function isGitCommit(command: string): boolean { - return /\bgit\s+commit\b/.test(command) -} - -interface TranscriptEntry { - type?: string | undefined - message?: - | { - content?: unknown | undefined - } - | undefined - toolUseResult?: unknown | undefined -} - -export function readTranscript(transcriptPath: string): TranscriptEntry[] { - let raw: string - try { - raw = readFileSync(transcriptPath, 'utf8') - } catch { - return [] - } - const out: TranscriptEntry[] = [] - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) { - continue - } - try { - out.push(JSON.parse(line) as TranscriptEntry) - } catch { - // skip - } - } - return out -} - -export function stagedFiles(cwd: string): string[] { - const r = spawnSync('git', ['diff', '--cached', '--name-only'], { - cwd, - timeout: 5_000, - }) - if (r.status !== 0) { - return [] - } - return String(r.stdout) - .split('\n') - .map((s: string) => s.trim()) - .filter(Boolean) -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.command ?? '' - if (!isGitCommit(command)) { - process.exit(0) - } - - const cwd = payload.cwd ?? process.cwd() - const staged = stagedFiles(cwd) - const uiStaged = staged.filter(f => UI_FILE_RE.test(f)) - if (uiStaged.length === 0) { - process.exit(0) - } - - if (!payload.transcript_path) { - process.exit(0) - } - const entries = readTranscript(payload.transcript_path) - const { buildCommand, buildIndex, verifyIndex } = analyzeTranscript(entries) - if (buildIndex < 0) { - // No build ran; can't reason about freshness. - process.exit(0) - } - if (verifyIndex > buildIndex) { - // User explicitly verified after the build. - process.exit(0) - } - - const lines: string[] = [] - lines.push( - '[verify-rendered-output-before-commit-reminder] About to commit UI/render files', - ) - lines.push('') - lines.push(' UI files staged:') - for (const f of uiStaged.slice(0, 5)) { - lines.push(` ${f}`) - } - if (uiStaged.length > 5) { - lines.push(` (+${uiStaged.length - 5} more)`) - } - lines.push('') - if (buildCommand) { - lines.push(` Recent build: ${buildCommand.slice(0, 80)}`) - } - lines.push(' No user verification signal since the build ran.') - lines.push('') - lines.push( - ' Past pattern: committing UI changes before verifying the rebuilt', - ) - lines.push( - ' output produces wasted commits. Open the rendered artifact, confirm', - ) - lines.push(' it looks correct, then commit.') - lines.push('') - lines.push(' Reminder-only; not a block.') - lines.push('') - process.stderr.write(lines.join('\n')) - process.exit(0) -} - -main().catch(e => { - process.stderr.write( - `[verify-rendered-output-before-commit-reminder] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/package.json b/.claude/hooks/verify-rendered-output-before-commit-reminder/package.json deleted file mode 100644 index 74e2f2d33..000000000 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-verify-rendered-output-before-commit-reminder", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts b/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts deleted file mode 100644 index 5d19bab16..000000000 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts +++ /dev/null @@ -1,135 +0,0 @@ -// node --test specs for the verify-rendered-output-before-commit-reminder hook. - -import { - spawn, - spawnSync, -} from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function mkRepoWithStaged(stagedFiles: string[]): string { - const repo = mkdtempSync(path.join(os.tmpdir(), 'commit-rebuild-test-')) - spawnSync('git', ['init', '-q'], { cwd: repo }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) - for (let i = 0, { length } = stagedFiles; i < length; i += 1) { - const f = stagedFiles[i]! - const p = path.join(repo, f) - mkdirSync(path.dirname(p), { recursive: true }) - writeFileSync(p, 'x') - } - spawnSync('git', ['add', ...stagedFiles], { cwd: repo }) - return repo -} - -function mkTranscript(entries: object[]): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-rebuild-tx-')) - const p = path.join(dir, 'session.jsonl') - writeFileSync(p, entries.map(e => JSON.stringify(e)).join('\n') + '\n') - return p -} - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-commit Bash passes silently', async () => { - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'ls -la' }, - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('commit with no UI files staged — no reminder', async () => { - const repo = mkRepoWithStaged(['src/foo.ts']) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git commit -m "feat: x"' }, - cwd: repo, - transcript_path: mkTranscript([]), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('commit with UI files but no build in transcript — no reminder', async () => { - const repo = mkRepoWithStaged(['site/index.html']) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git commit -m "feat: x"' }, - cwd: repo, - transcript_path: mkTranscript([ - { type: 'user', message: { content: 'fix the page' } }, - ]), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) - -test('commit with UI files + recent build + no verify — reminder fires', async () => { - const repo = mkRepoWithStaged(['site/index.html', 'site/app.css']) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git commit -m "feat: page" ' }, - cwd: repo, - transcript_path: mkTranscript([ - { type: 'user', message: { content: 'rebuild the site' } }, - { - type: 'assistant', - message: { - content: [{ name: 'Bash', input: { command: 'pnpm run build' } }], - }, - }, - ]), - }) - assert.strictEqual(r.code, 0) - assert.ok( - String(r.stderr).includes('verify-rendered-output-before-commit-reminder'), - ) -}) - -test('commit with UI files + build + later user verify — no reminder', async () => { - const repo = mkRepoWithStaged(['site/index.html']) - const r = await runHook({ - tool_name: 'Bash', - tool_input: { command: 'git commit -m "feat: page"' }, - cwd: repo, - transcript_path: mkTranscript([ - { - type: 'assistant', - message: { - content: [{ name: 'Bash', input: { command: 'pnpm run build' } }], - }, - }, - { type: 'user', message: { content: 'looks good, ship it' } }, - ]), - }) - assert.strictEqual(r.code, 0) - assert.strictEqual(r.stderr, '') -}) diff --git a/.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json b/.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/version-bump-order-guard/README.md b/.claude/hooks/version-bump-order-guard/README.md deleted file mode 100644 index 20786f338..000000000 --- a/.claude/hooks/version-bump-order-guard/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# version-bump-order-guard - -PreToolUse hook that blocks `git tag vX.Y.Z` when HEAD isn't a bump commit. Enforces step 3-4 of CLAUDE.md's "Version bumps" rule. - -## What it catches - -- `git tag v1.2.3` (or `git tag -a v…`, `git tag -s v…`) when the most-recent commit subject doesn't match `chore: bump version to X.Y.Z` or `chore(scope): release X.Y.Z`. - -## Why - -The bump commit must be the LAST commit on the release. Tagging on a non-bump commit produces a broken release: `git describe` lies, bisecting past the tag lands on a different state, and the changelog drifts from the artifact. - -## Bypass - -- Type `Allow version-bump-order bypass` in a recent user message (also accepts `Allow version bump order bypass` / `Allow versionbumporder bypass`), or -- Set `SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED=1`. - -## Test - -```sh -pnpm test -``` diff --git a/.claude/hooks/version-bump-order-guard/index.mts b/.claude/hooks/version-bump-order-guard/index.mts deleted file mode 100644 index 037ebb0e7..000000000 --- a/.claude/hooks/version-bump-order-guard/index.mts +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — version-bump-order-guard. -// -// Blocks `git tag vX.Y.Z` invocations when the prep wave or the bump -// commit hasn't landed yet. The fleet's "Version bumps" rule says: -// -// 1. `pnpm run update` → `pnpm i` → `pnpm run fix --all` → `pnpm run -// check --all` (each clean before the next). -// 2. CHANGELOG.md entry — public-facing only. -// 3. The `chore: bump version to X.Y.Z` commit is the LAST commit on -// the release branch. -// 4. THEN `git tag vX.Y.Z` at the bump commit. -// 5. Do NOT dispatch the publish workflow. -// -// This hook is a guard around step 4: when the user runs `git tag -// v...`, the most-recent commit on HEAD must look like a bump commit -// (its subject matches `bump version to X.Y.Z` or `chore: release -// X.Y.Z`). Without that, the tag is being placed on a non-bump commit, -// which produces a broken release. -// -// Bypass: "Allow version-bump-order bypass" in a recent user turn, or -// SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED=1. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -import { commandsFor } from '../_shared/shell-command.mts' -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface PreToolUsePayload { - readonly tool_name?: string | undefined - readonly tool_input?: { readonly command?: unknown | undefined } | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -const BYPASS_PHRASES = [ - 'Allow version-bump-order bypass', - 'Allow version bump order bypass', - 'Allow versionbumporder bypass', -] as const - -// `git tag <name>` (also `git tag -a`, `git tag -s`, etc.) creating a -// version tag (`vX.Y.Z`). Parser-based: a real `git` command with a -// `tag` arg and a version-shaped arg — so a quoted "git tag v1.2.3" in -// a message or a sibling command's string isn't a false trigger. -const VERSION_ARG_RE = /^v\d+\.\d+\.\d+$/ -function isVersionTagCommand(command: string): boolean { - return commandsFor(command, 'git').some( - c => c.args.includes('tag') && c.args.some(a => VERSION_ARG_RE.test(a)), - ) -} - -// Subject patterns that count as a "bump commit". Matches Keep-a- -// Changelog style and Conventional Commits style. -const BUMP_SUBJECT_RE = - /^(?:chore(?:\([\w-]+\))?:\s+(?:bump version to|release)\s+v?\d+\.\d+\.\d+|chore(?:\([\w-]+\))?:\s+v?\d+\.\d+\.\d+\s+release)/i - -async function main(): Promise<void> { - if (process.env['SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED']) { - process.exit(0) - } - const payloadRaw = await readStdin() - let payload: PreToolUsePayload - try { - payload = JSON.parse(payloadRaw) as PreToolUsePayload - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Bash') { - process.exit(0) - } - const command = payload.tool_input?.['command'] - if (typeof command !== 'string') { - process.exit(0) - } - if (!isVersionTagCommand(command)) { - process.exit(0) - } - if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASES)) { - process.exit(0) - } - - // Read the most-recent commit subject from HEAD. - const opts = payload.cwd ? { cwd: payload.cwd } : {} - const subjectResult = spawnSync('git', ['log', '-1', '--pretty=%s'], opts) - if (subjectResult.status !== 0) { - // Not a git repo or git unavailable — fail open. - process.exit(0) - } - const headSubject = String(subjectResult.stdout).trim() - if (BUMP_SUBJECT_RE.test(headSubject)) { - process.exit(0) - } - - // Look up whether CHANGELOG.md was touched in HEAD. - let changelogTouched = false - const filesResult = spawnSync( - 'git', - ['show', '--name-only', '--pretty=', 'HEAD'], - opts, - ) - if (filesResult.status === 0) { - changelogTouched = /\bCHANGELOG\.md\b/i.test(String(filesResult.stdout)) - } - - const lines = [ - '[version-bump-order-guard] Tagging vX.Y.Z but HEAD is not a bump commit.', - '', - ` HEAD subject : ${headSubject}`, - ` CHANGELOG.md : ${changelogTouched ? 'touched' : 'NOT touched'} in HEAD`, - '', - ' Per CLAUDE.md "Version bumps", the bump commit must be the LAST', - ' commit on the release. Expected subject shape:', - '', - ' chore: bump version to X.Y.Z', - ' chore(scope): release X.Y.Z', - '', - ' If a bump commit exists earlier in history, rebase it forward to', - " the tip. If it doesn't exist yet, run the prep wave first:", - '', - ' pnpm run update', - ' pnpm i', - ' pnpm run fix --all', - ' pnpm run check --all', - '', - ' Then update CHANGELOG.md and commit `chore: bump version to X.Y.Z`', - ' carrying package.json + CHANGELOG.md. Then tag.', - '', - ' Bypass: type "Allow version-bump-order bypass" in a recent message.', - '', - ] - process.stderr.write(lines.join('\n') + '\n') - process.exit(2) -} - -main().catch(() => { - process.exit(0) -}) diff --git a/.claude/hooks/version-bump-order-guard/package.json b/.claude/hooks/version-bump-order-guard/package.json deleted file mode 100644 index 17ff1a881..000000000 --- a/.claude/hooks/version-bump-order-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-version-bump-order-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/version-bump-order-guard/test/index.test.mts b/.claude/hooks/version-bump-order-guard/test/index.test.mts deleted file mode 100644 index 283970c78..000000000 --- a/.claude/hooks/version-bump-order-guard/test/index.test.mts +++ /dev/null @@ -1,153 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -interface FakeRepo { - readonly root: string - cleanup(): void -} - -function makeRepoWithHeadSubject(subject: string): FakeRepo { - const root = mkdtempSync(path.join(os.tmpdir(), 'bumporder-')) - spawnSync('git', ['init', '-q'], { cwd: root }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }) - spawnSync('git', ['config', 'user.name', 'tester'], { cwd: root }) - spawnSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root }) - writeFileSync(path.join(root, 'README.md'), 'hi\n') - spawnSync('git', ['add', '-A'], { cwd: root }) - spawnSync('git', ['commit', '-q', '-m', subject], { cwd: root }) - return { - root, - cleanup: () => rmSync(root, { recursive: true, force: true }), - } -} - -function makeTranscript(userText?: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'bumporder-tx-')) - const transcriptPath = path.join(dir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ role: 'user', content: userText ?? 'do it' }), - ) - return transcriptPath -} - -function runHook( - command: string, - cwd: string, - transcriptPath?: string, - extraEnv: Record<string, string> = {}, -): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Bash', - tool_input: { command }, - transcript_path: transcriptPath, - cwd, - }), - env: { ...process.env, ...extraEnv }, - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -test('BLOCKS git tag vX.Y.Z when HEAD subject is not a bump', () => { - const repo = makeRepoWithHeadSubject('feat: some random feature') - try { - const { stderr, exitCode } = runHook('git tag v1.2.3', repo.root) - assert.equal(exitCode, 2) - assert.match(stderr, /version-bump-order-guard/) - assert.match(stderr, /feat: some random feature/) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS git tag vX.Y.Z when HEAD subject is "chore: bump version to X.Y.Z"', () => { - const repo = makeRepoWithHeadSubject('chore: bump version to 1.2.3') - try { - const { exitCode } = runHook('git tag v1.2.3', repo.root) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS git tag vX.Y.Z when HEAD subject is "chore(release): bump version to X.Y.Z"', () => { - const repo = makeRepoWithHeadSubject('chore(release): bump version to 2.0.0') - try { - const { exitCode } = runHook('git tag v2.0.0', repo.root) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS "chore: release X.Y.Z" subject', () => { - const repo = makeRepoWithHeadSubject('chore: release 3.1.0') - try { - const { exitCode } = runHook('git tag v3.1.0', repo.root) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('ALLOWS git tag with non-version label (no enforcement)', () => { - const repo = makeRepoWithHeadSubject('feat: regular feature') - try { - const { exitCode } = runHook('git tag pre-release-snapshot', repo.root) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('IGNORES non-Bash tools', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify({ - tool_name: 'Write', - tool_input: { command: 'git tag v1.0.0' }, - }), - }) - assert.equal(result.status, 0) -}) - -test('ALLOWS with bypass phrase', () => { - const repo = makeRepoWithHeadSubject('feat: random commit') - try { - const t = makeTranscript('Allow version-bump-order bypass') - const { exitCode } = runHook('git tag v1.0.0', repo.root, t) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('disabled env var short-circuits', () => { - const repo = makeRepoWithHeadSubject('feat: random commit') - try { - const { exitCode } = runHook('git tag v1.0.0', repo.root, undefined, { - SOCKET_VERSION_BUMP_ORDER_GUARD_DISABLED: '1', - }) - assert.equal(exitCode, 0) - } finally { - repo.cleanup() - } -}) - -test('fails open when not in a git repo', () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'bumporder-nogit-')) - try { - const { exitCode } = runHook('git tag v1.0.0', root) - assert.equal(exitCode, 0) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}) diff --git a/.claude/hooks/version-bump-order-guard/tsconfig.json b/.claude/hooks/version-bump-order-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/version-bump-order-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/README.md b/.claude/hooks/vitest-include-vs-node-test-guard/README.md deleted file mode 100644 index 0a8539049..000000000 --- a/.claude/hooks/vitest-include-vs-node-test-guard/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# vitest-include-vs-node-test-guard - -PreToolUse Edit/Write hook that blocks creating a file at a path the repo's -vitest `include` glob would pick up if that file imports `node:test`. - -## Why - -Mismatched runners produce confusing errors. A file at -`scripts/test/foo.test.mts` that uses `import test from 'node:test'` belongs -to Node's built-in test runner. But if the repo's `vitest.config.*` has -`include: ['scripts/**/*.test.*']`, vitest will load it, see no -`describe`/`it`/`test` registration, and emit: - - Error: No test suite found in file scripts/test/foo.test.mts - -This was a real instance in socket-stuie — 4 `scripts/test/` files cascaded -from wheelhouse used `node:test` while the repo's vitest include caught -them. - -## What it blocks - -| Pattern | Block? | -| -------------------------------------------------------- | ------ | -| Write/Edit that adds `import test from 'node:test'` | | -| to a file matching the repo's vitest `include` glob | yes | -| Same import in a file NOT matching `include` | no | -| Vitest API (`describe`/`it`/`test` from `vitest`) | no | -| Existing `node:test` file with an unrelated body edit | yes | -| (the file imports `node:test`; the edit doesn't have to) | | - -## Bypass - -Type the canonical phrase in a new message: - - Allow node-test-in-vitest-include bypass - -Or — the long-term fix — add the file path to vitest's `exclude` array in -the vitest config. - -## Detection - -Reads `.config/vitest.config.mts` (or the standard fleet alternatives), -parses the `include: [...]` literal array, converts each glob to a regex, -and tests the target file's repo-relative path. Fails open if the config -isn't found or the include globs aren't string literals (dynamic includes -can't be validated statically). diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/index.mts b/.claude/hooks/vitest-include-vs-node-test-guard/index.mts deleted file mode 100644 index b8f4064ea..000000000 --- a/.claude/hooks/vitest-include-vs-node-test-guard/index.mts +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — vitest-include-vs-node-test-guard. -// -// Catches files that import `node:test` while sitting at a path the repo's -// `vitest.config.*` would pick up via its `include` glob. Mismatched runners -// produce confusing "No test suite found in file" errors because vitest -// loads the file, finds no `describe`/`it`/`test` registration (the file -// uses node:test's API instead), and bails. -// -// Detection model: -// - Fires on Write/Edit operations whose target file path imports -// `node:test`. -// - Reads the repo's `vitest.config.*` from the standard fleet locations -// (`.config/vitest.config.mts`, `vitest.config.mts/mjs/ts/js`, or the -// `template/.config/` mirror for wheelhouse). -// - Parses the config's `include` globs (string-literal extraction; if -// the config uses dynamic globs, we fail open). -// - Matches the target file path against each glob via a minimatch-style -// comparison. If a match is found, block. -// -// Bypass: `Allow node-test-in-vitest-include bypass` typed verbatim in a -// recent user turn. Or add the file path to vitest's `exclude` glob in -// `vitest.config.*` (the long-term fix). -// -// Fails open on parse / config-not-found errors — under-blocking is better -// than blocking on infrastructure problems. - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined - readonly cwd?: string | undefined -} - -const BYPASS_PHRASE = 'Allow node-test-in-vitest-include bypass' - -// Standard fleet vitest config locations, checked in order. -const VITEST_CONFIG_CANDIDATES = [ - '.config/vitest.config.mts', - '.config/vitest.config.mjs', - '.config/vitest.config.ts', - '.config/vitest.config.js', - 'vitest.config.mts', - 'vitest.config.mjs', - 'vitest.config.ts', - 'vitest.config.js', - 'template/.config/vitest.config.mts', - 'template/.config/vitest.config.mjs', - 'template/vitest.config.mts', -] - -// Extract `include: [...]` string-literal entries from a vitest config. -// Permissive parse — we look for the literal pattern `include: [...]` (or -// `include:[...]`) and pull every quoted string out of the matched bracket -// body. If the config uses dynamic globs (variable references, spreads, -// or function calls), we return undefined and fail open. -export function extractIncludeGlobs(configText: string): string[] | undefined { - const m = /include\s*:\s*\[([^\]]*)\]/.exec(configText) - if (!m) { - return undefined - } - const body = m[1]! - // Bail if the body has anything that isn't a string literal, comma, or - // whitespace. - if (/[^\s,'"`\w./*[\]{}-]/.test(body)) { - // contains identifiers / spreads / function calls / etc. - // Allow comma + whitespace + glob chars; bail on anything else. - } - const globs: string[] = [] - const stringRe = /(['"`])((?:\\.|(?!\1).)*?)\1/g - let strM: RegExpExecArray | null - while ((strM = stringRe.exec(body)) !== null) { - globs.push(strM[2]!) - } - if (globs.length === 0) { - return undefined - } - return globs -} - -export function fileImportsNodeTest(text: string): boolean { - // Detect `import test from 'node:test'`, `import { test } from 'node:test'`, - // or `from "node:test"`. Conservative; ignores `from 'node:test/...'`. - return /from\s+['"`]node:test['"`]/.test(text) -} - -export function findVitestConfig(startDir: string): string | undefined { - let cur = startDir - for (let depth = 0; depth < 10; depth += 1) { - for (let i = 0, { length } = VITEST_CONFIG_CANDIDATES; i < length; i += 1) { - const rel = VITEST_CONFIG_CANDIDATES[i]! - const p = path.join(cur, rel) - if (existsSync(p)) { - return p - } - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - return undefined -} - -// Convert a vitest-style glob to a regex. Supports `**`, `*`, `?`, and -// brace alternation `{a,b}`. Not a full minimatch — covers the patterns -// actually seen in fleet vitest configs. -export function globToRegex(glob: string): RegExp { - let re = '' - for (let i = 0; i < glob.length; i += 1) { - const c = glob[i]! - if (c === '*') { - if (glob[i + 1] === '*') { - re += '.*' - i += 1 - } else { - re += '[^/]*' - } - } else if (c === '?') { - re += '[^/]' - } else if (c === '{') { - const close = glob.indexOf('}', i) - if (close < 0) { - re += '\\{' - } else { - const alts = glob - .slice(i + 1, close) - .split(',') - .map(a => globToRegexBody(a)) - .join('|') - re += `(?:${alts})` - i = close - } - } else if (/[.+^$()|\\]/.test(c)) { - re += '\\' + c - } else { - re += c - } - } - return new RegExp('^' + re + '$') -} - -export function globToRegexBody(glob: string): string { - // Lightweight inner conversion used inside brace alternation; reuses - // globToRegex's main loop but returns just the body. To keep the code - // small, we run the main converter and strip the anchors. - const r = globToRegex(glob).source - return r.replace(/^\^/, '').replace(/\$$/, '') -} - -export function relPathFromRepoRoot( - filePath: string, - configPath: string, -): string { - // configPath is `<repo>/.config/vitest.config.mts` or - // `<repo>/vitest.config.mts` etc. — strip the trailing config dir to get - // the repo root. - let repoRoot = path.dirname(configPath) - if (repoRoot.endsWith('/.config') || repoRoot.endsWith('/template/.config')) { - repoRoot = path.dirname(repoRoot) - } - if (repoRoot.endsWith('/template')) { - repoRoot = path.dirname(repoRoot) - } - return path.relative(repoRoot, filePath).split(path.sep).join('/') -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !/\.(cjs|cts|js|mjs|mts|ts)$/.test(filePath)) { - process.exit(0) - } - - // Determine the after-content. - let afterText = '' - if (payload.tool_name === 'Write') { - afterText = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - } else { - // For Edit: the new_string is enough to check the import shape; if it - // doesn't reference node:test in the diff, also check the current file - // (in case the import was already there and the edit only touches body). - afterText = payload.tool_input?.new_string ?? '' - if (!fileImportsNodeTest(afterText) && existsSync(filePath)) { - try { - afterText = readFileSync(filePath, 'utf8') - } catch { - process.exit(0) - } - } - } - if (!fileImportsNodeTest(afterText)) { - process.exit(0) - } - - const configPath = findVitestConfig(payload.cwd ?? path.dirname(filePath)) - if (!configPath) { - process.exit(0) - } - let configText: string - try { - configText = readFileSync(configPath, 'utf8') - } catch { - process.exit(0) - } - const globs = extractIncludeGlobs(configText) - if (!globs || globs.length === 0) { - process.exit(0) - } - - const relPath = relPathFromRepoRoot(filePath, configPath) - const matched: string[] = [] - for (let i = 0, { length } = globs; i < length; i += 1) { - const glob = globs[i]! - try { - const re = globToRegex(glob) - if (re.test(relPath)) { - matched.push(glob) - } - } catch { - // Skip broken globs. - } - } - if (matched.length === 0) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - process.stderr.write( - [ - '[vitest-include-vs-node-test-guard] Blocked: node:test file under vitest include', - '', - ` File: ${filePath}`, - ` Rel: ${relPath}`, - ` Vitest config: ${configPath}`, - ` Matching globs: ${matched.map(g => `\`${g}\``).join(', ')}`, - '', - " The file imports `node:test` but its path matches one of vitest's", - ' `include` globs. Vitest will try to load it, see no describe/it/test', - ' registration, and emit "No test suite found in file."', - '', - ' Fix:', - " - Add the file path (or its parent directory) to vitest's", - ' `exclude` array in the vitest config, OR', - " - Convert the file to vitest's API (replace `node:test` imports", - ' with `vitest` describe/it/test).', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[vitest-include-vs-node-test-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/package.json b/.claude/hooks/vitest-include-vs-node-test-guard/package.json deleted file mode 100644 index cdebcf6f1..000000000 --- a/.claude/hooks/vitest-include-vs-node-test-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-vitest-include-vs-node-test-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts b/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts deleted file mode 100644 index a80f20f58..000000000 --- a/.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts +++ /dev/null @@ -1,145 +0,0 @@ -// node --test specs for the vitest-include-vs-node-test-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -interface FixtureOpts { - vitestInclude: string[] - testFilePath: string // relative to fake repo root - testFileContent: string -} - -function makeFixture(opts: FixtureOpts): { - repoRoot: string - testFile: string -} { - const repoRoot = mkdtempSync(path.join(os.tmpdir(), 'vit-guard-test-')) - mkdirSync(path.join(repoRoot, '.config'), { recursive: true }) - writeFileSync( - path.join(repoRoot, '.config', 'vitest.config.mts'), - `export default { test: { include: ${JSON.stringify(opts.vitestInclude)} } }\n`, - ) - const testFile = path.join(repoRoot, opts.testFilePath) - mkdirSync(path.dirname(testFile), { recursive: true }) - writeFileSync(testFile, opts.testFileContent) - return { repoRoot, testFile } -} - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-test file passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { file_path: '/tmp/foo.txt', content: 'hello' }, - }) - assert.strictEqual(r.code, 0) -}) - -test('vitest API file matches include — passes', async () => { - const { repoRoot, testFile } = makeFixture({ - vitestInclude: ['scripts/**/*.test.*'], - testFilePath: 'scripts/test/foo.test.mts', - testFileContent: "import { test } from 'vitest'\ntest('x', () => {})\n", - }) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: testFile, - content: "import { test } from 'vitest'\ntest('x', () => {})\n", - }, - cwd: repoRoot, - }) - assert.strictEqual(r.code, 0) -}) - -test('node:test file under vitest include — blocked', async () => { - const { repoRoot, testFile } = makeFixture({ - vitestInclude: ['scripts/**/*.test.*'], - testFilePath: 'scripts/test/foo.test.mts', - testFileContent: '', - }) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: testFile, - content: "import test from 'node:test'\ntest('x', () => {})\n", - }, - cwd: repoRoot, - }) - assert.strictEqual(r.code, 2) - assert.ok(String(r.stderr).includes('scripts/**/*.test.*')) -}) - -test('node:test file outside vitest include — passes', async () => { - const { repoRoot, testFile } = makeFixture({ - vitestInclude: ['test/**/*.test.*'], - testFilePath: 'scripts/test/foo.test.mts', - testFileContent: '', - }) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: testFile, - content: "import test from 'node:test'\ntest('x', () => {})\n", - }, - cwd: repoRoot, - }) - assert.strictEqual(r.code, 0) -}) - -test('bypass phrase passes', async () => { - const { repoRoot, testFile } = makeFixture({ - vitestInclude: ['scripts/**/*.test.*'], - testFilePath: 'scripts/test/foo.test.mts', - testFileContent: '', - }) - const txDir = mkdtempSync(path.join(os.tmpdir(), 'vit-guard-tx-')) - const transcriptPath = path.join(txDir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow node-test-in-vitest-include bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: testFile, - content: "import test from 'node:test'\ntest('x', () => {})\n", - }, - cwd: repoRoot, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json b/.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/workflow-uses-comment-guard/README.md b/.claude/hooks/workflow-uses-comment-guard/README.md deleted file mode 100644 index ea2e247eb..000000000 --- a/.claude/hooks/workflow-uses-comment-guard/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# workflow-uses-comment-guard - -A **Claude Code PreToolUse hook** that blocks Edit/Write tool calls -which would land a `uses: <action>@<40-char-sha>` line in a GitHub -Actions workflow or local-action YAML without the canonical trailing -`# <tag-or-version-or-branch> (YYYY-MM-DD)` staleness comment. - -## Why this rule - -SHA-pinning makes `uses:` lines opaque — a reader can't tell at-a-glance -whether `27d5ce7f...` is `v5.0.5` from last week or `v3.2.1` from 2024. -The trailing comment is the cheapest staleness signal we have outside of -running a full drift audit. The date stamp matters as much as the -version label: a comment that says `# v6.4.0` could have been written -the day v6.4.0 shipped, or could be eighteen months stale — the date -disambiguates. - -## Conventional shape - -```yaml -- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) -- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-05-15) -- uses: SocketDev/socket-registry/.github/actions/setup-pnpm@c14cb59f... # main (2026-05-15) -- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # 27d5ce7f (2026-05-15) -``` - -The label is the upstream tag, branch name, or short-SHA; the date is -when you pinned / refreshed the SHA (today's date for new pins). - -## What's enforced - -- Every `uses: <action>@<sha>` line where `<sha>` is a 40-char hex - digest must carry a trailing `# <label> (YYYY-MM-DD)` comment. -- The label is any non-paren text (`v1.0.0`, `main`, `27d5ce7f`). -- The date must match the ISO `YYYY-MM-DD` shape — no `2026/05/15` or - `15 May 2026`. - -## What's not enforced - -- Local-action references (`uses: ./.github/actions/foo`) — they don't - carry SHAs. -- Docker-image actions (`uses: docker://...`) — not SHA-pinned in the - GitHub sense. -- The accuracy of the label or date — that's a human-review concern. - -## Override marker - -For a legitimate one-off: - -```yaml -- uses: third-party/action@deadbeef... # socket-hook: allow uses-no-stamp -``` - -Don't reach for this — add the comment instead. - -## Wiring - -In `.claude/settings.json`: - -```json -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node .claude/hooks/workflow-uses-comment-guard/index.mts" - } - ] - } - ] - } -} -``` - -## Cross-fleet sync - -This hook lives in -[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/workflow-uses-comment-guard) -and is required to be byte-identical across every fleet repo. -`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it. diff --git a/.claude/hooks/workflow-uses-comment-guard/index.mts b/.claude/hooks/workflow-uses-comment-guard/index.mts deleted file mode 100644 index bbb996ff7..000000000 --- a/.claude/hooks/workflow-uses-comment-guard/index.mts +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — workflow-uses-comment-guard. -// -// Blocks Edit/Write tool calls that introduce a `uses: <action>@<sha>` -// line in a GitHub Actions YAML file (`.github/workflows/*.yml`, -// `.github/actions/*/action.yml`) without the canonical trailing -// `# <tag-or-version-or-branch> (YYYY-MM-DD)` staleness comment. -// -// Without that comment a reviewer can't tell at-a-glance whether the -// pin is fresh or six months stale, and the date-stamp is the cheapest -// staleness signal we have outside of running a full drift audit. -// -// Accepted comment shapes (the part inside the parens MUST be ISO date): -// # v6.4.0 (2026-05-15) -// # main (2026-05-15) -// # codeql-bundle-v2.25.4 (2026-05-15) -// # 27d5ce7f (2026-05-15) <- short-SHA also fine -// -// Rejected: -// # v6.4.0 <- no date stamp -// # main <- no date stamp -// # (2026-05-15) <- no version label -// -// Scope: -// - Fires on Edit and Write tool calls. -// - Only inspects `.github/workflows/*.{yml,yaml}` and -// `.github/actions/**/*.{yml,yaml}`. -// - Local-action references (`./.github/actions/foo`) are exempt — -// they don't carry SHAs. -// - Reusable-workflow refs (`uses: org/repo/.github/workflows/x.yml@sha`) -// are checked. -// - Lines marked `# socket-hook: allow uses-no-stamp` are exempt for -// one-off legitimate cases. -// -// The hook fails OPEN on its own bugs (exit 0 + stderr log) so a bad -// hook deploy can't brick the session. - -import process from 'node:process' - -const ALLOW_MARKER = '# socket-hook: allow uses-no-stamp' - -// Matches a YAML `uses:` line that pins a 40-char SHA, e.g. -// ` uses: actions/checkout@de0fac2e... # v6.0.2 (2026-05-15)` -// Captures: (1) ref-name, (2) sha, (3) trailing-comment (may be empty). -const USES_RE = /^\s*-?\s*uses:\s+([^\s@]+)@([0-9a-f]{40})(\s*#[^\n]*)?\s*$/ - -// Local actions (`./.github/...`) and Docker images (`docker://...`) -// don't have SHAs and aren't matched by USES_RE — no special-casing -// needed. - -// Comment must be exactly `# <label> (YYYY-MM-DD)` (label is any -// non-paren text, date is 4-2-2 digits). The leading `#` and a space -// are required; everything else after the date is rejected so we -// don't tolerate sloppy trailing junk. -const COMMENT_RE = /^#\s+\S[^()]*\s+\(\d{4}-\d{2}-\d{2}\)\s*$/ - -export function findBadUsesLines(text: string): BadLine[] { - const lines = text.split('\n') - const bad: BadLine[] = [] - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - if (!line) { - continue - } - if (line.includes(ALLOW_MARKER)) { - continue - } - const m = USES_RE.exec(line) - if (!m) { - continue - } - const comment = (m[3] ?? '').trim() - if (!comment) { - bad.push({ line: line.trim(), reason: 'no comment on uses:' }) - continue - } - if (!COMMENT_RE.test(comment)) { - bad.push({ - line: line.trim(), - reason: `comment does not match \`# <label> (YYYY-MM-DD)\` (got: ${comment})`, - }) - } - } - return bad -} - -interface Hook { - tool_name?: string | undefined - tool_input?: - | { - file_path?: string | undefined - new_string?: string | undefined - content?: string | undefined - } - | undefined -} - -interface BadLine { - line: string - reason: string -} - -export function isWorkflowYamlPath(p: string): boolean { - // Workflows: .github/workflows/*.{yml,yaml} - // Local actions: .github/actions/<name>/action.{yml,yaml} - if (!p.includes('/.github/')) { - return false - } - if (!/\.(ya?ml)$/.test(p)) { - return false - } - return ( - /\/\.github\/workflows\/[^/]+\.(ya?ml)$/.test(p) || - /\/\.github\/actions\/[^/]+\/action\.(ya?ml)$/.test(p) - ) -} - -function main() { - let stdin = '' - process.stdin.on('data', chunk => { - stdin += chunk - }) - process.stdin.on('end', () => { - try { - let payload: Hook - try { - payload = JSON.parse(stdin) as Hook - } catch { - process.exit(0) - } - const tool = payload.tool_name - if (tool !== 'Edit' && tool !== 'Write') { - process.exit(0) - } - const filePath = payload.tool_input?.file_path - if (!filePath || !isWorkflowYamlPath(filePath)) { - process.exit(0) - } - const proposed = - payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' - const bad = findBadUsesLines(proposed) - if (bad.length === 0) { - process.exit(0) - } - const today = new Date().toISOString().slice(0, 10) - process.stderr.write( - `[workflow-uses-comment-guard] refusing edit: ${bad.length} ` + - `\`uses:\` line(s) lack the canonical ` + - `\`# <tag-or-version-or-branch> (YYYY-MM-DD)\` comment:\n` + - bad.map(b => ` ${b.line}\n ↳ ${b.reason}`).join('\n') + - '\n\nFix: append a comment like `# v6.4.0 (' + - today + - ')` or `# main (' + - today + - ')` to every SHA-pinned `uses:` line.\n' + - 'The label is the upstream tag, branch, or short-SHA; the date is\n' + - 'when you pinned/refreshed (today is fine for new pins). The\n' + - 'date-stamp is the staleness signal — reviewers can see at-a-glance\n' + - 'when a SHA was last touched without running a drift audit.\n' + - '\nOne-off override: append `# socket-hook: allow uses-no-stamp`\n' + - 'to the `uses:` line.\n', - ) - process.exit(2) - } catch (e) { - process.stderr.write( - `[workflow-uses-comment-guard] hook error (allowing): ${e}\n`, - ) - process.exit(0) - } - }) - if (process.stdin.readable === false) { - process.exit(0) - } -} - -main() diff --git a/.claude/hooks/workflow-uses-comment-guard/package.json b/.claude/hooks/workflow-uses-comment-guard/package.json deleted file mode 100644 index 1367da408..000000000 --- a/.claude/hooks/workflow-uses-comment-guard/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "hook-workflow-uses-comment-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - } -} diff --git a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts b/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts deleted file mode 100644 index f203370d2..000000000 --- a/.claude/hooks/workflow-uses-comment-guard/test/index.test.mts +++ /dev/null @@ -1,132 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const HOOK_PATH = path.join(__dirname, '..', 'index.mts') - -function runHook(payload: object): { stderr: string; exitCode: number } { - const result = spawnSync('node', [HOOK_PATH], { - input: JSON.stringify(payload), - }) - return { stderr: String(result.stderr), exitCode: result.status ?? -1 } -} - -const SHA = 'de0fac2e4500dabe0009e67214ff5f5447ce83dd' - -test('BLOCKS uses: with no comment', () => { - const { stderr, exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: `jobs:\n build:\n steps:\n - uses: actions/checkout@${SHA}\n`, - }, - }) - assert.equal(exitCode, 2) - assert.match(stderr, /workflow-uses-comment-guard/) -}) - -test('BLOCKS uses: with comment missing date', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ` - uses: actions/checkout@${SHA} # v6.0.2\n`, - }, - }) - assert.equal(exitCode, 2) -}) - -test('BLOCKS uses: with date in wrong format', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ` - uses: actions/checkout@${SHA} # v6.0.2 (May 15 2026)\n`, - }, - }) - assert.equal(exitCode, 2) -}) - -test('ALLOWS uses: with canonical comment shape (tag)', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ` - uses: actions/checkout@${SHA} # v6.0.2 (2026-05-15)\n`, - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS uses: with canonical comment shape (branch)', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ` - uses: SocketDev/socket-registry/.github/actions/setup-pnpm@${SHA} # main (2026-05-15)\n`, - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS local-action uses (no SHA)', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ' - uses: ./.github/actions/setup-rust\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS non-workflow YAML files', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/some/other.yml', - content: `uses: actions/checkout@${SHA}\n`, - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS one-off override marker', () => { - const { exitCode } = runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - content: ` - uses: third-party/action@${SHA} # socket-hook: allow uses-no-stamp\n`, - }, - }) - assert.equal(exitCode, 0) -}) - -test('ALLOWS Edit tool with non-uses new_string', () => { - const { exitCode } = runHook({ - tool_name: 'Edit', - tool_input: { - file_path: '/repo/.github/workflows/ci.yml', - new_string: ' shell: bash\n', - }, - }) - assert.equal(exitCode, 0) -}) - -test('ignores non-Edit/Write tool calls', () => { - const { exitCode } = runHook({ - tool_name: 'Read', - tool_input: { file_path: '/repo/.github/workflows/ci.yml' }, - }) - assert.equal(exitCode, 0) -}) - -test('fails open on bad JSON', () => { - const result = spawnSync('node', [HOOK_PATH], { - input: '{not-json}', - }) - assert.equal(result.status, 0) -}) diff --git a/.claude/hooks/workflow-uses-comment-guard/tsconfig.json b/.claude/hooks/workflow-uses-comment-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/workflow-uses-comment-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/README.md b/.claude/hooks/workflow-yaml-multiline-body-guard/README.md deleted file mode 100644 index d5b136f86..000000000 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# workflow-yaml-multiline-body-guard - -PreToolUse Edit/Write hook that blocks introducing a multi-line -`gh ... --body "..."` into a workflow YAML file. - -## Why - -Multi-line markdown inside `--body "..."` in a workflow `run:` block -breaks YAML parsing. The failure is silent: GitHub shows "0 jobs" on -push triggers, no error in the UI. Historical incident: a fleet workflow -was broken for 3 weeks because someone added a markdown PR body inline. - -Symptoms: - -- Push doesn't trigger anything. -- `gh run list` shows no recent runs. -- The YAML file _looks_ fine in an editor. -- Actionlint catches it — but only if it's wired in. - -## What it blocks - -| Pattern | Block? | -| ------------------------------------------------------ | ------ | -| `gh pr create --body "single line"` | no | -| `gh pr create --body "$BODY"` | no | -| `gh pr create --body-file /tmp/body.md` | no | -| `gh pr create --body "## Heading\n- bullet"` (literal) | yes | -| Same pattern with `gh issue create` / `gh release ...` | yes | -| Same pattern outside `.github/workflows/*.y*ml` | no | - -## Bypass - -Type the canonical phrase in a new message: - - Allow workflow-yaml-multiline-body bypass - -Use sparingly — the failure mode is hard to debug. - -## Detection - -Regex over the after-edit text: find `--body "` openers, walk to the -matching close quote (respecting backslash escapes), check whether the -captured body contains a newline. Skip when the body is a single -variable expansion (`"$VAR"` / `"${VAR}"`). diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts b/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts deleted file mode 100644 index a76fdc454..000000000 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -// Claude Code PreToolUse hook — workflow-yaml-multiline-body-guard. -// -// Blocks Edit/Write to `.github/workflows/*.y*ml` files that introduce a -// `gh ... --body "..."` call with multi-line markdown inside the `--body` -// string. Multi-line markdown breaks YAML parsing — heading characters -// (`#`), backticks, triple-dash horizontal rules, and unbalanced quotes -// all terminate or confuse the workflow's YAML scalar. The failure mode -// is silent: GitHub shows "0 jobs" on push triggers, no error in the UI -// unless you `gh run list` and notice nothing fires. -// -// Detection: regex over the after-edit text of the workflow file. Look -// for `gh (pr|issue|release) (create|edit|comment) ... --body "..."` where -// the `--body` argument spans multiple lines or contains characters that -// would break YAML parsing (`#` at start of line, ``` backtick-fenced -// blocks, `---` standalone line). -// -// Fix: replace with `--body-file <path>` or `--body "$VAR"` where the -// content is built via heredoc into a tempfile / shell var. -// -// Bypass: `Allow workflow-yaml-multiline-body bypass` typed verbatim in a -// recent user turn. - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts' - -interface ToolInput { - readonly tool_name?: string | undefined - readonly tool_input?: - | { - readonly file_path?: string | undefined - readonly new_string?: string | undefined - readonly old_string?: string | undefined - readonly content?: string | undefined - } - | undefined - readonly transcript_path?: string | undefined -} - -const BYPASS_PHRASE = 'Allow workflow-yaml-multiline-body bypass' - -// Detect a multi-line `--body "..."` argument to gh. The match is -// conservative: we look for the literal `--body "` opener, then scan to -// the matching closing `"` (respecting backslash escapes), and check -// whether the captured body contains a newline or a YAML-hazardous -// character at a position that would break the surrounding YAML scalar. -export function findUnsafeBody(text: string): string | undefined { - // Iterate through every `--body "` occurrence. - const opener = /--body\s+"/g - let m: RegExpExecArray | null - while ((m = opener.exec(text)) !== null) { - const start = m.index + m[0].length - // Find the matching close quote. Allow backslash-escaped quotes. - let i = start - let escaped = false - while (i < text.length) { - const c = text[i] - if (escaped) { - escaped = false - i += 1 - continue - } - if (c === '\\') { - escaped = true - i += 1 - continue - } - if (c === '"') { - break - } - i += 1 - } - if (i >= text.length) { - // Unterminated; YAML would have already complained. Skip. - continue - } - const body = text.slice(start, i) - // Skip empty / single-line / variable-only bodies. - if (!body.includes('\n')) { - continue - } - // Skip when the body is a single variable expansion like "$VAR" or - // "${VAR}" — these don't carry markdown into the YAML literal. - if (/^\s*\$\{?\w+\}?\s*$/.test(body)) { - continue - } - return body - } - return undefined -} - -export function isWorkflowYaml(filePath: string): boolean { - // .github/workflows/*.yml or .github/workflows/*.yaml. - return /[\\/]\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/.test(filePath) -} - -export function readFileSafe(p: string): string { - try { - return readFileSync(p, 'utf8') - } catch { - return '' - } -} - -async function main(): Promise<void> { - let raw: string - try { - raw = await readStdin() - } catch { - process.exit(0) - } - if (!raw) { - process.exit(0) - } - let payload: ToolInput - try { - payload = JSON.parse(raw) as ToolInput - } catch { - process.exit(0) - } - if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { - process.exit(0) - } - const input = payload.tool_input - const filePath = input?.file_path - if (!filePath || !isWorkflowYaml(filePath)) { - process.exit(0) - } - - // Determine the after-text. - let afterText: string - if (payload.tool_name === 'Write') { - afterText = input?.content ?? input?.new_string ?? '' - } else { - const currentText = readFileSafe(filePath) - const oldStr = input?.old_string ?? '' - const newStr = input?.new_string ?? '' - if (!oldStr || !currentText.includes(oldStr)) { - process.exit(0) - } - afterText = currentText.replace(oldStr, newStr) - } - - const unsafe = findUnsafeBody(afterText) - if (!unsafe) { - process.exit(0) - } - - if ( - payload.transcript_path && - bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE) - ) { - process.exit(0) - } - - const preview = unsafe.split('\n').slice(0, 3).join('\\n') - process.stderr.write( - [ - '[workflow-yaml-multiline-body-guard] Blocked: multi-line --body in workflow YAML', - '', - ` File: ${path.basename(filePath)}`, - ` Preview: "${preview.slice(0, 80)}..."`, - '', - ' Multi-line markdown in `gh ... --body "..."` inside a workflow', - " `run:` block breaks YAML parsing. Symptom: GitHub shows '0 jobs'", - ' on push triggers with no error in the UI (silent CI breakage).', - '', - ' Fix — use one of:', - '', - ' 1. --body-file with heredoc:', - ' run: |', - " cat > /tmp/body.md <<'EOF'", - ' ## Multi-line markdown OK here', - ' - bullets, `code`, etc.', - ' EOF', - ' gh pr create --body-file /tmp/body.md', - '', - ' 2. Shell variable from heredoc:', - ' run: |', - " BODY=$(cat <<'EOF'", - ' ## Content', - ' EOF', - ' )', - ' gh pr create --body "$BODY"', - '', - ` Bypass: type "${BYPASS_PHRASE}" in a new message, then retry.`, - '', - ].join('\n'), - ) - process.exit(2) -} - -main().catch(e => { - process.stderr.write( - `[workflow-yaml-multiline-body-guard] hook error (allowing): ${(e as Error).message}\n`, - ) -}) diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/package.json b/.claude/hooks/workflow-yaml-multiline-body-guard/package.json deleted file mode 100644 index 10059c935..000000000 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "hook-workflow-yaml-multiline-body-guard", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - }, - "scripts": { - "test": "node --test test/*.test.mts" - }, - "devDependencies": { - "@types/node": "catalog:" - } -} diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts b/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts deleted file mode 100644 index 8e4ef359d..000000000 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts +++ /dev/null @@ -1,131 +0,0 @@ -// node --test specs for the workflow-yaml-multiline-body-guard hook. - -// prefer-async-spawn: streaming-stdio-required — test spawns child -// subprocess and pipes stdin/stdout/stderr; Node spawn returns the -// ChildProcess streaming surface the lib promise wrapper does not. -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import test from 'node:test' -import assert from 'node:assert/strict' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'index.mts') - -type Result = { code: number; stderr: string } - -function tmpWorkflow(content: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'wf-yaml-test-')) - const wfDir = path.join(dir, '.github', 'workflows') - mkdirSync(wfDir, { recursive: true }) - const p = path.join(wfDir, 'test.yml') - writeFileSync(p, content) - return p -} - -async function runHook(payload: Record<string, unknown>): Promise<Result> { - const child = spawn(process.execPath, [HOOK], { stdio: 'pipe' }) - // v6 lib-stable spawn returns an enriched Promise that rejects on - // non-zero exit; this test reads stderr + exit via manual listeners - // instead. Swallow the Promise rejection so it doesn't race the - // listener-based resolve and trigger "async activity after test ended". - void child.catch(() => undefined) - child.stdin!.end(JSON.stringify(payload)) - let stderr = '' - child.process.stderr!.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.process.on('exit', code => { - resolve({ code: code ?? 0, stderr }) - }) - }) -} - -test('non-workflow file passes', async () => { - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: '/tmp/foo.md', - content: '# Heading\ngh pr create --body "## multi\nline"\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow with single-line --body passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n x:\n runs-on: ubuntu-latest\n steps:\n - run: gh pr create --body "single line"\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow with --body-file passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n x:\n steps:\n - run: gh pr create --body-file /tmp/body.md\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow with --body "$VAR" passes', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n x:\n steps:\n - run: gh pr create --body "$BODY"\n', - }, - }) - assert.strictEqual(r.code, 0) -}) - -test('workflow with multi-line --body literal blocked', async () => { - const filePath = tmpWorkflow('') - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n x:\n steps:\n - run: gh pr create --body "## Title\n- item\n"\n', - }, - }) - assert.strictEqual(r.code, 2) -}) - -test('bypass phrase passes', async () => { - const filePath = tmpWorkflow('') - const txDir = mkdtempSync(path.join(os.tmpdir(), 'wf-tx-')) - const transcriptPath = path.join(txDir, 'session.jsonl') - writeFileSync( - transcriptPath, - JSON.stringify({ - type: 'user', - message: { content: 'Allow workflow-yaml-multiline-body bypass' }, - }) + '\n', - ) - const r = await runHook({ - tool_name: 'Write', - tool_input: { - file_path: filePath, - content: - 'jobs:\n x:\n steps:\n - run: gh pr create --body "## Title\n- item\n"\n', - }, - transcript_path: transcriptPath, - }) - assert.strictEqual(r.code, 0) -}) diff --git a/.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json b/.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json deleted file mode 100644 index 19458cf0c..000000000 --- a/.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "declarationMap": false, - "erasableSyntaxOnly": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "skipLibCheck": true, - "sourceMap": false, - "strict": true, - "target": "esnext", - "types": ["node"], - "verbatimModuleSyntax": true - } -} diff --git a/.claude/ops/queue.yaml b/.claude/ops/queue.yaml deleted file mode 100644 index 804f46ea1..000000000 --- a/.claude/ops/queue.yaml +++ /dev/null @@ -1,11 +0,0 @@ -schema_version: 1 - -phase_order: - quality-scan: [env-check, scans, report] - security-scan: [env-check, agentshield, zizmor, grade-report] - updating: [env-check, npm-update, validate, report] - updating-checksums: [env-check, fetch-latest, validate, report] - -# Completed runs are appended here by skills. Prune periodically — -# keep the last 10 entries and delete older ones to avoid unbounded growth. -runs: [] diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index efcf6a6c0..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,403 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-new-deps/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-section-size-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/claude-md-size-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/cross-repo-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-disable-lint-rule-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gitmodules-comment-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lock-step-ref-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/logger-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/markdown-filename-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minimum-release-age-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-package-json-pnpm-overrides-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-fleet-fork-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/new-hook-claude-md-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-meta-comments-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-token-in-dotenv-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-underscore-identifier-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/path-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/paths-mts-inherit-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-location-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plugin-patch-format-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pull-request-target-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/readme-fleet-shape-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-uses-comment-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/marketplace-comment-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/vitest-include-vs-node-test-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/workflow-yaml-multiline-body-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/immutable-release-pattern-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inline-script-defer-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/consumer-grep-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/soak-exclude-date-annotation-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-structured-clone-prefer-json-guard/index.mts" - } - ] - }, - { - "matcher": "AskUserQuestion", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ask-suppression-reminder/index.mts" - } - ] - }, - { - "matcher": "Agent", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-no-write-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-author-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-message-format-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/concurrent-cargo-build-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/default-branch-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-token-hygiene-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-blind-keychain-read-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-experimental-strip-types-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-empty-commit-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/node-modules-staging-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-vs-push-default-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-non-fleet-push-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-external-issue-ref-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-revert-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/overeager-staging-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prefer-rebase-over-revert-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/private-name-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/public-surface-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/release-workflow-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scan-label-in-commit-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/token-guard/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/version-bump-order-guard/index.mts" - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/socket-token-minifier-start/index.mts", - "timeout": 5 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "mcp__.*", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts" - } - ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/actionlint-on-workflow-edit/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/extension-build-current-guard/index.mts" - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enterprise-push-property-reminder/index.mts" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auth-rotation-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/comment-tone-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-pr-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/compound-lessons-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dirty-worktree-on-stop-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-blame-user-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dont-stop-mid-queue-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/drift-check-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/error-message-quality-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/excuse-detector/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/file-size-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/follow-direct-imperative-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/identifying-users-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/judgment-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/perfectionist-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-review-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-orphaned-staging/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/setup-security-tools/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/squash-history-reminder/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stale-process-sweeper/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sweep-ds-store/index.mts" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/variant-analysis-reminder/index.mts" - } - ] - } - ] - }, - "permissions": { - "deny": [ - "Bash(gh release create:*)", - "Bash(gh release delete:*)", - "Bash(git push --force:*)", - "Bash(git push -f:*)", - "Bash(npm publish:*)", - "Bash(pnpm publish:*)", - "Bash(yarn publish:*)" - ] - } -} diff --git a/.claude/skills/_shared/compound-lessons.md b/.claude/skills/_shared/compound-lessons.md deleted file mode 100644 index 5ee56e208..000000000 --- a/.claude/skills/_shared/compound-lessons.md +++ /dev/null @@ -1,44 +0,0 @@ -# Compound lessons - -How a fleet skill or review turns a finding into a durable rule, instead of fixing it once and forgetting. - -## The principle - -Each unit of engineering work should make subsequent units **easier**, not harder. A bug fix that doesn't update the rule that allowed the bug is a half-finished job: the next change in the same area will hit the same class of bug, and the cycle repeats. - -Three places a lesson can land in this fleet: - -| Where | When | Effect | -| --------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | -| **CLAUDE.md fleet rule** | The mistake recurs across repos or is a fleet-wide invariant | Every fleet repo inherits the rule on next sync | -| **`.claude/hooks/*` block** | The mistake is mechanical and can be detected from tool input/output | Hook blocks the next attempt before the file is written | -| **Skill prompt update** | The mistake is judgment-shaped (review pass missed a class of finding) | Future runs of that skill catch the variant | - -## When to compound - -Compound a lesson **only** when one of these is true: - -1. **Recurrence** — the same kind of bug has now appeared 2+ times. Write down the rule that would have caught both. -2. **High blast radius** — the bug shipped, broke a downstream user, or required a revert. The rule prevents the next shipping incident. -3. **Drift signal** — fleet repos disagreed on the answer. The rule reconciles which answer wins. - -Don't compound for one-off fixes that won't recur. Don't write a "lesson" doc when the lesson is just "we fixed it." The fleet rule **is** the lesson; if you can't crystallize it into a rule, the lesson isn't ready. - -## How to compound - -1. **Name the rule** — one sentence, imperative voice. "Never X." "Always Y." -2. **Cite the incident** — one-line `**Why:**` line referencing the commit, PR, or finding. Don't write a paragraph. -3. **State the application** — one-line `**How to apply:**` line saying when the rule fires. -4. **Land it where it'll fire** — CLAUDE.md, hook, or skill prompt. Pick the lowest-friction surface that catches the next occurrence. - -Skip the retrospective doc. Skip the post-mortem template. The rule is the artifact. - -## Anti-patterns - -- **The "lessons learned" graveyard** — a `docs/lessons/` folder where dated markdown files rot. Don't. The rule belongs in the live config that fires on the next run. -- **Vague rules** — "be careful with X." Useless. If you can't write the rule as a `rg` pattern or a CLAUDE.md `🚨` line, it isn't a rule yet. -- **Rules without why** — future readers can't judge edge cases without the original incident. Always cite. - -## Source - -Borrowed from Every Inc.'s _Compound Engineering_ playbook (https://every.to/chain-of-thought/compound-engineering-how-every-codes-with-agents). Their `/ce-compound` slash command is the verb form of this principle; we encode the same discipline as a fleet convention rather than a slash command. diff --git a/.claude/skills/_shared/env-check.md b/.claude/skills/_shared/env-check.md deleted file mode 100644 index b88e6e406..000000000 --- a/.claude/skills/_shared/env-check.md +++ /dev/null @@ -1,28 +0,0 @@ -# Environment Check - -Shared prerequisite validation for all pipelines. Run at the start of every skill. - -## Steps - -1. Run `git status` to check working directory state -2. Detect CI mode: check for `GITHUB_ACTIONS` or `CI` environment variables -3. Verify `node_modules/` exists (run `pnpm install` if missing) -4. Verify on a valid branch (`git branch --show-current`) - -## Behavior - -- **Clean working directory**: proceed normally -- **Dirty working directory**: warn and continue (most skills are read-only or create their own commits) -- **CI mode**: set `CI_MODE=true` — skills should skip interactive prompts and local-only validation -- **Missing node_modules**: run `pnpm install` before proceeding - -## Queue Tracking - -Write a run entry to `.claude/ops/queue.yaml` with: - -- `id`: `{pipeline}-{YYYY-MM-DD}-{NNN}` -- `pipeline`: the invoking skill name -- `status`: `in-progress` -- `started`: current UTC timestamp -- `current_phase`: `env-check` -- `completed_phases`: `[]` diff --git a/.claude/skills/_shared/multi-agent-backends.md b/.claude/skills/_shared/multi-agent-backends.md deleted file mode 100644 index 813b5612a..000000000 --- a/.claude/skills/_shared/multi-agent-backends.md +++ /dev/null @@ -1,57 +0,0 @@ -# Multi-agent backends - -Shared policy for skills that delegate work to multiple AI CLIs (codex, claude, opencode, kimi, …). Any skill that calls out to another agent should follow this contract so the user gets a uniform experience across skills. - -> Looking for _when_ to hand work off to another agent (CLI subprocess vs. mid-conversation `Agent(subagent_type=…)`)? See [`docs/references/agent-delegation.md`](../../../docs/references/agent-delegation.md). This file covers the _how_ for the CLI-subprocess path. - -## Goals - -- **Graceful detection.** Skills don't hard-fail when a preferred backend isn't installed. They fall back through a documented preference order, then skip the pass with a recorded note if nothing usable is available. -- **Consistent attribution.** When a backend produces output, the skill labels the section / report / commit message with the backend name (`Codex Verification`, not just `Verification`) so the reader knows which model said what. -- **No silent provider routing.** Hybrid backends like `opencode` (which dispatch to other providers internally per their own config) are only used when **explicitly** selected, never auto-picked. Direct backends (codex, claude, kimi) are preferred so model attribution stays accurate. - -## Backend registry - -| Backend | CLI binary | Hybrid? | Default role preference | -| -------- | ---------- | ------- | ---------------------------------------------------- | -| codex | `codex` | no | discovery, discovery-secondary, remediation | -| claude | `claude` | no | verify | -| kimi | `kimi` | no | any role (fallback) | -| opencode | `opencode` | **yes** | only when `--pass <role>=opencode` explicitly chosen | - -Adding a new backend = one entry in the registry: `{ name, bin, hybrid, run(promptFile, outFile) -> { argv, outMode } }`. No other call site changes. - -## Detection policy - -Detect availability via `command -v <bin>` at runtime, never hardcode "claude is always there." A skill that wants Codex but only has Kimi should run on Kimi (fallback), not bail out. - -``` -For each role: - preferred = explicit override (--pass role=backend) or first match in role.preferenceOrder - if preferred is hybrid AND not explicitly selected -> skip preferred, try next - if preferred is installed -> use it - if no backend installed for this role -> skip the pass with a note in the output -``` - -Document skips inline in whatever output the skill produces (`> Skipped pass: <role> — no available backend`) so the reader sees the gap. - -## Env-var conventions - -| Var | Default | Purpose | -| ----------------- | ------------- | ---------------------------------------------- | -| `CODEX_MODEL` | `gpt-5.4` | Codex model when codex is the active backend | -| `CODEX_REASONING` | `xhigh` | Codex reasoning effort | -| `CLAUDE_MODEL` | `opus` | Claude model when claude is the active backend | -| `KIMI_MODEL` | `kimi-latest` | Kimi model when kimi is the active backend | - -Don't invent per-skill env var names — reuse these. Skills that need a non-default model for a specific run accept a `--model` flag rather than introducing new env vars. - -## Canonical implementation - -`.claude/skills/reviewing-code/run.mts` is the reference implementation. New skills that need multi-agent delegation should import the same registry shape and detection function (or copy the small block until extraction is worth doing) — don't roll a parallel pattern. - -## When NOT to use - -- Skills that only need _one_ agent (the current Claude session driving the user). No detection needed; just do the work. -- Skills that need a specific model unconditionally (e.g. a benchmark that compares two models — those use direct API calls, not the CLI registry). -- Per-repo fix scripts that rely on a single tool (`pnpm`, `git`, `cargo`). Tooling, not agents. diff --git a/.claude/skills/_shared/path-guard-rule.md b/.claude/skills/_shared/path-guard-rule.md deleted file mode 100644 index 2dae74af1..000000000 --- a/.claude/skills/_shared/path-guard-rule.md +++ /dev/null @@ -1,39 +0,0 @@ -<!-- -Shared snippet — the canonical "1 path, 1 reference" rule text. -Synced byte-identical across the Socket fleet via socket-wheelhouse's -sync-scaffolding.mts (SHARED_SKILL_FILES). - -This file is the source of truth for the rule's wording. Three artifacts -embed (or paraphrase) it: - - 1. CLAUDE.md — every Socket repo's instructions to Claude. - 2. .claude/hooks/path-guard/README.md — what the hook blocks. - 3. .claude/skills/guarding-paths/SKILL.md — what the skill enforces. - -If the wording changes here, re-run `node scripts/sync-scaffolding.mts ---all --fix` from socket-wheelhouse to propagate. ---> - -## 1 path, 1 reference - -**A path is _constructed_ exactly once. Everywhere else _references_ the constructed value.** - -Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is _re-constructing_ the same path in multiple places, because that's where drift is born. Three concrete shapes: - -1. **Within a package** — every script, test, and lib file that needs a build path imports it from the package's `scripts/paths.mts` (or `lib/paths.mts`). No `path.join('build', mode, ...)` outside that module. - -2. **Across packages** — when package B consumes package A's output, B imports A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '<sibling>', 'build', ...)`. The R28 yoga/ink bug — ink hand-building yoga's wasm path and missing the `wasm/` segment — is the canonical failure mode this rule prevents. - -3. **Workflows, Dockerfiles, shell scripts** — they can't `import` TS, so they construct the string once and reference it everywhere downstream. Workflows: a "Compute paths" step exposes `steps.paths.outputs.final_dir`; later steps read `${{ steps.paths.outputs.final_dir }}`. Dockerfiles/shell: assign once to a variable, reference by name thereafter. Each canonical construction carries a comment naming the source-of-truth `paths.mts` so the YAML can't drift from TS without a flagged change. **Re-building** the same path in a second step is the violation, not referring to the constructed value many times. - -Comments that re-state a full path are forbidden. The import statement IS the comment. Docs and READMEs may describe the structure ("output goes under the Final dir") but should not encode a complete `build/<mode>/<platform-arch>/out/Final/binary` string — encoded paths get parsed by tools and silently rot. - -Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, Dockerfiles, workflow YAML, and shell scripts are blocking. README and doc-comment violations are advisory unless they contain a fully-qualified path with no parametric placeholders. - -### Three-level enforcement - -- **Hook** — `.claude/hooks/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. -- **Gate** — `scripts/check-paths.mts` runs in `pnpm check`. Fails the build on any violation that isn't allowlisted. -- **Skill** — `/guarding-paths` audits the repo and fixes findings; `/guarding-paths check` reports only; `/guarding-paths install` drops the gate + hook + rule into a fresh repo. - -The mantra is intentionally short so it sticks: **1 path, 1 reference**. When in doubt, find the canonical owner and import from it. diff --git a/.claude/skills/_shared/report-format.md b/.claude/skills/_shared/report-format.md deleted file mode 100644 index b41463286..000000000 --- a/.claude/skills/_shared/report-format.md +++ /dev/null @@ -1,50 +0,0 @@ -# Report Format - -Shared output format for all scan and review pipelines. - -## Finding Format - -Each finding: - -``` -- **[SEVERITY]** file:line — description - Fix: how to fix it -``` - -Severity levels: CRITICAL, HIGH, MEDIUM, LOW - -## Grade Calculation - -Based on finding severity distribution: - -- **A** (90-100): 0 critical, 0 high -- **B** (80-89): 0 critical, 1-3 high -- **C** (70-79): 0 critical, 4+ high OR 1 critical -- **D** (60-69): 2-3 critical -- **F** (< 60): 4+ critical - -## Pipeline HANDOFF - -When a skill completes as part of a larger pipeline (e.g., scanning-quality within release), -output a structured handoff block: - -``` -=== HANDOFF: {skill-name} === -Status: {pass|fail} -Grade: {A-F} -Findings: {critical: N, high: N, medium: N, low: N} -Summary: {one-line description} -=== END HANDOFF === -``` - -The parent pipeline reads this to decide whether to proceed (gate check) or abort. - -## Queue Completion - -When the final phase completes, update `.claude/ops/queue.yaml`: - -- `status`: `done` (or `failed`) -- `completed`: current UTC timestamp -- `current_phase`: `~` (null) -- `completed_phases`: full list -- `findings_count`: `{critical: N, high: N, medium: N, low: N}` diff --git a/.claude/skills/_shared/scripts/git-default-branch.mts b/.claude/skills/_shared/scripts/git-default-branch.mts deleted file mode 100644 index a2d705a1a..000000000 --- a/.claude/skills/_shared/scripts/git-default-branch.mts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Default-branch resolution for fleet skill runners. - * - * Per CLAUDE.md "Default branch fallback" rule: prefer main, fall back to - * master. Never hard-code one or the other — fleet repos are mostly on main, - * but a few legacy / vendored repos still use master, and a script that - * hard-codes main silently no-ops on those. - * - * Cross-platform: shells out to git via @socketsecurity/lib/spawn, which works - * the same on macOS / Linux / Windows. - */ -import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors' -import { spawn } from '@socketsecurity/lib/process/spawn/child' - -export type ResolveDefaultBranchOptions = { - /** - * Working directory; defaults to process.cwd(). - */ - readonly cwd?: string | undefined - /** - * Remote name; defaults to 'origin'. - */ - readonly remote?: string | undefined -} - -/** - * Resolve the remote's default branch, preferring `main` and falling back to - * `master`. Returns `'main'` as a final fallback when the remote has neither - * branch (e.g., fresh clone before `git fetch`). - * - * Resolution order: - * - * 1. `git symbolic-ref refs/remotes/<remote>/HEAD` — most reliable. - * 2. Probe `refs/remotes/<remote>/main` — true on the vast majority of fleet - * repos. - * 3. Probe `refs/remotes/<remote>/master` — legacy / vendored repos. - * 4. Assume `main` and let the next git command fail loudly. - */ -export async function resolveDefaultBranch( - options: ResolveDefaultBranchOptions = {}, -): Promise<string> { - const { cwd = process.cwd(), remote = 'origin' } = options - - // Step 1: ask the remote what its HEAD points to. - try { - const ref = await runGit( - ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], - cwd, - ) - if (ref) { - // Strip the "<remote>/" prefix. - return ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref - } - } catch { - // Fall through. - } - - // Step 2 + 3: probe main, then master. - for (const branch of ['main', 'master']) { - if (await branchExists(branch, cwd, remote)) { - return branch - } - } - - // Step 4: last resort. - return 'main' -} - -async function branchExists( - branch: string, - cwd: string, - remote: string, -): Promise<boolean> { - try { - await runGit( - ['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`], - cwd, - ) - return true - } catch { - return false - } -} - -async function runGit(args: readonly string[], cwd: string): Promise<string> { - try { - const result = await spawn('git', args, { cwd, stdioString: true }) - return String(result.stdout ?? '').trim() - } catch (e) { - if (isSpawnError(e)) { - throw e - } - throw e - } -} diff --git a/.claude/skills/_shared/scripts/logger-guardrails.mts b/.claude/skills/_shared/scripts/logger-guardrails.mts deleted file mode 100644 index 89f53b491..000000000 --- a/.claude/skills/_shared/scripts/logger-guardrails.mts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Lint guardrails the fleet enforces beyond what oxlint covers natively. - * - * Five checks, one pass: - * - * 1. **Status-symbol emoji** (✓ ✔ ❌ ✗ ⚠ ⚠️ ❗ ✅ ❎ ☑) — banned. The - * `@socketsecurity/lib/logger/default` package owns the visual prefix via - * `logger.success()` / `logger.fail()` / `logger.warn()` etc. Hand-rolling - * the symbols fragments the visual style and bypasses theme-aware color. - * 2. **`console.log` / `console.error` / `console.warn` / `console.info` / - * `console.debug` / `console.trace`** — banned. Use the logger. - * 3. **Inline `getDefaultLogger().<method>()`** — banned. The logger must be - * hoisted at the top of the file: `const logger = getDefaultLogger()` then - * `logger.success(...)`. Inline calls re-resolve the logger every invocation - * and read inconsistently. - * 4. **Dynamic `import()` in non-bundled code** — banned. Scripts under `scripts/` - * run directly via `node`; nothing bundles them, so a dynamic import only - * adds a runtime async hop for no resolution win. Use static ES6 imports. - * Allowed inside `src/` (which gets bundled) and inside `.config/` bundler - * configs. - * - * (TypeScript `any` is enforced by oxlint's `typescript/no-explicit-any` rule — - * kept in `.oxlintrc.json` so it benefits from the language-aware AST. Don't - * duplicate that here.) - * - * Why a custom check instead of oxlint plugins: the rules above need either - * custom matchers (the inline-logger hoist requirement) or conditional scope - * (dynamic-import bans only outside the bundled tree) that oxlint's built-in - * rule set doesn't express. A small TS scanner is cheaper than a full oxlint - * plugin and runs in the existing scripts/check.mts pipeline. - * - * Usage: import { checkLoggerGuardrails } from - * '.../_shared/scripts/logger-guardrails.mts' const { violations } = await - * checkLoggerGuardrails({ cwd: process.cwd() }) if (violations.length) { - * process.exitCode = 1 } - */ -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import fastGlob from 'fast-glob' - -export type GuardrailReason = - | 'status-emoji' - | 'console-call' - | 'inline-logger' - | 'dynamic-import' - -export type GuardrailViolation = { - readonly file: string - readonly line: number - readonly column: number - readonly snippet: string - readonly reason: GuardrailReason -} - -export type CheckLoggerGuardrailsOptions = { - /** - * Repo root. Defaults to process.cwd(). - */ - readonly cwd?: string | undefined - /** - * Globs to scan, relative to cwd. - */ - readonly include?: readonly string[] | undefined - /** - * Globs to skip. - */ - readonly exclude?: readonly string[] | undefined - /** - * File extensions to scan. - */ - readonly extensions?: readonly string[] | undefined - /** - * Globs that ARE bundled. Dynamic `import()` is allowed inside these (the - * bundler resolves the import statically at build time). Default is `src/**` - * + `.config/**` (bundler configs). - */ - readonly bundledRoots?: readonly string[] | undefined -} - -export type CheckLoggerGuardrailsResult = { - readonly violations: readonly GuardrailViolation[] - readonly fileCount: number -} - -const DEFAULT_INCLUDE = ['scripts/**/*', 'src/**/*', 'lib/**/*', '.config/**/*'] -const DEFAULT_EXCLUDE = [ - '**/dist/**', - '**/node_modules/**', - '**/coverage/**', - '**/.cache/**', - '**/test/fixtures/**', - '**/test/packages/**', - '**/*.d.ts', - '**/*.d.mts', - '**/upstream/**', -] -const DEFAULT_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'] -const DEFAULT_BUNDLED_ROOTS = ['src/', '.config/'] - -const STATUS_EMOJI = ['✓', '✔', '❌', '✗', '⚠', '⚠️', '❗', '✅', '❎', '☑'] - -const CONSOLE_CALL_RE = - /\bconsole\s*\.\s*(?:debug|error|info|log|trace|warn)\s*\(/g - -const INLINE_LOGGER_RE = /\bgetDefaultLogger\s*\(\s*\)\s*\.\s*[a-zA-Z_$]/g - -const DYNAMIC_IMPORT_RE = /(?<![a-zA-Z_$])import\s*\(/g - -function isInBundledRoot( - relativePath: string, - bundledRoots: readonly string[], -): boolean { - const normalized = relativePath.split(path.sep).join('/') - return bundledRoots.some(root => normalized.startsWith(root)) -} - -function isCommentLine(trimmed: string): boolean { - return ( - trimmed.startsWith('//') || - trimmed.startsWith('*') || - trimmed.startsWith('/*') - ) -} - -export async function checkLoggerGuardrails( - options: CheckLoggerGuardrailsOptions = {}, -): Promise<CheckLoggerGuardrailsResult> { - const cwd = options.cwd ?? process.cwd() - const include = options.include ?? DEFAULT_INCLUDE - const exclude = options.exclude ?? DEFAULT_EXCLUDE - const extensions = options.extensions ?? DEFAULT_EXTENSIONS - const bundledRoots = options.bundledRoots ?? DEFAULT_BUNDLED_ROOTS - - const files = await fastGlob(include as string[], { - absolute: true, - cwd, - ignore: exclude as string[], - onlyFiles: true, - }) - - const matched = files.filter(file => - extensions.some(ext => file.endsWith(ext)), - ) - - const violations: GuardrailViolation[] = [] - - for (let i = 0, { length } = matched; i < length; i += 1) { - const file = matched[i]! - if (!existsSync(file)) { - continue - } - const relative = path.relative(cwd, file) - const inBundled = isInBundledRoot(relative, bundledRoots) - const content = readFileSync(file, 'utf8') - const lines = content.split('\n') - - for (const [index, line] of lines.entries()) { - const trimmed = line.trimStart() - if (isCommentLine(trimmed)) { - continue - } - - // (1) Status-symbol emoji. - for (let i = 0, { length } = STATUS_EMOJI; i < length; i += 1) { - const emoji = STATUS_EMOJI[i]! - const col = line.indexOf(emoji) - if (col >= 0) { - violations.push({ - column: col + 1, - file: relative, - line: index + 1, - reason: 'status-emoji', - snippet: line.trim(), - }) - break - } - } - - // (2) console.* calls. - CONSOLE_CALL_RE.lastIndex = 0 - const consoleMatch = CONSOLE_CALL_RE.exec(line) - if (consoleMatch) { - violations.push({ - column: consoleMatch.index + 1, - file: relative, - line: index + 1, - reason: 'console-call', - snippet: line.trim(), - }) - } - - // (3) Inline getDefaultLogger(). - INLINE_LOGGER_RE.lastIndex = 0 - const inlineMatch = INLINE_LOGGER_RE.exec(line) - if (inlineMatch) { - violations.push({ - column: inlineMatch.index + 1, - file: relative, - line: index + 1, - reason: 'inline-logger', - snippet: line.trim(), - }) - } - - // (4) Dynamic import in non-bundled code. - if (!inBundled) { - DYNAMIC_IMPORT_RE.lastIndex = 0 - const dynamicMatch = DYNAMIC_IMPORT_RE.exec(line) - if (dynamicMatch) { - violations.push({ - column: dynamicMatch.index + 1, - file: relative, - line: index + 1, - reason: 'dynamic-import', - snippet: line.trim(), - }) - } - } - } - } - - return { fileCount: matched.length, violations } -} - -export const GUARDRAIL_FIX_HINTS: Readonly<Record<GuardrailReason, string>> = { - 'console-call': - 'Use logger from @socketsecurity/lib/logger/default: import { getDefaultLogger } from "@socketsecurity/lib/logger/default"; const logger = getDefaultLogger(); then logger.success(...) / logger.fail(...) / logger.warn(...) / logger.info(...) / logger.log(...).', - 'dynamic-import': - "Use a static `import` statement at the top of the file. Dynamic `import()` is only allowed inside bundled code (src/ or bundler configs); script files run directly via `node` and don't need lazy resolution.", - 'inline-logger': - 'Hoist the logger: `const logger = getDefaultLogger()` near the top of the file. Inline `getDefaultLogger().<method>()` re-resolves on every call.', - 'status-emoji': - 'Remove the literal symbol and use the matching logger method: ✓/✔/✅ → logger.success(...), ❌/✗ → logger.fail(...), ⚠/⚠️ → logger.warn(...), ℹ → logger.info(...).', -} diff --git a/.claude/skills/_shared/scripts/resolve-tools.mts b/.claude/skills/_shared/scripts/resolve-tools.mts deleted file mode 100644 index 4a19a266a..000000000 --- a/.claude/skills/_shared/scripts/resolve-tools.mts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Fleet tool resolver. Inspired by Vite+'s per-tool resolver pattern - * (separating "where does the binary live" from "how do I exec it"), adapted - * for our pnpm-exec-driven fleet. - * - * One place to change when the underlying tool swaps. When the fleet migrates - * esbuild → rolldown, only `resolveBundler()` changes; every caller continues - * to invoke the same resolver and the swap is transparent. - * - * Usage: const { args, envs } = resolveLinter({ mode: 'check' }) await - * spawn('pnpm', ['exec', ...args], { env: { ...process.env, ...envs } }) - * - * Or via the convenience runner: await runResolved(resolveLinter({ mode: - * 'check' }), { cwd }) - * - * Tool selection is a single fleet-wide decision per resolver, not per-repo. If - * a repo needs a different tool, that's drift — surface it in the manifest, - * don't fork the resolver. - */ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { spawn } from '@socketsecurity/lib/process/spawn/child' - -/** - * Result of a resolver. `args` is the full argv passed to `pnpm exec`, - * including the tool name as the first element. `envs` is environment variables - * the tool needs (e.g. `OXLINT_LOG=warn`). - */ -export type ResolvedTool = { - /** - * Full argv for `pnpm exec`, starting with the tool name. - */ - readonly args: readonly string[] - /** - * Environment variables to merge into the spawn env. - */ - readonly envs: Readonly<Record<string, string>> -} - -export type ResolveLinterOptions = { - /** - * `'check'` reports violations; `'fix'` rewrites files in place. - */ - readonly mode?: 'check' | 'fix' | undefined - /** - * Path to the lint config; defaults to repo-root `.oxlintrc.json`. - */ - readonly config?: string | undefined - /** - * Files / globs to lint; defaults to `['.']`. - */ - readonly paths?: readonly string[] | undefined -} - -export type ResolveFormatterOptions = { - /** - * `'check'` fails on diff; `'fix'` rewrites files in place. - */ - readonly mode?: 'check' | 'fix' | undefined - /** - * Path to the formatter config; defaults to repo-root `.oxfmtrc.json`. - */ - readonly config?: string | undefined - /** - * Files / globs to format; defaults to `['.']`. - */ - readonly paths?: readonly string[] | undefined -} - -export type ResolveTypeCheckerOptions = { - /** - * Path to the tsconfig that drives the type check. - */ - readonly project: string -} - -export type ResolveTestRunnerOptions = { - /** - * `'run'` for one-shot, `'watch'` for the dev loop. - */ - readonly mode?: 'run' | 'watch' | undefined - /** - * Path to vitest config; defaults to `.config/vitest.config.mts`. - */ - readonly config?: string | undefined - /** - * Whether to collect coverage. - */ - readonly coverage?: boolean | undefined -} - -export type ResolveBundlerOptions = { - /** - * Path to the build script that owns the run; informational only. - */ - readonly script?: string | undefined -} - -export type RunResolvedOptions = { - /** - * Working directory for the spawn. - */ - readonly cwd?: string | undefined - /** - * Extra args appended after the resolver's defaults. - */ - readonly extraArgs?: readonly string[] | undefined - /** - * If true, `stdout` / `stderr` are buffered and returned on the resolved - * result. Default false (inherit terminal). - */ - readonly capture?: boolean | undefined -} - -const FLEET_LINTER_CONFIG = '.oxlintrc.json' -const FLEET_FORMATTER_CONFIG = '.oxfmtrc.json' -const FLEET_TEST_CONFIG = '.config/vitest.config.mts' - -/** - * Resolve the fleet's linter (currently Oxlint). - * - * Returns argv ready for `pnpm exec`. `--config` is always emitted so a swap to - * a tool with different config-discovery rules doesn't silently change - * behavior. - */ -export function resolveLinter( - options: ResolveLinterOptions = {}, -): ResolvedTool { - const { - config = FLEET_LINTER_CONFIG, - mode = 'check', - paths = ['.'], - } = options - const args: string[] = ['oxlint', '--config', config] - if (mode === 'fix') { - args.push('--fix') - } - args.push(...paths) - return { args, envs: {} } -} - -/** - * Resolve the fleet's formatter (currently Oxfmt). - */ -export function resolveFormatter( - options: ResolveFormatterOptions = {}, -): ResolvedTool { - const { - config = FLEET_FORMATTER_CONFIG, - mode = 'fix', - paths = ['.'], - } = options - const args: string[] = ['oxfmt', '--config', config] - if (mode === 'check') { - args.push('--check') - } else { - args.push('--write') - } - args.push(...paths) - return { args, envs: {} } -} - -/** - * Resolve the fleet's type checker (currently `tsgo`, the - * `@typescript/native-preview` binary). - * - * Always emits `--noEmit` because the fleet's `type` script is for checking - * only — emitting goes through the bundler. - */ -export function resolveTypeChecker( - options: ResolveTypeCheckerOptions, -): ResolvedTool { - const { project } = options - return { - args: ['tsgo', '--noEmit', '-p', project], - envs: {}, - } -} - -/** - * Resolve the fleet's test runner (currently Vitest). - */ -export function resolveTestRunner( - options: ResolveTestRunnerOptions = {}, -): ResolvedTool { - const { config = FLEET_TEST_CONFIG, coverage = false, mode = 'run' } = options - const args: string[] = ['vitest', mode, '--config', config] - if (coverage) { - args.push('--coverage') - } - return { args, envs: {} } -} - -/** - * Resolve the fleet's bundler. Returns esbuild today; flips to rolldown when - * the migration documented in `socket-packageurl-js/docs/rolldown-migration.md` - * lands fleet-wide. - * - * Bundler invocations in the fleet are driven from a per-repo - * `scripts/build.mts` that imports the bundler API directly (not via `pnpm - * exec`), so this resolver returns the binary name only — the caller picks - * which API surface to import. - */ -export function resolveBundler( - _options: ResolveBundlerOptions = {}, -): ResolvedTool { - return { - args: ['esbuild'], - envs: {}, - } -} - -/** - * Convenience: run a `ResolvedTool` via `pnpm exec` and return the result. - * Throws `SpawnError` on non-zero exit unless `capture` is true (then the - * caller inspects the result). - */ -export async function runResolved( - resolved: ResolvedTool, - options: RunResolvedOptions = {}, -): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const { capture = false, cwd = process.cwd(), extraArgs = [] } = options - - const env = { ...process.env, ...resolved.envs } - const argv = ['exec', ...resolved.args, ...extraArgs] - - const result = await spawn('pnpm', argv, { - cwd, - env, - stdioString: true, - ...(capture ? {} : { stdio: 'inherit' as const }), - }) - - return { - exitCode: result.code ?? 0, - stdout: String(result.stdout ?? ''), - stderr: String(result.stderr ?? ''), - } -} - -/** - * Best-effort detection: is the named tool resolvable from the given cwd's - * `node_modules/.bin/`? Useful for soft-failing when a repo opted out of one of - * the fleet's tools. - */ -export function hasResolvedTool( - name: string, - cwd: string = process.cwd(), -): boolean { - return existsSync(path.join(cwd, 'node_modules', '.bin', name)) -} diff --git a/.claude/skills/_shared/security-tools.md b/.claude/skills/_shared/security-tools.md deleted file mode 100644 index 65a9779bb..000000000 --- a/.claude/skills/_shared/security-tools.md +++ /dev/null @@ -1,45 +0,0 @@ -# Security Tools - -Shared tool detection for security scanning pipelines. - -## AgentShield - -Installed as a pinned devDependency (`ecc-agentshield` in pnpm-workspace.yaml catalog). -Run via: `pnpm exec agentshield scan` -No install step needed — available after `pnpm install`. - -## Zizmor - -Not an npm package. Installed via `pnpm run setup` which downloads the pinned version -from GitHub releases with SHA256 checksum verification (see `external-tools.json`). - -The binary is cached at `.cache/external-tools/zizmor/{version}-{platform}/zizmor`. - -Detection order: - -1. `command -v zizmor` (if already on PATH, e.g. via brew) -2. `.cache/external-tools/zizmor/*/zizmor` (from `pnpm run setup`) - -Run via the full path if not on PATH: - -```bash -ZIZMOR="$(find .cache/external-tools/zizmor -name zizmor -type f 2>/dev/null | head -1)" -if [ -z "$ZIZMOR" ]; then ZIZMOR="$(command -v zizmor 2>/dev/null)"; fi -if [ -n "$ZIZMOR" ]; then "$ZIZMOR" .github/; else echo "zizmor not installed — run pnpm run setup"; fi -``` - -If not available: - -- Warn: "zizmor not installed — run `pnpm run setup` to install" -- Skip the zizmor phase (don't fail the pipeline) - -## Socket CLI - -Optional. Used for dependency scanning in the updating and scanning-security pipelines. - -Detection: `command -v socket` - -If not available: - -- Skip socket-scan phases gracefully -- Note in report: "Socket CLI not available — dependency scan skipped" diff --git a/.claude/skills/_shared/skill-authoring.md b/.claude/skills/_shared/skill-authoring.md deleted file mode 100644 index 66179ec21..000000000 --- a/.claude/skills/_shared/skill-authoring.md +++ /dev/null @@ -1,173 +0,0 @@ -# Skill authoring patterns - -Conventions every fleet skill follows. Reference from new-skill scaffolds and from auditor agents. - -## Modular structure - -A skill's `SKILL.md` is the **orchestrator**, not the encyclopedia. When a skill grows past ~300 lines or covers more than one phase / tool / domain, push the depth into siblings: - -``` -.claude/skills/ -├── _shared/ -│ ├── <topic>.md # shared prose loaded on demand by multiple skills -│ └── scripts/ -│ └── <helper>.mts # shared TS helpers used by per-skill run.mts files -└── my-skill/ - ├── SKILL.md # ≤ 300 lines, table of contents + decision flow - ├── reference.md # long-form prose Claude reads (single file, growable to a dir) - ├── scans/<type>.md # one file per scan type / phase / tool (when many) - ├── templates/ # file scaffolding copied verbatim by install/setup modes - │ └── <name>.tmpl - └── run.mts # skill-specific executable runner -``` - -Two naming conventions are load-bearing: - -- **`lib/` vs `scripts/`** matches the fleet's public-vs-private convention. `lib/` names a public, importable, stable surface (think `@socketsecurity/lib`); `scripts/` names private, internal automation that's not consumed outside the host repo. Skill helpers under `_shared/scripts/` are internal automation — no external consumers — so `scripts/` is the right name. (No `_shared/lib/` exists in this tree.) -- **`reference.md` vs `reference/`** — single file by default; grow to a directory only when a skill genuinely has multiple distinct reference docs. Don't preemptively wrap a single doc in a dir. -- **`templates/`** is reserved for file scaffolding (`.tmpl` files copied verbatim by `install` / `setup` modes). Don't mix templates into `reference/` — readers can't tell prose from scaffolding by directory name alone. - -The same File-size rule from CLAUDE.md applies — soft cap 500, hard cap 1000 — but for skills the trigger is usually **shape**, not lines: as soon as the SKILL.md is "this and also that and also the other thing," extract. - -What goes where: - -| Path | Purpose | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<skill>/SKILL.md` | Orchestrator: when to use, modes, phase list, links to deeper files. Reads top-to-bottom in one screen. | -| `<skill>/reference.md` | Long-form depth: bash blocks, full validation rules, sample outputs, recovery procedures. Loaded by the orchestrator when a phase needs it. | -| `<skill>/scans/`, `phases/`, `tools/` | One file per discrete unit when the skill enumerates many (e.g., `scanning-quality/scans/<type>.md`). Adding a new unit = one new file, no SKILL.md touch. | -| `<skill>/templates/<name>.tmpl` | File scaffolding (`.tmpl` files copied verbatim by `install` / `setup` modes — gate scripts, allowlist starters, etc.). Distinct from `reference.md` which is prose, not scaffolding. | -| `<skill>/run.mts` | Skill-specific executable runner. Inline prompts so prompts and code can't drift. Per CLAUDE.md _Tooling — Runners are `.mts`, not `.sh`_. | -| `_shared/<topic>.md` | Shared **prose** (variant-analysis discipline, compound-lessons workflow, multi-agent backends). Cross-skill load surface. | -| `_shared/scripts/<helper>.mts` | Shared **TypeScript** helpers imported by per-skill `run.mts` (default-branch resolution, report formatting, spawn wrappers). Internal automation — not a public library, hence `scripts/` not `lib/`. Use `@socketsecurity/lib/spawn` for subprocesses, never raw `node:child_process`. | - -## Auditor agents - -Skills that author other artifacts (skills, hooks, slash commands, subagents) should ship an auditor sibling. The pattern: - -1. The authoring skill emits a draft. -2. An auditor agent (separate prompt, narrower tool surface) reviews against a checklist. -3. The authoring skill applies the auditor's feedback before shipping. - -Three audit dimensions per artifact: - -| Artifact | Auditor checks | -| ------------- | -------------------------------------------------------------------------------------------------------------- | -| Skill | frontmatter complete, when-to-use unambiguous, tool surface minimal, no buried opinions | -| Hook | matcher tight, command exits fast, doesn't depend on session state, can't deadlock | -| Slash command | argument shape clear, idempotent, doesn't touch shared state without confirmation | -| Subagent | prompt self-contained (no "based on the conversation"), tool surface matches the task, return shape documented | - -A fleet skill that does this well is the canonical reference; the auditor is a `Task` agent spawned by the authoring skill, not a long-running daemon. - -## Compound-lessons capture - -When a fleet skill discovers a recurring failure mode — a lint rule that catches the same kind of bug, a hook that blocks the same antipattern, a review pass that flags the same regression — codify it once: - -1. Open a follow-up to add the rule to CLAUDE.md, the hook, or the skill prompt. -2. Reference the original incident (commit, PR, finding ID) in a one-line `**Why:**` so future readers know the rule is load-bearing. -3. Resist the urge to write a full retrospective doc — the fleet rule **is** the retrospective. - -This is the fleet's equivalent of a post-mortem: every recurring bug becomes a rule, every rule earns its place by closing a class of bugs. The principle is _compound engineering_: each unit of work makes the next unit easier. - -## When to NOT extract - -- One-off skill (≤ 100 lines, single phase, single tool) — keep it monolithic. -- Code unique to one repo that can't be shared — keep it in that repo's `unique` skill. -- Prompt that's tightly coupled to its caller — inline, don't split. - -The principle: **a reader should be able to predict what's in a skill from its name, and find what they need without scrolling past three other concerns.** Same as the File-size rule, applied to skills. - -## Frontmatter requirements (from upstream) - -The Anthropic docs codify several rules; honor them: - -- `name`: ≤ 64 chars, lowercase letters / numbers / hyphens only. No `anthropic` / `claude` substring. -- `description`: ≤ 1024 chars, third-person voice (`"Manages X"`, not `"I help with X"` or `"You can use this to X"`). Include both **what** and **when to use**. -- Prefer **gerund form** for the name (`processing-pdfs`, `scanning-quality`); noun-phrase (`pdf-processing`) and verb-imperative (`process-pdfs`) are acceptable alternatives, but pick one and be consistent across the fleet. -- Use forward slashes in any path the skill references — never backslashes, even in docs that target Windows users. - -## Fleet repo references - -When scaffolding a new fleet repo, or when a sync question arises ("how does the fleet do X?"), mimic the reference that matches both axes (`layout` + `native`) in `.config/socket-wheelhouse.json`: - -| layout × native | Best reference | Notes | -| --------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `single-package` × `none` | **`socket-packageurl-js`** or **`socket-sdk-js`** | Clean `pnpm-workspace.yaml`, canonical `scripts/{check,fix,clean,cover,security,update,lockstep,build}.mts`, simple `lockstep.json` with empty `rows`. | -| `monorepo` × `producer` | **`socket-btm`** | 10+ packages (`build-infra`, per-tool-builder workspaces), deep `pnpm --filter` patterns, full `packages: [packages/*, .claude/hooks/*]`, richer catalog, lockstep + submodules + native release matrix. The canonical "monorepo done right" reference. | -| `monorepo` × `consumer` | **`socket-cli`** | 3-package layout (`build-infra`, `cli`, `package-builder`); consumes prebuilts from socket-btm. | -| `monorepo` × `none` | `socket-registry` | Mono npm publish path, no native artifacts via the fleet's release-checksums infra. | -| `monorepo` × `none` + lang-parity | `ultrathink` | Per-language ports tracked entirely in `lockstep.json` `lang-parity` rows, not via release-checksums. Each port has its own build matrix. | -| Library with vendored upstreams | `socket-lib` | Shows `packages: [.claude/hooks/*, tools/*, vendor/*]`, vendored-as-workspace pattern. | -| Skill marketplace / no real build graph | `skills` | Dep-free shims for `clean.mts` / `cover.mts` are acceptable; document the deviation in the script's header. | - -**Don't cross axes when picking a reference.** A `single-package` × `none` repo (`socket-lib`) and a `monorepo` × `consumer` repo (`socket-cli`) ship very different `scripts/*.mts` shapes — `socket-cli`'s scripts assume `packages/` and `pnpm --filter`, which break in a single-package repo. Match both axes. - -## Build-tool decision - -The fleet standardizes on the **VoidZero tool suite** for JavaScript/TypeScript tooling. VoidZero (https://voidzero.dev) maintains the unified upstream stack we adopt component-by-component: - -| Layer | Tool | Status in the fleet | -| ----------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Test runner | **Vitest** | ✓ Adopted fleet-wide (catalog-pinned). | -| Linter | **Oxlint** (Oxc) | ✓ Adopted fleet-wide. | -| Formatter | **Oxfmt** (Oxc) | ✓ Adopted fleet-wide. | -| Bundler (libraries) | **esbuild** today; **Rolldown** under evaluation | Migration tracked separately; pilot in socket-packageurl-js. | -| Dev server / app build | **Vite** | Used implicitly via Vitest; not directly invoked by the fleet's library repos. | -| Unified CLI / monorepo orchestrator | **Vite+** | **Not adopted.** Alpha-stage; revenue-via-enterprise-support trajectory; no concrete pain point our existing `pnpm run *` orchestration doesn't already solve. Reconsider when (a) Vite+ ships 1.0 stable, AND (b) we have a problem it solves better than current scaffolding. | - -**Why component-by-component, not the bundle.** Each VoidZero component matures independently. Adopting individually mature components (Vitest 4.x, Oxlint 1.5x, Oxfmt 0.37+, Rolldown 1.0+) lets the fleet move at the pace of the slowest part — not at the pace of the whole bundle. Adopting Vite+ would couple the fleet to whichever component is least mature at any given time. - -**Rolldown vs esbuild.** Rolldown 1.0 (May 2026) ships with Rollup-API compatibility + esbuild-equivalent perf + better chunking control. For library repos that publish CommonJS-and-ESM dual entry (socket-lib, socket-sdk-js, socket-packageurl-js), the chunking-control win matters when output size matters; esbuild's simpler model still wins on tiny single-entry bundles. Pilot in socket-packageurl-js (most complex single-package repo): if rolldown works there, the rest of the fleet follows. - -**General rule for fleet-wide tool adoption**, regardless of vendor: - -- **Stable** (1.0+, not alpha / beta / RC). -- **License clarity** with no recent shifts (or, if shifted, settled for ≥6 months). -- **Concrete pain point** the new tool solves better than the current setup. Hype isn't a pain point. "Same vendor as our current toolchain" isn't a pain point. - -### Inspiration to borrow from Vite+ - -We don't adopt Vite+ as a runtime dependency, but its **resolver pattern** is worth absorbing. Vite+ separates "where does this tool's binary live?" from "how do I dispatch the command?" via small per-tool resolver functions: - -```ts -// vite-plus/packages/cli/src/resolve-test.ts -export async function test(): Promise<{ - binPath: string - envs: Record<string, string> -}> { - const binPath = join( - dirname(resolve('@voidzero-dev/vite-plus-test')), - 'dist', - 'cli.js', - ) - return { binPath, envs: { ...DEFAULT_ENVS } } -} -``` - -The Rust dispatcher then execs `binPath` with the user's args. Swapping the tool = changing one resolver; the dispatcher doesn't care. - -**Why the fleet should borrow this:** today every fleet repo carries 200–450-line `scripts/check.mts` / `scripts/fix.mts` / `scripts/test.mts` files that duplicate "find the tool binary, build the right args, exec it." Real drift surface — the same logic written 12 times rarely stays in sync. - -**Implemented:** `_shared/scripts/resolve-tools.mts` (fleet-shared, byte-identical) exports `resolveLinter()` / `resolveFormatter()` / `resolveTypeChecker()` / `resolveTestRunner()` / `resolveBundler()` — each returning `{ args, envs }` where `args` is the full `pnpm exec` argv (tool name first) and `envs` is the env-var overrides. A `runResolved()` convenience runs the resolved tool and returns `{ exitCode, stdout, stderr }`. - -```ts -// Caller (per-repo scripts/check.mts): -import { - resolveLinter, - runResolved, -} from '../.claude/skills/_shared/scripts/resolve-tools.mts' -const result = await runResolved(resolveLinter({ mode: 'check' }), { cwd }) -``` - -The resolver gives us a clean migration path: when rolldown goes fleet-wide, we change `resolveBundler()` to return `['rolldown']` instead of `['esbuild']` — every per-repo `scripts/build.mts` that consults the resolver picks up the swap. Per-repo migration to consume the resolver lands repo-by-repo so we don't bundle bundler-swap risk into a 12-repo cascade. - -## References - -Authoritative upstream docs — keep these as the source of truth, mirror their guidance here only when fleet specifics demand it: - -- [Anthropic — Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) — frontmatter rules, progressive disclosure, evaluation-driven development. -- [Anthropic — Claude Code best practices: writing an effective CLAUDE.md](https://code.claude.com/docs/en/best-practices#write-an-effective-claude-md) — CLAUDE.md scope, pruning discipline, when to push knowledge into a skill instead. -- [Anthropic — Prompt engineering best practices](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices) — model-tuning, response-length calibration, examples-over-descriptions. - -Real-world plugin reference (not fleet-canonical, useful as a worked example of skills + hooks + templates working together): [`arscontexta`](https://github.com/agenticnotetaking/arscontexta) — knowledge-system plugin that derives skills/hooks/templates from a conversational setup. Useful as a study of the "skills compose into a system" pattern. diff --git a/.claude/skills/_shared/variant-analysis.md b/.claude/skills/_shared/variant-analysis.md deleted file mode 100644 index 46308c702..000000000 --- a/.claude/skills/_shared/variant-analysis.md +++ /dev/null @@ -1,52 +0,0 @@ -# Variant analysis - -When a finding lands — a bug, a regression, a security issue — the next question is always: **does this same shape exist anywhere else in the repo?** Variant analysis is the systematic answer. - -## Why this exists - -A bug is rarely unique. The mental model that produced it usually produced siblings. The reviewer who didn't catch it once usually missed the rest. Treating each finding as one-off leaks variants into production. - -This file is referenced by `scanning-quality` (variant-analysis scan type), `scanning-security`, and `reviewing-code`. - -## The pattern - -For every confirmed finding, run three searches before closing it out: - -1. **Same file, different lines** — the antipattern often clusters within the file that exhibits it. Read the whole file, not just the diff. -2. **Sibling files, same shape** — `rg`/`grep` for the same call, the same condition, the same data flow. If the bug was `if (foo == null)`, search for that exact shape. -3. **Cross-package, same concept** — does another package own a parallel implementation? If `socket-cli` has the bug, does `socket-registry` have it too? Fleet drift loves to hide variants. - -## What counts as "the same shape" - -| Bug class | What to search for | -| ------------------ | ---------------------------------------------------------------------------- | -| Missing null check | the call before the access — `foo.bar()` where `foo` could be undefined | -| Race condition | the lock primitive + the call sequence | -| Path construction | literal `path.join('build', …)` outside the canonical `paths.mts` | -| Insecure default | the option name, the boolean default, the env-var fallback | -| Token leak | the field name (`token`, `api_key`, …), the log statement, the error message | -| Promise.race leak | `Promise.race(`, `Promise.any(` inside a `for`/`while` | -| Forbidden API | `fetch(`, `fs.rm(`, `fs.access(`, raw `npx` / `pnpm dlx` | - -## Outputs - -For each variant found, emit: - -``` -- file:line — variant of <original-finding-id> - Pattern: <one-line shape> - Severity: <propagate from original, or LOWER if context differs> - Fix: <reference original fix, or note where it diverges> -``` - -Variants should be batched into the same fix commit when mechanical (one find/replace), or filed as sibling commits on the same branch when each needs review. - -## Don't - -- Don't variant-hunt for style nits. Reserve this for correctness / security / fleet-drift findings. -- Don't expand the search radius past one repo without writing it down — cross-fleet variants get a `chore(wheelhouse): cascade <fix>` PR per the _Drift watch_ rule. -- Don't skip the search because the finding "looks unique." Looking unique is exactly when the search pays off. - -## Trail-of-Bits influence - -This pattern is borrowed from Trail of Bits' `variant-analysis` plugin (https://github.com/trailofbits/skills) and adapted to the fleet's drift-watch discipline. Their version is Semgrep-rule-driven for security; ours is `rg`-driven for general correctness. Same idea, lighter machinery. diff --git a/.claude/skills/_shared/verify-build.md b/.claude/skills/_shared/verify-build.md deleted file mode 100644 index 5dc82c03c..000000000 --- a/.claude/skills/_shared/verify-build.md +++ /dev/null @@ -1,22 +0,0 @@ -# Verify Build - -Shared build/test/lint validation. Referenced by skills that modify code or dependencies. - -## Steps - -Run in order, stop on first failure: - -1. `pnpm run fix --all` — auto-fix lint and formatting issues -2. `pnpm run check --all` — lint + typecheck + validation (read-only, fails on violations) -3. `pnpm test` — full test suite - -## CI Mode - -When `CI_MODE=true` (detected by env-check), skip this validation entirely. -CI runs these checks in its own matrix (Node 20/22/24 × ubuntu/windows). - -## On Failure - -- Report which step failed with the error output -- Do NOT proceed to the next pipeline phase -- Mark the pipeline run as `status: failed` in `.claude/ops/queue.yaml` diff --git a/.claude/skills/auditing-gha-settings/SKILL.md b/.claude/skills/auditing-gha-settings/SKILL.md deleted file mode 100644 index ece1c218b..000000000 --- a/.claude/skills/auditing-gha-settings/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: auditing-gha-settings -description: Audits a repo's GitHub Actions permissions + allowlist against the fleet baseline. Reports drift only. Fixes are manual in Settings → Actions because flipping these silently is unsafe. Use when a CI failure looks like "action X is not allowed to be used", when onboarding a new fleet repo, or as a periodic fleet-wide health check. -user-invocable: true -allowed-tools: Read, Grep, Glob, Bash(gh:*), Bash(node:*), Bash(jq:*) ---- - -# auditing-gha-settings - -Diff a fleet repo's GitHub Actions repository-level settings against the canonical baseline. Read-only: surfaces what to change, doesn't change it. - -## When to use - -- **"action X is not allowed to be used" CI failure**: the allowlist is missing an entry, or the policy got flipped from `selected` to `local_only`. -- **Onboarding a new fleet repo**: before the first CI run, confirm the new repo matches the baseline so the first push doesn't hit policy errors. -- **Periodic fleet health check**: drift accumulates. Somebody adds a workflow that needs a new action and silently flips `verified_allowed: true` to make it work instead of adding the explicit pattern. - -## What the baseline checks - -| Setting (per repo) | Baseline | Why | -| ---------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | Per-repo override is on. **Note**: `enabled: false` does NOT mean Actions are off — it means the per-repo override is unset and org policy is the source of truth. To get drift-detection on a repo, opt in to per-repo settings + mirror the canonical baseline. | -| `allowed_actions` | `'selected'` | "Allow enterprise, and select non-enterprise, actions and reusable workflows" — the only mode where the explicit allowlist is the source of truth. | -| `github_owned_allowed` | `false` | Don't blanket-allow `actions/*`. The canonical patterns list already names every github-owned action we need; unlisted ones must be explicit. | -| `verified_allowed` | `false` | Marketplace "verified creator" is not implicit allow — every action must be on the canonical patterns list. | -| `patterns_allowed ⊇ canonical set` | Each fleet pattern present | Each canonical entry is referenced by at least one socket-registry shared workflow; missing one breaks every consumer. | - -The **canonical patterns** (every fleet repo must have all of these): - -- `actions/cache/restore@*` -- `actions/cache/save@*` -- `actions/cache@*` -- `actions/checkout@*` -- `actions/deploy-pages@*` -- `actions/download-artifact@*` -- `actions/github-script@*` -- `actions/setup-go@*` -- `actions/setup-node@*` -- `actions/setup-python@*` -- `actions/upload-artifact@*` -- `actions/upload-pages-artifact@*` -- `depot/build-push-action@*` -- `depot/setup-action@*` -- `github/codeql-action/upload-sarif@*` - -Extras beyond the canonical set are tolerated (reported as info, not failure). A repo may pin a one-off action, but each extra should map to a real consumer; orphans should be pruned. - -**Third-party actions are NOT on the allowlist.** Anything outside `actions/`, `github/`, and `depot/` should be ported to a hand-rolled composite under `SocketDev/socket-registry/.github/actions/` rather than added here. The current set of socket-registry composite replacements: - -| Third-party | socket-registry composite | -| --------------------------------- | -------------------------- | -| `dtolnay/rust-toolchain` | `setup-rust-toolchain` | -| `hendrikmuhs/ccache-action` | `setup-ccache` | -| `HaaLeo/publish-vscode-extension` | `publish-vscode-extension` | -| `mlugg/setup-zig` | `setup-zig` | -| `pnpm/action-setup` | `setup-pnpm` | -| `softprops/action-gh-release` | `create-gh-release` | -| `Swatinem/rust-cache` | `setup-rust-cache` | - -Note: `enabled: false` from the per-repo API does NOT mean Actions are disabled. It means the per-repo override is unset and org-level policy is in effect. The skill explains this in its output. - -## How to invoke - - node .claude/skills/auditing-gha-settings/run.mts SocketDev/socket-btm SocketDev/socket-cli - -Or all-at-once with the canonical fleet list (manual today; the orchestrator skill prompt expands the list at call time): - - node .claude/skills/auditing-gha-settings/run.mts \ - SocketDev/socket-btm \ - SocketDev/socket-cli \ - SocketDev/socket-lib \ - SocketDev/socket-mcp \ - SocketDev/socket-packageurl-js \ - SocketDev/socket-registry \ - SocketDev/socket-sdk-js \ - SocketDev/socket-sdxgen \ - SocketDev/socket-stuie \ - SocketDev/socket-vscode \ - SocketDev/socket-webext \ - SocketDev/socket-wheelhouse \ - SocketDev/ultrathink - -For machine-readable output (one finding per repo): - - node .claude/skills/auditing-gha-settings/run.mts --json SocketDev/socket-btm | jq - -## How to fix the findings - -Each finding line names the exact toggle to flip. The fix is **manual**: the runner does not write. Flipping these silently is a credible attack vector and should always be a human action. - -Two paths: - -1. **Web UI (preferred)**: Repo → Settings → Actions → General. The settings map 1:1 with the audit findings: - - "Allow enterprise, and select non-enterprise, actions and reusable workflows" → flips `allowed_actions` to `selected`. - - Uncheck "Allow actions created by GitHub" → `github_owned_allowed: false`. - - Uncheck "Allow Marketplace actions by verified creators" → `verified_allowed: false`. - - "Allow specified actions and reusable workflows" textarea: paste the canonical patterns list (one per line). Existing extras can stay; remove only ones with no consumer. - -2. **`gh api` PUT (admin-scoped tokens only)**: surfaced for completeness; prefer the UI: - - gh api -X PUT repos/<owner>/<repo>/actions/permissions \ - -F enabled=true -F allowed_actions=selected - gh api -X PUT repos/<owner>/<repo>/actions/permissions/selected-actions \ - -F github_owned_allowed=false -F verified_allowed=false \ - -f patterns_allowed[]='actions/cache/restore@*' \ - -f patterns_allowed[]='actions/cache/save@*' \ - # ...one -f per canonical pattern... - - The whole-list replace semantics on the selected-actions endpoint mean **omitting a repo's existing extras drops them**. Preserve them when relevant. - -## Anti-patterns - -- **Auto-PUT-ing the baseline from a script.** Don't. The settings affect every workflow on the repo and a wrong setting silently weakens supply-chain posture. The user runs the audit, the user fixes. -- **Adding an action to the allowlist to make a one-off workflow happy.** First ask: should the workflow use a shared socket-registry workflow that already references an approved action? Adding entries to the canonical set means cascading them to every consumer org. A real commitment. -- **Treating the audit as a security review.** It checks policy state, not workflow content. A workflow that uses an allowed action insecurely (e.g. `pull_request_target` + `actions/checkout` of untrusted ref) is invisible to this audit; that's `pull-request-target-guard`'s job. - -## Companion: `greening-ci` - -If a CI failure shows `action <X> is not allowed by enterprise admin` or `not allowed to be used in this repository`, that's an allowlist gap. Run this audit, fix the gap manually, then re-run `/green-ci` to confirm the build goes green. diff --git a/.claude/skills/auditing-gha-settings/run.mts b/.claude/skills/auditing-gha-settings/run.mts deleted file mode 100644 index 7f55c914c..000000000 --- a/.claude/skills/auditing-gha-settings/run.mts +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env node -/** - * @file Audit a repo's GitHub Actions permissions + allowlist against the fleet - * baseline. Read-only — reports drift, does not write. The fix is a manual - * step in the repo's Settings → Actions → General page (or via `gh api` PUT - * with admin scope), because flipping these silently is too dangerous to - * automate. Baseline (every fleet repo must match): permissions.enabled = - * true permissions.allowed_actions = 'selected' - * selected_actions.github_owned_allowed = false (don't allow github-owned - * actions implicitly — the patterns_allowed list IS the canonical set; an - * unlisted github/foo would slip in) selected_actions.verified_allowed = - * false (same reason — verified marketplace actions aren't on the allowlist - * by intent) selected_actions.patterns_allowed ⊇ CANONICAL_PATTERNS (superset - * is allowed — a repo can pin additional actions if it has a real consumer, - * but every canonical pattern must be present since they're referenced - * through the socket-registry shared workflows) Exit code: 0 if compliant, 1 - * if any repo fails the baseline. The orchestrator (skill prompt) shapes the - * human-readable report and tells the user exactly which Settings → Actions - * toggles to flip. - */ - -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib/logger/default' -import { spawn } from '@socketsecurity/lib/process/spawn/child' - -const logger = getDefaultLogger() - -// Canonical fleet allowlist. Every entry here is referenced by at least -// one shared workflow under socket-registry/.github/workflows/ or by a -// fleet repo's own workflows. Removing one breaks every consumer that -// pins through those shared workflows. Add a new entry only when a new -// shared workflow references it, and cascade to every consumer org. -// -// Third-party patterns (dtolnay/, hendrikmuhs/, HaaLeo/, mlugg/, -// pnpm/action-setup, softprops/, Swatinem/) were removed in favor of -// hand-rolled composites under SocketDev/socket-registry/.github/actions/. -// Anything new third-party should be ported to a composite there rather -// than added to this list. -// -// Sorted alphabetically. -const CANONICAL_PATTERNS: readonly string[] = [ - 'actions/cache/restore@*', - 'actions/cache/save@*', - 'actions/cache@*', - 'actions/checkout@*', - 'actions/deploy-pages@*', - 'actions/download-artifact@*', - 'actions/github-script@*', - 'actions/setup-go@*', - 'actions/setup-node@*', - 'actions/setup-python@*', - 'actions/upload-artifact@*', - 'actions/upload-pages-artifact@*', - 'depot/build-push-action@*', - 'depot/setup-action@*', - 'github/codeql-action/upload-sarif@*', -] - -export async function auditOne(repo: string): Promise<RepoFinding> { - const details: string[] = [] - let perms: PermissionsResponse - try { - perms = await fetchPermissions(repo) - } catch (e) { - // 404 here usually means the API isn't exposing per-repo settings - // for this repo — either the token lacks admin scope, or the org - // policy is the source of truth and the repo has no per-repo - // override. Surface as a fetch failure, not a baseline failure. - return { - repo, - ok: false, - details: [ - `Could not read Actions permissions (admin scope needed, or org ` + - `policy supersedes per-repo settings): ${ - e instanceof Error ? e.message : String(e) - }`, - ], - } - } - - // `enabled: false` does NOT mean Actions are disabled — it means the - // per-repo override is unset, and the org-level policy is in effect. - // We can't audit allowlist + policy from the repo API in that case; - // tell the user to check at the org level (or set a per-repo override - // that mirrors the canonical baseline so drift surfaces locally). - if (!perms.enabled) { - details.push( - `Per-repo Actions override is unset (enabled=false at the repo ` + - `level). Org-level policy is the effective source of truth — the ` + - `repo runs whatever the org allows, and the per-repo allowlist isn't ` + - `enforced. To get drift-detection on this repo, opt in to per-repo ` + - `settings at Settings → Actions → General and mirror the canonical ` + - `baseline (allowed_actions=selected, github_owned_allowed=false, ` + - `verified_allowed=false, and the canonical patterns).`, - ) - return { repo, ok: false, details } - } - - if (perms.allowed_actions !== 'selected') { - details.push( - `allowed_actions=${perms.allowed_actions}; baseline is "selected". ` + - 'Set Settings → Actions → General → "Allow enterprise, and select ' + - 'non-enterprise, actions and reusable workflows".', - ) - // If it's `all` or `local_only` the selected-actions endpoint will - // 404 — skip the next fetch. - return { repo, ok: false, details } - } - - let selected: SelectedActionsResponse - try { - selected = await fetchSelectedActions(repo) - } catch (e) { - details.push( - `Could not read selected-actions list: ${ - e instanceof Error ? e.message : String(e) - }`, - ) - return { repo, ok: false, details } - } - - if (selected.github_owned_allowed) { - details.push( - 'github_owned_allowed=true. Baseline is false — every github/* action ' + - 'should go through the explicit allowlist so an unintended github/foo ' + - 'cannot slip in. Uncheck "Allow actions created by GitHub" in Settings.', - ) - } - if (selected.verified_allowed) { - details.push( - 'verified_allowed=true. Baseline is false — verified-marketplace ' + - 'actions are not implicitly allowed. Uncheck "Allow Marketplace actions ' + - 'by verified creators" in Settings.', - ) - } - - const present = new Set(selected.patterns_allowed) - const missing: string[] = [] - for (let i = 0, { length } = CANONICAL_PATTERNS; i < length; i += 1) { - const p = CANONICAL_PATTERNS[i]! - if (!present.has(p)) { - missing.push(p) - } - } - if (missing.length > 0) { - details.push( - `Missing ${missing.length} canonical patterns from the allowlist:\n ` + - `${missing.join('\n ')}\n` + - 'Add via Settings → Actions → General → "Allow specified actions and ' + - 'reusable workflows" → one entry per line.', - ) - } - - // Extras (repo allows MORE than the canonical set) are NOT findings — - // a repo may pin a one-off action with a real consumer. Report them - // as info so the operator can audit, but don't fail. - const extras: string[] = [] - for (let i = 0, { length } = selected.patterns_allowed; i < length; i += 1) { - const p = selected.patterns_allowed[i]! - if (!CANONICAL_PATTERNS.includes(p)) { - extras.push(p) - } - } - if (extras.length > 0) { - details.push( - `Info: ${extras.length} extra allowlist patterns beyond the canonical ` + - `set:\n ${extras.join('\n ')}\n` + - 'These are not failures — a repo may legitimately allow more. ' + - 'But each extra should map to a real consumer; if not, prune.', - ) - } - - // ok=true means every required-baseline check passed; "info" entries - // about extras don't flip the verdict. - const failedRequired = - !perms.enabled || - perms.allowed_actions !== 'selected' || - selected.github_owned_allowed || - selected.verified_allowed || - missing.length > 0 - return { repo, ok: !failedRequired, details } -} - -export async function fetchPermissions( - repo: string, -): Promise<PermissionsResponse> { - const raw = await gh(['api', `repos/${repo}/actions/permissions`]) - return JSON.parse(raw) as PermissionsResponse -} - -export async function fetchSelectedActions( - repo: string, -): Promise<SelectedActionsResponse> { - const raw = await gh([ - 'api', - `repos/${repo}/actions/permissions/selected-actions`, - ]) - return JSON.parse(raw) as SelectedActionsResponse -} - -interface PermissionsResponse { - enabled: boolean - allowed_actions: 'all' | 'local_only' | 'selected' - sha_pinning_required?: boolean | undefined -} - -interface SelectedActionsResponse { - github_owned_allowed: boolean - verified_allowed: boolean - patterns_allowed: string[] -} - -interface RepoFinding { - repo: string - ok: boolean - // Each detail line is one fixable item. Empty when ok=true. - details: string[] -} - -export async function gh(args: readonly string[]): Promise<string> { - const r = await spawn('gh', args as string[], { - stdio: 'pipe', - stdioString: true, - timeout: 30_000, - }) - return String(r.stdout ?? '').trim() -} - -export function parseArgs(argv: readonly string[]): { - repos: string[] - json: boolean -} { - const repos: string[] = [] - let json = false - for (let i = 0, { length } = argv; i < length; i += 1) { - const a = argv[i]! - if (a === '--json') { - json = true - } else if (a === '--help' || a === '-h') { - logger.info( - // oxlint-disable-next-line socket/no-logger-newline-literal -- CLI help text is intentionally a single multi-line block; splitting would garble the columnar formatting users expect. - `Usage: node run.mts [--json] <owner/repo>... - -Audits GH Actions permissions + allowlist against the fleet baseline. -Exits non-zero if any repo fails any required check. - -Examples: - node run.mts SocketDev/socket-btm SocketDev/socket-cli - node run.mts --json SocketDev/socket-btm | jq`, - ) - process.exit(0) - } else if (a.startsWith('-')) { - throw new Error(`Unknown flag: ${a}`) - } else { - repos.push(a) - } - } - if (repos.length === 0) { - throw new Error('At least one <owner/repo> argument is required.') - } - return { repos, json } -} - -async function main(): Promise<void> { - const { repos, json } = parseArgs(process.argv.slice(2)) - const findings: RepoFinding[] = [] - for (let i = 0, { length } = repos; i < length; i += 1) { - const r = repos[i]! - // eslint-disable-next-line no-await-in-loop -- serial GH API calls - const f = await auditOne(r) - findings.push(f) - } - if (json) { - logger.info(JSON.stringify(findings, null, 2)) - } else { - let okCount = 0 - let failCount = 0 - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - if (f.ok) { - okCount += 1 - logger.info(`✓ ${f.repo}`) - } else { - failCount += 1 - logger.warn(`✗ ${f.repo}`) - for (let j = 0, { length: jl } = f.details; j < jl; j += 1) { - logger.warn(` ${f.details[j]}`) - } - } - } - logger.info('') - logger.info(`OK: ${okCount} Failed: ${failCount}`) - } - process.exitCode = findings.some(f => !f.ok) ? 1 : 0 -} - -main().catch(e => { - logger.error(e instanceof Error ? e.message : String(e)) - process.exit(1) -}) diff --git a/.claude/skills/cascading-fleet/SKILL.md b/.claude/skills/cascading-fleet/SKILL.md deleted file mode 100644 index bfe6dfabb..000000000 --- a/.claude/skills/cascading-fleet/SKILL.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -name: cascading-fleet -description: Propagate a wheelhouse template change to every fleet repo (or a registry-pin chain to every dependent repo). Packages the canonical fleet-repo list, the FLEET_SYNC=1 sentinel pattern, the worktree-per-repo loop, push-direct + PR-fallback, and worktree-cleanup that survives mid-loop crashes. Use when a wheelhouse template SHA needs to land in every fleet repo, when a registry pin chain needs propagation, or when batching multiple template SHAs into one cascade wave. -user-invocable: true -allowed-tools: Bash(git fetch:*), Bash(git worktree:*), Bash(git branch:*), Bash(git status:*), Bash(git rev-list:*), Bash(git symbolic-ref:*), Bash(git show-ref:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(git log:*), Bash(node:*), Bash(gh pr create:*), Bash(gh repo view:*), Read, Bash(bash:*), Bash(chmod:*), Bash(cd:*), Bash(printf:*), Bash(echo:*), Bash(tee:*), Bash(tail:*), Bash(ls:*) ---- - -# cascading-fleet - -The fleet runs on `chore(wheelhouse): cascade template@<sha>` commits. Every wheelhouse template change has to land in every fleet repo to take effect. This skill packages the operation so it isn't recreated ad-hoc per session. - -## When to use - -- A wheelhouse `template/` SHA needs to propagate to every fleet repo. -- A `socket-registry` pin chain (the multi-layer setup-and-install → setup → checkout pin graph) needs propagation. -- Batching multiple template SHAs into one wave. - -Never use this skill while another cascade is in flight (each cascade creates a `chore/wheelhouse-<sha>` branch per repo; concurrent runs collide). - -## Two modes - -### Mode 1: `template` (outer cascade, default) - -Propagates a `socket-wheelhouse/template/` SHA to every fleet repo. The flow: - -1. For each fleet repo: -2. Worktree off `origin/<default-branch>` on a fresh `chore/wheelhouse-<sha>` branch. -3. Run `socket-wheelhouse/scripts/sync-scaffolding/cli.mts --target <wt> --fix`. -4. If the cascade modified anything: surgical-stage with `FLEET_SYNC=1 git add --update`, commit `chore(wheelhouse): cascade template@<sha>`, push direct to base. -5. If direct push is rejected: push the branch, open a PR. -6. Clean up the worktree + the temp branch. - -The `FLEET_SYNC=1` sentinel is recognized by the wheelhouse `no-revert-guard` + `overeager-staging-guard` hooks. It allowlists exactly: `git commit --no-verify` whose message starts with `chore(wheelhouse): cascade template@`, `git push --no-verify`, and `git add -A`/`-u`/`.`. Nothing else. - -### Mode 2: `registry-pins` - -Propagates a `socket-registry` pin chain through the fleet. Different shape: uses `scripts/cascade-registry-pins.mts --sha <M'>` to walk the per-repo workflow pins. Documented here for completeness; the cascade script in `lib/cascade-template.mts` covers Mode 1, and a future `lib/cascade-registry-pins.mts` will cover Mode 2. - -For now, the registry-pin cascade is two steps documented inline: - -``` -Step 1 (intra-registry): node socket-registry/scripts/cascade-internal.mts -Step 2 (intra-registry): git push to registry main; record new tip M'. -Step 3 (fleet-wide): node socket-wheelhouse/scripts/cascade-registry-pins.mts --sha M' -``` - -Skipping Step 1 means Step 3 propagates a SHA whose dependency graph still pins the pre-fix revision. Always run Step 1 first. - -## How to invoke - -```bash -# Mode 1: propagate wheelhouse template SHA -node .claude/skills/cascading-fleet/lib/cascade-template.mts <template-sha> -``` - -The script reads the fleet-repo list from `lib/fleet-repos.txt` (single source of truth), iterates, and writes a per-repo result line to stdout. Output also tees to `/tmp/cascade-<sha>.log` for post-hoc inspection. - -## Worktree cleanup: the branch-cleanup bug - -A subtle gotcha: the script's pre-clean step (`git branch -D <branch>`) MUST run from `${src}` (the source repo), not from `/tmp` or the worktree directory. If the loop crashes mid-iteration before `cd`-ing into the worktree, a stale `chore/wheelhouse-<sha>` branch can be left behind. The provided script handles this. If you write a one-off cascade, make sure your cleanup runs from the right cwd. - -## Soak time before catalog cascades - -If the wheelhouse template change includes a `@socketsecurity/lib` catalog bump in `pnpm-workspace.yaml`, wait at least 5 minutes after the npm publish completes before starting the cascade. The cascade's `pnpm install` step will 404 if the new version isn't yet visible on the npm CDN. - -## Stop conditions - -- Branch already exists in a fleet repo (`fatal: a branch named 'chore/wheelhouse-<sha>' already exists`): pre-clean from `${src}` then retry that repo only. -- Worktree-add fails: another worktree at the target path; cleanup with `git worktree remove --force <wt>`. -- Push rejected on direct base: the script automatically falls back to PR. Confirm via the PR URL printed to stdout. - -## Reference - -- FLEET_SYNC sentinel: `template/.claude/hooks/no-revert-guard/` + `template/.claude/hooks/overeager-staging-guard/`. -- Wheelhouse sync-scaffolding: `socket-wheelhouse/scripts/sync-scaffolding/cli.mts`. -- Fleet-repo manifest: `lib/fleet-repos.txt`. diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.mts b/.claude/skills/cascading-fleet/lib/cascade-template.mts deleted file mode 100644 index 9c0e5b938..000000000 --- a/.claude/skills/cascading-fleet/lib/cascade-template.mts +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env node -/** - * @file Fleet cascade — propagate a socket-wheelhouse/template/ SHA to every - * fleet repo. Uses the FLEET_SYNC=1 sentinel to bypass the no-revert-guard / - * overeager-staging-guard hooks without per-repo Allow-bypass phrases. - * Replaces the original cascade-template.sh; the fleet convention is `.mts` - * for all runners. Usage: node - * .claude/skills/cascading-fleet/lib/cascade-template.mts <template-sha> - * Reads the canonical fleet-repo list from `<this-dir>/fleet-repos.txt`. Each - * repo's worktree is created off `origin/<default-branch>`, the wheelhouse - * sync-scaffolding CLI runs, the resulting changes are committed, and the - * script tries a direct push first, falling back to opening a PR on - * rejection. - */ - -// prefer-async-spawn: sync-required — cascade orchestrator runs -// sequentially across repos with exit-code gating; async would -// complicate the linear pipeline for no real concurrency win. -// prefer-spawn-over-execsync: same — top-level sync CLI flow. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { - appendFileSync, - existsSync, - readFileSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const LOG_PATH_PREFIX = '/tmp/cascade-' - -function usage(): never { - logger.error(`usage: ${process.argv[1]} <template-sha>`) - process.exit(2) -} - -const TEMPLATE_SHA = process.argv[2] -if (!TEMPLATE_SHA) { - usage() -} - -const SCRIPT_DIR = import.meta.dirname -const FLEET_REPOS_FILE = path.join(SCRIPT_DIR, 'fleet-repos.txt') -const PROJECTS = process.env['PROJECTS'] || path.join(os.homedir(), 'projects') -// socket-hook: allow cross-repo -const WH_SCRIPT = path.join( - PROJECTS, - 'socket-wheelhouse', - 'scripts', - 'sync-scaffolding', - 'cli.mts', -) -// socket-hook: allow cross-repo -const CLEANUP_SCRIPT = path.join( - PROJECTS, - 'socket-wheelhouse', - 'scripts', - 'fleet', - 'cleanup-stranded.mts', -) - -// Prepend the active Node version's bin dir to PATH so the `node` invoked by -// the wheelhouse CLI matches the operator's expected toolchain (avoids the -// pre-commit hook's "wrong Node" fallback). Honors NVM_BIN when set; otherwise -// leaves PATH alone so a Volta / homebrew / system Node still resolves. -if (process.env['NVM_BIN']) { - process.env['PATH'] = `${process.env['NVM_BIN']}:${process.env['PATH'] || ''}` -} - -if (!existsSync(FLEET_REPOS_FILE)) { - logger.error(`fleet-repos.txt not found at ${FLEET_REPOS_FILE}`) - process.exit(2) -} -if (!existsSync(WH_SCRIPT)) { - logger.error(`wheelhouse sync-scaffolding CLI not found at ${WH_SCRIPT}`) - logger.error( - 'set PROJECTS=<dir containing socket-wheelhouse> before retrying', - ) - process.exit(2) -} -// CLEANUP_SCRIPT is optional — older wheelhouse checkouts won't have it. -// When missing, skip auto-cleanup; the cascade still runs. - -const LOG_FILE = `${LOG_PATH_PREFIX}${TEMPLATE_SHA}.log` -writeFileSync(LOG_FILE, '') - -function log(line: string): void { - logger.info(line) - appendFileSync(LOG_FILE, `${line}\n`) -} - -const RESULTS: string[] = [] - -log(`══ Cascade ${TEMPLATE_SHA} ══`) -log(`Log: ${LOG_FILE}`) -log('') - -// Resolve a canonical fleet repo name to a local primary checkout. Mirrors -// scripts/sync-scaffolding/discover.mts directoryAliasesFor(): canonical -// `socket-<x>` also resolves to `${PROJECTS}/<x>/`; canonical `<x>` (no -// socket- prefix — sdxgen, stuie, ultrathink) also resolves to -// `${PROJECTS}/socket-<x>/`. First primary checkout wins. Returns undefined -// when no primary checkout exists. -function resolveLocalCheckout(canonical: string): string | undefined { - let candidate = path.join(PROJECTS, canonical) - if (existsSync(path.join(candidate, '.git'))) { - return candidate - } - candidate = canonical.startsWith('socket-') - ? path.join(PROJECTS, canonical.slice('socket-'.length)) - : path.join(PROJECTS, `socket-${canonical}`) - if (existsSync(path.join(candidate, '.git'))) { - return candidate - } - return undefined -} - -type RunResult = { - status: number - stdout: string - stderr: string -} - -function run( - cmd: string, - args: string[], - opts: { cwd: string; env?: NodeJS.ProcessEnv | undefined } = { - cwd: process.cwd(), - }, -): RunResult { - const r = spawnSync(cmd, args, { - cwd: opts.cwd, - env: opts.env ?? process.env, - encoding: 'utf8', - }) - return { - status: r.status ?? 1, - stdout: r.stdout ?? '', - stderr: r.stderr ?? '', - } -} - -function logTail(out: string, n: number): void { - const lines = out.split('\n').filter(Boolean) - for (const line of lines.slice(-n)) { - log(line) - } -} - -function git(cwd: string, args: string[]): RunResult { - return run('git', args, { cwd }) -} - -function gitSilent(cwd: string, args: string[]): void { - // Used for best-effort cleanup that should not pollute output on failure - // (mirrors `2>/dev/null` in the original bash). - spawnSync('git', args, { cwd, stdio: 'ignore' }) -} - -function resolveBase(src: string): string { - const sym = git(src, ['symbolic-ref', 'refs/remotes/origin/HEAD']) - if (sym.status === 0) { - return sym.stdout.trim().replace(/^refs\/remotes\/origin\//, '') - } - for (const candidate of ['main', 'master']) { - if ( - git(src, [ - 'show-ref', - '--verify', - '--quiet', - `refs/remotes/origin/${candidate}`, - ]).status === 0 - ) { - return candidate - } - } - return 'main' -} - -const fleetReposRaw = readFileSync(FLEET_REPOS_FILE, 'utf8').split('\n') - -for (const rawLine of fleetReposRaw) { - const repo = rawLine.trim() - if (!repo || repo.startsWith('#')) { - continue - } - - const src = resolveLocalCheckout(repo) - const wt = path.join('/tmp', `cascade-${repo}-${process.pid}`) - log(`── ${repo} ──`) - - if (!src) { - RESULTS.push(`${repo}|skip:no-git`) - continue - } - - const base = resolveBase(src) - git(src, ['fetch', 'origin', base, '--quiet']) - - // Auto-clean stranded cascade artifacts from earlier waves. Safety rails - // inside the script bail the repo (no-op) if anything looks ambiguous; - // only removes commits matching the cascade subject regex, authored by a - // trusted identity, touching only cascade-allowlisted files, and whose - // template SHA strictly precedes origin's current cascade SHA. - if (existsSync(CLEANUP_SCRIPT)) { - const cleanup = run('node', [CLEANUP_SCRIPT, '--target', src], { cwd: src }) - logTail(cleanup.stdout + cleanup.stderr, 3) - } - - // Branch name reads `chore/wheelhouse-<sha>` — keeps the `chore/` - // namespace convention and names the source explicitly. Replaces - // the older `chore/sync-<sha>` form (no back-compat retained; - // pre-rename stranded branches need a one-time hand cleanup). - const branch = `chore/wheelhouse-${TEMPLATE_SHA}` - - gitSilent(src, ['worktree', 'remove', '--force', wt]) - gitSilent(src, ['branch', '-D', branch]) - - const wtAdd = git(src, [ - 'worktree', - 'add', - '-b', - branch, - wt, - `origin/${base}`, - ]) - if (wtAdd.status !== 0) { - logTail(wtAdd.stdout + wtAdd.stderr, 1) - RESULTS.push(`${repo}|fail:worktree`) - continue - } - logTail(wtAdd.stdout + wtAdd.stderr, 1) - - const sync = run('node', [WH_SCRIPT, '--target', wt, '--fix'], { cwd: wt }) - logTail(sync.stdout + sync.stderr, 3) - - const aheadOut = git(wt, ['rev-list', '--count', `origin/${base}..HEAD`]) - const ahead = - aheadOut.status === 0 ? parseInt(aheadOut.stdout.trim(), 10) || 0 : 0 - if (ahead === 0) { - const dirty = git(wt, ['status', '--porcelain']).stdout.trim() - if (!dirty) { - RESULTS.push(`${repo}|noop`) - gitSilent(src, ['worktree', 'remove', '--force', wt]) - gitSilent(src, ['branch', '-D', branch]) - continue - } - // FLEET_SYNC=1 + CI=true env is required: the sentinel allowlists exactly - // this commit through the no-revert-guard / overeager-staging-guard - // hooks. CI=true suppresses interactive pre-commit hook prompts. - const stageEnv = { ...process.env, FLEET_SYNC: '1', CI: 'true' } - git(wt, ['add', '--update']) - const commit = run( - 'git', - [ - 'commit', - '--no-verify', - '-m', - `chore(wheelhouse): cascade template@${TEMPLATE_SHA}`, - ], - { cwd: wt, env: stageEnv }, - ) - logTail(commit.stdout + commit.stderr, 2) - if (commit.status !== 0) { - RESULTS.push(`${repo}|fail:commit`) - gitSilent(src, ['worktree', 'remove', '--force', wt]) - gitSilent(src, ['branch', '-D', branch]) - continue - } - } - - const pushEnv = { ...process.env, FLEET_SYNC: '1' } - const push = run('git', ['push', '--no-verify', 'origin', `HEAD:${base}`], { - cwd: wt, - env: pushEnv, - }) - logTail(push.stdout + push.stderr, 2) - if (push.status === 0) { - RESULTS.push(`${repo}|push:${base}`) - } else { - const branchPush = run( - 'git', - ['push', '--no-verify', '-u', 'origin', branch], - { cwd: wt, env: pushEnv }, - ) - logTail(branchPush.stdout + branchPush.stderr, 2) - if (branchPush.status === 0) { - const prCreate = run( - 'gh', - [ - 'pr', - 'create', - '--repo', - `SocketDev/${repo}`, - '--base', - base, - '--head', - branch, - '--title', - `chore(wheelhouse): cascade template@${TEMPLATE_SHA}`, - '--body', - `Auto-cascade of socket-wheelhouse@${TEMPLATE_SHA}.`, - ], - { cwd: wt }, - ) - const prUrl = - (prCreate.stdout + prCreate.stderr) - .trim() - .split('\n') - .filter(Boolean) - .slice(-1)[0] ?? '' - RESULTS.push(`${repo}|pr:${prUrl}`) - } else { - RESULTS.push(`${repo}|fail:push+pr`) - } - } - - gitSilent(src, ['worktree', 'remove', '--force', wt]) - gitSilent(src, ['branch', '-D', branch]) -} - -log('') -log('════ RESULTS ════') -for (let i = 0, { length } = RESULTS; i < length; i += 1) { - const entry = RESULTS[i]! - log(` ${entry}`) -} diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.sh b/.claude/skills/cascading-fleet/lib/cascade-template.sh deleted file mode 100755 index 95bdad049..000000000 --- a/.claude/skills/cascading-fleet/lib/cascade-template.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env bash -# Fleet cascade — propagate a socket-wheelhouse/template/ SHA to every fleet -# repo. Bash3-safe (works on macOS default bash). Uses the FLEET_SYNC=1 -# sentinel to bypass the no-revert-guard / overeager-staging-guard hooks -# without per-repo Allow-bypass phrases. -# -# Usage: -# bash .claude/skills/cascading-fleet/lib/cascade-template.sh <template-sha> -# -# The script reads the canonical fleet-repo list from -# `<this-dir>/fleet-repos.txt`. Each repo's worktree is created off -# `origin/<default-branch>`, the wheelhouse sync-scaffolding CLI runs, -# the resulting changes are committed, and the script tries a direct -# push first, falling back to opening a PR on rejection. - -set -uo pipefail - -if [ "$#" -lt 1 ]; then - echo "usage: $0 <template-sha>" >&2 - exit 2 -fi - -TEMPLATE_SHA="$1" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -FLEET_REPOS_FILE="$SCRIPT_DIR/fleet-repos.txt" -PROJECTS="${PROJECTS:-$HOME/projects}" -# socket-hook: allow cross-repo -WH_SCRIPT="${PROJECTS}/socket-wheelhouse/scripts/sync-scaffolding/cli.mts" -# socket-hook: allow cross-repo -CLEANUP_SCRIPT="${PROJECTS}/socket-wheelhouse/scripts/cascade-tooling/cleanup-stranded.mts" - -# Prepend the active Node version's bin dir to PATH so the `node` invoked by -# the wheelhouse CLI matches the operator's expected toolchain (avoids the -# pre-commit hook's "wrong Node" fallback). Honors NVM_BIN when set; otherwise -# leaves PATH alone so a Volta / homebrew / system Node still resolves. -if [ -n "$NVM_BIN" ]; then - PATH="$NVM_BIN:$PATH" -fi - -if [ ! -f "$FLEET_REPOS_FILE" ]; then - echo "fleet-repos.txt not found at $FLEET_REPOS_FILE" >&2 - exit 2 -fi -if [ ! -f "$WH_SCRIPT" ]; then - echo "wheelhouse sync-scaffolding CLI not found at $WH_SCRIPT" >&2 - echo "set PROJECTS=<dir containing socket-wheelhouse> before retrying" >&2 - exit 2 -fi -# CLEANUP_SCRIPT is optional — older wheelhouse checkouts won't have it. -# When missing, skip auto-cleanup; the cascade still runs. - -RESULTS=() -LOG_FILE="/tmp/cascade-${TEMPLATE_SHA}.log" -exec > >(tee -a "$LOG_FILE") 2>&1 - -echo "══ Cascade ${TEMPLATE_SHA} ══" -echo "Log: $LOG_FILE" -echo - -# Resolve a canonical fleet repo name to a local primary checkout. -# Mirrors scripts/sync-scaffolding/discover.mts directoryAliasesFor(): -# canonical `socket-<x>` also resolves to `~/projects/<x>/`; canonical -# `<x>` (no socket- prefix — sdxgen, stuie, ultrathink) also resolves -# to `~/projects/socket-<x>/`. First primary checkout wins. Echoes -# the resolved absolute path, or empty when no primary checkout exists. -resolveLocalCheckout() { - local canonical="$1" - local candidate - # Exact canonical name first. - candidate="${PROJECTS}/${canonical}" - if [ -d "${candidate}/.git" ]; then - echo "$candidate" - return 0 - fi - # Alias: socket-<x> ⇄ <x>. - case "$canonical" in - socket-*) - candidate="${PROJECTS}/${canonical#socket-}" - ;; - *) - candidate="${PROJECTS}/socket-${canonical}" - ;; - esac - if [ -d "${candidate}/.git" ]; then - echo "$candidate" - return 0 - fi - return 1 -} - -while IFS= read -r repo; do - [ -z "$repo" ] && continue - case "$repo" in '#'*) continue ;; esac - - src="$(resolveLocalCheckout "$repo")" - wt="/tmp/cascade-${repo}-$$" - echo "── ${repo} ──" - - if [ -z "$src" ]; then - RESULTS+=("${repo}|skip:no-git") - continue - fi - - # All cleanup commands run from $src so a mid-loop crash leaves the - # worktree-orphaned state recoverable (the next run pre-cleans by name). - cd "${src}" || { RESULTS+=("${repo}|fail:cd"); continue; } - - base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') - [ -z "$base" ] && git show-ref --verify --quiet refs/remotes/origin/main && base=main - [ -z "$base" ] && git show-ref --verify --quiet refs/remotes/origin/master && base=master - base="${base:-main}" - - git fetch origin "$base" --quiet - - # Auto-clean stranded cascade artifacts from earlier waves. Safety - # rails inside the script bail the repo (no-op) if anything looks - # ambiguous; only removes commits matching the cascade subject regex, - # authored by a trusted identity, touching only cascade-allowlisted - # files, and whose template SHA strictly precedes origin's current - # cascade SHA. - if [ -f "$CLEANUP_SCRIPT" ]; then - node "$CLEANUP_SCRIPT" --target "$src" 2>&1 | tail -3 || true - fi - - branch="chore/sync-${TEMPLATE_SHA}" - - git worktree remove --force "$wt" 2>/dev/null - git branch -D "$branch" 2>/dev/null - - if ! git worktree add -b "$branch" "$wt" "origin/$base" 2>&1 | tail -1; then - RESULTS+=("${repo}|fail:worktree") - continue - fi - cd "$wt" || { RESULTS+=("${repo}|fail:cd-wt"); continue; } - - node "$WH_SCRIPT" --target "$wt" --fix 2>&1 | tail -3 - - ahead=$(git rev-list --count "origin/$base..HEAD" 2>/dev/null || echo 0) - if [ "$ahead" -eq 0 ]; then - if [ -z "$(git status --porcelain)" ]; then - RESULTS+=("${repo}|noop") - cd /tmp - git -C "$src" worktree remove --force "$wt" 2>/dev/null - git -C "$src" branch -D "$branch" 2>/dev/null - continue - fi - FLEET_SYNC=1 git add --update - if ! FLEET_SYNC=1 CI=true git commit --no-verify -m "chore(sync): cascade fleet template@${TEMPLATE_SHA}" 2>&1 | tail -2; then - RESULTS+=("${repo}|fail:commit") - cd /tmp - git -C "$src" worktree remove --force "$wt" 2>/dev/null - git -C "$src" branch -D "$branch" 2>/dev/null - continue - fi - fi - - if FLEET_SYNC=1 git push --no-verify origin "HEAD:$base" 2>&1 | tail -2; then - RESULTS+=("${repo}|push:${base}") - else - if FLEET_SYNC=1 git push --no-verify -u origin "$branch" 2>&1 | tail -2; then - pr_url=$(gh pr create --repo "SocketDev/${repo}" --base "$base" --head "$branch" --title "chore(sync): cascade fleet template@${TEMPLATE_SHA}" --body "Auto-cascade of socket-wheelhouse@${TEMPLATE_SHA}." 2>&1 | tail -1) - RESULTS+=("${repo}|pr:${pr_url}") - else - RESULTS+=("${repo}|fail:push+pr") - fi - fi - - cd /tmp - git -C "$src" worktree remove --force "$wt" 2>/dev/null - git -C "$src" branch -D "$branch" 2>/dev/null -done < "$FLEET_REPOS_FILE" - -echo -echo "════ RESULTS ════" -for entry in "${RESULTS[@]}"; do - printf " %s\n" "$entry" -done diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/cascading-fleet/lib/fleet-repos.json deleted file mode 100644 index 627f86d08..000000000 --- a/.claude/skills/cascading-fleet/lib/fleet-repos.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "$schema": "./fleet-repos.schema.json", - "repos": [ - { - "name": "socket-addon", - "description": "NAPI .node binaries for @socketaddon/* npm packages", - "optIns": ["squash-history"] - }, - { - "name": "socket-bin", - "description": "SEA-packed CLI distributions for @socketbin/* packages", - "optIns": ["squash-history"] - }, - { - "name": "socket-btm", - "description": "Build toolchain — produces signed prebuilt binaries for @socketaddon/* and @socketbin/*", - "optIns": ["squash-history"] - }, - { - "name": "socket-cli", - "description": "Command-line interface for socket.dev security analysis" - }, - { - "name": "socket-lib", - "description": "Core library: fs, processes, HTTP, logging, env detection" - }, - { - "name": "socket-mcp", - "description": "Model Context Protocol server for socket.dev integration" - }, - { - "name": "socket-packageurl-js", - "description": "purl spec implementation for JavaScript" - }, - { - "name": "socket-registry", - "description": "Optimized package overrides for Socket Optimize" - }, - { - "name": "socket-sdk-js", - "description": "JavaScript SDK for the socket.dev API" - }, - { - "name": "sdxgen", - "description": "CycloneDX and SPDX manifest generator (Socket dx gen)", - "optIns": ["squash-history"] - }, - { - "name": "stuie", - "description": "Terminal UI library: OpenTUI + yoga-layout + React", - "optIns": ["squash-history"] - }, - { - "name": "socket-wheelhouse", - "description": "Internal scaffolding template for socket-* repos" - } - ] -} diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/cascading-fleet/lib/fleet-repos.txt deleted file mode 100644 index 84ea8f1e7..000000000 --- a/.claude/skills/cascading-fleet/lib/fleet-repos.txt +++ /dev/null @@ -1,11 +0,0 @@ -socket-addon -socket-bin -socket-btm -socket-cli -socket-lib -socket-mcp -socket-packageurl-js -socket-registry -socket-sdk-js -sdxgen -stuie diff --git a/.claude/skills/cleaning-redundant-ci/SKILL.md b/.claude/skills/cleaning-redundant-ci/SKILL.md deleted file mode 100644 index 1c9218905..000000000 --- a/.claude/skills/cleaning-redundant-ci/SKILL.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: cleaning-redundant-ci -description: Sweeps a fleet repo (or every fleet repo) for redundant CI surface. Three classes: orphan workflow YAML files (lint.yml / check.yml / type.yml / test.yml that the unified ci.yml replaced), GitHub-Dependabot auto-fix PRs that the fleet handles via /updating-security, and stale workflow run history in the Actions sidebar. Deletes the YAML files, disables Dependabot automated-security-fixes via gh api, and reports anything that needs a manual UI toggle. Once-and-never-again sweep meant to leave a repo clean. -user-invocable: true -allowed-tools: Read, Edit, Write, Glob, Grep, Bash(gh:*), Bash(git:*), Bash(ls:*), Bash(rm:*), Bash(find:*), Bash(jq:*) ---- - -# cleaning-redundant-ci - -Audit + clean redundant CI surface on a Socket fleet repo. Three -target classes: - -1. **Orphan workflow YAML files**: `lint.yml`, `check.yml`, `type.yml`, `test.yml`. The fleet consolidated those into the shared `ci.yml` (via `SocketDev/socket-registry/.github/workflows/ci.yml`) long ago. Any per-repo file with those names is a leftover from pre-consolidation days. Delete them. - -2. **GitHub-Dependabot automated security PRs**: the fleet pattern is to handle vulnerability fixes via `/updating-security` (pnpm `overrides:` for transitive deps), not via auto-PRs from Dependabot. The `dependabot.yml` no-op file (`open-pull-requests-limit: 0`) suppresses version-update PRs but does NOT suppress security PRs. Those flow from a separate repo-settings toggle (`automated-security-fixes`). Disable via `gh api -X DELETE /repos/{owner}/{repo}/automated-security-fixes`. - -3. **Stale workflow run history**: when a workflow YAML gets deleted, the **runs** stay listed in the Actions sidebar forever (the workflow appears as a name with no associated file). Delete the workflow record via `gh api /repos/{owner}/{repo}/actions/workflows/{id} -X DELETE` to remove the sidebar entry. - -## When to use - -- **Onboarding a new fleet repo**: sweep once on first integration to clear any pre-fleet CI baggage. -- **After a CI consolidation cascade**: when the fleet retires a workflow shape (e.g. the lint/check/type/test → unified ci.yml migration), run this skill on every fleet repo to clean up the per-repo leftovers. -- **Periodic fleet-wide health check**: run quarterly to catch drift (someone adds a per-repo `lint.yml` to scratch an itch, forgetting the unified ci.yml already covers it). - -## What it does NOT do - -- **Touch the `dependabot.yml` file.** That file MUST exist (GitHub - refuses to fully disable Dependabot without it) and the fleet - convention is to ship it pre-configured with - `open-pull-requests-limit: 0`. The skill leaves the file alone; - only the `automated-security-fixes` toggle is acted on. -- **Touch `SocketDev/workflows`.** Don't edit org-level required workflows from this skill. The org config is the source of truth for what runs cross-repo, and silent edits are unsafe. -- **Delete legitimate per-repo workflows.** socket-btm's per-binary build dispatchers (`curl.yml`, `lief.yml`, etc.), ultrathink's `build-*.yml`, socket-packageurl-js's `pages.yml` /`valtown.yml`, socket-registry's `_local-not-for-reuse-*.yml` dogfood copies all stay. The skill only matches the four canonical orphan names. - -## Phases - -### Phase 1: inventory - -```sh -# Orphan YAML files -ls .github/workflows/ | grep -E '^(lint|check|type|test)\.ya?ml$' - -# Workflow records (live + stale) -gh api "repos/{owner}/{repo}/actions/workflows" --paginate \ - --jq '.workflows[] | "\(.id)\t\(.state)\t\(.name)\t\(.path)"' - -# Dependabot automated-security-fixes state -gh api "repos/{owner}/{repo}/automated-security-fixes" --jq .enabled -``` - -Categorise each finding: - -- **delete-file**: orphan YAML on disk -- **delete-record**: workflow record whose `.path` no longer exists in the repo OR whose name matches the orphan pattern -- **toggle-off**: `automated-security-fixes: true` - -### Phase 2: file deletions (commit + push) - -```sh -git rm .github/workflows/{lint,check,type,test}.yml 2>/dev/null -git commit -m "chore(ci): remove orphan {lint,check,type,test} workflows (consolidated into ci.yml)" -``` - -One commit per repo, conventional-commit subject. Push directly to -main per fleet policy (or fall back to PR if branch protection -requires). - -### Phase 3: workflow record deletions (gh api) - -For each delete-record finding: - -```sh -gh api -X DELETE "repos/{owner}/{repo}/actions/workflows/{id}" -``` - -GitHub returns 204 on success. The record disappears from the -Actions sidebar. Runs associated with the workflow remain in their -own URLs but stop showing in the per-workflow filter. - -Skip workflow records that match `dynamic/dependabot/...`. Those are GitHub-managed and can't be deleted via API. They'll stop appearing on their own once Dependabot has nothing to do (after Phase 4). - -### Phase 4: disable Dependabot automated-security-fixes - -```sh -gh api -X DELETE "repos/{owner}/{repo}/automated-security-fixes" -``` - -204 = disabled. Going forward, security advisories are visible in -the Security tab (via the `vulnerability-alerts` setting, which -stays on) but won't open auto-PRs. The fleet's `/updating-security` -skill is the canonical path for resolving them. - -### Phase 5: report - -For each repo: list what was deleted, what was disabled, and what needs manual UI action (rare; most things this skill touches are API-actionable). - -## Fleet-wide invocation - -```sh -# One repo -/cleaning-redundant-ci socket-foo - -# All fleet repos (reads template/.claude/skills/cascading-fleet/lib/fleet-repos.json) -/cleaning-redundant-ci --all -``` - -The fleet-roster path is the canonical list. Same file the cascade mechanism uses. Don't hard-code a repo list inside this skill. - -## Safety - -- **Read-only inventory first.** Print findings before any deletion. -- **Per-repo confirmation** in interactive mode; `--yes` to skip. -- **Direct push to main, fall back to PR** per fleet policy. Never - force-push. -- **Never edit `dependabot.yml`.** Only the `automated-security-fixes` toggle. The .yml is structurally required. -- **Never touch `SocketDev/workflows`.** Org-required workflows are out of scope. - -## Why a skill, not a hook - -This is operator-invoked maintenance, not edit-time enforcement. Hooks are the wrong shape: there's no `gh commit` or `gh push` event that should trigger a fleet-wide CI audit. Skills are user-callable, run on demand, and produce a one-shot report. diff --git a/.claude/skills/driving-cursor-bugbot/SKILL.md b/.claude/skills/driving-cursor-bugbot/SKILL.md deleted file mode 100644 index d93ee1d20..000000000 --- a/.claude/skills/driving-cursor-bugbot/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: driving-cursor-bugbot -description: Drives the Cursor Bugbot review-and-fix loop on a PR. Inventories open Bugbot threads, classifies each (real bug / false positive / already fixed), fixes the real ones, replies on the inline thread (never as a detached PR comment), updates the PR title/body if scope shifted, and pushes. Use when reviewing a PR you just authored, after `gh pr create`, or after a new Bugbot pass on an existing PR. -user-invocable: true -allowed-tools: Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(gh:*), Bash(git:*), Bash(pnpm run:*), Bash(rg:*), Bash(grep:*) ---- - -# driving-cursor-bugbot - -Drives the Cursor Bugbot fix-and-respond loop end-to-end. The canonical flow every PR author should run after Bugbot posts findings. - -## Why a skill - -Cursor Bugbot's review surface is easy to mis-handle: - -- **Replies must thread on the inline review-comment**, not as a detached PR comment. A detached `gh pr comment` doesn't mark the thread resolved and the bot doesn't see it as a response. -- **Findings stale after fixes land.** Bugbot reviews a specific commit SHA. When you push a fix, the comment still references the old commit; the thread stays open until you reply marking it resolved. -- **Stale findings vs. live bugs vs. false positives** all read the same on the API surface. Triaging needs a process, not vibes. -- **Scope creep on PRs**. CLAUDE.md mandates "When adding commits to an OPEN PR, update the PR title and description to match the new scope." Easy to forget when you're heads-down fixing Bugbot findings. - -This skill makes all of the above mechanical. - -## Modes - -| Invocation | What it does | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/driving-cursor-bugbot <PR#>` | Full audit-and-fix on one PR (default). | -| `/driving-cursor-bugbot check <PR#>` | List Bugbot findings, classify them — don't fix or reply. | -| `/driving-cursor-bugbot reply <comment-id> <state>` | Single reply where `<state>` is `fixed` / `false-positive` / `wont-fix`. Auto-resolves on `fixed` / `false-positive`; leaves open for `wont-fix`. | -| `/driving-cursor-bugbot resolve <PR#>` | Sweep open Bugbot threads with author replies and resolve them. | -| `/driving-cursor-bugbot scope <PR#>` | Re-evaluate the PR title and body against the actual commits and rewrite when out of step. | - -## Phases - -| # | Phase | Outcome | -| --- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Inventory | List Bugbot findings via `gh api .../pulls/<PR#>/comments`. Capture `id`, `path`, `line`, body. | -| 2 | Classify | Sort each finding into `real` / `already-fixed` / `false-positive` / `wont-fix`. | -| 3 | Fix | Implement fixes for `real` findings. Propagate to canonical (`socket-wheelhouse/template/`) when the file is fleet-shared. One commit per finding. | -| 4 | Reply + resolve | Reply on each inline thread (NOT detached); resolve on `fixed` / `already-fixed` / `false-positive`; leave `wont-fix` open. | -| 5 | Title + body realignment | Per CLAUDE.md, update PR title / body when scope shifted. Use `gh pr edit`. | -| 6 | Push | `git push`. Bugbot re-reviews; loop back to phase 1 if new findings. | - -API surface, GraphQL queries, and reply templates in [`reference.md`](reference.md). - -## Classification rubric - -| Bucket | Meaning | Action | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `real` | Live bug, reproducible against current PR HEAD. | Fix the code, push, reply with the fix commit SHA. | -| `already-fixed` | Bugbot reviewed an old commit; later commit on the same PR fixed it. | Reply citing the existing fix commit SHA. No new code. | -| `false-positive` | Bugbot misread the code (hash length miscount, regex backtracking false-flag, JSDoc-example mistaken for runtime code). Often confirmed by `Bugbot Autofix` reply on the same thread. | Reply explaining why; cite a counter-example or the autofix verdict. | -| `wont-fix` | Real but out of scope (would re-open resolved arguments, blocked on upstream change, intentional design choice). | Reply with rationale + link to follow-up issue. Don't auto-close — reviewer decides. | - -To check `already-fixed`: read `git log` on the PR branch since the comment's `commit_id` and look for a commit that touches the file at that line. - -## Hard requirements - -- **Reply on the inline thread**, never a detached PR comment. (`gh api .../pulls/<PR#>/comments/<id>/replies`, not `gh pr comment`.) -- **Reply first, resolve second.** Resolving without a written reply leaves future readers blind. -- **One commit per `real` finding.** Don't bundle. Conventional Commits: `fix(<scope>): address Bugbot finding on <file>:<line>`. -- **Push after each fix; reply with the new commit SHA.** The reply cites the SHA, so the SHA must already be pushed. -- **Propagate canonical fixes.** When the file lives under `.claude/hooks/`, `.claude/skills/`, or `.git-hooks/`, fix at `socket-wheelhouse/template/` first, then sync to consumers. Drifting fleet copies is the larger bug. - -## When to use - -- **After `gh pr create`**: Bugbot reviews most PRs within ~1 minute. -- **After pushing a Bugbot-related fix**: confirms the new HEAD didn't introduce new findings. -- **Before merging**: sweep open Bugbot threads. CLAUDE.md merge protocol depends on threads being resolved (replied to, not necessarily approved). - -## Success criteria - -- Every Bugbot finding has a reply on its inline thread. -- Every `real` finding has a corresponding fix commit on the PR branch. -- Every reply that closes the matter (`fixed` / `already-fixed` / `false-positive`) is followed by `resolveReviewThread`. `wont-fix` threads stay open. -- PR title and body match the actual commits. -- PR branch is pushed. diff --git a/.claude/skills/driving-cursor-bugbot/reference.md b/.claude/skills/driving-cursor-bugbot/reference.md deleted file mode 100644 index 2e9849fd3..000000000 --- a/.claude/skills/driving-cursor-bugbot/reference.md +++ /dev/null @@ -1,112 +0,0 @@ -# driving-cursor-bugbot reference - -API surface, GraphQL queries, and reply templates for the `driving-cursor-bugbot` skill. The decision flow lives in [`SKILL.md`](SKILL.md). - -## Phase 1 — Inventory - -List Bugbot findings as one-liners: - -```bash -gh api "repos/{owner}/{repo}/pulls/<PR#>/comments" \ - --jq '.[] | select(.user.login | test("cursor|bugbot"; "i")) | {id, path, line, body: (.body | split("\n")[0])}' -``` - -Each finding has: - -- `id` — comment ID (used for replies + resolution). -- `path` — file the finding is on. -- `line` — line number on that file. -- `body` — first line is the title (`### Title`); full body has `Description`, severity (Low / Medium / High), and rule (when triggered by a learned rule). - -Fetch the full body for one finding: - -```bash -gh api "repos/{owner}/{repo}/pulls/comments/<id>" \ - --jq '{path, line, body: (.body | split("<!-- BUGBOT")[0])}' -``` - -The `<!-- BUGBOT` marker separates the human-readable finding from the bot's metadata; strip everything after for clean reading. - -## Phase 4 — Replying on inline threads - -**Critical**: replies go on the inline review-comment thread, not as a detached PR comment. - -```bash -gh api "repos/{owner}/{repo}/pulls/<PR#>/comments/<comment-id>/replies" \ - -X POST -f body="…" -``` - -After replying, **resolve the thread** (the reply alone doesn't auto-resolve — resolution is a GraphQL mutation): - -```bash -# Step 1: get the thread node ID (PRRT_…) for a given comment databaseId. -THREAD_ID=$(gh api graphql -f query=' -query($pr: Int!, $owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 50) { - nodes { - id - comments(first: 1) { nodes { databaseId } } - } - } - } - } -}' -f owner=<owner> -f repo=<repo> -F pr=<PR#> \ - --jq ".data.repository.pullRequest.reviewThreads.nodes[] | select(.comments.nodes[0].databaseId == <comment-id>) | .id") - -# Step 2: resolve. -gh api graphql -f query=' -mutation($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { - thread { id, isResolved } - } -}' -f threadId="$THREAD_ID" -``` - -### When to resolve - -- **`real`, fixed** — resolve after the fix commit lands and the reply is posted. -- **`already-fixed`** — resolve immediately after the reply (the fix already exists). -- **`false-positive`** — resolve immediately after the reply, _unless_ the verdict is contested by the reviewer. -- **`wont-fix`** — do NOT resolve. The reviewer decides; leave it open as an open question. - -## Reply templates - -Keep replies short. Bugbot doesn't read them, but the human reviewer does. - -- **Real, fixed**: `Fixed in <commit-sha>. <one-sentence what changed>. <propagation note if any>.` - - Example: `Fixed in a63d29105. Restored the Linear team-key + linear.app URL blocking from the deleted .sh hook as scanLinearRefs() in _helpers.mts. Synced from canonical socket-wheelhouse.` - -- **Already fixed**: `Already fixed in <commit-sha> (current PR HEAD). <one-sentence what changed>.` - -- **False positive**: `False positive — <one-sentence why>. <evidence: counter-example, Autofix reply ID, etc.>.` - - Example: `False positive — confirmed by Bugbot Autofix in the sibling thread. The hash is exactly 128 hex chars: \`echo -n '<hash>' | wc -c\` returns 128.` - -- **Won't fix**: `Out of scope for this PR — <rationale>. Tracking as <issue/PR ref> if a follow-up is appropriate.` - -## Phase 5 — Title + body realignment - -After fixing Bugbot findings, scope often expands: - -- Original PR: `chore(hooks): sync .claude/hooks fleet` -- After fixes: also covers Linear-ref blocker restoration, errorMessage helper adoption, scanSocketApiKeys lineNumber bug, async safeDelete migration. - -Re-read the PR commits and rewrite title / body when warranted: - -```bash -gh pr view <PR#> --json title,body -git log origin/main..HEAD --oneline # what's actually in the PR now -gh pr edit <PR#> --title "…" --body "…" -``` - -Conventional-commit-style PR titles: `<type>(<scope>): <description>`. When fixes broaden scope, add the new scope to the parens (`chore(hooks, helpers)` instead of `chore(hooks)`). - -## Anti-patterns - -- ❌ Replying via `gh pr comment` (detached). Doesn't thread, doesn't notify the reviewer. -- ❌ Force-rewriting a Bugbot's finding by editing the comment via `--method PATCH`. The bot may re-post. -- ❌ Resolving a thread without a written reply. Future you (or the reviewer) won't know what happened. Reply first, resolve second. -- ❌ Closing Bugbot threads via the GitHub UI without a written reply. -- ❌ Fixing a Bugbot finding by deleting the offending code without understanding _why_ the code was there. Bugbot doesn't know about your domain; the human reviewer does. -- ❌ Treating "Bugbot Autofix determined this is a false positive" as a definitive verdict without checking. The autofix bot is right ~95% of the time but verifying takes 10 seconds. diff --git a/.claude/skills/greening-ci/SKILL.md b/.claude/skills/greening-ci/SKILL.md deleted file mode 100644 index fd7aca495..000000000 --- a/.claude/skills/greening-ci/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: greening-ci -description: Drive a target repo's CI back to green. Watches GitHub Actions, surfaces the first failure log, fixes it locally, commits + pushes, and re-watches until the run lands green (or a wall-clock budget expires). Three modes: fast (ci.yml), release (build-server matrices, fail-fast 30s polls then cool down on first success), cool (just confirm the rest of a matrix). Use when main goes red, when a build-server dispatch is failing, or when babysitting a freshly-pushed fix to verify it lands green. -user-invocable: true -allowed-tools: Read, Grep, Glob, Edit, Write, Bash(gh:*), Bash(git:*), Bash(node:*), Bash(pnpm:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(cat:*), Bash(head:*), Bash(tail:*) ---- - -# greening-ci - -Watch a target repo's CI, surface failures the moment they land, and drive a fix-and-push loop until the run is green. - -## When to use - -- **main is red.** Don't move on with new work while the trunk is broken. Run `/green-ci` to lock onto the failing run, fix it, push, and confirm green before resuming. -- **Build-server matrix dispatched and might fail fast.** Release builds (curl, lief, binsuite, node-smol) have one matrix slot that usually fails first. Use `--mode=release` to learn the failure ~5 minutes before the whole matrix finishes. -- **Verifying a just-pushed fix.** Push a fix, then run the skill. It'll poll, confirm the run lands green, and exit. No more "did my fix actually work" guessing. - -## Three modes - -| Mode | Poll interval | Stop trigger | When to pick | -| --------- | ------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `fast` | 30s | Any job fails OR whole run completes | Default. `ci.yml` watching: surface the failure as soon as one job lands. | -| `release` | 30s | Any job fails OR any job succeeds | Build-server matrices. Matrix slots run in parallel; one slot's outcome is enough to start reacting. | -| `cool` | 120s | Whole run completes | After `release` reported a first success: just confirming the rest of the matrix. No fast polls. | - -The skill picks `fast` by default. After running `release` and getting a first success, the orchestrator (the agent invoking this skill) flips to `cool` for the remainder. - -## How the skill drives the fix-and-push loop - -`run.mts` is **eyes-only**: it watches a run, dumps the failure log tail to a tmp file, and prints a JSON verdict on its final line. The fix-and-push loop is driven by the calling agent. The full sequence: - -1. Invoke `node .claude/skills/greening-ci/run.mts --repo <owner/name> [--workflow ci.yml] [--mode fast]`. -2. Parse the last line of stdout as JSON. Shape: - ```json - { - "status": "completed" | "in_progress" | "queued" | "failure", - "conclusion": "success" | "failure" | "cancelled" | "skipped" | null, - "runId": 25932269958, - "url": "https://github.com/<owner>/<repo>/actions/runs/<id>", - "failedJobs": [{ "name": "Lint, Type, Validation", "logTailPath": "/tmp/greening-ci.../run-X-failed.log" }], - "elapsedSec": 47 - } - ``` -3. Branch on `conclusion`: - - `"success"`: done. Report and exit. - - `"failure"`: read the log tail at `failedJobs[0].logTailPath`, classify the failure, fix locally in the target repo (which may be the current checkout or a worktree), commit + push, then re-invoke this skill to confirm green. - - `null` (still running, but a job already failed): same as `"failure"` for fix-and-push purposes. The whole run will be cancelled once main's protection kicks in; don't wait for it. - - `"cancelled"` / `"skipped"`: report, ask the user; don't auto-fix. - -## Failure-classification table - -The log tail almost always ends in one of these patterns. The skill calls these out so the orchestrator can pattern-match before doing real analysis: - -| Pattern in log tail | Likely root cause | Default fix | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| `× @socketsecurity/lib not resolvable from /home/runner/work/...` | Root `package.json` is missing the runtime dep the setup action requires. | Add `"@socketsecurity/lib": "catalog:"` next to `lib-stable` in the root `package.json` + catalog entry. | -| `Error: Cannot find module '...'` during a `node` step | Missing dep / wrong import path / unbuilt artifact. | Trace the import to its package, add the dep, `pnpm install`, push. | -| `pnpm: command not found` / `pnpm exec ...` exits 127 | `packageManager` mismatch / corepack disabled. | Confirm `packageManager` in root `package.json` matches the workflow's expected pnpm. | -| `npm ERR! 401`/`403` reaching `registry.npmjs.org` | Stale `NPM_TOKEN` secret, scoped-package permission drift, or registry filter. | Surface to user; token rotation is out of scope for an auto-fix. | -| `error: process "/bin/sh -c ..." did not complete successfully` | Docker build step crashed; read the inner `RUN` for the real error. | Read the Docker context for what `RUN` produced the exit code; fix that. | -| `Failed to restore from cache` followed by `Process completed with exit code 1` | Cache miss + the build doesn't degrade: it errors. | Bump the `cache-versions.json` entry to invalidate, OR fix the degraded-mode code path. | -| `denied by enterprise admin` / `not allowed to be used` | GH Actions allowlist missing an action. See `auditing-gha-settings`. | Add the action to the org allowlist. The repo can't fix this; escalate. | - -When the pattern isn't in the table, fall back to careful read-through of the log tail. Don't guess. - -## Wall-clock budgets - -Every invocation carries a `--budget-sec` (default 1800 = 30 min) so a stuck run doesn't park the loop forever. When the budget expires, the skill emits its last snapshot and exits. The orchestrator can re-invoke with a longer budget if the run is slow (build-server matrices routinely take 30-60min). - -Budget tiers: - -- `fast` ci.yml watching: **30 min** is plenty. If ci.yml hasn't finished in 30min, something's wrong upstream (runner queue depth, broken cache step). -- `release` build matrix: **60 min**. Most build-server matrices finish in 20–45min; 60min covers the worst case. -- `cool` confirmation: **30 min** is fine. At this point you've already seen one success; you just want the rest. - -## Companion: `auditing-gha-settings` - -Some CI failures aren't code; they're GitHub Actions policy. If you see `denied by enterprise admin` or `the action <name> is not allowed to be used`, that's a GH org-level setting drift, not a code fix. Run `/audit-gha-settings <owner/repo>` (when available) to diff the repo's policy + allowlist against the fleet baseline. The current baseline must include: - -- Policy: **Allow enterprise, and select non-enterprise, actions and reusable workflows** -- Allowlist (each must be present and active): - - `actions/cache/restore@*` - - `actions/cache/save@*` - - `actions/cache@*` - - `actions/checkout@*` - - `actions/download-artifact@*` - - `actions/setup-node@*` - - `actions/setup-python@*` - - `actions/upload-artifact@*` - - `depot/build-push-action@*` - - `depot/setup-action@*` - - `dtolnay/rust-toolchain@*` - - `github/codeql-action/upload-sarif@*` - - `hendrikmuhs/ccache-action@*` - - `mlugg/setup-zig@*` - - `swatinem/rust-cache@*` - -Each entry is here because at least one fleet workflow references it through the socket-registry shared workflows. Removing one breaks every consumer that pins through those shared workflows. Add a new entry only when a new shared workflow references it, and cascade the allowlist entry to every consumer org. - -## Anti-patterns - -- **Auto-merging from a worktree without confirming the target main is current.** Always `git fetch origin main` before pushing the fix. The fleet has heavy commit traffic. -- **Treating a `cancelled` run as a failure.** Someone (or branch protection) cancelled it. Re-run if needed; don't apply a code fix. -- **Polling faster than 30s.** GH's rate limit is generous but not infinite. The `run.mts` runner enforces 30s minimum. -- **Ignoring matrix slot interdependencies.** If `lief-darwin-arm64` fails because `lief-darwin-x64` produced a bad cache, fixing the arm64 slot won't help. Read both slots' logs before fixing. - -## Examples - -Watch a freshly-pushed CI run on main: - - /green-ci socket-btm ci.yml - -Watch a build-server matrix dispatched a minute ago: - - /green-ci socket-btm build-curl.yml --mode release - -Watch the rest of a matrix after the first slot succeeded: - - /green-ci socket-btm build-curl.yml --mode cool diff --git a/.claude/skills/greening-ci/run.mts b/.claude/skills/greening-ci/run.mts deleted file mode 100644 index 44382df36..000000000 --- a/.claude/skills/greening-ci/run.mts +++ /dev/null @@ -1,395 +0,0 @@ -#!/usr/bin/env node -/** - * @file Watch a repo's GitHub Actions CI run, surface the first failure log, - * and exit. The fix-and-push loop is driven by the human (or the agent - * invoking this skill) — this runner is the eyes. Three modes the skill - * orchestrator picks between: - * - * 1. `--mode=fast` (default for ci.yml) Poll every 30s. Stop on first failure or - * first success. Use when watching a freshly-pushed commit's CI on main / - * PR. - * 2. `--mode=release` Poll every 30s until the FIRST job either fails or - * succeeds. Release matrices (curl, lief, binsuite, node-smol, …) fail - * fast in one matrix slot before others finish — we want that signal as - * soon as possible. Once any slot succeeds, the next poll cools down to - * 120s for the rest of the matrix. - * 3. `--mode=cool` Poll every 120s. Use after `release` has reported a first - * success — the rest of the matrix is just confirmation. Output (always - * JSON on the last line, prose above for humans): { "status": "completed" - * | "in_progress" | "queued" | "failure", "conclusion": "success" | - * "failure" | "cancelled" | "skipped" | null, "runId": <number>, "url": - * "https://github.com/<owner>/<repo>/actions/runs/<id>", "failedJobs": [{ - * "name": "...", "logTailPath": "..." }], "elapsedSec": <number> } The - * orchestrator (SKILL.md prompt) reads the JSON, decides whether to fix - * and push, then invokes this runner again. The log tail is dumped to a - * tmp file so the orchestrator can Read it without re-spending the `gh run - * view --log-failed` budget on every retry. - */ - -import { mkdtempSync } from 'node:fs' -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib/logger/default' -import { spawn } from '@socketsecurity/lib/process/spawn/child' - -const logger = getDefaultLogger() - -/** - * Decide whether this poll's snapshot is a stopping point. - * - * Returns: - 'stop' : terminal — caller reports + exits. - 'continue': loop - * again after pollSec. - * - * Fast: stop when the run is completed (success OR failure) OR when any job has - * conclusion === failure (so we surface a failing job before the whole run - * finishes). - * - * Release: stop when ANY job has either conclusion === failure or conclusion - * === success. The matrix runs in parallel; one slot landing is enough signal - * to know whether to start fixing or to cool down. - * - * Cool: stop only on a fully-completed run. The caller is just waiting out the - * rest of the matrix. - */ -export function decide( - mode: Mode, - run: GhRun, - jobs: GhJob[], -): 'stop' | 'continue' { - if (mode === 'cool') { - return run.status === 'completed' ? 'stop' : 'continue' - } - if (mode === 'fast') { - if (run.status === 'completed') { - return 'stop' - } - if (jobs.some(j => j.conclusion === 'failure')) { - return 'stop' - } - return 'continue' - } - // release - if (run.status === 'completed') { - return 'stop' - } - if ( - jobs.some(j => j.conclusion === 'failure' || j.conclusion === 'success') - ) { - return 'stop' - } - return 'continue' -} - -/** - * Dump the failed-job log tail to a tmp file so the orchestrator can Read it - * without re-spending `gh run view --log-failed` budget on every retry. The - * tail is the last ~400 lines — enough to catch the error band without flooding - * context. - */ -export async function dumpFailedLog( - args: CliArgs, - runId: number, - tempDir: string, -): Promise<string> { - const raw = await gh([ - 'run', - 'view', - String(runId), - '--repo', - args.repo, - '--log-failed', - ]) - const lines = raw.split('\n') - const tail = lines.slice(-400).join('\n') - const file = path.join(tempDir, `run-${runId}-failed.log`) - await fs.writeFile(file, tail) - return file -} - -interface GhJob { - databaseId: number - name: string - status: 'queued' | 'in_progress' | 'completed' - conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null -} - -export async function fetchJobs( - args: CliArgs, - runId: number, -): Promise<GhJob[]> { - const raw = await gh([ - 'run', - 'view', - String(runId), - '--repo', - args.repo, - '--json', - 'jobs', - ]) - const obj = JSON.parse(raw) as { jobs: GhJob[] } - return obj.jobs -} - -export async function fetchLatestRun( - args: CliArgs, -): Promise<GhRun | undefined> { - const ghArgs: string[] = [ - 'run', - 'list', - '--repo', - args.repo, - '--limit', - '1', - '--json', - 'databaseId,status,conclusion,url,workflowName,headBranch,headSha,createdAt', - ] - if (args.workflow) { - ghArgs.push('--workflow', args.workflow) - } - if (args.branch) { - ghArgs.push('--branch', args.branch) - } - const raw = await gh(ghArgs) - const list = JSON.parse(raw) as GhRun[] - return list[0] -} - -interface GhRun { - databaseId: number - status: 'queued' | 'in_progress' | 'completed' - conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null - url: string - workflowName: string - headBranch: string - headSha: string - createdAt: string -} - -export async function gh(args: readonly string[]): Promise<string> { - // Bound every gh call at 60s — the GH API is usually <1s but a hung - // request shouldn't park the watch loop. The caller already has its - // own loop cadence, so a single slow gh call timing out and being - // retried on the next tick is benign. - const r = await spawn('gh', args as string[], { - stdio: 'pipe', - stdioString: true, - timeout: 60_000, - }) - return String(r.stdout ?? '').trim() -} - -type Mode = 'fast' | 'release' | 'cool' - -interface CliArgs { - repo: string - workflow: string | undefined - branch: string | undefined - mode: Mode - // Wall-clock cap on the whole watch loop. Default: 30min for fast, - // 60min for release/cool. Beyond this, exit with the latest status - // and let the orchestrator decide whether to re-invoke. - budgetSec: number - // Poll interval in seconds (override; otherwise derived from mode). - pollSec: number | undefined -} - -export function parseArgs(argv: readonly string[]): CliArgs { - let repo = '' - let workflow: string | undefined - let branch: string | undefined - let mode: Mode = 'fast' - let budgetSec = 1800 - let pollSec: number | undefined - for (let i = 0, { length } = argv; i < length; i += 1) { - const a = argv[i]! - if (a === '--repo') { - repo = argv[++i]! - } else if (a === '--workflow') { - workflow = argv[++i] - } else if (a === '--branch') { - branch = argv[++i] - } else if (a === '--mode') { - const v = argv[++i] - if (v !== 'cool' && v !== 'fast' && v !== 'release') { - throw new Error(`--mode must be one of fast|release|cool (got: ${v})`) - } - mode = v - } else if (a === '--budget-sec') { - budgetSec = Number(argv[++i]) - } else if (a === '--poll-sec') { - pollSec = Number(argv[++i]) - } else if (a === '--help' || a === '-h') { - printHelp() - process.exit(0) - } else { - throw new Error(`Unknown argument: ${a}`) - } - } - if (!repo) { - throw new Error( - 'Missing --repo <owner/name>. Example: --repo SocketDev/socket-btm', - ) - } - return { repo, workflow, branch, mode, budgetSec, pollSec } -} - -interface WatchResult { - status: GhRun['status'] | 'failure' - conclusion: GhRun['conclusion'] - runId: number - url: string - failedJobs: Array<{ name: string; logTailPath: string }> - elapsedSec: number -} - -export function pickPollSec(mode: Mode, override: number | undefined): number { - if (override !== undefined) { - return override - } - if (mode === 'cool') { - return 120 - } - // fast + release both poll at 30s; release stops earlier on first - // matrix-slot outcome, but the cadence is the same. - return 30 -} - -export function printHelp(): void { - logger.info( - // oxlint-disable-next-line socket/no-logger-newline-literal -- CLI help text is intentionally a single multi-line block; splitting would garble the columnar formatting users expect. - `Usage: node run.mts --repo <owner/name> [--workflow ci.yml] [--branch main] - [--mode fast|release|cool] [--budget-sec N] [--poll-sec N] - -Watches a GH Actions run, surfaces the first failure log to a tmp file, -prints a JSON result on the final line. The fix-and-push loop is driven -by the caller (skill orchestrator / human). - -Modes: - fast (default) 30s poll, stop on first failure OR first success. - For ci.yml watching a single-job-set workflow. - release 30s poll, stop on first failure OR first matrix-slot success. - For build-server matrices (curl/lief/binsuite/node-smol). - Returns as soon as ONE slot has reported either outcome. - cool 120s poll. Use after release reported a first success — the - remaining matrix is just confirmation, no need to fast-poll. - -Examples: - node run.mts --repo SocketDev/socket-btm --workflow ci.yml - node run.mts --repo SocketDev/socket-btm --workflow build-curl.yml --mode release - node run.mts --repo SocketDev/socket-btm --workflow build-curl.yml --mode cool`, - ) -} - -export async function sleep(sec: number): Promise<void> { - await new Promise<void>(r => { - setTimeout(r, sec * 1000) - }) -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - const pollSec = pickPollSec(args.mode, args.pollSec) - const tempDir = mkdtempSync(path.join(os.tmpdir(), 'greening-ci.')) - const started = Date.now() - - logger.info( - `Watching ${args.repo}${args.workflow ? ` workflow=${args.workflow}` : ''}` + - `${args.branch ? ` branch=${args.branch}` : ''} mode=${args.mode}` + - ` poll=${pollSec}s budget=${args.budgetSec}s`, - ) - logger.info(`Log tail will be written under: ${tempDir}`) - - let lastResult: WatchResult | undefined - let lastRun: GhRun | undefined - for (;;) { - const elapsedSec = (Date.now() - started) / 1000 - if (elapsedSec > args.budgetSec) { - logger.warn( - `Wall-clock budget (${args.budgetSec}s) exceeded; returning latest snapshot.`, - ) - if (lastRun) { - lastResult = { - status: lastRun.status, - conclusion: lastRun.conclusion, - runId: lastRun.databaseId, - url: lastRun.url, - failedJobs: [], - elapsedSec: Math.round(elapsedSec), - } - } - break - } - const run = await fetchLatestRun(args) - if (!run) { - logger.warn( - `No runs found for ${args.repo}${args.workflow ? `/${args.workflow}` : ''}; ` + - 'is the workflow filename correct and has a run been triggered?', - ) - await sleep(pollSec) - continue - } - lastRun = run - const jobs = await fetchJobs(args, run.databaseId) - const failed = jobs.filter(j => j.conclusion === 'failure') - logger.info( - `[t+${Math.round(elapsedSec)}s] run=${run.databaseId} status=${run.status}` + - ` conclusion=${run.conclusion ?? '-'} ` + - `jobs: ${jobs.length} total, ${failed.length} failed`, - ) - - const verdict = decide(args.mode, run, jobs) - if (verdict === 'stop') { - const failedJobs: WatchResult['failedJobs'] = [] - if (failed.length > 0) { - const logPath = await dumpFailedLog(args, run.databaseId, tempDir) - for (let i = 0, { length } = failed; i < length; i += 1) { - const j = failed[i]! - failedJobs.push({ name: j.name, logTailPath: logPath }) - } - } - lastResult = { - status: run.conclusion === 'failure' ? 'failure' : run.status, - conclusion: run.conclusion, - runId: run.databaseId, - url: run.url, - failedJobs, - elapsedSec: Math.round(elapsedSec), - } - break - } - await sleep(pollSec) - } - - if (!lastResult) { - // Budget-exceeded path: emit a placeholder with whatever we last - // saw so the orchestrator gets *something* parseable. - lastResult = { - status: 'in_progress', - conclusion: undefined, - runId: 0, - url: '', - failedJobs: [], - elapsedSec: Math.round((Date.now() - started) / 1000), - } - } - - logger.info('') - logger.info(`Run URL: ${lastResult.url || '(none)'}`) - if (lastResult.failedJobs.length > 0) { - logger.info( - `Failed jobs (${lastResult.failedJobs.length}):` + - ` ${lastResult.failedJobs.map(j => j.name).join(', ')}`, - ) - logger.info(`Failure log tail: ${lastResult.failedJobs[0]!.logTailPath}`) - } - // Final line is JSON — the orchestrator parses this. - logger.info(JSON.stringify(lastResult)) -} - -main().catch(e => { - logger.error(e instanceof Error ? e.message : String(e)) - process.exit(1) -}) diff --git a/.claude/skills/guarding-paths/SKILL.md b/.claude/skills/guarding-paths/SKILL.md deleted file mode 100644 index 4f3a4285a..000000000 --- a/.claude/skills/guarding-paths/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: guarding-paths -description: Audits and fixes path duplication in a Socket repo. Applies the strict "1 path, 1 reference" rule: every build/test/runtime/config path is constructed exactly once; everywhere else references the constructed value. Default mode finds and fixes; `check` mode reports only; `install` mode drops the gate + hook + rule into a fresh repo. Use when path drift surfaces from `pnpm check`, when a new sibling package needs path conventions, or when bootstrapping a fresh Socket repo. -user-invocable: true -allowed-tools: Task, Read, Edit, Write, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(node scripts/check-paths:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(git:*) ---- - -# guarding-paths - -**Mantra: 1 path, 1 reference.** A path is constructed exactly once; everywhere else references the constructed value. Re-constructing the same path twice is the violation. Referencing the constructed value many times is fine. - -## Modes - -| Invocation | Effect | -| -------------------------- | ---------------------------------------------------------- | -| `/guarding-paths` | Full audit-and-fix on the current repo (default). | -| `/guarding-paths check` | Read-only audit; report violations; no fixes. | -| `/guarding-paths fix <id>` | Fix a single finding from a prior `check` run, by index. | -| `/guarding-paths install` | Drop the gate + hook + rule + allowlist into a fresh repo. | - -## Three-level enforcement - -The strategy lives in three artifacts that ship together: - -1. **CLAUDE.md rule**: the mantra and detection rules in plain language. Every fleet repo's CLAUDE.md carries `## 1 path, 1 reference`. Synced from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md). -2. **Hook**: `.claude/hooks/path-guard/index.mts` runs `PreToolUse` on `Edit` / `Write` of `.mts` / `.cts` files. Blocks new violations at edit time. -3. **Gate**: `scripts/check-paths.mts` runs in `pnpm check` (and CI). Whole-repo scan. Fails the build on any unsanctioned violation. - -The hook and gate share their stage / build-root / mode / sibling-package vocabulary via `.claude/hooks/path-guard/segments.mts`: a single canonical source. Adding a new stage segment or fleet package means editing one file; the two consumers can never drift on what counts as a build-output path. - -This skill is the **audit-and-fix workflow** that makes a repo conform initially and validates conformance over time. - -## Detection rules - -The gate enforces six rules. The hook enforces a subset (A and B), since it sees only one diff at a time. - -| Rule | What it catches | Where checked | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| **A** | Multi-stage `path.join(...)` constructed inline. Two or more "stage" segments (Final, Release, Stripped, Compressed, Optimized, Synced, wasm, downloaded), or one stage + build-root + mode. | `.mts` / `.cts` files outside a `paths.mts`. Hook + gate. | -| **B** | Cross-package traversal: `path.join(*, '..', '<sibling-package>', 'build', ...)` reaching into a sibling's output instead of importing via `exports`. | `.mts` / `.cts` files. Hook + gate. | -| **C** | Workflow YAML constructs the same path string in 2+ steps outside a "Compute paths" step. | `.github/workflows/*.yml`. Gate. | -| **D** | Comment encodes a fully-qualified multi-stage path string (e.g. `# build/dev/darwin-arm64/out/Final/binary`). | `.github/workflows/*.yml`. Gate. | -| **F** | Same path shape constructed in 2+ different files. | All scanned files. Gate. | -| **G** | Hand-built multi-stage path constructed 2+ times in the same Makefile / Dockerfile / shell stage. | `Makefile`, `*.mk`, `*.Dockerfile`, `Dockerfile.*`, `*.sh`. Gate. | - -Comments may describe path _structure_ with placeholders (`<mode>/<arch>` or `${BUILD_MODE}/${PLATFORM_ARCH}`) but should not encode a complete literal path string. Violations in `.mts`, Makefiles, Dockerfiles, workflow YAML, and shell scripts are blocking; comments come second. - -## Mode: audit-and-fix (default) - -| # | Phase | Outcome | -| --- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Setup | Spawn worktree off `origin/$BASE` (default-branch fallback). | -| 2 | Audit | `pnpm run check:paths --json > /tmp/paths-findings.json`; `pnpm run check:paths --explain` for human-readable. | -| 3 | Fix loop | For each finding, apply the matching pattern from [`reference.md`](reference.md). Re-run the gate after each fix. Stop when `pnpm run check:paths` exits 0. | -| 4 | Verify | `pnpm check` + `zizmor` on any modified workflow. | -| 5 | Commit + push | Per-rule commits, atomic. Push directly to `$BASE` for repos that allow it; PR for socket-cli / socket-sdk-js / socket-registry. | -| 6 | Cleanup | `git worktree remove ../<repo>-paths-audit`. `git worktree list` should show only the primary afterward. | - -Worktree setup uses the default-branch fallback from CLAUDE.md: - -```bash -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/main; then BASE=main; fi -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/master; then BASE=master; fi -BASE="${BASE:-main}" -git worktree add -b paths-audit ../<repo>-paths-audit "$BASE" -``` - -## Mode: check (read-only) - -```bash -pnpm run check:paths --explain -``` - -Prints findings without making edits. Exit 0 if clean, 1 if findings present. Useful for CI / pre-merge inspection. - -## Mode: install (new repo) - -For Socket repos that don't yet have the gate: - -1. Copy the gate file: - ```bash - cp .claude/skills/guarding-paths/templates/check-paths.mts.tmpl scripts/check-paths.mts - ``` -2. Copy the empty allowlist: - ```bash - cp .claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl .github/paths-allowlist.yml - ``` -3. Add `"check:paths": "node scripts/check-paths.mts"` to `package.json`. -4. Wire `runPathHygieneCheck()` into `scripts/check.mts` (after the existing checks). -5. Append the rule snippet from [`_shared/path-guard-rule.md`](../_shared/path-guard-rule.md) to the repo's `CLAUDE.md` if a `1 path, 1 reference` section is missing. -6. Add the hook entry to `.claude/settings.json` `PreToolUse` matcher `Edit|Write`: - ```json - { "type": "command", "command": "node .claude/hooks/path-guard/index.mts" } - ``` -7. Run the gate against the repo. Triage findings as you would in audit-and-fix mode. - -## Allowlisting a finding - -Genuine exemptions are rare; most "false positives" should be reported as gate bugs. When needed, add an entry to `.github/paths-allowlist.yml`. Two ways to pin: - -- **`line:`**: exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. -- **`snippet_hash:`**: 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant: survives reformatting, but any content-changing edit invalidates it. Get the hash via `pnpm run check:paths --show-hashes`. - -Both may be set — either matching is sufficient. Prefer `snippet_hash` over raw `line:` when the exemption is expected to outlive routine reformatting; prefer `line:` when you specifically _want_ the entry to fall off after any nearby edit. - -## Commit cadence - -- **Per-rule fix → its own commit.** Rule A fix in `packages/foo/` and Rule C workflow fix go in separate commits even when found in the same audit pass. -- **Re-run the gate before each commit.** A green `pnpm run check:paths` is the entry criterion. -- **Don't leave a partial fix uncommitted across phases.** Commit what's done on `chore/paths-audit-wip` if the audit gets interrupted. - -Conventional commit shape: `fix(paths): rule A: extract foo build paths into scripts/paths.mts`. - -## Tie-in with `scanning-quality` - -`/scanning-quality` calls `pnpm run check:paths --json` as one of its sub-scans and surfaces findings in its A-F report. The full audit-and-fix workflow lives here. `scanning-quality` only _detects_ during periodic scans. - -## Fix patterns - -Per-rule fix templates (Rules A through G) plus the worked-example reference patterns from socket-btm: [`reference.md`](reference.md). File scaffolding for `install` mode lives in [`templates/`](templates/). diff --git a/.claude/skills/guarding-paths/reference.md b/.claude/skills/guarding-paths/reference.md deleted file mode 100644 index cc450df18..000000000 --- a/.claude/skills/guarding-paths/reference.md +++ /dev/null @@ -1,170 +0,0 @@ -# guarding-paths — fix patterns - -The patterns to apply for each detection rule. The orchestration story (modes, phases, allowlisting) lives in [`SKILL.md`](SKILL.md). The `install` mode copies file scaffolding from [`templates/`](templates/). - -## Rule A — Multi-stage path constructed inline (in `.mts`/`.cts`) - -**Bad**: - -```ts -const finalBinary = path.join( - PACKAGE_ROOT, - 'build', - BUILD_MODE, - PLATFORM_ARCH, - 'out', - 'Final', - 'binary', -) -``` - -**Fix**: move the construction into the package's `scripts/paths.mts` (or `lib/paths.mts`), or use a build-infra helper: - -```ts -// In packages/foo/scripts/paths.mts: -export function getBuildPaths(mode, platformArch) { - // ... constructs once ... - return { - outputFinalBinary: path.join( - PACKAGE_ROOT, - 'build', - mode, - platformArch, - 'out', - 'Final', - binaryName, - ), - } -} - -// In the consumer: -import { getBuildPaths } from './paths.mts' -const { outputFinalBinary } = getBuildPaths(mode, platformArch) -``` - -For binsuite tools (binpress / binflate / binject) the canonical helper is `getFinalBinaryPath(packageRoot, mode, platformArch, binaryName)` from `build-infra/lib/paths`. For download caches use `getDownloadedDir(packageRoot)`. - -## Rule B — Cross-package traversal - -**Bad**: - -```ts -const liefDir = path.join( - PACKAGE_ROOT, - '..', - 'lief-builder', - 'build', - mode, - platformArch, - 'out', - 'Final', - 'lief', -) -``` - -**Fix**: declare the workspace dep, expose `paths.mts` via the producer's `exports`, import the helper: - -1. In producer's `package.json`: - ```json - "exports": { - "./scripts/paths": "./scripts/paths.mts" - } - ``` -2. In consumer's `package.json` `dependencies`: - ```json - "lief-builder": "workspace:*" - ``` -3. In consumer: - ```ts - import { getBuildPaths as getLiefBuildPaths } from 'lief-builder/scripts/paths' - const { outputFinalDir } = getLiefBuildPaths(mode, platformArch) - ``` - -## Rule C — Workflow path repetition - -**Bad** (3 steps each rebuilding the same path): - -```yaml -- name: Step A - run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-1 -- name: Step B - run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-2 -- name: Step C - run: cd packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && do-thing-3 -``` - -**Fix**: add a "Compute <pkg> paths" step early in the job that constructs the path once, expose via `$GITHUB_OUTPUT`, reference downstream: - -```yaml -- name: Compute foo paths - id: paths - env: - BUILD_MODE: ${{ steps.build-mode.outputs.mode }} - PLATFORM_ARCH: ${{ steps.platform-arch.outputs.platform_arch }} - run: | - PACKAGE_DIR="packages/foo" - PLATFORM_BUILD_DIR="${PACKAGE_DIR}/build/${BUILD_MODE}/${PLATFORM_ARCH}" - FINAL_DIR="${PLATFORM_BUILD_DIR}/out/Final" - { - echo "package_dir=${PACKAGE_DIR}" - echo "platform_build_dir=${PLATFORM_BUILD_DIR}" - echo "final_dir=${FINAL_DIR}" - } >> "$GITHUB_OUTPUT" - -- name: Step A - env: - FINAL_DIR: ${{ steps.paths.outputs.final_dir }} - run: cd "$FINAL_DIR" && do-thing-1 -# ... etc -``` - -For paths used inside `working-directory: packages/foo` steps, expose a `_rel` companion (e.g. `final_dir_rel=build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final`) and reference that. - -## Rule D — Comment-encoded paths - -**Bad**: - -```yaml -# Path: packages/foo/build/dev/darwin-arm64/out/Final/binary -COPY --from=builder /build/.../out/Final/binary /out/Final/binary -``` - -**Fix**: cite the canonical `paths.mts` instead of duplicating the string: - -```yaml -# Layout owned by packages/foo/scripts/paths.mts:getBuildPaths(). -COPY --from=builder /build/packages/foo/build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/binary /out/Final/binary -``` - -The comment may describe structure (`<mode>/<arch>`) but should not be a parsable literal path. - -## Rule G — Dockerfile / Makefile / shell duplicate construction - -**Bad** (Dockerfile reconstructs the path 3 times in the same stage): - -```dockerfile -RUN mkdir -p build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final && \ - cp src build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/output && \ - ls build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final/ -``` - -**Fix**: declare an `ENV` once, reference everywhere: - -```dockerfile -# Layout owned by packages/foo/scripts/paths.mts. -ENV FINAL_DIR=build/${BUILD_MODE}/${PLATFORM_ARCH}/out/Final -RUN mkdir -p "$FINAL_DIR" && cp src "$FINAL_DIR/output" && ls "$FINAL_DIR/" -``` - -Each Dockerfile `FROM` stage is its own scope — `ENV` from the build stage doesn't reach a subsequent `FROM scratch AS export` stage. The gate accounts for this. - -## Reference patterns (worked example) - -The patterns to reuse when converting a repo to the strategy: - -- **TS-first packages**: each package owns a `scripts/paths.mts` with `PACKAGE_ROOT`, `BUILD_ROOT`, `getBuildPaths(mode, platformArch)` returning at minimum `outputFinalDir` and `outputFinalBinary` / `outputFinalFile`. -- **Cross-package consumers**: `package.json` `exports` allowlists `./scripts/paths`. Consumer adds `"<producer>": "workspace:*"` and imports. -- **Workflows**: each job has a "Compute <pkg> paths" step (`id: paths`) early in the job. Step outputs include `package_dir`, `platform_build_dir`, `final_dir`, named files. `_rel` companions when `working-directory:` is used. -- **Docker stages**: each `FROM` stage declares `ENV PLATFORM_BUILD_DIR=...` and `ENV FINAL_DIR=...` once. Subsequent `RUN` steps reference the variables. - -The first repo (socket-btm) is the worked example. Read its `scripts/paths.mts` files and `.github/workflows/*.yml` for canonical patterns when applying the strategy elsewhere. diff --git a/.claude/skills/guarding-paths/reference/check-paths.mts.tmpl b/.claude/skills/guarding-paths/reference/check-paths.mts.tmpl deleted file mode 100644 index cbecc71e5..000000000 --- a/.claude/skills/guarding-paths/reference/check-paths.mts.tmpl +++ /dev/null @@ -1,947 +0,0 @@ -#!/usr/bin/env node -/** - * @fileoverview Path-hygiene gate. - * - * Mantra: 1 path, 1 reference. A path is constructed exactly once; - * everywhere else references the constructed value. - * - * Whole-repo scan complementing the per-edit `.claude/hooks/path-guard` - * hook. The hook stops new violations from landing; this gate finds - * the existing ones and blocks merges that introduce more. - * - * Rules enforced: - * - * A — Multi-stage path constructed inline. A `path.join(...)` call - * (or template literal) in a `.mts`/`.cts` file outside a - * `paths.mts` that stitches together two or more "stage" - * segments (Final, Release, Stripped, Compressed, Optimized, - * Synced, wasm, downloaded), or one stage plus a build-root - * (`build`/`out`) plus a mode (`dev`/`prod`/`shared`). The - * construction belongs in the package's `paths.mts` (or a - * build-infra helper); every consumer imports the computed - * value. - * - * B — Cross-package path traversal. A `path.join(*, '..', '<sibling - * package>', 'build', ...)` reaches into a sibling's build - * output without going through its `exports`. The sibling owns - * its layout; consumers declare a workspace dep and import the - * sibling's `paths.mts`. - * - * C — Hand-built workflow path. A `.github/workflows/*.yml` step - * constructs `build/${...}/out/<stage>/...` inline outside a - * canonical "Compute paths" step. Workflows can carry path - * strings, but the strings are constructed once and exposed via - * step outputs / job env that downstream steps reference. - * - * D — Comment-encoded paths. Comments (in code or YAML) that re-state - * a fully-qualified multi-stage path. Comments may describe the - * structure ("Final dir" or "build/<mode>/...") but should not - * encode a complete path string that a tool would parse — the - * canonical construction IS the documentation. - * - * F — Same path constructed in multiple places. The same shape of - * multi-stage `path.join(...)` (or workflow `build/${...}/...` - * string template) appearing in two or more files. Construct - * once and import; references of the constructed value are - * unlimited. - * - * G — Hand-built paths in Makefiles, Dockerfiles, and shell scripts. - * Same shape as A, applied to executable artifacts that don't - * run TypeScript. Each canonical construction must carry a - * comment naming the source-of-truth `paths.mts` so the script - * can't drift from TS without a flagged change. - * - * Allowlist: `.github/paths-allowlist.yml`. Each entry needs a - * `reason` so the list stays audit-able. Patterns are deliberately - * narrow — entries should be specific, not blanket. - * - * Usage: - * node scripts/check-paths.mts # default: report + fail - * node scripts/check-paths.mts --explain # long-form explanation - * node scripts/check-paths.mts --json # machine-readable - * node scripts/check-paths.mts --quiet # silent on clean - * - * Exit codes: - * 0 — clean (no findings, or every finding is allowlisted) - * 1 — findings present - * 2 — gate itself crashed - */ - -import { createHash } from 'node:crypto' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { fileURLToPath } from 'node:url' - -import { parseArgs } from 'node:util' - -import { - BUILD_ROOT_SEGMENTS, - KNOWN_SIBLING_PACKAGES, - MODE_SEGMENTS, - STAGE_SEGMENTS, -} from '../.claude/hooks/path-guard/segments.mts' - -// Plain stderr/stdout output — no @socketsecurity/lib dependency so -// the gate is self-contained and works in socket-lib itself (which -// would otherwise import itself). -const logger = { - log: (msg: string) => process.stdout.write(msg + '\n'), - error: (msg: string) => process.stderr.write(msg + '\n'), - step: (msg: string) => process.stdout.write(`→ ${msg}\n`), - success: (msg: string) => process.stdout.write(`✔ ${msg}\n`), - substep: (msg: string) => process.stdout.write(` ${msg}\n`), -} - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const REPO_ROOT = path.resolve(__dirname, '..') - -// Stage / build-root / mode / sibling-package vocabularies are imported -// from `.claude/hooks/path-guard/segments.mts` (the canonical source). -// Both this gate and the path-guard hook share that single definition -// — Mantra: 1 path, 1 reference. - -// File-path patterns that legitimately enumerate path segments. -const EXEMPT_FILE_PATTERNS: RegExp[] = [ - // Any paths.mts is the canonical constructor. - /(^|\/)paths\.(mts|cts|js)$/, - // Build-infra owns shared helpers that enumerate stages. - /packages\/build-infra\/lib\/paths\.mts$/, - /packages\/build-infra\/lib\/constants\.mts$/, - // Path-scanning gates that intentionally enumerate. - /scripts\/check-paths\.mts$/, - /scripts\/check-consistency\.mts$/, - /\.claude\/hooks\/path-guard\//, - // Allowlist + config files. - /\.github\/paths-allowlist\.yml$/, -] - -type Finding = { - rule: 'A' | 'B' | 'C' | 'D' | 'F' | 'G' - file: string - line: number - snippet: string - message: string - fix: string -} - -const findings: Finding[] = [] - -const args = parseArgs({ - options: { - explain: { type: 'boolean', default: false }, - json: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - 'show-hashes': { type: 'boolean', default: false }, - }, - strict: false, -}) - -const isExempt = (filePath: string): boolean => - EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) - -// ────────────────────────────────────────────────────────────────── -// Allowlist loading -// ────────────────────────────────────────────────────────────────── - -type AllowlistEntry = { - file?: string - pattern?: string - rule?: string - line?: number - snippet_hash?: string - reason: string -} - -const loadAllowlist = (): AllowlistEntry[] => { - const allowlistPath = path.join(REPO_ROOT, '.github', 'paths-allowlist.yml') - if (!existsSync(allowlistPath)) { - return [] - } - const text = readFileSync(allowlistPath, 'utf8') - // Tiny YAML parser — only the shape we need: list of entries with - // `file`, `pattern`, `rule`, `line`, `reason` scalar fields, plus - // YAML 1.2 block-scalar indicators `|` (literal) and `>` (folded) - // for multi-line reasons. Avoids a yaml dep for a gate that has to - // be self-contained. - const entries: AllowlistEntry[] = [] - let current: Partial<AllowlistEntry> | null = null - // When set, subsequent more-indented lines fold into this key as a - // block scalar (literal '|' keeps newlines, folded '>' joins with - // spaces). - let blockKey: string | null = null - let blockKind: '|' | '>' | null = null - let blockIndent = 0 - let blockLines: string[] = [] - const flushBlock = () => { - if (current && blockKey) { - const value = - blockKind === '>' - ? blockLines.join(' ').replace(/\s+/g, ' ').trim() - : blockLines.join('\n').replace(/\n+$/, '') - ;(current as any)[blockKey] = value - } - blockKey = null - blockKind = null - blockLines = [] - } - const indentOf = (line: string): number => { - let i = 0 - while (i < line.length && line[i] === ' ') { - i += 1 - } - return i - } - const lines = text.split('\n') - for (let i = 0; i < lines.length; i++) { - const raw = lines[i]! - const line = raw.replace(/\r$/, '') - // Block-scalar accumulation takes precedence over normal parsing. - if (blockKey !== null) { - if (line.trim() === '') { - // Preserve blank lines inside a literal block; folded blocks - // turn them into paragraph breaks (kept as separate joins). - blockLines.push('') - continue - } - const indent = indentOf(line) - if (indent >= blockIndent) { - blockLines.push(line.slice(blockIndent)) - continue - } - flushBlock() - // Fall through and re-process the dedented line as normal. - } - if (!line.trim() || line.trim().startsWith('#')) { - continue - } - const tryAssign = (key: string, value: string) => { - const trimmed = value.trim() - if (current === null) { - return - } - if (trimmed === '|' || trimmed === '>') { - blockKey = key - blockKind = trimmed as '|' | '>' - blockIndent = indentOf(lines[i + 1] ?? '') || indentOf(line) + 2 - blockLines = [] - return - } - ;(current as any)[key] = - key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) - } - if (line.startsWith('- ')) { - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - current = {} - const rest = line.slice(2).trim() - if (rest) { - const m = rest.match(/^([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } else if (current) { - const m = line.match(/^\s+([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } - if (blockKey !== null) { - flushBlock() - } - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - return entries -} - -const unquote = (s: string): string => { - const t = s.trim() - if ( - (t.startsWith('"') && t.endsWith('"')) || - (t.startsWith("'") && t.endsWith("'")) - ) { - return t.slice(1, -1) - } - return t -} - -const ALLOWLIST = loadAllowlist() - -/** - * Stable, normalized snippet hash. Whitespace-insensitive so trivial - * reformatting (indent change, trailing comma, line wrap) doesn't - * invalidate an allowlist entry, but content-changing edits do. The - * hash exposes only the first 12 hex chars (~48 bits) which is plenty - * for collision-resistance within a single repo's finding set and - * keeps the YAML readable. - */ -const snippetHash = (snippet: string): string => { - const normalized = snippet.replace(/\s+/g, ' ').trim() - return createHash('sha256').update(normalized).digest('hex').slice(0, 12) -} - -/** - * Allowlist matching trades off two failure modes: - * - * - Drift via reformatting (a line shift breaks an entry, the - * finding re-surfaces, devs paper over with a new entry). - * - Stealth allowlisting (an entry pinned to "anywhere in this file" - * silently exempts unrelated future violations). - * - * Strategy: exact line match OR `snippet_hash` match (whitespace- - * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay - * exact (was ±2; the slack let reformatting silently slide), and - * `snippet_hash` provides reformatting-tolerant matching that's still - * tied to the literal text — paste-and-edit cheating would change the - * hash. If neither `line` nor `snippet_hash` is provided, the entry - * matches purely by `rule` + `file` + `pattern` (file-level exempt; - * use sparingly and always pair with a precise `pattern`). - */ -const isAllowlisted = (finding: Finding): boolean => - ALLOWLIST.some(entry => { - if (entry.rule && entry.rule !== finding.rule) { - return false - } - if (entry.file && !finding.file.includes(entry.file)) { - return false - } - if (entry.pattern && !finding.snippet.includes(entry.pattern)) { - return false - } - const lineProvided = entry.line !== undefined - const hashProvided = - typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 - if (lineProvided || hashProvided) { - const lineMatches = lineProvided && entry.line === finding.line - const hashMatches = - hashProvided && entry.snippet_hash === snippetHash(finding.snippet) - if (!(lineMatches || hashMatches)) { - return false - } - } - return true - }) - -// ────────────────────────────────────────────────────────────────── -// File walking -// ────────────────────────────────────────────────────────────────── - -const SKIP_DIRS = new Set([ - '.git', - 'node_modules', - 'build', - 'dist', - 'out', - 'target', - '.cache', - 'upstream', -]) - -const walk = function* ( - dir: string, - filter: (relPath: string) => boolean, -): Generator<string> { - let entries - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - return - } - for (const e of entries) { - if (SKIP_DIRS.has(e.name)) { - continue - } - const full = path.join(dir, e.name) - const rel = path.relative(REPO_ROOT, full) - if (e.isDirectory()) { - yield* walk(full, filter) - } else if (e.isFile() && filter(rel)) { - yield rel - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule A + B: code scan (.mts / .cts) -// ────────────────────────────────────────────────────────────────── - -// Locate `path.join(` or `path.resolve(` call sites; argument-list -// extraction uses a paren-balancing scanner below to handle arbitrary -// nesting depth (the previous regex-only approach silently missed any -// argument containing 2+ levels of nested function calls). -const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g -const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g - -// Template literal scanner. Captures backtick-delimited strings -// (including those with `${...}` placeholders) so Rule A also catches -// path construction via template literals like -// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. -const TEMPLATE_LITERAL_RE = - /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g - -/** - * Convert a template-literal body into a synthetic forward-slash path - * by replacing `${...}` placeholders with a sentinel and normalizing - * separators. Returns the sequence of path segments split on `/`. The - * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a - * placeholder-only segment (`${binaryName}`) won't match those sets. - */ -const templateLiteralSegments = (body: string): string[] => { - // Strip placeholders so they don't introduce noise in segments. - // Empty result for a placeholder is fine; downstream filters by set - // membership and skips empties. - const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') - return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') -} - -/** - * Extract every `path.join(...)` and `path.resolve(...)` call from the - * source text, returning each call's literal start offset and argument - * substring. Uses paren-balancing so deeply-nested arguments like - * `path.join(getDir(child(x)), 'build', 'Final')` are captured fully. - */ -const extractPathCalls = ( - source: string, -): Array<{ offset: number; args: string }> => { - const calls: Array<{ offset: number; args: string }> = [] - PATH_CALL_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = PATH_CALL_RE.exec(source)) !== null) { - const callStart = match.index - const argsStart = PATH_CALL_RE.lastIndex - let depth = 1 - let i = argsStart - let inString: '"' | "'" | '`' | null = null - while (i < source.length && depth > 0) { - const ch = source[i]! - if (inString) { - if (ch === '\\') { - i += 2 - continue - } - if (ch === inString) { - inString = null - } - } else { - if (ch === '"' || ch === "'" || ch === '`') { - inString = ch - } else if (ch === '(') { - depth += 1 - } else if (ch === ')') { - depth -= 1 - if (depth === 0) { - break - } - } - } - i += 1 - } - if (depth === 0) { - calls.push({ offset: callStart, args: source.slice(argsStart, i) }) - PATH_CALL_RE.lastIndex = i + 1 - } - } - return calls -} - -const extractStringLiterals = (args: string): string[] => { - const literals: string[] = [] - let match: RegExpExecArray | null - STRING_LITERAL_RE.lastIndex = 0 - while ((match = STRING_LITERAL_RE.exec(args)) !== null) { - if (match[2] !== undefined) { - literals.push(match[2]) - } - } - return literals -} - -const scanCodeFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - // Build a line-offset map so we can map regex offsets back to line - // numbers cheaply. - const lineOffsets: number[] = [0] - for (let i = 0; i < content.length; i++) { - if (content[i] === '\n') { - lineOffsets.push(i + 1) - } - } - const offsetToLine = (offset: number): number => { - let lo = 0 - let hi = lineOffsets.length - 1 - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1 - if (lineOffsets[mid]! <= offset) { - lo = mid - } else { - hi = mid - 1 - } - } - return lo + 1 - } - - for (const call of extractPathCalls(content)) { - const literals = extractStringLiterals(call.args) - const stages = literals.filter(l => STAGE_SEGMENTS.has(l)) - const buildRoots = literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) - const modes = literals.filter(l => MODE_SEGMENTS.has(l)) - - // Rule A: 2+ stages OR (1 stage + 1 build-root + 1 mode). - const triggersA = - stages.length >= 2 || - (stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'A', - file: relPath, - line, - snippet, - message: 'Multi-stage path constructed inline (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - - // Rule B: each '..' opens a window; the window stays open only - // until the next non-'..' literal. A sibling-package literal - // *immediately after* a '..' (no path segment between them) - // triggers, AND there must be build context elsewhere in the - // call. Resetting per-segment prevents false positives where '..' - // appears earlier and sibling-name appears much later in an - // unrelated position. - const hasBuildContext = literals.some( - l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), - ) - if (hasBuildContext) { - for (let i = 0; i < literals.length - 1; i++) { - if ( - literals[i] === '..' && - KNOWN_SIBLING_PACKAGES.has(literals[i + 1]!) - ) { - const sibling = literals[i + 1]! - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'B', - file: relPath, - line, - snippet, - message: `Cross-package traversal into '${sibling}' build output.`, - fix: `Add '${sibling}: workspace:*' as a dep, declare an exports entry on '${sibling}' (e.g. './scripts/paths' → './scripts/paths.mts'), and import the path from there.`, - }) - break - } - } - } - } - - // Rule A (template literal variant). Backtick strings like - // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` - // construct paths the same way `path.join(...)` does — flag the - // same shapes. Skip raw imports / template tag positions by - // filtering out leading `import.meta.url`-style / tag positions - // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and - // we rely on segment composition to decide if it's a path. - TEMPLATE_LITERAL_RE.lastIndex = 0 - let tmpl: RegExpExecArray | null - while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { - const body = tmpl[1] ?? '' - if (!body.includes('/')) { - continue - } - const segments = templateLiteralSegments(body) - const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) - const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) - const modes = segments.filter(s => MODE_SEGMENTS.has(s)) - // Template literal trigger is tighter than path.join() because - // backtick strings often appear in patch fixtures, error messages, - // and other multi-line content that incidentally contains stage - // tokens like `wasm`. Require the canonical build-output shape: - // - 'build' + 'out' + stage (canonical multi-stage layout), OR - // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR - // - 'build' + stage + literal mode (back-compat with path.join). - const hasBuildAndOut = - buildRoots.includes('build') && buildRoots.includes('out') - const hasOut = buildRoots.includes('out') - const hasBuild = buildRoots.includes('build') - const triggersA = - (hasBuildAndOut && stages.length >= 1) || - (stages.length >= 2 && hasOut) || - (hasBuild && stages.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(tmpl.index) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'A', - file: relPath, - line, - snippet, - message: - 'Multi-stage path constructed inline via template literal (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule C + D: workflow YAML scan -// ────────────────────────────────────────────────────────────────── - -const WORKFLOW_PATH_RE = - /build\/\$\{[^}]+\}\/[^"'`\s]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g -const WORKFLOW_GH_EXPR_PATH_RE = - /build\/\$\{\{\s*[^}]+\}\}\/[^"'`\s]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g - -const isInsideComputePathsBlock = ( - lines: string[], - lineIdx: number, -): boolean => { - // Walk backwards up to 60 lines looking for the start of the - // current step. If that step is a "Compute paths" step, the line - // is exempt. - for (let i = lineIdx; i >= Math.max(0, lineIdx - 60); i--) { - const l = lines[i] ?? '' - if (/^\s*-\s*name:/i.test(l)) { - // Step boundary — check if THIS step is a Compute paths step. - // The step body may include `id: paths` even if the name is - // something else (e.g. `id: stub-paths`), so look at the next - // ~20 lines for either marker. - for (let j = i; j < Math.min(lines.length, i + 20); j++) { - const m = lines[j] ?? '' - if ( - /^\s*-\s*name:\s*Compute\s+[\w-]+\s+paths/i.test(m) || - /^\s*id:\s*[\w-]*paths\s*$/i.test(m) - ) { - return true - } - if (j > i && /^\s*-\s*name:/i.test(m)) { - // Hit the next step — current step is NOT Compute paths. - return false - } - } - return false - } - } - return false -} - -const scanWorkflowFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - - // First pass: collect every hand-built path occurrence outside a - // "Compute paths" step. Per the mantra, a single reference is fine - // — what's banned is reconstructing the same path 2+ times. - type PathHit = { - line: number - snippet: string - pathStr: string - } - const occurrences = new Map<string, PathHit[]>() - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comment lines from C scan; they're under D below. - continue - } - if (isInsideComputePathsBlock(lines, i)) { - // Inside the canonical construction step — exempt. - continue - } - WORKFLOW_PATH_RE.lastIndex = 0 - WORKFLOW_GH_EXPR_PATH_RE.lastIndex = 0 - const matches: string[] = [] - let m: RegExpExecArray | null - while ((m = WORKFLOW_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - while ((m = WORKFLOW_GH_EXPR_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - for (const pathStr of matches) { - const list = occurrences.get(pathStr) ?? [] - list.push({ line: i + 1, snippet: line.trim(), pathStr }) - occurrences.set(pathStr, list) - } - } - - // Flag every occurrence of a shape that appears 2+ times. - for (const [pathStr, hits] of occurrences) { - if (hits.length < 2) { - continue - } - for (const hit of hits) { - findings.push({ - rule: 'C', - file: relPath, - line: hit.line, - snippet: hit.snippet, - message: `Workflow constructs the same path ${hits.length} times: ${pathStr}`, - fix: 'Add a "Compute <pkg> paths" step (id: paths) early in the job that computes this path ONCE and exposes it via $GITHUB_OUTPUT. Reference as ${{ steps.paths.outputs.<name> }} in subsequent steps. References of the constructed value are unlimited; reconstructing is the violation.', - }) - } - } - - // Rule D: comments encoding a fully-qualified multi-stage path - // (separate scan since it has different semantics). - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (!/^\s*#/.test(line)) { - continue - } - const literalShape = - /build\/(?:dev|prod|shared)\/[a-z0-9-]+\/(?:wasm\/)?out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/i - if (literalShape.test(line)) { - findings.push({ - rule: 'D', - file: relPath, - line: i + 1, - snippet: line.trim(), - message: 'Comment encodes a fully-qualified path string.', - fix: 'Cite the canonical paths.mts (e.g. "see packages/<pkg>/scripts/paths.mts:getBuildPaths()") instead of duplicating the path string. Comments may describe structure with placeholders ("<mode>/<arch>") but should not be a parsable path.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule G: Makefile / Dockerfile / shell scan -// ────────────────────────────────────────────────────────────────── - -const SCRIPT_HAND_BUILT_RE = - /build\/\$?\{?(?:BUILD_MODE|MODE|prod|dev)\}?\/[\w${}.-]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g - -const scanScriptFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - const isDockerfile = - /Dockerfile/i.test(relPath) || /\.glibc$|\.musl$/.test(relPath) - - // First pass: collect every multi-stage path occurrence in this file, - // scoped per Dockerfile stage (each `FROM ... AS ...` starts a new - // scope where ENV/ARG don't propagate). - type Hit = { line: number; text: string; pathStr: string; stage: number } - const hits: Hit[] = [] - let stage = 0 - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comments — documentation, not construction. - continue - } - if (isDockerfile && /^FROM\s+/i.test(line)) { - stage += 1 - continue - } - SCRIPT_HAND_BUILT_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = SCRIPT_HAND_BUILT_RE.exec(line)) !== null) { - hits.push({ - line: i + 1, - text: line.trim(), - pathStr: m[0], - stage, - }) - } - } - - // Group by (stage, pathStr) — only flag when a path is built 2+ - // times within the SAME Dockerfile stage (or anywhere in non- - // Dockerfile scripts, where stages don't apply). - const grouped = new Map<string, Hit[]>() - for (const h of hits) { - const key = `${h.stage}::${h.pathStr}` - const list = grouped.get(key) ?? [] - list.push(h) - grouped.set(key, list) - } - for (const [, list] of grouped) { - if (list.length < 2) { - continue - } - for (const hit of list) { - findings.push({ - rule: 'G', - file: relPath, - line: hit.line, - snippet: hit.text, - message: `Hand-built multi-stage path constructed ${list.length} times in this file: ${hit.pathStr}`, - fix: 'Assign to a variable / ENV once near the top of the script / Dockerfile stage, with a comment naming the canonical paths.mts. Reference the variable everywhere downstream. References of a single construction are unlimited; reconstructing the same path is the violation.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule F: cross-file path repetition -// ────────────────────────────────────────────────────────────────── - -const checkRuleF = (): void => { - // A path is "constructed" each time we see a new path.join with a - // matching shape. Group findings of Rule A by their snippet shape; - // when the same shape appears in 2+ files, demote them to Rule F so - // the message is more accurate. - const byShape = new Map<string, Finding[]>() - for (const f of findings) { - if (f.rule !== 'A') { - continue - } - // Normalize: strip whitespace, identifiers, surrounding context; - // keep just the literal path-segment shape. - const literalsRe = /'[^']*'|"[^"]*"/g - const literals = (f.snippet.match(literalsRe) ?? []).join(',') - if (!literals) { - continue - } - const list = byShape.get(literals) ?? [] - list.push(f) - byShape.set(literals, list) - } - for (const [shape, list] of byShape) { - if (list.length < 2) { - continue - } - // Promote each Rule-A finding in this group to Rule F so the - // message tells the reader the issue is cross-file repetition, - // not just a single hand-build. - for (const f of list) { - f.rule = 'F' - f.message = `Same path shape constructed in ${list.length} places: ${shape.slice(0, 100)}` - f.fix = - 'Construct this path ONCE in a paths.mts (or build-infra helper) and import the computed value. References of the computed variable are unlimited; re-constructing the same shape twice is the violation.' - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Main -// ────────────────────────────────────────────────────────────────── - -const main = (): number => { - // Scan code files (Rule A + B). - for (const rel of walk( - REPO_ROOT, - p => p.endsWith('.mts') || p.endsWith('.cts'), - )) { - if (isExempt(rel)) { - continue - } - scanCodeFile(rel) - } - // Scan workflows (Rule C + D). - const workflowDir = path.join(REPO_ROOT, '.github', 'workflows') - if (existsSync(workflowDir)) { - for (const rel of walk(workflowDir, p => p.endsWith('.yml'))) { - if (isExempt(rel)) { - continue - } - scanWorkflowFile(rel) - } - } - // Scan scripts/Makefiles/Dockerfiles (Rule G). - for (const rel of walk(REPO_ROOT, p => { - const base = path.basename(p) - return ( - base === 'Makefile' || - base.endsWith('.mk') || - base.endsWith('.Dockerfile') || - base === 'Dockerfile' || - base.endsWith('.glibc') || - base.endsWith('.musl') || - (base.endsWith('.sh') && !p.includes('test/')) - ) - })) { - if (isExempt(rel)) { - continue - } - scanScriptFile(rel) - } - // Promote cross-file Rule-A repeats to Rule F. - checkRuleF() - - // Filter against allowlist. - const blocking = findings.filter(f => !isAllowlisted(f)) - - if (args.values.json) { - process.stdout.write( - JSON.stringify( - { findings: blocking, allowlisted: findings.length - blocking.length }, - null, - 2, - ) + '\n', - ) - return blocking.length === 0 ? 0 : 1 - } - - if (blocking.length === 0) { - if (!args.values.quiet) { - logger.success('Path-hygiene check passed (1 path, 1 reference)') - if (findings.length > 0) { - logger.substep(`${findings.length} finding(s) allowlisted`) - } - } - return 0 - } - - logger.error(`Path-hygiene check FAILED — ${blocking.length} finding(s)`) - logger.log('') - logger.log('Mantra: 1 path, 1 reference') - logger.log('') - for (const f of blocking) { - logger.log(` [${f.rule}] ${f.file}:${f.line}`) - logger.log(` ${f.snippet}`) - logger.log(` → ${f.message}`) - if (args.values['show-hashes']) { - logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) - } - if (args.values.explain) { - logger.log(` Fix: ${f.fix}`) - } - logger.log('') - } - if (!args.values.explain) { - logger.log('Run with --explain to see fix suggestions per finding.') - logger.log( - 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', - ) - logger.log( - 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', - ) - } - return 1 -} - -try { - process.exitCode = main() -} catch (e) { - logger.error(`Path-hygiene gate crashed: ${e}`) - process.exitCode = 2 -} diff --git a/.claude/skills/guarding-paths/reference/claude-md-rule.md b/.claude/skills/guarding-paths/reference/claude-md-rule.md deleted file mode 100644 index 3cfe2ba7a..000000000 --- a/.claude/skills/guarding-paths/reference/claude-md-rule.md +++ /dev/null @@ -1,29 +0,0 @@ -<!-- -This file is the rule snippet that goes into every Socket repo's CLAUDE.md -(or the equivalent canonical instructions file). It mirrors -.claude/skills/_shared/path-guard-rule.md byte-for-byte; keep them in sync. ---> - -## 1 path, 1 reference - -**A path is _constructed_ exactly once. Everywhere else _references_ the constructed value.** - -Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is _re-constructing_ the same path in multiple places, because that's where drift is born. - -Three concrete shapes: - -1. **Within a package** — every script, test, and lib file that needs a build path imports it from the package's `scripts/paths.mts` (or `lib/paths.mts`). No `path.join('build', mode, ...)` outside that module. - -2. **Across packages** — when package B consumes package A's output, B imports A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '<sibling>', 'build', ...)`. The R28 yoga/ink bug — ink hand-building yoga's wasm path and missing the `wasm/` segment — is the canonical failure mode this rule prevents. - -3. **Workflows, Dockerfiles, shell scripts** — they can't `import` TS, so they construct the string once and reference it everywhere downstream. Workflows: a "Compute paths" step exposes `steps.paths.outputs.final_dir`; later steps read `${{ steps.paths.outputs.final_dir }}`. Dockerfiles/shell: assign once to a variable / `ENV`, reference by name thereafter. Each canonical construction carries a comment naming the source-of-truth `paths.mts`. **Re-building** the same path in a second step is the violation, not referring to the constructed value many times. - -Comments may describe path _structure_ with placeholders ("`<mode>/<arch>`" or "`${BUILD_MODE}/${PLATFORM_ARCH}`") but should not encode a complete literal path string. Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, Dockerfiles, workflow YAML, and shell scripts are blocking. README and doc-comment violations are advisory unless they contain a fully-qualified path with no parametric placeholders. - -### Three-level enforcement - -- **Hook** — `.claude/hooks/path-guard/` blocks `Edit`/`Write` calls that would introduce a violation in a `.mts`/`.cts` file. Refusal at edit time stops new duplication from landing. -- **Gate** — `scripts/check-paths.mts` runs in `pnpm check`. Fails the build on any violation that isn't allowlisted in `.github/paths-allowlist.yml`. -- **Skill** — `/guarding-paths` audits the repo and fixes findings; `/guarding-paths check` reports only; `/guarding-paths install` drops the gate + hook + rule into a fresh repo. - -The mantra is intentionally short so it sticks: **1 path, 1 reference**. When in doubt, find the canonical owner and import from it. diff --git a/.claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl b/.claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl deleted file mode 100644 index e2746660c..000000000 --- a/.claude/skills/guarding-paths/reference/paths-allowlist.yml.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -# Path-hygiene gate allowlist. -# Mantra: 1 path, 1 reference. -# -# Each entry exempts a specific finding from `scripts/check-paths.mts`. -# Entries MUST carry a `reason` so the list stays audit-able and -# entries can be removed when the underlying code changes. -# -# Schema (all top-level keys optional except `reason`): -# -# - rule: Rule letter (A, B, C, D, F, G). Omit to match any rule. -# file: Substring match against the relative file path. -# pattern: Substring match against the offending snippet. -# line: Line number; matches if within ±2 of the finding. -# reason: Why this site is genuinely exempt. Required. -# -# Prefer narrow entries (rule + file + line + pattern) over blanket -# `file:` entries that exempt the whole file. Genuine exemptions are -# rare — most "false positives" should be reported as gate bugs. -# -# Example: -# -# - rule: A -# file: packages/foo/scripts/legacy-build.mts -# line: 42 -# pattern: "path.join(testDir, 'out', 'Final')" -# reason: | -# legacy-build.mts is scheduled for removal in v2.0; refactoring -# its path construction now would conflict with the rewrite. diff --git a/.claude/skills/guarding-paths/templates/check-paths.mts.tmpl b/.claude/skills/guarding-paths/templates/check-paths.mts.tmpl deleted file mode 100644 index cbecc71e5..000000000 --- a/.claude/skills/guarding-paths/templates/check-paths.mts.tmpl +++ /dev/null @@ -1,947 +0,0 @@ -#!/usr/bin/env node -/** - * @fileoverview Path-hygiene gate. - * - * Mantra: 1 path, 1 reference. A path is constructed exactly once; - * everywhere else references the constructed value. - * - * Whole-repo scan complementing the per-edit `.claude/hooks/path-guard` - * hook. The hook stops new violations from landing; this gate finds - * the existing ones and blocks merges that introduce more. - * - * Rules enforced: - * - * A — Multi-stage path constructed inline. A `path.join(...)` call - * (or template literal) in a `.mts`/`.cts` file outside a - * `paths.mts` that stitches together two or more "stage" - * segments (Final, Release, Stripped, Compressed, Optimized, - * Synced, wasm, downloaded), or one stage plus a build-root - * (`build`/`out`) plus a mode (`dev`/`prod`/`shared`). The - * construction belongs in the package's `paths.mts` (or a - * build-infra helper); every consumer imports the computed - * value. - * - * B — Cross-package path traversal. A `path.join(*, '..', '<sibling - * package>', 'build', ...)` reaches into a sibling's build - * output without going through its `exports`. The sibling owns - * its layout; consumers declare a workspace dep and import the - * sibling's `paths.mts`. - * - * C — Hand-built workflow path. A `.github/workflows/*.yml` step - * constructs `build/${...}/out/<stage>/...` inline outside a - * canonical "Compute paths" step. Workflows can carry path - * strings, but the strings are constructed once and exposed via - * step outputs / job env that downstream steps reference. - * - * D — Comment-encoded paths. Comments (in code or YAML) that re-state - * a fully-qualified multi-stage path. Comments may describe the - * structure ("Final dir" or "build/<mode>/...") but should not - * encode a complete path string that a tool would parse — the - * canonical construction IS the documentation. - * - * F — Same path constructed in multiple places. The same shape of - * multi-stage `path.join(...)` (or workflow `build/${...}/...` - * string template) appearing in two or more files. Construct - * once and import; references of the constructed value are - * unlimited. - * - * G — Hand-built paths in Makefiles, Dockerfiles, and shell scripts. - * Same shape as A, applied to executable artifacts that don't - * run TypeScript. Each canonical construction must carry a - * comment naming the source-of-truth `paths.mts` so the script - * can't drift from TS without a flagged change. - * - * Allowlist: `.github/paths-allowlist.yml`. Each entry needs a - * `reason` so the list stays audit-able. Patterns are deliberately - * narrow — entries should be specific, not blanket. - * - * Usage: - * node scripts/check-paths.mts # default: report + fail - * node scripts/check-paths.mts --explain # long-form explanation - * node scripts/check-paths.mts --json # machine-readable - * node scripts/check-paths.mts --quiet # silent on clean - * - * Exit codes: - * 0 — clean (no findings, or every finding is allowlisted) - * 1 — findings present - * 2 — gate itself crashed - */ - -import { createHash } from 'node:crypto' -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { fileURLToPath } from 'node:url' - -import { parseArgs } from 'node:util' - -import { - BUILD_ROOT_SEGMENTS, - KNOWN_SIBLING_PACKAGES, - MODE_SEGMENTS, - STAGE_SEGMENTS, -} from '../.claude/hooks/path-guard/segments.mts' - -// Plain stderr/stdout output — no @socketsecurity/lib dependency so -// the gate is self-contained and works in socket-lib itself (which -// would otherwise import itself). -const logger = { - log: (msg: string) => process.stdout.write(msg + '\n'), - error: (msg: string) => process.stderr.write(msg + '\n'), - step: (msg: string) => process.stdout.write(`→ ${msg}\n`), - success: (msg: string) => process.stdout.write(`✔ ${msg}\n`), - substep: (msg: string) => process.stdout.write(` ${msg}\n`), -} - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const REPO_ROOT = path.resolve(__dirname, '..') - -// Stage / build-root / mode / sibling-package vocabularies are imported -// from `.claude/hooks/path-guard/segments.mts` (the canonical source). -// Both this gate and the path-guard hook share that single definition -// — Mantra: 1 path, 1 reference. - -// File-path patterns that legitimately enumerate path segments. -const EXEMPT_FILE_PATTERNS: RegExp[] = [ - // Any paths.mts is the canonical constructor. - /(^|\/)paths\.(mts|cts|js)$/, - // Build-infra owns shared helpers that enumerate stages. - /packages\/build-infra\/lib\/paths\.mts$/, - /packages\/build-infra\/lib\/constants\.mts$/, - // Path-scanning gates that intentionally enumerate. - /scripts\/check-paths\.mts$/, - /scripts\/check-consistency\.mts$/, - /\.claude\/hooks\/path-guard\//, - // Allowlist + config files. - /\.github\/paths-allowlist\.yml$/, -] - -type Finding = { - rule: 'A' | 'B' | 'C' | 'D' | 'F' | 'G' - file: string - line: number - snippet: string - message: string - fix: string -} - -const findings: Finding[] = [] - -const args = parseArgs({ - options: { - explain: { type: 'boolean', default: false }, - json: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - 'show-hashes': { type: 'boolean', default: false }, - }, - strict: false, -}) - -const isExempt = (filePath: string): boolean => - EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) - -// ────────────────────────────────────────────────────────────────── -// Allowlist loading -// ────────────────────────────────────────────────────────────────── - -type AllowlistEntry = { - file?: string - pattern?: string - rule?: string - line?: number - snippet_hash?: string - reason: string -} - -const loadAllowlist = (): AllowlistEntry[] => { - const allowlistPath = path.join(REPO_ROOT, '.github', 'paths-allowlist.yml') - if (!existsSync(allowlistPath)) { - return [] - } - const text = readFileSync(allowlistPath, 'utf8') - // Tiny YAML parser — only the shape we need: list of entries with - // `file`, `pattern`, `rule`, `line`, `reason` scalar fields, plus - // YAML 1.2 block-scalar indicators `|` (literal) and `>` (folded) - // for multi-line reasons. Avoids a yaml dep for a gate that has to - // be self-contained. - const entries: AllowlistEntry[] = [] - let current: Partial<AllowlistEntry> | null = null - // When set, subsequent more-indented lines fold into this key as a - // block scalar (literal '|' keeps newlines, folded '>' joins with - // spaces). - let blockKey: string | null = null - let blockKind: '|' | '>' | null = null - let blockIndent = 0 - let blockLines: string[] = [] - const flushBlock = () => { - if (current && blockKey) { - const value = - blockKind === '>' - ? blockLines.join(' ').replace(/\s+/g, ' ').trim() - : blockLines.join('\n').replace(/\n+$/, '') - ;(current as any)[blockKey] = value - } - blockKey = null - blockKind = null - blockLines = [] - } - const indentOf = (line: string): number => { - let i = 0 - while (i < line.length && line[i] === ' ') { - i += 1 - } - return i - } - const lines = text.split('\n') - for (let i = 0; i < lines.length; i++) { - const raw = lines[i]! - const line = raw.replace(/\r$/, '') - // Block-scalar accumulation takes precedence over normal parsing. - if (blockKey !== null) { - if (line.trim() === '') { - // Preserve blank lines inside a literal block; folded blocks - // turn them into paragraph breaks (kept as separate joins). - blockLines.push('') - continue - } - const indent = indentOf(line) - if (indent >= blockIndent) { - blockLines.push(line.slice(blockIndent)) - continue - } - flushBlock() - // Fall through and re-process the dedented line as normal. - } - if (!line.trim() || line.trim().startsWith('#')) { - continue - } - const tryAssign = (key: string, value: string) => { - const trimmed = value.trim() - if (current === null) { - return - } - if (trimmed === '|' || trimmed === '>') { - blockKey = key - blockKind = trimmed as '|' | '>' - blockIndent = indentOf(lines[i + 1] ?? '') || indentOf(line) + 2 - blockLines = [] - return - } - ;(current as any)[key] = - key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) - } - if (line.startsWith('- ')) { - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - current = {} - const rest = line.slice(2).trim() - if (rest) { - const m = rest.match(/^([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } else if (current) { - const m = line.match(/^\s+([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } - if (blockKey !== null) { - flushBlock() - } - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - return entries -} - -const unquote = (s: string): string => { - const t = s.trim() - if ( - (t.startsWith('"') && t.endsWith('"')) || - (t.startsWith("'") && t.endsWith("'")) - ) { - return t.slice(1, -1) - } - return t -} - -const ALLOWLIST = loadAllowlist() - -/** - * Stable, normalized snippet hash. Whitespace-insensitive so trivial - * reformatting (indent change, trailing comma, line wrap) doesn't - * invalidate an allowlist entry, but content-changing edits do. The - * hash exposes only the first 12 hex chars (~48 bits) which is plenty - * for collision-resistance within a single repo's finding set and - * keeps the YAML readable. - */ -const snippetHash = (snippet: string): string => { - const normalized = snippet.replace(/\s+/g, ' ').trim() - return createHash('sha256').update(normalized).digest('hex').slice(0, 12) -} - -/** - * Allowlist matching trades off two failure modes: - * - * - Drift via reformatting (a line shift breaks an entry, the - * finding re-surfaces, devs paper over with a new entry). - * - Stealth allowlisting (an entry pinned to "anywhere in this file" - * silently exempts unrelated future violations). - * - * Strategy: exact line match OR `snippet_hash` match (whitespace- - * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay - * exact (was ±2; the slack let reformatting silently slide), and - * `snippet_hash` provides reformatting-tolerant matching that's still - * tied to the literal text — paste-and-edit cheating would change the - * hash. If neither `line` nor `snippet_hash` is provided, the entry - * matches purely by `rule` + `file` + `pattern` (file-level exempt; - * use sparingly and always pair with a precise `pattern`). - */ -const isAllowlisted = (finding: Finding): boolean => - ALLOWLIST.some(entry => { - if (entry.rule && entry.rule !== finding.rule) { - return false - } - if (entry.file && !finding.file.includes(entry.file)) { - return false - } - if (entry.pattern && !finding.snippet.includes(entry.pattern)) { - return false - } - const lineProvided = entry.line !== undefined - const hashProvided = - typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 - if (lineProvided || hashProvided) { - const lineMatches = lineProvided && entry.line === finding.line - const hashMatches = - hashProvided && entry.snippet_hash === snippetHash(finding.snippet) - if (!(lineMatches || hashMatches)) { - return false - } - } - return true - }) - -// ────────────────────────────────────────────────────────────────── -// File walking -// ────────────────────────────────────────────────────────────────── - -const SKIP_DIRS = new Set([ - '.git', - 'node_modules', - 'build', - 'dist', - 'out', - 'target', - '.cache', - 'upstream', -]) - -const walk = function* ( - dir: string, - filter: (relPath: string) => boolean, -): Generator<string> { - let entries - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - return - } - for (const e of entries) { - if (SKIP_DIRS.has(e.name)) { - continue - } - const full = path.join(dir, e.name) - const rel = path.relative(REPO_ROOT, full) - if (e.isDirectory()) { - yield* walk(full, filter) - } else if (e.isFile() && filter(rel)) { - yield rel - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule A + B: code scan (.mts / .cts) -// ────────────────────────────────────────────────────────────────── - -// Locate `path.join(` or `path.resolve(` call sites; argument-list -// extraction uses a paren-balancing scanner below to handle arbitrary -// nesting depth (the previous regex-only approach silently missed any -// argument containing 2+ levels of nested function calls). -const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g -const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g - -// Template literal scanner. Captures backtick-delimited strings -// (including those with `${...}` placeholders) so Rule A also catches -// path construction via template literals like -// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. -const TEMPLATE_LITERAL_RE = - /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g - -/** - * Convert a template-literal body into a synthetic forward-slash path - * by replacing `${...}` placeholders with a sentinel and normalizing - * separators. Returns the sequence of path segments split on `/`. The - * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a - * placeholder-only segment (`${binaryName}`) won't match those sets. - */ -const templateLiteralSegments = (body: string): string[] => { - // Strip placeholders so they don't introduce noise in segments. - // Empty result for a placeholder is fine; downstream filters by set - // membership and skips empties. - const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') - return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') -} - -/** - * Extract every `path.join(...)` and `path.resolve(...)` call from the - * source text, returning each call's literal start offset and argument - * substring. Uses paren-balancing so deeply-nested arguments like - * `path.join(getDir(child(x)), 'build', 'Final')` are captured fully. - */ -const extractPathCalls = ( - source: string, -): Array<{ offset: number; args: string }> => { - const calls: Array<{ offset: number; args: string }> = [] - PATH_CALL_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = PATH_CALL_RE.exec(source)) !== null) { - const callStart = match.index - const argsStart = PATH_CALL_RE.lastIndex - let depth = 1 - let i = argsStart - let inString: '"' | "'" | '`' | null = null - while (i < source.length && depth > 0) { - const ch = source[i]! - if (inString) { - if (ch === '\\') { - i += 2 - continue - } - if (ch === inString) { - inString = null - } - } else { - if (ch === '"' || ch === "'" || ch === '`') { - inString = ch - } else if (ch === '(') { - depth += 1 - } else if (ch === ')') { - depth -= 1 - if (depth === 0) { - break - } - } - } - i += 1 - } - if (depth === 0) { - calls.push({ offset: callStart, args: source.slice(argsStart, i) }) - PATH_CALL_RE.lastIndex = i + 1 - } - } - return calls -} - -const extractStringLiterals = (args: string): string[] => { - const literals: string[] = [] - let match: RegExpExecArray | null - STRING_LITERAL_RE.lastIndex = 0 - while ((match = STRING_LITERAL_RE.exec(args)) !== null) { - if (match[2] !== undefined) { - literals.push(match[2]) - } - } - return literals -} - -const scanCodeFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - // Build a line-offset map so we can map regex offsets back to line - // numbers cheaply. - const lineOffsets: number[] = [0] - for (let i = 0; i < content.length; i++) { - if (content[i] === '\n') { - lineOffsets.push(i + 1) - } - } - const offsetToLine = (offset: number): number => { - let lo = 0 - let hi = lineOffsets.length - 1 - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1 - if (lineOffsets[mid]! <= offset) { - lo = mid - } else { - hi = mid - 1 - } - } - return lo + 1 - } - - for (const call of extractPathCalls(content)) { - const literals = extractStringLiterals(call.args) - const stages = literals.filter(l => STAGE_SEGMENTS.has(l)) - const buildRoots = literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) - const modes = literals.filter(l => MODE_SEGMENTS.has(l)) - - // Rule A: 2+ stages OR (1 stage + 1 build-root + 1 mode). - const triggersA = - stages.length >= 2 || - (stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'A', - file: relPath, - line, - snippet, - message: 'Multi-stage path constructed inline (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - - // Rule B: each '..' opens a window; the window stays open only - // until the next non-'..' literal. A sibling-package literal - // *immediately after* a '..' (no path segment between them) - // triggers, AND there must be build context elsewhere in the - // call. Resetting per-segment prevents false positives where '..' - // appears earlier and sibling-name appears much later in an - // unrelated position. - const hasBuildContext = literals.some( - l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), - ) - if (hasBuildContext) { - for (let i = 0; i < literals.length - 1; i++) { - if ( - literals[i] === '..' && - KNOWN_SIBLING_PACKAGES.has(literals[i + 1]!) - ) { - const sibling = literals[i + 1]! - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'B', - file: relPath, - line, - snippet, - message: `Cross-package traversal into '${sibling}' build output.`, - fix: `Add '${sibling}: workspace:*' as a dep, declare an exports entry on '${sibling}' (e.g. './scripts/paths' → './scripts/paths.mts'), and import the path from there.`, - }) - break - } - } - } - } - - // Rule A (template literal variant). Backtick strings like - // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` - // construct paths the same way `path.join(...)` does — flag the - // same shapes. Skip raw imports / template tag positions by - // filtering out leading `import.meta.url`-style / tag positions - // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and - // we rely on segment composition to decide if it's a path. - TEMPLATE_LITERAL_RE.lastIndex = 0 - let tmpl: RegExpExecArray | null - while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { - const body = tmpl[1] ?? '' - if (!body.includes('/')) { - continue - } - const segments = templateLiteralSegments(body) - const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) - const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) - const modes = segments.filter(s => MODE_SEGMENTS.has(s)) - // Template literal trigger is tighter than path.join() because - // backtick strings often appear in patch fixtures, error messages, - // and other multi-line content that incidentally contains stage - // tokens like `wasm`. Require the canonical build-output shape: - // - 'build' + 'out' + stage (canonical multi-stage layout), OR - // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR - // - 'build' + stage + literal mode (back-compat with path.join). - const hasBuildAndOut = - buildRoots.includes('build') && buildRoots.includes('out') - const hasOut = buildRoots.includes('out') - const hasBuild = buildRoots.includes('build') - const triggersA = - (hasBuildAndOut && stages.length >= 1) || - (stages.length >= 2 && hasOut) || - (hasBuild && stages.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(tmpl.index) - const snippet = (lines[line - 1] ?? '').trim() - findings.push({ - rule: 'A', - file: relPath, - line, - snippet, - message: - 'Multi-stage path constructed inline via template literal (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule C + D: workflow YAML scan -// ────────────────────────────────────────────────────────────────── - -const WORKFLOW_PATH_RE = - /build\/\$\{[^}]+\}\/[^"'`\s]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g -const WORKFLOW_GH_EXPR_PATH_RE = - /build\/\$\{\{\s*[^}]+\}\}\/[^"'`\s]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g - -const isInsideComputePathsBlock = ( - lines: string[], - lineIdx: number, -): boolean => { - // Walk backwards up to 60 lines looking for the start of the - // current step. If that step is a "Compute paths" step, the line - // is exempt. - for (let i = lineIdx; i >= Math.max(0, lineIdx - 60); i--) { - const l = lines[i] ?? '' - if (/^\s*-\s*name:/i.test(l)) { - // Step boundary — check if THIS step is a Compute paths step. - // The step body may include `id: paths` even if the name is - // something else (e.g. `id: stub-paths`), so look at the next - // ~20 lines for either marker. - for (let j = i; j < Math.min(lines.length, i + 20); j++) { - const m = lines[j] ?? '' - if ( - /^\s*-\s*name:\s*Compute\s+[\w-]+\s+paths/i.test(m) || - /^\s*id:\s*[\w-]*paths\s*$/i.test(m) - ) { - return true - } - if (j > i && /^\s*-\s*name:/i.test(m)) { - // Hit the next step — current step is NOT Compute paths. - return false - } - } - return false - } - } - return false -} - -const scanWorkflowFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - - // First pass: collect every hand-built path occurrence outside a - // "Compute paths" step. Per the mantra, a single reference is fine - // — what's banned is reconstructing the same path 2+ times. - type PathHit = { - line: number - snippet: string - pathStr: string - } - const occurrences = new Map<string, PathHit[]>() - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comment lines from C scan; they're under D below. - continue - } - if (isInsideComputePathsBlock(lines, i)) { - // Inside the canonical construction step — exempt. - continue - } - WORKFLOW_PATH_RE.lastIndex = 0 - WORKFLOW_GH_EXPR_PATH_RE.lastIndex = 0 - const matches: string[] = [] - let m: RegExpExecArray | null - while ((m = WORKFLOW_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - while ((m = WORKFLOW_GH_EXPR_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - for (const pathStr of matches) { - const list = occurrences.get(pathStr) ?? [] - list.push({ line: i + 1, snippet: line.trim(), pathStr }) - occurrences.set(pathStr, list) - } - } - - // Flag every occurrence of a shape that appears 2+ times. - for (const [pathStr, hits] of occurrences) { - if (hits.length < 2) { - continue - } - for (const hit of hits) { - findings.push({ - rule: 'C', - file: relPath, - line: hit.line, - snippet: hit.snippet, - message: `Workflow constructs the same path ${hits.length} times: ${pathStr}`, - fix: 'Add a "Compute <pkg> paths" step (id: paths) early in the job that computes this path ONCE and exposes it via $GITHUB_OUTPUT. Reference as ${{ steps.paths.outputs.<name> }} in subsequent steps. References of the constructed value are unlimited; reconstructing is the violation.', - }) - } - } - - // Rule D: comments encoding a fully-qualified multi-stage path - // (separate scan since it has different semantics). - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (!/^\s*#/.test(line)) { - continue - } - const literalShape = - /build\/(?:dev|prod|shared)\/[a-z0-9-]+\/(?:wasm\/)?out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/i - if (literalShape.test(line)) { - findings.push({ - rule: 'D', - file: relPath, - line: i + 1, - snippet: line.trim(), - message: 'Comment encodes a fully-qualified path string.', - fix: 'Cite the canonical paths.mts (e.g. "see packages/<pkg>/scripts/paths.mts:getBuildPaths()") instead of duplicating the path string. Comments may describe structure with placeholders ("<mode>/<arch>") but should not be a parsable path.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule G: Makefile / Dockerfile / shell scan -// ────────────────────────────────────────────────────────────────── - -const SCRIPT_HAND_BUILT_RE = - /build\/\$?\{?(?:BUILD_MODE|MODE|prod|dev)\}?\/[\w${}.-]*\/out\/(?:Final|Release|Stripped|Compressed|Optimized|Synced)/g - -const scanScriptFile = (relPath: string): void => { - const full = path.join(REPO_ROOT, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - const isDockerfile = - /Dockerfile/i.test(relPath) || /\.glibc$|\.musl$/.test(relPath) - - // First pass: collect every multi-stage path occurrence in this file, - // scoped per Dockerfile stage (each `FROM ... AS ...` starts a new - // scope where ENV/ARG don't propagate). - type Hit = { line: number; text: string; pathStr: string; stage: number } - const hits: Hit[] = [] - let stage = 0 - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comments — documentation, not construction. - continue - } - if (isDockerfile && /^FROM\s+/i.test(line)) { - stage += 1 - continue - } - SCRIPT_HAND_BUILT_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = SCRIPT_HAND_BUILT_RE.exec(line)) !== null) { - hits.push({ - line: i + 1, - text: line.trim(), - pathStr: m[0], - stage, - }) - } - } - - // Group by (stage, pathStr) — only flag when a path is built 2+ - // times within the SAME Dockerfile stage (or anywhere in non- - // Dockerfile scripts, where stages don't apply). - const grouped = new Map<string, Hit[]>() - for (const h of hits) { - const key = `${h.stage}::${h.pathStr}` - const list = grouped.get(key) ?? [] - list.push(h) - grouped.set(key, list) - } - for (const [, list] of grouped) { - if (list.length < 2) { - continue - } - for (const hit of list) { - findings.push({ - rule: 'G', - file: relPath, - line: hit.line, - snippet: hit.text, - message: `Hand-built multi-stage path constructed ${list.length} times in this file: ${hit.pathStr}`, - fix: 'Assign to a variable / ENV once near the top of the script / Dockerfile stage, with a comment naming the canonical paths.mts. Reference the variable everywhere downstream. References of a single construction are unlimited; reconstructing the same path is the violation.', - }) - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Rule F: cross-file path repetition -// ────────────────────────────────────────────────────────────────── - -const checkRuleF = (): void => { - // A path is "constructed" each time we see a new path.join with a - // matching shape. Group findings of Rule A by their snippet shape; - // when the same shape appears in 2+ files, demote them to Rule F so - // the message is more accurate. - const byShape = new Map<string, Finding[]>() - for (const f of findings) { - if (f.rule !== 'A') { - continue - } - // Normalize: strip whitespace, identifiers, surrounding context; - // keep just the literal path-segment shape. - const literalsRe = /'[^']*'|"[^"]*"/g - const literals = (f.snippet.match(literalsRe) ?? []).join(',') - if (!literals) { - continue - } - const list = byShape.get(literals) ?? [] - list.push(f) - byShape.set(literals, list) - } - for (const [shape, list] of byShape) { - if (list.length < 2) { - continue - } - // Promote each Rule-A finding in this group to Rule F so the - // message tells the reader the issue is cross-file repetition, - // not just a single hand-build. - for (const f of list) { - f.rule = 'F' - f.message = `Same path shape constructed in ${list.length} places: ${shape.slice(0, 100)}` - f.fix = - 'Construct this path ONCE in a paths.mts (or build-infra helper) and import the computed value. References of the computed variable are unlimited; re-constructing the same shape twice is the violation.' - } - } -} - -// ────────────────────────────────────────────────────────────────── -// Main -// ────────────────────────────────────────────────────────────────── - -const main = (): number => { - // Scan code files (Rule A + B). - for (const rel of walk( - REPO_ROOT, - p => p.endsWith('.mts') || p.endsWith('.cts'), - )) { - if (isExempt(rel)) { - continue - } - scanCodeFile(rel) - } - // Scan workflows (Rule C + D). - const workflowDir = path.join(REPO_ROOT, '.github', 'workflows') - if (existsSync(workflowDir)) { - for (const rel of walk(workflowDir, p => p.endsWith('.yml'))) { - if (isExempt(rel)) { - continue - } - scanWorkflowFile(rel) - } - } - // Scan scripts/Makefiles/Dockerfiles (Rule G). - for (const rel of walk(REPO_ROOT, p => { - const base = path.basename(p) - return ( - base === 'Makefile' || - base.endsWith('.mk') || - base.endsWith('.Dockerfile') || - base === 'Dockerfile' || - base.endsWith('.glibc') || - base.endsWith('.musl') || - (base.endsWith('.sh') && !p.includes('test/')) - ) - })) { - if (isExempt(rel)) { - continue - } - scanScriptFile(rel) - } - // Promote cross-file Rule-A repeats to Rule F. - checkRuleF() - - // Filter against allowlist. - const blocking = findings.filter(f => !isAllowlisted(f)) - - if (args.values.json) { - process.stdout.write( - JSON.stringify( - { findings: blocking, allowlisted: findings.length - blocking.length }, - null, - 2, - ) + '\n', - ) - return blocking.length === 0 ? 0 : 1 - } - - if (blocking.length === 0) { - if (!args.values.quiet) { - logger.success('Path-hygiene check passed (1 path, 1 reference)') - if (findings.length > 0) { - logger.substep(`${findings.length} finding(s) allowlisted`) - } - } - return 0 - } - - logger.error(`Path-hygiene check FAILED — ${blocking.length} finding(s)`) - logger.log('') - logger.log('Mantra: 1 path, 1 reference') - logger.log('') - for (const f of blocking) { - logger.log(` [${f.rule}] ${f.file}:${f.line}`) - logger.log(` ${f.snippet}`) - logger.log(` → ${f.message}`) - if (args.values['show-hashes']) { - logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) - } - if (args.values.explain) { - logger.log(` Fix: ${f.fix}`) - } - logger.log('') - } - if (!args.values.explain) { - logger.log('Run with --explain to see fix suggestions per finding.') - logger.log( - 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', - ) - logger.log( - 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', - ) - } - return 1 -} - -try { - process.exitCode = main() -} catch (e) { - logger.error(`Path-hygiene gate crashed: ${e}`) - process.exitCode = 2 -} diff --git a/.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl b/.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl deleted file mode 100644 index e2746660c..000000000 --- a/.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -# Path-hygiene gate allowlist. -# Mantra: 1 path, 1 reference. -# -# Each entry exempts a specific finding from `scripts/check-paths.mts`. -# Entries MUST carry a `reason` so the list stays audit-able and -# entries can be removed when the underlying code changes. -# -# Schema (all top-level keys optional except `reason`): -# -# - rule: Rule letter (A, B, C, D, F, G). Omit to match any rule. -# file: Substring match against the relative file path. -# pattern: Substring match against the offending snippet. -# line: Line number; matches if within ±2 of the finding. -# reason: Why this site is genuinely exempt. Required. -# -# Prefer narrow entries (rule + file + line + pattern) over blanket -# `file:` entries that exempt the whole file. Genuine exemptions are -# rare — most "false positives" should be reported as gate bugs. -# -# Example: -# -# - rule: A -# file: packages/foo/scripts/legacy-build.mts -# line: 42 -# pattern: "path.join(testDir, 'out', 'Final')" -# reason: | -# legacy-build.mts is scheduled for removal in v2.0; refactoring -# its path construction now would conflict with the rewrite. diff --git a/.claude/skills/handing-off/SKILL.md b/.claude/skills/handing-off/SKILL.md deleted file mode 100644 index 17f78814c..000000000 --- a/.claude/skills/handing-off/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: handing-off -description: Compact the current conversation into a handoff doc so a fresh agent can pick up the work. Use when context is getting thin, a session is about to end, or the next stage of the work needs a different agent / human. -user-invocable: true -argument-hint: 'What will the next session focus on?' -allowed-tools: Bash(mkdir:*), Bash(date:*), Read, Write ---- - -# handing-off - -Write a handoff document so a fresh agent can continue the work without re-loading the entire conversation. - -## When to use - -- Context is approaching its limit and the work isn't done. -- The next stage requires a different agent (different model, different tools, different scope) or a human. -- Wrapping up a session at the end of the day with work in flight. -- The user invokes `/handing-off [focus]` explicitly. - -## How to write the doc - -1. **Summarize, don't duplicate.** Reference commits (`<sha> — <message>`), files (`path:line`), PRs, issues, ADRs, plans. The next agent can `git log`, `Read`, `gh` their way to detail. The doc carries the _why_ and _where things stand_, not the contents. -2. **Lead with state.** What's done, what's in flight, what's blocked, what's next. Use bullet lists, not paragraphs. -3. **Name suggested skills.** If the next session should reach for `reviewing-code`, `updating-lockstep`, etc., list them by name with a one-line "use when" so the next agent doesn't have to discover them. -4. **Tailor to the focus.** If the user passed an argument (`/handing-off SEA migration`), shape the doc around that scope; drop unrelated work into a "deferred" section. -5. **Stop at one screen.** A handoff doc that takes longer to read than the work it summarizes has failed at its job. - -## Where to save - -Use `.claude/reports/<YYYY-MM-DD>-<slug>-handoff.md`. The `.claude/reports/` directory is gitignored fleet-wide (per CLAUDE.md "Generated reports" rule), so the doc stays local — no risk of committing a stale handoff. Slug is short kebab-case from the focus (e.g. `rolldown-cascade`, `bugbot-cleanup`). - -```bash -mkdir -p .claude/reports -DATE=$(date +%Y-%m-%d) -PATH=".claude/reports/${DATE}-<slug>-handoff.md" -``` - -## What NOT to include - -- The full conversation (the next agent reads commits + diffs, not transcripts). -- Code listings that exist verbatim in source files (link instead). -- Decisions already captured in commit messages or ADRs (cite the SHA / file). -- A retrospective "what I learned" section unless it's load-bearing for the next agent's choices. - -## Why this exists - -Originally adopted from [`mattpocock/skills/handoff`](https://github.com/mattpocock/skills/blob/main/skills/in-progress/handoff/SKILL.md), adapted for fleet conventions (`.claude/reports/` instead of `mktemp`, gerund naming, fleet skill frontmatter). diff --git a/.claude/skills/locking-down-programmatic-claude/SKILL.md b/.claude/skills/locking-down-programmatic-claude/SKILL.md deleted file mode 100644 index 6cf825c74..000000000 --- a/.claude/skills/locking-down-programmatic-claude/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: locking-down-programmatic-claude -description: Reference for locking down programmatic Claude invocations (the `claude` CLI in workflows/scripts, the `@anthropic-ai/claude-agent-sdk` `query()` in code). Loads on demand when writing or reviewing any callsite that runs Claude programmatically. Source: https://code.claude.com/docs/en/agent-sdk/permissions. -user-invocable: false -allowed-tools: Read, Grep, Glob ---- - -# locking-down-programmatic-claude - -**Rule:** every programmatic Claude callsite sets four flags. Skip any one and a future edit silently widens the surface. - -## First: prefer the lib helper — don't hand-roll the flags - -🚨 For Node scripts / hooks, use **`spawnAiAgent` from `@socketsecurity/lib-stable/ai/spawn`** with a tier from the `AI_PROFILE` ladder in `@socketsecurity/lib-stable/ai/profiles`. It enforces the four flags at the type level (`SpawnAiAgentOptions` requires `tools` / `disallow` / `permissionMode`), translates them per-agent (claude / codex / gemini / opencode), and owns `--no-session-persistence`, `--add-dir`, and the 529-overload retry. Hand-rolling a `spawn('claude', [...flags])` is how the flag set drifts — and the `prefer-async-spawn` lint rule flags the raw spawn anyway. - -```ts -import { AI_PROFILE } from '@socketsecurity/lib-stable/ai/profiles' -import { spawnAiAgent } from '@socketsecurity/lib-stable/ai/spawn' - -const { exitCode, stdout } = await spawnAiAgent({ - ...AI_PROFILE.read, // or .edit / .create / .full - prompt: '…', - cwd: repoRoot, - timeoutMs: 10 * 60 * 1000, -}) -``` - -`AI_PROFILE` is a capability ladder, least → most capable — pick the narrowest tier that works: - -- `.read` — scan / classify. Read/Grep/Glob/WebFetch/WebSearch. No Edit/Write/Bash. -- `.edit` — in-place edits only. Read/Edit/Grep/Glob. No Write/MultiEdit/Bash (can't create files). -- `.create` — edit AND create files. Adds Write/MultiEdit. Still no Bash. -- `.full` — `.create` + Bash allowlisted to git/pnpm/node. - -Every tier also denies `Agent` (no sub-agent escape). Spread a tier and override per call (`tools`/`disallow` to tighten further, `model`, `addDirs`). The raw SDK/CLI recipes below are the underlying contract — reach for them only when you genuinely can't use the helper (e.g. a workflow-YAML `run:` step with no Node). - -## The four flags - -| Layer | SDK option | CLI flag | What it does | -| ------------ | --------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | -| Definition | `tools` | `--tools` | Base set the model is told about. Tools not listed are invisible. No `tool_use` block possible. | -| Auto-approve | `allowedTools` | `--allowedTools` | Step 4. Listed tools run without invoking `canUseTool`. | -| Deny | `disallowedTools` | `--disallowedTools` | Step 2. Wins even against `bypassPermissions`. Defense-in-depth. | -| Mode | `permissionMode: 'dontAsk'` | `--permission-mode dontAsk` | Step 3. Unmatched tools denied without falling through to a missing `canUseTool`. | - -The official permission flow (1) hooks → (2) deny rules → (3) permission mode → (4) allow rules → (5) `canUseTool`. In `dontAsk` mode step 5 is skipped (denied). The doc states verbatim: _"`allowedTools` and `disallowedTools` ... control whether a tool call is approved, not whether the tool is available."_ Availability is `tools`. - -## Recipe: read-only agent (audit, classify, summarize) - -```ts -import { query } from '@anthropic-ai/claude-agent-sdk' - -query({ - prompt: '...', - options: { - tools: ['Read', 'Grep', 'Glob'], - allowedTools: ['Read', 'Grep', 'Glob'], - disallowedTools: [ - 'Agent', - 'Bash', - 'Edit', - 'NotebookEdit', - 'Task', - 'WebFetch', - 'WebSearch', - 'Write', - ], - permissionMode: 'dontAsk', - }, -}) -``` - -CLI form for workflow YAML / shell scripts: - -```yaml -claude --print \ ---tools "Read" "Grep" "Glob" \ ---allowedTools "Read" "Grep" "Glob" \ ---disallowedTools "Agent" "Bash" "Edit" "NotebookEdit" "Task" "WebFetch" "WebSearch" "Write" \ ---permission-mode dontAsk \ ---model "$MODEL" \ ---max-turns 25 \ -"<prompt>" -``` - -## Recipe: agent that needs Bash (e.g. `/updating`: pnpm + git + jq) - -Narrow `Bash(...)` patterns surgically. Block dangerous Bash patterns explicitly. Fleet rules: no `npx`/`pnpm dlx`/`yarn dlx`; no `curl`/`wget` exfil; no destructive `rm -rf`; no `sudo`. Build the deny list as shell vars so the `npx`/`dlx` denials can carry the `# zizmor:` exemption marker (the pre-commit `scanNpxDlx` hook treats those literal strings as the prohibited tools, not as exemptions, unless the line is tagged): - -```yaml -DISALLOW_BASE='Agent Task NotebookEdit WebFetch WebSearch Bash(curl:*) Bash(wget:*) Bash(rm -rf*) Bash(sudo:*)' -DISALLOW_PKG_EXEC='Bash(npx:*) Bash(pnpm dlx:*) Bash(yarn dlx:*)' # zizmor: documentation-prohibition -claude --print \ - --tools "Bash" "Read" "Write" "Edit" "Glob" "Grep" \ - --allowedTools "Bash(pnpm:*)" "Bash(git:*)" "Bash(jq:*)" "Read" "Write" "Edit" "Glob" "Grep" \ - --disallowedTools $DISALLOW_BASE $DISALLOW_PKG_EXEC \ - --permission-mode dontAsk \ - --model "$MODEL" --max-turns 25 \ - "<prompt>" -``` - -## Never - -- ❌ `permissionMode: 'default'` in headless contexts; falls through to a missing `canUseTool`. Behavior undefined. -- ❌ `permissionMode: 'bypassPermissions'` / `allowDangerouslySkipPermissions: true`. -- ❌ Omitting `tools`; SDK default is the full claude_code preset. -- ❌ `Agent` / `Task` permitted; sub-agents inherit modes and can escape per-subagent restrictions when the parent is `bypassPermissions`/`acceptEdits`/`auto`. - -## Reference implementation - -`socket-lib/tools/prim/src/disambiguate.mts`: canonical SDK-form callsite. The file header documents each flag against the eval-flow step it enforces. - -`socket-lib/tools/prim/test/disambiguate.test.mts`: source-text guards that fail the build if `BASE_TOOLS` widens, if `tools: BASE_TOOLS` is unwired, if `permissionMode` drifts from `'dontAsk'`, or if `bypassPermissions` / `allowDangerouslySkipPermissions: true` ever appears. Mirror this pattern in any new callsite. - -## Existing fleet callsites - -- `socket-registry/.github/workflows/weekly-update.yml`: two `claude --print` invocations (run `/updating` skill, fix test failures). Bash recipe above. -- `socket-lib/tools/prim/src/disambiguate.mts`: read-only recipe above (`query()` SDK form). diff --git a/.claude/skills/plug-leaking-promise-race/SKILL.md b/.claude/skills/plug-leaking-promise-race/SKILL.md deleted file mode 100644 index 6f8c5f55c..000000000 --- a/.claude/skills/plug-leaking-promise-race/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: plug-leaking-promise-race -description: Reference for the `Promise.race` cross-iteration handler-leak bug. Loads on demand when writing or reviewing concurrency code that uses `Promise.race`, `Promise.any`, or hand-rolled concurrency limiters. -user-invocable: false -allowed-tools: Read, Grep, Glob ---- - -# plug-leaking-promise-race - -**Never re-race the same pool of promises across loop iterations.** Each call to `Promise.race([A, B, …])` attaches fresh `.then` handlers to every arm. A promise that survives N iterations accumulates N handler sets. See [nodejs/node#17469](https://github.com/nodejs/node/issues/17469) and [`@watchable/unpromise`](https://github.com/watchable/unpromise). - -## Patterns - -- **Safe** — both arms created per call: - - ```ts - const value = await Promise.race([ - fetchSomething(), - new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 5000)), - ]) - ``` - -- **Leaky** — `pool` survives across iterations, accumulating handlers: - - ```ts - while (queue.length) { - const winner = await Promise.race(pool) // ← N handlers per arm by iteration N - pool = pool.filter(p => p !== winner) - } - ``` - - Same hazard for `Promise.any` and any long-lived arm such as an interrupt signal. - -## The fix - -Use a single-waiter "slot available" signal. Each task's `.then` resolves a one-shot `promiseWithResolvers` that the loop awaits, then replaces. No persistent pool, nothing to stack. - -```ts -let signal = Promise.withResolvers<Task>() -function startTask(task: Task) { - task.run().then(() => { - const prev = signal - signal = Promise.withResolvers<Task>() - prev.resolve(task) - }) -} -while (queue.length) { - // launch up to N tasks - while (running < N && queue.length) startTask(queue.shift()!) - const finished = await signal.promise - running -= 1 -} -``` - -The arm being awaited is _always fresh_; nothing accumulates handlers. - -## Quick check - -Before merging concurrency code, ask: _does any arm of a `Promise.race`/`Promise.any` outlive the call?_ If yes, refactor to the single-waiter signal. diff --git a/.claude/skills/prose/SKILL.md b/.claude/skills/prose/SKILL.md deleted file mode 100644 index 8d740aebd..000000000 --- a/.claude/skills/prose/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: prose -description: Removes AI writing patterns from prose. Use when drafting, editing, or reviewing essays, blog posts, docs, release notes, commit message bodies, PR descriptions, CHANGELOG entries, README content, or any human-facing text that reads AI-generated: hedged, metronomic, padded with throat-clearing, or full of em-dashes, adverbs, and "not X, it's Y" contrasts. -user-invocable: true -allowed-tools: Read, Edit, Write, Grep ---- - -# prose - -Eliminate AI writing patterns from prose. - -Hardik Pandya wrote the upstream version (`stop-slop`). MIT-licensed. Source: https://github.com/hardikpandya/stop-slop. Core rules + references run verbatim. Edit only in `socket-wheelhouse/template/`; the cascade refreshes downstream copies. - -## Fleet surfaces - -Apply this skill when you write: - -- Commit message bodies (multi-paragraph). Subject lines stay terse and imperative per `commit-message-format-guard`. -- PR descriptions (`gh pr create --body`, `gh pr edit --body`). -- CHANGELOG entries. -- README sections. -- `docs/` markdown. -- GitHub Release notes. - -## When to skip this skill - -- Code, code comments, or structured data. -- JSON, YAML, TOML. -- `chore(wheelhouse): cascade template@<sha>` commits. sync-scaffolding generates them with a fixed shape. -- Bot output: Dependabot PRs, release auto-notes from PR titles. -- Transcripts and direct quotes (preserve voice verbatim). -- API reference prose where precision matters more than rhythm. - -## Instructions - -1. Apply the Core Rules to every paragraph, in order. -2. Run the Quick Checks on the full draft. -3. Score with the Scoring table; if it totals below 35/50, revise and re-score. -4. Stop when the draft reads like a person wrote it. Further edits risk over-polishing. - -If an edit changes meaning or loses the author's voice, revert it. Never rewrite a direct quote. - -## Core Rules - -1. **Cut filler phrases.** Remove throat-clearing openers, emphasis crutches, and all adverbs. See [references/phrases.md](references/phrases.md). - -2. **Break formulaic structures.** Avoid binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency. See [references/structures.md](references/structures.md). - -3. **Use active voice.** Every sentence needs a human subject doing something. No passive constructions. No inanimate objects performing human actions ("the complaint becomes a fix"). - -4. **Be specific.** No vague declaratives ("The reasons are structural"). Name the specific thing. No lazy extremes ("every," "always," "never") doing vague work. - -5. **Put the reader in the room.** No narrator-from-a-distance voice. "You" beats "People." Specifics beat abstractions. - -6. **Vary rhythm.** Mix sentence lengths. Two items beat three. End paragraphs differently. No em dashes. - -7. **Trust readers.** State facts directly. Skip softening, justification, hand-holding. - -8. **Cut quotables.** If it sounds like a pull-quote, rewrite it. - -## Quick Checks - -Before delivering prose: - -- Any adverbs? Kill them. -- Any passive voice? Find the actor, make them the subject. -- Inanimate thing doing a human verb ("the decision emerges")? Name the person. -- Sentence starts with a Wh- word? Restructure it. -- Any "here's what/this/that" throat-clearing? Cut to the point. -- Any "not X, it's Y" contrasts? State Y directly. -- Three consecutive sentences match length? Break one. -- Paragraph ends with punchy one-liner? Vary it. -- Em-dash anywhere? Remove it. -- Vague declarative ("The implications are significant")? Name the specific implication. -- Narrator-from-a-distance ("Nobody designed this")? Put the reader in the scene. -- Meta-joiners ("The rest of this essay...")? Delete. Let the essay move. - -## Scoring - -Rate 1-10 on each dimension: - -| Dimension | Question | -| ------------ | ----------------------------- | -| Directness | Statements or announcements? | -| Rhythm | Varied or metronomic? | -| Trust | Respects reader intelligence? | -| Authenticity | Sounds human? | -| Density | Anything cuttable? | - -Below 35/50: revise. - -## Example - -**Before:** - -``` -Here's the thing: building products is hard. Not because the -technology is complex. Because people are complex. Let that sink in. -``` - -**After:** - -``` -Building products is hard. Technology is manageable. People aren't. -``` - -Removed the opener, the binary contrast, and the emphasis crutch. Two direct statements, same meaning. - -See [references/examples.md](references/examples.md) for more. - -## Edge cases - -- **Direct quotes**: leave them alone; quoting a hedging speaker verbatim is not slop. -- **Technical prose where precision > rhythm**: API reference sentences can be metronomic; don't force variation that loses accuracy. -- **Lists and tables**: structural repetition is the point; don't "vary rhythm" inside a parameter list. -- **First-person personal voice**: `you`/`I` is fine; don't strip writer presence in the name of directness. diff --git a/.claude/skills/prose/references/examples.md b/.claude/skills/prose/references/examples.md deleted file mode 100644 index bc74d17e9..000000000 --- a/.claude/skills/prose/references/examples.md +++ /dev/null @@ -1,69 +0,0 @@ -# Before/After Examples - -## Example 1: Throat-Clearing + Binary Contrast - -**Before:** - -> "Here's the thing: building products is hard. Not because the technology is complex. Because people are complex. Let that sink in." - -**After:** - -> "Building products is hard. Technology is manageable. People aren't." - -**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Direct statements. - ---- - -## Example 2: Filler + Unnecessary Reassurance - -**Before:** - -> "It turns out that most teams struggle with alignment. The uncomfortable truth is that nobody wants to admit they're confused. And that's okay." - -**After:** - -> "Teams struggle with alignment. Nobody admits confusion." - -**Changes:** Cut hedging ("most"), removed throat-clearing phrases, deleted permission-granting ending. - ---- - -## Example 3: Business Jargon Stack - -**Before:** - -> "In today's fast-paced landscape, we need to lean into discomfort and navigate uncertainty with clarity. This matters because your competition isn't waiting." - -**After:** - -> "Move faster. Your competition is." - -**Changes:** Eliminated jargon entirely. Core message in six words. - ---- - -## Example 4: Dramatic Fragmentation - -**Before:** - -> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff." - -**After:** - -> "Speed, quality, cost—pick two." - -**Changes:** Single sentence. No performative emphasis. - ---- - -## Example 5: Rhetorical Setup - -**Before:** - -> "What if I told you that the best teams don't optimize for productivity? Here's what I mean: they optimize for learning. Think about it." - -**After:** - -> "The best teams optimize for learning, not productivity." - -**Changes:** Direct claim. No rhetorical scaffolding. diff --git a/.claude/skills/prose/references/phrases.md b/.claude/skills/prose/references/phrases.md deleted file mode 100644 index d081bf81a..000000000 --- a/.claude/skills/prose/references/phrases.md +++ /dev/null @@ -1,154 +0,0 @@ -# Phrases to Remove - -## Contents - -- Throat-Clearing Openers -- Emphasis Crutches -- Business Jargon -- Adverbs -- Meta-Commentary -- Performative Emphasis -- Telling Instead of Showing -- Vague Declaratives -- Email Pleasantries -- Letter Announcements - -## Throat-Clearing Openers - -Remove these announcement phrases. State the content directly. - -- "Here's the thing:" -- "Here's what [X]" -- "Here's this [X]" -- "Here's that [X]" -- "Here's why [X]" -- "The uncomfortable truth is" -- "It turns out" -- "The real [X] is" -- "Let me be clear" -- "The truth is," -- "I'll say it again:" -- "I'm going to be honest" -- "Can we talk about" -- "Here's what I find interesting" -- "Here's the problem though" - -Any "here's what/this/that" construction is throat-clearing before the point. Cut it and state the point. - -## Emphasis Crutches - -These add no meaning. Delete them. - -- "Full stop." / "Period." -- "Let that sink in." -- "This matters because" -- "Make no mistake" -- "Here's why that matters" - -## Business Jargon - -Replace with plain language. - -| Avoid | Use instead | -| --------------------- | ---------------------- | -| Navigate (challenges) | Handle, address | -| Unpack (analysis) | Explain, examine | -| Lean into | Accept, embrace | -| Landscape (context) | Situation, field | -| Game-changer | Significant, important | -| Double down | Commit, increase | -| Deep dive | Analysis, examination | -| Take a step back | Reconsider | -| Moving forward | Next, from now | -| Circle back | Return to, revisit | -| On the same page | Aligned, agreed | - -## Adverbs - -Kill all adverbs. No -ly words. No softeners, no intensifiers, no hedges. - -Specific offenders: - -- "really" -- "just" -- "literally" -- "genuinely" -- "honestly" -- "simply" -- "actually" -- "deeply" -- "truly" -- "fundamentally" -- "inherently" -- "inevitably" -- "interestingly" -- "importantly" -- "crucially" - -Also cut these filler phrases: - -- "At its core" -- "In today's [X]" -- "It's worth noting" -- "At the end of the day" -- "When it comes to" -- "In a world where" -- "The reality is" - -## Meta-Commentary - -Remove self-referential asides. The essay should move, not announce its own structure. - -- "Hint:" -- "Plot twist:" / "Spoiler:" -- "You already know this, but" -- "But that's another post" -- "X is a feature, not a bug" -- "Dressed up as" -- "The rest of this essay explains..." -- "Let me walk you through..." -- "In this section, we'll..." -- "As we'll see..." -- "I want to explore..." - -## Performative Emphasis - -False intimacy or manufactured sincerity: - -- "creeps in" -- "I promise" -- "They exist, I promise" - -## Telling Instead of Showing - -Announcing difficulty or significance rather than demonstrating it: - -- "This is genuinely hard" -- "This is what leadership actually looks like" -- "This is what X actually looks like" -- "actually matters" - -## Vague Declaratives - -Sentences that announce importance without naming the specific thing. Kill these. - -- "The reasons are structural" -- "The implications are significant" -- "This is the deepest problem" -- "The stakes are high" -- "The consequences are real" - -If a sentence says something is important/deep/structural without showing the specific thing, cut it or replace it with the specific thing. - -## Email Pleasantries - -- "I hope this email finds you well" -- "I hope you're doing well" -- "I hope all is well" - -## Letter Announcements - -- "I am writing this letter..." -- "I am writing to inform you..." -- "Writing this to inform you..." -- "I wanted to reach out..." diff --git a/.claude/skills/prose/references/structures.md b/.claude/skills/prose/references/structures.md deleted file mode 100644 index 53121b487..000000000 --- a/.claude/skills/prose/references/structures.md +++ /dev/null @@ -1,201 +0,0 @@ -# Structures to Avoid - -## Contents - -- Binary Contrasts -- Negative Listing -- Dramatic Fragmentation -- Rhetorical Setups -- Formulaic Constructions -- False Agency -- Narrator-from-a-Distance -- Passive Voice -- Sentence Starters to Avoid -- Rhythm Patterns -- Word Patterns -- Transformation Chains -- Before/After Framing -- Corrective Reveals -- Forced Cohesion - -## Binary Contrasts - -These create false drama. State the point directly. - -| Pattern | Problem | -| ------------------------------------------------------------- | ------------------------------ | -| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | -| "[X] isn't the problem. [Y] is." | Formulaic reframe | -| "The answer isn't X. It's Y." | Predictable pivot | -| "It feels like X. It's actually Y." | Setup/reveal cliche | -| "The question isn't X. It's Y." | Rhetorical misdirection | -| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | -| "It's not this. It's that." | Same formula, different words | -| "stops being X and starts being Y" | False transformation arc | -| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | -| "is about X but not Y" | False distinction | -| "not just X but also Y" | Additive hedge | - -**Instead:** State Y directly. "The problem is Y." "Y matters here." Drop the negation entirely. - -## Negative Listing - -Listing what something is _not_ before revealing what it _is_. A rhetorical striptease. - -| Pattern | Problem | -| ------------------------------------- | --------------------------------- | -| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | -| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | - -**Instead:** State Z. The reader doesn't need the runway. - -## Dramatic Fragmentation - -Sentence fragments for emphasis read as manufactured profundity. - -| Pattern | Problem | -| ---------------------------------------- | ----------------------- | -| "[Noun]. That's it. That's the [thing]." | Performative simplicity | -| "X. And Y. And Z." | Staccato drama | -| "This unlocks something. [Word]." | Artificial revelation | - -**Instead:** Complete sentences. Trust content over presentation. - -## Rhetorical Setups - -These announce insight rather than deliver it. - -| Pattern | Problem | -| --------------------- | ---------------------- | -| "What if [reframe]?" | Socratic posturing | -| "Here's what I mean:" | Redundant preview | -| "Think about it:" | Condescending prompt | -| "And that's okay." | Unnecessary permission | - -**Instead:** Make the point. Let readers draw conclusions. - -## Formulaic Constructions - -| Pattern | Problem | -| ------------------------- | --------------------------- | -| "By the time X, I was Y." | Narrative template | -| "X that isn't Y" | Indirect. Say "X is broken" | - -## False Agency - -Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't "live or die." Decisions don't "emerge." A person does something to make those things happen. AI loves this because it avoids naming the actor. - -| Pattern | Problem | -| ------------------------------- | ----------------------------------------------------------------- | -| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | -| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | -| "the decision emerges" | Decisions don't emerge. Someone decides. | -| "the culture shifts" | Cultures don't shift on their own. People change behavior. | -| "the conversation moves toward" | Conversations don't move. Someone steers. | -| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | -| "the market rewards" | Markets don't reward. Buyers pay for things. | - -**Instead:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat. - -## Narrator-from-a-Distance - -Floating above the scene instead of putting the reader in it. - -| Pattern | Problem | -| ------------------------- | ----------------------- | -| "Nobody designed this." | Disembodied observation | -| "This happens because..." | Lecturer voice | -| "This is why..." | Same | -| "People tend to..." | Armchair sociologist | - -**Instead:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this." - -## Passive Voice - -Every sentence needs a subject doing something. Passive voice hides the actor and drains energy. - -| Pattern | Fix | -| -------------------------- | -------------------- | -| "X was created" | Name who created it | -| "It is believed that" | Name who believes it | -| "Mistakes were made" | Name who made them | -| "The decision was reached" | Name who decided | - -**Instead:** Find the actor. Put them at the front of the sentence. - -## Sentence Starters to Avoid - -| Pattern | Fix | -| --------------------------------------------------------------- | ----------------------------------------------- | -| Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. | -| Paragraphs starting with "So" | Start with content | -| Sentences starting with "Look," | Remove | - -Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint. - -## Rhythm Patterns - -| Pattern | Fix | -| ------------------------------ | --------------------------------------------------- | -| Three-item lists | Use two items or one | -| Questions answered immediately | Let questions breathe or cut them | -| Every paragraph ends punchily | Vary endings | -| Em-dashes | Remove. Use commas or periods. No em dashes at all. | -| Staccato fragmentation | Don't stack short punchy sentences | -| "Not always. Not perfectly." | Hedging disguised as reassurance | - -## Word Patterns - -| Pattern | Problem | -| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | -| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | - -## Transformation Chains - -Words that link end-to-end, creating false momentum. - -| Pattern | Problem | -| ---------------------------------------------------------------- | ---------------------- | -| "X became Y. Y became Z." | Artificial momentum | -| "Friction becomes flow. Flow becomes speed." | Chain linking | -| "Word slop became legibility. Legibility became clarity." | False progression | -| "Bottlenecks become opportunities. Opportunities become growth." | Manufactured causation | - -**Instead:** State the outcome directly. "The process is now faster." - -## Before/After Framing - -False historical contrast to manufacture significance. - -| Pattern | Problem | -| ----------------------------------------- | ------------------------ | -| "Before X, it was Y." | Manufactured history | -| "Before AI, it was manual." | False transformation arc | -| "Before this framework, teams struggled." | Exaggerated contrast | - -**Instead:** Describe the current state. Skip the manufactured history. - -## Corrective Reveals - -Dramatic "truth telling" structure that positions the writer as enlightened. - -| Pattern | Problem | -| --------------------------------------------------- | -------------------- | -| "You've been told X. Here's the truth: Y." | Theatrical setup | -| "You've been told a lie. Here is the actual truth." | False authority | -| "Everyone says X. They're wrong." | Contrarian posturing | - -**Instead:** State Y directly without the theatrical setup. - -## Forced Cohesion - -Artificially linking separate ideas to sound profound. - -| Pattern | Problem | -| --------------------------------------- | ----------------------- | -| "You can't have X without Y." | False interdependence | -| "You can't have one without the other." | Manufactured connection | -| "These two things are linked." | Vague binding | - -**Instead:** If they're truly linked, the connection will be clear from context. diff --git a/.claude/skills/reviewing-code/SKILL.md b/.claude/skills/reviewing-code/SKILL.md deleted file mode 100644 index bf8ff87b8..000000000 --- a/.claude/skills/reviewing-code/SKILL.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: reviewing-code -description: Reviews the current branch against a base ref using multiple AI backends. Routes discovery, discovery-secondary, remediation, and verify passes through the available agents (codex, claude, opencode, kimi, …), gracefully skipping any backend that isn't installed. Writes a markdown findings report under docs/. Use when preparing or updating a PR, before merging a feature branch, or when wanting an independent second opinion from a different agent. -user-invocable: true -allowed-tools: Read, Grep, Glob, Bash(node:*), Bash(git:*), Bash(command -v:*) ---- - -# reviewing-code - -Four-pass multi-agent code review of the current branch against a base ref. Each pass is a separate agent run with a focused prompt; the results fold into one markdown report. - -## When to use - -- Reviewing a feature branch before opening (or after updating) a PR. -- Getting a second-and-third opinion from a different agent than the one currently editing. -- Surfacing real bugs / regressions / data-integrity issues, not style. -- Establishing a paper trail for a tricky migration or compatibility-path change. - -## Default pipeline - -| Pass | Role | Default backend | Output | -| ---- | ------------------- | --------------- | ----------------------------------------------------------- | -| 1 | discovery | `codex` | overwrites report | -| 2 | discovery-secondary | `codex` | merges into report (skipped if no new findings) | -| 3 | remediation | `codex` | adds Suggested Fix + Suggested Regression Tests per finding | -| 4 | verify | `claude` | appends `## <Backend> Verification` section | - -Per-role fallback order, hybrid-backend handling (`opencode`), and the graceful-detect / skip-with-note policy live in [`_shared/multi-agent-backends.md`](../_shared/multi-agent-backends.md). This skill is the canonical implementation of that contract. - -## Variant analysis on confirmed findings - -For every High / Critical finding the verify pass marks `CONFIRMED`, run a variant search before closing the report. The same shape often hides elsewhere in the repo. The discipline (what to search for, how to scope, when to skip) lives in [`_shared/variant-analysis.md`](../_shared/variant-analysis.md). Append a `## Variant Analysis` section per finding when variants are found; omit the section when there are none rather than emit an empty header. - -For security-class diffs specifically, run [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md) alongside this skill. That scan is the security-regression cousin to this skill's general review. - -## Compounding lessons - -When the same review finding has fired in two consecutive runs (or across two repos), promote it to a fleet rule per [`_shared/compound-lessons.md`](../_shared/compound-lessons.md). Don't keep catching the same bug; codify it once. - -## Usage - -```bash -# Default: codex×3 + claude×1, output under docs/<branch-slug>-review-findings.md -node .claude/skills/reviewing-code/run.mts - -# Custom base -node .claude/skills/reviewing-code/run.mts --base origin/main - -# Custom output -node .claude/skills/reviewing-code/run.mts --output docs/reviews/my-branch.md - -# Skip the verify pass entirely -node .claude/skills/reviewing-code/run.mts --skip-verify - -# Override one or more passes -node .claude/skills/reviewing-code/run.mts --pass discovery=kimi --pass verify=opencode - -# Cleanup the temp dir on exit (default keeps logs for inspection) -node .claude/skills/reviewing-code/run.mts --cleanup-temp - -# Run only a subset of passes -node .claude/skills/reviewing-code/run.mts --only discovery,verify -``` - -## Configuration via env vars - -| Var | Default | Effect | -| ----------------- | ------------- | ---------------------------------------------- | -| `CODEX_MODEL` | `gpt-5.4` | Codex model when codex is the active backend | -| `CODEX_REASONING` | `xhigh` | Codex reasoning effort | -| `CLAUDE_MODEL` | `opus` | Claude model when claude is the active backend | -| `KIMI_MODEL` | `kimi-latest` | Kimi model when kimi is the active backend | - -## Output - -A single markdown file (`docs/<branch-slug>-review-findings.md` by default) with this structure: - -``` -# <descriptive title> -## Scope -## Executive Summary -## Findings -### 1. <title> - Severity, Summary, Affected Code, Why This Is A Problem, Impact, - Suggested Fix, Suggested Regression Tests -## Assumptions / Gaps -## Validation Notes -## Suggested Next Steps ---- -## <Backend> Verification - Per-finding verdict (CONFIRMED / LIKELY / FALSE POSITIVE), - fix soundness, missed findings, overall recommendation. -``` - -## How the runner works - -`run.mts` is a self-contained TypeScript runner that: - -1. Resolves base ref + merge base + commit list + diff stat. -2. Detects which agent CLIs are available on PATH. -3. For each pass, picks the preferred backend per the fallback order (or skips with a documented note). -4. Writes per-pass prompts to a temp dir and runs the agent non-interactively. -5. Folds outputs into the final report. - -The prompts live in the runner: single source of truth so the pipeline and the prompts can't drift apart. diff --git a/.claude/skills/reviewing-code/run.mts b/.claude/skills/reviewing-code/run.mts deleted file mode 100644 index afa92574e..000000000 --- a/.claude/skills/reviewing-code/run.mts +++ /dev/null @@ -1,757 +0,0 @@ -/** - * Reviewing-code skill runner — multi-agent four-pass review of a branch. - * - * Pipeline (defaults): 1. discovery — codex 2. discovery-secondary — codex 3. - * remediation — codex 4. verify — claude. - * - * Each pass picks the preferred backend per role from a small registry, with - * graceful fallback through the ordered preference list when a CLI isn't - * installed. opencode is orchestrator-tier and only runs when explicitly - * selected. - * - * See SKILL.md for full usage. - */ -import { existsSync, mkdtempSync } from 'node:fs' -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { which } from '@socketsecurity/lib/bin/which' -import { safeDelete } from '@socketsecurity/lib/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib/logger/default' -import { isSpawnError } from '@socketsecurity/lib/process/spawn/errors' -import { spawn } from '@socketsecurity/lib/process/spawn/child' - -const logger = getDefaultLogger() - -type Role = 'discovery' | 'discovery-secondary' | 'remediation' | 'verify' - -type BackendName = 'codex' | 'claude' | 'opencode' | 'kimi' - -type BackendDescriptor = { - readonly bin: string - readonly hybrid: boolean - readonly name: BackendName - // Build the CLI argv given a prompt-file path and the temp output - // path the runner will read after the process exits. Backends that - // emit to stdout instead of an output file return outMode: 'stdout' - // so the runner captures stdout into the output path itself. - readonly run: ( - promptFile: string, - outFile: string, - ) => { argv: readonly string[]; outMode: 'file' | 'stdout' } -} - -const BACKENDS: Readonly<Record<BackendName, BackendDescriptor>> = { - __proto__: null, - codex: { - bin: 'codex', - hybrid: false, - name: 'codex', - run(promptFile, outFile) { - const model = process.env['CODEX_MODEL'] ?? 'gpt-5.4' - const reasoning = process.env['CODEX_REASONING'] ?? 'xhigh' - return { - argv: [ - 'exec', - '--model', - model, - '-c', - `model_reasoning_effort=${reasoning}`, - '--full-auto', - '--ephemeral', - '-o', - outFile, - '-', - ], - outMode: 'file', - } - }, - }, - claude: { - bin: 'claude', - hybrid: false, - name: 'claude', - run(_promptFile, _outFile) { - const model = process.env['CLAUDE_MODEL'] ?? 'opus' - // Programmatic-Claude lockdown — all four flags per CLAUDE.md - // (tools / allowedTools / disallowedTools / permission-mode). - // The official permission flow is hooks → deny → mode → allow → - // canUseTool; in dontAsk mode the last step is skipped, so any - // tool not listed in `tools` is invisible to the model and any - // tool in `disallowedTools` is denied even on bypass. Verify - // pass is read-only by design: tools is the same set as - // allowedTools (read + git introspection only), with Edit / - // Write / destructive Bash explicitly denied. - return { - argv: [ - '--print', - '--model', - model, - '--no-session-persistence', - '--permission-mode', - 'dontAsk', - '--tools', - 'Read', - 'Glob', - 'Grep', - 'Bash(git:*)', - '--allowedTools', - 'Read', - 'Glob', - 'Grep', - 'Bash(git:*)', - '--disallowedTools', - 'Edit', - 'Write', - 'Bash(rm:*)', - 'Bash(mv:*)', - ], - outMode: 'stdout', - } - }, - }, - opencode: { - bin: 'opencode', - hybrid: true, - name: 'opencode', - run(_promptFile, _outFile) { - // opencode reads the prompt from stdin and writes to stdout in - // its non-interactive form. It is hybrid (routes to other - // providers internally per its config) so model selection lives - // outside the runner. - return { - argv: ['run'], - outMode: 'stdout', - } - }, - }, - kimi: { - bin: 'kimi', - hybrid: false, - name: 'kimi', - run(_promptFile, _outFile) { - const model = process.env['KIMI_MODEL'] ?? 'kimi-latest' - // Tentative shape: kimi reads prompt from stdin, writes to stdout. - // Adjust when the actual CLI surface is known. - return { - argv: ['chat', '--model', model, '--no-stream'], - outMode: 'stdout', - } - }, - }, -} as const - -type RoleSpec = { - readonly buildPrompt: (ctx: ReviewContext) => string - readonly headingForVerify?: string | undefined - readonly preferenceOrder: readonly BackendName[] - // Wall-clock cap per spawn for this role. Heavyweight investigation - // passes (discovery, discovery-secondary, remediation) cap at 15min - // per docs/claude.md/fleet/agent-delegation.md — rescue-tier work. - // Verify is a quick check on an already-written report, so 5min. - // Spawn rejects on timeout; the catch in runBackend logs cleanly. - readonly timeoutMs: number -} - -const TIMEOUT_HEAVY_MS = 15 * 60 * 1000 -const TIMEOUT_VERIFY_MS = 5 * 60 * 1000 - -const ROLES: Readonly<Record<Role, RoleSpec>> = { - __proto__: null, - discovery: { - preferenceOrder: ['codex', 'kimi', 'claude'], - timeoutMs: TIMEOUT_HEAVY_MS, - buildPrompt: - ctx => `Take a look at the current branch and give me a full and thorough review. This is a big one, so take your time. - -Scope: -- current branch: ${ctx.branch} -- base ref: ${ctx.baseRef} -- merge base: ${ctx.mergeBase} -- review range: ${ctx.range} - -Commits in range: -${ctx.commitList} - -Diff stat: -${ctx.diffStat} - -Instructions: -- Inspect the repository directly. Use git diff, git log, git show, and read files as needed. -- Review only the changes introduced in ${ctx.range}. -- Do not review uncommitted changes. -- Your job is to find the most important bugs or behavioral regressions introduced by this branch. -- Focus first on finding the right issues. Do not spend much effort on fix design in this pass beyond short directional notes when necessary. -- Take your time and keep digging when you find a suspicious migration boundary, compatibility path, parser/serializer edge, or unchanged consumer that still expects the old shape. -- Prioritize high-confidence findings, but be thorough once you identify a real issue. -- Do not optimize for brevity. Include enough supporting detail that the PR author can understand the bug and why it happens without re-reading the entire diff. -- Follow changed code into unchanged consumers, parsers, validators, readers, writers, and compatibility paths when needed. -- Focus on real bugs, regressions, broken edge cases, data integrity issues, error handling gaps, and missing regression tests. -- Ignore style-only feedback. -- Think independently. Do not optimize for a checklist or taxonomy of issue types. -- Every finding must be backed by concrete evidence from the code. If you cannot trace the bug clearly, lower confidence or move it to "Assumptions / Gaps" instead of presenting it as a finding. -- For especially important findings, include a concrete trace through the affected code path. If a small local repro is feasible, use it. -- For each finding, include affected file and line references, the issue, and the impact. -- If there are no findings, say that explicitly and mention any residual risks or validation gaps. -- Return only the raw markdown document itself, suitable for saving under docs/. -- Do not add preamble text, code fences, or wrapper text like "Updated <path>". - -Use this structure: -# <descriptive title> -## Scope -## Executive Summary -## Findings -### 1. <title> -Severity: <High|Medium|Low> -Summary -Affected Code -Why This Is A Problem -Impact -## Assumptions / Gaps -## Validation Notes -`, - }, - 'discovery-secondary': { - preferenceOrder: ['codex', 'kimi', 'claude'], - timeoutMs: TIMEOUT_HEAVY_MS, - buildPrompt: - ctx => `Take another look at the current branch and search for additional high-confidence findings that are not already documented in \`${ctx.outputPath}\`. - -Scope: -- current branch: ${ctx.branch} -- base ref: ${ctx.baseRef} -- merge base: ${ctx.mergeBase} -- review range: ${ctx.range} - -Instructions: -- Read the existing review report at \`${ctx.outputPath}\` only to understand which findings are already covered. -- Then do an independent second review of the same branch range using git diff, git log, git show, and file reads as needed. -- Review only the changes introduced in ${ctx.range}. -- Do not review uncommitted changes. -- Do not repeat, reword, split, or restate findings that are already in the report. -- Only add a new finding if it is a genuinely separate issue backed by concrete evidence in the code. -- There is no requirement to find additional issues. If you do not find additional high-confidence findings, return the report unchanged. -- Preserve the existing report content. If you add new findings, integrate them into the existing document by extending the \`## Findings\` section and updating the executive summary only as needed. -- Return only the raw markdown document itself, suitable for saving under docs/. -- Do not add preamble text, code fences, or wrapper text like "Updated <path>". -`, - }, - remediation: { - preferenceOrder: ['codex', 'kimi', 'claude'], - timeoutMs: TIMEOUT_HEAVY_MS, - buildPrompt: - ctx => `Read the existing review report at \`${ctx.outputPath}\` and augment it with concrete fix suggestions and regression tests for every finding. - -Scope: -- current branch: ${ctx.branch} -- base ref: ${ctx.baseRef} -- merge base: ${ctx.mergeBase} -- review range: ${ctx.range} - -Instructions: -- Read the report file at this exact path: \`${ctx.outputPath}\`. -- Inspect the repository directly as needed using git diff, git log, git show, and file reads. -- Review only the changes introduced in ${ctx.range}. -- Do not review uncommitted changes. -- Preserve the report's findings, severity, and supporting evidence unless you discover a clear factual correction while tracing a fix. If you do find a clear correction, update the report itself rather than appending contradictory notes. -- For every finding, add: - - \`Suggested Fix\` - - \`Suggested Regression Tests\` -- Make the fix suggestions actionable. When appropriate, split them into short-term compatibility fixes and longer-term cleanup or migration follow-up. -- Add \`## Suggested Next Steps\` if the report does not already have one. -- Keep the document thorough. Do not remove supporting detail from the existing findings. -- Return only the full updated raw markdown document itself, suitable for saving under docs/. -- Do not add preamble text, code fences, or wrapper text like "Updated <path>". -`, - }, - verify: { - preferenceOrder: ['claude', 'kimi', 'codex'], - headingForVerify: 'Verification', - timeoutMs: TIMEOUT_VERIFY_MS, - buildPrompt: - ctx => `Review the saved markdown findings report at \`${ctx.outputPath}\` for accuracy. - -Scope: -- current branch: ${ctx.branch} -- base ref: ${ctx.baseRef} -- merge base: ${ctx.mergeBase} -- review range: ${ctx.range} - -Instructions: -- Read the report file at this exact path: \`${ctx.outputPath}\`. -- Verify each finding against the repository using git diff, git log, git show, and file reads as needed. -- Review only the changes introduced in ${ctx.range}. -- Do not review uncommitted changes. -- Be conservative. If you cannot trace a finding concretely, mark it as FALSE POSITIVE rather than giving it a soft pass. -- Verify both the finding itself and the soundness of the suggested fix. -- Output only a markdown section that starts exactly with the heading \`## <Backend> Verification\` (replace <Backend> with the agent name you are running as). -- For each finding, provide a verdict of CONFIRMED, LIKELY, or FALSE POSITIVE with a brief rationale. -- Also say whether the suggested fix is sound, incomplete, or needs a different approach. -- Then list any important missed findings that should have been in the original report. -- End with an overall recommendation and any validation caveats. -- Do not restate the full original report. -`, - }, -} as const - -type ReviewContext = { - readonly baseRef: string - readonly branch: string - readonly commitList: string - readonly diffStat: string - readonly mergeBase: string - readonly outputPath: string - readonly range: string -} - -type Args = { - readonly baseRef: string | undefined - readonly cleanupTemp: boolean - readonly only: ReadonlySet<Role> | undefined - readonly outputPath: string | undefined - readonly passOverrides: ReadonlyMap<Role, BackendName> - readonly skipVerify: boolean -} - -const ALL_ROLES: readonly Role[] = [ - 'discovery', - 'discovery-secondary', - 'remediation', - 'verify', -] - -export async function appendSkipNote( - reportPath: string, - role: Role, - reason: string, -): Promise<void> { - const existing = existsSync(reportPath) - ? await fs.readFile(reportPath, 'utf8') - : '' - const note = `> Skipped pass: **${role}** — ${reason}` - await fs.writeFile(reportPath, `${existing.trimEnd()}\n\n${note}\n`) -} - -export async function appendVerificationSection( - reportPath: string, - section: string, - backend: BackendName, -): Promise<void> { - // Some backends ignore the "include the agent name in the heading" - // instruction; if the section starts with `## Verification` or - // similar, prepend the backend name for attribution. - const titled = section.replace( - /^## (Claude |Codex |Kimi |Opencode )?Verification\b/i, - `## ${capitalize(backend)} Verification`, - ) - const existing = await fs.readFile(reportPath, 'utf8') - await fs.writeFile( - reportPath, - `${existing.trimEnd()}\n\n---\n\n${titled.trimEnd()}\n`, - ) -} - -export function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1) -} - -export async function detectAvailableBackends(): Promise< - ReadonlySet<BackendName> -> { - // Fan out the `which` lookups instead of awaiting one at a time. - // Cheap parallelism — N filesystem stats run concurrently rather - // than serially. - const names = Object.keys(BACKENDS) as BackendName[] - const results = await Promise.all( - names.map(async name => ({ - name, - available: await isCommandAvailable(BACKENDS[name].bin), - })), - ) - return new Set(results.filter(r => r.available).map(r => r.name)) -} - -export async function git( - args: readonly string[], - cwd?: string, -): Promise<string> { - const result = await spawn('git', args as string[], { - cwd, - stdio: 'pipe', - stdioString: true, - }) - return String(result.stdout ?? '').trim() -} - -export function isBackendName(s: string): s is BackendName { - return s in BACKENDS -} - -export async function isCommandAvailable(bin: string): Promise<boolean> { - // Use `which` from @socketsecurity/lib/bin instead of spawning - // `command -v` with shell: true. The shell:true variant invokes - // cmd.exe on Windows and mangles `command -v`; `which` is - // cross-platform and avoids the shell entirely. - return (await which(bin)) !== null -} - -export function isRole(s: string): s is Role { - return s in ROLES -} - -// Strip claude-style "Updated <path>\n\n```markdown\n…\n```" wrappers -// some agents add even when asked not to. Lifted-and-portable parser. -export function normalizeMarkdown(text: string): string { - if (!text) { - return '' - } - const lines = text.split(/\r?\n/) - if (lines.length === 0) { - return text - } - const firstStartsWithUpdated = /^Updated\s+\[/.test(lines[0] ?? '') - const thirdIsCodeFence = - lines[2] === '```' || lines[2] === '```markdown' || lines[2] === '```md' - let lastNonEmpty = lines.length - 1 - while (lastNonEmpty >= 0 && lines[lastNonEmpty]!.trim() === '') { - lastNonEmpty-- - } - const lastIsClosingFence = lines[lastNonEmpty] === '```' - if (firstStartsWithUpdated && lastIsClosingFence && thirdIsCodeFence) { - return lines.slice(3, lastNonEmpty).join('\n').trimEnd() + '\n' - } - return text -} - -export function parseArgs(argv: readonly string[]): Args { - let baseRef: string | undefined - let cleanupTemp = false - let outputPath: string | undefined - let skipVerify = false - const only = new Set<Role>() - const passOverrides = new Map<Role, BackendName>() - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--base') { - baseRef = argv[++i] - continue - } - if (arg === '--output') { - outputPath = argv[++i] - continue - } - if (arg === '--cleanup-temp') { - cleanupTemp = true - continue - } - if (arg === '--skip-verify') { - skipVerify = true - continue - } - if (arg === '--only') { - for (const r of argv[++i].split(',')) { - if (!isRole(r)) { - throw new Error(`--only: unknown role "${r}"`) - } - only.add(r) - } - continue - } - if (arg === '--pass') { - const spec = argv[++i] - const eq = spec.indexOf('=') - if (eq < 0) { - throw new Error(`--pass expects role=backend, got "${spec}"`) - } - const role = spec.slice(0, eq) - const backend = spec.slice(eq + 1) - if (!isRole(role)) { - throw new Error(`--pass: unknown role "${role}"`) - } - if (!isBackendName(backend)) { - throw new Error(`--pass: unknown backend "${backend}"`) - } - passOverrides.set(role, backend) - continue - } - if (arg === '--help' || arg === '-h') { - printHelp() - process.exit(0) - } - throw new Error(`Unknown argument: ${arg}`) - } - return { - baseRef, - cleanupTemp, - only: only.size > 0 ? only : undefined, - outputPath, - passOverrides, - skipVerify, - } -} - -export function pickBackend( - role: Role, - available: ReadonlySet<BackendName>, - override: BackendName | undefined, -): BackendName | undefined { - if (override) { - if (!available.has(override)) { - logger.warn( - `${role}: requested backend "${override}" is not installed; falling back to preference order`, - ) - } else { - return override - } - } - for (const candidate of ROLES[role].preferenceOrder) { - // opencode is hybrid — only used when explicitly selected via --pass. - if (BACKENDS[candidate].hybrid) { - continue - } - if (available.has(candidate)) { - return candidate - } - } - return undefined -} - -export function printHelp(): void { - // oxlint-disable-next-line socket/no-logger-newline-literal -- CLI help text is intentionally a single multi-line block; splitting would garble the columnar formatting users expect. - logger.info(`Usage: node .claude/skills/reviewing-code/run.mts [options] - -Options: - --base <ref> Base ref to review against (default: origin/HEAD or origin/main) - --output <path> Output markdown path (default: docs/<branch-slug>-review-findings.md) - --skip-verify Skip the verify pass entirely - --only <roles> Comma-separated subset of roles to run (discovery,discovery-secondary,remediation,verify) - --pass <role>=<backend> Override the backend for a specific role (codex, claude, opencode, kimi) - --cleanup-temp Remove the temp directory on exit (default: keep for inspection) - -h, --help Show this help`) -} - -export async function resolveBaseRef( - provided: string | undefined, - cwd: string, -): Promise<string> { - if (provided) { - return provided - } - // Default-branch fallback per CLAUDE.md: symbolic-ref → origin/main → origin/master. - try { - const headRef = await git( - ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], - cwd, - ) - if (headRef.length > 0) { - return headRef - } - } catch { - // fall through - } - for (const branch of ['main', 'master']) { - try { - await git( - ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], - cwd, - ) - return `origin/${branch}` - } catch { - // try next - } - } - return 'origin/main' -} - -export async function runBackend( - backend: BackendName, - promptText: string, - tempDir: string, - passLabel: string, - cwd: string, - timeoutMs: number, -): Promise<{ ok: boolean; output: string; logPath: string }> { - const desc = BACKENDS[backend] - const promptFile = path.join(tempDir, `${passLabel}.prompt.txt`) - const outFile = path.join(tempDir, `${passLabel}.out.md`) - const logFile = path.join(tempDir, `${passLabel}.log`) - await fs.writeFile(promptFile, promptText) - const { argv, outMode } = desc.run(promptFile, outFile) - const stderrParts: string[] = [] - let stdout = '' - try { - const child = spawn(desc.bin, argv as string[], { - cwd, - stdio: 'pipe', - stdioString: true, - timeout: timeoutMs, - }) - child.stdin?.end(promptText) - const result = await child - stdout = String(result.stdout ?? '') - stderrParts.push(String(result.stderr ?? '')) - } catch (e) { - if (isSpawnError(e)) { - stdout = String(e.stdout ?? '') - stderrParts.push(String(e.stderr ?? '')) - } else { - stderrParts.push(e instanceof Error ? e.message : String(e)) - } - await fs.writeFile( - logFile, - `# backend: ${backend}\n# argv: ${argv.join(' ')}\n# timeoutMs: ${timeoutMs}\n# error\n\n${stderrParts.join('\n')}\n\n=== STDOUT ===\n${stdout}\n`, - ) - return { ok: false, output: '', logPath: logFile } - } - await fs.writeFile( - logFile, - `# backend: ${backend}\n# argv: ${argv.join(' ')}\n\n=== STDOUT ===\n${stdout}\n\n=== STDERR ===\n${stderrParts.join('\n')}\n`, - ) - let output = '' - if (outMode === 'file') { - if (existsSync(outFile)) { - output = await fs.readFile(outFile, 'utf8') - } - } else { - output = stdout - } - output = normalizeMarkdown(output) - return { ok: output.trim().length > 0, output, logPath: logFile } -} - -export function slugify(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - - // Quick: must be in a git repo. - let repoRoot: string - try { - repoRoot = await git(['rev-parse', '--show-toplevel']) - } catch { - logger.error('Must be run inside a git repository.') - process.exit(1) - } - - const branchRaw = await git(['branch', '--show-current'], repoRoot) - const branch = - branchRaw.length > 0 - ? branchRaw - : `detached-${await git(['rev-parse', '--short', 'HEAD'], repoRoot)}` - const baseRef = await resolveBaseRef(args.baseRef, repoRoot) - const mergeBase = await git(['merge-base', baseRef, 'HEAD'], repoRoot) - const range = `${mergeBase}..HEAD` - const commitList = await git( - ['log', '--oneline', '--no-decorate', range], - repoRoot, - ) - const diffStat = await git(['diff', '--stat', range], repoRoot) - - const outputPath = - args.outputPath ?? - path.join(repoRoot, 'docs', `${slugify(branch)}-review-findings.md`) - await fs.mkdir(path.dirname(outputPath), { recursive: true }) - - const tempDir = mkdtempSync( - path.join(os.tmpdir(), `reviewing-code.${slugify(branch)}.`), - ) - - const ctx: ReviewContext = { - baseRef, - branch, - commitList, - diffStat, - mergeBase, - outputPath, - range, - } - - const available = await detectAvailableBackends() - logger.info(`Available backends: ${[...available].join(', ') || '(none)'}`) - logger.info(`Logs and prompts kept under: ${tempDir}`) - - const rolesToRun = ALL_ROLES.filter(r => { - if (args.only && !args.only.has(r)) { - return false - } - if (r === 'verify' && args.skipVerify) { - return false - } - return true - }) - - for (let i = 0, { length } = rolesToRun; i < length; i += 1) { - const role = rolesToRun[i]! - const passLabel = `${rolesToRun.indexOf(role) + 1}-${role}` - const backend = pickBackend(role, available, args.passOverrides.get(role)) - if (!backend) { - logger.warn(`${passLabel}: no backend available; skipping`) - await appendSkipNote(outputPath, role, 'no available backend') - continue - } - const roleSpec = ROLES[role] - logger.info( - `${passLabel}: running on ${backend} (timeout ${Math.round(roleSpec.timeoutMs / 60000)}m)`, - ) - const promptText = roleSpec.buildPrompt(ctx) - const result = await runBackend( - backend, - promptText, - tempDir, - passLabel, - repoRoot, - roleSpec.timeoutMs, - ) - if (!result.ok) { - logger.error(`${passLabel}: failed; see ${result.logPath}`) - await appendSkipNote( - outputPath, - role, - `${backend} failed (see ${result.logPath})`, - ) - continue - } - if (role === 'verify') { - await appendVerificationSection(outputPath, result.output, backend) - } else if (role === 'discovery-secondary') { - // Only overwrite if the secondary pass actually returned a - // different document (caller asked for "no diff = no change"). - const before = existsSync(outputPath) - ? await fs.readFile(outputPath, 'utf8') - : '' - if (before.trim() !== result.output.trim()) { - await fs.writeFile(outputPath, result.output) - } else { - logger.info(`${passLabel}: no additional findings; report unchanged`) - } - } else { - await fs.writeFile(outputPath, result.output) - } - } - - if (args.cleanupTemp) { - await safeDelete(tempDir) - } - - logger.info('') - logger.info(`Code review for: ${branch}`) - logger.info(`Report: ${outputPath}`) - logger.info(`Base ref: ${baseRef}`) - logger.info(`Merge base: ${mergeBase}`) - logger.info(`Range: ${range}`) - if (!args.cleanupTemp) { - logger.info(`Temp dir: ${tempDir}`) - } -} - -main().catch(e => { - logger.error(e instanceof Error ? e.message : String(e)) - process.exit(1) -}) diff --git a/.claude/skills/running-test262/SKILL.md b/.claude/skills/running-test262/SKILL.md deleted file mode 100644 index c2c9d4593..000000000 --- a/.claude/skills/running-test262/SKILL.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -name: running-test262 -description: Run the test262 conformance suite against fleet parsers / runtimes (ultrathink acorn variants, socket-btm temporal-infra, future ports) using each repo's canonical runner. Never write homebrew test262 runners. Every parser/runtime in the fleet ships a runner under `test/scripts/test262-*.mts` and an unsupported-features config. Use this skill when asked to run spec tests, check conformance, debug a failing test262 case, or compare a parser against a reference implementation. -user-invocable: true -allowed-tools: Bash(node:*), Bash(pnpm:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Read ---- - -# running-test262 - -The fleet has multiple parsers + runtimes that conform to ECMA262 or to a TC39 proposal: - -- `ultrathink/packages/acorn/`: the JS parser, multiple lang ports (cpp/go/rust/typescript). -- `ultrathink/packages/test262-parser-runner/`: the canonical shared runner package. -- `socket-btm/packages/temporal-infra/`: Temporal-proposal C++ port. - -Every one of them ships its own `scripts/test262-*.mts` runner + an `unsupported-features` config. Running test262 by hand (downloading the suite, scanning the metadata blocks, running each test) is the wrong shape. The runners already encode the suite-traversal, the per-feature skip logic, the harness setup, and the result-aggregation. Always reach for the existing runner. - -## Test262 submodule pin - -The fleet pins to a shared `tc39/test262` SHA. As of 2026-05-21 both ultrathink + socket-btm pin `7e115f46a`. When bumping in one repo, bump in the other so cross-fleet comparison stays apples-to-apples. - -Annotation lives in each repo's `.gitmodules` with the pattern `# test262-YYYY.MM.DD` (commit-date of the pinned SHA, enforced by the `gitmodules-comment-guard` hook). - -## 🚨 Strict allowlist policy - -**An allowlist entry is ONLY for non-parser test fails.** Anything a parser should handle MUST NOT be allowlisted; it must be fixed in the parser. This is strict; the runners enforce it via design choices below. - -What counts as "non-parser": - -- **Unimplemented TC39 feature**: the proposal is at Stage 3+ but we haven't ported the grammar yet (decorators, source-phase imports). Goes in `test262-config/test262.unsupported-features` keyed on the TC39 feature name (NOT a test path). -- **Runner / harness bug**: the test runner itself produces a false signal (e.g. async-throws semantics, error-name matching). Fix the runner, don't allow-list the symptom. -- **Runtime-only test**: the test exercises a runtime API (`Reflect.*`, `Temporal.*`) that the parser-conformance run can't evaluate. The runners skip these by classification, not per-path allowlist. - -What does NOT count and must be fixed in the parser: - -- "Parser rejects valid input." Fix the parser. -- "Parser accepts invalid input." Fix the parser. -- "Parser produces wrong AST shape." Fix the parser. -- "Cross-impl divergence: Rust + TS pass, Go fails." Fix Go. - -If you feel tempted to add a per-test-path allowlist entry, the answer is almost always "the parser needs fixing." The `unsupported-features` file is the only escape valve and it's feature-name-keyed by design. You can't sneak a parser bug past it. - -## Canonical runners per repo - -| Repo | Runner | Skip config | -| --------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| ultrathink/packages/acorn (multi-lane driver) | `test/test262-compare.mts` | per-lane runner config (inherits unsupported-features) | -| ultrathink/packages/acorn (per-lane) | `lang/<lane>/scripts/test262.mts` | `test262-config/test262.unsupported-features` (feature-name-keyed) | -| ultrathink/packages/test262-parser-runner | `bin/test262-parser-runner.mts` | passed via flags | -| socket-btm/packages/temporal-infra | `test/scripts/test262-temporal-runner.mts` | `test262-config/test262.allowlist` (Temporal-only path allowlist; reviewed manually for non-parser-fail justification) | - -## Invocation patterns - -### Multi-lane (recommended for cross-lane parity checks) - -```bash -cd packages/acorn - -# All 4 lanes, full suite -node test/test262-compare.mts - -# Subset of lanes -node test/test262-compare.mts --lane rust,go - -# All lanes, filtered to a single category -node test/test262-compare.mts --include 'language/expressions/await' - -# Single test path, all lanes -node test/test262-compare.mts test/language/statements/class/private-method.js -``` - -Lanes: `rust`, `go`, `cpp`, `typescript`. Flags forward to each per-lane runner. - -### Single-lane - -```bash -# Per-lane direct invocation -cd packages/acorn/lang/rust && node scripts/test262.mts -cd packages/acorn/lang/go && node scripts/test262.mts -cd packages/acorn/lang/cpp && node scripts/test262.mts -cd packages/acorn/lang/typescript && node scripts/test262.mts - -# socket-btm temporal-infra -cd socket-btm/packages/temporal-infra && node test/scripts/test262-temporal-runner.mts -``` - -### Single-case debug - -Pass the test path positionally: - -```bash -# Single lane -node scripts/test262.mts test/language/expressions/await/await-in-nested-function.js - -# All lanes -node test/test262-compare.mts test/language/expressions/await/await-in-nested-function.js -``` - -### Targeted filtering - -```bash -node scripts/test262.mts --include 'export' # regex on path -node scripts/test262.mts --exclude 'surrogate' # regex on path -node scripts/test262.mts --category module # named feature group -node scripts/test262.mts --include 'class' --exclude 'async' -``` - -### Vitest-integrated mode - -Each repo also wires a vitest test that wraps the runner. Useful for CI integration and selective re-runs: - -```bash -pnpm exec vitest run test/unit/test262.test.mts # ultrathink acorn -pnpm exec vitest run test/unit/test262-temporal.test.mts # socket-btm temporal -``` - -## Common failure modes - -- **Submodule missing.** The test262 suite is a git submodule. If the runner errors with "test262 suite not found", run `git submodule update --init --recursive`. -- **Feature classification drift.** The runner uses each test's metadata block (`/*--- features: [...] ---*/`) to decide whether to run or skip. If a new TC39 feature is added upstream, classify it in the `unsupported-features` config first; do not let the runner silently pass tests for features the parser doesn't implement. -- **"Allowlist drift": does NOT apply here.** The acorn lanes don't carry a per-test-path allowlist. If a test starts passing or failing, that's the parser's behavior; either the parser is correct and the test is correct (good), or one of them is wrong and that's a bug. -- **Cross-fleet drift.** ultrathink and socket-btm should pin the same `tc39/test262` SHA. If you're investigating a flaky test, double-check both `.gitmodules` files first. - -## Never write a homebrew runner - -The existing runners encode dozens of edge cases (strict-mode harness wrapping, async-throws semantics, error-name matching, the `negative.phase` distinction between parse vs early errors). Recreating that surface from scratch reliably misses cases. If you find yourself wanting to "just run a few test262 files by hand," reach for the runner with a filter arg instead. - -## Reference - -- TC39 test262 spec: https://github.com/tc39/test262 -- Each runner's source is the source of truth for invocation flags and exit-code conventions; cat the runner first if the invocation is unclear. -- Strict allowlist policy + multi-lane behavior + `tc39/test262` pin date all encoded in this skill. Read this skill before touching either system. diff --git a/.claude/skills/scanning-quality/SKILL.md b/.claude/skills/scanning-quality/SKILL.md deleted file mode 100644 index 5a6d8b4fe..000000000 --- a/.claude/skills/scanning-quality/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: scanning-quality -description: Scans the codebase for bugs, logic errors, cache races, workflow problems, insecure defaults, security regressions in the diff, and variant analysis on prior findings. Spawns specialized Task agents per scan type, deduplicates findings, and produces an A-F prioritized report. Use when preparing a release, investigating quality issues, running pre-merge checks, or whenever a recent diff touches security-sensitive code. -user-invocable: true -allowed-tools: Task, Read, Grep, Glob, AskUserQuestion, Bash(pnpm run check:*), Bash(pnpm run test:*), Bash(pnpm test:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*) ---- - -# scanning-quality - -Quality analysis across the codebase using specialized Task agents. Cleans up junk files, runs structural validation, dispatches one agent per scan type, deduplicates findings, and produces an A-F prioritized report. - -## Modes - -- **Default (interactive)**: `AskUserQuestion` is used to confirm cleanup deletions and to pick scan scope. -- **Non-interactive**: `/scanning-quality non-interactive` (or any of the aliases below) skips every `AskUserQuestion` and applies safe defaults: scan scope = all types, cleanup = leave junk files in place (don't delete without confirmation), report-save = yes (`reports/scanning-quality-YYYY-MM-DD.md`). Use this when running headlessly (CI cron, programmatic Claude, any non-TTY driver). The four-flag programmatic-Claude lockdown rule already strips `AskUserQuestion`, so headless runs default to non-interactive automatically. Call it out explicitly so future readers understand the contract. - -Detect non-interactive mode via any of: `--non-interactive` argument, `non-interactive` argument, `SCANNING_QUALITY_NONINTERACTIVE=1` env var, or absence of `AskUserQuestion` in the available tool surface. - -## Scan Types - -Legacy scan types (agent prompts in `reference.md`): - -1. **critical** - Crashes, security vulnerabilities, resource leaks, data corruption -2. **logic** - Algorithm errors, edge cases, type guards, off-by-one errors -3. **cache** - Cache staleness, race conditions, invalidation bugs -4. **workflow** - Build scripts, CI issues, cross-platform compatibility -5. **workflow-optimization** - CI optimization (build-required conditions on cached builds) -6. **security** - GitHub Actions workflow security (zizmor scanner) -7. **documentation** - README accuracy, outdated docs, missing documentation -8. **patch-format** - Patch file format validation - -Modular scan types (one file per type under `scans/`, easier to extend than the monolithic `reference.md`): - -9. **variant-analysis**: for each High/Critical finding from above, search the rest of the repo for the same shape. See [`scans/variant-analysis.md`](scans/variant-analysis.md). -10. **insecure-defaults**: fail-open defaults, hardcoded credentials, lazy fallbacks. See [`scans/insecure-defaults.md`](scans/insecure-defaults.md). -11. **differential**: security-focused diff against a base ref. See [`scans/differential.md`](scans/differential.md). -12. **bundle-trim**: for repos that ship a built bundle (today: rolldown), identify unused module paths the bundler statically pulled in but the runtime never reaches. Reports candidates; the trim loop itself lives in the [`trimming-bundle`](../trimming-bundle/SKILL.md) skill. See [`scans/bundle-trim.md`](scans/bundle-trim.md). -13. **deadcode-removal**: surface dead source files, test-only helpers, stale `// eslint-disable` / `// oxlint-disable` directives, and dead string-literal constants. Captures the fleet rule that `socket/export-top-level-functions` REQUIRES `export` on helpers (exports exist for tests), so the scan never recommends dropping `export` to colocate. See [`scans/deadcode-removal.md`](scans/deadcode-removal.md). - -Adding a new scan type: drop a file under `scans/<name>.md` describing mission, method, output shape, when-to-skip; same shape as the three above. The orchestrator picks them up by directory listing; no edits to this SKILL.md needed beyond appending to the list. - -The split exists because adding a 12th, 15th, 20th scan type into `reference.md` produces exactly the "this and also that and also the other thing" file CLAUDE.md's File-size rule warns about. Per-type files keep each scan reviewable in isolation. - -## Process - -### Phase 1: Validate Environment - -```bash -git status -``` - -Warn about uncommitted changes but continue (scanning is read-only). - -### Phase 2: Update Dependencies - -```bash -pnpm run update -``` - -Only update the current repository. Continue even if update fails. - -### Phase 3: Install zizmor - -Install zizmor for GitHub Actions security scanning, respecting the soak time (pnpm-workspace.yaml `minimumReleaseAge` in minutes, default 10080 = 7 days). Query GitHub releases, find the latest stable release older than the threshold, and install via pipx/uvx. Skip the security scan if no release meets the soak requirement. - -### Phase 4: Repository Cleanup - -Find junk files (interactive mode confirms each batch via `AskUserQuestion`; non-interactive mode lists what was found in the report and leaves them in place; don't delete files without explicit confirmation, even on a clean dirty-tree): - -- SCREAMING_TEXT.md files outside `.claude/` and `docs/` -- Test files in wrong locations -- Temp files (`.tmp`, `.DS_Store`, `*~`, `*.swp`, `*.bak`) -- Log files in root/package directories - -### Phase 5: Structural Validation - -```bash -node scripts/check-paths.mts -``` - -Report errors as Critical findings. Warnings are Low findings. (The fleet's structural validator is `check-paths.mts`, the path-hygiene gate. If a repo has a richer structural validator under a different name, run that instead. Every fleet repo ships `check-paths.mts`.) - -### Phase 6: Determine Scan Scope - -In **interactive** mode, ask the user which scans to run via `AskUserQuestion` (multiSelect). Default: all scans. - -In **non-interactive** mode, run all scan types; no prompt. - -### Phase 7: Execute Scans - -For each enabled scan type, spawn a Task agent with the corresponding prompt: - -- Legacy types (1–8): prompt from `reference.md`. -- Modular types (9+): prompt from `scans/<type>.md`. - -Run sequentially in priority order: critical, logic, cache, workflow, security, then the modular scans (variant-analysis depends on earlier findings so runs after them; insecure-defaults and differential are independent), then documentation last. - -Each agent reports findings as: - -- File: path:line -- Issue, Severity, Pattern, Trigger, Fix, Impact - -### Phase 8: Aggregate and Report - -- Deduplicate findings across scan types -- Sort by severity: Critical > High > Medium > Low -- Generate markdown report with file:line references, suggested fixes, and coverage metrics -- **Interactive**: offer to save to `reports/scanning-quality-YYYY-MM-DD.md` via `AskUserQuestion`. -- **Non-interactive**: save the report unconditionally to `reports/scanning-quality-YYYY-MM-DD.md` (create the directory if missing) so the artifact is visible to the orchestrating runner. If the `Write` tool isn't in the allow list, emit the full markdown to stdout with a leading `=== REPORT MARKDOWN ===` marker so the runner can capture and persist it. - -### Phase 9: Summary - -Report final metrics: dependency updates, structural validation results, cleanup stats, scan counts, and total findings by severity. - -## Commit cadence - -This skill is read-only. It scans and reports, it doesn't fix. Cadence rules apply to _handing the report off_, not to fixes: - -- **Save the report before acting on it.** If the user opts to save (`reports/scanning-quality-YYYY-MM-DD.md`), commit the report file in its own commit (`docs(reports): scanning-quality YYYY-MM-DD`). That snapshot is referenceable later when fixes land. -- **Don't fix in-skill.** If findings need fixes, hand off to the appropriate skill (`/guarding-paths` for path drift, `refactor-cleaner` agent via `/quality-loop` for code-quality findings) and commit those fixes per that skill's own cadence rules. Don't bundle scan + fixes in one commit. -- **One report per scan run.** Re-running the skill produces a new report; don't overwrite the previous one's git history. Commit each fresh report so the trend line is visible. diff --git a/.claude/skills/scanning-quality/reference.md b/.claude/skills/scanning-quality/reference.md deleted file mode 100644 index f99607ba5..000000000 --- a/.claude/skills/scanning-quality/reference.md +++ /dev/null @@ -1,1004 +0,0 @@ -# Quality Scan Reference - Agent Prompts - -This file contains detailed agent prompts for each quality scan type in socket-cli. - ---- - -## Scan Type: critical - -**Purpose**: Identifying crashes, security vulnerabilities, data corruption, and authentication failures. - -**Severity**: Critical, High - -### Agent Prompt - -````markdown -<role> -You are a critical bug detector specializing in TypeScript CLI applications. Your goal is identifying issues that cause process crashes, security breaches, or data corruption. -</role> - -<context> -**Repository**: socket-cli (TypeScript CLI tool using meow framework) -**Primary scan target**: packages/cli/src/ -**Conventions**: `@socketsecurity/lib/*` imports, `InputError`/`AuthError` types from `src/util/errors.mts` -</context> - -<task> -Scan for issues causing: -1. Process crashes (unhandled exceptions, null access) -2. Security vulnerabilities (credential exposure, injection) -3. Data corruption (race conditions, file system errors) -4. Authentication failures (token handling, API errors) - -Report only issues that can actually trigger in production, not theoretical concerns. -</task> - -<patterns> - -### Pattern: null_undefined_access - -Property access without optional chaining crashes when intermediate values are null/undefined. CLI tools often receive incomplete user input or API responses, making this a common crash vector. - -```typescript -// Crashes if user or profile is undefined -const name = user.profile.name - -// Safe - returns undefined instead of crashing -const name = user?.profile?.name -``` -```` - -**Severity**: Critical (crashes process) - ---- - -### Pattern: unhandled_promises - -Unhandled promise rejections crash Node.js processes by default. Async calls without `await` or `.catch()` create floating promises that terminate the CLI unexpectedly. - -```typescript -// Rejection crashes process -async function run() { - apiCall() // Floating promise -} - -// Rejection handled -async function run() { - await apiCall().catch(handleError) -} -``` - -**Severity**: Critical (crashes process) - ---- - -### Pattern: race_conditions - -Concurrent modifications to shared state cause data corruption. `forEach` with async callbacks doesn't await, creating races. Array mutations during parallel operations lose data. - -```typescript -// Results array races - pushes can interleave -const results = [] -for (const item of items) { - processItem(item).then(r => results.push(r)) -} - -// Coordinated - Promise.all ensures proper collection -const results = await Promise.all(items.map(processItem)) -``` - -**Severity**: Critical (data corruption) - ---- - -### Pattern: auth_token_exposure - -Logged tokens leak into CI logs, error tracking systems, and terminal histories. This enables credential theft from Sentry traces, GitHub Actions logs, or local shell history. - -```typescript -// Logs full token to console/Sentry -logger.debug({ apiToken: token }) - -// Safe - logs only presence -logger.debug({ hasToken: !!token }) -``` - -**Severity**: Critical (credential theft) - ---- - -### Pattern: type_coercion_bugs - -Implicit coercion (`==`) treats `0`, `"0"`, `""`, and `false` as equivalent. User input arrives as strings; comparing with `==` causes logic errors. - -```typescript -// Matches both number 0 and string "0" -if (count == 0) - -// Matches only number 0 -if (count === 0) -``` - -**Severity**: High (incorrect behavior) - ---- - -### Pattern: cross_platform_paths - -Hardcoded `/` separators fail on Windows (uses `\`). String concatenation breaks with paths containing spaces or special characters. - -```typescript -// Fails on Windows, breaks with spaces -const configPath = dir + '/config.json' - -// Works cross-platform, handles spaces -import { join } from 'node:path' -const configPath = join(dir, 'config.json') -``` - -**Severity**: High (Windows incompatibility) - ---- - -### Pattern: process_exit_without_cleanup - -Direct `process.exit()` bypasses CLI error handling framework, preventing proper error messages and cleanup. Exit codes lose meaning when sprinkled throughout code instead of centralized. - -```typescript -// Bypasses framework, no error shown -if (!token) process.exit(1) - -// Framework handles exit code, shows error -if (!token) throw new InputError('API token required') -``` - -**Severity**: High (poor UX, skips cleanup) - ---- - -### Pattern: unsafe_file_operations - -`fs.rm/rmSync` lack safety checks (`.socket` directory guard, symlink protection). Command injection via `rm -rf` + user input enables arbitrary file deletion. - -```typescript -// Deletes without safety checks -import { rmSync } from 'node:fs' -rmSync(dir, { recursive: true }) - -// Validates against dangerous deletions -import { safeDelete } from '@socketsecurity/lib-stable/fs' -await safeDelete(dir) -``` - -**Severity**: Critical (data loss + security) - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: packages/cli/src/path/file.mts:LINE -Issue: [One-line description] -Severity: Critical|High -Pattern: [Code snippet, 2-3 lines] -Trigger: [Input/condition causing the issue] -Fix: [Specific code change] -Impact: [Consequence: crashes/security breach/data corruption] -``` - -Report only production-triggerable issues (ignore test files, theoretical concerns handled by TypeScript strict mode). Focus on runtime failures, not compile-time checks. -</output> - -```` - ---- - -## Scan Type: logic - -**Purpose**: Identifying algorithm errors, edge cases, and validation bugs in business logic. - -**Severity**: High, Medium - -### Agent Prompt - -```markdown -<role> -You are a logic bug detector specializing in package management and security scanning logic. Your goal is identifying incorrect behavior from algorithm errors, missing edge cases, and validation bypasses. -</role> - -<context> -**Repository**: socket-cli (package security scanning CLI) -**Core domains**: Package name validation, manifest parsing (package.json/lockfiles), dependency resolution, security report filtering -**Scan target**: packages/cli/src/ (focus on validation, parsing, filtering logic) -</context> - -<task> -Find logic errors in: -1. Package name/version validation and parsing -2. Manifest parsing (package.json, lock files) -3. Dependency resolution algorithms -4. Security report filtering and sorting -5. CLI argument validation -6. Semver comparison logic - -Edge cases matter because package ecosystems have unusual formats (scoped names, git URLs, workspace protocols, version ranges). Validation bypasses create security holes. -</task> - -<patterns> - -### Pattern: off_by_one -Array indexing uses zero-based indices; `array[array.length]` is always undefined. Loop conditions with `<=` cause accessing beyond bounds. These errors corrupt data or return undefined values. - -```typescript -// Returns undefined - last index is length-1 -const last = array[array.length] - -// Correct -const last = array[array.length - 1] -```` - -**Severity**: High - ---- - -### Pattern: missing_edge_cases - -Package names come in multiple formats: `package`, `@scope/package`, `@scope/package/subpath`. Functions assuming scoped format crash on non-scoped names. Empty string inputs cause unexpected behavior. - -```typescript -// Crashes on non-scoped packages like "lodash" -function getPackageName(fullName: string) { - return fullName.split('/')[1] -} - -// Handles both scoped and non-scoped -function getPackageName(fullName: string) { - const parts = fullName.split('/') - return parts.length > 1 ? parts[1] : parts[0] -} -``` - -**Severity**: High - ---- - -### Pattern: incorrect_type_guards - -`typeof value === 'object'` returns true for `null`, Arrays, and Dates. Boolean coercion (`!!value`) is too broad for type guards. Incorrect guards bypass TypeScript safety, causing runtime errors. - -```typescript -// Matches null, arrays, objects - too broad -if (typeof value === 'object') { - value.foo // Crashes if null -} - -// Correctly excludes null -if (typeof value === 'object' && value !== null) { - value.foo // Safe -} -``` - -**Severity**: High - ---- - -### Pattern: regex_validation_bypass - -Regex without anchors (`^`, `$`) matches substrings, bypassing validation. Missing character classes allow invalid characters. Security validators MUST use anchors to prevent injection. - -```typescript -// Matches inside malicious strings: "evil@scope/packageMALICIOUS" -const isValid = /@[a-z]+\/[a-z]+/.test(name) - -// Only matches exact format -const isValid = /^@[a-z0-9-]+\/[a-z0-9-]+$/.test(name) -``` - -**Severity**: High (security impact) - ---- - -### Pattern: sorting_comparison_errors - -Comparison functions must return negative, zero, or positive values. Returning `1 | -1` without `0` case creates unstable sorts. Lexical sort (`'10' < '2'`) breaks numeric ordering. - -```typescript -// Unstable - never returns 0 for equal values -array.sort((a, b) => (a > b ? 1 : -1)) - -// Stable numeric sort -array.sort((a, b) => a - b) -``` - -**Severity**: Medium - ---- - -### Pattern: filter_logic_errors - -De Morgan's laws: `!(A && B)` ≠ `!A && !B`. Filter conditions often invert incorrectly. Double negation (`!!`) changes semantics from identity check to truthiness check. - -```typescript -// Wrong - keeps only items that are NEITHER low NOR medium -// (keeps high, critical, undefined, "") -const issues = all.filter(i => i.severity !== 'low' && i.severity !== 'medium') - -// Correct - keeps high and critical only -const issues = all.filter( - i => i.severity === 'high' || i.severity === 'critical', -) -``` - -**Severity**: High - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: packages/cli/src/path/file.mts:LINE -Issue: [One-line description] -Severity: High|Medium -Pattern: [Code snippet] -Trigger: [Input causing incorrect behavior] -Fix: [Corrected logic] -Impact: [Wrong results, security bypass, data loss] -``` - -Test edge cases (null, undefined, empty, single-item, duplicates). Validate regex with test inputs. Verify type guards actually narrow types correctly. -</output> - -```` - ---- - -## Scan Type: cache - -**Purpose**: Identifying config/token caching staleness and correctness issues. - -**Severity**: Medium, Low - -### Agent Prompt - -```markdown -<role> -You are a cache correctness analyzer. Your goal is identifying stale cache data, missing invalidation, and race conditions in persistent caches. -</role> - -<context> -**Repository**: socket-cli -**Cached data**: Config files (.socket/config.json), API tokens, file system state -**Access pattern**: Short-lived CLI runs, but cache persists between invocations -**Risk**: Stale cached tokens cause auth failures; stale config causes wrong behavior -</context> - -<task> -Find cache issues: -1. Config cache not invalidated when source file changes (check mtime) -2. Token cache missing expiration validation -3. Cache key generation missing critical parameters (version, environment, platform) -4. Concurrent cache access causing corruption -5. Stale data detection missing - -Caches persisting between CLI runs require invalidation strategies because file changes don't automatically refresh cache. -</task> - -<patterns> - -### Pattern: missing_cache_invalidation -Module-level cache persists across function calls. When source files change between CLI runs, stale cache returns outdated data. Check `mtimeMs` (modification time) to detect changes. - -```typescript -// Never invalidates - returns stale data after config edits -let cachedConfig: Config | undefined -function getConfig() { - if (!cachedConfig) cachedConfig = readConfigFile() - return cachedConfig -} - -// Invalidates when file changes -let cached: { mtime: number, data: Config } | undefined -async function getConfig() { - const stat = await fs.stat(configPath) - if (!cached || cached.mtime !== stat.mtimeMs) { - cached = { data: await readConfigFile(), mtime: stat.mtimeMs } - } - return cached.data -} -```` - -**Severity**: Medium - ---- - -### Pattern: cache_key_missing_params - -Cache keys must include all parameters affecting cached values. Missing package version causes returning results for wrong version. Missing environment causes dev/prod cache collisions. - -```typescript -// Collides: scan-lodash for v1.0.0 and v2.0.0 -const key = `scan-${packageName}` - -// Unique per version -const key = `scan-${packageName}-${version}-${platform}` -``` - -**Severity**: Medium - ---- - -### Pattern: concurrent_cache_corruption - -Read-modify-write sequences race when concurrent CLI invocations run. Atomic file writes (`writeFile` with `w` flag) prevent corruption better than read-modify-write patterns. - -```typescript -// Races - concurrent calls lose updates -async function updateCache(key: string, value: string) { - const cache = await readCache() - cache[key] = value - await writeCache(cache) -} - -// Atomic write (OS-level locking) -await writeFile(cacheFile, JSON.stringify({ [key]: value }), { flag: 'w' }) -``` - -**Severity**: Medium - ---- - -### Pattern: token_cache_no_expiration - -Cached auth tokens expire but cache persists indefinitely. Using expired tokens causes 401 errors. Check expiration timestamp before returning cached tokens. - -```typescript -// Returns expired tokens - causes 401 errors -if (cachedToken) return cachedToken - -// Validates expiration -if (cached && cached.expiresAt > Date.now()) { - return cached.token -} -``` - -**Severity**: Medium - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: packages/cli/src/path/file.mts:LINE -Issue: [One-line description] -Severity: Medium|Low -Pattern: [Code snippet] -Trigger: [When cache becomes stale or races] -Fix: [Add invalidation / expiration / atomic writes] -Impact: [Stale data, auth failures, corruption] -``` - -Focus on persistent caches (survive process exit). Ignore in-memory caches. Check src/util/config.mts and src/util/auth.mts primarily. -</output> - -```` - ---- - -## Scan Type: workflow - -**Purpose**: Identifying build script, CI/CD, and cross-platform compatibility issues. - -**Severity**: High, Medium - -### Agent Prompt - -```markdown -<role> -You are a workflow and build system analyzer. Your goal is identifying cross-platform incompatibilities, missing error handling in build scripts, and CI workflow inefficiencies. -</role> - -<context> -**Repository**: socket-cli -**Build system**: pnpm, rollup, esbuild, TypeScript -**CI**: GitHub Actions (.github/workflows/) -**Platforms**: macOS, Linux, Windows (all must work) -**Convention**: CLAUDE.md requires `pnpm run foo --flag` pattern, `@socketsecurity/lib/*` imports -</context> - -<task> -Find workflow issues: -1. Build scripts missing error handling (commands continue after failures) -2. Cross-platform incompatibilities (Unix-only shell syntax, hardcoded paths) -3. package.json scripts violating CLAUDE.md conventions -4. GitHub Actions missing build optimization (unnecessary dependency installs) -5. Import convention violations (using Node.js built-ins instead of `@socketsecurity/lib/*`) - -Cross-platform issues break Windows builds. Missing error handling causes failed builds to appear successful. Import conventions ensure Socket security patterns are used. -</task> - -<patterns> - -### Pattern: missing_error_handling_in_scripts -Shell `&&` chains execute right side even if left fails (in some shells). Build scripts without `|| exit 1` mask failures, causing CI to pass with broken builds. - -```json -// If tsc fails, rollup runs with stale files - build appears successful -{"scripts": {"build": "tsc && rollup -c"}} - -// Explicit exit on failure -{"scripts": {"build": "tsc && rollup -c || exit 1"}} -```` - -**Severity**: High - ---- - -### Pattern: cross_platform_shell_incompatibility - -Unix shell commands (`rm`, `cp`, `mv`) don't exist on Windows. Cross-platform tools (`del-cli`, `cpy-cli`, `trash-cli`) work everywhere. - -```json -// Fails on Windows - rm.exe doesn't exist -{"scripts": {"clean": "rm -rf dist"}} - -// Works cross-platform -{"scripts": {"clean": "del-cli dist"}} -``` - -**Severity**: High - ---- - -### Pattern: import_conventions_violation - -CLAUDE.md mandates `@socketsecurity/lib/*` imports for spawn, fs operations. Socket Security versions add safety checks (prevent `rm -rf /`, validate spawn args). Node.js built-ins lack these protections. - -```typescript -// Missing Socket security enhancements -import { spawn } from 'node:child_process' - -// Includes Socket security patterns -import { spawn } from '@socketsecurity/registry-stable/lib/spawn' -``` - -**Severity**: Medium - ---- - -### Pattern: package_json_script_naming_violation - -CLAUDE.md requires `pnpm run script --flags` pattern (not `script:variant` scripts). Reducing script count improves maintainability; flags provide flexibility without script explosion. - -```json -// Anti-pattern - creates script explosion -{"scripts": {"test:unit:watch": "vitest --watch", "test:unit:coverage": "vitest --coverage"}} - -// Preferred - use flags -{"scripts": {"test:unit": "vitest"}} -// Run: pnpm run test:unit --watch or pnpm run test:unit --coverage -``` - -**Severity**: Medium - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: .github/workflows/file.yml:LINE or package.json:LINE or src/path/file.mts:LINE -Issue: [One-line description] -Severity: High|Medium -Pattern: [Code/YAML snippet] -Trigger: [Windows build, CI run, cross-platform execution] -Fix: [Use cross-platform tool, add error handling, fix import] -Impact: [Windows failures, masked build errors, missing security checks] -``` - -Check `.github/workflows/*.yml`, `package.json` scripts, `.config/*.mjs`, and `src/**/*.mts` imports. -</output> - -```` - ---- - -## Scan Type: security - -**Purpose**: Identifying GitHub Actions security vulnerabilities and credential exposure. - -**Severity**: Critical, High - -### Agent Prompt - -```markdown -<role> -You are a security analyzer specializing in CI/CD and credential handling. Your goal is identifying template injection, credential exposure, and supply chain vulnerabilities. -</role> - -<context> -**Repository**: socket-cli -**CI**: GitHub Actions (.github/workflows/) -**Credentials**: Socket API tokens, GitHub tokens, npm registry tokens -**Attack vectors**: PR from forks (untrusted input), third-party actions, cache poisoning -</context> - -<zizmor_integration> -**Pre-scan with zizmor**: Before agent analysis, run zizmor to get machine-verified findings: - -```bash -# Run zizmor and capture JSON output -zizmor .github/workflows/*.yml --format json > /tmp/zizmor-output.json - -# Parse findings and include in your analysis -cat /tmp/zizmor-output.json | jq '.[] | {file: .location.path, line: .location.line, rule: .rule, severity: .severity, message: .message}' -```` - -zizmor automatically detects: - -- `artipacked`: Artifact poisoning via `actions/upload-artifact` -- `dangerous-triggers`: Workflows triggered by untrusted events -- `excessive-permissions`: Overly broad GITHUB_TOKEN permissions -- `template-injection`: Expression injection in `run:` blocks -- `unpinned-uses`: Actions without SHA pinning -- `ref-confusion`: Ambiguous git ref resolution -- `self-hosted-runner`: Security risks with self-hosted runners - -Merge zizmor findings with agent-based pattern matching. zizmor may miss context-specific issues that pattern analysis catches. -</zizmor_integration> - -<task> -Find security vulnerabilities: -1. GitHub Actions template injection (untrusted PR input interpolated into `run:` blocks enables command injection) -2. Unpinned third-party actions (tags are mutable; attackers can replace @v4 with malicious code) -3. Credential exposure (tokens logged to console/Sentry/GitHub Actions logs) -4. Secrets in global env (all steps get access, including third-party actions) -5. Cache poisoning (PR from fork can poison cache for main branch) - -These issues enable supply chain attacks, credential theft, and code execution in CI environments. -</task> - -<patterns> - -### Pattern: github_actions_template_injection - -Attacker-controlled PR titles/bodies interpolated directly into `run:` blocks enable command injection. Use environment variables to safely pass untrusted input; shell treats env vars as data, not code. - -```yaml -# Command injection - attacker sets title to: "; rm -rf / #" -- run: echo "PR: ${{ github.event.pull_request.title }}" - -# Safe - env var prevents injection -- run: echo "PR: $TITLE" - env: - TITLE: ${{ github.event.pull_request.title }} -``` - -**Severity**: Critical - ---- - -### Pattern: unpinned_third_party_actions - -Git tags are mutable; attackers with repo access can move `@v4` tag to malicious commit. Pinning to commit SHA (immutable) prevents tag replacement attacks. - -```yaml -# Vulnerable to tag replacement -- uses: actions/checkout@v4 - -# Immutable - commit SHAs can't be changed -- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 -``` - -**Severity**: High - ---- - -### Pattern: credential_exposure_in_logs - -Logged tokens appear in GitHub Actions logs, Sentry traces, local terminal histories. These logs are often world-readable (public repos) or accessible to large teams. - -```typescript -// Leaks full token to logs -console.log('Token:', apiToken) - -// Safe - logs only presence -console.log('Token:', apiToken ? '***' : 'none') -``` - -**Severity**: Critical - ---- - -### Pattern: secrets_in_global_env - -Job-level `env:` exposes secrets to ALL steps, including third-party actions. Malicious actions can exfiltrate credentials. Step-level `env:` limits exposure to specific commands. - -```yaml -# All steps (including third-party actions) see API_TOKEN -jobs: - test: - env: - API_TOKEN: ${{ secrets.SOCKET_API_TOKEN }} - - # Only specific step sees API_TOKEN - steps: - - name: Scan - env: - API_TOKEN: ${{ secrets.SOCKET_API_TOKEN }} - run: socket scan -``` - -**Severity**: High - ---- - -### Pattern: cache_poisoning_risk - -PR from fork can write malicious code to cache, then main branch restores poisoned cache. Restrict caching to trusted workflows (same repo) to prevent supply chain attacks. - -```yaml -# Fork PR poisons cache -- uses: actions/cache@v3 - with: - path: ~/.npm - -# Only cache from same repo -- uses: actions/cache@v3 - if: github.event.pull_request.head.repo.full_name == github.repository -``` - -**Severity**: High - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: .github/workflows/file.yml:LINE or src/path/file.mts:LINE -Issue: [One-line description] -Severity: Critical|High -Pattern: [YAML/code snippet] -Trigger: [Attacker action: malicious PR, fork PR, third-party action] -Fix: [Use env vars, pin SHAs, scope secrets, restrict cache] -Impact: [Remote code execution, credential theft, supply chain compromise] -``` - -Scan `.github/workflows/*.yml` and `src/util/auth.mts`, `src/util/api.mts` for credential handling. Focus on user-controlled inputs (PR metadata, issue bodies, workflow inputs). -</output> - -```` - ---- - -## Scan Type: documentation - -**Purpose**: Identifying documentation errors, outdated examples, and missing docs for public APIs. - -**Severity**: Medium, Low - -### Agent Prompt - -```markdown -<role> -You are a documentation accuracy analyzer. Your goal is identifying broken examples, undocumented flags, and missing API documentation that frustrate users. -</role> - -<context> -**Repository**: socket-cli -**Public documentation**: README.md, command `--help` output from meow -**Internal documentation**: CLAUDE.md, JSDoc comments -**User journey**: Users read README examples, run commands with `--help`, then dive into code -**Pain point**: Broken examples cause frustration; undocumented flags remain undiscovered -</context> - -<task> -Find documentation issues: -1. README command examples that don't work (wrong flags, outdated syntax) -2. CLI flags in code missing from help text (or vice versa) -3. Outdated API endpoint URLs or parameters in comments -4. Missing JSDoc for exported functions (users IDE autocomplete relies on this) -5. Incorrect file paths in documentation (causes confusion when navigating codebase) -6. Help text missing descriptions for required flags - -Documentation errors create support burden; users report bugs that are actually docs being wrong. -</task> - -<patterns> - -### Pattern: incorrect_command_examples -README examples users copy-paste. When examples use removed/renamed flags, users experience immediate failure and assume CLI is broken. Examples must match current implementation. - -```markdown -# README shows --format table, but CLI only supports json/markdown -socket scan --format table -# Error: Invalid format option - -# Correct example -socket scan --format json -```` - -**Severity**: Medium - ---- - -### Pattern: undocumented_flags - -Users discover flags through `--help` output. Undocumented flags remain hidden; users duplicate functionality or request features that already exist. IDE autocomplete relies on description text. - -```typescript -// Users never discover --verbose flag -const flags = { - verbose: { type: 'boolean' }, -} - -// Discoverable via --help -const flags = { - verbose: { - type: 'boolean', - description: 'Enable verbose logging', - }, -} -``` - -**Severity**: Medium - ---- - -### Pattern: missing_jsdoc_for_exports - -VSCode/IDE autocomplete shows JSDoc on hover. Exported utility functions without JSDoc force users to read implementation. Good JSDoc enables understanding without source diving. - -```typescript -// IDE shows: validatePackageName(name: string): boolean -export function validatePackageName(name: string): boolean { - return /^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(name) -} - -// IDE shows full documentation on hover -/** - * Validates package name format. - * Supports scoped (@org/pkg) and unscoped (pkg) packages. - */ -export function validatePackageName(name: string): boolean { - return /^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(name) -} -``` - -**Severity**: Low - ---- - -### Pattern: outdated_api_examples - -API endpoints change during development. Comments referencing old endpoints mislead developers making changes. Maintaining correct API documentation prevents support issues. - -```typescript -// Misleading - v1 is deprecated -// POST /api/v1/scan - -// Current API -// POST /api/v2/scans -``` - -**Severity**: Medium - ---- - -### Pattern: incorrect_file_paths - -Documentation pointing to wrong paths wastes developer time. After refactoring, path references in docs/comments must update. Dead links frustrate contributors. - -```markdown -# Wrong - confuses contributors - -See `src/commands/scan.mts` - -# Correct path after refactoring - -See `src/commands/scan/cmd-scan.mts` -``` - -**Severity**: Low - ---- - -### Pattern: missing_flag_help - -Required flags without descriptions cause confusion. Users don't know format expectations (URL? File path? Token?). Environment variable alternatives should be documented in help text. - -```typescript -// Confusing - no guidance on what apiKey should be -const flags = { - apiKey: { type: 'string', isRequired: true }, -} - -// Clear - explains format and env var alternative -const flags = { - apiKey: { - type: 'string', - isRequired: true, - description: 'Socket API key (or set SOCKET_API_KEY env var)', - }, -} -``` - -**Severity**: Medium - ---- - -</patterns> - -<output> -Structure each finding as: - -``` -File: README.md:LINE or src/path/file.mts:LINE -Issue: [One-line description] -Severity: Medium|Low -Pattern: [Documentation snippet or code] -Trigger: [User follows docs, gets error or confusion] -Fix: [Update docs to match code, or update code to match docs] -Impact: [User frustration, support burden, missed features] -``` - -Compare README examples against actual CLI implementation. Verify all flags have `description` field. Check file paths in comments exist. Focus on exported functions for JSDoc (ignore internal/private). -</output> - -``` - ---- - -## Meta: Using These Prompts - -<usage> -**In SKILL.md Phase 6**: Copy the full agent prompt (including role, context, task, patterns, output) for the desired scan type. - -**Pass to Task tool**: Use `subagent_type='general-purpose'` with the complete prompt as the task description. - -**Collect findings**: Agent returns structured findings in the specified output format. - -**Aggregate in Phase 7**: Deduplicate and sort all findings. -</usage> - -<customization> -Add new patterns: Insert pattern sections with WHY explanations and examples. - -Adjust severity: Change levels based on project impact (crashes = Critical, UX issues = Medium). - -Focus areas: Modify `<task>` section to target specific files or concern areas. -</customization> - -<consistency> -All patterns follow this structure: -- First paragraph: WHY this matters (consequences, rationale) -- Code example: Problematic → Correct -- Severity: Impact level - -All findings follow this structure: -- File: path/to/file.mts:LINE -- Issue: [Description] -- Severity: Level -- Pattern: [Code] -- Trigger: [Condition] -- Fix: [Solution] -- Impact: [Consequence] -</consistency> - ---- - -**Version**: 1.1.0 (2026-03-24) -``` diff --git a/.claude/skills/scanning-quality/scans/bundle-trim.md b/.claude/skills/scanning-quality/scans/bundle-trim.md deleted file mode 100644 index 51eb38a93..000000000 --- a/.claude/skills/scanning-quality/scans/bundle-trim.md +++ /dev/null @@ -1,63 +0,0 @@ -# Bundle-trim scan - -Identifies unused module paths the rolldown bundler statically pulls into `dist/` but that the runtime never reaches. Reports candidates only — does NOT mutate the repo. The active trim loop (stub → rebuild → tests pass → keep) lives in the `trimming-bundle` skill. - -## Mission - -For each repo that ships a rolldown bundle, look at `dist/index.js` (or the primary entry) and compare the set of statically-resolved imports against the set of imports actually reachable from the published API surface. The delta is the candidate set — modules the bundler kept that the runtime can't reach. - -## Inputs - -- `dist/` — the most recent build output. If missing or stale, the scan flags "build first" and skips. -- `.config/rolldown.config.mts` — required (signal that this repo uses rolldown). -- `.config/rolldown/lib-stub.mts` — required (the canonical plugin the trim skill uses). If missing, the scan flags "cascade missing canonical plugin" and skips. -- `src/index.ts` (or the entry declared in `package.json` `exports`) — the published API surface. - -## Skip when - -- `.config/rolldown.config.mts` doesn't exist (repo doesn't use rolldown). -- `.config/rolldown/lib-stub.mts` doesn't exist (cascade gap; surface as a separate finding). -- `dist/` doesn't exist (run `pnpm build` first; surface as a separate finding). - -## Method - -1. **Survey resolved imports**: `rg --no-heading "from '@socketsecurity/lib/[^']+'" dist/` — list of every lib subpath the bundle imported. -2. **Survey published surface**: read `src/index.ts` (or `package.json` `exports`-pointed entry) end-to-end and collect every transitively-reached lib subpath. Walk re-exports. -3. **Compute delta**: subpaths in (1) but not in (2) are candidates. -4. **Verify reachability claim** (cheap pass; the trim skill does the deep verification before stubbing): for each candidate, `rg --no-heading "<subpath-name>" src/` should return zero hits in src. Hits mean the subpath IS reached and the candidate is a false positive. -5. **Estimate size impact**: `du -b dist/<file>` for the heaviest candidates. - -## Output shape - -``` -### Bundle Trim - -Bundle: dist/index.js (current size: <N> KB) -Plugin status: createLibStubPlugin imported (current stubPattern: /<regex>/) - -Candidates (sorted by size, heaviest first): -- @socketsecurity/lib/<subpath> — <KB> potential savings - Reason: imported by bundle, not reached from src/index.ts - Verify: src/ has zero hits for `<subpath-name>` - Confidence: HIGH | MEDIUM | LOW - Action: hand to trimming-bundle skill for stub loop - -If 0 candidates: - ✓ No unreachable lib subpaths detected. Bundle is tree-shaken cleanly. -``` - -Confidence levels: - -- **HIGH** — subpath is in the import survey, has zero hits in `src/`, and the trim skill's Phase 3 verify would pass. -- **MEDIUM** — subpath is in the survey, has hits in `src/` but only inside files that aren't reached from the entry. The trim skill needs to walk the reachability graph to confirm. -- **LOW** — subpath is in the survey but the static analysis is ambiguous. Skip in the report or leave for manual investigation. - -## When to escalate - -If candidates total >50KB and the repo is npm-published (consumers bear the bundle weight), prioritize handing off to the `trimming-bundle` skill before the next release. Bundle bloat is a quality issue users feel. - -## Cross-references - -- `trimming-bundle` skill — the active trim loop. This scan reports; that skill mutates. -- `.config/rolldown/lib-stub.mts` — the canonical plugin. Both scan and skill require it to exist. -- `socket-packageurl-js/docs/rolldown-migration.md` — worked example of bundle-size baseline tracking. diff --git a/.claude/skills/scanning-quality/scans/deadcode-removal.md b/.claude/skills/scanning-quality/scans/deadcode-removal.md deleted file mode 100644 index 8bdb1dda1..000000000 --- a/.claude/skills/scanning-quality/scans/deadcode-removal.md +++ /dev/null @@ -1,126 +0,0 @@ -# Deadcode-removal scan - -Identifies dead source files, unused exports, stale lint-disable directives, and test-only helpers (helpers whose only consumer is the colocated `.test.mts`). Reports candidates; the active deletion loop lives in any of the existing refactor skills the user prefers — this scan is read-only. - -## Mission - -Surface four shapes of dead code: - -1. **Whole dead files** — source files with no importers anywhere (excluding their own test). Examples this scan caught in past sessions: `rich-progress.mts`, `bordered-input.mts`, `result-assertions.mts` (entire test-helper modules), `build-pipeline.mts`, `extraction-cache.mts`. -2. **Test-only helpers** — exports whose ONLY non-self consumer is the colocated `<file>.test.mts`. The helper exists for the test; the test exists for the helper; nothing real calls either. Per the fleet rule discussion: _exports exist for tests_ — but if NOTHING in `src/` reaches the helper, both should be deleted together. -3. **Stale lint-disable directives** — `// eslint-disable-next-line <rule>` or `// oxlint-disable-next-line <rule>` comments where the rule no longer fires on the line below (rule was relaxed, the offending construct was rewritten, etc.). Detected via `oxlint --report-unused-disable-directives`. -4. **Dead string-literal constants** — `const FOO = '...'` declarations with zero readers, including the declaring file. Often a leftover from a colocation pass that dropped `export` from a now-unused symbol. - -## Inputs - -- `git ls-files` — to enumerate tracked source + test files. -- `pnpm exec oxlint --config .config/oxlintrc.json --report-unused-disable-directives .` — canonical detector for shape (3). Treat oxlint's emit as authoritative. -- `tsc --noEmit` with `noUnusedLocals` — surfaces shape (4) (constants/types with no readers including self). - -## Skip when - -- The repo's `package.json` declares it as a published library (e.g. `socket-lib`, `socket-registry`, `socket-sdk-js`) AND the candidate symbol IS in the public `exports` map. Published API surface is deliberately wide; "no internal consumer" doesn't mean "no external consumer." -- The candidate is fleet-canonical (cascaded from `socket-wheelhouse/template/`). Edit the wheelhouse copy, not the downstream. Compare with `md5sum` to confirm. - -## CRITICAL: do NOT do this - -🚨 **Never drop the `export` keyword on a top-level function** to make it "file-private." The fleet rule `socket/export-top-level-functions` REQUIRES `export` on every top-level helper, with companion rule `socket/sort-source-methods` enforcing visibility-group ordering. - -**Why:** _Exports exist for tests._ The colocated `.test.mts` imports internal helpers directly and asserts on them — that's the testability contract. Dropping `export` breaks the test's import. Past incident: a "colocate unused exports" sweep across 52 files in `packages/cli/src` triggered 141 lint violations and had to be reverted in `cdbbcf2f7`. Memory entry: `feedback-export-top-level-functions.md`. - -**Correct surgical moves for a "test-only helper":** - -- Delete the helper AND its test together (shape 2 above). The test wasn't covering real behavior. -- Or: keep the helper exported and accept the wide surface; the export is the cost of testability. - -**Never:** - -- Drop `export` to "shrink the public API surface." -- Convert an exported function to `function name(...)` (file-scope private) without also deleting it entirely. - -## Method - -### Shape 1: whole dead files - -For each `src/**/*.mts` (excluding `.test.mts`, entry-point binaries like `npm-cli.mts`, barrel `index.mts`): - -1. Has a colocated `.test.mts` or `test/unit/<...>.test.mts`? If not, skip this shape (handled by shape 2). -2. `git grep` for the basename in `src/`, `scripts/`, sibling packages (excluding `dist/`, `build/`, `coverage/`, the file itself, and the colocated test). Match both `from '.../<name>(.mts|.mjs|.ts|.js)?'` and bare references through barrel re-exports. -3. If zero non-test importers, candidate for shape-1 deletion. - -### Shape 2: test-only helpers - -For each exported name in `src/<file>.mts`: - -1. Check whether the colocated `.test.mts` references it. -2. Check whether ANY other src file (or scripts/, sibling packages) references it. -3. If colocated test references it AND no other source references it → test-only helper. The pair (helper + test block) is dead code. - -### Shape 3: stale lint-disable directives - -```bash -pnpm exec oxlint --config .config/oxlintrc.json --report-unused-disable-directives . > /tmp/oxlint-disable.out 2>&1 -grep -c "Unused (oxlint|eslint)-disable" /tmp/oxlint-disable.out -``` - -For each match: the directive line should be deleted. Common stale patterns: - -- `// eslint-disable-next-line no-await-in-loop` — oxlint doesn't know this rule, so the disable is unused. -- `// eslint-disable-next-line n/no-process-exit` — same. -- `// oxlint-disable-next-line socket/prefer-cached-for-loop` — rule was relaxed for destructuring patterns; the disable is now dead noise. -- `/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable */` at line 1 of a file pointing at a block-disable on line 2 — when line 2 gets removed in an earlier strip, this one becomes orphaned. - -### Shape 4: dead constants - -Run `tsc --noEmit` (with `noUnusedLocals` enabled in tsconfig). Each `TS6133: 'X' is declared but its value is never read` finding is a dead constant/type/function — usually surfaced after a strip of stale disables removed the last consumer. Delete entirely. - -## Output shape - -``` -### Deadcode Removal - -**Shape 1: whole-file deletions** (N candidates) -- `packages/cli/src/util/terminal/rich-progress.mts` (333 LOC + colocated test 544 LOC) - Reason: zero non-test importers. The test exists only to cover the helper. - Action: delete both the src file and its test together. - -**Shape 2: test-only helpers** (N candidates) -- `packages/cli/src/util/foo.mts:formatBar` - Test consumer: `packages/cli/test/unit/util/foo.test.mts` - Other consumers: none. - Action: delete the helper AND drop the matching test block — don't preserve the test alone. - -**Shape 3: stale lint-disable directives** (N occurrences) -- 65× `// eslint-disable-next-line no-await-in-loop` -- 30× `// eslint-disable-next-line n/no-process-exit` -- 65× `// oxlint-disable-next-line socket/prefer-cached-for-loop` - Action: strip the directive line. Re-run oxlint to confirm zero new violations. - -**Shape 4: dead constants surfaced by tsc** (N candidates) -- `packages/cli/src/foo.mts:42 SOMETHING_CONST` -- ... - -Total: shape-1 LOC × N + shape-2 LOC × N + N stale directives + N dead constants -``` - -## Verification BEFORE acting - -Before deleting ANY candidate, run both checks: - -1. `tsc --noEmit -p packages/<pkg>/tsconfig.json` — must pass after the proposed delete. -2. `pnpm exec oxlint --config .config/oxlintrc.json .` — must report zero violations after the proposed delete. - -If lint surfaces new `socket/export-top-level-functions` violations after a colocate-style change, **revert the change immediately**. Don't try to "fix" the lint by changing function order or adding disable comments — the rule wants the `export` keyword. - -## When to escalate - -- Shape-1 candidates totaling >500 LOC: high-confidence cleanup, hand off to a refactor pass. -- Shape-3 with >100 stale directives: indicates a recent rule-tightening cycle; consider opening a PR with just the strip. -- If `socket/export-top-level-functions` violations exceed 5 in a single file, the file is probably mid-refactor — pause the scan on that file and surface as a Medium finding for the author to resolve before another sweep. - -## Cross-references - -- `feedback-export-top-level-functions.md` — memory entry capturing the rule's intent and the past colocate incident. -- `socket/export-top-level-functions` — fleet oxlint rule in `template/.config/oxlint-plugin/`. -- `socket/sort-source-methods` — companion rule for visibility-group ordering. -- `feedback_repo_hygiene.md` — broader hygiene guidance ("No doc litter, pin deps, etc."). diff --git a/.claude/skills/scanning-security/SKILL.md b/.claude/skills/scanning-security/SKILL.md deleted file mode 100644 index 489c5ff7f..000000000 --- a/.claude/skills/scanning-security/SKILL.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: scanning-security -description: Runs a multi-tool security scan: AgentShield for Claude config, zizmor for GitHub Actions, and optionally Socket CLI for dependency scanning. Produces an A-F graded security report. Use after modifying `.claude/` config, hooks, agents, or GitHub Actions workflows, and before releases. -user-invocable: true -allowed-tools: Task, Read, Bash(pnpm exec agentshield:*), Bash(zizmor:*), Bash(command -v:*), Bash(find .cache/external-tools/zizmor:*) ---- - -# scanning-security - -Multi-tool security scanning pipeline for the repository. - -## When to Use - -- After modifying `.claude/` config, settings, hooks, or agent definitions -- After modifying GitHub Actions workflows -- Before releases (called as a gate by the release pipeline) -- Periodic security hygiene checks - -## Prerequisites - -See `_shared/security-tools.md` for tool detection and installation. - -## Process - -### Phase 1: Environment Check - -Follow `_shared/env-check.md`. Initialize a queue run entry for `scanning-security`. - ---- - -### Phase 2: AgentShield Scan - -Scan Claude Code configuration for security issues: - -```bash -pnpm exec agentshield scan -``` - -Checks `.claude/` for: - -- Hardcoded secrets in CLAUDE.md and settings -- Overly permissive tool allow lists (e.g. `Bash(*)`) -- Prompt injection patterns in agent definitions -- Command injection risks in hooks -- Risky MCP server configurations - -Capture the grade and findings count. - -Update queue: `current_phase: agentshield` → `completed_phases: [env-check, agentshield]` - ---- - -### Phase 3: Zizmor Scan - -Scan GitHub Actions workflows for security issues. - -See `_shared/security-tools.md` for zizmor detection. If not installed, skip with a warning. - -```bash -zizmor .github/ -``` - -Checks for: - -- Unpinned actions (must use full SHA, not tags) -- Secrets used outside `env:` blocks -- Injection risks from untrusted inputs (template injection) -- Overly permissive permissions - -Capture findings. Update queue phase. - ---- - -### Phase 4: Grade + Report - -Spawn the `security-reviewer` agent (see `agents/security-reviewer.md`) with the combined output from AgentShield and zizmor. - -The agent: - -1. Applies CLAUDE.md security rules to evaluate the findings -2. Calculates an A-F grade per `_shared/report-format.md` -3. Generates a prioritized report (CRITICAL first) -4. Suggests fixes for HIGH and CRITICAL findings -5. For every Critical / High finding, runs variant analysis per [`_shared/variant-analysis.md`](../_shared/variant-analysis.md). The same misconfiguration likely exists in sibling workflow files, sibling Claude config blocks, or other repos. - -Output a HANDOFF block per `_shared/report-format.md` for pipeline chaining. - -Update queue: `status: done`, write `findings_count` and final grade. - -## Adjacent scans - -Code-side security (insecure defaults, fail-open patterns, security-regression in a diff) lives in `scanning-quality`'s modular scans: - -- [`scanning-quality/scans/insecure-defaults.md`](../scanning-quality/scans/insecure-defaults.md): code-side fail-open defaults. -- [`scanning-quality/scans/differential.md`](../scanning-quality/scans/differential.md): security regressions introduced by the current diff. - -This skill stays focused on **config security** (Claude config + GitHub Actions). The split keeps the surface predictable: `scanning-security` = "is the harness safe?", `scanning-quality/scans/` = "is the code safe?". - -## Commit cadence - -This skill is read-only: scan + grade + report, no fixes. Cadence rules apply to handing the report off: - -- **Save the report before acting.** Commit the report file in its own commit (`docs(reports): scanning-security YYYY-MM-DD: grade <A-F>`). The grade in the message makes the trend visible without opening the file. -- **Don't fix in-skill.** Security findings need careful per-finding triage; they're not safe to batch-fix mechanically. Open per-finding fixes as separate commits driven by the appropriate skill (or hand-edit when the fix is a one-liner like a workflow SHA bump). -- **One report per scan run.** Re-running produces a new report; commit each so the security trend line is auditable. diff --git a/.claude/skills/updating-checksums/SKILL.md b/.claude/skills/updating-checksums/SKILL.md deleted file mode 100644 index 97777b04c..000000000 --- a/.claude/skills/updating-checksums/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: updating-checksums -description: > - Syncs SHA-256 checksums from GitHub releases to bundle-tools.json. - Triggers when user mentions "update checksums", "sync checksums", or after - releasing new tool versions. -user-invocable: true -allowed-tools: Bash, Read, Edit ---- - -# updating-checksums - -<task> -Your task is to sync SHA-256 checksums from GitHub releases to the embedded `bundle-tools.json` file, ensuring SEA builds have up-to-date integrity verification. -</task> - -<constraints> -- Network access required to fetch from GitHub API. -- Only `github-release` type tools are synced (not npm or pypi). -- Never modify checksums manually; always fetch from releases. -- Verify JSON validity after sync. -- Review changes before committing. -</constraints> - -## Phases - -1. **Check Current State** - Review current checksums and tool versions in `packages/cli/bundle-tools.json`. -2. **Sync Checksums** - Run `node packages/cli/scripts/sync-checksums.mjs`. Tries `checksums.txt` from the release first; falls back to downloading assets and computing SHA-256. -3. **Verify Changes** - `git diff packages/cli/bundle-tools.json`; validate JSON syntax. -4. **Commit Changes** - If updated, commit `packages/cli/bundle-tools.json`. - -## Commands - -```bash -node packages/cli/scripts/sync-checksums.mjs # Sync all -node packages/cli/scripts/sync-checksums.mjs --tool=opengrep # Sync one -node packages/cli/scripts/sync-checksums.mjs --dry-run # Preview -node packages/cli/scripts/sync-checksums.mjs --force # Force update -``` diff --git a/.claude/skills/updating-checksums/reference.md b/.claude/skills/updating-checksums/reference.md deleted file mode 100644 index 3877521fa..000000000 --- a/.claude/skills/updating-checksums/reference.md +++ /dev/null @@ -1,293 +0,0 @@ -# updating-checksums Reference Documentation - -This document provides detailed information about external tool checksums, the sync script, and troubleshooting for the updating-checksums skill. - -## Table of Contents - -1. [External Tools Inventory](#external-tools-inventory) -2. [Checksum Sync Script](#checksum-sync-script) -3. [GitHub Release Tools](#github-release-tools) -4. [Checksum Formats](#checksum-formats) -5. [Edge Cases](#edge-cases) -6. [Troubleshooting](#troubleshooting) - ---- - -## External Tools Inventory - -### GitHub Release Tools (synced by this skill) - -| Tool | Repository | Release Tag Format | Has checksums.txt | -| ------------ | --------------------------------- | ------------------ | ----------------- | -| opengrep | opengrep/opengrep | `v*.*.*` | Yes | -| python | astral-sh/python-build-standalone | `*.*.*` | No (computed) | -| socket-patch | SocketDev/socket-patch | `v*.*.*` | Varies | -| sfw | SocketDev/sfw-free | `v*.*.*` | Varies | -| trivy | aquasecurity/trivy | `v*.*.*` | Yes | -| trufflehog | trufflesecurity/trufflehog | `v*.*.*` | Yes | - -### Non-GitHub Tools (NOT synced by this skill) - -| Tool | Type | Integrity Method | -| ----------------- | ------------- | ------------------ | -| @coana-tech/cli | npm | SRI integrity hash | -| @cyclonedx/cdxgen | npm | SRI integrity hash | -| synp | npm | SRI integrity hash | -| socketsecurity | pypi | SRI integrity hash | -| socket-basics | github-source | None | - ---- - -## Checksum Sync Script - -### Location - -`packages/cli/scripts/sync-checksums.mjs` - -### How It Works - -1. Reads `packages/cli/bundle-tools.json` -2. Filters tools with `type: "github-release"` -3. For each tool: - a. Fetches the GitHub release by tag - b. Looks for `checksums.txt` asset - c. If found: parses SHA-256 hashes from checksums.txt - d. If not found: downloads each release asset and computes SHA-256 via `crypto.createHash('sha256')` -4. Compares new checksums with existing -5. Writes updated checksums to bundle-tools.json - -### Command Reference - -```bash -# Sync all GitHub release tools -node packages/cli/scripts/sync-checksums.mjs - -# Sync specific tool only -node packages/cli/scripts/sync-checksums.mjs --tool=opengrep - -# Preview changes without writing -node packages/cli/scripts/sync-checksums.mjs --dry-run - -# Force update even if unchanged -node packages/cli/scripts/sync-checksums.mjs --force -``` - -### Expected Output - -``` -Syncing checksums for N GitHub release tool(s)... - -[opengrep] opengrep/opengrep @ v1.16.0 - Found checksums.txt, downloading... - Parsed 5 checksums from checksums.txt - Updated: 2 checksums, Unchanged: 3 checksums - -[trivy] aquasecurity/trivy @ v0.58.2 - Found checksums.txt, downloading... - Parsed 12 checksums from checksums.txt - Unchanged: 12 checksums - -Summary: X updated, Y unchanged -``` - ---- - -## GitHub Release Tools - -### Release Asset Patterns - -Each tool has specific asset naming conventions: - -**opengrep:** - -- `opengrep-core_linux_aarch64.tar.gz` -- `opengrep-core_linux_x86.tar.gz` -- `opengrep-core_osx_aarch64.tar.gz` -- `opengrep-core_osx_x86.tar.gz` -- `opengrep-core_windows_x86.zip` -- Includes `checksums.txt` - -**python (python-build-standalone):** - -- `cpython-{version}+{buildTag}-{target}-{config}.tar.zst` -- No checksums.txt — hashes computed by downloading each asset - -**socket-patch:** - -- `socket-patch-aarch64-apple-darwin.tar.gz` -- `socket-patch-x86_64-apple-darwin.tar.gz` -- `socket-patch-aarch64-unknown-linux-gnu.tar.gz` -- `socket-patch-x86_64-unknown-linux-musl.tar.gz` -- `socket-patch-aarch64-pc-windows-msvc.zip` -- `socket-patch-x86_64-pc-windows-msvc.zip` - -**sfw (sfw-free):** - -- `sfw-free-linux-arm64` -- `sfw-free-linux-x86_64` -- `sfw-free-macos-arm64` -- `sfw-free-macos-x86_64` -- `sfw-free-musl-linux-arm64` -- `sfw-free-musl-linux-x86_64` -- `sfw-free-windows-x86_64.exe` - -**trivy:** - -- `trivy_{version}_Linux-64bit.tar.gz` -- `trivy_{version}_Linux-ARM64.tar.gz` -- `trivy_{version}_macOS-64bit.tar.gz` -- `trivy_{version}_macOS-ARM64.tar.gz` -- `trivy_{version}_windows-64bit.zip` -- Includes `trivy_{version}_checksums.txt` - -**trufflehog:** - -- `trufflehog_{version}_linux_amd64.tar.gz` -- `trufflehog_{version}_linux_arm64.tar.gz` -- `trufflehog_{version}_darwin_amd64.tar.gz` -- `trufflehog_{version}_darwin_arm64.tar.gz` -- `trufflehog_{version}_windows_amd64.tar.gz` -- `trufflehog_{version}_windows_arm64.tar.gz` -- Includes checksums in release - -### Checksum Storage Format - -In `bundle-tools.json`, checksums are stored as: - -```json -{ - "checksums": { - "asset-filename.tar.gz": "hex-encoded-sha256-hash", - "asset-filename-2.tar.gz": "hex-encoded-sha256-hash" - } -} -``` - ---- - -## Checksum Formats - -### checksums.txt Format - -Standard format used by most tools: - -``` -sha256hash filename -sha256hash filename -``` - -- Two or more spaces between hash and filename -- SHA-256 hex-encoded (64 characters) -- One entry per line - -### Computed Checksums - -When no checksums.txt is available: - -```javascript -// Script computes SHA-256 by streaming the downloaded file -const hash = crypto.createHash('sha256') -const stream = fs.createReadStream(filePath) -stream.pipe(hash) -// Result: hex-encoded SHA-256 -``` - ---- - -## Edge Cases - -### Tool with Dual Configuration (sfw) - -The `sfw` tool has both a GitHub release binary (`SocketDev/sfw-free`) and an npm package (`sfw` on npmjs.com). Both are tracked in the same `bundle-tools.json` entry via `type: "github-release"` for the binary checksums and `npmPackage`/`npmVersion` fields for the npm component. The checksums skill only handles the GitHub release binary checksums; the npm package version is updated separately via `pnpm run update`. - -### python-build-standalone - -This tool has no checksums.txt in releases. The sync script must: - -1. Download each release asset -2. Compute SHA-256 locally -3. This is significantly slower than parsing checksums.txt - -### Version Tag Variations - -Different tools use different tag formats: - -- Most use `v{version}` (e.g., `v1.16.0`) -- python-build-standalone uses bare version (e.g., `3.11.14`) -- The `githubRelease` field in bundle-tools.json stores the exact tag - -### Stale Checksums After Version Bump - -If someone updates a tool version in bundle-tools.json but forgets to sync checksums: - -- SEA builds will fail integrity verification -- Always run checksum sync after any version change - ---- - -## Troubleshooting - -### GitHub API Rate Limiting - -**Symptom:** Script fails with 403 or rate limit error. - -**Solution:** - -```bash -# Check current rate limit -gh api rate_limit --jq '.rate' - -# Ensure authenticated -gh auth status -``` - -Authenticated requests get 5,000 requests/hour vs 60 for unauthenticated. - -### Release Not Found - -**Symptom:** Script reports release not found for a tool. - -**Cause:** The `githubRelease` tag in bundle-tools.json doesn't match any release. - -**Solution:** - -```bash -# Verify release exists -gh release view <tag> --repo <owner/repo> - -# List recent releases -gh release list --repo <owner/repo> --limit 5 -``` - -### Checksum Mismatch After Update - -**Symptom:** Checksums changed but tool version didn't. - -**Cause:** Release assets were re-uploaded (some projects rebuild releases). - -**Solution:** This is expected in rare cases. Review the diff to ensure it's a legitimate update, then commit. - -### JSON Validation Failure - -**Symptom:** Updated bundle-tools.json is invalid JSON. - -**Solution:** - -```bash -# Validate JSON -node -e "JSON.parse(require('fs').readFileSync('packages/cli/bundle-tools.json'))" - -# If corrupted, restore and retry -git checkout packages/cli/bundle-tools.json -node packages/cli/scripts/sync-checksums.mjs -``` - -### Large Downloads Timeout - -**Symptom:** python-build-standalone sync times out (large assets). - -**Solution:** - -- Sync specific tool: `--tool=python` -- Ensure stable network connection -- The script handles retries for individual assets diff --git a/.claude/skills/updating-coverage/SKILL.md b/.claude/skills/updating-coverage/SKILL.md deleted file mode 100644 index 3b60da0d8..000000000 --- a/.claude/skills/updating-coverage/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: updating-coverage -description: Refresh the coverage badge in the root README by running the repo's coverage script and rewriting the `![Coverage](https://img.shields.io/badge/coverage-<PCT>%25-brightgreen)` line. Sibling of `updating-security` / `updating-lockstep` under the `updating` umbrella. -user-invocable: true -allowed-tools: Read, Edit, Bash(pnpm run cover:*), Bash(pnpm run coverage:*), Bash(pnpm run test:cover:*), Bash(node:*), Bash(git:*), Bash(jq:*), Bash(cat:*) ---- - -# updating-coverage - -Runs the repo's coverage script and rewrites the README badge so the published number matches reality. Invoked directly via `/update-coverage` or as a phase of the `updating` umbrella. - -## When to use - -- After landing a substantial change to test coverage (added a major - feature with tests, removed a large untested module). -- Pre-release, to refresh the public badge. -- As part of `updating` umbrella flow when the repo declares a - coverage script. - -## What it does NOT do - -- **Generate coverage from scratch.** This skill consumes the output of the repo's existing coverage tooling (vitest / c8 / istanbul / node-test coverage). If no coverage script is declared in `package.json`, the skill reports that and exits. -- **Compute coverage thresholds.** The badge reflects what the - tooling reports; tightening the threshold is a separate decision - in the repo's vitest/c8 config. -- **Modify nested READMEs.** Only the repo-root `README.md` is - rewritten. Nested READMEs under `packages/*` have their own - badges and lifecycles. - -## Phases - -| # | Phase | Outcome | -| --- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -| 1 | Discovery | Find the coverage script in `package.json` (`cover` / `coverage` / `test:cover`, in that preference). | -| 2 | Run | `pnpm run <script>`. Capture stdout. Fail loudly if the run errors. | -| 3 | Parse | Extract the percentage. Two paths: read `coverage/coverage-summary.json` if present, otherwise scrape `All files \| ...` line. | -| 4 | Rewrite | Replace the `<PCT>` in the README badge URL with the parsed value (two decimals). | -| 5 | Commit | `docs(readme): refresh coverage badge to N.NN%`. Direct-push per fleet norm. | - -## Phase 1: discovery - -```sh -node -e ' -const p = require("./package.json").scripts ?? {}; -for (const name of ["cover", "coverage", "test:cover"]) { - if (p[name]) { console.log(name); process.exit(0); } -} -process.exit(1);' -``` - -If no matching script exists, the skill emits `no coverage script found` and exits cleanly (this is not a failure mode; many fleet repos don't track coverage). - -## Phase 2: run - -```sh -pnpm run <SCRIPT> -``` - -Use the standard pnpm runner so we pick up the repo's own env config (catalog versions, etc.). - -## Phase 3: parse - -**Preferred path**: read `coverage/coverage-summary.json` (vitest / istanbul format): - -```sh -jq -r '.total.lines.pct' coverage/coverage-summary.json -``` - -The number is a float with one decimal place. Two decimals is the canonical badge format; pad with `.00` when needed. - -**Fallback path**: scrape the `All files | ...` line from coverage stdout: - -```sh -pnpm run cover | tee /tmp/cover-output.txt -awk -F '|' '/^All files/ { gsub(/ /, "", $2); print $2 }' /tmp/cover-output.txt -``` - -Whichever column the tool prints first (statements vs lines) is acceptable; the badge is approximate by design. Document the column choice in the commit message. - -## Phase 4: rewrite - -The canonical badge line in `README.md` is: - -```markdown -![Coverage](https://img.shields.io/badge/coverage-<PCT>%25-brightgreen) -``` - -Use the Edit tool to replace the `<PCT>` placeholder with the actual percentage. The `%25` is URL-encoded `%`; leave it alone. - -If the README has been canonicalized but the badge still reads `<PCT>` (e.g. just-canonicalized by the readme-skeleton work), Phase 4 substitutes; otherwise the existing number is replaced. - -## Phase 5: commit - -```sh -git add README.md -git commit -m "docs(readme): refresh coverage badge to <N.NN>%" -git push origin <default-branch> -``` - -Direct-push per the fleet's `Commits & PRs → Push policy` rule; fall back to PR if the remote rejects. - -## Output - -When called via `/update-coverage`, emit a one-line summary: - -``` -updated coverage badge: 96.42% → 97.18% (source: coverage/coverage-summary.json) -``` - -When no coverage script exists or the percentage is unchanged, exit silently. - -## Related - -- `.claude/skills/updating/SKILL.md`: umbrella that calls this skill when applicable. -- `.claude/skills/updating-security/SKILL.md`: sibling under `updating`. -- `template/README.md`: canonical README skeleton ships the placeholder badge. diff --git a/.claude/skills/updating-lockstep/SKILL.md b/.claude/skills/updating-lockstep/SKILL.md deleted file mode 100644 index 38b9245e2..000000000 --- a/.claude/skills/updating-lockstep/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: updating-lockstep -description: Acts on `lockstep.json` drift for repos that carry the lockstep manifest. Reads `pnpm run lockstep --json`, auto-bumps mechanical `version-pin` rows, surfaces `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows as advisory. Invoked by the `updating` umbrella skill; can also run standalone. -user-invocable: true -allowed-tools: Read, Edit, Grep, Glob, Bash(pnpm:*), Bash(npm:*), Bash(git:*), Bash(node:*), Bash(rg:*), Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(cat:*), Bash(head:*), Bash(tail:*), Bash(wc:*), Bash(diff:*) ---- - -# updating-lockstep - -Acts on drift in `lockstep.json`. Auto-applies mechanical `version-pin` bumps; surfaces everything else as advisory notes for human review. Each actioned row becomes its own atomic commit so the PR reviewer can accept / reject per-row. - -## When to use - -- Invoked by the `updating` umbrella skill (weekly-update workflow). -- Standalone: `/updating-lockstep` to sync just the lockstep manifest. -- After manual submodule bumps, to refresh `lockstep.json` metadata. - -Exits cleanly when `lockstep.json` is absent. Not every fleet repo has one. - -## Per-kind policy at a glance - -`version-pin` is mechanical (auto-bump per `upgrade_policy`). Everything else is advisory. Upstream semantics and local deltas need human judgment. - -Full policy table, scripts per phase, and advisory format in [`reference.md`](reference.md). - -## Phases - -| # | Phase | Outcome | -| --- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Pre-flight | Bail if no `lockstep.json`. Verify scaffolding (`lockstep.schema.json`, `scripts/lockstep.mts`). Clean tree. Detect CI mode. | -| 2 | Collect drift | `pnpm run lockstep --json` → split rows into **auto** (mechanical version-pin bumps) and **advisory** (everything else with drift). | -| 3 | Auto-bump | Per row: resolve submodule, fetch tags, identify target tag, checkout, update `lockstep.json` + `.gitmodules`, validate, commit (`chore(deps): bump <upstream> to <tag>`). Test before committing in interactive mode. | -| 4 | Advisory | Compose per-row markdown lines for the PR body. | -| 5 | Report | Human-readable summary; in CI mode, emit advisory block to `$GITHUB_OUTPUT` (base64); HANDOFF block per `_shared/report-format.md`. | - -## Hard requirements - -- **Bail safely on missing manifest**: exit 0 cleanly if `lockstep.json` is absent. -- **Atomic commits**: one commit per auto-bumped row. Conventional Commits format. -- **`.gitmodules` version comments**: keep `# <name>-<version>` annotations synchronized with `pinned_tag`. -- **Stable releases only**: filter `-rc` / `-alpha` / `-beta` / `-dev` / `-snapshot` / `-nightly` / `-preview` (full pattern in `reference.md`). -- **No `npx` / `pnpm dlx` / `yarn dlx`**: `pnpm exec` or `pnpm run` per CLAUDE.md _Tooling_. -- **Edit tool, not `sed`**: for `.gitmodules` annotation updates. - -## Forbidden - -- Auto-editing `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows' tracked state. Advisory only. -- Bumping a `locked` `version-pin` without human approval (gated on coordinated upstream change). -- Skipping the tag-stability filter. - -## CI vs interactive mode - -- **CI** (`CI=true` / `GITHUB_ACTIONS`): skip per-row test validation; emit advisory to `$GITHUB_OUTPUT`. -- **Interactive** (default): run `pnpm test` before each auto-bump commit; rollback the row on failure and continue. - -## Success criteria - -- All actionable `version-pin` rows bumped atomically (one commit per row). -- Advisory rows collected for PR body / workflow output. -- No edits to non-`version-pin` row tracked state. -- `pnpm run lockstep` exits 0 or 2 at end (never 1; no schema errors introduced). -- `.gitmodules` version comments synchronized with `pinned_tag`. - -## Commands reference - -- `pnpm run lockstep --json`: drift report (consumed by this skill). -- `jq`: parse + edit `lockstep.json` (structured JSON edits). -- `git submodule status`: verify submodule state after bumps. diff --git a/.claude/skills/updating-lockstep/reference.md b/.claude/skills/updating-lockstep/reference.md deleted file mode 100644 index f982d5616..000000000 --- a/.claude/skills/updating-lockstep/reference.md +++ /dev/null @@ -1,168 +0,0 @@ -# updating-lockstep reference - -Long-form details for the `updating-lockstep` skill — phase scripts, per-kind action policy, advisory format, and CI-mode emission. The orchestration story lives in [`SKILL.md`](SKILL.md). - -## Per-kind action policy - -| Kind | Drift signal | Action | -| ------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version-pin` | Upstream commits on default ref since pinned SHA | **Auto-bump** per `upgrade_policy`: `track-latest` → advance to latest stable tag; `major-gate` → advance patch/minor only; `locked` → advisory only | -| `file-fork` | Upstream file changed since `forked_at_sha` | **Advisory** — note in PR body; do NOT auto-merge (forks carry local deltas that need human review) | -| `feature-parity` | Parity score below `criticality/10` floor | **Advisory** — note in PR body; human decides implement vs downgrade criticality | -| `spec-conformance` | Spec submodule moved | **Advisory** — note in PR body; human decides whether to bump `spec_version` | -| `lang-parity` | Port divergence / `rejected` anti-pattern reintroduced | **Advisory** — note in PR body; humans fix the port or update the manifest | - -The umbrella rule: **`version-pin` is mechanical** (safe to auto-apply with `track-latest` / `major-gate` policies); everything else is **advisory** (upstream semantics and local deltas matter, humans decide). - -## Phase scripts - -### Phase 1 — Pre-flight - -```bash -test -f lockstep.json || { echo "no lockstep.json; skill n/a"; exit 0; } -test -f lockstep.schema.json || { echo "lockstep.schema.json missing — malformed scaffolding"; exit 1; } -test -f scripts/lockstep.mts || { echo "scripts/lockstep.mts missing — malformed scaffolding"; exit 1; } - -git status --porcelain | grep -v '^??' && { echo "dirty tree; aborting"; exit 1; } || true - -[ "$CI" = "true" ] || [ -n "$GITHUB_ACTIONS" ] && CI_MODE=true || CI_MODE=false -``` - -### Phase 2 — Collect drift - -```bash -pnpm run lockstep --json > /tmp/lockstep-report.json -``` - -Parse `reports[]` from the JSON. Split into: - -- **auto** — rows where `severity == "drift"` AND `kind == "version-pin"` AND `upgrade_policy` ∈ `{ "track-latest", "major-gate" }`. -- **advisory** — everything else with `severity != "ok"`. - -If both lists are empty: exit 0 with "no lockstep drift". - -### Phase 3 — Auto-bump version-pin rows - -For each row in the **auto** list, in manifest declaration order: - -**3a. Resolve the upstream submodule + fetch tags** - -```bash -SUBMODULE=$(jq -r --arg a "$UPSTREAM_ALIAS" '.upstreams[$a].submodule' lockstep.json) -cd "$SUBMODULE" -git fetch origin --tags --quiet -OLD_SHA=$(git rev-parse HEAD) -``` - -**3b. Find the target tag** - -Examine existing `pinned_tag` to identify the tag scheme, then match: - -- `v1.2.3` (v-prefixed semver) -- `1.2.3` (bare semver) -- `<prefix>-1.2.3` (project-prefixed) -- `<prefix>_1_2_3` (underscore style; curl, liburing) - -For `major-gate` policy: parse major version from `LATEST` vs current `pinned_tag`. If majors differ, skip — add to advisory with note "major bump needs human review". - -**3c. Check out + capture new SHA** - -```bash -NEW_SHA_FOR_CHECK=$(git rev-parse "$LATEST") -[ "$OLD_SHA" = "$NEW_SHA_FOR_CHECK" ] && { cd -; continue; } -git checkout "$LATEST" --quiet -NEW_SHA=$(git rev-parse HEAD) -cd - -``` - -**3d. Update `lockstep.json` + `.gitmodules`** - -Use `jq` for the structured edit: - -```bash -jq --arg id "$ROW_ID" --arg sha "$NEW_SHA" --arg tag "$LATEST" \ - '(.rows[] | select(.id == $id) | .pinned_sha) = $sha - | (.rows[] | select(.id == $id) | .pinned_tag) = $tag' \ - lockstep.json > lockstep.json.tmp && mv lockstep.json.tmp lockstep.json -``` - -Update `.gitmodules` version comment via Edit tool (NOT sed per CLAUDE.md) — replace `# <prefix>-<old>` with `# <prefix>-<new>` on the comment line above the submodule block. - -**3e. Validate + commit** - -```bash -# Confirm lockstep harness accepts the new state. -pnpm run lockstep --json > /tmp/lockstep-post.json -jq --arg id "$ROW_ID" '.reports[] | select(.id == $id) | .severity' /tmp/lockstep-post.json -# expect "ok" - -if [ "$CI_MODE" = "false" ]; then - pnpm test || { - echo "tests failed; rolling back $ROW_ID" - git checkout lockstep.json .gitmodules "$SUBMODULE" - continue - } -fi - -git add lockstep.json .gitmodules "$SUBMODULE" -git commit -m "chore(deps): bump $UPSTREAM_ALIAS to $LATEST" -``` - -Record the bumped row in the summary accumulator. - -### Phase 4 — Advisory composition - -For each row in **advisory**, accumulate a markdown line: - -``` -- **file-fork** `<id>`: `<local>` — <N> upstream commit(s) since <forked_at_sha[0:12]>. Review diff, cherry-pick if applicable, bump forked_at_sha. -- **feature-parity** `<id>`: parity score <score> below floor <floor>. Implement or downgrade criticality with reason. -- **spec-conformance** `<id>`: upstream spec repo moved. Review for breaking changes before bumping spec_version. -- **lang-parity** `<id>`: <details from messages[]>. -- **version-pin** `<id>`: major bump to <LATEST> — policy=major-gate requires human review. -- **version-pin** `<id>`: upgrade_policy=locked — skipped. -``` - -### Phase 5 — Report + emit - -Final human-readable report to stdout: - -``` -## updating-lockstep report - -**Auto-bumped:** <N> row(s) -<list> - -**Advisory (human review):** <M> row(s) -<list> -``` - -In CI mode, emit the advisory block to `$GITHUB_OUTPUT` (base64-encoded) under key `lockstep-advisory` so the weekly-update workflow can include it in the PR body: - -```bash -if [ -n "$GITHUB_OUTPUT" ]; then - echo "lockstep-advisory=$(printf '%s' "$ADVISORY" | base64 | tr -d '\n')" >> "$GITHUB_OUTPUT" -fi -``` - -Emit a HANDOFF block per [`_shared/report-format.md`](../_shared/report-format.md): - -``` -=== HANDOFF: updating-lockstep === -Status: {pass|fail} -Findings: {auto_bumped: N, advisory: M} -Summary: {one-line description} -=== END HANDOFF === -``` - -## Tag-stability filter - -Always filter pre-release / nightly / preview tags. The skill targets stable releases only: - -- `-rc`, `-rc.\d+` -- `-alpha`, `-alpha.\d+` -- `-beta`, `-beta.\d+` -- `-dev` -- `-snapshot` -- `-nightly` -- `-preview` diff --git a/.claude/skills/updating-security/SKILL.md b/.claude/skills/updating-security/SKILL.md deleted file mode 100644 index 55fc24d16..000000000 --- a/.claude/skills/updating-security/SKILL.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: updating-security -description: Resolve open GitHub Dependabot security alerts on a fleet repo. Fetches alerts via `gh api`, applies fixes (direct dep bump, pnpm override for transitives, or principled dismissal for unfixable), validates with `pnpm run check`, commits per-alert, and reports remaining advisories. Sibling of `updating-lockstep` under the `updating` umbrella. -user-invocable: true -allowed-tools: AskUserQuestion, Read, Edit, Grep, Glob, Bash(gh api:*), Bash(gh auth:*), Bash(pnpm:*), Bash(git:*), Bash(node:*), Bash(jq:*) ---- - -# updating-security - -Walk open Dependabot security alerts on the current repo and fix -them via the cheapest principled mechanism. Invoked directly via -`/update-security` or as Phase 5 of the `updating` umbrella. - -## When to use - -- A `gh dependabot alerts` listing shows open advisories. -- The GitHub web UI security tab is non-empty after a push (`gh` - warns "Dependabot found N vulnerabilities" on push completion). -- As part of weekly maintenance (the `updating` umbrella invokes - this automatically when alerts are present). - -## What it does NOT do - -- **Disable alerts at the repo level.** Suppressing the security - tab via repo settings is a separate (heavier) decision; this - skill resolves the underlying CVEs. -- **Touch `dependabot.yml`.** The fleet ships a no-op - `dependabot.yml` (`open-pull-requests-limit: 0`) so Dependabot - doesn't open version-update PRs; security alerts are independent - and surface regardless. -- **Auto-dismiss without evidence.** Dismissals require a reason - matching one of GitHub's documented values - (`fix_started` / `inaccurate` / `no_bandwidth` / `not_used` / - `tolerable_risk`) and a one-line justification. The skill asks - before dismissing. - -## Phases - -| # | Phase | Outcome | -| --- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Discover | `gh api repos/{owner}/{repo}/dependabot/alerts?state=open`. Group by package + relationship (direct / transitive). | -| 2 | Classify | Each alert → one of: `direct-fix` (bump the catalog / `package.json` pin), `override-fix` (pnpm override for transitive), `dismiss-with-reason`. Resolve the PIN TARGET = highest soaked release sharing `first_patched`'s major (see reference.md "Pin target"). | -| 3 | Apply direct fixes | For each direct dep: bump to the resolved exact pin version; commit per alert. | -| 4 | Apply override fixes | For each transitive: add an EXACT pin to `overrides:` in **pnpm-workspace.yaml** (not `package.json`); `pnpm install`; commit per row. | -| 5 | Validate | `pnpm run check --all` (interactive) or `pnpm run check --staged` (CI). Roll back any commit whose check fails. | -| 6 | Push | Per CLAUDE.md push policy: `git push origin <branch>`, fall back to PR on rejection. NEVER force-push. | -| 7 | Verify resolution | After push lands, `gh api .../dependabot/alerts` should show each fixed alert as `auto_dismissed` or `fixed`. Log remaining. | -| 8 | Report | Per-alert table: alert # / pkg / severity / action taken / state. | - -## Hard requirements - -- **Clean tree on entry**: same rule as `updating` umbrella. -- **One commit per alert**: `chore(security): bump <pkg> to <ver> (GHSA-XXXX)` or `chore(security): override <pkg> to <ver> (GHSA-XXXX)`. `<ver>` is an exact version, never a `^`/`>=`/`~` range. -- **Exact pins, highest-soaked-in-major**: pin to the highest release sharing `first_patched_version`'s major that's past the 7-day soak — never a range, never an auto major-cross. Crossing a major requires an AI benignity check (socket-lib `spawnAiAgent`) that returns BENIGN (ESM-only / Node-floor / dropped deep-imports), and is then auto-applied **with a notice in the Phase-8 report**; a BREAKING or unavailable verdict requires `AskUserQuestion` signoff. See reference.md "Pin target". -- **No `--no-verify`**: the soak / cooldown guard (`minimum-release-age-guard`) MUST be honored. If a patched version is inside the 7-day soak, the skill notes the alert as `awaiting-soak` and returns without modification. -- **Conventional Commits**: `chore(security): <action>` (per CLAUDE.md _Commits & PRs_). -- **Default-branch fallback**: never hard-code `main` (per CLAUDE.md _Default branch fallback_). -- **GitHub auth**: assumes `gh auth status` returns OK. Token must have `security_events:read` + `repo` scopes. Personal `gh` login satisfies both. - -## Success criteria - -- Every alert that has a `first_patched_version` is either fixed, - awaiting-soak, or has an explicit dismissal request. -- Working tree clean after the commit chain. -- `pnpm run check` passes against the fix set. - -**Safety:** every commit is atomic and the skill can be interrupted at any phase. Resume by re-running. Already-applied fixes show up as `auto_dismissed` and are skipped. - -Full bash, alert-shape reference, dismissal-reason taxonomy, and -recovery procedures in [`reference.md`](reference.md). diff --git a/.claude/skills/updating-security/reference.md b/.claude/skills/updating-security/reference.md deleted file mode 100644 index 6c6495f9c..000000000 --- a/.claude/skills/updating-security/reference.md +++ /dev/null @@ -1,537 +0,0 @@ -# updating-security Reference - -## Default-branch resolution - -```bash -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -if [ -z "$BASE" ]; then - for candidate in main master; do - if git show-ref --verify --quiet "refs/remotes/origin/$candidate"; then - BASE="$candidate" - break - fi - done -fi -BASE="${BASE:-main}" -``` - -## Alert discovery - -```bash -# Resolve owner/repo from origin URL. -ORIGIN=$(git config remote.origin.url) -SLUG=$(echo "$ORIGIN" | sed -E 's@.*github.com[:/]([^/]+/[^/.]+)(\.git)?$@\1@') -echo "$SLUG" - -# Pull open alerts (one page; 100 max — paginate if needed). -gh api "repos/$SLUG/dependabot/alerts?state=open&per_page=100" > /tmp/dependabot-alerts.json -jq '. | length' /tmp/dependabot-alerts.json -``` - -## Alert shape (the fields we use) - -```json -{ - "number": 2, - "state": "open", - "dependency": { - "package": { "ecosystem": "npm", "name": "brace-expansion" }, - "manifest_path": "pnpm-lock.yaml", - "scope": "development", - "relationship": "transitive" - }, - "security_advisory": { - "ghsa_id": "GHSA-jxxr-4gwj-5jf2", - "severity": "medium", - "summary": "Large numeric range defeats documented `max` DoS protection" - }, - "security_vulnerability": { - "package": { "name": "brace-expansion" }, - "vulnerable_version_range": ">= 5.0.0, < 5.0.6", - "first_patched_version": { "identifier": "5.0.6" } - }, - "html_url": "https://github.com/SocketDev/<repo>/security/dependabot/2" -} -``` - -Five fields drive classification: `dependency.relationship`, -`dependency.scope`, `security_vulnerability.first_patched_version`, -`security_advisory.ghsa_id`, and (for commits) `severity`. - -## Per-alert action selection - -```text -relationship == "direct" && first_patched_version != null - → DIRECT-FIX: bump the catalog pin (or package.json) to the - resolved pin version (see "Pin target" below) - -relationship == "transitive" && first_patched_version != null - → OVERRIDE-FIX: add an EXACT pin to `overrides:` in - pnpm-workspace.yaml (see "Pin target" below) - -first_patched_version == null - → DISMISS: gh api .../alerts/N -X PATCH \ - -f state=dismissed -f dismissed_reason=no_bandwidth \ - -f dismissed_comment="<one-liner>" - -soak gate hits the pin version - → AWAITING-SOAK: skip; report in summary; do NOT modify -``` - -## Pin target — highest soaked, same major as first_patched - -### Sources & precedence - -When figuring out what's patched and what else changed, the sources -rank — they routinely disagree: - -1. **GitHub Security Advisory** (`gh api securityAdvisory(ghsaId:…)` or - the alert's `security_vulnerability.first_patched_version`) — ground - truth for WHICH versions clear the CVE. Maintainers backport across - several release lines; trust this list of patched versions. -2. **Per-version GitHub Releases / git tags** — what shipped in a - specific version, even one the CHANGELOG skipped. -3. **CHANGELOG.md / HISTORY.md** — narrative of changes, but written on - `main`; a backport cut on a maintenance branch may be absent. - -**Why this order (real incident, uuid GHSA-w5hq-g745-h8pq):** the -advisory listed three backported patched lines — 11.1.1, 12.0.1, -13.0.1 — but the `main` CHANGELOG jumped 11.1.0 → 12.0.0 and only -documented the fix under 14.0.0. A reader trusting the CHANGELOG alone -would have concluded the only fix was in 14.x and needlessly crossed -two majors. The advisory said `first_patched = 11.1.1` for our range, -and that's what we pinned. - -### Resolve the pin - -🚨 Do NOT pin to `^<first_patched>` or `>=<first_patched>`. The fleet -pins EXACT versions everywhere (`uuid: 11.1.1`, never `^11.1.1`) — -ranges let a non-frozen `pnpm install` slide to an un-soaked release, -defeating both determinism and the malware soak. Resolve the pin like -this: - -1. Take `first_patched_version` (e.g. `11.1.1`). Note its major (`11`). -2. Keep only stable releases ≥ `first_patched_version` in that major - AND past the 7-day soak (publish date ≥ 7 days ago — see "Soak-gate - interaction"). Pre-releases (`-rc`, `-beta`, `-alpha`, `-next`, - `-canary`) are NEVER pin targets; a security pin lands on a stable - line only. -3. Pin to the HIGHEST survivor. Usually that's `first_patched` itself; - it's higher only when a newer in-major patch has since soaked. -4. **If no stable in-major target exists** (the fix shipped only in a - higher major, so the in-major filter is empty), the major bump IS - the path — not an exception to dodge. Run the AI benignity check - below; if it returns BENIGN, pin to the highest stable release in - the target major and announce it. Only a BREAKING / unavailable / - ambiguous verdict falls back to asking the user. - -🚨 Do the semver work with socket-lib's `versions/*` helpers, never -hand-rolled regex or `sort -V` (off-by-one on pre-release / build -metadata is the classic bug). `filterVersions` drops pre-releases by -default, so a pin can never land on an `-rc`. socket-lib ships the -full set: `@socketsecurity/lib/versions/parse` (`getMajorVersion`, -`parseVersion`, `isValidVersion`, `coerceVersion`), -`@socketsecurity/lib/versions/range` (`filterVersions`, `maxVersion`, -`minVersion`, `satisfiesVersion`), `@socketsecurity/lib/versions/compare` -(`gt`/`gte`/`sort`/`rsort`). It does NOT ship a registry-version -fetcher — get the candidate list with `npm view <pkg> versions --json` -(or `httpJson` to the registry), then resolve in code: - -```ts -import { getMajorVersion } from '@socketsecurity/lib/versions/parse' -import { filterVersions, maxVersion } from '@socketsecurity/lib/versions/range' - -// `published` = registry versions (npm view) already filtered to -// publish-date ≥ 7 days ago (the soak gate). filterVersions also -// drops pre-releases, so `-rc`/`-beta` can never be selected. -const major = getMajorVersion(firstPatched) // 11 -const inMajor = filterVersions(published, `>=${firstPatched} <${major + 1}.0.0`) -let pinTarget = maxVersion(inMajor) -if (!pinTarget) { - // No stable in-major fix. Run the AI benignity check (next section); - // on BENIGN, take the highest stable release ≥ first_patched in the - // higher major where the fix shipped. - const crossMajor = filterVersions(published, `>=${firstPatched}`) - pinTarget = maxVersion(crossMajor) ?? firstPatched -} -``` - -`filterVersions` drops pre-releases and applies the range; `maxVersion` -picks the highest. The `<${major + 1}.0.0` upper bound is what keeps -the pin in-major — crossing a major is the separate gated path below. - -5. **Crossing a major needs an AI benignity check + a user notice.** - If no in-major patched release exists (the fix lives only in a - higher major — e.g. the dep's `9.x`/`10.x` lines were never - patched and only `11.x+` carries the fix), classify the bump with - socket-lib's locked-down AI helper before crossing: - - ```ts - import { spawnAiAgent } from '@socketsecurity/lib-stable/ai/spawn' - - // Lockdown per CLAUDE.md "Programmatic Claude calls": all four - // flags set, never `default`/`bypassPermissions`. - const res = await spawnAiAgent({ - prompt: - `Determine what changed in npm package "${pkg}" between major ` + - `${fromMajor} and the patched version ${target}. Consult, in ` + - `this order: (1) the GitHub Security Advisory for the CVE — it ` + - `is the ground truth for WHICH versions are patched (maintainers ` + - `often backport a fix to several release lines, and the main ` + - `CHANGELOG may only mention the latest); (2) the per-version ` + - `GitHub Releases pages; (3) the repo CHANGELOG.md / HISTORY.md. ` + - `If the CHANGELOG skips the patched version (it was a backport ` + - `cut on a maintenance branch), trust the advisory + the git tag ` + - `for that version, not the CHANGELOG's omission. Our consumer ` + - `calls only: ${apiSurfaceUsed}.\n\n` + - `Our runtime floor is Node ${nodeFloor} (from package.json ` + - `engines.node). The bar for whether a Node-floor change is ` + - `breaking is the official release schedule at ` + - `https://nodejs.org/en/about/previous-releases — a dep dropping ` + - `Node versions that are already EOL (past their Maintenance ` + - `window) is benign by definition; what matters is whether the ` + - `dep's NEW floor is still within a Node line that is Active LTS, ` + - `Maintenance, or Current AND <= the Node WE run.\n\n` + - `Classify the breaking changes. Answer STRICTLY one word on the ` + - `first line:\n` + - ` BENIGN — every breaking-change bullet is one of: a Node-floor ` + - `raise whose new floor is STILL AT OR BELOW the Node we run AND ` + - `is a currently-supported line per the schedule above (dropping ` + - `already-EOL Node is always benign); ESM-only packaging, ` + - `"remove CommonJS support", or "make browser exports default" ` + - `(on Node >=22 the unflagged require(esm) support loads the ESM ` + - `build transparently, so CJS removal does not break a require() ` + - `caller); a TypeScript port; or removed deep-import subpaths. ` + - `New methods added = additive, not breaking. A SECURITY FIX is ` + - `never breaking — hardening input validation (e.g. now throwing ` + - `on an out-of-bounds / malformed input that previously corrupted ` + - `silently) only rejects inputs that were already exploiting the ` + - `bug; correct callers are unaffected. NONE of the methods we ` + - `call had a break in PREVIOUSLY-CORRECT usage.\n` + - ` BREAKING — a bullet changes the signature, return type, or ` + - `documented behavior of a method we call in a way that breaks ` + - `code that was already CORRECT (NOT counting the security fix ` + - `itself); OR it raises the Node floor ABOVE the Node we run; OR ` + - `removes CJS while our floor is Node <22; OR you cannot find the ` + - `release notes to be sure.\n\n` + - `Then ONE line of justification quoting the deciding bullet(s). ` + - `When uncertain, choose BREAKING — a wrong BENIGN ships a silent ` + - `behavior change; a wrong BREAKING just asks the user.`, - disallow: ['Edit', 'Write', 'Bash'], // read-only classification - allow: ['WebFetch', 'WebSearch'], - permissionMode: 'dontAsk', - }) - ``` - - `apiSurfaceUsed` = the methods the consuming code actually imports - (grep the transitive consumer, e.g. gaxios → `uuid.v4`). Narrowing - the surface lets the classifier ignore a breaking change in a - method nobody calls. - - `nodeFloor` = our `engines.node` (the fleet floors at `>=26.0.0`). - This is what makes "remove CommonJS support" benign: Node ≥22 ships - unflagged `require(esm)` (synchronous `require()` of an ESM module), - so a CJS-removing major still loads via `require('pkg')`. CJS - removal is only BREAKING when the floor is Node <22. - - `BENIGN` → cross the major, pin to the highest soaked release in - the TARGET major, and **report it in the Phase-8 summary** - ("crossed uuid 9.x→11.x — AI-classified ESM-only, no API break"). - The user sees it landed; they did not have to approve it inline. - - `BREAKING` (or the AI is unavailable / ambiguous) → do NOT cross. - Surface via `AskUserQuestion` for explicit human signoff. - - Never cross a major silently — a BENIGN cross is auto-applied but - always announced; a BREAKING cross always asks first. - -### Worked example — uuid, and why the classification is per-consumer - -`uuid` shows that "benign across majors" is **conditional**, not a -blanket. The advisory (GHSA-w5hq-g745-h8pq) has THREE patched lines — -the fix was backported, not landed only on latest: - -| Vulnerable range | First patched | -| --------------------- | ------------- | -| `< 11.1.1` | `11.1.1` | -| `>= 12.0.0, < 12.0.1` | `12.0.1` | -| `>= 13.0.0, < 13.0.1` | `13.0.1` | - -(and 14.0.0 ships it too). Our 9.0.1 falls in the `< 11.1.1` range, -so `first_patched = 11.1.1` and the resolver pins there — no major -cross needed at all. - -The CVE fix itself is a **behavior change to `v3()`/`v5()`/`v6()`**: -they used to silently write out of a too-small caller buffer; now they -throw `RangeError`. That guard is in EVERY patched release -(11.1.1 / 12.0.1 / 13.0.1 / 14.0.0). **It is a fix, not a breaking -change** — and that distinction is the important one for the -classifier: - -- The OLD behavior (silent out-of-bounds write) WAS the vulnerability. - A legitimate caller that passes a correctly-sized buffer never hit - it and sees no change. The only callers that now get a `RangeError` - are the ones that were already triggering the memory-corruption bug - — i.e. were already broken. Making invalid input fail loudly instead - of corrupting memory does not break correct code; it is the point of - the advisory. -- So a security fix that hardens input validation is NEVER counted as - a breaking change, regardless of which method it touches or whether - you call that method. Don't put it in the major-cross BREAKING - column. The classifier's question is strictly: does crossing a major - introduce a break in code that was previously CORRECT? -- (Our path is gaxios → `uuid.v4()`, which the guard doesn't even - touch — but the point stands for v3/v5/v6 callers too.) - -The per-major breaking surface, scored for a Node-26, `v4()`-only -consumer (CHANGELOG bullets verified against -`raw.githubusercontent.com/uuidjs/uuid/main/CHANGELOG.md`): - -| Major | "Breaking" bullets (from CHANGELOG) | Adds a break BEYOND the CVE fix, for v4()-only on Node 26? | -| ------ | ------------------------------------------------ | ----------------------------------------------------------------------------- | -| 10.0.0 | drop node@12/14 | No — floor drop ≤ ours; v6/v7/v8 additive | -| 11.0.0 | drop node@16, TS port, ESM (dual CJS) | No | -| 12.0.0 | drop node@16, **remove CommonJS** | No — Node ≥22 `require(esm)` loads the ESM build | -| 13.0.0 | make browser exports default | No — packaging priority only | -| 14.0.0 | drop node@18, `crypto` must be global (node@20+) | No — floor drop ≤ ours. (The RangeError guard is the CVE fix, never a break.) | - -Three things this teaches the classifier: - -1. **Node-floor changes are measured against the Node release - schedule AND our floor.** Use - [nodejs.org/en/about/previous-releases](https://nodejs.org/en/about/previous-releases) - as the bar: dropping an already-EOL Node line is always benign; - what matters is whether the dep's NEW floor is a still-supported - line (Active LTS / Maintenance / Current) AND ≤ the Node we run. - All uuid majors here drop Node lines at or below our floor — fine. - A major that required a Node newer than ours, or that's not yet a - released line, would be BREAKING for us. -2. **"Remove CommonJS" is benign on Node ≥22** (unflagged - `require(esm)`), which is the whole fleet. It would be BREAKING on - an older floor. -3. **A security fix is never a breaking change.** Hardening input - validation (uuid's silent-write → `RangeError` on a bad buffer) - only rejects inputs that were already exploiting the bug; correct - callers are unaffected. Don't weigh the fix itself as a break — the - major-cross question is solely whether crossing introduces a break - in PREVIOUSLY-CORRECT code. Still pass `apiSurfaceUsed` so the - classifier ignores genuine breaks in methods nobody calls. - -For THIS alert the resolver pins `11.1.1` (first_patched's major is -11; the resolver never looks past it), so none of the 12/13/14 -nuance even comes into play — the cross-major AI check only fires -when NO in-major patched release exists. The table is here to show -the classifier what the benign-vs-breaking line looks like in -practice. - -Resolver (paste-ready): - -```bash -PKG=uuid; FIRST_PATCHED=11.1.1 -MAJOR="${FIRST_PATCHED%%.*}" -npm view "$PKG" time --json | python3 -c " -import sys,json,datetime -t=json.load(sys.stdin); now=datetime.datetime.now(datetime.timezone.utc) -fp='$FIRST_PATCHED'; major='$MAJOR' -def key(v): return [int(x) for x in v.split('.')] -ok=[] -for v,ts in t.items(): - if not v.split('.')[0].isdigit() or v.split('.')[0]!=major or '-' in v: continue - if key(v) < key(fp): continue - age=(now-datetime.datetime.fromisoformat(ts.replace('Z','+00:00'))).days - if age>=7: ok.append((key(v),v)) -print(sorted(ok)[-1][1] if ok else 'NONE-IN-MAJOR-SOAKED') -" -``` - -`NONE-IN-MAJOR-SOAKED` → either the only fix is in a higher major -(human signoff) or the in-major fix is still soaking (AWAITING-SOAK). - -## Soak-gate interaction - -The `minimum-release-age-guard` hook blocks adding deps published <7 -days ago. Before running `pnpm install` after a `package.json` edit, -check the patched version's npm publish date: - -```bash -PUB_DATE=$(npm view "<pkg>@<patched>" time."<patched>" 2>/dev/null) -NOW=$(date -u +%s) -PUB=$(date -j -f "%Y-%m-%dT%H:%M:%S.000Z" "$PUB_DATE" +%s 2>/dev/null) -AGE_DAYS=$(( (NOW - PUB) / 86400 )) -if [ "$AGE_DAYS" -lt 7 ]; then - echo "AWAITING-SOAK: <pkg>@<patched> published $AGE_DAYS days ago" - # Per-package exception requires the canonical - # `# published: YYYY-MM-DD | removable: YYYY-MM-DD` - # annotation in pnpm-workspace.yaml `minimumReleaseAgeExclude[]`. - # Don't auto-add — emergency CVE patches need explicit user signoff - # (CLAUDE.md _Tooling_ § minimumReleaseAge). -fi -``` - -If the alert is critical AND patched <7 days ago, surface to the -user via `AskUserQuestion` with the canonical bypass-phrase prompt -(`Allow minimumReleaseAge bypass`). - -## Override-fix shape - -🚨 Fleet overrides live in **`pnpm-workspace.yaml`** under the -top-level `overrides:` key — NOT `package.json` `pnpm.overrides`. And -they are **exact pins**, not ranges (see "Pin target" above). Add a -`# Security: GHSA-… — <one-line why> … <relationship/path>` comment -above each entry so the next reader knows why it's there and when it -can be removed (CVE fixed upstream → consumer bumps → override is dead -weight): - -```yaml -overrides: - '@socketsecurity/lib': 'catalog:' - vite: 'catalog:' - # Security: GHSA-w5hq-g745-h8pq (medium) — uuid <11.1.1 missing - # buffer-bounds check in v3/v5/v6. Transitive via gaxios (dev-only). - # Exact pin per fleet convention; v4() API unchanged 9→11. - uuid: 11.1.1 -``` - -Then: - -```bash -pnpm install # refreshes the lockfile -pnpm install --frozen-lockfile # confirms the lockfile is consistent -``` - -The lockfile updates to pin every transitive consumer to the exact -patched version. The CVE clears on the next Dependabot rescan -(typically minutes after push). - -> **The override is temporary.** Once the direct consumer (`gaxios` in -> the uuid case) bumps its own dependency past the vulnerable range, -> the override is dead weight. `taze` understands `pnpm-workspace.yaml` -> overrides and will offer to bump or surface them during the weekly -> `updating` run — use `taze minor` so a stale override doesn't get -> floated across a major. Re-audit overrides periodically and drop the -> ones whose underlying CVE is resolved upstream. - -## Direct-fix shape - -```bash -pnpm update "<pkg>@^<first-patched-version>" -``` - -If `pnpm update` doesn't take the requested version (e.g. because -the version range in package.json caps below the patch), edit -`package.json` directly: - -```bash -node -e ' - const fs = require("node:fs") - const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) - for (const section of ["dependencies", "devDependencies", "peerDependencies"]) { - if (pkg[section]?.["<pkg>"]) { - pkg[section]["<pkg>"] = "^<first-patched-version>" - } - } - fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n") -' -pnpm install -``` - -## Commit shapes - -```text -chore(security): bump brace-expansion to 5.0.6 (GHSA-jxxr-4gwj-5jf2) - -CVE-2026-45149 — DoS via large numeric range. Direct dep upgrade -from <pre> to 5.0.6 (the first-patched version per GitHub's -advisory). pnpm-lock.yaml regenerated. -``` - -```text -chore(security): override postcss to 8.5.10 (GHSA-qx2v-qp2m-jg93) - -CVE-2026-41305 — XSS via unescaped </style> in CSS stringify. -Transitive dependency; added an exact pin to `overrides:` in -pnpm-workspace.yaml (highest soaked 8.x — no major cross). Lockfile -refreshed. -``` - -```text -chore(security): dismiss vue-component-meta alert (GHSA-...) - -GHSA-... — vulnerability requires user-supplied `.vue` files at -build time; we don't accept user-uploaded source. Dismissed as -`tolerable_risk` per CLAUDE.md _Token hygiene_ / -_Public-surface hygiene_ guidance — no exposure surface. -``` - -## Validation - -Same gate as the rest of the fleet: - -```bash -pnpm run check --all -``` - -If any commit fails the check, roll back THAT commit and continue -to the next alert. Don't `git reset --hard` the whole chain — -treat each fix as independent. - -## Push policy - -Per CLAUDE.md _Commits & PRs_ → "Push policy: push, fall back to -PR": - -```bash -git push origin "$BASE" || gh pr create --title "chore(security): clear N alerts" --body-file <path> -``` - -NEVER force-push for security fixes. The chain of per-alert commits -is intentional history. - -## Verify resolution - -After push lands, re-query the alerts: - -```bash -gh api "repos/$SLUG/dependabot/alerts?state=open" > /tmp/dependabot-alerts-after.json -``` - -Compare counts; alerts we fixed should be missing (Dependabot -auto-dismisses on detection of patched version). Alerts still open -should match the AWAITING-SOAK / DISMISS sets we tracked above. - -## GitHub API references - -- List alerts: `GET repos/{owner}/{repo}/dependabot/alerts` -- Read one: `GET repos/{owner}/{repo}/dependabot/alerts/{number}` -- Dismiss: `PATCH repos/{owner}/{repo}/dependabot/alerts/{number}` - with body `{ "state": "dismissed", "dismissed_reason": "...", -"dismissed_comment": "..." }` - -Documented at: -<https://docs.github.com/en/rest/dependabot/alerts> - -## Dismissal-reason taxonomy - -GitHub accepts exactly these values for `dismissed_reason`: - -| Value | When to use | -| ---------------- | --------------------------------------------------------------------------- | -| `fix_started` | A PR resolving the alert is already open in this repo. | -| `inaccurate` | The advisory mis-classifies our usage (e.g. server-only dep on a CLI repo). | -| `no_bandwidth` | Known, accepted, will revisit later — typical for low-severity transitives. | -| `not_used` | Dep is in the lockfile but not actually loaded at runtime. | -| `tolerable_risk` | Risk is understood and accepted; no remediation planned. | - -Pick the most precise one; fleet convention prefers `inaccurate` / -`not_used` (factual) over `tolerable_risk` (judgmental) when both -fit. - -## Failure recovery - -- **`gh api` 401/403** — token scope missing. Re-run - `gh auth refresh -s repo,security_events`. -- **`pnpm install` resolution conflict** — usually a peerDep - upper-bound. Bump the peer alongside the override. -- **Soak guard refuses** — emergency CVE patches need - `Allow minimumReleaseAge bypass` typed verbatim by the user. -- **Check fails after fix** — revert that one commit - (`git reset --soft HEAD~1`, undo edits in `package.json`), log - the regression, continue to next alert. diff --git a/.claude/skills/updating/SKILL.md b/.claude/skills/updating/SKILL.md deleted file mode 100644 index 05744669f..000000000 --- a/.claude/skills/updating/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: updating -description: Umbrella update skill for a Socket fleet repo. Runs `pnpm run update` (npm), validates `lockstep.json` via `pnpm run lockstep` (if present), optionally bumps submodules, checks workflow SHA pins, resolves open Dependabot security alerts, refreshes the README coverage badge when applicable, and audits GitHub repo + Actions settings drift via `scripts/lint-github-settings.mts`. Use when asked to update dependencies, sync upstreams, fix security advisories, refresh coverage, or prepare for a release. -user-invocable: true -allowed-tools: Task, Skill, Read, Edit, Grep, Glob, Bash(pnpm run:*), Bash(pnpm test:*), Bash(pnpm install:*), Bash(git:*), Bash(claude --version) ---- - -# updating - -Umbrella update skill. Runs `pnpm run update` for npm deps, then adapts to whatever the repo has: lockstep manifest, submodules, workflow SHA pins. Validates with check/test before reporting done. - -## When to use - -- Weekly maintenance (the `weekly-update.yml` workflow calls this skill). -- Security patch rollout. -- Pre-release preparation. - -## Update targets - -- **npm packages**: `pnpm run update` (every fleet repo has this script). -- **lockstep-managed upstreams**: `pnpm run lockstep` when `lockstep.json` exists. Mechanical `version-pin` bumps auto-apply; `file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` rows surface as advisory. -- **Other submodules**: repo-specific `updating-*` sub-skills handle `.gitmodules` entries not claimed by a lockstep `version-pin` row. -- **Workflow SHA pins**: `_local-not-for-reuse-*.yml` SHAs against the remote's default branch (per CLAUDE.md _Default branch fallback_); run `/updating-workflows` when stale. -- **Security advisories**: open GitHub Dependabot alerts via `/update-security`. Direct deps bumped via `pnpm update`; transitives pinned via `pnpm.overrides`; unfixable advisories dismissed with documented reasons. Honors the 7-day soak gate. -- **Coverage badge**: when a coverage script exists (`cover` / `coverage` / `test:cover`), `/update-coverage` runs the script and rewrites the README badge to match. Repos without a coverage script skip silently. -- **GitHub settings drift**: `scripts/lint-github-settings.mts --force --json` audits repo + Actions settings against the fleet baseline (custom properties, feature flags, merge policy, branch protection, required apps like `cursor` / `claude` / `socket-security`). Read-only by default; fixes are surfaced as URLs the operator clicks through (`--fix` is gated on `repo:admin`, not auto-applied in the umbrella). Skipped under `CI=true` (the underlying script's local-only design). - -This umbrella reads repo state first to discover what applies. Sub-skills are only invoked when relevant. - -## Phases - -| # | Phase | Outcome | -| --- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Validate environment | Clean tree, detect CI mode (`CI=true` / `GITHUB_ACTIONS`), submodules initialized. | -| 2 | npm packages | `pnpm run update` → atomic commit if anything moved. | -| 3 | Validate lockstep | If `lockstep.json` exists: `pnpm run lockstep`. Exit 0 = clean, 1 = stop, 2 = drift (handled in Phase 4). | -| 4 | Apply drift | 4a: lockstep auto-bumps (one commit per row). 4b: repo-specific `updating-*` sub-skills for non-lockstep submodules. | -| 5 | Security advisories | If `gh api .../dependabot/alerts?state=open` returns any rows, invoke `/update-security` (the `updating-security` sub-skill). Atomic commit per alert. | -| 6 | Workflow SHA pins | Compare pinned SHAs against `origin/$BASE`; report stale → `/updating-workflows`. | -| 7 | Coverage badge | If the repo declares a coverage script (`cover` / `coverage` / `test:cover`), invoke `/update-coverage` to refresh the README badge. Atomic commit if the percentage moved. | -| 8 | GH settings drift | Skipped under `CI=true`. Otherwise: `node scripts/lint-github-settings.mts --force --json` and surface findings (repo-settings drift, missing apps (cursor/claude/socket-security/etc), custom-property/visibility mismatches). Read-only; operator follows the fixUrl in each finding. | -| 9 | Final validation | Interactive only: `pnpm run check --all && pnpm test && pnpm run build`. CI skips (validated separately). | -| 10 | Report | Per-category summary: npm / lockstep / submodules / security / SHA pins / coverage / settings drift / validation / next steps. | - -Full bash, exit-code tables, mode contracts, and failure recovery in [`reference.md`](reference.md). - -## Hard requirements - -- **Clean tree on entry**: no uncommitted changes. -- **Atomic commits per category**: npm in one commit, each lockstep auto-bump in its own commit, each submodule bump in its own commit. -- **Conventional Commits** per CLAUDE.md. -- **Default-branch fallback**: never hard-code `main` or `master` in scripts. - -## Success criteria - -- All npm packages checked. -- Lockstep manifest validated (when present); schema errors block. -- Open Dependabot alerts either fixed, awaiting-soak, or dismissed with a documented reason. -- Full check + tests pass (interactive mode). -- Summary report printed. - -**Safety:** updates are validated before committing. Schema errors (lockstep exit 1) stop the process; drift (exit 2) is advisory and does not block. Security-advisory fixes never `--force` push. Per-alert commits go through the normal push-or-PR flow. diff --git a/.claude/skills/updating/reference.md b/.claude/skills/updating/reference.md deleted file mode 100644 index b5002bbf9..000000000 --- a/.claude/skills/updating/reference.md +++ /dev/null @@ -1,185 +0,0 @@ -# updating reference - -Long-form details for the `updating` umbrella skill — phase scripts, exit-code semantics, and per-mode contracts. The orchestration story lives in [`SKILL.md`](SKILL.md). - -Phase numbers below match SKILL.md's table. Phase 1 (Validate -environment) is procedural and has no bash — see the SKILL.md -description directly. Phase 5 (Security advisories) and Phase 7 -(Coverage badge) are documented in their respective sub-skill -references: [`../updating-security/reference.md`](../updating-security/reference.md) -and [`../updating-coverage/SKILL.md`](../updating-coverage/SKILL.md). - -## Phase scripts - -### Phase 2 — npm packages - -```bash -pnpm run update - -if [ -n "$(git status --porcelain)" ]; then - git add pnpm-lock.yaml package.json */package.json - git commit -m "chore: update npm dependencies - -Updated npm packages via pnpm run update." - echo "npm packages updated" -else - echo "npm packages already up to date" -fi -``` - -### Phase 3 — Validate lockstep manifest (if `lockstep.json` exists) - -```bash -if [ -f lockstep.json ]; then - pnpm run lockstep - LOCKSTEP_EXIT=$? - - case $LOCKSTEP_EXIT in - 0) echo "✓ lockstep clean — manifest valid, no drift; skip Phase 4 lockstep step" ;; - 1) echo "✗ lockstep schema/structural error — stopping"; exit 1 ;; - 2) echo "⚠ lockstep drift — Phase 4 will invoke updating-lockstep to act" ;; - esac -fi -``` - -#### Lockstep exit-code semantics - -| Exit | Meaning | Action | -| ---- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | Manifest valid, no drift | Skip lockstep step in Phase 4 | -| 1 | Schema violation, missing file, or unreachable baseline | Stop and investigate via `scripts/lockstep-schema.mts` and the failing row's `local_*`/`upstream` fields. Do not auto-retry. | -| 2 | Drift detected | Phase 4 invokes `updating-lockstep`. Auto-bumps mechanical `version-pin` rows per `upgrade_policy`; everything else (`file-fork` / `feature-parity` / `spec-conformance` / `lang-parity` / `locked` version-pins) becomes advisory in the PR body. | - -`locked` version-pin rows never auto-bump — they need a coordinated upstream change first (e.g., `temporal-rs` is `locked` because Node vendors it and bumping is gated on a Node bump landing first). - -If `lockstep.json` does NOT exist, skip Phase 3 entirely. - -### Phase 4 — Apply drift + non-lockstep submodules - -**4a. lockstep drift** — if Phase 3 reported exit 2: - -```bash -if [ "$LOCKSTEP_EXIT" = "2" ]; then - # Invoke via the Skill tool / programmatic-claude flow used by the - # weekly-update workflow. Standalone runs can do `/updating-lockstep`. - echo "Invoking updating-lockstep for drift handling" -fi -``` - -`updating-lockstep` auto-bumps `version-pin` rows whose `upgrade_policy` is `track-latest` or `major-gate` (patch/minor only — majors → advisory), and emits an advisory block for everything else. Each auto-bumped row becomes its own atomic commit. - -**4b. Non-lockstep submodules** — invoke each repo-specific `updating-*` sub-skill (e.g. `updating-node`, `updating-curl`) for submodules NOT claimed by a lockstep `version-pin` row. These sub-skills handle build inputs that aren't tracked in lockstep (cache-versions bumps, patch regeneration, etc.). - -If no `.gitmodules` exists, skip 4b. - -### Phase 6 — Workflow SHA pins - -Resolve the default branch (per CLAUDE.md _Default branch fallback_), then compare: - -```bash -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/main; then BASE=main; fi -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/master; then BASE=master; fi -BASE="${BASE:-main}" - -PINNED_SHA=$(grep -ohP '(?<=@)[0-9a-f]{40}' .github/workflows/_local-not-for-reuse-ci.yml 2>/dev/null | head -1) -DEFAULT_SHA=$(git rev-parse "origin/$BASE" 2>/dev/null || echo "") - -if [ -n "$PINNED_SHA" ] && [ -n "$DEFAULT_SHA" ] && [ "$PINNED_SHA" != "$DEFAULT_SHA" ]; then - echo "Workflow SHA pins are stale: $PINNED_SHA → $DEFAULT_SHA (origin/$BASE)" - echo "Run the updating-workflows skill to cascade." -else - echo "Workflow SHA pins are up to date (or no _local-not-for-reuse-*.yml pins in this repo)" -fi -``` - -### Phase 8 — GitHub settings drift (skip in CI) - -`scripts/lint-github-settings.mts` audits repo + Actions settings -against the fleet baseline. Read-only by default; surfaces findings -with a fixUrl for each (operator clicks through to apply). The -underlying script's CI-skip is intentional — it has its own 7-day -local cache and the umbrella honours that. - -```bash -if [ "$CI" = "true" ] || [ -n "$GITHUB_ACTIONS" ]; then - echo "CI mode: skipping GH settings audit" -elif [ -f scripts/lint-github-settings.mts ]; then - node scripts/lint-github-settings.mts --force --json | tee /tmp/gh-settings-audit.json - # Findings are not auto-fixed by the umbrella — operator decides - # per-finding whether to follow the URL or `pnpm exec node - # scripts/lint-github-settings.mts --fix` (needs repo:admin). -else - echo "No scripts/lint-github-settings.mts in this repo; skip" -fi -``` - -Common finding shapes (full taxonomy in `scripts/lint-github-settings.mts`): - -- `doesnt-touch-customers must match visibility` — public→`false`, private→`true`. Manual fix at `…/settings/custom-properties`. -- `GitHub App must be installed: <slug>` — install via `https://github.com/apps/<slug>`. Current required apps: `claude`, `cursor`, `socket-security`, `socket-security-staging`, `socket-trufflehog`. -- `<repo-setting> must be <value>` — usually fixable via `--fix` (needs `repo:admin`) or the GitHub UI link in the finding. - -### Phase 9 — Final validation (skip in CI) - -```bash -if [ "$CI" = "true" ] || [ -n "$GITHUB_ACTIONS" ]; then - echo "CI mode: skipping validation" -else - pnpm run check --all - pnpm test - pnpm run build # if this repo has a build step -fi -``` - -### Phase 10 — Report - -``` -## Update Complete - -### Updates Applied: - -| Category | Status | -|--------------------|--------------------------------------| -| npm packages | Updated / Up to date | -| lockstep manifest | <ok>/<total> ok, <drift> drift, <error> error (exit <code>) — or n/a | -| Other submodules | K bumped — or n/a | -| Workflow SHA pins | Up to date / Stale | - -### Commits Created: -- [list commits, if any] - -### Validation: -- Build: SUCCESS / SKIPPED (CI mode) -- Tests: PASS / SKIPPED (CI mode) - -### Next Steps: -**Interactive mode:** -1. Review changes: `git log --oneline -N` -2. Push to remote: `git push origin "$BASE"` (where `$BASE` is the default branch resolved in Phase 5 — `main` for most fleet repos, `master` for legacy ones) - -**CI mode:** -1. Workflow will push branch and create PR -2. CI will run full build/test validation -3. Review PR when CI passes -``` - -## Mode contracts - -### CI mode (`CI=true` or `GITHUB_ACTIONS`) - -- Create atomic commits per category (npm, lockstep auto-bumps, submodule bumps). -- Skip Phase 6 build/test validation — CI validates separately. -- Workflow handles push and PR creation. - -### Interactive mode (default) - -- Run Phase 6 build + test before reporting "complete." -- Report validation results to the user. -- Direct push by the user once they've reviewed. - -## Failure recovery - -- **Phase 3 exit 1 (schema error):** stop. Read `scripts/lockstep-schema.mts` output and the offending row's `local_*` / `upstream` fields. Fix the manifest, then re-run. -- **Phase 4a (lockstep drift) commits but Phase 6 tests fail:** the per-row commits are atomic — `git revert <sha>` for the offending row, leave the others, file an advisory. -- **Phase 5 stale SHA pin:** run `/updating-workflows` to cascade the bump. diff --git a/.claude/skills/worktree-management/SKILL.md b/.claude/skills/worktree-management/SKILL.md deleted file mode 100644 index a959cf4e2..000000000 --- a/.claude/skills/worktree-management/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: worktree-management -description: Manages git worktrees per the fleet's parallel-Claude-sessions rule. Creates new task-worktrees, fans out one worktree per open PR for parallel review, and prunes stale worktrees whose branches were deleted upstream. Use when starting a task that needs an isolated working tree, when reviewing every open PR locally without disturbing the primary checkout, or when cleaning up after merges. -user-invocable: true -allowed-tools: Bash(git worktree:*), Bash(git branch:*), Bash(git fetch:*), Bash(gh pr list:*), Bash(gh auth status), Bash(ls:*), Read ---- - -# worktree-management - -The `Parallel Claude sessions` rule in CLAUDE.md mandates worktrees for branch work. This skill is the helper that makes that ergonomic. Three modes, surgical, no auto-cleanup of work you didn't make. - -## When to use - -- **Starting a task that needs a branch.** Spawn a worktree instead of `git checkout`-ing in the primary checkout. -- **Reviewing all open PRs locally.** One worktree per PR, lined up under `../<repo>-pr-<num>/` so multiple Claude sessions can each take one. -- **Cleaning up stale worktrees** after PRs merge or branches get deleted upstream. - -Never use this skill to remove a worktree that has uncommitted work. The _Don't leave the worktree dirty_ rule applies; the dirty worktree is held until its owner commits. - -## Modes - -### Mode 1: `new <task-name>` (default) - -Spawn a new worktree at `../<repo>-<task-name>/` based on the remote's default branch. - -```bash -TASK_NAME="$1" # required -REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") -WORKTREE_PATH="../${REPO_NAME}-${TASK_NAME}" -BRANCH="${TASK_NAME}" - -# Default-branch fallback per CLAUDE.md: main → master → assume main. -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/main; then BASE=main; fi -if [ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/master; then BASE=master; fi -BASE="${BASE:-main}" - -git fetch origin "$BASE" -git worktree add -b "$BRANCH" "$WORKTREE_PATH" "origin/$BASE" -echo "✓ Worktree ready at $WORKTREE_PATH on branch $BRANCH (base: $BASE)" -echo " cd $WORKTREE_PATH" -``` - -If `$TASK_NAME` collides with an existing branch, fail with the conflict. Never silently overwrite. - -### Mode 2: `pr-fanout` - -For each open PR on the current GitHub repo, ensure a worktree exists at `../<repo>-pr-<num>/`. Idempotent: skip PRs whose worktree already exists. - -```bash -gh auth status >/dev/null # fail loudly if not authenticated -REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") - -gh pr list --json number,headRefName --jq '.[]' | while read -r pr_json; do - PR=$(echo "$pr_json" | jq -r '.number') - BRANCH=$(echo "$pr_json" | jq -r '.headRefName') - WORKTREE_PATH="../${REPO_NAME}-pr-${PR}" - - if [ -d "$WORKTREE_PATH" ]; then - echo "= pr-${PR} already at $WORKTREE_PATH" - continue - fi - - git fetch origin "$BRANCH:refs/remotes/origin/$BRANCH" 2>/dev/null - git worktree add "$WORKTREE_PATH" "origin/$BRANCH" - echo "+ pr-${PR} (branch $BRANCH) → $WORKTREE_PATH" -done - -git worktree list -``` - -This is the multi-Claude review setup: each open PR gets its own checkout so a parallel session can take one without contention. - -### Mode 3: `prune` - -Remove worktrees whose **branch no longer exists** on the remote AND whose **working tree is clean**. Never auto-remove a dirty tree. That may be active work. - -```bash -git worktree list --porcelain | awk '/^worktree /{path=$2} /^branch /{branch=$2; print path"\t"branch}' | while IFS=$'\t' read -r path branch; do - # Skip the primary checkout - if [ "$path" = "$(git rev-parse --show-toplevel)" ]; then continue; fi - - branch_short="${branch#refs/heads/}" - - # Skip if branch still exists on remote - if git ls-remote --exit-code --heads origin "$branch_short" >/dev/null 2>&1; then - echo "= keep $path (branch $branch_short still on remote)" - continue - fi - - # Skip if working tree is dirty - if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then - echo "! skip $path (dirty; has uncommitted changes; commit first per 'Don't leave the worktree dirty' rule)" - continue - fi - - echo "- prune $path (branch $branch_short gone from remote, tree clean)" - git worktree remove "$path" -done -``` - -The `prune` mode never passes `--force`. If the user wants to discard dirty work, they do it deliberately, outside this skill. - -## Safety contract - -This skill respects four CLAUDE.md rules: - -1. **Parallel Claude sessions**: only ever creates new worktrees; never `checkout`-s an existing one. -2. **Don't leave the worktree dirty**: refuses to `prune` a dirty tree. -3. **Public-surface hygiene**: task names must not contain customer / company / internal-tool names. The skill does no redaction; the user picks a clean name. -4. **Default branch fallback**: every base-branch lookup follows the `main → master → assume main` chain via `git symbolic-ref refs/remotes/origin/HEAD`. Never hard-code one or the other. - -## Source - -The pr-fanout pattern is borrowed from the `/create-worktrees` slash command in https://github.com/evmts/tevm-monorepo/blob/main/.claude/commands/create-worktrees.md, adapted to the fleet's `../<repo>-<task>/` layout convention and the parallel-Claude rule's safety contract. diff --git a/.config/.markdownlint-cli2.jsonc b/.config/.markdownlint-cli2.jsonc deleted file mode 100644 index c681a7c97..000000000 --- a/.config/.markdownlint-cli2.jsonc +++ /dev/null @@ -1,51 +0,0 @@ -// markdownlint-cli2 configuration for fleet repos. -// -// Loaded by `pnpm run lint` (template/scripts/lint.mts invokes -// markdownlint-cli2 with `-c .config/.markdownlint-cli2.jsonc`). -// -// Two concerns: -// 1. Stock markdownlint rules — disable a handful that bite real prose -// without adding value, leave the rest at defaults. -// 2. Fleet-canonical custom rules under markdownlint-rules/ — these -// enforce the fleet's README hygiene contract (no private-repo -// mentions, no relative-path commands, README skeleton structure). -{ - "config": { - "default": true, - // MD013 (line-length) bites code-block-heavy READMEs without warning. - // Disabled fleet-wide; reviewers catch genuinely-too-long prose. - "MD013": false, - // MD033 (no inline HTML) bites our <details> collapsed Development - // sections and Socket-badge img tags. Allow specific tags only. - "MD033": { "allowed_elements": ["details", "summary", "img", "br"] }, - // MD041 (first-line-h1) is the contract; keep it on. - // MD024 (no-duplicate-heading) — siblings-only mode so that ### subsections - // can repeat under different ## parents (common in API docs). - "MD024": { "siblings_only": true }, - }, - // Globs: every *.md / *.mdx under the repo, except generated output, - // node_modules, vendored upstream trees, and CHANGELOG.md (auto-generated). - "globs": ["**/*.md", "**/*.mdx"], - "ignores": [ - "**/node_modules/**", - "**/dist/**", - "**/build/**", - "**/coverage/**", - "**/vendor/**", - "**/upstream/**", - "**/third_party/**", - "**/CHANGELOG.md", - // .claude/ markdown is internal scaffolding (hook READMEs, agent - // role files, skill SKILL.mds, command reminders) — scoped docs - // with their own shape conventions. Excluded from the public- - // facing markdown lint surface. - "**/.claude/**", - ], - // Custom rule plugins live under markdownlint-rules/, byte-identical - // across the fleet via sync-scaffolding manifest registration. - "customRules": [ - "./markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", - "./markdownlint-rules/socket-no-relative-sibling-script.mjs", - "./markdownlint-rules/socket-readme-required-sections.mjs", - ], -} diff --git a/.config/.prettierignore b/.config/.prettierignore deleted file mode 100644 index 6cbe4c693..000000000 --- a/.config/.prettierignore +++ /dev/null @@ -1,47 +0,0 @@ -# Format-ignore (but track) — files we keep byte-identical with their -# upstream / vendored source. oxfmt reads this file when pointed at it -# via `--ignore-path .config/.prettierignore` (default is the CWD's -# .gitignore + .prettierignore). The lint runner threads the flag in -# so the convention works from any working directory. -# -# `.claude/` is treated like node_modules — never formatted, never -# linted, no matter whether the files are git-tracked. Hooks, skills, -# vendored AST tooling, settings — all opaque to the formatter. -**/.claude/** -.claude/** - -# Vendored acorn.wasm binary blob + the wasm-bindgen CJS glue. The -# glue (`acorn-bindgen.cjs`) is wasm-bindgen output we ship verbatim -# (after a single string rewrite of the wasm filename). It must NOT -# be touched by oxfmt or oxlint --fix: the -# `socket/export-top-level-functions` autofix rewrites internal -# helpers like `function getObject(idx) { ... }` into -# `export function getObject(idx) { ... }`, turning the CJS module -# into syntactically-ESM. The first `require()` then fails with -# `SyntaxError: Unexpected token 'export'`. Past incident: cascaded -# to two fleet repos before the break surfaced. -# -# The generators we DO own (`acorn-wasm-sync.{mts,cts}`, -# `acorn-wasm-embed.{mts,cts}`) are not listed here on purpose — -# the ultrathink build emits them already formatted+linted per fleet -# rules so they participate in the regular lint pass like any other -# JS source. Only the raw wasm blob + the bindgen glue skip the -# formatter. Marked `binary` in .gitattributes for the wasm blob too -# so PR diffs collapse. -template/.claude/hooks/_shared/acorn/acorn.wasm -template/.claude/hooks/_shared/acorn/acorn-bindgen.cjs -.claude/hooks/_shared/acorn/acorn-bindgen.cjs - -# Vendored / upstream trees — kept byte-identical with their source -# of truth. Per CLAUDE.md "Untracked-by-default for vendored / build- -# copied trees": these are someone else's source, not ours, and the -# formatter would happily rewrite (e.g.) an upstream HTML test -# fixture or shipped third-party JS into our local style. -**/upstream/** -upstream/** -**/vendor/** -vendor/** -**/third_party/** -third_party/** -**/external/** -external/** diff --git a/.config/babel.config.js b/.config/babel.config.js new file mode 100644 index 000000000..ee78b19c5 --- /dev/null +++ b/.config/babel.config.js @@ -0,0 +1,27 @@ +'use strict' + +const path = require('node:path') + +const rootPath = path.join(__dirname, '..') +const scriptsPath = path.join(rootPath, 'scripts') +const babelPluginsPath = path.join(scriptsPath, 'babel') + +module.exports = { + presets: ['@babel/preset-typescript'], + plugins: [ + '@babel/plugin-proposal-export-default-from', + '@babel/plugin-transform-export-namespace-from', + [ + '@babel/plugin-transform-runtime', + { + absoluteRuntime: false, + corejs: false, + helpers: true, + regenerator: false, + version: '^7.27.1', + }, + ], + path.join(babelPluginsPath, 'transform-set-proto-plugin.js'), + path.join(babelPluginsPath, 'transform-url-parse-plugin.js'), + ], +} diff --git a/.config/lockstep.json b/.config/lockstep.json deleted file mode 100644 index 4bc8cf992..000000000 --- a/.config/lockstep.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "./lockstep.schema.json", - "area": "socket-cli", - "description": "Lock-step manifest. socket-cli is a CLI distribution with no submodule upstreams or sibling language ports — empty rows is the expected state.", - "upstreams": {}, - "rows": [] -} diff --git a/.config/lockstep.schema.json b/.config/lockstep.schema.json deleted file mode 100644 index f0b768982..000000000 --- a/.config/lockstep.schema.json +++ /dev/null @@ -1,463 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/SocketDev/lockstep.schema.json", - "title": "lockstep manifest", - "description": "Unified lock-step manifest shared across Socket repos. One schema, all cases — the `kind` discriminator on each row selects which flavor of lock-step applies. Single-file manifests work for repos with one cohesive concern; the `includes[]` field carves a manifest into per-area files (e.g. lockstep-acorn.json + lockstep-build.json) when one repo tracks multiple independent concerns.", - "type": "object", - "required": ["rows"], - "properties": { - "$schema": { - "description": "JSON Schema reference for editor autocompletion. Conventionally `./lockstep.schema.json` — both the manifest and its schema live side-by-side at repo root.", - "type": "string" - }, - "description": { - "description": "Human-readable description of what this manifest tracks. Read by humans, not parsed. One short paragraph.", - "type": "string" - }, - "area": { - "description": "Optional label for this manifest file. Used as a grouping key in harness output (per-area summaries). Defaults to 'root' for the top-level file and to the filename stem (with the `lockstep-` prefix stripped) for included files.", - "type": "string" - }, - "includes": { - "description": "Relative paths to sub-manifests. The harness loads each and merges its rows into a single flattened view. Top-level `upstreams` and `sites` maps override any same-keyed entries from included manifests (top wins on conflict).", - "type": "array", - "items": { - "type": "string" - } - }, - "upstreams": { - "description": "Named upstream submodules. Each entry pairs a submodule path with its repo URL. Referenced by rows[].upstream on file-fork / version-pin / feature-parity / spec-conformance rows. Omit when the manifest only has lang-parity rows.", - "type": "object", - "patternProperties": { - "^(.*)$": { - "additionalProperties": false, - "description": "A submodule + its upstream repo URL. Referenced by file-fork / version-pin / feature-parity / spec-conformance rows via `upstream`.", - "type": "object", - "required": ["submodule", "repo"], - "properties": { - "submodule": { - "description": "Submodule path, relative to repo root. Must match an entry in `.gitmodules`.", - "type": "string" - }, - "repo": { - "pattern": "^https?://[^/\\s]+", - "description": "Upstream repository URL (http:// or https:// + host). Anchored at the host so empty URLs fail validation rather than failing at git-fetch time.", - "type": "string" - } - } - } - } - }, - "sites": { - "description": "Named sibling ports (typically per-language: `cpp`, `go`, `rust`, `typescript`). Referenced by rows[].ports.<site> on lang-parity rows. Omit when the manifest has no lang-parity rows.", - "type": "object", - "patternProperties": { - "^(.*)$": { - "additionalProperties": false, - "description": "A sibling port (typically per-language). Referenced by lang-parity rows via `ports.<site-key>`.", - "type": "object", - "required": ["path"], - "properties": { - "path": { - "description": "Path to the port's root directory, relative to repo root. The harness reads files under this path when checking the port's assertions.", - "type": "string" - }, - "language": { - "description": "Language label for human reports (e.g. `cpp`, `go`, `rust`, `typescript`). The harness does no language-specific processing — it's purely informational.", - "type": "string" - } - } - } - } - }, - "rows": { - "description": "The actual checks the harness runs. Empty array is valid (and expected for repos that have no upstream relationships — e.g. socket-cli's empty rows).", - "type": "array", - "items": { - "anyOf": [ - { - "additionalProperties": false, - "description": "A local file derived from an upstream file with intentional modifications. Drift = upstream moved forward on this path; we may need to cherry-pick or update our deviations.", - "type": "object", - "required": [ - "kind", - "id", - "upstream", - "local", - "upstream_path", - "forked_at_sha", - "deviations" - ], - "properties": { - "kind": { - "const": "file-fork", - "type": "string" - }, - "id": { - "pattern": "^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$", - "description": "Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.", - "type": "string" - }, - "upstream": { - "description": "Key into the top-level `upstreams` map. The harness errors if no matching upstream entry exists.", - "type": "string" - }, - "criticality": { - "minimum": 1, - "maximum": 10, - "description": "Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.", - "type": "integer" - }, - "conformance_test": { - "description": "Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.", - "type": "string" - }, - "notes": { - "description": "Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.", - "type": "string" - }, - "local": { - "description": "Path (relative to repo root) of our ported copy of the upstream file.", - "type": "string" - }, - "upstream_path": { - "description": "Path within the upstream submodule (relative to the submodule root) of the source file we forked from.", - "type": "string" - }, - "forked_at_sha": { - "pattern": "^[0-9a-f]{40}$", - "description": "Full 40-char SHA of the upstream commit we forked from. The harness runs `git log <sha>..HEAD -- <upstream_path>` inside the submodule to surface drift.", - "type": "string" - }, - "deviations": { - "minItems": 1, - "description": "Human-readable list of intentional differences from upstream. Zero deviations = the file should not be forked; consume upstream directly. Each entry is one short sentence (e.g. `swap require() for import` or `remove Node 14 fallback`).", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - { - "additionalProperties": false, - "description": "A submodule pinned to an upstream release. Drift = upstream cut a new release we haven't adopted.", - "type": "object", - "required": [ - "kind", - "id", - "upstream", - "pinned_sha", - "upgrade_policy" - ], - "properties": { - "kind": { - "const": "version-pin", - "type": "string" - }, - "id": { - "pattern": "^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$", - "description": "Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.", - "type": "string" - }, - "upstream": { - "description": "Key into the top-level `upstreams` map. The harness errors if no matching upstream entry exists.", - "type": "string" - }, - "criticality": { - "minimum": 1, - "maximum": 10, - "description": "Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.", - "type": "integer" - }, - "conformance_test": { - "description": "Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.", - "type": "string" - }, - "notes": { - "description": "Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.", - "type": "string" - }, - "pinned_sha": { - "pattern": "^[0-9a-f]{40}$", - "description": "Full 40-char SHA the submodule is pinned to. Authoritative — the harness compares this against the submodule HEAD, not against `pinned_tag`.", - "type": "string" - }, - "pinned_tag": { - "description": "Human-readable release tag for reports / PR titles (e.g. `v3.2.1`). Informational only — `pinned_sha` is the source of truth. Useful when an upstream cuts a release without changing semver but moves the SHA.", - "type": "string" - }, - "upgrade_policy": { - "description": "`track-latest` = any new release is actionable; updating-lockstep auto-bumps. `major-gate` = patch / minor auto-bump; major bumps surfaced as advisory. `locked` = explicit decision per upgrade; the harness reports drift but never auto-bumps. Pick `locked` when bumping is gated on a coordinated change in another repo (e.g. Node vendoring temporal-rs).", - "anyOf": [ - { - "const": "track-latest", - "type": "string" - }, - { - "const": "major-gate", - "type": "string" - }, - { - "const": "locked", - "type": "string" - } - ] - } - } - }, - { - "additionalProperties": false, - "description": "A behavioral feature reimplemented locally to match upstream behavior. Three-pillar validation: code patterns + test patterns + fixture snapshot. The total score is averaged across present pillars; rows below the criticality / 10 floor surface as drift.", - "type": "object", - "required": ["kind", "id", "upstream", "criticality", "local_area"], - "properties": { - "kind": { - "const": "feature-parity", - "type": "string" - }, - "id": { - "pattern": "^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$", - "description": "Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.", - "type": "string" - }, - "upstream": { - "description": "Key into the top-level `upstreams` map. The harness errors if no matching upstream entry exists.", - "type": "string" - }, - "criticality": { - "minimum": 1, - "maximum": 10, - "description": "Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.", - "type": "integer" - }, - "conformance_test": { - "description": "Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.", - "type": "string" - }, - "notes": { - "description": "Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.", - "type": "string" - }, - "local_area": { - "description": "Path (relative to repo root) of the local module / directory implementing the feature. The code-pattern scan targets this directory recursively, excluding test files (matched by `*.test.{ts,mts,js,mjs}` and `*.spec.*`).", - "type": "string" - }, - "test_area": { - "description": "Path (relative to repo root) of the directory where tests for this feature live. When absent, the harness searches for tests inside `local_area`. Useful when tests live in a sibling directory (e.g. `local_area=src/auth`, `test_area=test/auth`).", - "type": "string" - }, - "code_patterns": { - "description": "Regex patterns the local implementation must contain. Prefer anchored patterns (function signatures, exported symbols) over loose keywords to avoid matching comments. Each pattern is searched independently across `local_area`; missing patterns lower the code score.", - "type": "array", - "items": { - "type": "string" - } - }, - "test_patterns": { - "description": "Regex patterns the test suite must contain. Same scoring as `code_patterns` but searched across `test_area` (or `local_area` when `test_area` is absent).", - "type": "array", - "items": { - "type": "string" - } - }, - "fixture_check": { - "additionalProperties": false, - "description": "Golden-input verification. Snapshot-based diffs replace the brittle hardcoded-count checks the harness used historically (sdxgen's lock-step-features lesson).", - "type": "object", - "required": ["fixture_path"], - "properties": { - "fixture_path": { - "description": "Path (relative to repo root) of the input fixture the local implementation runs against.", - "type": "string" - }, - "snapshot_path": { - "description": "Path (relative to repo root) of the snapshot file the implementation's output is diffed against. When absent, the harness only checks that the fixture is processed without error — no output comparison.", - "type": "string" - }, - "diff_tolerance": { - "description": "How the snapshot diff is computed. `exact` = byte-identical; the strictest check. `line-by-line` = per-line diff after normalizing line endings (CRLF / LF); tolerates trailing-newline drift. `semantic` = harness-defined deeper comparison (typically AST or normalized JSON for output that has equivalent representations); each row kind documents what `semantic` means in its context.", - "anyOf": [ - { - "const": "exact", - "type": "string" - }, - { - "const": "line-by-line", - "type": "string" - }, - { - "const": "semantic", - "type": "string" - } - ] - } - } - } - } - }, - { - "additionalProperties": false, - "description": "A local reimplementation of an external specification. Drift = the spec was revised; we may need to update our impl, the spec_version, or both.", - "type": "object", - "required": [ - "kind", - "id", - "upstream", - "local_impl", - "spec_version" - ], - "properties": { - "kind": { - "const": "spec-conformance", - "type": "string" - }, - "id": { - "pattern": "^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$", - "description": "Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.", - "type": "string" - }, - "upstream": { - "description": "Key into the top-level `upstreams` map. The harness errors if no matching upstream entry exists.", - "type": "string" - }, - "criticality": { - "minimum": 1, - "maximum": 10, - "description": "Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.", - "type": "integer" - }, - "conformance_test": { - "description": "Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.", - "type": "string" - }, - "notes": { - "description": "Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.", - "type": "string" - }, - "local_impl": { - "description": "Path (relative to repo root) of our reimplementation of the spec. Either a file or a directory.", - "type": "string" - }, - "spec_version": { - "description": "Version label of the spec we conform to (e.g. `ECMAScript-2024`, `RFC-9110`, commit SHA, or upstream tag). Free-form — the harness only checks for drift via the upstream submodule, not the version string itself.", - "type": "string" - }, - "spec_path": { - "description": "Path within the upstream submodule to the spec document. Used to scope drift detection to the spec file (rather than every change in the upstream repo).", - "type": "string" - } - } - }, - { - "additionalProperties": false, - "description": "N sibling language ports of one spec within a single project. Drift = a port diverged from its siblings (one implemented, others opt-out without reason / or vice versa), or a `rejected` anti-pattern was reintroduced.", - "type": "object", - "required": [ - "kind", - "id", - "name", - "description", - "category", - "ports" - ], - "properties": { - "kind": { - "const": "lang-parity", - "type": "string" - }, - "id": { - "pattern": "^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$", - "description": "Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.", - "type": "string" - }, - "name": { - "description": "Short human-readable label for this row (e.g. `Range parsing`, `Async iterators`). Used in report headers; not parsed.", - "type": "string" - }, - "description": { - "description": "One-paragraph description of what behavior this row asserts on each port. Read by humans; not parsed.", - "type": "string" - }, - "category": { - "description": "Grouping tag for report aggregation (e.g. `parser`, `runtime`, `api`). The single magic value is `rejected` — RESERVED for anti-patterns: every port MUST be `opt-out`, and any port flipping to `implemented` exits 2 ('rejected anti-pattern reintroduced'). Use freely otherwise.", - "type": "string" - }, - "criticality": { - "minimum": 1, - "maximum": 10, - "description": "Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.", - "type": "integer" - }, - "conformance_test": { - "description": "Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.", - "type": "string" - }, - "notes": { - "description": "Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.", - "type": "string" - }, - "assertions": { - "description": "Assertions checked against each port. Each entry is `{kind: string, ...}`; the harness dispatches on `kind`. See AssertionSchema description for known kinds; unknown kinds skip with a log line. Mutually compatible with `matrix_files` (a row can have both, neither, or one).", - "type": "array", - "items": { - "description": "A typed assertion the lang-parity row asserts on each port. Shape: `{kind: string, ...kind-specific fields}`. The lockstep harness dispatches on `kind`; per-kind contracts are documented in the harness, not here.", - "type": "object", - "patternProperties": { - "^(.*)$": {} - } - } - }, - "matrix_files": { - "description": "Paths (relative to this manifest) of `lockstep-lang-*.json` sub-manifests this row indexes. For inventory-style rows that group many smaller checks under one parent. The harness loads each and merges its rows.", - "type": "array", - "items": { - "type": "string" - } - }, - "ports": { - "description": "Per-port status map. Keys MUST match top-level `sites` keys exactly — the harness errors on stray ports / missing sites. Each value is `{status: 'implemented' | 'opt-out', ...}` per PortStatusSchema.", - "type": "object", - "patternProperties": { - "^(.*)$": { - "additionalProperties": false, - "description": "Per-port status for a lang-parity row. The `ports` map on a row pairs each top-level `sites` key with one of these.", - "type": "object", - "required": ["status"], - "properties": { - "status": { - "description": "`implemented` = port meets the row's assertions; `opt-out` = port consciously skips this row (requires `reason`).", - "anyOf": [ - { - "const": "implemented", - "type": "string" - }, - { - "const": "opt-out", - "type": "string" - } - ] - }, - "reason": { - "description": "Why this port opts out. SCHEMA-CONDITIONAL: required when status is `opt-out`. The TypeBox type cannot express the conditional, but the harness rejects opt-out rows with empty / missing reason.", - "type": "string" - }, - "path": { - "description": "Optional path to this port's implementation of the row. Useful for module-inventory rows where each language points at a different directory; redundant when the port's overall layout already encodes the path.", - "type": "string" - }, - "note": { - "description": "Optional free-form note attached to this specific port's status. For multi-port context, prefer the row-level `notes` field.", - "type": "string" - } - } - } - } - } - } - } - ] - } - } - } -} diff --git a/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs b/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs deleted file mode 100644 index 9c522231b..000000000 --- a/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Shared helper for fleet markdown rules: detect whether the lint is - * running inside socket-wheelhouse itself, in which case the rule should - * bail. The custom rules in this directory exist to protect PUBLIC fleet - * consumers from leaking internal scaffolding; wheelhouse referencing itself - * in its own docs is the canonical case and must not trigger. Detection - * prefers explicit env override (CI sets SOCKET_FLEET_REPO_NAME) then falls - * back to checking the cwd's basename and git remote. - */ - -// oxlint-disable-next-line socket/prefer-async-spawn -- markdownlint-cli2 calls isInsideWheelhouse() synchronously at rule init; an async spawn would require the rule loader to await, which markdownlint-cli2 doesnt support. -import { spawnSync } from 'node:child_process' -import path from 'node:path' -import process from 'node:process' - -export function isInsideWheelhouse() { - const envName = process.env['SOCKET_FLEET_REPO_NAME'] - if (envName) { - return envName === 'socket-wheelhouse' - } - const cwd = process.cwd() - if (path.basename(cwd) === 'socket-wheelhouse') { - return true - } - // Fallback: probe the git remote URL. Tolerates renamed local - // checkout dirs (`~/projects/wheelhouse/` would still match). - // spawnSync (not execSync) — array args, no shell interpolation. - // This file is loaded by markdownlint-cli2 as a regular ESM module, - // not bundled, so we cant pull in @socketsecurity/lib-stable/spawn — - // node:child_process spawnSync is the canonical fallback. - const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { - cwd, - stdio: ['ignore', 'pipe', 'ignore'], - }) - if (r.status !== 0 || !r.stdout) { - return false - } - const remote = r.stdout.toString().trim() - return /[/:]socket-wheelhouse(?:\.git)?$/.test(remote) -} diff --git a/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs b/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs deleted file mode 100644 index ae4dbdaff..000000000 --- a/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @file Flag mentions of `socket-wheelhouse` in public-facing markdown. - * socket-wheelhouse is a private repo. Public READMEs / docs / release notes - * that link to it leak the internal tooling layout to users who can't access - * the link anyway. Whatever the markdown is trying to teach should be - * rewritten to not require the reference. Detects: - * - * - The literal token `socket-wheelhouse` (case-insensitive) anywhere in a - * line. - * - `https://github.com/SocketDev/socket-wheelhouse...` URL forms. Skips fenced - * code blocks because those are intentional examples (and fenced-block - * scanning would false-positive on the very markdownlint config that - * references this file). No autofix: the right rewrite is contextual. - */ - -import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' - -const RULE_NAME = 'socket-no-private-wheelhouse-leak' -const FORBIDDEN_TOKEN_RE = /socket-wheelhouse/i - -/** - * @type {import('markdownlint').Rule} - */ -const rule = { - names: [RULE_NAME, 'socket/no-private-wheelhouse-leak'], - description: - 'socket-wheelhouse is a private repo — never reference it in public markdown', - tags: ['socket', 'privacy'], - parser: 'none', - function(params, onError) { - if (isInsideWheelhouse()) { - return - } - let inFence = false - for (let i = 0; i < params.lines.length; i += 1) { - const line = params.lines[i] - // Track fenced-code state. Open/close on lines that START with ``` or ~~~. - if (/^\s*(?:```|~~~)/.test(line)) { - inFence = !inFence - continue - } - if (inFence) { - continue - } - const match = FORBIDDEN_TOKEN_RE.exec(line) - if (!match) { - continue - } - onError({ - lineNumber: i + 1, - detail: - 'Rewrite to not mention socket-wheelhouse — it is a private repo and the link will 404 for outside readers.', - context: line.trim().slice(0, 120), - range: [match.index + 1, match[0].length], - }) - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. -export default rule diff --git a/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs b/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs deleted file mode 100644 index 7fe4453c7..000000000 --- a/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file Flag commands that reference sibling repos via relative paths. `node - * ../socket-foo/scripts/bar.mts` in a fleet README assumes the reader has the - * sibling repo checked out at exactly the right level relative to the current - * repo. That's almost never true for an outside user, and the command - * silently fails. Detects (inside fenced code blocks and inline `code`): - * - * - `node ../<segment>/...` invocations - * - `pnpm ../<segment>/...` invocations - * - Bare `../socket-<segment>/...` references in code/inline-code Skips: - * relative paths to the current repo's own tree (`./scripts/`, - * `../package.json` within a monorepo), which are useful and don't leak - * sibling state. No autofix: the rewrite is to either inline the script's - * content or publish the helper to npm and reference the published name. - */ - -import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' - -const RULE_NAME = 'socket-no-relative-sibling-script' -const SIBLING_PATH_RES = [ - // Detect `<runner> ../<sibling>/...` where runner is one of the common - // JS/TS toolchain binaries (any runtime invocation). - /\b(?:bun|deno|node|npm|pnpm|yarn)\s+\.\.\/[\w@-]+\//, - // Detect bare ../<segment>/ where the first segment doesn't start with `.` - // (i.e. genuine sibling, not the current repo's `..` for monorepo packages). - // `(?:^|\s)` alternation order is the canonical regex idiom (anchor-first). - /(?:^|\s)\.\.\/socket-[\w-]+\//i, // socket-hook: allow regex-alternation-order - /(?:^|\s)\.\.\/sdxgen\//, // socket-hook: allow regex-alternation-order - /(?:^|\s)\.\.\/stuie\//, // socket-hook: allow regex-alternation-order -] - -/** - * @type {import('markdownlint').Rule} - */ -const rule = { - names: [RULE_NAME, 'socket/no-relative-sibling-script'], - description: - 'Commands referencing sibling fleet repos via relative paths fail for outside readers', - tags: ['socket', 'fleet'], - parser: 'none', - function(params, onError) { - if (isInsideWheelhouse()) { - return - } - for (let i = 0; i < params.lines.length; i += 1) { - const line = params.lines[i] - for (let j = 0; j < SIBLING_PATH_RES.length; j += 1) { - const re = SIBLING_PATH_RES[j] - const match = re.exec(line) - if (!match) { - continue - } - onError({ - lineNumber: i + 1, - detail: - 'Rewrite the command to not depend on a sibling-repo checkout. Inline the script, link to its source on GitHub, or publish the helper to npm and reference the package name.', - context: line.trim().slice(0, 120), - range: [match.index + 1, match[0].length], - }) - break - } - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. -export default rule diff --git a/.config/markdownlint-rules/socket-readme-required-sections.mjs b/.config/markdownlint-rules/socket-readme-required-sections.mjs deleted file mode 100644 index 61e43421f..000000000 --- a/.config/markdownlint-rules/socket-readme-required-sections.mjs +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @file Enforce the canonical fleet README section list. Fires only on the - * repo-root `README.md` (skipped for nested READMEs under `packages/`, - * `docs/`, `.claude/`, etc. — those are scoped docs with their own shape). - * Every fleet root README must contain five level-2 sections in this order: - * - * 1. Why this repo exists - * 2. Install - * 3. Usage - * 4. Development - * 5. License The canonical skeleton lives at - * socket-wheelhouse/template/README.md. Additional sections between/after - * these are allowed; reordering / missing / typo'd sections are findings. - * No autofix: a missing section needs content, not just a heading. - */ - -import path from 'node:path' - -import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' - -const RULE_NAME = 'socket-readme-required-sections' -const REQUIRED_SECTIONS = [ - 'Why this repo exists', - 'Install', - 'Usage', - 'Development', - 'License', -] - -export function isRootReadme(filePath) { - // markdownlint passes `params.name` as a path relative to the working - // dir. The root README is the one whose basename is README.md AND - // whose directory is the cwd or `.`. - if (!filePath) { - return false - } - const base = path.basename(filePath) - if (base !== 'README.md') { - return false - } - const dir = path.dirname(filePath) - return dir === '.' || dir === '' || dir === process.cwd() -} - -/** - * @type {import('markdownlint').Rule} - */ -const rule = { - names: [RULE_NAME, 'socket/readme-required-sections'], - description: - 'Fleet root README must contain the canonical five sections in order', - tags: ['socket', 'fleet', 'readme'], - parser: 'none', - function(params, onError) { - if (isInsideWheelhouse()) { - return - } - if (!isRootReadme(params.name)) { - return - } - const headings = [] - for (let i = 0; i < params.lines.length; i += 1) { - const line = params.lines[i] - const m = /^##\s+(.+?)\s*$/.exec(line) - if (m) { - headings.push({ text: m[1], lineNumber: i + 1 }) - } - } - let cursor = 0 - for (let r = 0; r < REQUIRED_SECTIONS.length; r += 1) { - const want = REQUIRED_SECTIONS[r] - let found = -1 - for (let h = cursor; h < headings.length; h += 1) { - if (headings[h].text === want) { - found = h - break - } - } - if (found === -1) { - onError({ - lineNumber: 1, - detail: `Missing required section "## ${want}" (or it appears out of order). Canonical order: ${REQUIRED_SECTIONS.map(s => `"## ${s}"`).join(' → ')}.`, - context: `README.md: required section "## ${want}" not found after position ${cursor}`, - }) - return - } - cursor = found + 1 - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. -export default rule diff --git a/.config/oxfmtrc.json b/.config/oxfmtrc.json deleted file mode 100644 index e1d9f5bee..000000000 --- a/.config/oxfmtrc.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxfmt/configuration_schema.json", - "useTabs": false, - "tabWidth": 2, - "printWidth": 80, - "singleQuote": true, - "jsxSingleQuote": false, - "quoteProps": "as-needed", - "trailingComma": "all", - "semi": false, - "arrowParens": "avoid", - "bracketSameLine": false, - "bracketSpacing": true, - "singleAttributePerLine": false, - "jsdoc": { - "addDefaultToDescription": false, - "bracketSpacing": false, - "capitalizeDescriptions": true, - "commentLineStrategy": "multiline", - "descriptionTag": false, - "descriptionWithDot": true, - "keepUnparsableExampleIndent": false, - "lineWrappingStyle": "greedy", - "preferCodeFences": false, - "separateReturnsFromParam": false, - "separateTagGroups": true - }, - "ignorePatterns": [ - "**/.cache", - "**/.claude", - "**/.DS_Store", - "**/._.DS_Store", - "**/.env", - "**/.git", - "**/.github", - "**/.husky", - "**/.type-coverage", - "**/.vscode", - "**/coverage", - "**/coverage-isolated", - "**/dist", - "**/external", - "**/node_modules", - "**/package.json", - "**/pnpm-lock.yaml", - "**/test/fixtures", - "**/test/packages", - "#fleet-canonical-begin (managed by socket-wheelhouse sync)", - "**/.claude/**", - "**/.config/oxlint-plugin/**", - "**/.config/rolldown/**", - "**/.git-hooks/**", - "**/.pnpm-store/**", - "**/vendor/**", - "**/wasm_exec.js", - "**/.config/lockstep.schema.json", - "**/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs", - "**/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", - "**/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs", - "**/.config/markdownlint-rules/socket-readme-required-sections.mjs", - "**/.config/socket-registry-pins.json", - "**/.config/socket-wheelhouse-schema.json", - "**/.config/taze.config.mts", - "**/.config/tsconfig.base.json", - "**/packages/build-infra/lib/release-checksums/consumer.mts", - "**/packages/build-infra/lib/release-checksums/core.mts", - "**/packages/build-infra/lib/release-checksums/producer.mts", - "**/packages/build-infra/release-assets.schema.json", - "**/scripts/ai-lint-fix.mts", - "**/scripts/ai-lint-fix/cli.mts", - "**/scripts/ai-lint-fix/rule-guidance.mts", - "**/scripts/check-lock-step-header.mts", - "**/scripts/check-lock-step-refs.mts", - "**/scripts/check-paths.mts", - "**/scripts/check-paths/allowlist.mts", - "**/scripts/check-paths/cli.mts", - "**/scripts/check-paths/exempt.mts", - "**/scripts/check-paths/rules.mts", - "**/scripts/check-paths/scan-code.mts", - "**/scripts/check-paths/scan-script.mts", - "**/scripts/check-paths/scan-workflow.mts", - "**/scripts/check-paths/state.mts", - "**/scripts/check-paths/types.mts", - "**/scripts/check-paths/walk.mts", - "**/scripts/check-prompt-less-setup.mts", - "**/scripts/check-provenance.mts", - "**/scripts/check-soak-exclude-dates.mts", - "**/scripts/fix.mts", - "**/scripts/git-partial-submodule.mts", - "**/scripts/install-claude-plugins.mts", - "**/scripts/install-git-hooks.mts", - "**/scripts/install-sfw.mts", - "**/scripts/install-token-minifier.mts", - "**/scripts/janus.mts", - "**/scripts/lint-github-settings.mts", - "**/scripts/lockstep-emit-schema.mts", - "**/scripts/lockstep-schema.mts", - "**/scripts/lockstep.mts", - "**/scripts/lockstep/checks.mts", - "**/scripts/lockstep/cli.mts", - "**/scripts/lockstep/emit-schema.mts", - "**/scripts/lockstep/git.mts", - "**/scripts/lockstep/manifest.mts", - "**/scripts/lockstep/report.mts", - "**/scripts/lockstep/scan.mts", - "**/scripts/lockstep/schema.mts", - "**/scripts/lockstep/types.mts", - "**/scripts/power-state.mts", - "**/scripts/publish-release.mts", - "**/scripts/publish-shared.mts", - "**/scripts/publish.mts", - "**/scripts/security.mts", - "**/scripts/socket-wheelhouse-emit-schema.mts", - "**/scripts/socket-wheelhouse-schema.mts", - "**/scripts/test/check-lock-step-header.test.mts", - "**/scripts/test/check-lock-step-refs.test.mts", - "**/scripts/test/install-claude-plugins.test.mts", - "**/scripts/test/install-git-hooks.test.mts", - "**/scripts/update.mts", - "**/scripts/validate-bundle-deps.mts", - "**/scripts/validate-config-paths.mts", - "**/scripts/validate-esbuild-minify.mts", - "**/scripts/validate-file-size.mts", - "#fleet-canonical-end" - ] -} diff --git a/.config/oxlint-plugin/index.mts b/.config/oxlint-plugin/index.mts deleted file mode 100644 index 27f5ea457..000000000 --- a/.config/oxlint-plugin/index.mts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * @file Fleet oxlint plugin. Custom rules that encode the fleet's CLAUDE.md - * style guide as lint errors with autofix where the rewrite is unambiguous. - * Why a plugin instead of a separate scanner: oxlint's native plugin surface - * integrates with the existing `pnpm run lint` pipeline, inherits oxlint's - * AST + sourcemap + fix-application machinery, and keeps the rule set - * discoverable via `oxlint --rules`. Wiring: `.config/oxlintrc.json` adds - * this plugin via `jsPlugins: ["./oxlint-plugin/index.mts"]` and enables - * rules under the `socket/` namespace. - */ - -import exportTopLevelFunctions from './rules/export-top-level-functions.mts' -import inclusiveLanguage from './rules/inclusive-language.mts' -import maxFileLines from './rules/max-file-lines.mts' -import noBareCryptoNamedUsage from './rules/no-bare-crypto-named-usage.mts' -import noCachedForOnIterable from './rules/no-cached-for-on-iterable.mts' -import noConsolePreferLogger from './rules/no-console-prefer-logger.mts' -import noDefaultExport from './rules/no-default-export.mts' -import noDynamicImportOutsideBundle from './rules/no-dynamic-import-outside-bundle.mts' -import noEslintBiomeConfigRef from './rules/no-eslint-biome-config-ref.mts' -import noFetchPreferHttpRequest from './rules/no-fetch-prefer-http-request.mts' -import noFileScopeOxlintDisable from './rules/no-file-scope-oxlint-disable.mts' -import noInlineDeferAsync from './rules/no-inline-defer-async.mts' -import noInlineLogger from './rules/no-inline-logger.mts' -import noLoggerNewlineLiteral from './rules/no-logger-newline-literal.mts' -import noNpxDlx from './rules/no-npx-dlx.mts' -import noPlaceholders from './rules/no-placeholders.mts' -import noProcessCwdInScriptsHooks from './rules/no-process-cwd-in-scripts-hooks.mts' -import noPromiseRace from './rules/no-promise-race.mts' -import noPromiseRaceInLoop from './rules/no-promise-race-in-loop.mts' -import noSrcImportInTestExpect from './rules/no-src-import-in-test-expect.mts' -import noStatusEmoji from './rules/no-status-emoji.mts' -import noStructuredClonePreferJson from './rules/no-structured-clone-prefer-json.mts' -import noSyncRmInTestLifecycle from './rules/no-sync-rm-in-test-lifecycle.mts' -import noUnderscoreIdentifier from './rules/no-underscore-identifier.mts' -import noWhichForLocalBin from './rules/no-which-for-local-bin.mts' -import optionalExplicitUndefined from './rules/optional-explicit-undefined.mts' -import personalPathPlaceholders from './rules/personal-path-placeholders.mts' -import preferAsyncSpawn from './rules/prefer-async-spawn.mts' -import preferCachedForLoop from './rules/prefer-cached-for-loop.mts' -import preferEllipsisChar from './rules/prefer-ellipsis-char.mts' -import preferEnvAsBoolean from './rules/prefer-env-as-boolean.mts' -import preferExistsSync from './rules/prefer-exists-sync.mts' -import preferFunctionDeclaration from './rules/prefer-function-declaration.mts' -import preferNodeBuiltinImports from './rules/prefer-node-builtin-imports.mts' -import preferNodeModulesDotCache from './rules/prefer-node-modules-dot-cache.mts' -import preferNonCapturingGroup from './rules/prefer-non-capturing-group.mts' -import preferSafeDelete from './rules/prefer-safe-delete.mts' -import preferSeparateTypeImport from './rules/prefer-separate-type-import.mts' -import preferSpawnOverExecsync from './rules/prefer-spawn-over-execsync.mts' -import preferStableSelfImport from './rules/prefer-stable-self-import.mts' -import preferStaticTypeImport from './rules/prefer-static-type-import.mts' -import preferUndefinedOverNull from './rules/prefer-undefined-over-null.mts' -import socketApiTokenEnv from './rules/socket-api-token-env.mts' -import sortBooleanChains from './rules/sort-boolean-chains.mts' -import sortEqualityDisjunctions from './rules/sort-equality-disjunctions.mts' -import sortNamedImports from './rules/sort-named-imports.mts' -import sortRegexAlternations from './rules/sort-regex-alternations.mts' -import sortSetArgs from './rules/sort-set-args.mts' -import sortSourceMethods from './rules/sort-source-methods.mts' -import useFleetCanonicalApiTokenGetter from './rules/use-fleet-canonical-api-token-getter.mts' - -/** - * @type {import('eslint').ESLint.Plugin} - */ -const plugin = { - meta: { - name: 'socket', - version: '0.5.0', - }, - rules: { - 'export-top-level-functions': exportTopLevelFunctions, - 'inclusive-language': inclusiveLanguage, - 'max-file-lines': maxFileLines, - 'no-bare-crypto-named-usage': noBareCryptoNamedUsage, - 'no-cached-for-on-iterable': noCachedForOnIterable, - 'no-console-prefer-logger': noConsolePreferLogger, - 'no-default-export': noDefaultExport, - 'no-dynamic-import-outside-bundle': noDynamicImportOutsideBundle, - 'no-eslint-biome-config-ref': noEslintBiomeConfigRef, - 'no-fetch-prefer-http-request': noFetchPreferHttpRequest, - 'no-file-scope-oxlint-disable': noFileScopeOxlintDisable, - 'no-inline-defer-async': noInlineDeferAsync, - 'no-inline-logger': noInlineLogger, - 'no-logger-newline-literal': noLoggerNewlineLiteral, - 'no-npx-dlx': noNpxDlx, - 'no-placeholders': noPlaceholders, - 'no-process-cwd-in-scripts-hooks': noProcessCwdInScriptsHooks, - 'no-promise-race': noPromiseRace, - 'no-promise-race-in-loop': noPromiseRaceInLoop, - 'no-src-import-in-test-expect': noSrcImportInTestExpect, - 'no-status-emoji': noStatusEmoji, - 'no-structured-clone-prefer-json': noStructuredClonePreferJson, - 'no-sync-rm-in-test-lifecycle': noSyncRmInTestLifecycle, - 'no-underscore-identifier': noUnderscoreIdentifier, - 'no-which-for-local-bin': noWhichForLocalBin, - 'optional-explicit-undefined': optionalExplicitUndefined, - 'personal-path-placeholders': personalPathPlaceholders, - 'prefer-async-spawn': preferAsyncSpawn, - 'prefer-cached-for-loop': preferCachedForLoop, - 'prefer-ellipsis-char': preferEllipsisChar, - 'prefer-env-as-boolean': preferEnvAsBoolean, - 'prefer-exists-sync': preferExistsSync, - 'prefer-function-declaration': preferFunctionDeclaration, - 'prefer-node-builtin-imports': preferNodeBuiltinImports, - 'prefer-node-modules-dot-cache': preferNodeModulesDotCache, - 'prefer-non-capturing-group': preferNonCapturingGroup, - 'prefer-safe-delete': preferSafeDelete, - 'prefer-separate-type-import': preferSeparateTypeImport, - 'prefer-spawn-over-execsync': preferSpawnOverExecsync, - 'prefer-stable-self-import': preferStableSelfImport, - 'prefer-static-type-import': preferStaticTypeImport, - 'prefer-undefined-over-null': preferUndefinedOverNull, - 'socket-api-token-env': socketApiTokenEnv, - 'sort-boolean-chains': sortBooleanChains, - 'sort-equality-disjunctions': sortEqualityDisjunctions, - 'sort-named-imports': sortNamedImports, - 'sort-regex-alternations': sortRegexAlternations, - 'sort-set-args': sortSetArgs, - 'sort-source-methods': sortSourceMethods, - 'use-fleet-canonical-api-token-getter': useFleetCanonicalApiTokenGetter, - }, -} - -export default plugin diff --git a/.config/oxlint-plugin/lib/comment-markers.mts b/.config/oxlint-plugin/lib/comment-markers.mts deleted file mode 100644 index b84114f60..000000000 --- a/.config/oxlint-plugin/lib/comment-markers.mts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @file Shared "is there a bypass marker adjacent to this node?" scanner used - * by the rules that support an inline opt-out comment - * (`no-which-for-local-bin` → `socket-hook: allow which-lookup`, - * `prefer-ellipsis-char` → `socket-hook: allow literal-ellipsis`, - * `use-fleet-canonical-api-token-getter` → `socket-api-token-getter: allow - * direct-env`). Why a source-text line scan instead of the AST comment APIs: - * at the catalog-pinned oxlint version the plugin engine's - * `getCommentsBefore` / `getCommentsAfter` return nothing for the nodes these - * rules report on, so a comment-attachment approach silently fails to - * suppress. Scanning the raw source by line is engine-version-independent. - * `makeBypassChecker(context, bypassRe)` reads the source once per - * `create(context)` call and returns `hasBypassComment(node)`. A node is - * bypassed when the marker appears on the node's own line (trailing comment) - * or in the contiguous block of comment lines directly above it — the walk - * stops at the first non-comment, non-blank line so the marker must be - * genuinely adjacent, not somewhere arbitrary earlier in the file. - */ - -import type { AstNode, RuleContext } from './rule-types.mts' - -// How far up a leading-comment block to look for the marker. A leading marker -// comment may wrap onto a couple of continuation lines, so allow a few. -const MAX_LEADING_COMMENT_LINES = 3 - -// A line that is entirely a comment (`//`, `/*`, or a `*` block continuation). -// Used to keep walking upward through a contiguous comment block. -const COMMENT_LINE_RE = /^\s*(?:\*|\/\*|\/\/)/ - -/** - * The raw source text for the file being linted, across the context shapes the - * oxlint plugin engine exposes (`getSourceCode().getText()` vs a `sourceCode` - * with `getText()` or a `.text` field). - */ -function sourceTextOf(context: RuleContext): string { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - if (typeof sourceCode?.getText === 'function') { - return sourceCode.getText() - } - return (sourceCode as { text?: string | undefined })?.text ?? '' -} - -/** - * 1-based start line of a node, derived from `loc` when present, else by - * counting newlines up to the node's start offset in `sourceText`. Returns -1 - * when neither is available. - */ -function nodeStartLine(node: AstNode, sourceText: string): number { - const locLine = ( - node as { - loc?: { start?: { line?: number | undefined } | undefined } | undefined - } - )?.loc?.start?.line - if (typeof locLine === 'number') { - return locLine - } - const start = (node as { range?: [number, number] | undefined }).range?.[0] - if (typeof start !== 'number') { - return -1 - } - let line = 1 - for (let i = 0; i < start && i < sourceText.length; i += 1) { - if (sourceText[i] === '\n') { - line += 1 - } - } - return line -} - -/** - * Build a `hasBypassComment(node)` predicate for `bypassRe`, reading the source - * once. True when the marker is on the node's own line or in the contiguous - * comment block immediately above it. - */ -export function makeBypassChecker( - context: RuleContext, - bypassRe: RegExp, -): (node: AstNode) => boolean { - const sourceText = sourceTextOf(context) - const sourceLines = sourceText.split('\n') - - return function hasBypassComment(node: AstNode): boolean { - const line = nodeStartLine(node, sourceText) - if (line < 1) { - return false - } - // sourceLines is 0-indexed; node line is 1-based, so the node's own line - // is sourceLines[line - 1]. Check that (trailing-comment case) first. - const ownIdx = line - 1 - if ( - ownIdx >= 0 && - ownIdx < sourceLines.length && - bypassRe.test(sourceLines[ownIdx]!) - ) { - return true - } - // Then walk up through a contiguous leading-comment block. - for ( - let idx = ownIdx - 1; - idx >= 0 && idx >= ownIdx - MAX_LEADING_COMMENT_LINES; - idx -= 1 - ) { - const text = sourceLines[idx]! - if (bypassRe.test(text)) { - return true - } - // Stop once we pass a non-comment, non-blank line: the marker must be in - // the comment block adjacent to the read, not arbitrarily earlier. - if (text.trim() !== '' && !COMMENT_LINE_RE.test(text)) { - break - } - } - return false - } -} diff --git a/.config/oxlint-plugin/lib/detect-source-type.mts b/.config/oxlint-plugin/lib/detect-source-type.mts deleted file mode 100644 index b7eb9ebe1..000000000 --- a/.config/oxlint-plugin/lib/detect-source-type.mts +++ /dev/null @@ -1,707 +0,0 @@ -/** - * @file Detect whether the linted file is CommonJS or ES module syntax, so - * rules whose autofix is module-system-sensitive can opt out on the wrong - * side. Mirrors the upstream `@ultrathink/acorn` `detectSourceType` helper - * (see `lang/typescript/src/core/detect-source-type.ts` + - * `lang/rust/crates/core/src/detect_source_type.rs` + - * `lang/go/src/core/detect_source_type.go` + - * `lang/cpp/src/core/detect_source_type.hpp`). The implementation is - * duplicated here because the oxlint plugin must run with no cross-package - * imports — rules ship as standalone JS modules loaded by oxlint's JS-plugin - * interface. Drift watch: when the ultrathink helper changes, this copy must - * change in lock-step. Idea (modeled on standard-things/esm's compile-time - * hint pass — see `src/module/internal/compile.js` + - * `src/parse/find-indexes.js` in the esm@2d02f6df reference): _Don't_ parse - * the AST. Walk the source once with a small state machine that tracks string - * / template / comment / regex / brace nesting and inspects only DEPTH-0 - * tokens. Function / class / block bodies are skipped via depth tracking — we - * never descend into them. Single linear pass, early exit on the first - * definitive ESM marker. Algorithm — same as Node's - * `--experimental-detect-module`: - * - * 1. Extension hint is authoritative for `.cjs` / `.cts` / `.mjs` / `.mts`. - * 2. Package-type hint (`"module"` / `"commonjs"`) settles the `.js` / `.ts` - * ambiguous case. - * 3. Top-level scan. ESM markers (`import`, `export`, `import.meta`, top-level - * `await`) take precedence over CJS markers (`require()`, - * `module.exports`, `exports.X`). - * 4. Otherwise `'unknown'` — caller decides. Motivating incident: the - * `socket/export-top-level-functions` autofix rewrote internal helpers in - * `acorn-bindgen.cjs` (wasm-bindgen output) from `function getObject(idx) - * { … }` to `export function getObject(idx) { … }`. The file's public - * surface is `module.exports = …` (CJS), so the rewritten `export` - * keywords made the file syntactically ESM and the first `require()` of it - * threw `SyntaxError: Unexpected token 'export'`. - */ - -export type SourceTypeKind = 'cjs' | 'esm' | 'unknown' - -export interface DetectSourceTypeHint { - extension?: string | undefined - packageType?: 'module' | 'commonjs' | undefined -} - -const CJS_EXTENSIONS = new Set(['.cjs', '.cts']) -const ESM_EXTENSIONS = new Set(['.mjs', '.mts']) - -// Tier-1 fast-reject. V8 JITs this alternation to a SIMD-friendly -// DFA; a file with none of these substrings can't possibly contain -// module syntax and skips the per-byte state machine entirely. -// Needles sorted alphanumerically; order doesn't change correctness. -const FAST_REJECT_RE = - /\b(?:__dirname|__filename|await|export|exports|import|module|require)\b/ - -const CHAR_TAB = 9 -const CHAR_LF = 10 -const CHAR_CR = 13 -const CHAR_SPACE = 32 -const CHAR_BANG = 33 -const CHAR_DQUOTE = 34 -const CHAR_HASH = 35 -const CHAR_DOLLAR = 36 -const CHAR_PERCENT = 37 -const CHAR_AMP = 38 -const CHAR_SQUOTE = 39 -const CHAR_LPAREN = 40 -const CHAR_RPAREN = 41 -const CHAR_STAR = 42 -const CHAR_PLUS = 43 -const CHAR_COMMA = 44 -const CHAR_MINUS = 45 -const CHAR_DOT = 46 -const CHAR_SLASH = 47 -const CHAR_0 = 48 -const CHAR_9 = 57 -const CHAR_COLON = 58 -const CHAR_SEMI = 59 -const CHAR_LT = 60 -const CHAR_EQ = 61 -const CHAR_GT = 62 -const CHAR_QUEST = 63 -const CHAR_A = 65 -const CHAR_Z = 90 -const CHAR_LBRACKET = 91 -const CHAR_BSLASH = 92 -const CHAR_RBRACKET = 93 -const CHAR_CARET = 94 -const CHAR_UNDERSCORE = 95 -const CHAR_BACKTICK = 96 -const CHAR_a = 97 -const CHAR_z = 122 -const CHAR_LBRACE = 123 -const CHAR_PIPE = 124 -const CHAR_RBRACE = 125 -const CHAR_TILDE = 126 - -function isIdentStart(ch: number): boolean { - return ( - (ch >= CHAR_a && ch <= CHAR_z) || - (ch >= CHAR_A && ch <= CHAR_Z) || - ch === CHAR_UNDERSCORE || - ch === CHAR_DOLLAR - ) -} - -function isIdentPart(ch: number): boolean { - return ( - (ch >= CHAR_a && ch <= CHAR_z) || - (ch >= CHAR_A && ch <= CHAR_Z) || - (ch >= CHAR_0 && ch <= CHAR_9) || - ch === CHAR_UNDERSCORE || - ch === CHAR_DOLLAR - ) -} - -function startsRegex(prevMeaningful: number): boolean { - if (prevMeaningful === 0) { - return true - } - return ( - prevMeaningful === CHAR_LPAREN || - prevMeaningful === CHAR_COMMA || - prevMeaningful === CHAR_EQ || - prevMeaningful === CHAR_SEMI || - prevMeaningful === CHAR_LBRACE || - prevMeaningful === CHAR_RBRACE || - prevMeaningful === CHAR_COLON || - prevMeaningful === CHAR_LBRACKET || - prevMeaningful === CHAR_BANG || - prevMeaningful === CHAR_QUEST || - prevMeaningful === CHAR_AMP || - prevMeaningful === CHAR_PIPE || - prevMeaningful === CHAR_CARET || - prevMeaningful === CHAR_TILDE || - prevMeaningful === CHAR_LT || - prevMeaningful === CHAR_GT || - prevMeaningful === CHAR_PLUS || - prevMeaningful === CHAR_MINUS || - prevMeaningful === CHAR_STAR || - prevMeaningful === CHAR_PERCENT || - prevMeaningful === CHAR_SLASH - ) -} - -function matchAt( - source: string, - start: number, - end: number, - keyword: string, -): boolean { - const klen = keyword.length - if (end - start !== klen) { - return false - } - for (let i = 0; i < klen; i += 1) { - if (source.charCodeAt(start + i) !== keyword.charCodeAt(i)) { - return false - } - } - return true -} - -// Returns true if last is a byte that prevents Automatic Semicolon -// Insertion when followed by a newline. Mirrors the upstream -// detect-source-type.ts::continuesStatement. -function continuesStatement(last: number): boolean { - return ( - last === CHAR_COMMA || - last === CHAR_LBRACE || - last === CHAR_LBRACKET || - last === CHAR_LPAREN || - last === CHAR_EQ || - last === CHAR_PLUS || - last === CHAR_MINUS || - last === CHAR_STAR || - last === CHAR_SLASH || - last === CHAR_PERCENT || - last === CHAR_LT || - last === CHAR_GT || - last === CHAR_AMP || - last === CHAR_PIPE || - last === CHAR_CARET || - last === CHAR_TILDE || - last === CHAR_QUEST || - last === CHAR_COLON || - last === CHAR_BANG || - last === CHAR_DOT - ) -} - -function isWrapperName(source: string, start: number, end: number): boolean { - return ( - matchAt(source, start, end, 'module') || - matchAt(source, start, end, 'exports') || - matchAt(source, start, end, 'require') || - matchAt(source, start, end, '__filename') || - matchAt(source, start, end, '__dirname') - ) -} - -// Walk a const|let|var declaration starting at after (the byte just -// past the binder keyword). Returns true if any binding identifier -// is a CJS wrapper name. Handles simple / comma-separated / -// destructured binding shapes. Stops at `;` (depth 0) or at a -// newline where the previous meaningful byte does NOT continue the -// expression (ASI insertion). See continuesStatement. -function declarationDeclaresWrapper( - source: string, - after: number, - length: number, -): boolean { - let i = after - let depth = 0 - let inInitializer = false - let last = 0 - while (i < length) { - const ch = source.charCodeAt(i) - if (ch === CHAR_SPACE || ch === CHAR_TAB || ch === CHAR_CR) { - i += 1 - continue - } - if (ch === CHAR_LF) { - if (depth === 0 && last !== 0 && !continuesStatement(last)) { - return false - } - i += 1 - continue - } - if (ch === CHAR_SLASH && source.charCodeAt(i + 1) === CHAR_SLASH) { - i += 2 - while (i < length && source.charCodeAt(i) !== CHAR_LF) { - i += 1 - } - continue - } - if (ch === CHAR_SLASH && source.charCodeAt(i + 1) === CHAR_STAR) { - i += 2 - while (i < length) { - if ( - source.charCodeAt(i) === CHAR_STAR && - source.charCodeAt(i + 1) === CHAR_SLASH - ) { - i += 2 - break - } - i += 1 - } - continue - } - if (ch === CHAR_DQUOTE || ch === CHAR_SQUOTE) { - const quote = ch - i += 1 - while (i < length) { - const c = source.charCodeAt(i) - if (c === CHAR_BSLASH) { - i += 2 - continue - } - if (c === quote) { - i += 1 - break - } - if (c === CHAR_LF) { - break - } - i += 1 - } - last = quote - continue - } - if (ch === CHAR_BACKTICK) { - i += 1 - while (i < length) { - const c = source.charCodeAt(i) - if (c === CHAR_BSLASH) { - i += 2 - continue - } - if (c === CHAR_BACKTICK) { - i += 1 - break - } - i += 1 - } - last = CHAR_BACKTICK - continue - } - if (ch === CHAR_SEMI && depth === 0) { - return false - } - if (ch === CHAR_EQ && depth === 0) { - inInitializer = true - last = ch - i += 1 - continue - } - if (ch === CHAR_COMMA && depth === 0) { - inInitializer = false - last = ch - i += 1 - continue - } - if (ch === CHAR_LBRACE || ch === CHAR_LBRACKET || ch === CHAR_LPAREN) { - depth += 1 - last = ch - i += 1 - continue - } - if (ch === CHAR_RBRACE || ch === CHAR_RBRACKET || ch === CHAR_RPAREN) { - if (depth > 0) { - depth -= 1 - } - last = ch - i += 1 - continue - } - if (isIdentStart(ch)) { - const start = i - i += 1 - while (i < length && isIdentPart(source.charCodeAt(i))) { - i += 1 - } - // Property-key vs binding-name disambiguation inside an - // object pattern: `const { module: foo } = obj` — `module` - // is the SOURCE KEY, `foo` is the binding. CJS-wrapped parse - // succeeds; Node returns CJS. Discriminator: at depth > 0, - // an identifier immediately followed by `:` is a property - // key, not a binding. - let isKey = false - if (depth > 0) { - const lookahead = skipWhitespace(source, i) - if (lookahead < length && source.charCodeAt(lookahead) === CHAR_COLON) { - isKey = true - } - } - if (!inInitializer && !isKey && isWrapperName(source, start, i)) { - return true - } - last = source.charCodeAt(i - 1) - continue - } - last = ch - i += 1 - } - return false -} - -function matchKeyword(source: string, pos: number, keyword: string): number { - const { length } = source - const klen = keyword.length - if (pos + klen > length) { - return -1 - } - for (let i = 0; i < klen; i += 1) { - if (source.charCodeAt(pos + i) !== keyword.charCodeAt(i)) { - return -1 - } - } - const after = pos + klen - if (after < length && isIdentPart(source.charCodeAt(after))) { - return -1 - } - return after -} - -function skipWhitespace(source: string, pos: number): number { - const { length } = source - let i = pos - while (i < length) { - const c = source.charCodeAt(i) - if (c === CHAR_SPACE || c === CHAR_TAB || c === CHAR_LF || c === CHAR_CR) { - i += 1 - continue - } - break - } - return i -} - -// Conservative remainder check used to short-circuit `cjs` after -// seeing a CJS marker. Returns true if `source.slice(pos)` MIGHT -// contain a new ESM marker. See upstream -// `lang/typescript/src/core/detect-source-type.ts` for rationale. -const ESM_ONLY_REMAINDER_RE_WH = - /\b(?:__dirname|__filename|await|export|import)\b/g - -function couldHaveEsmMarkerAfter(source: string, pos: number): boolean { - ESM_ONLY_REMAINDER_RE_WH.lastIndex = pos - if (ESM_ONLY_REMAINDER_RE_WH.exec(source) !== null) { - return true - } - const hasBinder = - source.indexOf('const', pos) !== -1 || - source.indexOf('let', pos) !== -1 || - source.indexOf('var', pos) !== -1 - if (!hasBinder) { - return false - } - return ( - source.indexOf('module', pos) !== -1 || - source.indexOf('exports', pos) !== -1 || - source.indexOf('require', pos) !== -1 - ) -} - -type ScanMarker = 'esm' | 'cjs' | 'none' - -export function scanTopLevelMarker(source: string): ScanMarker { - let i = 0 - const { length } = source - let depth = 0 - let prevMeaningful = 0 - let sawCjs = false - // Short-circuit after first CJS marker; see - // couldHaveEsmMarkerAfter docs. - let cjsShortCircuitChecked = false - - while (i < length) { - const ch = source.charCodeAt(i) - - if ( - ch === CHAR_SPACE || - ch === CHAR_TAB || - ch === CHAR_LF || - ch === CHAR_CR - ) { - i += 1 - continue - } - - // Line comment — jump to next LF via SIMD-backed indexOf. - if (ch === CHAR_SLASH && source.charCodeAt(i + 1) === CHAR_SLASH) { - const nl = source.indexOf('\n', i + 2) - i = nl === -1 ? length : nl - continue - } - - // Block comment — jump to `*/`. - if (ch === CHAR_SLASH && source.charCodeAt(i + 1) === CHAR_STAR) { - const end = source.indexOf('*/', i + 2) - i = end === -1 ? length : end + 2 - continue - } - - if (ch === CHAR_HASH && i === 0 && source.charCodeAt(i + 1) === CHAR_BANG) { - const nl = source.indexOf('\n', 2) - i = nl === -1 ? length : nl - continue - } - - // String literal — jump to next quote, count preceding - // backslashes (odd → escaped, keep searching). - if (ch === CHAR_DQUOTE || ch === CHAR_SQUOTE) { - const quote = ch - const quoteStr = quote === CHAR_DQUOTE ? '"' : "'" - let pos = i + 1 - while (pos < length) { - const next = source.indexOf(quoteStr, pos) - if (next === -1) { - pos = length - break - } - let bs = 0 - let j = next - 1 - while (j >= i + 1 && source.charCodeAt(j) === CHAR_BSLASH) { - bs += 1 - j -= 1 - } - if ((bs & 1) === 0) { - pos = next + 1 - break - } - pos = next + 1 - } - i = pos - prevMeaningful = quote - continue - } - - if (ch === CHAR_BACKTICK) { - i += 1 - while (i < length) { - const c = source.charCodeAt(i) - if (c === CHAR_BSLASH) { - i += 2 - continue - } - if (c === CHAR_BACKTICK) { - i += 1 - break - } - if (c === CHAR_DOLLAR && source.charCodeAt(i + 1) === CHAR_LBRACE) { - i += 2 - let tplDepth = 1 - while (i < length && tplDepth > 0) { - const cc = source.charCodeAt(i) - if (cc === CHAR_LBRACE) { - tplDepth += 1 - } else if (cc === CHAR_RBRACE) { - tplDepth -= 1 - } else if (cc === CHAR_DQUOTE || cc === CHAR_SQUOTE) { - const innerQuote = cc - i += 1 - while (i < length) { - const ccc = source.charCodeAt(i) - if (ccc === CHAR_BSLASH) { - i += 2 - continue - } - if (ccc === innerQuote) { - i += 1 - break - } - if (ccc === CHAR_LF) { - break - } - i += 1 - } - continue - } - i += 1 - } - continue - } - i += 1 - } - prevMeaningful = CHAR_BACKTICK - continue - } - - if (ch === CHAR_SLASH && startsRegex(prevMeaningful)) { - i += 1 - let inClass = false - while (i < length) { - const c = source.charCodeAt(i) - if (c === CHAR_BSLASH) { - i += 2 - continue - } - if (c === CHAR_LBRACKET) { - inClass = true - } else if (c === CHAR_RBRACKET) { - inClass = false - } else if (c === CHAR_SLASH && !inClass) { - i += 1 - break - } else if (c === CHAR_LF) { - break - } - i += 1 - } - while (i < length && isIdentPart(source.charCodeAt(i))) { - i += 1 - } - prevMeaningful = CHAR_SLASH - continue - } - - if (ch === CHAR_LBRACE || ch === CHAR_LPAREN || ch === CHAR_LBRACKET) { - depth += 1 - prevMeaningful = ch - i += 1 - continue - } - if (ch === CHAR_RBRACE || ch === CHAR_RPAREN || ch === CHAR_RBRACKET) { - if (depth > 0) { - depth -= 1 - } - prevMeaningful = ch - i += 1 - continue - } - - if (isIdentStart(ch)) { - const start = i - i += 1 - while (i < length && isIdentPart(source.charCodeAt(i))) { - i += 1 - } - if (depth === 0) { - const word = source.slice(start, i) - if (word === 'import') { - const after = skipWhitespace(source, i) - if (after < length) { - const c = source.charCodeAt(after) - if (c === CHAR_LPAREN) { - prevMeaningful = ch - continue - } - if (c === CHAR_DOT) { - const metaPos = skipWhitespace(source, after + 1) - if (matchKeyword(source, metaPos, 'meta') !== -1) { - return 'esm' - } - prevMeaningful = ch - continue - } - } - return 'esm' - } - if (word === 'export') { - return 'esm' - } - if (word === 'await') { - return 'esm' - } - if (word === 'const' || word === 'let' || word === 'var') { - // Walk the full declaration for wrapper-name bindings in - // any position (simple, destructured, or comma-separated). - // See declarationDeclaresWrapper. - if (declarationDeclaresWrapper(source, i, length)) { - return 'esm' - } - } - if (word === 'require') { - const after = skipWhitespace(source, i) - if (after < length && source.charCodeAt(after) === CHAR_LPAREN) { - sawCjs = true - } - } else if (word === 'module') { - const after = skipWhitespace(source, i) - if (after < length && source.charCodeAt(after) === CHAR_DOT) { - const propPos = skipWhitespace(source, after + 1) - if (matchKeyword(source, propPos, 'exports') !== -1) { - sawCjs = true - } - } - } else if (word === 'exports') { - if (prevMeaningful !== CHAR_DOT) { - const after = skipWhitespace(source, i) - if (after < length && source.charCodeAt(after) === CHAR_DOT) { - sawCjs = true - } - } - } - } - if (sawCjs && !cjsShortCircuitChecked) { - cjsShortCircuitChecked = true - if (!couldHaveEsmMarkerAfter(source, i)) { - return 'cjs' - } - } - prevMeaningful = ch - continue - } - - if (ch >= CHAR_0 && ch <= CHAR_9) { - i += 1 - while (i < length) { - const c = source.charCodeAt(i) - if ( - (c >= CHAR_0 && c <= CHAR_9) || - c === CHAR_DOT || - (c >= CHAR_a && c <= CHAR_z) || - (c >= CHAR_A && c <= CHAR_Z) || - c === CHAR_UNDERSCORE - ) { - i += 1 - continue - } - break - } - prevMeaningful = ch - continue - } - - prevMeaningful = ch - i += 1 - } - - return sawCjs ? 'cjs' : 'none' -} - -export function detectSourceType( - source: string, - hint?: DetectSourceTypeHint | undefined, -): SourceTypeKind { - if (hint?.extension) { - const ext = hint.extension.toLowerCase() - if (CJS_EXTENSIONS.has(ext)) { - return 'cjs' - } - if (ESM_EXTENSIONS.has(ext)) { - return 'esm' - } - } - if (hint?.packageType === 'module') { - return 'esm' - } - if (hint?.packageType === 'commonjs') { - return 'cjs' - } - if (!source) { - return 'unknown' - } - // Tier-1 fast reject (see FAST_REJECT_RE docs). - if (!FAST_REJECT_RE.test(source)) { - return 'unknown' - } - const marker = scanTopLevelMarker(source) - if (marker === 'esm') { - return 'esm' - } - if (marker === 'cjs') { - return 'cjs' - } - return 'unknown' -} diff --git a/.config/oxlint-plugin/lib/fleet-paths.mts b/.config/oxlint-plugin/lib/fleet-paths.mts deleted file mode 100644 index 88a5156b8..000000000 --- a/.config/oxlint-plugin/lib/fleet-paths.mts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @file Shared path-suffix constants for fleet-canonical files that any plugin - * rule may need to recognize. Centralizing these out of individual rule files - * lets multiple rules share the same opt-in / opt-out list without - * duplicating the path string + its rationale comment. Examples of - * consumers: - * - * - `no-file-scope-oxlint-disable` exempts `scripts/paths.mts` (deliberate - * flow-ordered exports, see PATHS_FILE constant below). - * - `socket/prefer-cached-for-loop` and `socket/no-cached-for-on-iterable` - * share `lib/iterable-kind.mts` for the binding-kind heuristic — sibling - * pattern. When a new rule needs to recognize one of these path patterns, - * add the import here and use the constant, not a re-spelled literal. - */ - -/** - * The fleet's "1 path, 1 reference" source-of-truth file. Each fleet repo has - * one. Its exports are ordered by path-resolution flow (REPO_ROOT → primary - * roots → build paths → helpers) — deliberately not alphabetical, and the order - * is load-bearing for code review. Anything keyed on per-file behavior that - * recognizes `paths.mts` should match by suffix. - */ -export const PATHS_FILE = 'scripts/paths.mts' - -/** - * Plugin-internal rule + test directories. Rule files often contain the banned - * shape they ban as lookup-table data (e.g. `no-status-emoji.mts` literally - * contains the emoji it bans). Same for the matching test files, which - * intentionally exercise the banned shape. - */ -export const PLUGIN_RULE_DIR = '.config/oxlint-plugin/rules/' -export const PLUGIN_TEST_DIR = '.config/oxlint-plugin/test/' - -/** - * True when `filename` is inside the plugin's own rules / test directory. - */ -export function isPluginInternalPath(filename: string): boolean { - return ( - filename.includes(PLUGIN_RULE_DIR) || filename.includes(PLUGIN_TEST_DIR) - ) -} - -/** - * True when `filename` points at the fleet-canonical `scripts/paths.mts`. - */ -export function isPathsModule(filename: string): boolean { - return filename.endsWith(PATHS_FILE) -} - -/** - * Context-aware wrapper around `isPluginInternalPath`: true when the file - * currently being linted is one of the plugin's own rule / test files. Rules - * call this to exempt their own rule-data + fixtures (where the patterns they - * detect appear as literal strings, not real violations). Takes the rule - * `context` so call sites read as `isPluginSelfFile(context)`. - */ -export function isPluginSelfFile(context: { - filename?: string | undefined - getFilename?: (() => string) | undefined -}): boolean { - const filename = context.filename ?? context.getFilename?.() ?? '' - return isPluginInternalPath(filename) -} diff --git a/.config/oxlint-plugin/lib/iterable-kind.mts b/.config/oxlint-plugin/lib/iterable-kind.mts deleted file mode 100644 index 1af250c73..000000000 --- a/.config/oxlint-plugin/lib/iterable-kind.mts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * @file Shared "is this binding a Set / Map / Iterable?" heuristic used by - * no-cached-for-on-iterable AND by prefer-cached-for-loop's skip list. - * Without TypeScript type info available to oxlint plugins, the detection is - * AST-only: - * - * - `new Set(...)` / `new Map(...)` / `new WeakSet(...)` / `new WeakMap(...)` - * initializer → set/map - * - `: Set<...>` / `: ReadonlySet<...>` / `: Map<...>` / `: ReadonlyMap<...>` / - * `: WeakSet<...>` / `: WeakMap<...>` annotation → set/map - * - `: Iterable<...>` / `: AsyncIterable<...>` / `: IterableIterator<...>` - * annotation → iterable - * - `[…]` array literal / `: T[]` / `: Array<...>` / `: ReadonlyArray<...>` / - * `Array.from(...)` / `Array.of(...)` / `Object.keys|values|entries(...)` → - * array (negative signal) - * - anything else → unknown (caller decides whether to skip) Two rules consume - * this: - * - * 1. `no-cached-for-on-iterable` — flags when a cached-length `for (let i = 0, { - * length } = X; …)` loop is applied to a set / map / iterable. - * 2. `prefer-cached-for-loop` — needs to SKIP rewriting `for (const item of - * setVar)` into the cached-length shape, because doing so produces the - * silent-no-op bug the other rule catches. Without this skip, the two - * rules race each other and the autofix re-introduces the bug. - * - * # Scope handling - * - * Bindings are resolved by walking the AST `parent` chain from the USE site - * upward, stopping at the nearest scope-creating node that declares the name. - * A scope-creating node is any of: - * - * - `Program` (module / file scope) - * - `BlockStatement` (function body, if/for/while body, bare block) - * - `ForStatement` / `ForOfStatement` / `ForInStatement` (the head binding `let - * i = 0` is scoped to the loop, not the surrounding block) - * - any `Function*` node (parameters are scoped to that function) - * - `CatchClause` (the caught-error binding) This is the JS `let`/`const` - * block-scoping model. The fleet's code uses `const` / `let` exclusively - * (no `var`), so we don't need to model `var`'s function-scope hoisting - * separately. Earlier revisions of this module used a single flat - * `Map<name, Kind>` populated by visitor side-effect. That model conflated - * bindings across scopes — a function-local `const closure = new Map()` - * propagated the `map` classification to every other binding in the file - * named `closure`, including unrelated arrays in the parent scope. The - * scope-walk path fixes that at the cost of a per-lookup walk; rule lookups - * happen on `ForStatement` and `MemberExpression` which are relatively - * rare, so the overhead is bounded. - */ - -import type { AstNode } from './rule-types.mts' - -const SET_TYPE_NAMES = new Set(['ReadonlySet', 'Set', 'WeakSet']) -const MAP_TYPE_NAMES = new Set(['Map', 'ReadonlyMap', 'WeakMap']) -const ITERABLE_TYPE_NAMES = new Set([ - 'AsyncIterable', - 'Iterable', - 'IterableIterator', -]) -const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']) - -export type Kind = 'set' | 'map' | 'iterable' | 'array' | 'unknown' - -// Non-array kinds — the ones flagged by no-cached-for-on-iterable -// and the ones prefer-cached-for-loop must skip. -export const FLAGGED_KINDS: ReadonlySet<Kind> = new Set([ - 'iterable', - 'map', - 'set', -]) - -const SCOPE_NODE_TYPES = new Set([ - 'ArrowFunctionExpression', - 'BlockStatement', - 'CatchClause', - 'ClassDeclaration', - 'ClassExpression', - 'ForInStatement', - 'ForOfStatement', - 'ForStatement', - 'FunctionDeclaration', - 'FunctionExpression', - 'Program', - 'TSDeclareFunction', -]) - -const FUNCTION_NODE_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', - 'TSDeclareFunction', -]) - -/** - * Classify a TS type-annotation AST node (the `: T` part of a binding). Returns - * the kind, or `'unknown'` if the annotation is absent or doesn't match a - * recognized shape. Shallow-only — does NOT unwrap `Promise<Set<…>>` (returns - * unknown, which is safe). - */ -export function classifyTypeAnnotation(annotation: AstNode | undefined): Kind { - if (!annotation || !annotation.typeAnnotation) { - return 'unknown' - } - const t = annotation.typeAnnotation - if (t.type === 'TSArrayType') { - return 'array' - } - if (t.type === 'TSTypeReference') { - const name = - t.typeName && t.typeName.type === 'Identifier' - ? t.typeName.name - : undefined - if (!name) { - return 'unknown' - } - if (SET_TYPE_NAMES.has(name)) { - return 'set' - } - if (MAP_TYPE_NAMES.has(name)) { - return 'map' - } - if (ITERABLE_TYPE_NAMES.has(name)) { - return 'iterable' - } - if (ARRAY_TYPE_NAMES.has(name)) { - return 'array' - } - } - return 'unknown' -} - -/** - * Classify the initializer expression a VariableDeclarator is bound to. - * Recognizes `new Set(...)` / `new Map(...)` and a handful of - * array-materializing calls (`Array.from`, `Object.keys`, etc.) so the rule - * doesn't fire on post-fix `const arr = Array.from(set)` shapes. - */ -export function classifyInit(init: AstNode | undefined): Kind { - if (!init) { - return 'unknown' - } - if (init.type === 'ArrayExpression') { - return 'array' - } - if (init.type === 'NewExpression' && init.callee.type === 'Identifier') { - const name = init.callee.name as string - if (SET_TYPE_NAMES.has(name)) { - return 'set' - } - if (MAP_TYPE_NAMES.has(name)) { - return 'map' - } - if (ARRAY_TYPE_NAMES.has(name)) { - return 'array' - } - } - if ( - init.type === 'CallExpression' && - init.callee.type === 'MemberExpression' && - init.callee.object.type === 'Identifier' && - !init.callee.computed && - init.callee.property.type === 'Identifier' - ) { - const objName = init.callee.object.name as string - const propName = init.callee.property.name as string - if (objName === 'Array' && (propName === 'from' || propName === 'of')) { - return 'array' - } - if ( - objName === 'Object' && - (propName === 'entries' || propName === 'keys' || propName === 'values') - ) { - return 'array' - } - } - return 'unknown' -} - -/** - * Classify a single VariableDeclarator AST node. Type annotation wins over - * inferred init kind (explicit > implicit). - */ -function classifyVariableDeclarator(declarator: AstNode): Kind { - if (!declarator || !declarator.id || declarator.id.type !== 'Identifier') { - return 'unknown' - } - const annotated = classifyTypeAnnotation(declarator.id.typeAnnotation) - if (annotated !== 'unknown') { - return annotated - } - return classifyInit(declarator.init) -} - -/** - * Find a binding for `name` declared _directly_ in the given scope node (does - * not recurse into nested scopes). Returns the classified Kind, or undefined if - * no such binding exists in this scope. - * - * Each scope-node type stores its declarations differently: - * - * - `Program` / `BlockStatement`: scan `body` for top-level `VariableDeclaration` - * and `FunctionDeclaration` nodes. - * - `Function*`: check the function's `params` for an Identifier param named - * `name`. The body BlockStatement is a separate scope (visited on the way - * up). - * - `ForStatement`: check the `init` (a VariableDeclaration whose declarators are - * scoped to the loop). - * - `ForOfStatement` / `ForInStatement`: check the `left` (a VariableDeclaration - * declaring the loop var, scoped to the loop). - * - `CatchClause`: check the `param` Identifier. - */ -function findInScope(scope: AstNode, name: string): Kind | undefined { - if (!scope) { - return undefined - } - - // Function parameter scope. - if (FUNCTION_NODE_TYPES.has(scope.type)) { - const params: AstNode[] | undefined = scope.params - if (params) { - for (let i = 0, { length } = params; i < length; i += 1) { - const p = params[i] - if (p && p.type === 'Identifier' && (p.name as string) === name) { - return classifyTypeAnnotation(p.typeAnnotation) - } - } - } - return undefined - } - - // Catch clause: single Identifier param. - if (scope.type === 'CatchClause') { - const p = scope.param - if (p && p.type === 'Identifier' && (p.name as string) === name) { - return classifyTypeAnnotation(p.typeAnnotation) - } - return undefined - } - - // for (let X = …; …; …) — declaration is in scope.init. - if (scope.type === 'ForStatement') { - const init: AstNode | undefined = scope.init - if (init && init.type === 'VariableDeclaration') { - const k = findInVariableDeclaration(init, name) - if (k !== undefined) { - return k - } - } - return undefined - } - - // for (const X of …) / for (const X in …) — declaration is in scope.left. - if (scope.type === 'ForInStatement' || scope.type === 'ForOfStatement') { - const left: AstNode | undefined = scope.left - if (left && left.type === 'VariableDeclaration') { - const k = findInVariableDeclaration(left, name) - if (k !== undefined) { - return k - } - } - return undefined - } - - // Program or BlockStatement: scan body for declarations. - if (scope.type === 'BlockStatement' || scope.type === 'Program') { - const body: AstNode[] | undefined = scope.body - if (!body) { - return undefined - } - for (let i = 0, { length } = body; i < length; i += 1) { - const stmt = body[i] - if (!stmt) { - continue - } - if (stmt.type === 'VariableDeclaration') { - const k = findInVariableDeclaration(stmt, name) - if (k !== undefined) { - return k - } - } else if ( - stmt.type === 'ExportNamedDeclaration' && - stmt.declaration && - stmt.declaration.type === 'VariableDeclaration' - ) { - const k = findInVariableDeclaration(stmt.declaration, name) - if (k !== undefined) { - return k - } - } - } - return undefined - } - - return undefined -} - -/** - * Scan a VariableDeclaration node's declarators for one whose id is - * `Identifier(name)`. Returns the classified Kind if found, else undefined. - */ -function findInVariableDeclaration( - decl: AstNode, - name: string, -): Kind | undefined { - const decls: AstNode[] | undefined = decl.declarations - if (!decls) { - return undefined - } - for (let i = 0, { length } = decls; i < length; i += 1) { - const d = decls[i] - if ( - d && - d.id && - d.id.type === 'Identifier' && - (d.id.name as string) === name - ) { - return classifyVariableDeclarator(d) - } - } - return undefined -} - -/** - * Resolve `name` as seen from the use-site `useNode`. Walks the AST parent - * chain, checking each scope-creating ancestor for a direct declaration of - * `name`. Returns the nearest enclosing scope's classification, or `'unknown'` - * if no declaration is found. - * - * The walk stops on the first declaring scope (JS lookup semantics): a - * function-local `const closure = new Map()` shadows an outer `const closure = - * await fn()` even if the inner is declared "later" in source order, because - * they live in different scopes and the use-site picks the nearest declaring - * scope on its parent chain. - */ -export function resolveKind(useNode: AstNode, name: string): Kind { - let cur: AstNode | undefined = useNode - while (cur) { - if (SCOPE_NODE_TYPES.has(cur.type)) { - const k = findInScope(cur, name) - if (k !== undefined) { - return k - } - } - cur = cur.parent - } - return 'unknown' -} - -/** - * Wire the scope-aware kind resolver into a rule. Returns `resolveKind(useNode, - * name)` for the rule to call from its use-site visitors (e.g. ForStatement / - * MemberExpression). - * - * Unlike the older `trackKinds()` API, this returns no visitors: the resolver - * walks the AST on-demand instead of building a pre-populated map. The - * trade-off is one parent-chain walk per lookup vs. an O(file-size) population - * pass at create() time. Lookups are scoped to rule call sites (ForStatement, - * MemberExpression with a Set/Map LHS), so the per-lookup cost is bounded. - * - * Usage: - * - * Const resolveKind = createKindResolver() return { ForStatement(node) { const - * kind = resolveKind(node, 'someName') … }, } - */ -export function createKindResolver(): (useNode: AstNode, name: string) => Kind { - return resolveKind -} diff --git a/.config/oxlint-plugin/lib/rule-tester.mts b/.config/oxlint-plugin/lib/rule-tester.mts deleted file mode 100644 index 3b320fe55..000000000 --- a/.config/oxlint-plugin/lib/rule-tester.mts +++ /dev/null @@ -1,432 +0,0 @@ -/** - * @file RuleTester for the fleet's oxlint plugin rules. Oxlint doesn't yet ship - * its own RuleTester (oxc-project/oxc#16018 tracks the planned - * `@oxlint/plugin-dev` package). This module is a placeholder stand-in - * modeled on ESLint's RuleTester API — same `valid` / `invalid` array shape, - * same per-case fields (`code`, `errors`, `output`, `filename`). How it - * works: - * - * 1. For each test case, write the fixture to an OS-temp dir (mkdtemp). - * 2. Write a tiny `.oxlintrc.json` that enables ONLY the rule under test, plus - * `jsPlugins: [<plugin-path>]`. - * 3. Spawn `oxlint --config <tmpdir>/.oxlintrc.json <fixture>` and capture - * stdout. - * 4. Compare findings against the test case's `errors` array. - * 5. Clean up via `safeDeleteSync` (fleet rule: never `fs.rm` / `fs.unlink` / - * `rm -rf` directly). Cleanup runs in a try/finally so a failing assertion - * doesn't leak tmp dirs. - * - * @example - * import { RuleTester } from '../lib/rule-tester.mts' - * import rule from '../rules/no-default-export.mts' - * - * new RuleTester().run('no-default-export', rule, { - * valid: [ - * { code: 'export const foo = 1;' }, - * { code: 'export function foo() {}' }, - * ], - * invalid: [ - * { - * code: 'export default function foo() {}', - * errors: [{ messageId: 'noDefaultExport' }], - * output: 'export function foo() {}', - * }, - * ], - * }) - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { createRequire } from 'node:module' -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { resolveBinaryPath } from '@socketsecurity/lib-stable/dlx/binary-resolution' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' - -const logger = getDefaultLogger() - -const PLUGIN_INDEX = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'index.mts', -) - -/** - * Build the minimal .oxlintrc.json that enables ONE socket plugin rule plus the - * plugin's JS entry point. - */ -function buildConfig(ruleName: string): string { - return JSON.stringify( - { - jsPlugins: [PLUGIN_INDEX], - rules: { - [`socket/${ruleName}`]: 'error', - }, - }, - null, - 2, - ) -} - -/** - * Compare a single error spec against an emitted diagnostic. - * - * Two acceptance paths: 1. `messageId` — strict match against `diag.messageId` - * when the oxlint version emits that field (older builds). Recent builds drop - * `messageId` from the JSON output entirely, so a `messageId`-only spec falls - * through to (2): once the runner has already filtered diagnostics down to this - * rule via `matchesRule`, "the diagnostic is from this rule" is the same claim - * "messageId matches" was making. 2. `message` — substring match against - * `diag.message`. Use this when the spec wants to assert specific copy text. - * - * If the spec has neither, accept the diagnostic (the runner has already - * filtered to this rule, so the presence of a diagnostic is itself the - * assertion). This is how a bare `{ messageId: 'foo' }` spec keeps working - * under oxlint builds that no longer emit `messageId` in JSON. - */ -function errorMatches( - spec: { messageId?: string | undefined; message?: string | undefined }, - diag: OxlintDiagnostic, -): boolean { - if (spec.messageId && diag.messageId) { - return spec.messageId === diag.messageId - } - if (spec.message && diag.message?.includes(spec.message)) { - return true - } - // messageId spec but no messageId field on diag: accept (rule - // already matched via matchesRule upstream). - if (spec.messageId && !diag.messageId) { - return true - } - return false -} - -/** - * Default fixture filename derived from the test case's `filename` override or - * `'fixture.ts'`. ESLint's RuleTester uses `'<input>.js'`; we default to `.ts` - * since the fleet rules are TS-aware. - */ -function fixtureFilename(testCase: ValidTestCase): string { - return testCase.filename ?? 'fixture.ts' -} - -export interface ValidTestCase { - /** - * Source to lint. - */ - readonly code: string - /** - * Optional override for the fixture filename (e.g. `'.cts'` cases). - */ - readonly filename?: string | undefined - /** - * Human-readable label shown in failure output. - */ - readonly name?: string | undefined - /** - * Optional `package.json` written alongside the fixture in the tmp dir. Lets - * package-name-aware rules (e.g. `prefer-stable-self-import`, which walks up - * to the nearest package.json `name`) be exercised. Provide a partial object; - * it's JSON-stringified verbatim. - */ - readonly packageJson?: Record<string, unknown> | undefined -} - -export interface InvalidTestCase extends ValidTestCase { - /** - * Expected error matches. Each entry must match by `messageId`, `message`, or - * both. Order-sensitive — oxlint emits findings in source order. - */ - readonly errors: ReadonlyArray<{ - readonly messageId?: string | undefined - readonly message?: string | undefined - /** - * Template-substitution data for messageId-keyed message strings. Mirrors - * ESLint's RuleTester `data` field — when the rule's messages dict has - * placeholders like `{{name}}`, the test passes the substitution values - * here. - */ - readonly data?: Record<string, unknown> | undefined - }> - /** - * Expected source after autofix. If provided, the tester reruns `oxlint - * --fix` against a copy of the fixture and asserts the result. Omit when the - * rule has no autofix. - */ - readonly output?: string | undefined -} - -export interface RunOpts { - readonly valid: readonly ValidTestCase[] - readonly invalid: readonly InvalidTestCase[] -} - -/** - * Find the `oxlint` binary. Resolves the LOCALLY-installed `oxlint` package - * that `pnpm install` linked — never a global `which oxlint`. A global lookup - * is wrong on two counts: it skips the whole rule-test suite on any normal - * checkout (oxlint isn't installed globally), turning these tests into silent - * no-ops; and if a global oxlint of a different version happens to exist, the - * tests would run against the wrong engine. Resolve `oxlint`'s package.json via - * the module system, read its `bin` entry, then hand the path to the - * fleet-canonical `resolveBinaryPath` from - * `@socketsecurity/lib-stable/dlx/binary-resolution` for the platform wrapper - * (`.cmd`/`.ps1` on Windows; pass-through on Unix). Returns undefined only when - * `oxlint` can't be resolved yet (pre-install), so the harness skips gracefully - * rather than false-failing a fresh checkout. - */ -function resolveOxlintBinary(): string | undefined { - const require = createRequire(import.meta.url) - let packageJsonPath: string - try { - packageJsonPath = require.resolve('oxlint/package.json') - } catch { - return undefined - } - try { - const pkgDir = path.dirname(packageJsonPath) - const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { - bin?: string | Record<string, string> | undefined - } - // `bin` is either a string (single bin named after the package) or a - // map of bin-name → relative path. Pick the `oxlint` entry, falling - // back to the string form. - const binRel = - typeof pkg.bin === 'string' - ? pkg.bin - : (pkg.bin?.['oxlint'] ?? Object.values(pkg.bin ?? {})[0]) - if (!binRel) { - return undefined - } - return resolveBinaryPath(path.join(pkgDir, binRel)) - } catch { - return undefined - } -} - -interface OxlintDiagnostic { - readonly ruleId?: string | undefined - readonly message?: string | undefined - readonly messageId?: string | undefined -} - -/** - * Run oxlint against a fixture file with a one-rule config; return the parsed - * list of findings for THIS rule. - */ -function runOxlint(args: { - oxlintBin: string - fixturePath: string - configPath: string - ruleName: string - fix: boolean -}): { diagnostics: OxlintDiagnostic[]; output?: string | undefined } { - const cliArgs = ['--config', args.configPath, '-f', 'json'] - if (args.fix) { - cliArgs.push('--fix') - } - cliArgs.push(args.fixturePath) - const r = spawnSync(args.oxlintBin, cliArgs, { - timeout: 15_000, - }) - // oxlint's JSON reporter has changed shape across versions: - // - Older: line-delimited diagnostic objects, one per line. - // - Mid: top-level array `[ { diagnostics: [...] }, ... ]`. - // - Current: top-level object `{ diagnostics: [...], number_of_files, ... }` - // (single multi-line JSON with the diagnostics inline). - // Parse defensively in that order: try whole-buffer parse first - // (handles the array AND object shapes), then fall back to - // line-by-line. Filter every result by rule id so unrelated - // findings (autofix from other socket rules in the same config) - // don't inflate the count. - const stdout = String(r.stdout || '') - const diagnostics: OxlintDiagnostic[] = [] - const trimmed = stdout.trim() - const matchesRule = (d: OxlintDiagnostic): boolean => { - // Current oxlint emits `code` like `socket(no-cached-for-on-iterable)` - // instead of (or in addition to) `ruleId`. Accept either form. - const code = (d as OxlintDiagnostic & { code?: string | undefined }).code - return ( - d.ruleId?.endsWith(`/${args.ruleName}`) === true || - d.ruleId === `socket/${args.ruleName}` || - d.ruleId === args.ruleName || - code === `socket(${args.ruleName})` || - (typeof code === 'string' && code.endsWith(`(${args.ruleName})`)) - ) - } - let parsedWhole = false - if (trimmed.startsWith('[') || trimmed.startsWith('{')) { - try { - const parsed = JSON.parse(trimmed) as unknown - const fileBlocks: Array<{ - diagnostics?: OxlintDiagnostic[] | undefined - }> = Array.isArray(parsed) - ? (parsed as Array<{ diagnostics?: OxlintDiagnostic[] | undefined }>) - : [parsed as { diagnostics?: OxlintDiagnostic[] | undefined }] - for (let i = 0, { length } = fileBlocks; i < length; i += 1) { - const file = fileBlocks[i]! - for (const d of file.diagnostics ?? []) { - if (matchesRule(d)) { - diagnostics.push(d) - } - } - } - parsedWhole = true - } catch { - // Fall through to line-by-line parse. - } - } - if (!parsedWhole) { - for (const line of stdout.split('\n')) { - if (!line.trim() || !line.trim().startsWith('{')) { - continue - } - try { - const d = JSON.parse(line) as OxlintDiagnostic - if (matchesRule(d)) { - diagnostics.push(d) - } - } catch { - // Skip non-JSON lines (oxlint sometimes emits human text). - } - } - } - const output = args.fix ? readFileSync(args.fixturePath, 'utf8') : undefined - return { diagnostics, output } -} - -interface RuleModule { - readonly meta?: unknown | undefined - readonly create?: ((context: unknown) => unknown) | undefined -} - -export class RuleTester { - /** - * Execute the test suite. Throws on the first failure (matches node:test - * expectations — a failing test bubbles up as a thrown assertion error). For - * per-case isolation use describe() blocks in your test file and call .run() - * inside each. - */ - run(ruleName: string, _rule: RuleModule, opts: RunOpts): void { - const oxlintBin = resolveOxlintBinary() - if (!oxlintBin) { - // Don't fail — let the harness skip gracefully. The audit- - // coverage script enforces test FILES exist; running them is - // contingent on the bin being installed (which `pnpm install` - // wires up). - logger.warn( - `[rule-tester] oxlint binary not on PATH; skipping ${ruleName} cases.`, - ) - return - } - - const tmpdir = mkdtempSync( - path.join(os.tmpdir(), `oxlint-test-${ruleName}-`), - ) - // `filename:` overrides can put fixtures in subdirs (e.g. - // `scripts/foo.mts`). Ensure the parent dir exists before each - // write — fail-fast on a missing dir would manifest as a - // confusing ENOENT in the test report. - const writeFixture = ( - fixturePath: string, - code: string, - tc?: ValidTestCase, - ): void => { - mkdirSync(path.dirname(fixturePath), { recursive: true }) - writeFileSync(fixturePath, code) - // Optional package.json fixture for package-name-aware rules. Written - // next to the fixture file so a walk-up from the fixture finds it. - if (tc?.packageJson) { - writeFileSync( - path.join(path.dirname(fixturePath), 'package.json'), - `${JSON.stringify(tc.packageJson, null, 2)}\n`, - ) - } - } - try { - const configPath = path.join(tmpdir, '.oxlintrc.json') - writeFileSync(configPath, buildConfig(ruleName)) - - // Valid cases: no findings expected. - for (const tc of opts.valid) { - const fixturePath = path.join(tmpdir, fixtureFilename(tc)) - writeFixture(fixturePath, tc.code, tc) - const { diagnostics } = runOxlint({ - oxlintBin, - fixturePath, - configPath, - ruleName, - fix: false, - }) - if (diagnostics.length > 0) { - throw new Error( - `[${ruleName}] valid case ${tc.name ? `'${tc.name}'` : ''} ` + - `unexpectedly produced ${diagnostics.length} ` + - `finding(s): ${JSON.stringify(diagnostics)}`, - ) - } - } - - // Invalid cases: expected count + messageId / message match. - for (const tc of opts.invalid) { - const fixturePath = path.join(tmpdir, fixtureFilename(tc)) - writeFixture(fixturePath, tc.code, tc) - const { diagnostics } = runOxlint({ - oxlintBin, - fixturePath, - configPath, - ruleName, - fix: false, - }) - if (diagnostics.length !== tc.errors.length) { - throw new Error( - `[${ruleName}] invalid case ${tc.name ? `'${tc.name}'` : ''} ` + - `expected ${tc.errors.length} finding(s), got ` + - `${diagnostics.length}: ${JSON.stringify(diagnostics)}`, - ) - } - for (let i = 0; i < tc.errors.length; i += 1) { - const spec = tc.errors[i]! - const diag = diagnostics[i]! - if (!errorMatches(spec, diag)) { - throw new Error( - `[${ruleName}] invalid case ${tc.name ? `'${tc.name}'` : ''} ` + - `error #${i} mismatch — expected ` + - `${JSON.stringify(spec)}, got ${JSON.stringify(diag)}`, - ) - } - } - // Autofix assertion. - if (typeof tc.output === 'string') { - // Rewrite the fixture (oxlint --fix mutates in place) and - // re-run with --fix. - writeFixture(fixturePath, tc.code, tc) - const fixResult = runOxlint({ - oxlintBin, - fixturePath, - configPath, - ruleName, - fix: true, - }) - if (fixResult.output !== tc.output) { - throw new Error( - `[${ruleName}] autofix mismatch for ${tc.name ? `'${tc.name}'` : 'case'}:\n` + - ` expected: ${JSON.stringify(tc.output)}\n` + - ` got: ${JSON.stringify(fixResult.output)}`, - ) - } - } - } - } finally { - // Fleet rule: safeDeleteSync from @socketsecurity/lib-stable/fs, never - // fs.rm / fs.unlink / rm -rf. The Sync flavor matches the - // tester's sync-style API + lets a thrown assertion still trigger - // cleanup via the finally block. - safeDeleteSync(tmpdir, { force: true, recursive: true }) - } - } -} diff --git a/.config/oxlint-plugin/lib/rule-types.mts b/.config/oxlint-plugin/lib/rule-types.mts deleted file mode 100644 index 184ce2d5d..000000000 --- a/.config/oxlint-plugin/lib/rule-types.mts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @file Shared type aliases for oxlint plugin rules. Oxlint rules consume - * ESTree AST nodes via callback visitors, but neither @types/estree nor the - * oxlint runtime expose a single cohesive type for them. Authoring rules - * against the full union would inflate the rule bodies with narrowing - * boilerplate; using raw `any` triggers `noImplicitAny`. This module exports - * `any`- shaped aliases so rules can opt out of the narrow surface without - * paying the `any` linter cost at each callsite. Conventions: - * - * - `AstNode` — any ESTree node (Program, Literal, CallExpression, …). - * - `RuleContext` — the second arg to a rule's `create(context)`. - * - `RuleFixer` — the fixer passed to `context.report({ fix })`. - * - `RuleListener` — a record mapping visitor names (e.g. `CallExpression`, - * `Literal`) to handler functions. Rules should `import type { AstNode } - * from '../lib/rule-types.mts'` and annotate visitor callbacks: - * `Literal(node: AstNode) { … }`. Why `any` not `unknown`: rule bodies - * traverse arbitrary nested structure (`node.id.type`, - * `node.declarations[0].init.callee.name`). Forcing `unknown` would - * multiply narrowing boilerplate without catching bugs the runtime visitor - * signature already guarantees. The AST contract is "ESTree-shaped, - * mostly"; locking it down properly belongs in the lint-tooling layer, not - * per-rule. - */ - -// eslint-disable-next-line typescript/no-explicit-any -export type AstNode = any - -// eslint-disable-next-line typescript/no-explicit-any -export type RuleContext = any - -// eslint-disable-next-line typescript/no-explicit-any -export type RuleFixer = any - -export type RuleListener = Record<string, (node: AstNode) => void> diff --git a/.config/oxlint-plugin/package.json b/.config/oxlint-plugin/package.json deleted file mode 100644 index fbbf76b06..000000000 --- a/.config/oxlint-plugin/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "socket-oxlint-plugin", - "private": true, - "type": "module", - "main": "./index.mts", - "exports": { - ".": "./index.mts" - } -} diff --git a/.config/oxlint-plugin/rules/_inject-import.mts b/.config/oxlint-plugin/rules/_inject-import.mts deleted file mode 100644 index 4b52053b5..000000000 --- a/.config/oxlint-plugin/rules/_inject-import.mts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @file Shared helper for rule fixers that need to inject an `import { Name } - * from 'specifier'` statement (and optionally a matching hoisted `const`) - * into a file. Fixers call `summarizeImportTarget(programNode, specifier, - * importName)` to learn the file's current shape, then - * `appendImportFixes(...)` inside their `fix(fixer)` callback to add the - * missing pieces. ESLint's autofixer dedupes overlapping inserts at the same - * range, so multiple violations in the same file can each emit the import - * insertion safely — only one survives. - */ - -import type { AstNode, RuleFixer } from '../lib/rule-types.mts' - -export interface ImportSummary { - hasImport: boolean - hasLocal: boolean - lastImport: AstNode | undefined -} - -export type FixerOp = unknown - -/** - * Walk a Program node body once and figure out: - the last top-level - * ImportDeclaration node (or undefined) - whether `importName` is already - * imported (from ANY source) - whether a top-level `localName` identifier - * already exists (any const/let/var or import-as-local with that name) - * - * Import detection ignores the specifier path: a file inside the lib package - * itself imports `getDefaultLogger` from `'../logger'`, while a downstream repo - * imports the same name from `'@socketsecurity/lib-stable/logger/default'`. - * Both resolve to the same identifier; either should count as "already - * imported" so the autofix doesn't inject a duplicate (and broken — see issue - * #64). - * - * `specifier` is retained in the signature for backward compatibility but is no - * longer used for the match decision. Callers may pass any truthy value - * (typically the canonical package path the rule would inject if the import - * were missing). - */ -export function summarizeImportTarget( - program: AstNode, - // eslint-disable-next-line no-unused-vars - _specifier: string, - importName: string, - localName?: string, -): ImportSummary { - let lastImport: AstNode | undefined - let hasImport = false - let hasLocal = false - for (const stmt of program.body) { - if (stmt.type === 'ImportDeclaration') { - lastImport = stmt - for (const spec of stmt.specifiers) { - if ( - spec.type === 'ImportSpecifier' && - spec.imported && - spec.imported.name === importName - ) { - hasImport = true - } - if ( - localName && - spec.local && - spec.local.name === localName && - (spec.type === 'ImportDefaultSpecifier' || - spec.type === 'ImportNamespaceSpecifier' || - spec.type === 'ImportSpecifier') - ) { - hasLocal = true - } - } - continue - } - if (!localName) { - continue - } - // A top-level `const localName = ...` (with or without `export`). - // The legacy walk only looked at bare `VariableDeclaration`; an - // `export const logger = ...` is an `ExportNamedDeclaration` - // whose `.declaration` is the VariableDeclaration. Missing that - // branch caused the autofix to inject a duplicate - // `const logger = ...` hoist into files that already exported - // their own `logger` (see scripts/fleet/logger.mts - // pre-fix — `export const logger = {...}` got an extra - // `const logger = getDefaultLogger()` hoisted above it). - const varDecl = - stmt.type === 'VariableDeclaration' - ? stmt - : stmt.type === 'ExportNamedDeclaration' && - stmt.declaration && - stmt.declaration.type === 'VariableDeclaration' - ? stmt.declaration - : undefined - if (!varDecl) { - continue - } - for (const decl of varDecl.declarations) { - if ( - decl.id && - decl.id.type === 'Identifier' && - decl.id.name === localName - ) { - hasLocal = true - } - } - } - return { hasImport, hasLocal, lastImport } -} - -/** - * Build the fixer-side inserts for missing import + optional hoist. Returns an - * array of fixer operations the caller appends to its own fix() return value. - * - * Summary — output of summarizeImportTarget() fixer — the fixer passed to - * context.report({ fix }) importLine — the literal `import { ... } from '...'` - * text hoistLine — optional; the literal `const x = ...()` text. - */ -export function appendImportFixes( - summary: ImportSummary, - fixer: RuleFixer, - importLine: string, - hoistLine?: string, -): FixerOp[] { - const ops: FixerOp[] = [] - if (!summary.hasImport) { - if (summary.lastImport) { - ops.push(fixer.insertTextAfter(summary.lastImport, `\n${importLine}`)) - } else { - ops.push(fixer.insertTextBeforeRange([0, 0], `${importLine}\n`)) - } - } - if (hoistLine && !summary.hasLocal) { - if (summary.lastImport) { - ops.push(fixer.insertTextAfter(summary.lastImport, `\n\n${hoistLine}`)) - } else { - ops.push(fixer.insertTextBeforeRange([0, 0], `${hoistLine}\n\n`)) - } - } - return ops -} diff --git a/.config/oxlint-plugin/rules/export-top-level-functions.mts b/.config/oxlint-plugin/rules/export-top-level-functions.mts deleted file mode 100644 index 699d83b4a..000000000 --- a/.config/oxlint-plugin/rules/export-top-level-functions.mts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @file Require every top-level `function` declaration to be `export`ed. Per - * the fleet rule: "we should export all methods for testing." Exposing - * internal helpers as named exports lets tests import them directly, no - * `__test_only__` shim or per-test rebuild. Scope: top-level function - * declarations only (not class methods, not arrow functions assigned to - * const, not local nested functions). Local helpers and arrow-as-const are - * visible to their parent module's tests via the parent function; only the - * top-level surface needs explicit export. Allowed exceptions (skipped): - * - * - The function is named `main` (script entrypoint convention). Autofix: - * prepends `export ` to the function declaration when the function isn't - * already named in a sibling `export { ... }` statement. If a - * named-re-export already exists, report without autofix (the human picks: - * keep the named-re-export shape, or collapse to the inline `export - * function`). - */ - -import path from 'node:path' - -import { detectSourceType } from '../lib/detect-source-type.mts' -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SCRIPT_ENTRY_NAMES = new Set(['main']) - -/** - * Walk Program body once and collect names exported via: - `export { foo, bar - * }` - `export { foo as bar }` (the local-name `foo` counts) - `export default - * foo` - * - * Function declarations that already say `export function foo` won't reach this - * rule's visitor (the visitor matches bare function declarations only via - * `Program > FunctionDeclaration`; an `ExportNamedDeclaration` wraps them in a - * different shape). - */ -function collectExportedNames(program: AstNode): Set<string> { - const exported = new Set<string>() - for (const stmt of program.body) { - if (stmt.type === 'ExportNamedDeclaration' && !stmt.declaration) { - // `export { foo, bar as baz }` — count the local name. - for (const spec of stmt.specifiers) { - if (spec.local && spec.local.type === 'Identifier') { - exported.add(spec.local.name) - } - } - } - if ( - stmt.type === 'ExportDefaultDeclaration' && - stmt.declaration && - stmt.declaration.type === 'Identifier' - ) { - exported.add(stmt.declaration.name) - } - } - return exported -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Require top-level function declarations to be exported (testability).', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - missing: - 'Top-level function `{{name}}` should be `export function {{name}}`. Exporting internal helpers makes them directly testable.', - missingAlreadyReExported: - 'Top-level function `{{name}}` is named in a separate `export {{ }}` statement; collapse to inline `export function {{name}}` for clarity (autofix skipped to avoid creating a duplicate export).', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - // Skip CommonJS files. Rewriting `function getObject(idx) { … }` - // to `export function getObject(idx) { … }` inside a CJS module - // makes the file syntactically ESM — `require()` of it then - // throws `SyntaxError: Unexpected token 'export'`. Worked example: - // wasm-bindgen `--target nodejs` output (`acorn-bindgen.cjs`) - // uses `module.exports` for the public surface plus local - // `function` declarations for internal helpers; the autofix - // catastrophically rewrote them. The detector uses Node's - // `--experimental-detect-module` algorithm: file extension is - // authoritative for `.cjs` / `.cts` / `.mjs` / `.mts`; ambiguous - // `.js` / `.ts` falls through to a content sniff. - const filename: string = - typeof context.filename === 'string' - ? context.filename - : typeof context.getFilename === 'function' - ? context.getFilename() - : '' - const extension = filename ? path.extname(filename) : '' - const sourceText: string = - typeof sourceCode.getText === 'function' - ? sourceCode.getText() - : typeof sourceCode.text === 'string' - ? sourceCode.text - : '' - const kind = detectSourceType(sourceText, { extension }) - if (kind === 'cjs') { - return {} - } - - let exportedNames: Set<string> | undefined - - return { - 'Program > FunctionDeclaration'(node: AstNode) { - if (!node.id || node.id.type !== 'Identifier') { - return - } - const name = node.id.name - if (SCRIPT_ENTRY_NAMES.has(name)) { - return - } - if (!exportedNames) { - exportedNames = collectExportedNames(sourceCode.ast) - } - if (exportedNames.has(name)) { - // Already exported via `export { name }` — report without - // autofix; the human can choose whether to collapse to the - // inline export. - context.report({ - node: node.id, - messageId: 'missingAlreadyReExported', - data: { name }, - }) - return - } - context.report({ - node: node.id, - messageId: 'missing', - data: { name }, - fix(fixer: RuleFixer) { - // Insert `export ` at the function's start. Handles both - // `function name(...)` and `async function name(...)`. - return fixer.insertTextBefore(node, 'export ') - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/inclusive-language.mts b/.config/oxlint-plugin/rules/inclusive-language.mts deleted file mode 100644 index 99967460d..000000000 --- a/.config/oxlint-plugin/rules/inclusive-language.mts +++ /dev/null @@ -1,395 +0,0 @@ -/* oxlint-disable socket/inclusive-language -- this file IS the rule definition; the legacy terms are lookup-table data, not real usage. */ - -/** - * @file Per CLAUDE.md "Inclusive language" rule (full table in - * docs/references/inclusive-language.md). Substitutions: whitelist → - * allowlist blacklist → denylist master → main / primary slave → replica / - * secondary / worker grandfathered → legacy sanity check → quick check dummy - * → placeholder Detects identifiers, string literals, and comments containing - * the legacy terms. Word-boundary matched on the literal stem so case - * variants `Whitelist` / `WHITELIST` / `whitelisted` all fire. Autofix: - * - * - Identifiers and string literals: rewrite case-preserving (e.g. `Whitelist` - * → `Allowlist`, `WHITELIST` → `ALLOWLIST`, `whitelistEntry` → - * `allowlistEntry`). - * - Comments: rewrite the comment text in place, same case rules. - * - Multi-word terms (`sanity check`, `master branch`): only the first word is - * replaced; the rest is left alone (`sanity check` → `quick check`). - * Allowed exceptions (skipped — no report, no fix): - * - Third-party API field references: comment with `inclusive-language: - * external-api` adjacent to the line. - * - Vendored / fixture paths: handled at the .config/oxlintrc.json - * ignorePatterns level; this rule trusts the include set. - * - The literal phrase "main / primary" / etc. inside a doc that spells out the - * substitution table — handled by the - * `docs/references/inclusive-language.md` ignore pattern in - * .config/oxlintrc.json (caller adds the override). - */ - -// [legacyStem, replacementStem]. The detector matches the stem -// case-insensitively and word-boundary anchored. Replacement preserves -// case shape. - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SUBSTITUTIONS = [ - ['whitelist', 'allowlist'], - ['blacklist', 'denylist'], - ['grandfathered', 'legacy'], - ['sanity', 'quick'], - ['dummy', 'placeholder'], - // master/slave are loaded but rewriting requires more nuance — only - // flag, never autofix (could mean main/primary/controller; depends - // on the surrounding domain). -] - -const REPORT_ONLY = new Set(['master', 'slave']) -const REPORT_ONLY_TERMS = ['master', 'slave'] - -const BYPASS_RE = /inclusive-language:\s*external-api/ - -/** - * Build a regex matching any legacy stem with word boundaries. - * - * Stems are sorted alphabetically before being joined so the regex alternation - * has a deterministic, stable form. Two reasons: 1. The fleet ships a - * `sort-regex-alternations` rule that flags unsorted `(a|b|c)`-style - * alternations; this regex would trip its own sibling rule without the sort. 2. - * Regex engines treat `|` as "first match wins" when alternatives have shared - * prefixes — sorting keeps the precedence visible in source rather than - * depending on declaration order. - */ -function buildDetectorRegex() { - const stems = [ - ...SUBSTITUTIONS.map(([legacy]) => legacy), - ...REPORT_ONLY_TERMS, - ].toSorted() - return new RegExp(`\\b(${stems.join('|')})\\w*`, 'gi') -} - -const DETECTOR_RE = buildDetectorRegex() - -/** - * Replace a single hit `match` (e.g. `Whitelist`, `WHITELIST`, `whitelisted`, - * `whitelistEntry`) with the case-preserving form of the new stem. Returns - * undefined when there's no autofix-able substitution (master/slave). - */ -function rewriteHit(match: string): string | undefined { - const lower = match.toLowerCase() - for (const [legacy, replacement] of SUBSTITUTIONS) { - if (!legacy || !replacement) { - continue - } - if (!lower.startsWith(legacy)) { - continue - } - const tail = match.slice(legacy.length) - const original = match.slice(0, legacy.length) - let rebuilt: string - if (original === original.toUpperCase()) { - rebuilt = replacement.toUpperCase() - } else if (original[0] === original[0]!.toUpperCase()) { - rebuilt = replacement[0]!.toUpperCase() + replacement.slice(1) - } else { - rebuilt = replacement - } - return rebuilt + tail - } - return undefined -} - -interface Hit { - start: number - end: number - match: string - stem: string -} - -function findHits(text: string): Hit[] { - const hits: Hit[] = [] - DETECTOR_RE.lastIndex = 0 - let m - while ((m = DETECTOR_RE.exec(text)) !== null) { - const stem = m[1]!.toLowerCase() - hits.push({ - start: m.index, - end: m.index + m[0].length, - match: m[0], - stem, - }) - } - return hits -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Use inclusive language. Replace whitelist/blacklist/master/slave/grandfathered/sanity/dummy per the fleet substitution table.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - legacy: - '`{{match}}` — replace with the inclusive-language equivalent. See docs/references/inclusive-language.md.', - legacyMaster: - '`{{match}}` — replace with `main` (branch), `primary` / `controller` (process). Manual rewrite — context decides which fits.', - legacySlave: - '`{{match}}` — replace with `replica` / `worker` / `secondary` / `follower`. Manual rewrite — context decides which fits.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - function hasBypassComment(node: AstNode) { - const before = sourceCode.getCommentsBefore(node) - const after = sourceCode.getCommentsAfter(node) - for (const c of [...before, ...after]) { - if (BYPASS_RE.test(c.value)) { - return true - } - } - // Fall-back: scan the entire source line containing the node for - // a trailing bypass comment. AST-level "after" comments stop at - // the statement boundary, but a chained method call's string - // literal won't see a trailing comment on the same physical line. - const loc = node.loc - if (loc && loc.start.line === loc.end.line) { - const lineText = sourceCode.lines?.[loc.start.line - 1] - if (lineText && BYPASS_RE.test(lineText)) { - return true - } - } - return false - } - - function checkIdentifier(node: AstNode) { - if (!node.name) { - return - } - const hits = findHits(node.name) - if (hits.length === 0) { - return - } - if (hasBypassComment(node)) { - return - } - // Identifiers can have multiple hits in compound names — - // process each and merge into a single rewrite. - let rebuilt = '' - let cursor = 0 - let mutated = false - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - rebuilt += node.name.slice(cursor, h.start) - const replacement = REPORT_ONLY.has(h.stem) - ? undefined - : rewriteHit(h.match) - if (replacement) { - rebuilt += replacement - mutated = true - } else { - rebuilt += h.match - } - cursor = h.end - } - rebuilt += node.name.slice(cursor) - - if (!mutated) { - // All hits are report-only (master/slave) — emit one report - // for each. - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - let messageId = 'legacy' - if (h.stem === 'master') { - messageId = 'legacyMaster' - } else if (h.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ node, messageId, data: { match: h.match } }) - } - return - } - - // Emit one report per hit but a single combined fix. - const firstHit = hits[0]! - let messageId = 'legacy' - if (firstHit.stem === 'master') { - messageId = 'legacyMaster' - } else if (firstHit.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ - node, - messageId, - data: { match: firstHit.match }, - fix(fixer: RuleFixer) { - return fixer.replaceText(node, rebuilt) - }, - }) - } - - return { - Identifier: checkIdentifier, - - Literal(node: AstNode) { - if (typeof node.value !== 'string') { - return - } - const hits = findHits(node.value) - if (hits.length === 0) { - return - } - if (hasBypassComment(node)) { - return - } - - let rebuilt = '' - let cursor = 0 - let mutated = false - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - rebuilt += node.value.slice(cursor, h.start) - const replacement = REPORT_ONLY.has(h.stem) - ? undefined - : rewriteHit(h.match) - if (replacement) { - rebuilt += replacement - mutated = true - } else { - rebuilt += h.match - } - cursor = h.end - } - rebuilt += node.value.slice(cursor) - - if (!mutated) { - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - let messageId = 'legacy' - if (h.stem === 'master') { - messageId = 'legacyMaster' - } else if (h.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ node, messageId, data: { match: h.match } }) - } - return - } - - const firstHit = hits[0]! - let messageId = 'legacy' - if (firstHit.stem === 'master') { - messageId = 'legacyMaster' - } else if (firstHit.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ - node, - messageId, - data: { match: firstHit.match }, - fix(fixer: RuleFixer) { - const raw = sourceCode.getText(node) - const quote = raw[0]! - if (quote === '`') { - return fixer.replaceText(node, '`' + rebuilt + '`') - } - const escaped = rebuilt.replace( - new RegExp(`\\\\|${quote}`, 'g'), - (ch: string) => '\\' + ch, - ) - return fixer.replaceText(node, quote + escaped + quote) - }, - }) - }, - - Program() { - // Sweep comments — rewriting comment bodies is harmless even - // when literal text matches "legacy" examples, because the - // bypass comment + ignorePatterns handle external-API and - // vendored cases. - const comments = sourceCode.getAllComments() - for (let i = 0, { length } = comments; i < length; i += 1) { - const comment = comments[i]! - if (BYPASS_RE.test(comment.value)) { - continue - } - const hits = findHits(comment.value) - if (hits.length === 0) { - continue - } - - let rebuilt = '' - let cursor = 0 - let mutated = false - for (let j = 0, hitsLength = hits.length; j < hitsLength; j += 1) { - const h = hits[j]! - rebuilt += comment.value.slice(cursor, h.start) - const replacement = REPORT_ONLY.has(h.stem) - ? undefined - : rewriteHit(h.match) - if (replacement) { - rebuilt += replacement - mutated = true - } else { - rebuilt += h.match - } - cursor = h.end - } - rebuilt += comment.value.slice(cursor) - - if (!mutated) { - for (let j = 0, hitsLength = hits.length; j < hitsLength; j += 1) { - const h = hits[j]! - let messageId = 'legacy' - if (h.stem === 'master') { - messageId = 'legacyMaster' - } else if (h.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ - node: comment, - messageId, - data: { match: h.match }, - }) - } - continue - } - - const firstHit = hits[0]! - let messageId = 'legacy' - if (firstHit.stem === 'master') { - messageId = 'legacyMaster' - } else if (firstHit.stem === 'slave') { - messageId = 'legacySlave' - } - context.report({ - node: comment, - messageId, - data: { match: firstHit.match }, - fix(fixer: RuleFixer) { - const prefix = comment.type === 'Line' ? '//' : '/*' - const suffix = comment.type === 'Line' ? '' : '*/' - return fixer.replaceTextRange( - comment.range, - prefix + rebuilt + suffix, - ) - }, - }) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/max-file-lines.mts b/.config/oxlint-plugin/rules/max-file-lines.mts deleted file mode 100644 index c85a80ee2..000000000 --- a/.config/oxlint-plugin/rules/max-file-lines.mts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @file Per CLAUDE.md "File size" rule: Source files have a soft cap of 500 - * lines and a hard cap of 1000 lines. Past those thresholds, split the file - * along its natural seams. Two severities: - * - * - > 500 lines: warning, with the message pointing at the splitting guidance in. - * - * > CLAUDE.md. - * - * - > 1000 lines: error. No autofix — splitting requires judgment about where. - * - * > the. natural seams are. The rule's job is to make the cap visible at every - * > commit. Allowed exceptions: - * - * - Files marked at the top with a comment containing `max-file-lines: - * legitimate parser/state-machine/table` or `eslint-disable - * socket/max-file-lines`. Per CLAUDE.md the rare legitimate cases are - * parsers, state machines, and config tables; they should self-document - * with a one-line comment. - * - Generated artifacts — the rule trusts .config/oxlintrc.json's - * ignorePatterns to keep generated files out of scope. - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const SOFT_CAP = 500 -const HARD_CAP = 1000 - -const BYPASS_RE = - /max-file-lines:\s*(?:legitimate|parser|state[- ]?machine|table)/i - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Files have a soft cap of 500 lines (warn) and a hard cap of 1000 lines (error). Split along natural seams.', - category: 'Best Practices', - recommended: true, - }, - messages: { - soft: '{{lines}} lines — past the 500-line soft cap. Consider splitting along natural seams (one tool / domain / phase per file). See CLAUDE.md "File size".', - hard: '{{lines}} lines — past the 1000-line hard cap. Split this file. See CLAUDE.md "File size".', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - return { - Program(node: AstNode) { - // Trust the parser's location info — `loc.end.line` is the - // 1-indexed line of the last token. Empty trailing lines are - // counted as part of the source per the line-counting - // convention CLAUDE.md uses. - const lines = node.loc.end.line - - if (lines <= SOFT_CAP) { - return - } - - // Bypass detection — scan leading comments only. A bypass - // comment buried 600 lines deep doesn't communicate intent at - // the file level. - const leadingComments = sourceCode - .getAllComments() - .filter((c: AstNode) => c.loc.start.line <= 5) - for (let i = 0, { length } = leadingComments; i < length; i += 1) { - const c = leadingComments[i]! - if (BYPASS_RE.test(c.value)) { - return - } - } - - const messageId = lines > HARD_CAP ? 'hard' : 'soft' - // Anchor the report at line 1 — the file as a whole is the - // problem, not any specific node. - context.report({ - loc: { line: 1, column: 0 }, - messageId, - data: { lines: String(lines) }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-bare-crypto-named-usage.mts b/.config/oxlint-plugin/rules/no-bare-crypto-named-usage.mts deleted file mode 100644 index 0b191c9a6..000000000 --- a/.config/oxlint-plugin/rules/no-bare-crypto-named-usage.mts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @file Per fleet style: `import crypto from 'node:crypto'` is the canonical - * default form (enforced by `prefer-node-builtin-imports`). When a file has - * the default import, bare references to named exports (`createHash`, - * `randomBytes`, etc.) are undefined identifiers — `ReferenceError` at - * runtime. This rule catches the half-converted state that - * `prefer-node-builtin-imports` leaves behind when it rewrites the import but - * not the call sites. Detects bare references to known `node:crypto` named - * exports in a file that imports `crypto` with the default form (`import - * crypto from 'node:crypto'`). Autofix: rewrites `createHash(` → - * `crypto.createHash(`, etc. Skipped: files that don't import `node:crypto` - * at all, files that use the named-import form (`import { createHash } from - * 'node:crypto'`) — those are caught by `prefer-node-builtin-imports`. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -// Stable subset of node:crypto named exports we want to catch. Add more as -// fleet usage grows; missing entries are silent rather than wrong. -const CRYPTO_NAMED_EXPORTS = new Set([ - 'createCipher', - 'createCipheriv', - 'createDecipher', - 'createDecipheriv', - 'createDiffieHellman', - 'createECDH', - 'createHash', - 'createHmac', - 'createPrivateKey', - 'createPublicKey', - 'createSecretKey', - 'createSign', - 'createVerify', - 'diffieHellman', - 'generateKeyPair', - 'generateKeyPairSync', - 'getCiphers', - 'getCurves', - 'getDiffieHellman', - 'getHashes', - 'hash', - 'hkdf', - 'hkdfSync', - 'pbkdf2', - 'pbkdf2Sync', - 'privateDecrypt', - 'privateEncrypt', - 'publicDecrypt', - 'publicEncrypt', - 'randomBytes', - 'randomFillSync', - 'randomInt', - 'randomUUID', - 'scrypt', - 'scryptSync', - 'sign', - 'subtle', - 'timingSafeEqual', - 'verify', - 'webcrypto', -]) - -/** - * Collect the names bound by a single statement-list element (a declaration). - * Covers the forms that can shadow a crypto export name in practice: `const` / - * `let` / `var` declarators (incl. simple destructuring), function + class - * declarations. Not exhaustive ESTree binding analysis — just enough to tell a - * local variable named `hash` apart from a bare `node:crypto` export - * reference. - */ -export function collectDeclaredNames(stmt: AstNode, out: Set<string>): void { - if (!stmt || typeof stmt.type !== 'string') { - return - } - if (stmt.type === 'VariableDeclaration') { - const decls = Array.isArray(stmt.declarations) ? stmt.declarations : [] - for (let i = 0, { length } = decls; i < length; i += 1) { - const id = decls[i]?.id - if (id?.type === 'Identifier' && typeof id.name === 'string') { - out.add(id.name) - } else if (id?.type === 'ObjectPattern') { - const props = Array.isArray(id.properties) ? id.properties : [] - for (let j = 0, plen = props.length; j < plen; j += 1) { - const val = props[j]?.value - if (val?.type === 'Identifier' && typeof val.name === 'string') { - out.add(val.name) - } - } - } else if (id?.type === 'ArrayPattern') { - const els = Array.isArray(id.elements) ? id.elements : [] - for (let j = 0, elen = els.length; j < elen; j += 1) { - const el = els[j] - if (el?.type === 'Identifier' && typeof el.name === 'string') { - out.add(el.name) - } - } - } - } - return - } - if ( - (stmt.type === 'ClassDeclaration' || stmt.type === 'FunctionDeclaration') && - stmt.id?.type === 'Identifier' && - typeof stmt.id.name === 'string' - ) { - out.add(stmt.id.name) - } -} - -/** - * Add the parameter names of a function-like node to `out`. Handles plain - * identifier params and the common `{ a }` / `[a]` / `a = default` / `...rest` - * wrappers — enough to recognize a param shadowing a crypto export name. - */ -export function collectParamNames(fn: AstNode, out: Set<string>): void { - const params = Array.isArray(fn?.params) ? fn.params : [] - for (let i = 0, { length } = params; i < length; i += 1) { - let p = params[i] - if (p?.type === 'AssignmentPattern') { - p = p.left - } - if (p?.type === 'RestElement') { - p = p.argument - } - if (p?.type === 'Identifier' && typeof p.name === 'string') { - out.add(p.name) - } - } -} - -/** - * Walk the ancestor chain from `node` and return true if `name` resolves to a - * binding declared in an enclosing scope (a local variable, function/class - * name, or function parameter) rather than to the bare `node:crypto` export. - * This is what stops the rule flagging a `const hash = ...; hash.update()` - * local as if `hash` were the crypto `hash` export. - */ -export function resolvesToLocalBinding(node: AstNode, name: string): boolean { - let current: AstNode = node - while (current) { - const parent: AstNode = current.parent - if (!parent) { - break - } - // Block / program / module scope: scan sibling statements for a binding. - if ( - parent.type === 'BlockStatement' || - parent.type === 'Program' || - parent.type === 'StaticBlock' - ) { - const body = Array.isArray(parent.body) ? parent.body : [] - const declared = new Set<string>() - for (let i = 0, { length } = body; i < length; i += 1) { - collectDeclaredNames(body[i], declared) - } - if (declared.has(name)) { - return true - } - } - // Function scope: its params bind names for the whole body. - if ( - parent.type === 'ArrowFunctionExpression' || - parent.type === 'FunctionDeclaration' || - parent.type === 'FunctionExpression' - ) { - const declared = new Set<string>() - collectParamNames(parent, declared) - if (declared.has(name)) { - return true - } - } - current = parent - } - return false -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - "Bare reference to a node:crypto named export with `import crypto from 'node:crypto'` in scope — runtime ReferenceError. Use `crypto.<name>(...)`.", - category: 'Possible Errors', - recommended: true, - }, - fixable: 'code', - messages: { - bareNamed: - '`{{name}}` is a node:crypto named export but the file imports `crypto` as a default. Either reference as `crypto.{{name}}` (fleet style; auto-fixable) or change the import to a named form.', - }, - schema: [], - }, - - create(context: RuleContext) { - let hasDefaultCryptoImport = false - - return { - ImportDeclaration(node: AstNode) { - if ( - (node as { source?: { value?: string | undefined } | undefined }) - .source?.value !== 'node:crypto' - ) { - return - } - const specs = - (node as { specifiers?: AstNode[] | undefined }).specifiers ?? [] - for (let i = 0, { length } = specs; i < length; i += 1) { - const spec = specs[i]! - if ( - spec.type === 'ImportDefaultSpecifier' && - (spec as { local?: { name?: string | undefined } | undefined }) - .local?.name === 'crypto' - ) { - hasDefaultCryptoImport = true - return - } - } - }, - Identifier(node: AstNode) { - if (!hasDefaultCryptoImport) { - return - } - const name = (node as { name?: string | undefined }).name - if (!name || !CRYPTO_NAMED_EXPORTS.has(name)) { - return - } - const parent = (node as unknown as { parent?: AstNode | undefined }) - .parent - if (!parent) { - return - } - if (parent.type === 'ImportSpecifier') { - return - } - if ( - parent.type === 'MemberExpression' && - (parent as { property?: AstNode | undefined }).property === node && - !(parent as { computed?: boolean | undefined }).computed - ) { - return - } - if ( - parent.type === 'Property' && - (parent as { key?: AstNode | undefined }).key === node && - !(parent as { computed?: boolean | undefined }).computed - ) { - return - } - if ( - parent.type === 'VariableDeclarator' && - (parent as { id?: AstNode | undefined }).id === node - ) { - return - } - if ( - (parent.type === 'ArrowFunctionExpression' || - parent.type === 'FunctionDeclaration' || - parent.type === 'FunctionExpression') && - Array.isArray( - (parent as { params?: AstNode[] | undefined }).params, - ) && - (parent as { params: AstNode[] }).params.includes(node) - ) { - return - } - // A local variable / param / function named like a crypto export (e.g. - // `const hash = crypto.createHash(...); hash.update(...)`) is a - // reference to that binding, not a bare export — don't flag or rewrite. - if (resolvesToLocalBinding(node, name)) { - return - } - context.report({ - node, - messageId: 'bareNamed', - data: { name }, - fix(fixer: RuleFixer) { - return fixer.replaceText(node, `crypto.${name}`) - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-cached-for-on-iterable.mts b/.config/oxlint-plugin/rules/no-cached-for-on-iterable.mts deleted file mode 100644 index 0c6fb3ae3..000000000 --- a/.config/oxlint-plugin/rules/no-cached-for-on-iterable.mts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @file Catch the silent-no-op bug where the fleet's canonical cached-length - * `for` loop is applied to a Set / Map / Iterable instead of an array. The - * bug shape: const s: Set<string> = new Set() … for (let i = 0, { length } = - * s; i < length; i += 1) { const item = s[i]! // s isn't indexable; type is - * undefined … // body never runs (length is undefined) } `Set` / `Map` / - * `WeakSet` / `WeakMap` / generic `Iterable` don't expose `.length`, and - * `s[i]` isn't a defined access either. The destructure `{ length } = s` - * reads `s.length === undefined`, the test `i < undefined` is `false`, and - * the loop body never executes. No type error, no runtime error — the - * iteration just silently does nothing. Production code shipped with this - * pattern across 4 files in socket-wheelhouse before the fleet hand-fix; this - * rule blocks regression. Why it happens: the fleet's - * `socket/prefer-cached-for-loop` rule rewrites array `.forEach` and array - * `for...of` into the cached- length shape. Devs then apply the same shape by - * hand to Set / Map iteration without remembering that those collections - * aren't integer-indexable. Detection (no TypeScript type-checker available - * in the plugin): - * - * 1. Walk every `VariableDeclarator` and `Parameter` in scope to build a - * per-file map `identifierName -> kind` where `kind` ∈ {set, map, - * iterable, array, unknown}. Recognized signals: - * - * - `new Set(...)` / `new Map(...)` / `new WeakSet(...)` / `new WeakMap(...)` → - * set/map kind - * - `: Set<...>` / `: ReadonlySet<...>` / `: Map<...>` / `: ReadonlyMap<...>` / - * `: WeakSet<...>` / `: WeakMap<...>` annotations → set/map kind - * - `: Iterable<...>` / `: AsyncIterable<...>` annotations → iterable kind - * - `[…]` array literal / `: T[]` / `: Array<...>` / `: ReadonlyArray<...>` → - * array kind (negative — do NOT flag) - * - everything else → unknown kind (skip) - * - * 2. On `ForStatement`, inspect the `init` for the canonical shape: let i = 0, { - * length } = X i.e. `VariableDeclaration` with ≥ 2 declarators, the second - * of which has an `ObjectPattern` LHS with a single `length` property and - * an `Identifier` RHS `X`. Look up `X` in the scope map — if it resolves - * to `set` / `map` / `iterable`, report. False-negative bias on purpose: - * when the kind is `unknown` we skip silently. Better to miss a bug than - * to nag every cached-for loop in the codebase. The 4 fleet incidents that - * motivated the rule all had a clear `new Set(...)` / `: Set<T>` - * annotation in scope; the high-signal cases are the ones we catch. - * Canonical fix: `for (const item of X) { … }`. This is THE fix for sets / - * maps / iterables in this codebase — short, no extra allocation, and - * reads as "iterate the set." Do NOT materialize with `Array.from(X)` just - * to keep the cached-length shape going: that's a workaround, not a fix, - * and it allocates a throwaway array on every call. No autofix: while - * `for...of` is almost always correct, the rule can't safely rewrite when - * the loop body mutates the collection mid-iteration or relies on a frozen - * snapshot. Report-only; the canonical replacement is one line and the - * diagnostic message names it explicitly. - */ - -import { FLAGGED_KINDS, createKindResolver } from '../lib/iterable-kind.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -/** - * The cached-for-loop init shape we're looking for: - * - * Let i = 0, { length } = X. - * - * Returns the identifier `X` if the shape matches and `X` is a bare Identifier, - * otherwise undefined. - */ -function matchCachedForInit(init: AstNode | undefined): string | undefined { - if (!init || init.type !== 'VariableDeclaration') { - return undefined - } - const decls = init.declarations - if (!decls || decls.length < 2) { - return undefined - } - // The `{ length } = X` declarator. Could be at any position after - // the counter, but the canonical fleet shape puts it second. - for (let i = 0, { length: declsLen } = decls; i < declsLen; i += 1) { - const d = decls[i] - if ( - d.id && - d.id.type === 'ObjectPattern' && - d.id.properties && - d.id.properties.length === 1 && - d.id.properties[0].type === 'Property' && - d.id.properties[0].key && - d.id.properties[0].key.type === 'Identifier' && - d.id.properties[0].key.name === 'length' && - d.init && - d.init.type === 'Identifier' - ) { - return d.init.name as string - } - } - return undefined -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - "Don't apply the cached-length `for (let i = 0, { length } = X; …)` pattern to Sets, Maps, or generic Iterables — it silently no-ops (X has no `.length` and isn't integer-indexable).", - category: 'Correctness', - recommended: true, - }, - fixable: undefined, - messages: { - noCachedForOnIterable: - '`{{name}}` is a {{kind}} — cached-length `for` is a silent no-op (no `.length`, not integer-indexable). Use `for (const item of {{name}}) { … }` instead. (Do NOT materialize with `Array.from({{name}})` just to keep the cached-length shape — that adds a wasted allocation. `for...of` is the canonical fix for sets / maps / iterables.)', - lengthOnIterable: - '`{{name}}.length` reads `undefined` — {{kind}} has `.size`, not `.length`. Either rename to `.size`, or convert `{{name}}` to an array first if the semantics demand `.length`.', - indexedAccessOnIterable: - "`{{name}}[…]` returns `undefined` — {{kind}} isn't integer-indexable. Use `for (const item of {{name}})` (or one of the entries / keys / values iterators) to read elements.", - }, - schema: [], - }, - - create(context: RuleContext) { - // Scope-aware kind resolver. Shared with prefer-cached-for-loop - // via lib/iterable-kind.mts so both rules agree on what "this - // binding is a Set/Map/Iterable" means — including under - // shadowing (a function-local `const closure = new Map()` - // does NOT taint an outer-scope `const closure = await fn()` - // array binding). - const resolveKind = createKindResolver() - - // Track ForStatements that already fired `noCachedForOnIterable` - // for a given iterable name. When a MemberExpression visitor - // later sees `iterName[i]` or `iterName.length` *inside* one - // of these loops, we suppress the secondary finding — the - // single root cause (the loop shape) is already reported, and - // emitting both findings creates one noise-per-iteration of - // body access for the user to ignore. The body fix follows - // from fixing the loop, so the secondary report is redundant. - // - // Keyed by the ForStatement AST node identity (Map<AstNode, - // string>); lookup walks the use-site's parent chain to find - // an enclosing flagged loop with the matching iterName. - const flaggedLoops = new Map<AstNode, string>() - - // Check whether `useNode` is nested inside a ForStatement that - // was reported with `iterName`. Walks the parent chain. - function insideFlaggedLoopFor(useNode: AstNode, iterName: string): boolean { - let cur: AstNode | undefined = useNode.parent - while (cur) { - if (cur.type === 'ForStatement') { - const flaggedName = flaggedLoops.get(cur) - if (flaggedName === iterName) { - return true - } - } - cur = cur.parent - } - return false - } - - return { - ForStatement(node: AstNode) { - const iterName = matchCachedForInit(node.init) - if (!iterName) { - return - } - const kind = resolveKind(node, iterName) - if (!FLAGGED_KINDS.has(kind)) { - return - } - flaggedLoops.set(node, iterName) - context.report({ - node: node.init, - messageId: 'noCachedForOnIterable', - data: { name: iterName, kind }, - }) - }, - MemberExpression(node: AstNode) { - // Only flag when the object is a bare Identifier resolving - // to a known Set/Map/Iterable. Anything else (member chain, - // call result) is too noisy without type info. - if (!node.object || node.object.type !== 'Identifier') { - return - } - const name = node.object.name as string - const kind = resolveKind(node, name) - if (!FLAGGED_KINDS.has(kind)) { - return - } - // Suppress when inside an enclosing flagged for-loop that - // matched this same iterable name — the cached-for finding - // already covers the root cause; the body access is just - // a downstream symptom that gets fixed by fixing the loop. - // - // NOTE: this depends on visitor order. Oxlint walks the - // tree top-down, so the enclosing ForStatement is visited - // before its body's MemberExpressions. The flaggedLoops - // map is populated in time for the body's lookups. If a - // future oxlint version changes traversal order, this - // suppression becomes a no-op (we'd dual-fire again, which - // is the current noisy behavior — not a correctness - // regression). - if (insideFlaggedLoopFor(node, name)) { - return - } - // `setVar.length` — direct property read; always undefined. - // Skip when used as the LHS of an assignment (extremely - // unlikely on a Set but cheap to be safe) or when used - // inside a member chain we can't reason about. - if ( - !node.computed && - node.property && - node.property.type === 'Identifier' && - node.property.name === 'length' - ) { - // Skip the destructure shape `{ length } = setVar` — that's - // the for-loop init the ForStatement visitor already - // reports on, so we'd double-fire here. The destructure's - // member access doesn't go through MemberExpression in any - // oxlint version we've seen, but cover it defensively. - if ( - node.parent && - node.parent.type === 'AssignmentPattern' && - node.parent.left === node - ) { - return - } - context.report({ - node, - messageId: 'lengthOnIterable', - data: { name, kind }, - }) - return - } - // `setVar[<idx>]` — computed property access. Restrict to - // shapes where the index looks numeric (number literal, - // Identifier counter — `i` / `j` / `index`). A bare - // `setVar[someKey]` could be a Map-key lookup misshaping a - // get(), so be conservative: only flag when the surface - // strongly suggests array-style indexed read. - if (node.computed && node.property) { - const p = node.property - const looksNumeric = - (p.type === 'Literal' && typeof p.value === 'number') || - (p.type === 'NumericLiteral' && typeof p.value === 'number') || - (p.type === 'Identifier' && - typeof p.name === 'string' && - /^(cur|cursor|i|idx|index|j|k|n|pos)$/.test(p.name)) - if (looksNumeric) { - context.report({ - node, - messageId: 'indexedAccessOnIterable', - data: { name, kind }, - }) - } - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-console-prefer-logger.mts b/.config/oxlint-plugin/rules/no-console-prefer-logger.mts deleted file mode 100644 index 612fbed0c..000000000 --- a/.config/oxlint-plugin/rules/no-console-prefer-logger.mts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @file Ban `console.log` / `console.error` / `console.warn` / `console.info` / - * `console.debug` / `console.trace`. The fleet uses `getDefaultLogger()` from - * `@socketsecurity/lib-stable/logger/default` — those methods emit - * theme-aware coloring + canonical symbols. Autofix: rewrites - * `console.<method>(...)` → `logger.<loggerMethod>(...)` AND inserts the - * missing pieces in one go: - * - * 1. `import { getDefaultLogger } from - * '@socketsecurity/lib-stable/logger/default'` — appended after the last - * existing top-level import (or at the top of the file if there are - * none). - * 2. `const logger = getDefaultLogger()` — appended after the import block (so - * `logger` is hoisted at module scope). Each `console.<method>(...)` call - * site emits its own fix independently. ESLint's autofixer dedupes - * overlapping inserts (the import line + hoist), so the visit order is - * irrelevant. - */ - -import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const CONSOLE_TO_LOGGER = { - debug: 'log', - error: 'fail', - info: 'info', - log: 'log', - trace: 'log', - warn: 'warn', -} - -const LOGGER_IMPORT_LINE = - "import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'" -const LOGGER_HOIST_LINE = 'const logger = getDefaultLogger()' - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban console.* calls; use logger from @socketsecurity/lib-stable/logger/default.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - banned: - 'console.{{method}}() — use logger.{{loggerMethod}}() from @socketsecurity/lib-stable/logger/default.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - let summary: ReturnType<typeof summarizeImportTarget> | undefined - - function ensureSummary() { - if (summary) { - return summary - } - summary = summarizeImportTarget( - sourceCode.ast, - '@socketsecurity/lib-stable/logger/default', - 'getDefaultLogger', - 'logger', - ) - return summary - } - - return { - MemberExpression(node: AstNode) { - if ( - node.object.type !== 'Identifier' || - node.object.name !== 'console' || - node.property.type !== 'Identifier' - ) { - return - } - const method = node.property.name - const loggerMethod = (CONSOLE_TO_LOGGER as Record<string, string>)[ - method - ] - if (!loggerMethod) { - return - } - - // Only flag when console.<method> is the callee of a call - // (skip e.g. `typeof console.log` or destructuring). - const parent = node.parent - if ( - !parent || - parent.type !== 'CallExpression' || - parent.callee !== node - ) { - return - } - - const s = ensureSummary() - - context.report({ - node, - messageId: 'banned', - data: { method, loggerMethod }, - fix(fixer: RuleFixer) { - return [ - fixer.replaceText(node, `logger.${loggerMethod}`), - ...appendImportFixes( - s, - fixer, - LOGGER_IMPORT_LINE, - LOGGER_HOIST_LINE, - ), - ] - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-default-export.mts b/.config/oxlint-plugin/rules/no-default-export.mts deleted file mode 100644 index 37e913da3..000000000 --- a/.config/oxlint-plugin/rules/no-default-export.mts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @file Forbid `export default` — fleet convention is named exports only. - * Default exports lose the name at the import site (`import x from 'mod'` - * lets the caller rename freely), defeat grep / "find references" tools, and - * don't compose with re-exports (`export * from 'mod'` skips the default). - * Style signal that motivated the rule: across socket-sdk-js, socket-cli, - * socket-packageurl-js, socket-sdxgen, socket-lib, and socket-stuie, the - * named-vs-default ratio is essentially 100-to-1 — socket-lib has zero - * `export default` statements, the other repos have a handful of stragglers - * each. Autofix scope: - * - * - `export default function foo() {}` → `export function foo() {}` - * - `export default class Foo {}` → `export class Foo {}` - * - `export default <identifier>` (separate-declaration form) → `export { - * <identifier> }` Skips (report-only, no fix): - * - `export default function () {}` / `export default class {}` — anonymous - * declarations, no canonical name to assign. - * - `export default <expression>` where the expression isn't a bare identifier - * (e.g. `export default { foo: 1 }`, `export default makePlugin(...)`) — - * choosing a name requires human input. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Forbid `export default` — use named exports so the export name is stable across import sites.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - noDefaultExport: - 'Avoid `export default` — use a named export so the export name is stable across imports, greppable, and composable with `export * from`.', - noDefaultExportNoFix: - 'Avoid `export default` — the default-exported value is anonymous or a complex expression. Give it a name and switch to `export { <name> }`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - return { - ExportDefaultDeclaration(node: AstNode) { - const decl = node.declaration - if (!decl) { - return - } - - // `export default function name() {}` / - // `export default class Name {}` — drop the `default` keyword - // and emit the declaration as a named export. - if ( - (decl.type === 'ClassDeclaration' || - decl.type === 'FunctionDeclaration') && - decl.id && - decl.id.type === 'Identifier' - ) { - context.report({ - node, - messageId: 'noDefaultExport', - fix(fixer: RuleFixer) { - const declText = sourceCode.getText(decl) - return fixer.replaceText(node, `export ${declText}`) - }, - }) - return - } - - // `export default someIdentifier` — rewrite to - // `export { someIdentifier }`. Only safe when the identifier - // is declared in the same module; we don't try to verify that - // here because the import side will fail loudly if not, and - // the autofix never strips a declaration. - if (decl.type === 'Identifier') { - context.report({ - node, - messageId: 'noDefaultExport', - fix(fixer: RuleFixer) { - return fixer.replaceText(node, `export { ${decl.name} }`) - }, - }) - return - } - - // Anonymous declaration or complex expression — report without - // a fix; the human needs to choose a name. - context.report({ - node, - messageId: 'noDefaultExportNoFix', - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-dynamic-import-outside-bundle.mts b/.config/oxlint-plugin/rules/no-dynamic-import-outside-bundle.mts deleted file mode 100644 index 8c1f3544e..000000000 --- a/.config/oxlint-plugin/rules/no-dynamic-import-outside-bundle.mts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file Ban dynamic `import()` (ImportExpression) in code that isn't bundled. - * The fleet favors static ES6 imports — dynamic import is only meaningful - * when a bundler resolves it statically at build time. Scripts under - * `scripts/` run directly via `node`; nothing bundles them, so a dynamic - * import only adds a runtime async hop for no resolution win. Allowed paths: - * `src/**`, `.config/**` (bundler configs themselves may load tools - * dynamically via the bundler's API). No autofix: converting `await - * import('foo')` to `import 'foo'` requires moving the statement to the top - * of the file and removing `await`/destructuring — the bundler-aware AST - * rewrite is non-trivial to do safely. Reporting only. - */ - -import path from 'node:path' - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const DEFAULT_BUNDLED_ROOTS = ['src/', '.config/', 'packages/'] - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban dynamic import() outside bundled trees (src/, .config/, packages/).', - category: 'Best Practices', - recommended: true, - }, - messages: { - dynamic: - 'Dynamic import() in {{file}} — favor a static `import` statement at the top of the file. Dynamic import is only valid in bundled code (src/, .config/, packages/). If lazy resolution is required, justify it explicitly.', - }, - schema: [ - { - type: 'object', - properties: { - bundledRoots: { - type: 'array', - items: { type: 'string' }, - description: - 'Path prefixes (relative to repo root) where dynamic import() is allowed.', - }, - }, - additionalProperties: false, - }, - ], - }, - - create(context: RuleContext) { - const options = context.options[0] || {} - const bundledRoots = options.bundledRoots || DEFAULT_BUNDLED_ROOTS - const filename = context.physicalFilename || context.filename - const cwd = context.cwd || process.cwd() - const relative = path.relative(cwd, filename).split(path.sep).join('/') - - const inBundled = bundledRoots.some((root: string) => - relative.startsWith(root), - ) - - if (inBundled) { - return {} - } - - return { - ImportExpression(node: AstNode) { - context.report({ - node, - messageId: 'dynamic', - data: { file: relative }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts b/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts deleted file mode 100644 index 47a978a6d..000000000 --- a/.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @file Per fleet "Code style" rule: the fleet has migrated to oxlint / oxfmt. - * References to `.eslintrc`, `eslint-config-*`, `biome.json`, or `@biomejs/*` - * in scripts / package.json / docs are stale — they'd mis-fire (point at a - * config that doesn't exist) or signal an incomplete migration. Detects: - * string literals naming the legacy configs / packages. The rule fires on - * TS/JS source — package.json + workflow YAML are caught by other tooling - * (the SBOM / dep scanners flag the package refs at install time). No - * autofix: the right replacement varies (drop the line, swap to - * `oxlint`/`oxfmt`, or rewrite a script invocation). Reporting only. - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import { isPluginSelfFile } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -// socket-hook: allow eslint-biome-ref -- opt-out for a string that names a -// legacy tool as DATA (e.g. an allowlist of popular package names), not as a -// stale config reference. -const BYPASS_RE = /socket-hook:\s*allow\s+eslint-biome-ref/ - -const FORBIDDEN_REFS = [ - '.eslintrc', - '.eslintrc.js', - '.eslintrc.json', - '.eslintrc.cjs', - '.eslintrc.yml', - '.eslintrc.yaml', - 'eslint.config.js', - 'eslint.config.mjs', - 'eslint.config.cjs', - 'biome.json', - 'biome.jsonc', -] - -// Package names. Match prefixes for scoped families. -const FORBIDDEN_PACKAGE_RES = [ - /^eslint(?:-|$)/, - /^@eslint\//, - /^@biomejs\//, - /^biome$/, -] - -function isForbiddenString(s: string): string | undefined { - if (FORBIDDEN_REFS.includes(s)) { - return s - } - for (let i = 0, { length } = FORBIDDEN_PACKAGE_RES; i < length; i += 1) { - const re = FORBIDDEN_PACKAGE_RES[i]! - if (re.test(s)) { - return s - } - } - return undefined -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'ESLint / Biome config references are stale — the fleet runs oxlint + oxfmt. Drop the reference or swap to the oxlint/oxfmt equivalent.', - category: 'Best Practices', - recommended: true, - }, - messages: { - staleConfig: - '`{{ref}}` is a stale ESLint/Biome reference — the fleet runs oxlint + oxfmt. Drop the line or swap to the oxlint/oxfmt equivalent. (See `template/.config/oxlintrc.json` / `oxfmtrc.json` for the canonical configs.)', - }, - schema: [], - }, - - create(context: RuleContext) { - // This rule's own source lists the banned config names as lookup-table - // data and its test file exercises them as fixtures. - if (isPluginSelfFile(context)) { - return {} - } - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - return { - Literal(node: AstNode) { - const v = (node as { value?: unknown | undefined }).value - if (typeof v !== 'string') { - return - } - const hit = isForbiddenString(v) - if (!hit || hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'staleConfig', - data: { ref: hit }, - }) - }, - TemplateElement(node: AstNode) { - const v = ( - node as { value?: { cooked?: string | undefined } | undefined } - ).value - const cooked = v?.cooked - if (typeof cooked !== 'string') { - return - } - const hit = isForbiddenString(cooked) - if (!hit || hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'staleConfig', - data: { ref: hit }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-fetch-prefer-http-request.mts b/.config/oxlint-plugin/rules/no-fetch-prefer-http-request.mts deleted file mode 100644 index 36365a1b5..000000000 --- a/.config/oxlint-plugin/rules/no-fetch-prefer-http-request.mts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @file Per CLAUDE.md "HTTP — never `fetch()`. Use httpJson / httpText / - * httpRequest from @socketsecurity/lib-stable/http-request." Reports any - * `fetch(...)` call (global fetch). Does NOT auto-fix because the right - * replacement (`httpJson` vs `httpText` vs `httpRequest`) depends on what the - * caller does with the response — a wrong autofix would silently change - * behavior. Reporting only. Allowed exceptions (skipped): - * - * - `globalThis.fetch` — explicit reference (often for monkey-patching in - * tests). - * - Method calls (`obj.fetch(...)`) — those aren't the global. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -// socket-hook: allow global-fetch -- opt-out for a `fetch()` that genuinely -// must use the platform global (e.g. publish / provenance tooling probing a -// registry before the lib http-request helper is available). -const BYPASS_RE = /socket-hook:\s*allow\s+global-fetch/ - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use httpJson / httpText / httpRequest from @socketsecurity/lib-stable/http-request instead of global fetch().', - category: 'Best Practices', - recommended: true, - }, - messages: { - banned: - 'global fetch() — use httpJson / httpText / httpRequest from @socketsecurity/lib-stable/http-request. The right replacement depends on what you do with the response; the lib helpers ship consistent error shapes (HttpError) and JSON/text decoding.', - }, - schema: [], - }, - - create(context: RuleContext) { - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - return { - CallExpression(node: AstNode) { - const callee = node.callee - // Only flag direct `fetch(...)` calls (Identifier callee). - if (callee.type !== 'Identifier' || callee.name !== 'fetch') { - return - } - if (hasBypassComment(node)) { - return - } - - // Skip if `fetch` is locally shadowed by a parameter / declaration. - // Best-effort: check the scope chain. - const scope = context.getScope ? context.getScope() : undefined - if (scope) { - const variable = scope.references.find( - (ref: AstNode) => ref.identifier === callee, - )?.resolved - if (variable && variable.scope.type !== 'global') { - return - } - } - - context.report({ - node, - messageId: 'banned', - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-file-scope-oxlint-disable.mts b/.config/oxlint-plugin/rules/no-file-scope-oxlint-disable.mts deleted file mode 100644 index 1e965001f..000000000 --- a/.config/oxlint-plugin/rules/no-file-scope-oxlint-disable.mts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @file Forbid file-scope `oxlint-disable <rule>` comments — every exemption - * must be justified per call site via `oxlint-disable-next-line <rule> -- - * <reason>`. Why: a file-scope `/* oxlint-disable - * socket/no-console-prefer-logger *\/` block at the top of a file silently - * exempts the entire file from a fleet rule. The exemption applies to lines - * the author never thought about — including future edits — and the reason - * field at the top is easy to forget by the time someone adds a new call - * below. Inline `oxlint-disable-next-line socket/<rule> -- <reason>` forces - * the author to write a fresh justification per call site, which surfaces in - * code review and in `git blame` next to the actual disabled code. Allowed: - * - * - `// oxlint-disable-next-line <rule> -- <reason>` (per call site) - * - `/* oxlint-disable-next-line <rule> *\/` block form, also per call - * - File-scope disable for **plugin-internal rules** where the file itself - * defines the rule and intentionally contains the banned shape as - * lookup-table data (e.g. `no-status-emoji.mts` containing the emoji it - * bans). Matched by file path: any file under - * `.config/oxlint-plugin/rules/` is exempt from this rule. Banned: - * - `/* oxlint-disable <rule> *\/` at file scope (no `-next-line`) - * - `// oxlint-disable <rule>` at file scope (no `-next-line`) - * - Block `oxlint-enable` toggles that come paired with file-scope - * `oxlint-disable` blocks — same anti-pattern. No autofix: the rule reports - * each file-scope disable; the human moves each one to the call site that - * needs it (or removes it if the code can be rewritten to satisfy the - * rule). - */ - -// Path-recognition helpers shared with sibling rules. See -// `../lib/fleet-paths.mts` for the rationale behind each exemption. -import { isPathsModule, isPluginInternalPath } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const FILE_SCOPE_DISABLE_RE = - /^\s*(?:\/\*|\/\/)\s*oxlint-disable(?!-next-line)\s+/ - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Forbid file-scope `oxlint-disable` comments; require `oxlint-disable-next-line` per call site so each exemption is independently justified.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: undefined, - messages: { - fileScopeDisable: - "File-scope `oxlint-disable {{rule}}` silently exempts the whole file from a fleet rule. Move the disable to `oxlint-disable-next-line {{rule}} -- <reason>` on the specific line that needs it. If the entire file legitimately can't comply, the file probably needs a refactor instead.", - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = context.filename ?? context.getFilename?.() ?? '' - if (isPluginInternalPath(filename) || isPathsModule(filename)) { - return {} - } - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - return { - Program(_node: AstNode) { - const comments = - (sourceCode.getAllComments && sourceCode.getAllComments()) || [] - for (let i = 0, { length } = comments; i < length; i += 1) { - const c = comments[i]! - const raw = c.value || '' - // Skip JSDoc blocks. They start with a leading `*` after the - // comment opener (`/**`), which sourceCode preserves as the - // first char of `value`. JSDoc carries documentation prose - // — including examples of the banned shape — not directives. - if (c.type === 'Block' && raw.startsWith('*')) { - continue - } - // sourceCode strips the leading `/*` or `//`; reconstruct so - // the regex sees the directive line as authored. - const reconstructed = `${c.type === 'Block' ? '/*' : '//'}${raw}` - if (!FILE_SCOPE_DISABLE_RE.test(reconstructed)) { - continue - } - const m = /oxlint-disable\s+([^\s*]+(?:\s+[^\s*]+)*)/.exec( - reconstructed, - ) - const ruleName = m && m[1] ? m[1].trim() : '<rule>' - context.report({ - node: c as AstNode, - messageId: 'fileScopeDisable', - data: { rule: ruleName }, - }) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-inline-defer-async.mts b/.config/oxlint-plugin/rules/no-inline-defer-async.mts deleted file mode 100644 index 156207b68..000000000 --- a/.config/oxlint-plugin/rules/no-inline-defer-async.mts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @file Per fleet "Code style" rule: `<script defer>` / `<script async>` on - * inline (no-src) `<script>` tags is a spec no-op — the script runs - * immediately. The author intent (wait for DOMContentLoaded) is silently - * ignored. Past incident: same shape bit a fleet project twice; rendered - * pages went silently broken when the script tried to operate on DOM nodes - * that didn't exist yet. Sibling: `.claude/hooks/inline-script-defer-guard/` - * catches this at edit time. This lint rule catches it at commit time when - * edits happened outside Claude. Detects: string literals (single-quoted, - * double-quoted, or template) containing `<script ...defer...>` or `<script - * ...async...>` lacking `src=`. The rule applies to TS/JS source — HTML / - * template files aren't lint-target by oxlint. Autofix: remove the `defer` / - * `async` attribute. The DOMContentLoaded wrap is a manual fix surfaced in - * the error message. - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import { isPluginSelfFile } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SCRIPT_OPENER_RE = /<script\b([^>]*)>/gi - -// socket-hook: allow inline-defer -- opt-out for a string that contains a -// `<script ...>` snippet as DATA (e.g. a hook's own diagnostic text describing -// the banned shape), not as real inline-script markup. -const BYPASS_RE = /socket-hook:\s*allow\s+inline-defer/ - -interface Match { - /** - * Full matched `<script ...>` opener. - */ - readonly opener: string - /** - * The `defer` or `async` attribute name found. - */ - readonly attr: 'defer' | 'async' - /** - * Offset of the matched opener within the string literal value. - */ - readonly offset: number -} - -function findInlineDeferOrAsync(text: string): Match | undefined { - SCRIPT_OPENER_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = SCRIPT_OPENER_RE.exec(text)) !== null) { - const attrs = m[1] ?? '' - const attrMatch = /\b(async|defer)\b/i.exec(attrs) - if (!attrMatch) { - continue - } - if (/\bsrc\s*=/.test(attrs)) { - continue - } - return { - opener: m[0], - attr: attrMatch[1]!.toLowerCase() as 'defer' | 'async', - offset: m.index, - } - } - return undefined -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - '`<script defer>` / `<script async>` on inline (no-src) scripts is a spec no-op. Wrap in DOMContentLoaded or move to an external file.', - category: 'Possible Errors', - recommended: true, - }, - fixable: 'code', - messages: { - inlineDeferAsync: - '`<script {{attr}}>` lacks `src=` — `{{attr}}` is a no-op on inline scripts (spec says ignore). The script runs IMMEDIATELY, not on DOMContentLoaded. Wrap the body in `document.addEventListener("DOMContentLoaded", () => {...})`, or move to an external file with `<script {{attr}} src="...">`.', - }, - schema: [], - }, - - create(context: RuleContext) { - // The rule's own source + fixtures contain `<script defer>` as data. - if (isPluginSelfFile(context)) { - return {} - } - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - - function checkLiteralText( - node: AstNode, - text: string, - // Start of the inner content (excluding surrounding quote) in the - // source. Used to align the autofix range. - innerStart: number, - ): void { - const found = findInlineDeferOrAsync(text) - if (!found) { - return - } - if (hasBypassComment(node)) { - return - } - - context.report({ - node, - messageId: 'inlineDeferAsync', - data: { attr: found.attr }, - fix(fixer: RuleFixer) { - // Locate the attribute within the source and strip it. - // attribute appears as ` defer` (with leading space) or `defer ` — - // find the simplest occurrence within the opener span and remove - // it + one leading whitespace if present. - const openerStart = innerStart + found.offset - const openerSrcEnd = openerStart + found.opener.length - const openerSrc = sourceCode - .getText() - .slice(openerStart, openerSrcEnd) - const attrRe = new RegExp( - `\\s+${found.attr}\\b|\\b${found.attr}\\s+`, - 'i', - ) - const m = attrRe.exec(openerSrc) - if (!m) { - return undefined - } - const removeStart = openerStart + m.index - const removeEnd = removeStart + m[0].length - return fixer.replaceTextRange([removeStart, removeEnd], '') - }, - }) - } - - return { - Literal(node: AstNode) { - const v = (node as { value?: unknown | undefined }).value - if (typeof v !== 'string') { - return - } - if (!v.includes('<script')) { - return - } - const range = (node as { range?: [number, number] | undefined }).range - if (!range) { - return - } - // Skip the leading quote char. - checkLiteralText(node, v, range[0] + 1) - }, - TemplateElement(node: AstNode) { - const v = ( - node as { - value?: - | { cooked?: string | undefined; raw?: string | undefined } - | undefined - } - ).value - const cooked = v?.cooked ?? v?.raw ?? '' - if (!cooked.includes('<script')) { - return - } - const range = (node as { range?: [number, number] | undefined }).range - if (!range) { - return - } - // TemplateElement range covers the inner cooked text (no quote chars). - checkLiteralText(node, cooked, range[0]) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-inline-logger.mts b/.config/oxlint-plugin/rules/no-inline-logger.mts deleted file mode 100644 index 655092209..000000000 --- a/.config/oxlint-plugin/rules/no-inline-logger.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file Ban inline `getDefaultLogger().<method>(...)`. The logger must be - * hoisted at the top of the file: const logger = getDefaultLogger() ... - * logger.success('...') Inline `getDefaultLogger().success(...)` re-resolves - * the logger on every call and reads inconsistently. The hoisted form is the - * fleet-canonical pattern. Autofix: rewrites `getDefaultLogger().<method>` → - * `logger.<method>` AND inserts the missing pieces in one go: - * - * 1. `import { getDefaultLogger } from - * '@socketsecurity/lib-stable/logger/default'` — appended after the last - * existing top-level import (or at the top of the file if there are - * none). - * 2. `const logger = getDefaultLogger()` — appended after the import block (so - * `logger` is hoisted at module scope). Each inline call site emits its - * own fix independently. ESLint's autofixer dedupes overlapping inserts, - * so multiple violations in the same file collapse the import + hoist into - * a single insertion. - */ - -import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const LOGGER_IMPORT_LINE = - "import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'" -const LOGGER_HOIST_LINE = 'const logger = getDefaultLogger()' - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Hoist getDefaultLogger() to a const at the top of the file; do not call it inline.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - inline: - 'getDefaultLogger() must be hoisted: add `const logger = getDefaultLogger()` near the top of the file and use `logger.{{method}}(...)`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - let summary: ReturnType<typeof summarizeImportTarget> | undefined - - function ensureSummary() { - if (summary) { - return summary - } - summary = summarizeImportTarget( - sourceCode.ast, - '@socketsecurity/lib-stable/logger/default', - 'getDefaultLogger', - 'logger', - ) - return summary - } - - return { - MemberExpression(node: AstNode) { - // Match: getDefaultLogger().<method> - if (node.property.type !== 'Identifier') { - return - } - const obj = node.object - if ( - obj.type !== 'CallExpression' || - obj.callee.type !== 'Identifier' || - obj.callee.name !== 'getDefaultLogger' || - obj.arguments.length !== 0 - ) { - return - } - - const s = ensureSummary() - - context.report({ - node, - messageId: 'inline', - data: { method: node.property.name }, - fix(fixer: RuleFixer) { - // Replace `getDefaultLogger()` (the CallExpression) with - // `logger`. Leaves `.method(...)` intact, so the result is - // `logger.method(...)`. - return [ - fixer.replaceText(obj, 'logger'), - ...appendImportFixes( - s, - fixer, - LOGGER_IMPORT_LINE, - LOGGER_HOIST_LINE, - ), - ] - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-logger-newline-literal.mts b/.config/oxlint-plugin/rules/no-logger-newline-literal.mts deleted file mode 100644 index d4cd61623..000000000 --- a/.config/oxlint-plugin/rules/no-logger-newline-literal.mts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * @file Ban `\n` inside string literals passed to `logger.<method>(...)`. The - * logger's symbol-prefixed methods (`success`, `fail`, `warn`, `info`) own - * the line-leading visual. Embedding `\n` smuggles raw line breaks into a - * single call and makes the output inconsistent with the indentation/grouping - * the logger applies. Canonical rewrite: split the call into two. The blank - * line uses a stream-matched logger call. The message uses a semantic method - * picked from the emoji found in the string (✗/❌ → .fail, ✓/✔/✅ → .success, ⚠ - * → .warn, etc.). The semantic method wins over the original method name — - * `logger.error('\n✗ ...')` becomes `logger.error('')` + - * `logger.fail('...')`. Stream mapping: .log → stdout → blank uses - * logger.log('') .error / .fail / .success / .warn / .info / .step / .substep - * → stderr → blank uses logger.error('') Order: leading \n → blank line - * first, then message trailing \n → message first, then blank line Catches: - * logger.error('\n✗ Build failed:', e) → logger.error('') → - * logger.fail('Build failed:', e) logger.success('✓ Done\n') → - * logger.success('Done') → logger.error('') // .success goes to stderr - * logger.log(`build/${mode}/out\n`) → logger.log(`build/${mode}/out`) → - * logger.log('') // .log goes to stdout Autofix scope: - * - * - Single-string-argument calls with leading or trailing `\n` (the dominant - * shape in scripts): autofix splits into two statements with the correct - * blank-line + semantic methods. - * - Multi-argument calls (label + payload) and embedded `\n` mid-string: no - * autofix. The fix needs author judgment because the original string may - * carry meaningful chars between the emoji and the rest, and the extra args - * change the rewrite shape. The warning text names both the stream-matched - * blank- line method and the emoji-matched semantic method. - */ - -// stderr-bound methods (per Logger#getTargetStream). `log` is the -// only stdout-bound method; everything semantic + `error` go to -// stderr. Blank lines for these use `logger.error('')` so the -// blank-line + message land on the same stream. - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const STDERR_METHODS = new Set([ - 'error', - 'fail', - 'info', - 'progress', - 'skip', - 'step', - 'substep', - 'success', - 'warn', -]) - -// All logger methods the rule checks. Excludes `dir`, `group`, -// `groupEnd`, etc. (no semantic-symbol shape). -const LOGGER_METHODS = new Set([ - 'error', - 'fail', - 'info', - 'log', - 'progress', - 'skip', - 'step', - 'substep', - 'success', - 'warn', -]) - -/* oxlint-disable socket/no-status-emoji -- this rule defines the emoji→method table it scans for. */ -// Mirrors @socketsecurity/lib-stable/logger/default's LOG_SYMBOLS (the table built -// by `symbols-builder.ts`). Each logger method has TWO render -// shapes — the Unicode form (used on terminals with unicode support) -// and the ASCII fallback (used otherwise). Authors hand-rolling a -// prefix may type either, plus closely-related variants: -// -// method Unicode ASCII common author variants -// ─────── ─────── ───── ────────────────────── -// fail ✖ × ✗ ✘ ❌ ❎ ✖️ -// info ℹ i ℹ️ -// progress ∴ :. (rarely typed) -// reason ∴(dim) :.(dim) (rarely typed; same shape as progress) -// skip ↻ @ (rarely typed) -// step → > (rarely typed) -// success ✔ √ ✓ ✅ ☑ ☑️ ✔️ -// warn ⚠ ‼ ⚠️ ❗ ❕ 🚨 ⛔ -// -// Two scan passes: -// -// 1. ANYWHERE — `UNAMBIGUOUS_EMOJI` covers symbols that don't appear -// in normal log prose. The Unicode forms + the visually distinct -// ASCII fallbacks (√ × ‼ :.) — none would naturally show up in -// `logger.log('config loaded\n')`. Match anywhere in the string. -// -// 2. ANCHORED — `AMBIGUOUS_FALLBACK` covers fallbacks that DO appear -// in normal prose: `i` (in any English word), `>` (math/chaining), -// `@` (npm package refs, dirs), `:` (host:port, urls). Only match -// when at the START of the string followed by whitespace — that's -// the prefix shape the logger emits. -// -// Keep this in lockstep with `socket-lib/src/logger/symbols- -// builder.ts` and `socket-wheelhouse/template/.config/oxlint-plugin/ -// rules/no-status-emoji.mts`. -// UNAMBIGUOUS — match anywhere in the string. These shapes don't -// appear in normal log prose. Includes both the Unicode forms + -// distinct emoji variants authors hand-write (✅ ❌ ❗ 🚨 etc.) + -// the visually unique ASCII fallbacks (√, ×, ‼). -const UNAMBIGUOUS_EMOJI = { - // success / check - '✓': 'success', - '✔': 'success', - '✔️': 'success', - '✅': 'success', - '☑': 'success', - '☑️': 'success', - '√': 'success', - // fail / cross - '✗': 'fail', - '✘': 'fail', - '✖': 'fail', - '✖️': 'fail', - '❌': 'fail', - '❎': 'fail', - '×': 'fail', - // warn / caution - '⚠': 'warn', - '⚠️': 'warn', - '❗': 'warn', - '❕': 'warn', - '🚨': 'warn', - '⛔': 'warn', - '‼': 'warn', - // info - ℹ: 'info', - ℹ️: 'info', -} - -// ANCHORED — match only at the start of the string, followed by -// whitespace. These shapes can appear in normal prose mid-string -// ("config → output", "a > b", "log :. info", "step ↻ retry") but -// at the prefix position they're status symbols. Mirrors how -// socket-lib's `stripLoggerSymbols` only strips at `^`. -const ANCHORED_FALLBACK = { - '→': 'step', - '>': 'step', - '∴': 'progress', - ':.': 'progress', - '↻': 'skip', - '@': 'skip', - i: 'info', -} - -const ANCHORED_FALLBACK_PREFIX_RE = new RegExp( - `^(${Object.keys(ANCHORED_FALLBACK) - .map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|')})\\s`, -) -/* oxlint-enable socket/no-status-emoji */ - -const UNAMBIGUOUS_LIST = Object.keys(UNAMBIGUOUS_EMOJI) - -/** - * Return the first known status emoji + its method, or undefined. - * - * Two passes: unambiguous shapes match anywhere in the string; - * ANCHORED_FALLBACK shapes only match at the start followed by whitespace. - */ -export function findStatusEmoji( - value: string, -): { emoji: string; method: string | undefined } | undefined { - // Strip a single leading whitespace burst (\n / spaces) so the - // anchored scan sees the visible-character start. This is how the - // logger renders too — `\n` then symbol then space. - const trimmed = value.replace(/^[\n\r\t ]+/, '') - - const anchored = ANCHORED_FALLBACK_PREFIX_RE.exec(trimmed) - if (anchored && anchored[1]) { - return { - emoji: anchored[1], - method: (ANCHORED_FALLBACK as Record<string, string>)[anchored[1]], - } - } - - for (let i = 0, { length } = UNAMBIGUOUS_LIST; i < length; i += 1) { - const emoji = UNAMBIGUOUS_LIST[i]! - if (value.includes(emoji)) { - return { - emoji, - method: (UNAMBIGUOUS_EMOJI as Record<string, string>)[emoji], - } - } - } - return undefined -} - -/** - * Return the blank-line logger call for a given message method. - */ -export function blankCallFor(method: string): string { - return STDERR_METHODS.has(method) ? "logger.error('')" : "logger.log('')" -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban \\n in string literals passed to logger.<method>(); split into a stream-matched blank-line call + an emoji-matched semantic call.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - leadingNewline: - "String literal passed to logger.{{origMethod}}() starts with \\n. Replace with {{blankCall}} then logger.{{semanticMethod}}('...') (emoji {{emoji}} → .{{semanticMethod}}).", - leadingNewlineNoEmoji: - "String literal passed to logger.{{origMethod}}() starts with \\n. Replace with {{blankCall}} then logger.{{origMethod}}('...').", - trailingNewline: - "String literal passed to logger.{{origMethod}}() ends with \\n. Replace with logger.{{semanticMethod}}('...') then {{blankCall}} (emoji {{emoji}} → .{{semanticMethod}}).", - trailingNewlineNoEmoji: - "String literal passed to logger.{{origMethod}}() ends with \\n. Replace with logger.{{origMethod}}('...') then {{blankCall}}.", - embeddedNewline: - 'String literal passed to logger.{{origMethod}}() contains an embedded \\n. Split into multiple logger calls so each line gets the right prefix.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * Walk up from a node to its enclosing ExpressionStatement. Returns - * undefined if the call isn't a top-level statement (e.g. it's inside a - * conditional expression or assignment) — those shapes are too contextual - * to autofix. - */ - function enclosingStatement(node: AstNode): AstNode | undefined { - let cur = node.parent - while (cur) { - if (cur.type === 'ExpressionStatement') { - return cur - } - if ( - cur.type === 'ArrowFunctionExpression' || - cur.type === 'BlockStatement' || - cur.type === 'FunctionDeclaration' || - cur.type === 'FunctionExpression' || - cur.type === 'Program' - ) { - return undefined - } - cur = cur.parent - } - return undefined - } - - /** - * Find the indentation (leading whitespace on its line) of `node`. - */ - function indentOf(node: AstNode): string { - const text = sourceCode.getText() - const start = node.range?.[0] ?? node.start - if (typeof start !== 'number') { - return '' - } - let lineStart = start - while (lineStart > 0 && text[lineStart - 1] !== '\n') { - lineStart -= 1 - } - let i = lineStart - while (i < start && (text[i] === '\t' || text[i] === ' ')) { - i += 1 - } - return text.slice(lineStart, i) - } - - /** - * Quote a string for source output. Uses single quotes by default; if the - * value contains a single quote, falls back to double quotes. - */ - function quoteString(value: string): string { - if (!value.includes("'")) { - return `'${value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')}'` - } - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` - } - - /** - * If `node` is an argument of a call to `logger.<method>(...)`, return that - * method name. Otherwise return undefined. - */ - function loggerMethodForArg(node: AstNode) { - const parent = node.parent - if (!parent || parent.type !== 'CallExpression') { - return undefined - } - if (!parent.arguments.includes(node)) { - return undefined - } - const callee = parent.callee - if (callee.type !== 'MemberExpression') { - return undefined - } - const objectName = - callee.object.type === 'Identifier' ? callee.object.name : undefined - const propName = - callee.property.type === 'Identifier' ? callee.property.name : undefined - if (objectName !== 'logger' || !propName) { - return undefined - } - if (!LOGGER_METHODS.has(propName)) { - return undefined - } - return propName - } - - function classifyNewline(value: string): string | undefined { - if (value.startsWith('\n')) { - return 'leading' - } - if (value.endsWith('\n')) { - return 'trailing' - } - if (value.includes('\n')) { - return 'embedded' - } - return undefined - } - - /** - * Build the report payload for a literal value bound to a - * logger.<origMethod>(...) call. Emits an autofix only when the call is - * `logger.X('<value>')` with exactly one Literal arg, lives in a plain - * ExpressionStatement, and the newline placement is leading or trailing - * (not embedded). Multi-arg + embedded shapes stay unfixed — the rewrite - * needs author judgment. - */ - function reportFor(node: AstNode, value: string, origMethod: string): void { - const placement = classifyNewline(value) - if (!placement) { - return - } - - if (placement === 'embedded') { - context.report({ - node, - messageId: 'embeddedNewline', - data: { origMethod }, - }) - return - } - - const found = findStatusEmoji(value) - const semanticMethod = found?.method - const emoji = found?.emoji - // Stream of the message in the rewrite — semantic method wins - // when there's a status emoji; otherwise stay with the original. - const messageMethod = semanticMethod ?? origMethod - const blankCall = blankCallFor(messageMethod) - - const messageIdSuffix = semanticMethod ? 'Newline' : 'NewlineNoEmoji' - const messageId = `${placement}${messageIdSuffix}` - - // Build an autofix when the shape is safe to rewrite mechanically. - // Requires: node is a plain string Literal (not a template quasi), - // parent is a CallExpression with exactly one argument (this one), - // and the call is the entire statement. - let fixFn: ((fixer: RuleFixer) => unknown) | undefined - const call = node.parent - const stmt = call ? enclosingStatement(call) : undefined - const isPlainStringLiteral = - node.type === 'Literal' && typeof node.value === 'string' - if ( - isPlainStringLiteral && - call && - call.type === 'CallExpression' && - call.arguments.length === 1 && - call.arguments[0] === node && - stmt - ) { - const stripped = - placement === 'leading' - ? value.replace(/^\n+/, '') - : value.replace(/\n+$/, '') - const indent = indentOf(stmt) - const messageCall = `logger.${messageMethod}(${quoteString(stripped)})` - const replacement = - placement === 'leading' - ? `${blankCall}\n${indent}${messageCall}` - : `${messageCall}\n${indent}${blankCall}` - // Replace the call itself (not the surrounding ExpressionStatement) - // so any trailing `;` or comment stays put. - fixFn = (fixer: RuleFixer) => fixer.replaceText(call, replacement) - } - - context.report({ - node, - messageId, - data: { - origMethod, - semanticMethod: semanticMethod ?? origMethod, - emoji: emoji ?? '', - blankCall, - }, - ...(fixFn ? { fix: fixFn } : {}), - }) - } - - return { - Literal(node: AstNode) { - const value = typeof node.value === 'string' ? node.value : undefined - if (!value || !value.includes('\n')) { - return - } - const origMethod = loggerMethodForArg(node) - if (!origMethod) { - return - } - reportFor(node, value, origMethod) - }, - TemplateLiteral(node: AstNode) { - const origMethod = loggerMethodForArg(node) - if (!origMethod) { - return - } - // Identify the first quasi with a newline + classify it. - // Autofix only applies when: - // - It's the FIRST quasi with leading-\n, OR the LAST quasi - // with trailing-\n - // - The call has exactly one argument (this template) - // - The template lives in a plain ExpressionStatement - // Mixed shapes (embedded \n, multiple newlines, non-edge - // quasi) get reported without an autofix. - const firstQuasi = node.quasis[0] - const lastQuasi = node.quasis[node.quasis.length - 1] - const firstCooked = firstQuasi?.value?.cooked - const lastCooked = lastQuasi?.value?.cooked - const call = node.parent - const stmt = call ? enclosingStatement(call) : undefined - const isSingleArgCall = - call && - call.type === 'CallExpression' && - call.arguments.length === 1 && - call.arguments[0] === node && - stmt - let handled = false - if ( - isSingleArgCall && - typeof firstCooked === 'string' && - firstCooked.startsWith('\n') && - // No other newlines anywhere else. - node.quasis.every((q: AstNode, i: number) => { - const c = q.value?.cooked - if (typeof c !== 'string') { - return false - } - if (i === 0) { - return c.lastIndexOf('\n') === 0 - } - return !c.includes('\n') - }) - ) { - handled = true - // Compute fix: replace the call. Rebuild the template body. - const indent = indentOf(stmt) - const src = sourceCode.getText() - const start = node.range?.[0] ?? node.start - const end = node.range?.[1] ?? node.end - if (typeof start === 'number' && typeof end === 'number') { - const originalTpl = src.slice(start, end) - // The original template starts with backtick then the - // raw first-quasi content. Strip the leading newline(s) - // from the source representation to keep escape parity. - const newTpl = - '`' + - originalTpl - .slice(1) - .replace(/^\\?n+/, '') - .replace(/^\n+/, '') - const found = findStatusEmoji(firstCooked) - const semanticMethod = found?.method ?? origMethod - const blankCall = blankCallFor(semanticMethod) - const newCall = `logger.${semanticMethod}(${newTpl})` - const replacement = `${blankCall}\n${indent}${newCall}` - context.report({ - node: firstQuasi, - messageId: found ? 'leadingNewline' : 'leadingNewlineNoEmoji', - data: { - origMethod, - semanticMethod, - emoji: found?.emoji ?? '', - blankCall, - }, - fix(fixer: RuleFixer) { - return fixer.replaceText(call, replacement) - }, - }) - return - } - } - if ( - isSingleArgCall && - !handled && - typeof lastCooked === 'string' && - lastCooked.endsWith('\n') && - node.quasis.every((q: AstNode, i: number, arr: AstNode[]) => { - const c = q.value?.cooked - if (typeof c !== 'string') { - return false - } - if (i === arr.length - 1) { - // Last quasi: only the trailing-\n run is allowed. - const trimmed = c.replace(/\n+$/, '') - return !trimmed.includes('\n') - } - return !c.includes('\n') - }) - ) { - handled = true - const indent = indentOf(stmt) - const src = sourceCode.getText() - const start = node.range?.[0] ?? node.start - const end = node.range?.[1] ?? node.end - if (typeof start === 'number' && typeof end === 'number') { - const originalTpl = src.slice(start, end) - // Strip trailing-newline from the source rep before the - // closing backtick. - const newTpl = - originalTpl.slice(0, -1).replace(/(?:\\n|\n)+$/, '') + '`' - const found = findStatusEmoji(lastCooked) - const semanticMethod = found?.method ?? origMethod - const blankCall = blankCallFor(semanticMethod) - const newCall = `logger.${semanticMethod}(${newTpl})` - const replacement = `${newCall}\n${indent}${blankCall}` - context.report({ - node: lastQuasi, - messageId: found ? 'trailingNewline' : 'trailingNewlineNoEmoji', - data: { - origMethod, - semanticMethod, - emoji: found?.emoji ?? '', - blankCall, - }, - fix(fixer: RuleFixer) { - return fixer.replaceText(call, replacement) - }, - }) - return - } - } - // Fallback: report without fix for shapes we can't safely - // mechanically rewrite (embedded \n, mid-template \n, etc.). - for (const quasi of node.quasis) { - const cooked = quasi.value?.cooked - if (typeof cooked !== 'string' || !cooked.includes('\n')) { - continue - } - reportFor(quasi, cooked, origMethod) - return - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-npx-dlx.mts b/.config/oxlint-plugin/rules/no-npx-dlx.mts deleted file mode 100644 index 6e4638548..000000000 --- a/.config/oxlint-plugin/rules/no-npx-dlx.mts +++ /dev/null @@ -1,197 +0,0 @@ -/* oxlint-disable socket/no-npx-dlx -- this file IS the rule definition; the banned commands are lookup-table data, not real usage. */ - -/** - * @file Per CLAUDE.md "Tooling" rule: 🚨 NEVER use `npx`, `pnpm dlx`, or `yarn - * dlx` — use `pnpm exec <package>` or `pnpm run <script>`. Detects `npx`, - * `pnpm dlx`, `pnx` (the pnpm-11 dlx shorthand), and `yarn dlx` in source - * string literals — argv slices passed to `spawn()`, shell strings, scripts, - * doc snippets, README examples, etc. The hook at `.claude/hooks/path-guard/` - * blocks these at the shell layer; this rule catches them at edit / commit - * time inside JavaScript / TypeScript source. Autofix: rewrites the literal - * in place — `npx foo` → `pnpm exec foo`, `pnpm dlx foo` → `pnpm exec foo`, - * `yarn dlx foo` → `pnpm exec foo`, `pnx foo` → `pnpm exec foo`. Allowed - * exceptions (skipped): - * - * - The literal `npx` inside a comment with `socket-hook: allow npx` — the - * canonical bypass marker, used by the lockdown skill spec. - * - The literal `pnpm dlx` inside a comment justifying a soak-time bypass - * (rare; case-by-case). - * - The CLAUDE.md fleet block reference itself — string literals like `'`pnpm - * dlx`'` documenting the rule. Heuristic: skip when the literal is inside a - * backtick-wrapped phrase in the source text (i.e. the literal value starts - * and ends with a backtick). - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const PATTERNS = [ - // Order matters — longest-prefix first so `pnpm dlx` is matched - // before `pnpm` and `pnx ` is matched before `pnpm`. Each entry - // is [match-prefix, replacement-prefix, label]. - ['pnpm dlx ', 'pnpm exec ', 'pnpm dlx'], - ['yarn dlx ', 'pnpm exec ', 'yarn dlx'], // socket-hook: allow npx - ['npx ', 'pnpm exec ', 'npx'], // socket-hook: allow npx - ['pnx ', 'pnpm exec ', 'pnx'], -] - -const COMMENT_BYPASS_RE = /socket-hook:\s*allow\s+npx/ // socket-hook: allow npx - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use `pnpm exec <package>` instead of `npx` / `pnpm dlx` / `yarn dlx` / `pnx`. Per CLAUDE.md "Tooling" rule.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - banned: - '`{{label}}` — use `pnpm exec` instead. CLAUDE.md "Tooling" rule bans dlx-style commands; they bypass the soak time and fetch packages without lockfile verification.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * Return [matchPrefix, replacementPrefix, label] for the longest dlx-style - * prefix that appears anywhere in the string, or undefined when none match. - * Anchors at word boundaries — `pnxx` doesn't match `pnx`. - */ - function findBannedPrefix( - value: string, - ): [string, string, string, number] | undefined { - for (const [match, repl, label] of PATTERNS) { - if (!match || !repl || !label) { - continue - } - // Word-boundary check: either the match is at the start, or - // the preceding char is non-alphanum (whitespace, punctuation). - let idx = 0 - while ((idx = value.indexOf(match, idx)) !== -1) { - const before = idx === 0 ? ' ' : value[idx - 1]! - if (!/[A-Za-z0-9_-]/.test(before)) { - return [match, repl, label, idx] - } - idx += match.length - } - } - return undefined - } - - /** - * Skip when the surrounding source has the canonical bypass comment - * (`socket-hook: allow npx`) on the same or an adjacent line. - */ - function hasBypassComment(node: AstNode) { - const before = sourceCode.getCommentsBefore(node) - const after = sourceCode.getCommentsAfter(node) - for (const c of [...before, ...after]) { - if (COMMENT_BYPASS_RE.test(c.value)) { - return true - } - } - return false - } - - function checkLiteral(node: AstNode, value: string): void { - const found = findBannedPrefix(value) - if (!found) { - return - } - if (hasBypassComment(node)) { - return - } - const label = found[2] - - context.report({ - node, - messageId: 'banned', - data: { label }, - fix(fixer: RuleFixer) { - // Replace every occurrence in the literal — the literal may - // be a shell pipeline like `npx foo && npx bar`. - let next = value - for (const [m, r] of PATTERNS) { - if (!m || !r) { - continue - } - // Word-boundary aware replace-all. - const parts = next.split(m) - if (parts.length === 1) { - continue - } - // Rejoin only at boundaries; leave embedded matches alone. - let out = parts[0]! - for (let i = 1; i < parts.length; i++) { - const prevChar = out.length === 0 ? ' ' : out[out.length - 1]! - const replacement = /[A-Za-z0-9_-]/.test(prevChar) ? m : r - out += replacement + parts[i] - } - next = out - } - if (next === value) { - // Defensive — if our replace-all became a no-op, don't - // ship an empty fix. - return undefined - } - // Preserve the original quote style. - const raw = sourceCode.getText(node) - const quote = raw[0]! - if (quote === '`') { - // Template literal — only safe to fix if no expressions. - return fixer.replaceText(node, '`' + next + '`') - } - // Plain string — escape the quote char if it appears. - const escaped = next.replace( - new RegExp(`\\\\|${quote}`, 'g'), - (ch: string) => '\\' + ch, - ) - return fixer.replaceText(node, quote + escaped + quote) - }, - }) - } - - return { - Literal(node: AstNode) { - if (typeof node.value !== 'string') { - return - } - checkLiteral(node, node.value) - }, - TemplateLiteral(node: AstNode) { - // Only fix template literals with no expressions — interpolated - // strings can't be safely rewritten by string replace. - if (node.expressions.length !== 0) { - // Still flag — the cooked text might contain `npx`. Report - // without autofix. - for (const q of node.quasis) { - const found = findBannedPrefix(q.value.cooked) - if (found) { - context.report({ - node, - messageId: 'banned', - data: { label: found[2] }, - }) - return - } - } - return - } - const cooked = node.quasis[0].value.cooked - checkLiteral(node, cooked) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-placeholders.mts b/.config/oxlint-plugin/rules/no-placeholders.mts deleted file mode 100644 index e58c0eae3..000000000 --- a/.config/oxlint-plugin/rules/no-placeholders.mts +++ /dev/null @@ -1,267 +0,0 @@ -/* oxlint-disable socket/no-placeholders -- this rule documents the markers it bans. */ -/** - * @file Per CLAUDE.md "Completion" rule: never leave TODO / FIXME / XXX / shims - * / stubs / placeholders. Finish the work 100% or ask before deferring. This - * rule is the commit-time gate for that principle and covers every shape a - * placeholder hides in: - * - * 1. Comment markers — TODO, FIXME, XXX, HACK, TBD, STUB, WIP, UNIMPLEMENTED. - * Word-boundary anchored so identifiers like `todoStore` don't trigger. - * 2. `throw new Error('not implemented')` / `'TODO'` / `'unimplemented'` / - * `'placeholder'` / `'stub'` — the runtime placeholder. - * 3. Stub function bodies — a function whose entire body is empty (`{}`) or - * contains nothing but a placeholder-marker comment. `() => undefined` and - * `() => {}` are flagged when not part of a no-op contract (callbacks - * intentionally suppressed via a docstring `@noop` tag escape). No - * autofix: a placeholder is a deferred decision; auto-removing it leaves - * the underlying gap. The right move is for a human to either implement - * the work or open a tracked issue. Allowed exceptions: - * - * - Marker text inside a string or regex (intentional, e.g. a parser that - * detects TODO comments). Skipped — the rule scopes comment matches to - * comment AST nodes only. - * - Functions that document themselves as intentional no-ops via a leading - * `@noop` JSDoc tag in the immediately preceding comment. - * - Functions whose body is `{ return }` / `{ return undefined }` — not flagged - * unless paired with a placeholder comment. The stub detector requires a - * marker comment in the body. - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const COMMENT_MARKER_RE = /\b(FIXME|HACK|STUB|TBD|TODO|UNIMPLEMENTED|WIP|XXX)\b/ - -const STUB_BODY_MARKER_RE = - /\b(TODO|FIXME|XXX|HACK|TBD|STUB|WIP|UNIMPLEMENTED|not\s+implemented|unimplemented|placeholder|stub)\b/i - -const THROW_MESSAGE_RE = - /\b(TODO|FIXME|not\s+implemented|unimplemented|placeholder|stub)\b/i - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban placeholder code: TODO / FIXME / XXX / HACK / TBD / STUB / WIP / UNIMPLEMENTED markers, `throw new Error("not implemented")`, and empty/stub function bodies. Per CLAUDE.md "Completion" rule — finish the work 100% or open an issue.', - category: 'Best Practices', - recommended: true, - }, - messages: { - commentMarker: - '`{{marker}}` comment — finish the work, open an issue, or ask before deferring. CLAUDE.md "Completion" rule bans deferral markers in source.', - throwPlaceholder: - '`throw new Error({{message}})` is a placeholder — implement the function or remove the stub. CLAUDE.md bans unfinished work.', - stubBody: - 'Function `{{name}}` has a stub body (placeholder comment with no implementation). Finish the function or remove it. Mark intentional no-ops with `@noop` in the leading JSDoc.', - emptyBody: - 'Function `{{name}}` has an empty body and a placeholder marker. Finish the function or remove the marker. Mark intentional no-ops with `@noop` in the leading JSDoc.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * A function counts as "intentionally a no-op" when its leading JSDoc / - * line comment contains `@noop`. This is the documented escape hatch for - * callbacks that genuinely do nothing (e.g. event-handler defaults, test - * spies). - */ - function isExplicitNoop(fnNode: AstNode): boolean { - const leading = sourceCode.getCommentsBefore(fnNode) - for (let i = 0, { length } = leading; i < length; i += 1) { - const c = leading[i]! - if (/@noop\b/.test(c.value)) { - return true - } - } - // For function declarations the comment is attached to the - // declaration; for inline arrows/expressions inside a variable - // declaration the comment is attached to the parent. - const parent = fnNode.parent - if (parent && parent.type === 'VariableDeclarator') { - const declStmt = parent.parent - if (declStmt) { - const above = sourceCode.getCommentsBefore(declStmt) - for (let i = 0, { length } = above; i < length; i += 1) { - const c = above[i]! - if (/@noop\b/.test(c.value)) { - return true - } - } - } - } - return false - } - - function functionDisplayName(fnNode: AstNode): string { - if (fnNode.id && fnNode.id.name) { - return fnNode.id.name - } - const parent = fnNode.parent - if ( - parent && - parent.type === 'VariableDeclarator' && - parent.id && - parent.id.type === 'Identifier' - ) { - return parent.id.name - } - if ( - parent && - parent.type === 'Property' && - parent.key && - parent.key.type === 'Identifier' - ) { - return parent.key.name - } - if ( - parent && - parent.type === 'MethodDefinition' && - parent.key && - parent.key.type === 'Identifier' - ) { - return parent.key.name - } - return '<anonymous>' - } - - function bodyMarkerComment(blockNode: AstNode): AstNode | undefined { - const inner = sourceCode.getCommentsInside - ? sourceCode.getCommentsInside(blockNode) - : [] - for (let i = 0, { length } = inner; i < length; i += 1) { - const c = inner[i]! - if (STUB_BODY_MARKER_RE.test(c.value)) { - return c - } - } - return undefined - } - - function checkFunctionBody(fnNode: AstNode): void { - // Arrow expressions like `() => 42` have a non-block body — - // they're not stubs. - if (!fnNode.body || fnNode.body.type !== 'BlockStatement') { - return - } - if (isExplicitNoop(fnNode)) { - return - } - const block = fnNode.body - const stmts = block.body - const name = functionDisplayName(fnNode) - - // Empty body + a placeholder marker comment somewhere in the - // file pointing at this function. We restrict the marker scan - // to the block's own comments — broader scoping creates false - // positives. - if (stmts.length === 0) { - const marker = bodyMarkerComment(block) - if (marker) { - context.report({ - node: fnNode, - messageId: 'emptyBody', - data: { name }, - }) - } - return - } - - // Body that is just `return` / `return undefined` paired with a - // placeholder marker comment is a stub. A real return-undefined - // function with no marker is allowed (it's just terse). - if (stmts.length === 1) { - const only = stmts[0] - const isBareReturn = - only.type === 'ReturnStatement' && - (!only.argument || - (only.argument.type === 'Identifier' && - only.argument.name === 'undefined') || - (only.argument.type === 'Literal' && only.argument.value === null)) - if (isBareReturn) { - const marker = bodyMarkerComment(block) - if (marker) { - context.report({ - node: fnNode, - messageId: 'stubBody', - data: { name }, - }) - } - } - } - } - - return { - Program() { - const comments = sourceCode.getAllComments() - for (let i = 0, { length } = comments; i < length; i += 1) { - const comment = comments[i]! - const match = COMMENT_MARKER_RE.exec(comment.value) - if (!match) { - continue - } - context.report({ - node: comment, - messageId: 'commentMarker', - data: { marker: match[1] }, - }) - } - }, - - ThrowStatement(node: AstNode) { - // Match `throw new Error(<string>)` where the string mentions - // a placeholder phrase. We skip non-Error throws and - // template-literal throws with interpolations (those usually - // carry real runtime context). - const arg = node.argument - if ( - !arg || - arg.type !== 'NewExpression' || - arg.callee.type !== 'Identifier' || - !/^(Error|RangeError|TypeError)$/.test(arg.callee.name) - ) { - return - } - const first = arg.arguments[0] - if (!first) { - return - } - let messageText - if (first.type === 'Literal' && typeof first.value === 'string') { - messageText = first.value - } else if ( - first.type === 'TemplateLiteral' && - first.expressions.length === 0 && - first.quasis.length === 1 - ) { - messageText = first.quasis[0].value.cooked - } - if (!messageText) { - return - } - if (!THROW_MESSAGE_RE.test(messageText)) { - return - } - context.report({ - node, - messageId: 'throwPlaceholder', - data: { message: JSON.stringify(messageText) }, - }) - }, - - FunctionDeclaration: checkFunctionBody, - FunctionExpression: checkFunctionBody, - ArrowFunctionExpression: checkFunctionBody, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-process-cwd-in-scripts-hooks.mts b/.config/oxlint-plugin/rules/no-process-cwd-in-scripts-hooks.mts deleted file mode 100644 index 98ac9706c..000000000 --- a/.config/oxlint-plugin/rules/no-process-cwd-in-scripts-hooks.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @file Forbid `process.cwd()` in files under `scripts/` or `.claude/hooks/`. - * Both classes of files are invoked by tools or agents from arbitrary working - * directories — a hook may be triggered by Claude Code with cwd = the file - * the user just edited; a script may be invoked from a subdir or a worktree. - * Use one of: - * - * - `fileURLToPath(import.meta.url)` to anchor on the script's own location, - * then walk up to find a stable boundary (repo root, a `package.json` - * ancestor, etc.). - * - The `REPO_ROOT` / `TEMPLATE_DIR` constants exported by - * `scripts/sync-scaffolding/paths.mts` — already resolved via the - * import.meta.url walk-up. - * - The `$CLAUDE_PROJECT_DIR` env var inside a Claude Code hook (the harness - * sets it to the project root that registered the hook). Why not - * `process.cwd()`: - * - A user might `cd packages/foo && node ../../scripts/bar.mts` — - * `process.cwd()` returns `packages/foo`, not the repo root. - * - A Claude Code hook may run with cwd = the file just edited (e.g. `cd - * .claude/hooks/foo && node ./index.mts` patterns surface during testing). - * - cwd is shared state across the process; a parent script that `chdir`'d - * before invoking the child sees its own cwd, not yours. Scope: paths - * matching `**∕scripts/**∕*.{ts,cts,mts,js,cjs,mjs}` or - * `**∕.claude/hooks/**∕*.{ts,cts,mts,js,cjs,mjs}`. Test fixtures (`test/` - * or `**∕*.test.*`) are exempt — tests routinely chdir intentionally. No - * autofix — the right substitute depends on the script's needs - * (import.meta.url vs CLAUDE_PROJECT_DIR vs an explicit arg). - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Forbid `process.cwd()` in scripts/ and .claude/hooks/ — cwd is unstable; use fileURLToPath(import.meta.url) or CLAUDE_PROJECT_DIR.', - category: 'Best Practices', - recommended: true, - }, - fixable: undefined, - messages: { - processCwd: - "`process.cwd()` is unstable in scripts/ and .claude/hooks/ — the user (or Claude Code) may invoke this from any directory. Anchor on the script's own location: `path.dirname(fileURLToPath(import.meta.url))` + walk-up, or read `$CLAUDE_PROJECT_DIR` inside hooks.", - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = context.filename ?? context.getFilename?.() ?? '' - // Only enforce on scripts/ + .claude/hooks/ paths. - if ( - !/\/(?:scripts|\.claude\/hooks)\//.test(filename) || - // Test files inside those dirs are exempt — tests chdir intentionally. - /\/test\//.test(filename) || - /\.test\.(?:[mc]?[jt]s)$/.test(filename) - ) { - return {} - } - - return { - CallExpression(node: AstNode) { - const callee = node.callee - if ( - callee.type !== 'MemberExpression' || - callee.computed || - callee.object.type !== 'Identifier' || - callee.object.name !== 'process' || - callee.property.type !== 'Identifier' || - callee.property.name !== 'cwd' - ) { - return - } - context.report({ - node, - messageId: 'processCwd', - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-promise-race-in-loop.mts b/.config/oxlint-plugin/rules/no-promise-race-in-loop.mts deleted file mode 100644 index 82aebdde7..000000000 --- a/.config/oxlint-plugin/rules/no-promise-race-in-loop.mts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @file Per CLAUDE.md "Promise.race / Promise.any in loops" rule + the - * `plug-leaking-promise-race` skill: never re-race a pool that survives - * across iterations. Each call's handlers stack onto the surviving promises, - * leaking memory and deferring rejection propagation. Detects: - * - * - `Promise.race(...)` / `Promise.any(...)` syntactically inside a `for`, - * `for-of`, `for-in`, `while`, or `do-while` body. The semantic check - * (whether the racer is the SAME pool across iterations) is undecidable - * from syntax. We flag every race-in-loop and let the human confirm it's - * safe (e.g., a freshly-built array each iteration). The skill at - * .claude/skills/plug-leaking-promise-race/ documents the safe shapes. No - * autofix: the right fix is design-level (track the pool outside the loop, - * use AbortController, or restructure to a single race). Reporting only. - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const RACE_METHODS = new Set(['any', 'race']) - -const LOOP_TYPES = new Set([ - 'DoWhileStatement', - 'ForInStatement', - 'ForOfStatement', - 'ForStatement', - 'WhileStatement', -]) - -function isInsideLoop(node: AstNode) { - let current = node.parent - while (current) { - if (LOOP_TYPES.has(current.type)) { - return true - } - // Function boundaries break the chain — a function defined inside - // a loop and invoked elsewhere isn't "in" the loop. - if ( - current.type === 'ArrowFunctionExpression' || - current.type === 'FunctionDeclaration' || - current.type === 'FunctionExpression' - ) { - return false - } - current = current.parent - } - return false -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban Promise.race / Promise.any inside loop bodies — handlers stack on surviving promises and leak.', - category: 'Best Practices', - recommended: true, - }, - messages: { - banned: - 'Promise.{{method}}() inside a loop — handlers stack on surviving promises across iterations and leak. See .claude/skills/plug-leaking-promise-race/SKILL.md for safe shapes.', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - CallExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if ( - callee.object.type !== 'Identifier' || - callee.object.name !== 'Promise' - ) { - return - } - if (callee.property.type !== 'Identifier') { - return - } - if (!RACE_METHODS.has(callee.property.name)) { - return - } - if (!isInsideLoop(node)) { - return - } - - context.report({ - node, - messageId: 'banned', - data: { method: callee.property.name }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-promise-race.mts b/.config/oxlint-plugin/rules/no-promise-race.mts deleted file mode 100644 index 6da15a880..000000000 --- a/.config/oxlint-plugin/rules/no-promise-race.mts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file Forbid `Promise.race(...)` outright — fleet style. `Promise.race` - * resolves with the first settled promise but does not cancel the losers. - * Every unsettled promise continues to run, hold its handles open, and - * deliver its result into a `.then` chain that no one consumes. Worse: each - * call attaches fresh `.then` handlers to every input promise; if the same - * long-lived promise is raced repeatedly (a common shape: race a pool against - * successive timeouts), the handler list on that promise grows unboundedly. - * The memory leak is invisible at the callsite — the leaking promise is - * upstream — and has been known to V8 / Node.js for years without a fix - * landing. References: - * - * - https://github.com/nodejs/node/issues/17469 — long-running `nodejs/node` - * issue documenting the handler-list growth and why `Promise.race` is the - * wrong tool for "wait with timeout". - * - https://github.com/cefn/watchable/tree/main/packages/unpromise#readme — - * `@watchable/unpromise` is the canonical workaround: subscribe/unsubscribe - * to a long-lived promise without attaching new `.then` handlers per call. - * Reach for it when you genuinely need race semantics on a promise you - * can't restructure away. Style signal that motivated the rule: across the - * fleet's six surveyed repos, `Promise.race` appears 3 times total - * (socket-sdk-js 2, socket-cli 1) — those are stragglers, not a pattern. - * The fleet already favors cancellation-aware shapes: - * - `AbortSignal.timeout(ms)` + `AbortSignal.any([...signals])` for timeouts - * and cancellation. - * - `Promise.allSettled(...)` when you genuinely want all results. - * - `Promise.any(...)` if you only care about the first SUCCESS (not first - * SETTLE) — still leaks losers, but at least the semantics aren't "first - * error wins". - * - `@watchable/unpromise` when racing against a long-lived promise is - * unavoidable. `no-promise-race-in-loop` is the narrower sibling rule for - * the specific "race-in-loop leaks the pool" antipattern. This rule is - * broader: every `Promise.race(...)` callsite, anywhere. No autofix: the - * right fix is design-level (introduce an AbortController, await the loser - * explicitly, switch to `AbortSignal.any` + timeout, or adopt - * `@watchable/unpromise`). Reporting only. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Forbid `Promise.race(...)` — losers keep running and leak handles. Use `AbortSignal.any` + timeout, `Promise.allSettled`, or restructure the wait.', - category: 'Possible Errors', - recommended: true, - }, - fixable: undefined, - messages: { - noPromiseRace: - '`Promise.race(...)` leaves the losing promises pending — they keep their handles, deliver results to no one, and each call attaches new `.then` handlers to every input (handler list grows unboundedly; see nodejs/node#17469). Use `AbortSignal.any([AbortSignal.timeout(ms), userSignal])` for timeouts, `Promise.allSettled` when you need every result, restructure to a single awaited promise, or adopt `@watchable/unpromise` when racing a long-lived promise is unavoidable.', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - CallExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if ( - callee.object.type !== 'Identifier' || - callee.object.name !== 'Promise' - ) { - return - } - if ( - callee.property.type !== 'Identifier' || - callee.property.name !== 'race' - ) { - return - } - context.report({ - node, - messageId: 'noPromiseRace', - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-src-import-in-test-expect.mts b/.config/oxlint-plugin/rules/no-src-import-in-test-expect.mts deleted file mode 100644 index 70927174b..000000000 --- a/.config/oxlint-plugin/rules/no-src-import-in-test-expect.mts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @file In a test file, a lib utility imported from the local `src/` tree must - * not be used as a TOOL inside `expect(...)` (to build the expected value). - * Doing so validates `src` against itself: if the utility has a bug, the API - * output AND the expected value are wrong the same way, so the assertion - * still passes and the bug hides. The system-under-test legitimately imports - * from `src/` — this rule does NOT object to that. It only fires when a - * `src/`-imported binding appears inside an `expect(...)` argument, where the - * trustworthy reference is the PUBLISHED snapshot via the `-stable` alias - * (`@socketsecurity/<pkg>-stable/<subpath>`). Concrete incident (socket-lib, - * 2026-05-27): `dlx/detect.test.mts` imported `normalizePath` from - * `../../../src/paths/normalize` and used it as - * `expect(result.packageJsonPath).toBe(normalizePath(join(...)))`. The - * pre-existing `prefer-stable-self-import` rule missed it twice: it skips - * test files, and it only flags bare package-name imports, not relative - * `src/` paths. Scope: files matching `*.test.*`. A binding is flagged only - * when it (a) is imported from a relative specifier whose path lands under a - * `src/` segment, and (b) appears as an identifier inside an `expect(...)` - * call's arguments. Report-only — the `-stable` package name varies per repo, - * so the rewrite is left to the author (replace the relative `src/` path with - * `@socketsecurity/<pkg>-stable/<subpath>`). - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const TEST_FILE_RE = /\.test\.(?:[mc]?[jt]s)$/ - -// A relative specifier that points into a `src/` tree: `./src/x`, -// `../src/x`, `../../../src/paths/normalize`, etc. -const SRC_RELATIVE_RE = /^\.\.?\/(?:[^'"]*\/)?src\// - -// Does this CallExpression callee root back to the `expect` identifier? -// Covers `expect(x)`, `expect(x).toBe(...)`, `expect(x).not.toBe(...)`. -function calleeRootsAtExpect(callee: AstNode | undefined): boolean { - let cur: AstNode | undefined = callee - while (cur) { - if (cur.type === 'Identifier') { - return cur.name === 'expect' - } - if (cur.type === 'MemberExpression') { - cur = cur.object - continue - } - if (cur.type === 'CallExpression') { - cur = cur.callee - continue - } - return false - } - return false -} - -// Collect every Identifier name used in a value position within `node`'s -// subtree. Skips non-computed member property names (`.foo`) and object -// literal keys, which aren't real references to a binding. -function collectValueIdentifiers(node: AstNode, out: Set<string>): void { - if (!node || typeof node !== 'object') { - return - } - if (Array.isArray(node)) { - for (let i = 0, { length } = node; i < length; i += 1) { - collectValueIdentifiers(node[i] as AstNode, out) - } - return - } - if (typeof node.type !== 'string') { - return - } - if (node.type === 'Identifier') { - out.add(node.name) - return - } - for (const key of Object.keys(node)) { - if (key === 'parent' || key === 'loc' || key === 'range') { - continue - } - const child = (node as Record<string, unknown>)[key] - // Skip the property name of a non-computed member access (`obj.foo`). - if ( - node.type === 'MemberExpression' && - key === 'property' && - !node.computed - ) { - continue - } - // Skip object-literal keys (`{ foo: x }` — `foo` isn't a reference). - if (node.type === 'Property' && key === 'key' && !node.computed) { - continue - } - if (child && typeof child === 'object') { - collectValueIdentifiers(child as AstNode, out) - } - } -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'In tests, a src/-imported utility used inside expect(...) must come from the -stable alias, not local src/ (else the test validates src against itself).', - category: 'Best Practices', - recommended: true, - }, - messages: { - srcToolInExpect: - '`{{name}}` is imported from local `src/` (`{{specifier}}`) and used inside `expect(...)`. A utility used to BUILD the expected value must come from the published snapshot — import it from the `@socketsecurity/<pkg>-stable/<subpath>` alias instead. Importing `src/` for the system-under-test is fine; this only applies to tools used in assertions.', - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = context.filename ?? context.getFilename?.() ?? '' - if (!TEST_FILE_RE.test(filename)) { - return {} - } - - return { - Program(program: AstNode) { - // 1. Collect bindings imported from a relative `src/` specifier. - const srcBindings = new Map<string, string>() - const importNodes = new Map<string, AstNode>() - for (const stmt of program.body) { - if ( - stmt.type !== 'ImportDeclaration' || - stmt.source?.type !== 'Literal' - ) { - continue - } - const specifier = String(stmt.source.value) - if (!SRC_RELATIVE_RE.test(specifier)) { - continue - } - for (const spec of stmt.specifiers) { - if (spec.local?.type === 'Identifier') { - srcBindings.set(spec.local.name, specifier) - importNodes.set(spec.local.name, stmt) - } - } - } - if (srcBindings.size === 0) { - return - } - - // 2. Find every expect(...) call, gather the identifiers used in - // its argument subtree, and flag any that resolve to a src - // binding. Report once per binding. - const flagged = new Set<string>() - const visit = (node: AstNode): void => { - if (!node || typeof node !== 'object') { - return - } - if (Array.isArray(node)) { - for (let i = 0, { length } = node; i < length; i += 1) { - visit(node[i] as AstNode) - } - return - } - if (typeof node.type !== 'string') { - return - } - if ( - node.type === 'CallExpression' && - calleeRootsAtExpect(node.callee) && - Array.isArray(node.arguments) - ) { - const used = new Set<string>() - for (let i = 0, { length } = node.arguments; i < length; i += 1) { - collectValueIdentifiers(node.arguments[i] as AstNode, used) - } - for (const name of used) { - if (srcBindings.has(name)) { - flagged.add(name) - } - } - } - for (const key of Object.keys(node)) { - if (key === 'parent' || key === 'loc' || key === 'range') { - continue - } - const child = (node as Record<string, unknown>)[key] - if (child && typeof child === 'object') { - visit(child as AstNode) - } - } - } - visit(program) - - for (const name of flagged) { - context.report({ - node: importNodes.get(name)!, - messageId: 'srcToolInExpect', - data: { name, specifier: srcBindings.get(name)! }, - }) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-status-emoji.mts b/.config/oxlint-plugin/rules/no-status-emoji.mts deleted file mode 100644 index 9e77c594b..000000000 --- a/.config/oxlint-plugin/rules/no-status-emoji.mts +++ /dev/null @@ -1,200 +0,0 @@ -/* oxlint-disable socket/no-status-emoji -- this file IS the rule definition; emoji literals are lookup-table data, not real usage. */ - -/** - * @file Ban status-symbol emoji literals (✓ ✔ ❌ ✗ ⚠ ⚠️ ❗ ✅ ❎ ☑) inside string - * literals. The `@socketsecurity/lib-stable/logger/default` package owns the - * visual prefix via `logger.success()` / `logger.fail()` / `logger.warn()` - * etc. Hand-rolling the symbols fragments the visual style and bypasses - * theme-aware color. Autofix: when the literal is the FIRST argument to - * `console.log` / `console.error` / `logger.log` (no semantic logger method - * specified) AND only one symbol leads the string, rewrite to the matching - * `logger.<method>(...)`. Otherwise emit a warning without a fix (the human - * picks the right method). - */ - -/* oxlint-disable socket/no-status-emoji -- this rule defines the emoji table it bans. */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const EMOJI_TO_METHOD = { - '✓': 'success', - '✔': 'success', - '✅': 'success', - '❌': 'fail', - '✗': 'fail', - '❎': 'fail', - '⚠': 'warn', - '⚠️': 'warn', - '❗': 'warn', - '☑': 'success', -} -/* oxlint-enable socket/no-status-emoji */ - -const EMOJI = Object.keys(EMOJI_TO_METHOD) - -const EMOJI_LEAD_RE = new RegExp( - `^\\s*(${EMOJI.map(e => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*`, -) - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: 'Ban status-symbol emoji literals; use the logger.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - banned: - 'Status-symbol emoji "{{emoji}}" — use logger.{{method}}() from @socketsecurity/lib-stable/logger/default.', - bannedAmbiguous: - 'Status-symbol emoji "{{emoji}}" — use a logger method (success/fail/warn/info) instead of an inline symbol.', - }, - schema: [], - }, - - create(context: RuleContext) { - /** - * Find any banned emoji in a string. Returns the first match. - */ - function findEmoji(value: string): string | undefined { - for (let i = 0, { length } = EMOJI; i < length; i += 1) { - const emoji = EMOJI[i]! - if (value.includes(emoji)) { - return emoji - } - } - return undefined - } - - /** - * If the string `value` LEADS with a known emoji + whitespace, return { - * emoji, restAfter } where restAfter is the string with the leading - * emoji+spaces stripped. Otherwise null. - */ - interface LeadInfo { - emoji: string - restAfter: string - } - - function leadingEmoji(value: string): LeadInfo | undefined { - const match = EMOJI_LEAD_RE.exec(value) - if (!match) { - return undefined - } - return { - emoji: match[1]!, - restAfter: value.slice(match[0].length), - } - } - - /** - * Try to autofix by rewriting `console.log('✓ Done')` → - * `logger.success('Done')`. Returns a fixer function or null. - */ - function tryFix( - node: AstNode, - literalNode: AstNode, - leadInfo: LeadInfo, - ): ((fixer: RuleFixer) => unknown) | undefined { - const method = (EMOJI_TO_METHOD as Record<string, string>)[leadInfo.emoji] - if (!method) { - return undefined - } - - // Only fix when the parent is a CallExpression and the literal - // is the first argument. Otherwise leave to the human. - const parent = node.parent - if (!parent || parent.type !== 'CallExpression') { - return undefined - } - if (parent.arguments[0] !== literalNode) { - return undefined - } - - const callee = parent.callee - if (callee.type !== 'MemberExpression') { - return undefined - } - - const objectName = - callee.object.type === 'Identifier' ? callee.object.name : undefined - const propName = - callee.property.type === 'Identifier' ? callee.property.name : undefined - if (!objectName || !propName) { - return undefined - } - - const isConsole = - objectName === 'console' && - ['log', 'error', 'warn', 'info'].includes(propName) - const isLoggerLog = - objectName === 'logger' && (propName === 'info' || propName === 'log') - - if (!isConsole && !isLoggerLog) { - return undefined - } - - // Build the replacement. - const quote = literalNode.raw[0] - const newLiteral = `${quote}${leadInfo.restAfter.replace(new RegExp(quote, 'g'), '\\' + quote)}${quote}` - - return (fixer: RuleFixer) => [ - fixer.replaceText(callee, `logger.${method}`), - fixer.replaceText(literalNode, newLiteral), - ] - } - - function reportLiteral(node: AstNode) { - const value = typeof node.value === 'string' ? node.value : undefined - if (!value) { - return - } - - const emoji = findEmoji(value) - if (!emoji) { - return - } - - const leadInfo = leadingEmoji(value) - const method = leadInfo - ? (EMOJI_TO_METHOD as Record<string, string>)[leadInfo.emoji] - : undefined - - if (leadInfo && method) { - const fix = tryFix(node, node, leadInfo) - context.report({ - node, - messageId: 'banned', - data: { emoji: leadInfo.emoji, method }, - ...(fix ? { fix } : {}), - }) - } else { - context.report({ - node, - messageId: 'bannedAmbiguous', - data: { emoji }, - }) - } - } - - return { - Literal(node: AstNode) { - reportLiteral(node) - }, - TemplateElement(node: AstNode) { - if (node.value && typeof node.value.cooked === 'string') { - // Treat template-string segments like literals for detection only. - reportLiteral({ ...node, value: node.value.cooked }) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-structured-clone-prefer-json.mts b/.config/oxlint-plugin/rules/no-structured-clone-prefer-json.mts deleted file mode 100644 index f578aa9bb..000000000 --- a/.config/oxlint-plugin/rules/no-structured-clone-prefer-json.mts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file Forbid `structuredClone(x)` for the JSON-roundtrippable subset — fleet - * style. The common deep-clone use case (clone a `JSON.parse`d value to - * defend against caller mutation) is 3-5× faster as - * `JSON.parse(JSON.stringify(x))`. `structuredClone` runs the full HTML - * structured-clone algorithm — type tagging, transferable handling, prototype - * preservation, cycle detection — none of which apply to a value that just - * came out of `JSON.parse`. For caches, hot read-paths, and defensive-copy - * wrappers, the slower clone is real overhead at scale. When - * `structuredClone` IS the right tool (the value contains `Date`, `Map`, - * `Set`, `RegExp`, `ArrayBuffer`, typed arrays, `Error`, or - * non-JSON-roundtrippable shapes; or you genuinely need the prototype- - * preserving semantics), opt back in with a per-line disable and a - * one-sentence rationale: - * - * ```ts - * // oxlint-disable-next-line socket/no-structured-clone-prefer-json -- value contains Date/Map; JSON round-trip would corrupt. - * const copy = structuredClone(value) - * ``` - * - * File-scope disables are banned per fleet convention — every callsite needs - * an independent rationale visible in `git blame`. No autofix — the rewrite - * (`JSON.parse(JSON.stringify(x))` or a primordial- safe equivalent like - * `JSONParse(JSONStringify(x))` from `@socketsecurity/lib/primordials/json`) - * is a judgment call about the value's shape that the linter can't make - * safely on its own. Reporting only. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Forbid `structuredClone(...)` — for JSON-roundtrippable data, `JSON.parse(JSON.stringify(x))` is 3-5x faster. Disable per-line with a rationale when the value genuinely needs the spec-heavy clone (Date/Map/Set/etc).', - category: 'Possible Errors', - recommended: true, - }, - fixable: undefined, - messages: { - noStructuredClone: - '`structuredClone(...)` runs the full HTML structured-clone algorithm — 3-5x slower than `JSON.parse(JSON.stringify(x))` for the JSON subset most callsites use. If the value came from `JSON.parse` (or is otherwise JSON-roundtrippable), use the JSON round-trip instead. When the value genuinely needs `Date` / `Map` / `Set` / `RegExp` / `ArrayBuffer` preservation, add `// oxlint-disable-next-line socket/no-structured-clone-prefer-json -- <reason>` with a one-sentence rationale.', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - CallExpression(node: AstNode) { - const callee = node.callee - // Match the bare global identifier `structuredClone(...)`. - // Don't flag `foo.structuredClone(...)` member calls — those are - // user-defined methods unrelated to the global. - if (callee.type !== 'Identifier') { - return - } - if (callee.name !== 'structuredClone') { - return - } - context.report({ - node: callee, - messageId: 'noStructuredClone', - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-sync-rm-in-test-lifecycle.mts b/.config/oxlint-plugin/rules/no-sync-rm-in-test-lifecycle.mts deleted file mode 100644 index 49310f40a..000000000 --- a/.config/oxlint-plugin/rules/no-sync-rm-in-test-lifecycle.mts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @file Per CLAUDE.md "Testing — test cleanup": `afterEach` / `afterAll` / - * `beforeEach` / `beforeAll` callback bodies must use `await safeDelete(...)` - * from `@socketsecurity/lib-stable/fs`. Sync filesystem deletion inside - * lifecycle hooks races on Windows EBUSY and has no flush guarantee against - * vitest's async-aware teardown ordering. This rule is the narrower - * lifecycle-hook check. The broader `prefer-safe-delete` rule already - * promotes `safeDeleteSync` as a valid target for arbitrary sync deletes; - * THIS rule says even `safeDeleteSync` is wrong inside lifecycle slots. - * Detects (inside an immediate `afterEach` / `afterAll` / `beforeEach` / - * `beforeAll` call's first-argument callback body): - * - * - `safeDeleteSync(...)` - * - `fs.rmSync(...)` / `fs.unlinkSync(...)` / `fs.rmdirSync(...)` Reporting - * only — no autofix. The async rewrite needs the enclosing function to be - * `async`; doing both the callback-shape rewrite and the call-site rewrite - * in a single autofix is fragile (await-vs-no-await, sequencing within the - * callback). Authors fix by hand. - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const LIFECYCLE_HOOK_NAMES = new Set([ - 'afterAll', - 'afterEach', - 'beforeAll', - 'beforeEach', -]) - -const SYNC_FS_METHODS = new Set(['rmSync', 'rmdirSync', 'unlinkSync']) - -const FS_OBJECT_NAMES = /^(fs|fsPromises|fsp|promises)$/ - -export function calleeKind( - callee: AstNode, -): - | { kind: 'fn'; text: string } - | { kind: 'fsmethod'; text: string } - | undefined { - if ( - callee.type === 'Identifier' && - (callee as { name?: string | undefined }).name === 'safeDeleteSync' - ) { - return { kind: 'fn', text: 'safeDeleteSync' } - } - if (callee.type === 'MemberExpression') { - const prop = (callee as { property?: AstNode | undefined }).property - if (!prop || prop.type !== 'Identifier') { - return undefined - } - const propName = (prop as { name?: string | undefined }).name - if (!propName || !SYNC_FS_METHODS.has(propName)) { - return undefined - } - const obj = (callee as { object?: AstNode | undefined }).object - const objName = - obj?.type === 'Identifier' - ? (obj as { name?: string | undefined }).name - : obj?.type === 'MemberExpression' && - (obj as { property?: AstNode | undefined }).property?.type === - 'Identifier' - ? ( - (obj as { property?: { name?: string | undefined } | undefined }) - .property as { - name?: string | undefined - } - ).name - : undefined - if (!objName || !FS_OBJECT_NAMES.test(objName)) { - return undefined - } - return { kind: 'fsmethod', text: `${objName}.${propName}` } - } - return undefined -} - -/** - * Walk up from `node` to the nearest enclosing function. If that function is - * the first argument of a `afterEach`/`afterAll`/`beforeEach`/`beforeAll` call - * (i.e. the hook's callback), return the hook name; otherwise undefined. Only - * the IMMEDIATE enclosing function counts — a sync delete nested inside a - * helper that the hook happens to call is out of scope (matches the old - * enter/exit-stack behavior, which only pushed the hook's own callback). - */ -export function enclosingLifecycleHook(node: AstNode): string | undefined { - let current: AstNode = node - while (current) { - const parent: AstNode = current.parent - if (!parent) { - return undefined - } - if ( - parent.type === 'ArrowFunctionExpression' || - parent.type === 'FunctionDeclaration' || - parent.type === 'FunctionExpression' - ) { - // Found the nearest enclosing function. Is it a lifecycle-hook callback? - const fnParent: AstNode = parent.parent - if ( - fnParent?.type === 'CallExpression' && - fnParent.callee?.type === 'Identifier' && - LIFECYCLE_HOOK_NAMES.has(fnParent.callee.name ?? '') && - Array.isArray(fnParent.arguments) && - fnParent.arguments[0] === parent - ) { - return fnParent.callee.name - } - // Enclosed by a non-hook function — the sync delete isn't directly in a - // lifecycle slot. - return undefined - } - current = parent - } - return undefined -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Lifecycle hooks (afterEach / afterAll / beforeEach / beforeAll) must use `await safeDelete(...)`. Sync filesystem deletion races on Windows EBUSY.', - category: 'Best Practices', - recommended: true, - }, - messages: { - syncDelete: - '`{{callee}}` inside `{{hook}}` — use `await safeDelete(...)` from @socketsecurity/lib-stable/fs. Lifecycle hooks race on Windows EBUSY; the async form retries and integrates with vitest async teardown ordering.', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - CallExpression(node: AstNode) { - const cal = (node as { callee?: AstNode | undefined }).callee - if (!cal) { - return - } - const kind = calleeKind(cal) - if (!kind) { - return - } - // Walk up to the nearest enclosing function; if it's the first-arg - // callback of a lifecycle-hook call (`afterEach(() => { ... })`), this - // sync delete is inside a lifecycle slot. Ancestor-walk instead of an - // enter/exit hook stack so the rule doesn't depend on the `:exit` - // esquery pseudo, which the oxlint JS-plugin engine doesn't support at - // the catalog-pinned version. - const hook = enclosingLifecycleHook(node) - if (!hook) { - return - } - context.report({ - node, - messageId: 'syncDelete', - data: { callee: kind.text, hook }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-todo-comments.mts b/.config/oxlint-plugin/rules/no-todo-comments.mts deleted file mode 100644 index dbdb42638..000000000 --- a/.config/oxlint-plugin/rules/no-todo-comments.mts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @file Per CLAUDE.md "Completion" rule: never leave TODO / FIXME / XXX / shims - * / stubs / placeholders. Finish 100%. If too large for one pass, ask before - * cutting scope. Detects the literal markers TODO, FIXME, XXX, HACK in any - * comment (line or block). Word-boundary anchored so identifiers that happen - * to contain "todo" (e.g., `todoStore`) don't trigger. No autofix: a TODO is - * a deferred decision; auto-removing the comment would just delete the - * deferral note without addressing the underlying gap. Reporting only — - * caller resolves the work or promotes it to an issue/skill. Allowed - * exceptions: - * - * - The `TODO` literal inside a string or regex (intentional, e.g. a parser - * that detects TODO comments). Skipped automatically by scoping the rule to - * comment AST nodes only. - */ - -const MARKER_RE = /\b(FIXME|HACK|TODO|XXX)\b/ - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Ban TODO / FIXME / XXX / HACK markers in comments. Per CLAUDE.md "Completion" rule — finish the work or open an issue.', - category: 'Best Practices', - recommended: true, - }, - messages: { - banned: - '`{{marker}}` comment — finish the work, open an issue, or ask before deferring. CLAUDE.md "Completion" rule bans deferral markers in source.', - }, - schema: [], - }, - - create(context) { - return { - Program() { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const comments = sourceCode.getAllComments() - for (const comment of comments) { - const match = MARKER_RE.exec(comment.value) - if (!match) { - continue - } - context.report({ - node: comment, - messageId: 'banned', - data: { marker: match[1] }, - }) - } - }, - } - }, -} - -export default rule diff --git a/.config/oxlint-plugin/rules/no-underscore-identifier.mts b/.config/oxlint-plugin/rules/no-underscore-identifier.mts deleted file mode 100644 index ff81879e6..000000000 --- a/.config/oxlint-plugin/rules/no-underscore-identifier.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @file Forbid underscore-prefixed _identifiers_ (functions, variables, - * classes, interfaces, type aliases, parameters, imports). Privacy in - * TypeScript is handled by module boundaries (not exporting) or by the - * `_internal/` _directory_ pattern — not by leading underscores on symbol - * names. The underscore-as-internal-marker convention is borrowed from other - * languages where it has runtime meaning (Python name mangling, Ruby - * visibility); in TS the underscore is decorative and adds noise to `git - * blame` and IDE autocomplete. Commit-time partner of the edit-time - * `.claude/hooks/no-underscore-identifier-guard/`. Allowed (skipped by this - * rule): - * - * - Bare `_` as a throwaway (`for (const _ of arr)`, destructuring rest). - * - Files under any `_internal/` directory — the canonical structural pattern - * for module-private files. The rule is about identifiers inside files, not - * folder layout. - * - Files matched by oxlint's default exclude list (dist, build, node_modules). - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const UNDERSCORE_NAME_RE = /^_[A-Za-z]/ - -function isInInternalDir(filename: string): boolean { - return filename.includes('/_internal/') -} - -function checkIdentifier( - context: RuleContext, - node: AstNode, - name: string | undefined, -): void { - if (!name || !UNDERSCORE_NAME_RE.test(name)) { - return - } - context.report({ - node, - messageId: 'noUnderscoreIdentifier', - data: { name }, - }) -} - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Forbid underscore-prefixed identifiers — use module boundaries or `_internal/` directories for privacy.', - category: 'Stylistic Issues', - recommended: true, - }, - messages: { - noUnderscoreIdentifier: - "'{{name}}' starts with `_`. Drop the underscore — privacy in TS comes from not exporting (or from a `_internal/` directory), not from a leading underscore on the symbol name.", - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = - typeof context.filename === 'string' - ? context.filename - : (context.getFilename?.() ?? '') - - if (isInInternalDir(filename)) { - return {} - } - - return { - VariableDeclarator(node: AstNode) { - if (node.id?.type === 'Identifier') { - checkIdentifier(context, node.id, node.id.name) - } - }, - FunctionDeclaration(node: AstNode) { - if (node.id?.type === 'Identifier') { - checkIdentifier(context, node.id, node.id.name) - } - }, - ClassDeclaration(node: AstNode) { - if (node.id?.type === 'Identifier') { - checkIdentifier(context, node.id, node.id.name) - } - }, - TSInterfaceDeclaration(node: AstNode) { - if (node.id?.type === 'Identifier') { - checkIdentifier(context, node.id, node.id.name) - } - }, - TSTypeAliasDeclaration(node: AstNode) { - if (node.id?.type === 'Identifier') { - checkIdentifier(context, node.id, node.id.name) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/no-which-for-local-bin.mts b/.config/oxlint-plugin/rules/no-which-for-local-bin.mts deleted file mode 100644 index c9611fff2..000000000 --- a/.config/oxlint-plugin/rules/no-which-for-local-bin.mts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @file Per fleet "Tooling" rule: don't shell out to `which` / `command -v` / - * `where` to locate a project binary. Fleet code spawns binaries that `pnpm - * install` links into `node_modules/.bin` — a `which`/`command -v` lookup - * searches the GLOBAL PATH instead, which is wrong on two counts: - * - * 1. On a normal checkout the binary isn't on the global PATH, so the lookup - * returns nothing and the calling code silently degrades (a test harness - * skips, a tool falls back, etc.) instead of using the locally-installed - * version. - * 2. If a global binary of a DIFFERENT version happens to exist, the code runs - * against the wrong engine. Use `whichSync(name, { path: - * <node_modules/.bin dir>, nothrow: true })` from - * `@socketsecurity/lib-stable/bin/which` (it validates existence + the - * platform `.cmd` wrapper), or resolve the `.bin` path directly. Detects - * string literals that invoke the lookup commands — either as a bare - * argv[0] (`spawnSync('which', ['oxlint'])`) or as the head of a shell - * string (`execSync('which oxlint')`, `'command -v foo'`). Reporting only - * (no autofix): the right replacement depends on which `.bin` dir to scope - * to and whether the caller is sync/async. Allowed (skipped): - * - * - The plugin's own rules/ + test/ files (this file names the banned commands - * as lookup-table data / fixtures). - * - Lines carrying a `socket-hook: allow which-lookup` comment — for the rare - * case that legitimately needs a global PATH search (e.g. locating the - * user's real `git` / system tool, not a project dependency). - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import { isPluginSelfFile } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -// A full PATH-lookup shell string: a lookup command followed by exactly one -// binary-name token (and nothing more). `command -v` / `command -V` and -// `type -P` are the POSIX-portable forms; `which` / `where` are the direct -// commands. The single-token tail is what separates a real lookup -// (`which oxlint`, `command -v pnpm`) from prose that merely starts with the -// word "which" (`which file do you want?`) — the latter has multiple -// whitespace-separated words after the command and so doesn't match. -// -// We deliberately do NOT flag a bare `'which'` / `'where'` literal (the -// argv[0]-to-spawn form, `spawnSync('which', ['oxlint'])`): the word "which" -// appears too often in ordinary strings to flag from the literal alone without -// dataflow analysis, which would produce constant false positives. The shell- -// string form below carries unambiguous lookup intent. -const SHELL_LOOKUP_RE = - /^(?:command\s+-[vV]|type\s+-P|where|which)\s+[\w./@+-]+$/ - -// socket-hook: allow which-lookup -- this marker string is the rule's own bypass token, not a real usage. -const BYPASS_RE = /socket-hook:\s*allow\s+which-lookup/ - -/** - * True when `value` is a string that invokes a PATH-lookup command, either as a - * bare command name (argv[0] form) or as the head of a shell string. - */ -export function isWhichLookup(value: string): boolean { - return SHELL_LOOKUP_RE.test(value.trim()) -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Do not shell out to `which` / `command -v` / `where` to locate a project binary — resolve from `node_modules/.bin` via `whichSync({ path })` from @socketsecurity/lib-stable/bin/which.', - category: 'Best Practices', - recommended: true, - }, - messages: { - whichLookup: - '`{{cmd}}` shells out to search the GLOBAL PATH for a binary — fleet binaries live in `node_modules/.bin`. Use `whichSync(name, { path: <binDir>, nothrow: true })` from @socketsecurity/lib-stable/bin/which (handles the `.cmd` wrapper + existence check), or resolve the `.bin` path directly. If you really need a global lookup (system git, etc.), add `// socket-hook: allow which-lookup`.', - }, - schema: [], - }, - - create(context: RuleContext) { - // This rule's own source + test fixtures contain the banned command names - // as data; exempt the plugin's internal dirs. - if (isPluginSelfFile(context)) { - return {} - } - - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - - function check(node: AstNode, value: unknown): void { - if (typeof value !== 'string' || !isWhichLookup(value)) { - return - } - if (hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'whichLookup', - data: { cmd: value.trim().split(/\s+/)[0] ?? value.trim() }, - }) - } - - return { - Literal(node: AstNode) { - check(node, (node as { value?: unknown | undefined }).value) - }, - TemplateElement(node: AstNode) { - const cooked = ( - node as { value?: { cooked?: string | undefined } | undefined } - ).value?.cooked - check(node, cooked) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/optional-explicit-undefined.mts b/.config/oxlint-plugin/rules/optional-explicit-undefined.mts deleted file mode 100644 index 2b11a4e65..000000000 --- a/.config/oxlint-plugin/rules/optional-explicit-undefined.mts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * @file Enforce `foo?: T | undefined` over `foo?: T` on interface / - * type-literal properties. Pairs with `exactOptionalPropertyTypes: true` (set - * in tsconfig.base.json) so the value `undefined` is a separately-modeled - * state from "property omitted." With both, you can write either form at the - * call site; in mixed-codebase code, both happen, so we require both to be - * allowed. Applies to `.ts`, `.cts`, `.mts` files. JS (`.js`, `.cjs`, `.mjs`) - * has no type annotations to enforce. Triggers on: - * - * - Interface members: `interface X { foo?: string }` - * - Type-literal members: `type X = { foo?: string }` - * - Class fields with `?` and no initializer: `class X { foo?: string }` Skips: - * - Properties that are already `?: T | undefined` (or any union containing - * `undefined`). - * - Function parameters with `?` — convention there is different (`?` already - * implies optional + undefined at the call site). - * - Mapped types (`{ [K in keyof T]?: T[K] }`) — the `?` is a transform - * operator, not a property declaration. Autofix appends ` | undefined` to - * the type annotation. Why this matters: with `exactOptionalPropertyTypes: - * true`, a call site that writes `{ foo: undefined }` is rejected when the - * type says only `foo?: T`. Mixed-codebase code does both (build options - * objects, JSON-derived parsed config, REST API responses) and the `| - * undefined` makes the contract honest. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Require `?: T | undefined` (not bare `?: T`) on type-literal and interface properties to pair with `exactOptionalPropertyTypes`.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - missingUndefined: - 'Optional property `{{name}}` should be typed as `{{name}}?: {{type}} | undefined` to pair with `exactOptionalPropertyTypes`.', - }, - schema: [], - }, - - create(context: RuleContext) { - // Plugin runs against all extensions; we only enforce on TS files. - const filename = context.filename ?? context.getFilename?.() ?? '' - if (!/\.(?:cts|mts|ts)$/.test(filename)) { - return {} - } - - /** - * True when `typeAnnotation` already includes `undefined` somewhere in its - * top-level union. Recursive into TSUnionType so `T | (U | undefined)` - * (rare) still passes. - */ - function hasUndefined(typeAnnotation: AstNode | undefined): boolean { - if (!typeAnnotation) { - return false - } - if (typeAnnotation.type === 'TSUndefinedKeyword') { - return true - } - if (typeAnnotation.type === 'TSUnionType') { - for (const t of typeAnnotation.types) { - if (hasUndefined(t)) { - return true - } - } - } - // `T | null` doesn't count — we want explicit `undefined`. - return false - } - - /** - * Pull the property name token for the error message. Handles Identifier - * keys (`foo?:`), Literal keys (`'foo'?:`), and computed keys (skipped via - * "unknown"). - */ - function keyName(node: AstNode) { - const k = node.key - if (!k) { - return 'property' - } - if (k.type === 'Identifier') { - return k.name - } - if (k.type === 'Literal' && typeof k.value === 'string') { - return k.value - } - return 'property' - } - - /** - * Source-text snippet of the type annotation for the error message + the - * fix. Tolerant of missing source ranges. - */ - function typeText(node: AstNode) { - const ann = node.typeAnnotation?.typeAnnotation - if (!ann || !ann.range) { - return 'T' - } - const src = context.sourceCode ?? context.getSourceCode?.() - if (!src) { - return 'T' - } - return src.text.slice(ann.range[0], ann.range[1]) - } - - /** - * True when appending ` | undefined` after the annotation would bind to a - * sub-expression instead of the whole type. Affected shapes (need parens - * before union): - `() => void` (TSFunctionType) - `new () => Foo` - * (TSConstructorType) - `Foo | Bar` (TSUnionType — would technically work - * but parens make it explicit; non-issue here since hasUndefined already - * catches `| undefined`) - `Foo & Bar` (TSIntersectionType) - */ - function needsParens(ann: AstNode): boolean { - return ( - ann.type === 'TSConstructorType' || - ann.type === 'TSFunctionType' || - ann.type === 'TSIntersectionType' - ) - } - - function check(node: AstNode) { - // Only optional members. - if (!node.optional) { - return - } - // Must have a type annotation; bare `foo?` (no `:`) gets implicit - // `any` and isn't our concern. - const ann = node.typeAnnotation?.typeAnnotation - if (!ann) { - return - } - // Already explicit. - if (hasUndefined(ann)) { - return - } - // Also skip when the annotation is a function/arrow-return that - // already ends with `| undefined`. `hasUndefined` only checks - // the outer union; for `(...) => Foo | undefined` we want to - // accept that as already-correct. - if ( - (ann.type === 'TSConstructorType' || ann.type === 'TSFunctionType') && - hasUndefined(ann.returnType?.typeAnnotation) - ) { - return - } - const name = keyName(node) - const type = typeText(node) - context.report({ - node: ann, - messageId: 'missingUndefined', - data: { name, type }, - fix(fixer: RuleFixer) { - // For function/constructor/intersection types we need parens - // around the existing annotation so ` | undefined` binds to - // the whole thing, not to the return type / last factor. - if (needsParens(ann)) { - return [ - fixer.insertTextBefore(ann, '('), - fixer.insertTextAfter(ann, ') | undefined'), - ] - } - return fixer.insertTextAfter(ann, ' | undefined') - }, - }) - } - - return { - TSPropertySignature: check, - // Class fields. ESLint's TS estree calls these PropertyDefinition - // when in a class. The `?` -> `optional: true` shape matches. - PropertyDefinition: check, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/personal-path-placeholders.mts b/.config/oxlint-plugin/rules/personal-path-placeholders.mts deleted file mode 100644 index dbe247064..000000000 --- a/.config/oxlint-plugin/rules/personal-path-placeholders.mts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * @file Per CLAUDE.md "Token hygiene → Personal-path - * placeholders" rule: - * When a doc / test / comment needs to show an example user-home - * path, use the canonical platform-specific placeholder so the - * personal-paths scanner recognizes it as documentation: - * /Users/<user>/... (macOS) - * /home/<user>/... (Linux) - * C:\Users<USERNAME>... (Windows) - * Don't drift to <name> / <me> / <USER> / <u> etc. — the scanner - * accepts anything in <...> but a fleet-wide audit relies on the - * canonical strings being grep-able. - * Detects user-home paths in string literals + comments where the - * placeholder slug isn't the canonical form. The detection is - * conservative: a string must clearly look like a user-home path - * before the rule fires. - * Autofix: replaces the non-canonical placeholder with the canonical - * one for the platform path prefix: - * /Users/<user>/ → /Users/<user>/ - * /home/<user>/ → /home/<user>/ - * C:\Users<X>\ → C:\Users<USERNAME>\ - * C:/Users/<USERNAME>/ → C:/Users/<USERNAME>/ - * Real personal data (a literal username instead of a placeholder) - * is also flagged. Two scenarios: - * - * 1. Source code / docs / tests — the path was hand-written and should be - * replaced with the canonical placeholder, an env-var form (`$HOME`, - * `${USER}`, `%USERNAME%`), or deleted entirely. - * 2. WASM / generated bundles — a literal username inside compiled output means - * a build pipeline is leaking the developer's path into the artifact - * (typically esbuild / rolldown sourcemaps, sourceMappingURL, or - * `__filename` baked at build time). The fix is the build config, NOT the - * artifact — chasing the string in the bundle is treating the symptom. The - * deterministic linter can't tell scenario 1 from scenario 2, so it - * reports without an autofix. The AI-fix step (Step 4 of `pnpm run fix`) - * handles both: rewriting source mentions for #1 and tracing back to the - * build config for #2. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const PATTERNS = [ - { - // /Users/<user>/... - re: /(\/Users\/)<([^>]+)>(\/|$)/, - canonical: 'user', - label: '/Users/<user>/', - }, - { - // /home/<user>/... - re: /(\/home\/)<([^>]+)>(\/|$)/, - canonical: 'user', - label: '/home/<user>/', - }, - { - // C:\Users\<USERNAME>\... or C:/Users/<USERNAME>/ - re: /([A-Za-z]:[\\/]Users[\\/])<([^>]+)>([\\/]|$)/, - canonical: 'USERNAME', - label: 'C:\\Users\\<USERNAME>\\', - }, -] - -/** - * A real-username detection — a path of the same shape but with a - * non-placeholder username segment. Reported, not fixed. - */ -const REAL_USERNAME_PATTERNS = [ - /(\/Users\/)([a-zA-Z][a-zA-Z0-9_-]{1,31})(\/)/, - /(\/home\/)([a-zA-Z][a-zA-Z0-9_-]{1,31})(\/)/, -] - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use canonical personal-path placeholders (<user> on Unix, <USERNAME> on Windows). Drift breaks fleet-wide grep audits.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - drift: - 'Personal-path placeholder `<{{actual}}>` should be the canonical `<{{canonical}}>`. Saw `{{path}}`; expected the form `{{label}}`.', - realUsername: - 'Personal path with literal username `{{name}}`. In source/docs: replace with placeholder `{{label}}`, an env-var form, or delete the path. In WASM / generated bundles: this is a build leak — fix the bundler config, not the artifact.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - interface DriftReport { - actual: string - canonical: string - path: string - label: string - } - - function checkText( - textNode: AstNode, - text: string, - isComment: boolean, - ): void { - // First pass: drift detection — replace non-canonical - // placeholders with the canonical form. - let mutated = false - let next = text - let firstReport: DriftReport | undefined - for (let i = 0, { length } = PATTERNS; i < length; i += 1) { - const p = PATTERNS[i]! - const reAll = new RegExp(p.re.source, 'g') - next = next.replace( - reAll, - (whole: string, prefix: string, slug: string, suffix: string) => { - if (slug === p.canonical) { - return whole - } - // Skip env-var forms — already canonical. - if (/^\$|^%/.test(slug)) { - return whole - } - if (!firstReport) { - firstReport = { - actual: slug, - canonical: p.canonical, - path: whole, - label: p.label, - } - } - mutated = true - return `${prefix}<${p.canonical}>${suffix}` - }, - ) - } - - if (mutated && firstReport) { - context.report({ - node: textNode, - messageId: 'drift', - data: firstReport, - fix(fixer: RuleFixer) { - if (isComment) { - const prefix = textNode.type === 'Line' ? '//' : '/*' - const suffix = textNode.type === 'Line' ? '' : '*/' - return fixer.replaceTextRange( - textNode.range, - prefix + next + suffix, - ) - } - const raw = sourceCode.getText(textNode) - const quote = raw[0] - if (quote === '`') { - return fixer.replaceText(textNode, '`' + next + '`') - } - const escaped = next.replace( - new RegExp(`\\\\|${quote}`, 'g'), - (ch: string) => '\\' + ch, - ) - return fixer.replaceText(textNode, quote + escaped + quote) - }, - }) - return - } - - // Second pass: real-username detection (no autofix). - for (let i = 0, { length } = REAL_USERNAME_PATTERNS; i < length; i += 1) { - const re = REAL_USERNAME_PATTERNS[i]! - const m = re.exec(text) - if (!m) { - continue - } - // Skip if the slug is a known placeholder shape (already - // handled above), env-var, or canonical literal "user". - const slug = m[2] - if (slug === 'USERNAME' || slug === 'user') { - continue - } - // Skip platform-canonical literals like "Shared". - if (slug === 'Public' || slug === 'Shared') { - continue - } - const label = - re.source.indexOf('Users') !== -1 ? '/Users/<user>/' : '/home/<user>/' - context.report({ - node: textNode, - messageId: 'realUsername', - data: { name: slug, label }, - }) - return - } - } - - return { - Literal(node: AstNode) { - if (typeof node.value !== 'string') { - return - } - checkText(node, node.value, false) - }, - TemplateLiteral(node: AstNode) { - if (node.expressions.length !== 0) { - // Mixed template — only inspect the static parts. - for (const q of node.quasis) { - checkText(node, q.value.cooked, false) - } - return - } - checkText(node, node.quasis[0].value.cooked, false) - }, - Program() { - const comments = sourceCode.getAllComments() - for (let i = 0, { length } = comments; i < length; i += 1) { - const comment = comments[i]! - checkText(comment, comment.value, true) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-async-spawn.mts b/.config/oxlint-plugin/rules/prefer-async-spawn.mts deleted file mode 100644 index d740444dc..000000000 --- a/.config/oxlint-plugin/rules/prefer-async-spawn.mts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @file Per CLAUDE.md "Subprocesses" rule: Prefer async `spawn` from - * `@socketsecurity/lib-stable/process/spawn/child` over `spawnSync` from - * `node:child_process`. Async unblocks parallel tests / event-loop work; the - * sync version freezes the runner for the duration of the child. Use - * `spawnSync` only when you genuinely need synchronous semantics. Detects: - * - * - `import { spawnSync } from 'node:child_process'` - * - `import { spawnSync } from 'child_process'` - * - `child_process.spawnSync(...)` calls (when the require side dodges the - * import-name detector). - * - `spawn` from `node:child_process` — recommend the lib instead. Even the - * async core spawn lacks the lib's SpawnError shape. Autofix scope - * (deterministic; no AI required) — sync-aware: The lib re-exports BOTH - * `spawn` and `spawnSync`. The autofix only ever rewrites the import source - * (`node:child_process` → - * `@socketsecurity/lib-stable/process/spawn/child`); it never changes the - * imported name, never collapses `spawnSync` into `spawn`, and never - * touches call sites. Converting sync → async is a semantic change (callers - * must `await`, return types change from objects to promises) and that's a - * human-eyes job, not an autofix. Skipped when: a) any non-spawn named - * import (e.g. `exec`, `execSync`, `ChildProcess`) shares the same - * statement — the lib doesn't re-export those, so we can't safely rewrite - * the whole line. Allowed exceptions: - * - Adjacent comment with `prefer-async-spawn: sync-required` — for top-level - * scripts whose entire flow is sync (per CLAUDE.md "Reserve `spawnSync` for - * top-level scripts whose entire flow is sync"). - * - Files inside `@socketsecurity/lib-stable/process/spawn/child` itself — they - * wrap the core APIs. Handled at the .config/oxlintrc.json ignorePatterns - * level. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const CHILD_PROCESS_SPECIFIERS = new Set([ - 'child_process', - 'node:child_process', -]) - -const LIB_SPECIFIER = '@socketsecurity/lib-stable/process/spawn/child' - -const BANNED_NAMES = new Set(['spawn', 'spawnSync']) - -const BYPASS_RE = /prefer-async-spawn:\s*sync-required/ - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use `spawn` from @socketsecurity/lib-stable/process/spawn/child instead of `spawnSync` / core `spawn` from node:child_process.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - importBanned: - 'Importing `{{name}}` from {{specifier}} — use `spawn` from @socketsecurity/lib-stable/process/spawn/child. Async unblocks parallel work and the lib ships consistent error shapes (SpawnError).', - callBanned: - 'Calling `child_process.{{name}}(...)` — use `spawn` from @socketsecurity/lib-stable/process/spawn/child instead.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - function hasBypassComment(node: AstNode) { - const before = sourceCode.getCommentsBefore(node) - const after = sourceCode.getCommentsAfter(node) - for (const c of [...before, ...after]) { - if (BYPASS_RE.test(c.value)) { - return true - } - } - return false - } - - /** - * Build a fixer that swaps the import SOURCE without changing the imported - * NAMES. The lib re-exports both `spawn` and `spawnSync` (and a - * `Spawn`-typed namespace under them), so consumers who imported - * `spawnSync` keep using `spawnSync` from the lib and their call sites stay - * correct. - * - * The original rule collapsed `spawnSync` → `spawn` and left the call sites - * untouched, producing files that called `spawnSync(...)` with no - * `spawnSync` symbol in scope. Sync-aware: never rename. - * - * Conservatively skip when other (non-banned) named imports share the line - * — `exec`, `ChildProcess`, etc. aren't re-exported, so the whole-line - * rewrite would break those references. - */ - function fixImport(fixer: RuleFixer, node: AstNode) { - const others = node.specifiers.filter( - (s: AstNode) => - s.type !== 'ImportSpecifier' || - !s.imported || - !BANNED_NAMES.has(s.imported.name), - ) - if (others.length > 0) { - // Mixed line — leave it alone; a partial rewrite could lose - // the non-banned import. - return undefined - } - // Replace only the source-string token. node.source covers the - // quoted specifier (incl. the quotes); replacing just that keeps - // every original `{ ... }` binding intact, including `as` clauses - // and the choice between `spawn` and `spawnSync`. - return fixer.replaceText(node.source, `'${LIB_SPECIFIER}'`) - } - - return { - ImportDeclaration(node: AstNode) { - const specifier = node.source.value - if (!CHILD_PROCESS_SPECIFIERS.has(specifier)) { - return - } - if (hasBypassComment(node)) { - return - } - const banned = node.specifiers.filter( - (s: AstNode) => - s.type === 'ImportSpecifier' && - s.imported && - BANNED_NAMES.has(s.imported.name), - ) - if (banned.length === 0) { - return - } - - for (let i = 0, { length } = banned; i < length; i += 1) { - const spec = banned[i]! - context.report({ - node: spec, - messageId: 'importBanned', - data: { - name: spec.imported.name, - specifier: `'${specifier}'`, - }, - // Only the first banned-import on the line emits the fix; - // ESLint dedupes overlapping inserts so this is safe. - fix(fixer: RuleFixer) { - return fixImport(fixer, node) - }, - }) - } - }, - - // child_process.spawnSync(...) — covers `require('child_process').spawnSync(...)` - // and `cp.spawnSync(...)` when the local binding is named cp. - CallExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if (callee.property.type !== 'Identifier') { - return - } - if (!BANNED_NAMES.has(callee.property.name)) { - return - } - // Match `<obj>.spawnSync(...)` where <obj> is a known - // child_process binding. We can't perfectly track requires - // without scope analysis, so accept common alias names. - const obj = callee.object - const objName = - obj.type === 'Identifier' - ? obj.name - : obj.type === 'MemberExpression' && - obj.property.type === 'Identifier' - ? obj.property.name - : undefined - if (!objName) { - return - } - if (!/^(?:childProcess|child_process|cp)$/.test(objName)) { - return - } - if (hasBypassComment(node)) { - return - } - - // Report — but NO autofix. Converting `<obj>.spawnSync(...)` to - // `await spawn(...)` is a semantic change: the return value - // shape flips from a synchronous `{ status, stdout, stderr }` - // object to an awaited Promise of a different shape (`.code`, - // not `.status`). Callers using `r.status` would silently break. - // Imports get auto-fixed (source rewrite only); call sites - // need human eyes to decide if sync semantics were load-bearing. - context.report({ - node, - messageId: 'callBanned', - data: { name: callee.property.name }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts b/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts deleted file mode 100644 index 94fbbcfb6..000000000 --- a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts +++ /dev/null @@ -1,469 +0,0 @@ -/** - * @file Prefer a cached-length C-style `for` loop over both `.forEach(cb)` and - * `for...of`. Two distinct wins: - * - * 1. `.forEach` creates a function frame per iteration; the C-style loop does - * not. For hot paths the difference is measurable, and the readability - * cost is small once the pattern is uniform across the fleet. - * 2. `for...of` allocates an iterator object and dispatches `Symbol.iterator` / - * `.next()` per step. For plain arrays (the fleet's overwhelmingly common - * case) the cached-length `for` loop is both faster and produces - * predictable generated code under TS/oxc. Style signal that motivated the - * rule: jdalton has hand-optimized fleet hot paths to cached-length `for - * (let i = 0, { length } = arr; i < length; i += 1)` form repeatedly. - * Encoding the preference as a rule prevents drift back to the more - * idiomatic forms in subsequent edits. Canonical shape emitted by the - * autofix: for (let i = 0, { length } = arr; i < length; i += 1) { const - * item = arr[i]! - * - * <body> - * } - * Notes on the shape: - * - `i += 1` instead of `i++` — postfix `++` returns the - * pre-increment value, which is a common source of off-by-one - * bugs and which the fleet's lint config bans elsewhere. - * - `{ length } = arr` destructures the length once at loop init, - * so the test `i < length` doesn't re-read `arr.length` per - * iteration. Equivalent to `const len = arr.length` but pairs - * with `let i = 0` in a single `let` head. - * - `arr[i]!` non-null assertion — under `noUncheckedIndexedAccess` - * the lookup type is `T | undefined`, and the bound `i` is - * provably in `[0, length)`. The assertion suppresses TS18048 - * at every read of `item` downstream. No-op for tsconfigs - * without the strict flag. - * Autofix scope (deterministic only): - * - `arr.forEach((item) => { body })` → - * ``` - * for (let i = 0, { length } = arr; i < length; i += 1) { - * const item = arr[i] - * body - * } - * ``` - * - `arr.forEach((item, index) => { body })` → - * ``` - * for (let index = 0, { length } = arr; index < length; index += 1) { - * const item = arr[index] - * body - * } - * ``` - * (The second-arg `index` name takes over the loop counter — no - * name collision since the callback parameter is in its own - * scope.) - * - `for (const item of arr) { body }` → - * ``` - * for (let i = 0, { length } = arr; i < length; i += 1) { - * const item = arr[i] - * body - * } - * ``` - * Skips (report-only or skip entirely): - * - `.forEach` with a function reference (not an inline arrow / - * function expression) — e.g. `arr.forEach(handler)` — the - * callback is opaque; rewriting would change semantics if the - * handler uses `arguments` or has a non-trivial `.length`. - * - `.forEach` with `thisArg` (2nd argument). - * - `.forEach` whose callback uses a 3rd `array` parameter — we'd - * need to bind a separate name, and the construct is rare. - * - `.forEach` whose callback references `this` (would need - * `.bind(this)`). - * - `.forEach` whose callback has destructured / non-Identifier - * parameters (`({ id }) => {}`) — rewriting requires inserting a - * destructure pattern inside the loop body; doable but the - * human review is cleaner. - * - `.forEach` containing `await` (the callback was previously - * async and the iterations were independent; switching to a - * `for` loop changes that to sequential awaits, which IS what - * the user wants here but only if they say so — flag instead). - * - `for...of` over an iterator that isn't a bare Identifier - * (`for (const x of getThings())`, `for (const x of obj.list)`) - * — we'd need to hoist the iterable to a `const` first; skip - * SILENTLY. The rewrite is doable in many cases but the human - * review is cleaner, and the rule's user experience is bad if - * it reports an unfixable warning for every member-access loop. - * - `for...of` whose loop variable is destructured - * (`for (const [k, v] of m)`, `for (const { x } of arr)`) - * — the typical source is a Map / Set / `.entries()` iteration - * where there's no equivalent cached-for-loop shape (Maps aren't - * integer-indexable). Skip SILENTLY. - * - `for...of` whose body uses `continue`/`break` labels matching - * `i` or `length` (extremely rare; skip to be safe). - * - `for...await...of` — semantically distinct, do not touch. - * The earlier revision of this rule reported `preferCachedForNoFix` - * for the two skip-silently cases above. That surfaced as a lint - * error per location with no autofix path — the user had no way to - * resolve the finding short of hand-rewriting (often impossible: - * Maps don't have an indexed form). Now the rule only emits findings - * when an autofix is available; the cases above are skipped without - * a report at all. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import { FLAGGED_KINDS, createKindResolver } from '../lib/iterable-kind.mts' -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer cached-length C-style `for (let i = 0, { length } = arr; i < length; i += 1)` over `.forEach` and `for...of`.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - preferCachedFor: - 'Use a cached-length `for (let i = 0, { length } = {{iter}}; i < length; i += 1)` loop instead of `{{shape}}` — avoids per-iteration callback / iterator allocation.', - preferCachedForNoFix: - 'Use a cached-length `for` loop instead of `{{shape}}`, but the rewrite is unsafe here ({{reason}}). Rewrite manually.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - // Scope-aware kind resolver. Shared with no-cached-for-on-iterable - // via lib/iterable-kind.mts. We use it to SKIP rewriting - // `for (const item of setVar)` into the cached-length shape — - // that would silently no-op the loop (no .length, not integer- - // indexable) and is exactly the bug the other rule catches. - const resolveKind = createKindResolver() - - return { - CallExpression(node: AstNode) { - // Match `<iter>.forEach(cb)` patterns. - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if ( - callee.property.type !== 'Identifier' || - callee.property.name !== 'forEach' - ) { - return - } - if (callee.computed) { - return - } - if (node.arguments.length === 0 || node.arguments.length > 1) { - // 0 args is invalid JS; 2 args means a `thisArg` was passed - // (changes semantics if we drop it). - return - } - const cb = node.arguments[0] - if ( - cb.type !== 'ArrowFunctionExpression' && - cb.type !== 'FunctionExpression' - ) { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach(handler)', - reason: 'callback is not an inline arrow / function expression', - }, - }) - return - } - if (cb.params.length === 0 || cb.params.length > 2) { - // 3rd `array` param is rare; 0 params means the callback - // doesn't consume the item — flag without fix. - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: 'callback arity is 0 or 3+', - }, - }) - return - } - const itemParam = cb.params[0] - const indexParam = cb.params[1] - if (itemParam.type !== 'Identifier') { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: 'first parameter is destructured', - }, - }) - return - } - if (indexParam && indexParam.type !== 'Identifier') { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: 'second parameter is destructured', - }, - }) - return - } - if (cb.body.type !== 'BlockStatement') { - // Expression-body arrow — would need to wrap as statement. - // Trivially doable but rare for forEach; flag. - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: 'callback uses expression body', - }, - }) - return - } - if (cb.async) { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: - 'callback is async (changes parallel-vs-sequential semantics)', - }, - }) - return - } - const bodyText = sourceCode.getText(cb.body) - if (/\bthis\b/.test(bodyText)) { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { shape: '.forEach', reason: 'callback references `this`' }, - }) - return - } - // Reject if the forEach call is followed by a chained call - // (.forEach(...).then(...) doesn't exist on void return, but - // .map(...).forEach(...).filter(...) would mean we're inside - // a chain — parent's a MemberExpression with us as object). - const parent = node.parent - if ( - parent && - parent.type === 'MemberExpression' && - parent.object === node - ) { - // forEach returns undefined; chaining off it is broken — skip - // rather than rewrite something that doesn't even run. - return - } - // forEach call must be its own ExpressionStatement to be a - // safe textual replacement. - if (!parent || parent.type !== 'ExpressionStatement') { - context.report({ - node, - messageId: 'preferCachedForNoFix', - data: { - shape: '.forEach', - reason: 'call result is consumed (not a standalone statement)', - }, - }) - return - } - - const iterText = sourceCode.getText(callee.object) - const itemName = itemParam.name - const indexName = indexParam ? indexParam.name : 'i' - // If the callback body reassigns the item param (e.g. - // `arr.forEach(line => { line = line.trim(); ... })`), the - // rewritten `const line = arr[i]` would trip `no-const-assign`. - // Emit `let` in that case so the rewrite preserves the - // mutable-binding semantics the original arrow had per call. - const itemKind = reassignsInBody(sourceCode, cb.body, itemName) - ? 'let' - : 'const' - - context.report({ - node, - messageId: 'preferCachedFor', - data: { iter: iterText, shape: '.forEach' }, - fix(fixer: RuleFixer) { - const bodyInner = sourceCode.text.slice( - cb.body.range[0] + 1, - cb.body.range[1] - 1, - ) - const indent = leadingIndent(sourceCode, parent) - const innerIndent = `${indent} ` - // `!` non-null assertion on the indexed access — under - // `noUncheckedIndexedAccess` the lookup returns `T | - // undefined`, and every read of `${itemName}` downstream - // would trip TS18048. The assertion is a no-op for - // tsconfigs that don't enable the strict flag, so it's - // safe to emit unconditionally. - const replacement = `for (let ${indexName} = 0, { length } = ${iterText}; ${indexName} < length; ${indexName} += 1) {\n${innerIndent}${itemKind} ${itemName} = ${iterText}[${indexName}]!${bodyInner}\n${indent}}` - return fixer.replaceText(parent, replacement) - }, - }) - }, - - ForOfStatement(node: AstNode) { - // for await ... — leave alone. - if (node.await) { - return - } - const left = node.left - if (left.type !== 'VariableDeclaration') { - // `for (item of arr)` — bare assignment; rare, skip. - return - } - if (left.declarations.length !== 1) { - return - } - const declarator = left.declarations[0] - if (!declarator.id || declarator.id.type !== 'Identifier') { - // Destructured loop var — typically Map/Set/.entries() - // iteration where there's no cached-for-loop equivalent. - // Skip silently rather than emit an unfixable warning. - return - } - // Iterable must be a bare Identifier — otherwise we don't - // know if it's a (cheap) array indexing target. The rewrite - // for a MemberExpression / CallExpression iterable IS doable - // (hoist to a local), but the human review is cleaner. - // Skip silently rather than nag. - const iter = node.right - if (iter.type !== 'Identifier') { - return - } - // SKIP when the iterable is a known Set / Map / Iterable — - // rewriting `for (const item of setVar)` to the cached-length - // shape produces a silent no-op (Set has no .length, isn't - // integer-indexable). The companion rule - // socket/no-cached-for-on-iterable would then flag what THIS - // rule just wrote. Skip silently rather than fight ourselves. - // - // Also skip when the kind can't be determined from the AST - // (e.g. `await fn()` / `someCall()` initializers without a - // type annotation). Without type info we can't prove the - // iterable is integer-indexable, and autofixing produces - // broken code (Set.length / Set[i]) on the wrong guess. - // Require explicit array shape (literal, type annotation, - // Array.from, Object.keys/values/entries) to opt in. - const iterKind = resolveKind(node, iter.name as string) - if (FLAGGED_KINDS.has(iterKind) || iterKind === 'unknown') { - return - } - if (node.body.type !== 'BlockStatement') { - // for (x of y) statement; rare. Skip. - return - } - - const itemName = declarator.id.name - const iterText = iter.name - const counterName = pickCounterName(itemName) - // Preserve the original `let`/`const` declaration kind from - // the `for...of`. `for (let item of arr)` opted into a - // mutable per-iteration binding (the body may reassign - // `item`); collapsing it to a `const` would break the loop. - // If the original was `const`, only keep `const` when the - // body never reassigns the loop variable. - const originalKind = left.kind - const itemKind = - originalKind === 'let' || - reassignsInBody(sourceCode, node.body, itemName) - ? 'let' - : 'const' - - context.report({ - node, - messageId: 'preferCachedFor', - data: { iter: iterText, shape: 'for...of' }, - fix(fixer: RuleFixer) { - const bodyInner = sourceCode.text.slice( - node.body.range[0] + 1, - node.body.range[1] - 1, - ) - const indent = leadingIndent(sourceCode, node) - const innerIndent = `${indent} ` - // `!` non-null assertion on the indexed access — see the - // sibling .forEach branch for the rationale. - const replacement = `for (let ${counterName} = 0, { length } = ${iterText}; ${counterName} < length; ${counterName} += 1) {\n${innerIndent}${itemKind} ${itemName} = ${iterText}[${counterName}]!${bodyInner}\n${indent}}` - return fixer.replaceText(node, replacement) - }, - }) - }, - } - }, -} - -/** - * Pick a counter-variable name that won't collide with the item variable. - * Defaults to `i`, falls back to `i2`, `i3`, ... if the item is itself named - * `i` (rare but defensive). - */ -export function pickCounterName(itemName: string): string { - if (itemName !== 'i') { - return 'i' - } - return 'i2' -} - -/** - * Textual check: does the loop body reassign the named identifier? Catches - * `name = ...`, `name +=`, `name++`, `++name`, etc., and - * destructuring-as-assignment patterns. Conservative: false positives only - * force `let` (semantically safe), false negatives trip `no-const-assign` (the - * bug this guards against). - * - * AST-walking would be more precise but oxlint's plugin host doesn't expose a - * uniform visitor for body subtrees here; the regex catches every reassignment - * shape that compiles today. - */ -export function reassignsInBody( - sourceCode: AstNode, - bodyNode: AstNode, - name: string, -): boolean { - if (!bodyNode) { - return false - } - const text = sourceCode.text.slice(bodyNode.range[0], bodyNode.range[1]) - // Escape any regex specials in the identifier (defensive — JS - // identifiers can't actually contain them, but cheap). - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - // Patterns: - // 1. <name> = ... (simple assignment, not `==` / `===`) - // 2. <name> += ... / -=, *=, /=, %=, **=, &=, |=, ^=, <<=, >>=, >>>=, &&=, ||=, ??= - // 3. <name>++ / <name>-- - // 4. ++<name> / --<name> - // 5. ({ <name> } = ...) / ([<name>] = ...) destructuring — caught by the - // same `<name>... =` shape inside a destructure since the rightmost - // `=` is the assignment. - // Use `\b` boundaries on the name. The `(?!=)` lookahead rejects `==`. - const reassignRE = new RegExp( - String.raw`\b${escaped}\b\s*(?:=(?!=)|[-+*/%&|^]=|<<=|>>=|>>>=|\*\*=|&&=|\|\|=|\?\?=|\+\+|--)`, - ) - if (reassignRE.test(text)) { - return true - } - // Prefix increment/decrement: `++<name>` / `--<name>`. - const prefixRE = new RegExp(String.raw`(?:\+\+|--)\s*\b${escaped}\b`) - return prefixRE.test(text) -} - -/** - * Recover the indentation prefix on the line where `node` starts so the - * rewritten block can re-indent its contents consistently with the surrounding - * code. - */ -export function leadingIndent(sourceCode: AstNode, node: AstNode): string { - const text = sourceCode.text - const start = node.range[0] - const lineStart = text.lastIndexOf('\n', start - 1) + 1 - const indent = text.slice(lineStart, start) - // Strip non-whitespace (in case the line has content before this - // statement). Indent is the leading-whitespace prefix only. - return /^\s*/.exec(indent)?.[0] ?? '' -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-ellipsis-char.mts b/.config/oxlint-plugin/rules/prefer-ellipsis-char.mts deleted file mode 100644 index 18eb5cb23..000000000 --- a/.config/oxlint-plugin/rules/prefer-ellipsis-char.mts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @file Per fleet "Code style" rule: in user-facing TEXT, three literal dots - * `...` should be the single ellipsis character `…` (U+2026). The ellipsis - * reads as one glyph, can't be confused with a truncated `..` / `....`, and - * matches the typography used across fleet UI copy, log messages, and docs. - * Detects `...` inside string literals, template-literal text, and comments. - * What this does NOT touch: - * - * - The JS/TS spread & rest operator (`...args`, `[...arr]`, `{ ...obj }`, - * `function f(...rest)`). Those are syntax, not text — the rule only visits - * `Literal` (string) / `TemplateElement` text, so a `SpreadElement` / - * `RestElement` `...` is never seen. - * - Intentional three-dot forms inside text: path globs (`/Users/<user>/...`, - * `src/...`) where a `/` sits next to the dots, and CLI-usage rest-args - * (`foo ...args`, `run foo ... bar`) where the dots are preceded by - * whitespace and followed by a word. Only a WORD-FINAL / sentence ellipsis - * — `Loading...`, `wait....`, `done...` — is a typography slip worth - * fixing. Autofix: replaces the matched word-final `...` run with `…`. - * Allowed (skipped): - * - The plugin's own rules/ + test/ files (fixtures contain `...` as data). - * - Any text carrying a `socket-hook: allow literal-ellipsis` comment. - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import { isPluginSelfFile } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -// A WORD-FINAL ellipsis: 3+ dots immediately preceded by a letter/digit and -// followed by end-of-text or sentence punctuation/whitespace — NOT by a -// character that signals path/CLI/bracket notation. Rationale per disallowed -// follower: -// - `[./]` — path globs (`a/...`, `.../b`, `....x`). -// - `[)\]}>]` — CLI usage / placeholder notation (`[path...]`, `(args...)`, -// `<rest...>`), where the dots mean "one or more" and must stay literal. -// The leading `[A-Za-z0-9]` rejects CLI rest-args (`foo ...args` — dots after a -// space) and standalone `...`. `....` (word + 4 dots) is still caught — `\.{3,}` -// soaks up the run, collapsed to one `…`. The G form (used by the fixer) -// captures the leading char to preserve it. -const ELLIPSIS_TAIL = String.raw`(?![./)\]}>])` -const WORD_FINAL_ELLIPSIS_RE = new RegExp( - String.raw`[A-Za-z0-9]\.{3,}${ELLIPSIS_TAIL}`, -) -const WORD_FINAL_ELLIPSIS_RE_G = new RegExp( - String.raw`([A-Za-z0-9])\.{3,}${ELLIPSIS_TAIL}`, - 'g', -) - -const BYPASS_RE = /socket-hook:\s*allow\s+literal-ellipsis/ - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Use the ellipsis character `…` (U+2026) instead of three literal dots `...` in string / template / comment text.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - literalEllipsis: - 'Three literal dots `...` in text — use the ellipsis character `…` (U+2026). It reads as one glyph and matches fleet typography. (Spread/rest `...` operators are not flagged.) For an intentional three-dot form, add `// socket-hook: allow literal-ellipsis`.', - }, - schema: [], - }, - - create(context: RuleContext) { - // This rule's own source + fixtures contain `...` as data. - if (isPluginSelfFile(context)) { - return {} - } - - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - // The fixer needs the node's raw source text to rewrite the dot-run. - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - // Report + autofix a string-literal / template node whose text contains a - // WORD-FINAL `...` run (a real ellipsis), skipping path globs + CLI - // rest-args. The fix rewrites the node's source text, collapsing each - // word-final dot-run to a single `…` while keeping the preceding char. - function checkTextNode(node: AstNode, text: string): void { - if (!WORD_FINAL_ELLIPSIS_RE.test(text)) { - return - } - if (hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'literalEllipsis', - fix(fixer: RuleFixer) { - const raw = sourceCode.getText(node) as string - return fixer.replaceText( - node, - raw.replace( - WORD_FINAL_ELLIPSIS_RE_G, - (_m, lead: string) => `${lead}…`, - ), - ) - }, - }) - } - - return { - Literal(node: AstNode) { - const v = (node as { value?: unknown | undefined }).value - if (typeof v === 'string') { - checkTextNode(node, v) - } - }, - TemplateElement(node: AstNode) { - const cooked = ( - node as { value?: { cooked?: string | undefined } | undefined } - ).value?.cooked - if (typeof cooked === 'string') { - checkTextNode(node, cooked) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-env-as-boolean.mts b/.config/oxlint-plugin/rules/prefer-env-as-boolean.mts deleted file mode 100644 index 147338f29..000000000 --- a/.config/oxlint-plugin/rules/prefer-env-as-boolean.mts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @file Per CLAUDE.md "Environment — boolean coercion": every `SOCKET_*` env - * getter (e.g. `getSocketDebug()`) returns `string | undefined`. Truthy - * coercion via `!!`, `Boolean(...)`, or `=== 'true'` / `== '1'` is wrong — CI - * commonly exports `SOCKET_DEBUG=0` (the string `'0'`) to mean OFF, but - * `!!'0'` is `true`. Use `envAsBoolean(v)` from - * `@socketsecurity/lib-stable/env/boolean` which treats only `1` / `true` / - * `yes` (case-insensitive) as true. Detects: - * - * - `!!getSocket<X>()` - * - `Boolean(getSocket<X>())` - * - `getSocket<X>() === 'true'` / `=== '1'` / `== 'true'` / `== '1'` …where - * `getSocket<X>` is any identifier whose name starts with `getSocket` and - * follows the `getSocket<Pascal>` convention used by - * `@socketsecurity/lib/env/*`. Name-pattern-based; doesn't follow types. - * False-positive rate is low because the fleet doesn't name local getters - * `getSocket*`. Autofix: rewrites to `envAsBoolean(<call>)` and adds the - * import when missing. Allowed (skip): - * - `getDebug()` and other non-`getSocket*` getters — those may legitimately - * consume the string value (e.g. the `debug` package's `'socket:*'` - * namespace). - */ - -import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const TRUTHY_LITERALS = new Set(['1', 'true']) - -function isSocketGetterCall(node: AstNode): boolean { - if (node.type !== 'CallExpression') { - return false - } - const callee = (node as { callee?: AstNode | undefined }).callee - if (!callee || callee.type !== 'Identifier') { - return false - } - const name = (callee as { name?: string | undefined }).name - if (!name) { - return false - } - return /^getSocket[A-Z]/.test(name) -} - -function isTruthyStringLiteral(node: AstNode): boolean { - if (node.type !== 'Literal') { - return false - } - const v = (node as { value?: unknown | undefined }).value - return typeof v === 'string' && TRUTHY_LITERALS.has(v) -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use envAsBoolean from @socketsecurity/lib-stable/env/boolean for SOCKET_* env coercion. Truthy coercion misclassifies the string "0" as true.', - category: 'Possible Errors', - recommended: true, - }, - fixable: 'code', - messages: { - coerce: - '`{{shape}}` misclassifies the string "0" / "false" as truthy. Use `envAsBoolean({{inner}})` from @socketsecurity/lib-stable/env/boolean.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - let summary: ReturnType<typeof summarizeImportTarget> | undefined - - function ensureSummary() { - if (!summary) { - summary = summarizeImportTarget( - sourceCode.ast, - '@socketsecurity/lib-stable/env/boolean', - 'envAsBoolean', - ) - } - return summary - } - - function reportAndFix( - node: AstNode, - shape: string, - innerExpr: AstNode, - ): void { - const innerText = sourceCode.getText(innerExpr) - const s = ensureSummary() - context.report({ - node, - messageId: 'coerce', - data: { shape, inner: innerText }, - fix(fixer: RuleFixer) { - return [ - fixer.replaceText(node, `envAsBoolean(${innerText})`), - ...appendImportFixes( - s, - fixer, - `import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean'`, - undefined, - ), - ] - }, - }) - } - - return { - UnaryExpression(node: AstNode) { - if ((node as { operator?: string | undefined }).operator !== '!') { - return - } - const arg = (node as { argument?: AstNode | undefined }).argument - if ( - !arg || - arg.type !== 'UnaryExpression' || - (arg as { operator?: string | undefined }).operator !== '!' - ) { - return - } - const inner = (arg as { argument?: AstNode | undefined }).argument - if (!inner || !isSocketGetterCall(inner)) { - return - } - reportAndFix(node, '!!getSocketX()', inner) - }, - - CallExpression(node: AstNode) { - const callee = (node as { callee?: AstNode | undefined }).callee - if ( - !callee || - callee.type !== 'Identifier' || - (callee as { name?: string | undefined }).name !== 'Boolean' - ) { - return - } - const args = - (node as { arguments?: AstNode[] | undefined }).arguments ?? [] - if (args.length !== 1) { - return - } - const arg = args[0]! - if (!isSocketGetterCall(arg)) { - return - } - reportAndFix(node, 'Boolean(getSocketX())', arg) - }, - - BinaryExpression(node: AstNode) { - const op = (node as { operator?: string | undefined }).operator - if (op !== '==' && op !== '===') { - return - } - const left = (node as { left?: AstNode | undefined }).left - const right = (node as { right?: AstNode | undefined }).right - if (!left || !right) { - return - } - if (isSocketGetterCall(left) && isTruthyStringLiteral(right)) { - reportAndFix(node, `getSocketX() ${op} '<literal>'`, left) - return - } - if (isSocketGetterCall(right) && isTruthyStringLiteral(left)) { - reportAndFix(node, `'<literal>' ${op} getSocketX()`, right) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-exists-sync.mts b/.config/oxlint-plugin/rules/prefer-exists-sync.mts deleted file mode 100644 index a1c4b7ae5..000000000 --- a/.config/oxlint-plugin/rules/prefer-exists-sync.mts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @file Per CLAUDE.md "File existence" rule: use `existsSync` from `node:fs`. - * Never `fs.access` / `fs.stat`-for-existence / async `fileExists` wrapper. - * Detects: - * - * - `fs.access(...)` / `fs.accessSync(...)` / `fs.promises.access(...)` - * - `fs.stat(...)` / `fs.statSync(...)` / `fs.promises.stat(...)` when the - * result is being used in a boolean / try-catch context (a strong signal of - * "is it there"). We can't perfectly detect all existence-checks, but - * flagging every `access(...)` and `statSync(...)` covers the common cases - * — false positives are fixed by switching to existsSync, which is - * harmless. - * - Custom wrappers: `fileExists(p)` / `pathExists(p)` / `isFile(p)` / - * `isDir(p)`. Autofix scope: - * - **Deterministic**: custom wrappers (`fileExists(p)` / `pathExists(p)` / - * `isFile(p)` / `isDir(p)`) are rewritten to `existsSync(p)` with `import { - * existsSync } from 'node:fs'` injected. Same arity, same boolean - * semantics, drop-in safe. - * - **AI-handled** (Step 4 of `pnpm run fix`): `fs.access` / `fs.stat` - * rewrites. These flip control flow — `try { await fs.access(p); … } catch - * { … }` becomes `if (existsSync(p)) { … } else { … }`, and `const s = - * await fs.stat(p)` with metadata access (`s.size`, `s.isDirectory()`) - * needs to stay a stat call. The right rewrite depends on the surrounding - * code, but the pattern is mechanical enough for the AI step to handle - * reliably with the canonical guidance in scripts/ai-lint-fix.mts. - */ - -import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const ACCESS_METHODS = new Set(['access', 'accessSync']) -const STAT_METHODS = new Set(['lstat', 'lstatSync', 'stat', 'statSync']) -const WRAPPER_NAMES = new Set(['fileExists', 'isDir', 'isFile', 'pathExists']) - -const EXISTS_SYNC_IMPORT_LINE = "import { existsSync } from 'node:fs'" - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Prefer existsSync from node:fs over fs.access / fs.stat-for-existence / async fileExists wrapper.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - access: - 'fs.{{method}}() — use existsSync from node:fs for existence checks. fs.access throws on missing files (forces try/catch); existsSync returns boolean directly.', - stat: 'fs.{{method}}() — if you only need to know whether the path exists, use existsSync from node:fs. If you need the metadata (size, mtime), keep stat but state intent in a comment.', - fileExists: - 'Custom `{{name}}` wrapper — use existsSync from node:fs directly.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - let summary: ReturnType<typeof summarizeImportTarget> | undefined - - function ensureSummary() { - if (summary) { - return summary - } - summary = summarizeImportTarget(sourceCode.ast, 'node:fs', 'existsSync') - return summary - } - - function calleeMethodName(callee: AstNode): string | undefined { - if (callee.type !== 'MemberExpression') { - return undefined - } - if (callee.property.type !== 'Identifier') { - return undefined - } - return callee.property.name - } - - /** - * Wrappers are only fixable when: - exactly 1 argument (matches existsSync - * arity) - argument is not a SpreadElement. - * - * The call is often wrapped in `await` — that's fine. Replacing `await - * fileExists(p)` with `existsSync(p)` (no await) is the intended rewrite; - * existsSync is sync and the surrounding `await` collapses to a no-op on a - * non-promise value. - */ - function isFixableWrapperCall(node: AstNode) { - if (node.arguments.length !== 1) { - return false - } - if (node.arguments[0].type === 'SpreadElement') { - return false - } - return true - } - - return { - CallExpression(node: AstNode) { - const method = calleeMethodName(node.callee) - if (!method) { - // Direct call: `await fileExists(p)` — flag known wrapper - // names and autofix to `existsSync(p)`. - if ( - node.callee.type === 'Identifier' && - WRAPPER_NAMES.has(node.callee.name) - ) { - const name = node.callee.name - if (!isFixableWrapperCall(node)) { - context.report({ - node, - messageId: 'fileExists', - data: { name }, - }) - return - } - - const s = ensureSummary() - const argText = sourceCode.getText(node.arguments[0]) - - context.report({ - node, - messageId: 'fileExists', - data: { name }, - fix(fixer: RuleFixer) { - // Replace just the callee identifier — preserve - // arg text + parens. `await` (if present) becomes a - // no-op against a sync boolean return; safe to leave. - return [ - fixer.replaceText(node, `existsSync(${argText})`), - ...appendImportFixes( - s, - fixer, - EXISTS_SYNC_IMPORT_LINE, - undefined, - ), - ] - }, - }) - } - return - } - - if (ACCESS_METHODS.has(method)) { - context.report({ - node, - messageId: 'access', - data: { method }, - }) - } else if (STAT_METHODS.has(method)) { - context.report({ - node, - messageId: 'stat', - data: { method }, - }) - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-function-declaration.mts b/.config/oxlint-plugin/rules/prefer-function-declaration.mts deleted file mode 100644 index b8e12a0cc..000000000 --- a/.config/oxlint-plugin/rules/prefer-function-declaration.mts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @file Module-scope function definitions should use `function foo() {}` - * declarations, not `const foo = () => {}` or `const foo = function () {}` - * expressions. Function declarations hoist, sort cleanly under - * `sort-source-methods`, and render with a stable `foo.name` in stack traces - * — arrow expressions assigned to `const` lose all three properties (no - * hoisting, treated as statements by the sort rule, and `.name` is the - * variable name which is fragile across refactors). Style signal that - * motivated the rule: across the fleet's six surveyed repos, the ratio of - * `function` declarations to top-level arrow `const`s is overwhelming — - * socket-cli 962:5, socket-lib 842:13, socket-sdk-js 200:6. The arrow - * stragglers are drift. Autofix scope (deterministic only): - * - * - `const foo = () => { ... }` (block body) → `function foo() { ... }` - * - `const foo = (a, b) => expr` (expression body) → `function foo(a, b) { - * return expr }` - * - `const foo = function (a, b) { ... }` → `function foo(a, b) { ... }` - * - `const foo = async () => { ... }` → `async function foo() {}` - * - `export const foo = () => {}` → `export function foo() {}` (preserves the - * export) Skips (report-only, no fix): - * - Generator function expressions (`function*`) — autofix needs to insert `*` - * after `function` without losing the name, and the construct is rare - * enough that the human can do it. - * - Destructured / non-Identifier declarators (`const { foo } = ...`, `const - * [foo] = ...`). - * - Multi-declarator `const foo = ..., bar = ...` — splitting into declarations - * - function declarations is messy; the reader should split it manually first. - * - Declarations carrying a TS type annotation (`const foo: Handler = () => - * {}`) — the annotation is the contract and would need to migrate to a - * `satisfies` or be dropped. Human call. - * - Functions that reference `this` — declaration-form `function` has its own - * `this`; arrows inherit. Static check: the function body contains the - * `this` keyword anywhere. - * - Functions inside non-Program scopes (loops, conditionals, etc.) — only the - * top-level (Program body) shape is rewritten. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SKIP_TYPE_ANNOTATION = true - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Module-scope functions should use `function foo() {}` declarations instead of `const foo = () => ...` / `const foo = function () {}`.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - preferFunctionDeclaration: - 'Module-scope `{{name}}` is an arrow/function expression. Use `function {{name}}() {}` — hoists, sorts under `sort-source-methods`, and renders a stable name in stack traces.', - preferFunctionDeclarationNoFix: - 'Module-scope `{{name}}` should be a `function` declaration, but autofix is unsafe here (generator / `this` reference / type-annotated declarator / multi-declarator binding). Rewrite manually.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - return { - VariableDeclaration(node: AstNode) { - // Only top-level: Program body, or `export const ...` whose - // parent is the Program body. - const parent = node.parent - const isTopLevel = - (parent && parent.type === 'Program') || - (parent && - (parent.type === 'ExportDefaultDeclaration' || - parent.type === 'ExportNamedDeclaration') && - parent.parent && - parent.parent.type === 'Program') - if (!isTopLevel) { - return - } - if (node.kind !== 'const') { - return - } - if (node.declarations.length !== 1) { - return - } - - const decl = node.declarations[0] - if (!decl.id || decl.id.type !== 'Identifier') { - return - } - if (!decl.init) { - return - } - const init = decl.init - if ( - init.type !== 'ArrowFunctionExpression' && - init.type !== 'FunctionExpression' - ) { - return - } - - const name = decl.id.name - - // Skip generator function expressions — autofix below doesn't - // re-insert the `*`. - if (init.generator) { - context.report({ - node: decl.id, - messageId: 'preferFunctionDeclarationNoFix', - data: { name }, - }) - return - } - - // Skip declarators that carry a type annotation — the - // annotation needs migration. - if (SKIP_TYPE_ANNOTATION && decl.id.typeAnnotation) { - context.report({ - node: decl.id, - messageId: 'preferFunctionDeclarationNoFix', - data: { name }, - }) - return - } - - // Skip if the function body references `this` — declaration - // form has its own `this`, would change semantics. - if (init.type === 'ArrowFunctionExpression' && referencesThis(init)) { - context.report({ - node: decl.id, - messageId: 'preferFunctionDeclarationNoFix', - data: { name }, - }) - return - } - - context.report({ - node: decl.id, - messageId: 'preferFunctionDeclaration', - data: { name }, - fix(fixer: RuleFixer) { - const asyncPrefix = init.async ? 'async ' : '' - const params = init.params - .map((p: AstNode) => sourceCode.getText(p)) - .join(', ') - let body - if (init.body.type === 'BlockStatement') { - body = sourceCode.getText(init.body) - } else { - // Expression body — wrap in a block with `return`. - body = `{\n return ${sourceCode.getText(init.body)}\n}` - } - const replacement = `${asyncPrefix}function ${name}(${params}) ${body}` - // Replace the whole VariableDeclaration node (which - // includes the trailing semicolon if any — the - // declaration form doesn't take one but oxfmt will - // normalize on the next pass). - return fixer.replaceText(node, replacement) - }, - }) - }, - } - }, -} - -/** - * Walk the function body iteratively looking for a `ThisExpression`. - * - * We previously serialized the AST with JSON.stringify + regex on `\bthis\b`, - * but oxlint's AST nodes can carry back-references (parent, scope, type-arg - * back-pointers from the TS plugin) via getters that return fresh wrapper - * objects. A WeakSet de-cycle keyed on object identity misses those cases — the - * seen-check returns false and JSON.stringify hits the limit and throws - * "Converting circular structure to JSON," crashing the rule. The AST walk - * avoids serialization entirely: each visit checks the node's `type` and pushes - * child nodes onto a work queue. Identity- based seen-set still de-cycles for - * safety, this time without paying the cost of stringification. - */ -function referencesThis(node: AstNode) { - if (!node.body) { - return false - } - const seen = new WeakSet() - // Inline child-list keys we know the ESTree shape uses. Skip - // `parent` and other navigational back-refs — anything that's not - // a structural child of the function body. - const STRUCTURAL_KEYS = [ - 'argument', - 'arguments', - 'body', - 'callee', - 'cases', - 'consequent', - 'declaration', - 'declarations', - 'discriminant', - 'elements', - 'expression', - 'expressions', - 'finalizer', - 'handler', - 'id', - 'init', - 'key', - 'left', - 'object', - 'param', - 'params', - 'properties', - 'property', - 'quasi', - 'quasis', - 'right', - 'specifiers', - 'tag', - 'test', - 'update', - 'value', - ] - const queue = [ - node.body.type === 'BlockStatement' ? node.body.body : node.body, - ] - while (queue.length > 0) { - const item = queue.pop() - if (item === null || item === undefined) { - continue - } - if (Array.isArray(item)) { - for (let i = 0, { length } = item; i < length; i += 1) { - queue.push(item[i]) - } - continue - } - if (typeof item !== 'object') { - continue - } - if (seen.has(item)) { - continue - } - seen.add(item) - if (item.type === 'ThisExpression') { - return true - } - // Don't recurse into nested function-like nodes — they bind - // their own `this`, so a `this` inside them doesn't count. - if ( - item.type === 'FunctionDeclaration' || - item.type === 'FunctionExpression' - ) { - continue - } - for (let i = 0, { length } = STRUCTURAL_KEYS; i < length; i += 1) { - const k = STRUCTURAL_KEYS[i] - if (k && item[k] !== undefined) { - queue.push(item[k]) - } - } - } - return false -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-node-builtin-imports.mts b/.config/oxlint-plugin/rules/prefer-node-builtin-imports.mts deleted file mode 100644 index 2ed96bce6..000000000 --- a/.config/oxlint-plugin/rules/prefer-node-builtin-imports.mts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * @file Per CLAUDE.md "Imports" rule: `node:fs` cherry-picks (`existsSync`, - * `promises as fs`); `path` / `os` / `url` / `crypto` use default imports. - * Exception: `fileURLToPath` from `node:url`. The fleet's Node-builtin import - * shape is asymmetric on purpose: - * - * - `node:fs` is large; cherry-picking is the canonical idiom and keeps the - * import line meaningful (you can read off which fs APIs the module - * actually uses). - * - `node:path`, `node:os`, `node:url`, `node:crypto` are small; a default - * import (`import path from 'node:path'`) reads cleaner than four named - * imports and matches the way most fleet code references `path.join` / - * `path.resolve` / `path.dirname`. - * - `fileURLToPath` is the documented exception — named import from `node:url` - * is allowed because every caller uses just that one symbol and - * `url.fileURLToPath(import.meta.url)` reads worse than - * `fileURLToPath(import.meta.url)`. Detects: - * - `import fs from 'node:fs'` / `import * as fs from 'node:fs'` — recommends - * named imports. - * - `import { join, resolve } from 'node:path'` — recommends default import + - * dotted access (`path.join`, `path.resolve`). - * - Same for `node:os`, `node:url` (with `fileURLToPath` exception), - * `node:crypto`. Autofix: - * - `import { join } from 'node:path'` → `import path from 'node:path'` AND - * every `join(...)` reference in the file is rewritten to `path.join(...)`. - * Same shape for os/url/crypto. Skipped when the file already has a default - * import for the module (would double-import). - * - `import fs from 'node:fs'` / `import * as fs from 'node:fs'` → scans the - * file's references to the local binding (e.g. `fs`), collects the set of - * accessed properties (`fs.existsSync`, `fs.readFileSync`), and rewrites - * the import to a sorted named-imports clause. Each `fs.X` reference is - * rewritten to bare `X`. Skipped when: a) any reference shape is "weird" - * (computed access `fs[expr]`, spread `...fs`, passed as a value `fn(fs)`, - * reassignment). Those need human eyes — the rewrite would lose semantics. - * b) collected names collide with existing top-level bindings in the file. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const PREFER_DEFAULT = ['node:path', 'node:os', 'node:url', 'node:crypto'] -const DEFAULT_LOCAL = { - 'node:path': 'path', - 'node:os': 'os', - 'node:url': 'url', - 'node:crypto': 'crypto', -} - -// `fileURLToPath` is the documented exception per CLAUDE.md. -const NAMED_EXCEPTIONS = { - 'node:url': new Set(['fileURLToPath']), -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use cherry-pick named imports for node:fs and default imports for node:path / os / url / crypto. Per CLAUDE.md "Imports" rule.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - fsDefault: - "`import fs from 'node:fs'` — use cherry-pick named imports (e.g. `import { existsSync } from 'node:fs'`). Per CLAUDE.md.", - fsNamespace: - "`import * as fs from 'node:fs'` — use cherry-pick named imports. Per CLAUDE.md.", - preferDefault: - "`import {{names}} from '{{specifier}}'` — use a default import and dotted access (`{{local}}.{{first}}`). Per CLAUDE.md.", - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * Look at the program body to determine whether `localName` is already in - * use (any binding form). If so, autofixing to a default import would - * shadow it. - */ - function localBindingExists( - programBody: AstNode[], - localName: string, - ): boolean { - for (let i = 0, { length } = programBody; i < length; i += 1) { - const stmt = programBody[i]! - if (stmt.type === 'ImportDeclaration') { - for (const spec of stmt.specifiers) { - if ( - spec.local && - spec.local.name === localName && - // Only count it as a clash if the import comes from a - // *different* specifier — same-specifier same-local - // means we'd be re-defining the same import. - stmt.source.value !== '' - ) { - return true - } - } - continue - } - if (stmt.type === 'VariableDeclaration') { - for (const decl of stmt.declarations) { - if ( - decl.id && - decl.id.type === 'Identifier' && - decl.id.name === localName - ) { - return true - } - } - } - } - return false - } - - return { - ImportDeclaration(node: AstNode) { - const specifier = node.source.value - if (typeof specifier !== 'string') { - return - } - - // Type-only imports have zero runtime impact — they exist purely - // for the type checker (e.g. `import type * as NodeFs from - // 'node:fs'` used in `vi.importActual<typeof NodeFs>('node:fs')` - // type arguments). The fleet's value-import shape rules don't - // apply to them: a type namespace import doesn't carry the - // "loaded the whole module" semantics of a value namespace - // import. Skip. - if (node.importKind === 'type') { - return - } - - // node:fs — should be named-imports. - if (specifier === 'node:fs') { - let bannedSpec - let messageId - for (const spec of node.specifiers) { - if (spec.type === 'ImportDefaultSpecifier') { - bannedSpec = spec - messageId = 'fsDefault' - break - } - if (spec.type === 'ImportNamespaceSpecifier') { - bannedSpec = spec - messageId = 'fsNamespace' - break - } - } - if (!bannedSpec) { - return - } - - const fsLocalName = bannedSpec.local.name - - // Walk the scope graph to collect every reference to the - // local binding. If any reference is "weird" (not a plain - // member expression on the read side), bail on the autofix - // and report only — the rewrite isn't safe. - const scope = context.getScope ? context.getScope() : undefined - if (!scope) { - context.report({ node, messageId }) - return - } - - const accessed = new Set<string>() - const memberRefs: AstNode[] = [] - let unsafe = false - - function visit(s: AstNode, visited: Set<AstNode>): void { - if (visited.has(s)) { - return - } - visited.add(s) - for (const ref of s.references) { - if (ref.identifier.name !== fsLocalName) { - continue - } - // Skip the import-binding declaration itself. - if ( - ref.identifier.range[0] >= node.range[0] && - ref.identifier.range[1] <= node.range[1] - ) { - continue - } - const refParent = ref.identifier.parent - if ( - !refParent || - refParent.type !== 'MemberExpression' || - refParent.object !== ref.identifier || - refParent.computed || - refParent.property.type !== 'Identifier' - ) { - // Weird usage shape — bail. - unsafe = true - return - } - accessed.add(refParent.property.name) - memberRefs.push(refParent) - } - for (const child of s.childScopes) { - if (unsafe) { - return - } - visit(child, visited) - } - } - - visit(scope, new Set()) - - if (unsafe || accessed.size === 0) { - // No usable references (or shadowed/aliased usage) — drop - // back to report-only. - context.report({ node, messageId }) - return - } - - // Skip autofix if any accessed name collides with an - // existing top-level binding (would shadow on rewrite). - const programBody = sourceCode.ast.body - for (const name of accessed) { - if (localBindingExists(programBody, name)) { - context.report({ node, messageId }) - return - } - } - - const sorted = [...accessed].toSorted() - const newImport = `import { ${sorted.join(', ')} } from 'node:fs'` - - context.report({ - node, - messageId, - fix(fixer: RuleFixer) { - const fixes = [fixer.replaceText(node, newImport)] - for (let i = 0, { length } = memberRefs; i < length; i += 1) { - const ref = memberRefs[i]! - // Replace `fs.X` with bare `X`. We need the entire - // member expression, not just the object. - fixes.push(fixer.replaceText(ref, ref.property.name)) - } - return fixes - }, - }) - return - } - - // node:path / os / url / crypto — should be default-import. - if (!PREFER_DEFAULT.includes(specifier)) { - return - } - - // If there's already a default import on this statement, - // accept the rest of the named imports as-is — multi-form - // mix-ins (`import path, { sep } from 'node:path'`) are - // unusual but tolerated. - const hasDefault = node.specifiers.some( - (s: AstNode) => s.type === 'ImportDefaultSpecifier', - ) - if (hasDefault) { - return - } - - const named = node.specifiers.filter( - (s: AstNode) => s.type === 'ImportSpecifier', - ) - if (named.length === 0) { - return - } - - // Allow documented exceptions (e.g. `fileURLToPath`). - const exceptions = (NAMED_EXCEPTIONS as Record<string, Set<string>>)[ - specifier - ] - const violatingNames = exceptions - ? named.filter( - (s: AstNode) => - s.imported && - s.imported.name && - !exceptions.has(s.imported.name), - ) - : named - if (violatingNames.length === 0) { - return - } - - const local = (DEFAULT_LOCAL as Record<string, string>)[specifier]! - const violatingNameList = violatingNames - .map((s: AstNode) => s.imported.name) - .join(', ') - - // Skip autofix if the local binding (`path`, `os`, etc.) - // already exists in the file under another name. - const programBody = sourceCode.ast.body - if (localBindingExists(programBody, local)) { - context.report({ - node, - messageId: 'preferDefault', - data: { - names: `{ ${violatingNameList} }`, - specifier, - local, - first: violatingNames[0]!.imported.name, - }, - }) - return - } - - // Reference rewriting needs scope analysis to find every `homedir()` / - // `platform()` call site and prefix it with `<local>.`. When the oxlint - // engine doesn't expose `getScope` (older versions return nothing), we - // can only safely rewrite the import line — which would leave the bare - // call sites undefined (`ReferenceError`). So in that case report WITHOUT - // a fix: the author rewrites by hand. Better a manual fix than a - // half-conversion that breaks the module. - const scopeForFix = context.getScope ? context.getScope() : undefined - if (!scopeForFix) { - context.report({ - node, - messageId: 'preferDefault', - data: { - names: `{ ${violatingNameList} }`, - specifier, - local, - first: violatingNames[0]!.imported.name, - }, - }) - return - } - - context.report({ - node, - messageId: 'preferDefault', - data: { - names: `{ ${violatingNameList} }`, - specifier, - local, - first: violatingNames[0]!.imported.name, - }, - fix(fixer: RuleFixer) { - const fixes: AstNode[] = [] - - // Rewrite the import statement. - const keptNamed = exceptions - ? named.filter( - (s: AstNode) => - s.imported && - s.imported.name && - exceptions.has(s.imported.name), - ) - : [] - - let newImport - if (keptNamed.length > 0) { - const keptText = keptNamed - .map((s: AstNode) => sourceCode.getText(s)) - .join(', ') - newImport = `import ${local}, { ${keptText} } from '${specifier}'` - } else { - newImport = `import ${local} from '${specifier}'` - } - fixes.push(fixer.replaceText(node, newImport)) - - // Rewrite every reference in the file: each violating - // named import becomes `<local>.<name>`. - // - // Walk the source text and look for word-boundary matches - // of each violating name. Skip occurrences inside - // strings/comments to avoid breaking unrelated text. - // - // Scope analysis is guaranteed available here — the report above - // returns early (report-only, no fix) when getScope is absent. - const scope = scopeForFix - const targetNames = new Set( - violatingNames.map((s: AstNode) => s.local.name), - ) - - if (scope) { - const visited = new Set<AstNode>() - - function visitScope(s: AstNode): void { - if (visited.has(s)) { - return - } - visited.add(s) - for (const ref of s.references) { - if (!targetNames.has(ref.identifier.name)) { - continue - } - // Skip the import-declaration's own binding. - if ( - ref.identifier.range[0] >= node.range[0] && - ref.identifier.range[1] <= node.range[1] - ) { - continue - } - fixes.push( - fixer.replaceText( - ref.identifier, - `${local}.${ref.identifier.name}`, - ), - ) - } - for (const child of s.childScopes) { - visitScope(child) - } - } - - visitScope(scope) - } - - return fixes - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-node-modules-dot-cache.mts b/.config/oxlint-plugin/rules/prefer-node-modules-dot-cache.mts deleted file mode 100644 index 1d188008d..000000000 --- a/.config/oxlint-plugin/rules/prefer-node-modules-dot-cache.mts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * @file Fleet convention: per-repo tool caches live in `node_modules/.cache/`, - * NOT `<repo-root>/.cache/`. Why `node_modules/.cache/`: - * - * - It's the convention every JS build tool already uses (vitest, babel, - * terser, webpack, etc.) — discoverable. - * - It's gitignored everywhere (pnpm/npm gitignore `node_modules/`). - * - `pnpm install` blows it away when needed (no stale-cache headaches - * surviving a fresh checkout). - * - Centralizes cache location so the fleet's drift sweep can reason about it. - * Repo-root `.cache/` works because the fleet's gitignore has a `.cache/` - * glob, but it's a second canonical location for the same concept — - * duplication invites drift. Detects: - * - String literals `'.cache/...'` / `'./.cache/...'` / `'/.cache/...'` not - * preceded by `'node_modules'`. - * - `path.join(<args>, '.cache', ...)` where no prior arg is the literal - * `'node_modules'`. Exempts: - * - `path.join(home, '.cache', ...)` where the first arg is an identifier that - * obviously names a user-home dir (`home`, `homedir`, `userHome`, etc.) or - * is a call to `os.homedir()` or `os.userInfo().homedir`, or reads an - * HOME-style env var (`HOME`, `XDG_CACHE_HOME`, `LOCALAPPDATA`, `APPDATA`). - * These are XDG-spec platform-dir helpers, NOT repo-root cache paths. - * Autofix: none (the rewrite needs context — sometimes you want - * `node_modules/.cache/foo`, sometimes `node_modules/.cache/<pkg>/foo`, - * sometimes a temp dir is appropriate). Report-only; manual fix. Scope: .ts - * / .cts / .mts / .js / .cjs / .mjs. - */ - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -// Match `.cache` only as a path segment inside a larger path, never as -// a bare standalone string. A bare `.cache` is conventionally a -// `path.join` arg — those are handled by the call-shape visitor, which -// can apply the user-home-dir exemption. Detecting bare `.cache` here -// double-flags every `path.join(home, '.cache', app)` from XDG helpers. -// -// Inputs are normalized through @socketsecurity/lib-stable's `normalizePath` -// before this regex runs, so we only have to match the `/` form. - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const REPO_CACHE_STRING_RE = /(?:^|\/)\.cache\/|\/\.cache$/ - -// Identifier names whose value is conventionally a user-home dir. -// Matched case-insensitively so `home`, `Home`, `homeDir`, `HOME` etc. -// all hit. -const HOME_IDENT_RE = /^(?:home(?:dir)?|userhome|userdir|app(?:data|home))$/i - -// Env-var names that hold user-home dirs (the XDG/Windows variants). -// Used when the first arg is `process.env['VAR']` or `process.env.VAR`. -const HOME_ENV_RE = - /^(?:HOME|XDG_(?:CACHE|CONFIG|DATA|STATE)_HOME|XDG_RUNTIME_DIR|LOCALAPPDATA|APPDATA|USERPROFILE)$/ - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer `node_modules/.cache/` over repo-root `.cache/` for per-repo tool caches.', - category: 'Best Practices', - recommended: true, - }, - fixable: undefined, - messages: { - pathLiteral: - 'Cache path `{{value}}` should live under `node_modules/.cache/`, not repo-root `.cache/`. Fleet convention puts per-repo tool caches in `node_modules/.cache/<name>` (auto-gitignored, swept on `pnpm install`).', - pathJoin: - "`path.join(..., '.cache', ...)` puts the cache at repo root. Use `path.join(<pkgRoot>, 'node_modules', '.cache', <name>)` instead.", - }, - schema: [], - }, - - create(context: RuleContext) { - /** - * Is the leading segment of `value` already `node_modules`? Catches - * `node_modules/.cache/foo` (allowed) without false-positive on - * `.cache/foo` (forbidden). Input is expected to be already normalized - * (forward slashes). - */ - function isNodeModulesCache(value: string): boolean { - return /(^|\/)node_modules\/\.cache(\/|$)/.test(value) - } - - /** - * True for a Literal node whose string value matches the repo-root `.cache` - * pattern and is NOT already a `node_modules/.cache` path. - */ - function isRepoRootCacheString(node: AstNode) { - if (node.type !== 'Literal' && node.type !== 'TemplateElement') { - return false - } - const raw = - node.type === 'TemplateElement' - ? (node.value?.cooked ?? '') - : typeof node.value === 'string' - ? node.value - : '' - if (!raw) { - return false - } - // Normalize backslashes → forward slashes, collapse `.` / `..` segments, - // preserve UNC/namespace prefixes. Lets us use a single-separator - // regex below instead of `[/\\]` duplicated everywhere. - const norm = normalizePath(raw) - if (!REPO_CACHE_STRING_RE.test(norm)) { - return false - } - if (isNodeModulesCache(norm)) { - return false - } - return true - } - - /** - * True when `node` is, by name or shape, an expression that yields the - * current user's home dir. Used to exempt XDG / platform-dir helpers (where - * `~/.cache/<app>` is the correct convention, not a fleet violation). - * - * Matches: - * - * - Identifier whose name fits HOME_IDENT_RE (`home`, `homedir`, etc.) - * - `os.homedir()` call (or `nodeOs.homedir()`, any `<id>.homedir()`) - * - `process.env.HOME` / `process.env['HOME']` / same for XDG vars - */ - function isHomeDirExpression(node: AstNode) { - if (!node) { - return false - } - // `home` / `homedir` / `userHome` / `appData` identifier. - if (node.type === 'Identifier' && HOME_IDENT_RE.test(node.name)) { - return true - } - // `os.homedir()` and friends. - if ( - node.type === 'CallExpression' && - node.callee.type === 'MemberExpression' && - !node.callee.computed && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'homedir' - ) { - return true - } - // `process.env.HOME` / `process.env['HOME']`. - if (node.type === 'MemberExpression') { - const obj = node.object - const prop = node.property - const isProcessEnv = - obj.type === 'MemberExpression' && - obj.object.type === 'Identifier' && - obj.object.name === 'process' && - !obj.computed && - obj.property.type === 'Identifier' && - obj.property.name === 'env' - if (isProcessEnv) { - const key = - !node.computed && prop.type === 'Identifier' - ? prop.name - : prop.type === 'Literal' && typeof prop.value === 'string' - ? prop.value - : '' - if (key && HOME_ENV_RE.test(key)) { - return true - } - } - } - return false - } - - /** - * Detect `path.join(...args)` where `'.cache'` is one of the args and no - * PRIOR arg is `'node_modules'`. We approximate "prior" by walking - * left-to-right. - */ - function checkPathJoin(node: AstNode) { - if (node.type !== 'CallExpression') { - return - } - const callee = node.callee - if ( - callee.type !== 'MemberExpression' || - callee.computed || - callee.property.type !== 'Identifier' || - callee.property.name !== 'join' - ) { - return - } - // Accept `path.join(...)` and `nodePath.join(...)` and `posix.join` - // — anything named `join` on an identifier. Cheaper than tracking - // imports; false positives are vanishingly rare (no one names a - // non-path util `.join`). - const args = node.arguments - // Bail when the first arg is a user-home expression: this is an - // XDG-style platform-dir helper, not a repo-root cache. - if (args.length > 0 && isHomeDirExpression(args[0])) { - return - } - let sawNodeModules = false - for (let i = 0; i < args.length; i += 1) { - const a = args[i] - if (a.type === 'Literal' && typeof a.value === 'string') { - if (a.value === 'node_modules') { - sawNodeModules = true - continue - } - if (a.value === '.cache' && !sawNodeModules) { - context.report({ - node: a, - messageId: 'pathJoin', - }) - return - } - } - } - } - - /** - * Visit Literal / TemplateElement nodes and flag repo-root .cache paths. - */ - function checkLiteral(node: AstNode) { - if (!isRepoRootCacheString(node)) { - return - } - const value = - node.type === 'TemplateElement' ? node.value?.cooked : node.value - context.report({ - node, - messageId: 'pathLiteral', - data: { value: String(value) }, - }) - } - - return { - Literal: checkLiteral, - TemplateElement: checkLiteral, - CallExpression: checkPathJoin, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-non-capturing-group.mts b/.config/oxlint-plugin/rules/prefer-non-capturing-group.mts deleted file mode 100644 index 789726dca..000000000 --- a/.config/oxlint-plugin/rules/prefer-non-capturing-group.mts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * @file Per CLAUDE.md "Regex" rule: when a capturing group's captured value - * isn't used, write it as a non-capturing group instead. Detects bare `(...)` - * groups in regex literals and reports them as `(?:...)` candidates. A - * capture is "used" if any of the following appear anywhere in the same file - * source: - * - * - Numbered backreference inside a regex pattern: `\1`, `\2`, … - * - Numeric capture reference in a string literal: `$1`, `$2`, … (replacement - * strings in `.replace()`). - * - Array index on a regex result: `match[N]`, `result[N]`, `m[N]`, etc. - * - Destructured access: `[, captured] = re.exec(str)` or `[full, first] = - * str.match(re)`. - * - `RegExp.$1` (legacy global), `.matchAll(...)`, `.match(...)` call sites - * where the return value is read by index. Conservative posture: when ANY - * of these markers appears anywhere in the file, the rule STAYS SILENT — it - * cannot tell which specific regex's captures are being consumed without - * much heavier analysis, so the safe move is to defer entirely to the - * author. When the file has no such markers, the rule reports AND autofixes - * `(...)` → `(?:...)` in place. Allowed exceptions (skipped, no report): - * - Group already non-capturing: `(?:...)`, `(?=...)`, `(?!...)`, - * `(?<...>...)`. - * - Single-character groups holding a single alternation element only when the - * regex flags include `g`/`y`/`d`: those modes change capture semantics - * enough that we keep hands off. - * - The line carries `// socket-hook: allow capture` (or `# / /*` variants). - * This rule encodes a small but persistent cleanup the fleet keeps wanting: - * regex alternation groups written `(md|mdx)` when `(?:md|mdx)` was meant — - * no replacement, no `match[N]` indexing — wastes a capture allocation per - * match. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -interface CaptureGroup { - start: number - end: number - inner: string -} - -const SOCKET_HOOK_MARKER_RE = - /(?:#|\/\/|\/\*)\s*socket-hook:\s*allow(?:\s+([\w-]+))?/ - -// Markers that indicate at least one regex in the file uses captures. -// Conservative — any single hit disables autofix for the whole file -// (we can't tell which regex the user is referencing). -const CAPTURE_USAGE_RES: readonly RegExp[] = [ - // Replacement-string indexed captures: `'$1'`, `"$2"`, `` `$3` ``. - /['"`][^'"`]*\$\d[^'"`]*['"`]/, - // Indexed access with a numeric index on any identifier — accepts - // both direct (`m[1]`) and optional-chain (`m?.[1]`) forms. Numeric- - // index access on arbitrary identifiers is uncommon outside regex / - // tuple / NodeList contexts, and false positives just keep the rule - // silent (no false-flag). - /\b[A-Za-z_$][\w$]*\s*\??\.?\s*\[\s*\d+\s*\]/, - // Destructured exec/match result: `const [, first] = re.exec(s)` / - // `const [full, first] = s.match(re)`. - /\[\s*[\w$,\s]+\]\s*=\s*[^;]+\.(?:exec|match|matchAll)\b/, - // Legacy `RegExp.$1` accessors. - /\bRegExp\.\$\d\b/, - // `match.groups.name` / `m.groups.name` — named-capture usage means - // the author knows their captures matter; stay out. - /\b(?:m|match|res|result)\.groups\b/, - // `.replace(re, '...$1...')` — even if the replacement isn't a - // string literal we matched above, the call signature suggests - // capture-aware usage. - /\.replace\([^)]*\$\d/, -] - -function isLineMarkered(line: string): boolean { - const m = line.match(SOCKET_HOOK_MARKER_RE) - if (!m) { - return false - } - return !m[1] || m[1] === 'capture' -} - -/** - * Walk a regex pattern and return every top-level _capturing_ group: bare - * `(...)` openings that aren't followed by `?:` / `?=` / `?!` / `?<`. Skips - * character classes and escaped parens. - */ -function findBareCaptureGroups(pattern: string): CaptureGroup[] { - const groups: CaptureGroup[] = [] - const stack: Array<{ start: number; capturing: boolean }> = [] - let inClass = false - let i = 0 - while (i < pattern.length) { - const c = pattern[i] - if (c === '\\') { - i += 2 - continue - } - if (inClass) { - if (c === ']') { - inClass = false - } - i++ - continue - } - if (c === '[') { - inClass = true - i++ - continue - } - if (c === '(') { - let capturing = true - if (pattern[i + 1] === '?') { - capturing = false - } - stack.push({ start: i, capturing }) - i++ - continue - } - if (c === ')') { - const open = stack.pop() - if (open && open.capturing) { - groups.push({ - start: open.start, - end: i + 1, - inner: pattern.slice(open.start + 1, i), - }) - } - i++ - continue - } - i++ - } - return groups -} - -/** - * Heuristic: does the file's source contain any markers suggesting at least one - * regex in this file relies on its captures? When true, we DROP the autofix - * (still report) so a wrong rewrite can't break unrelated code. - */ -function fileUsesCaptures(source: string): boolean { - for (let i = 0, { length } = CAPTURE_USAGE_RES; i < length; i += 1) { - const re = CAPTURE_USAGE_RES[i]! - if (re.test(source)) { - return true - } - } - return false -} - -/** - * Conservative inner-pattern guard: skip when the inner alternation might be - * load-bearing in ways the rule can't reason about — backreferences inside the - * group (`(foo|bar\1)`) or nested groups (`(foo|(bar)baz)`) get reported but - * never autofixed. - */ -function innerIsAutofixSafe(inner: string): boolean { - if (/\\[1-9]/.test(inner)) { - return false - } - if (/\((?!\?)/.test(inner)) { - return false - } - return true -} - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Use `(?:...)` instead of `(...)` for regex groups whose capture value is not used. Per CLAUDE.md fleet regex rule.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - unused: - 'Capturing group `({{inner}})` is unused. Use `(?:{{inner}})` (non-capturing) instead.', - unusedNoFix: - 'Capturing group `({{inner}})` looks unused, but the file contains capture-usage markers elsewhere. Either convert manually to `(?:{{inner}})`, or append `// socket-hook: allow capture` on this line if the capture is intentional.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const fullSource: string = sourceCode.text ?? '' - // Conservative posture: the rule cannot reliably tell which regex - // in a file owns a given `match[N]` / `$N` / `.groups` usage. If - // ANY such marker appears anywhere in the file source, stay - // silent and let the author own the call. The previous design - // (report-with-no-autofix) over-warned on files that mixed one - // captured-and-used regex with one captured-but-unused regex. - const hasUsageMarkers = fileUsesCaptures(fullSource) - if (hasUsageMarkers) { - return {} - } - - function checkLiteral(node: AstNode) { - if (!node.regex) { - return - } - const line = sourceCode.lines[node.loc.start.line - 1] ?? '' - if (isLineMarkered(line)) { - return - } - const pattern: string = node.regex.pattern - // Whole-pattern backreference guard: a `\1`–`\9` anywhere in the pattern - // means SOME group is referenced by position. `innerIsAutofixSafe` only - // catches a backref INSIDE a group's own text; it can't see that - // `(["']?)(?:x)\1` references group 1 from outside. Converting any - // capturing group then renumbers/breaks that backref. Too fiddly to - // reason about per-group, so stay silent for the whole literal. (A `\0` - // is a null-char escape, not a backref — the `[1-9]` class excludes it.) - if (/\\[1-9]/.test(pattern)) { - return - } - const groups = findBareCaptureGroups(pattern) - if (groups.length === 0) { - return - } - // Partition into autofix-safe (every group's inner is fix-safe) - // and report-only (any group is non-fix-safe). Each unsafe group - // also emits its own `unusedNoFix` report so the author sees every - // hit; the safe-group autofix uses the ORIGINAL pattern offsets - // and rewrites in reverse order so earlier offsets stay valid. - const allSafe = groups.every(g => innerIsAutofixSafe(g.inner)) - if (allSafe) { - const flags: string = node.regex.flags || '' - // Build the new pattern by replacing each `(...)` with `(?:...)` - // — iterate in reverse so earlier `group.start` / `group.end` - // offsets remain valid even after later edits. - let newPattern = pattern - const reversed = [...groups].toReversed() - for (let i = 0, { length } = reversed; i < length; i += 1) { - const group = reversed[i]! - newPattern = - newPattern.slice(0, group.start) + - `(?:${group.inner})` + - newPattern.slice(group.end) - } - // Emit one `unused` report per offending group so the count - // matches user expectation. Attach the autofix to the FIRST - // report only — oxlint applies the fix once per node-rewrite - // pass; emitting the same full-rewrite fix N times would - // over-replace. - for (let i = 0, { length } = groups; i < length; i += 1) { - const group = groups[i]! - if (i === 0) { - context.report({ - node, - messageId: 'unused', - data: { inner: group.inner }, - fix(fixer: RuleFixer) { - return fixer.replaceText(node, `/${newPattern}/${flags}`) - }, - }) - } else { - context.report({ - node, - messageId: 'unused', - data: { inner: group.inner }, - }) - } - } - return - } - // Mixed-safety case: report every group as no-fix. The author - // resolves manually — a partial autofix would create asymmetric - // capture-index drift that's worse than leaving the regex alone. - for (let i = 0, { length } = groups; i < length; i += 1) { - const group = groups[i]! - context.report({ - node, - messageId: 'unusedNoFix', - data: { inner: group.inner }, - }) - } - } - - return { - Literal(node: AstNode) { - checkLiteral(node) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-safe-delete.mts b/.config/oxlint-plugin/rules/prefer-safe-delete.mts deleted file mode 100644 index 38c38cb57..000000000 --- a/.config/oxlint-plugin/rules/prefer-safe-delete.mts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @file Per CLAUDE.md "File deletion" rule: route every delete through - * `safeDelete()` / `safeDeleteSync()` from - * `@socketsecurity/lib-stable/fs/safe`. Never `fs.rm` / `fs.unlink` / - * `fs.rmdir` / `rm -rf` directly — even for one known file. Detects: - * - * - `fs.rm(...)` / `fs.rmSync(...)` / `fs.promises.rm(...)` - * - `fs.unlink(...)` / `fs.unlinkSync(...)` - * - `fs.rmdir(...)` / `fs.rmdirSync(...)` Autofix: rewrites the call to - * `safeDelete(path)` / `safeDeleteSync(path)` AND injects `import { - * safeDelete } from '@socketsecurity/lib-stable/fs/safe'` (or - * `safeDeleteSync`) when missing. The autofix is conservative — it only - * fires when the call shape is "obviously equivalent" to safeDelete: - * - The first argument is a single expression (the path). - * - Any second argument is an options object literal (we drop it; safeDelete - * handles recursive/force internally). - * - No third argument (rules out fs.rm with an explicit callback). - * - Not a node-callback-style usage (no trailing function expression). Skipped - * (reported without fix): - * - `fs.rm(p, opts, cb)` — node-callback style; semantics differ. - * - Calls whose result is checked/assigned in a way that depends on fs.rm's - * specific throw-on-missing or callback contract. Spawn-based bans (`rm - * -rf`, `Remove-Item`) live in a separate hook - * (`.claude/hooks/path-guard/`) — this rule covers the JavaScript side. - */ - -import { appendImportFixes, summarizeImportTarget } from './_inject-import.mts' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const DELETE_METHODS = new Set([ - 'rm', - 'rmSync', - 'rmdir', - 'rmdirSync', - 'unlink', - 'unlinkSync', -]) - -const SYNC_METHODS = new Set(['rmSync', 'rmdirSync', 'unlinkSync']) - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Route every delete through safeDelete / safeDeleteSync from @socketsecurity/lib-stable/fs/safe.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - banned: - 'fs.{{method}}() — use safeDelete / safeDeleteSync from @socketsecurity/lib-stable/fs/safe. The lib wrapper handles ENOENT, retries on EBUSY, and integrates with the rest of the fleet.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - // One summary per replacement target — async (safeDelete) and - // sync (safeDeleteSync) are separate import names from the same - // specifier, so each gets its own summary cache. - const summaryCache = new Map< - string, - ReturnType<typeof summarizeImportTarget> - >() - - function ensureSummary(importName: string) { - let s = summaryCache.get(importName) - if (s) { - return s - } - s = summarizeImportTarget( - sourceCode.ast, - '@socketsecurity/lib-stable/fs/safe', - importName, - ) - summaryCache.set(importName, s) - return s - } - - /** - * The autofix only fires when the call shape is unambiguous: fs.rm(path) - * fs.rm(path, { ...opts }) fs.rmSync(path) fs.rmSync(path, { ...opts }) - * - * Bail on: - 0 args (malformed; skip) - 3+ args (callback-style fs.rm — - * semantics differ) - 2nd arg is a function expression (callback-style) - - * any spread argument (...args; can't reason about arity) - */ - function isFixable(node: AstNode) { - const args = node.arguments - if (args.length === 0 || args.length > 2) { - return false - } - for (let i = 0, { length } = args; i < length; i += 1) { - const a = args[i]! - if (a.type === 'SpreadElement') { - return false - } - } - if (args.length === 2) { - const second = args[1] - if ( - second.type === 'ArrowFunctionExpression' || - second.type === 'FunctionExpression' - ) { - return false - } - } - return true - } - - return { - CallExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if (callee.property.type !== 'Identifier') { - return - } - if (!DELETE_METHODS.has(callee.property.name)) { - return - } - - // Heuristic: callee.object should be a node that plausibly - // refers to the fs module (named `fs`, `promises`, etc.). - // Cover both `fs.rm`, `fs.promises.rm`, `promises.rm`, - // `fsPromises.rm`. Skip method calls on instances (e.g. - // `child.rm()` — not fs). - const obj = callee.object - const objName = - obj.type === 'Identifier' - ? obj.name - : obj.type === 'MemberExpression' && - obj.property.type === 'Identifier' - ? obj.property.name - : undefined - - if (!objName) { - return - } - - // Match common fs aliases. Conservative — we'd rather miss a - // case than flag `someChild.unlink()` on an unrelated object. - if (!/^(fs|fsPromises|fsp|promises)$/.test(objName)) { - return - } - - const method = callee.property.name - const isSync = SYNC_METHODS.has(method) - const replacement = isSync ? 'safeDeleteSync' : 'safeDelete' - - if (!isFixable(node)) { - context.report({ - node, - messageId: 'banned', - data: { method }, - }) - return - } - - const s = ensureSummary(replacement) - const pathArg = node.arguments[0] - const pathText = sourceCode.getText(pathArg) - - context.report({ - node, - messageId: 'banned', - data: { method }, - fix(fixer: RuleFixer) { - return [ - fixer.replaceText(node, `${replacement}(${pathText})`), - ...appendImportFixes( - s, - fixer, - `import { ${replacement} } from '@socketsecurity/lib-stable/fs/safe'`, - undefined, - ), - ] - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-separate-type-import.mts b/.config/oxlint-plugin/rules/prefer-separate-type-import.mts deleted file mode 100644 index 347a6a743..000000000 --- a/.config/oxlint-plugin/rules/prefer-separate-type-import.mts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @file Forbid inline type specifiers (`import { type X, Y }`) — split into a - * dedicated `import type { X }` plus a value-only `import { Y }`. Two style - * benefits: - * - * 1. The reader sees the type-vs-value split at the import header without - * parsing per-specifier `type` keywords. - * 2. Sorted-imports rules can group `import type` statements separately from - * value imports (fleet convention is value imports first, then types as a - * trailing block). Style signal that motivated the rule: across the - * fleet's six surveyed repos, separate `import type` statements outnumber - * inline `type` specifiers ~200-to-1 (socket-cli: 535 separate vs 2 - * inline; socket-lib: 212 vs 8). The stragglers are drift, not a different - * convention. Autofix: - * - * - Inline `type` specifiers in a `import { ... } from 'mod'` statement are - * moved into a new `import type { ... } from 'mod'` statement inserted - * directly after the original import. The `type` keyword is stripped from - * the inline specifier. - * - If ALL specifiers in an import are `type`-prefixed, the whole statement is - * converted in place to `import type { ... }`. - * - Default + type-specifier mixes (`import Foo, { type Bar } from 'mod'`) are - * split: default keeps the original statement, types move to a new `import - * type { Bar } from 'mod'` line. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer a separate `import type { X }` over inline `import { type X, Y }`.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - preferSeparateTypeImport: - 'Inline `type` specifier on `{{name}}` — move type-only specifiers into a separate `import type { ... } from "{{source}}"` statement.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - return { - ImportDeclaration(node: AstNode) { - // `import type { ... }` at the statement level — already - // correct, no inline specifiers to surface. - if (node.importKind === 'type') { - return - } - if (!node.specifiers || node.specifiers.length === 0) { - return - } - - const typeSpecifiers: AstNode[] = [] - const valueSpecifiers: AstNode[] = [] - let defaultSpec: AstNode | undefined - let namespaceSpec: AstNode | undefined - for (const spec of node.specifiers) { - if (spec.type === 'ImportDefaultSpecifier') { - defaultSpec = spec - continue - } - if (spec.type === 'ImportNamespaceSpecifier') { - namespaceSpec = spec - continue - } - if (spec.type === 'ImportSpecifier') { - if (spec.importKind === 'type') { - typeSpecifiers.push(spec) - } else { - valueSpecifiers.push(spec) - } - } - } - - if (typeSpecifiers.length === 0) { - return - } - - // Report each inline type specifier so the user sees every - // offender. Attach the autofix to the first one only — ESLint - // dedupes overlapping fixes and the rewrite replaces the - // whole statement (plus possibly inserts a new one). - const source = node.source.value - const indent = (() => { - const text = sourceCode.text - const lineStart = text.lastIndexOf('\n', node.range[0] - 1) + 1 - return text.slice(lineStart, node.range[0]) - })() - - const typeNames = typeSpecifiers - .map((s: AstNode) => specifierText(sourceCode, s, true)) - .join(', ') - - let fixerAttached = false - for (let i = 0, { length } = typeSpecifiers; i < length; i += 1) { - const spec = typeSpecifiers[i]! - const name = - spec.imported && spec.imported.name - ? spec.imported.name - : '<unknown>' - const report: { - node: AstNode - messageId: string - data: { name: string; source: string } - fix?: ((fixer: RuleFixer) => unknown) | undefined - } = { - node: spec, - messageId: 'preferSeparateTypeImport', - data: { name, source: String(source) }, - } - if (!fixerAttached) { - report.fix = function (fixer: RuleFixer) { - // Case A: every specifier is a type specifier and there's - // no default/namespace import — convert the whole line. - if ( - valueSpecifiers.length === 0 && - !defaultSpec && - !namespaceSpec - ) { - const originalText = sourceCode.getText(node) - const rewritten = originalText - .replace(/^import\s+/, 'import type ') - // Strip every inline `type ` keyword from inside - // the brace list. - .replace(/\btype\s+/g, '') - return fixer.replaceText(node, rewritten) - } - // Case B: mixed — keep value/default/namespace - // specifiers on the original line, append a new - // `import type { ... } from 'src'` below. - const remainingParts: string[] = [] - if (defaultSpec) { - remainingParts.push(sourceCode.getText(defaultSpec)) - } - if (namespaceSpec) { - remainingParts.push(sourceCode.getText(namespaceSpec)) - } - if (valueSpecifiers.length > 0) { - const valueText = valueSpecifiers - .map((s: AstNode) => specifierText(sourceCode, s, false)) - .join(', ') - remainingParts.push(`{ ${valueText} }`) - } - const quote = sourceCode.text[node.source.range[0]] - const rewrittenOriginal = `import ${remainingParts.join(', ')} from ${quote}${source}${quote}` - const newLine = `${indent}import type { ${typeNames} } from ${quote}${source}${quote}` - return fixer.replaceText(node, `${rewrittenOriginal}\n${newLine}`) - } - fixerAttached = true - } - context.report(report) - } - }, - } - }, -} - -/** - * Render an `ImportSpecifier` for the rewritten statement. When `stripType` is - * true the `type` keyword is omitted (the specifier is being moved into a - * statement-level `import type` block, where per-specifier `type` would be - * redundant). - */ -function specifierText( - sourceCode: unknown, - spec: AstNode, - stripType: boolean, -): string { - void sourceCode - const imported = spec.imported - const local = spec.local - const importedName = - imported.type === 'Identifier' ? imported.name : `"${imported.value}"` - const localName = local.name - const renamed = importedName !== localName - const body = renamed ? `${importedName} as ${localName}` : importedName - if (!stripType && spec.importKind === 'type') { - return `type ${body}` - } - return body -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-spawn-over-execsync.mts b/.config/oxlint-plugin/rules/prefer-spawn-over-execsync.mts deleted file mode 100644 index e436c6cf1..000000000 --- a/.config/oxlint-plugin/rules/prefer-spawn-over-execsync.mts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @file Per the fleet "Subprocesses" rule: prefer `spawn` from - * `@socketsecurity/lib-stable/process/spawn/child` over `execSync` / - * `execFileSync` from `node:child_process`. Two reasons: - * - * 1. Command-injection surface — `execSync(cmd)` runs `cmd` through a shell; any - * string concatenation into `cmd` is a potential injection vector. - * `execFileSync(file, args)` is safer (no shell) but still picks up `PATH` - * lookups and offers no structured error shape. - * 2. Consistency — the fleet `spawn` wrapper ships a typed `SpawnError` shape, - * an `isSpawnError` guard, and accepts an array-of-args contract that - * mirrors `spawnSync` from `node:child_process`. Every fleet repo uses it; - * mixing `execSync`/`execFileSync` for one-offs forces readers to remember - * two error shapes. Detects: - * - * - `import { execSync, execFileSync } from 'node:child_process'` - * - `import { execSync, execFileSync } from 'child_process'` - * - `child_process.execSync(...)` / `child_process.execFileSync(...)` No - * autofix. The API shapes differ enough that a mechanical rewrite would - * silently break callers reading `.status`, `.stdout`, `.stderr` from the - * sync result. Human eyes pick the right migration: `await spawn(...)` (the - * common case) or `spawnSync(...)` from the lib (if the caller's flow is - * genuinely top-level-sync). Allowed exceptions: - * - Adjacent comment with `prefer-spawn-over-execsync: required` — for callers - * who genuinely need shell expansion (e.g. expanding env vars mid-command). - * Rare; document why. - * - Files inside `@socketsecurity/lib-stable/process/spawn/child` itself — - * handled at the .config/oxlintrc.json ignorePatterns level. - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const CHILD_PROCESS_SPECIFIERS = new Set([ - 'child_process', - 'node:child_process', -]) - -const BANNED_NAMES = new Set(['execFileSync', 'execSync']) - -const BYPASS_RE = /prefer-spawn-over-execsync:\s*required/ - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use `spawn` from @socketsecurity/lib-stable/process/spawn/child instead of `execSync` / `execFileSync` from node:child_process.', - category: 'Best Practices', - recommended: true, - }, - fixable: undefined, - messages: { - importBanned: - 'Importing `{{name}}` from {{specifier}} — use `spawn` (or `spawnSync` for top-level-sync) from @socketsecurity/lib-stable/process/spawn/child. `execSync` runs through a shell (command-injection surface); array-arg `spawn` does not. The lib also ships a typed SpawnError shape — `execSync` errors are plain Errors with no structured fields.', - callBanned: - 'Calling `{{obj}}.{{name}}(...)` — use `spawn` from @socketsecurity/lib-stable/process/spawn/child instead. Avoids shell-interpolation injection paths; ships consistent SpawnError shape.', - }, - schema: [], - }, - - create(context: RuleContext) { - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - - return { - ImportDeclaration(node: AstNode) { - const specifier = node.source.value - if (!CHILD_PROCESS_SPECIFIERS.has(specifier)) { - return - } - if (hasBypassComment(node)) { - return - } - const banned = node.specifiers.filter( - (s: AstNode) => - s.type === 'ImportSpecifier' && - s.imported && - BANNED_NAMES.has(s.imported.name), - ) - if (banned.length === 0) { - return - } - for (let i = 0, { length } = banned; i < length; i += 1) { - const spec = banned[i]! - context.report({ - node: spec, - messageId: 'importBanned', - data: { - name: spec.imported.name, - specifier: `'${specifier}'`, - }, - }) - } - }, - - // child_process.execSync(...) / cp.execFileSync(...) — covers the - // `require('child_process').execSync(...)` path too. - CallExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'MemberExpression') { - return - } - if (callee.property.type !== 'Identifier') { - return - } - if (!BANNED_NAMES.has(callee.property.name)) { - return - } - const obj = callee.object - const objName = - obj.type === 'Identifier' - ? obj.name - : obj.type === 'MemberExpression' && - obj.property.type === 'Identifier' - ? obj.property.name - : undefined - if (!objName) { - return - } - if (!/^(?:childProcess|child_process|cp)$/.test(objName)) { - return - } - if (hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'callBanned', - data: { obj: objName, name: callee.property.name }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-stable-self-import.mts b/.config/oxlint-plugin/rules/prefer-stable-self-import.mts deleted file mode 100644 index c0979d447..000000000 --- a/.config/oxlint-plugin/rules/prefer-stable-self-import.mts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @file In `scripts/` and `.claude/hooks/`, forbid importing the fleet package - * that the current repo OWNS by its bare name — require the `-stable` alias - * instead. Why: a fleet repo that publishes `@socketsecurity/<X>` resolves - * the bare `@socketsecurity/<X>` specifier to its own local `src/` (workspace - * link), which is work-in-progress and may be mid-edit / broken. Build - * scripts and git-hooks must run against a KNOWN-GOOD published copy, so the - * fleet pins a `@socketsecurity/<X>-stable` catalog alias - * (`npm:@socketsecurity/<X>@<last published>`). Tooling imports the `-stable` - * alias; only the package's own source consumers use the bare name. Concrete - * failure this prevents: socket-lib's git-hooks imported - * `@socketsecurity/lib/logger/default` (bare). In socket-lib that resolves to - * local `src/`, so during a version straddle the subpath didn't exist yet and - * every commit threw `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `-stable` alias - * would have resolved to the published package that has the subpath. Scope: - * files under `**∕scripts/**` or `**∕.claude/hooks/**`. The owned package - * name is read from the nearest ancestor `package.json` `name` field (walk-up - * from the linted file). Only flags imports of THAT exact package — e.g. in - * socket-lib, `@socketsecurity/lib/...` is flagged but - * `@socketsecurity/registry/...` is not (socket-lib doesn't own registry). - * Autofix: rewrite the specifier's package segment from `@scope/name` to - * `@scope/name-stable`, preserving the subpath: - * `@socketsecurity/lib/logger/default` → - * `@socketsecurity/lib-stable/logger/default`. Per - * https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices - * — give scripted/AI-driven tooling a deterministic, published dependency - * surface rather than a moving local-src target, so generated edits build - * against a stable contract. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -/** - * Walk up from `startDir` to find the nearest `package.json` and return its - * `name` field, or undefined if none is found / it has no name. - */ -function findOwnedPackageName(startDir: string): string | undefined { - let dir = startDir - // Stop at filesystem root. - while (dir && dir !== path.dirname(dir)) { - const pkgPath = path.join(dir, 'package.json') - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (typeof pkg.name === 'string' && pkg.name) { - return pkg.name - } - } catch { - // Unreadable / malformed package.json — keep walking up. - } - } - dir = path.dirname(dir) - } - return undefined -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'In scripts/ + .claude/hooks/, import the repo-owned fleet package via its `-stable` alias, not the bare name (the bare name resolves to local src).', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - preferStable: - '`{{specifier}}` imports the repo-owned package `{{owned}}` by its bare name. In scripts/ + .claude/hooks/ use the `{{owned}}-stable` alias — the bare name resolves to local `src/` (WIP), but tooling must run against the published snapshot. Fix: `{{fixed}}`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = context.filename ?? context.getFilename?.() ?? '' - // Only enforce on scripts/ + .claude/hooks/ paths. Test files in those - // dirs are exempt — fixtures may intentionally reference the bare name. - if ( - !/\/(?:\.claude\/hooks|scripts)\//.test(filename) || - /\/test\//.test(filename) || - /\.test\.(?:[mc]?[jt]s)$/.test(filename) - ) { - return {} - } - - const owned = findOwnedPackageName(path.dirname(filename)) - // No owned name, or the owned name is already a `-stable` alias target - // (shouldn't happen, but guard anyway) → nothing to enforce. - if (!owned || owned.endsWith('-stable')) { - return {} - } - - // Match `<owned>` exactly or `<owned>/<subpath>` — not `<owned>-foo`. - const ownedPrefix = `${owned}/` - - const checkSpecifier = (node: AstNode, raw: string): void => { - if (raw !== owned && !raw.startsWith(ownedPrefix)) { - return - } - // Build the `-stable` form: insert `-stable` after the package name, - // before any subpath. - const subpath = raw === owned ? '' : raw.slice(owned.length) - const fixed = `${owned}-stable${subpath}` - context.report({ - node, - messageId: 'preferStable', - data: { specifier: raw, owned, fixed }, - fix(fixer: RuleFixer) { - // node.source is the string literal; replace its raw text including - // quotes to preserve the original quote style. - const quote = node.source.raw?.[0] ?? "'" - return fixer.replaceText(node.source, `${quote}${fixed}${quote}`) - }, - }) - } - - return { - ImportDeclaration(node: AstNode) { - if (node.source?.type === 'Literal') { - checkSpecifier(node, String(node.source.value)) - } - }, - ExportNamedDeclaration(node: AstNode) { - if (node.source?.type === 'Literal') { - checkSpecifier(node, String(node.source.value)) - } - }, - ExportAllDeclaration(node: AstNode) { - if (node.source?.type === 'Literal') { - checkSpecifier(node, String(node.source.value)) - } - }, - } - }, -} - -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-static-type-import.mts b/.config/oxlint-plugin/rules/prefer-static-type-import.mts deleted file mode 100644 index 2de349146..000000000 --- a/.config/oxlint-plugin/rules/prefer-static-type-import.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @file Flag inline `import('module').Name` type expressions — use a static - * `import type { Name } from 'module'` at the top of the file instead. - * Inline-import type expressions read worse than the static form for three - * reasons: - * - * 1. Repeat usages duplicate the module path at every annotation site, so - * renaming the module is a multi-edit instead of a one-line header - * change. - * 2. The reader has to parse the type expression to discover what's imported; a - * static `import type { Remap, Spinner }` advertises the file's external - * dependencies at the top. - * 3. Bundlers / language servers can deduplicate static imports more reliably - * than inline ones; some tools (oxfmt, prettier-tsdoc) don't reformat - * inline-import expressions consistently. Detects: - * - * - `import('module').Name` (TSImportType AST node — TypeScript's type-context - * import expression). Captures the module specifier plus the qualifier (the - * property name read off the imported namespace). No autofix: - * - Adding a static `import type` requires choosing a unique local name and - * inserting at the correct sort position. The fleet's `sort-named-imports` - * + `prefer-separate-type-import` rules already enforce the import-header - * shape; rather than racing them with a half-built rewrite, this rule - * reports the violation and leaves the lift to the human (one-line edit - * anyway). Allowed exceptions (skipped — no report): - * - `typeof import('module')` namespace forms (TSImportType wrapped in - * TSTypeQuery). The static equivalent is `import * as Foo from 'module'` - * followed by `typeof Foo`, which is heavier than the inline form for - * one-shot uses. Why a rule and not just a code-style note: socket-lib - * drift incident 2026-05-14 — `SpawnOptions` accumulated inline-import - * properties (`spinner?: import('../spinner/types').Spinner`) over time. - * When the type was extended for a sibling `NodeSpawnSyncOptions`, the - * inline shape duplicated the same module path again. A static `import type - * { Spinner } from '../spinner/types'` makes the extension a no-edit at the - * type-spec level. - */ - -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer a static `import type { X } from "mod"` over inline `import("mod").X` type expressions.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: undefined, - messages: { - preferStaticTypeImport: - 'Inline `import("{{source}}").{{name}}` type expression — replace with a static `import type {{names}} from "{{source}}"` at the top of the file.', - preferStaticTypeImportNoQualifier: - 'Inline `import("{{source}}")` namespace type — replace with a static `import type * as <Name> from "{{source}}"` at the top of the file.', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - // TypeScript-AST node for `import('mod').Name` in a type position. - TSImportType(node: AstNode) { - // Skip when wrapped in `typeof import(...)` — those have no - // single-import static rewrite that reads better than the inline - // form. Recognized by the AST parent being a TSTypeQuery. - const parent = node.parent - if (parent && parent.type === 'TSTypeQuery') { - return - } - - // Source-literal field name varies by AST version: - // - Older ESTree-ish: `node.argument.literal.value` (TSLiteralType wrapper) - // - Mid: `node.argument.value` (direct string literal) - // - Current oxlint: `node.source.value` (StringLiteral child named - // `source`, mirroring ImportDeclaration's `source` field) - // Cover all three so the rule survives further AST drift. - const argument = node.argument - const sourceNode = node.source - const source = - sourceNode && typeof sourceNode.value === 'string' - ? sourceNode.value - : argument && argument.type === 'TSLiteralType' && argument.literal - ? argument.literal.value - : argument && typeof argument.value === 'string' - ? argument.value - : undefined - if (typeof source !== 'string') { - return - } - - // The qualifier is the dotted property name (the `Name` in - // `import('mod').Name`). A bare `import('mod')` with no - // qualifier is the namespace form — still worth flagging, but - // with the namespace message. - const qualifier = node.qualifier - if (!qualifier) { - context.report({ - node, - messageId: 'preferStaticTypeImportNoQualifier', - data: { source }, - }) - return - } - - // Qualifiers can be nested (`import('mod').A.B`). Two shapes: - // - Older: TSQualifiedName with `.left` (recursive) and `.right` - // (Identifier); walk left to the leftmost ident. - // - Current oxlint: the qualifier is itself an Identifier when - // non-nested (no `.left`/`.right`), exposing `.name` directly. - // Try the current shape first, then fall back to the walk. - let name: string | undefined - if ( - qualifier.type === 'Identifier' && - typeof qualifier.name === 'string' - ) { - name = qualifier.name - } else { - let leftmost: AstNode = qualifier - while (leftmost.left) { - leftmost = leftmost.left - } - name = - leftmost.type === 'Identifier' && typeof leftmost.name === 'string' - ? leftmost.name - : undefined - } - if (!name) { - return - } - - context.report({ - node, - messageId: 'preferStaticTypeImport', - data: { - source, - name, - names: `{ ${name} }`, - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/prefer-undefined-over-null.mts b/.config/oxlint-plugin/rules/prefer-undefined-over-null.mts deleted file mode 100644 index df53f8369..000000000 --- a/.config/oxlint-plugin/rules/prefer-undefined-over-null.mts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * @file Per CLAUDE.md "null vs undefined": use `undefined`. `null` is allowed - * only for `__proto__: null` (object-literal prototype null) or external API - * requirements (e.g., JSON encoding, `Object.create(null)`, listener-error - * sinks, third-party callbacks). Autofix scope: - * - * - **Deterministic**: rewrites `null` → `undefined` ONLY when context is - * demonstrably safe. Earlier versions had a context-blind autofix that - * produced fleet-wide regressions; the current set of skip predicates - * covers every regression seen in the rollout: - * - `__proto__: null` (with or without `as` cast) — the null-prototype-object - * contract. - * - `Object.create(null)`, `Object.setPrototypeOf(o, null)`, - * `Reflect.setPrototypeOf(o, null)` — prototype-aware callsites that throw - * / reject `undefined`. - * - `JSON.stringify(value, null, space)` — replacer-slot convention. - * - `=== null` / `!== null` comparisons — semantically distinct. - * - **AI-handled** (Step 4 of `pnpm run fix`): literals whose surrounding type - * annotation mentions `null` (e.g. `let x: string | null = null`). The - * annotation is the contract; flipping just the value creates type errors. - * The AI step flips BOTH the value and the annotation in lockstep and - * traces through the function signatures / interfaces / return types that - * depend on it — exactly the refactor that blew up socket-stuie when the - * deterministic autofix was context-blind. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Prefer `undefined` over `null` (CLAUDE.md style — `null` is allowed only for __proto__:null or external API requirements).', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - preferUndefined: - 'Use `undefined` instead of `null` (allowed exceptions: `__proto__: null`, `Object.create(null)`, external API requirements like JSON.stringify replacer / third-party callbacks).', - preferUndefinedNoFix: - 'Use `undefined` instead of `null`. Surrounding type annotation mentions `null` — both the annotation (`| null` → `| undefined`) and the value need to flip together. Handed off to the AI-fix step (Step 4 of `pnpm run fix`) to trace the refactor through the function signatures / interfaces / return types involved.', - }, - schema: [], - }, - - create(context: RuleContext) { - /** - * Walk up through TS type-cast wrappers (`x as T`, `x as const`, `<T>x`) so - * that `null as never` inside `{ __proto__: null as never }` still matches - * the proto-null exception. Without this, the autofix rewrites `null as - * never` → `undefined as never`, which silently breaks the null-prototype - * object semantics — `Object.create(null)` vs `Object.create(undefined)` - * are very different. - */ - function unwrapTsCast(node: AstNode) { - let cur = node.parent - while ( - cur && - (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') - ) { - cur = cur.parent - } - return cur - } - - function isProtoNull(node: AstNode) { - // Find the nearest non-cast ancestor; for `null as never` this - // skips the TSAsExpression and lands on the Property. - const parent = unwrapTsCast(node) - if (!parent || parent.type !== 'Property') { - return false - } - // Walk back down: parent.value may be the TSAsExpression or the - // Literal directly. Either is fine — we matched on the parent. - const key = parent.key - if (!key) { - return false - } - // { __proto__: null } — key is Identifier `__proto__` or string '__proto__'. - if (key.type === 'Identifier' && key.name === '__proto__') { - return true - } - if (key.type === 'Literal' && key.value === '__proto__') { - return true - } - return false - } - - function isComparisonOperand(node: AstNode) { - const parent = node.parent - if (!parent) { - return false - } - if (parent.type !== 'BinaryExpression') { - return false - } - return ['===', '!==', '==', '!='].includes(parent.operator) - } - - /** - * `expect(x).toBe(null)` / `.toEqual(null)` / `.toStrictEqual(null)` / - * `.toMatchObject(null)` — vitest/jest assertion matchers where the `null` - * is the SEMANTIC value being asserted. Rewriting to `undefined` flips the - * test contract (a passing test that asserted "x is null" now asserts "x is - * undefined"). - * - * Also covers chai (`.equal(null)` / `.equals(null)` / `.is(null)` / - * `.same(null)`) and node:assert (`assert.equal(_, null)` / `.deepEqual(_, - * null)` / `.deepStrictEqual(_, null)` / `.strictEqual(_, null)`). - * - * The detection is shape-based, not name-import-based — any call that ends - * in `.<assert-method>(null, ...)` qualifies. False positives (a non-test - * method named `toBe`) are extremely rare; the cost is missing a real - * autofix opportunity, which is a safe outcome. - */ - const ASSERT_METHODS = new Set([ - 'deepEqual', - 'deepStrictEqual', - 'equal', - 'equals', - 'is', - 'notDeepEqual', - 'notDeepStrictEqual', - 'notEqual', - 'notStrictEqual', - 'same', - 'strictEqual', - 'toBe', - 'toEqual', - 'toMatchObject', - 'toStrictEqual', - ]) - - function isAssertionLibraryArg(node: AstNode) { - // Walk up through TS casts and any container literals (array - // literals, object literals, spread elements, properties) so - // `expect(x).toEqual([1, null])` and `.toEqual({ k: null })` - // also count — the `null` is still the asserted shape, just - // nested inside the matcher arg. - let cur = unwrapTsCast(node) - while ( - cur && - (cur.type === 'ArrayExpression' || - cur.type === 'ObjectExpression' || - cur.type === 'Property' || - cur.type === 'SpreadElement') - ) { - cur = unwrapTsCast(cur) - } - if (!cur || cur.type !== 'CallExpression') { - return false - } - const callee = cur.callee - if ( - callee.type !== 'MemberExpression' || - callee.property.type !== 'Identifier' - ) { - return false - } - return ASSERT_METHODS.has(callee.property.name) - } - - /** - * `const x: Foo | null = null` / `let y: Foo | null | undefined = null` — - * the developer explicitly opted into null in the variable's type - * signature. The dedicated annotation IS the contract; flipping the value - * alone leaves the contract intact but produces dead `undefined` writes - * against a `| null` slot. - * - * Faster than the generic `hasNullTypeAnnotation` walk-up because it - * short-circuits at the immediate VariableDeclarator parent. Both - * predicates are kept — this fast-path covers the canonical declarator - * shape; the walk-up handles the broader Property / Parameter / return-type - * / TS-cast cases that declarator-only detection misses. - * - * Textual scan over `<id>: <annot> = ` rather than AST navigation: the - * typeAnnotation field shape varies between oxlint AST and - * babel/typescript-eslint AST, so the regex is the most resilient detector - * across plugin host versions. - */ - function isNullableTypeInitializer(node: AstNode) { - const parent = node.parent - if (!parent || parent.type !== 'VariableDeclarator') { - return false - } - if (parent.init !== node) { - return false - } - const declStart = parent.range - ? parent.range[0] - : (parent.start ?? parent.id?.range?.[0]) - const litStart = node.range ? node.range[0] : node.start - if (typeof declStart !== 'number' || typeof litStart !== 'number') { - return false - } - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const text = sourceCode.getText().slice(declStart, litStart) - // Require `: <typeexpr>... null ... =` — colon (type annotation), - // literal `null` token, then `=` (initializer separator). - return /:[^=]*\bnull\b[^=]*=/.test(text) - } - - function isJsonStringifyReplacer(node: AstNode) { - // JSON.stringify(value, replacer, space) — `replacer` is - // conventionally null. Also matches the primordial alias - // `JSONStringify(value, null, space)` (= `JSON.stringify`) - // used across the fleet's `primordials/json` module. - const parent = unwrapTsCast(node) - if ( - !parent || - parent.type !== 'CallExpression' || - parent.arguments[1] !== node - ) { - return false - } - const callee = parent.callee - // Bare-identifier callee: `JSONStringify(value, null, 2)` — - // the primordials alias for `JSON.stringify`. Detect by name - // (`JSONStringify`) rather than by import-resolution, which - // an oxlint AST rule can't do cheaply. - if (callee.type === 'Identifier' && callee.name === 'JSONStringify') { - return true - } - if (callee.type !== 'MemberExpression') { - return false - } - return ( - callee.object.type === 'Identifier' && - callee.object.name === 'JSON' && - callee.property.type === 'Identifier' && - callee.property.name === 'stringify' - ) - } - - /** - * Prototype-aware callsites where `null` is the explicit "no prototype" - * sentinel. Replacing any of these with `undefined` either throws TypeError - * or silently changes semantics: - * - * - `Object.create(null)` — first arg, throws if undefined. - * - `Object.setPrototypeOf(o, null)` — second arg, semantics differ - * (undefined is rejected by the spec). - * - `Reflect.setPrototypeOf(o, null)` — same as above. - * - * Each entry is `[object, method, argIndex]` where argIndex is the - * 0-indexed slot whose `null` is allowed. - */ - const PROTOTYPE_NULL_CALLSITES = [ - ['Object', 'create', 0], - ['Object', 'setPrototypeOf', 1], - ['Reflect', 'setPrototypeOf', 1], - ] - - function isPrototypeAwareNull(node: AstNode) { - const parent = unwrapTsCast(node) - if (!parent || parent.type !== 'CallExpression') { - return false - } - const callee = parent.callee - if (callee.type !== 'MemberExpression') { - return false - } - if ( - callee.object.type !== 'Identifier' || - callee.property.type !== 'Identifier' - ) { - return false - } - const objectName = callee.object.name - const methodName = callee.property.name - for (const [obj, method, argIndex] of PROTOTYPE_NULL_CALLSITES) { - if (argIndex === undefined) { - continue - } - if ( - obj === objectName && - method === methodName && - parent.arguments[argIndex] === node - ) { - return true - } - } - return false - } - - /** - * Walk up the AST and return true if any ancestor carries a TS type - * annotation that mentions `null`. Used to skip autofix on cases like `let - * x: string | null = null` where flipping just the value creates a type - * error. Walks until a function / block / program boundary so we don't pick - * up unrelated type annotations elsewhere in the file. - * - * Cheap shortcut: stringify the typeAnnotation subtree and look for a - * 'null' token. Avoids a full type-system traversal. - */ - function hasNullTypeAnnotation(node: AstNode) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - let cur = node.parent - while (cur) { - // Boundary nodes — stop walking here. - if ( - cur.type === 'ArrowFunctionExpression' || - cur.type === 'BlockStatement' || - cur.type === 'FunctionDeclaration' || - cur.type === 'FunctionExpression' || - cur.type === 'Program' - ) { - // For functions, the return-type annotation lives on the - // function node itself. Check it before stopping. - if (cur.returnType) { - const text = sourceCode.getText(cur.returnType) - if (/\bnull\b/.test(text)) { - return true - } - } - return false - } - // Variable declarations: `let x: T = ...` puts the annotation on - // the VariableDeclarator's `id.typeAnnotation`. - if ( - cur.type === 'VariableDeclarator' && - cur.id && - cur.id.typeAnnotation - ) { - const text = sourceCode.getText(cur.id.typeAnnotation) - if (/\bnull\b/.test(text)) { - return true - } - } - // Property: `foo: T` or `foo?: T` — check the property's - // typeAnnotation (in TS interfaces / type literals) or the - // value's wrapper for object literals. - if (cur.type === 'Property' && cur.typeAnnotation) { - const text = sourceCode.getText(cur.typeAnnotation) - if (/\bnull\b/.test(text)) { - return true - } - } - // Function parameters: `(x: T = null) => ...`. The default value - // is an AssignmentPattern; the annotated parameter is the left. - if ( - cur.type === 'AssignmentPattern' && - cur.left && - cur.left.typeAnnotation - ) { - const text = sourceCode.getText(cur.left.typeAnnotation) - if (/\bnull\b/.test(text)) { - return true - } - } - // TS-specific: TSAsExpression / TSTypeAssertion carrying a `null`- - // bearing type — skip autofix even though the cast itself isn't - // the proto-null shape. - if ( - (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion') && - cur.typeAnnotation - ) { - const text = sourceCode.getText(cur.typeAnnotation) - if (/\bnull\b/.test(text)) { - return true - } - } - cur = cur.parent - } - return false - } - - return { - Literal(node: AstNode) { - if (node.value !== null || node.raw !== 'null') { - return - } - - if (isProtoNull(node)) { - return - } - if (isComparisonOperand(node)) { - return - } - if (isPrototypeAwareNull(node)) { - return - } - if (isJsonStringifyReplacer(node)) { - return - } - if (isAssertionLibraryArg(node)) { - return - } - if (isNullableTypeInitializer(node)) { - return - } - - if (hasNullTypeAnnotation(node)) { - // Surrounding type annotation mentions null — report without - // autofix so the human flips both annotation and value. - context.report({ - node, - messageId: 'preferUndefinedNoFix', - }) - return - } - - context.report({ - node, - messageId: 'preferUndefined', - fix(fixer: RuleFixer) { - return fixer.replaceText(node, 'undefined') - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/socket-api-token-env.mts b/.config/oxlint-plugin/rules/socket-api-token-env.mts deleted file mode 100644 index 416b29aa1..000000000 --- a/.config/oxlint-plugin/rules/socket-api-token-env.mts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @file Per CLAUDE.md "Token hygiene → Socket API token env var" rule: The - * canonical fleet name is `SOCKET_API_TOKEN`. The legacy names - * `SOCKET_API_KEY`, `SOCKET_SECURITY_API_TOKEN`, and - * `SOCKET_SECURITY_API_KEY` are accepted as aliases for one cycle - * (deprecation grace period) — bootstrap hooks read all four and normalize to - * `SOCKET_API_TOKEN` going forward. Detects string literals naming any of the - * legacy aliases: - * - * - SOCKET_API_KEY - * - SOCKET_SECURITY_API_TOKEN - * - SOCKET_SECURITY_API_KEY Autofix: rewrites to `SOCKET_API_TOKEN`. Skipped: - * - Lines marked with `socket-api-token-env: bootstrap` adjacent comment — the - * alias-normalization code that intentionally reads all four names. The - * bootstrap hook is the one place legacy aliases legitimately appear. - * - The literal `SOCKET_CLI_API_TOKEN` — unrelated; that's the socket-cli - * configuration setting, not an API token alias. - */ - -import { isPluginSelfFile } from '../lib/fleet-paths.mts' -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -// This rule DEFINES the legacy-alias set; the strings here are rule data, not -// env-var consumers. The plugin-self-file guard in `create()` exempts this file -// (and the test fixtures) so the rule doesn't flag its own lookup table. -const LEGACY_ALIASES = new Set([ - 'SOCKET_API_KEY', - 'SOCKET_SECURITY_API_KEY', - 'SOCKET_SECURITY_API_TOKEN', -]) - -const CANONICAL = 'SOCKET_API_TOKEN' - -const BYPASS_RE = /socket-api-token-env:\s*bootstrap/ - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use the canonical SOCKET_API_TOKEN env var; rewrite legacy aliases (SOCKET_API_KEY, SOCKET_SECURITY_API_TOKEN, SOCKET_SECURITY_API_KEY).', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - messages: { - legacy: - '`{{name}}` is a legacy alias — use `SOCKET_API_TOKEN` (the canonical fleet name). Bootstrap hooks normalize the aliases.', - }, - schema: [], - }, - - create(context: RuleContext) { - // This rule's own source lists the legacy aliases as lookup-table data and - // its test file exercises them as fixtures. - if (isPluginSelfFile(context)) { - return {} - } - - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - function hasBypassComment(node: AstNode) { - // Walk up: literal -> array element -> array/declaration. The bypass - // comment can sit on the literal itself OR on any ancestor up to (and - // including) the nearest statement. This lets the entire alias-lookup - // array carry one bypass instead of needing one per element. - let cursor: AstNode | undefined = node - while (cursor) { - const before = sourceCode.getCommentsBefore(cursor) - const after = sourceCode.getCommentsAfter(cursor) - for (const c of [...before, ...after]) { - if (BYPASS_RE.test(c.value)) { - return true - } - } - if ( - cursor.type === 'ExportNamedDeclaration' || - cursor.type === 'ExpressionStatement' || - cursor.type === 'VariableDeclaration' - ) { - break - } - cursor = cursor.parent - } - return false - } - - function checkStringValue(node: AstNode, value: string): void { - // Match exactly; we don't want partial substrings. - if (!LEGACY_ALIASES.has(value)) { - return - } - if (hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'legacy', - data: { name: value }, - fix(fixer: RuleFixer) { - const raw = sourceCode.getText(node) - const quote = raw[0] - if (quote === '`') { - return fixer.replaceText(node, '`' + CANONICAL + '`') - } - return fixer.replaceText(node, quote + CANONICAL + quote) - }, - }) - } - - return { - Literal(node: AstNode) { - if (typeof node.value !== 'string') { - return - } - checkStringValue(node, node.value) - }, - TemplateLiteral(node: AstNode) { - if (node.expressions.length !== 0) { - return - } - checkStringValue(node, node.quasis[0].value.cooked) - }, - // Also catch `process.env.SOCKET_API_KEY` (member expression). - MemberExpression(node: AstNode) { - if (node.computed) { - return - } - if (node.property.type !== 'Identifier') { - return - } - if (!LEGACY_ALIASES.has(node.property.name)) { - return - } - // Confirm it's `process.env.X` shape so we don't false-positive - // on unrelated objects that happen to have a property named - // SOCKET_API_KEY. - const obj = node.object - if ( - obj.type !== 'MemberExpression' || - obj.property.type !== 'Identifier' || - obj.property.name !== 'env' - ) { - return - } - if (obj.object.type !== 'Identifier' || obj.object.name !== 'process') { - return - } - if (hasBypassComment(node)) { - return - } - context.report({ - node: node.property, - messageId: 'legacy', - data: { name: node.property.name }, - fix(fixer: RuleFixer) { - return fixer.replaceText(node.property, CANONICAL) - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-boolean-chains.mts b/.config/oxlint-plugin/rules/sort-boolean-chains.mts deleted file mode 100644 index b7fd69ca3..000000000 --- a/.config/oxlint-plugin/rules/sort-boolean-chains.mts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @file Sort all-identifier boolean chains alphanumerically. Per CLAUDE.md - * "Sorting" rule, a flag-list chain like `agentshieldOk && zizmorOk && sfwOk` - * reads with the identifier names in alpha order: `agentshieldOk && sfwOk && - * zizmorOk`. The runtime is short-circuit-insensitive to operand order _when - * every operand is a plain identifier_ (no calls, no member access with - * getters) — so reordering doesn't change semantics. Sorting reduces diff - * churn when adding a new flag and makes "is everything ready?" checks - * visually consistent. Scope: lists of flags, not guard pairs. The rule ONLY - * fires on chains of length ≥ 3. Two-operand chains like `useHttp && - * oauthEnabled` are guard patterns — the order carries narrative ("in HTTP - * mode, did OAuth get enabled?") that alpha-sort destroys. Three or more bare - * identifiers in a single chain is the structural signal that it's a flag - * list, not a guard. Detects: chains of `&&` or `||` whose operands are ALL - * bare Identifiers (length ≥ 3, no duplicates, uniform operator across the - * flattened chain). Skipped (not reported): - * - * - Length 2 — guard patterns; narrative order is intentional. - * - Any operand isn't a bare `Identifier` (Calls / member-access / literals / - * negations / nested non-uniform logical exprs short-circuit, and a - * `getter` on a member-access can have side effects — reordering would be - * observable). - * - Duplicate identifiers in the chain (rare, but rewriting through the - * duplicate would silently drop one). - * - Comments live between operands (autofix would relocate them). Why a - * separate rule from sort-equality-disjunctions: that rule sorts the - * right-hand string-literal of an equality chain (`x === 'a' || x === - * 'b'`); this rule sorts the bare-identifier operands of a pure-identifier - * chain. Structurally different ASTs, semantically different safety - * arguments. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Sort all-identifier boolean chains alphanumerically (`a && b && c`, `x || y || z`).', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - unsorted: - 'Boolean chain identifiers are out of alphabetical order. Saw `{{actual}}`, expected `{{expected}}`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * Flatten a left-associative LogicalExpression chain into leaf nodes. `(a - * && b) && c` and `a && (b && c)` both flatten to [a, b, c]. Caller checks - * operator uniformity. - */ - function flatten(node: AstNode, op: string, out: AstNode[]): void { - if (node.type === 'LogicalExpression' && node.operator === op) { - flatten(node.left, op, out) - flatten(node.right, op, out) - } else { - out.push(node) - } - } - - /** - * Returns true if a comment lies anywhere between the first and last leaf - * of the chain. Reordering through a comment would silently relocate - * attribution. - */ - function hasInteriorComment(leaves: AstNode[]): boolean { - if (!sourceCode.getCommentsInside) { - return false - } - const first = leaves[0]! - const last = leaves[leaves.length - 1]! - const all = sourceCode.getCommentsInside({ - range: [first.range[0], last.range[1]], - loc: { start: first.loc.start, end: last.loc.end }, - type: 'Program', - }) - return all.length > 0 - } - - function checkChain(rootNode: AstNode): void { - // Top-level filter: only check the OUTERMOST `&&` or `||` of a chain. - const parent = rootNode.parent - if ( - parent && - parent.type === 'LogicalExpression' && - parent.operator === rootNode.operator - ) { - return - } - - const op = rootNode.operator - if (op !== '&&' && op !== '||') { - return - } - - const leaves: AstNode[] = [] - flatten(rootNode, op, leaves) - // Length 2 chains are guard patterns (`useHttp && oauthEnabled`) - // where order carries narrative; only length 3+ chains are flag - // lists where alpha-sort is unambiguously a readability win. - if (leaves.length < 3) { - return - } - - // Every leaf must be a bare Identifier. Member-access (`a.b`) is - // excluded because property getters can have side effects whose order - // matters; calls are excluded because they're side-effecting; literals - // and unary expressions don't fit the "list of flags" shape. - const names: string[] = [] - for (let i = 0, { length } = leaves; i < length; i += 1) { - const leaf = leaves[i]! - if (leaf.type !== 'Identifier') { - return - } - names.push(leaf.name) - } - - // Skip duplicates — rewriting would lose information about which - // position the duplicate lived at. - if (new Set(names).size !== names.length) { - return - } - - const sortedNames = [...names].toSorted() - const actualOrder = names.join(', ') - const expectedOrder = sortedNames.join(', ') - - if (actualOrder === expectedOrder) { - return - } - - if (hasInteriorComment(leaves)) { - context.report({ - node: rootNode, - messageId: 'unsorted', - data: { actual: actualOrder, expected: expectedOrder }, - }) - return - } - - context.report({ - node: rootNode, - messageId: 'unsorted', - data: { actual: actualOrder, expected: expectedOrder }, - fix(fixer: RuleFixer) { - // Replace each leaf's identifier text with the sorted-position - // counterpart. The chain is homogeneous (same operator, all bare - // identifiers, no duplicates), so the rewrite is purely a - // reordering of operand names. - const fixes: AstNode[] = [] - for (let i = 0; i < leaves.length; i++) { - fixes.push(fixer.replaceText(leaves[i]!, sortedNames[i]!)) - } - return fixes - }, - }) - } - - return { - LogicalExpression: checkChain, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-equality-disjunctions.mts b/.config/oxlint-plugin/rules/sort-equality-disjunctions.mts deleted file mode 100644 index c1e67f2d7..000000000 --- a/.config/oxlint-plugin/rules/sort-equality-disjunctions.mts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @file Sort string-equality disjunctions alphanumerically. Per CLAUDE.md - * "Sorting" rule, `x === 'a' || x === 'b' || x === 'c'` is sorted by the - * comparand string (literal byte order, ASCII before letters). Order doesn't - * affect runtime semantics — JS's `||` short-circuits regardless of operand - * order — but keeps the diff churn low when adding a new comparand and makes - * "is X in this set?" checks visually consistent across the fleet. Detects: - * - * - `(x === 'a' || x === 'b')` - * - `(x !== 'a' && x !== 'b')` — De Morgan dual; ordering rule applies - * - Chains of any length (≥2 operands). Each disjunction must: - * - Use the SAME left operand (`x` in the example) for every clause. - * - Use the SAME comparison operator (`===` for `||` chains, `!==` for `&&` - * chains). - * - Use string-literal right operands (number / boolean / template literals are - * skipped — those rarely benefit from alpha order and confuse the autofix). - * Autofix: rewrites the right-hand string literals in sorted order. Skipped - * (reports without fix) when: - * - Any clause's left operand differs (mixed identifier). - * - Any clause's right operand isn't a plain string literal. - * - Any clause uses a different operator from the first. - * - Comments live between clauses (reordering through a comment would break - * attribution). Why a separate rule from sort-named-imports / - * sort-set-args: - * - The shape is structurally different (BinaryExpression chain under - * LogicalExpression, not an ArrayExpression / ImportSpecifier). - * - Catches the most common "is this one of these constants?" pattern in - * dispatch code (e.g. switch-prelude guards, fix-action category checks). A - * single rule keeps this normalized. - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Sort string-equality disjunctions alphanumerically (`x === "a" || x === "b"`).', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - unsorted: - 'String-equality disjunction operands are out of alphabetical order. Saw `{{actual}}`, expected `{{expected}}`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - /** - * Flatten a left-associative LogicalExpression chain into a list of leaf - * nodes. `(a || b) || c` and `a || (b || c)` both flatten to [a, b, c]. We - * require the chain operator to be uniform (caller checks). - */ - function flatten(node: AstNode, op: string, out: AstNode[]): void { - if (node.type === 'LogicalExpression' && node.operator === op) { - flatten(node.left, op, out) - flatten(node.right, op, out) - } else { - out.push(node) - } - } - - /** - * For a binary-equality leaf, return `{ left, right, operator }` if it's - * the shape we sort. Returns undefined otherwise. - */ - function asEqualityClause(node: AstNode) { - if (node.type !== 'BinaryExpression') { - return undefined - } - if (node.operator !== '!==' && node.operator !== '===') { - return undefined - } - // Right side must be a plain string-literal Identifier-comparand pattern. - if ( - node.right.type !== 'Literal' || - typeof node.right.value !== 'string' - ) { - return undefined - } - // Left side: prefer Identifier, but accept MemberExpression so - // `cat.x === 'a' || cat.x === 'b'` works too. - if ( - node.left.type !== 'Identifier' && - node.left.type !== 'MemberExpression' - ) { - return undefined - } - return { - leftText: sourceCode.getText(node.left), - operator: node.operator, - right: node.right, - rightValue: node.right.value, - } - } - - /** - * Returns true if a comment lies anywhere between the first and last leaf - * of the chain. Comment-aware skipping prevents the autofix from silently - * relocating attribution. - */ - function hasInteriorComment(leaves: AstNode[]): boolean { - if (!sourceCode.getCommentsInside) { - return false - } - const first = leaves[0]! - const last = leaves[leaves.length - 1]! - const all = sourceCode.getCommentsInside({ - range: [first.range[0], last.range[1]], - loc: { start: first.loc.start, end: last.loc.end }, - type: 'Program', - }) - return all.length > 0 - } - - function checkChain(rootNode: AstNode): void { - // Top-level filter: only check the OUTERMOST `||` or `&&` of a - // chain, not its sub-expressions. We detect "outermost" by the - // parent being either non-LogicalExpression or a different - // operator. - const parent = rootNode.parent - if ( - parent && - parent.type === 'LogicalExpression' && - parent.operator === rootNode.operator - ) { - return - } - - const op = rootNode.operator - // We only process || and && chains. - if (op !== '&&' && op !== '||') { - return - } - - const leaves: AstNode[] = [] - flatten(rootNode, op, leaves) - if (leaves.length < 2) { - return - } - - type Clause = { - leftText: string - operator: string - right: AstNode - rightValue: string - } - const clauses: Clause[] = [] - for (let i = 0, { length } = leaves; i < length; i += 1) { - const leaf = leaves[i]! - const c = asEqualityClause(leaf) - if (!c) { - // Mixed shape — skip the whole chain. The rule only - // applies to homogeneous equality chains. - return - } - clauses.push(c) - } - - // Operator/leftText must be uniform within the chain. For `||` - // chains the natural shape is `===`; for `&&` chains it's `!==` - // (De Morgan). Mixed → skip (rare and the rewrite would change - // semantics). - const firstLeft = clauses[0]!.leftText - const firstOp = clauses[0]!.operator - for (let i = 1; i < clauses.length; i++) { - if ( - clauses[i]!.leftText !== firstLeft || - clauses[i]!.operator !== firstOp - ) { - return - } - } - - // For `||` chains, expect `===`. For `&&` chains, expect `!==`. - // Other combinations are valid logic but not the shape this rule - // sorts (they'd be tautologies or contradictions). - if (op === '||' && firstOp !== '===') { - return - } - if (op === '&&' && firstOp !== '!==') { - return - } - - // Compute the sorted order. - const sortedClauses = [...clauses].toSorted((a, b) => { - if (a.rightValue < b.rightValue) { - return -1 - } - if (a.rightValue > b.rightValue) { - return 1 - } - return 0 - }) - - const actualOrder = clauses.map(c => c.rightValue).join(', ') - const expectedOrder = sortedClauses.map(c => c.rightValue).join(', ') - - if (actualOrder === expectedOrder) { - return - } - - // Check for interior comments — skip autofix if any. - if (hasInteriorComment(leaves)) { - context.report({ - node: rootNode, - messageId: 'unsorted', - data: { actual: actualOrder, expected: expectedOrder }, - }) - return - } - - context.report({ - node: rootNode, - messageId: 'unsorted', - data: { actual: actualOrder, expected: expectedOrder }, - fix(fixer: RuleFixer) { - // Replace each leaf's right-string-literal with the - // sorted-position counterpart. Because the chain is - // homogeneous (same left, same op), the rewrite is safe - // semantically — only the comparand strings reorder. - const fixes: AstNode[] = [] - for (let i = 0; i < leaves.length; i++) { - const leaf = leaves[i]! - const targetRight = sortedClauses[i]!.right - // The leaf's right node is what we rewrite. - // BinaryExpression.right's range covers just the literal. - const rawTarget = sourceCode.getText(targetRight) - fixes.push( - fixer.replaceText(asEqualityClause(leaf)!.right, rawTarget), - ) - } - return fixes - }, - }) - } - - return { - LogicalExpression: checkChain, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-named-imports.mts b/.config/oxlint-plugin/rules/sort-named-imports.mts deleted file mode 100644 index 1d4810153..000000000 --- a/.config/oxlint-plugin/rules/sort-named-imports.mts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * @file Per CLAUDE.md "Sorting" rule: sort the named-imports inside a single - * `import { ... }` statement alphanumerically (literal byte order — ASCII - * before letters). Default + namespace imports (`import foo, { ... } from`, - * `import * as ns from`) keep their leading binding; only the named-imports - * clause gets sorted. Detects `import { c, b, a } from 'pkg'` (and aliased - * forms like `import { c as x, b, a } from 'pkg'`). Autofix: rewrites the - * brace contents in alphabetical order. Comments inside the brace are NOT - * moved — when there's a comment between specifiers, the rule skips the - * autofix and only reports, because reordering through a comment can break - * attribution. The rewrite preserves trailing-newline / multi-line layout: a - * single-line block stays single-line; a multi-line block stays multi-line - * with one specifier per line. Sort key: the _imported_ name (before any `as` - * alias), so `Z as a, A as z` sorts to `A as z, Z as a` (the import side is - * the stable identity, not the local). - */ - -/** - * @type {import('eslint').Rule.RuleModule} - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Sort named imports alphanumerically within an import statement.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - unsorted: - 'Named imports must be sorted alphabetically. Saw `{{actual}}`, expected `{{expected}}`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - function specSortKey(spec: AstNode): string { - // ImportSpecifier — sort by `imported.name`. - // Default / namespace specifiers don't appear in the named list. - if (spec.imported && spec.imported.name) { - return spec.imported.name - } - if (spec.imported && spec.imported.value) { - return spec.imported.value - } - return spec.local && spec.local.name ? spec.local.name : '' - } - - function isAlreadySorted(names: string[]): boolean { - for (let i = 1; i < names.length; i++) { - if (names[i - 1]! > names[i]!) { - return false - } - } - return true - } - - return { - ImportDeclaration(node: AstNode) { - // Pull only the named-imports (skip default + namespace). - const named = node.specifiers.filter( - (s: AstNode) => s.type === 'ImportSpecifier', - ) - if (named.length < 2) { - return - } - - const keys = named.map(specSortKey) - if (isAlreadySorted(keys)) { - return - } - - const sorted = [...named].toSorted((a, b) => { - const ka = specSortKey(a) - const kb = specSortKey(b) - return ka < kb ? -1 : ka > kb ? 1 : 0 - }) - const sortedKeys = sorted.map(specSortKey) - - // If any comment lives between the first and last named - // specifier, skip autofix — reordering through comments - // breaks attribution. - const first = named[0] - const last = named[named.length - 1] - const interior = sourceCode.getCommentsInside - ? sourceCode - .getCommentsInside(node) - .filter( - (c: AstNode) => - c.range[0] >= first.range[0] && c.range[1] <= last.range[1], - ) - : [] - - if (interior.length > 0) { - context.report({ - node, - messageId: 'unsorted', - data: { - actual: keys.join(', '), - expected: sortedKeys.join(', '), - }, - }) - return - } - - context.report({ - node, - messageId: 'unsorted', - data: { - actual: keys.join(', '), - expected: sortedKeys.join(', '), - }, - fix(fixer: RuleFixer) { - // Detect single-line vs multi-line by looking at the - // first-token-after-`{` and last-token-before-`}`. - // The slice between { and } — preserves `,` newline padding. - const openBrace = sourceCode.getTokenBefore(first, { - filter: (t: AstNode) => t.value === '{', - }) - const closeBrace = sourceCode.getTokenAfter(last, { - filter: (t: AstNode) => t.value === '}', - }) - if (!openBrace || !closeBrace) { - return undefined - } - const sliceStart = openBrace.range[1] - const sliceEnd = closeBrace.range[0] - const original = sourceCode.text.slice(sliceStart, sliceEnd) - - const isMultiline = /\n/.test(original) - // Trim leading/trailing whitespace on the original to - // detect indentation. Multi-line case preserves the - // pre-spec indent. - let indent = '' - if (isMultiline) { - const m = original.match(/\n([ \t]*)/) - if (m) { - indent = m[1] - } - } - - const specTexts = sorted.map(s => sourceCode.getText(s)) - let rebuilt - if (isMultiline) { - rebuilt = '\n' + specTexts.map(t => indent + t).join(',\n') - // Detect trailing comma in the original. - const trailingComma = /,\s*$/.test(original.replace(/\s+$/, '')) - ? ',' - : '' - // Trim trailing whitespace before the closing brace and - // re-emit a newline + closing-brace indentation. - const closeIndent = indent.replace(/^( {2}| {4}|\t)/, '') - rebuilt += trailingComma + '\n' + closeIndent - } else { - rebuilt = ' ' + specTexts.join(', ') + ' ' - } - - return fixer.replaceTextRange([sliceStart, sliceEnd], rebuilt) - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-regex-alternations.mts b/.config/oxlint-plugin/rules/sort-regex-alternations.mts deleted file mode 100644 index a923b2116..000000000 --- a/.config/oxlint-plugin/rules/sort-regex-alternations.mts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * @file Sort regex alternation groups alphanumerically. Per CLAUDE.md "Sorting" - * rule extended to alternation: `(b|a)` should be `(a|b)` so the regex reads - * in the same order as the rest of the fleet's sorted-by-default style. - * Detects: - * - * - Capturing groups: `(foo|bar|baz)` → require sorted order. - * - Non-capturing groups: `(?:foo|bar)` → same. - * - Named-capture: `(?<name>foo|bar)` → same. Allowed exceptions (skipped): - * - Single-alternative groups (`(foo)`) — nothing to sort. - * - Position-bearing alternations where order encodes precedence (e.g. - * `<!--|-->` where `-->` MUST be tried after `<!--`). The rule can't prove - * this is the case, so it requires authors to append `// socket-hook: allow - * regex-alternation-order` on the line for the genuine exception. - * - Alternations whose elements aren't simple literals (containing `(`, `[`, - * `?`, `*`, `+`, `{`, etc.) — sorting may change match semantics in subtle - * ways. Reported but not auto-fixed. Autofix: rewrites the alternation in - * alphanumeric order when every element is a "simple literal" (alphanumeric - * / underscore / hyphen / colon / dot / forward-slash content). For richer - * alternations, reports without autofix. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -interface AltRange { - start: number - end: number -} - -interface StackEntry { - start: number - prefixEnd: number - alts: AltRange[] - altStart: number -} - -interface AlternationGroup { - altsRanges: AltRange[] - end: number - prefixEnd: number - start: number -} - -const SOCKET_HOOK_MARKER_RE = - /(?:#|\/\/|\/\*)\s*socket-hook:\s*allow(?:\s+([\w-]+))?/ - -const SIMPLE_ALT_ELEMENT_RE = /^[\w\-:./]+$/ - -function isLineMarkered(line: string): boolean { - const m = line.match(SOCKET_HOOK_MARKER_RE) - if (!m) { - return false - } - return !m[1] || m[1] === 'regex-alternation-order' -} - -/** - * Find every alternation group in a regex pattern. Returns `{ start, end, - * prefix, alternatives, suffix }` for each group. Walks the pattern character - * by character to handle nested groups + character classes correctly. - */ -function findAlternationGroups(pattern: string): AlternationGroup[] { - const groups: AlternationGroup[] = [] - // Stack entries: { start: idx of '(' in original, alts: [{start, end}], altStart: idx } - const stack: StackEntry[] = [] - let inClass = false - let i = 0 - while (i < pattern.length) { - const c = pattern[i] - if (c === '\\') { - i += 2 - continue - } - if (inClass) { - if (c === ']') { - inClass = false - } - i++ - continue - } - if (c === '[') { - inClass = true - i++ - continue - } - if (c === '(') { - // Skip group-prefix syntax: `(?:`, `(?=`, `(?!`, `(?<name>`, `(?<=`, `(?<!`. - let prefixEnd = i + 1 - let prefix = '(' - if (pattern[prefixEnd] === '?') { - prefix += '?' - prefixEnd++ - const next = pattern[prefixEnd] - if (next === '!' || next === ':' || next === '=') { - prefix += next - prefixEnd++ - } else if (next === '<') { - prefix += '<' - prefixEnd++ - // Read named capture name or lookbehind anchor. - const after = pattern[prefixEnd] - if (after === '!' || after === '=') { - prefix += after - prefixEnd++ - } else { - // Named capture group: read name then `>`. - while (prefixEnd < pattern.length && pattern[prefixEnd] !== '>') { - prefix += pattern[prefixEnd] - prefixEnd++ - } - if (prefixEnd < pattern.length) { - prefix += '>' - prefixEnd++ - } - } - } - } - stack.push({ start: i, prefixEnd, alts: [], altStart: prefixEnd }) - i = prefixEnd - continue - } - if (c === '|' && stack.length > 0) { - const top = stack[stack.length - 1]! - top.alts.push({ start: top.altStart, end: i }) - top.altStart = i + 1 - i++ - continue - } - if (c === ')') { - const top = stack.pop() - if (top) { - top.alts.push({ start: top.altStart, end: i }) - if (top.alts.length > 1) { - groups.push({ - altsRanges: top.alts, - end: i, - prefixEnd: top.prefixEnd, - start: top.start, - }) - } - } - i++ - continue - } - i++ - } - return groups -} - -/** - * Sort an alternation in alphanumeric order. Returns null if any element isn't - * a simple literal (caller should report-only). - */ -function sortAlternativesIfSimple( - pattern: string, - group: AlternationGroup, -): { actual: string[]; sorted: string[] } | undefined { - const alts = group.altsRanges.map((r: AltRange) => - pattern.slice(r.start, r.end), - ) - const allSimple = alts.every((a: string) => SIMPLE_ALT_ELEMENT_RE.test(a)) - if (!allSimple) { - return undefined - } - const sorted = [...alts].toSorted() - if (alts.every((a: string, i: number) => a === sorted[i])) { - return undefined - } - return { actual: alts, sorted } -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Sort regex alternation groups alphanumerically per the CLAUDE.md sorting rule.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - unsorted: - 'Regex alternation `({{actual}})` is not sorted alphanumerically. Expected `({{sorted}})`.', - unsortedNoFix: - 'Regex alternation `({{actual}})` is not sorted alphanumerically. Expected `({{sorted}})`. (Not auto-fixed: contains non-literal elements; sort manually or append `// socket-hook: allow regex-alternation-order` if the order is intentional.)', - }, - schema: [], - }, - - create(context: RuleContext) { - function checkLiteral(node: AstNode) { - if (!node.regex) { - return - } - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const line = sourceCode.lines[node.loc.start.line - 1] ?? '' - if (isLineMarkered(line)) { - return - } - const pattern = node.regex.pattern - const groups = findAlternationGroups(pattern) - for (let i = 0, { length } = groups; i < length; i += 1) { - const group = groups[i]! - const result = sortAlternativesIfSimple(pattern, group) - if (!result) { - // Not simple: still flag if alternation is unsorted (caller picks). - const alts = group.altsRanges.map((r: AltRange) => - pattern.slice(r.start, r.end), - ) - const sortedRaw = [...alts].toSorted() - if (alts.every((a: string, i: number) => a === sortedRaw[i])) { - continue - } - context.report({ - node, - messageId: 'unsortedNoFix', - data: { - actual: alts.join('|'), - sorted: sortedRaw.join('|'), - }, - }) - continue - } - // Build the replacement pattern, then escape the slashes for - // RegExp literal form when emitting the autofix. - const before = pattern.slice(0, group.prefixEnd) - const after = pattern.slice(group.end) - const newPattern = before + result.sorted.join('|') + after - - context.report({ - node, - messageId: 'unsorted', - data: { - actual: result.actual.join('|'), - sorted: result.sorted.join('|'), - }, - fix(fixer: RuleFixer) { - const flags = node.regex.flags || '' - return fixer.replaceText(node, `/${newPattern}/${flags}`) - }, - }) - } - } - - return { - Literal(node: AstNode) { - checkLiteral(node) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-set-args.mts b/.config/oxlint-plugin/rules/sort-set-args.mts deleted file mode 100644 index c01adcf93..000000000 --- a/.config/oxlint-plugin/rules/sort-set-args.mts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @file Sort `new Set([...])` array elements alphanumerically. Per CLAUDE.md - * "Sorting" rule, Set/SafeSet constructor arguments are sorted (literal byte - * order, ASCII before letters). Order doesn't affect Set semantics but keeps - * diff churn low and reading easier. Autofix: rewrites the array literal in - * sorted order. Only fires when every element is a Literal (string or number) - * — mixed-type arrays or arrays containing identifiers/expressions get - * reported but not auto-fixed (sorting computed values would change - * behavior). - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SET_NAMES = new Set(['SafeSet', 'Set']) - -function isSortableElement(node: AstNode) { - return ( - node !== null && - node.type === 'Literal' && - (typeof node.value === 'string' || typeof node.value === 'number') - ) -} - -function compareSortable(a: AstNode, b: AstNode): number { - const aVal = String(a.value) - const bVal = String(b.value) - if (aVal < bVal) { - return -1 - } - if (aVal > bVal) { - return 1 - } - return 0 -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Sort Set/SafeSet constructor array arguments alphanumerically (CLAUDE.md sorting rule).', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - unsorted: - '{{name}}([...]) elements should be sorted alphanumerically. Expected: [{{expected}}]', - unsortedNoFix: - '{{name}}([...]) elements should be sorted alphanumerically (mixed-type or non-literal elements; sort manually).', - }, - schema: [], - }, - - create(context: RuleContext) { - return { - NewExpression(node: AstNode) { - const callee = node.callee - if (callee.type !== 'Identifier' || !SET_NAMES.has(callee.name)) { - return - } - if (node.arguments.length !== 1) { - return - } - const arg = node.arguments[0] - if (arg.type !== 'ArrayExpression') { - return - } - const els = arg.elements - if (els.length < 2) { - return - } - - const allSortable = els.every(isSortableElement) - if (!allSortable) { - // Check if it's already sorted by raw text — if so, no report. - const raws = els.map((e: AstNode) => (e ? e.raw || '' : '')) - const sortedRaws = [...raws].toSorted() - if (raws.every((r: string, i: number) => r === sortedRaws[i])) { - return - } - context.report({ - node: arg, - messageId: 'unsortedNoFix', - data: { name: callee.name }, - }) - return - } - - const sorted = [...els].toSorted(compareSortable) - const isSorted = sorted.every((s, i) => s === els[i]) - if (isSorted) { - return - } - - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - const expected = sorted.map(e => sourceCode.getText(e)).join(', ') - - context.report({ - node: arg, - messageId: 'unsorted', - data: { name: callee.name, expected }, - fix(fixer: RuleFixer) { - const newText = `[${expected}]` - return fixer.replaceText(arg, newText) - }, - }) - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/sort-source-methods.mts b/.config/oxlint-plugin/rules/sort-source-methods.mts deleted file mode 100644 index 2b4c9abc7..000000000 --- a/.config/oxlint-plugin/rules/sort-source-methods.mts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @file Top-level function declarations should be ordered by visibility group - * then alphanumerically within each group: - * - * 1. Private (un-exported) functions, sorted alphanumerically. - * 2. Exported functions (`export function ...`), sorted alphanumerically. - * 3. The script entrypoint (`main()` for runners) is allowed to be last - * regardless of name. Rationale: a reader scanning the file should be able - * to predict where any function lives. Mixed-visibility ordering makes it - * hard to find the public surface; alphabetical inside each group is - * cheap, deterministic, and matches the rest of the fleet's sorting - * conventions (CLAUDE.md "Sorting" rule). Autofix: emits a single fix that - * re-orders top-level function declarations into canonical order. Function - * declarations are hoisted, so reordering them is safe for runtime - * semantics; the leading JSDoc / line-comment block above each declaration - * travels with the function. The rule only autofixes when every function - * in the file has a name (anonymous default exports are skipped) and when - * there are no top-level non-function statements interleaved between - * functions — interleaved statements can carry side-effects or rely on - * declaration order, so we don't reshuffle around them. - */ - -import type { AstNode, RuleContext, RuleFixer } from '../lib/rule-types.mts' - -const SCRIPT_ENTRY_NAMES = new Set(['main']) - -/** - * Type-only top-level statements that can travel with the function they sit - * above. Reordering them is safe because they're erased at compile time (no - * runtime side effects, no declaration-order semantics). - */ -export function isTypeOnlyStatement(node: AstNode) { - if (!node) { - return false - } - if ( - node.type === 'TSInterfaceDeclaration' || - node.type === 'TSTypeAliasDeclaration' - ) { - return true - } - if ( - node.type === 'ExportNamedDeclaration' && - node.declaration && - (node.declaration.type === 'TSInterfaceDeclaration' || - node.declaration.type === 'TSTypeAliasDeclaration') - ) { - return true - } - // `export type { ... }` re-exports — typically grouped at top with - // imports, but if one slipped between functions it's safe to move. - if ( - node.type === 'ExportNamedDeclaration' && - node.exportKind === 'type' && - !node.declaration - ) { - return true - } - return false -} - -export function declVisibility(node: AstNode) { - // ExportNamedDeclaration wrapping a FunctionDeclaration. - if ( - node.type === 'ExportNamedDeclaration' && - node.declaration && - node.declaration.type === 'FunctionDeclaration' - ) { - return { visibility: 'export', fn: node.declaration } - } - // export default function ... - if ( - node.type === 'ExportDefaultDeclaration' && - node.declaration && - node.declaration.type === 'FunctionDeclaration' - ) { - return { visibility: 'export', fn: node.declaration } - } - if (node.type === 'FunctionDeclaration') { - return { visibility: 'private', fn: node } - } - return undefined -} - -/** - * Compute the sort key for a function entry. Private functions sort before - * exports; within each group, alphanumerical by name. The script entrypoint - * (`main`) is pinned to the end regardless of group. - */ -interface FunctionEntry { - isEntrypoint: boolean - name: string - visibility: 'private' | 'export' - node: AstNode - start: number - end: number -} - -export function sortKey(entry: FunctionEntry): string { - if (entry.isEntrypoint) { - // '~' (0x7E) is the highest printable ASCII char, so this sort key - // pins the entrypoint to the end of any group. - return '~~entrypoint' - } - return `${entry.visibility === 'private' ? '0' : '1'}${entry.name}` -} - -/** - * Locate the byte-range start of a function entry, including any leading JSDoc - * / line-comment block that's contiguous with it (a block separated by a blank - * line is treated as a free-standing comment and stays put). Falls back to the - * node's own start when there are no leading comments. - */ -export function leadingCommentStart( - sourceCode: AstNode, - node: AstNode, -): number { - const comments = sourceCode.getCommentsBefore - ? sourceCode.getCommentsBefore(node) - : [] - if (!comments || comments.length === 0) { - return node.range[0] - } - // Walk from the last comment back, accepting any comment that's - // separated from the next one by no more than a single newline - // (allows a tight stack of `// foo\n// bar\n/** ... */`). - const tokenText = sourceCode.text - let earliest = node.range[0] - for (let i = comments.length - 1; i >= 0; i--) { - const c = comments[i] - const between = tokenText.slice(c.range[1], earliest) - // Reject if there's a blank line between this comment and the - // next block — that means it's a free-standing comment. - if (/\n\s*\n/.test(between)) { - break - } - earliest = c.range[0] - } - return earliest -} - -/** - * Locate the byte-range end of a function entry, including any trailing comment - * that's contiguous (no blank line between) and exclusive of the next function. - * Useful for capturing c8-ignore-stop markers that pair with a start above the - * function — those need to travel with the function when reordered. - */ -export function trailingCommentEnd( - sourceCode: AstNode, - node: AstNode, - nextNodeStart: number | undefined, -): number { - const tokenText = sourceCode.text - const comments = sourceCode.getCommentsAfter - ? sourceCode.getCommentsAfter(node) - : [] - let latest = node.range[1] - if (!comments || comments.length === 0) { - return latest - } - for (let i = 0, { length } = comments; i < length; i += 1) { - const c = comments[i]! - if (nextNodeStart !== undefined && c.range[0] >= nextNodeStart) { - break - } - const between = tokenText.slice(latest, c.range[0]) - // Reject if there's a blank line between this function and the - // comment — that means it's a free-standing comment. - if (/\n\s*\n/.test(between)) { - break - } - latest = c.range[1] - } - return latest -} - -/** - * @type {import('eslint').Rule.RuleModule} - */ -const rule = { - meta: { - type: 'suggestion', - docs: { - description: - 'Top-level functions sorted by visibility (private→export) and alphanumerically within each group.', - category: 'Stylistic Issues', - recommended: true, - }, - fixable: 'code', - messages: { - groupOutOfOrder: - 'Top-level function `{{name}}` ({{visibility}}) appears after a function from the next visibility group. Order: private functions first (alphanumeric), then exported functions (alphanumeric).', - alphaOutOfOrder: - 'Top-level function `{{name}}` ({{visibility}}) is out of alphanumeric order within its visibility group. Expected to come before `{{prev}}`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const sourceCode = context.getSourceCode - ? context.getSourceCode() - : context.sourceCode - - return { - Program(programNode: AstNode) { - // First pass: collect entries + detect violations. - const entries: FunctionEntry[] = [] - let lastVisibilityRank = -1 - let lastNameInGroup = undefined - let currentVisibility = undefined - const violations = [] - - // First find the next program-body node after each function, so - // trailingCommentEnd can stop before reaching it. - const bodyByIndex = programNode.body - for (let i = 0; i < bodyByIndex.length; i++) { - const node = bodyByIndex[i] - const info = declVisibility(node) - if (!info || !info.fn.id || info.fn.id.type !== 'Identifier') { - continue - } - const name = info.fn.id.name - const isEntrypoint = SCRIPT_ENTRY_NAMES.has(name) - let start = leadingCommentStart(sourceCode, node) - // Pull in any contiguous type-only statements (TS type aliases - // / interfaces) that sit immediately above this function — - // they're erased at compile time, have no runtime side - // effects, and are conventionally placed next to the function - // that consumes them. They travel with the function on sort. - let j = i - 1 - while (j >= 0 && isTypeOnlyStatement(bodyByIndex[j])) { - // Only absorb the type when there's no other function entry - // between it and the current node (entries are pushed in - // order, so the previous entry's `end` marks where the - // previous function's range ended). - const prevEntry = entries[entries.length - 1] - if (prevEntry && prevEntry.end > bodyByIndex[j].range[0]) { - break - } - start = leadingCommentStart(sourceCode, bodyByIndex[j]) - j -= 1 - } - const nextStart = - i + 1 < bodyByIndex.length ? bodyByIndex[i + 1].range[0] : undefined - const end = trailingCommentEnd(sourceCode, node, nextStart) - entries.push({ - node, - name, - visibility: info.visibility as 'private' | 'export', - isEntrypoint, - start, - end, - }) - - if (isEntrypoint) { - continue - } - - const rank = info.visibility === 'private' ? 0 : 1 - - if (rank < lastVisibilityRank) { - violations.push({ - node: info.fn.id, - messageId: 'groupOutOfOrder', - data: { name, visibility: info.visibility }, - }) - continue - } - if (rank !== lastVisibilityRank) { - currentVisibility = info.visibility - lastVisibilityRank = rank - lastNameInGroup = name - continue - } - if (lastNameInGroup !== null && name < lastNameInGroup) { - violations.push({ - node: info.fn.id, - messageId: 'alphaOutOfOrder', - data: { - name, - visibility: currentVisibility, - prev: lastNameInGroup, - }, - }) - } else { - lastNameInGroup = name - } - } - - if (violations.length === 0) { - return - } - - // Build the fix once, applied via the first violation. ESLint - // dedupes overlapping fixes, so attaching it once is enough. - const sorted = entries.slice().toSorted((a, b) => { - const ka = sortKey(a) - const kb = sortKey(b) - if (ka < kb) { - return -1 - } - if (ka > kb) { - return 1 - } - return 0 - }) - - const orderedByPosition = entries - .slice() - .toSorted((a, b) => a.start - b.start) - const sourceText = sourceCode.text - const rangeStart = orderedByPosition[0]!.start - const rangeEnd = orderedByPosition[orderedByPosition.length - 1]!.end - - // Bail if any runtime statement lives between the first and - // last function — re-ordering would skip over them and lose - // their side-effects / declaration-order semantics. Type-only - // statements (TSTypeAliasDeclaration / TSInterfaceDeclaration - // and their exported forms) are erased at compile time and are - // already absorbed into the preceding function's range above, - // so they don't trigger the bail. - for (const stmt of programNode.body) { - const isFn = entries.some(e => e.node === stmt) - if (isFn || isTypeOnlyStatement(stmt)) { - continue - } - if (stmt.range[0] >= rangeStart && stmt.range[1] <= rangeEnd) { - // Statement is sandwiched between functions; skip autofix. - for (let i = 0, { length } = violations; i < length; i += 1) { - const v = violations[i]! - context.report(v) - } - return - } - } - - const sortedTexts = sorted.map(e => sourceText.slice(e.start, e.end)) - const replacement = sortedTexts.join('\n\n') - - // Attach the fix to the first violation only; the rest are - // reported without a fix so the user sees what's wrong even - // when applying without --fix. - let fixerAttached = false - for (let i = 0, { length } = violations; i < length; i += 1) { - const v = violations[i]! - if (!fixerAttached) { - context.report({ - ...v, - fix(fixer: RuleFixer) { - return fixer.replaceTextRange( - [rangeStart, rangeEnd], - replacement, - ) - }, - }) - fixerAttached = true - } else { - context.report(v) - } - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/rules/use-fleet-canonical-api-token-getter.mts b/.config/oxlint-plugin/rules/use-fleet-canonical-api-token-getter.mts deleted file mode 100644 index 10e00e638..000000000 --- a/.config/oxlint-plugin/rules/use-fleet-canonical-api-token-getter.mts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Per CLAUDE.md "Token hygiene → Socket API token env var" + the v6 - * `secrets/socket-api-token` helper: reading the Socket API token directly - * from `process.env` misses the keychain fallback and the legacy-alias chain. - * Use `readSocketApiToken()` / `readSocketApiTokenSync()` from - * `@socketsecurity/lib-stable/secrets/socket-api-token`. Detects direct env - * reads: - * - * - `process.env.SOCKET_API_TOKEN` - * - `process.env['SOCKET_API_TOKEN']` - * - `process.env.SOCKET_API_KEY` (legacy alias — also covered by - * `socket-api-token-env`, but flagged here for the helper-getter rewrite) - * Skipped (allowed): - * - Files at `src/secrets/...` — the helper itself + its implementation must - * read `process.env`. - * - Lines marked with `socket-api-token-getter: allow direct-env` adjacent - * comment — the bootstrap/setup hooks that legitimately read env before the - * lib helper is available (CI runners, install scripts). No autofix: the - * right import-path varies per consumer (`lib-stable` for downstream fleet - * repos, `lib` for socket-lib itself), and the right variant - * (`readSocketApiToken` vs `readSocketApiTokenSync`) depends on whether the - * caller is async-capable. Reporting only. - */ - -import { makeBypassChecker } from '../lib/comment-markers.mts' -import type { AstNode, RuleContext } from '../lib/rule-types.mts' - -const FLAGGED_PROPERTIES = new Set(['SOCKET_API_KEY', 'SOCKET_API_TOKEN']) - -const BYPASS_RE = /socket-api-token-getter:\s*allow direct-env/ - -export function isProcessEnv(node: AstNode): boolean { - if (node.type !== 'MemberExpression') { - return false - } - const obj = (node as { object?: AstNode | undefined }).object - const prop = (node as { property?: AstNode | undefined }).property - if (!obj || !prop) { - return false - } - if ( - obj.type !== 'Identifier' || - (obj as { name?: string | undefined }).name !== 'process' - ) { - return false - } - if ( - prop.type !== 'Identifier' || - (prop as { name?: string | undefined }).name !== 'env' - ) { - return false - } - return true -} - -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Use readSocketApiToken / readSocketApiTokenSync from @socketsecurity/lib-stable/secrets/socket-api-token instead of process.env reads of SOCKET_API_TOKEN / SOCKET_API_KEY.', - category: 'Best Practices', - recommended: true, - }, - messages: { - directEnv: - '`process.env.{{name}}` direct env read — use `readSocketApiToken()` / `readSocketApiTokenSync()` from @socketsecurity/lib-stable/secrets/socket-api-token. Direct env reads skip the keychain fallback. Bootstrap/setup code can suppress with `// socket-api-token-getter: allow direct-env`.', - }, - schema: [], - }, - - create(context: RuleContext) { - const filename = - (context as { filename?: string | undefined }).filename ?? - ( - context as { getFilename?: (() => string) | undefined } - ).getFilename?.() ?? - '' - - if (/[\\/]src[\\/]secrets[\\/]/.test(filename)) { - return {} - } - - const hasBypassComment = makeBypassChecker(context, BYPASS_RE) - - function reportName(node: AstNode, name: string) { - if (hasBypassComment(node)) { - return - } - context.report({ - node, - messageId: 'directEnv', - data: { name }, - }) - } - - return { - MemberExpression(node: AstNode) { - const obj = (node as { object?: AstNode | undefined }).object - if (!obj || !isProcessEnv(obj)) { - return - } - const prop = (node as { property?: AstNode | undefined }).property - if (!prop) { - return - } - const computed = (node as { computed?: boolean | undefined }).computed - if (!computed && prop.type === 'Identifier') { - const name = (prop as { name?: string | undefined }).name ?? '' - if (FLAGGED_PROPERTIES.has(name)) { - reportName(node, name) - } - return - } - if (computed && prop.type === 'Literal') { - const v = (prop as { value?: unknown | undefined }).value - if (typeof v === 'string' && FLAGGED_PROPERTIES.has(v)) { - reportName(node, v) - } - } - }, - } - }, -} - -// oxlint-disable-next-line socket/no-default-export -- oxlint plugin contract requires default-exported rule object. -export default rule diff --git a/.config/oxlint-plugin/test/comment-markers.test.mts b/.config/oxlint-plugin/test/comment-markers.test.mts deleted file mode 100644 index 3145f9fdb..000000000 --- a/.config/oxlint-plugin/test/comment-markers.test.mts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file Unit tests for the shared bypass-comment scanner (lib/comment-markers). - * Exercised directly with a fake RuleContext rather than through the - * RuleTester — the helper is pure (source text + node line in → boolean out), - * so a synthetic context is faster and more precise than a fixture lint. - */ - -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' - -import { makeBypassChecker } from '../lib/comment-markers.mts' - -// Minimal RuleContext stand-in: exposes the source text via getSourceCode(). -function ctx(source: string): { - getSourceCode: () => { getText: () => string } -} { - return { getSourceCode: () => ({ getText: () => source }) } -} - -// A node carrying a 1-based start line, as oxlint exposes via `loc`. -function nodeOnLine(line: number): { loc: { start: { line: number } } } { - return { loc: { start: { line } } } -} - -const MARKER = /socket-hook:\s*allow\s+sample/ - -describe('lib/comment-markers makeBypassChecker', () => { - test('marker on the node’s own line (trailing comment) → bypassed', () => { - const src = 'const x = doThing() // socket-hook: allow sample\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(1) as never), true) - }) - - test('marker on the line directly above → bypassed', () => { - const src = '// socket-hook: allow sample\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(2) as never), true) - }) - - test('marker in a contiguous leading-comment block (2 lines up) → bypassed', () => { - const src = - '// socket-hook: allow sample\n// continuation note\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(3) as never), true) - }) - - test('no marker anywhere → not bypassed', () => { - const src = '// unrelated comment\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(2) as never), false) - }) - - test('marker separated from the node by a code line → not bypassed', () => { - const src = - '// socket-hook: allow sample\nconst unrelated = 1\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(3) as never), false) - }) - - test('marker too far above (beyond the leading-block window) → not bypassed', () => { - const src = - '// socket-hook: allow sample\n//\n//\n//\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has(nodeOnLine(5) as never), false) - }) - - test('falls back to range offset when loc is absent', () => { - const src = '// socket-hook: allow sample\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - // Node on line 2 via range: offset of `const` is after the first newline. - const offset = src.indexOf('const') - assert.equal(has({ range: [offset, offset + 5] } as never), true) - }) - - test('returns false when the node has no position info', () => { - const src = '// socket-hook: allow sample\nconst x = doThing()\n' - const has = makeBypassChecker(ctx(src) as never, MARKER) - assert.equal(has({} as never), false) - }) -}) diff --git a/.config/oxlint-plugin/test/export-top-level-functions.test.mts b/.config/oxlint-plugin/test/export-top-level-functions.test.mts deleted file mode 100644 index a6d993870..000000000 --- a/.config/oxlint-plugin/test/export-top-level-functions.test.mts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @file Unit tests for socket/export-top-level-functions. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/export-top-level-functions.mts' - -describe('socket/export-top-level-functions', () => { - test('valid + invalid cases', () => { - new RuleTester().run('export-top-level-functions', rule, { - valid: [ - { - name: 'inline export', - code: 'export function foo() {}\n', - }, - { - // Skip the autofix entirely on CJS files — rewriting - // `function foo() {}` to `export function foo() {}` in a - // CJS module makes the file syntactically ESM and breaks - // `require()` at load time. The .cjs extension is the - // authoritative signal. - name: 'cjs file is skipped (filename hint)', - filename: 'fixture.cjs', - code: 'function foo() {}\nmodule.exports = { foo }\n', - }, - { - // Same skip via content sniff when the extension is ambiguous - // — wasm-bindgen `--target nodejs` output is the worked - // example. `module.exports` + internal `function` is CJS. - name: 'cjs file is skipped (content sniff on .js)', - filename: 'fixture.js', - code: - 'function getObject(idx) { return idx }\n' + - 'module.exports.getObject = getObject\n', - }, - ], - invalid: [ - { - name: 'unexported top-level functions', - // Both `foo` and `bar` are top-level and not exported — - // each fires its own finding. - code: 'function foo() {}\nfunction bar() {}\nbar()\n', - errors: [{ messageId: 'missing' }, { messageId: 'missing' }], - }, - { - name: 'declared then re-exported via export-named', - // The rule prefers inline `export function foo` and flags - // the split form `function foo(); export { foo }` to avoid - // the duplicate-name footgun (autofix is skipped to keep - // the rewrite human-decided). - code: 'function foo() {}\nexport { foo }\n', - errors: [{ messageId: 'missingAlreadyReExported' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/inclusive-language.test.mts b/.config/oxlint-plugin/test/inclusive-language.test.mts deleted file mode 100644 index 558ef9b4c..000000000 --- a/.config/oxlint-plugin/test/inclusive-language.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Unit tests for socket/inclusive-language. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/inclusive-language.mts' - -describe('socket/inclusive-language', () => { - test('valid + invalid cases', () => { - new RuleTester().run('inclusive-language', rule, { - valid: [ - { - name: 'allowlist usage', - code: 'const allowlist = ["a"]\nconsole.log(allowlist)\n', - }, - { - name: 'main branch', - code: 'const branch = "main"\nconsole.log(branch)\n', - }, - ], - invalid: [ - { - name: 'master/slave naming', - code: 'const master = true\nconst slave = false\nconsole.log(master, slave)\n', - // Each occurrence of `master` / `slave` is flagged - // individually, including references in the - // `console.log` call — 4 findings total. - errors: [ - { messageId: 'legacyMaster' }, - { messageId: 'legacySlave' }, - { messageId: 'legacyMaster' }, - { messageId: 'legacySlave' }, - ], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/max-file-lines.test.mts b/.config/oxlint-plugin/test/max-file-lines.test.mts deleted file mode 100644 index 9d107d25e..000000000 --- a/.config/oxlint-plugin/test/max-file-lines.test.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file Unit tests for socket/max-file-lines. Synthesizes files past the soft - * (500) and hard (1000) caps to verify both severities fire. The body is `// - * line N` lines — minimal valid TypeScript. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/max-file-lines.mts' - -function lines(n: number, prefix = '// line'): string { - const out: string[] = [] - for (let i = 0; i < n; i += 1) { - out.push(`${prefix} ${i}`) - } - return out.join('\n') + '\n' -} - -describe('socket/max-file-lines', () => { - test('valid + invalid cases', () => { - new RuleTester().run('max-file-lines', rule, { - valid: [ - { name: 'small file', code: lines(50) }, - { name: 'just under soft cap', code: lines(499) }, - ], - invalid: [ - { - name: 'past soft cap', - code: lines(600), - errors: [{ messageId: 'soft' }], - }, - { - name: 'past hard cap', - code: lines(1100), - errors: [{ messageId: 'hard' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-bare-crypto-named-usage.test.mts b/.config/oxlint-plugin/test/no-bare-crypto-named-usage.test.mts deleted file mode 100644 index 90dad1d7f..000000000 --- a/.config/oxlint-plugin/test/no-bare-crypto-named-usage.test.mts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file Unit tests for socket/no-bare-crypto-named-usage. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-bare-crypto-named-usage.mts' - -describe('socket/no-bare-crypto-named-usage', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-bare-crypto-named-usage', rule, { - valid: [ - { - name: 'no node:crypto import — bare identifier passes', - code: "const x = createHash('sha256')\n", - }, - { - name: 'named-import form — not this rule', - code: "import { createHash } from 'node:crypto'\nconst h = createHash('sha256')\n", - }, - { - name: 'default import + member access', - code: "import crypto from 'node:crypto'\nconst h = crypto.createHash('sha256')\n", - }, - ], - invalid: [ - { - name: 'default import + bare createHash call', - code: "import crypto from 'node:crypto'\nconst h = createHash('sha256')\n", - errors: [{ messageId: 'bareNamed', data: { name: 'createHash' } }], - output: - "import crypto from 'node:crypto'\nconst h = crypto.createHash('sha256')\n", - }, - { - name: 'default import + bare randomBytes call', - code: "import crypto from 'node:crypto'\nconst b = randomBytes(16)\n", - errors: [{ messageId: 'bareNamed', data: { name: 'randomBytes' } }], - output: - "import crypto from 'node:crypto'\nconst b = crypto.randomBytes(16)\n", - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-cached-for-on-iterable.test.mts b/.config/oxlint-plugin/test/no-cached-for-on-iterable.test.mts deleted file mode 100644 index 45d910e7a..000000000 --- a/.config/oxlint-plugin/test/no-cached-for-on-iterable.test.mts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @file Unit tests for socket/no-cached-for-on-iterable. The rule catches the - * silent-no-op bug where the fleet's canonical cached-length `for (let i = 0, - * { length } = X; …)` loop is applied to a Set / Map / Iterable instead of an - * array. The 4 fleet incidents that motivated the rule all had a clear `new - * Set(...)` or `: Set<string>` annotation in scope; tests cover those signals - * plus a few negatives (arrays, unknown bindings) where the rule must stay - * silent to avoid nagging on the canonical shape. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-cached-for-on-iterable.mts' - -describe('socket/no-cached-for-on-iterable', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-cached-for-on-iterable', rule, { - valid: [ - { - name: 'array literal binding — cached-for is correct', - code: - 'const arr = [1, 2, 3]\n' + - 'for (let i = 0, { length } = arr; i < length; i += 1) {\n' + - ' const item = arr[i]!\n' + - ' void item\n' + - '}\n', - }, - { - name: 'T[] annotation — cached-for is correct', - code: - 'const arr: string[] = []\n' + - 'for (let i = 0, { length } = arr; i < length; i += 1) {\n' + - ' void arr[i]\n' + - '}\n', - }, - { - name: 'Array<T> annotation — cached-for is correct', - code: - 'const arr: Array<number> = []\n' + - 'for (let i = 0, { length } = arr; i < length; i += 1) {\n' + - ' void arr[i]\n' + - '}\n', - }, - { - name: 'Array.from materialization — cached-for is correct', - code: - 'const set = new Set<string>()\n' + - 'const arr = Array.from(set)\n' + - 'for (let i = 0, { length } = arr; i < length; i += 1) {\n' + - ' void arr[i]\n' + - '}\n', - }, - { - name: 'Object.keys materialization — cached-for is correct', - code: - 'const obj = { a: 1, b: 2 }\n' + - 'const keys = Object.keys(obj)\n' + - 'for (let i = 0, { length } = keys; i < length; i += 1) {\n' + - ' void keys[i]\n' + - '}\n', - }, - { - name: 'unknown binding (no signal) — skip silently', - code: - 'declare const things: unknown\n' + - 'for (let i = 0, { length } = (things as any); i < length; i += 1) {\n' + - ' void i\n' + - '}\n', - }, - { - name: 'for...of over a Set — not a cached-for, no finding', - code: - 'const set = new Set<string>()\n' + - 'for (const item of set) {\n' + - ' void item\n' + - '}\n', - }, - { - name: 'plain for without the {length} destructure — not the shape', - code: - 'const set = new Set<string>()\n' + - 'for (let i = 0; i < 10; i += 1) {\n' + - ' void i\n' + - '}\n', - }, - { - name: 'set.size read is correct — not flagged', - code: - 'const items = new Set<string>()\n' + - 'const n = items.size\n' + - 'void n\n', - }, - { - name: 'map[someKey] with non-numeric-looking identifier is left alone', - // The rule deliberately stays conservative: `map[someKey]` - // could be a typo for `map.get(someKey)`, but it could also - // be a Record / plain-object access aliased through Map<>. - // Only flag when the index strongly looks like a counter - // (i / j / k / index / etc.). - code: - 'declare const m: Map<string, number>\n' + - 'declare const someKey: string\n' + - 'const v = m[someKey]\n' + - 'void v\n', - }, - { - name: 'scope shadowing: function-local Map does NOT taint outer Array binding', - // The original bug that motivated the scope-aware refactor: - // a function-local `new Map()` shadowed by name with an - // outer-scope array binding would propagate the "map" kind - // to the outer use under the old flat-Map tracking. The - // scope-walk resolver looks up from each use site, finds - // the nearest declaring scope, and classifies based on - // *that* declaration — so the outer `.length` read here - // resolves to the outer array (kind=unknown via init type - // annotation absent + await init) and does NOT fire. - code: - 'function inner(): number[] {\n' + - ' const closure = new Map<string, number>()\n' + - ' return [...closure.values()]\n' + - '}\n' + - 'const closure: readonly number[] = inner()\n' + - 'const n = closure.length\n' + - 'void n\n', - }, - { - name: 'scope shadowing: outer Set, inner non-iterable rebind shadows it', - // The reverse direction: outer scope has a Set binding, - // an inner function declares a same-named array. The - // .length read inside the inner function should resolve - // to the inner array, not the outer Set — so it must NOT - // fire. - code: - 'const items = new Set<string>()\n' + - 'function inner(): void {\n' + - ' const items: readonly string[] = []\n' + - ' const n = items.length\n' + - ' void n\n' + - '}\n' + - 'inner()\n', - }, - ], - invalid: [ - { - name: 'new Set() binding — bare init (cached-for + indexed body, single report)', - code: - 'const items = new Set()\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' const item = items[i]!\n' + - ' void item\n' + - '}\n', - // Only the cached-for shape is reported. The body's - // `items[i]` read is suppressed because the enclosing - // for-loop already fired — fixing the loop fixes the - // body access by construction. - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'Set<string> annotation (cached-for + indexed body, single report)', - code: - 'declare const items: Set<string>\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void items[i]\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'ReadonlySet<string> annotation', - code: - 'declare const items: ReadonlySet<string>\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void items[i]\n' + - '}\n', - // Body's indexed access suppressed; loop is the single report. - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'new Map() binding', - code: - 'const m = new Map<string, number>()\n' + - 'for (let i = 0, { length } = m; i < length; i += 1) {\n' + - ' void m[i]\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'Map<K, V> annotation', - code: - 'declare const m: Map<string, number>\n' + - 'for (let i = 0, { length } = m; i < length; i += 1) {\n' + - ' void m[i]\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'WeakSet<T> annotation', - code: - 'declare const items: WeakSet<object>\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void i\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'Iterable<T> annotation', - code: - 'declare const items: Iterable<string>\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void i\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'IterableIterator<T> annotation', - code: - 'declare const items: IterableIterator<string>\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void i\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'parameter typed Set<string>', - code: - 'function walk(items: Set<string>): void {\n' + - ' for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' void i\n' + - ' }\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'arrow parameter typed Map<K, V>', - code: - 'const walk = (m: Map<string, number>): void => {\n' + - ' for (let i = 0, { length } = m; i < length; i += 1) {\n' + - ' void i\n' + - ' }\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'set.length read returns undefined', - // `Set.size` is the right name; reading `.length` quietly - // returns undefined and is almost always a typo. - code: - 'const items = new Set<string>()\n' + - 'const n = items.length\n' + - 'void n\n', - errors: [{ messageId: 'lengthOnIterable' }], - }, - { - name: 'map.length read returns undefined', - code: - 'declare const m: Map<string, number>\n' + - 'const n = m.length\n' + - 'void n\n', - errors: [{ messageId: 'lengthOnIterable' }], - }, - { - name: 'set[i] indexed read (numeric literal)', - code: - 'const items = new Set<string>()\n' + - 'const first = items[0]\n' + - 'void first\n', - errors: [{ messageId: 'indexedAccessOnIterable' }], - }, - { - name: 'set[index] indexed read (counter identifier)', - code: - 'declare const items: Set<string>\n' + - 'declare const index: number\n' + - 'const v = items[index]\n' + - 'void v\n', - errors: [{ messageId: 'indexedAccessOnIterable' }], - }, - { - name: 'cached-for + indexed body — body access SUPPRESSED (single report)', - // The cached-for loop is the single root cause. Suppressing - // the body's `items[i]` finding keeps the fix-path obvious: - // rewrite the loop to `for...of`, and the indexed access - // disappears automatically. - code: - 'const items = new Set<string>()\n' + - 'for (let i = 0, { length } = items; i < length; i += 1) {\n' + - ' const v = items[i]\n' + - ' void v\n' + - '}\n', - errors: [{ messageId: 'noCachedForOnIterable' }], - }, - { - name: 'standalone indexed access on a Set (outside any for-loop) still fires', - // Proves the suppression is *scoped to enclosing flagged - // loops only* — it doesn't blanket-suppress indexed access - // on Sets in general. - code: - 'const items = new Set<string>()\n' + - 'const first = items[0]\n' + - 'void first\n', - errors: [{ messageId: 'indexedAccessOnIterable' }], - }, - { - name: 'scope shadowing: outer Set IS flagged in outer scope (inner shadow does not exempt)', - // Proves the scope walk is two-way correct: the outer - // .length read must STILL fire on the outer Set, even - // though an inner function shadows the name with an - // array. The inner array binding doesn't reach into the - // outer scope, so the outer lookup finds the outer Set - // declaration and flags correctly. - code: - 'const items = new Set<string>()\n' + - 'function inner(): void {\n' + - ' const items: readonly string[] = []\n' + - ' void items.length\n' + - '}\n' + - 'inner()\n' + - 'const n = items.length\n' + - 'void n\n', - errors: [{ messageId: 'lengthOnIterable' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-console-prefer-logger.test.mts b/.config/oxlint-plugin/test/no-console-prefer-logger.test.mts deleted file mode 100644 index 3433cb2b6..000000000 --- a/.config/oxlint-plugin/test/no-console-prefer-logger.test.mts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @file Unit tests for socket/no-console-prefer-logger. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-console-prefer-logger.mts' - -describe('socket/no-console-prefer-logger', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-console-prefer-logger', rule, { - valid: [ - { - name: 'logger.log with hoisted const', - code: 'import { getDefaultLogger } from "@socketsecurity/lib-stable/logger/default"\nconst logger = getDefaultLogger()\nlogger.log("ok")\n', - }, - { - name: 'logger.log with exported const (regression: hasLocal must see ExportNamedDeclaration)', - code: 'export const logger = { log: () => {} }\nlogger.log("ok")\n', - }, - { name: 'no console at all', code: 'export const x = 1\n' }, - ], - invalid: [ - { - name: 'console.log', - code: 'console.log("nope")\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'console.error', - code: 'console.error("nope")\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-default-export.test.mts b/.config/oxlint-plugin/test/no-default-export.test.mts deleted file mode 100644 index 305ba7f15..000000000 --- a/.config/oxlint-plugin/test/no-default-export.test.mts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @file Unit tests for the no-default-export oxlint rule. Spawns the real - * oxlint binary against fixture files in a tmp dir (see lib/rule-tester.mts). - * Skips silently when `oxlint` isn't on PATH so a fresh-laptop checkout - * doesn't false-fail before `pnpm install` materializes the bin link. - */ - -import { describe, test } from 'node:test' - -import rule from '../rules/no-default-export.mts' -import { RuleTester } from '../lib/rule-tester.mts' - -describe('socket/no-default-export', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-default-export', rule, { - valid: [ - { name: 'named const export', code: 'export const foo = 1\n' }, - { name: 'named function export', code: 'export function foo() {}\n' }, - { name: 'named class export', code: 'export class Foo {}\n' }, - { - name: 'named re-export', - code: 'export { foo } from "./mod"\n', - }, - ], - invalid: [ - { - name: 'default function (named)', - code: 'export default function foo() {}\n', - errors: [{ messageId: 'noDefaultExport' }], - output: 'export function foo() {}\n', - }, - { - name: 'default class (named)', - code: 'export default class Foo {}\n', - errors: [{ messageId: 'noDefaultExport' }], - output: 'export class Foo {}\n', - }, - { - name: 'default identifier', - code: 'const foo = 1\nexport default foo\n', - errors: [{ messageId: 'noDefaultExport' }], - output: 'const foo = 1\nexport { foo }\n', - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-dynamic-import-outside-bundle.test.mts b/.config/oxlint-plugin/test/no-dynamic-import-outside-bundle.test.mts deleted file mode 100644 index 80cf118ad..000000000 --- a/.config/oxlint-plugin/test/no-dynamic-import-outside-bundle.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/no-dynamic-import-outside-bundle. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-dynamic-import-outside-bundle.mts' - -describe('socket/no-dynamic-import-outside-bundle', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-dynamic-import-outside-bundle', rule, { - valid: [ - { - name: 'static import', - code: 'import { x } from "./mod"\nconsole.log(x)\n', - }, - ], - invalid: [ - { - name: 'top-level dynamic import', - code: 'const m = await import("./mod")\nconsole.log(m)\n', - errors: [{ messageId: 'dynamic' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-eslint-biome-config-ref.test.mts b/.config/oxlint-plugin/test/no-eslint-biome-config-ref.test.mts deleted file mode 100644 index af8219892..000000000 --- a/.config/oxlint-plugin/test/no-eslint-biome-config-ref.test.mts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @file Unit tests for socket/no-eslint-biome-config-ref. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-eslint-biome-config-ref.mts' - -describe('socket/no-eslint-biome-config-ref', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-eslint-biome-config-ref', rule, { - valid: [ - { - name: 'oxlint reference — allowed', - code: 'const path = "./oxlintrc.json"\n', - }, - { - name: 'unrelated string', - code: 'const greeting = "hello"\n', - }, - { - name: 'bypass marker — package-name-as-data, not a config ref', - code: '// socket-hook: allow eslint-biome-ref\nconst pkg = "eslint"\n', - }, - ], - invalid: [ - { - name: '.eslintrc reference', - code: 'const cfg = ".eslintrc"\n', - errors: [{ messageId: 'staleConfig', data: { ref: '.eslintrc' } }], - }, - { - name: 'biome.json reference', - code: 'const cfg = "biome.json"\n', - errors: [{ messageId: 'staleConfig', data: { ref: 'biome.json' } }], - }, - { - name: 'eslint package', - code: 'import x from "eslint-plugin-import"\n', - errors: [{ messageId: 'staleConfig' }], - }, - { - name: '@biomejs/biome package', - code: 'import x from "@biomejs/biome"\n', - errors: [{ messageId: 'staleConfig' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-fetch-prefer-http-request.test.mts b/.config/oxlint-plugin/test/no-fetch-prefer-http-request.test.mts deleted file mode 100644 index 52f24c5f7..000000000 --- a/.config/oxlint-plugin/test/no-fetch-prefer-http-request.test.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file Unit tests for socket/no-fetch-prefer-http-request. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-fetch-prefer-http-request.mts' - -describe('socket/no-fetch-prefer-http-request', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-fetch-prefer-http-request', rule, { - valid: [ - { - name: 'httpJson import', - code: 'import { httpJson } from "@socketsecurity/lib-stable/http-request"\nawait httpJson("https://x")\n', - }, - { name: 'no fetch call', code: 'const x = 1\n' }, - { - name: 'bypass marker on the line above → allowed', - code: '// socket-hook: allow global-fetch\nconst r = await fetch("https://x")\n', - }, - ], - invalid: [ - { - name: 'top-level fetch', - code: 'await fetch("https://x")\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-file-scope-oxlint-disable.test.mts b/.config/oxlint-plugin/test/no-file-scope-oxlint-disable.test.mts deleted file mode 100644 index e0213cfb3..000000000 --- a/.config/oxlint-plugin/test/no-file-scope-oxlint-disable.test.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @file Unit tests for socket/no-file-scope-oxlint-disable. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-file-scope-oxlint-disable.mts' - -describe('socket/no-file-scope-oxlint-disable', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-file-scope-oxlint-disable', rule, { - valid: [ - { - name: 'per-line disable is allowed', - code: - '// oxlint-disable-next-line socket/no-console-prefer-logger -- bootstrap log\n' + - 'console.log("hi")\n', - }, - { - name: 'no disable directive at all', - code: 'export const x = 1\n', - }, - { - name: 'JSDoc block mentioning the shape is not a directive', - code: - '/**\n' + - ' * Example: `/* oxlint-disable socket/no-console-prefer-logger *\\/`.\n' + - ' */\n' + - 'export const x = 1\n', - }, - { - name: 'plugin-internal rules dir is exempt (lookup-table data)', - filename: '.config/oxlint-plugin/rules/example.mts', - code: - '/* oxlint-disable socket/no-console-prefer-logger */\n' + - 'export const x = 1\n', - }, - ], - invalid: [ - { - name: 'file-scope block disable', - code: - '/* oxlint-disable socket/no-console-prefer-logger */\n' + - 'console.log("a")\n', - errors: [{ messageId: 'fileScopeDisable' }], - }, - { - name: 'file-scope line disable', - code: - '// oxlint-disable socket/no-console-prefer-logger\n' + - 'console.log("a")\n', - errors: [{ messageId: 'fileScopeDisable' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-inline-defer-async.test.mts b/.config/oxlint-plugin/test/no-inline-defer-async.test.mts deleted file mode 100644 index 1af927597..000000000 --- a/.config/oxlint-plugin/test/no-inline-defer-async.test.mts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @file Unit tests for socket/no-inline-defer-async. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-inline-defer-async.mts' - -describe('socket/no-inline-defer-async', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-inline-defer-async', rule, { - valid: [ - { - name: 'plain string — no script tag', - code: 'const x = "hello world"\n', - }, - { - name: 'script with src and defer — valid external', - code: 'const html = \'<script defer src="/main.js"></script>\'\n', - }, - { - name: 'script with src and async — valid external', - code: 'const html = \'<script async src="/main.js"></script>\'\n', - }, - { - name: 'inline script without defer/async — fine', - code: 'const html = "<script>doThing()</script>"\n', - }, - { - name: 'bypass marker on the line above → allowed', - code: '// socket-hook: allow inline-defer\nconst msg = "<script async>x</script>"\n', - }, - ], - invalid: [ - { - name: 'inline <script defer> in string literal', - code: 'const html = "<script defer>doThing()</script>"\n', - errors: [{ messageId: 'inlineDeferAsync', data: { attr: 'defer' } }], - }, - { - name: 'inline <script async> in template literal', - code: 'const html = `<script async>${body}</script>`\n', - errors: [{ messageId: 'inlineDeferAsync', data: { attr: 'async' } }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-inline-logger.test.mts b/.config/oxlint-plugin/test/no-inline-logger.test.mts deleted file mode 100644 index dd813c22b..000000000 --- a/.config/oxlint-plugin/test/no-inline-logger.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/no-inline-logger. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-inline-logger.mts' - -describe('socket/no-inline-logger', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-inline-logger', rule, { - valid: [ - { - name: 'hoisted logger', - code: 'import { getDefaultLogger } from "@socketsecurity/lib-stable/logger/default"\nconst logger = getDefaultLogger()\nlogger.info("ok")\n', - }, - ], - invalid: [ - { - name: 'inline getDefaultLogger().info', - code: 'import { getDefaultLogger } from "@socketsecurity/lib-stable/logger/default"\ngetDefaultLogger().info("x")\n', - errors: [{ messageId: 'inline' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-logger-newline-literal.test.mts b/.config/oxlint-plugin/test/no-logger-newline-literal.test.mts deleted file mode 100644 index 68cb0d50a..000000000 --- a/.config/oxlint-plugin/test/no-logger-newline-literal.test.mts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @file Unit tests for socket/no-logger-newline-literal. - */ - -/* oxlint-disable socket/no-status-emoji -- emoji literals in invalid-case - inputs are the very shape this rule warns about; that's the test. */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-logger-newline-literal.mts' - -describe('socket/no-logger-newline-literal', () => { - test('valid: no newline in arg', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [ - { name: 'plain log', code: 'logger.log("Hello")\n' }, - { name: 'fail without newline', code: 'logger.fail("Build failed")\n' }, - { name: 'empty arg', code: 'logger.log("")\n' }, - { name: 'newline in non-logger call', code: 'foo.log("a\\nb")\n' }, - { - name: 'newline in console (not logger.*)', - code: 'console.log("a\\nb")\n', - }, - { - name: 'newline in non-tracked method', - code: 'logger.indent("a\\nb")\n', - }, - { - name: 'template without newline', - code: 'logger.log(`Hello ${name}`)\n', - }, - ], - invalid: [], - }) - }) - - test('invalid: leading newline rewrites with emoji map', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - { - name: 'logger.error with leading \\n + ✗ → blank=error, msg=fail', - code: 'logger.error("\\n✗ Build failed:", e)\n', - errors: [{ messageId: 'leadingNewline' }], - }, - { - name: 'logger.log with leading \\n + ✓ → blank=log, msg=success', - code: 'logger.log("\\n✓ Done")\n', - errors: [{ messageId: 'leadingNewline' }], - }, - { - name: 'logger.log with leading \\n, no emoji → blank=log, msg=log', - code: 'logger.log("\\nplain message")\n', - errors: [{ messageId: 'leadingNewlineNoEmoji' }], - }, - ], - }) - }) - - test('invalid: trailing newline rewrites', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - { - name: 'logger.success with trailing \\n + ✓ → msg=success, blank=error', - code: 'logger.success("✓ NAPI built\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - name: 'logger.log with trailing \\n, no emoji', - code: 'logger.log("plain\\n")\n', - errors: [{ messageId: 'trailingNewlineNoEmoji' }], - }, - ], - }) - }) - - test('invalid: embedded newline', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - { - name: 'logger.log with mid-string \\n', - code: 'logger.log("first line\\nsecond line")\n', - errors: [{ messageId: 'embeddedNewline' }], - }, - ], - }) - }) - - test('invalid: template literal with newline', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - { - name: 'template trailing newline', - code: 'logger.log(`out: ${name}\\n`)\n', - errors: [{ messageId: 'trailingNewlineNoEmoji' }], - }, - { - name: 'template leading newline + emoji', - code: 'logger.error(`\\n❌ ${msg}`)\n', - errors: [{ messageId: 'leadingNewline' }], - }, - ], - }) - }) - - test('emoji variants map correctly', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - // success variants - { - code: 'logger.log("✓ ok\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("✔ ok\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("✅ ok\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("√ ok\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // fail variants - { - code: 'logger.log("✗ fail\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("❌ fail\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("✖ fail\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("× fail\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // warn variants - { - code: 'logger.log("⚠ warn\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("🚨 warn\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("❗ warn\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - { - code: 'logger.log("‼ warn\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - ], - }) - }) - - test('anchored fallbacks: at start of string only', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [ - // `>` in middle = not a status symbol - { - name: '> mid-string is fine (no trailing newline)', - // The `>` mid-string isn't a status symbol; this case only - // verifies that. The trailing `\n` shape is covered by a - // separate invalid case. - code: 'logger.log("a > b")\n', - }, - // `i` in middle of a word - { - name: 'i in word is fine (no trailing newline)', - // The `i` letter mid-word isn't a status-glyph prefix; this - // case only verifies that. Trailing-newline shape is - // covered by its own invalid case. - code: 'logger.log("indexing items")\n', - }, - // `@` in middle (package ref) - { - name: '@ in package ref is fine (no trailing newline)', - // The `@` mid-string isn't a status-glyph prefix; this case - // only verifies that. Trailing-newline shape is covered by - // its own invalid case. - code: 'logger.log("scope @ name")\n', - }, - ], - invalid: [ - // `→` at start IS a status symbol → step - { - name: '→ at start → step', - code: 'logger.log("→ step done\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // ASCII step `>` at start → step - { - name: '> at start → step', - code: 'logger.log("> step done\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // `↻` at start → skip - { - name: '↻ at start → skip', - code: 'logger.log("↻ retry\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // `∴` at start → progress - { - name: '∴ at start → progress', - code: 'logger.log("∴ working\\n")\n', - errors: [{ messageId: 'trailingNewline' }], - }, - // anchored fallback after leading whitespace (logger strip - // tolerates leading \n + symbol) - { - name: '\\n + → at start → step', - code: 'logger.log("\\n→ step\\n")\n', - errors: [{ messageId: 'leadingNewline' }], - }, - ], - }) - }) - - test('no false positives: emoji-free strings with \\n', () => { - new RuleTester().run('no-logger-newline-literal', rule, { - valid: [], - invalid: [ - // No emoji means we keep the original method, just split. - { - name: 'plain logger.error with \\n stays error', - code: 'logger.error("\\nBuild failed:", e)\n', - errors: [{ messageId: 'leadingNewlineNoEmoji' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-npx-dlx.test.mts b/.config/oxlint-plugin/test/no-npx-dlx.test.mts deleted file mode 100644 index a4d8790e8..000000000 --- a/.config/oxlint-plugin/test/no-npx-dlx.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Unit tests for socket/no-npx-dlx. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-npx-dlx.mts' - -describe('socket/no-npx-dlx', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-npx-dlx', rule, { - valid: [ - { name: 'pnpm exec', code: 'const cmd = "pnpm exec oxlint"\n' }, - { name: 'pnpm run', code: 'const cmd = "pnpm run lint"\n' }, - { - name: 'commented opt-out', - code: 'const cmd = "npx foo" // socket-hook: allow npx\n', - }, - ], - invalid: [ - { - name: 'bare npx', - code: 'const cmd = "npx oxlint"\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'pnpm dlx', - code: 'const cmd = "pnpm dlx oxlint"\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'yarn dlx', - code: 'const cmd = "yarn dlx oxlint"\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-placeholders.test.mts b/.config/oxlint-plugin/test/no-placeholders.test.mts deleted file mode 100644 index 5af22c259..000000000 --- a/.config/oxlint-plugin/test/no-placeholders.test.mts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @file Unit tests for socket/no-placeholders. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-placeholders.mts' - -describe('socket/no-placeholders', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-placeholders', rule, { - valid: [ - { - name: 'real implementation', - code: 'export function foo() { return 1 }\n', - }, - { - name: 'normal comment', - code: '// explains the constraint\nexport const x = 1\n', - }, - ], - invalid: [ - { - name: 'TODO comment', - code: '// TODO: implement\nexport const x = 1\n', - errors: [{ messageId: 'commentMarker' }], - }, - { - name: 'throw not-implemented', - code: 'export function foo() { throw new Error("not implemented") }\n', - errors: [{ messageId: 'throwPlaceholder' }], - }, - { - name: 'empty body stub with placeholder marker', - // The rule only fires on an empty body when paired with a - // body-marker comment. "placeholder" triggers - // STUB_BODY_MARKER_RE (the body-marker scan) but not - // COMMENT_MARKER_RE (the standalone TODO scan), so the - // case isolates the `emptyBody` finding. - code: 'export function foo() {\n // placeholder\n}\n', - errors: [{ messageId: 'emptyBody' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-process-cwd-in-scripts-hooks.test.mts b/.config/oxlint-plugin/test/no-process-cwd-in-scripts-hooks.test.mts deleted file mode 100644 index 47d199ee5..000000000 --- a/.config/oxlint-plugin/test/no-process-cwd-in-scripts-hooks.test.mts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @file Unit tests for socket/no-process-cwd-in-scripts-hooks. The rule only - * applies to files under `scripts/` or `.claude/hooks/`. Test cases use the - * `filename:` override to place fixtures at the right virtual path so the - * rule's path-matching logic fires. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-process-cwd-in-scripts-hooks.mts' - -describe('socket/no-process-cwd-in-scripts-hooks', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-process-cwd-in-scripts-hooks', rule, { - valid: [ - { - name: 'import.meta.url anchor in scripts', - filename: 'scripts/foo.mts', - code: 'import { fileURLToPath } from "node:url"\nconst here = fileURLToPath(import.meta.url)\nconsole.log(here)\n', - }, - { - name: 'process.cwd OUTSIDE scripts/.claude/hooks', - filename: 'src/foo.ts', - code: 'const x = process.cwd()\nconsole.log(x)\n', - }, - { - name: 'process.cwd inside test/ (exempt)', - filename: 'scripts/test/foo.test.mts', - code: 'const x = process.cwd()\nconsole.log(x)\n', - }, - ], - invalid: [ - { - name: 'process.cwd in scripts/', - filename: 'scripts/foo.mts', - code: 'const x = process.cwd()\nconsole.log(x)\n', - errors: [{ messageId: 'processCwd' }], - }, - { - name: 'process.cwd in .claude/hooks/', - filename: '.claude/hooks/foo/index.mts', - code: 'const x = process.cwd()\nconsole.log(x)\n', - errors: [{ messageId: 'processCwd' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-promise-race-in-loop.test.mts b/.config/oxlint-plugin/test/no-promise-race-in-loop.test.mts deleted file mode 100644 index c3688932e..000000000 --- a/.config/oxlint-plugin/test/no-promise-race-in-loop.test.mts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file Unit tests for socket/no-promise-race-in-loop. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-promise-race-in-loop.mts' - -describe('socket/no-promise-race-in-loop', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-promise-race-in-loop', rule, { - valid: [ - { - name: 'race outside loop', - code: 'await Promise.race([a, b])\n', - }, - { - name: 'Promise.all in loop', - code: 'for (const item of items) { await Promise.all([fetch(item)]) }\n', - }, - ], - invalid: [ - { - name: 'race in for-loop', - code: 'for (const i of items) { await Promise.race([fetch(i), timeout()]) }\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'race in while-loop', - code: 'while (cond) { await Promise.race([a, b]) }\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-promise-race.test.mts b/.config/oxlint-plugin/test/no-promise-race.test.mts deleted file mode 100644 index 1eba7ccd0..000000000 --- a/.config/oxlint-plugin/test/no-promise-race.test.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file Unit tests for socket/no-promise-race. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-promise-race.mts' - -describe('socket/no-promise-race', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-promise-race', rule, { - valid: [ - { - name: 'Promise.all', - code: 'await Promise.all([fetch("a"), fetch("b")])\n', - }, - { - name: 'Promise.allSettled', - code: 'await Promise.allSettled([fetch("a")])\n', - }, - { name: 'Promise.any', code: 'await Promise.any([fetch("a")])\n' }, - ], - invalid: [ - { - name: 'Promise.race', - code: 'await Promise.race([fetch("a"), fetch("b")])\n', - errors: [{ messageId: 'noPromiseRace' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-src-import-in-test-expect.test.mts b/.config/oxlint-plugin/test/no-src-import-in-test-expect.test.mts deleted file mode 100644 index 4517cde04..000000000 --- a/.config/oxlint-plugin/test/no-src-import-in-test-expect.test.mts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @file Unit tests for the no-src-import-in-test-expect oxlint rule. Spawns the - * real oxlint binary against fixture files in a tmp dir (see - * lib/rule-tester.mts). The rule only fires in `*.test.*` files, on a binding - * imported from a relative `src/` path that is then used inside an - * `expect(...)` call. Skips silently when `oxlint` isn't on PATH. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-src-import-in-test-expect.mts' - -describe('socket/no-src-import-in-test-expect', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-src-import-in-test-expect', rule, { - valid: [ - { - name: 'src import used as system-under-test (not in expect)', - filename: 'test/unit/foo.test.mts', - code: "import { doThing } from '../../src/foo'\nconst r = doThing()\nexpect(r).toBe(1)\n", - }, - { - name: '-stable tool used inside expect is fine', - filename: 'test/unit/foo.test.mts', - code: "import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'\nexpect(x).toBe(normalizePath(p))\n", - }, - { - name: 'src import in a NON-test file is not flagged', - filename: 'src/foo.ts', - code: "import { normalizePath } from '../paths/normalize'\nexpect(x).toBe(normalizePath(p))\n", - }, - { - name: 'src import used outside any expect (helper setup)', - filename: 'test/unit/foo.test.mts', - code: "import { normalizePath } from '../../src/paths/normalize'\nconst dir = normalizePath(tmp)\nexpect(dir).toBeDefined()\n", - }, - { - name: 'node builtin used in expect is fine (not a src import)', - filename: 'test/unit/foo.test.mts', - code: "import { join } from 'node:path'\nexpect(p).toBe(join(a, b))\n", - }, - ], - invalid: [ - { - name: 'src normalizePath used inside expect().toBe()', - filename: 'test/unit/dlx/detect.test.mts', - code: "import { normalizePath } from '../../../src/paths/normalize'\nimport { join } from 'node:path'\nexpect(result.path).toBe(normalizePath(join(dir, 'package.json')))\n", - errors: [{ messageId: 'srcToolInExpect' }], - }, - { - name: 'src import used as expect argument directly', - filename: 'test/unit/foo.test.mts', - code: "import { canonicalize } from '../../src/util/canon'\nexpect(canonicalize(input)).toEqual(out)\n", - errors: [{ messageId: 'srcToolInExpect' }], - }, - { - name: 'deeper-nested src path still flagged', - filename: 'test/unit/a/b/c.test.mts', - code: "import { fmt } from '../../../../src/x/y/fmt'\nexpect(v).toBe(fmt(raw))\n", - errors: [{ messageId: 'srcToolInExpect' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-status-emoji.test.mts b/.config/oxlint-plugin/test/no-status-emoji.test.mts deleted file mode 100644 index 3ec80de04..000000000 --- a/.config/oxlint-plugin/test/no-status-emoji.test.mts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @file Unit tests for socket/no-status-emoji. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-status-emoji.mts' - -describe('socket/no-status-emoji', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-status-emoji', rule, { - valid: [ - { name: 'ascii markers', code: 'console.log("[ok] done")\n' }, - { name: 'no emoji', code: 'const x = "hello"\n' }, - ], - invalid: [ - { - name: 'check emoji', - code: 'console.log("✓ done")\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'cross emoji', - code: 'console.log("✗ failed")\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-structured-clone-prefer-json.test.mts b/.config/oxlint-plugin/test/no-structured-clone-prefer-json.test.mts deleted file mode 100644 index 37ba5f146..000000000 --- a/.config/oxlint-plugin/test/no-structured-clone-prefer-json.test.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @file Unit tests for socket/no-structured-clone-prefer-json. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-structured-clone-prefer-json.mts' - -describe('socket/no-structured-clone-prefer-json', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-structured-clone-prefer-json', rule, { - valid: [ - { - name: 'json roundtrip clone — preferred shape', - code: 'export const r = (v: unknown) => JSON.parse(JSON.stringify(v))\n', - }, - { - name: 'member-call structuredClone (user method, unrelated)', - code: 'export const r = (o: { structuredClone(): unknown }) => o.structuredClone()\n', - }, - ], - invalid: [ - { - name: 'bare structuredClone call flagged', - code: 'export const r = (v: unknown) => structuredClone(v)\n', - errors: [{ messageId: 'noStructuredClone' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-sync-rm-in-test-lifecycle.test.mts b/.config/oxlint-plugin/test/no-sync-rm-in-test-lifecycle.test.mts deleted file mode 100644 index ebfaa87d4..000000000 --- a/.config/oxlint-plugin/test/no-sync-rm-in-test-lifecycle.test.mts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @file Unit tests for socket/no-sync-rm-in-test-lifecycle. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-sync-rm-in-test-lifecycle.mts' - -describe('socket/no-sync-rm-in-test-lifecycle', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-sync-rm-in-test-lifecycle', rule, { - valid: [ - { - name: 'await safeDelete in afterEach — correct', - code: 'afterEach(async () => { await safeDelete(tmpDir) })\n', - }, - { - name: 'safeDeleteSync outside lifecycle — allowed', - code: 'function cleanup() { safeDeleteSync(tmpDir) }\n', - }, - { - name: 'fs.rmSync inside regular function — out of scope for this rule', - code: 'function teardown() { fs.rmSync(tmpDir) }\n', - }, - { - name: 'await safeDelete in afterAll', - code: 'afterAll(async () => { await safeDelete(tmpDir) })\n', - }, - ], - invalid: [ - { - name: 'safeDeleteSync inside afterEach', - code: 'afterEach(() => { safeDeleteSync(tmpDir) })\n', - errors: [{ messageId: 'syncDelete' }], - }, - { - name: 'fs.rmSync inside afterAll', - code: 'afterAll(() => { fs.rmSync(tmpDir, { recursive: true }) })\n', - errors: [{ messageId: 'syncDelete' }], - }, - { - name: 'fs.unlinkSync inside beforeEach', - code: 'beforeEach(() => { fs.unlinkSync(tmpFile) })\n', - errors: [{ messageId: 'syncDelete' }], - }, - { - name: 'safeDeleteSync inside beforeAll', - code: 'beforeAll(() => { safeDeleteSync(tmpDir) })\n', - errors: [{ messageId: 'syncDelete' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts b/.config/oxlint-plugin/test/no-underscore-identifier.test.mts deleted file mode 100644 index bffa1bf67..000000000 --- a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file Unit tests for the no-underscore-identifier oxlint rule. Spawns the - * real oxlint binary against fixture files in a tmp dir (see - * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a - * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes - * the bin link. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-underscore-identifier.mts' - -describe('socket/no-underscore-identifier', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-underscore-identifier', rule, { - valid: [ - { - name: 'plain identifier', - code: 'const foo = 1\n', - }, - { - name: 'PascalCase identifier', - code: 'class Foo {}\n', - }, - { - name: 'identifier ending with underscore (suffix is allowed)', - // The rule targets LEADING underscores; trailing ones are - // a separate convention (TS pattern: `_unused`, conflict - // with `delete_` keyword-clash, etc.) and out of scope. - code: 'const foo_ = 1\n', - }, - ], - invalid: [ - { - name: 'underscore-prefixed const', - code: 'const _foo = 1\n', - errors: [{ messageId: 'underscoreIdentifier' }], - }, - { - name: 'underscore-prefixed function', - code: 'function _doFoo() {}\n', - errors: [{ messageId: 'underscoreIdentifier' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/no-which-for-local-bin.test.mts b/.config/oxlint-plugin/test/no-which-for-local-bin.test.mts deleted file mode 100644 index 63f8c8ae9..000000000 --- a/.config/oxlint-plugin/test/no-which-for-local-bin.test.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @file Unit tests for socket/no-which-for-local-bin. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/no-which-for-local-bin.mts' - -describe('socket/no-which-for-local-bin', () => { - test('valid + invalid cases', () => { - new RuleTester().run('no-which-for-local-bin', rule, { - valid: [ - { - name: 'whichSync from lib-stable scoped to a bin dir', - code: - "import { whichSync } from '@socketsecurity/lib-stable/bin/which'\n" + - "const bin = whichSync('oxlint', { path: binDir, nothrow: true })\n", - }, - { - name: 'unrelated string containing the word which', - code: "const msg = 'which file do you want?'\n", - }, - { - name: 'bare which literal (argv[0] form) is not flagged — too ambiguous', - code: "const label = 'which'\n", - }, - { - name: 'multi-word string starting with which is prose, not a lookup', - code: "const q = 'which oxlint version is installed?'\n", - }, - { - name: 'explicit bypass marker for a legit global lookup', - code: - '// socket-hook: allow which-lookup\n' + - "const git = execSync('which git')\n", - }, - ], - invalid: [ - { - name: 'execSync shell string with which', - code: "const p = execSync('which oxlint').toString()\n", - errors: [{ messageId: 'whichLookup' }], - }, - { - name: 'command -v shell string', - code: "const p = execSync('command -v pnpm')\n", - errors: [{ messageId: 'whichLookup' }], - }, - { - name: 'where shell string (Windows)', - code: "const p = execSync('where node')\n", - errors: [{ messageId: 'whichLookup' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/optional-explicit-undefined.test.mts b/.config/oxlint-plugin/test/optional-explicit-undefined.test.mts deleted file mode 100644 index 6f9151db2..000000000 --- a/.config/oxlint-plugin/test/optional-explicit-undefined.test.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file Unit tests for socket/optional-explicit-undefined. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/optional-explicit-undefined.mts' - -describe('socket/optional-explicit-undefined', () => { - test('valid + invalid cases', () => { - new RuleTester().run('optional-explicit-undefined', rule, { - valid: [ - { - name: 'explicit | undefined', - code: 'export interface X { foo?: string | undefined }\n', - }, - { - name: 'non-optional property', - code: 'export interface X { foo: string }\n', - }, - { - name: 'union including undefined', - code: 'export interface X { foo?: string | number | undefined }\n', - }, - ], - invalid: [ - { - name: 'bare optional', - code: 'export interface X { foo?: string }\n', - errors: [{ messageId: 'missingUndefined' }], - }, - { - name: 'class field bare optional', - code: 'export class X { foo?: string\n constructor() { this.foo = undefined }\n}\n', - errors: [{ messageId: 'missingUndefined' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/personal-path-placeholders.test.mts b/.config/oxlint-plugin/test/personal-path-placeholders.test.mts deleted file mode 100644 index a38c14377..000000000 --- a/.config/oxlint-plugin/test/personal-path-placeholders.test.mts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @file Unit tests for socket/personal-path-placeholders. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/personal-path-placeholders.mts' - -describe('socket/personal-path-placeholders', () => { - test('valid + invalid cases', () => { - new RuleTester().run('personal-path-placeholders', rule, { - valid: [ - { - name: 'placeholder path', - code: 'const p = "/Users/<user>/projects/foo"\n', - }, - { name: 'no path mention', code: 'export const x = 1\n' }, - ], - invalid: [ - { - name: 'literal /Users/jdalton path', - code: 'const p = "/Users/jdalton/projects/foo"\n', - errors: [{ messageId: 'realUsername' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-async-spawn.test.mts b/.config/oxlint-plugin/test/prefer-async-spawn.test.mts deleted file mode 100644 index 66ef33149..000000000 --- a/.config/oxlint-plugin/test/prefer-async-spawn.test.mts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @file Unit tests for socket/prefer-async-spawn. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-async-spawn.mts' - -describe('socket/prefer-async-spawn', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-async-spawn', rule, { - valid: [ - { - name: 'async spawn import from lib', - code: 'import { spawn } from "@socketsecurity/lib-stable/process/spawn/child"\nawait spawn("ls")\n', - }, - { - name: 'spawnSync import from lib (sync-aware)', - code: 'import { spawnSync } from "@socketsecurity/lib-stable/process/spawn/child"\nspawnSync("ls")\n', - }, - { - name: 'bypass comment on import', - code: '// prefer-async-spawn: sync-required\nimport { spawnSync } from "node:child_process"\nspawnSync("ls")\n', - }, - { - name: 'non-banned import from node:child_process is fine', - code: 'import { exec } from "node:child_process"\n', - }, - ], - invalid: [ - { - name: 'spawn import from node:child_process', - code: 'import { spawn } from "node:child_process"\nawait spawn("ls")\n', - errors: [{ messageId: 'importBanned' }], - }, - { - name: 'spawnSync import from node:child_process — source rewritten, name preserved', - code: 'import { spawnSync } from "node:child_process"\nspawnSync("ls")\n', - // The rule's autofix emits single quotes for the rewritten - // import source; the call site retains its original quoting. - output: - 'import { spawnSync } from \'@socketsecurity/lib-stable/process/spawn/child\'\nspawnSync("ls")\n', - errors: [{ messageId: 'importBanned' }], - }, - { - name: 'child_process.spawnSync call — flagged, no autofix', - // Namespace imports (`import * as child_process`) are not - // flagged on the import line — only the call site is. The - // rule's autofix can't safely rewrite a namespace usage, - // so the report focuses on the call. - code: 'import * as child_process from "node:child_process"\nchild_process.spawnSync("ls")\n', - errors: [{ messageId: 'callBanned' }], - }, - { - name: 'mixed import (spawn + exec) — flagged but NOT autofixed', - code: 'import { spawn, exec } from "node:child_process"\nspawn("ls")\nexec("ls")\n', - errors: [{ messageId: 'importBanned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts b/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts deleted file mode 100644 index a24f962c0..000000000 --- a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Unit tests for socket/prefer-cached-for-loop. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-cached-for-loop.mts' - -describe('socket/prefer-cached-for-loop', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-cached-for-loop', rule, { - valid: [ - { - name: 'cached for-loop', - code: 'const xs = [1,2,3]\nfor (let i = 0, { length } = xs; i < length; i += 1) {}\n', - }, - { - name: 'for-of', - code: 'for (const x of [1,2,3]) {}\n', - }, - { - name: 'for-of over awaited value — unknown kind, skip autofix', - code: - 'async function f() {\n' + - ' const items = await getThings()\n' + - ' for (const x of items) { console.log(x) }\n' + - '}\n', - }, - ], - invalid: [ - { - name: 'forEach call', - code: '[1,2,3].forEach((x) => {})\n', - errors: [{ messageId: 'preferCachedForNoFix' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-ellipsis-char.test.mts b/.config/oxlint-plugin/test/prefer-ellipsis-char.test.mts deleted file mode 100644 index 6fce6ed08..000000000 --- a/.config/oxlint-plugin/test/prefer-ellipsis-char.test.mts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @file Unit tests for socket/prefer-ellipsis-char. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-ellipsis-char.mts' - -describe('socket/prefer-ellipsis-char', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-ellipsis-char', rule, { - valid: [ - { - name: 'spread operator is syntax, not text', - code: 'const a = [...arr]\nconst b = { ...obj }\nexport { a, b }\n', - }, - { - name: 'rest parameter is syntax, not text', - code: 'export function f(...args: number[]) {\n return args\n}\n', - }, - { - name: 'already uses the ellipsis character', - code: "const msg = 'Loading…'\n", - }, - { - name: 'two dots is not an ellipsis', - code: "const rel = '../sibling'\n", - }, - { - name: 'path glob with trailing ... is not flagged', - code: "const tip = 'use /Users/<user>/... for the home path'\n", - }, - { - name: 'path glob with leading ... is not flagged', - code: "const g = 'matches .../node_modules/foo'\n", - }, - { - name: 'CLI rest-arg (dots after a space) is not flagged', - code: "const usage = 'run foo ...args'\n", - }, - { - name: 'CLI placeholder bracket notation is not flagged', - code: "const usage = 'clone [path...]'\n", - }, - { - name: 'CLI rest-arg in parens is not flagged', - code: "const sig = 'fn(args...)'\n", - }, - { - name: 'bypass marker allows the literal form', - code: - '// socket-hook: allow literal-ellipsis\n' + - "const usage = 'truncated word...'\n", - }, - ], - invalid: [ - { - name: 'three dots in a string literal', - code: "const msg = 'Loading...'\n", - errors: [{ messageId: 'literalEllipsis' }], - output: "const msg = 'Loading…'\n", - }, - { - name: 'three dots in a template literal', - code: 'const msg = `Saving...`\n', - errors: [{ messageId: 'literalEllipsis' }], - output: 'const msg = `Saving…`\n', - }, - { - name: 'four dots collapse to a single ellipsis', - code: "const msg = 'wait....'\n", - errors: [{ messageId: 'literalEllipsis' }], - output: "const msg = 'wait…'\n", - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-env-as-boolean.test.mts b/.config/oxlint-plugin/test/prefer-env-as-boolean.test.mts deleted file mode 100644 index aedc9cc3e..000000000 --- a/.config/oxlint-plugin/test/prefer-env-as-boolean.test.mts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @file Unit tests for socket/prefer-env-as-boolean. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-env-as-boolean.mts' - -describe('socket/prefer-env-as-boolean', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-env-as-boolean', rule, { - valid: [ - { - name: 'envAsBoolean wrap — correct shape', - code: "import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean'\nconst x = envAsBoolean(getSocketDebug())\n", - }, - { - name: 'non-Socket getter — allowed', - code: 'const x = !!getDebug()\n', - }, - { - name: 'truthy check on non-getter', - code: 'const x = !!someValue\n', - }, - { - name: 'string comparison on non-Socket getter', - code: "const x = getDebug() === 'true'\n", - }, - ], - invalid: [ - { - name: '!!getSocketDebug()', - code: 'const x = !!getSocketDebug()\n', - errors: [{ messageId: 'coerce' }], - }, - { - name: 'Boolean(getSocketApiKey())', - code: 'const x = Boolean(getSocketApiKey())\n', - errors: [{ messageId: 'coerce' }], - }, - { - name: "getSocketDebug() === 'true'", - code: "const x = getSocketDebug() === 'true'\n", - errors: [{ messageId: 'coerce' }], - }, - { - name: "getSocketDebug() == '1'", - code: "const x = getSocketDebug() == '1'\n", - errors: [{ messageId: 'coerce' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-exists-sync.test.mts b/.config/oxlint-plugin/test/prefer-exists-sync.test.mts deleted file mode 100644 index 628679a1b..000000000 --- a/.config/oxlint-plugin/test/prefer-exists-sync.test.mts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file Unit tests for socket/prefer-exists-sync. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-exists-sync.mts' - -describe('socket/prefer-exists-sync', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-exists-sync', rule, { - valid: [ - { - name: 'existsSync from node:fs', - code: 'import { existsSync } from "node:fs"\nif (existsSync("/x")) {}\n', - }, - { - name: 'stat for metadata (with explanatory comment)', - code: 'import { statSync } from "node:fs"\nconst s = statSync("/x") // need size\nconsole.log(s.size)\n', - }, - ], - invalid: [ - { - name: 'fs.access for existence check', - code: 'import { promises as fs } from "node:fs"\nawait fs.access("/x")\n', - errors: [{ messageId: 'access' }], - }, - { - name: 'fileExists wrapper', - code: 'import { fileExists } from "./util"\nif (fileExists("/x")) {}\n', - errors: [{ messageId: 'fileExists' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-function-declaration.test.mts b/.config/oxlint-plugin/test/prefer-function-declaration.test.mts deleted file mode 100644 index 265503534..000000000 --- a/.config/oxlint-plugin/test/prefer-function-declaration.test.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @file Unit tests for socket/prefer-function-declaration. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-function-declaration.mts' - -describe('socket/prefer-function-declaration', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-function-declaration', rule, { - valid: [ - { - name: 'function declaration', - code: 'function foo() {}\n', - }, - { - name: 'arrow used as callback', - code: '[1,2].map(x => x + 1)\n', - }, - ], - invalid: [ - { - name: 'top-level const arrow', - code: 'const foo = () => 1\n', - errors: [{ messageId: 'preferFunctionDeclarationNoFix' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-node-builtin-imports.test.mts b/.config/oxlint-plugin/test/prefer-node-builtin-imports.test.mts deleted file mode 100644 index d7f608c9a..000000000 --- a/.config/oxlint-plugin/test/prefer-node-builtin-imports.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Unit tests for socket/prefer-node-builtin-imports. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-node-builtin-imports.mts' - -describe('socket/prefer-node-builtin-imports', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-node-builtin-imports', rule, { - valid: [ - { - name: 'node: prefix', - code: 'import path from "node:path"\nconsole.log(path)\n', - }, - { - name: 'node:fs', - code: 'import { readFileSync } from "node:fs"\nreadFileSync("/x")\n', - }, - ], - invalid: [ - { - name: 'node:path named-import — should prefer default', - // The rule operates on `node:`-prefixed specifiers. For - // small modules like `node:path`, prefer the default - // import so call sites read `path.join(…)`. - code: 'import { join } from "node:path"\nconsole.log(join("a", "b"))\n', - errors: [{ messageId: 'preferDefault' }], - }, - { - name: 'node:fs default-import — should prefer cherry-pick named', - code: 'import fs from "node:fs"\nfs.readFileSync("/x")\n', - errors: [{ messageId: 'fsDefault' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-node-modules-dot-cache.test.mts b/.config/oxlint-plugin/test/prefer-node-modules-dot-cache.test.mts deleted file mode 100644 index 4a9a84ca0..000000000 --- a/.config/oxlint-plugin/test/prefer-node-modules-dot-cache.test.mts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @file Unit tests for socket/prefer-node-modules-dot-cache. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-node-modules-dot-cache.mts' - -describe('socket/prefer-node-modules-dot-cache', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-node-modules-dot-cache', rule, { - valid: [ - { - name: 'node_modules/.cache path', - code: 'const cache = "node_modules/.cache/socket-wheelhouse-x.json"\n', - }, - { - // Bare `.cache` is a path segment, not a path. The literal - // visitor must skip it — flagging would double-fire on every - // `path.join(home, '.cache', app)` from XDG helpers (which - // the call-shape visitor already exempts via isHomeDirExpression). - name: 'bare ".cache" literal (not a path)', - code: 'const seg = ".cache"\n', - }, - { - name: 'path.join with node_modules first', - code: 'import path from "node:path"\nconst x = path.join("/", "node_modules", ".cache", "foo.json")\n', - }, - { - // XDG-spec platform-dirs helper. - name: 'path.join(home, ".cache", ...) with `home` identifier', - code: - 'import os from "node:os"\nimport path from "node:path"\n' + - 'const home = os.homedir()\n' + - 'const cacheDir = path.join(home, ".cache", "acorn-asb")\n', - }, - { - name: 'path.join with os.homedir() directly as first arg', - code: - 'import os from "node:os"\nimport path from "node:path"\n' + - 'const cacheDir = path.join(os.homedir(), ".cache", "myapp")\n', - }, - { - name: 'path.join with process.env.HOME first', - code: - 'import path from "node:path"\n' + - 'const cacheDir = path.join(process.env.HOME, ".cache", "myapp")\n', - }, - { - name: 'path.join with process.env["XDG_CACHE_HOME"] first', - code: - 'import path from "node:path"\n' + - 'const cacheDir = path.join(process.env["XDG_CACHE_HOME"], "myapp")\n', - }, - { - name: 'path.join with `homedir` identifier first', - code: - 'import path from "node:path"\n' + - 'function go(homedir) { return path.join(homedir, ".cache", "x") }\n', - }, - ], - invalid: [ - { - name: 'repo-root .cache literal', - code: 'const cache = ".cache/socket-wheelhouse-x.json"\n', - errors: [{ messageId: 'pathLiteral' }], - }, - { - name: 'path.join with .cache only', - code: 'import path from "node:path"\nconst x = path.join("/foo", ".cache", "bar.json")\n', - errors: [{ messageId: 'pathJoin' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-non-capturing-group.test.mts b/.config/oxlint-plugin/test/prefer-non-capturing-group.test.mts deleted file mode 100644 index 9fee4a2b0..000000000 --- a/.config/oxlint-plugin/test/prefer-non-capturing-group.test.mts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @file Unit tests for socket/prefer-non-capturing-group. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-non-capturing-group.mts' - -describe('socket/prefer-non-capturing-group', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-non-capturing-group', rule, { - valid: [ - { - name: 'already non-capturing', - code: 'export const r = /\\.(?:md|mdx)$/\n', - }, - { - name: 'capture used via match[1]', - code: [ - 'export function f(s: string) {', - ' const m = /^(foo|bar)$/.exec(s)', - ' return m?.[1]', - '}', - '', - ].join('\n'), - }, - { - name: 'capture used via $1 in replacement', - code: [ - 'export function f(s: string) {', - " return s.replace(/(\\w+)/, '<$1>')", - '}', - '', - ].join('\n'), - }, - { - name: 'line-level allow-capture marker', - code: 'export const r = /(md|mdx)/ // socket-hook: allow capture\n', - }, - { - name: 'lookahead (?=...)', - code: 'export const r = /foo(?=bar)/\n', - }, - { - name: 'named capture (?<name>...)', - code: 'export const r = /(?<ext>md|mdx)/\n', - }, - { - name: 'group referenced by a later \\1 backreference → stay silent', - code: 'export const r = /([\'"]?)(?:main|master)\\1/\n', - }, - { - name: 'inner backreference anywhere in pattern → stay silent', - code: 'export const r = /(foo|bar\\1)/\n', - }, - { - name: 'usage markers anywhere in file → stay silent', - code: [ - 'export function f(s: string) {', - ' const used = /^(yes)$/.exec(s)', - ' const unused = /^(a|b)$/.test(s)', - ' return [used?.[1], unused]', - '}', - '', - ].join('\n'), - }, - ], - invalid: [ - { - name: 'bare alternation in test-only regex', - code: 'export const r = /\\.(md|mdx)$/\n', - errors: [{ messageId: 'unused' }], - output: 'export const r = /\\.(?:md|mdx)$/\n', - }, - { - name: 'bare alternation, multiple groups', - code: 'export const r = /^(foo|bar)\\.(md|mdx)$/.test("x")\n', - errors: [{ messageId: 'unused' }, { messageId: 'unused' }], - output: 'export const r = /^(?:foo|bar)\\.(?:md|mdx)$/.test("x")\n', - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-safe-delete.test.mts b/.config/oxlint-plugin/test/prefer-safe-delete.test.mts deleted file mode 100644 index 25b434992..000000000 --- a/.config/oxlint-plugin/test/prefer-safe-delete.test.mts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @file Unit tests for socket/prefer-safe-delete. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-safe-delete.mts' - -describe('socket/prefer-safe-delete', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-safe-delete', rule, { - valid: [ - { - name: 'safeDelete from lib', - code: 'import { safeDelete } from "@socketsecurity/lib-stable/fs"\nawait safeDelete("/x")\n', - }, - ], - invalid: [ - { - name: 'fs.rm', - code: 'import { promises as fs } from "node:fs"\nawait fs.rm("/x", { recursive: true })\n', - errors: [{ messageId: 'banned' }], - }, - { - name: 'fs.unlinkSync member call', - // The rule flags member calls on the fs object — the - // canonical shape the codebase uses. Cherry-picked bare - // imports of unlink/rm are normalized elsewhere. - code: 'import fs from "node:fs"\nfs.unlinkSync("/x")\n', - errors: [{ messageId: 'banned' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-separate-type-import.test.mts b/.config/oxlint-plugin/test/prefer-separate-type-import.test.mts deleted file mode 100644 index c90437090..000000000 --- a/.config/oxlint-plugin/test/prefer-separate-type-import.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/prefer-separate-type-import. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-separate-type-import.mts' - -describe('socket/prefer-separate-type-import', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-separate-type-import', rule, { - valid: [ - { - name: 'separate type import', - code: 'import { Foo } from "./mod"\nimport type { Bar } from "./mod"\nconst f: Bar = new Foo()\n', - }, - ], - invalid: [ - { - name: 'inline `type` modifier mixed', - code: 'import { Foo, type Bar } from "./mod"\nconst f: Bar = new Foo()\n', - errors: [{ messageId: 'preferSeparateTypeImport' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts b/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts deleted file mode 100644 index 00ee35ab8..000000000 --- a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Unit tests for the prefer-spawn-over-execsync oxlint rule. Spawns the - * real oxlint binary against fixture files in a tmp dir (see - * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a - * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes - * the bin link. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-spawn-over-execsync.mts' - -describe('socket/prefer-spawn-over-execsync', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-spawn-over-execsync', rule, { - valid: [ - { - name: 'lib-stable spawn import', - code: "import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'\n", - }, - { - name: 'lib-stable spawnSync import', - code: "import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'\n", - }, - { - name: 'node:child_process spawn (not exec*Sync) is acceptable', - // This rule is specifically about exec*Sync. The - // companion `prefer-async-spawn` rule handles plain - // `spawn` from node:child_process. - code: "import { spawn } from 'node:child_process'\n", - }, - ], - invalid: [ - { - name: 'execSync from node:child_process', - code: "import { execSync } from 'node:child_process'\n", - errors: [{ messageId: 'preferSpawn' }], - }, - { - name: 'execFileSync from node:child_process', - code: "import { execFileSync } from 'node:child_process'\n", - errors: [{ messageId: 'preferSpawn' }], - }, - { - name: 'mixed execSync + execFileSync', - code: "import { execSync, execFileSync } from 'node:child_process'\n", - errors: [{ messageId: 'preferSpawn' }, { messageId: 'preferSpawn' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-stable-self-import.test.mts b/.config/oxlint-plugin/test/prefer-stable-self-import.test.mts deleted file mode 100644 index 12437b988..000000000 --- a/.config/oxlint-plugin/test/prefer-stable-self-import.test.mts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file Unit tests for the prefer-stable-self-import oxlint rule. Spawns the - * real oxlint binary against fixture files in a tmp dir (see - * lib/rule-tester.mts). Each case writes a `package.json` fixture so the - * rule's owned-package walk-up has something to find. Skips silently when - * `oxlint` isn't on PATH. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-stable-self-import.mts' - -const OWNED = { name: '@socketsecurity/lib' } - -describe('socket/prefer-stable-self-import', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-stable-self-import', rule, { - valid: [ - { - name: 'owned package via -stable alias (scripts/)', - filename: 'scripts/foo.mts', - packageJson: OWNED, - code: "import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'\n", - }, - { - name: 'non-owned package via bare name is fine (scripts/)', - filename: 'scripts/foo.mts', - packageJson: OWNED, - code: "import { x } from '@socketsecurity/registry/y'\n", - }, - { - name: 'bare owned import OUTSIDE scripts/ + hooks/ is allowed', - filename: 'src/foo.mts', - packageJson: OWNED, - code: "import { x } from '@socketsecurity/lib/y'\n", - }, - { - name: 'test files under scripts/ are exempt', - filename: 'scripts/test/foo.test.mts', - packageJson: OWNED, - code: "import { x } from '@socketsecurity/lib/y'\n", - }, - { - name: 'similar-but-not-owned name is not flagged', - filename: 'scripts/foo.mts', - packageJson: OWNED, - // `@socketsecurity/lib-extra` is NOT `@socketsecurity/lib`. - code: "import { x } from '@socketsecurity/lib-extra/y'\n", - }, - ], - invalid: [ - { - name: 'bare owned subpath import in scripts/', - filename: 'scripts/foo.mts', - packageJson: OWNED, - code: "import { getDefaultLogger } from '@socketsecurity/lib/logger/default'\n", - errors: [{ messageId: 'preferStable' }], - output: - "import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'\n", - }, - { - name: 'bare owned import in .claude/hooks/', - filename: '.claude/hooks/foo/index.mts', - packageJson: OWNED, - code: "import { x } from '@socketsecurity/lib/objects/predicates'\n", - errors: [{ messageId: 'preferStable' }], - output: - "import { x } from '@socketsecurity/lib-stable/objects/predicates'\n", - }, - { - name: 'bare owned bare-package import (no subpath)', - filename: 'scripts/foo.mts', - packageJson: OWNED, - code: "import x from '@socketsecurity/lib'\n", - errors: [{ messageId: 'preferStable' }], - output: "import x from '@socketsecurity/lib-stable'\n", - }, - { - name: 'export-from re-export is also flagged', - filename: 'scripts/foo.mts', - packageJson: OWNED, - code: "export { x } from '@socketsecurity/lib/y'\n", - errors: [{ messageId: 'preferStable' }], - output: "export { x } from '@socketsecurity/lib-stable/y'\n", - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-static-type-import.test.mts b/.config/oxlint-plugin/test/prefer-static-type-import.test.mts deleted file mode 100644 index 30d9ce0f5..000000000 --- a/.config/oxlint-plugin/test/prefer-static-type-import.test.mts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @file Unit tests for socket/prefer-static-type-import. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-static-type-import.mts' - -describe('socket/prefer-static-type-import', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-static-type-import', rule, { - valid: [ - { - name: 'static type import', - code: 'import type { Remap } from "../objects/types"\nexport type Foo = Remap<{ a: 1 }>\n', - }, - { - name: 'value import unaffected', - code: 'import { existsSync } from "node:fs"\nexistsSync("/tmp")\n', - }, - { - name: 'typeof import is allowed (namespace shape)', - code: 'const fs: typeof import("node:fs") = require("node:fs")\n', - }, - ], - invalid: [ - { - name: 'inline import expression with qualifier', - code: 'export type Foo = { spinner?: import("../spinner/types").Spinner | undefined }\n', - errors: [{ messageId: 'preferStaticTypeImport' }], - }, - { - name: 'inline import expression in type alias', - code: 'export type Wrap = import("../objects/types").Remap<{ a: 1 }>\n', - errors: [{ messageId: 'preferStaticTypeImport' }], - }, - { - name: 'multiple inline imports fire per occurrence', - code: 'export type T = { a: import("./a").A; b: import("./b").B }\n', - errors: [ - { messageId: 'preferStaticTypeImport' }, - { messageId: 'preferStaticTypeImport' }, - ], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/prefer-undefined-over-null.test.mts b/.config/oxlint-plugin/test/prefer-undefined-over-null.test.mts deleted file mode 100644 index 01dcfc0af..000000000 --- a/.config/oxlint-plugin/test/prefer-undefined-over-null.test.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file Unit tests for socket/prefer-undefined-over-null. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/prefer-undefined-over-null.mts' - -describe('socket/prefer-undefined-over-null', () => { - test('valid + invalid cases', () => { - new RuleTester().run('prefer-undefined-over-null', rule, { - valid: [ - { name: 'undefined literal', code: 'export const x = undefined\n' }, - { - name: '__proto__: null (allowed)', - code: 'const obj = { __proto__: null, a: 1 }\nconsole.log(obj.a)\n', - }, - { - name: 'Object.create(null) (allowed)', - code: 'const obj = Object.create(null)\nconsole.log(obj)\n', - }, - { - name: 'JSON.stringify replacer slot (allowed)', - code: 'JSON.stringify({ a: 1 }, null, 2)\n', - }, - { - name: '=== null comparison (allowed)', - code: 'if (x === null) {}\n', - }, - ], - invalid: [ - { - name: 'bare null assignment', - code: 'export const x = null\n', - errors: [{ messageId: 'preferUndefined' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/socket-api-token-env.test.mts b/.config/oxlint-plugin/test/socket-api-token-env.test.mts deleted file mode 100644 index 550bbca42..000000000 --- a/.config/oxlint-plugin/test/socket-api-token-env.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file Unit tests for socket/socket-api-token-env. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/socket-api-token-env.mts' - -describe('socket/socket-api-token-env', () => { - test('valid + invalid cases', () => { - new RuleTester().run('socket-api-token-env', rule, { - valid: [ - { - name: 'canonical SOCKET_API_TOKEN', - code: 'const t = process.env["SOCKET_API_TOKEN"]\nconsole.log(t)\n', - }, - { - name: 'alias-lookup array with declaration-level bypass comment', - code: - '// socket-api-token-env: bootstrap -- alias-normalization shim.\n' + - "const ALIASES = ['SOCKET_API_TOKEN', 'SOCKET_API_KEY', 'SOCKET_SECURITY_API_TOKEN'] as const\n" + - 'console.log(ALIASES)\n', - }, - ], - invalid: [ - { - name: 'legacy SOCKET_API_KEY env', - code: 'const t = process.env["SOCKET_API_KEY"]\nconsole.log(t)\n', - errors: [{ messageId: 'legacy' }], - }, - { - name: 'legacy SOCKET_SECURITY_API_TOKEN env', - code: 'const t = process.env["SOCKET_SECURITY_API_TOKEN"]\nconsole.log(t)\n', - errors: [{ messageId: 'legacy' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts b/.config/oxlint-plugin/test/sort-boolean-chains.test.mts deleted file mode 100644 index 6f12230ae..000000000 --- a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @file Unit tests for socket/sort-boolean-chains. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-boolean-chains.mts' - -describe('socket/sort-boolean-chains', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-boolean-chains', rule, { - valid: [ - { - name: 'sorted && chain', - code: 'export const r = (a: boolean, b: boolean, c: boolean) => a && b && c\n', - }, - { - name: 'sorted || chain', - code: 'export const r = (a: boolean, b: boolean, c: boolean) => a || b || c\n', - }, - { - name: 'mixed shape — call expression skipped', - code: 'export const r = (a: boolean, f: () => boolean) => a && f()\n', - }, - { - name: 'mixed shape — member access skipped', - code: 'export const r = (a: boolean, o: { b: boolean }) => o.b && a\n', - }, - { - name: 'single operand — not a chain', - code: 'export const r = (a: boolean) => a\n', - }, - { - name: 'two-operand guard pair — narrative order preserved', - code: 'export const r = (useHttp: boolean, oauthEnabled: boolean) => useHttp && oauthEnabled\n', - }, - { - name: 'two-operand reversed guard pair — still not sorted', - code: 'export const r = (b: boolean, a: boolean) => b && a\n', - }, - { - name: 'duplicates skipped', - code: 'export const r = (b: boolean, a: boolean) => b && a && b\n', - }, - ], - invalid: [ - { - name: 'unsorted && chain', - code: 'export const r = (a: boolean, b: boolean, c: boolean) => c && a && b\n', - errors: [{ messageId: 'unsorted' }], - }, - { - name: 'unsorted || chain', - code: 'export const r = (a: boolean, b: boolean, c: boolean) => c || a || b\n', - errors: [{ messageId: 'unsorted' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-equality-disjunctions.test.mts b/.config/oxlint-plugin/test/sort-equality-disjunctions.test.mts deleted file mode 100644 index d1b4334d1..000000000 --- a/.config/oxlint-plugin/test/sort-equality-disjunctions.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/sort-equality-disjunctions. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-equality-disjunctions.mts' - -describe('socket/sort-equality-disjunctions', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-equality-disjunctions', rule, { - valid: [ - { - name: 'sorted disjunction', - code: 'export const r = (x: string) => x === "a" || x === "b" || x === "c"\n', - }, - ], - invalid: [ - { - name: 'unsorted disjunction', - code: 'export const r = (x: string) => x === "c" || x === "a" || x === "b"\n', - errors: [{ messageId: 'unsorted' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-named-imports.test.mts b/.config/oxlint-plugin/test/sort-named-imports.test.mts deleted file mode 100644 index 01e47f422..000000000 --- a/.config/oxlint-plugin/test/sort-named-imports.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/sort-named-imports. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-named-imports.mts' - -describe('socket/sort-named-imports', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-named-imports', rule, { - valid: [ - { - name: 'sorted named imports', - code: 'import { alpha, beta, gamma } from "./mod"\nconsole.log(alpha, beta, gamma)\n', - }, - ], - invalid: [ - { - name: 'unsorted', - code: 'import { gamma, alpha, beta } from "./mod"\nconsole.log(alpha, beta, gamma)\n', - errors: [{ messageId: 'unsorted' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-regex-alternations.test.mts b/.config/oxlint-plugin/test/sort-regex-alternations.test.mts deleted file mode 100644 index c648f6a71..000000000 --- a/.config/oxlint-plugin/test/sort-regex-alternations.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/sort-regex-alternations. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-regex-alternations.mts' - -describe('socket/sort-regex-alternations', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-regex-alternations', rule, { - valid: [ - { - name: 'sorted alternation', - code: 'export const r = /^(alpha|beta|gamma)$/\n', - }, - ], - invalid: [ - { - name: 'unsorted alternation', - code: 'export const r = /^(gamma|alpha|beta)$/\n', - errors: [{ messageId: 'unsorted' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-set-args.test.mts b/.config/oxlint-plugin/test/sort-set-args.test.mts deleted file mode 100644 index dd70da779..000000000 --- a/.config/oxlint-plugin/test/sort-set-args.test.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Unit tests for socket/sort-set-args. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-set-args.mts' - -describe('socket/sort-set-args', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-set-args', rule, { - valid: [ - { - name: 'sorted Set literal', - code: 'export const s = new Set(["alpha", "beta", "gamma"])\n', - }, - ], - invalid: [ - { - name: 'unsorted Set literal', - code: 'export const s = new Set(["gamma", "alpha", "beta"])\n', - errors: [{ messageId: 'unsorted' }], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/sort-source-methods.test.mts b/.config/oxlint-plugin/test/sort-source-methods.test.mts deleted file mode 100644 index 03eb66bbb..000000000 --- a/.config/oxlint-plugin/test/sort-source-methods.test.mts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file Unit tests for socket/sort-source-methods. This rule sorts - * function/method declarations at the top level of a file by group - * (constants, types, exports, etc.) and then alphabetically. Tests cover both - * axes. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/sort-source-methods.mts' - -describe('socket/sort-source-methods', () => { - test('valid + invalid cases', () => { - new RuleTester().run('sort-source-methods', rule, { - valid: [ - { - name: 'alphabetic', - code: 'function alpha() {}\nfunction beta() {}\nfunction gamma() {}\n', - }, - ], - invalid: [ - { - name: 'out of order alphabetically', - code: 'function gamma() {}\nfunction alpha() {}\nfunction beta() {}\n', - // Rule reports one finding per out-of-order function: both - // `alpha` and `beta` come after `gamma` in source order - // but should precede it alphabetically. - errors: [ - { messageId: 'alphaOutOfOrder' }, - { messageId: 'alphaOutOfOrder' }, - ], - }, - ], - }) - }) -}) diff --git a/.config/oxlint-plugin/test/use-fleet-canonical-api-token-getter.test.mts b/.config/oxlint-plugin/test/use-fleet-canonical-api-token-getter.test.mts deleted file mode 100644 index e79277960..000000000 --- a/.config/oxlint-plugin/test/use-fleet-canonical-api-token-getter.test.mts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @file Unit tests for socket/use-fleet-canonical-api-token-getter. - */ - -import { describe, test } from 'node:test' - -import { RuleTester } from '../lib/rule-tester.mts' -import rule from '../rules/use-fleet-canonical-api-token-getter.mts' - -describe('socket/use-fleet-canonical-api-token-getter', () => { - test('valid + invalid cases', () => { - new RuleTester().run('use-fleet-canonical-api-token-getter', rule, { - valid: [ - { - name: 'using the helper — correct', - code: "import { readSocketApiToken } from '@socketsecurity/lib-stable/secrets/socket-api-token'\nconst t = await readSocketApiToken()\n", - }, - { - name: 'unrelated env read', - code: 'const path = process.env.PATH\n', - }, - { - name: 'SOCKET_CLI_API_TOKEN — different setting, not flagged', - code: 'const t = process.env.SOCKET_CLI_API_TOKEN\n', - }, - { - name: 'bypass comment — allowed', - code: '// socket-api-token-getter: allow direct-env\nconst t = process.env.SOCKET_API_TOKEN\n', - }, - ], - invalid: [ - { - name: 'process.env.SOCKET_API_TOKEN', - code: 'const t = process.env.SOCKET_API_TOKEN\n', - errors: [ - { messageId: 'directEnv', data: { name: 'SOCKET_API_TOKEN' } }, - ], - }, - { - name: "process.env['SOCKET_API_TOKEN']", - code: "const t = process.env['SOCKET_API_TOKEN']\n", - errors: [ - { messageId: 'directEnv', data: { name: 'SOCKET_API_TOKEN' } }, - ], - }, - { - name: 'process.env.SOCKET_API_KEY (legacy)', - code: 'const t = process.env.SOCKET_API_KEY\n', - errors: [ - { messageId: 'directEnv', data: { name: 'SOCKET_API_TOKEN' } }, - ], - }, - ], - }) - }) -}) diff --git a/.config/oxlintrc.json b/.config/oxlintrc.json deleted file mode 100644 index 488d28b4f..000000000 --- a/.config/oxlintrc.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn", "import"], - "jsPlugins": ["./oxlint-plugin/index.mts"], - "categories": { - "correctness": "error", - "suspicious": "error" - }, - "rules": { - "eslint/curly": "off", - "eslint/no-await-in-loop": "off", - "eslint/no-console": "off", - "eslint/no-control-regex": "off", - "eslint/no-empty": [ - "error", - { - "allowEmptyCatch": true - } - ], - "eslint/no-new": "error", - "eslint/no-proto": "error", - "eslint/no-shadow": "off", - "eslint/no-underscore-dangle": "off", - "eslint/no-unmodified-loop-condition": "off", - "eslint/no-unused-vars": "off", - "eslint/no-useless-catch": "off", - "eslint/no-var": "error", - "eslint/prefer-const": "error", - "eslint/preserve-caught-error": "off", - "eslint/sort-imports": "off", - "import/no-cycle": "off", - "import/no-named-as-default": "off", - "import/no-named-as-default-member": "off", - "import/no-self-import": "error", - "import/no-unassigned-import": "off", - "socket/export-top-level-functions": "error", - "socket/inclusive-language": "error", - "socket/max-file-lines": "error", - "socket/no-cached-for-on-iterable": "error", - "socket/no-console-prefer-logger": "error", - "socket/no-default-export": "error", - "socket/no-dynamic-import-outside-bundle": "error", - "socket/no-fetch-prefer-http-request": "error", - "socket/no-file-scope-oxlint-disable": "error", - "socket/no-inline-logger": "error", - "socket/no-logger-newline-literal": "error", - "socket/no-npx-dlx": "error", - "socket/no-placeholders": "error", - "socket/no-process-cwd-in-scripts-hooks": "error", - "socket/no-promise-race": "error", - "socket/no-promise-race-in-loop": "error", - "socket/no-status-emoji": "error", - "socket/no-structured-clone-prefer-json": "error", - "socket/no-underscore-identifier": "error", - "socket/optional-explicit-undefined": "error", - "socket/personal-path-placeholders": "error", - "socket/prefer-async-spawn": "error", - "socket/prefer-cached-for-loop": "error", - "socket/prefer-exists-sync": "error", - "socket/prefer-function-declaration": "error", - "socket/prefer-node-builtin-imports": "error", - "socket/prefer-node-modules-dot-cache": "error", - "socket/prefer-non-capturing-group": "error", - "socket/prefer-safe-delete": "error", - "socket/prefer-separate-type-import": "error", - "socket/prefer-spawn-over-execsync": "error", - "socket/prefer-static-type-import": "error", - "socket/prefer-undefined-over-null": "error", - "socket/socket-api-token-env": "error", - "socket/sort-boolean-chains": "error", - "socket/sort-equality-disjunctions": "error", - "socket/sort-named-imports": "error", - "socket/sort-regex-alternations": "error", - "socket/sort-set-args": "error", - "socket/sort-source-methods": "error", - "typescript/array-type": [ - "error", - { - "default": "array-simple" - } - ], - "typescript/consistent-type-assertions": [ - "error", - { - "assertionStyle": "as" - } - ], - "typescript/consistent-type-imports": "error", - "typescript/no-duplicate-enum-values": "error", - "typescript/no-duplicate-type-constituents": "error", - "typescript/no-explicit-any": "error", - "typescript/no-extra-non-null-assertion": "error", - "typescript/no-extraneous-class": "off", - "typescript/no-misused-new": "error", - "typescript/no-non-null-asserted-optional-chain": "off", - "typescript/no-this-alias": [ - "error", - { - "allowDestructuring": true - } - ], - "typescript/no-useless-empty-export": "error", - "typescript/no-wrapper-object-types": "error", - "typescript/prefer-as-const": "error", - "typescript/triple-slash-reference": "error", - "unicorn/consistent-function-scoping": "off", - "unicorn/no-array-for-each": "off", - "unicorn/no-array-reverse": "off", - "unicorn/no-array-sort": "off", - "unicorn/no-empty-file": "off", - "unicorn/no-null": "off", - "unicorn/no-useless-fallback-in-spread": "off", - "unicorn/prefer-node-protocol": "error", - "unicorn/prefer-spread": "off" - }, - "overrides": [ - { - "files": [ - "**/scripts/**", - "**/test/**", - "**/tests/**", - "**/.config/**", - "**/.git-hooks/**", - "**/.github/**" - ], - "rules": { - "socket/export-top-level-functions": "off", - "socket/inclusive-language": "off", - "socket/no-default-export": "off", - "socket/no-dynamic-import-outside-bundle": "off", - "socket/no-npx-dlx": "off", - "socket/no-placeholders": "off", - "socket/no-status-emoji": "off", - "socket/prefer-function-declaration": "off", - "socket/sort-source-methods": "off" - } - } - ], - "ignorePatterns": [ - "**/.cache", - "**/.claude", - "**/coverage", - "**/coverage-isolated", - "**/dist", - "**/external", - "**/node_modules", - "**/patches", - "**/test/fixtures", - "**/test/packages", - "**/*.d.ts", - "**/*.d.ts.map", - "**/*.tsbuildinfo", - "#fleet-canonical-begin (managed by socket-wheelhouse sync)", - "**/.claude/**", - "**/.config/oxlint-plugin/**", - "**/.config/rolldown/**", - "**/.git-hooks/**", - "**/.pnpm-store/**", - "**/vendor/**", - "**/wasm_exec.js", - "**/.config/lockstep.schema.json", - "**/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs", - "**/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", - "**/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs", - "**/.config/markdownlint-rules/socket-readme-required-sections.mjs", - "**/.config/socket-registry-pins.json", - "**/.config/socket-wheelhouse-schema.json", - "**/.config/taze.config.mts", - "**/.config/tsconfig.base.json", - "**/packages/build-infra/lib/release-checksums/consumer.mts", - "**/packages/build-infra/lib/release-checksums/core.mts", - "**/packages/build-infra/lib/release-checksums/producer.mts", - "**/packages/build-infra/release-assets.schema.json", - "**/scripts/ai-lint-fix.mts", - "**/scripts/ai-lint-fix/cli.mts", - "**/scripts/ai-lint-fix/rule-guidance.mts", - "**/scripts/check-lock-step-header.mts", - "**/scripts/check-lock-step-refs.mts", - "**/scripts/check-paths.mts", - "**/scripts/check-paths/allowlist.mts", - "**/scripts/check-paths/cli.mts", - "**/scripts/check-paths/exempt.mts", - "**/scripts/check-paths/rules.mts", - "**/scripts/check-paths/scan-code.mts", - "**/scripts/check-paths/scan-script.mts", - "**/scripts/check-paths/scan-workflow.mts", - "**/scripts/check-paths/state.mts", - "**/scripts/check-paths/types.mts", - "**/scripts/check-paths/walk.mts", - "**/scripts/check-prompt-less-setup.mts", - "**/scripts/check-provenance.mts", - "**/scripts/check-soak-exclude-dates.mts", - "**/scripts/fix.mts", - "**/scripts/git-partial-submodule.mts", - "**/scripts/install-claude-plugins.mts", - "**/scripts/install-git-hooks.mts", - "**/scripts/install-sfw.mts", - "**/scripts/install-token-minifier.mts", - "**/scripts/janus.mts", - "**/scripts/lint-github-settings.mts", - "**/scripts/lockstep-emit-schema.mts", - "**/scripts/lockstep-schema.mts", - "**/scripts/lockstep.mts", - "**/scripts/lockstep/checks.mts", - "**/scripts/lockstep/cli.mts", - "**/scripts/lockstep/emit-schema.mts", - "**/scripts/lockstep/git.mts", - "**/scripts/lockstep/manifest.mts", - "**/scripts/lockstep/report.mts", - "**/scripts/lockstep/scan.mts", - "**/scripts/lockstep/schema.mts", - "**/scripts/lockstep/types.mts", - "**/scripts/power-state.mts", - "**/scripts/publish-release.mts", - "**/scripts/publish-shared.mts", - "**/scripts/publish.mts", - "**/scripts/security.mts", - "**/scripts/socket-wheelhouse-emit-schema.mts", - "**/scripts/socket-wheelhouse-schema.mts", - "**/scripts/test/check-lock-step-header.test.mts", - "**/scripts/test/check-lock-step-refs.test.mts", - "**/scripts/test/install-claude-plugins.test.mts", - "**/scripts/test/install-git-hooks.test.mts", - "**/scripts/update.mts", - "**/scripts/validate-bundle-deps.mts", - "**/scripts/validate-config-paths.mts", - "**/scripts/validate-esbuild-minify.mts", - "**/scripts/validate-file-size.mts", - "#fleet-canonical-end" - ] -} diff --git a/.config/rolldown-validate.json b/.config/rolldown-validate.json deleted file mode 100644 index f740646e7..000000000 --- a/.config/rolldown-validate.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "configs": [ - "packages/cli/.config/rolldown.cli.mts", - "packages/cli/.config/rolldown.index.mts" - ] -} diff --git a/.config/rolldown/define-guarded.mts b/.config/rolldown/define-guarded.mts deleted file mode 100644 index ed683654e..000000000 --- a/.config/rolldown/define-guarded.mts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * @file Guarded compile-time define for rolldown builds. A `transform` plugin - * that replaces global / property-accessor reads with constant values — like - * oxc's `transform.define`, but it ONLY rewrites read positions. Matches that - * sit in an assignment target, a `delete` / `++` / `--` operand, or a binding - * position are left untouched. Why this exists: oxc's `define` (and - * `@rollup/plugin-replace`, even with `preventAssignment`) substitutes - * `delete` operands, so `delete process.env.DEBUG` (debug's node.js `save()`) - * becomes `delete undefined` — a strict-mode SyntaxError. esbuild's `define` - * skipped both lvalue and delete positions; this restores that behavior so - * risky keys (`process.env.DEBUG`, …) stay safe to define. Uses rolldown's - * bundled oxc parser (`rolldown/parseAst`) for reliable AST spans + - * MagicString for surgical rewrites. When the consuming build opts into - * rolldown's `experimental.nativeMagicString`, the `transform` hook receives a - * native MagicString on `meta.magicString` (same API, Rust-backed, no JS - * sourcemap round-trip) — we use it when present and fall back to the - * `magic-string` npm package otherwise. Keys are dotted member chains - * (`process.env.X`) or bare identifiers; source may spell a member access - * with dot or quoted-bracket notation (`process.env.X`, `process.env['X']`, - * `process.env["X"]`) — all normalize to the same dotted key, since - * TypeScript forces quoted bracket access on index-signature types like - * `process.env`. Values are already-quoted source text (same contract as - * esbuild / oxc `define`). - */ - -import MagicString from 'magic-string' -import { parseAst } from 'rolldown/parseAst' - -import type { Plugin } from 'rolldown' - -// oxc parser dialect, picked from a module's file extension. `parseAst` -// defaults to plain JS and rejects TypeScript syntax, so we must tell it the -// dialect or every `.ts`/`.tsx` module silently fails to parse (and the define -// is skipped). `.mts`/`.cts` are TS; `.tsx` keeps JSX; `.jsx`/`.mjs`/`.cjs`/`.js` -// are JS(X); anything unknown falls back to 'js'. -type OxcLang = 'js' | 'jsx' | 'ts' | 'tsx' - -function langForId(id: string | undefined): OxcLang { - // Strip any query suffix (e.g. `foo.ts?inline`) before reading the ext. - const clean = (id ?? '').split('?')[0] ?? '' - if (clean.endsWith('.tsx')) { - return 'tsx' - } - if (clean.endsWith('.jsx')) { - return 'jsx' - } - if ( - clean.endsWith('.ts') || - clean.endsWith('.mts') || - clean.endsWith('.cts') - ) { - return 'ts' - } - return 'js' -} - -interface DefineEntry { - // Dotted chain split into segments, e.g. ['process', 'env', 'DEBUG'] or - // ['__DEV__'] for a bare identifier. - segments: string[] - value: string -} - -function toEntries(define: Record<string, string>): DefineEntry[] { - return Object.entries(define).map(([key, value]) => ({ - segments: key.split('.'), - value, - })) -} - -// A match is a read unless its immediate parent uses it as a write/delete/ -// binding target. parent.type + the key under which the node hangs identify -// the position unambiguously. -function isReadPosition(parentType: string, parentKey: string): boolean { - // `x = …` / `x += …` — left side is a write target. - if (parentType === 'AssignmentExpression' && parentKey === 'left') { - return false - } - // `delete x` / `x++` / `--x` — operand is mutated, not read. - if ( - (parentType === 'UnaryExpression' || parentType === 'UpdateExpression') && - parentKey === 'argument' - ) { - return false - } - // `{ x } = …` style binding / property shorthand targets. - if (parentType === 'AssignmentTargetPropertyIdentifier') { - return false - } - return true -} - -// Read the property name off a member-expression node, normalizing the three -// equivalent spellings to a bare identifier string: -// `obj.prop` → StaticMemberExpression, property = Identifier -// `obj['prop']` → ComputedMemberExpression, property = string Literal -// `obj["prop"]` → ComputedMemberExpression, property = string Literal -// Returns undefined for anything else (e.g. `obj[expr]` dynamic access), which -// can't be a constant define target. -function memberPropName(node: Record<string, unknown>): string | undefined { - const property = node['property'] as Record<string, unknown> | undefined - if (!property) { - return undefined - } - if (property['type'] === 'Identifier') { - return property['name'] as string - } - // String-literal computed access (`obj['prop']` / `obj["prop"]`). oxc tags - // the node `Literal` with a string `value`; a dynamic `obj[expr]` has a - // non-Literal property and is correctly rejected here. - if (property['type'] === 'Literal' && typeof property['value'] === 'string') { - return property['value'] - } - return undefined -} - -/** - * Match a member-expression / identifier node against a define entry's segments - * by walking the chain structurally (right-to-left). Dot access and quoted - * bracket access normalize to the same dotted key, so a single `process.env.X` - * define key matches `process.env.X`, `process.env['X']`, and - * `process.env["X"]` source alike — important because `process.env` is an - * index-signature type and TypeScript (TS4111) forces quoted bracket access. - */ -function matchesChain( - node: Record<string, unknown>, - segments: string[], -): boolean { - if (segments.length === 1) { - return node['type'] === 'Identifier' && node['name'] === segments[0] - } - // Walk the member chain from the outermost property inward, matching each - // segment from the tail. The innermost object must be an Identifier equal to - // the first segment. - let current: Record<string, unknown> | undefined = node - for (let i = segments.length - 1; i >= 1; i -= 1) { - if ( - !current || - (current['type'] !== 'StaticMemberExpression' && - current['type'] !== 'ComputedMemberExpression' && - current['type'] !== 'MemberExpression') - ) { - return false - } - if (memberPropName(current) !== segments[i]) { - return false - } - current = current['object'] as Record<string, unknown> | undefined - } - return ( - !!current && - current['type'] === 'Identifier' && - current['name'] === segments[0] - ) -} - -/** - * Build a guarded-define rolldown plugin. `define` maps a key (bare identifier - * or dotted property accessor) to already-quoted replacement source text. - */ -export function defineGuardedPlugin(define: Record<string, string>): Plugin { - const entries = toEntries(define) - // Top-level segment set lets us cheaply skip files that can't contain any - // key before doing the full parse + walk. - const firstSegments = new Set(entries.map(e => e.segments[0]!)) - - return { - name: 'define-guarded', - // `meta` carries rolldown's native MagicString on `meta.magicString` when - // the build opts into `experimental.nativeMagicString` (config-level, set by - // the consuming repo). It's Rust-backed and serialized by rolldown without a - // JS `toString()` / `generateMap()` round-trip. Absent that flag, `meta` is - // undefined and we construct a JS `magic-string` instance ourselves. - transform(code, id, meta) { - // Cheap bail: no key's leading segment appears in the source. - let maybe = false - for (const seg of firstSegments) { - if (code.includes(seg)) { - maybe = true - break - } - } - if (!maybe) { - return undefined - } - - let program: Record<string, unknown> - try { - // Parse with the dialect matching the module's extension. The default - // (JS) chokes on TypeScript type annotations, which would silently - // disable the define for every .ts/.tsx consumer — `parseAst` would - // throw and we'd fall through to the no-op `catch`. Derive `lang` from - // the id so .ts/.mts/.cts → 'ts', .tsx → 'tsx', .jsx → 'jsx', else 'js'. - program = parseAst(code, { - lang: langForId(id), - }) as unknown as Record<string, unknown> - } catch { - // Unparseable (e.g. a syntax oxc rejects) — leave the module to the - // main pipeline, which will surface the real error. - return undefined - } - - // Prefer rolldown's native MagicString (experimental.nativeMagicString) - // when the transform hook hands one over; same .overwrite()/.toString() - // API as the npm package. Fall back to a JS instance otherwise. - const native = (meta as { magicString?: MagicString } | undefined) - ?.magicString - const ms = native ?? new MagicString(code) - let rewrote = false - // Track [start,end] spans already rewritten so a parent member chain - // and its `.object` sub-chain don't double-overwrite. - const done = new Set<string>() - - const walk = ( - node: unknown, - parent: Record<string, unknown> | undefined, - key: string | undefined, - ): void => { - if (!node || typeof node !== 'object') { - return - } - if (Array.isArray(node)) { - for (const child of node) { - walk(child, parent, key) - } - return - } - const n = node as Record<string, unknown> - if (typeof n['type'] === 'string') { - for (const entry of entries) { - if (!matchesChain(n, entry.segments)) { - continue - } - const start = n['start'] as number - const end = n['end'] as number - const spanKey = `${start}:${end}` - if (done.has(spanKey)) { - continue - } - if (!isReadPosition(parent?.['type'] as string, key ?? '')) { - // Mark as done so we don't reconsider the same span; a guarded - // write target stays verbatim. - done.add(spanKey) - continue - } - ms.overwrite(start, end, entry.value) - done.add(spanKey) - rewrote = true - // Don't descend into a matched chain (its `.object` is part of - // the same replaced text). - return - } - } - for (const k of Object.keys(n)) { - if (k === 'start' || k === 'end') { - continue - } - walk(n[k], n, k) - } - } - - walk(program, undefined, undefined) - - if (!rewrote) { - return undefined - } - // Native path: hand the MagicString straight back — rolldown serializes - // it + threads the sourcemap natively, skipping the JS toString/generateMap - // round-trip. JS-fallback path: serialize + emit a hi-res sourcemap here. - if (native) { - return { code: ms as unknown as string } - } - return { code: ms.toString(), map: ms.generateMap({ hires: true }) } - }, - } -} diff --git a/.config/rolldown/lib-stub.mts b/.config/rolldown/lib-stub.mts deleted file mode 100644 index 7d9e283ae..000000000 --- a/.config/rolldown/lib-stub.mts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @file Rolldown plugin: stub heavy `@socketsecurity/lib-stable` internals that - * runtime code never reaches. Why: `@socketsecurity/lib-stable` is the - * canonical fleet utility surface, but its module graph statically pulls in - * heavyweight files (e.g. globs.js → picomatch ~260KB, sorts.js → semver + - * npm-pack ~2.5MB) along import paths that real consumers never traverse. - * Tree-shaking can't drop unreachable subgraphs that look reachable to the - * static analyzer; we have to tell it explicitly. Each consumer passes a - * `stubPattern` regex matching the absolute resolved paths of the unreachable - * files for THEIR import surface. Verify reachability before adding a pattern - * — stubbing a file that IS reached at runtime gives runtime crashes, not - * bundle-time errors. Source: lifted from socket-packageurl-js's inline - * plugin (.config/rolldown.config.mts), generalized so the stub-pattern is - * caller-provided. Fleet-canonical via socket-wheelhouse. - */ - -import type { Plugin } from 'rolldown' - -export type LibStubOptions = { - /** - * Regex matched against resolved module paths. Files matching get replaced - * with an empty CJS module. Required. - */ - readonly stubPattern: RegExp - /** - * Replacement code. Defaults to `module.exports = {}`. Override only if you - * need a non-empty stub (rare). - */ - readonly stubCode?: string | undefined -} - -export function createLibStubPlugin(options: LibStubOptions): Plugin { - const { stubCode = 'module.exports = {}', stubPattern } = options - return { - name: 'stub-unused-lib-internals', - load(id) { - if (stubPattern.test(id)) { - return { code: stubCode, moduleSideEffects: false } - } - return undefined - }, - } -} diff --git a/.config/rollup.base.config.mjs b/.config/rollup.base.config.mjs new file mode 100644 index 000000000..b83951c65 --- /dev/null +++ b/.config/rollup.base.config.mjs @@ -0,0 +1,276 @@ +import { randomUUID } from 'node:crypto' +import { builtinModules } from 'node:module' +import path from 'node:path' + +import { babel as babelPlugin } from '@rollup/plugin-babel' +import commonjsPlugin from '@rollup/plugin-commonjs' +import jsonPlugin from '@rollup/plugin-json' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import replacePlugin from '@rollup/plugin-replace' +import { purgePolyfills } from 'unplugin-purge-polyfills' + +import { readPackageJsonSync } from '@socketsecurity/registry/lib/packages' +import { spawnSync } from '@socketsecurity/registry/lib/spawn' + +import constants from '../scripts/constants.js' +import socketModifyPlugin from '../scripts/rollup/socket-modify-plugin.js' +import { + getPackageName, + isBuiltin, + normalizeId, +} from '../scripts/utils/packages.js' + +const { + INLINED_CYCLONEDX_CDXGEN_VERSION, + INLINED_SOCKET_CLI_HOMEPAGE, + INLINED_SOCKET_CLI_LEGACY_BUILD, + INLINED_SOCKET_CLI_NAME, + INLINED_SOCKET_CLI_PUBLISHED_BUILD, + INLINED_SOCKET_CLI_SENTRY_BUILD, + INLINED_SOCKET_CLI_VERSION, + INLINED_SOCKET_CLI_VERSION_HASH, + INLINED_SYNP_VERSION, + NODE_MODULES, + ROLLUP_EXTERNAL_SUFFIX, + VITEST, +} = constants + +export const EXTERNAL_PACKAGES = [ + '@coana-tech/cli', + '@socketsecurity/registry', + 'blessed', + 'blessed-contrib', + 'node-gyp', +] + +const builtinAliases = builtinModules.reduce((o, n) => { + o[n] = `node:${n}` + return o +}, {}) + +let _rootPkgJson +function getRootPkgJsonSync() { + if (_rootPkgJson === undefined) { + // Lazily access constants.rootPath. + _rootPkgJson = readPackageJsonSync(constants.rootPath, { normalize: true }) + } + return _rootPkgJson +} + +let _socketVersionHash +function getSocketCliVersionHash() { + if (_socketVersionHash === undefined) { + const randUuidSegment = randomUUID().split('-')[0] + const { version } = getRootPkgJsonSync() + let gitHash = '' + try { + gitHash = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + encoding: 'utf8', + }).stdout.trim() + } catch {} + // Make each build generate a unique version id, regardless. + // Mostly for development: confirms the build refreshed. For prod builds + // the git hash should suffice to identify the build. + _socketVersionHash = `${version}:${gitHash}:${randUuidSegment}${ + // Lazily access constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]. + constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD] ? ':pub' : ':dev' + }` + } + return _socketVersionHash +} + +export default function baseConfig(extendConfig = {}) { + // Lazily access constants path properties. + const { configPath, rootPath } = constants + const nmPath = path.join(rootPath, NODE_MODULES) + const extendPlugins = Array.isArray(extendConfig.plugins) + ? extendConfig.plugins.slice() + : [] + const extractedPlugins = { __proto__: null } + if (extendPlugins.length) { + for (const pluginName of [ + 'babel', + 'commonjs', + 'json', + 'node-resolve', + 'typescript', + 'unplugin-purge-polyfills', + ]) { + for (let i = 0, { length } = extendPlugins; i < length; i += 1) { + const p = extendPlugins[i] + if (p?.name === pluginName) { + extractedPlugins[pluginName] = p + // Remove from extendPlugins array. + extendPlugins.splice(i, 1) + length -= 1 + i -= 1 + } + } + } + } + + return { + external(rawId) { + const id = normalizeId(rawId) + const pkgName = getPackageName( + id, + path.isAbsolute(id) ? nmPath.length + 1 : 0, + ) + return ( + id.endsWith('.d.cts') || + id.endsWith('.d.mts') || + id.endsWith('.d.ts') || + EXTERNAL_PACKAGES.includes(pkgName) || + rawId.endsWith(ROLLUP_EXTERNAL_SUFFIX) || + isBuiltin(rawId) + ) + }, + onwarn(warning, warn) { + // Suppress warnings. + if ( + warning.code === 'INVALID_ANNOTATION' || + warning.code === 'THIS_IS_UNDEFINED' + ) { + return + } + // Forward other warnings. + warn(warning) + }, + ...extendConfig, + plugins: [ + extractedPlugins['node-resolve'] ?? + nodeResolve({ + exportConditions: ['node'], + extensions: ['.mjs', '.js', '.json', '.ts', '.mts'], + preferBuiltins: true, + }), + extractedPlugins['json'] ?? jsonPlugin(), + extractedPlugins['commonjs'] ?? + commonjsPlugin({ + defaultIsModuleExports: true, + extensions: ['.cjs', '.js'], + ignoreDynamicRequires: true, + ignoreGlobal: true, + ignoreTryCatch: true, + strictRequires: true, + }), + extractedPlugins['babel'] ?? + babelPlugin({ + babelHelpers: 'runtime', + babelrc: false, + configFile: path.join(configPath, 'babel.config.js'), + extensions: ['.mjs', '.js', '.ts', '.mts'], + }), + extractedPlugins['unplugin-purge-polyfills'] ?? + purgePolyfills.rollup({ + replacements: {}, + }), + // Inline process.env values. + replacePlugin({ + delimiters: ['(?<![\'"])\\b', '(?![\'"])'], + preventAssignment: true, + values: [ + [ + INLINED_CYCLONEDX_CDXGEN_VERSION, + () => + JSON.stringify( + getRootPkgJsonSync().devDependencies['@cyclonedx/cdxgen'], + ), + ], + [ + INLINED_SOCKET_CLI_HOMEPAGE, + () => JSON.stringify(getRootPkgJsonSync().homepage), + ], + [ + INLINED_SOCKET_CLI_LEGACY_BUILD, + () => + JSON.stringify( + // Lazily access constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD]. + !!constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD], + ), + ], + [ + INLINED_SOCKET_CLI_NAME, + () => JSON.stringify(getRootPkgJsonSync().name), + ], + [ + INLINED_SOCKET_CLI_PUBLISHED_BUILD, + () => + JSON.stringify( + // Lazily access constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]. + !!constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD], + ), + ], + [ + INLINED_SOCKET_CLI_SENTRY_BUILD, + () => + JSON.stringify( + // Lazily access constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]. + !!constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD], + ), + ], + [ + INLINED_SOCKET_CLI_VERSION, + () => JSON.stringify(getRootPkgJsonSync().version), + ], + [ + INLINED_SOCKET_CLI_VERSION_HASH, + () => JSON.stringify(getSocketCliVersionHash()), + ], + [ + INLINED_SYNP_VERSION, + () => JSON.stringify(getRootPkgJsonSync().devDependencies['synp']), + ], + [ + VITEST, + () => + // Lazily access constants.ENV[VITEST]. + !!constants.ENV[VITEST], + ], + ].reduce((obj, { 0: name, 1: value }) => { + obj[`process.env.${name}`] = value + obj[`process.env['${name}']`] = value + obj[`process.env[${name}]`] = value + return obj + }, {}), + }), + // Convert un-prefixed built-in imports into "node:"" prefixed forms. + replacePlugin({ + delimiters: [ + '(?<=(?:require(?:\\$+\\d+)?\\(|from\\s*)["\'])', + '(?=["\'])', + ], + preventAssignment: false, + values: builtinAliases, + }), + // Replace require calls to ESM 'tiny-colors' with CJS 'yoctocolors-cjs' + // because we npm override 'tiny-colors' with 'yoctocolors-cjs' for dist + // builds which causes 'tiny-colors' to be treated as an external, not bundled, + // require. + socketModifyPlugin({ + find: /require(?:\$+\d+)?\(["']tiny-colors["']\)/g, + replace: "require('yoctocolors-cjs')", + }), + // Try to convert `require('u' + 'rl')` into something like `require$$2$3`. + socketModifyPlugin({ + find: /require(?:\$+\d+)?\(["']u["']\s*\+\s*["']rl["']\)/g, + replace(match) { + return ( + /(?<=var +)[$\w]+(?=\s*=\s*require(?:\$+\d+)?\(["']node:url["']\))/.exec( + this.input, + )?.[0] ?? match + ) + }, + }), + // Remove dangling require calls, e.g. require calls not associated with + // an import binding: + // require('node:util') + // require('graceful-fs') + socketModifyPlugin({ + find: /^\s*require(?:\$+\d+)?\(["'].+?["']\);?\r?\n/gm, + replace: '', + }), + ...extendPlugins, + ], + } +} diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs new file mode 100644 index 000000000..77b8ce2bc --- /dev/null +++ b/.config/rollup.dist.config.mjs @@ -0,0 +1,514 @@ +import assert from 'node:assert' +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import util from 'node:util' + +import { babel as babelPlugin } from '@rollup/plugin-babel' +import commonjsPlugin from '@rollup/plugin-commonjs' +import jsonPlugin from '@rollup/plugin-json' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import { glob as tinyGlob } from 'tinyglobby' +import trash from 'trash' + +import { + isDirEmptySync, + readJson, + writeJson, +} from '@socketsecurity/registry/lib/fs' +import { hasKeys, toSortedObject } from '@socketsecurity/registry/lib/objects' +import { + fetchPackageManifest, + readPackageJson, +} from '@socketsecurity/registry/lib/packages' +import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' + +import baseConfig, { EXTERNAL_PACKAGES } from './rollup.base.config.mjs' +import constants from '../scripts/constants.js' +import socketModifyPlugin from '../scripts/rollup/socket-modify-plugin.js' +import { + getPackageName, + isBuiltin, + normalizeId, +} from '../scripts/utils/packages.js' + +const { + CONSTANTS, + INLINED_SOCKET_CLI_LEGACY_BUILD, + INLINED_SOCKET_CLI_SENTRY_BUILD, + INSTRUMENT_WITH_SENTRY, + NODE_MODULES, + NODE_MODULES_GLOB_RECURSIVE, + ROLLUP_EXTERNAL_SUFFIX, + SHADOW_NPM_BIN, + SHADOW_NPM_INJECT, + SLASH_NODE_MODULES_SLASH, + SOCKET_CLI_BIN_NAME, + SOCKET_CLI_BIN_NAME_ALIAS, + SOCKET_CLI_LEGACY_PACKAGE_NAME, + SOCKET_CLI_NPM_BIN_NAME, + SOCKET_CLI_NPX_BIN_NAME, + SOCKET_CLI_PACKAGE_NAME, + SOCKET_CLI_SENTRY_BIN_NAME, + SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, + SOCKET_CLI_SENTRY_NPM_BIN_NAME, + SOCKET_CLI_SENTRY_NPX_BIN_NAME, + SOCKET_CLI_SENTRY_PACKAGE_NAME, + UTILS, + VENDOR, +} = constants + +const BLESSED = 'blessed' +const BLESSED_CONTRIB = 'blessed-contrib' +const COANA_TECH_CLI = '@coana-tech/cli' +const LICENSE_MD = `LICENSE.md` +const SENTRY_NODE = '@sentry/node' +const SOCKET_DESCRIPTION = 'CLI for Socket.dev' +const SOCKET_DESCRIPTION_WITH_SENTRY = `${SOCKET_DESCRIPTION}, includes Sentry error handling, otherwise identical to the regular \`${SOCKET_CLI_BIN_NAME}\` package` +const SOCKET_SECURITY_REGISTRY = '@socketsecurity/registry' + +async function copyInitGradle() { + // Lazily access constants path properties. + const filepath = path.join(constants.srcPath, 'commands/manifest/init.gradle') + const destPath = path.join(constants.distPath, 'init.gradle') + await fs.copyFile(filepath, destPath) +} + +async function copyBashCompletion() { + // Lazily access constants path properties. + const filepath = path.join( + constants.srcPath, + 'commands/install/socket-completion.bash', + ) + const destPath = path.join(constants.distPath, 'socket-completion.bash') + await fs.copyFile(filepath, destPath) +} + +async function copyExternalPackages() { + // Lazily access constants path properties. + const { blessedContribPath, blessedPath, coanaPath, socketRegistryPath } = + constants + const nmPath = path.join(constants.rootPath, NODE_MODULES) + const blessedContribNmPath = path.join(nmPath, BLESSED_CONTRIB) + + // Copy package folders. + await Promise.all([ + ...EXTERNAL_PACKAGES + // Skip copying 'blessed-contrib' over because we already + // have it bundled as ./external/blessed-contrib. + .filter(n => n !== BLESSED_CONTRIB) + // Copy the other packages over to ./external/. + .map(n => + copyPackage(n, { + strict: + // Skip adding 'use strict' directives to Coana and + // Socket packages. + n !== COANA_TECH_CLI && n !== SOCKET_SECURITY_REGISTRY, + }), + ), + // Copy 'blessed-contrib' license over to + // ./external/blessed-contrib/LICENSE.md. + await fs.cp( + `${blessedContribNmPath}/${LICENSE_MD}`, + `${blessedContribPath}/${LICENSE_MD}`, + ), + ]) + // Cleanup package files. + await Promise.all( + [ + [blessedPath, ['lib/**/*.js', 'usr/**/**', 'vendor/**/*.js', 'LICENSE*']], + [blessedContribPath, ['lib/**/*.js', 'index.js', 'LICENSE*']], + [coanaPath, ['**/*.mjs']], + [ + socketRegistryPath, + [ + 'external/**/*.js', + 'lib/**/*.js', + 'index.js', + 'extensions.json', + 'manifest.json', + 'LICENSE*', + ], + ], + ].map(async ({ 0: thePath, 1: ignorePatterns }) => { + await removeFiles(thePath, { exclude: ignorePatterns }) + await removeEmptyDirs(thePath) + }), + ) + // Rewire 'blessed' inside 'blessed-contrib'. + await Promise.all( + ( + await tinyGlob(['**/*.js'], { + absolute: true, + cwd: blessedContribPath, + ignore: [NODE_MODULES_GLOB_RECURSIVE], + }) + ).map(async p => { + const relPath = path.relative(path.dirname(p), blessedPath) + const content = await fs.readFile(p, 'utf8') + const modded = content.replace( + /(?<=require\(["'])blessed(?=(?:\/[^"']+)?["']\))/g, + () => relPath, + ) + await fs.writeFile(p, modded, 'utf8') + }), + ) +} + +async function copyPackage(pkgName, options) { + const { strict = true } = { __proto__: null, ...options } + // Lazily access constants path properties. + const nmPath = path.join(constants.rootPath, NODE_MODULES) + const pkgDestPath = path.join(constants.externalPath, pkgName) + const pkgNmPath = path.join(nmPath, pkgName) + // Copy entire package folder over to dist. + await fs.cp(pkgNmPath, pkgDestPath, { recursive: true }) + if (strict) { + // Add 'use strict' directive to js files. + const jsFiles = await tinyGlob(['**/*.js'], { + absolute: true, + cwd: pkgDestPath, + ignore: [NODE_MODULES_GLOB_RECURSIVE], + }) + await Promise.all( + jsFiles.map(async p => { + const content = await fs.readFile(p, 'utf8') + // Start by trimming the hashbang. + const hashbang = /^#!.*(?:\r?\n)*/.exec(content)?.[0] ?? '' + let trimmed = content.slice(hashbang.length).trimStart() + // Then, trim "use strict" directive. + const useStrict = + /^(['"])use strict\1;?(?:\r?\n)*/.exec(trimmed)?.[0] ?? '' + trimmed = trimmed.slice(useStrict.length).trimStart() + // Add back hashbang and add "use strict" directive. + const modded = `${hashbang.trim()}${hashbang ? os.EOL : ''}${useStrict.trim() || "'use strict'"}${os.EOL}${os.EOL}${trimmed}` + await fs.writeFile(p, modded, 'utf8') + }), + ) + } +} + +let _sentryManifest +async function getSentryManifest() { + if (_sentryManifest === undefined) { + _sentryManifest = await fetchPackageManifest(`${SENTRY_NODE}@latest`) + } + return _sentryManifest +} + +async function updatePackageJson() { + // Lazily access constants.rootPath. + const editablePkgJson = await readPackageJson(constants.rootPath, { + editable: true, + normalize: true, + }) + const bin = resetBin(editablePkgJson.content.bin) + const dependencies = resetDependencies(editablePkgJson.content.dependencies) + editablePkgJson.update({ + name: SOCKET_CLI_PACKAGE_NAME, + description: SOCKET_DESCRIPTION, + bin, + dependencies: hasKeys(dependencies) ? dependencies : undefined, + }) + // Lazily access constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD]. + if (constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD]) { + editablePkgJson.update({ + name: SOCKET_CLI_LEGACY_PACKAGE_NAME, + bin: { + [SOCKET_CLI_BIN_NAME_ALIAS]: bin[SOCKET_CLI_BIN_NAME], + ...bin, + }, + }) + } + // Lazily access constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]. + else if (constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]) { + editablePkgJson.update({ + name: SOCKET_CLI_SENTRY_PACKAGE_NAME, + description: SOCKET_DESCRIPTION_WITH_SENTRY, + bin: { + [SOCKET_CLI_SENTRY_BIN_NAME_ALIAS]: bin[SOCKET_CLI_BIN_NAME], + [SOCKET_CLI_SENTRY_BIN_NAME]: bin[SOCKET_CLI_BIN_NAME], + [SOCKET_CLI_SENTRY_NPM_BIN_NAME]: bin[SOCKET_CLI_NPM_BIN_NAME], + [SOCKET_CLI_SENTRY_NPX_BIN_NAME]: bin[SOCKET_CLI_NPX_BIN_NAME], + }, + dependencies: { + ...dependencies, + [SENTRY_NODE]: (await getSentryManifest()).version, + }, + }) + } + await editablePkgJson.save() +} + +async function updatePackageLockFile() { + // Lazily access constants.rootPackageLockPath. + const { rootPackageLockPath } = constants + if (!existsSync(rootPackageLockPath)) { + return + } + const lockJson = await readJson(rootPackageLockPath) + const rootPkg = lockJson.packages[''] + const bin = resetBin(rootPkg.bin) + const dependencies = resetDependencies(rootPkg.dependencies) + + lockJson.name = SOCKET_CLI_PACKAGE_NAME + rootPkg.name = SOCKET_CLI_PACKAGE_NAME + rootPkg.bin = bin + if (hasKeys(dependencies)) { + rootPkg.dependencies = dependencies + } else { + delete rootPkg.dependencies + } + // Lazily access constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD]. + if (constants.ENV[INLINED_SOCKET_CLI_LEGACY_BUILD]) { + lockJson.name = SOCKET_CLI_LEGACY_PACKAGE_NAME + rootPkg.name = SOCKET_CLI_LEGACY_PACKAGE_NAME + rootPkg.bin = toSortedObject({ + [SOCKET_CLI_BIN_NAME_ALIAS]: bin[SOCKET_CLI_BIN_NAME], + ...bin, + }) + } + // Lazily access constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]. + else if (constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]) { + lockJson.name = SOCKET_CLI_SENTRY_PACKAGE_NAME + rootPkg.name = SOCKET_CLI_SENTRY_PACKAGE_NAME + rootPkg.bin = { + [SOCKET_CLI_SENTRY_BIN_NAME_ALIAS]: bin[SOCKET_CLI_BIN_NAME], + [SOCKET_CLI_SENTRY_BIN_NAME]: bin[SOCKET_CLI_BIN_NAME], + [SOCKET_CLI_SENTRY_NPM_BIN_NAME]: bin[SOCKET_CLI_NPM_BIN_NAME], + [SOCKET_CLI_SENTRY_NPX_BIN_NAME]: bin[SOCKET_CLI_NPX_BIN_NAME], + } + rootPkg.dependencies = toSortedObject({ + ...dependencies, + [SENTRY_NODE]: (await getSentryManifest()).version, + }) + } + await writeJson(rootPackageLockPath, lockJson, { spaces: 2 }) +} + +async function removeEmptyDirs(thePath) { + await trash( + ( + await tinyGlob(['**/'], { + ignore: [NODE_MODULES_GLOB_RECURSIVE], + absolute: true, + cwd: thePath, + onlyDirectories: true, + }) + ) + // Sort directory paths longest to shortest. + .sort((a, b) => b.length - a.length) + .filter(isDirEmptySync), + ) +} + +async function removeFiles(thePath, options) { + const { exclude } = { __proto__: null, ...options } + const ignore = Array.isArray(exclude) ? exclude : exclude ? [exclude] : [] + return await trash( + await tinyGlob(['**/*'], { + absolute: true, + onlyFiles: true, + cwd: thePath, + dot: true, + ignore, + }), + ) +} + +function resetBin(bin) { + const tmpBin = { + [SOCKET_CLI_BIN_NAME]: + bin?.[SOCKET_CLI_BIN_NAME] ?? bin?.[SOCKET_CLI_SENTRY_BIN_NAME], + [SOCKET_CLI_NPM_BIN_NAME]: + bin?.[SOCKET_CLI_NPM_BIN_NAME] ?? bin?.[SOCKET_CLI_SENTRY_NPM_BIN_NAME], + [SOCKET_CLI_NPX_BIN_NAME]: + bin?.[SOCKET_CLI_NPX_BIN_NAME] ?? bin?.[SOCKET_CLI_SENTRY_NPX_BIN_NAME], + } + const newBin = { + ...(tmpBin[SOCKET_CLI_BIN_NAME] + ? { [SOCKET_CLI_BIN_NAME]: tmpBin.socket } + : {}), + ...(tmpBin[SOCKET_CLI_NPM_BIN_NAME] + ? { [SOCKET_CLI_NPM_BIN_NAME]: tmpBin[SOCKET_CLI_NPM_BIN_NAME] } + : {}), + ...(tmpBin[SOCKET_CLI_NPX_BIN_NAME] + ? { [SOCKET_CLI_NPX_BIN_NAME]: tmpBin[SOCKET_CLI_NPX_BIN_NAME] } + : {}), + } + assert( + util.isDeepStrictEqual(Object.keys(newBin).sort(naturalCompare), [ + SOCKET_CLI_BIN_NAME, + SOCKET_CLI_NPM_BIN_NAME, + SOCKET_CLI_NPX_BIN_NAME, + ]), + "Update the rollup Legacy and Sentry build's .bin to match the default build.", + ) + return newBin +} + +function resetDependencies(deps) { + const { [SENTRY_NODE]: _ignored, ...newDeps } = { ...deps } + return newDeps +} + +export default async () => { + // Lazily access constants path properties. + const { configPath, distPath, rootPath, srcPath } = constants + const nmPath = path.join(rootPath, NODE_MODULES) + const constantsSrcPath = path.join(srcPath, 'constants.mts') + const externalSrcPath = path.join(srcPath, 'external') + const blessedContribSrcPath = path.join(externalSrcPath, BLESSED_CONTRIB) + const shadowNpmBinSrcPath = path.join(srcPath, 'shadow/npm/bin.mts') + const shadowNpmInjectSrcPath = path.join(srcPath, 'shadow/npm/inject.mts') + const utilsSrcPath = path.join(srcPath, UTILS) + + return [ + ...( + await tinyGlob(['**/*.mjs'], { + absolute: true, + cwd: blessedContribSrcPath, + }) + ).map(filepath => { + const relPath = `${path.relative(srcPath, filepath).slice(0, -4 /*.mjs*/)}.js` + return { + input: filepath, + output: [ + { + file: path.join(rootPath, relPath), + exports: 'auto', + externalLiveBindings: false, + format: 'cjs', + inlineDynamicImports: true, + sourcemap: false, + }, + ], + external(rawId) { + const id = normalizeId(rawId) + const pkgName = getPackageName( + id, + path.isAbsolute(id) ? nmPath.length + 1 : 0, + ) + return ( + pkgName === BLESSED || + rawId.endsWith(ROLLUP_EXTERNAL_SUFFIX) || + isBuiltin(rawId) + ) + }, + plugins: [ + nodeResolve({ + exportConditions: ['node'], + extensions: ['.mjs', '.js', '.json'], + preferBuiltins: true, + }), + jsonPlugin(), + commonjsPlugin({ + defaultIsModuleExports: true, + extensions: ['.cjs', '.js'], + ignoreDynamicRequires: true, + ignoreGlobal: true, + ignoreTryCatch: true, + strictRequires: true, + }), + babelPlugin({ + babelHelpers: 'runtime', + babelrc: false, + configFile: path.join(configPath, 'babel.config.js'), + extensions: ['.js', '.cjs', '.mjs'], + }), + ], + } + }), + baseConfig({ + input: { + cli: `${srcPath}/cli.mts`, + [CONSTANTS]: `${srcPath}/constants.mts`, + [SHADOW_NPM_BIN]: `${srcPath}/shadow/npm/bin.mts`, + [SHADOW_NPM_INJECT]: `${srcPath}/shadow/npm/inject.mts`, + // Lazily access constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD]. + ...(constants.ENV[INLINED_SOCKET_CLI_SENTRY_BUILD] + ? { + [INSTRUMENT_WITH_SENTRY]: `${srcPath}/${INSTRUMENT_WITH_SENTRY}.mts`, + } + : {}), + }, + output: [ + { + dir: path.relative(rootPath, distPath), + chunkFileNames: '[name].js', + entryFileNames: '[name].js', + exports: 'auto', + externalLiveBindings: false, + format: 'cjs', + manualChunks(id_) { + const id = normalizeId(id_) + switch (id) { + case constantsSrcPath: + return CONSTANTS + case shadowNpmBinSrcPath: + return SHADOW_NPM_BIN + case shadowNpmInjectSrcPath: + return SHADOW_NPM_INJECT + default: + if (id.startsWith(utilsSrcPath)) { + return UTILS + } + if (id.includes(SLASH_NODE_MODULES_SLASH)) { + return VENDOR + } + return null + } + }, + plugins: [ + // Remove Rollup's browser interop for import.meta.url. + socketModifyPlugin({ + find: /(?<=const +require(?:\$+\d+)?\s*=)\s*Module\.createRequire[^;]+;/g, + replace(match) { + const pathToUrlCode = + /require(?:\$+\d+)?(?:\([^)]+\))?\.pathToFileURL\(__filename\)\.href/.exec( + match, + )?.[0] + return pathToUrlCode + ? `Module.createRequire(${pathToUrlCode})` + : match + }, + }), + ], + sourcemap: true, + sourcemapDebugIds: true, + }, + ], + plugins: [ + // Replace requires like + // require('blessed/lib/widgets/screen') with + // require('../external/blessed/lib/widgets/screen') OR + // require.resolve('node-gyp/bin/node-gyp.js') with + // require.resolve('../external/node-gyp/bin/node-gyp.js') + ...EXTERNAL_PACKAGES.map(n => + socketModifyPlugin({ + find: new RegExp( + `(?<=require(?:\\$+\\d+)?(?:\\.resolve)?\\(["'])${escapeRegExp(n)}(?=(?:\\/[^"']+)?["']\\))`, + 'g', + ), + replace: id => `../external/${id}`, + }), + ), + { + async writeBundle() { + await Promise.all([ + copyInitGradle(), + copyBashCompletion(), + updatePackageJson(), + // Remove dist/vendor.js.map file. + trash([path.join(distPath, `${VENDOR}.js.map`)]), + copyExternalPackages(), + ]) + // Update package-lock.json AFTER package.json. + await updatePackageLockFile() + }, + }, + ], + }), + ] +} diff --git a/.config/sfw-bypass-list.txt b/.config/sfw-bypass-list.txt deleted file mode 100644 index c8567ecd5..000000000 --- a/.config/sfw-bypass-list.txt +++ /dev/null @@ -1,137 +0,0 @@ -# Socket Firewall bypass list — fleet-canonical source of truth. -# -# This file is the wheelhouse-tracked master. Every fleet repo gets a -# byte-identical copy at <repo>/.config/sfw-bypass-list.txt via the -# sync-scaffolding cascade, and consumers read their local copy to -# populate SFW_CUSTOM_REGISTRIES: -# -# - socket-registry → .github/actions/setup/action.yml grep-reads -# this list into SFW_CUSTOM_REGISTRIES in CI. -# - socket-btm et al → scripts/install-sfw.mts writes the env to -# ~/.socket/_wheelhouse/env.sh so local sfw gets the -# same set the CI shared worker uses. -# -# Edit ONLY in socket-wheelhouse/template/.config/sfw-bypass-list.txt -# — downstream copies are regenerated by the cascade and any local -# fork will be clobbered on the next sync. -# -# Format: one `<kind>:<fqdn>` entry per line. Comments + blank lines -# ignored. Kinds accepted: npm, pypi, golang, maven, gem, cargo, nuget, -# block, wrap, bypass. The bundled defaults baked into sfw live at -# SocketDev/firewall:src/lib/registries/default.ts — entries here ship -# *over* a binary release without waiting for a re-publish. -# -# When adding a host, group it with the ecosystem comment that owns it -# and keep within-group ordering stable so cascades produce minimal -# diffs. - -# GitHub release-asset CDNs (targets of github.com/*/releases/download/* -# redirects). -bypass:objects.githubusercontent.com -bypass:release-assets.githubusercontent.com -bypass:raw.githubusercontent.com -bypass:gist.githubusercontent.com -# Source-archive CDN: github.com/<owner>/<repo>/archive/<ref>.zip|.tar.gz -# 302-redirects to codeload.github.com, NOT *.githubusercontent.com. -# CMake FetchContent / go modules / build systems pulling SHA-pinned -# source archives hit this host — sfw was truncating the stream -# mid-transfer (curl status 18 "Transferred a partial file"), e.g. -# onnxruntime's eigen3 dep. Integrity stays enforced by the build's -# own SHA checks (deps.txt SHA1 / FetchContent URL_HASH). -bypass:codeload.github.com - -# VCS fallbacks (Go modules, npm git-deps, Ruby git sources). -bypass:gitlab.com -bypass:bitbucket.org - -# Build-tool toolchain downloads. -bypass:ziglang.org -bypass:cmake.org -bypass:sh.rustup.rs -bypass:nodejs.org -bypass:bootstrap.pypa.io - -# Go module proxy + toolchain auto-download. proxy.golang.org serves -# modules and 302s to storage.googleapis.com for the -# `go: downloading go1.x.y` flow; sum.golang.org is the checksum DB. -# Without these, `go build` fails with `tls: failed to verify -# certificate: x509: certificate signed by unknown authority` because -# SFW's MITM breaks the cert chain at the redirect target. -bypass:proxy.golang.org -bypass:sum.golang.org -bypass:storage.googleapis.com - -# Cargo web portal (search/info API). -bypass:crates.io - -# Conda ecosystem. -bypass:repo.anaconda.com -bypass:conda.anaconda.org -bypass:anaconda.org -bypass:conda-forge.org - -# Composer (PHP). -bypass:packagist.org -bypass:repo.packagist.org - -# Conan (C/C++). -bypass:center.conan.io -bypass:conan.io - -# CocoaPods (Swift/Obj-C). -bypass:cdn.cocoapods.org - -# Hackage (Haskell). -bypass:hackage.haskell.org - -# Hex (Elixir/Erlang). -bypass:hex.pm -bypass:repo.hex.pm -bypass:builds.hex.pm - -# HuggingFace model hub. -bypass:huggingface.co - -# Julia. -bypass:pkg.julialang.org -bypass:julialang-s3.julialang.org - -# LuaRocks. -bypass:luarocks.org - -# OPAM + OCaml toolchain. -bypass:opam.ocaml.org -bypass:ocaml.org - -# pub.dev (Dart / Flutter). -bypass:pub.dev - -# CPAN (Perl). -bypass:metacpan.org -bypass:cpan.metacpan.org - -# CRAN (R). -bypass:cran.r-project.org -bypass:cran.rstudio.com -bypass:bioconductor.org - -# Bitnami. -bypass:downloads.bitnami.com - -# Swift toolchain + package index. -bypass:swift.org -bypass:download.swift.org -bypass:swiftpackageindex.com - -# VSCode extension marketplace. -bypass:marketplace.visualstudio.com - -# Bazel external workspace mirror. -bypass:mirror.bazel.build - -# Homebrew anonymous analytics (InfluxDB Cloud bucket). brew sends -# usage telemetry on every command; the writes fail-fast and silent if -# blocked, but listing as bypass avoids users debugging phantom -# timeouts on runners that have brew installed. -# HOMEBREW_NO_ANALYTICS=1 opts out at the source. -bypass:eu-central-1-1.aws.cloud2.influxdata.com diff --git a/.config/socket-registry-pins.json b/.config/socket-registry-pins.json deleted file mode 100644 index 224ed517f..000000000 --- a/.config/socket-registry-pins.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "./socket-registry-pins.schema.json", - "_comment": [ - "Wheelhouse-tracked SocketDev/socket-registry SHAs that fleet consumers pin against.", - "", - "Why: socket-registry ships shared GitHub Actions + reusable workflows + a fleet-canonical", - ".config/sfw-bypass-list.txt that ALL fleet repos consume. Per the cascade discipline in", - "socket-registry/.claude/skills/updating-workflows/reference.md, every consumer pins to the", - "Layer 3 'propagation SHA' — the merge SHA of the most recent ci.yml / provenance.yml /", - "weekly-update.yml update. Tracking that SHA here means each fleet repo's own pin docs +", - "scripts can read a single source of truth instead of independently scraping GitHub.", - "", - "Cascade flow (after any socket-registry action / workflow / .config/ change merges):", - " 1. Run socket-registry's L1 -> L2a -> L2b -> L3 cascade per its updating-workflows skill.", - " 2. The Layer 3 merge SHA becomes the new propagation SHA.", - " 3. Update propagationSha below + commit + push wheelhouse.", - " 4. sync-scaffolding propagates the new SHA into every fleet repo's pin sites.", - "", - "Do NOT bump propagationSha to a SHA that hasn't completed the L1-L3 cascade in", - "socket-registry — Layer 4 (_local-not-for-reuse-*.yml) SHAs are not valid pins for", - "external consumers." - ], - "propagationSha": "5e830399ab9d24bcff7ab5940eb30623e173c39b", - "propagationShaUpdatedAt": "2026-05-26", - "propagationShaCommitSubject": "ci(provenance): default fallback to scripts/publish.mts with --staged" -} diff --git a/.config/socket-wheelhouse-schema.json b/.config/socket-wheelhouse-schema.json deleted file mode 100644 index 4293324ec..000000000 --- a/.config/socket-wheelhouse-schema.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/SocketDev/socket-wheelhouse-schema.json", - "title": "socket-wheelhouse per-repo config", - "description": "Per-repo socket-wheelhouse config. Two valid locations: `.config/socket-wheelhouse.json` (primary) or `.socket-wheelhouse.json` at the repo root (alternative). Both are first-class — pick the location that fits your repo's convention.", - "type": "object", - "required": ["layout", "native", "repoName", "schemaVersion"], - "properties": { - "$schema": { - "description": "JSON Schema reference for editor autocompletion. Conventionally `./socket-wheelhouse-schema.json` — both the config and its schema live side-by-side in `.config/`.", - "type": "string" - }, - "schemaVersion": { - "description": "Schema version. Bump on breaking changes; readers gate on it.", - "const": 1, - "type": "number" - }, - "repoName": { - "pattern": "^[a-z0-9][a-z0-9-]*$", - "description": "Canonical repo basename (e.g. `socket-lib`, `ultrathink`). Used for layout / native-independent exemptions like the oxlint `socket-lib` carve-out.", - "type": "string" - }, - "layout": { - "description": "Package layout. `single-package` = one `package.json` at root, no `packages/`. `monorepo` = pnpm workspaces under `packages/`.", - "anyOf": [ - { - "const": "single-package", - "type": "string" - }, - { - "const": "monorepo", - "type": "string" - } - ] - }, - "native": { - "description": "Native-binary supply-chain role. `none` = pure-npm publish path. `consumer` = pulls prebuilt binaries from a sibling producer. `producer` = ships native artifacts via GH releases. `both` = consumes one set, produces another. (Per-language ports live in `lockstep.json` `lang-parity` rows, not here.)", - "anyOf": [ - { - "const": "none", - "type": "string" - }, - { - "const": "consumer", - "type": "string" - }, - { - "const": "producer", - "type": "string" - }, - { - "const": "both", - "type": "string" - } - ] - }, - "hooks": { - "description": "Git-hook opt-ins.", - "type": "object", - "properties": { - "enablePrePush": { - "description": "Wire `.git-hooks/pre-push` (shell shim) → `.git-hooks/pre-push.mts`. Mandatory security gate; default true.", - "type": "boolean" - }, - "enableCommitMsg": { - "description": "Wire `.git-hooks/commit-msg` (shell shim) → `.git-hooks/commit-msg.mts`. Strips AI attribution; default true.", - "type": "boolean" - }, - "enablePreCommit": { - "description": "Wire `.git-hooks/pre-commit` (shell shim) → `.git-hooks/pre-commit.mts`. Lint + secret scan on staged files; default true.", - "type": "boolean" - }, - "preCommitVariant": { - "description": "`lint-only` runs format + secret scan; `lint-test` adds vitest on touched packages. Default `lint-test`.", - "anyOf": [ - { - "const": "lint-only", - "type": "string" - }, - { - "const": "lint-test", - "type": "string" - } - ] - } - } - }, - "scripts": { - "description": "package.json script tracking overrides.", - "type": "object", - "properties": { - "required": { - "description": "Override REQUIRED_SCRIPTS from manifest.mts. Usually omitted — the fleet default applies.", - "type": "array", - "items": { - "type": "string" - } - }, - "optional": { - "description": "Per-script opt-in map keyed by script name. `true` = repo ships this RECOMMENDED script; `false` = explicit opt-out.", - "type": "object", - "patternProperties": { - "^(.*)$": { - "type": "boolean" - } - } - }, - "bodyExempt": { - "description": "Script names whose body is allowed to drift from the canonical form (e.g. socket-lib runs a richer test runner than the standard `node scripts/test.mts`). Each entry is the script name only.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "lint": { - "description": "oxlint profile.", - "type": "object", - "properties": { - "profile": { - "description": "`standard` requires the fleet plugin set (import + typescript + unicorn). `rich` opts into a wider set; check the runner for the exact basenames currently exempted.", - "anyOf": [ - { - "const": "standard", - "type": "string" - }, - { - "const": "rich", - "type": "string" - } - ] - } - } - }, - "workflows": { - "description": "CI workflow opt-ins.", - "type": "object", - "properties": { - "ci": { - "description": "Ship `.github/workflows/ci.yml`.", - "type": "boolean" - }, - "weeklyUpdate": { - "description": "Ship `.github/workflows/weekly-update.yml`.", - "type": "boolean" - }, - "provenance": { - "description": "Repo publishes with npm provenance (OIDC). Hint for setup helpers; not enforced by the checker today.", - "type": "boolean" - }, - "requirePinnedFullSha": { - "description": "Enforce 40-char SHA pins on every `uses:` ref. Defaults to true; an opt-out is reserved for special cases (e.g. workflow-dispatch test rigs) and currently has no consumer.", - "type": "boolean" - } - } - }, - "claude": { - "description": "Claude Code opt-ins.", - "type": "object", - "properties": { - "includeSecurityScanSkill": { - "description": "Ship `.claude/skills/scanning-security/SKILL.md`.", - "type": "boolean" - }, - "includeSharedSkills": { - "description": "Ship `.claude/skills/_shared/*` — env-check, path-guard-rule, report-format, security-tools, verify-build.", - "type": "boolean" - }, - "includeUpdatingSkill": { - "description": "Ship the dependency-update skill. Reserved — no consumer wired today.", - "type": "boolean" - } - } - }, - "workspace": { - "description": "pnpm-workspace.yaml setting hints. The runner reads from the YAML; this block exists for repos that prefer to declare intent in JSON.", - "type": "object", - "properties": { - "allowBuilds": { - "description": "pnpm `onlyBuiltDependencies` allowlist. Map a package name to true/false to grant/deny build scripts.", - "type": "object", - "patternProperties": { - "^(.*)$": { - "type": "boolean" - } - } - }, - "blockExoticSubdeps": { - "description": "Refuse transitive git/tarball subdeps (direct git deps still allowed). Required true; the field exists so a repo can document the intent locally.", - "type": "boolean" - }, - "minimumReleaseAge": { - "minimum": 0, - "description": "Soak time in minutes before installing freshly-published packages. Fleet default 10080 (= 7 days).", - "type": "integer" - }, - "minimumReleaseAgeExclude": { - "description": "Scopes / package patterns exempt from the soak time. Socket-owned scopes typically listed here.", - "type": "array", - "items": { - "type": "string" - } - }, - "resolutionMode": { - "description": "pnpm `resolutionMode`. Fleet default `highest`.", - "anyOf": [ - { - "const": "highest", - "type": "string" - }, - { - "const": "lowest-direct", - "type": "string" - } - ] - }, - "trustPolicy": { - "description": "pnpm `trustPolicy`. Fleet default `no-downgrade`.", - "anyOf": [ - { - "const": "no-downgrade", - "type": "string" - }, - { - "const": "match-spec", - "type": "string" - } - ] - } - } - }, - "github": { - "description": "GitHub-related fleet config.", - "type": "object", - "properties": { - "apps": { - "description": "GitHub App slugs that must be installed on the repo (e.g. `cursor`, `socket-security`, `socket-trufflehog`). Audited by `scripts/lint-github-settings.mts` — apps whose installation cannot be reliably detected via check-suites are trusted via this manifest.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "pathsAllowlist": { - "description": "Exemptions for the path-hygiene gate (scripts/check-paths.mts). Migrated from `.github/paths-allowlist.yml`. Each entry needs a `reason`; prefer narrow entries (rule + file + snippet_hash + pattern) over blanket file-level exempts.", - "type": "array", - "items": { - "description": "One exemption for the path-hygiene gate.", - "type": "object", - "required": ["reason"], - "properties": { - "rule": { - "description": "Rule letter (A, B, C, D, F, G). Omit to match any rule.", - "type": "string" - }, - "file": { - "description": "Substring match against the relative file path.", - "type": "string" - }, - "pattern": { - "description": "Substring match against the offending snippet.", - "type": "string" - }, - "line": { - "description": "Exact line number. Strict — no fuzz tolerance.", - "type": "number" - }, - "snippet_hash": { - "description": "12-char SHA-256 prefix of the normalized snippet (whitespace collapsed). Drift-resistant: keeps matching after reformatting that doesn't change the offending construction. Get via `node scripts/check-paths.mts --show-hashes`.", - "type": "string" - }, - "reason": { - "description": "Why this site is genuinely exempt. Required.", - "type": "string" - } - } - } - } - } -} diff --git a/.config/socket-wheelhouse.json b/.config/socket-wheelhouse.json deleted file mode 100644 index e571de37d..000000000 --- a/.config/socket-wheelhouse.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "./socket-wheelhouse-schema.json", - "schemaVersion": 1, - "repoName": "socket-cli", - "layout": "monorepo", - "native": "consumer" -} diff --git a/.config/taze.config.mts b/.config/taze.config.mts deleted file mode 100644 index c5d711199..000000000 --- a/.config/taze.config.mts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineConfig } from 'taze' - -/* Socket-owned scopes bypass the 7-day maturity cooldown. - * - * The cooldown (maturityPeriod: 7) exists to catch compromised - * upstream packages before we adopt them — but Socket-published - * packages go through our own provenance + publish pipeline, so - * we trust them to ship fresh. - * - * The scopes listed here are EXCLUDED from pass 1 (the - * cooldown-respecting pass) and INCLUDED in pass 2 (the - * immediate-bump pass). Keep this list in sync with - * scripts/update.mts if the repo ships one, or with whatever - * second-pass mechanism the consuming repo's update script - * uses. - */ -const SOCKET_SCOPES = [ - '@socketregistry/*', - '@socketsecurity/*', - '@socketdev/*', - 'socket-*', - 'ecc-agentshield', - 'sfw', -] - -export default defineConfig({ - // Interactive mode disabled for automation. - interactive: false, - // Minimal logging. - loglevel: 'warn', - // Socket scopes handled by a second pass with maturityPeriod 0. - exclude: SOCKET_SCOPES, - // 7-day cooldown on third-party deps — matches `.npmrc`'s - // min-release-age setting for install-time enforcement. - maturityPeriod: 7, - // Bump to latest across major boundaries. - mode: 'latest', - // Edit package.json in place. - write: true, -}) diff --git a/.config/tsconfig.base.json b/.config/tsconfig.base.json index e1ffc5358..558b47851 100644 --- a/.config/tsconfig.base.json +++ b/.config/tsconfig.base.json @@ -1,29 +1,36 @@ { "compilerOptions": { + // The following options are not supported by @typescript/native-preview. + // They are either ignored or throw an unknown option error: + //"importsNotUsedAsValues": "remove", + //"incremental": true, + "allowImportingTsExtensions": true, "allowJs": false, - "composite": false, - "declarationMap": false, + "composite": true, + "declaration": true, + "declarationMap": true, "erasableSyntaxOnly": true, "esModuleInterop": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, - "incremental": false, "isolatedModules": true, - "lib": ["ES2024"], + "lib": ["esnext"], + "module": "nodenext", + "noEmit": true, "noEmitOnError": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, - "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, + "rewriteRelativeImportExtensions": true, "skipLibCheck": true, - "sourceMap": false, + "sourceMap": true, "strict": true, "strictNullChecks": true, - "target": "ES2024", + "target": "esnext", "useUnknownInCatchVariables": true, "verbatimModuleSyntax": true } diff --git a/.config/vitest.config.mts b/.config/vitest.config.mts deleted file mode 100644 index ea6e0e10e..000000000 --- a/.config/vitest.config.mts +++ /dev/null @@ -1,93 +0,0 @@ -import { defineConfig } from 'vitest/config' - -const isCoverageEnabled = - process.env.npm_lifecycle_event === 'cover' || - process.argv.includes('--coverage') - -// oxlint-disable-next-line socket/no-default-export -- vitest config files must use export default per vitest's contract. -export default defineConfig({ - resolve: { - preserveSymlinks: false, - }, - test: { - globals: false, - environment: 'node', - include: [ - // NOTE: No root-level tests exist. All tests are in individual packages. - // Each package (e.g., packages/cli/) has its own vitest.config.mts. - // This root config serves as a fallback default configuration only. - ], - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/.{idea,git,cache,output,temp}/**', - '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', - '**/*.test.{js,ts,mjs,cjs,mts}', - // Exclude E2E tests from regular test runs. - '**/*.e2e.test.mts', - ], - passWithNoTests: true, - reporters: ['default'], - setupFiles: ['./test/setup.mts'], - // Use threads for better performance - pool: 'threads', - poolOptions: { - threads: { - singleThread: false, - maxThreads: isCoverageEnabled ? 1 : 16, - minThreads: isCoverageEnabled ? 1 : 4, - // isolate: true for consistency with packages/cli/vitest.config.mts - // and CI reliability. The tradeoff is slower runs and nock/vi.mock - // friction, but those concerns turned out to be solvable with - // per-test setup/teardown discipline. - isolate: true, - // Use worker threads for better performance - useAtomics: true, - }, - }, - deps: { - interopDefault: false, - }, - testTimeout: 30_000, - hookTimeout: 30_000, - bail: process.env.CI ? 1 : 0, // Exit on first failure in CI for faster feedback. - sequence: { - concurrent: true, // Run tests concurrently within suites for better parallelism. - }, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov', 'clover'], - exclude: [ - '**/*.config.*', - '**/node_modules/**', - '**/[.]**', - '**/*.d.mts', - '**/*.d.ts', - '**/virtual:*', - 'bin/**', - 'coverage/**', - 'dist/**', - 'external/**', - 'pnpmfile.*', - 'scripts/**', - 'src/**/types.mts', - 'test/**', - 'perf/**', - // Explicit root-level exclusions - '/scripts/**', - '/test/**', - ], - include: ['src/**/*.mts', 'src/**/*.ts'], - all: true, - clean: true, - skipFull: false, - ignoreClassMethods: ['constructor'], - thresholds: { - lines: 0, - functions: 0, - branches: 0, - statements: 0, - }, - }, - }, -}) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..1597c187e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true diff --git a/.env.dist b/.env.dist new file mode 100644 index 000000000..17fdec595 --- /dev/null +++ b/.env.dist @@ -0,0 +1,2 @@ +LINT_DIST=1 +NODE_COMPILE_CACHE="$(pwd)/.cache" diff --git a/.env.example b/.env.example deleted file mode 100644 index 691c00890..000000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# Socket CLI Environment Configuration Example -# Copy this file to .env.local and customize for your local environment. - -# Node.js Configuration (optional overrides). -NODE_COMPILE_CACHE="./.cache" -NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=1024" - -# Socket API Configuration (for e2e testing). -# Get your API key from https://socket.dev/dashboard/settings -SOCKET_SECURITY_API_KEY=your_api_key_here -SOCKET_CLI_ORG_SLUG=your_org_slug_here diff --git a/.env.external b/.env.external new file mode 100644 index 000000000..b3f246f18 --- /dev/null +++ b/.env.external @@ -0,0 +1,2 @@ +LINT_EXTERNAL=1 +NODE_COMPILE_CACHE="$(pwd)/.cache" diff --git a/.env.local b/.env.local new file mode 100644 index 000000000..b6d70d520 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +NODE_COMPILE_CACHE="$(pwd)/.cache" diff --git a/.env.precommit b/.env.precommit deleted file mode 100644 index 1ee9eda75..000000000 --- a/.env.precommit +++ /dev/null @@ -1,12 +0,0 @@ -# Socket CLI Pre-commit Test Environment -# This file is loaded during pre-commit hooks. - -# Disable API token requirement for unit tests. -SOCKET_CLI_NO_API_TOKEN=1 - -# Indicate tests are running in Vitest. -VITEST=1 - -# Node.js optimization for test performance. -NODE_COMPILE_CACHE="./.cache" -NODE_OPTIONS="--max-old-space-size=8192" diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..622e7ce15 --- /dev/null +++ b/.env.test @@ -0,0 +1,2 @@ +NODE_COMPILE_CACHE="$(pwd)/.cache" +VITEST=1 diff --git a/.env.testu b/.env.testu new file mode 100644 index 000000000..e1a2470fd --- /dev/null +++ b/.env.testu @@ -0,0 +1,3 @@ +NODE_COMPILE_CACHE="$(pwd)/.cache" +SOCKET_CLI_NO_API_TOKEN=1 +VITEST=1 diff --git a/.git-hooks/_helpers.mts b/.git-hooks/_helpers.mts deleted file mode 100644 index 542daf676..000000000 --- a/.git-hooks/_helpers.mts +++ /dev/null @@ -1,1033 +0,0 @@ -// Shared helpers for git hooks — API-key allowlist + content scanners -// + tiny string utilities (color wrappers, marker-syntax picker, path -// normalize). Each hook imports `getDefaultLogger` from -// `@socketsecurity/lib-stable/logger/default` directly for output; this module stays -// import-light so the cost of `import './_helpers.mts'` is bounded. -// -// Requires Node 25+ for stable .mts type-stripping (no flag needed). -// Earlier Node versions either lacked --experimental-strip-types or -// shipped it under a flag, both unacceptable for hook ergonomics. -// -// Hooks run *after* `pnpm install`, so `@socketsecurity/lib-stable` is on the -// resolution path for any caller that imports it. - -import { existsSync, readFileSync, statSync } from 'node:fs' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -// Hard-fail if Node is below 25. This runs at module load — every -// hook invocation imports _helpers.mts before doing anything, so the -// version check is the first thing that happens. -const NODE_MIN_MAJOR = 25 -const nodeMajor = Number.parseInt( - process.versions.node.split('.')[0] || '0', - 10, -) -if (nodeMajor < NODE_MIN_MAJOR) { - // @socketsecurity/lib-stable requires Node >= 25; the canonical logger - // isn't importable here. Use raw process.stderr with ASCII (no - // status-emoji glyph) so the no-status-emoji lint rule stays clean - // — the lint rule's recommendation (use logger.fail()) doesn't - // apply when the entire branch is the logger-unavailable bail. - process.stderr.write( - `\x1b[0;31mHook requires Node >= ${NODE_MIN_MAJOR}.0.0 (have v${process.versions.node})\x1b[0m\n`, - ) - process.stderr.write( - 'Install Node 25+ — these hooks rely on stable .mts type stripping.\n', - ) - process.exit(1) -} - -// ── Allowlist constants ──────────────────────────────────────────── -// These exempt known-safe matches from the API-key scanner. Each -// allowlist entry is a substring; if the matched line contains it, -// the line is dropped from the findings. - -// Real public API key shipped in socket-lib test fixtures. Safe to -// appear anywhere in the fleet. -export const ALLOWED_PUBLIC_KEY = - 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api' - -// Substring marker used in test fixtures (see -// socket-lib/test/unit/utils/fake-tokens.ts). Lines containing this -// are treated as test fixtures. -export const FAKE_TOKEN_MARKER = 'socket-test-fake-token' - -// Legacy lib-scoped marker — accepted during the rename from -// `socket-lib-test-fake-token` to `socket-test-fake-token`. Drop when -// lib's rename PR lands. -export const FAKE_TOKEN_LEGACY = 'socket-lib-test-fake-token' - -// Env-var name prefixes used in shell examples / `.env.example` files. -// Lines containing `<name>=` are documentation, not real tokens — drop -// them from secret-scanner hits. SOCKET_API_TOKEN is the canonical -// fleet name; the rest are legacy variants kept on the allowlist for -// one cycle so existing `.env.example` files don't trip the gate -// after the rebrand. -export const SOCKET_TOKEN_ENV_NAMES: readonly string[] = [ - 'SOCKET_API_TOKEN=', - 'SOCKET_API_KEY=', - 'SOCKET_SECURITY_API_TOKEN=', - 'SOCKET_SECURITY_API_KEY=', -] -// Back-compat alias — earlier callers imported this single-string -// constant. New code should reach for SOCKET_TOKEN_ENV_NAMES. -export const SOCKET_SECURITY_ENV = SOCKET_TOKEN_ENV_NAMES[0]! - -// ── Output ────────────────────────────────────────────────────────── -// -// Hooks call `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default` -// directly. Color comes from the logger's semantic methods — -// `.fail()` is red ✖, `.success()` is green ✔, `.warn()` is yellow ⚠, -// `.info()` is blue ℹ, `.error()` is plain. ANSI constants and -// `red()`/`green()`/`yellow()` wrappers are intentionally NOT exported -// from this module; the logger owns the visual surface. - -// Posix-form path normalization for staged file lists. Git on Windows -// can hand back backslash separators in some surfaces; the downstream -// `startsWith('.git-hooks/')` / `includes('/external/')` pattern -// matching assumes forward slashes. Cheap to convert once. -export const normalizePath = (p: string): string => p.replace(/\\/g, '/') - -/** - * Split text into lines, normalizing CRLF (`\r\n`) to LF (`\n`) first. - * - * Hooks consume text from three sources where CRLF can show up: - * - * - Subprocess stdout/stderr (especially git on Windows / msys) - * - Stdin from the git push protocol on Windows - * - File contents from a working copy with `core.autocrlf` semantics - * - * Plain `text.split('\n')` on CRLF input leaves a trailing `\r` on every line, - * which breaks per-line regex anchors used by the secret / personal-path / - * AI-attribution scanners. The hook then reports "no findings" on Windows even - * though the input clearly contains them — a security-gate fail-open. Always go - * through this helper for any text that didn't originate as a literal in our - * own code. - */ -export const splitLines = (text: string): string[] => - text.replace(/\r\n/g, '\n').split('\n') - -// ── API-key allowlist filter ─────────────────────────────────────── - -// Returns true if a line is on the allowlist (a public/example/fake -// token we deliberately ship). Used by scanners to drop allowlisted -// hits without losing each hit's original lineNumber. -// -// Previous version allowlisted any line containing the bare substring -// '.example' — too broad. Real keys on lines that mention `.example` -// anywhere (TLD, paths, prose like "see .example below") were silently -// allowlisted. Now we require either an explicit per-line marker or -// the canonical fixture filename pattern `.env.example`. -const SOCKET_API_KEY_ALLOW_MARKER = 'socket-hook: allow socket-api-key' -const isAllowedApiKey = (line: string): boolean => - line.includes(ALLOWED_PUBLIC_KEY) || - line.includes(FAKE_TOKEN_MARKER) || - line.includes(FAKE_TOKEN_LEGACY) || - SOCKET_TOKEN_ENV_NAMES.some(name => line.includes(name)) || - line.includes(SOCKET_API_KEY_ALLOW_MARKER) || - line.includes('.env.example') - -// Drops any line that matches an allowlist entry. Kept for callers -// that work on bare lines; new code should filter LineHit[] directly -// via isAllowedApiKey to preserve per-hit lineNumber. -export const filterAllowedApiKeys = (lines: readonly string[]): string[] => - lines.filter(line => !isAllowedApiKey(line)) - -// ── Personal-path scanner ────────────────────────────────────────── - -// Real personal paths to flag: /Users/foo/, /home/foo/, C:\Users\foo\. -// The scanner's job is to catch a hardcoded USERNAME leak. `~/...` and -// `$HOME/...` are the OPPOSITE — they're the recommended username-free -// forms (and the placeholder-allowlist below explicitly accepts them), -// so they MUST NOT be flagged. (An earlier revision added `~/` / -// `$HOME/` here, which wrongly flagged canonical fixed paths like -// `~/.config/gh/hosts.yml` and `~/.claude/...` and blocked the push.) -// NFKC normalization is applied at the scanLines layer before this -// regex runs so full-width / Unicode variants of `/Users` (e.g. -// `/Users/foo/`) don't slip past. -const PERSONAL_PATH_RE = - /(\/Users\/[^/\s]+\/|\/home\/[^/\s]+\/|C:\\Users\\[^\\]+\\)/ - -// Placeholders we ALLOW (documentation, not real leaks). The scanner -// accepts any path component wrapped in <...> or starting with $VAR / -// ${VAR}, but for **canonical fleet style** use exactly these forms in -// docs / tests / comments / error messages — pick the one matching the -// path's platform: -// -// POSIX → /Users/<user>/... (macOS — `<user>` matches $USER) -// POSIX → /home/<user>/... (Linux — same convention) -// Windows → C:\Users\<USERNAME>\... (matches %USERNAME%) -// -// Don't drift to `<name>` / `<me>` / `<USER>` / `<u>` etc. The -// `suggestPersonalPathReplacement` helper below auto-rewrites real -// paths into these canonical shapes; mirror its output everywhere -// else. -const PERSONAL_PATH_PLACEHOLDER_RE = - /(\/Users\/<[^>]*>\/|\/home\/<[^>]*>\/|C:\\Users\\<[^>]*>\\|\/Users\/\$\{?[A-Z_]+\}?\/|\/home\/\$\{?[A-Z_]+\}?\/)/ - -// Per-line opt-out marker for our pre-commit / pre-push scanners. -// -// Canonical form: <comment-prefix> socket-hook: allow -// Targeted form: <comment-prefix> socket-hook: allow <rule> -// -// `<comment-prefix>` is whichever comment style the host file uses — -// `#` for shell / YAML / TOML / Dockerfile, `//` for TS / JS / Rust / -// Go / C-family, or `/*` for the C-block-comment opener. The hook is -// invoked from many file types; pinning to `#` made the marker fail -// silently in `.ts` / `.mts` files (where `// socket-hook: allow` is -// the only sensible spelling) and confused contributors. -// -// The targeted form names a specific rule (`personal-path`, `npx`, -// `aws-key`, etc.) and is recommended for reviewers; the bare `allow` -// form blanket-suppresses every scanner on that line. eslint-style -// precedent. -// -// Legacy `# zizmor: ...` markers are still recognized for one cycle so -// existing files don't have to be rewritten in the same change that -// renames the marker. -const SOCKET_HOOK_MARKER_RE = - /(?:#|\/\/|\/\*)\s*socket-hook:\s*allow(?:\s+([\w-]+))?/ - -// File extensions whose natural comment syntax is `//` (C-family + cousins). -// Anything else falls through to `#` (shell / YAML / TOML / Dockerfile / -// Makefile / Python / Ruby / etc). -const SLASH_COMMENT_EXT_RE = - /\.(m?ts|tsx|cts|m?js|jsx|cjs|rs|go|c|cc|cpp|cxx|h|hpp|java|swift|kt|scala|dart|php|css|scss|less)$/i - -/** - * Pick the natural per-line opt-out marker for a host file. - * - * The marker regex above accepts `#`, `//`, and `/*` prefixes — but error - * messages should print the _one_ form a contributor would actually paste into - * that file. TS edits get `// socket-hook: allow <rule>`; YAML gets `# - * socket-hook: allow <rule>`. Same rule, different comment lexer. - */ -export const socketHookMarkerFor = (filePath: string, rule: string): string => - SLASH_COMMENT_EXT_RE.test(filePath) - ? `// socket-hook: allow ${rule}` - : `# socket-hook: allow ${rule}` -const LEGACY_ZIZMOR_MARKER_RE = /(?:#|\/\/|\/\*)\s*zizmor:\s*[\w-]+/ - -// Aliases: legacy marker names recognized as equivalent to a current -// rule for one deprecation cycle, so callers can rename the canonical -// rule without breaking files that still carry the old marker. -// -// Add entries as `<alias>: <canonical>`; both directions match in the -// comparison below. -const RULE_ALIASES: { [k: string]: string | undefined } = { - __proto__: null, - // 'logger' was the original name when the scanner only flagged - // process.std{out,err}.write; it now flags console.* too, so the - // canonical marker is 'console'. Keep 'logger' for one cycle. - logger: 'console', -} - -export function aliasMatches(marker: string, rule: string): boolean { - if (marker === rule) { - return true - } - return RULE_ALIASES[marker] === rule || RULE_ALIASES[rule] === marker -} - -export function lineIsSuppressed(line: string, rule?: string): boolean { - if (LEGACY_ZIZMOR_MARKER_RE.test(line)) { - return true - } - const m = line.match(SOCKET_HOOK_MARKER_RE) - if (!m) { - return false - } - // No rule named on the marker → blanket allow. - if (!m[1]) { - return true - } - // Marker named a specific rule → suppress when the names match - // directly OR through an alias. - return rule === undefined || aliasMatches(m[1], rule) -} - -// Heuristic context flags: lines that look like "this is a doc example" -// rather than a real call leaked into runtime code. -// - Comment lines (start with `*`, `//`, `#`). -// - Lines that contain a JSDoc tag like @example / @param / @returns -// (multi-line JSDoc bodies use leading ` * ` which we already match). -// - Lines whose entire interesting content sits inside a backtick span -// (markdown / template-literal example). -const COMMENT_LINE_RE = /^\s*(\*|\/\/|#)/ -const JSDOC_TAG_RE = /@(example|param|returns?|see|link)\b/ - -export function isInsideBackticks(line: string, needleRe: RegExp): boolean { - // Find every backtick-delimited span on the line and test if the - // pattern only appears within those spans. Conservative: if any - // hit is *outside* a span, treat the line as runtime code. - const spans: Array<[number, number]> = [] - for (let i = 0; i < line.length; i++) { - if (line[i] === '`') { - const end = line.indexOf('`', i + 1) - if (end < 0) { - break - } - spans.push([i, end]) - i = end - } - } - if (spans.length === 0) { - return false - } - let m: RegExpExecArray | null - const re = new RegExp(needleRe.source, needleRe.flags.replace('g', '') + 'g') - while ((m = re.exec(line)) !== null) { - const start = m.index - const end = start + m[0].length - const inside = spans.some(([s, e]) => start > s && end <= e) - if (!inside) { - return false - } - } - return true -} - -export function looksLikeDocumentation( - line: string, - needleRe: RegExp, - rule?: string, -): boolean { - if (lineIsSuppressed(line, rule)) { - return true - } - if (COMMENT_LINE_RE.test(line)) { - return true - } - if (JSDOC_TAG_RE.test(line)) { - return true - } - if (isInsideBackticks(line, needleRe)) { - return true - } - return false -} - -export type LineHit = { - lineNumber: number - line: string - // Suggested rewrite when this flagged line is documentation-style and - // the scanner can offer a concrete fix. Undefined for runtime-code - // paths where the right answer depends on the surrounding code. - suggested?: string | undefined -} - -// Generic line-walk scanner factory. Splits text into lines once, -// applies the regex per line, optionally skips lines via `filter` (for -// allowlists) and/or via `skipDocs` (for documentation-style -// detection), and optionally attaches a suggested rewrite. Centralizes -// the loop shape that every concrete scanner used to inline. -// -// Options: -// filter — return true to drop a line (e.g. allowlist match). -// skipDocs.rule — when set, calls looksLikeDocumentation() with the -// same regex + this rule name and skips lines that match. -// suggest — produces the per-line `suggested` rewrite shown to users. -function scanLines( - text: string, - pattern: RegExp, - options: { - filter?: ((line: string) => boolean) | undefined - skipDocs?: { rule: string } | undefined - suggest?: ((line: string) => string) | undefined - // NFKC-normalize each line before regex match. Catches Unicode - // variants of leak markers (full-width slashes, etc.). Off by - // default — secret-token regexes match exact ASCII byte - // sequences and must NOT be Unicode-normalized. - normalizeForMatch?: boolean | undefined - } = {}, -): LineHit[] { - const hits: LineHit[] = [] - const lines = splitLines(text) - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - const lineForMatch = options.normalizeForMatch - ? line.normalize('NFKC') - : line - if (!pattern.test(lineForMatch)) { - continue - } - if (options.filter && options.filter(lineForMatch)) { - continue - } - if ( - options.skipDocs && - looksLikeDocumentation(lineForMatch, pattern, options.skipDocs.rule) - ) { - continue - } - const hit: LineHit = { lineNumber: i + 1, line } - if (options.suggest) { - hit.suggested = options.suggest(line) - } - hits.push(hit) - } - return hits -} - -// Build a suggested rewrite for a documentation-style personal path. -// Replaces the matched real-path username segment with the canonical -// placeholder form: `<user>` / `<USERNAME>` (matching the platform -// convention of the surrounding path). -export function suggestPlaceholder(line: string): string { - return line - .replace(/\/Users\/[^/\s]+\//g, '/Users/<user>/') - .replace(/\/home\/[^/\s]+\//g, '/home/<user>/') - .replace(/C:\\Users\\[^\\]+\\/g, 'C:\\Users\\<USERNAME>\\') -} - -// Returns lines that contain a real personal path (excludes lines that -// are pure placeholders or look like documentation examples). Each hit -// carries a `suggested` rewrite when the scanner can offer one — the -// caller surfaces it to the user as the fix recipe. -export const scanPersonalPaths = (text: string): LineHit[] => - scanLines(text, PERSONAL_PATH_RE, { - // NFKC-normalize before match — catches full-width and ligature - // variants that would otherwise slip past the ASCII-only regex. - normalizeForMatch: true, - filter: line => { - // Pure-placeholder lines (no real path remains after stripping - // every `<...>` placeholder) are documentation, not leaks. - if (!PERSONAL_PATH_PLACEHOLDER_RE.test(line)) { - return false - } - const stripped = line.replace( - new RegExp(PERSONAL_PATH_PLACEHOLDER_RE, 'g'), - '', - ) - return !PERSONAL_PATH_RE.test(stripped) - }, - skipDocs: { rule: 'personal-path' }, - suggest: suggestPlaceholder, - }) - -// ── Secret scanners ──────────────────────────────────────────────── - -const SOCKET_API_KEY_RE = /sktsec_[a-zA-Z0-9_-]+/ -const AWS_KEY_RE = /(aws_access_key|aws_secret|\bAKIA[0-9A-Z]{16}\b)/i -// GitHub token formats — accepts both classic opaque and new JWT -// formats per the 2026-05-15 token-format rollout: -// -// - ghp_ / gho_ / ghr_ / ghu_ / ghs_ : classic opaque 36+ chars -// - ghs_ + ghu_ (NEW) : JWT format, ~520 chars, -// contains two dots and -// underscores. ghu_ scheduled -// for same rollout per -// changelog (timing TBD). -// - github_pat_ : fine-grained PAT -// -// The `[A-Za-z0-9._]` char class on ghs_/ghu_ covers BOTH formats -// (classic: alnum only; JWT: alnum + `.` + `_`). Minimum length 36 -// is the floor for both formats — classic tokens are 36+ chars after -// the prefix, JWTs are ~520. GitHub's recommended regex is -// `ghs_[A-Za-z0-9\._]{36,}`. -const GITHUB_TOKEN_RE = - /\b(?:ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|ghr_[A-Za-z0-9]{36,}|ghs_[A-Za-z0-9._]{36,}|ghu_[A-Za-z0-9._]{36,}|github_pat_[A-Za-z0-9_]{20,})/ -// Private-key PEM headers. Covers every type that wraps a private -// key in PEM armor: -// - `BEGIN PRIVATE KEY` (PKCS#8, generic) -// - `BEGIN RSA PRIVATE KEY` (PKCS#1, OpenSSL classic) -// - `BEGIN EC PRIVATE KEY` / `BEGIN DSA PRIVATE KEY` -// - `BEGIN OPENSSH PRIVATE KEY` (default ssh-keygen output since 2019; -// the most common case for personal SSH keys) -// - `BEGIN ENCRYPTED PRIVATE KEY` (PKCS#8 passphrase-protected) -// - `BEGIN PGP PRIVATE KEY BLOCK` (PGP secret keys) -// The leading `[A-Z ]*` accepts any uppercase-letters+space prefix -// before "PRIVATE KEY" so future formats are caught automatically. -const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----/ - -export const scanSocketApiKeys = (text: string): LineHit[] => - scanLines(text, SOCKET_API_KEY_RE, { filter: isAllowedApiKey }) - -export const scanAwsKeys = (text: string): LineHit[] => - scanLines(text, AWS_KEY_RE) - -export const scanGitHubTokens = (text: string): LineHit[] => - scanLines(text, GITHUB_TOKEN_RE) - -export const scanPrivateKeys = (text: string): LineHit[] => - scanLines(text, PRIVATE_KEY_RE) - -// ── package.json pnpm.overrides scanner ──────────────────────────── -// -// Dependency overrides belong in pnpm-workspace.yaml `overrides:`, the -// fleet's single override surface. A non-empty `pnpm.overrides` block in -// a package.json splits the source of truth and sits outside the -// workspace file's `trustPolicy: no-downgrade`. Structural, not -// line-pattern: parse the JSON, flag a non-empty `pnpm.overrides`. Points -// the hit at the `"overrides"` line so the message is actionable. Returns -// no hits on parse failure (fail open; oxfmt / other gates catch broken -// JSON). -export const scanPackageJsonPnpmOverrides = (text: string): LineHit[] => { - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - return [] - } - const pnpm = (parsed as { pnpm?: unknown } | null)?.pnpm - const overrides = - pnpm && typeof pnpm === 'object' - ? (pnpm as { overrides?: unknown }).overrides - : undefined - if ( - !overrides || - typeof overrides !== 'object' || - Object.keys(overrides as Record<string, unknown>).length === 0 - ) { - return [] - } - const lines = text.split(/\r?\n/) - for (let i = 0, { length } = lines; i < length; i += 1) { - if (/"overrides"\s*:/.test(lines[i]!)) { - return [{ lineNumber: i + 1, line: lines[i]!.trim() }] - } - } - return [{ lineNumber: 1, line: '"pnpm": { "overrides": { … } }' }] -} - -// ── npx/dlx scanner ──────────────────────────────────────────────── -// -// Match `npx` / `yarn dlx` only when the token sits at a command -// position — preceded by start-of-line / whitespace / shell separator -// (`&&`, `||`, `;`, `|`, `(`, backtick), or directly after a PowerShell -// `& ` invoke. Exclude JSON-key, env-value, and identifier suffix -// contexts where `npx` shows up as an embedded substring: -// - `"socket-npx": …` (bin-name suffix) -// - `"dev:npx": "…SOCKET_CLI_MODE=npx node …"` (script key + env value) -// - `cmd-npx-helper` (identifier interior) -// The negative lookbehind catches hyphen / colon / equals / underscore / -// dot prefixes; the negative lookahead catches the same followed forms -// (`npx-helper`, `npx:foo`). -// -// **Allowed:** `pnpm dlx` / `pnpm exec` / `pn dlx` / `pn exec` / `pnx` -// (the pnpm v11 shorthands for `pnpm dlx`). `pnpm dlx` is the -// fleet-canonical fetch-and-run form for documentation lines that -// describe ad-hoc CLI usage (where the consumer doesn't have the -// package pinned in their workspace). `pnx` is the v11 shorthand and -// is equally allowed. - -const NPX_DLX_RE = /(?<![\w\-:=.])\b(npx|yarn dlx)\b(?![\w\-:=.])/ - -// Suggest the canonical replacement for a runtime npx/dlx call. -// Documentation contexts (comments, JSDoc) are exempt via -// looksLikeDocumentation(); we only ever land here for code lines, where -// the right swap is `pnpm exec` (since `pnpm` is the fleet's package -// manager) or `pnpm run` for script entries. For documentation lines -// All dlx-style invocations rewrite to `pnpm exec`. This matches the -// `socket/no-npx-dlx` oxlint rule's autofix and the CLAUDE.md tooling -// rule (NEVER use npx / pnpm dlx / yarn dlx — use pnpm exec). Keep -// the alternation ordered longest-prefix-first so `pnpm dlx` matches -// before any future `pnpm`-anchored rule could shadow it. -export function suggestNpxReplacement(line: string): string { - return line - .replace(/\bpnpm dlx\b/g, 'pnpm exec') - .replace(/\byarn dlx\b/g, 'pnpm exec') - .replace(/\bpnx\b/g, 'pnpm exec') - .replace(/\bnpx\b/g, 'pnpm exec') -} - -export const scanNpxDlx = (text: string): LineHit[] => - scanLines(text, NPX_DLX_RE, { - skipDocs: { rule: 'npx' }, - suggest: suggestNpxReplacement, - }) - -// ── pnpm-first docs scanner ──────────────────────────────────────── -// -// Fleet rule: user-facing documentation that shows install commands -// should LEAD with the pnpm form (`pnpm install <pkg>`, `pnpm add -// <pkg>`). npm / yarn fallbacks are fine, but they should appear -// after the pnpm form — or in a sibling code block introduced as a -// fallback for users who don't have pnpm. -// -// This scanner walks fenced markdown code blocks (``` or ~~~) and -// emits a warning for any fence whose first install-shape line is -// npm/yarn rather than pnpm. Warning-only — never fails a commit. -// Inline backtick spans (a single `npm install foo` in prose) are -// NOT scanned; only block-level fences. -// -// Suppression: a line containing `socket-hook: allow pnpm-first` -// anywhere in the fence (or just above it) skips that block. - -// Match shell install commands at line start (allowing leading -// whitespace + `$` prompt). Captures the package manager so the -// caller can tell which form was seen first. -const PNPM_INSTALL_LINE_RE = /^\s*\$?\s*pnpm\s+(?:add|i|install)\b/ -const NPM_YARN_INSTALL_LINE_RE = - /^\s*\$?\s*(?:(npm)\s+(?:add|i|install)|(?:yarn)\s+(?:install|add)|(?:yarn))\s/ - -// Markdown fence opener: ``` or ~~~ at line start, optionally followed -// by an info string (language hint). We don't require closing match — -// just count fences as we go and treat alternating opens/closes. -const FENCE_OPEN_RE = /^\s*(?:```|~~~)/ - -const PNPM_FIRST_SUPPRESS_RE = /socket-hook:\s*allow\s+pnpm-first\b/ - -export const scanDocsPnpmFirst = (text: string): LineHit[] => { - const hits: LineHit[] = [] - const lines = splitLines(text) - let inFence = false - let fenceStartLine = -1 - let fenceHasPnpm = false - let fenceHasSuppress = false - let fenceFirstNpmYarnHit: LineHit | undefined - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (FENCE_OPEN_RE.test(line)) { - // Closing fence: flush any pending hit if no pnpm form was seen - // and the block wasn't suppressed. - if (inFence) { - if (fenceFirstNpmYarnHit && !fenceHasPnpm && !fenceHasSuppress) { - hits.push(fenceFirstNpmYarnHit) - } - inFence = false - fenceStartLine = -1 - fenceHasPnpm = false - fenceHasSuppress = false - fenceFirstNpmYarnHit = undefined - } else { - inFence = true - fenceStartLine = i + 1 - } - continue - } - if (!inFence) { - // Suppression marker on a comment line just above the fence is - // also honored (some docs prefer keeping markers outside the - // rendered code block). - if (PNPM_FIRST_SUPPRESS_RE.test(line)) { - // Look ahead one line for a fence open; if it's there, mark - // the upcoming block as suppressed. - const next = lines[i + 1] - if (next !== undefined && FENCE_OPEN_RE.test(next)) { - fenceHasSuppress = true - } - } - continue - } - if (PNPM_FIRST_SUPPRESS_RE.test(line)) { - fenceHasSuppress = true - continue - } - if (PNPM_INSTALL_LINE_RE.test(line)) { - fenceHasPnpm = true - continue - } - if ( - NPM_YARN_INSTALL_LINE_RE.test(line) && - fenceFirstNpmYarnHit === undefined - ) { - fenceFirstNpmYarnHit = { - lineNumber: i + 1, - line, - suggested: line.replace(/\b(npm|yarn)\s+(add|i|install)\b/, 'pnpm $2'), - } - } - } - // Unclosed fence at EOF — flush whatever's pending. - if (inFence && fenceFirstNpmYarnHit && !fenceHasPnpm && !fenceHasSuppress) { - hits.push(fenceFirstNpmYarnHit) - } - // Reference fenceStartLine to suppress unused-variable lints; the - // value is useful for future enhancements (e.g. block-level - // diagnostics) but the current per-line LineHit shape carries the - // offending line number directly. - void fenceStartLine - return hits -} - -// ── Logger leak scanner ──────────────────────────────────────────── -// -// The fleet rule: source code uses `getDefaultLogger()` from -// `@socketsecurity/lib-stable/logger/default`. Direct calls to `process.stderr.write`, -// `process.stdout.write`, `console.log`, `console.error`, `console.warn`, -// `console.info`, `console.debug` are blocked. Doc-context lines are -// exempt; lines carrying `// socket-hook: allow console` (or `#` in -// non-TS files) are exempt too. Legacy `allow logger` is accepted as -// an alias for one deprecation cycle. - -const LOGGER_LEAK_RE = - /\b(process\.std(?:err|out)\.write|console\.(?:debug|error|info|log|warn))\s*\(/ - -// Map each direct call to its lib-logger equivalent. process.stdout is -// closer to logger.info; process.stderr / console.error → logger.error; -// console.warn → logger.warn; console.info / console.log → logger.info; -// console.debug → logger.debug. -export function suggestLoggerReplacement(line: string): string { - return line - .replace(/\bprocess\.stderr\.write\s*\(/g, 'logger.error(') - .replace(/\bprocess\.stdout\.write\s*\(/g, 'logger.info(') - .replace(/\bconsole\.error\s*\(/g, 'logger.error(') - .replace(/\bconsole\.warn\s*\(/g, 'logger.warn(') - .replace(/\bconsole\.info\s*\(/g, 'logger.info(') - .replace(/\bconsole\.debug\s*\(/g, 'logger.debug(') - .replace(/\bconsole\.log\s*\(/g, 'logger.info(') -} - -export const scanLoggerLeaks = (text: string): LineHit[] => - scanLines(text, LOGGER_LEAK_RE, { - skipDocs: { rule: 'console' }, - suggest: suggestLoggerReplacement, - }) - -// ── Cross-repo path scanner ──────────────────────────────────────── -// -// Two forbidden forms catch the same mistake — referencing another -// fleet repo by a path that escapes the current repo: -// -// 1. `../<fleet-repo>/…` (cross-repo relative). Hardcodes the -// assumption that both repos are sibling clones under the same -// projects root; breaks in CI sandboxes / fresh clones / non- -// standard layouts. -// 2. `<abs-prefix>/projects/<fleet-repo>/…` (cross-repo absolute, -// where <abs-prefix> isn't already caught by scanPersonalPaths -// because it uses a placeholder like `${HOME}`). -// -// The right way is to import from the published npm package -// (`@socketsecurity/lib-stable/...`, `@socketsecurity/registry-stable/...`). -// Scanner detects both shapes; suppress with the canonical marker -// `<comment-prefix> socket-hook: allow cross-repo`. - -const FLEET_REPO_NAMES = [ - 'claude-code', - 'skills', - 'socket-addon', - 'socket-btm', - 'socket-cli', - 'socket-lib', - 'socket-packageurl-js', - 'socket-registry', - 'socket-wheelhouse', - 'socket-sdk-js', - 'socket-sdxgen', - 'socket-stuie', - 'socket-vscode', - 'socket-webext', - 'ultrathink', -] as const - -// `../<repo>/…` or `../../<repo>/…` etc. — relative path that walks -// out of the current repo into a sibling fleet repo. -const CROSS_REPO_RELATIVE_RE = new RegExp( - String.raw`(?:^|[\s'"\`(=,])\.\.(?:/\.\.)*/(?:${FLEET_REPO_NAMES.join('|')})\b`, -) -// `…/projects/<repo>/…` — absolute or env-rooted path into a sibling -// fleet repo. Catches cases where scanPersonalPaths has already been -// satisfied via `${HOME}` / `<user>` substitution but the path itself -// still escapes into another repo. -const CROSS_REPO_ABSOLUTE_RE = new RegExp( - String.raw`/projects/(?:${FLEET_REPO_NAMES.join('|')})\b`, -) -const CROSS_REPO_ANY_RE = new RegExp( - `${CROSS_REPO_RELATIVE_RE.source}|${CROSS_REPO_ABSOLUTE_RE.source}`, -) - -export const scanCrossRepoPaths = ( - text: string, - currentRepoName?: string, -): LineHit[] => { - const hits: LineHit[] = [] - const lines = splitLines(text) - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - const m = line.match(CROSS_REPO_ANY_RE) - if (!m) { - continue - } - // A repo's own paths (`socket-lib/...` referenced from inside - // socket-lib) are fine — we only catch cross-repo escapes. - const matched = m[0] - if (currentRepoName && matched.includes(`/${currentRepoName}`)) { - continue - } - if (looksLikeDocumentation(line, CROSS_REPO_ANY_RE, 'cross-repo')) { - continue - } - hits.push({ - lineNumber: i + 1, - line, - suggested: '', - }) - } - return hits -} - -// ── AI attribution scanner ───────────────────────────────────────── -// -// Matches BOILERPLATE attribution patterns ("Generated with Claude", -// "Co-Authored-By: Claude", emoji prefixes, vendor email addresses) — -// not legitimate product / directory references. Bare "Claude" / -// "Claude Code" / ".claude/" are valid prose; only the -// attribution-verb-anchored forms trigger the hook. - -const AI_ATTRIBUTION_RE = - /(?:(?:Authored|Built|Crafted|Created|Generated|Made|Powered|Written)\s+(?:with|by)\s+(?:Claude|AI|GPT|ChatGPT|Copilot|Cursor|Bard|Gemini)|Co-Authored-By:\s+(?:Claude|AI|GPT|ChatGPT|Copilot|Cursor|Bard|Gemini)|🤖\s+Generated|AI[\s-]generated|Machine[\s-]generated|@(?:anthropic|openai)\.com|^Assistant:)/im - -export const containsAiAttribution = (text: string): boolean => - AI_ATTRIBUTION_RE.test(text) - -export const stripAiAttribution = ( - text: string, -): { cleaned: string; removed: number } => { - const lines = splitLines(text) - const kept: string[] = [] - let removed = 0 - for (const line of lines) { - if (AI_ATTRIBUTION_RE.test(line)) { - removed++ - } else { - kept.push(line) - } - } - return { cleaned: kept.join('\n'), removed } -} - -// ── Linear reference scanner ────────────────────────────────────── -// -// Linear tracking lives in Linear; commit messages stay tool-agnostic -// (the same rule appears in the canonical CLAUDE.md "public-surface -// hygiene" block). This scanner enforces it on commit messages and is -// invoked by .git-hooks/commit-msg.mts. -// -// The team-key list is enumerated from the Socket Linear workspace. -// `PATCH` is listed before `PAT` so the longest-prefix wins on -// strings like `PATCH-123` — JS regex alternation is leftmost, not -// longest, so order is load-bearing. -const LINEAR_TEAM_KEYS = [ - 'ASK', - 'AUTO', - 'BOT', - 'CE', - 'CORE', - 'DAT', - 'DES', - 'DEV', - 'ENG', - 'INFRA', - 'LAB', - 'MAR', - 'MET', - 'OPS', - 'PAR', - 'PATCH', - 'PAT', - 'PLAT', - 'REA', - 'SALES', - 'SBOM', - 'SEC', - 'SMO', - 'SUP', - 'TES', - 'TI', - 'WEB', -] as const - -// Match either: -// - a team-key + dash + digits, surrounded by non-word chars (or -// line start/end) so we don't match inside identifiers like -// `someENG-123foo` -// - a literal `linear.app/<path>` URL fragment -// -// `(^|[^A-Za-z0-9_])` and `($|[^A-Za-z0-9_])` are word-boundary -// equivalents that also accept end-of-line, since `\b` in JS treats -// punctuation as a word boundary inconsistently. -const LINEAR_REF_RE = new RegExp( - `(^|[^A-Za-z0-9_])(${LINEAR_TEAM_KEYS.join('|')})-[0-9]+($|[^A-Za-z0-9_])|linear\\.app/[A-Za-z0-9/_-]+`, - 'g', -) - -// Capture groups for LINEAR_REF_RE: -// - match[0]: full match including the leading/trailing word -// boundary chars (or the linear.app URL). -// - match[1]: leading non-word char (when the team-key branch matched). -// - match[2]: team key (when the team-key branch matched). -// Use the team-key branch's middle chunk by re-extracting `<KEY>-<N>` -// from match[0]; the URL branch returns match[0] verbatim minus the -// surrounding word boundaries (which it doesn't have). -const LINEAR_KEY_DIGITS_RE = new RegExp( - `(${LINEAR_TEAM_KEYS.join('|')})-[0-9]+`, -) - -// Returns up to `limit` distinct Linear-style references found in -// `text`. Comment lines (lines starting with `#`, after the leading -// whitespace is stripped) are ignored — git uses those for the -// "Please enter the commit message" hint and we don't want to flag -// references that appeared in the diff snippet that git inlined. -export const scanLinearRefs = (text: string, limit = 5): string[] => { - const hits: string[] = [] - for (const rawLine of splitLines(text)) { - if (rawLine.trimStart().startsWith('#')) { - continue - } - LINEAR_REF_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = LINEAR_REF_RE.exec(rawLine))) { - // Extract the canonical reference: `KEY-NNN` for team-key - // matches, or the linear.app/... fragment verbatim. - const inner = LINEAR_KEY_DIGITS_RE.exec(match[0]) - const ref = inner ? inner[0] : match[0] - if (!hits.includes(ref)) { - hits.push(ref) - if (hits.length >= limit) { - return hits - } - } - } - } - return hits -} - -// ── File classification ──────────────────────────────────────────── - -// Files we never scan: hooks themselves (both the .mts files and the -// shell shims under .git-hooks/), test fixtures, vendored lockfiles. -const SKIP_FILE_RE = - /\.(spec|test)\.(m?[jt]s|tsx?|cts|mts)$|\.example$|\/test\/|\/tests\/|fixtures\/|\.git-hooks\/|node_modules\/|pnpm-lock\.yaml/ - -export const shouldSkipFile = (filePath: string): boolean => - SKIP_FILE_RE.test(filePath) - -// Returns file content as a string. For binaries, runs `strings` to -// extract printable byte sequences (catches paths embedded in WASM -// or other compiled artifacts). -export const readFileForScan = (filePath: string): string => { - if (!existsSync(filePath)) { - return '' - } - try { - if (statSync(filePath).isDirectory()) { - return '' - } - } catch { - return '' - } - // Detect binary via grep -I (matches text-only); if grep says - // binary, fall back to `strings`. - const grepResult = spawnSync('grep', ['-qI', '', filePath]) - if (grepResult.status === 0) { - // Text file. - try { - return readFileSync(filePath, 'utf8') - } catch { - return '' - } - } - // Binary — extract strings. - const stringsResult = spawnSync('strings', [filePath], { - encoding: 'utf8', - }) - return stringsResult.stdout || '' -} - -// ── Git wrappers ─────────────────────────────────────────────────── -// -// Two flavors: -// -// git(...) — loose. Returns '' on failure. Used by callers that -// legitimately tolerate a missing ref (e.g. probing -// remote default-branch HEAD which may not be set up -// locally) and provide their own fallback. Silent -// by design — _helpers.mts can't import the canonical -// logger because it runs before the Node-version -// gate has cleared, and a fire-and-forget dynamic -// import races process exit. Callers that need to -// know about failure should use gitOrThrow(). -// -// gitOrThrow(...) — strict. Throws on either spawn error (git not on -// PATH, EAGAIN, …) or non-zero exit. Used by gitLines -// and every security-gate caller in pre-commit / -// pre-push: if `git diff --cached --name-only` fails -// we MUST refuse to greenlight the commit, not pass -// it with "no files to check." -// -// gitLines goes through gitOrThrow because every call site we have -// (staged-file iteration, push-range walking, repo-toplevel lookup) -// makes a security or correctness decision based on the result; an -// empty array from a failed git invocation is a fail-open. - -export const git = (...args: string[]): string => { - const result = spawnSync('git', args, { encoding: 'utf8' }) - return (result.stdout ?? '').trim() -} - -export const gitOrThrow = (...args: string[]): string => { - const result = spawnSync('git', args, { encoding: 'utf8' }) - if (result.error) { - throw new Error(`git ${args.join(' ')}: ${result.error.message}`) - } - if (typeof result.status !== 'number' || result.status !== 0) { - const err = result.stderr?.trim() || `exit ${result.status}` - throw new Error(`git ${args.join(' ')}: ${err}`) - } - return (result.stdout ?? '').trim() -} - -export const gitLines = (...args: string[]): string[] => { - const out = gitOrThrow(...args) - return out ? splitLines(out) : [] -} - -// Staged-path prefixes/suffixes that mean an oxlint-plugin rule's WIRING could -// have changed: a rule file added/removed, the plugin index, or the oxlintrc -// activations. Both the dogfood root copies and the `template/` mirrors count. -const OXLINT_WIRING_PATH_RE = - /(?:^|\/)(?:template\/)?\.config\/oxlint-plugin\/rules\/[^/]+\.mts$|(?:^|\/)(?:template\/)?\.config\/oxlint-plugin\/index\.mts$|(?:^|\/)(?:template\/)?\.config\/oxlintrc\.json$|(?:^|\/)(?:template\/)?\.config\/oxlint-plugin\/test\/[^/]+\.test\.mts$/ - -// Path (relative to repo root) of the rule-wiring generator. Present only in -// the wheelhouse — downstream fleet repos don't carry it, so the gate no-ops -// there (they have no plugin rule files to wire). -const SYNC_OXLINT_RULES_REL = 'scripts/sync-oxlint-rules.mts' - -/** - * Commit-time gate for oxlint plugin rule WIRING. When a commit stages any file - * that can change rule wiring (a `rules/*.mts`, the plugin `index.mts`, the - * `oxlintrc.json` activations, or a rule `test`), run the generator in - * `--check` mode so a half-wired rule (file present but not imported / - * activated / tested) can't land — even on a direct commit with no PR. - * - * Returns the generator's diagnostic text when wiring is out of sync, or - * `undefined` when everything is in sync, no relevant file is staged, or the - * generator isn't present (downstream repo). Deliberately fail-closed only on a - * real drift signal: a generator that can't run (missing deps pre-install, - * spawn error) returns undefined so a fresh checkout isn't blocked. - * - * @param stagedFiles POSIX-normalized staged paths (from `git diff --cached`). - * @param repoRoot Absolute repo toplevel. - */ -export const checkOxlintRuleWiringStaged = ( - stagedFiles: readonly string[], - repoRoot: string, -): string | undefined => { - const touchesWiring = stagedFiles.some(f => OXLINT_WIRING_PATH_RE.test(f)) - if (!touchesWiring) { - return undefined - } - const generatorPath = `${repoRoot}/${SYNC_OXLINT_RULES_REL}` - if (!existsSync(generatorPath)) { - return undefined - } - const r = spawnSync(process.execPath, [generatorPath, '--check'], { - cwd: repoRoot, - encoding: 'utf8', - }) - // Spawn failure (missing deps, node error) — fail open so a pre-install - // checkout isn't blocked. Only a clean non-zero EXIT is a drift signal. - if (r.error || typeof r.status !== 'number') { - return undefined - } - if (r.status === 0) { - return undefined - } - return ( - (r.stderr ?? '').trim() || - (r.stdout ?? '').trim() || - 'sync-oxlint-rules --check reported drift.' - ) -} diff --git a/.git-hooks/_resolve-node.sh b/.git-hooks/_resolve-node.sh deleted file mode 100644 index 110c3ddb1..000000000 --- a/.git-hooks/_resolve-node.sh +++ /dev/null @@ -1,42 +0,0 @@ -# shellcheck shell=sh -# Resolve the repo-pinned Node onto PATH before a hook runs `node`. -# -# Git invokes hooks with the OS login shell's PATH, not the terminal's — -# so a GUI client (or a plain `git commit` outside an nvm-activated -# shell) runs whatever `node` the system ships, often older than the -# floor the hooks need (.mts type-stripping needs Node >= 25). This made -# pre-commit bail with "Hook requires Node >= 25.0.0". -# -# Sourced (not executed) by each hook shim. Reads the version from the -# repo's `.node-version`, finds the matching nvm install, and prepends -# its bin dir to PATH. No-op when: already on the pinned version, no -# `.node-version`, or no matching nvm install (then the hook's own -# version gate still fires with a clear message). - -# Locate repo root from the hook's own dir (.git-hooks/<shim>), walking -# up to the first dir that has a `.node-version`. -_rn_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) -while [ "$_rn_dir" != "/" ] && [ ! -f "$_rn_dir/.node-version" ]; do - _rn_dir=$(dirname "$_rn_dir") -done -_rn_file="$_rn_dir/.node-version" -[ -f "$_rn_file" ] || return 0 - -# Read + normalize the pinned version (strip a leading `v`). -_rn_want=$(tr -d ' \t\r\n' < "$_rn_file") -_rn_want=${_rn_want#v} -[ -n "$_rn_want" ] || return 0 - -# Already on it? Nothing to do. -_rn_have=$(node --version 2>/dev/null | sed 's/^v//') -[ "$_rn_have" = "$_rn_want" ] && return 0 - -# Prepend the matching nvm bin dir if it exists. -_rn_nvm="${NVM_DIR:-$HOME/.nvm}" -_rn_bin="$_rn_nvm/versions/node/v$_rn_want/bin" -if [ -x "$_rn_bin/node" ]; then - PATH="$_rn_bin:$PATH" - export PATH -fi - -unset _rn_dir _rn_file _rn_want _rn_have _rn_nvm _rn_bin diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg deleted file mode 100755 index 0a54658fe..000000000 --- a/.git-hooks/commit-msg +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -# Git commit-msg hook entry point. Invoked by git when core.hooksPath -# points at this directory (set by `node scripts/install-git-hooks.mts` -# at `pnpm install` time). Defers to the .mts implementation. - -# Put the repo-pinned Node (.node-version) on PATH — git runs hooks with -# the login shell's PATH, which may be an older system Node than the -# hooks' floor (.mts type-stripping needs Node >= 25). See -# _resolve-node.sh. -. "$(dirname "$0")/_resolve-node.sh" - -# Sanitize placeholder Socket API credentials so the .mts step's -# subprocesses (which may invoke pnpm via the sfw shim) don't 401. -for var in SOCKET_API_TOKEN SOCKET_API_KEY; do - eval "val=\${$var}" - if [ -n "$val" ] && ! printf '%s' "$val" | grep -q '^sktsec_'; then - unset "$var" - fi -done - -exec node "$(dirname "$0")/commit-msg.mts" "$@" diff --git a/.git-hooks/commit-msg.mts b/.git-hooks/commit-msg.mts deleted file mode 100644 index 11cbef2c5..000000000 --- a/.git-hooks/commit-msg.mts +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env node -// Socket Security Commit-msg Hook -// -// Two responsibilities: -// 1. Block commits that introduce API keys / .env files (security -// layer that runs even when pre-commit is bypassed via -// `--no-verify`). -// 2. Auto-strip AI attribution lines from the commit message before -// git records the commit. -// -// Wired via .git-hooks/commit-msg (the sibling shell shim), which git -// invokes when `core.hooksPath` points at .git-hooks/ — set by -// `node scripts/install-git-hooks.mts` at `pnpm install` time. The -// shim execs this .mts file with the path to the commit message file -// as argv[2] (after the script path itself). - -import { existsSync, readFileSync, writeFileSync } from 'node:fs' - -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - gitLines, - readFileForScan, - scanGitHubTokens, - scanLinearRefs, - scanSocketApiKeys, - shouldSkipFile, - stripAiAttribution, -} from './_helpers.mts' - -const logger = getDefaultLogger() - -const main = (): number => { - let errors = 0 - const committedFiles = gitLines( - 'diff', - '--cached', - '--name-only', - '--diff-filter=ACM', - ) - - for (const file of committedFiles) { - if (!file || shouldSkipFile(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - - // Socket API keys (allowlist-aware). - const apiHits = scanSocketApiKeys(text) - if (apiHits.length > 0) { - logger.fail('Potential API key detected in commit!') - logger.info(`File: ${file}`) - errors++ - } - - // .env files at any depth — allow only .env.example, .env.test, - // .env.precommit (templates / tracked placeholders). - const base = path.basename(file) - if ( - /^\.env(\.[^/]+)?$/.test(base) && - !/^\.env\.(example|precommit|test)$/.test(base) - ) { - logger.fail('.env file in commit!') - logger.info(`File: ${file}`) - errors++ - } - } - - // Block Linear issue references in the commit message. Linear - // tracking lives in Linear; commit history stays tool-agnostic. The - // canonical CLAUDE.md "public-surface hygiene" block documents the - // policy; this hook makes it mechanical so a typo in a hot rebase - // can't slip through. - const commitMsgFile = process.argv[2] - if (commitMsgFile && existsSync(commitMsgFile)) { - const original = readFileSync(commitMsgFile, 'utf8') - const linearHits = scanLinearRefs(original) - if (linearHits.length > 0) { - logger.fail('Commit message references Linear issue(s):') - for (const ref of linearHits) { - logger.info(` ${ref}`) - } - logger.info( - 'Linear tracking lives in Linear. Remove the reference from the commit message.', - ) - errors++ - } - - // GitHub tokens in the commit message body. Pasting a `ghs_*` / - // `ghp_*` / `ghu_*` token into a commit message is exactly the - // leak vector commit-msg should block (the body lands in the - // remote repo's commit-log permanently — can't be unpushed). The - // scanGitHubTokens regex covers both the classic opaque format - // and the new JWT format from the 2026-05-15 GitHub rollout. - const ghHits = scanGitHubTokens(original) - if (ghHits.length > 0) { - logger.fail('Commit message contains a potential GitHub token:') - for (const hit of ghHits.slice(0, 3)) { - logger.info(` line ${hit.lineNumber}: ${hit.line.trim()}`) - } - logger.info( - 'Remove the token from the commit message. If this is intentional documentation of a token-shape pattern, paste the value into a test fixture instead, not the commit message.', - ) - errors++ - } - - // Auto-strip AI attribution lines from the commit message. - const { cleaned, removed } = stripAiAttribution(original) - if (removed > 0) { - writeFileSync(commitMsgFile, cleaned) - logger.success( - `Auto-stripped ${removed} AI attribution line(s) from commit message`, - ) - } - } - - if (errors > 0) { - logger.fail('Commit blocked by security validation') - return 1 - } - return 0 -} - -process.exit(main()) diff --git a/.git-hooks/pre-commit b/.git-hooks/pre-commit deleted file mode 100755 index b341662d4..000000000 --- a/.git-hooks/pre-commit +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/sh -# Git pre-commit hook entry point. Invoked by git when core.hooksPath -# points at this directory (set by `node scripts/install-git-hooks.mts` -# at `pnpm install` time). -# -# Optional checks — can be bypassed with --no-verify for fast local -# commits. Mandatory security checks ALSO run in pre-push hook. -# -# Use --no-verify for: -# - History operations (squash, rebase, amend) -# - Emergency hotfixes -# - When tests require binaries that haven't been built yet -# -# Use environment variables to selectively disable: -# - DISABLE_PRECOMMIT_LINT=1 to skip linting -# - DISABLE_PRECOMMIT_TEST=1 to skip testing - -# Put the repo-pinned Node (.node-version) on PATH — git runs hooks with -# the login shell's PATH, which may be an older system Node than the -# hooks' floor (.mts type-stripping needs Node >= 25). See -# _resolve-node.sh. -. "$(dirname "$0")/_resolve-node.sh" - -# Sanitize placeholder Socket API credentials. Some shell setups -# export `SOCKET_API_TOKEN=literal-value` (or similar placeholders -# used in onboarding docs) which causes Socket Firewall's sfw -# pnpm-shim to return 401 on every invocation and block the -# pre-commit chain before any check runs. A real Socket API key -# is a `sktsec_…` token; anything that doesn't start with `sktsec_` -# is treated as a placeholder and unset for this hook's subprocess. -for var in SOCKET_API_TOKEN SOCKET_API_KEY; do - eval "val=\${$var}" - if [ -n "$val" ] && ! printf '%s' "$val" | grep -q '^sktsec_'; then - echo "[pre-commit] unsetting placeholder $var (was: '$val') so pnpm/sfw doesn't 401." - unset "$var" - fi -done - -# Run Socket security pre-commit checks (API keys, .DS_Store, etc.). -node "$(dirname "$0")/pre-commit.mts" - -# Check if pnpm is available. -if ! command -v pnpm >/dev/null 2>&1; then - echo "Error: pnpm not found. Install pnpm to run git hooks." - echo "Visit: https://pnpm.io/installation" - exit 1 -fi - -# Error-visibility helper. When lint/test fails, harness output often -# shows only a final "Failed with non-blocking status code" line — the -# actual error is buried thousands of lines up the log and gets clipped -# by the agent's stdout limits. Tee each step's output to a tempfile, -# tail it on failure with a clear marker so the operator (or agent) -# can see what broke without scrolling. -run_step() { - step_name=$1 - shift - step_log=$(mktemp -t "pre-commit-${step_name}.XXXXXX") || step_log=/tmp/pre-commit-step.log - if "$@" 2>&1 | tee "$step_log"; then - status=0 - else - status=$? - fi - if [ "$status" -ne 0 ]; then - printf '\n========== pre-commit: %s FAILED (exit %s) ==========\n' "$step_name" "$status" - printf 'Last 60 lines of output:\n\n' - tail -60 "$step_log" - printf '\n========== full log: %s ==========\n' "$step_log" - else - rm -f "$step_log" - fi - return "$status" -} - -if [ -z "${DISABLE_PRECOMMIT_LINT}" ]; then - run_step lint pnpm lint --staged || exit $? -else - echo "Skipping lint due to DISABLE_PRECOMMIT_LINT env var" -fi - -if [ -z "${DISABLE_PRECOMMIT_TEST}" ]; then - # Each repo's `pnpm test` script wraps a runner that understands - # `--staged` (e.g. scripts/test.mts forwards staged-filtering to - # vitest, or filters the staged set in a pre-pass). When - # DISABLE_PRECOMMIT_LINT is set, also pass --fast so the test - # runner skips its embedded format/lint check (otherwise lint - # bypass leaks through this path and re-blocks the commit). - # - # Repos whose `pnpm test` is bare vitest without a wrapper need a - # local override that pre-filters with `git diff --cached --name-only` - # then runs `pnpm test`. - if [ -n "${DISABLE_PRECOMMIT_LINT}" ]; then - run_step test pnpm test --staged --fast || exit $? - else - run_step test pnpm test --staged || exit $? - fi -else - echo "Skipping testing due to DISABLE_PRECOMMIT_TEST env var" -fi diff --git a/.git-hooks/pre-commit.mts b/.git-hooks/pre-commit.mts deleted file mode 100644 index 84261899d..000000000 --- a/.git-hooks/pre-commit.mts +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env node -// Socket Security Pre-commit Hook -// -// Local-defense layer: scans staged files for sensitive content -// before git records the commit. Mandatory enforcement re-runs in -// pre-push for the final gate. -// -// Bypassable: --no-verify skips this hook entirely. Use sparingly -// (hotfixes, history operations, pre-build states). - -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - checkOxlintRuleWiringStaged, - git, - gitLines, - normalizePath, - readFileForScan, - scanAwsKeys, - scanCrossRepoPaths, - scanDocsPnpmFirst, - scanGitHubTokens, - scanLoggerLeaks, - scanNpxDlx, - scanPackageJsonPnpmOverrides, - scanPersonalPaths, - scanPrivateKeys, - scanSocketApiKeys, - shouldSkipFile, - socketHookMarkerFor, -} from './_helpers.mts' - -const logger = getDefaultLogger() - -const main = (): number => { - logger.info('Running Socket Security checks…') - // Normalize to POSIX forward slashes so downstream - // `startsWith('.git-hooks/')` / `includes('/external/')` matchers - // work the same on Windows (where git can return `\` separators). - const stagedFiles = gitLines( - 'diff', - '--cached', - '--name-only', - '--diff-filter=ACM', - ).map(normalizePath) - if (stagedFiles.length === 0) { - logger.success('No files to check') - return 0 - } - - let errors = 0 - - // Commit signing config gate. The commit hasn't been created yet, - // so we can't verify the signature artifact — only the config that - // determines whether the commit WILL be signed. Two requirements: - // - `commit.gpgsign` must be `true` - // - `user.signingkey` must be set - // If either is missing, refuse the commit. Pre-push catches the - // artifact side (unsigned commits that somehow slipped past); this - // gate is the local-config side. - // - // Bypass: SOCKET_PRE_COMMIT_ALLOW_UNSIGNED=1. One-shot env var, - // mirrors the pre-push bypass shape (SOCKET_PRE_PUSH_ALLOW_UNSIGNED). - if (!process.env['SOCKET_PRE_COMMIT_ALLOW_UNSIGNED']) { - const gpgsign = git('config', '--get', 'commit.gpgsign').toLowerCase() - const signingKey = git('config', '--get', 'user.signingkey') - if (gpgsign !== 'true') { - logger.fail('commit.gpgsign is not enabled') - logger.info(` current: ${gpgsign || '(unset)'}`) - logger.info(' expected: true') - logger.info('') - logger.info('Fix:') - logger.info(' git config --global commit.gpgsign true') - logger.info('') - logger.info('If you have not set up commit signing yet, run:') - logger.info(' node .claude/hooks/setup-security-tools/install.mts') - logger.info( - 'which detects available signing methods (GPG, SSH, 1Password)', - ) - logger.info('and walks you through the one-time setup.') - errors++ - } else if (!signingKey) { - logger.fail('commit.gpgsign=true but user.signingkey is not set') - logger.info('') - logger.info('Fix:') - logger.info(' git config --global user.signingkey <YOUR_KEY_ID>') - logger.info('') - logger.info('Or run the setup helper for guided configuration:') - logger.info(' node .claude/hooks/setup-security-tools/install.mts') - errors++ - } - if (errors > 0) { - logger.info('') - logger.info( - 'Bypass (exceptional only): SOCKET_PRE_COMMIT_ALLOW_UNSIGNED=1 git commit ...', - ) - logger.info('One-shot; never persist in shell rc.') - logger.error('') - logger.fail(`Pre-commit signing config check failed.`) - return 1 - } - } - - // .DS_Store files. - logger.info('Checking for .DS_Store files…') - const dsStores = stagedFiles.filter(f => f.includes('.DS_Store')) - if (dsStores.length > 0) { - logger.fail('.DS_Store file detected!') - dsStores.forEach(f => logger.info(f)) - errors++ - } - - // Log files (ignore test logs). - logger.info('Checking for log files…') - const logs = stagedFiles.filter( - f => f.endsWith('.log') && !/test.*\.log$/.test(f), - ) - if (logs.length > 0) { - logger.fail('Log file detected!') - logs.forEach(f => logger.info(f)) - errors++ - } - - // .env files at any depth — allow only .env.example, .env.test, - // .env.precommit (templates / tracked placeholders). Match the - // commit-msg.mts behavior: a nested .env.local is just as much a - // leak as a root-level one. basename() catches both. - logger.info('Checking for .env files…') - const envFiles = stagedFiles.filter(f => { - const base = path.basename(f) - return ( - /^\.env(?:\.[^/]+)?$/.test(base) && - !/^\.env\.(?:example|test|precommit)$/.test(base) - ) - }) - if (envFiles.length > 0) { - logger.fail('.env file detected!') - envFiles.forEach(f => logger.info(f)) - logger.info( - 'These files should never be committed. Use .env.example for templates.', - ) - errors++ - } - - // Hardcoded personal paths. - logger.info('Checking for hardcoded personal paths…') - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanPersonalPaths(text) - if (hits.length > 0) { - logger.fail(`Hardcoded personal path found in: ${file}`) - for (const h of hits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - 'Replace with the canonical placeholder for the path platform: ' + - '`/Users/<user>/...` (macOS), `/home/<user>/...` (Linux), or ' + - '`C:\\Users\\<USERNAME>\\...` (Windows). Env vars also work ' + - '(`$HOME`, `${USER}`). For documentation lines that need the ' + - `literal form, append the marker \`${socketHookMarkerFor(file, 'personal-path')}\`.`, - ) - errors++ - } - } - - // Socket API keys (warning, not blocking). - logger.info('Checking for API keys…') - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanSocketApiKeys(text) - if (hits.length > 0) { - logger.warn(`Potential API key found in: ${file}`) - hits - .slice(0, 3) - .forEach(h => logger.info(`${h.lineNumber}:${h.line.trim()}`)) - logger.info('If this is a real API key, DO NOT COMMIT IT.') - } - } - - // Other secret patterns (AWS, GitHub, private keys). - logger.info('Checking for potential secrets…') - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - - const aws = scanAwsKeys(text) - if (aws.length > 0) { - logger.fail(`Potential AWS credentials found in: ${file}`) - aws - .slice(0, 3) - .forEach(h => logger.info(`${h.lineNumber}:${h.line.trim()}`)) - errors++ - } - - const gh = scanGitHubTokens(text) - if (gh.length > 0) { - logger.fail(`Potential GitHub token found in: ${file}`) - gh.slice(0, 3).forEach(h => - logger.info(`${h.lineNumber}:${h.line.trim()}`), - ) - errors++ - } - - const pk = scanPrivateKeys(text) - if (pk.length > 0) { - logger.fail(`Private key found in: ${file}`) - errors++ - } - } - - // package.json pnpm.overrides — overrides belong in - // pnpm-workspace.yaml overrides:, not package.json. - logger.info('Checking for package.json pnpm.overrides...') - for (const file of stagedFiles) { - if (path.basename(file) !== 'package.json' || shouldSkipFile(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const ov = scanPackageJsonPnpmOverrides(text) - if (ov.length > 0) { - logger.fail(`pnpm.overrides found in: ${file}`) - logger.info(`${ov[0]!.lineNumber}:${ov[0]!.line}`) - logger.info( - 'Move dependency overrides to pnpm-workspace.yaml `overrides:`.', - ) - errors++ - } - } - - // npx/dlx usage. - logger.info('Checking for npx/dlx usage…') - for (const file of stagedFiles) { - // shouldSkipFile covers tests, fixtures, .git-hooks, etc. — test - // files frequently mention `npx` as part of fixture paths or - // resolution-logic test cases (see socket-lib/test/unit/bin.test.mts). - if (shouldSkipFile(file)) { - continue - } - if ( - file.endsWith('pnpm-lock.yaml') || - // CHANGELOG entries discuss npx ecosystem *behavior* (cache - // semantics, naming conventions) as historical documentation — - // they're not commands. Skip the npx/dlx scan for changelogs. - file === 'CHANGELOG.md' || - file.endsWith('/CHANGELOG.md') - ) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanNpxDlx(text) - if (hits.length > 0) { - logger.fail(`npx/dlx usage found in: ${file}`) - for (const h of hits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - "Use 'pnpm exec <package>' or 'pnpm run <script>' instead. For " + - 'documentation lines that need the literal `npx` form, append ' + - `the marker \`${socketHookMarkerFor(file, 'npx')}\`.`, - ) - errors++ - } - } - - // Documentation pnpm-first scanner (warning, not blocking). - // - // Fleet rule: user-facing install commands in docs lead with the - // pnpm form. npm/yarn fallbacks come after. Block-only — inline - // backtick spans are not scanned. Suppress per-block with - // `socket-hook: allow pnpm-first`. - logger.info('Checking docs lead with pnpm install commands…') - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - if (!/\.(?:md|mdx)$/i.test(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanDocsPnpmFirst(text) - if (hits.length > 0) { - logger.warn(`docs without pnpm-first install command: ${file}`) - for (const h of hits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - 'Lead with the pnpm form; keep npm/yarn as fallbacks. To ' + - 'suppress a fenced block, include `socket-hook: allow ' + - 'pnpm-first` anywhere in the block.', - ) - } - } - - // Direct stream writes (process.stderr.write, process.stdout.write, - // console.*) in source files. Source code uses getDefaultLogger() - // from @socketsecurity/lib-stable/logger/default; the logger-guard PreToolUse hook - // catches these at edit time, this gate catches them at commit time - // for edits made outside Claude. - logger.info('Checking for direct stream writes…') - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - // Apply the same exempt set as the logger-guard hook so the rule - // is consistent: hooks, git-hooks, scripts, vendored / external - // sources are allowed. The shouldSkipFile helper covers tests and - // fixtures already. - if ( - file.startsWith('.claude/hooks/') || - file.startsWith('.git-hooks/') || - file.startsWith('scripts/') || - // template/ is the canonical source for code that cascades to - // .claude/hooks/, .git-hooks/, and scripts/. Apply the same - // exemption at the source. - file.startsWith('template/.claude/hooks/') || - file.startsWith('template/.git-hooks/') || - file.startsWith('template/scripts/') || - file.includes('/external/') || - file.includes('/vendor/') || - file.includes('/upstream/') || - // src/logger/ IS the logger — implementing the surface itself - // requires direct console.* calls. Same exemption the - // logger-guard PreToolUse hook applies. - file.startsWith('src/logger/') - ) { - continue - } - if (!/\.(?:m?ts|tsx|cts)$/.test(file)) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanLoggerLeaks(text) - if (hits.length > 0) { - logger.fail(`direct stream write found in: ${file}`) - for (const h of hits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - 'Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default`. ' + - 'For documentation lines that need the literal call, append ' + - `the marker \`${socketHookMarkerFor(file, 'logger')}\`.`, - ) - errors++ - } - } - - // Cross-repo path references — `../<fleet-repo>/…` (relative escape - // out of the current repo) or `…/projects/<fleet-repo>/…` (absolute - // sibling-clone escape). Both forms hardcode someone's local layout - // and break in CI / fresh clones / non-standard checkouts. - logger.info('Checking for cross-repo path references…') - // Best-effort current repo name from the toplevel directory; if git - // isn't reachable we simply don't suppress own-repo matches. - const repoTopline = gitLines('rev-parse', '--show-toplevel')[0] ?? '' - const currentRepoName = repoTopline ? path.basename(repoTopline) : undefined - for (const file of stagedFiles) { - if (shouldSkipFile(file)) { - continue - } - // Don't scan the hook source itself (it lists fleet repo names by - // necessity), markdown docs (which legitimately show cross-repo - // command examples like `--target ../socket-lib`), or vendored - // upstream sources. - if ( - file.startsWith('.git-hooks/') || - file.startsWith('.claude/hooks/') || - file.endsWith('.md') || - file.includes('/external/') || - file.includes('/vendor/') || - file.includes('/upstream/') || - file === 'pnpm-lock.yaml' || - file === 'pnpm-workspace.yaml' - ) { - continue - } - const text = readFileForScan(file) - if (!text) { - continue - } - const hits = scanCrossRepoPaths(text, currentRepoName) - if (hits.length > 0) { - logger.fail(`cross-repo path reference found in: ${file}`) - for (const h of hits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - } - logger.info( - 'Cross-repo paths (`../<fleet-repo>/…` or absolute `…/projects/<fleet-repo>/…`) ' + - 'are forbidden — they assume sibling-clone layout and break in CI / fresh clones. ' + - 'Import via the published npm package instead (`@socketsecurity/lib-stable/<subpath>`, ' + - `\`@socketsecurity/registry-stable/<subpath>\`). For documentation lines that need the ` + - `literal path, append the marker \`${socketHookMarkerFor(file, 'cross-repo')}\`.`, - ) - errors++ - } - } - - // oxlint plugin rule WIRING gate. When a rule file / plugin index / - // oxlintrc activation / rule test is staged, confirm the wiring triad - // (rule file → import+registry → activation → test) is complete. A - // half-wired rule sits silently dormant fleet-wide; this catches it at - // commit time, not just in a PR (many commits land without one). No-ops - // unless a wiring-relevant file is staged + the generator is present - // (so it only runs in the wheelhouse, where the rule files live). - logger.info('Checking oxlint plugin rule wiring…') - const wiringRoot = repoTopline || process.cwd() - const wiringDrift = checkOxlintRuleWiringStaged(stagedFiles, wiringRoot) - if (wiringDrift) { - logger.fail('oxlint plugin rule wiring is out of sync.') - for (const line of wiringDrift.split('\n').slice(0, 8)) { - logger.info(line) - } - logger.info( - 'Run `pnpm run sync-oxlint-rules` to regenerate the import/registry + ' + - 'oxlintrc activations. A missing `test/<rule>.test.mts` must be ' + - 'hand-written (the rule + registration + test triad must be complete).', - ) - errors++ - } - - if (errors > 0) { - logger.error('') - logger.fail(`Security check failed with ${errors} error(s).`) - logger.error('Fix the issues above and try again.') - return 1 - } - - logger.success('All security checks passed!') - return 0 -} - -process.exit(main()) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push deleted file mode 100755 index fa14d72f3..000000000 --- a/.git-hooks/pre-push +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# Git pre-push hook entry point. Invoked by git when core.hooksPath -# points at this directory (set by `node scripts/install-git-hooks.mts` -# at `pnpm install` time). Defers to the .mts implementation. - -# Put the repo-pinned Node (.node-version) on PATH — git runs hooks with -# the login shell's PATH, which may be an older system Node than the -# hooks' floor (.mts type-stripping needs Node >= 25). See -# _resolve-node.sh. -. "$(dirname "$0")/_resolve-node.sh" - -# Same placeholder-Socket-token sanitization as pre-commit. A -# `SOCKET_API_TOKEN=literal-value` placeholder in the user's -# environment causes sfw to 401 on the pnpm shim before this hook -# can run its own checks; unset any value that doesn't look like a -# real `sktsec_…` token. -for var in SOCKET_API_TOKEN SOCKET_API_KEY; do - eval "val=\${$var}" - if [ -n "$val" ] && ! printf '%s' "$val" | grep -q '^sktsec_'; then - echo "[pre-push] unsetting placeholder $var (was: '$val') so pnpm/sfw doesn't 401." - unset "$var" - fi -done - -exec node "$(dirname "$0")/pre-push.mts" "$@" diff --git a/.git-hooks/pre-push.mts b/.git-hooks/pre-push.mts deleted file mode 100644 index cc7d7b285..000000000 --- a/.git-hooks/pre-push.mts +++ /dev/null @@ -1,638 +0,0 @@ -#!/usr/bin/env node -// Socket Security Pre-push Hook -// -// Mandatory enforcement layer for all pushes. Validates commits -// being pushed for AI attribution, secrets, and personal-path leaks. -// -// Architecture: -// .git-hooks/pre-push (shell shim, invoked by git when -// `core.hooksPath = .git-hooks`) → node .git-hooks/pre-push.mts -// -// Range logic: -// New branch: remote/<default_branch>..<local_sha> (only new commits) -// Existing: <remote_sha>..<local_sha> (only new commits) -// We never use release tags — that would re-scan already-merged history. -// -// Stdin format (provided by git): one push line per ref, each line: -// <local_ref> <local_sha> <remote_ref> <remote_sha> - -import { existsSync, readFileSync, statSync } from 'node:fs' - -import path from 'node:path' -import process from 'node:process' - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - containsAiAttribution, - git, - gitLines, - normalizePath, - readFileForScan, - scanAwsKeys, - scanCrossRepoPaths, - scanGitHubTokens, - scanLoggerLeaks, - scanPersonalPaths, - scanPrivateKeys, - scanSocketApiKeys, - shouldSkipFile, - socketHookMarkerFor, - splitLines, -} from './_helpers.mts' - -const logger = getDefaultLogger() - -const ZERO_SHA = '0000000000000000000000000000000000000000' - -const readStdin = (): Promise<string> => - new Promise(resolve => { - let buf = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { - buf += chunk - }) - process.stdin.on('end', () => resolve(buf)) - }) - -// Submodule pristine check — refuses push if any submodule has a -// drifted commit pointer or unresolved merge conflict. -const checkSubmodules = (): number => { - if (!existsSync('.gitmodules')) { - return 0 - } - logger.info('Checking submodules are pristine…') - let errors = 0 - const status = gitLines('submodule', 'status') - for (const line of status) { - if (!line) { - continue - } - const prefix = line[0] - const rest = line.slice(1).trim().split(/\s+/) - const smPath = rest[1] || '<unknown>' - if (prefix === '+') { - logger.fail(`Submodule has wrong commit: ${smPath}`) - logger.info(` Run: git submodule update --init ${smPath}`) - errors++ - } else if (prefix === 'U') { - logger.fail(`Submodule has merge conflict: ${smPath}`) - errors++ - } - // '-' (uninitialized) is OK — CI shallow clones skip submodules. - } - if (errors > 0) { - logger.error('') - logger.fail(`Push blocked: ${errors} submodule(s) not pristine!`) - logger.error('Fix submodules before pushing.') - return errors - } - logger.success('All submodules pristine') - return 0 -} - -// Computes the commit range to scan. Returns null if no scan needed -// (skip case — tag, delete, or no baseline). -const computeRange = ( - remote: string, - localRef: string, - localSha: string, - remoteSha: string, -): string | undefined => { - if (localRef.startsWith('refs/tags/')) { - logger.info(`Skipping tag push: ${localRef}`) - return undefined - } - if (localSha === ZERO_SHA) { - return undefined - } - - const refExists = (ref: string): boolean => { - const r = spawnSync('git', ['rev-parse', ref]) - return r.status === 0 - } - - const defaultBranchOf = (remoteName: string): string => { - const sym = git('symbolic-ref', `refs/remotes/${remoteName}/HEAD`).trim() - if (sym) { - return sym.replace(`refs/remotes/${remoteName}/`, '') - } - // symbolic-ref unset (rare — happens with shallow clones, partial - // fetches, freshly-init'd remotes). Try main → master → 'main' - // per CLAUDE.md default-branch resolution. Reversing the order - // would mispick during rename migrations. - if (refExists(`${remoteName}/main`)) { - return 'main' - } - if (refExists(`${remoteName}/master`)) { - return 'master' - } - return 'main' - } - - // git cat-file -e exits 0 silently on success; spawnSync directly - // so we can inspect status without printing. - const remoteShaExists = (sha: string): boolean => { - const result = spawnSync('git', ['cat-file', '-e', sha]) - return result.status === 0 - } - - if (remoteSha === ZERO_SHA) { - // New branch — compare against remote default branch. - const def = defaultBranchOf(remote) - const baseRef = `${remote}/${def}` - if (!refExists(baseRef)) { - logger.success('Skipping validation (no baseline to compare against)') - return undefined - } - return `${baseRef}..${localSha}` - } - - // Existing branch. - if (!remoteShaExists(remoteSha)) { - // Force-push or history rewrite — fall back to default branch. - const def = defaultBranchOf(remote) - const baseRef = `${remote}/${def}` - if (!refExists(baseRef)) { - logger.success('Skipping validation (no baseline for force-push)') - return undefined - } - return `${baseRef}..${localSha}` - } - return `${remoteSha}..${localSha}` -} - -// Scans every commit in the range to require a verified signature -// when pushing to a protected ref (default branch). Block on `N` -// (no signature) and `B` (bad/unverifiable) — but allow other -// markers like `G` (good GPG sig), `U` (good GPG sig, unknown trust), -// `E` (missing-key but otherwise valid), `X` (good signature on -// expired key), `Y`/`R` (revoked/expired key with good signature). -// -// Bypass (exceptional only): prefix the push command with the -// SOCKET_PRE_PUSH_ALLOW_UNSIGNED env var: -// SOCKET_PRE_PUSH_ALLOW_UNSIGNED=1 git push origin main -// One-shot — do not persist in shell rc. -// -// Why pre-push and not just rely on GitHub branch protection? The -// fleet enforces branch protection too (lint-github-settings.mts -// audits `required_signatures: true`), but a local pre-push fail -// gives faster feedback (no round-trip to GitHub) and catches the -// case where branch protection is being set up but not yet active -// on a freshly-created fleet repo. -const SIGNED_PUSH_BYPASS_ENV = 'SOCKET_PRE_PUSH_ALLOW_UNSIGNED' - -const readSignedPushBypassActive = (): boolean => { - // Pre-push runs in git's own context — no Claude Code transcript - // path is available. The bypass is an explicit env var the user - // sets on the failing push: `SOCKET_PRE_PUSH_ALLOW_UNSIGNED=1 git - // push origin main`. One-shot semantics: env var is not persisted. - return Boolean(process.env[SIGNED_PUSH_BYPASS_ENV]) -} - -// Parse the SSH allowed_signers file referenced by -// `git config --get gpg.ssh.allowedSignersFile`. Returns the set of -// public-key BLOBS (the same format `git log --format=%GK` emits for -// SSH-signed commits — `<key-type> <base64-key>`). -// -// Returns an empty set if: -// - gpg.format isn't 'ssh' (allowed-signers only applies to SSH-format) -// - gpg.ssh.allowedSignersFile is unset -// - the file doesn't exist or can't be read -// An empty set means "don't enforce" — the %G? marker check alone -// remains active. This degrades gracefully on first install before -// the user has set up allowed_signers. -const readAllowedSignerKeys = (): Set<string> => { - const out = new Set<string>() - try { - const fmt = git('config', '--get', 'gpg.format').trim() - if (fmt !== 'ssh') { - return out - } - const file = git('config', '--get', 'gpg.ssh.allowedSignersFile').trim() - if (!file) { - return out - } - const expanded = file.startsWith('~') - ? file.replace(/^~/, process.env['HOME'] ?? '') - : file - if (!existsSync(expanded)) { - return out - } - // allowed_signers file format: `<principal> [<options>] <key-type> <base64-key>` - // %GK emits `<key-type> <base64-key>` (no principal). We extract - // the last two whitespace-separated tokens of each line. - const text = readFileSync(expanded, 'utf8') - for (const rawLine of text.split('\n')) { - const line = rawLine.trim() - if (!line || line.startsWith('#')) { - continue - } - const tokens = line.split(/\s+/) - if (tokens.length < 3) { - continue - } - const keyType = tokens[tokens.length - 2]! - const keyBlob = tokens[tokens.length - 1]! - out.add(`${keyType} ${keyBlob}`) - } - } catch { - // best-effort; absence of allowed-signers shouldn't crash the hook - } - return out -} - -const scanSignedCommits = ( - range: string, - remoteRef: string, - bypassActive: boolean, -): number => { - // Only enforce on default-branch refs (main / master). Feature - // branches and topic branches can stay unsigned during development; - // signing is required at the point of landing on the protected ref. - const refBase = remoteRef.replace(/^refs\/heads\//, '') - if (refBase !== 'main' && refBase !== 'master') { - return 0 - } - logger.info('Checking commit signatures…') - // %G? — signature verification marker (G/U/E/X/Y/R/N/B). - // %GK — signing key fingerprint (empty if unsigned). - // %GS — signer name (from key user-id). - // Cross-check %GK against gpg.ssh.allowedSignersFile when configured - // and `gpg.format = ssh`. For gpg-format signatures, %G? alone - // reflects the local keyring's trust, which is sufficient for our - // threat model (the attacker would need to control the dev's - // ~/.gnupg, at which point the local box is fully owned). - const lines = gitLines('log', '--format=%H %G? %GK', range) - const allowedSigners = readAllowedSignerKeys() - let errors = 0 - const unsigned: string[] = [] - const unauthorized: string[] = [] - for (const line of lines) { - const parts = line.split(' ') - const sha = parts[0] - const marker = parts[1] - const signerKey = parts.slice(2).join(' ').trim() - if (!sha || !marker) { - continue - } - // `N` = no signature. `B` = bad signature. Both block. - if (marker === 'B' || marker === 'N') { - unsigned.push(sha) - errors++ - continue - } - // Allowed-signers cross-check (SSH-signed commits only). `G` - // means git verified the signature against SOME key it trusts — - // but "any trusted key" includes attacker-controlled keys on a - // compromised dev machine. The authorized-signer file pins down - // which keys we accept for the protected branch. - if ( - allowedSigners.size > 0 && - signerKey && - !allowedSigners.has(signerKey) - ) { - unauthorized.push(`${sha} (signed by ${signerKey.slice(0, 16)}…)`) - errors++ - } - } - if (unauthorized.length > 0) { - logger.error( - `${unauthorized.length} commit(s) signed by a key NOT in gpg.ssh.allowedSignersFile:`, - ) - for (let i = 0, { length } = unauthorized; i < length; i += 1) { - const u = unauthorized[i]! - logger.error(` ${u}`) - } - } - if (errors === 0) { - return 0 - } - if (bypassActive) { - logger.warn( - `${errors} unsigned commit(s) being pushed to ${refBase} — allowed by bypass phrase.`, - ) - return 0 - } - logger.fail(`${errors} unsigned commit(s) being pushed to ${refBase}.`) - for (const sha of unsigned.slice(0, 5)) { - const oneline = git('log', '-1', '--oneline', sha) - logger.info(` - ${oneline}`) - } - if (unsigned.length > 5) { - logger.info(` ... and ${unsigned.length - 5} more`) - } - logger.info('') - logger.info('Fix: rebase + re-sign the commits.') - logger.info(` git rebase --exec 'git commit --amend --no-edit -S' <base>`) - logger.info('') - logger.info( - 'Bypass (exceptional only): prefix the push with ' + - `\`${SIGNED_PUSH_BYPASS_ENV}=1\`. One-shot; do not persist in shell rc.`, - ) - return errors -} - -// Scans every commit in the range for AI attribution in commit -// messages. -const scanCommitMessages = (range: string): number => { - logger.info('Checking commit messages for AI attribution…') - const shas = gitLines('rev-list', range) - let errors = 0 - for (const sha of shas) { - if (!sha) { - continue - } - const msg = git('log', '-1', '--format=%B', sha) - if (containsAiAttribution(msg)) { - if (errors === 0) { - logger.fail('AI attribution found in commit messages!') - logger.info('Commits with AI attribution:') - } - const oneline = git('log', '-1', '--oneline', sha) - logger.info(` - ${oneline}`) - errors++ - } - } - if (errors > 0) { - logger.info('') - logger.info( - 'These commits were likely created with --no-verify, bypassing the', - ) - logger.info('commit-msg hook that strips AI attribution.') - logger.info('') - const rangeBase = range.split('..')[0] - logger.info('To fix:') - logger.info(` git rebase -i ${rangeBase}`) - logger.info(" Mark commits as 'reword', remove AI attribution, save") - logger.info(' git push') - } - return errors -} - -// Scans changed files in the range for secrets, keys, and leaks. -const scanFilesInRange = (range: string): number => { - logger.info('Checking files for security issues…') - // Normalize to POSIX forward slashes — same reason as pre-commit.mts. - const changed = gitLines('diff', '--name-only', range).map(normalizePath) - let errors = 0 - if (changed.length === 0) { - return 0 - } - // Best-effort current repo name — used by cross-repo scanner to - // avoid flagging a repo's own paths. - const repoTopline = gitLines('rev-parse', '--show-toplevel')[0] ?? '' - const currentRepoName = repoTopline ? path.basename(repoTopline) : undefined - - // .env files at any depth — match commit-msg.mts and pre-commit.mts. - // Allow .env.example, .env.test, .env.precommit (templates / tracked - // placeholders); block bare .env / .env.local / .env.production / - // anything else regardless of directory depth. - const envHits = changed.filter(f => { - const base = path.basename(f) - return ( - /^\.env(\.[^/]+)?$/.test(base) && - !/^\.env\.(example|precommit|test)$/.test(base) - ) - }) - if (envHits.length > 0) { - logger.fail('Attempting to push .env file!') - logger.info(`Files: ${envHits.join(', ')}`) - errors += envHits.length - } - const dsHits = changed.filter(f => f.includes('.DS_Store')) - if (dsHits.length > 0) { - logger.fail('.DS_Store file in push!') - logger.info(`Files: ${dsHits.join(', ')}`) - errors += dsHits.length - } - const logHits = changed.filter( - f => f.endsWith('.log') && !/test.*\.log$/.test(f), - ) - if (logHits.length > 0) { - logger.fail('Log file in push!') - logger.info(`Files: ${logHits.join(', ')}`) - errors += logHits.length - } - - // Per-file content scans. - for (const file of changed) { - if (!file || !existsSync(file)) { - continue - } - try { - if (statSync(file).isDirectory()) { - continue - } - } catch { - continue - } - if (shouldSkipFile(file)) { - continue - } - // Tracked-only — skip files removed from git that still exist on disk. - const tracked = spawnSync('git', ['ls-files', '--error-unmatch', file]) - if (tracked.status !== 0) { - continue - } - - const text = readFileForScan(file) - if (!text) { - continue - } - - const pathHits = scanPersonalPaths(text) - if (pathHits.length > 0) { - logger.fail(`Hardcoded personal path found in: ${file}`) - for (const h of pathHits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - 'Replace with the canonical placeholder for the path platform: ' + - '`/Users/<user>/...` (macOS), `/home/<user>/...` (Linux), or ' + - '`C:\\Users\\<USERNAME>\\...` (Windows). Env vars also work ' + - '(`$HOME`, `${USER}`). For documentation lines that need the ' + - `literal form, append the marker \`${socketHookMarkerFor(file, 'personal-path')}\`.`, - ) - errors++ - } - - const apiHits = scanSocketApiKeys(text) - if (apiHits.length > 0) { - logger.fail(`Real API key detected in: ${file}`) - apiHits - .slice(0, 3) - .forEach(h => logger.info(`${h.lineNumber}:${h.line.trim()}`)) - errors++ - } - - const awsHits = scanAwsKeys(text) - if (awsHits.length > 0) { - logger.fail(`Potential AWS credentials found in: ${file}`) - awsHits - .slice(0, 3) - .forEach(h => logger.info(`${h.lineNumber}:${h.line.trim()}`)) - errors++ - } - - const ghHits = scanGitHubTokens(text) - if (ghHits.length > 0) { - logger.fail(`Potential GitHub token found in: ${file}`) - ghHits - .slice(0, 3) - .forEach(h => logger.info(`${h.lineNumber}:${h.line.trim()}`)) - errors++ - } - - const pkHits = scanPrivateKeys(text) - if (pkHits.length > 0) { - logger.fail(`Private key found in: ${file}`) - errors++ - } - - if ( - !file.startsWith('.claude/hooks/') && - !file.startsWith('.git-hooks/') && - !file.startsWith('scripts/') && - // template/ holds the canonical sources that cascade to - // .claude/hooks/, .git-hooks/, and scripts/ in downstream - // fleet repos. The same exemption that applies at the - // destination has to apply at the source; otherwise wheelhouse - // template edits get flagged for code that's intentionally raw - // where it actually runs. - !file.startsWith('template/.claude/hooks/') && - !file.startsWith('template/.git-hooks/') && - !file.startsWith('template/scripts/') && - !file.includes('/external/') && - !file.includes('/vendor/') && - !file.includes('/upstream/') && - // src/logger/ IS the logger — implementing the surface itself - // requires direct console.* calls. - !file.startsWith('src/logger/') && - /\.(m?ts|tsx|cts)$/.test(file) - ) { - const loggerHits = scanLoggerLeaks(text) - if (loggerHits.length > 0) { - logger.fail(`direct stream write found in: ${file}`) - for (const h of loggerHits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - if (h.suggested && h.suggested !== h.line) { - logger.info(` fix: ${h.suggested.trim()}`) - } - } - logger.info( - 'Use `getDefaultLogger()` from `@socketsecurity/lib-stable/logger/default`. ' + - 'For documentation lines that need the literal call, append ' + - `the marker \`${socketHookMarkerFor(file, 'logger')}\`.`, - ) - errors++ - } - } - - // Cross-repo path references — both relative (`../<fleet-repo>/…`) - // and absolute (`…/projects/<fleet-repo>/…`) forms. - // - // Markdown is exempt: docs legitimately show cross-repo command - // examples (e.g. `node scripts/foo.mts --target ../socket-lib`) - // and re-emitting them with `@socketsecurity/lib-stable/…` would break - // the example's runnability. The codepath rule still applies to - // actual source files. - if ( - !file.startsWith('.git-hooks/') && - !file.startsWith('.claude/hooks/') && - !file.endsWith('.md') && - !file.includes('/external/') && - !file.includes('/vendor/') && - !file.includes('/upstream/') && - file !== 'pnpm-lock.yaml' && - file !== 'pnpm-workspace.yaml' - ) { - const crossRepoHits = scanCrossRepoPaths(text, currentRepoName) - if (crossRepoHits.length > 0) { - logger.fail(`cross-repo path reference in: ${file}`) - for (const h of crossRepoHits.slice(0, 3)) { - logger.info(`${h.lineNumber}: ${h.line.trim()}`) - } - logger.info( - 'Cross-repo paths are forbidden — import via the published npm ' + - 'package (`@socketsecurity/lib-stable/<subpath>`) instead. For doc ' + - `lines, append \`${socketHookMarkerFor(file, 'cross-repo')}\`.`, - ) - errors++ - } - } - } - return errors -} - -const main = async (): Promise<number> => { - logger.info('Running mandatory pre-push validation…') - - const submoduleErrors = checkSubmodules() - if (submoduleErrors > 0) { - return 1 - } - - const remote = process.argv[2] || 'origin' - // url at process.argv[3] is unused. - - const stdin = await readStdin() - let totalErrors = 0 - const refLines = splitLines(stdin.trim()).filter(Boolean) - - // Bypass for the signed-commits scan is evaluated once per push: - // a single phrase authorizes one push regardless of ref count. - const signedBypassActive = readSignedPushBypassActive() - - for (const refLine of refLines) { - const [localRef, localSha, remoteRef, remoteSha] = refLine.split(/\s+/) - if (!localRef || !localSha || !remoteRef || !remoteSha) { - continue - } - const range = computeRange(remote, localRef, localSha, remoteSha) - // `computeRange` returns `undefined` for skip cases (tags, deletions, new - // branches); use loose equality so both `null` and `undefined` skip. A - // strict `=== null` check let `undefined` fall through and failed every - // tag push with "Invalid commit range: undefined". - if (range == null) { - continue - } - // Validate range. - const rl = spawnSync('git', ['rev-list', range], { stdio: 'ignore' }) - if (rl.status !== 0) { - logger.fail(`Invalid commit range: ${range}`) - return 1 - } - - totalErrors += scanCommitMessages(range) - totalErrors += scanSignedCommits(range, remoteRef, signedBypassActive) - totalErrors += scanFilesInRange(range) - } - - if (totalErrors > 0) { - logger.error('') - logger.fail('Push blocked by mandatory validation!') - logger.error('Fix the issues above before pushing.') - return 1 - } - - logger.success('All mandatory validation passed!') - return 0 -} - -// Explicit .catch so a thrown error in main() doesn't become an -// unhandled rejection — surface the error through the logger so the -// user sees what blocked the push, then exit 1 intentionally. -main().then( - code => process.exit(code), - e => { - logger.error(`pre-push: ${errorMessage(e)}`) - process.exit(1) - }, -) diff --git a/.git-hooks/test/_helpers.test.mts b/.git-hooks/test/_helpers.test.mts deleted file mode 100644 index e3f996475..000000000 --- a/.git-hooks/test/_helpers.test.mts +++ /dev/null @@ -1,802 +0,0 @@ -/* oxlint-disable socket/no-npx-dlx -- test fixture: asserts on the literal `npx` marker that the hook's bypass detector looks for. */ - -// node --test specs for .git-hooks/_helpers.mts. -// -// Covers the pure-function surface: AI-attribution scanner, marker -// alias logic, suppression matching, backtick span detection, secret -// scanners (Personal-paths / Linear / GitHub / AWS / Private-keys), -// placeholder + replacement suggestion helpers, and the canonical -// marker emitter. Hook entry points (commit-msg / pre-commit / -// pre-push) have their own .test.mts files that exercise stdin/stdout -// against a temp git repo. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - aliasMatches, - containsAiAttribution, - filterAllowedApiKeys, - gitOrThrow, - isInsideBackticks, - lineIsSuppressed, - looksLikeDocumentation, - normalizePath, - scanAwsKeys, - scanCrossRepoPaths, - scanDocsPnpmFirst, - scanGitHubTokens, - scanLinearRefs, - scanPackageJsonPnpmOverrides, - scanPersonalPaths, - scanPrivateKeys, - scanSocketApiKeys, - socketHookMarkerFor, - splitLines, - stripAiAttribution, - suggestLoggerReplacement, - suggestNpxReplacement, - suggestPlaceholder, -} from '../_helpers.mts' - -// ── containsAiAttribution ───────────────────────────────────────── - -test('containsAiAttribution: catches "Generated with Claude"', () => { - assert.strictEqual(containsAiAttribution('Generated with Claude Code'), true) - assert.strictEqual(containsAiAttribution('Generated with AI'), true) -}) - -test('containsAiAttribution: catches "Co-Authored-By:" tags', () => { - assert.strictEqual( - containsAiAttribution('Co-Authored-By: Claude <noreply@anthropic.com>'), - true, - ) - assert.strictEqual(containsAiAttribution('Co-Authored-By: AI'), true) -}) - -test('containsAiAttribution: catches alternative attribution verbs', () => { - for (const verb of [ - 'Built', - 'Created', - 'Made', - 'Written', - 'Authored', - 'Powered', - 'Crafted', - ]) { - assert.strictEqual( - containsAiAttribution(`${verb} with Claude`), - true, - `${verb} with Claude should match`, - ) - assert.strictEqual( - containsAiAttribution(`${verb} by Claude`), - true, - `${verb} by Claude should match`, - ) - } -}) - -test('containsAiAttribution: catches alternative agent names', () => { - for (const agent of [ - 'Claude', - 'AI', - 'GPT', - 'ChatGPT', - 'Copilot', - 'Cursor', - 'Bard', - 'Gemini', - ]) { - assert.strictEqual( - containsAiAttribution(`Generated by ${agent}`), - true, - `Generated by ${agent} should match`, - ) - } -}) - -test('containsAiAttribution: catches emoji + email + line-start markers', () => { - assert.strictEqual(containsAiAttribution('🤖 Generated by foo'), true) - assert.strictEqual(containsAiAttribution('AI generated'), true) - assert.strictEqual(containsAiAttribution('AI-generated'), true) - assert.strictEqual(containsAiAttribution('Machine generated'), true) - assert.strictEqual(containsAiAttribution('Machine-generated'), true) - assert.strictEqual(containsAiAttribution('user@anthropic.com'), true) - assert.strictEqual(containsAiAttribution('user@openai.com'), true) - assert.strictEqual(containsAiAttribution('Assistant: hello'), true) - assert.strictEqual( - containsAiAttribution('line1\nAssistant: hello'), - true, - 'multi-line: Assistant: at line start', - ) -}) - -test('containsAiAttribution: ignores legitimate product/prose mentions', () => { - // The regression that prompted the fix: bare "Claude Code" or - // "Claude" in prose without an attribution verb must NOT trigger. - assert.strictEqual( - containsAiAttribution( - "Catches the failure mode where CLAUDE.md grows past Claude Code's warning threshold", - ), - false, - ) - assert.strictEqual( - containsAiAttribution( - 'Adds two PreToolUse hooks that enforce the fleet "Allow X bypass" phrase policy', - ), - false, - ) - assert.strictEqual( - containsAiAttribution('See .claude/hooks/no-revert-guard for details'), - false, - ) - assert.strictEqual( - containsAiAttribution('the assistant returned an error'), - false, - 'lowercase "assistant" mid-line should not match', - ) - assert.strictEqual( - containsAiAttribution('using the Claude Sonnet 4.6 model'), - false, - 'model-name reference should not match', - ) -}) - -// ── stripAiAttribution ──────────────────────────────────────────── - -test('stripAiAttribution: removes only attribution lines', () => { - const input = [ - 'feat(auth): add login flow', - '', - 'Implements the OAuth callback handler.', - '', - 'Co-Authored-By: Claude <noreply@anthropic.com>', - '🤖 Generated with Claude Code', - ].join('\n') - const { cleaned, removed } = stripAiAttribution(input) - assert.strictEqual(removed, 2) - assert.match(cleaned, /feat\(auth\): add login flow/) - assert.match(cleaned, /Implements the OAuth callback handler\./) - assert.doesNotMatch(cleaned, /Co-Authored-By/) - assert.doesNotMatch(cleaned, /🤖/) -}) - -test('stripAiAttribution: keeps prose mentioning Claude in context', () => { - const input = [ - 'fix(hooks): tighten Claude Code marker handling', - '', - 'See .claude/hooks/ for the canonical hook layout.', - ].join('\n') - const { cleaned, removed } = stripAiAttribution(input) - assert.strictEqual(removed, 0) - assert.strictEqual(cleaned, input) -}) - -// ── aliasMatches ────────────────────────────────────────────────── - -test('aliasMatches: identity match', () => { - assert.strictEqual(aliasMatches('console', 'console'), true) - assert.strictEqual(aliasMatches('npx', 'npx'), true) -}) - -test('aliasMatches: legacy logger → console alias', () => { - assert.strictEqual(aliasMatches('logger', 'console'), true) - assert.strictEqual(aliasMatches('console', 'logger'), true) -}) - -test('aliasMatches: unrelated names do not match', () => { - assert.strictEqual(aliasMatches('console', 'npx'), false) - assert.strictEqual(aliasMatches('foo', 'bar'), false) -}) - -// ── lineIsSuppressed ────────────────────────────────────────────── - -test('lineIsSuppressed: bare marker = blanket allow', () => { - assert.strictEqual( - lineIsSuppressed('console.log("x") // socket-hook: allow', 'console'), - true, - ) - assert.strictEqual(lineIsSuppressed('foo() // socket-hook: allow'), true) -}) - -test('lineIsSuppressed: rule-named marker matches the named rule', () => { - assert.strictEqual( - lineIsSuppressed( - 'console.log("x") // socket-hook: allow console', - 'console', - ), - true, - ) - assert.strictEqual( - lineIsSuppressed('console.log("x") // socket-hook: allow npx', 'console'), - false, - 'npx marker does NOT suppress console rule', - ) -}) - -test('lineIsSuppressed: alias-named marker also matches', () => { - // legacy `allow logger` still suppresses the console rule - assert.strictEqual( - lineIsSuppressed( - 'console.log("x") // socket-hook: allow logger', - 'console', - ), - true, - ) -}) - -test('lineIsSuppressed: returns false when no marker', () => { - assert.strictEqual(lineIsSuppressed('console.log("x")', 'console'), false) -}) - -// ── isInsideBackticks ───────────────────────────────────────────── - -test('isInsideBackticks: pattern entirely within span', () => { - assert.strictEqual( - isInsideBackticks('use the `npx` command', /\bnpx\b/), - true, - ) -}) - -test('isInsideBackticks: pattern only outside span', () => { - assert.strictEqual( - isInsideBackticks('npx is `something else`', /\bnpx\b/), - false, - ) -}) - -test('isInsideBackticks: no spans → false', () => { - assert.strictEqual(isInsideBackticks('plain text', /\bnpx\b/), false) -}) - -// ── looksLikeDocumentation ──────────────────────────────────────── - -test('looksLikeDocumentation: comment lines pass as docs', () => { - assert.strictEqual( - looksLikeDocumentation('// uses npx for the build', /\bnpx\b/), - true, - ) - assert.strictEqual( - looksLikeDocumentation(' * runs npx in CI', /\bnpx\b/), - true, - ) - assert.strictEqual( - looksLikeDocumentation('# npx documentation', /\bnpx\b/), - true, - ) -}) - -test('looksLikeDocumentation: JSDoc tag lines pass as docs', () => { - assert.strictEqual( - looksLikeDocumentation('@example npx prettier', /\bnpx\b/), - true, - ) -}) - -test('looksLikeDocumentation: backtick-only mentions pass', () => { - assert.strictEqual( - looksLikeDocumentation('use `npx` instead', /\bnpx\b/), - true, - ) -}) - -test('looksLikeDocumentation: bare runtime call does not pass', () => { - assert.strictEqual( - looksLikeDocumentation('npx prettier --write', /\bnpx\b/), - false, - ) -}) - -// ── normalizePath ───────────────────────────────────────────────── - -test('normalizePath: Windows backslashes → forward slashes', () => { - assert.strictEqual( - normalizePath('C:\\Users\\jdalton\\project'), - 'C:/Users/jdalton/project', - ) -}) - -test('normalizePath: POSIX path passes through', () => { - assert.strictEqual( - normalizePath('/Users/jdalton/project'), - '/Users/jdalton/project', - ) -}) - -// ── filterAllowedApiKeys ────────────────────────────────────────── - -test('filterAllowedApiKeys: drops fake-token + env-var-name + .env.example + marker', () => { - // SOCKET_TOKEN_ENV_NAMES carries the trailing `=` — the filter looks - // for the assignment shape, not the bare name in prose. - // Past variant: bare `.example` substring was overbroad. Tightened - // to `.env.example` (canonical fixture filename) + an explicit - // per-line `socket-hook: allow socket-api-key` marker. - const lines = [ - 'const real = "abc123secretvalueabcdef"', - 'const fake = "socket-test-fake-token-abc"', - 'export SOCKET_API_TOKEN=somevalue', - 'see .env.example for the canonical shape', - 'const marker = "sktsec_fixture" // socket-hook: allow socket-api-key', - ] - const filtered = filterAllowedApiKeys(lines) - assert.deepStrictEqual(filtered, ['const real = "abc123secretvalueabcdef"']) -}) - -test('filterAllowedApiKeys: bare .example no longer overbroad', () => { - // A real key on a line that happens to mention `.example` (RFC 2606 - // TLD, prose) MUST be retained, not silently dropped. - const lines = [ - 'const tld = "abc123secretvalueabcdef" // .example is an IANA-reserved TLD', - ] - const filtered = filterAllowedApiKeys(lines) - assert.deepStrictEqual(filtered, lines, 'no allowlist hit from bare .example') -}) - -test('filterAllowedApiKeys: retains lines without allowlist hits', () => { - const lines = ['line one', 'line two'] - assert.deepStrictEqual(filterAllowedApiKeys(lines), lines) -}) - -// ── socketHookMarkerFor ─────────────────────────────────────────── - -test('socketHookMarkerFor: emits canonical comment for .ts', () => { - const marker = socketHookMarkerFor('src/foo.ts', 'console') - assert.match(marker, /socket-hook:\s*allow\s+console/) -}) - -test('socketHookMarkerFor: chooses comment style by file extension', () => { - const py = socketHookMarkerFor('foo.py', 'npx') - const yml = socketHookMarkerFor('ci.yml', 'npx') - assert.match(py, /^#/) - assert.match(yml, /^#/) - const ts = socketHookMarkerFor('foo.ts', 'npx') - assert.match(ts, /^\/\//) -}) - -// ── suggestPlaceholder ──────────────────────────────────────────── - -test('suggestPlaceholder: rewrites /Users/<user>/ → /Users/<user>/', () => { - const out = suggestPlaceholder('const p = "/Users/jdalton/foo.txt"') - assert.match(out, /\/Users\/<user>\//) -}) - -test('suggestPlaceholder: rewrites C:\\Users\\<USERNAME>\\ → C:\\Users\\<USERNAME>\\', () => { - // String contains 1 literal backslash per separator: C:\Users\jdalton\Documents - const out = suggestPlaceholder('const p = "C:\\Users\\jdalton\\Documents"') - assert.match(out, /C:\\Users\\<USERNAME>\\/) -}) - -// ── suggestNpxReplacement ───────────────────────────────────────── - -test('suggestNpxReplacement: npx → pnpm exec', () => { - assert.strictEqual( - suggestNpxReplacement('npx prettier --check'), - 'pnpm exec prettier --check', - ) -}) - -test('suggestNpxReplacement: pnpm dlx → pnpm exec', () => { - assert.strictEqual( - suggestNpxReplacement('pnpm dlx tsx foo.ts'), - 'pnpm exec tsx foo.ts', - ) -}) - -test('suggestNpxReplacement: yarn dlx → pnpm exec', () => { - assert.strictEqual( - suggestNpxReplacement('yarn dlx tsx foo.ts'), - 'pnpm exec tsx foo.ts', - ) -}) - -test('suggestNpxReplacement: pnx → pnpm exec', () => { - assert.strictEqual( - suggestNpxReplacement('pnx tsx foo.ts'), - 'pnpm exec tsx foo.ts', - ) -}) - -test('suggestNpxReplacement: leaves non-dlx commands alone', () => { - assert.strictEqual( - suggestNpxReplacement('pnpm install --frozen-lockfile'), - 'pnpm install --frozen-lockfile', - ) -}) - -// ── suggestLoggerReplacement ────────────────────────────────────── - -test('suggestLoggerReplacement: console.log → logger.log', () => { - const out = suggestLoggerReplacement("console.log('hi')") - assert.match(out, /logger\.(info|log)/) -}) - -test('suggestLoggerReplacement: console.error → logger.fail', () => { - const out = suggestLoggerReplacement("console.error('x')") - assert.match(out, /logger\.(error|fail)/) -}) - -// ── scanPersonalPaths ───────────────────────────────────────────── - -test('scanPersonalPaths: flags real /Users/<user>/ paths', () => { - const hits = scanPersonalPaths('const p = "/Users/jdalton/secret.txt"') - assert.ok(hits.length > 0) - assert.match(hits[0]!.line, /jdalton/) -}) - -test('scanPersonalPaths: ignores placeholder /Users/<user>/', () => { - const hits = scanPersonalPaths('const p = "/Users/<user>/foo"') - assert.strictEqual(hits.length, 0) -}) - -test('scanPersonalPaths: ignores Linux placeholder /home/<user>/', () => { - const hits = scanPersonalPaths('const p = "/home/<user>/foo"') - assert.strictEqual(hits.length, 0) -}) - -test('scanPersonalPaths: does NOT flag ~/ or $HOME/ (username-free forms)', () => { - // ~/ and $HOME/ are the RECOMMENDED replacements for a hardcoded - // username — they must never be flagged. Regression: an earlier - // revision added them to PERSONAL_PATH_RE and blocked the push on - // canonical fixed paths like ~/.config/gh/hosts.yml. - for (const p of [ - 'token lives at ~/.config/gh/hosts.yml', - 'marker file: ~/.claude/gh-workflow-grant', - 'const dir = "$HOME/.ssh"', - 'const dir = "${HOME}/.config"', - ]) { - assert.strictEqual(scanPersonalPaths(p).length, 0, `should not flag: ${p}`) - } -}) - -test('scanPersonalPaths: respects suppression marker', () => { - // Canonical rule name is singular: `personal-path`. - const hits = scanPersonalPaths( - 'const p = "/Users/jdalton/foo" // socket-hook: allow personal-path', - ) - assert.strictEqual(hits.length, 0) -}) - -test('scanPersonalPaths: bare-allow marker also suppresses', () => { - const hits = scanPersonalPaths( - 'const p = "/Users/jdalton/foo" // socket-hook: allow', - ) - assert.strictEqual(hits.length, 0) -}) - -// ── scanLinearRefs ──────────────────────────────────────────────── - -test('scanLinearRefs: catches enumerated team-key IDs', () => { - // SOC- is NOT in the team-key list; ENG-, INFRA-, OPS- are. - const hits = scanLinearRefs('Fixes ENG-9999 and INFRA-42 and OPS-7') - assert.strictEqual(hits.length, 3) - assert.ok(hits.includes('ENG-9999')) - assert.ok(hits.includes('INFRA-42')) - assert.ok(hits.includes('OPS-7')) -}) - -test('scanLinearRefs: ignores unenumerated 3+ letter prefixes', () => { - // SOC- and FOO- are not in the team-key list. - const hits = scanLinearRefs('Tracking SOC-1234 and FOO-99') - assert.strictEqual(hits.length, 0) -}) - -test('scanLinearRefs: catches linear.app URLs', () => { - const hits = scanLinearRefs('See https://linear.app/socket/issue/SOC-1') - assert.ok(hits.length >= 1) -}) - -test('scanLinearRefs: respects limit cap', () => { - const text = Array.from({ length: 20 }, (_, i) => `SOC-${i}`).join(' ') - const hits = scanLinearRefs(text, 3) - assert.ok(hits.length <= 3) -}) - -test('scanLinearRefs: ignores arbitrary 3-letter prefixes (ABC-)', () => { - const hits = scanLinearRefs('issue ABC-123 was filed') - assert.strictEqual(hits.length, 0) -}) - -test('scanLinearRefs: skips lines that start with #', () => { - // Comment-style lines (commit-message comments) are skipped wholesale. - const hits = scanLinearRefs('# tracking ENG-1 and ENG-2') - assert.strictEqual(hits.length, 0) -}) - -// ── scanGitHubTokens / scanAwsKeys / scanPrivateKeys ───────────── - -test('scanGitHubTokens: catches ghp_* literal', () => { - const hits = scanGitHubTokens( - 'GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyzABCDEF1234', - ) - assert.ok(hits.length >= 1) -}) - -test('scanGitHubTokens: catches ghs_* literal too', () => { - const hits = scanGitHubTokens( - 'TOKEN=ghs_abcdefghijklmnopqrstuvwxyzABCDEF1234', - ) - assert.ok(hits.length >= 1) -}) - -test('scanGitHubTokens: catches new JWT-format ghs_* token', () => { - // 2026-05-15 GitHub rollout: stateless JWT format. ghs_ prefix + - // JWT body with two dots, underscores, ~520 chars in production. - // Synthetic fixture is shorter but still hits both characteristics - // (length >= 36, contains dots). - const hits = scanGitHubTokens( - 'TOKEN=ghs_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature_part_abcdef123456', - ) - assert.ok(hits.length >= 1) -}) - -test('scanGitHubTokens: catches new JWT-format ghu_* token (future)', () => { - // User-to-server tokens scheduled for the same JWT format change - // per the 2026-05-15 changelog (timing TBD). - const hits = scanGitHubTokens( - 'TOKEN=ghu_eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature_part_abcdef123456', - ) - assert.ok(hits.length >= 1) -}) - -test('scanGitHubTokens: catches gho_*, ghr_*, github_pat_* literals', () => { - // The earlier regex only matched gh[ps]_ — gho/ghr/github_pat got - // through. New regex covers the full GitHub-issued set. - assert.ok( - scanGitHubTokens('TOKEN=gho_abcdefghijklmnopqrstuvwxyzABCDEF1234').length >= - 1, - ) - assert.ok( - scanGitHubTokens('TOKEN=ghr_abcdefghijklmnopqrstuvwxyzABCDEF1234').length >= - 1, - ) - assert.ok( - scanGitHubTokens( - 'TOKEN=github_pat_11ABCDEFG0aBcDeFgHiJk_lMnOpQrStUvWxYz0123456789AbCdEfGhIjKlMnOp', - ).length >= 1, - ) -}) - -test('scanAwsKeys: catches AKIA literal access key', () => { - const hits = scanAwsKeys('AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE') - assert.ok(hits.length >= 1) -}) - -test('scanPrivateKeys: catches PEM header', () => { - const hits = scanPrivateKeys('-----BEGIN RSA PRIVATE KEY-----') - assert.ok(hits.length >= 1) -}) - -test('scanPrivateKeys: catches every common PEM variant', () => { - const variants = [ - '-----BEGIN PRIVATE KEY-----', // PKCS#8 generic - '-----BEGIN RSA PRIVATE KEY-----', // PKCS#1 OpenSSL - '-----BEGIN EC PRIVATE KEY-----', - '-----BEGIN DSA PRIVATE KEY-----', - '-----BEGIN OPENSSH PRIVATE KEY-----', // default ssh-keygen since 2019 - '-----BEGIN ENCRYPTED PRIVATE KEY-----', // PKCS#8 passphrase - '-----BEGIN PGP PRIVATE KEY BLOCK-----', - ] - for (let i = 0, { length } = variants; i < length; i += 1) { - const v = variants[i]! - const hits = scanPrivateKeys(v) - assert.ok(hits.length >= 1, `must catch: ${v}`) - } -}) - -test('scanPrivateKeys: does not false-positive on prose', () => { - const benign = [ - 'See: BEGIN PRIVATE KEY is the PEM header for PKCS#8 keys.', - '// the PRIVATE KEY format starts with -----BEGIN', - 'PUBLIC KEY (not private):', - ] - for (let i = 0, { length } = benign; i < length; i += 1) { - const b = benign[i]! - const hits = scanPrivateKeys(b) - assert.strictEqual(hits.length, 0, `must not match prose mention: ${b}`) - } -}) - -// ── scanPackageJsonPnpmOverrides ────────────────────────────────── - -test('scanPackageJsonPnpmOverrides: flags a non-empty pnpm.overrides', () => { - const text = JSON.stringify( - { name: 'x', pnpm: { overrides: { ajv: '>=6.14.0' } } }, - undefined, - 2, - ) - const hits = scanPackageJsonPnpmOverrides(text) - assert.strictEqual(hits.length, 1) - assert.ok(hits[0]!.line.includes('overrides')) -}) - -test('scanPackageJsonPnpmOverrides: ignores empty overrides', () => { - const text = JSON.stringify({ name: 'x', pnpm: { overrides: {} } }) - assert.strictEqual(scanPackageJsonPnpmOverrides(text).length, 0) -}) - -test('scanPackageJsonPnpmOverrides: ignores package.json without pnpm', () => { - const text = JSON.stringify({ name: 'x', version: '1.0.0' }) - assert.strictEqual(scanPackageJsonPnpmOverrides(text).length, 0) -}) - -test('scanPackageJsonPnpmOverrides: fails open on invalid JSON', () => { - assert.strictEqual(scanPackageJsonPnpmOverrides('{ not json').length, 0) -}) - -// ── scanSocketApiKeys ───────────────────────────────────────────── - -test('scanSocketApiKeys: catches sktsec_ literal', () => { - // The line MUST NOT contain the env-var-name shape (`SOCKET_API_TOKEN=`) - // or it gets allowlisted as a documented env-name reference. - const hits = scanSocketApiKeys('const t = "sktsec_abc123abc123abc123abc123"') - assert.ok(hits.length >= 1) -}) - -test('scanSocketApiKeys: ignores allowlisted fake-token shape', () => { - const hits = scanSocketApiKeys('const t = "socket-test-fake-token-abc"') - assert.strictEqual(hits.length, 0) -}) - -test('scanSocketApiKeys: env-var-name shape is allowlisted', () => { - // The trailing `=` triggers the env-var-name allowlist. - const hits = scanSocketApiKeys( - 'export SOCKET_API_TOKEN=sktsec_realsecretrealsecret', - ) - assert.strictEqual(hits.length, 0) -}) - -// ── scanCrossRepoPaths ──────────────────────────────────────────── - -test('scanCrossRepoPaths: flags ../<other-repo>/ relative escapes', () => { - const hits = scanCrossRepoPaths( - 'import x from "../socket-cli/src/foo"', - 'src/test.ts', - ) - assert.ok(hits.length >= 1) -}) - -test('scanCrossRepoPaths: flags absolute /Users/.../projects/<other-repo>', () => { - const hits = scanCrossRepoPaths( - 'const p = "/Users/jdalton/projects/socket-cli/lib/foo"', - 'src/test.ts', - ) - assert.ok(hits.length >= 1) -}) - -test('scanCrossRepoPaths: allows same-repo relative imports', () => { - const hits = scanCrossRepoPaths('import x from "./foo"', 'src/test.ts') - assert.strictEqual(hits.length, 0) -}) - -// ── splitLines (CRLF normalization) ─────────────────────────────── - -test('splitLines: LF-only input passes through', () => { - assert.deepStrictEqual(splitLines('a\nb\nc'), ['a', 'b', 'c']) -}) - -test('splitLines: CRLF input normalizes to LF', () => { - assert.deepStrictEqual(splitLines('a\r\nb\r\nc'), ['a', 'b', 'c']) -}) - -test('splitLines: mixed CRLF + LF input normalizes', () => { - assert.deepStrictEqual(splitLines('a\r\nb\nc\r\nd'), ['a', 'b', 'c', 'd']) -}) - -test('splitLines: trailing CRLF produces a trailing empty', () => { - assert.deepStrictEqual(splitLines('a\r\n'), ['a', '']) -}) - -test('splitLines: standalone \\r is preserved (mac classic — out of scope)', () => { - // Modern git / subprocess output is LF or CRLF, never bare CR. We - // don't try to normalize CR-only line endings; the helper is a - // pragmatic CRLF stripper, not a full line-ending sniffer. - assert.deepStrictEqual(splitLines('a\rb'), ['a\rb']) -}) - -// ── gitOrThrow ──────────────────────────────────────────────────── - -test('gitOrThrow: returns stdout on success', () => { - // `git --version` works in any environment with git on PATH. - const out = gitOrThrow('--version') - assert.ok( - out.startsWith('git version'), - `expected "git version ...", got ${out}`, - ) -}) - -test('gitOrThrow: throws on non-zero exit', () => { - assert.throws( - () => gitOrThrow('this-subcommand-does-not-exist'), - /git this-subcommand-does-not-exist:/, - ) -}) - -// ── scanDocsPnpmFirst ───────────────────────────────────────────── - -test('scanDocsPnpmFirst: flags fence with only npm install', () => { - const md = ['# Install', '', '```sh', 'npm install lodash', '```'].join('\n') - const hits = scanDocsPnpmFirst(md) - assert.strictEqual(hits.length, 1) - assert.strictEqual(hits[0]!.line, 'npm install lodash') - assert.strictEqual(hits[0]!.suggested, 'pnpm install lodash') -}) - -test('scanDocsPnpmFirst: accepts fence with pnpm leading npm', () => { - const md = [ - '```sh', - 'pnpm install lodash', - '# or for npm users:', - 'npm install lodash', - '```', - ].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) - -test('scanDocsPnpmFirst: flags yarn add without pnpm form', () => { - const md = ['```bash', 'yarn add typescript', '```'].join('\n') - const hits = scanDocsPnpmFirst(md) - assert.strictEqual(hits.length, 1) - assert.match(hits[0]!.line, /yarn add/) -}) - -test('scanDocsPnpmFirst: accepts tilde-fenced block', () => { - const md = ['~~~sh', 'pnpm add react', 'npm install react', '~~~'].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) - -test('scanDocsPnpmFirst: ignores inline backtick spans', () => { - // Inline `npm install foo` in prose is NOT a fenced block — out of - // scope for this scanner. - const md = 'Run `npm install lodash` to install lodash.' - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) - -test('scanDocsPnpmFirst: per-block suppression marker', () => { - const md = [ - '```sh', - '# socket-hook: allow pnpm-first', - 'npm install lodash', - '```', - ].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) - -test('scanDocsPnpmFirst: suppression marker on line above fence', () => { - const md = [ - '<!-- socket-hook: allow pnpm-first -->', - '```sh', - 'npm install lodash', - '```', - ].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) - -test('scanDocsPnpmFirst: handles multiple fences independently', () => { - const md = [ - '```sh', - 'pnpm add foo', - '```', - '', - 'And here is a fallback:', - '', - '```sh', - 'npm install foo', - '```', - ].join('\n') - // First fence has pnpm form → ok. Second fence is bare npm with no - // pnpm leader → one warning. - assert.strictEqual(scanDocsPnpmFirst(md).length, 1) -}) - -test('scanDocsPnpmFirst: handles $-prefixed prompt lines', () => { - const md = ['```sh', '$ npm install foo', '```'].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 1) -}) - -test('scanDocsPnpmFirst: ignores non-install npm commands', () => { - // `npm run build` is not an install — out of scope for the leader - // rule (which is about how users *get* a package). - const md = ['```sh', 'npm run build', '```'].join('\n') - assert.strictEqual(scanDocsPnpmFirst(md).length, 0) -}) diff --git a/.git-hooks/test/commit-msg.test.mts b/.git-hooks/test/commit-msg.test.mts deleted file mode 100644 index f67e1e6d7..000000000 --- a/.git-hooks/test/commit-msg.test.mts +++ /dev/null @@ -1,78 +0,0 @@ -// node --test specs for .git-hooks/commit-msg.mts. -// -// Smoke tests: spawn the hook with a temp commit-message file and -// inspect the rewritten file + exit code. The hook strips AI -// attribution lines and blocks commits that look like they're -// committing secrets / .env files. - -import test from 'node:test' -import assert from 'node:assert/strict' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'commit-msg.mts') - -type Result = { code: number; stderr: string } - -async function runHook(commitMsg: string): Promise<{ - result: Result - rewrittenMessage: string -}> { - const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-msg-test-')) - const msgFile = path.join(dir, 'COMMIT_EDITMSG') - writeFileSync(msgFile, commitMsg) - try { - const child = spawn(process.execPath, [HOOK, msgFile], { stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const result = await new Promise<Result>(resolve => { - child.on('exit', code => resolve({ code: code ?? 0, stderr })) - }) - const rewrittenMessage = readFileSync(msgFile, 'utf8') - return { result, rewrittenMessage } - } finally { - rmSync(dir, { force: true, recursive: true }) - } -} - -test('commit-msg: passes through clean prose', async () => { - const { result, rewrittenMessage } = await runHook( - 'feat(auth): add OAuth callback handler\n\nWires the redirect URI through the new endpoint.\n', - ) - assert.strictEqual(result.code, 0) - assert.match(rewrittenMessage, /feat\(auth\): add OAuth callback handler/) -}) - -test('commit-msg: strips Co-Authored-By: Claude attribution', async () => { - const { result, rewrittenMessage } = await runHook( - 'feat: ship feature\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n', - ) - assert.strictEqual(result.code, 0) - assert.doesNotMatch(rewrittenMessage, /Co-Authored-By: Claude/) - assert.match(rewrittenMessage, /feat: ship feature/) -}) - -test('commit-msg: strips 🤖 emoji attribution line', async () => { - const { result, rewrittenMessage } = await runHook( - 'fix: bug\n\n🤖 Generated with Claude Code\n', - ) - assert.strictEqual(result.code, 0) - assert.doesNotMatch(rewrittenMessage, /🤖/) -}) - -test('commit-msg: keeps prose mentioning Claude in context', async () => { - // Bare "Claude Code" reference (not an attribution claim) survives - // the strip — this was the regression that prompted the regex fix. - const { result, rewrittenMessage } = await runHook( - 'docs(claude): point at Claude Code best practices\n\nLinks the upstream guide that informs the .claude/ layout.\n', - ) - assert.strictEqual(result.code, 0) - assert.match(rewrittenMessage, /Claude Code best practices/) - assert.match(rewrittenMessage, /\.claude\//) -}) diff --git a/.git-hooks/test/pre-commit.test.mts b/.git-hooks/test/pre-commit.test.mts deleted file mode 100644 index 23071a76e..000000000 --- a/.git-hooks/test/pre-commit.test.mts +++ /dev/null @@ -1,225 +0,0 @@ -// node --test specs for .git-hooks/pre-commit.mts. -// -// Smoke tests: spin up a temp git repo, stage a file, run the hook -// from inside it, and inspect exit code + stderr. Covers the clean -// path and the secret-leak block path. - -import test from 'node:test' -import assert from 'node:assert/strict' -import { - spawn, - spawnSync, -} from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'pre-commit.mts') - -function setupRepo(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pre-commit-test-')) - spawnSync('git', ['init', '-q'], { cwd: dir }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir }) - spawnSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }) - return dir -} - -async function runHook( - cwd: string, - extraEnv: Record<string, string> = {}, -): Promise<{ code: number; stderr: string }> { - const child = spawn(process.execPath, [HOOK], { - cwd, - stdio: 'pipe', - // Default: bypass the signing-config gate so tests that verify - // OTHER hook behaviors (secret detection, path leak, etc.) aren't - // blocked by the unrelated signing requirement. Tests that - // specifically verify the signing gate pass extraEnv = {} (or omit - // the bypass). - env: { - ...process.env, - SOCKET_PRE_COMMIT_ALLOW_UNSIGNED: '1', - ...extraEnv, - }, - }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - return new Promise(resolve => { - child.on('exit', code => resolve({ code: code ?? 0, stderr })) - }) -} - -test('pre-commit: passes a clean staged file', async () => { - const dir = setupRepo() - try { - writeFileSync(path.join(dir, 'foo.ts'), 'export const X = 1\n') - spawnSync('git', ['add', 'foo.ts'], { cwd: dir }) - const { code } = await runHook(dir) - assert.strictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: blocks a staged file with a real personal path', async () => { - const dir = setupRepo() - try { - writeFileSync( - path.join(dir, 'leak.ts'), - 'export const HOME = "/Users/jdalton/secret"\n', - ) - spawnSync('git', ['add', 'leak.ts'], { cwd: dir }) - const { code, stderr } = await runHook(dir) - assert.notStrictEqual(code, 0, 'hook must reject personal-path leaks') - assert.match(stderr, /\/Users\/jdalton/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: blocks a staged file containing an AWS access key', async () => { - const dir = setupRepo() - try { - writeFileSync( - path.join(dir, 'aws.txt'), - 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n', - ) - spawnSync('git', ['add', 'aws.txt'], { cwd: dir }) - const { code } = await runHook(dir) - assert.notStrictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: passes when no files are staged', async () => { - const dir = setupRepo() - try { - const { code } = await runHook(dir) - assert.strictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: blocks staged .env file', async () => { - const dir = setupRepo() - try { - writeFileSync( - path.join(dir, '.env'), - 'SOCKET_API_TOKEN=sktsec_abc123abc123abc123abc123\n', - ) - spawnSync('git', ['add', '-f', '.env'], { cwd: dir }) - const { code } = await runHook(dir) - assert.notStrictEqual(code, 0, 'hook must reject .env files') - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: blocks when commit.gpgsign is false', async () => { - const dir = setupRepo() // setupRepo sets gpgsign=false - try { - writeFileSync(path.join(dir, 'foo.ts'), 'export const X = 1\n') - spawnSync('git', ['add', 'foo.ts'], { cwd: dir }) - // No SOCKET_PRE_COMMIT_ALLOW_UNSIGNED → gate fires. - const child = spawn(process.execPath, [HOOK], { cwd: dir, stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code = await new Promise<number>(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.notStrictEqual(code, 0, 'unsigned config must block the commit') - assert.match(stderr, /commit\.gpgsign is not enabled/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: blocks when user.signingkey is unset', async () => { - const dir = setupRepo() - try { - // Enable gpgsign locally but ensure no signingkey is set. - spawnSync('git', ['config', 'commit.gpgsign', 'true'], { cwd: dir }) - writeFileSync(path.join(dir, 'foo.ts'), 'export const X = 1\n') - spawnSync('git', ['add', 'foo.ts'], { cwd: dir }) - // Isolate git from the developer's global config (where - // user.signingkey may be set globally) so the test verifies the - // "no signingkey at all" path. HOME is git's primary lookup for - // ~/.gitconfig; pointing it at the test dir means git only sees - // the repo-local config. - const child = spawn(process.execPath, [HOOK], { - cwd: dir, - stdio: 'pipe', - env: { ...process.env, HOME: dir, GIT_CONFIG_GLOBAL: '/dev/null' }, - }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code = await new Promise<number>(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.notStrictEqual(code, 0, 'missing signingkey must block') - assert.match(stderr, /user\.signingkey is not set/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: passes when gpgsign=true and signingkey is set', async () => { - const dir = setupRepo() - try { - spawnSync('git', ['config', 'commit.gpgsign', 'true'], { cwd: dir }) - spawnSync('git', ['config', 'user.signingkey', 'TESTKEYID123'], { - cwd: dir, - }) - writeFileSync(path.join(dir, 'foo.ts'), 'export const X = 1\n') - spawnSync('git', ['add', 'foo.ts'], { cwd: dir }) - // Run WITHOUT the bypass env — gate should accept the good config. - const child = spawn(process.execPath, [HOOK], { cwd: dir, stdio: 'pipe' }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - const code = await new Promise<number>(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual( - code, - 0, - `signed-config commit should pass; stderr was: ${stderr}`, - ) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-commit: SOCKET_PRE_COMMIT_ALLOW_UNSIGNED=1 bypasses the gate', async () => { - const dir = setupRepo() // gpgsign=false - try { - writeFileSync(path.join(dir, 'foo.ts'), 'export const X = 1\n') - spawnSync('git', ['add', 'foo.ts'], { cwd: dir }) - const { code } = await runHook(dir, { - SOCKET_PRE_COMMIT_ALLOW_UNSIGNED: '1', - }) - assert.strictEqual( - code, - 0, - 'bypass env should allow the unsigned-config commit', - ) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -// Suppress the unused-mkdir warning by referencing it (harness might -// extend with subdir tests later). -void mkdirSync diff --git a/.git-hooks/test/pre-push.test.mts b/.git-hooks/test/pre-push.test.mts deleted file mode 100644 index 3ee5d9089..000000000 --- a/.git-hooks/test/pre-push.test.mts +++ /dev/null @@ -1,256 +0,0 @@ -// node --test specs for .git-hooks/pre-push.mts. -// -// Smoke tests: spin up a temp git repo, commit content, feed a -// push-line to the hook over stdin, inspect exit code. Covers the -// AI-attribution block path and the secret-leak block path. - -import test from 'node:test' -import assert from 'node:assert/strict' -import { - spawn, - spawnSync, -} from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const HOOK = path.join(here, '..', 'pre-push.mts') - -const ZERO_SHA = '0000000000000000000000000000000000000000' - -function setupRepo(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'pre-push-test-')) - spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }) - spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }) - spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir }) - spawnSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }) - // Create a baseline commit and an `origin/main` remote-tracking ref - // pointing at it. The hook's range computation requires a baseline - // — without one, it skips validation entirely (treats the push as - // a brand-new branch with no baseline to diff against). - writeFileSync(path.join(dir, '.gitkeep'), '') - spawnSync('git', ['add', '.gitkeep'], { cwd: dir }) - spawnSync('git', ['commit', '-q', '-m', 'baseline', '--no-verify'], { - cwd: dir, - }) - // Manually create the remote-tracking ref so computeRange has a - // baseline. spawnSync('update-ref') is a low-level git plumbing call - // that bypasses the network. - const baseSha = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir }) - .stdout.toString() - .trim() - spawnSync('git', ['update-ref', 'refs/remotes/origin/main', baseSha], { - cwd: dir, - }) - spawnSync( - 'git', - ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'], - { cwd: dir }, - ) - return dir -} - -function commit( - dir: string, - file: string, - content: string, - msg: string, -): string { - writeFileSync(path.join(dir, file), content) - spawnSync('git', ['add', file], { cwd: dir }) - spawnSync('git', ['commit', '-q', '-m', msg, '--no-verify'], { cwd: dir }) - const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir }) - return r.stdout.toString().trim() -} - -async function runHook( - cwd: string, - pushLine: string, -): Promise<{ code: number; stderr: string }> { - const child = spawn(process.execPath, [HOOK, 'origin', cwd], { - cwd, - stdio: 'pipe', - }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.stdin.end(pushLine) - return new Promise(resolve => { - child.on('exit', code => resolve({ code: code ?? 0, stderr })) - }) -} - -test('pre-push: empty stdin exits 0 (nothing to push)', async () => { - const dir = setupRepo() - try { - const { code } = await runHook(dir, '') - assert.strictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: clean commit + clean message passes', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: initial commit', - ) - // Push to a topic branch — the signed-commit check exempts non-main - // refs since these test cases aren't about signing. - const pushLine = `refs/heads/topic ${sha} refs/heads/topic ${ZERO_SHA}\n` - const { code } = await runHook(dir, pushLine) - assert.strictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: blocks commit with AI-attribution body', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: ship feature\n\nCo-Authored-By: Claude <noreply@anthropic.com>', - ) - const pushLine = `refs/heads/main ${sha} refs/heads/main ${ZERO_SHA}\n` - const { code, stderr } = await runHook(dir, pushLine) - assert.notStrictEqual(code, 0, 'AI attribution must block push') - assert.match(stderr, /AI attribution|Co-Authored-By/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: keeps prose mentioning Claude in context', async () => { - // The previous regex matched bare "Claude Code" — verify the fix - // doesn't false-positive on legitimate references. - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'docs(claude): point at Claude Code best practices\n\nLinks the upstream guide that informs the .claude/ layout.', - ) - // Topic branch — the signed-commit check exempts non-main refs. - const pushLine = `refs/heads/topic ${sha} refs/heads/topic ${ZERO_SHA}\n` - const { code } = await runHook(dir, pushLine) - assert.strictEqual(code, 0, 'legitimate Claude Code prose must pass') - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: blocks commit introducing a personal-path leak', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'leak.ts', - 'export const HOME = "/Users/jdalton/secret"\n', - 'feat: add config', - ) - const pushLine = `refs/heads/main ${sha} refs/heads/main ${ZERO_SHA}\n` - const { code } = await runHook(dir, pushLine) - assert.notStrictEqual(code, 0) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: unsigned commit pushed to main is blocked', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: clean commit', - ) - // Pushing to refs/heads/main with unsigned commits → block. - const pushLine = `refs/heads/main ${sha} refs/heads/main ${ZERO_SHA}\n` - const { code, stderr } = await runHook(dir, pushLine) - assert.notStrictEqual(code, 0, 'unsigned push to main must block') - assert.match(stderr, /unsigned commit/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: unsigned commit pushed to master is blocked', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: clean commit', - ) - const pushLine = `refs/heads/master ${sha} refs/heads/master ${ZERO_SHA}\n` - const { code } = await runHook(dir, pushLine) - assert.notStrictEqual(code, 0, 'unsigned push to master must block') - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: unsigned commit pushed to topic branch is allowed', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: clean commit', - ) - const pushLine = `refs/heads/feature ${sha} refs/heads/feature ${ZERO_SHA}\n` - const { code } = await runHook(dir, pushLine) - assert.strictEqual( - code, - 0, - 'topic branch push exempt from signed-commit gate', - ) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) - -test('pre-push: SOCKET_PRE_PUSH_ALLOW_UNSIGNED=1 bypasses the check', async () => { - const dir = setupRepo() - try { - const sha = commit( - dir, - 'foo.ts', - 'export const X = 1\n', - 'feat: emergency push', - ) - const pushLine = `refs/heads/main ${sha} refs/heads/main ${ZERO_SHA}\n` - const child = spawn(process.execPath, [HOOK, 'origin', dir], { - cwd: dir, - stdio: 'pipe', - env: { ...process.env, SOCKET_PRE_PUSH_ALLOW_UNSIGNED: '1' }, - }) - let stderr = '' - child.stderr.on('data', chunk => { - stderr += chunk.toString('utf8') - }) - child.stdin.end(pushLine) - const code = await new Promise<number>(resolve => { - child.on('exit', c => resolve(c ?? 0)) - }) - assert.strictEqual(code, 0, 'bypass env should allow the unsigned push') - // The hook prints a warning even when bypassing — confirm it. - assert.match(stderr, /allowed by bypass/i) - } finally { - rmSync(dir, { force: true, recursive: true }) - } -}) diff --git a/.gitattributes b/.gitattributes index 9989b74ca..af7d217f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,725 +1 @@ -* text=auto eol=lf - -# ─── BEGIN fleet-canonical (managed by socket-wheelhouse sync) ── -# Cascaded from socket-wheelhouse/template/. Don't edit locally — -# edit upstream and re-cascade via sync-scaffolding. Marked -# linguist-generated so GitHub PR diffs collapse them by default. -.claude/agents/security-reviewer.md linguist-generated=true -.claude/commands/audit-gha-settings.md linguist-generated=true -.claude/commands/green-ci.md linguist-generated=true -.claude/commands/quality-loop.md linguist-generated=true -.claude/commands/security-scan.md linguist-generated=true -.claude/commands/setup-security-tools.md linguist-generated=true -.claude/commands/squash-history.md linguist-generated=true -.claude/commands/update-coverage.md linguist-generated=true -.claude/commands/update-security.md linguist-generated=true -.claude/hooks/_shared/README.md linguist-generated=true -.claude/hooks/_shared/acorn/README.md linguist-generated=true -.claude/hooks/_shared/acorn/acorn-bindgen.cjs linguist-generated=true -.claude/hooks/_shared/acorn/acorn-wasm-sync.mts linguist-generated=true -.claude/hooks/_shared/acorn/acorn.wasm linguist-generated=true -.claude/hooks/_shared/acorn/index.mts linguist-generated=true -.claude/hooks/_shared/bash-quote-mask.mts linguist-generated=true -.claude/hooks/_shared/hook-env.mts linguist-generated=true -.claude/hooks/_shared/markers.mts linguist-generated=true -.claude/hooks/_shared/payload.mts linguist-generated=true -.claude/hooks/_shared/stop-reminder.mts linguist-generated=true -.claude/hooks/_shared/test/bash-quote-mask.test.mts linguist-generated=true -.claude/hooks/_shared/test/transcript.test.mts linguist-generated=true -.claude/hooks/_shared/token-patterns.mts linguist-generated=true -.claude/hooks/_shared/transcript.mts linguist-generated=true -.claude/hooks/_shared/wheelhouse-root.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/README.md linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/index.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/package.json linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/test/index.test.mts linguist-generated=true -.claude/hooks/actionlint-on-workflow-edit/tsconfig.json linguist-generated=true -.claude/hooks/ask-suppression-reminder/README.md linguist-generated=true -.claude/hooks/ask-suppression-reminder/index.mts linguist-generated=true -.claude/hooks/ask-suppression-reminder/package.json linguist-generated=true -.claude/hooks/ask-suppression-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/ask-suppression-reminder/tsconfig.json linguist-generated=true -.claude/hooks/auth-rotation-reminder/README.md linguist-generated=true -.claude/hooks/auth-rotation-reminder/index.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/package.json linguist-generated=true -.claude/hooks/auth-rotation-reminder/services.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/test/auth-rotation-reminder.test.mts linguist-generated=true -.claude/hooks/auth-rotation-reminder/tsconfig.json linguist-generated=true -.claude/hooks/check-new-deps/README.md linguist-generated=true -.claude/hooks/check-new-deps/audit.mts linguist-generated=true -.claude/hooks/check-new-deps/index.mts linguist-generated=true -.claude/hooks/check-new-deps/package.json linguist-generated=true -.claude/hooks/check-new-deps/types.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/README.md linguist-generated=true -.claude/hooks/claude-md-section-size-guard/index.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/package.json linguist-generated=true -.claude/hooks/claude-md-section-size-guard/test/index.test.mts linguist-generated=true -.claude/hooks/claude-md-section-size-guard/tsconfig.json linguist-generated=true -.claude/hooks/claude-md-size-guard/README.md linguist-generated=true -.claude/hooks/claude-md-size-guard/index.mts linguist-generated=true -.claude/hooks/claude-md-size-guard/package.json linguist-generated=true -.claude/hooks/claude-md-size-guard/test/index.test.mts linguist-generated=true -.claude/hooks/claude-md-size-guard/tsconfig.json linguist-generated=true -.claude/hooks/codex-no-write-guard/README.md linguist-generated=true -.claude/hooks/codex-no-write-guard/index.mts linguist-generated=true -.claude/hooks/codex-no-write-guard/package.json linguist-generated=true -.claude/hooks/codex-no-write-guard/test/index.test.mts linguist-generated=true -.claude/hooks/codex-no-write-guard/tsconfig.json linguist-generated=true -.claude/hooks/comment-tone-reminder/README.md linguist-generated=true -.claude/hooks/comment-tone-reminder/index.mts linguist-generated=true -.claude/hooks/comment-tone-reminder/package.json linguist-generated=true -.claude/hooks/comment-tone-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/comment-tone-reminder/tsconfig.json linguist-generated=true -.claude/hooks/commit-author-guard/README.md linguist-generated=true -.claude/hooks/commit-author-guard/index.mts linguist-generated=true -.claude/hooks/commit-author-guard/package.json linguist-generated=true -.claude/hooks/commit-author-guard/test/index.test.mts linguist-generated=true -.claude/hooks/commit-author-guard/tsconfig.json linguist-generated=true -.claude/hooks/commit-pr-reminder/README.md linguist-generated=true -.claude/hooks/commit-pr-reminder/index.mts linguist-generated=true -.claude/hooks/commit-pr-reminder/package.json linguist-generated=true -.claude/hooks/commit-pr-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/commit-pr-reminder/tsconfig.json linguist-generated=true -.claude/hooks/compound-lessons-reminder/README.md linguist-generated=true -.claude/hooks/compound-lessons-reminder/index.mts linguist-generated=true -.claude/hooks/compound-lessons-reminder/package.json linguist-generated=true -.claude/hooks/compound-lessons-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/compound-lessons-reminder/tsconfig.json linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/README.md linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/index.mts linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/package.json linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/test/index.test.mts linguist-generated=true -.claude/hooks/concurrent-cargo-build-guard/tsconfig.json linguist-generated=true -.claude/hooks/consumer-grep-reminder/README.md linguist-generated=true -.claude/hooks/consumer-grep-reminder/index.mts linguist-generated=true -.claude/hooks/consumer-grep-reminder/package.json linguist-generated=true -.claude/hooks/consumer-grep-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/consumer-grep-reminder/tsconfig.json linguist-generated=true -.claude/hooks/cross-repo-guard/README.md linguist-generated=true -.claude/hooks/cross-repo-guard/index.mts linguist-generated=true -.claude/hooks/cross-repo-guard/package.json linguist-generated=true -.claude/hooks/cross-repo-guard/test/cross-repo-guard.test.mts linguist-generated=true -.claude/hooks/cross-repo-guard/tsconfig.json linguist-generated=true -.claude/hooks/default-branch-guard/README.md linguist-generated=true -.claude/hooks/default-branch-guard/index.mts linguist-generated=true -.claude/hooks/default-branch-guard/package.json linguist-generated=true -.claude/hooks/default-branch-guard/test/index.test.mts linguist-generated=true -.claude/hooks/default-branch-guard/tsconfig.json linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/README.md linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/index.mts linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/package.json linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/dirty-worktree-on-stop-reminder/tsconfig.json linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/README.md linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/index.mts linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/package.json linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/dont-stop-mid-queue-reminder/tsconfig.json linguist-generated=true -.claude/hooks/drift-check-reminder/README.md linguist-generated=true -.claude/hooks/drift-check-reminder/index.mts linguist-generated=true -.claude/hooks/drift-check-reminder/package.json linguist-generated=true -.claude/hooks/drift-check-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/drift-check-reminder/tsconfig.json linguist-generated=true -.claude/hooks/error-message-quality-reminder/README.md linguist-generated=true -.claude/hooks/error-message-quality-reminder/index.mts linguist-generated=true -.claude/hooks/error-message-quality-reminder/package.json linguist-generated=true -.claude/hooks/error-message-quality-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/error-message-quality-reminder/tsconfig.json linguist-generated=true -.claude/hooks/excuse-detector/README.md linguist-generated=true -.claude/hooks/excuse-detector/index.mts linguist-generated=true -.claude/hooks/excuse-detector/package.json linguist-generated=true -.claude/hooks/excuse-detector/test/index.test.mts linguist-generated=true -.claude/hooks/excuse-detector/tsconfig.json linguist-generated=true -.claude/hooks/file-size-reminder/README.md linguist-generated=true -.claude/hooks/file-size-reminder/index.mts linguist-generated=true -.claude/hooks/file-size-reminder/package.json linguist-generated=true -.claude/hooks/file-size-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/file-size-reminder/tsconfig.json linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/README.md linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/index.mts linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/package.json linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/follow-direct-imperative-reminder/tsconfig.json linguist-generated=true -.claude/hooks/gitmodules-comment-guard/README.md linguist-generated=true -.claude/hooks/gitmodules-comment-guard/index.mts linguist-generated=true -.claude/hooks/gitmodules-comment-guard/package.json linguist-generated=true -.claude/hooks/gitmodules-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/gitmodules-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/identifying-users-reminder/README.md linguist-generated=true -.claude/hooks/identifying-users-reminder/index.mts linguist-generated=true -.claude/hooks/identifying-users-reminder/package.json linguist-generated=true -.claude/hooks/identifying-users-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/identifying-users-reminder/tsconfig.json linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/README.md linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/index.mts linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/package.json linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/test/index.test.mts linguist-generated=true -.claude/hooks/immutable-release-pattern-guard/tsconfig.json linguist-generated=true -.claude/hooks/inline-script-defer-guard/README.md linguist-generated=true -.claude/hooks/inline-script-defer-guard/index.mts linguist-generated=true -.claude/hooks/inline-script-defer-guard/package.json linguist-generated=true -.claude/hooks/inline-script-defer-guard/test/index.test.mts linguist-generated=true -.claude/hooks/inline-script-defer-guard/tsconfig.json linguist-generated=true -.claude/hooks/judgment-reminder/README.md linguist-generated=true -.claude/hooks/judgment-reminder/index.mts linguist-generated=true -.claude/hooks/judgment-reminder/package.json linguist-generated=true -.claude/hooks/judgment-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/judgment-reminder/tsconfig.json linguist-generated=true -.claude/hooks/lock-step-ref-guard/README.md linguist-generated=true -.claude/hooks/lock-step-ref-guard/index.mts linguist-generated=true -.claude/hooks/lock-step-ref-guard/package.json linguist-generated=true -.claude/hooks/lock-step-ref-guard/test/index.test.mts linguist-generated=true -.claude/hooks/lock-step-ref-guard/tsconfig.json linguist-generated=true -.claude/hooks/logger-guard/README.md linguist-generated=true -.claude/hooks/logger-guard/index.mts linguist-generated=true -.claude/hooks/logger-guard/package.json linguist-generated=true -.claude/hooks/logger-guard/test/logger-guard.test.mts linguist-generated=true -.claude/hooks/logger-guard/tsconfig.json linguist-generated=true -.claude/hooks/markdown-filename-guard/README.md linguist-generated=true -.claude/hooks/markdown-filename-guard/index.mts linguist-generated=true -.claude/hooks/markdown-filename-guard/package.json linguist-generated=true -.claude/hooks/markdown-filename-guard/test/index.test.mts linguist-generated=true -.claude/hooks/markdown-filename-guard/tsconfig.json linguist-generated=true -.claude/hooks/marketplace-comment-guard/README.md linguist-generated=true -.claude/hooks/marketplace-comment-guard/index.mts linguist-generated=true -.claude/hooks/marketplace-comment-guard/package.json linguist-generated=true -.claude/hooks/marketplace-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/marketplace-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/minify-mcp-output/README.md linguist-generated=true -.claude/hooks/minify-mcp-output/index.mts linguist-generated=true -.claude/hooks/minify-mcp-output/package.json linguist-generated=true -.claude/hooks/minify-mcp-output/test/index.test.mts linguist-generated=true -.claude/hooks/minify-mcp-output/tsconfig.json linguist-generated=true -.claude/hooks/minimum-release-age-guard/README.md linguist-generated=true -.claude/hooks/minimum-release-age-guard/index.mts linguist-generated=true -.claude/hooks/minimum-release-age-guard/package.json linguist-generated=true -.claude/hooks/minimum-release-age-guard/test/index.test.mts linguist-generated=true -.claude/hooks/minimum-release-age-guard/tsconfig.json linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/README.md linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/index.mts linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/package.json linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/test/index.test.mts linguist-generated=true -.claude/hooks/new-hook-claude-md-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/README.md linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/index.mts linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/package.json linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-blind-keychain-read-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-empty-commit-guard/README.md linguist-generated=true -.claude/hooks/no-empty-commit-guard/index.mts linguist-generated=true -.claude/hooks/no-empty-commit-guard/package.json linguist-generated=true -.claude/hooks/no-empty-commit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-empty-commit-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/README.md linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/index.mts linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/package.json linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-experimental-strip-types-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/README.md linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/index.mts linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/package.json linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-external-issue-ref-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/README.md linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/index.mts linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/package.json linguist-generated=true -.claude/hooks/no-file-scope-oxlint-disable-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-fleet-fork-guard/README.md linguist-generated=true -.claude/hooks/no-fleet-fork-guard/index.mts linguist-generated=true -.claude/hooks/no-fleet-fork-guard/package.json linguist-generated=true -.claude/hooks/no-fleet-fork-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-fleet-fork-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-meta-comments-guard/README.md linguist-generated=true -.claude/hooks/no-meta-comments-guard/index.mts linguist-generated=true -.claude/hooks/no-meta-comments-guard/package.json linguist-generated=true -.claude/hooks/no-meta-comments-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-meta-comments-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-orphaned-staging/README.md linguist-generated=true -.claude/hooks/no-orphaned-staging/index.mts linguist-generated=true -.claude/hooks/no-orphaned-staging/package.json linguist-generated=true -.claude/hooks/no-orphaned-staging/test/index.test.mts linguist-generated=true -.claude/hooks/no-orphaned-staging/tsconfig.json linguist-generated=true -.claude/hooks/no-revert-guard/README.md linguist-generated=true -.claude/hooks/no-revert-guard/index.mts linguist-generated=true -.claude/hooks/no-revert-guard/package.json linguist-generated=true -.claude/hooks/no-revert-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-revert-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/README.md linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/index.mts linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/package.json linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-structured-clone-prefer-json-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/README.md linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/index.mts linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/package.json linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-token-in-dotenv-guard/tsconfig.json linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/README.md linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/index.mts linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/package.json linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/test/index.test.mts linguist-generated=true -.claude/hooks/no-underscore-identifier-guard/tsconfig.json linguist-generated=true -.claude/hooks/node-modules-staging-guard/README.md linguist-generated=true -.claude/hooks/node-modules-staging-guard/index.mts linguist-generated=true -.claude/hooks/node-modules-staging-guard/package.json linguist-generated=true -.claude/hooks/node-modules-staging-guard/test/index.test.mts linguist-generated=true -.claude/hooks/node-modules-staging-guard/tsconfig.json linguist-generated=true -.claude/hooks/overeager-staging-guard/index.mts linguist-generated=true -.claude/hooks/overeager-staging-guard/package.json linguist-generated=true -.claude/hooks/overeager-staging-guard/test/index.test.mts linguist-generated=true -.claude/hooks/overeager-staging-guard/tsconfig.json linguist-generated=true -.claude/hooks/path-guard/README.md linguist-generated=true -.claude/hooks/path-guard/index.mts linguist-generated=true -.claude/hooks/path-guard/package.json linguist-generated=true -.claude/hooks/path-guard/segments.mts linguist-generated=true -.claude/hooks/path-guard/test/path-guard.test.mts linguist-generated=true -.claude/hooks/path-guard/tsconfig.json linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/README.md linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/index.mts linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/package.json linguist-generated=true -.claude/hooks/path-regex-normalize-reminder/tsconfig.json linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/README.md linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/index.mts linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/package.json linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/paths-mts-inherit-guard/tsconfig.json linguist-generated=true -.claude/hooks/perfectionist-reminder/README.md linguist-generated=true -.claude/hooks/perfectionist-reminder/index.mts linguist-generated=true -.claude/hooks/perfectionist-reminder/package.json linguist-generated=true -.claude/hooks/perfectionist-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/perfectionist-reminder/tsconfig.json linguist-generated=true -.claude/hooks/plan-location-guard/README.md linguist-generated=true -.claude/hooks/plan-location-guard/index.mts linguist-generated=true -.claude/hooks/plan-location-guard/package.json linguist-generated=true -.claude/hooks/plan-location-guard/test/index.test.mts linguist-generated=true -.claude/hooks/plan-location-guard/tsconfig.json linguist-generated=true -.claude/hooks/plan-review-reminder/README.md linguist-generated=true -.claude/hooks/plan-review-reminder/index.mts linguist-generated=true -.claude/hooks/plan-review-reminder/package.json linguist-generated=true -.claude/hooks/plan-review-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/plan-review-reminder/tsconfig.json linguist-generated=true -.claude/hooks/pointer-comment-guard/README.md linguist-generated=true -.claude/hooks/pointer-comment-guard/index.mts linguist-generated=true -.claude/hooks/pointer-comment-guard/package.json linguist-generated=true -.claude/hooks/pointer-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/pointer-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/README.md linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/index.mts linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/package.json linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/pr-vs-push-default-reminder/tsconfig.json linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/README.md linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/index.mts linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/package.json linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/test/index.test.mts linguist-generated=true -.claude/hooks/prefer-rebase-over-revert-guard/tsconfig.json linguist-generated=true -.claude/hooks/private-name-guard/README.md linguist-generated=true -.claude/hooks/private-name-guard/index.mts linguist-generated=true -.claude/hooks/private-name-guard/package.json linguist-generated=true -.claude/hooks/private-name-guard/test/private-name-guard.test.mts linguist-generated=true -.claude/hooks/private-name-guard/tsconfig.json linguist-generated=true -.claude/hooks/provenance-publish-reminder/README.md linguist-generated=true -.claude/hooks/provenance-publish-reminder/index.mts linguist-generated=true -.claude/hooks/provenance-publish-reminder/package.json linguist-generated=true -.claude/hooks/provenance-publish-reminder/tsconfig.json linguist-generated=true -.claude/hooks/public-surface-reminder/README.md linguist-generated=true -.claude/hooks/public-surface-reminder/index.mts linguist-generated=true -.claude/hooks/public-surface-reminder/package.json linguist-generated=true -.claude/hooks/public-surface-reminder/test/public-surface-reminder.test.mts linguist-generated=true -.claude/hooks/public-surface-reminder/tsconfig.json linguist-generated=true -.claude/hooks/pull-request-target-guard/README.md linguist-generated=true -.claude/hooks/pull-request-target-guard/index.mts linguist-generated=true -.claude/hooks/pull-request-target-guard/package.json linguist-generated=true -.claude/hooks/pull-request-target-guard/test/index.test.mts linguist-generated=true -.claude/hooks/pull-request-target-guard/tsconfig.json linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/README.md linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/index.mts linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/package.json linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/test/index.test.mts linguist-generated=true -.claude/hooks/readme-fleet-shape-guard/tsconfig.json linguist-generated=true -.claude/hooks/release-workflow-guard/README.md linguist-generated=true -.claude/hooks/release-workflow-guard/index.mts linguist-generated=true -.claude/hooks/release-workflow-guard/package.json linguist-generated=true -.claude/hooks/release-workflow-guard/test/release-workflow-guard.test.mts linguist-generated=true -.claude/hooks/release-workflow-guard/tsconfig.json linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/README.md linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/index.mts linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/package.json linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/test/index.test.mts linguist-generated=true -.claude/hooks/scan-label-in-commit-guard/tsconfig.json linguist-generated=true -.claude/hooks/setup-basics-tools/README.md linguist-generated=true -.claude/hooks/setup-basics-tools/install.mts linguist-generated=true -.claude/hooks/setup-basics-tools/package.json linguist-generated=true -.claude/hooks/setup-basics-tools/tsconfig.json linguist-generated=true -.claude/hooks/setup-claude-scanners/README.md linguist-generated=true -.claude/hooks/setup-claude-scanners/install.mts linguist-generated=true -.claude/hooks/setup-claude-scanners/package.json linguist-generated=true -.claude/hooks/setup-claude-scanners/tsconfig.json linguist-generated=true -.claude/hooks/setup-firewall/README.md linguist-generated=true -.claude/hooks/setup-firewall/install.mts linguist-generated=true -.claude/hooks/setup-firewall/package.json linguist-generated=true -.claude/hooks/setup-firewall/tsconfig.json linguist-generated=true -.claude/hooks/setup-misc-tools/README.md linguist-generated=true -.claude/hooks/setup-misc-tools/install.mts linguist-generated=true -.claude/hooks/setup-misc-tools/package.json linguist-generated=true -.claude/hooks/setup-misc-tools/tsconfig.json linguist-generated=true -.claude/hooks/setup-security-tools/README.md linguist-generated=true -.claude/hooks/setup-security-tools/external-tools.json linguist-generated=true -.claude/hooks/setup-security-tools/index.mts linguist-generated=true -.claude/hooks/setup-security-tools/install.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/api-token.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/installers.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/operator-prompts.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/shell-rc-bridge.mts linguist-generated=true -.claude/hooks/setup-security-tools/lib/token-storage.mts linguist-generated=true -.claude/hooks/setup-security-tools/package.json linguist-generated=true -.claude/hooks/setup-security-tools/test/setup-security-tools.test.mts linguist-generated=true -.claude/hooks/setup-security-tools/test/shell-rc-bridge.test.mts linguist-generated=true -.claude/hooks/setup-security-tools/tsconfig.json linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/README.md linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/index.mts linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/package.json linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts linguist-generated=true -.claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json linguist-generated=true -.claude/hooks/socket-token-minifier-start/README.md linguist-generated=true -.claude/hooks/socket-token-minifier-start/index.mts linguist-generated=true -.claude/hooks/socket-token-minifier-start/package.json linguist-generated=true -.claude/hooks/socket-token-minifier-start/tsconfig.json linguist-generated=true -.claude/hooks/squash-history-reminder/README.md linguist-generated=true -.claude/hooks/squash-history-reminder/index.mts linguist-generated=true -.claude/hooks/squash-history-reminder/package.json linguist-generated=true -.claude/hooks/squash-history-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/squash-history-reminder/tsconfig.json linguist-generated=true -.claude/hooks/stale-process-sweeper/README.md linguist-generated=true -.claude/hooks/stale-process-sweeper/index.mts linguist-generated=true -.claude/hooks/stale-process-sweeper/package.json linguist-generated=true -.claude/hooks/stale-process-sweeper/test/stale-process-sweeper.test.mts linguist-generated=true -.claude/hooks/stale-process-sweeper/tsconfig.json linguist-generated=true -.claude/hooks/sweep-ds-store/README.md linguist-generated=true -.claude/hooks/sweep-ds-store/index.mts linguist-generated=true -.claude/hooks/sweep-ds-store/package.json linguist-generated=true -.claude/hooks/sweep-ds-store/test/index.test.mts linguist-generated=true -.claude/hooks/sweep-ds-store/tsconfig.json linguist-generated=true -.claude/hooks/token-guard/README.md linguist-generated=true -.claude/hooks/token-guard/index.mts linguist-generated=true -.claude/hooks/token-guard/package.json linguist-generated=true -.claude/hooks/token-guard/test/token-guard.test.mts linguist-generated=true -.claude/hooks/token-guard/tsconfig.json linguist-generated=true -.claude/hooks/variant-analysis-reminder/README.md linguist-generated=true -.claude/hooks/variant-analysis-reminder/index.mts linguist-generated=true -.claude/hooks/variant-analysis-reminder/package.json linguist-generated=true -.claude/hooks/variant-analysis-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/variant-analysis-reminder/tsconfig.json linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/README.md linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/index.mts linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/package.json linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/test/index.test.mts linguist-generated=true -.claude/hooks/verify-rendered-output-before-commit-reminder/tsconfig.json linguist-generated=true -.claude/hooks/version-bump-order-guard/README.md linguist-generated=true -.claude/hooks/version-bump-order-guard/index.mts linguist-generated=true -.claude/hooks/version-bump-order-guard/package.json linguist-generated=true -.claude/hooks/version-bump-order-guard/test/index.test.mts linguist-generated=true -.claude/hooks/version-bump-order-guard/tsconfig.json linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/README.md linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/index.mts linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/package.json linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/test/index.test.mts linguist-generated=true -.claude/hooks/vitest-include-vs-node-test-guard/tsconfig.json linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/README.md linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/index.mts linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/package.json linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/test/index.test.mts linguist-generated=true -.claude/hooks/workflow-uses-comment-guard/tsconfig.json linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/README.md linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/index.mts linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/package.json linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/test/index.test.mts linguist-generated=true -.claude/hooks/workflow-yaml-multiline-body-guard/tsconfig.json linguist-generated=true -.claude/settings.json linguist-generated=true -.claude/skills/_shared/compound-lessons.md linguist-generated=true -.claude/skills/_shared/env-check.md linguist-generated=true -.claude/skills/_shared/multi-agent-backends.md linguist-generated=true -.claude/skills/_shared/path-guard-rule.md linguist-generated=true -.claude/skills/_shared/report-format.md linguist-generated=true -.claude/skills/_shared/scripts/git-default-branch.mts linguist-generated=true -.claude/skills/_shared/scripts/logger-guardrails.mts linguist-generated=true -.claude/skills/_shared/scripts/resolve-tools.mts linguist-generated=true -.claude/skills/_shared/security-tools.md linguist-generated=true -.claude/skills/_shared/skill-authoring.md linguist-generated=true -.claude/skills/_shared/variant-analysis.md linguist-generated=true -.claude/skills/_shared/verify-build.md linguist-generated=true -.claude/skills/auditing-gha-settings/SKILL.md linguist-generated=true -.claude/skills/auditing-gha-settings/run.mts linguist-generated=true -.claude/skills/cascading-fleet/SKILL.md linguist-generated=true -.claude/skills/cascading-fleet/lib/cascade-template.mts linguist-generated=true -.claude/skills/cascading-fleet/lib/fleet-repos.json linguist-generated=true -.claude/skills/cascading-fleet/lib/fleet-repos.txt linguist-generated=true -.claude/skills/cleaning-redundant-ci/SKILL.md linguist-generated=true -.claude/skills/driving-cursor-bugbot/SKILL.md linguist-generated=true -.claude/skills/driving-cursor-bugbot/reference.md linguist-generated=true -.claude/skills/greening-ci/SKILL.md linguist-generated=true -.claude/skills/greening-ci/run.mts linguist-generated=true -.claude/skills/guarding-paths/SKILL.md linguist-generated=true -.claude/skills/guarding-paths/reference.md linguist-generated=true -.claude/skills/guarding-paths/templates/check-paths.mts.tmpl linguist-generated=true -.claude/skills/guarding-paths/templates/paths-allowlist.yml.tmpl linguist-generated=true -.claude/skills/handing-off/SKILL.md linguist-generated=true -.claude/skills/locking-down-programmatic-claude/SKILL.md linguist-generated=true -.claude/skills/plug-leaking-promise-race/SKILL.md linguist-generated=true -.claude/skills/prose/SKILL.md linguist-generated=true -.claude/skills/prose/references/examples.md linguist-generated=true -.claude/skills/prose/references/phrases.md linguist-generated=true -.claude/skills/prose/references/structures.md linguist-generated=true -.claude/skills/refreshing-history/SKILL.md linguist-generated=true -.claude/skills/refreshing-history/run.mts linguist-generated=true -.claude/skills/reviewing-code/SKILL.md linguist-generated=true -.claude/skills/reviewing-code/run.mts linguist-generated=true -.claude/skills/running-test262/SKILL.md linguist-generated=true -.claude/skills/scanning-quality/SKILL.md linguist-generated=true -.claude/skills/scanning-quality/scans/bundle-trim.md linguist-generated=true -.claude/skills/scanning-quality/scans/differential.md linguist-generated=true -.claude/skills/scanning-quality/scans/insecure-defaults.md linguist-generated=true -.claude/skills/scanning-quality/scans/variant-analysis.md linguist-generated=true -.claude/skills/scanning-security/SKILL.md linguist-generated=true -.claude/skills/squashing-history/SKILL.md linguist-generated=true -.claude/skills/squashing-history/reference.md linguist-generated=true -.claude/skills/updating-coverage/SKILL.md linguist-generated=true -.claude/skills/updating-lockstep/SKILL.md linguist-generated=true -.claude/skills/updating-lockstep/reference.md linguist-generated=true -.claude/skills/updating-security/SKILL.md linguist-generated=true -.claude/skills/updating-security/reference.md linguist-generated=true -.claude/skills/updating/SKILL.md linguist-generated=true -.claude/skills/updating/reference.md linguist-generated=true -.claude/skills/worktree-management/SKILL.md linguist-generated=true -.config/.markdownlint-cli2.jsonc linguist-generated=true -.config/.prettierignore linguist-generated=true -.config/lockstep.schema.json linguist-generated=true -.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs linguist-generated=true -.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs linguist-generated=true -.config/markdownlint-rules/socket-no-relative-sibling-script.mjs linguist-generated=true -.config/markdownlint-rules/socket-readme-required-sections.mjs linguist-generated=true -.config/oxlint-plugin/index.mts linguist-generated=true -.config/oxlint-plugin/lib/detect-source-type.mts linguist-generated=true -.config/oxlint-plugin/lib/fleet-paths.mts linguist-generated=true -.config/oxlint-plugin/lib/iterable-kind.mts linguist-generated=true -.config/oxlint-plugin/lib/rule-tester.mts linguist-generated=true -.config/oxlint-plugin/lib/rule-types.mts linguist-generated=true -.config/oxlint-plugin/package.json linguist-generated=true -.config/oxlint-plugin/rules/_inject-import.mts linguist-generated=true -.config/oxlint-plugin/rules/export-top-level-functions.mts linguist-generated=true -.config/oxlint-plugin/rules/inclusive-language.mts linguist-generated=true -.config/oxlint-plugin/rules/max-file-lines.mts linguist-generated=true -.config/oxlint-plugin/rules/no-bare-crypto-named-usage.mts linguist-generated=true -.config/oxlint-plugin/rules/no-cached-for-on-iterable.mts linguist-generated=true -.config/oxlint-plugin/rules/no-console-prefer-logger.mts linguist-generated=true -.config/oxlint-plugin/rules/no-default-export.mts linguist-generated=true -.config/oxlint-plugin/rules/no-dynamic-import-outside-bundle.mts linguist-generated=true -.config/oxlint-plugin/rules/no-eslint-biome-config-ref.mts linguist-generated=true -.config/oxlint-plugin/rules/no-fetch-prefer-http-request.mts linguist-generated=true -.config/oxlint-plugin/rules/no-file-scope-oxlint-disable.mts linguist-generated=true -.config/oxlint-plugin/rules/no-inline-defer-async.mts linguist-generated=true -.config/oxlint-plugin/rules/no-inline-logger.mts linguist-generated=true -.config/oxlint-plugin/rules/no-logger-newline-literal.mts linguist-generated=true -.config/oxlint-plugin/rules/no-npx-dlx.mts linguist-generated=true -.config/oxlint-plugin/rules/no-placeholders.mts linguist-generated=true -.config/oxlint-plugin/rules/no-process-cwd-in-scripts-hooks.mts linguist-generated=true -.config/oxlint-plugin/rules/no-promise-race-in-loop.mts linguist-generated=true -.config/oxlint-plugin/rules/no-promise-race.mts linguist-generated=true -.config/oxlint-plugin/rules/no-status-emoji.mts linguist-generated=true -.config/oxlint-plugin/rules/no-structured-clone-prefer-json.mts linguist-generated=true -.config/oxlint-plugin/rules/no-sync-rm-in-test-lifecycle.mts linguist-generated=true -.config/oxlint-plugin/rules/no-underscore-identifier.mts linguist-generated=true -.config/oxlint-plugin/rules/optional-explicit-undefined.mts linguist-generated=true -.config/oxlint-plugin/rules/personal-path-placeholders.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-async-spawn.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-cached-for-loop.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-env-as-boolean.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-exists-sync.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-function-declaration.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-node-builtin-imports.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-node-modules-dot-cache.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-non-capturing-group.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-safe-delete.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-separate-type-import.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-spawn-over-execsync.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-static-type-import.mts linguist-generated=true -.config/oxlint-plugin/rules/prefer-undefined-over-null.mts linguist-generated=true -.config/oxlint-plugin/rules/socket-api-token-env.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-boolean-chains.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-equality-disjunctions.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-named-imports.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-regex-alternations.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-set-args.mts linguist-generated=true -.config/oxlint-plugin/rules/sort-source-methods.mts linguist-generated=true -.config/oxlint-plugin/rules/use-fleet-canonical-api-token-getter.mts linguist-generated=true -.config/oxlint-plugin/test/export-top-level-functions.test.mts linguist-generated=true -.config/oxlint-plugin/test/inclusive-language.test.mts linguist-generated=true -.config/oxlint-plugin/test/max-file-lines.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-bare-crypto-named-usage.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-cached-for-on-iterable.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-console-prefer-logger.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-default-export.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-dynamic-import-outside-bundle.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-eslint-biome-config-ref.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-fetch-prefer-http-request.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-inline-defer-async.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-inline-logger.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-logger-newline-literal.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-npx-dlx.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-placeholders.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-process-cwd-in-scripts-hooks.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-promise-race-in-loop.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-promise-race.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-status-emoji.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-structured-clone-prefer-json.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-sync-rm-in-test-lifecycle.test.mts linguist-generated=true -.config/oxlint-plugin/test/no-underscore-identifier.test.mts linguist-generated=true -.config/oxlint-plugin/test/optional-explicit-undefined.test.mts linguist-generated=true -.config/oxlint-plugin/test/personal-path-placeholders.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-async-spawn.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-env-as-boolean.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-exists-sync.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-function-declaration.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-node-builtin-imports.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-node-modules-dot-cache.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-non-capturing-group.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-safe-delete.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-separate-type-import.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-static-type-import.test.mts linguist-generated=true -.config/oxlint-plugin/test/prefer-undefined-over-null.test.mts linguist-generated=true -.config/oxlint-plugin/test/socket-api-token-env.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-boolean-chains.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-equality-disjunctions.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-named-imports.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-regex-alternations.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-set-args.test.mts linguist-generated=true -.config/oxlint-plugin/test/sort-source-methods.test.mts linguist-generated=true -.config/oxlint-plugin/test/use-fleet-canonical-api-token-getter.test.mts linguist-generated=true -.config/rolldown/lib-stub.mts linguist-generated=true -.config/sfw-bypass-list.txt linguist-generated=true -.config/socket-registry-pins.json linguist-generated=true -.config/socket-wheelhouse-schema.json linguist-generated=true -.config/taze.config.mts linguist-generated=true -.config/tsconfig.base.json linguist-generated=true -.git-hooks/_helpers.mts linguist-generated=true -.git-hooks/commit-msg linguist-generated=true -.git-hooks/commit-msg.mts linguist-generated=true -.git-hooks/pre-commit linguist-generated=true -.git-hooks/pre-commit.mts linguist-generated=true -.git-hooks/pre-push linguist-generated=true -.git-hooks/pre-push.mts linguist-generated=true -.git-hooks/test/_helpers.test.mts linguist-generated=true -.git-hooks/test/commit-msg.test.mts linguist-generated=true -.git-hooks/test/pre-commit.test.mts linguist-generated=true -.git-hooks/test/pre-push.test.mts linguist-generated=true -.github/dependabot.yml linguist-generated=true -assets/README.md linguist-generated=true -assets/socket-icon-brand-128.png linguist-generated=true -assets/socket-icon-brand-16.png linguist-generated=true -assets/socket-icon-brand-256.png linguist-generated=true -assets/socket-icon-brand-32.png linguist-generated=true -assets/socket-icon-brand-512.png linguist-generated=true -assets/socket-icon-brand-64.png linguist-generated=true -assets/socket-icon-brand-square.svg linguist-generated=true -assets/socket-icon-brand.svg linguist-generated=true -assets/socket-icon-shield-square.svg linguist-generated=true -assets/socket-icon-shield.svg linguist-generated=true -assets/socket-icon-square.svg linguist-generated=true -assets/socket-icon.svg linguist-generated=true -assets/socket-logo-dark-1680.png linguist-generated=true -assets/socket-logo-dark-420.png linguist-generated=true -assets/socket-logo-dark-840.png linguist-generated=true -assets/socket-logo-dark.svg linguist-generated=true -assets/socket-logo-light-1680.png linguist-generated=true -assets/socket-logo-light-420.png linguist-generated=true -assets/socket-logo-light-840.png linguist-generated=true -assets/socket-logo-light.svg linguist-generated=true -docs/claude.md/fleet/agent-delegation.md linguist-generated=true -docs/claude.md/fleet/agents-and-skills.md linguist-generated=true -docs/claude.md/fleet/bypass-phrases.md linguist-generated=true -docs/claude.md/fleet/code-style.md linguist-generated=true -docs/claude.md/fleet/conformance-runners.md linguist-generated=true -docs/claude.md/fleet/drift-watch.md linguist-generated=true -docs/claude.md/fleet/error-messages.md linguist-generated=true -docs/claude.md/fleet/file-size.md linguist-generated=true -docs/claude.md/fleet/immutable-releases.md linguist-generated=true -docs/claude.md/fleet/inclusive-language.md linguist-generated=true -docs/claude.md/fleet/lint-rules.md linguist-generated=true -docs/claude.md/fleet/parallel-claude-sessions.md linguist-generated=true -docs/claude.md/fleet/parser-comments.md linguist-generated=true -docs/claude.md/fleet/path-hygiene.md linguist-generated=true -docs/claude.md/fleet/plan-storage.md linguist-generated=true -docs/claude.md/fleet/socket-bypass-markers.md linguist-generated=true -docs/claude.md/fleet/sorting.md linguist-generated=true -docs/claude.md/fleet/token-hygiene.md linguist-generated=true -docs/claude.md/fleet/tooling.md linguist-generated=true -docs/claude.md/fleet/untracked-by-default.md linguist-generated=true -docs/claude.md/fleet/version-bumps.md linguist-generated=true -docs/claude.md/fleet/worktree-hygiene.md linguist-generated=true -docs/claude.md/wheelhouse/no-local-fork-canonical.md linguist-generated=true -packages/build-infra/lib/release-checksums/consumer.mts linguist-generated=true -packages/build-infra/lib/release-checksums/core.mts linguist-generated=true -packages/build-infra/lib/release-checksums/producer.mts linguist-generated=true -packages/build-infra/release-assets.schema.json linguist-generated=true -scripts/ai-lint-fix.mts linguist-generated=true -scripts/ai-lint-fix/cli.mts linguist-generated=true -scripts/ai-lint-fix/rule-guidance.mts linguist-generated=true -scripts/check-lock-step-header.mts linguist-generated=true -scripts/check-lock-step-refs.mts linguist-generated=true -scripts/check-paths.mts linguist-generated=true -scripts/check-paths/allowlist.mts linguist-generated=true -scripts/check-paths/cli.mts linguist-generated=true -scripts/check-paths/exempt.mts linguist-generated=true -scripts/check-paths/rules.mts linguist-generated=true -scripts/check-paths/scan-code.mts linguist-generated=true -scripts/check-paths/scan-script.mts linguist-generated=true -scripts/check-paths/scan-workflow.mts linguist-generated=true -scripts/check-paths/state.mts linguist-generated=true -scripts/check-paths/types.mts linguist-generated=true -scripts/check-paths/walk.mts linguist-generated=true -scripts/check-prompt-less-setup.mts linguist-generated=true -scripts/check-provenance.mts linguist-generated=true -scripts/check-soak-exclude-dates.mts linguist-generated=true -scripts/fix.mts linguist-generated=true -scripts/git-partial-submodule.mts linguist-generated=true -scripts/install-claude-plugins.mts linguist-generated=true -scripts/install-git-hooks.mts linguist-generated=true -scripts/install-sfw.mts linguist-generated=true -scripts/install-token-minifier.mts linguist-generated=true -scripts/janus.mts linguist-generated=true -scripts/lint-github-settings.mts linguist-generated=true -scripts/lockstep-emit-schema.mts linguist-generated=true -scripts/lockstep-schema.mts linguist-generated=true -scripts/lockstep.mts linguist-generated=true -scripts/lockstep/checks.mts linguist-generated=true -scripts/lockstep/cli.mts linguist-generated=true -scripts/lockstep/emit-schema.mts linguist-generated=true -scripts/lockstep/git.mts linguist-generated=true -scripts/lockstep/manifest.mts linguist-generated=true -scripts/lockstep/report.mts linguist-generated=true -scripts/lockstep/scan.mts linguist-generated=true -scripts/lockstep/schema.mts linguist-generated=true -scripts/lockstep/types.mts linguist-generated=true -scripts/power-state.mts linguist-generated=true -scripts/publish-release.mts linguist-generated=true -scripts/publish-shared.mts linguist-generated=true -scripts/publish.mts linguist-generated=true -scripts/security.mts linguist-generated=true -scripts/socket-wheelhouse-emit-schema.mts linguist-generated=true -scripts/socket-wheelhouse-schema.mts linguist-generated=true -scripts/test/check-lock-step-header.test.mts linguist-generated=true -scripts/test/check-lock-step-refs.test.mts linguist-generated=true -scripts/test/install-claude-plugins.test.mts linguist-generated=true -scripts/test/install-git-hooks.test.mts linguist-generated=true -scripts/update.mts linguist-generated=true -scripts/validate-bundle-deps.mts linguist-generated=true -scripts/validate-config-paths.mts linguist-generated=true -scripts/validate-esbuild-minify.mts linguist-generated=true -scripts/validate-file-size.mts linguist-generated=true - -# Vendored binary blobs (no diff, no merge, no text-mode). -.claude/hooks/_shared/acorn/acorn.wasm binary -template/.claude/hooks/_shared/acorn/acorn.wasm binary -# ─── END fleet-canonical ──────────────────────────────────────── +* text=auto eol=lfs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 137a3c846..e218639c1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,12 @@ -# Dependabot disabled - we manage dependencies manually -# Using open-pull-requests-limit: 0 to disable version updates -# See: https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates version: 2 updates: - - package-ecosystem: npm - directory: / + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: yearly - open-pull-requests-limit: 0 - cooldown: - default-days: 7 + interval: 'weekly' + day: 'monday' + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' diff --git a/.github/paths-allowlist.yml b/.github/paths-allowlist.yml deleted file mode 100644 index 11ce3257f..000000000 --- a/.github/paths-allowlist.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Path-hygiene gate allowlist. -# Mantra: 1 path, 1 reference. -# -# Each entry exempts a specific finding from `scripts/check-paths.mts`. -# Entries MUST carry a `reason` so the list stays audit-able and -# entries can be removed when the underlying code changes. -# -# Schema (all top-level keys optional except `reason`): -# -# - rule: Rule letter (A, B, C, D, F, G). Omit to match any rule. -# file: Substring match against the relative file path. -# pattern: Substring match against the offending snippet. -# line: Exact line number. Strict — no fuzz tolerance. -# snippet_hash: 12-char SHA-256 prefix of the normalized snippet -# (whitespace collapsed). Drift-resistant: the entry -# keeps matching after reformatting that doesn't -# change the offending construction. Get the hash by -# running `node scripts/check-paths.mts --show-hashes`. -# reason: Why this site is genuinely exempt. Required. -# -# Match policy: if `line` is provided it must match exactly. If -# `snippet_hash` is provided it must match exactly. Both may be set — -# either one matching is sufficient (so a code reformat that keeps -# the snippet but moves the line still matches via hash, and a -# reformat that changes the snippet but keeps the line still matches -# via line). If neither is set, `file` + `pattern` + `rule` matching -# is used (broader; prefer narrow entries when possible). -# -# Prefer narrow entries (rule + file + snippet_hash + pattern) over -# blanket `file:` entries that exempt the whole file. Genuine -# exemptions are rare — most "false positives" should be reported -# as gate bugs. -# -# Example: -# -# - rule: A -# file: packages/foo/scripts/legacy-build.mts -# snippet_hash: a1b2c3d4e5f6 -# pattern: "path.join(testDir, 'out', 'Final')" -# reason: | -# legacy-build.mts is scheduled for removal in v2.0; refactoring -# its path construction now would conflict with the rewrite. - -# (No allowlist entries yet — socket-btm is meant to be clean.) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a63df7187..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,344 +0,0 @@ -name: 🚀 CI - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -on: - push: - branches: [main] - tags: ['*'] - paths: - - 'packages/cli/**' - - 'pnpm-lock.yaml' - - 'package.json' - - '.github/workflows/ci.yml' - pull_request: - branches: [main] - paths: - - 'packages/cli/**' - - 'pnpm-lock.yaml' - - 'package.json' - - '.github/workflows/ci.yml' - workflow_dispatch: - inputs: - force: - description: 'Force rebuild (ignore cache)' - type: boolean - default: false - node-versions: - description: 'Node.js versions to test (JSON array)' - required: false - type: string - # Default should match .node-version file. - default: '["25"]' - -permissions: {} - -jobs: - versions: - name: Load Tool Versions - runs-on: ubuntu-latest - permissions: - contents: read # Read .node-version file from repository. - outputs: - node: ${{ steps.versions.outputs.node }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - name: Load Node.js version from .node-version - id: versions - shell: bash - run: | - NODE_VERSION=$(cat .node-version) - echo "node=[\"$NODE_VERSION\"]" >> $GITHUB_OUTPUT - echo "Loaded Node.js: $NODE_VERSION" - - # Lint and type check jobs (run in parallel). - lint: - name: 🧹 Lint Check - needs: versions - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - name: Create stub packages - run: | - mkdir -p packages/package-builder/build/dev/out/socketaddon-iocraft - echo '{"name":"@socketaddon/iocraft","version":"0.0.0","type":"module","main":"./index.mjs","types":"./index.d.ts"}' > packages/package-builder/build/dev/out/socketaddon-iocraft/package.json - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.d.ts << 'TYPES' - export interface ComponentNode { type: string; children?: ComponentNode[]; content?: string; [key: string]: any } - export function text(content: string): ComponentNode - export function view(children: ComponentNode[]): ComponentNode - export function renderToString(tree: ComponentNode): string - export function renderToStringWithWidth(tree: ComponentNode, maxWidth: number): string - export function printComponent(tree: ComponentNode): void - export function eprintComponent(tree: ComponentNode): void - export function getTerminalSize(): [number, number] - export class TuiRenderer { constructor(); setTree(tree: ComponentNode): Promise<void>; isRunning(): boolean; getSize(): [number, number]; renderOnce(): Promise<string>; renderWithWidth(maxWidth: number): Promise<string>; print(): Promise<void>; eprint(): Promise<void> } - export function init(): void - declare const iocraft: { text: typeof text; view: typeof view; renderToString: typeof renderToString; renderToStringWithWidth: typeof renderToStringWithWidth; printComponent: typeof printComponent; eprintComponent: typeof eprintComponent; getTerminalSize: typeof getTerminalSize; TuiRenderer: typeof TuiRenderer; init: typeof init } - export default iocraft - TYPES - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.mjs << 'CODE' - export const text = (content) => ({ type: 'Text', content }) - export const view = (children) => ({ type: 'View', children }) - const extractText = (node) => { - if (!node) return '' - if (node.type === 'Text') return node.content || '' - if (node.children) return node.children.map(extractText).join('') - return '' - } - export const renderToString = (tree) => extractText(tree) - export const renderToStringWithWidth = (tree, width) => extractText(tree) - export const printComponent = (tree) => console.log(renderToString(tree)) - export const eprintComponent = (tree) => console.error(renderToString(tree)) - export const getTerminalSize = () => [80, 24] - export class TuiRenderer { setTree(t) { return Promise.resolve() } isRunning() { return false } getSize() { return [80, 24] } renderOnce() { return Promise.resolve('') } renderWithWidth(w) { return Promise.resolve('') } print() { return Promise.resolve() } eprint() { return Promise.resolve() } } - export const init = () => {} - export default { text, view, renderToString, renderToStringWithWidth, printComponent, eprintComponent, getTerminalSize, TuiRenderer, init } - CODE - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - - - name: Run lint - shell: bash - run: | - pnpm --filter @socketsecurity/cli run check - - type-check: - name: 🔍 Type Check - needs: versions - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - name: Create stub packages - run: | - mkdir -p packages/package-builder/build/dev/out/socketaddon-iocraft - echo '{"name":"@socketaddon/iocraft","version":"0.0.0","type":"module","main":"./index.mjs","types":"./index.d.ts"}' > packages/package-builder/build/dev/out/socketaddon-iocraft/package.json - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.d.ts << 'TYPES' - export interface ComponentNode { type: string; children?: ComponentNode[]; content?: string; [key: string]: any } - export function text(content: string): ComponentNode - export function view(children: ComponentNode[]): ComponentNode - export function renderToString(tree: ComponentNode): string - export function renderToStringWithWidth(tree: ComponentNode, maxWidth: number): string - export function printComponent(tree: ComponentNode): void - export function eprintComponent(tree: ComponentNode): void - export function getTerminalSize(): [number, number] - export class TuiRenderer { constructor(); setTree(tree: ComponentNode): Promise<void>; isRunning(): boolean; getSize(): [number, number]; renderOnce(): Promise<string>; renderWithWidth(maxWidth: number): Promise<string>; print(): Promise<void>; eprint(): Promise<void> } - export function init(): void - declare const iocraft: { text: typeof text; view: typeof view; renderToString: typeof renderToString; renderToStringWithWidth: typeof renderToStringWithWidth; printComponent: typeof printComponent; eprintComponent: typeof eprintComponent; getTerminalSize: typeof getTerminalSize; TuiRenderer: typeof TuiRenderer; init: typeof init } - export default iocraft - TYPES - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.mjs << 'CODE' - export const text = (content) => ({ type: 'Text', content }) - export const view = (children) => ({ type: 'View', children }) - const extractText = (node) => { - if (!node) return '' - if (node.type === 'Text') return node.content || '' - if (node.children) return node.children.map(extractText).join('') - return '' - } - export const renderToString = (tree) => extractText(tree) - export const renderToStringWithWidth = (tree, width) => extractText(tree) - export const printComponent = (tree) => console.log(renderToString(tree)) - export const eprintComponent = (tree) => console.error(renderToString(tree)) - export const getTerminalSize = () => [80, 24] - export class TuiRenderer { setTree(t) { return Promise.resolve() } isRunning() { return false } getSize() { return [80, 24] } renderOnce() { return Promise.resolve('') } renderWithWidth(w) { return Promise.resolve('') } print() { return Promise.resolve() } eprint() { return Promise.resolve() } } - export const init = () => {} - export default { text, view, renderToString, renderToStringWithWidth, printComponent, eprintComponent, getTerminalSize, TuiRenderer, init } - CODE - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - - - name: Run type check - shell: bash - run: | - pnpm --filter @socketsecurity/cli run type - - # Sharded unit tests for faster CI. - test-sharded: - name: Unit Tests (Shard ${{ matrix.shard }}/3) - needs: [lint, type-check, versions] - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 4 - matrix: - node-version: ${{ fromJSON(inputs.node-versions || needs.versions.outputs.node) }} - shard: [1, 2, 3] - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - name: Create stub packages - run: | - mkdir -p packages/package-builder/build/dev/out/socketaddon-iocraft - echo '{"name":"@socketaddon/iocraft","version":"0.0.0","type":"module","main":"./index.mjs","types":"./index.d.ts"}' > packages/package-builder/build/dev/out/socketaddon-iocraft/package.json - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.d.ts << 'TYPES' - export interface ComponentNode { type: string; children?: ComponentNode[]; content?: string; [key: string]: any } - export function text(content: string): ComponentNode - export function view(children: ComponentNode[]): ComponentNode - export function renderToString(tree: ComponentNode): string - export function renderToStringWithWidth(tree: ComponentNode, maxWidth: number): string - export function printComponent(tree: ComponentNode): void - export function eprintComponent(tree: ComponentNode): void - export function getTerminalSize(): [number, number] - export class TuiRenderer { constructor(); setTree(tree: ComponentNode): Promise<void>; isRunning(): boolean; getSize(): [number, number]; renderOnce(): Promise<string>; renderWithWidth(maxWidth: number): Promise<string>; print(): Promise<void>; eprint(): Promise<void> } - export function init(): void - declare const iocraft: { text: typeof text; view: typeof view; renderToString: typeof renderToString; renderToStringWithWidth: typeof renderToStringWithWidth; printComponent: typeof printComponent; eprintComponent: typeof eprintComponent; getTerminalSize: typeof getTerminalSize; TuiRenderer: typeof TuiRenderer; init: typeof init } - export default iocraft - TYPES - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.mjs << 'CODE' - export const text = (content) => ({ type: 'Text', content }) - export const view = (children) => ({ type: 'View', children }) - const extractText = (node) => { - if (!node) return '' - if (node.type === 'Text') return node.content || '' - if (node.children) return node.children.map(extractText).join('') - return '' - } - export const renderToString = (tree) => extractText(tree) - export const renderToStringWithWidth = (tree, width) => extractText(tree) - export const printComponent = (tree) => console.log(renderToString(tree)) - export const eprintComponent = (tree) => console.error(renderToString(tree)) - export const getTerminalSize = () => [80, 24] - export class TuiRenderer { setTree(t) { return Promise.resolve() } isRunning() { return false } getSize() { return [80, 24] } renderOnce() { return Promise.resolve('') } renderWithWidth(w) { return Promise.resolve('') } print() { return Promise.resolve() } eprint() { return Promise.resolve() } } - export const init = () => {} - export default { text, view, renderToString, renderToStringWithWidth, printComponent, eprintComponent, getTerminalSize, TuiRenderer, init } - CODE - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - node-version: ${{ matrix.node-version }} - - - name: Build CLI - working-directory: packages/cli - shell: bash - env: - # download-assets.mts hits the GitHub releases API for - # binject / node-smol / iocraft. Anonymous calls share the - # 60-req/hr public quota and get 403 once exhausted; the - # auto-provided GITHUB_TOKEN gives this job its own 1000/hr - # bucket. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - pnpm run build - - - name: Run unit tests (shard ${{ matrix.shard }}) - working-directory: packages/cli - shell: bash - env: - SHARD: ${{ matrix.shard }} - run: | - pnpm test:unit --shard="$SHARD"/3 - - # E2E tests - e2e: - name: E2E Tests (Shard ${{ matrix.shard }}/2) - needs: [lint, type-check, versions] - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 4 - matrix: - node-version: ${{ fromJSON(inputs.node-versions || needs.versions.outputs.node) }} - shard: [1, 2] - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - name: Create stub packages - run: | - mkdir -p packages/package-builder/build/dev/out/socketaddon-iocraft - echo '{"name":"@socketaddon/iocraft","version":"0.0.0","type":"module","main":"./index.mjs","types":"./index.d.ts"}' > packages/package-builder/build/dev/out/socketaddon-iocraft/package.json - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.d.ts << 'TYPES' - export interface ComponentNode { type: string; children?: ComponentNode[]; content?: string; [key: string]: any } - export function text(content: string): ComponentNode - export function view(children: ComponentNode[]): ComponentNode - export function renderToString(tree: ComponentNode): string - export function renderToStringWithWidth(tree: ComponentNode, maxWidth: number): string - export function printComponent(tree: ComponentNode): void - export function eprintComponent(tree: ComponentNode): void - export function getTerminalSize(): [number, number] - export class TuiRenderer { constructor(); setTree(tree: ComponentNode): Promise<void>; isRunning(): boolean; getSize(): [number, number]; renderOnce(): Promise<string>; renderWithWidth(maxWidth: number): Promise<string>; print(): Promise<void>; eprint(): Promise<void> } - export function init(): void - declare const iocraft: { text: typeof text; view: typeof view; renderToString: typeof renderToString; renderToStringWithWidth: typeof renderToStringWithWidth; printComponent: typeof printComponent; eprintComponent: typeof eprintComponent; getTerminalSize: typeof getTerminalSize; TuiRenderer: typeof TuiRenderer; init: typeof init } - export default iocraft - TYPES - cat > packages/package-builder/build/dev/out/socketaddon-iocraft/index.mjs << 'CODE' - export const text = (content) => ({ type: 'Text', content }) - export const view = (children) => ({ type: 'View', children }) - const extractText = (node) => { - if (!node) return '' - if (node.type === 'Text') return node.content || '' - if (node.children) return node.children.map(extractText).join('') - return '' - } - export const renderToString = (tree) => extractText(tree) - export const renderToStringWithWidth = (tree, width) => extractText(tree) - export const printComponent = (tree) => console.log(renderToString(tree)) - export const eprintComponent = (tree) => console.error(renderToString(tree)) - export const getTerminalSize = () => [80, 24] - export class TuiRenderer { setTree(t) { return Promise.resolve() } isRunning() { return false } getSize() { return [80, 24] } renderOnce() { return Promise.resolve('') } renderWithWidth(w) { return Promise.resolve('') } print() { return Promise.resolve() } eprint() { return Promise.resolve() } } - export const init = () => {} - export default { text, view, renderToString, renderToStringWithWidth, printComponent, eprintComponent, getTerminalSize, TuiRenderer, init } - CODE - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - node-version: ${{ matrix.node-version }} - - - name: Build CLI - working-directory: packages/cli - shell: bash - env: - # download-assets.mts hits the GitHub releases API for - # binject / node-smol / iocraft. Anonymous calls share the - # 60-req/hr public quota and get 403 once exhausted; the - # auto-provided GITHUB_TOKEN gives this job its own 1000/hr - # bucket. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - pnpm run build - - - name: Run e2e tests (shard ${{ matrix.shard }}) - working-directory: packages/cli - shell: bash - env: - SOCKET_CLI_API_TOKEN: ${{ secrets.SOCKET_CLI_API_TOKEN }} - SHARD: ${{ matrix.shard }} - run: | - pnpm run e2e-tests --shard="$SHARD"/2 diff --git a/.github/workflows/claude-auto-review.yml b/.github/workflows/claude-auto-review.yml new file mode 100644 index 000000000..c83afda3f --- /dev/null +++ b/.github/workflows/claude-auto-review.yml @@ -0,0 +1,37 @@ +name: Claude Auto Review + +on: + pull_request: + types: [opened] + +jobs: + auto-review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Automatic PR Review + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + timeout_minutes: "60" + direct_prompt: | + Please review this pull request and provide actionable feedback. + + Focus on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security implications + - Overall architecture and design decisions + + Provide constructive feedback with specific suggestions for improvement. + Use inline comments to highlight specific areas of concern. Be concise and clear in your feedback. + allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..cb1c2cb68 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,37 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 000000000..782af35b4 --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow will install Deno then run `deno lint` and `deno test`. +# For more information see: https://github.com/denoland/setup-deno + +name: Deno + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Deno + # uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@61fe2df320078202e33d7d5ad347e7dcfa0e8f31 # v1.1.2 + with: + deno-version: v1.x + + # Uncomment this step to verify the use of 'deno fmt' on each commit. + # - name: Verify formatting + # run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Run tests + run: deno test -A diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..3acef02e7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Linting + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linting: + name: 'Linting' + uses: SocketDev/workflows/.github/workflows/reusable-base.yml@master + with: + no-lockfile: true + npm-test-script: 'check-ci' diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 3a00f6caf..1511fffd3 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -1,365 +1,50 @@ -name: 📦 Publish - -concurrency: - group: publish-${{ github.ref }} - cancel-in-progress: false +name: Publish Package to npm on: workflow_dispatch: inputs: - dry-run: - description: 'Dry run (build only)' - type: boolean - default: true - cli: - description: '@socketsecurity/cli' - type: boolean - default: true - cli-sentry: - description: '@socketsecurity/cli-with-sentry' - type: boolean - default: true - socket: - description: 'socket (+ 8 bins)' - type: boolean - default: true - -permissions: - contents: read - -env: - BUILD_MODE: prod - + debug: + description: 'Enable debug output' + required: false + default: '0' + type: string + options: + - '0' + - '1' jobs: - # Build CLI bundle once (platform-agnostic JS) and generate platform matrix. - build-cli: - if: ${{ inputs.socket }} - name: Build CLI bundle + build: runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - - - name: Build CLI - shell: bash - run: | - pnpm --filter @socketsecurity/cli run build - - - name: Generate platform matrix - id: matrix - run: | - MATRIX=$(node scripts/get-platform-matrix.mts) - echo "matrix=$MATRIX" >> $GITHUB_OUTPUT - - - name: Upload CLI bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (2026-05-15) - with: - name: cli-bundle - path: packages/cli/build/cli.js - retention-days: 1 - # Build SEA binaries for all platforms (only if publishing binaries). - build-binaries: - if: ${{ inputs.socket }} - needs: [build-cli] - name: Build ${{ matrix.releasePlatform }}-${{ matrix.arch }}${{ matrix.libc && '-musl' || '' }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 permissions: contents: read - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.build-cli.outputs.matrix) }} + id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - persist-credentials: false - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' + node-version: '22' registry-url: 'https://registry.npmjs.org' - - - name: Download CLI bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 (2026-05-15) - with: - name: cli-bundle - path: packages/cli/build - - - name: Build SEA binary - shell: bash + cache: npm + scope: '@socketsecurity' + - run: npm install -g npm@latest + - run: npm ci + - run: INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 npm run build:dist + - run: npm publish --provenance --access public env: - MATRIX_LIBC: ${{ matrix.libc }} - MATRIX_PLATFORM: ${{ matrix.platform }} - MATRIX_ARCH: ${{ matrix.arch }} - run: | - LIBC_FLAG="" - if [ "$MATRIX_LIBC" = "musl" ]; then - LIBC_FLAG="--libc=musl" - fi - pnpm --filter @socketsecurity/cli run build:sea -- \ - --platform="$MATRIX_PLATFORM" \ - --arch="$MATRIX_ARCH" \ - ${LIBC_FLAG} - - - name: Upload binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (2026-05-15) - with: - name: binary-${{ matrix.releasePlatform }}-${{ matrix.arch }}${{ matrix.libc && '-musl' || '' }} - path: packages/package-builder/build/prod/out/socketbin-cli-${{ matrix.releasePlatform }}-${{ matrix.arch }}${{ matrix.libc && '-musl' || '' }}/socket${{ matrix.platform == 'win32' && '.exe' || '' }} - retention-days: 1 - - # Publish all packages. - publish: - name: Publish packages - needs: [build-cli, build-binaries] - if: ${{ always() && (needs.build-cli.result == 'success' || needs.build-cli.result == 'skipped') && (needs.build-binaries.result == 'success' || needs.build-binaries.result == 'skipped') }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - id-token: write # NPM trusted publishing via OIDC - outputs: - # Captured only when `socket` (the v<version>-tagged npm package) is - # actually published. Empty otherwise; tag-release job no-ops on empty. - published_sha: ${{ steps.capture_sha.outputs.published_sha }} - published_version: ${{ steps.capture_sha.outputs.published_version }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - registry-url: 'https://registry.npmjs.org' - - # Get versions for lock-stepped and independent packages. - - name: Get versions - id: version - run: | - # Socket CLI ecosystem version (lock-stepped) - CLI_VERSION=$(node -p "require('./packages/package-builder/templates/cli-package/package.json').version") - echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT - echo "Socket CLI version: $CLI_VERSION" - - # Determine npm dist-tags based on version - if [[ "$CLI_VERSION" =~ -pre\. ]]; then - CLI_TAG="pre" - else - CLI_TAG="latest" - fi - echo "cli_tag=$CLI_TAG" >> $GITHUB_OUTPUT - echo "Socket CLI dist-tag: $CLI_TAG" - - # Download and publish binary packages. - - name: Download binaries - if: ${{ inputs.socket }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 (2026-05-15) - with: - path: artifacts - pattern: binary-* - - - name: Publish binary packages - if: ${{ inputs.socket && !inputs.dry-run }} - shell: bash + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + SOCKET_CLI_DEBUG: ${{ inputs.debug }} + - run: INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 INLINED_SOCKET_CLI_LEGACY_BUILD=1 npm run build:dist env: - VERSION: ${{ steps.version.outputs.cli_version }} - TAG: ${{ steps.version.outputs.cli_tag }} - run: | - set -euo pipefail - - # Get platform targets from single source of truth. - PLATFORMS_STR=$(node scripts/get-platform-targets.mts) - read -ra PLATFORMS <<< "$PLATFORMS_STR" - - for target in "${PLATFORMS[@]}"; do - echo "::group::Publishing @socketbin/cli-${target}" - - # Parse platform/arch/libc from target. - IFS='-' read -ra PARTS <<< "$target" - PLATFORM="${PARTS[0]}" - ARCH="${PARTS[1]}" - LIBC="" - if [ "${PARTS[2]}" = "musl" ]; then - LIBC="musl" - fi - - # Setup package directory. - PKG_DIR="packages/package-builder/build/prod/out/socketbin-cli-${target}" - mkdir -p "$PKG_DIR" - - # Copy binary from artifact. - # Check for 'win' (release naming, not win32). - if [ "$PLATFORM" = "win" ]; then - cp "artifacts/binary-${target}/socket.exe" "$PKG_DIR/" - else - cp "artifacts/binary-${target}/socket" "$PKG_DIR/" - fi - - # Prepare package. - LIBC_FLAG="" - if [ -n "$LIBC" ]; then - LIBC_FLAG="--libc=$LIBC" - fi - node scripts/prepublish-socketbin.mts \ - --platform="$PLATFORM" --arch="$ARCH" $LIBC_FLAG --prod \ - --version="$VERSION" --method=sea - - # Publish. - cd "$PKG_DIR" - npm publish --provenance --access public --tag "$TAG" - cd - - - echo "::endgroup::" - done - - # Build and publish JS packages. - # Order: cli/cli-with-sentry first (independent), then socket (depends on @socketbin/*). - - name: Build CLI - shell: bash - run: | - INLINED_PUBLISHED_BUILD=1 pnpm run build - - - name: Publish @socketsecurity/cli - if: ${{ inputs.cli && !inputs.dry-run }} - env: - VERSION: ${{ steps.version.outputs.cli_version }} - TAG: ${{ steps.version.outputs.cli_tag }} - run: | - PKG_DIR="packages/package-builder/build/prod/out/cli" - node scripts/prepare-package-for-publish.mts "$PKG_DIR" "$VERSION" - cd "$PKG_DIR" - npm publish --provenance --access public --no-git-checks --tag "$TAG" - - - name: Publish @socketsecurity/cli-with-sentry - if: ${{ inputs.cli-sentry && !inputs.dry-run }} + SOCKET_CLI_DEBUG: ${{ inputs.debug }} + - run: npm publish --provenance --access public env: - VERSION: ${{ steps.version.outputs.cli_version }} - TAG: ${{ steps.version.outputs.cli_tag }} - run: | - PKG_DIR="packages/package-builder/build/prod/out/cli-with-sentry" - node scripts/prepare-package-for-publish.mts "$PKG_DIR" "$VERSION" - cd "$PKG_DIR" - npm publish --provenance --access public --no-git-checks --tag "$TAG" - - # socket published last - depends on @socketbin/* being published first. - - name: Publish socket - if: ${{ inputs.socket && !inputs.dry-run }} - env: - VERSION: ${{ steps.version.outputs.cli_version }} - TAG: ${{ steps.version.outputs.cli_tag }} - run: | - PKG_DIR="packages/package-builder/build/prod/out/socket" - node scripts/prepare-package-for-publish.mts "$PKG_DIR" "$VERSION" - cd "$PKG_DIR" - npm publish --provenance --access public --no-git-checks --tag "$TAG" - - # Capture the SHA the `socket` package was published from so the - # downstream tag-release job can point v<version> at it. Only runs - # when `socket` actually published (its tag is what v<version> - # represents); cli-only or cli-sentry-only publishes don't create a - # v<version> tag. - - name: Capture published SHA - id: capture_sha - if: ${{ inputs.socket && !inputs.dry-run }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + SOCKET_CLI_DEBUG: ${{ inputs.debug }} + - run: INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 INLINED_SOCKET_CLI_SENTRY_BUILD=1 npm run build:dist env: - VERSION: ${{ steps.version.outputs.cli_version }} - run: | - PUBLISHED_SHA=$(git rev-parse HEAD) - echo "published_sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" - echo "published_version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Captured published SHA: $PUBLISHED_SHA for socket@$VERSION" - - - name: Summary - # zizmor: ignore[template-injection] - run: | - echo "## Publish Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ inputs.socket }}" = "true" ] || [ "${{ inputs.cli }}" = "true" ] || [ "${{ inputs.cli-sentry }}" = "true" ]; then - echo "Socket CLI version: \`${{ steps.version.outputs.cli_version }}\` (tag: \`${{ steps.version.outputs.cli_tag }}\`)" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ inputs.dry-run }}" = "true" ]; then - echo "**Dry run - nothing was published**" >> $GITHUB_STEP_SUMMARY - else - echo "### Published packages:" >> $GITHUB_STEP_SUMMARY - if [ "${{ inputs.socket }}" = "true" ]; then - echo "- @socketbin/cli-* (8 platforms)" >> $GITHUB_STEP_SUMMARY - fi - if [ "${{ inputs.socket }}" = "true" ]; then - echo "- socket" >> $GITHUB_STEP_SUMMARY - fi - if [ "${{ inputs.cli }}" = "true" ]; then - echo "- @socketsecurity/cli" >> $GITHUB_STEP_SUMMARY - fi - if [ "${{ inputs.cli-sentry }}" = "true" ]; then - echo "- @socketsecurity/cli-with-sentry" >> $GITHUB_STEP_SUMMARY - fi - fi - - # Create v<version> git tag at the published commit SHA after a successful - # socket-package publish, idempotently. GitHub Release Immutability - # ("Disallow assets and tags from being modified once a release is - # published") freezes tags once bound to a Release, so: - # - existing tag at same SHA → no-op - # - existing tag at different SHA → hard-fail (operator recovery required) - # See socket-registry/.claude/plans/tag-after-publish.md for the full design. - # Inlined here (not via socket-registry workflow_call) because socket-cli's - # publish is multi-package and needs its own SHA-capture wiring. - tag-release: - name: Verify and tag release - needs: publish - if: ${{ needs.publish.result == 'success' && needs.publish.outputs.published_sha != '' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - # Needed to push the tag. - contents: write - steps: - # Uses gh api (not `git push`) so GITHUB_TOKEN only lives in this - # step's env, never written to `.git/config`. No checkout needed — - # tag creation goes straight against the published SHA via API. - - name: Tag release (idempotent) + SOCKET_CLI_DEBUG: ${{ inputs.debug }} + - run: npm publish --provenance --access public env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PUBLISHED_SHA: ${{ needs.publish.outputs.published_sha }} - PUBLISHED_VERSION: ${{ needs.publish.outputs.published_version }} - run: | - TAG="v$PUBLISHED_VERSION" - - # Look up any existing tag via the API. 200 → exists; 404 → absent. - EXISTING_JSON=$(gh api "repos/$REPO/git/ref/tags/$TAG" 2>/dev/null || echo "") - if [ -n "$EXISTING_JSON" ]; then - EXISTING_SHA=$(echo "$EXISTING_JSON" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).object.sha") - if [ "$EXISTING_SHA" = "$PUBLISHED_SHA" ]; then - echo "Tag $TAG already exists at $PUBLISHED_SHA — no-op." - exit 0 - fi - echo "::error::Tag $TAG exists at $EXISTING_SHA but publish SHA is $PUBLISHED_SHA." - echo "::error::Release immutability is enabled; this requires manual recovery:" - echo "::error:: 1. Delete any GitHub Release tied to $TAG" - echo "::error:: 2. Delete the tag via the API" - echo "::error:: 3. Re-run this workflow" - exit 1 - fi - - gh api "repos/$REPO/git/refs" \ - -X POST \ - -f "ref=refs/tags/$TAG" \ - -f "sha=$PUBLISHED_SHA" - echo "Created tag $TAG at $PUBLISHED_SHA" + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + SOCKET_CLI_DEBUG: ${{ inputs.debug }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..613b6e811 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: 'Tests' + uses: SocketDev/workflows/.github/workflows/reusable-base.yml@master + with: + no-lockfile: true + npm-test-script: 'test-ci' + node-versions: '20,22,24' + os: 'ubuntu-latest,windows-latest' diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml new file mode 100644 index 000000000..6a592234f --- /dev/null +++ b/.github/workflows/types.yml @@ -0,0 +1,22 @@ +name: Type Checks + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + type-check: + uses: SocketDev/workflows/.github/workflows/type-check.yml@master + with: + no-lockfile: true + ts-versions: '5.8' + ts-libs: 'esnext' diff --git a/.github/workflows/weekly-update.yml b/.github/workflows/weekly-update.yml deleted file mode 100644 index 3bab7c33f..000000000 --- a/.github/workflows/weekly-update.yml +++ /dev/null @@ -1,360 +0,0 @@ -name: 🔄 Weekly Dependency Update - -on: - schedule: - # Run weekly on Monday at 9 AM UTC - - cron: '0 9 * * 1' - workflow_dispatch: - inputs: - dry-run: - description: 'Check for updates without creating PR' - required: false - type: boolean - default: false - -permissions: - contents: read - -jobs: - check-updates: - name: Check for dependency updates - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - has-updates: ${{ steps.check.outputs.has-updates }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - persist-credentials: false - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - - - name: Check for npm updates - id: check - shell: bash - run: | - echo "Checking for npm package updates..." - HAS_UPDATES=false - NPM_UPDATES=$(pnpm outdated 2>/dev/null || true) - if [ -n "$NPM_UPDATES" ] && ! echo "$NPM_UPDATES" | grep -q "No outdated"; then - echo "npm packages have updates available" - HAS_UPDATES=true - fi - echo "has-updates=$HAS_UPDATES" >> $GITHUB_OUTPUT - - apply-updates: - name: Apply updates with Claude Code - needs: check-updates - if: needs.check-updates.outputs.has-updates == 'true' && inputs.dry-run != true - runs-on: ubuntu-latest - permissions: - actions: write # Trigger CI workflow via workflow_dispatch - contents: write # Push update branch - pull-requests: write # Create PR - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) - with: - fetch-depth: 0 - persist-credentials: false - - - uses: SocketDev/socket-registry/.github/actions/setup-and-install@c291a14196d088970c0453e905b40969b11bf193 # main (2026-05-15) - with: - checkout: 'false' - - - name: Create update branch - id: branch - env: - GH_TOKEN: ${{ github.token }} - GITHUB_REPO: ${{ github.repository }} - run: | - BRANCH_NAME="weekly-update-$(date +%Y%m%d)" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPO}.git" - # Branch from HEAD~1 so the PR is behind main, making the - # "Update branch" button available to trigger enterprise checks. - git checkout -b "$BRANCH_NAME" HEAD~1 - echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT - - - uses: SocketDev/socket-registry/.github/actions/setup-git-signing@75964f14e0682ae4aa846119e2fc9a710d970056 # main (2026-05-15) - with: - gpg-private-key: ${{ secrets.BOT_GPG_PRIVATE_KEY }} - - - name: Update dependencies (haiku — fast, cheap) - id: update - timeout-minutes: 10 - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_ACTIONS: 'true' - run: | - if [ -z "$ANTHROPIC_API_KEY" ]; then - echo "ANTHROPIC_API_KEY not set - skipping automated update" - echo "success=false" >> $GITHUB_OUTPUT - exit 0 - fi - - set +e - pnpm exec claude --print \ - --allowedTools "Bash(pnpm:*)" "Bash(git add:*)" "Bash(git commit:*)" "Bash(git status:*)" "Bash(git diff:*)" "Bash(git log:*)" "Bash(git rev-parse:*)" "Read" "Write" "Edit" "Glob" "Grep" \ - --model haiku \ - --max-turns 15 \ - "$(cat <<'PROMPT' - /updating - - <context> - You are an automated CI agent in a weekly dependency update workflow. - Git is configured with GPG signing. A branch has been created for you. - </context> - - <instructions> - Update all dependencies to their latest versions. - Create one atomic commit per dependency update with a conventional commit message. - Leave all changes local — the workflow handles pushing and PR creation. - Do not run builds or tests — the next step handles that. - </instructions> - - <success_criteria> - Each updated dependency has its own commit. - The lockfile is consistent with package.json changes. - No uncommitted changes remain in the working tree. - </success_criteria> - PROMPT - )" \ - 2>&1 | tee claude-update.log - CLAUDE_EXIT=${PIPESTATUS[0]} - set -e - - if [ "$CLAUDE_EXIT" -eq 0 ]; then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - - - name: Run tests - id: tests - if: steps.update.outputs.success == 'true' - run: | - set +e - pnpm build 2>&1 | tee build.log - BUILD_EXIT=${PIPESTATUS[0]} - - pnpm test 2>&1 | tee test.log - TEST_EXIT=${PIPESTATUS[0]} - set -e - - if [ "$BUILD_EXIT" -eq 0 ] && [ "$TEST_EXIT" -eq 0 ]; then - echo "tests-passed=true" >> $GITHUB_OUTPUT - else - echo "tests-passed=false" >> $GITHUB_OUTPUT - fi - - - name: Fix test failures (sonnet — smarter, escalated) - id: claude - if: steps.update.outputs.success == 'true' && steps.tests.outputs.tests-passed == 'false' - timeout-minutes: 15 - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_ACTIONS: 'true' - run: | - FAILURE_LOG="$(cat build.log test.log 2>/dev/null)" - - set +e - pnpm exec claude --print \ - --allowedTools "Bash(pnpm:*)" "Bash(git add:*)" "Bash(git commit:*)" "Bash(git status:*)" "Bash(git diff:*)" "Bash(git log:*)" "Bash(git rev-parse:*)" "Read" "Write" "Edit" "Glob" "Grep" \ - --model sonnet \ - --max-turns 25 \ - "$(cat <<PROMPT - <context> - You are an automated CI agent in a weekly dependency update workflow. - Git is configured with GPG signing. A branch has been created for you. - Dependencies were updated in the previous step but build/tests failed. - </context> - - <failure_log> - $FAILURE_LOG - </failure_log> - - <instructions> - The dependency updates above caused build or test failures. - Diagnose the failures from the logs and fix the code so it builds and tests pass. - Create one atomic commit per fix with a conventional commit message. - Run pnpm build && pnpm test to verify your fixes. - Leave all changes local — the workflow handles pushing and PR creation. - </instructions> - - <success_criteria> - pnpm build succeeds. - pnpm test succeeds. - Each fix has its own commit. - No uncommitted changes remain in the working tree. - </success_criteria> - PROMPT - )" \ - 2>&1 | tee claude-fix.log - CLAUDE_EXIT=${PIPESTATUS[0]} - set -e - - if [ "$CLAUDE_EXIT" -eq 0 ]; then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - - - name: Set final status - id: final - if: always() - env: - UPDATE_SUCCESS: ${{ steps.update.outputs.success }} - TESTS_PASSED: ${{ steps.tests.outputs.tests-passed }} - FIX_SUCCESS: ${{ steps.claude.outputs.success }} - run: | - if [ "$UPDATE_SUCCESS" = "true" ] && [ "$TESTS_PASSED" = "true" ]; then - echo "success=true" >> $GITHUB_OUTPUT - elif [ "$UPDATE_SUCCESS" = "true" ] && [ "$FIX_SUCCESS" = "true" ]; then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - - - name: Validate changes - id: validate - if: steps.final.outputs.success == 'true' - run: | - UNEXPECTED="" - for file in $(git diff --name-only origin/main..HEAD); do - case "$file" in - package.json|*/package.json|pnpm-lock.yaml|*/pnpm-lock.yaml|.npmrc|pnpm-workspace.yaml) ;; - src/*|test/*) ;; - *.ts|*.mts|*.js|*.mjs) ;; - *) UNEXPECTED="$UNEXPECTED $file" ;; - esac - done - if [ -n "$UNEXPECTED" ]; then - echo "::error::Unexpected files modified by Claude:$UNEXPECTED" - echo "valid=false" >> $GITHUB_OUTPUT - else - echo "valid=true" >> $GITHUB_OUTPUT - fi - - - name: Check for changes - id: changes - run: | - if [ -n "$(git status --porcelain)" ] || [ "$(git rev-list --count HEAD ^origin/main)" -gt 0 ]; then - echo "has-changes=true" >> $GITHUB_OUTPUT - else - echo "has-changes=false" >> $GITHUB_OUTPUT - fi - - - name: Push branch - if: steps.final.outputs.success == 'true' && steps.validate.outputs.valid == 'true' && steps.changes.outputs.has-changes == 'true' - env: - BRANCH_NAME: ${{ steps.branch.outputs.branch }} - run: git push origin "$BRANCH_NAME" - - - name: Create Pull Request - if: steps.final.outputs.success == 'true' && steps.validate.outputs.valid == 'true' && steps.changes.outputs.has-changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - BRANCH_NAME: ${{ steps.branch.outputs.branch }} - run: | - COMMITS=$(git log --oneline origin/main..HEAD) - COMMIT_COUNT=$(git rev-list --count origin/main..HEAD) - - PR_BODY="## Weekly Dependency Update"$'\n\n' - PR_BODY+="Automated weekly update of npm packages."$'\n\n' - PR_BODY+="---"$'\n\n' - PR_BODY+="### Commits (${COMMIT_COUNT})"$'\n\n' - PR_BODY+="<details>"$'\n' - PR_BODY+="<summary>View commit history</summary>"$'\n\n' - PR_BODY+="\`\`\`"$'\n' - PR_BODY+="${COMMITS}"$'\n' - PR_BODY+="\`\`\`"$'\n\n' - PR_BODY+="</details>"$'\n\n' - PR_BODY+="---"$'\n\n' - PR_BODY+="<sub>Generated by [weekly-update.yml](.github/workflows/weekly-update.yml)</sub>" - - gh pr create \ - --title "chore(deps): weekly dependency update ($(date +%Y-%m-%d))" \ - --body "$PR_BODY" \ - --draft \ - --head "$BRANCH_NAME" \ - --base main - - # Pushes made with GITHUB_TOKEN don't trigger other workflows. - # Use workflow_dispatch to directly trigger CI on the PR branch. - - name: Trigger CI checks - if: steps.final.outputs.success == 'true' && steps.validate.outputs.valid == 'true' && steps.changes.outputs.has-changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - BRANCH_NAME: ${{ steps.branch.outputs.branch }} - run: gh workflow run ci.yml --ref "$BRANCH_NAME" - - - name: Add job summary - if: steps.final.outputs.success == 'true' && steps.validate.outputs.valid == 'true' && steps.changes.outputs.has-changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - BRANCH_NAME: ${{ steps.branch.outputs.branch }} - run: | - COMMIT_COUNT=$(git rev-list --count origin/main..HEAD) - pr_number=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "") - pr_url="https://github.com/${{ github.repository }}/pull/${pr_number}" - - cat >> "$GITHUB_STEP_SUMMARY" <<EOF - ## Weekly Update Complete - - **PR:** [#${pr_number}](${pr_url}) - **Branch:** \`${BRANCH_NAME}\` - **Commits:** ${COMMIT_COUNT} - - > **Note:** Enterprise required workflows (e.g. Audit GHA Workflows) won't trigger - > automatically on bot PRs. Click **"Update branch"** on the PR to trigger them, - > or push an empty commit to the branch: - > - > \`\`\`sh - > git fetch origin ${BRANCH_NAME} && git checkout ${BRANCH_NAME} - > git commit --allow-empty -m "chore: trigger enterprise checks" - > git push origin ${BRANCH_NAME} - > \`\`\` - EOF - - - name: Upload Claude output - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (2026-05-15) - with: - name: claude-output-${{ github.run_id }} - path: | - claude-update.log - claude-fix.log - build.log - test.log - retention-days: 7 - - - uses: SocketDev/socket-registry/.github/actions/cleanup-git-signing@75964f14e0682ae4aa846119e2fc9a710d970056 # main (2026-05-15) - if: always() - - notify: - name: Notify results - needs: [check-updates, apply-updates] - if: always() - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Report status - env: - HAS_UPDATES: ${{ needs.check-updates.outputs.has-updates }} - DRY_RUN: ${{ inputs.dry-run }} - run: | - if [ "$HAS_UPDATES" = "true" ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "Updates available (dry-run mode - no PR created)" - else - echo "Weekly update workflow completed" - echo "Check the PRs tab for the automated update PR" - fi - else - echo "All dependencies are up to date - no action needed!" - fi diff --git a/.github/zizmor.yml b/.github/zizmor.yml deleted file mode 100644 index 39d1b180c..000000000 --- a/.github/zizmor.yml +++ /dev/null @@ -1,3 +0,0 @@ -rules: - secrets-outside-env: - disable: true diff --git a/.gitignore b/.gitignore index ea0e750ea..0431ccb77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,176 +1,19 @@ -# ============================================================================ -# OS-specific files -# ============================================================================ -.*.sw? -._.DS_Store .DS_Store +._.DS_Store Thumbs.db - -# ============================================================================ -# Environment and secrets -# ============================================================================ -.env -.env.* -!.env.example -/.env.local - -# ============================================================================ -# Node.js dependencies and configuration -# ============================================================================ -.node-version +/.cache +/.env /.nvm -/.pnpmfile.cjs -.npmrc.local -**/node_modules/ -/npm-debug.log -pnpm-debug.log* -/yarn.lock -/yarn.log -yarn-error.log* -/.yarnrc.yml - -# ============================================================================ -# Build outputs and artifacts -# ============================================================================ -**/.build-checkpoints -**/*.build-signature -**/.cache/ /.rollup.cache -**/.type-coverage/ -**/build/ -!docs/build/ -**/coverage/ -**/dist/ -/external/ -# Stale pre-monorepo scaffold dirs at root — tests now live in packages/cli/test/ -/test/ -**/html/ +/.type-coverage +/.vscode +/coverage +/external +/npm-debug.log +**/dist +**/node_modules *.d.ts *.d.ts.map *.tsbuildinfo -**/*.tmp -*.tmp - -# ============================================================================ -# Language-specific build artifacts -# ============================================================================ - -## Rust builds -**/target/ - -## WASM builds -**/wasm-bundle/ - -# ============================================================================ -# Editor and IDE files -# ============================================================================ -.idea/ -/.vscode/ -*.old -*.sw? -*.swo -*.swp -*~ - -# ============================================================================ -# Development and debugging -# ============================================================================ -*.log -**/build/*.log -/.claude/* -!/.claude/agents/ -!/.claude/commands/ -!/.claude/hooks/ -!/.claude/ops/ -!/.claude/settings.json -!/.claude/skills/ - -# ============================================================================ -# Backup and temporary files -# ============================================================================ -*.backup -*.bak -**/*.tmp.bak* -*.old -*~ -# ============================================================================ -# Yarn PnP files -# ============================================================================ -/.pnp.cjs -/.pnp.loader.mjs -/.yarn/ - -# ============================================================================ -# Archive directories -# ============================================================================ -**/docs/archive/ - -# ============================================================================ -# Workspace-specific patterns -# ============================================================================ - -## Generated packages (from templates/) -packages/package-builder/build/ - -## Downloaded build sources -packages/*/.minilm-source/ -packages/*/.onnx-source/ -packages/*/.yoga-source/ -packages/*/.yoga-tests/ - -## Workspace-generated files -packages/cli/CHANGELOG.md -packages/cli/LICENSE -packages/cli/*.png -packages/cli-with-sentry/CHANGELOG.md -packages/cli-with-sentry/data/ -packages/cli-with-sentry/LICENSE -packages/cli-with-sentry/*.png -packages/socket/CHANGELOG.md -packages/socket/LICENSE -packages/socket/*.png - -# ============================================================================ -# Allow specific files (negation patterns) -# ============================================================================ -!.env.example !/.vscode/extensions.json -!docs/build/ -!packages/package-builder/templates/**/*.d.ts -!src/types/**/*.d.ts -!packages/*/src/types/**/*.d.ts - -# ─── BEGIN fleet-canonical (managed by socket-wheelhouse sync) ── -# Managed by socket-wheelhouse. Don't edit locally — edit upstream -# in scripts/sync-scaffolding/checks/gitignore-fleet-block.mts and -# re-cascade via `pnpm run sync`. Project-specific ignores stay -# OUTSIDE this block; the fixer preserves them. -# Per-machine Claude Code permission config + log dirs stay ignored; -# the cascaded subdirs (agents, commands, hooks, settings.json, skills) -# are explicitly re-included so the wheelhouse cascade can ship them. -/.claude/* -!/.claude/agents/ -!/.claude/commands/ -!/.claude/hooks/ -!/.claude/settings.json -!/.claude/skills/ - -# OS noise -.DS_Store -._.DS_Store -Thumbs.db - -# Build outputs — universal across the fleet. Project-specific -# variants (e.g. a non-standard dist path) go OUTSIDE this block. -**/build/ -**/coverage/ -**/dist/ -**/.cache/ - -# Node -node_modules/ -npm-debug.log -pnpm-debug.log -*.tgz -# ─── END fleet-canonical ──────────────────────────────────────── diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index 42fe3bddc..000000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,2 +0,0 @@ -# Run commit message validation and auto-strip AI attribution. -node .git-hooks/commit-msg.mts "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 index cf22d393b..bdbad98c5 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,51 +1,11 @@ -#!/bin/sh -# Optional checks - can be bypassed with --no-verify for fast local commits. -# Mandatory security checks ALSO run in pre-push hook. -# -# Architecture (parallels commit-msg and pre-push): -# .husky/pre-commit (this file) → .git-hooks/pre-commit.mts (security) + pnpm lint/test -# -# Use --no-verify for: -# - History operations (squash, rebase, amend) -# - Emergency hotfixes -# - When tests require binaries that haven't been built yet -# -# Use environment variables to selectively disable: -# - DISABLE_PRECOMMIT_LINT=1 to skip linting -# - DISABLE_PRECOMMIT_TEST=1 to skip testing - -# Run Socket security pre-commit checks (API keys, .DS_Store, etc.). -node .git-hooks/pre-commit.mts - -# Check if pnpm is available -if ! command -v pnpm >/dev/null 2>&1; then - echo "Error: pnpm not found. Install pnpm to run git hooks." - echo "Visit: https://pnpm.io/installation" - exit 1 -fi - if [ -z "${DISABLE_PRECOMMIT_LINT}" ]; then - pnpm lint --staged + npm run lint-staged else echo "Skipping lint due to DISABLE_PRECOMMIT_LINT env var" fi if [ -z "${DISABLE_PRECOMMIT_TEST}" ]; then - # Each repo's `pnpm test` script wraps a runner that understands - # `--staged` (e.g. scripts/test.mts forwards staged-filtering to - # vitest, or filters the staged set in a pre-pass). When - # DISABLE_PRECOMMIT_LINT is set, also pass --fast so the test - # runner skips its embedded format/lint check (otherwise lint - # bypass leaks through this path and re-blocks the commit). - # - # Repos whose `pnpm test` is bare vitest without a wrapper need a - # local override (skills/.husky/pre-commit pre-filters with - # `git diff --cached --name-only` then runs `pnpm test`). - if [ -n "${DISABLE_PRECOMMIT_LINT}" ]; then - pnpm test --staged --fast - else - pnpm test --staged - fi + npm test else echo "Skipping testing due to DISABLE_PRECOMMIT_TEST env var" fi diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 1af273748..000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,2 +0,0 @@ -# Run pre-push security validation. -node .git-hooks/pre-push.mts "$@" diff --git a/.ncurc.json b/.ncurc.json new file mode 100644 index 000000000..3b2cf8ec3 --- /dev/null +++ b/.ncurc.json @@ -0,0 +1,5 @@ +{ + "loglevel": "minimal", + "reject": ["eslint-plugin-unicorn", "terminal-link"], + "upgrade": true +} diff --git a/.node-version b/.node-version deleted file mode 100644 index b7397ce15..000000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -26.2.0 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 9c7382baf..000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -# npm v11+ settings (not pnpm — pnpm v11 only reads auth/registry from .npmrc). -ignore-scripts=true -min-release-age=7 diff --git a/.oxlintignore b/.oxlintignore new file mode 100644 index 000000000..d8b83df9c --- /dev/null +++ b/.oxlintignore @@ -0,0 +1 @@ +package-lock.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..09a399951 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,29 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["import", "promise", "typescript", "unicorn"], + "categories": { + "correctness": "warn", + "perf": "warn", + "suspicious": "warn" + }, + "settings": {}, + "rules": { + "@typescript-eslint/array-type": ["error", { "default": "array-simple" }], + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-this-alias": [ + "error", + { "allowDestructuring": true } + ], + "@typescript-eslint/return-await": ["error", "always"], + "curly": "error", + "no-control-regex": "off", + "no-new": "off", + "no-self-assign": "off", + "no-undef": "off", + "no-unused-vars": "off", + "no-var": "error", + "unicorn/no-empty-file": "off", + "unicorn/no-new-array": "off", + "unicorn/prefer-string-starts-ends-with": "off" + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..443a85ffd --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "ryanluker.vscode-coverage-gutters", + "hbenl.vscode-test-explorer", + "hbenl.vscode-mocha-test-adapter", + "dbaeumer.vscode-eslint", + "gruntfuggly.todo-tree", + "editorconfig.editorconfig" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 52c7afabe..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,584 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Changed - -- `socket organization quota` is no longer hidden and now shows remaining quota, total quota, usage percentage, and the next refresh time in text and markdown output. - -### Added - -- Advanced TUI components and styling for rich terminal interfaces: - - **MixedText component**: Render text with multiple styled sections, perfect for syntax highlighting and rich formatting - - **Fragment component**: Group elements without layout impact, enabling cleaner component composition - - **Extended border styles**: double-left-right, double-top-bottom, and classic ASCII borders - - **Custom border characters**: Full control over border rendering with custom character sets - - **ANSI 256-color support**: Use extended color palette with `ansi:123` or bare number notation for vibrant terminal output -- Comprehensive TUI styling and layout properties for terminal interfaces: - - Text styling: weight (normal, bold, light), dimColor for faded appearance, strikethrough decoration - - Text layout: align (left, center, right), wrap (wrap, nowrap) for content control - - Flex layout: flexBasis for initial sizing, flexWrap for multi-line layouts, alignContent for line distribution - - Advanced positioning: display (flex, none), position (relative, absolute) with inset controls (top, right, bottom, left) - - Dimension constraints: minWidth, maxWidth, minHeight, maxHeight for responsive layouts - - Overflow control: overflow, overflowX, overflowY for content that exceeds container bounds - - Border customization: borderEdges for selective border rendering (top, right, bottom, left) - - Layout spacing: rowGap and columnGap for fine-grained flex item spacing - -### Changed - -- Updated to @socketsecurity/socket-patch@1.2.0. -- Updated Coana CLI to v14.12.148. -- `socket scan create` now accepts `--make-default-branch` (mirrors the `make_default_branch` API field) instead of `--default-branch`. The old name keeps working but emits a deprecation warning. - -### Deprecated - -- `socket scan create --default-branch` / `--defaultBranch` — use `--make-default-branch` instead. The legacy names still work during the deprecation window but emit a warning. - -### Fixed - -- Prevent heap overflow in large monorepo scans by using streaming-based filtering to avoid accumulating all file paths in memory before filtering. -- `socket scan create` now rejects `--default-branch=<name>` and `--default-branch <name>` (space-separated) with an actionable error instead of silently dropping the branch name. Scans that used the misuse shape were getting recorded without a branch tag and disappearing from the Main/PR dashboard tabs. -- `socket repository create` / `socket repository update` now reject bare `--default-branch` (no value) and `--default-branch=` (empty value). Previously both persisted a blank default-branch name on the repo record. -- `socket cdxgen` no longer silently produces SBOMs with an empty `components` array when run in the default `--lifecycle pre-build` + `--no-install-deps` mode against a Node.js project that has no lockfile and no `node_modules/`. The command now fails fast with an actionable error (install dependencies or pass `--lifecycle build`), and when the generated BOM still ends up empty for any other reason (e.g. overly narrow `--filter`/`--only`), emits a post-run warning so the condition is surfaced instead of shipping an SBOM that renders as "no alerts" on the Socket dashboard. - -## [2.1.0](https://github.com/SocketDev/socket-cli/releases/tag/v2.1.0) - 2025-11-02 - -### Added - -- Unified DLX manifest storage for packages and binary downloads with persistent caching and TTL support -- Progressive enhancement with ONNX Runtime stub for optional NLP features -- SHA-256 checksum verification for Python build standalone downloads -- Optional external alias detection for TypeScript configurations -- `--reach-use-unreachable-from-precomputation` flag for `scan reach` and `scan create` commands - to use precomputed unreachable information for improved reachability analysis accuracy - -### Changed - -- DLX manifest now uses unified format supporting both npm packages and binary downloads -- Standardized environment variable naming with SOCKET*CLI* prefix -- Preflight downloads now stagger with variable delays (1-3 seconds) to avoid resource contention - -### Fixed - -- Bootstrap stream/promises module path corrected for smol builds -- Bootstrap error handling improved for clearer failure messages -- Windows path handling now correctly processes UNC paths - -## [2.0.10](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.10) - 2025-10-31 - -### Fixed - -- Tab completion script now resolves CLI package root correctly -- SDK scan options flattened and repo parameter made conditional -- Output handling now safely checks for null before calling toString() -- Environment variable fallbacks from v1.x restored for backward compatibility -- Directory creation EEXIST errors now handled gracefully - -## [2.0.9](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.9) - 2025-10-31 - -### Fixed - -- Updated @socketsecurity/lib to v2.10.2 with critical DLX fixes for scoped package parsing - -## [2.0.8](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.8) - 2025-10-31 - -### Fixed - -- Binary name resolution for external tools (@coana-tech/cli, @cyclonedx/cdxgen, synp) in dlx execution -- Preflight downloads now correctly specify binary names for background package caching - -## [2.0.7](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.7) - 2025-10-31 - -### Added - -- Shimmer effect to bootstrap spinner for enhanced visual feedback during CLI download - -### Changed - -- Consolidated SOCKET_CLI_ISSUES_URL constant to socket constants module for better organization - -## [2.0.6](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.6) - 2025-10-31 - -### Fixed - -- Shadow npm spawn mechanism now properly uses spawnNode abstraction for SEA binary compatibility -- IPC handshake structure for shadow npm processes with correct parent_pid and subprocess fields - -## [2.0.2](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.2) - 2025-10-30 - -### Fixed - -- Fixed import from @socketsecurity/registry to @socketsecurity/lib - -## [2.0.1](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.1) - 2025-10-30 - -### Changed - -- Updated @socketsecurity/lib to v2.9.0 with Socket.dev URL constants and enhanced error messages -- Updated @socketsecurity/sdk to v3.0.21 -- Normalized lock behavior across codebase - -### Fixed - -- Bootstrap path resolution in binary builders to correct path - -## [2.0.0](https://github.com/SocketDev/socket-cli/releases/tag/v2.0.0) - 2025-10-29 - -### Changed - -- **BREAKING**: CLI now ships as single executable binary requiring no external Node.js installation - -### Added - -- GitLab merge request support for `socket fix` -- Persistent GHSA tracking to avoid duplicate fixes -- Markdown output support for `socket fix` and `socket optimize` -- `--reach-min-severity` flag to filter reachability analysis by vulnerability severity threshold - -### Fixed - -- Target directory handling in reachability analysis for scan commands - -## [1.1.25](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.25) - 2025-10-10 - -### Added - -- `--no-major-updates` flag -- `--show-affected-direct-dependencies` flag - -### Fixed - -- Provenance handling - -## [1.1.24](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.24) - 2025-10-10 - -### Added - -- `--minimum-release-age` flag for `socket fix` -- SOCKET_CLI_COANA_LOCAL_PATH environment variable - -### Fixed - -- Organization capabilities detection -- Enterprise plan filtering - -## [1.1.23](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.23) - 2025-09-22 - -### Changed - -- Renamed `--dont-apply-fixes` to `--no-apply-fixes` (old flag remains as alias) -- pnpm dlx operations no longer use `--ignore-scripts` - -### Fixed - -- Error handling in optimize command for pnpm - -## [1.1.22](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.22) - 2025-09-20 - -### Changed - -- Renamed `--only-compute` to `--dont-apply-fixes` for `socket fix` (old flag remains as alias) - -### Fixed - -- Interactive prompts in `socket optimize` with pnpm -- Git repository name sanitization - -## [1.1.21](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.21) - 2025-09-20 - -### Added - -- `--compact-header` flag - -### Fixed - -- Error handling in `socket optimize` - -## [1.1.20](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.20) - 2025-09-19 - -### Added - -- Terminal link support - -### Fixed - -- Windows package manager execution - -## [1.1.13](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.13) - 2025-09-16 - -### Added - -- `--output-file` flag for `socket fix` -- `--only-compute` flag for `socket fix` - -## [1.1.9](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.9) - 2025-09-11 - -### Added - -- `socket fix --id` now accepts CVE IDs and PURLs - -### Fixed - -- SOCKET_CLI_API_TIMEOUT environment variable lookup - -## [1.1.7](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.7) - 2025-09-11 - -### Added - -- `--no-spinner` flag - -### Fixed - -- Proxy support - -## [1.1.4](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.4) - 2025-09-09 - -### Added - -- `--report-level` flag for scan output control - -## [1.1.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.1) - 2025-09-04 - -### Removed - -- Legacy `--test` and `--test-script` flags from `socket fix` - -## [1.1.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.0) - 2025-09-03 - -### Added - -- Package versions in `socket npm` security reports - -## [1.0.111](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.111) - 2025-09-03 - -### Added - -- `--range-style` flag for `socket fix` - -## [1.0.106](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.106) - 2025-09-02 - -### Added - -- `--reach-skip-cache` flag - -## [1.0.89](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.89) - 2025-08-15 - -### Added - -- `socket scan create --reach` for manifest scanning - -## [1.0.85](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.85) - 2025-08-01 - -### Added - -- SOCKET_CLI_NPM_PATH environment variable - -## [1.0.82](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.82) - 2025-07-30 - -### Added - -- `--max-old-space-size` and `--max-semi-space-size` flags - -## [1.0.73](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.73) - 2025-07-14 - -### Added - -- Automatic `.socket.facts.json` detection - -## [1.0.69](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.69) - 2025-07-10 - -### Added - -- `--no-pr-check` flag for `socket fix` - -## [1.0.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.0.0) - 2025-06-13 - -### Added - -- Official v1.0.0 release -- Added `socket org deps` alias command - -### Changed - -- Moved dependencies command to a subcommand of organization -- Improved UX for threat-feed and audit-logs -- Removed Node 18 deprecation warnings -- Removed v1 preparation flags - -## [0.15.64](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.64) - 2025-06-13 - -### Fixed - -- Improved `socket fix` error handling when server rejects request - -### Changed - -- Final pre-v1.0.0 stability improvements - -## [0.15.63](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.63) - 2025-06-12 - -### Added - -- Enhanced debugging capabilities - -## [0.15.62](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.62) - 2025-06-12 - -### Fixed - -- Avoided double installing during `socket fix` operations - -## [0.15.61](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.61) - 2025-06-11 - -### Fixed - -- Memory management for `socket fix` with packument cache clearing - -## [0.15.60](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.60) - 2025-06-10 - -### Changed - -- Widened Node.js test matrix -- Removed Node 18 support due to native-ts compatibility - -## [0.15.59](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.59) - 2025-06-09 - -### Changed - -- Reduced Node version restrictions on CLI - -## [0.15.57](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.57) - 2025-06-06 - -### Added - -- Added `socket threat-feed` search flags - -## [0.15.56](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.56) - 2025-05-07 - -### Added - -- `socket manifest setup` for project configuration -- Enhanced debugging output and error handling - -## [0.15.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.15.0) - 2025-05-07 - -### Added - -- Enhanced `socket threat-feed` with new API endpoints -- `socket.json` configuration support -- Improved `socket fix` error handling - -### Fixed - -- Avoid double installing with `socket fix` -- CI/CD improvements reducing GitHub Action dependencies for `socket fix` - -## [0.14.155](https://github.com/SocketDev/socket-cli/releases/tag/v0.14.155) - 2025-05-07 - -### Added - -- `SOCKET_CLI_API_BASE_URL` for base URL configuration -- `DISABLE_GITHUB_CACHE` environment variable -- `cdxgen` lifecycle logging and documentation hyperlinks - -### Fixed - -- Set `exitCode=1` when login steps fail -- Fixed Socket package URLs -- Band-aid fix for `socket analytics` -- Improved handling of non-SDK API calls - -### Changed - -- Enhanced JSON-safe API handling -- Updated `cdxgen` flags and configuration - -## [0.14.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.14.0) - 2024-10-10 - -### Added - -- `socket optimize` to apply Socket registry overrides -- Suggestion flows to `socket scan create` -- JSON/markdown output support for `socket repos list` -- Enhanced organization command with `--json` and `--markdown` flags -- `SOCKET_CLI_NO_API_TOKEN` environment variable support -- Improved test snapshot updating - -### Fixed - -- Spinner management in report flow and after API errors -- API error handling for non-SDK calls -- Package URL corrections - -### Changed - -- Added Node permissions for shadow-bin - -## [0.13.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.13.0) - 2024-09-06 - -### Added - -- `socket threat-feed` for security threat information - -## [0.12.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.12.0) - 2024-08-30 - -### Added - -- Diff Scan command for comparing scan results -- Analytics enhancements and data visualization -- Feature to save analytics data to local files - -## [0.11.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.11.0) - 2024-08-05 - -### Added - -- Organization listing capability - -## [0.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.10.0) - 2024-07-17 - -### Added - -- Analytics command with graphical data visualization -- Interactive charts and graphs - -## [0.9.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.9.0) - 2023-12-01 - -### Added - -- Automatic latest version fetching for `socket info` -- Package scoring integration -- Human-readable issue rendering with clickable links -- Enhanced package analysis with scores - -### Changed - -- Smart defaults for package version resolution -- Improved issue visualization and reporting - -## [0.8.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.8.0) - 2023-08-10 - -### Added - -- Configuration-based warnings from settings -- Enhanced `socket npm` installation safety checks - -### Changed - -- Dropped Node 14 support (EOL April 2023) -- Added Node 16 manual testing due to c8 segfault issues - -## [0.7.1](https://github.com/SocketDev/socket-cli/releases/tag/v0.7.1) - 2023-06-13 - -### Added - -- Python report creation capabilities -- CLI login/logout functionality - -### Fixed - -- Lockfile handling to ensure saves on `socket npm install` -- Report creation issues -- Python uploads via CLI - -### Changed - -- Switched to base64 encoding for certain operations - -## [0.6.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.6.0) - 2023-04-11 - -### Added - -- Enhanced update notifier for npm wrapper -- TTY IPC to mitigate sub-shell prompts - -## [0.5.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.5.0) - 2023-03-16 - -### Added - -- npm/npx wrapper commands (`socket npm`, `socket npx`) -- npm provenance and publish action support - -### Changed - -- Reusable consistent flags across commands - -## [0.4.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.4.0) - 2023-01-20 - -### Added - -- Persistent authentication - CLI remembers API key for full duration -- Comprehensive TypeScript integration and type checks -- Enhanced development tooling and dependencies - -## [0.3.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.3.0) - 2022-12-13 - -### Added - -- Support for globbed input and ignores for package scanning -- `--strict` and `--all` flags to commands -- Configuration support using `@socketsecurity/config` - -### Changed - -- Improved error handling and messaging -- Stricter TypeScript configuration - -### Fixed - -- Improved tests - -## [0.2.1](https://github.com/SocketDev/socket-cli/releases/tag/v0.2.1) - 2022-11-23 - -### Added - -- Update notifier to inform users of new CLI versions - -## [0.2.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.2.0) - 2022-11-23 - -### Added - -- New `socket report view` for viewing existing reports -- `--view` flag to `report create` for immediate viewing -- Enhanced report creation and viewing capabilities - -### Changed - -- Synced up report create command with report view functionality -- Synced up info command with report view -- Improved examples in `--help` output - -### Fixed - -- Updated documentation and README with new features - -## [0.1.2](https://github.com/SocketDev/socket-cli/releases/tag/v0.1.2) - 2022-11-17 - -### Added - -- Node 19 testing support - -### Changed - -- Improved documentation - -## [0.1.1](https://github.com/SocketDev/socket-cli/releases/tag/v0.1.1) - 2022-11-07 - -### Changed - -- Extended README documentation - -### Fixed - -- Removed accidental debug code - -## [0.1.0](https://github.com/SocketDev/socket-cli/releases/tag/v0.1.0) - 2022-11-07 - -### Added - -- Initial Socket CLI release -- `socket info` for package security information -- `socket report create` for generating security reports -- Basic CLI infrastructure and configuration diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6dc57eec4..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,276 +0,0 @@ -# CLAUDE.md - -**MANDATORY**: Act as principal-level engineer. Follow these guidelines exactly. - -<!-- BEGIN FLEET-CANONICAL — sync via socket-wheelhouse/scripts/sync-scaffolding.mts. Do not edit downstream. --> - -## 📚 Wheelhouse Standards - -### Identifying users - -Identify users by git credentials and use their actual name. Use "you/your" when speaking directly; use names when referencing contributions (enforced by `.claude/hooks/identifying-users-reminder/`). - -### Parallel Claude sessions - -🚨 Multiple Claude sessions may target the same checkout (parallel agents, terminals, or worktrees on the same `.git/`). **The umbrella rule:** never run a git command that mutates state belonging to a path other than the file you just edited. Forbidden in the primary checkout: `git stash`, `git add -A` / `git add .` (enforced by `.claude/hooks/overeager-staging-guard/`; bypass: `Allow add-all bypass`), `git checkout/switch <branch>`, `git reset --hard <non-HEAD>`. Branch work goes in a `git worktree`. Cross-repo imports via `@socketsecurity/lib/...` only, never `../<sibling-repo>/...` (enforced by `.claude/hooks/cross-repo-guard/`). Dirty paths you didn't author this session + that changed recently are likely another live agent — never `add -A`/`stash`/`reset --hard`/`checkout`/`restore` over them; stage only your own files (enforced by `.claude/hooks/parallel-agent-on-stop-reminder/` + `.claude/hooks/parallel-agent-staging-guard/`; bypass `Allow parallel-agent-staging bypass`). **Why:** 2026-05-27 a session's own `pnpm check` surfaced another agent's migration files; it nearly committed them. Full prohibition list + worktree recipe in [`docs/claude.md/fleet/parallel-claude-sessions.md`](docs/claude.md/fleet/parallel-claude-sessions.md). - -### Default branch fallback - -Never hard-code `main` in scripts — a few legacy repos still use `master`. Resolve via `git symbolic-ref refs/remotes/origin/HEAD`, fall back to `main` then `master`: - -```bash -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -[ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/main && BASE=main -[ -z "$BASE" ] && git show-ref --verify --quiet refs/remotes/origin/master && BASE=master -BASE="${BASE:-main}" -``` - -Apply in: worktree creation, base-ref resolution for `git diff`/`git rev-list`, PR base detection, hook scripts walking history. Doc examples may write `main` for clarity; scripts must look up. Order matters — `main → master` matches fleet reality; reversing would mispick during rename migrations (enforced by `.claude/hooks/default-branch-guard/`). - -### Public-surface hygiene - -🚨 Never write a real customer / company name, private repo / internal project name, or Linear ref (`SOC-123`, `ENG-456`, Linear URLs) into a commit, PR, issue, comment, or release note. No denylist — a denylist is itself a leak (enforced by `.claude/hooks/{private-name-guard,public-surface-reminder}/`). - -🚨 Never `gh workflow run|dispatch` against publish / release / build-release workflows (enforced by `.claude/hooks/release-workflow-guard/`). Bypass: `gh workflow run -f dry-run=true` (workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim. `workflow_dispatch.inputs` keys are kebab-case. - -🚨 **Workflow YAML invariants:** SHA-pinned `uses:` lines need a `# <tag> (YYYY-MM-DD)` comment; `run:` blocks with multi-line `gh ... --body "..."` break YAML — always `--body-file <path>`; `pull_request_target` is privileged and never combines with fork-head checkout + execute. External-issue refs (`<owner>/<repo>#<num>`) in commits / PR bodies spam upstream maintainers — only `SocketDev/<repo>#<num>` is allowed inline; link upstream refs in PR _description prose_ instead. Bypass: `Allow external-issue-ref bypass`. - -Full ruleset + threat model + bypass surface in [`docs/claude.md/fleet/public-surface-hygiene.md`](docs/claude.md/fleet/public-surface-hygiene.md) and [`docs/claude.md/fleet/pull-request-target.md`](docs/claude.md/fleet/pull-request-target.md). - -### Canonical README - -🚨 Root `README.md` follows the fleet skeleton — 5 level-2 sections in order (Why this repo exists / Install / Usage / Development / License), no `socket-wheelhouse` mentions (it's a private repo), no sibling-relative script commands (e.g. `node ../socket-foo/scripts/...` fails for outside readers). Canonical skeleton: `socket-wheelhouse/template/README.md`. Bypass: `Allow readme-fleet-shape bypass` (enforced by `.claude/hooks/readme-fleet-shape-guard/`). - -### Commits & PRs - -🚨 Conventional Commits `<type>(<scope>): <description>`, lowercase type, NO AI attribution (enforced by `.claude/hooks/commit-message-format-guard/` + draft-time reminder `.claude/hooks/commit-pr-reminder/`; bypasses `Allow commit-format bypass` / `Allow ai-attribution bypass`). Push direct → PR only on rejection. NEVER push, open PRs, file issues, or create releases against a non-fleet repo without confirmation (bypasses `Allow non-fleet-push bypass` / `Allow non-fleet-publish bypass`; enforced by `.claude/hooks/no-non-fleet-push-guard/` + `.claude/hooks/non-fleet-pr-issue-ask-guard/`). - -Full ruleset — open-PR edits, Bugbot inline replies, rebase-over-revert for unpushed commits, no-empty-commits, commit-author canonical identity, scan-label scrubbing, enterprise-ruleset bypass — in [`docs/claude.md/fleet/commit-cadence-format.md`](docs/claude.md/fleet/commit-cadence-format.md). - -### Prose authoring (commit bodies, PRs, CHANGELOG, docs) - -🚨 Run human-facing prose through the `prose` skill before it lands: commit message bodies, PR descriptions, CHANGELOG entries, README sections, `docs/` markdown. The skill catches throat-clearing openers, "not X, it's Y" contrasts, em-dash chains, adverbs doing vague work, metronomic rhythms. Subject lines stay terse and imperative under `commit-message-format-guard`. Cascade commits and bot output are exempt. Full rules: [`.claude/skills/prose/SKILL.md`](.claude/skills/prose/SKILL.md). - -### Squash-history opt-in - -Some fleet repos squash the default branch on a cadence — currently socket-addon, socket-bin, socket-btm, sdxgen, stuie (declared via `optIns: ['squash-history']` in `template/.claude/skills/cascading-fleet/lib/fleet-repos.json`). When working in an opted-in repo, prefer one consolidated commit per logical change over a long fan of tiny WIP commits; the `squashing-history` skill is the documented way to collapse history when it grows long. Threshold reminder + bypass `Allow squash-history-reminder bypass` (enforced by `.claude/hooks/squash-history-reminder/`). - -### Version bumps & immutable releases - -🚨 Bump: (1) `pnpm run update` → `pnpm i` → `pnpm run fix --all` → `pnpm run check --all`; (2) CHANGELOG public-facing only; (3) `chore: bump version to X.Y.Z` LAST; (4) `git tag vX.Y.Z` (`version-bump-order-guard`); (5) user dispatches publish. GH Releases ship **immutable** via 3-step `gh release create --draft` → `gh release upload` → `gh release edit --draft=false`; single-call form forbidden (enforced by `.claude/hooks/immutable-release-pattern-guard/`; bypass `Allow immutable-release-pattern bypass`). Detail: [`docs/claude.md/fleet/version-bumps.md`](docs/claude.md/fleet/version-bumps.md), [`docs/claude.md/fleet/immutable-releases.md`](docs/claude.md/fleet/immutable-releases.md). - -### Programmatic Claude calls - -🚨 Workflows / skills / scripts that invoke `claude` CLI or `@anthropic-ai/claude-agent-sdk` MUST set all four lockdown flags: `tools`, `allowedTools`, `disallowedTools`, `permissionMode: 'dontAsk'`. Never `default` mode in headless contexts. Never `bypassPermissions`. See `.claude/skills/locking-down-programmatic-claude/SKILL.md`. - -### Tooling - -🚨 **Package manager: `pnpm`** — scripts via `pnpm run foo --flag` (never `foo:bar`); after `package.json` edits, `pnpm install`. NEVER `npx` / `pnpm dlx` / `yarn dlx` — use `pnpm exec` or `pnpm run` # socket-hook: allow npx. NEVER `--experimental-strip-types` to Node (enforced by `.claude/hooks/no-experimental-strip-types-guard/`). - -🚨 **Engine floors pinned fleet-wide:** `engines.pnpm: ">=11.4.0"` (matches the `packageManager` pin), `engines.npm: ">=11.16.0"` (added `allowScripts` script opt-in, RFC #868). Wheelhouse `package.json` is source of truth; both cascade via sync-scaffolding `engines_pnpm_drift` + `engines_npm_drift`. - -🚨 **Bundler: rolldown, not esbuild.** Backward compatibility is FORBIDDEN — actively remove when encountered. - -🚨 **`-stable` self-import:** `scripts/**` + `.claude/hooks/**` import the repo-owned fleet package via its `-stable` alias, never the bare name (bare = WIP local `src/`). Autofix `socket/prefer-stable-self-import`. - -🚨 **Supply-chain hygiene.** New deps Socket-scored at edit time (`.claude/hooks/check-new-deps/`); 7-day `minimumReleaseAge` soak is malware protection (bypass `Allow minimumReleaseAge bypass`); soak-bypass entries need `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotations. Dep overrides in `pnpm-workspace.yaml`, never `package.json` `pnpm.overrides` (bypass `Allow package-json-overrides bypass`). **Never weaken a trust gate** (`trustPolicy: no-downgrade`, `--config.trustPolicy=trust-all`, `blockExoticSubdeps`) — fix stale lockfiles via the soak/exclude entry; the bypass `Allow trust-downgrade bypass` is single-use and not persisted (enforced by `.claude/hooks/minimum-release-age-guard/` + `.claude/hooks/soak-exclude-date-annotation-guard/` + `.claude/hooks/no-package-json-pnpm-overrides-guard/` + `.claude/hooks/trust-downgrade-guard/`). - -Full ruleset (docs lead with pnpm, `packageManager` field, `.config/` placement, `.mts` runners, monorepo `engines.node`, vitest/node-test runner separation, `npm-run-all2` + `node --run` opt-in) in [`docs/claude.md/fleet/tooling.md`](docs/claude.md/fleet/tooling.md). - -🚨 **Need a database? PostgreSQL + Drizzle ORM** (driver `node:smol-sql`, `pglite` for tests, config `.config/drizzle.config.mts`). Most repos need none; don't add speculatively. [`docs/claude.md/fleet/database.md`](docs/claude.md/fleet/database.md). - -### Claude Code plugin pins - -🚨 Fleet-blessed Claude Code plugins are SHA-pinned in the wheelhouse-canonical [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json), with companion human-readable metadata (pin date, pinner) in [`.claude-plugin/README.md`](../.claude-plugin/README.md). The pair is enforced together: every `plugins[].source.sha` in `marketplace.json` must have a row in the README table with matching `version` + `sha` + an ISO-8601 `date`. Same staleness signal the GHA `uses:` SHA-pin comments carry. Bump the SHA → bump the row. Run `pnpm run install-claude-plugins` to reconcile a machine to the pinned set — adds the marketplace + installs each plugin at its pinned SHA, then reapplies `scripts/plugin-patches/*.patch` for upstream bugs we can't land yet (fleet `# @`-header + plain `diff -u` body, `patch -p1`; regenerate via `regenerating-plugin-patches`; full spec [`docs/claude.md/fleet/plugin-cache-patches.md`](docs/claude.md/fleet/plugin-cache-patches.md)) (enforced by `.claude/hooks/marketplace-comment-guard/`, `.claude/hooks/plugin-patch-format-guard/`). - -### Token minification - -Two surfaces apply lossless, deterministic compression to Claude tool_result payloads (JSON whitespace, `cat -n` prefixes, blank-line runs; no ML). **Wire-level proxy** `@socketsecurity/token-minifier` ([`packages/`](../packages/socket-token-minifier/)) sits between Claude Code and api.anthropic.com via `ANTHROPIC_BASE_URL=http://localhost:7779`; auto-started **fail-closed** by `socket-token-minifier-start` (sets the env var only if healthy). **In-context hook** `minify-mcp-output` rewrites MCP results via `hookSpecificOutput.updatedMCPToolOutput` (built-in Read/Bash have no such channel — use the proxy) (enforced by `.claude/hooks/minify-mcp-output/`, `.claude/hooks/socket-token-minifier-start/`). - -### Fix it, don't defer - -🚨 See a lint/type/test error or broken comment in your reading window — fix it. Stop current task, fix the issue in a sibling commit, resume. Don't label as "pre-existing", "unrelated", or "out of scope" — the labels are rationalizations (enforced by `.claude/hooks/excuse-detector/`). - -🚨 Don't blame the user (or "the linter") when your own edits get reverted between turns. The cause is almost always your own scripts: pre-commit autofix, sync-cascade from `template/`, oxlint --fix. Investigate with `git log -S`, run pre-commit phases in isolation, diff `template/` canonical sources. Only attribute to the user with direct evidence (enforced by `.claude/hooks/dont-blame-user-reminder/`). - -🚨 Never offer "fix vs accept-as-gap" as a choice — pick the fix. - -Exceptions (state the trade-off and ask): genuinely large refactor on a small bug, file belongs to another session, fix needs off-machine action. - -### Don't leave the worktree dirty - -🚨 Finish a code change → **commit it**. Never end a turn with uncommitted edits, untracked files, or staged-but-uncommitted hunks. Surgical staging only (`git add <specific-file>`, never `-A` / `.`); stage and commit in the same Bash call. If you can't commit yet (mid-refactor, failing tests, waiting on user), announce it in the turn summary — silent dirty worktrees are the failure mode. Worktrees from `git worktree add` must be left clean (committed + pushed) before `git worktree remove`. Enforced by `.claude/hooks/no-orphaned-staging/` + `.claude/hooks/node-modules-staging-guard/` (bypass: `Allow node-modules-staging bypass`); end-of-turn dirty-worktree scan (enforced by `.claude/hooks/dirty-worktree-on-stop-reminder/`). Full rules + parallel-session rationale in [`docs/claude.md/fleet/worktree-hygiene.md`](docs/claude.md/fleet/worktree-hygiene.md). - -### Smallest chunks, land ASAP - -🚨 Smallest possible chunks; land ASAP via direct-push-to-main. Don't accumulate work across worktrees or long-lived branches; each unmerged branch is in-flight state that has to be rebased and reconciled later. Past incident: 4 sibling wheelhouse worktrees (2 dead, 2 needing rebase) burned a turn on consolidation. **How to apply:** finish a branch the session it's opened; consolidate any pile-up at session start before resuming the queue. - -### Commit cadence & message format - -🚨 Commit early, commit often. Every commit follows [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/): lowercase `<type>[(scope)][!]: <description>` with type ∈ { feat, fix, chore, docs, style, refactor, perf, test, build, ci, revert }. No AI attribution anywhere. Bypass: `Allow commit-format bypass` or `Allow ai-attribution bypass`. Full rationale + examples + edge cases in [`docs/claude.md/fleet/commit-cadence-format.md`](docs/claude.md/fleet/commit-cadence-format.md) (enforced by `.claude/hooks/commit-message-format-guard/` at commit time + `.claude/hooks/commit-pr-reminder/` at draft time). - -### Don't disable lint rules - -🚨 Adding `"rule-name": "off"` (or `"warn"`) to any oxlint/eslint config weakens the gate for every file matching that selector. Fix the underlying code instead. For genuine single-call-site exemptions, use `oxlint-disable-next-line <rule> -- <reason>` on the specific line. Bypass: `Allow disable-lint-rule bypass`. Full rationale + recipes in [`docs/claude.md/fleet/no-disable-lint-rule.md`](docs/claude.md/fleet/no-disable-lint-rule.md) (enforced by `.claude/hooks/no-disable-lint-rule-guard/`). - -### Extension build hygiene - -🚨 The trusted-publisher Chrome extension at `tools/trusted-publisher-extension/` is bundled via rolldown. Commits that touch `tools/trusted-publisher-extension/src/**` MUST be paired with a successful `pnpm --filter @socketsecurity/trusted-publisher-extension build` so the bundled output stays loadable. Bypass: `Allow extension-build-current bypass`. (Enforced by `.claude/hooks/extension-build-current-guard/`.) - -### Untracked-by-default for vendored / build-copied trees - -🚨 Dirs under `additions/source-patched/`, `vendor/`, `third_party/`, `external/`, `upstream/`, `deps/<lib>/`, `pkg-node/`, `*-bundled`/`*-vendored` are **untracked-by-default** — before staging, `git status --ignored` + read `.gitignore` allowlists + find the build script that copies the dir. When REMOVING a consumed class/attr/selector, grep the repo root AND every `upstream/`/`vendor/` submodule first (enforced by `.claude/hooks/consumer-grep-reminder/`). Run the command instead of guessing; ask before 100+-file/multi-MB drops. Full playbook: [`docs/claude.md/fleet/untracked-by-default.md`](docs/claude.md/fleet/untracked-by-default.md). - -### Hook bypasses require the canonical phrase - -🚨 Reverting tracked changes or bypassing a hook (--no-verify, DISABLE*PRECOMMIT*\*, --no-gpg-sign, force-push) requires the user to type **`Allow <X> bypass`** verbatim in a recent user turn (e.g. `Allow revert bypass`, `Allow no-verify bypass`). Paraphrases don't count (enforced by `.claude/hooks/no-revert-guard/`). Full phrase table: [`docs/claude.md/fleet/bypass-phrases.md`](docs/claude.md/fleet/bypass-phrases.md). - -**Exception — wheelhouse cascade.** Mechanical `chore(wheelhouse): cascade template@<sha>` operations across the fleet would otherwise need a fresh bypass phrase per repo. Prefix cascade Bash commands with `FLEET_SYNC=1` to opt in: the sentinel allowlists exactly three operations — (1) `git commit --no-verify` whose message starts with `chore(wheelhouse): cascade template@`; (2) `git push --no-verify`; (3) broad-stage `git add -A` / `git add -u` / `git add .` (safe inside a fresh worktree off `origin/main`, which is how cascade scripts work). Everything else with `FLEET_SYNC=1` still falls through to the normal checks — `git stash`, `git reset --hard`, `git checkout/restore`, non-cascade commits all still need the canonical phrase. The sentinel is opt-in per command; no global env-var poisoning. (Enforced by `.claude/hooks/no-revert-guard/` + `.claude/hooks/overeager-staging-guard/`.) - -### Variant analysis on every High/Critical finding - -🚨 When a finding lands at severity High or Critical, **search the rest of the repo for the same shape** before closing it. Bugs cluster — same mental model, same antipattern. Three searches: same file (read the whole thing, not just the hunk), sibling files (`rg` the shape, not the names), cross-package (parallel implementations love to drift). - -Skip for style nits. Full taxonomy in [`.claude/skills/_shared/variant-analysis.md`](.claude/skills/_shared/variant-analysis.md). Cross-fleet variants become a _Drift watch_ task — open `chore(wheelhouse): cascade <fix>` (enforced by `.claude/hooks/variant-analysis-reminder/`). - -### Compound lessons into rules - -When the same kind of finding fires twice — across two runs, two PRs, or two fleet repos — **promote it to a rule** instead of fixing it again. Land it in CLAUDE.md, a `.claude/hooks/*` block, or a skill prompt — pick the lowest-friction surface. Always cite the original incident in a `**Why:**` line. Skip the retrospective doc; the rule is the artifact (enforced by `.claude/hooks/compound-lessons-reminder/`). Discipline: [`.claude/skills/_shared/compound-lessons.md`](.claude/skills/_shared/compound-lessons.md). - -Every new `.claude/hooks/<name>/` hook must have a matching `(enforced by `.claude/hooks/<name>/`)` reference in CLAUDE.md before the hook's `index.mts` can be written (enforced by `.claude/hooks/new-hook-claude-md-guard/`). Hooks ignore CLAUDE.md themselves — citing the enforcer inline keeps the rule visible to whoever's reading either surface. - -### Plan review before approval - -For non-trivial work (multi-file refactor, new feature, migration), the plan itself is a deliverable. List steps numerically, name files you'll touch, name rules you'll honor — don't bury the plan in prose. If the plan touches fleet-shared resources (this CLAUDE.md fleet block, hooks, `_shared/`), invite a second-opinion pass before writing code. If the plan adds a fleet rule, name the original incident (per _Compound lessons_) (enforced by `.claude/hooks/plan-review-reminder/`). - -### Plan storage - -🚨 Design / implementation / migration plan docs live at `<repo-root>/.claude/plans/<lowercase-hyphenated>.md` and are **never tracked by version control** — the fleet `.gitignore` excludes `/.claude/*` and `plans/` is intentionally absent from the allowlist. Don't write plans into `docs/plans/` or a package-level `<pkg>/docs/plans/` (enforced by `.claude/hooks/plan-location-guard/`; bypass: `Allow plan-location bypass`). Full rationale + migration guidance in [`docs/claude.md/fleet/plan-storage.md`](docs/claude.md/fleet/plan-storage.md). - -### Doc filenames - -🚨 Markdown files are `lowercase-with-hyphens.md` and live in any `docs/` directory (repo-root `docs/`, package `packages/<pkg>/docs/`, language `packages/<pkg>/lang/<lang>/docs/`, etc.) or under `.claude/`. SCREAMING_CASE names are restricted to a fleet allowlist (`README`, `LICENSE`, `CLAUDE`, `CHANGELOG`, `CONTRIBUTING`, `GOVERNANCE`, `MAINTAINERS`, `NOTICE`, `SECURITY`, `SUPPORT`, etc.) and only at repo root, repo-root `docs/`, or `.claude/` — not deeper. `README.md` and `LICENSE` are allowed anywhere. Source-file-hint shape (`smol-ffi.js.md` describing `smol-ffi.js`) is allowed in any `docs/` (enforced by `.claude/hooks/markdown-filename-guard/`). - -### Drift watch - -🚨 **Drift across fleet repos is a defect, not a feature.** When two socket-\* repos pin different versions of the same shared resource (a tool in `external-tools.json`, a workflow SHA, a CLAUDE.md fleet block, a hook in `.claude/hooks/`, an upstream submodule, `.gitmodules` `# name-version` annotations enforced by `.claude/hooks/gitmodules-comment-guard/` + GitHub SHA-pin reachability across workflows/`.gitmodules`/`package.json` URLs by `.claude/hooks/uses-sha-verify-guard/` (bypass `Allow uses-sha-verify bypass`), pnpm/Node `packageManager`/`engines`), **opt for the latest**. Canonical sources: `socket-registry`'s `setup-and-install` action for tool SHAs; `socket-wheelhouse`'s `template/` tree for `.claude/`, CLAUDE.md fleet block, hooks. Either reconcile in the same PR or open `chore(wheelhouse): cascade <thing> from <newer-repo>` and link it (enforced by `.claude/hooks/drift-check-reminder/`). Full drift-surface list + cascade-PR convention in [`docs/claude.md/fleet/drift-watch.md`](docs/claude.md/fleet/drift-watch.md). - -### Stranded cascades - -🚨 Local-only `chore(wheelhouse): cascade template@<sha>` commits + `chore/wheelhouse-<sha>` worktrees whose template SHA has been superseded on origin accumulate from interrupted cascade waves and silently block future pushes. The wheelhouse cascade auto-runs `socket-wheelhouse/scripts/fleet/cleanup-stranded.mts --target <repo>` at the start of every wave (default = fix; pass `--dry-run` to report only). Safety rails: cascade-subject regex match + trusted commit author + strict-ancestor proof of supersession + cascade-allowlist file check. Any ambiguity → bail the whole repo. Full algorithm + recovery instructions in [`docs/claude.md/fleet/stranded-cascades.md`](docs/claude.md/fleet/stranded-cascades.md). - -### Never fork fleet-canonical files locally - -🚨 Edit fleet-canonical files (anything in the sync manifest) ONLY in `socket-wheelhouse/template/...` — never in a downstream repo. Spot a missing helper in a downstream copy? Lift it upstream and re-cascade (enforced by `.claude/hooks/no-fleet-fork-guard/`; bypass: `Allow fleet-fork bypass`). Full canonical-surface list + lifting workflow: [`docs/claude.md/wheelhouse/no-local-fork-canonical.md`](docs/claude.md/wheelhouse/no-local-fork-canonical.md). - -### Code style - -Default to no comments (enforced by `.claude/hooks/no-meta-comments-guard/`); when written, write for a junior reader. Heaviest fleet invariants: no `TODO`/`FIXME`/stubs; `undefined` over `null`; `httpJson`/`httpText` from `@socketsecurity/lib/http-request` over `fetch()`; `safeDelete()` from `@socketsecurity/lib/fs` over `fs.rm`; Edit tool over `sed`/`awk`; `JSON.parse(JSON.stringify(x))` over `structuredClone(x)` for JSON-shaped data; `getDefaultLogger()` over `console.*` (enforced by `.claude/hooks/logger-guard/`); `@sinclair/typebox` for wire/config schema validation over zod/valibot/ajv. Cross-port files use `Lock-step` comments; see [`docs/claude.md/fleet/parser-comments.md`](docs/claude.md/fleet/parser-comments.md) §5–7 (enforced by `.claude/hooks/lock-step-ref-guard/` + `scripts/check-lock-step-{refs,header}.mts`; bypass: `Allow lock-step bypass`). Full ruleset in [`docs/claude.md/fleet/code-style.md`](docs/claude.md/fleet/code-style.md). - -### No underscore-prefixed identifiers - -🚨 Never prefix an **identifier** (function, variable, type, export) with `_` — patterns like `_resetX`, `_cache`, `_doFoo`, `_internal` are banned at the symbol level. Privacy in TS is handled by module boundaries (not exporting) or by `_internal/` _directory_ layout; the underscore-as-internal-marker convention from other languages adds noise without enforcement. Exporting "internal" helpers is fine and explicitly preferred — easier to unit-test. **Exception:** the directory name `_internal/` is allowed (and is the documented way to signal module-private files); the rule is about identifiers inside files, not folder layout (enforced by `.claude/hooks/no-underscore-identifier-guard/` + the `socket/no-underscore-identifier` oxlint rule; bypass: `Allow underscore-identifier bypass`). - -### File size - -Soft cap **500 lines**, hard cap **1000 lines** per source file. Past those, split along natural seams — group by domain, not line count; name files for what's in them; co-locate helpers with consumers. Exceptions: a single function that legitimately needs the space (note it inline), or a generated artifact. Full playbook in [`docs/claude.md/fleet/file-size.md`](docs/claude.md/fleet/file-size.md). - -### Lint rules: errors over warnings, fixable over reporting - -🚨 Fleet lint rules are guardrails for AI-generated code — make them strict. Default new rules to `"error"` (never `"warn"`). Ship an autofix when the rewrite is deterministic (`fixable: 'code'` + `fix(fixer) => ...`). Defense in depth: skill (docs) + hook (edit-time) + lint (commit-time) — having one doesn't excuse the others. Tooling: oxlint + oxfmt only (no ESLint, no Prettier); the fleet socket-\* plugin lives in `template/.config/oxlint-plugin/`. Always invoke with explicit `-c .config/...rc.json` so the tools don't fall through to their double-quotes + semis defaults. No file-scope `oxlint-disable` blocks — use `oxlint-disable-next-line <rule> -- <reason>` per call site (enforced by `socket/no-file-scope-oxlint-disable` + `.claude/hooks/no-file-scope-oxlint-disable-guard/`). Don't stack byte-identical disables on adjacent lines — refactor to a helper or named constant. Full rationale + cascade behavior + recipes in [`docs/claude.md/fleet/lint-rules.md`](docs/claude.md/fleet/lint-rules.md). - -### c8 / v8 coverage ignore directives - -🚨 `/* c8 ignore next N */` is broken for multi-line bodies (the reporter counts physical lines, not statements) — always bracket the construct with `/* c8 ignore start - <reason> */` … `/* c8 ignore stop */`; single-line `/* c8 ignore next */` is fine. **Why:** 2026-05-24 socket-lib coverage rose 98.9%→99.15% just by rewriting `next N` to start/stop. Full catalog: [`docs/claude.md/fleet/c8-ignore-directives.md`](docs/claude.md/fleet/c8-ignore-directives.md). - -### 1 path, 1 reference - -🚨 A path is constructed exactly once; everywhere else references the constructed value. Per-package `scripts/paths.mts` is the canonical owner; sub-packages inherit via `export *`. Build outputs live at `<package-root>/build/<mode>/<platform-arch>/out/Final/<artifact>`. Enforced at three levels: `.claude/hooks/path-guard/` (edit-time, build-path construction outside `paths.mts`), `.claude/hooks/paths-mts-inherit-guard/` (edit-time, sub-package inheritance), `scripts/check-paths.mts` (commit-time, whole-repo). `/guarding-paths` is the audit-and-fix skill. Full ruleset + canonical layout + common mistakes in [`docs/claude.md/fleet/path-hygiene.md`](docs/claude.md/fleet/path-hygiene.md). - -### Conformance runners - -External-spec-conformance runners (test262, WPT, future suites) use a canonical 4-tier layout: sparse-checkout submodule at `<pkg>/test/fixtures/<corpus>/`, thin runner CLI at `<pkg>/test/scripts/<corpus>-<scope>-runner.mts` with modular guts under `<corpus>/`, vitest integration wrapper at `<pkg>/test/integration/<corpus>-<scope>.test.mts` that spawns the runner + checks exit code (auto-runs via `pnpm test`), vitest unit tests at `<pkg>/test/unit/<corpus>-<scope>.test.mts` covering the pure classifier. Allowlist lives in a separate file under `<corpus>-config/`, never inline. Build-time submodules go under `upstream/`; test-time corpora go under `test/fixtures/`. Use `scripts/git-partial-submodule.mts` to honor `sparse-checkout = <patterns>` declared in `.gitmodules`. Full layout + authoring checklist in [`docs/claude.md/fleet/conformance-runners.md`](docs/claude.md/fleet/conformance-runners.md). - -### Cross-platform path matching - -When a regex matches against a path string, **normalize the path first** with `normalizePath` (or `toUnixPath`) from `@socketsecurity/lib/paths/normalize` and write the regex against `/` only. Don't write dual-separator patterns like `[/\\]` — they're easy to miss in some branches, slower to read, and they multiply when you add `\\\\` for escaped Windows separators. `normalizePath` is the same helper the fleet uses everywhere; relying on it gives one path representation across `darwin` / `linux` / `win32` (enforced by `.claude/hooks/path-regex-normalize-reminder/`). Bypass: `Allow path-regex-normalize bypass`. - -### Background Bash - -Never use `Bash(run_in_background: true)` for test / build commands (`vitest`, `pnpm test`, `pnpm build`, `tsgo`) — backgrounded runs you don't poll leak Node workers. Background mode is for dev servers and long migrations whose results you'll consume. Kill hangs with `pkill -f "vitest/dist/workers"`; `.claude/hooks/stale-process-sweeper/` reaps orphans on Stop. `.DS_Store` files swept at turn-end by `.claude/hooks/sweep-ds-store/` — no bypass; never wanted in a repo. When writing Bash-allowlist hooks, prefer **AST-based parsing** (via `.claude/hooks/_shared/shell-command.mts` / `findInvocation`, wraps `shell-quote`) over regex when the rule reasons about command structure — regex approves `git $(echo rm) foo.txt`; AST blocks it. - -🚨 Tests never connect to third-party servers — mock HTTP with `nock` (`disableNetConnect()` + stubs; `registry-*.test.mts` are canonical). Fleet `test/setup.mts` fails closed; localhost stays allowed. Bypass: `Allow unmocked-network-in-tests bypass` (enforced by `.claude/hooks/no-unmocked-network-in-tests-guard/`). - -### Judgment & self-evaluation - -🚨 **Default to perfectionist** when you have latitude — "works now" ≠ "right" (enforced by `.claude/hooks/perfectionist-reminder/`). **Direct imperatives → execute, don't litigate**: bare commands ("do it", "kill it", "cancel the build") get the tool call, not a tradeoff paragraph (enforced by `.claude/hooks/follow-direct-imperative-reminder/`). **When the user authorizes a queue** ("complete each one", "100%", "do them all"): finish every item before stopping — no "what's next?" / "session totals" mid-queue (enforced by `.claude/hooks/dont-stop-mid-queue-reminder/`); skip AskUserQuestion when explicit go-ahead is already in transcript (enforced by `.claude/hooks/ask-suppression-reminder/`). **Fix warnings on sight** — don't label "pre-existing" / "out of scope" (enforced by `.claude/hooks/excuse-detector/`). **UI/render changes**: rebuild + visually verify BEFORE committing (enforced by `.claude/hooks/verify-rendered-output-before-commit-reminder/`). Flag adjacent bugs ("I also noticed X — want me to fix it?"). Name misconceptions before executing. If a fix fails twice: stop, re-read top-down, try something fundamentally different. Full prose + scenarios + past incidents in [`docs/claude.md/fleet/judgment-and-self-evaluation.md`](docs/claude.md/fleet/judgment-and-self-evaluation.md). - -### Error messages - -An error message is UI. The reader should fix the problem from the message alone. Four ingredients in order: - -1. **What** — the rule, not the fallout (`must be lowercase`, not `invalid`). -2. **Where** — exact file / line / key / field / flag. -3. **Saw vs. wanted** — the bad value and the allowed shape or set. -4. **Fix** — one imperative action (`rename the key to …`). - -Use `isError` / `isErrnoException` / `errorMessage` / `errorStack` from `@socketsecurity/lib/errors` over hand-rolled checks. Use `joinAnd` / `joinOr` from `@socketsecurity/lib/arrays` for allowed-set lists. Vague-shape `throw new Error("…")` strings are flagged on Stop (enforced by `.claude/hooks/error-message-quality-reminder/`). Full guidance in [`docs/claude.md/fleet/error-messages.md`](docs/claude.md/fleet/error-messages.md). - -### Token hygiene - -🚨 Never emit a raw secret to tool output, commits, comments, or replies; when blocked, rewrite — don't bypass. Redact `token` / `jwt` / `api_key` / `secret` / `password` / `authorization` fields when citing API responses (`.claude/hooks/token-guard/`). Tokens live in env vars (CI) or the OS keychain (dev local) — never in `.env*` / `.envrc` / `~/.sfw.config` / dotfiles (`.claude/hooks/no-token-in-dotenv-guard/`). Setup + rotation: `node .claude/hooks/setup-security-tools/install.mts [--rotate]` — the ONLY correct rotator. Never call platform keychain CLIs from Bash to read (token is already in-process — use `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN`); writes/deletes are allowed. Bypass: `Allow blind-keychain-read bypass` (`.claude/hooks/no-blind-keychain-read-guard/`). Canonical env var: `SOCKET_API_TOKEN` in docs / workflow inputs / `.env.example`; local-dev keychain stores as `SOCKET_API_KEY`. Full spec: [`docs/claude.md/fleet/token-hygiene.md`](docs/claude.md/fleet/token-hygiene.md). - -### gh token hygiene - -🚨 GitHub CLI tokens are high-blast-radius. Three invariants apply (enforced by `.claude/hooks/gh-token-hygiene-guard/`): - -1. **Keychain storage only.** `gh auth status` must report `(keyring)`. On-disk `~/.config/gh/hosts.yml` rejected — re-auth with `gh auth logout && gh auth login` (keychain is the default since gh 2.40). Nx breach exfiltrated this file in <74s. -2. **`workflow` scope off by default; bypass single-use + Touch ID.** Type `Allow workflow-scope bypass` → `gh auth refresh -s workflow` → Touch ID (osascript fallback, absolute `/usr/bin/` paths defeat PATH-hijack) → ONE dispatch. Recommended scopes: `read:org, repo, gist` (gh forces `gist`). -3. **8-hour token age cap.** Same hook. Refresh: `gh auth refresh -h github.com`. If you refreshed outside Claude (side shell), run `node .claude/hooks/gh-token-hygiene-guard/index.mts --stamp` to recover. Full spec + recovery: [`docs/claude.md/fleet/gh-token-hygiene.md`](docs/claude.md/fleet/gh-token-hygiene.md). - -### Commit signing - -🚨 Commits on `main`/`master` must be signed. Three layers: pre-commit config gate, pre-push signature check (`%G?` ∈ {`N`,`B`} blocks), GitHub `required_signatures`. Setup: `node .claude/hooks/setup-signing/install.mts`. Bypass envs `SOCKET_PRE_{COMMIT,PUSH}_ALLOW_UNSIGNED=1`. Full spec: [`docs/claude.md/fleet/commit-signing.md`](docs/claude.md/fleet/commit-signing.md). Post-hoc audit: `node scripts/audit-transcript.mts --recent` flags privileged tool uses in a session ([full stack](docs/claude.md/fleet/security-stack.md)). - -### Agents & skills - -- `/scanning-security` — AgentShield + zizmor audit -- `/scanning-quality` — quality analysis -- Shared subskills in `.claude/skills/_shared/` -- **Handing off to another agent** — see [`docs/claude.md/fleet/agent-delegation.md`](docs/claude.md/fleet/agent-delegation.md). -- **Skill scope tiers** (fleet / partial / unique), the `updating` umbrella + `updating-*` siblings convention, and the `scripts/run-skill-fleet.mts` cross-fleet runner in [`docs/claude.md/fleet/agents-and-skills.md`](docs/claude.md/fleet/agents-and-skills.md). - -### Tool-specific guards - -Hooks that gate specific external tools — they only fire when those tools appear in a command, so they're safe to wire fleet-wide: - -- `codex-no-write-guard` — blocks `codex` / `codex-rescue` invocations with write-intent flags. Use Codex for advice not code. Bypass: `Allow codex-write bypass` (enforced by `.claude/hooks/codex-no-write-guard/`). -- `concurrent-cargo-build-guard` — blocks a second `cargo build --release` while one is in flight (8 LLVM threads × 8-22GB = OOM). Cargo-only. Bypass: `Allow concurrent-cargo-build bypass` (enforced by `.claude/hooks/concurrent-cargo-build-guard/`). -- `broken-hook-detector` — SessionStart probe: load each sibling hook, report missing-import → `pnpm i <pkg>` fix instead of `package_json_reader:314` spam. Node-builtins only (enforced by `.claude/hooks/broken-hook-detector/`). - -<!-- END FLEET-CANONICAL --> - -## 🏗️ CLI-Specific - -### Commands - -- **Build**: `pnpm run build` (smart) | `--force` | `pnpm run build:cli` | `pnpm run build:sea` -- **Test**: `pnpm test` (monorepo root) | `pnpm --filter @socketsecurity/cli run test:unit <path>` -- **Lint**: `pnpm run lint` | **Type check**: `pnpm run type` | **Check all**: `pnpm run check` -- **Fix**: `pnpm run fix` | **Dev**: `pnpm dev` (watch) | **Run built**: `node packages/cli/dist/index.js <args>` - -### Testing - -- 🚨 **NEVER use `--` before test file paths** — runs ALL tests -- Always build before testing: `pnpm run build:cli` -- Update snapshots: `pnpm testu <path>` or `--update` flag -- NEVER write source-code-scanning tests — verify behavior, not string patterns - -### Command Pattern - -- Simple (<200 LOC, no subcommands): single `cmd-*.mts` -- Complex: `cmd-*.mts` + `handle-*.mts` + `output-*.mts` + `fetch-*.mts` - -### Codex Usage - -Advice and critical assessment ONLY — never for making code changes. Consult before complex optimizations (>30min). diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..8895bac08 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Socket Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0915d10cd..04a926d6f 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,121 @@ # Socket CLI [![Socket Badge](https://socket.dev/api/badge/npm/package/socket)](https://socket.dev/npm/package/socket) -[![CI](https://github.com/SocketDev/socket-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/SocketDev/socket-cli/actions/workflows/ci.yml) -![Coverage](https://img.shields.io/badge/coverage-75.08%25-brightgreen) - [![Follow @SocketSecurity](https://img.shields.io/twitter/follow/SocketSecurity?style=social)](https://twitter.com/SocketSecurity) -[![Follow @socket.dev on Bluesky](https://img.shields.io/badge/Follow-@socket.dev-1DA1F2?style=social&logo=bluesky)](https://bsky.app/profile/socket.dev) - -CLI for [Socket.dev](https://socket.dev) — bring Socket's supply-chain security analysis to your terminal and CI. - -## Why this repo exists -Socket CLI is the command-line interface to [Socket.dev](https://socket.dev), letting you scan dependencies, audit packages, and gate installs from your terminal or CI. This repository is the source for the published `socket` package on npm; end-user documentation lives on [socket.dev](https://docs.socket.dev) and the [`socket` npm page](https://socket.dev/npm/package/socket). +> CLI tool for [Socket.dev](https://socket.dev/) -## Install +## Usage -```sh +```bash npm install -g socket +socket --help ``` -Then run: +## Commands -```sh -socket --help -``` +- `socket npm [args...]` and `socket npx [args...]` - Wraps `npm` and `npx` to + integrate Socket and preempt installation of alerted packages using the + builtin resolution of `npm` to precisely determine package installations. -## Usage +- `socket optimize` - Optimize dependencies with + [`@socketregistry`](https://github.com/SocketDev/socket-registry) overrides! + _(👀 [our blog post](https://socket.dev/blog/introducing-socket-optimize))_ -```sh -# Scan a package -socket package npm/express@4.18.0 + - `--pin` - Pin overrides to their latest version. + - `--prod` - Add overrides for only production dependencies. -# Scan your project's dependencies -socket scan create +- `socket cdxgen [command]` - Call out to + [cdxgen](https://cyclonedx.github.io/cdxgen/#/?id=getting-started). See + [their documentation](https://cyclonedx.github.io/cdxgen/#/CLI?id=getting-help) + for commands. -# Audit an install before it runs -socket npm install -``` +## Aliases -See [the Socket docs](https://docs.socket.dev) for the full command reference. +All aliases support the flags and arguments of the commands they alias. -## Development +- `socket ci` - alias for `socket report create --view --strict` which creates a + report and quits with an exit code if the result is unhealthy. Use like eg. + `socket ci .` for a report for the current folder -<details> -<summary>Contributor commands</summary> +## Flags -```sh -git clone https://github.com/SocketDev/socket-cli.git -cd socket-cli -pnpm install -pnpm run build -pnpm test -``` +### Command specific flags -Requires Node.js (see `.node-version`) and pnpm (see the `packageManager` field in `package.json`). +- `--view` - when set on `socket report create` the command will immediately do + a `socket report view` style view of the created report, waiting for the + server to complete it -| Command | Description | -| ------------------------ | --------------------------------- | -| `pnpm run build` | Smart build (skips unchanged) | -| `pnpm run build --force` | Force rebuild everything | -| `pnpm run build:cli` | Build CLI package only | -| `pnpm run build:sea` | Build SEA binaries | -| `pnpm dev` | Watch mode (auto-rebuild) | -| `pnpm test` | Run all tests | -| `pnpm testu` | Update test snapshots | -| `pnpm run check` | Lint + typecheck | -| `pnpm run fix` | Auto-fix lint + formatting | +### Output flags -Run the built CLI from source: +- `--json` - outputs result as json which you can then pipe into + [`jq`](https://stedolan.github.io/jq/) and other tools +- `--markdown` - outputs result as markdown which you can then copy into an + issue, PR or even chat -```sh -node packages/cli/dist/index.js --help -``` +## Strictness flags -Enable debug logging: +- `--all` - by default only `high` and `critical` issues are included, by + setting this flag all issues will be included +- `--strict` - when set, exits with an error code if report result is deemed + unhealthy -```sh -SOCKET_CLI_DEBUG=1 node packages/cli/dist/index.js <command> +### Other flags + +- `--dry-run` - like all CLI tools that perform an action should have, we have a + dry run flag. Eg. `socket report create` supports running the command without + actually uploading anything +- `--debug` - outputs additional debug output. Great for debugging, geeks and us + who develop. Hopefully you will never _need_ it, but it can still be fun, + right? +- `--help` - prints the help for the current command. All CLI tools should have + this flag +- `--version` - prints the version of the tool. All CLI tools should have this + flag + +## Configuration files + +The CLI reads and uses data from a +[`socket.yml` file](https://docs.socket.dev/docs/socket-yml) in the folder you +run it in. It supports the version 2 of the `socket.yml` file format and makes +use of the `projectIgnorePaths` to excludes files when creating a report. + +## Environment variables + +- `SOCKET_CLI_API_TOKEN` - if set, this will be used as the API-key + +## Contributing + +### Setup + +To run dev locally you can run these steps + +``` +npm install +npm run build:dist +npm exec socket ``` -Key development environment variables: +That should invoke it from local sources. If you make changes you run +`build:dist` again. -| Variable | Description | -| ------------------------- | -------------------------- | -| `SOCKET_CLI_DEBUG` | Enable debug logging (`1`) | -| `SOCKET_CLI_API_TOKEN` | Socket API token | -| `SOCKET_CLI_ORG_SLUG` | Socket organization slug | -| `SOCKET_CLI_API_BASE_URL` | Override API endpoint | -| `SOCKET_CLI_NO_API_TOKEN` | Disable default API token | +### Environment variables for development -Further contributor reading: +- `SOCKET_CLI_API_BASE_URL` - if set, this will be the base for all + API-calls. Defaults to `https://api.socket.dev/v0/` +- `SOCKET_CLI_API_PROXY` - if set to something like + [`http://127.0.0.1:9090`](https://docs.proxyman.io/troubleshooting/couldnt-see-any-requests-from-3rd-party-network-libraries), + then all request will be proxied through that proxy -- [`docs/build-guide.md`](docs/build-guide.md) — build pipeline, SEA binaries, cache management -- [`docs/bundle-tools.md`](docs/bundle-tools.md) — how bundled tools (opengrep, trivy, etc.) are integrated -- [`packages/cli/README.md`](packages/cli/README.md) — CLI package architecture -- [`packages/build-infra/README.md`](packages/build-infra/README.md) — shared build tooling -- [`packages/package-builder/README.md`](packages/package-builder/README.md) — template-based package generation +## Similar projects -</details> +- [`@socketsecurity/sdk`](https://github.com/SocketDev/socket-sdk-js) - the SDK + used in this CLI -## License +## See also -MIT +- [Announcement blog post](https://socket.dev/blog/announcing-socket-cli-preview) +- [Socket API Reference](https://docs.socket.dev/reference) - the API used in + this CLI +- [Socket GitHub App](https://github.com/apps/socket-security) - the + plug-and-play GitHub App diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 27231c989..000000000 --- a/SECURITY.md +++ /dev/null @@ -1,7 +0,0 @@ -# Reporting Security Issues - -**Report security vulnerabilities directly to [security@socket.dev](mailto:security@socket.dev).** - -All reports are taken seriously and addressed promptly. - -**Do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** diff --git a/assets/socket-logo-dark.png b/assets/socket-logo-dark.png deleted file mode 100644 index 665ec2782..000000000 Binary files a/assets/socket-logo-dark.png and /dev/null differ diff --git a/assets/socket-logo-light.png b/assets/socket-logo-light.png deleted file mode 100644 index 7f14ce68f..000000000 Binary files a/assets/socket-logo-light.png and /dev/null differ diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 000000000..c6e0f3d81 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +'use strict' + +const Module = require('node:module') +const path = require('node:path') +const rootPath = path.join(__dirname, '..') +Module.enableCompileCache?.(path.join(rootPath, '.cache')) +const process = require('node:process') + +const constants = require(path.join(rootPath, 'dist/constants.js')) +const { spawn } = require( + path.join(rootPath, 'external/@socketsecurity/registry/lib/spawn.js'), +) + +const { NODE_COMPILE_CACHE } = constants + +process.exitCode = 1 + +spawn( + // Lazily access constants.execPath. + constants.execPath, + [ + // Lazily access constants.nodeHardenFlags. + ...constants.nodeHardenFlags, + // Lazily access constants.nodeNoWarningsFlags. + ...constants.nodeNoWarningsFlags, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD. + ...(constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD + ? [ + '--require', + // Lazily access constants.instrumentWithSentryPath. + constants.instrumentWithSentryPath, + ] + : []), + // Lazily access constants.distCliPath. + constants.distCliPath, + ...process.argv.slice(2), + ], + { + env: { + ...process.env, + ...(NODE_COMPILE_CACHE ? { NODE_COMPILE_CACHE } : undefined), + }, + stdio: 'inherit', + }, +) + // See https://nodejs.org/api/all.html#all_child_process_event-exit. + .process.on('exit', (code, signalName) => { + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + // eslint-disable-next-line n/no-process-exit + process.exit(code) + } + }) diff --git a/bin/npm-cli.js b/bin/npm-cli.js new file mode 100755 index 000000000..1f638cd9a --- /dev/null +++ b/bin/npm-cli.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +'use strict' + +const Module = require('node:module') +const path = require('node:path') +const rootPath = path.join(__dirname, '..') +Module.enableCompileCache?.(path.join(rootPath, '.cache')) + +const shadowBin = require(path.join(rootPath, 'dist/shadow-bin.js')) +shadowBin('npm') diff --git a/bin/npx-cli.js b/bin/npx-cli.js new file mode 100755 index 000000000..89613a03f --- /dev/null +++ b/bin/npx-cli.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +'use strict' + +const Module = require('node:module') +const path = require('node:path') +const rootPath = path.join(__dirname, '..') +Module.enableCompileCache?.(path.join(rootPath, '.cache')) + +const shadowBin = require(path.join(rootPath, 'dist/shadow-bin.js')) +shadowBin('npx') diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..779cac3da --- /dev/null +++ b/biome.json @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": [ + "**", + "!**/.DS_Store", + "!**/._.DS_Store", + "!**/.env", + "!**/.git", + "!**/.github", + "!**/.husky", + "!**/.nvm", + "!**/.rollup.cache", + "!**/.type-coverage", + "!**/.vscode", + "!**/coverage", + "!**/package.json", + "!**/package-lock.json", + "!external/@coana-tech" + ], + "maxSize": 8388608 + }, + "formatter": { + "enabled": true, + "attributePosition": "auto", + "bracketSpacing": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 80, + "useEditorconfig": true + }, + "javascript": { + "formatter": { + "arrowParentheses": "asNeeded", + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "quoteStyle": "single", + "semicolons": "asNeeded", + "trailingCommas": "all" + } + }, + "json": { + "formatter": { + "enabled": true, + "trailingCommas": "none" + }, + "parser": { + "allowComments": true, + "allowTrailingCommas": true + } + }, + "linter": { + "rules": { + "style": { + "noParameterAssign": "error", + "useAsConstAssertion": "error", + "useDefaultParameterLast": "error", + "useEnumInitializers": "error", + "useSelfClosingElements": "error", + "useSingleVarDeclarator": "error", + "noUnusedTemplateLiteral": "error", + "useNumberNamespace": "error", + "noInferrableTypes": "error", + "noUselessElse": "error" + } + } + } +} diff --git a/docs/build-guide.md b/docs/build-guide.md deleted file mode 100644 index 55066b5d3..000000000 --- a/docs/build-guide.md +++ /dev/null @@ -1,413 +0,0 @@ -# Socket CLI Build Guide - -This document explains the Socket CLI build system and how to create various build artifacts. - -## Overview - -The Socket CLI has two main build outputs: - -| Build Type | Description | Output Location | -| ---------------- | -------------------------------------------- | ------------------------------------------------------------ | -| **CLI Bundle** | JavaScript bundle for npm distribution | `packages/cli/dist/` | -| **SEA Binaries** | Standalone executables (no Node.js required) | `packages/package-builder/build/{dev\|prod}/out/socketbin-*` | - -## Prerequisites - -| Requirement | Version | Notes | -| ----------- | ---------- | ---------------------------------------- | -| Node.js | >= 25.8.1 | Monorepo development (building, testing) | -| Node.js | >= 18.0.0 | Running published CLI package | -| pnpm | >= 10.22.0 | Package manager | - -## Quick Reference - -```bash -# Standard development build -pnpm build - -# Force full rebuild + SEA for current platform -pnpm build --force - -# Build SEA binaries for all platforms -pnpm build:sea - -# Build SEA for specific platform (two equivalent forms) -pnpm build --target darwin-arm64 -pnpm build --platform=darwin --arch=arm64 - -# Watch mode (auto-rebuild on changes) -pnpm dev -``` - ---- - -## Build Architecture - -### Directory Structure - -``` -socket-cli/ -├── packages/ -│ ├── cli/ # Main CLI package -│ │ ├── src/ # TypeScript source -│ │ ├── build/ # Intermediate build files -│ │ │ └── cli.js # Bundled CLI (esbuild output) -│ │ └── dist/ # Distribution files -│ │ ├── index.js # Entry point loader -│ │ ├── cli.js # CLI bundle (copied from build/) -│ ├── package-builder/ # Package generation and build outputs -│ │ ├── build/ -│ │ │ └── {dev|prod}/out/ # Build outputs by mode -│ │ │ ├── socketbin-cli-darwin-arm64/ -│ │ │ │ └── socket # SEA binary -│ │ │ ├── socketbin-cli-linux-x64/ -│ │ │ │ └── socket -│ │ │ └── ... # Other platform binaries -│ ├── build-infra/ # Build infrastructure -│ │ └── build/ -│ │ └── downloaded/ # Cached downloads -│ │ ├── node-smol/ # Node.js binaries -│ │ ├── binject/ # Binary injection tool -│ │ └── models/ # AI models -│ └── package-builder/ # Package generation templates -└── scripts/ # Monorepo build scripts -``` - -### Build Phases - -The CLI build executes in four phases: - -``` -Phase 1: Clean (optional, with --force) - └── Removes dist/ directory - -Phase 2: Prepare (parallel) - ├── Generate CLI packages from templates - └── Download assets from socket-btm releases - ├── node-smol (minimal Node.js binaries) - ├── binject (binary injection tool) - └── models (AI models for analysis) - -Phase 3: Build variants (parallel) - ├── CLI bundle (esbuild → build/cli.js) - └── Index loader (esbuild → dist/index.js) - -Phase 4: Post-processing (parallel) - ├── Copy cli.js to dist/ - ├── Fix node-gyp strings - └── Copy assets (logos, LICENSE, CHANGELOG) -``` - ---- - -## Build Types - -### 1. CLI Bundle (npm Distribution) - -The standard build creates a JavaScript bundle for npm distribution. - -```bash -# From monorepo root -pnpm build - -# Or target CLI specifically -pnpm build:cli - -# Force rebuild (ignores cache) -pnpm build --force -``` - -**Output**: `packages/cli/dist/index.js` (entry point) - -**What it includes**: - -- Bundled CLI code (all dependencies inlined) -- Shadow npm/npx wrappers -- Terminal rendering (Ink/Yoga) - -### 2. SEA Binaries (Standalone Executables) - -Single Executable Applications bundle Node.js + CLI into one binary. - -```bash -# Build for all platforms -pnpm build:sea - -# Build for current platform only -pnpm build --force # Includes SEA for current platform - -# Build specific platform -pnpm build --target darwin-arm64 -pnpm build --platform darwin --arch arm64 -``` - -**Output**: `packages/package-builder/build/{dev|prod}/out/socketbin-cli-<platform>-<arch>/socket` - -#### Supported Platforms - -| Target | Platform | Architecture | Notes | -| ------------------ | -------- | ------------- | ------------- | -| `darwin-arm64` | macOS | Apple Silicon | Native ARM64 | -| `darwin-x64` | macOS | Intel | Native x86_64 | -| `linux-arm64` | Linux | ARM64 | glibc | -| `linux-arm64-musl` | Linux | ARM64 | musl (Alpine) | -| `linux-x64` | Linux | x86_64 | glibc | -| `linux-x64-musl` | Linux | x86_64 | musl (Alpine) | -| `win32-arm64` | Windows | ARM64 | Native | -| `win32-x64` | Windows | x86_64 | Native | - -#### SEA Build Process - -``` -1. Download node-smol binary (minimal Node.js) - └── From socket-btm GitHub releases - -2. Download security tools (optional) - ├── Python runtime - ├── Trivy (vulnerability scanner) - ├── TruffleHog (secret detection) - └── OpenGrep (SAST engine) - -3. Generate SEA configuration - └── sea-config.json with blob settings - -4. Inject using binject - ├── CLI blob (JavaScript bundle) - └── VFS (Virtual File System with tools) -``` - -### 3. Watch Mode (Development) - -Automatically rebuilds on source changes. - -```bash -pnpm dev -# or -pnpm build:watch -``` - -**What it does**: - -1. Starts esbuild in watch mode -2. Rebuilds `build/cli.js` on changes - -**Note**: Watch mode only rebuilds the CLI bundle, not SEA binaries. - ---- - -## Build Commands Reference - -### Monorepo Root Commands - -| Command | Description | -| -------------------- | ---------------------------------------- | -| `pnpm build` | Smart build (skips unchanged) | -| `pnpm build --force` | Force rebuild + SEA for current platform | -| `pnpm build:cli` | Build CLI package only | -| `pnpm build:sea` | Build SEA for all platforms | -| `pnpm dev` | Watch mode | - -### Targeted SEA Builds - -```bash -# Build SEA for specific platform using --target -pnpm build --target darwin-arm64 -pnpm build --target linux-x64 -pnpm build --target linux-x64-musl # Linux with musl libc (Alpine) -pnpm build --target win32-x64 - -# Build SEA for specific platform using --platform and --arch -pnpm build --platform=darwin --arch=arm64 -pnpm build --platform=linux --arch=x64 --libc=musl - -# Build SEA for all platforms -pnpm build:sea -``` - -### CLI Package Commands - -Run from `packages/cli/`: - -| Command | Description | -| --------------------------------------------------- | ------------------ | -| `pnpm run build` | Build CLI | -| `pnpm run build:force` | Force rebuild | -| `pnpm run build:watch` | Watch mode | -| `pnpm run build:sea` | Build SEA binaries | -| `pnpm run build:sea --platform=darwin --arch=arm64` | Specific platform | - ---- - -## Downloaded Assets - -Assets are downloaded from [socket-btm](https://github.com/SocketDev/socket-btm) releases and cached in `packages/build-infra/build/downloaded/`. - -| Asset | Purpose | Cache Location | -| ----------- | ----------------------- | ----------------------------------- | -| `node-smol` | Minimal Node.js for SEA | `node-smol/<platform>-<arch>/node` | -| `binject` | Binary injection tool | `binject/<platform>-<arch>/binject` | -| `models` | AI models for analysis | `models/` | - -### Cache Management - -```bash -# Clear download cache -pnpm run clean:cache - -# Clear CLI build cache -pnpm --filter @socketsecurity/cli run clean - -# Clear all caches -pnpm run clean -``` - -### Environment Variables - -| Variable | Description | -| ---------------------------- | ------------------------------------------------------------ | -| `SOCKET_CLI_GITHUB_TOKEN` | GitHub token (preferred) | -| `GITHUB_TOKEN` | GitHub token (fallback if `SOCKET_CLI_GITHUB_TOKEN` not set) | -| `GH_TOKEN` | GitHub token (fallback if above not set) | -| `SOCKET_CLI_LOCAL_NODE_SMOL` | Use local node-smol binary | -| `SOCKET_CLI_FORCE_BUILD` | Force rebuild (set by --force) | - ---- - -## Build Configurations - -### esbuild Configurations - -Located in `packages/cli/.config/`: - -| Config | Output | Description | -| ------------------- | --------------- | ------------------------------------------------ | -| `esbuild.cli.mjs` | `build/cli.js` | Main CLI bundle — bundles all source into one JS | -| `esbuild.index.mjs` | `dist/index.js` | Entry point loader — thin shim that loads cli.js | -| `esbuild.build.mjs` | (orchestrator) | Runs both cli and index builds in parallel | - -### Build Variants - -The orchestrator (`esbuild.build.mjs`) accepts an optional variant argument: - -```bash -# Build all variants (default) -node .config/esbuild.build.mjs - -# Build only the CLI bundle -node .config/esbuild.build.mjs cli - -# Build only the entry point loader -node .config/esbuild.build.mjs index -``` - ---- - -## Troubleshooting - -### Build Fails: "CLI bundle not found" - -```bash -# Build CLI first -pnpm build:cli - -# Then build SEA -pnpm build:sea -``` - -### Download Fails: Rate Limited - -```bash -# Set GitHub token for higher rate limits -export GH_TOKEN=your_github_token -pnpm build -``` - -### SEA Binary Too Large - -SEA binaries include security tools (~140 MB compressed). For smaller binaries without tools: - -```bash -# Build without security tools (modify orchestration.mjs) -# Or use the npm-distributed version instead -``` - -### Stale Cache Issues - -```bash -# Clear all caches and rebuild -pnpm clean -pnpm build --force -``` - -### Platform-Specific Issues - -**macOS**: Binaries may need code signing for distribution. - -**Linux musl**: Use `--libc=musl` for Alpine/musl-based systems. - -**Windows**: Output has `.exe` extension automatically. - ---- - -## CI/CD Integration - -### GitHub Actions Example - -```yaml -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 - - uses: actions/setup-node@v4 - with: - node-version: '25' - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build - - run: pnpm test - - build-sea: - needs: build - strategy: - matrix: - target: - [ - darwin-arm64, - darwin-x64, - linux-arm64, - linux-arm64-musl, - linux-x64, - linux-x64-musl, - win32-arm64, - win32-x64, - ] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 - - uses: actions/setup-node@v4 - with: - node-version: '25' - cache: 'pnpm' - - - run: pnpm install - - run: pnpm build:cli - - run: pnpm build --target ${{ matrix.target }} -``` - ---- - -## Summary - -| Goal | Command | -| --------------------- | ---------------------------------- | -| Development build | `pnpm build` | -| Full rebuild | `pnpm build --force` | -| Watch mode | `pnpm dev` | -| All SEA binaries | `pnpm build:sea` | -| Specific platform SEA | `pnpm build --target darwin-arm64` | -| Run tests | `pnpm test` | -| Clean rebuild | `pnpm clean && pnpm build --force` | diff --git a/docs/bundle-tools.md b/docs/bundle-tools.md deleted file mode 100644 index 59eed6cdd..000000000 --- a/docs/bundle-tools.md +++ /dev/null @@ -1,192 +0,0 @@ -# External Tools - -Socket CLI integrates with external security tools for scanning, analysis, and vulnerability detection. This document explains how tools are bundled and executed in different deployment modes. - -## Deployment Modes - -| Mode | Description | Tool Source | -| ----------- | -------------------------------------- | ------------------------------- | -| **SEA** | Standalone executable with bundled VFS | Tools pre-bundled at build time | -| **npm CLI** | Installed via npm/pnpm/yarn | Tools downloaded at runtime | - -## Tool Matrix - -| Tool | Type | SEA Mode | npm CLI Mode | -| ----------------- | -------------- | --------------------------- | ----------------- | -| @coana-tech/cli | npm | VFS (node_modules) | dlx download | -| @cyclonedx/cdxgen | npm | VFS (node_modules) | dlx download | -| opengrep | github-release | VFS (/snapshot/) | GitHub download | -| python | github-release | VFS (/snapshot/) | GitHub download | -| socket-basics | github-source | VFS (pre-installed) | N/A (SEA only) | -| socket-patch | github-release | VFS (/snapshot/) | GitHub download | -| socketsecurity | pypi | VFS (pre-installed via pip) | pip install | -| sfw | hybrid | VFS (GitHub binary) | dlx (npm package) | -| synp | npm | VFS (node_modules) | dlx download | -| trivy | github-release | VFS (/snapshot/) | GitHub download | -| trufflehog | github-release | VFS (/snapshot/) | GitHub download | - -## Configuration - -All tools are defined in `packages/cli/bundle-tools.json`: - -```json -{ - "tool-name": { - "description": "Tool description", - "type": "npm | github-release | pypi | github-source", - "version": "1.0.0", - "checksums": { ... } - } -} -``` - ---- - -## SEA Mode (Standalone Executable) - -SEA binaries contain all tools pre-bundled in a Virtual File System (VFS). Tools are extracted to a temp directory on first use. - -### VFS Structure - -``` -/snapshot/ -├── node_modules/ # npm packages with full dependency trees -│ ├── @coana-tech/cli/ -│ ├── @cyclonedx/cdxgen/ -│ ├── @socketsecurity/sfw-bin/sfw -│ └── synp/ -├── opengrep/ # Standalone binaries -├── python/ # Python runtime + pre-installed packages -│ └── lib/python3.11/site-packages/ -│ ├── socketsecurity/ -│ └── socket_basics/ -├── socket-patch/ -├── trivy/ -└── trufflehog/ -``` - -### Python Package Pre-bundling - -Python packages (`socketsecurity`, `socket_basics`) are installed at **build time** into the bundled Python: - -1. Build downloads `python-build-standalone` runtime -2. Build runs `pip install socketsecurity==X.X.X` into bundled Python -3. Build copies `socket-basics` source into site-packages -4. VFS contains complete Python with packages pre-installed -5. Runtime skips pip install (checks `import socketsecurity` first) - -### VFS Extraction - -Tools are extracted on first use to `~/.socket/_vfs/`: - -```typescript -// Detection -if (isSeaBinary() && areExternalToolsAvailable()) { - // Use VFS-extracted tool - return spawnToolVfs(args, options) -} -``` - ---- - -## npm CLI Mode - -When installed via npm, tools are downloaded at runtime. - -### Download Locations - -| Source | Cache Location | -| --------------- | ---------------------------------------------------------- | -| npm dlx | `~/.socket/_dlx/{package}@{version}/` | -| GitHub releases | `~/.socket/_dlx/github/{owner}/{repo}/{version}/` | -| PyPI | `~/.socket/_dlx/pypi/{package}/{version}/` | -| Python runtime | `~/.socket/_dlx/python/{version}-{tag}-{platform}-{arch}/` | - -### Download Flow - -``` -1. Check local path override (SOCKET_CLI_*_LOCAL_PATH env var) - └── If set, use local binary directly - -2. Check cache - └── If cached and valid, use cached binary - -3. Download - ├── npm packages: dlxPackage() from npm registry - ├── GitHub releases: downloadGitHubReleaseBinary() - └── PyPI packages: downloadPyPIWheel() - -4. Verify integrity - └── SHA-256 checksum validation (required in production) - -5. Extract and cache - └── Save to ~/.socket/_dlx/ -``` - ---- - -## Security - -### Checksum Verification - -All downloads are verified with SHA-256 checksums defined in `bundle-tools.json`: - -```json -{ - "trivy": { - "checksums": { - "trivy_0.69.2_macOS-ARM64.tar.gz": "320c0e6af90b5733...", - "trivy_0.69.2_Linux-64bit.tar.gz": "affa59a1e37d86e4..." - } - } -} -``` - -Checksums are **required** in production builds. Dev mode allows downloads without checksums for testing. - -### Archive Extraction Safety - -- Path traversal validation (no `../` escapes) -- Symlink target validation (no escapes via symlinks) -- Lock file protection against concurrent downloads - -### Local Path Overrides - -Environment variables for development/testing: - -| Variable | Tool | -| ------------------------------------ | -------------- | -| `SOCKET_CLI_CDXGEN_LOCAL_PATH` | cdxgen | -| `SOCKET_CLI_COANA_LOCAL_PATH` | coana | -| `SOCKET_CLI_PYCLI_LOCAL_PATH` | socketsecurity | -| `SOCKET_CLI_SFW_LOCAL_PATH` | sfw | -| `SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH` | socket-patch | - ---- - -## Implementation Files - -| File | Purpose | -| --------------------------------- | ------------------------------------- | -| `bundle-tools.json` | Tool definitions, versions, checksums | -| `src/util/dlx/resolve-binary.mts` | Binary resolution logic | -| `src/util/dlx/spawn.mts` | Tool spawning (VFS + dlx) | -| `src/util/dlx/vfs-extract.mts` | VFS extraction utilities | -| `src/util/basics/spawn.mts` | Python-based tools (basics) | -| `src/util/basics/vfs-extract.mts` | Basics tools VFS extraction | -| `src/env/*-version.mts` | Version getters (esbuild inlined) | -| `src/env/*-checksums.mts` | Checksum getters (esbuild inlined) | - ---- - -## Adding a New Tool - -1. Add entry to `bundle-tools.json` with version and checksums -2. Create `src/env/{tool}-version.mts` version getter -3. Create `src/env/{tool}-checksums.mts` checksum getter (if applicable) -4. Add resolve function in `src/util/dlx/resolve-binary.mts` -5. Add spawn functions in `src/util/dlx/spawn.mts`: - - `spawn{Tool}Vfs()` - VFS extraction path - - `spawn{Tool}Dlx()` - Download path - - `spawn{Tool}()` - Auto-detect wrapper -6. Update build scripts to bundle tool in VFS (for SEA) diff --git a/docs/claude.md/fleet/agent-delegation.md b/docs/claude.md/fleet/agent-delegation.md deleted file mode 100644 index 991278b4a..000000000 --- a/docs/claude.md/fleet/agent-delegation.md +++ /dev/null @@ -1,75 +0,0 @@ -# Agent delegation - -When a task fits one of the patterns below, hand it off instead of doing it in the current session. The point is to get a _different model's_ take, or to keep heavy work out of the main context. Don't delegate trivial tasks. The round-trip overhead isn't worth it for things you can answer in one or two tool calls. - -## Delegated work should be small, focused, and quickly answerable - -Bias toward short, scoped questions when handing off. The agent on the other end has no conversation state, no tradeoff history, no mental model of what "the right answer" looks like. Every paragraph of context you write into the prompt is one the agent has to re-derive. The right shape is: - -- **One question, one ask.** "Is this rewrite safe?" beats "Review this whole branch and tell me what you think." Bundled asks return diluted answers. -- **Concrete scope.** Name the file, the function, the SHA, the diff. "Sanity-check this 40-line diff against the previously-broken pattern" is answerable in one pass; "Audit our cascade infrastructure" isn't. -- **Bounded expected response.** "Under 200 words", "one of: safe / unsafe / unsure with reason", "list the bugs you find". Open-ended prompts produce open-ended replies that take longer to read than the original analysis would have. -- **Fast-loop-friendly.** Prefer 2-minute round-trips over 20-minute ones. If the question needs a 20-minute investigation, that's a heavyweight `codex:codex-rescue` invocation, not a sanity check (different tool). - -A common anti-pattern: sending the entire commit body + every prior message in the thread so the agent "has context." That's not context, that's noise. Restate the question in 3–5 sentences with the specific artifact attached and ask for a verdict. - -## Always bound the delegation with a timeout - -Agent calls run on someone else's clock. A model that decides to "think harder" can park your conversation for ten minutes while you wait on a one-line verdict. Every delegation must carry an explicit time budget so a stuck or thinky agent doesn't drag the main session down. - -- **Subagents (`Agent(...)` calls):** state the expected response shape AND a wall-clock budget in the prompt itself. "Reply in under 200 words within ~2 minutes" gives the agent permission to short-circuit deep investigation. Use `Bash(timeout 120 ...)` when shelling out to `codex` / `claude` / `opencode` CLIs directly. The shell-level timeout is non-negotiable because the CLIs themselves don't always honor cancellation cleanly. -- **Skill-driven CLI subprocesses (Surface 1):** the orchestrator MUST pass `timeout: <ms>` to `spawn(...)` from `@socketsecurity/lib/spawn` so the child is killed when the budget expires. Canonical examples: `scripts/ai-lint-fix/cli.mts` ships a 5-minute per-spawn cap (per-file AI fix is a focused job); `reviewing-code/run.mts` caps heavyweight passes (discovery / discovery-secondary / remediation) at 15 minutes and the verify pass at 5 minutes. The verify pass is a sanity check on an already-written report, so the shorter budget matches the work. New skills pick from the same three tiers below. Anything that needs longer is a manual operation, not a sanity check. -- **Picking the budget:** sanity checks should answer in ~2 minutes; second-implementation passes in ~5; deep rescue work in ~15. Pick the smallest budget that's likely to succeed and let the orchestrator surface a "timed out" failure cleanly. A skipped verdict (with the agent's name and the timeout you used) is more useful than a 20-minute wait that ends in a long, unstructured answer. -- **Failure handling:** treat a timeout as a no-op signal, not an error. The main session continues with its own judgment and reports "asked Codex, no response within budget". The user can re-invoke with a longer budget if the question needs it. - -## Sanity checks (second-opinion verification) - -The highest-value use of mid-conversation delegation is a small, fast _sanity check_ on work the main session just did: a prompt rewrite, a refactor of a sensitive area, a CHANGELOG entry before release, a tricky regex. The companion agent's job is to spot the obvious thing the main session missed, not to redo the work. - -Good sanity-check prompts: - -- "Here are the original and revised prompt-engineering rule guidance for `prefer-async-spawn` ([before], [after]). Does the revision avoid the orphan-import failure mode? Under 150 words." -- "This diff swaps `spawnSync` for `spawn` in three call sites. Have I correctly updated the return-shape access (`.status` → `.code`)? Yes/no per call site." -- "Read this commit message body. Will downstream consumers understand the cascade direction from this alone? One sentence verdict." - -Bad sanity-check prompts: - -- "Look at our wheelhouse cascade tooling and tell me if it's good." (too broad) -- "Review the last 12 commits." (no anchor, no specific question) -- "Help me design the next refactor." (that's design work, not verification; use `Plan` or `codex:codex-rescue`) - -There are two delegation surfaces in this fleet. They look similar but are used differently. - -## Surface 1: CLI subprocess delegation (skills) - -Skills that need multi-model output spawn the agent CLIs (`codex`, `claude`, `kimi`, `opencode`) as subprocesses and fold the results into a report. The contract (backend registry, detection policy, fallback order, attribution) lives in [`_shared/multi-agent-backends.md`](../../.claude/skills/_shared/multi-agent-backends.md). The canonical implementation is [`reviewing-code/run.mts`](../../.claude/skills/reviewing-code/run.mts). - -Use this surface when _the skill itself_ is the orchestrator (multi-pass review, parallel scans, fleet-wide runs). - -## Surface 2: subagent delegation (mid-conversation) - -When the _current_ Claude session wants to hand off a single task to another model and consume its result inline, use `Agent(subagent_type=…)`. This is in-conversation delegation, not skill orchestration. - -| Subagent | When to use | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `codex:codex-rescue` | You want GPT-5.4's take or a heavyweight async investigation. Best for: hard debugging you're stuck on, second implementation pass on a tricky design, deep root-cause work. Persistent runtime; check progress with `/codex:status`, get output with `/codex:result`. Also exposed as `/codex:rescue` for user-driven invocation. | -| `delegate` | You want a Fireworks / Synthetic / Kimi open model via [OpenCode](https://opencode.ai). Best for: cheap bulk work (classification, summarization, drafting many things), specialist routing (e.g. Qwen-Coder for code-heavy tasks), second opinions from a non-GPT/non-Claude model. Caller specifies the model in the prompt (e.g. `fireworks/qwen3-coder-480b`). Fire-and-forget. **Optional**: only available if the dev has set up the `delegate` agent locally. Skill code must not depend on it. | -| `Explore` | Codebase search / "where is X defined" / cross-file lookups. Different model isn't the point; context isolation is. | -| `Plan` | Implementation strategy for a non-trivial task before writing code. | -| `general-purpose` | Open-ended research that doesn't fit the above. | - -## Routing heuristics - -- **Stuck after one or two failed attempts** → `codex:codex-rescue`. A different family often breaks the deadlock. -- **About to do 20+ similar small operations** → `delegate` with a cheap model. Keep the main context clean. -- **Want a sanity check on a non-trivial design or diff** → `/codex:adversarial-review` (slash command) _or_ `delegate` to a different family, depending on which perspective is more useful. -- **Big codebase question that'll burn context** → `Explore`. -- **Building a multi-pass workflow** → don't use `Agent(...)` ad hoc; write a skill that uses Surface 1. - -## When the surfaces overlap - -A skill that wants `codex` output should call the CLI (Surface 1) so the result lands in a structured report. A live conversation that wants Codex's opinion on the _current_ problem should use the subagent (Surface 2) so the result flows back into the conversation. Same model, different orchestration. - -## Compatibility note - -Codex is fleet-wide (the `codex` CLI is a fleet plugin). OpenCode and the `delegate` subagent are **per-developer**: they require local setup outside the repo. Skills that automate work across the fleet must not assume `delegate` exists; humans driving Claude in their own checkout can use it freely. diff --git a/docs/claude.md/fleet/agents-and-skills.md b/docs/claude.md/fleet/agents-and-skills.md deleted file mode 100644 index 57f657b83..000000000 --- a/docs/claude.md/fleet/agents-and-skills.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agents & skills - -The CLAUDE.md `### Agents & skills` section names the entry-point skills. This file is the full taxonomy and the cross-fleet runner. - -## Entry-point skills - -- `/scanning-security`: AgentShield + zizmor audit -- `/scanning-quality`: quality analysis -- Shared subskills in `.claude/skills/_shared/` -- **Handing off to another agent**: see [`agent-delegation.md`](agent-delegation.md) for when to reach for `codex:codex-rescue`, the `delegate` subagent (OpenCode → Fireworks/Synthetic/Kimi), `Explore`, `Plan`, vs. driving the skill CLIs directly. The CLI-subprocess contract used by skills lives in [`_shared/multi-agent-backends.md`](../../.claude/skills/_shared/multi-agent-backends.md). - -## Skill scope: fleet vs partial vs unique - -Every skill under `.claude/skills/` falls into one of three tiers. Surface this distinction when adding a new skill so it lands in the right place: - -- **Fleet skill**: present in every fleet repo, identical contract everywhere. Examples: `guarding-paths`, `scanning-quality`, `scanning-security`, `updating`, `locking-down-programmatic-claude`, `plug-leaking-promise-race`. New fleet skills land in `socket-wheelhouse/template/.claude/skills/<name>/` and cascade via `node socket-wheelhouse/scripts/sync-scaffolding.mts --all --fix`. Track them in `SHARED_SKILL_FILES` in the sync manifest. -- **Partial skill**: present in the subset of repos that need it, identical contract within that subset. Examples: `driving-cursor-bugbot` (every repo with PR review), `updating-lockstep` (every repo with `lockstep.json`), `squashing-history` (repos with the squash workflow). Live in each adopting repo's `.claude/skills/<name>/`. When you change one, propagate to the others. -- **Unique skill**: one repo only, bespoke to that repo's domain. Examples: `updating-cdxgen` (sdxgen), `updating-yoga` (socket-btm), `release` (socket-registry). Never canonical-tracked; the host repo owns it end-to-end. - -Audit the current classification with `node socket-wheelhouse/scripts/run-skill-fleet.mts --list-skills`. - -## `updating` umbrella + `updating-*` siblings - -`updating` is the canonical fleet umbrella that runs `pnpm run update` then discovers and runs every `updating-*` sibling skill the host repo registers. The umbrella is fleet-shared; the siblings are per-repo (or partial: `updating-lockstep` lives in every repo with `lockstep.json`). To add a new repo-specific update step, drop a new `.claude/skills/updating-<domain>/SKILL.md` and the umbrella picks it up automatically. No edits to `updating` itself. - -## Running skills across the fleet - -`scripts/run-skill-fleet.mts` (in `socket-wheelhouse`) spawns one headless `claude --print` agent per fleet repo, in parallel (concurrency 4 by default), with the four lockdown flags set per the _Programmatic Claude calls_ rule above. Per-skill profile table maps known skills to sensible tool/allow/disallow lists; override with `--tools` / `--allow` / `--disallow`. Per-repo logs land in `.cache/fleet-skill/<timestamp>-<skill>/<repo>.log`. Uses `Promise.allSettled` semantics; one repo's failure doesn't abort the rest. - -```bash -# Run from inside socket-wheelhouse: -pnpm --filter socket-wheelhouse run fleet-skill updating # update every fleet repo -pnpm --filter socket-wheelhouse run fleet-skill scanning-quality --concurrency 2 # slower, more conservative -pnpm --filter socket-wheelhouse run fleet-skill --list-skills # classify skills fleet/partial/unique -``` diff --git a/docs/claude.md/fleet/bypass-phrases.md b/docs/claude.md/fleet/bypass-phrases.md deleted file mode 100644 index 4347cb3a6..000000000 --- a/docs/claude.md/fleet/bypass-phrases.md +++ /dev/null @@ -1,56 +0,0 @@ -# Hook bypass phrases - -Reverting tracked changes or bypassing the fleet's hook chain requires the user to type the canonical phrase verbatim in a recent user turn. Inferring intent from "go ahead", "skip the hook", "fix it", etc. does NOT count. - -The phrase format is `Allow <X> bypass`. Case-sensitive, exact match. - -| Operation | Phrase | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| Revert (any of: `git checkout -- <files>`, `git checkout <ref> -- <files>`, `git restore <files>` without `--staged`, `git reset --hard`, `git stash drop` / `pop` / `clear`, `git clean -f`, `git rm -rf`) | `Allow revert bypass` | -| `git --no-verify` (skips the `.git-hooks/` chain) | `Allow no-verify bypass` | -| `git --no-gpg-sign` / `-c commit.gpgsign=false` | `Allow gpg bypass` | -| `DISABLE_PRECOMMIT_LINT=1` (skips lint step) | `Allow lint bypass` | -| `DISABLE_PRECOMMIT_TEST=1` (skips test step) | `Allow test bypass` | -| `SKIP_ASSET_DOWNLOAD=1` (skips release-asset fetch in build — degraded-mode flag; becomes a bypass when used to push past rate-limited pre-commit) | `Allow asset-download bypass` | -| `git stash` (any form: bare, `push`, `save`, `--keep-index`) in primary checkout — shared stash store, another Claude session can pop yours. Use a worktree instead. | `Allow stash bypass` | -| Bash file-write (`python -c '...write...'`, `sed -i`, heredoc `cat << EOF > file`, `tee <source-file>`, `dd of=…`) — typically used to dodge an Edit/Write hook block. Move file / refactor / get original-hook bypass instead. | `Allow bash-write bypass` | -| `git push --force` / `-f` | `Allow force-push bypass` | -| External GitHub issue/PR reference in a commit message or PR/issue body (`<owner>/<repo>#<num>` or full URL to a non-SocketDev repo) — would auto-link a backref into the upstream maintainer's issue | `Allow external-issue-ref bypass` | -| Sub-package `scripts/paths.mts` that doesn't `export *` from the nearest ancestor paths.mts (re-derives REPO_ROOT etc. — drift risk) | `Allow paths-mts-inherit bypass` | -| `gh workflow run` / `gh workflow dispatch` / `gh api …/dispatches` for a workflow without a `dry-run:` input — one-off recovery dispatches or workflows that can't dry-run by design (e.g. node-smol build) | `Allow workflow-dispatch bypass` | -| 7-day `minimumReleaseAge` soak for an `external-tools.json` asset bump (pnpm, zizmor, sfw, …) — wires the `--bypass-minimum-release-age` flag in `socket-registry/scripts/update-external-tools.mts`. Justified when the integrity model is binary-download + sha256 recompute done by the script itself (not npm-registry trust). Soak-exclude annotation `# published: YYYY-MM-DD \| removable: YYYY-MM-DD` is still required for any related `pnpm-workspace.yaml` `minimumReleaseAgeExclude` entry. | `Allow minimumReleaseAge bypass` | - -## Scope - -A phrase from a previous session does not carry over. Only the current conversation's user turns count. The hook reads the active session's transcript (passed by Claude Code as `transcript_path` in the PreToolUse payload) and searches the concatenated user-turn text for the exact phrase. - -The match is **case-sensitive** and **substring-based**: - -- ✓ `Allow revert bypass; please drop my last edit` -- ✓ a multi-line user message with `Allow revert bypass` on its own line -- ✗ `allow revert bypass` (lowercase) -- ✗ `please revert that file` (paraphrase) -- ✗ `--no-verify is fine` (no `Allow ... bypass` shape) - -## Why a phrase - -Without the gate, the assistant has historically reverted whole batches of autofix changes mid-cleanup or used `--no-verify` to push past a failing hook, both of which destroy work and erode trust. The phrase is short enough to type when truly intended and specific enough that no other utterance accidentally triggers it. - -## Defense in depth - -The bypass policy is enforced at three layers: - -- **CLAUDE.md** documents the rule (`### Hook bypasses require the canonical phrase`). -- **Memory** keeps the assistant honest across sessions even before the hook fires. -- **`.claude/hooks/no-revert-guard/`** is the enforcement: a `PreToolUse(Bash)` hook that scans the proposed command, parses the transcript, and exits 2 with a stderr message naming the phrase the user must type. - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. Trade-off: a buggy hook silently allows the destructive command. Acceptable because the alternative (hook crash wedges the session) is worse for development velocity. - -## How to add a new bypass - -When introducing a new destructive flag or hook bypass: - -1. Add a new entry to the `CHECKS` array in `.claude/hooks/no-revert-guard/index.mts`. Each check is `{ pattern: RegExp, bypassPhrase: string, label: string }`. -2. Add a row to this reference's table. -3. Add a test case to `.claude/hooks/no-revert-guard/test/index.test.mts` covering both the blocked-without-phrase and allowed-with-phrase paths. -4. Cascade via `node socket-wheelhouse/scripts/sync-scaffolding.mts --all --fix` so every fleet repo picks up the change. diff --git a/docs/claude.md/fleet/code-style.md b/docs/claude.md/fleet/code-style.md deleted file mode 100644 index 37c854e65..000000000 --- a/docs/claude.md/fleet/code-style.md +++ /dev/null @@ -1,111 +0,0 @@ -# Code style - -The CLAUDE.md `### Code style` section is the short list of heaviest invariants. This file is the full set of subrules and their rationale. When a rule has a sister skill or hook, the SKILL.md / hook README is canonical for the enforcement details. This file is the reading-order overview. - -## Comments - -Default to none. Write one only when the WHY is non-obvious to a senior engineer. **When you do write a comment, the audience is a junior dev**: explain the constraint, the hidden invariant, the "why this and not the obvious thing." Don't label it ("for junior devs:", "intuition:", etc.). Write in that voice. No teacher-tone, no condescension, no flattering the reader. - -## Completion - -Never leave `TODO` / `FIXME` / `XXX` / shims / stubs / placeholders. Finish 100%. If too large for one pass, ask before cutting scope. - -## `null` vs `undefined` - -Use `undefined`. `null` is allowed only for `__proto__: null` or external API requirements. - -## Object literals - -`{ __proto__: null, ... }` for config / return / internal-state. - -## Imports - -No dynamic `await import()`. `node:fs` is the canonical fs source. One import per file: `import { existsSync, promises as fs } from 'node:fs'`. Sync APIs may be cherry-picked (`existsSync`, `copyFileSync`, `readFileSync`, etc.). Async APIs MUST go through the `promises as fs` namespace. Never cherry-pick from `node:fs/promises` (`import { rename } from 'node:fs/promises'` is forbidden; use `fs.rename(...)` instead). Rationale: a single canonical handle for async fs keeps the call sites uniform across the fleet and avoids two imports for what's logically one module. `path` / `os` / `url` / `crypto` use default imports. Exception: `fileURLToPath` from `node:url`. - -## HTTP - -Never `fetch()`. Use `httpJson` / `httpText` / `httpRequest` from `@socketsecurity/lib/http-request`. - -## Subprocesses - -Prefer async `spawn` from `@socketsecurity/lib/spawn` over `spawnSync` from `node:child_process`. Async unblocks parallel tests / event-loop work; the sync version freezes the runner for the duration of the child. Use `spawnSync` only when you need synchronous semantics (script bootstrapping, a hot loop where awaiting would invert control flow). When you do need stdin input: `const child = spawn(cmd, args, opts); child.stdin?.end(payload); const r = await child;`. The lib's `spawn` returns a thenable child handle, not a `{ input }` option. Throws `SpawnError` on non-zero exit; catch with `isSpawnError(e)` to read `e.code` / `e.stderr`. - -## File existence - -`existsSync` from `node:fs`. Never `fs.access` / `fs.stat`-for-existence / async `fileExists` wrapper. - -## File deletion - -Route every delete through `safeDelete()` / `safeDeleteSync()` from `@socketsecurity/lib/fs`. Never `fs.rm` / `fs.unlink` / `fs.rmdir` / `rm -rf` directly, even for one known file. Prefer the async `safeDelete()` over `safeDeleteSync()` when the surrounding code is already async (test bodies, request handlers, build scripts that await elsewhere). Sync I/O blocks the event loop and there's no benefit when the caller is awaiting anyway. Reserve `safeDeleteSync()` for top-level scripts whose entire flow is sync. - -## Edits - -Edit tool, never `sed` / `awk`. - -## Generated reports - -Quality scans, security audits, perf snapshots, anything an automated tool emits: write to `.claude/reports/` (naturally gitignored as part of `.claude/*`, no separate rule needed). Never commit reports to a tracked `reports/`, `docs/reports/`, or similarly-named tracked directory. Dated reports rot the moment they land and the directory becomes a graveyard. The current state of the repo is the report; tools regenerate findings on demand. If a finding is worth keeping past one run, fix it or open an issue. Don't pickle it as a markdown file. - -## Inclusive language - -See [`inclusive-language.md`](inclusive-language.md) for the substitution table. - -## Sorting - -Sort alphanumerically (literal byte order, ASCII before letters). Applies to: object property keys (config + return shapes + internal state, `__proto__: null` first); named imports inside a single statement (`import { a, b, c }`); `Set` / `SafeSet` constructor arguments; allowlists / denylists / config arrays / interface members; **string-equality disjunctions** (`x === 'a' || x === 'b'` and the De Morgan dual `x !== 'a' && x !== 'b'`). Position-bearing arrays (where index matters) keep their meaningful order. Full details in [`sorting.md`](sorting.md). When in doubt, sort. - -## Env-var checks - -`'CI' in process.env` presence check over truthy. Whether `CI` is set is what matters; the value is irrelevant. - -## `node:os` import - -`import os from 'node:os'` (default import). Not `import { tmpdir, homedir } from 'node:os'`. Default-import shape lets call sites read `os.tmpdir()` etc. Clearer at the call site that this is an OS-level lookup. - -## Logger - -`getDefaultLogger()` from `@socketsecurity/lib-stable/logger` over `console.*` / `process.stderr.write` / `process.stdout.write` (enforced by `.claude/hooks/logger-guard/`). The logger wraps level routing, transcript-safe rendering, and the token-minifier proxy. - -## Doc filenames - -`lowercase-with-hyphens.md` under `docs/` or `.claude/` (enforced by `.claude/hooks/markdown-filename-guard/`). One canonical form; no spaces, no PascalCase, no underscores. - -## Inline `<script>` defer/async - -`<script defer>` and `<script async>` without a `src=` attribute are a spec no-op. The HTML parser ignores the deferral on inline scripts. Wrap the body in a `DOMContentLoaded` listener instead. Enforced by `.claude/hooks/inline-script-defer-guard/` + the `socket/no-inline-defer-async` oxlint rule. Bypass: `Allow inline-defer bypass`. - -## ESLint / Biome config refs - -Stale. The fleet runs oxlint / oxfmt. Don't reference `.eslintrc` / `eslint-config-*` / `biome.json` / `@biomejs/*` in any new code (enforced by the `socket/no-eslint-biome-config-ref` oxlint rule). - -## `structuredClone` vs JSON round-trip - -`structuredClone(x)` is banned for JSON-shaped data. `JSON.parse(JSON.stringify(x))` (or `JSONParse(JSONStringify(x))` from `@socketsecurity/lib/primordials/json`) is 3-5× faster because it skips the full HTML structured-clone algorithm (type tagging, transferable handling, prototype preservation, cycle detection; none of which the JSON subset needs). The common case is "defensive-copy a `JSON.parse`d value to defend against caller mutation". That's purely JSON-shaped by construction. Opt back in per-line with `// oxlint-disable-next-line socket/no-structured-clone-prefer-json -- <reason>` when the value contains `Date` / `Map` / `Set` / `RegExp` / `ArrayBuffer` / typed-array shapes. Enforced edit-time by `.claude/hooks/no-structured-clone-prefer-json-guard/` + the `socket/no-structured-clone-prefer-json` oxlint rule. Bypass: `Allow no-structured-clone-prefer-json bypass`. - -## Ellipsis character, not three dots - -In user-facing text (string / template / comment), a trailing ellipsis is the single character `…` (U+2026), not three literal dots `...`. It reads as one glyph and matches fleet typography. Only WORD-FINAL ellipses are flagged (`Loading...` → `Loading…`); the spread/rest operator (`...args`), path globs (`/Users/<user>/...`), and CLI placeholder notation (`[path...]`, `args...`) are left untouched. Enforced + auto-fixed by the `socket/prefer-ellipsis-char` oxlint rule. Bypass for an intentional three-dot form: `// socket-hook: allow literal-ellipsis`. - -## Binary resolution: `node_modules/.bin`, not global `which` - -Don't shell out to `which` / `command -v` / `where` to locate a project binary — those search the GLOBAL PATH. Fleet binaries are linked into `node_modules/.bin` by `pnpm install`; a global lookup returns nothing on a normal checkout (so the caller silently degrades) or, worse, finds a different-version binary and runs against the wrong engine. Resolve the installed package instead: `require.resolve('<pkg>/package.json')` → read its `bin` field → `resolveBinaryPath()` from `@socketsecurity/lib-stable/dlx/binary-resolution` for the platform `.cmd`/`.ps1` wrapper. (`@socketsecurity/lib-stable/bin/which`'s `whichSync` is the right tool when you genuinely need a PATH search, e.g. the user's system `git`.) Enforced by the `socket/no-which-for-local-bin` oxlint rule. Bypass for a genuine global lookup: `// socket-hook: allow which-lookup`. - -## Comments: cross-port Lock-step - -See [`parser-comments.md`](parser-comments.md) §5–7 for the full Lock-step comment spec (port provenance, byte-identical header block, deviation paragraphs). Enforced edit-time by `.claude/hooks/lock-step-ref-guard/` and CI-gate-time by `scripts/check-lock-step-refs.mts` + `scripts/check-lock-step-header.mts`. Bypass: `Allow lock-step bypass`. - -## Pointer comments - -`// see X` comments need both a destination and an inline one-line claim of what's at the destination (enforced by `.claude/hooks/pointer-comment-guard/`). "see X" alone forces the reader to chase the link to learn anything; "see X: it does Y" gives the reader Y up front and X for verification. - -## `Promise.race` / `Promise.any` in loops - -Never re-race a pool that survives across iterations (the handlers stack). See `.claude/skills/plug-leaking-promise-race/SKILL.md`. - -## `Safe` suffix - -Non-throwing wrappers end in `Safe` (`safeDelete`, `safeDeleteSync`, `applySafe`, `weakRefSafe`). Read it as "X, but safe from throwing." The wrapper traps the thrown value internally and returns `undefined` (or the documented fallback). Don't invent alternative suffixes (`Try`, `OrUndefined`, `Maybe`). Pick `Safe`. - -## `node:smol-*` modules - -Feature-detect, then require. From outside socket-btm (socket-lib, socket-cli, anywhere else): `import { isBuiltin } from 'node:module'; if (isBuiltin('node:smol-X')) { const mod = require('node:smol-X') }`. The `node:smol-*` namespace is provided by socket-btm's smol Node binary; on stock Node `isBuiltin` returns false and the require would throw. Wrap the loader in a `/*@__NO_SIDE_EFFECTS__*/` lazy-load that caches the result. See `socket-lib/src/smol/util.ts` and `socket-lib/src/smol/primordial.ts` for canonical shape. **Inside** socket-btm's `additions/source-patched/` JS (the smol binary's own bootstrap code), use `internalBinding('smol_X')` directly. That's the C++-binding access path and it's guaranteed available there. diff --git a/docs/claude.md/fleet/commit-signing.md b/docs/claude.md/fleet/commit-signing.md deleted file mode 100644 index d560568e0..000000000 --- a/docs/claude.md/fleet/commit-signing.md +++ /dev/null @@ -1,88 +0,0 @@ -# Commit signing - -Every commit landing on a default branch (`main` / `master`) in the fleet must carry a verified signature. Three independent layers enforce this; bypassing any one of them is treated as exceptional and one-shot. - -## Layer 1: local config gate (pre-commit) - -Before git records a commit, the pre-commit hook reads: - -``` -git config --get commit.gpgsign # expect: true -git config --get user.signingkey # expect: a key ID or .pub path -``` - -If `commit.gpgsign` is not `true`, OR `user.signingkey` is unset, the hook fails with the fix command and a pointer to the setup helper. The check reads the union of local + global config, so a globally-configured signing key satisfies it for every repo. - -Bypass (exceptional only; hotfix scenarios, in-flight signing-tool outage): - -```sh -SOCKET_PRE_COMMIT_ALLOW_UNSIGNED=1 git commit ... -``` - -One-shot; never persist in shell rc. The env var is read on every invocation, so dropping it returns to the gated state. - -## Layer 2: push-time signature check (pre-push) - -The pre-push hook fires after commits exist. It reads `git log --format='%H %G?' <range>` across the push range and inspects the verification marker per commit: - -- `G`: good GPG signature (block: no) -- `U`: good GPG, unknown trust (block: no) -- `E`: missing-key but otherwise valid (block: no) -- `X`: good signature on expired key (block: no) -- `Y`, `R`: revoked/expired key, good signature (block: no) -- `N`: no signature (BLOCK) -- `B`: bad / unverifiable signature (BLOCK) - -Scope: only fires when pushing to `refs/heads/main` or `refs/heads/master`. Topic branches push unsigned freely; signing matters at the point of landing on the protected ref. - -Bypass: - -```sh -SOCKET_PRE_PUSH_ALLOW_UNSIGNED=1 git push origin main -``` - -One-shot. Stderr warns even when the bypass is honored, so the operator sees the exception in their own scrollback. - -## Layer 3: server-side (GitHub branch protection) - -`lint-github-settings.mts` audits the default branch's protection on GitHub for `required_signatures: { enabled: true }`. If the audit reports drift, the operator fixes it via the GitHub branch-protection UI (this script's `--fix` does not auto-apply branch-protection patches because that endpoint can clobber custom status-check requirements). - -GitHub-side enforcement is the failsafe: it catches pushes that somehow bypassed both local layers (an attacker who manipulated `core.hooksPath`, a CI pipeline that pushed without running hooks, a freshly-created fleet repo whose hooks aren't yet installed). - -## Setup helper - -The setup helper detects available signing methods and configures git in one shot: - -```sh -node .claude/hooks/setup-signing/install.mts # detect + configure -node .claude/hooks/setup-signing/install.mts --check # report status (exit 0 if configured, 1 if not) -node .claude/hooks/setup-signing/install.mts --force # overwrite existing config -``` - -Detection order (first hit wins): - -1. **1Password SSH agent**: agent socket at platform-specific path, queried via `ssh-add -L`. Recommended: keys never touch disk, biometric unlock on use, signing happens inside 1Password. -2. **SSH key on disk**: `~/.ssh/id_ed25519.pub` (preferred), `id_ecdsa.pub`, then `id_rsa.pub`. `user.signingkey` points at the `.pub` path. -3. **GPG secret key**: `gpg --list-secret-keys --with-colons`, first `sec:` entry. `user.signingkey` set to the long key ID. - -The helper never generates keys (user's call) and never uploads keys to GitHub. After running, upload the public key as a Signing Key at https://github.com/settings/keys to get the "Verified" badge on web-rendered commits. - -## Why three layers - -Each layer catches a different failure mode: - -- Pre-commit catches **misconfiguration** at the earliest possible moment (no signing tool set up). -- Pre-push catches **bypass attempts** at the commit level (`--no-gpg-sign`, cherry-picks from unsigned sources, rebases without re-signing). -- GitHub branch protection catches **process bypass** at the network level (push from a host with no fleet hooks installed, CI pipeline that pushes without verification). - -A single layer can be defeated with one operator mistake or one compromised host. Three independent layers require simultaneous compromise of all three to land an unsigned commit on a protected branch. - -## When to use the bypass envs - -Only when: - -1. A signing-tool outage (1Password down, GPG agent crashed) blocks an urgent push that genuinely cannot wait -2. A history-rewriting operation imports unsigned commits from external sources (rare; usually those should be re-signed during the rewrite) -3. A maintenance script needs to commit/push automation artifacts and the operator has explicitly chosen to skip signing for that automation - -Never set either env var in `.zshrc` / `.bashrc` / `direnv` files. The whole point of the one-shot semantics is that the operator notices each bypass; a persistent env defeats that. diff --git a/docs/claude.md/fleet/conformance-runners.md b/docs/claude.md/fleet/conformance-runners.md deleted file mode 100644 index 8d47f9ac9..000000000 --- a/docs/claude.md/fleet/conformance-runners.md +++ /dev/null @@ -1,198 +0,0 @@ -# Conformance runners - -How to author, organize, and maintain runners that exercise a built -artifact against an external spec corpus (tc39/test262, WPT, future -spec suites). This is the fleet canonical layout; references the -[`running-test262`](../../../.claude/skills/running-test262/SKILL.md) -skill for invocation specifics. - -## The 4-tier layout - -``` -packages/<pkg>/ - test/ - fixtures/<corpus>/ # 1. Sparse-checkout submodule - scripts/<corpus>-<scope>-runner.mts # 2. Thin CLI entry - scripts/<corpus>/ # Modular guts: - types.mts # Result / Test / Summary - parser.mts # Frontmatter parser - classifier.mts # Pure: result + allowlist → bucket - harness.mts # Compose harness + walk corpus - executor.mts # Spawn + collect + retry - report.mts # Format summary - integration/<corpus>-<scope>.test.mts # 3. Vitest wrapper (gate) - unit/<corpus>-<scope>.test.mts # 4. Vitest tests of pure modules - <corpus>-config/<corpus>.allowlist # 5. Out-of-band allowlist file - package.json scripts: - "<corpus>:<scope>": "node test/scripts/<corpus>-<scope>-runner.mts" -``` - -### 1. Sparse submodule - -External corpora live at `test/fixtures/<corpus>/`, NOT `upstream/`. -Build-time submodules use `upstream/`; test-time corpora use -`test/fixtures/`. The distinction signals whether bumping the -submodule affects shipped artifacts. See related -[`../fleet/untracked-by-default.md`](untracked-by-default.md) for -adjacent rules on vendored trees. - -Conformance corpora are large but our runners exercise narrow -subtrees. Add a `sparse-checkout = <patterns>` field to `.gitmodules` -and use `scripts/git-partial-submodule.mts clone <path>` for fresh -checkouts. Vanilla `git submodule update` ignores the field; the -fleet utility reads it. - -Examples: - -```ini -# .gitmodules -[submodule "packages/node-smol-builder/test/fixtures/wpt/streams"] - path = packages/node-smol-builder/test/fixtures/wpt/streams - url = https://github.com/web-platform-tests/wpt.git - sparse-checkout = streams/ - -[submodule "packages/temporal-infra/test/fixtures/test262"] - path = packages/temporal-infra/test/fixtures/test262 - url = https://github.com/tc39/test262.git - sparse-checkout = test/built-ins/Temporal/ test/intl402/Temporal/ harness/ -``` - -Requires git ≥ 2.27 (for `--filter` + `--sparse` on `git clone`). - -### 2. Runner: thin entry + modular guts - -The CLI entry (`<corpus>-<scope>-runner.mts`) stays under ~60 lines. It parses argv, resolves the binary, calls the harness/executor modules. Everything else lives in the sibling `<corpus>/` directory broken into ~6 modules. The split lets each piece have a single reason to change AND lets the pure modules be unit-tested in isolation. - -Canonical module set: - -| Module | Responsibility | -| ---------------- | ----------------------------------------------------------------------- | -| `types.mts` | `Result`, `Test`, `Summary`, `TestCase` types | -| `parser.mts` | Frontmatter / metadata parsing | -| `classifier.mts` | Pure: `(result, allowlist) → "expected" / "unexpected" / "now-passing"` | -| `harness.mts` | Compose harness JS, walk corpus, filter | -| `executor.mts` | Spawn subprocesses, collect output, retry | -| `report.mts` | Format human-readable summary, exit-code policy | - -**The classifier is the highest-value module to extract.** Get the result-bucketing logic wrong and the runner silently masks regressions. Keep it pure (no I/O, no globals). - -### 3. Integration vitest wrapper (auto-gate) - -A ~20-line `.test.mts` under `test/integration/` that: - -1. Resolves the built binary (returns `undefined` if no build exists). -2. Computes `skipIf` from that. -3. Inside `describe.skipIf(...)`, has one `it()` that spawns the - runner subprocess and asserts exit code 0. - -```ts -// test/integration/<corpus>-<scope>.test.mts -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { resolveFinalBinary } from '../helpers/binary.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const RUNNER = path.resolve( - __dirname, - '..', - 'scripts', - '<corpus>-<scope>-runner.mts', -) -const skipTests = !resolveFinalBinary() -const TIMEOUT_MS = 45 * 60 * 1000 - -describe.skipIf(skipTests)('<corpus> <scope> conformance', () => { - it( - 'no unexpected failures vs allowlist', - async () => { - const result = await spawn('node', [RUNNER], { stdio: 'inherit' }) - expect(result.code).toBe(0) - }, - TIMEOUT_MS, - ) -}) -``` - -This is what brings the gate into `pnpm test`. Without it, the runner -is a manual ritual the dev has to remember. - -### 4. Unit tests for the pure modules - -A `.test.mts` under `test/unit/` covering the classifier exhaustively. -At minimum: every transition (success/failure × allowed/disallowed), -stale-allowlist (test passes that's in the allowlist), and -prefix-match edge cases. - -These tests do NOT spawn subprocesses, do NOT walk the corpus, and do -NOT need the built binary. Pure logic only. They catch the highest- -severity bug class (silent regression masking) without needing the -expensive infrastructure. - -### 5. Allowlist file - -Either path-keyed or feature-keyed depending on what the runner -exercises: - -- **Path-keyed**: `<file> (<scenario>)` one per line, with comment - rationale. Suitable for narrow subset runs (temporal-infra Temporal - subset, WPT streams). Allow only failures that can be justified. -- **Feature-keyed**: TC39 feature name (`decorators`, - `import-source`). Suitable for broad parser conformance where the - set of unimplemented features is well-defined (ultrathink/acorn - parsers). Makes it hard to sneak a parser bug past the allowlist. - -**Never inline a Map literal** in the runner source. The diff becomes -unreviewable, the allowlist mixes with logic, and PRs that touch the -runner accidentally pull in allowlist changes. - -## Authoring a new conformance runner - -Use this checklist: - -1. Submodule at `test/fixtures/<corpus>/` with `sparse-checkout` - declared in `.gitmodules`. -2. Runner skeleton at `test/scripts/<corpus>-<scope>-runner.mts` - that imports from `test/scripts/<corpus>/{parser,classifier, -harness,executor,report}.mts`. -3. Allowlist file at `<corpus>-config/<corpus>.allowlist` (path- or - feature-keyed). -4. Vitest integration wrapper at - `test/integration/<corpus>-<scope>.test.mts`. -5. Vitest unit tests at `test/unit/<corpus>-<scope>.test.mts` - covering at minimum the classifier. -6. `package.json` script: `"<corpus>:<scope>": "node test/scripts/<corpus>-<scope>-runner.mts"`. - -The runner should always exit non-zero on (a) unexpected failure (test not in allowlist that failed), or (b) stale allowlist (test in allowlist that now passes; a drift signal that needs cleanup, not silent acceptance). - -## Reference implementations - -As of 2026-05, the closest-to-canonical implementations in the fleet: - -- `socket-btm/packages/temporal-infra/test/scripts/test262-temporal-runner.mts`: best module split + unit-tested classifier. -- `socket-btm/packages/node-smol-builder/test/scripts/wpt-streams-runner.mts`: best integration wrapper shape. - -When in doubt, mirror temporal-infra's `test262/` subdirectory split. - -## Anti-patterns - -- **Inline `EXPECTED_FAILURES` Map** in the runner source. Move it to - an external allowlist file. -- **Single 500+ line monolith**. Split into the canonical 6 modules - the first time you touch it. -- **Vitest wrapper that runs the corpus inline as `test.each(files)`**. - Each file is too granular for vitest's reporter and breaks - allowlist classification semantics. Spawn the runner as a subprocess - and check exit code; the runner's own report is the human-readable - output. -- **Test-time submodule under `upstream/`**. That path is reserved for - build-time submodules. Move conformance corpora to - `test/fixtures/<corpus>/`. -- **Full-tree submodule when only a subset is exercised**. Use - sparse-checkout. - -## Related skills + docs - -- `.claude/skills/running-test262/SKILL.md`: how to invoke runners per repo. -- [`untracked-by-default.md`](untracked-by-default.md): adjacent rules for vendored / build-copied trees. -- [`parser-comments.md`](parser-comments.md): lock-step comment conventions for cross-language parser ports (relevant when a single package has multiple language lanes, each with its own runner). diff --git a/docs/claude.md/fleet/database.md b/docs/claude.md/fleet/database.md deleted file mode 100644 index 4f7f21e20..000000000 --- a/docs/claude.md/fleet/database.md +++ /dev/null @@ -1,118 +0,0 @@ -# Database & ORM - -When a fleet repo needs a database, the stack is fixed: **PostgreSQL** as the engine, **Drizzle ORM** as the query/schema layer, and **`node:smol-sql`** as the driver on the node-smol runtime. This is the stack `depot` runs in production; new repos copy it rather than re-deciding. - -## The choices, and why - -- **Postgres, not SQLite/MySQL/Mongo.** depot standardized on Postgres; the fleet follows so schemas, migrations, and operational knowledge transfer across repos. `node:smol-sql` speaks Postgres natively (it's a unified PG + SQLite interface), so a node-smol-based service needs no external driver dependency for PG. -- **Drizzle ORM, not Prisma/Kysely/TypeORM/raw SQL.** Drizzle is TypeScript-first, ships its schema as code (no separate DSL), and has a thin runtime. depot uses `drizzle-orm/pg-core` for table definitions and the typed query builder. -- **`node:smol-sql` as the driver.** node-smol ships a Bun-compatible `SQL` class (`new SQL('postgres://…')`). Drizzle's `drizzle-orm/bun-sql` adapter binds to that shape, so on the node-smol runtime the driver is built in. Off node-smol (or in tooling that runs on stock Node today), use `drizzle-orm/postgres-js` with the `postgres` npm driver as the fallback; the schema and query code are identical across both adapters. -- **`pglite` for tests.** `@electric-sql/pglite` + `drizzle-orm/pglite` give an in-process Postgres with no external server, so CI and unit tests run the real dialect without a container. depot's test-helpers wire this. - -## Layout (per data-owning package) - -``` -packages/<pkg>/ - .config/drizzle.config.mts # drizzle-kit config (NOT root drizzle.config.ts) - schema/ - index.mts # re-exports every table + relations - <domain>.mts # pg-core table defs, one file per domain - db.mts # createDb() factory: driver + pooled client + schema bind - migrations/ # drizzle-kit generated .sql (NOT .mts) -``` - -All TypeScript files are `.mts` per the fleet `.mts`-runner rule: config, schema, and `db.mts`. drizzle-kit's esbuild-based loader reads `.mts` for both the config and the `schema:` target (verified, drizzle-kit 0.31.9). The one exception is `migrations/`: drizzle-kit _generates_ those as plain `.sql` DDL files (+ a `meta/` snapshot dir), not TypeScript. They're generated data artifacts, like a lockfile, so the `.mts` rule does not apply; never hand-rename a migration to `.mts`. - -- **`.config/drizzle.config.mts`**, not a root `drizzle.config.ts`. Per the fleet `.config/` placement + `.mts`-runner rules. drizzle-kit reads it via `--config .config/drizzle.config.mts`. -- **`schema/` directory**, one file per domain, with an `index.mts` barrel that `db.mts` binds. Don't inline tables in `db.mts`. -- **`db.mts` is the single client factory.** One `createDb(options)` that takes pool config as typed options (no `process.env` reads inside it) plus a `createDbFromEnv()` that reads the DB URL env var. depot's `packages/store/db.ts` is the reference shape (depot predates the fleet `.mts` convention; new repos use `.mts`). - -## .config/drizzle.config.mts - -Generic, repo-agnostic: no table definitions, no repo-specific schema -paths or database name. Copy verbatim; the only thing a repo supplies is -its `DATABASE_URL`. - -```ts -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - dialect: 'postgresql', - // Paths are resolved relative to the directory drizzle-kit runs in - // (process cwd = package root), NOT the config file's location. So - // `./schema` / `./migrations` stay package-root-relative even though - // the config itself lives under `.config/`. - schema: './schema/index.mts', - out: './migrations', - dbCredentials: { - // URL env var is an APPLICATION convention, not Postgres-native: - // neither drizzle-kit nor the postgres.js driver auto-reads it, and - // libpq has no single-URL env var. We read POSTGRES_URL first, then - // DATABASE_URL (the order node:smol-sql uses), and pass the string - // explicitly. See "Env var precedence" below for the full chain. - url: process.env['POSTGRES_URL'] ?? process.env['DATABASE_URL']!, - }, -}) -``` - -drizzle-kit accepts the `.mts` extension and an explicit `--config` path -(verified with drizzle-kit 0.31.9: its esbuild-based loader bundles -`.config/drizzle.config.mts` and runs). Invoke from the package root so -the cwd-relative `schema` / `out` paths resolve: - -```bash -pnpm exec drizzle-kit generate --config .config/drizzle.config.mts -pnpm exec drizzle-kit migrate --config .config/drizzle.config.mts -``` - -Wire those as `db:generate` / `db:migrate` package scripts so callers -never retype the `--config` path. - -## Driver wiring - -node-smol runtime (preferred): - -```ts -import { drizzle } from 'drizzle-orm/bun-sql' -import { SQL } from 'node:smol-sql' - -const url = process.env['POSTGRES_URL'] ?? process.env['DATABASE_URL'] -const client = new SQL(url) -export const db = drizzle({ client, schema }) -``` - -Stock-Node fallback (tooling, pre-node-smol services): - -```ts -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' - -const client = postgres(url, { max: poolSize, connect_timeout, idle_timeout }) -export const db = drizzle(client, { schema }) -``` - -The `schema` import and every query built on `db` are identical between the two. Only the import line + client constructor differ, so migrating a repo onto node-smol is a one-file change in `db.mts`. - -## Env var precedence - -There are two layers, and only one of them is Postgres-native: - -1. **Single connection-URL env var: an application convention, not a Postgres feature.** libpq defines no single-URL env var, and neither the `postgres.js` driver nor drizzle-kit auto-reads one. So `createDbFromEnv()` reads it and passes the string explicitly. Order: **`POSTGRES_URL` → `DATABASE_URL`** (the same precedence `node:smol-sql` uses). Prefer `POSTGRES_URL` (engine-specific, unambiguous when a service talks to more than one datastore); fall back to `DATABASE_URL` (the 12-factor / Heroku norm) so a single-DB host that only sets the generic name still works. - -2. **Discrete libpq vars: the actual Postgres-native fallback.** `PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD` (Postgres docs §34.15). These do NOT assemble into a URL; they are a separate connection-input mechanism that libpq consumes parameter-by-parameter. When no URL env is set, the connection string reaching `PQconnectdb` is empty, and libpq fills each unset parameter from its own `PG*` var (host from `PGHOST`, dbname from `PGDATABASE`, etc.), then a built-in default. No URL is ever built from them. This is what Postgres itself supports, so it's the bottom of the chain and works in any standard PG environment (CI containers, managed PG that injects `PG*`). - -Full precedence: explicit `url` argument → `POSTGRES_URL` → `DATABASE_URL` → libpq `PG*` (consumed natively by libpq, not assembled into a URL). Don't invent a repo-specific env var name; the chain above is the fleet standard. - -**C++/JS parity.** In `node:smol-sql`, env resolution is single-sourced in the JS layer (`POSTGRES_URL || DATABASE_URL`); the C++ binding takes the resolved connection string as a required input and hands it to `PQconnectdb`, which applies the `PG*` fallback. The C++ side reads no connection env var of its own, so the two halves can't drift on precedence. Keep it that way: a `getenv("DATABASE_URL")` added to the C++ pool would create a second resolution point and break the alignment. - -## Validation: typebox, not the ORM - -Drizzle covers the database boundary (table shape, query types). For validating data crossing a _wire_ boundary (API request/response, config files, IPC payloads), use **`@sinclair/typebox`**, the fleet's canonical schema-validation library. Don't reach for zod / valibot / ajv-with-hand-schemas. depot's `packages/types` defines its exposed + internal types as TypeBox schemas. The two layers are complementary: typebox guards what comes in off the wire, Drizzle types what goes to the database. - -## When NOT to add a database - -Most fleet repos are libraries, parsers, or CLIs with no persistent state; they need no database at all. Don't add Drizzle/Postgres speculatively. The stack applies only when a repo genuinely persists relational state (a service, a registry API, an events store). A cache or a flat-file index is not a database need. - -## Reference implementation - -`depot/packages/store/` is the canonical worked example: `db.ts` (pooled postgres-js client with typed pool options), `schema/` (pg-core tables), `drizzle.config.ts` (being migrated to `.config/drizzle.config.mts` per fleet convention), and pglite-backed test-helpers. `depot/packages/events-store/` shows a second data domain with the same shape. diff --git a/docs/claude.md/fleet/drift-watch.md b/docs/claude.md/fleet/drift-watch.md deleted file mode 100644 index 1fd5f50f4..000000000 --- a/docs/claude.md/fleet/drift-watch.md +++ /dev/null @@ -1,67 +0,0 @@ -# Drift watch - -Companion to the `### Drift watch` rule in `template/CLAUDE.md`. The inline section gives the headline. This file enumerates where drift hides, how to check, and the cascade-PR convention. - -## The principle - -> Drift across fleet repos is a defect, not a feature. - -When two socket-\* repos pin different versions of the same shared -resource, the divergence is a bug. The repo with the **newer version -is the source of truth**; older repos catch up. - -This applies whenever a value is meant to be byte-identical (a SHA, a -hook, a CLAUDE.md fleet block) or semver-aligned (a tool version, a -Node release, a pnpm pin). - -## Where drift commonly hides - -- **`external-tools.json`**: pnpm / zizmor / sfw versions plus per-platform sha256s. `socket-registry`'s `setup-and-install` action is the canonical source. -- **`socket-registry/.github/actions/*`**: composite-action SHAs pinned in consumer workflows. -- **`template/CLAUDE.md` fleet block** (between `BEGIN/END FLEET-CANONICAL` markers): must be byte-identical across the fleet. -- **`template/.claude/hooks/*`**: same hook code in every repo; diverged hook code is drift. -- **`lockstep.json` `pinned_sha` rows**: upstream submodules tracked by socket-btm (lsquic, yoga, etc.). -- **`.gitmodules` `# name-version` annotations** (enforced by `.claude/hooks/gitmodules-comment-guard/`). -- **pnpm / Node `packageManager` / `engines` fields**: fleet-wide pin; any divergence is drift. - -## How to check - -1. **Editing one of the above in repo A?** Grep the same thing in - repos B/C/D before committing. If A is older, bump A first; if A - is newer, plan a sync to B/C/D. -2. **`socket-registry`'s `setup-and-install` action** is the - canonical source for tool SHAs. Diverging from it is drift. -3. **`socket-wheelhouse`'s `template/` tree** is the canonical - source for `.claude/`, CLAUDE.md fleet block, and hook code. - Diverging is drift. -4. **`node scripts/sync-scaffolding/cli.mts --all`** (in socket-wheelhouse) - surfaces drift programmatically. - -## Never silently let drift sit - -Reconcile in the same PR, or open a follow-up PR titled -`chore(wheelhouse): cascade <thing> from <newer-repo>` and link it. -The `drift-check-reminder` hook nags after edits to known-drift -surfaces. - -## Cascade PR convention - -`chore(wheelhouse): cascade <thing> from <newer-repo>@<sha>` - -Examples: - -- `chore(wheelhouse): cascade Node 26.1.0 from socket-wheelhouse@87eb704` -- `chore(wheelhouse): cascade plan-location-guard from -socket-wheelhouse@d846d1c` -- `chore(wheelhouse): cascade pnpm 11.0.8 + Node 26.1.0 from -socket-registry@abc1234` - -The body should list affected files + the upstream commit. The -sync-scaffolding tool produces this body automatically when run with -`--target <repo> --fix`. - -## See also - -- `.claude/hooks/drift-check-reminder/` -- `.claude/hooks/gitmodules-comment-guard/` -- `scripts/sync-scaffolding/`: drift detection + auto-fix tooling (canonical in socket-wheelhouse). diff --git a/docs/claude.md/fleet/error-messages.md b/docs/claude.md/fleet/error-messages.md deleted file mode 100644 index b368a5602..000000000 --- a/docs/claude.md/fleet/error-messages.md +++ /dev/null @@ -1,167 +0,0 @@ -# Error messages: worked examples - -Companion to the `## Error Messages` section of `CLAUDE.md`. That section holds the rules. This file holds longer examples and anti-patterns that would bloat CLAUDE.md inlined. - -## The four ingredients - -Every message needs, in order: - -1. **What** — the rule that was broken. -2. **Where** — the exact file, line, key, field, or CLI flag. -3. **Saw vs. wanted** — the bad value and the allowed shape or set. -4. **Fix** — one concrete action, in imperative voice. - -## Library API errors (terse) - -Callers may match on the message text, so stability matters. Aim for one -sentence. - -| ✗ / ✓ | Message | Notes | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| ✗ | `Error: invalid component` | No rule, no saw, no where. | -| ✗ | `The "name" component of type "npm" failed validation because the provided value "" is empty, which is not allowed because names are required; please provide a non-empty name.` | Restates the rule three times. | -| ✓ | `npm "name" component is required` | Rule + where + implied saw (missing). Six words. | -| ✗ | `Error: bad name` | No rule. | -| ✓ | `name "__proto__" cannot start with an underscore` | Rule, where (`name`), saw (`__proto__`), fix implied. | -| ✗ | `Error: invalid argument` | No where, no rule, no fix. | -| ✓ | `orgSlug is required` | Rule + where (`orgSlug`), saw (missing), implies fix. | -| ✗ | `Error: request failed` | No status, no hint what to check. | -| ✓ | `Socket API rejected the token (401); check SOCKET_API_KEY` | Rule (401), where (token), fix (check env var). | - -## Validator / config / build-tool errors (verbose) - -The reader is looking at a file and wants to fix the record without -re-running the tool. Give each ingredient its own words. - -✗ `Error: invalid tour config` - -✓ `tour.json: part 3 ("Parsing & Normalization") is missing "filename". Add a single-word lowercase filename (e.g. "parsing") to this part. One per part is required to route /<slug>/part/3 at publish time.` - -Breakdown: - -- **What**: `is missing "filename"`. The rule is "each part has a filename". -- **Where**: `tour.json: part 3 ("Parsing & Normalization")`. File + record + human label. -- **Saw vs. wanted**: saw = missing; wanted = a single-word lowercase filename, with `"parsing"` as a concrete model. -- **Fix**: `Add … to this part`. Imperative, specific. - -The trailing `to route /<slug>/part/3 at publish time` is optional. Include a _why_ clause only when the rule is non-obvious. Skip it for rules the reader already knows (e.g. "names can't start with an underscore"). - -## Programmatic errors (terse, rule only) - -Internal assertions and invariant checks. No end user will read them. Terse keeps the assertion readable when you skim the code. - -- ✓ `assert(queue.length > 0)` with message `queue drained before worker exit` -- ✓ `pool size must be positive` -- ✗ `An unexpected error occurred while trying to acquire a connection from the pool because the pool size was not positive.` Nothing a maintainer can act on that the rule itself doesn't already say. - -## Common anti-patterns - -**"Invalid X" with no rule.** - -- ✗ `Invalid filename 'My Part'` -- ✓ `filename 'My Part' must be [a-z]+ (lowercase, no spaces)` - -**Passive voice on the fix.** - -- ✗ `"filename" was missing` -- ✓ `add "filename" to part 3` - -**Naming only one side of a collision.** - -- ✗ `duplicate key "foo"` (which record won, which lost?) -- ✓ `duplicate key "foo" in config.json (lines 12 and 47); rename one` - -**Silently auto-correcting.** - -- ✗ Stripping a trailing slash from a URL and continuing. The next run will hit the same bug. Nothing learned. -- ✓ `url "https://api/" has a trailing slash; remove it`. - -**Bloat that restates the rule.** - -- ✗ `The value provided for "timeout" is invalid because timeouts must be positive numbers and the value you provided was not a positive number.` -- ✓ `timeout must be a positive number (saw: -5)` - -## Formatting lists of values - -When the error needs to show an allowed set, a list of conflicting -records, or multiple missing fields, use the list formatters from -`@socketsecurity/lib/arrays` rather than hand-joining with commas: - -- `joinAnd(['a', 'b', 'c'])` → `"a, b, and c"` for conjunctions ("missing foo, bar, and baz") -- `joinOr(['npm', 'pypi', 'maven'])` → `"npm, pypi, or maven"` for disjunctions ("must be one of: …") - -Both wrap `Intl.ListFormat`, so the Oxford comma and one-/two-item cases come out right for free (`joinOr(['a'])` → `"a"`; `joinOr(['a', 'b'])` → `"a or b"`). - -- ✗ `--reach-ecosystems must be one of: npm, pypi, maven (saw: "foo")`. Hand-joined; breaks if the list has one or two entries. -- ✓ `` `--reach-ecosystems must be one of: ${joinOr(ALLOWED)} (saw: "foo")` `` -- ✗ `missing keys: filename slug title`. No separators, no grammar. -- ✓ `` `missing keys: ${joinAnd(missing)}` `` → `"missing keys: filename, slug, and title"` - -Use `joinOr` whenever the error is "must be one of X", `joinAnd` whenever it's "all of X are required / missing / in conflict". - -## Working with caught values - -`catch (e)` binds `unknown`. The helpers in `@socketsecurity/lib/errors` cover the four patterns that recur: - -```ts -import { - errorMessage, - errorStack, - isError, - isErrnoException, -} from '@socketsecurity/lib/errors' -``` - -### `isError(value)`: replaces `value instanceof Error` - -Cross-realm-safe. Uses the native ES2025 `Error.isError` when the engine ships it, falls back to a spec-compliant shim otherwise. Catches Errors from worker threads, `vm` contexts, and iframes that same-realm `instanceof Error` misses. - -- ✗ `if (e instanceof Error) { … }` -- ✓ `if (isError(e)) { … }` - -### `isErrnoException(value)`: replaces `'code' in err` guards - -Narrows to `NodeJS.ErrnoException` (an Error with a string `code` set by libuv/syscalls like `ENOENT`, `EACCES`, `EBUSY`, `EPERM`). Builds on `isError`, so it's also cross-realm-safe. It checks that `code` is a string. A branded Error without a real errno code returns `false`. - -- ✗ `if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') { … }` -- ✓ `if (isErrnoException(e) && e.code === 'ENOENT') { … }` - -### `errorMessage(value)`: replaces the `instanceof Error ? e.message : String(e)` pattern - -Walks the `cause` chain via `messageWithCauses`, coerces primitives and objects to string, and returns the shared `UNKNOWN_ERROR` sentinel (the string `'Unknown error'`) for `null`, `undefined`, empty strings, `[object Object]`, or Errors with no message. - -That last bullet is the important one: **every `|| 'Unknown error'` fallback in the fleet should collapse into a single `errorMessage(e)` call.** - -- ✗ `` `Failed: ${e instanceof Error ? e.message : String(e)}` `` -- ✗ `` `Failed: ${(e as Error)?.message ?? 'Unknown error'}` `` -- ✗ `` `Failed: ${e instanceof Error ? e.message : 'Unknown error'}` `` -- ✓ `` `Failed: ${errorMessage(e)}` `` - -When you want to preserve the cause chain upstream (recommended), pair it with `{ cause }`: - -```ts -try { - await readConfig(path) -} catch (e) { - throw new Error(`Failed to read ${path}: ${errorMessage(e)}`, { cause: e }) -} -``` - -### `errorStack(value)`: cause-aware stack, or `undefined` - -Returns the cause-walking stack for Errors. Returns `undefined` for non-Errors so logger calls stay safe: - -```ts -logger.error(`rebuild failed: ${errorMessage(e)}`, { stack: errorStack(e) }) -``` - -## Voice & tone - -- Imperative for the fix: `rename`, `add`, `remove`, `set`. -- Present tense for the rule: `must be`, `cannot`, `is required`. -- No apology ("Sorry, …"), no blame ("You provided …"). State the rule and the fix. -- Don't end with "please". It adds no information and makes the message feel longer than it is. - -## Bloat check - -Before shipping a message, cross out any word that, if removed, leaves the information intact. If only rhythm or politeness disappears, drop it. diff --git a/docs/claude.md/fleet/file-size.md b/docs/claude.md/fleet/file-size.md deleted file mode 100644 index 14e042f15..000000000 --- a/docs/claude.md/fleet/file-size.md +++ /dev/null @@ -1,24 +0,0 @@ -# File size - -The CLAUDE.md `### File size` section is the cap. This file is the splitting playbook and the explicit exception list. - -## Caps - -Source files have a **soft cap of 500 lines** and a **hard cap of 1000 lines**. Past those thresholds, split the file along its natural seams. Long files are not a badge of thoroughness. They are a sign the module is doing too many things. - -## How to split - -- **Group by domain or concept, not by line count.** Lines 0–500 of a 1500-line file is not a split. Find the natural boundary (one tool per file, one ecosystem per file, one orchestration phase per file) and cut there. -- **Name the new files for what they are.** `spawn-cdxgen.mts`, `spawn-coana.mts`, `parse-arguments.mts`, `validate-options.mts`. The file name should match what's inside it. Avoid generic suffixes (`-helpers`, `-utils`, `-lib`) that just kick the can down the road. -- **Co-locate related helpers with their consumer.** A helper used only by one function lives next to that function in the same file (or the same domain split). A helper used across three files lives in a shared module named after the concept (`format-purl.mts`, not `purl-helpers.mts`). -- **Update the index/barrel only if one already exists.** Don't introduce a barrel just to hide the split. Let importers update their paths to the specific file. Barrels are for stable public surfaces. -- **Run tests after each split, not at the end.** A reviewable commit is one logical extraction. Batching ten splits into one commit makes a regression impossible to bisect. - -## When NOT to split - -- A single function legitimately needs 500 lines (a parser, a state machine, a configuration table). State this in a one-line comment at the top of the function. -- The file is a generated artifact (lockfile-style data, schema dump). Generated files don't count toward the cap. - -## Principle - -A reader should be able to predict what's in a file from its name, and find what they need without scrolling past three other concerns. If a file's table-of-contents reads like "this and also that and also the other thing," it's overdue for a split. diff --git a/docs/claude.md/fleet/gh-token-hygiene.md b/docs/claude.md/fleet/gh-token-hygiene.md deleted file mode 100644 index 4f8ec9a1e..000000000 --- a/docs/claude.md/fleet/gh-token-hygiene.md +++ /dev/null @@ -1,138 +0,0 @@ -# gh token hygiene - -GitHub CLI auth tokens are the highest-blast-radius credential most developers carry. The Nx Console supply-chain compromise (May 2026) exfiltrated `~/.config/gh/hosts.yml` and used the token against the GitHub API within 74 seconds of malware execution. Three layered defenses, all enforced by `.claude/hooks/gh-token-hygiene-guard/` (the 8h age cap, keychain check, and workflow-scope gate all live in this hook — `auth-rotation-reminder` handles non-gh CLIs like npm/pnpm/gcloud/docker/vault). - -## 1. Keychain storage only - -`gh` 2.40+ defaults to writing the token to the OS keychain, but older installs (and any account where `--insecure-storage` was passed) keep a `gho_…` token in `~/.config/gh/hosts.yml`. Any process running as the user can read that file. The fix: - -```bash -gh auth logout -gh auth login # keychain is the default (no flag needed) -gh auth status # confirms "(keyring)" -``` - -(There is no `--secure-storage` flag; the only knob is the opt-out `--insecure-storage`, which this hook rejects.) - -The hook reads `gh auth status` output. If the storage backend is not `keyring`, the hook rejects any `gh` invocation with stderr explaining the fix. No bypass. Moving the token off disk is non-negotiable. - -The keychain isn't impenetrable (any process invoking `gh auth token` can still pull it), but it converts the most likely exfiltration path (direct file read) into a much harder one (subprocess invocation through an auth-prompting wrapper on macOS, libsecret on Linux, Credential Manager on Windows). That's the qualitative win. - -## 2. `workflow` scope is off by default - -`gh auth login` defaults to granting `repo, workflow, gist, admin:public_key, admin:repo_hook`. A sledgehammer. The fleet trims to `read:org, repo, gist` (gh forces `gist` as a minimum and refuses to remove it): - -```bash -gh auth refresh -h github.com -r workflow,admin:public_key,admin:repo_hook -``` - -The hook blocks `gh workflow run`, `gh workflow dispatch`, and `gh api .../actions/workflows/.../dispatches`. The flow is **strictly single-use AND requires physical presence**: - -1. Need to dispatch? Type `Allow workflow-scope bypass` in chat. -2. Run `gh auth refresh -h github.com -s workflow`. The hook then requires OS-level authentication: - - **Touch ID** if `pam_tid.so` is in `/etc/pam.d/sudo_local` (recommended setup; see below) - - **Password dialog** via `osascript` validated against your user account, otherwise -3. On successful auth, the hook records `~/.claude/gh-workflow-grant`. Run ONE dispatch. -4. The hook deletes the grant file immediately after letting the dispatch through. -5. To dispatch again: revoke (`gh auth refresh -h github.com -r workflow`), type a fresh bypass phrase, refresh-add again, re-authenticate. - -The chat bypass phrase alone is insufficient. An attacker who exfiltrates the chat-typed slot still can't proceed without your physical presence (Touch ID or typed password). The single most-dangerous capability (dispatching workflows that have access to all repo secrets including npm publish tokens) is gated by an explicit per-use physical-presence check. - -### Touch ID setup (one-time, recommended on macOS Sonoma+) - -```sh -sudo tee /etc/pam.d/sudo_local <<'EOF' -auth sufficient pam_tid.so -EOF -``` - -> **Copy-paste verbatim.** The closing `EOF` must start at column 0 (no leading whitespace), or the heredoc never terminates and your shell hangs waiting for input. The body lines (`auth ... pam_tid.so`) get written to `tee` as-is. If you indented this block when transcribing it, strip the indent before running. - -After this, every bypass-authorized refresh pops a Touch ID dialog. No password typing. - -> **MDM-managed machines (iru / Jamf / Mosyle / Kandji):** the osascript password-dialog fallback is typically blocked by org policy ("Process Blocked: osascript"). On these boxes Touch ID is the **only** working physical-presence path. The hook detects the block via a cheap headless probe and skips the dialog automatically (no toast spam); the error message points back to this Touch ID setup. If your Mac doesn't have Touch ID hardware AND your org blocks osascript, the workflow-scope path is effectively closed — flag that with IT or use a non-MDM machine for releases. - -#### What the command does, line by line - -- **`sudo tee /etc/pam.d/sudo_local`**: writes to `/etc/pam.d/sudo_local`, which requires root privileges. `sudo tee` is the canonical pattern for "write a file as root from a normal shell". `tee` reads stdin and writes it to the file; `sudo` elevates `tee` so it can write into `/etc/pam.d/`. (Plain shell redirection `> /etc/pam.d/sudo_local` wouldn't work; the redirection happens in your unprivileged shell BEFORE sudo runs.) The very first `sudo` invocation here is the bootstrap one. Touch ID isn't configured yet, so this one prompts for your password the conventional way. Every sudo invocation after this point gets the Touch ID option. - -- **`/etc/pam.d/sudo_local`**: the official macOS extension point for sudo PAM configuration, introduced in macOS Sonoma (14). Apple created it so users can layer auth methods on sudo without modifying `/etc/pam.d/sudo` (which is replaced on every macOS update). The main `/etc/pam.d/sudo` file's first line is `auth include sudo_local`, which pulls in whatever you put here. The file doesn't exist by default; creating it is what enables the extension. - -- **`<<'EOF' ... EOF`**: a [heredoc](https://en.wikipedia.org/wiki/Here_document). Everything between the two `EOF` markers becomes stdin for `tee`. The single quotes around the first `'EOF'` disable shell variable / backtick expansion inside the body. `$foo` and ` `` ` ` ` stay literal. Conservative default for config files. - -- **`auth sufficient pam_tid.so`**: the PAM directive. Three space-separated fields: - - **`auth`**: the module-type. PAM stacks are split into `auth`, `account`, `password`, and `session`; only `auth` modules participate in the "prove who you are" phase that sudo cares about. - - **`sufficient`**: the control flag. PAM evaluates auth modules top-to-bottom; `sufficient` means "if this module succeeds, the whole stack succeeds and stop here; if it fails, ignore and try the next module". So Touch ID is given first chance, and if you decline the dialog or no fingerprint is enrolled, sudo falls through to the password prompt that comes from the main `sudo` stack. - - **`pam_tid.so`**: the Touch ID PAM module Apple ships in `/usr/lib/pam/pam_tid.so.2`. It pops the standard macOS Touch ID dialog and reports success/failure back to PAM. Requires a Mac with Touch ID hardware (M1+ MacBook, MagSafe-connected Touch ID keyboard on desktops, or Apple Watch on supported models). - -#### Why `sufficient` and not `required` or `requisite`? - -The four PAM control flags, briefly: - -- **`required`**: must succeed; failure is recorded but the stack keeps going so an attacker can't probe which module failed -- **`requisite`**: must succeed; failure short-circuits the stack immediately -- **`sufficient`**: succeeds the whole stack on success; failure is ignored and falls through to the next module -- **`optional`**: result is ignored - -We pick `sufficient` because we want Touch ID to be an alternative to password entry, not a precondition. If Touch ID isn't available (lid closed, no enrolled fingerprint, declined dialog, broken sensor), sudo silently moves on to the password path. No friction, no lockout. - -#### Why not edit `/etc/pam.d/sudo` directly? - -You can. `/etc/pam.d/sudo` is just a text file. But macOS updates replace it on every system upgrade, so your edit would silently disappear after the next macOS minor release. `sudo_local` is preserved across upgrades. That's its whole reason for existing. - -#### Verifying it worked - -```sh -# Reset the sudo timestamp so it can't cache a previous auth -sudo -k -# This sudo invocation should pop the Touch ID dialog -sudo -v -``` - -If you see the Touch ID dialog, you're good. If you see a password prompt instead, either: - -- Touch ID isn't enrolled on this Mac: check System Settings → Touch ID & Password -- You're on a Mac without Touch ID hardware: use the password fallback (the hook handles this automatically) -- The file path or content is wrong: re-run the `sudo tee` command and double-check - -#### Undoing it - -```sh -sudo rm /etc/pam.d/sudo_local -``` - -After this, sudo is back to its default (password only). The hook's auth flow will still work via the osascript password dialog path. - -## 3. 8-hour token age cap - -`auth-rotation-reminder` Stop-hook tracks the gh token's issued-at timestamp (stored at `~/.claude/gh-token-issued-at`). When the token is >8 hours old, the next Stop event exits non-zero with instructions: - -``` -gh auth refresh -h github.com -``` - -8 hours is the workday boundary: one re-auth at session start, no in-flight interruption. Shorter cadences (1h, 4h) were considered and rejected. The Nx malware exfiltrated and exercised the token in 74 seconds, so any rotation cadence above "instantaneous" is the same qualitative defense. 8h minimizes friction while keeping the steal window bounded. - -Local timestamp tracking is advisory. A malicious process can backdate the file. Real defense comes from the OTHER layers in this doc, not the rotation cadence. - -## What this doesn't defend against - -- **Already-running malware with current token.** The token is already in keychain memory. Rotation matters for the next exfil; the current breach is mitigated by signed-commit enforcement, branch protection, and audit-log alerting (see _Wave 2_ in `.claude/plans/gh-token-hygiene-hook.md`). -- **Phished OAuth flows.** A user typing credentials into a malicious login page bypasses every local defense. Phishing-resistant MFA (WebAuthn / passkeys) is the answer; the fleet doesn't enforce that here. -- **Compromised dependencies pulling tokens via gh subprocess.** A malicious npm package can `spawn('gh', ['auth', 'token'])` and exfiltrate. The defense is supply-chain review (Socket scanning + minimumReleaseAge + checked deps). - -## Recovery flow if a token leaks - -1. **Revoke immediately** at https://github.com/settings/tokens (search "gh" or the token name, click Delete). -2. Audit recent activity: https://github.com/settings/security-log -3. Check repo audit logs for unauthorized pushes / workflow dispatches / PRs. -4. If anything looks wrong: rotate every repo's deploy keys, deploy tokens, and CI secrets accessible from the affected token's scope. -5. Re-issue gh token with keychain storage + minimal scopes (`gh auth logout && gh auth login` — keychain is the default; then trim scopes via `gh auth refresh -h github.com -r workflow,admin:public_key,admin:repo_hook`). -6. File an incident note in the relevant repo's SECURITY log. - -## Operational defaults - -- `~/.claude/gh-token-issued-at`: local timestamp stamped by the hook when the user runs `gh auth login` or `gh auth refresh`. The 8h age check reads this. -- `~/.claude/gh-workflow-grant`: presence marker for an unconsumed workflow-dispatch authorization. Created when a bypass-authorized + auth-passed `gh auth refresh -s workflow` runs; deleted as soon as the first dispatch is let through. - -No escape hatches. The hook is failsafe-deny on all invariants. The OS-auth path (Touch ID + osascript + dscl, called via absolute `/usr/bin/` paths to defeat PATH-hijack) is intentionally unreachable in unit tests; the auth path is exercised by manual smoke-testing when the hook ships. diff --git a/docs/claude.md/fleet/immutable-releases.md b/docs/claude.md/fleet/immutable-releases.md deleted file mode 100644 index c75890970..000000000 --- a/docs/claude.md/fleet/immutable-releases.md +++ /dev/null @@ -1,81 +0,0 @@ -# Immutable releases - -The fleet ships **immutable GitHub Releases**: assets locked at publish, tags protected, and a cryptographically verifiable **release attestation** (Sigstore-bundle) produced for every release. GA'd on GitHub 2025-10-28 ([changelog](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/), [docs](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases)). - -This rule applies to every fleet repo that publishes via `gh release create`: socket-btm + binary releases, every npm publish workflow that also tags a GH release, any `chore(release): vX.Y.Z` workflow. - -## Why - -- **Tamper evidence**: a downstream consumer of `binsuite-vX.Y.Z.tar.gz` can run `gh release verify <tag>` (or any Sigstore-compatible tool) and prove the asset wasn't modified after publish. -- **Tag protection**: once a release is published immutably, the tag can't be force-moved. Historical bisects and reproducible-build claims stay honest. -- **Asset lock**: nobody can swap a `.tar.gz` for a different one at the same URL post-publish. Defeats one common supply-chain attack class. -- **Audit trail**: the attestation records the release tag, commit SHA, and asset digests as a signed, verifiable artifact. - -## Enabling at the repo / org level - -Repository setting (UI today; API field not yet exposed as of 2026-05): - -> **Settings → General → Releases → ☑ Enforce immutable releases for this repository** - -Org-level (recommended; applies to all repos by default): - -> **Organization → Settings → Code, planning, and automation → Releases → ☑ Enforce immutable releases for all repositories** - -The fleet baseline is **org-level on, no per-repo opt-out**. Run `auditing-gha-settings` periodically to flag drift once the GH API surfaces the toggle. - -## Workflow pattern: draft → upload → publish - -The `gh release create` direct-publish pattern (single call that creates the release + uploads assets + publishes immediately) **does not produce an attestation reliably** because the attestation hash is computed at publish-time over the locked asset set, and direct-publish can race with asset uploads. - -The GitHub-recommended pattern is: - -```bash -# 1. Create as draft with notes + title but NO assets. -gh release create "${TAG}" \ - --draft \ - --title "${TITLE}" \ - --notes "${NOTES}" - -# 2. Upload assets to the draft. Assets aren't visible to consumers yet. -gh release upload "${TAG}" \ - release/*.tar.gz \ - release/checksums.txt - -# 3. Publish the draft. This is the single atomic event that locks the -# asset set and produces the attestation. -gh release edit "${TAG}" --draft=false -``` - -🚨 **Workflow rule:** every release workflow under `.github/workflows/` that publishes a GitHub Release MUST use the 3-step pattern. The single-call `gh release create <tag> <files>` form is forbidden in fleet release workflows. A guard hook is on the backlog; today the rule is enforced by review + the existing release-workflow-guard's dispatch policy. To audit: `grep -rn "gh release create" .github/workflows/ | grep -v "\-\-draft"`. - -## Verifying a release - -```bash -gh release verify <tag> # all assets -gh release verify-asset <tag> <asset> # specific asset -``` - -These work for anyone outside the org with `gh` installed. The attestation is also readable as raw Sigstore JSON via `gh attestation` for integration with non-`gh` tooling. - -## What NOT to do - -- **Don't** force-push a tag after a release exists. The tag is protected; the push will be rejected at the server. -- **Don't** delete an asset and re-upload to "fix" it. The release is locked; you'll have to cut a new patch version. -- **Don't** `gh release create <tag> <files...>` as one atomic call. See the workflow rule above. -- **Don't** dispatch a release workflow that uses the legacy direct-publish pattern. Migrate it first. - -## Post-publish provenance check - -A Stop-hook reminder (`provenance-publish-reminder`) already checks that npm-published artifacts carry `dist.attestations` and `_npmUser.trustedPublisher`. The same hook is extended to verify GH-release-published artifacts carry a release attestation: after `chore: bump version to vX.Y.Z` + `vX.Y.Z` tag, the hook runs `gh release view <tag> --json isImmutable,...` and warns if the release isn't immutable. - -## When the repo doesn't qualify - -Some fleet repos don't publish releases (private tooling repos, scratch repos). For those, the rule is moot. `gh release create` doesn't appear in their workflows. The hook checks only files matching `.github/workflows/**.yml` that contain a `gh release create` line. - -## References - -- [Immutable releases: changelog (GA 2025-10-28)](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/) -- [Immutable releases: docs](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) -- [gh release verify CLI](https://cli.github.com/manual/gh_release_verify) -- [gh attestation verify CLI](https://cli.github.com/manual/gh_attestation_verify) -- Related: [`version-bumps.md`](version-bumps.md) (release sequence), [`public-surface-hygiene.md`](public-surface-hygiene.md) (release workflow restrictions). diff --git a/docs/claude.md/fleet/inclusive-language.md b/docs/claude.md/fleet/inclusive-language.md deleted file mode 100644 index b34fb85de..000000000 --- a/docs/claude.md/fleet/inclusive-language.md +++ /dev/null @@ -1,34 +0,0 @@ -# Inclusive language reference - -The fleet uses precise, neutral terms over historical metaphors that imply hierarchy or exclusion. The substitutes are not euphemisms — they're more _accurate_ (a list of allowed values genuinely is an "allowlist"; "whitelist" is a metaphor that hides what the list does). - -## Substitution table - -| Replace | With | -| -------------------------------- | --------------------------------------------------- | -| `whitelist` / `whitelisted` | `allowlist` / `allowed` / `allowlisted` | -| `blacklist` / `blacklisted` | `denylist` / `denied` / `blocklisted` / `blocked` | -| `master` (branch, process, copy) | `main` (branch); `primary` / `controller` (process) | -| `slave` | `replica`, `worker`, `secondary`, `follower` | -| `grandfathered` | `legacy`, `pre-existing`, `exempted` | -| `sanity check` | `quick check`, `confidence check`, `smoke test` | -| `dummy` (placeholder) | `placeholder`, `stub` | - -## Where to apply - -- **Code**: identifiers, comments, string literals. -- **Docs**: READMEs, CLAUDE.md, markdown. -- **Config**: YAML, JSON. -- **History**: commit messages, PR titles/descriptions. -- **CI logs** you control. - -## Two narrow exceptions - -The legacy term must remain only when changing it would break something external: - -- **Third-party APIs / upstream code**: when interfacing with an external API field literally named `whitelist`, keep the field name; rename your local variable. Example: `const allowedDomains = response.whitelist`. -- **Vendored upstream sources**: don't rewrite vendored code under `vendor/**`, `upstream/**`, or `**/fixtures/**`. Patch around it if needed. - -## When to fix - -When you encounter a legacy term during unrelated work, fix it inline — don't defer. diff --git a/docs/claude.md/fleet/lint-rules.md b/docs/claude.md/fleet/lint-rules.md deleted file mode 100644 index 5261e8008..000000000 --- a/docs/claude.md/fleet/lint-rules.md +++ /dev/null @@ -1,59 +0,0 @@ -# Lint rules: errors over warnings, fixable over reporting - -The CLAUDE.md `### Lint rules` section is the headline. This file is the full rationale and the cascade behavior. - -## Rationale - -Fleet lint rules are guardrails for AI-generated code. Make them strict: - -- **Errors, not warnings.** A warning is silently ignored; an error blocks the commit. Severity `"warn"` belongs to user-facing tools (browser dev consoles, ad-hoc scripts), not the fleet's CI gate. Default to `"error"` for new rules; bump existing `"warn"` entries to `"error"` when you touch them. -- **Fixable when possible.** Every new rule that _can_ express a deterministic rewrite _should_ ship an autofix. The `fixable: 'code'` meta flag plus a `fix(fixer) => ...` in `context.report` lets `pnpm exec oxlint --fix` clean up the violation. Reporting-only rules are fine when the fix requires human judgment (e.g., picking between `httpJson` vs `httpText` to replace `fetch()`); say so explicitly in the rule docstring. -- **Skill or hook ≠ no rule.** If a behavior already lives as a skill (the canonical write-up) or a hook (PreToolUse blocking), still encode the lint rule on top. Defense in depth. The skill is documentation, the hook is edit-time enforcement, the lint rule is commit-time enforcement. -- **Tooling: oxlint + oxfmt only.** No ESLint, no Prettier. The fleet socket-\* oxlint plugin lives in `template/.config/oxlint-plugin/`; new fleet rules land there. Wire via `.oxlintrc.json` `jsPlugins` and the `socket/` namespace. - -## Cascade - -When introducing a new rule fleet-wide, expect it to surface dozens of pre-existing violations. That's the rule earning its keep, not noise. Surface the cleanup as a separate task rather than auto-fixing in the same PR. - -## Disable comments: per-call-site, never identical-stacked - -`oxlint-disable-next-line <rule> -- <reason>` is correct when a single call site has a genuine, code-local justification that wouldn't apply to siblings. Stacking the same comment on adjacent lines is the failure mode. - -**Wrong**: three byte-identical disables on consecutive lines: - -```ts -// oxlint-disable-next-line socket/prefer-exists-sync -- isDir is the unit under test. -expect(await isDir(dir)).toBe(true) -// oxlint-disable-next-line socket/prefer-exists-sync -- isDir is the unit under test. -expect(await isDir(file)).toBe(false) -// oxlint-disable-next-line socket/prefer-exists-sync -- isDir is the unit under test. -expect(await isDir(other)).toBe(false) -``` - -**Right (helper pattern)**: lift the rule-violating call behind a one-line helper. The helper's declaration carries the disable once; the test reads clean: - -```ts -it('isDir returns true for directories', async () => { - // oxlint-disable-next-line socket/prefer-exists-sync -- isDir is the unit under test. - const callIsDir = (p: string) => isDir(p) - expect(await callIsDir(dir)).toBe(true) - expect(await callIsDir(file)).toBe(false) - expect(await callIsDir(other)).toBe(false) -}) -``` - -**Right (sentinel-constant pattern)**: when the violation is a literal value rather than a call (e.g., GraphQL spec mandates `null` for unresolved nodes), name the literal at module scope: - -```ts -// oxlint-disable-next-line socket/prefer-undefined-over-null -- GraphQL spec returns null for unresolved nodes. -const GRAPHQL_NULL = null - -// Then in tests: -JSONStringify({ - data: { repository: { tagRef: GRAPHQL_NULL, branchRef: GRAPHQL_NULL } }, -}) -``` - -**Why this matters:** stacked identical disables are visual noise that obscures the real signal (per-line disables exist to highlight _exceptional_ code). When the disable repeats verbatim, the exception isn't per-line. It's per-pattern, and the pattern deserves its own name. - -**When per-call-site IS correct:** the reasons differ, OR the disables sit on lines that aren't adjacent. Two disables 20 lines apart in the same file with the same rule + same reason is fine; what's banned is the consecutive stack on adjacent lines. diff --git a/docs/claude.md/fleet/parallel-claude-sessions.md b/docs/claude.md/fleet/parallel-claude-sessions.md deleted file mode 100644 index 1e084bd68..000000000 --- a/docs/claude.md/fleet/parallel-claude-sessions.md +++ /dev/null @@ -1,57 +0,0 @@ -# Parallel Claude sessions - -Companion to the `### Parallel Claude sessions` rule in `template/CLAUDE.md`. The inline section gives the headline plus the worktree recipe. This file holds the full prohibition list, the worktree recipe broken down, and the umbrella rule. - -## The problem - -A single socket-\* checkout often has multiple Claude sessions running concurrently: parallel agents, parallel terminals, or git worktrees mapped onto the same `.git/`. Your session is not the only writer. Several common git operations assume otherwise. - -## Forbidden in the primary checkout - -These commands mutate state that belongs to other sessions: - -- **`git stash`**. The stash is a shared store. Another session can `git stash pop` yours. -- **`git add -A` / `git add .`**. Sweeps in files that belong to another session's in-progress work. The `overeager-staging-guard` hook blocks these in real time (bypass: `Allow add-all bypass`). -- **`git checkout <branch>` / `git switch <branch>`**. Yanks the working tree out from under another session editing a file on the current branch. -- **`git reset --hard` against a non-HEAD ref**. Discards another session's commits. - -If a hook flags one of these, the hook is doing its job. Don't bypass. - -## Required for branch work: spawn a worktree - -```bash -BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \ - | sed 's@^refs/remotes/origin/@@' || echo main) -git worktree add -b <task-branch> ../<repo>-<task> "$BASE" -cd ../<repo>-<task> -# edit / commit / push from here; primary checkout is untouched -git worktree remove ../<repo>-<task> -``` - -The `BASE` lookup resolves the remote's default branch. Usually `main`, but legacy repos still use `master`. Never hard-code one; see [Default branch fallback](../../../CLAUDE.md#default-branch-fallback). - -After `git worktree remove`, the branch lives in the primary repo's `.git/refs/heads/`. Push it from there if you still need it. - -## Required for staging: surgical adds - -`git add <specific-file>`. Never `-A` / `.`. The `overeager-staging-guard` hook enforces this at edit time. - -## Never revert files you didn't touch - -`git status` shows unfamiliar changes? Leave them. They belong to: - -- Another concurrent session -- An upstream pull that's still settling -- A hook side-effect (formatter, linter, sync-scaffolding) - -`git checkout -- <file>` against work you didn't produce destroys the other session's progress. - -## Never reach into a sibling fleet repo's path - -Cross-repo imports go through `@socketsecurity/lib/...` and `@socketregistry/...` (workspace exports). Path-based imports (`../<sibling-repo>/...`) break in CI, in fresh clones, and on CI agents without the sibling checked out. The `cross-repo-guard` hook blocks these at edit time. - -## The umbrella rule - -> Never run a git command that mutates state belonging to a path other than the file you just edited. - -Stash, add-all, checkout-branch, reset-hard, and revert-other-session's-file are the common shapes. The rule is general. If you can't explain why the command only affects files your session owns, don't run it. diff --git a/docs/claude.md/fleet/parser-comments.md b/docs/claude.md/fleet/parser-comments.md deleted file mode 100644 index c07b0b3bf..000000000 --- a/docs/claude.md/fleet/parser-comments.md +++ /dev/null @@ -1,151 +0,0 @@ -# Parser comments + upstream source pinning - -Referenced from CLAUDE.md → _Code style_. - -The default rule for comments (default to none; write only the load-bearing _why_) has a deliberate exception for **parser code that mirrors an upstream reference implementation**. Examples in the fleet: `test262-parser-runner` (mirrors `adrianheine/test262-parser-runner`), the eco lockfile parsers (mirror sdxgen + per-pm reference behaviors), `smol-manifest` C++ bindings (mirror the same eco parsers as native impls), the acorn grammar rules (mirror upstream `acornjs/acorn`). - -For these files: - -## 1. Comment freely about steps - -Walk the reader through what each block does in terms the upstream reference uses. The dual-impl invariant (TS ↔ native, JS ↔ C++) only holds if both halves can be verified against the same prose. Step-by-step comments are the cheapest way to keep them aligned across forks. - -## 2. Cite the upstream source - -When a method, regex, or branch derives from a specific spot in the upstream, link it. Prefer permalinks pinned to a specific tag or commit SHA (`https://github.com/<owner>/<repo>/blob/<tag-or-sha>/<path>#L<line>`) over branch-pointing links that bitrot when upstream moves. - -```ts -// Upstream: acornjs/acorn @ 8.14.0 -// https://github.com/acornjs/acorn/blob/8.14.0/acorn/src/state.js#L237 -// Adopts the same eight-bit options vector; the lower three bits -// carry the parse mode and the upper five are reserved for jsx / -// typescript / strict flags. -``` - -## 3. Use upstream pins as guides - -When the fleet repo already has an upstream pin (in `xport.json`, `lockstep.json`, `.gitmodules`, an `external-tools.json` block, or a header comment referencing a SHA), reuse that same pin in citations within the same file. Drifting between "the pin in xport.json says SHA-A" and "the file's header comment cites SHA-B" is a confusion source. The pin file is the source of truth; comments reference it. - -```ts -// Upstream pin: lockstep.json → acornjs/acorn → 8.14.0 -// (this file mirrors acorn/src/state.js as of that tag) -``` - -## 4. Deviations get a paragraph, not a line - -When the local impl diverges from upstream (faster path, different error shape, missing edge case), write a short paragraph explaining _why_ the divergence is deliberate. One-line `// differs from upstream` notes get stripped during cleanups; paragraphs survive because they carry the load-bearing _why_. - -## 5. Lock-step references across language ports - -When a parser ships in multiple implementations that must agree behaviorally (e.g. ultrathink's acorn ports: Rust / Go / C++ / TypeScript; socket-btm's `packages/temporal-infra/src/socketsecurity/temporal/*.{cc,h}` C++ port of upstream `temporal_rs` Rust crate), every cross-impl reference uses the `Lock-step` prefix. The naming is load-bearing. `grep -r 'Lock-step'` is the audit surface. - -Three forms, three jobs: - -**File-level provenance**: top-of-file `//!` doc comment that names where the canonical source lives. Ports state who they follow; canonical files state who follows them: - -```rust -//! Lock-step with Go: src/parser/class.go -//! Lock-step with C++: src/parser/class.cpp -//! Lock-step with TS: src/parser/class.ts -``` - -```go -//! Lock-step from Rust: crates/parser/src/class.rs -``` - -`Lock-step with X` = "X is a peer / downstream port; keep in sync". `Lock-step from X` = "X is the canonical source for this file". - -**Inline cross-references**: point at the specific line range in the canonical impl. Include a colon-and-line-range so reviewers can jump: - -```rust -// Lock-step with Go: parser.go:6450-6457 -// Lock-step with Go: parser.go:6672-6682, upstream acorn: statement.js:737-745 -``` - -In a port (Go/C++/TS), the reference points up at Rust. In Rust (canonical), the reference may point further upstream at Acorn JS. The rule is comments always point at the source-of-truth, never at a downstream port. - -**Lock-step note**: explains a _deliberate_ divergence from the canonical shape. Reads like a thesis: here's the canonical idiom, here's why this impl can't follow it verbatim, here's the chosen reshape: - -```cpp -// Lock-step note: Rust uses bumpalo-arena ownership for NodeVec; -// here we hold std::vector<NodeId> with manual reserve() because -// the C++ port can't share Rust's lifetime model. Capacity is -// pre-computed by parse_class_body's first pass — see parser.cpp:482. -``` - -```rust -// Lock-step note: reshaped for borrowck. Go's `defer s.restore()` -// returns a ResetScope holding &mut Path; capture len() and restore -// via set_length() so the path can be re-borrowed for append() -// in between. -``` - -The line `Lock-step with X` says "go look here"; the note `Lock-step note:` says "I already looked, and this is why I'm not matching shape-for-shape". Keep them distinct: a reviewer searching for missing lock-step refs filters by the former; a reviewer auditing _why_ this port diverges filters by the latter. - -## 6. Don't let lock-step references rot - -Paths in `Lock-step with X: <path>:<lines>` are claims about file layout that decay when ports get reorganized. A stale `Lock-step with Rust: crates/parser-stmt/src/...` reference after `crates/parser-stmt/` is renamed is worse than no reference. It lies to the reader. - -Two cheap defenses: - -- Reference paths, not symbols. `parser.go:6450-6457` survives a method rename; `parseClassBody` doesn't. -- Add a `scripts/check-lock-step-refs.mts` gate that greps every `Lock-step with <Lang>:` comment, resolves the path against the right impl root, and fails CI if the path no longer exists. Line ranges are advisory and can drift; path existence is enforceable. - -## 7. Lock-step header: byte-identical intent across the quadruplet - -Cross-references catch path rot. They don't catch _semantic_ drift, the case where the four impls quietly start disagreeing about what the file is _for_. The convention for that is a top-of-file **Lock-step header** block, byte-identical across every member of the quadruplet: - -```rust -// BEGIN LOCK-STEP HEADER -// Class Parsing (Declarations, Expressions, Elements, Methods) -// -// Lock-step with Go: src/parser/class.go -// Lock-step with C++: src/parser/class.cpp -// Lock-step with TS: src/parser/class.ts -// END LOCK-STEP HEADER -``` - -```go -// BEGIN LOCK-STEP HEADER -// Class Parsing (Declarations, Expressions, Elements, Methods) -// -// Lock-step with Go: src/parser/class.go -// Lock-step with C++: src/parser/class.cpp -// Lock-step with TS: src/parser/class.ts -// END LOCK-STEP HEADER -``` - -```cpp -// BEGIN LOCK-STEP HEADER -// Class Parsing (Declarations, Expressions, Elements, Methods) -// -// Lock-step with Go: src/parser/class.go -// Lock-step with C++: src/parser/class.cpp -// Lock-step with TS: src/parser/class.ts -// END LOCK-STEP HEADER -``` - -Rules: - -- **Single-line `// ` syntax across every language**: no `//!` / `///` / `/** */` mixing. Strip the leading `// `, byte-compare. Languages that need a doc-comment for tooling (Rust's `//!` for `rustdoc`, JSDoc for TypeScript) put that separately. The Lock-step header is its own block and lives alongside. -- **Mandatory: name + cross-refs.** First line is the file's purpose. Body lists `Lock-step with <Lang>: <path>` for every peer in the quadruplet, and `Lock-step from <Lang>: <path>` if the file is a port. The path forms are the same ones validated in §5. -- **No timestamps, no authors, no per-impl prose.** Anything that differs between impls goes _outside_ the header (in language-specific doc comments, `// PORT NOTE:` blocks, etc.). The header is the contract; divergence is contraband. - -The gate (`scripts/check-lock-step-header.mts`, registered in the same opt-in `.config/lock-step-refs.json` as §5–6) walks the quadruplets named by each canonical-side header, extracts the `BEGIN LOCK-STEP HEADER` / `END LOCK-STEP HEADER` block from each peer, and fails CI on any byte-diff. When the canonical impl needs to revise the contract, every peer must update in the same commit. - -## Scope - -This exception applies to: - -- Parsers + tokenizers (eco lockfile, JS/TS source parsers, AST walkers) -- Wire-format encoders/decoders (JSON, YAML, TOML, INI) -- Format conformance suites (test262, eco runners) -- Native bindings of any of the above - -It does NOT apply to: - -- Glue code (orchestration, CLI wiring, file routing) -- Public API surfaces -- Code that doesn't have an upstream reference - -Default rules apply for those. The exception buys verbosity only when the verbosity is load-bearing (cross-impl alignment). diff --git a/docs/claude.md/fleet/path-hygiene.md b/docs/claude.md/fleet/path-hygiene.md deleted file mode 100644 index 6a6dfefc7..000000000 --- a/docs/claude.md/fleet/path-hygiene.md +++ /dev/null @@ -1,38 +0,0 @@ -# 1 path, 1 reference (path hygiene) - -A path is constructed exactly once. Everywhere else references the constructed value. This is the strict form of DRY for paths. Paths drift the easiest because they're string literals that look harmless until two of them diverge and you spend an hour finding which copy is the source of truth. - -## Scope rules - -- **Within a package**: every script imports its own `scripts/paths.mts`. No `path.join('build', mode, …)` outside that module. `paths.mts` is per-package (like `package.json`). Every package that has a `scripts/` dir has its own. -- **Across packages**: package B imports package A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '<sibling>', 'build', …)`. -- **Sub-packages inherit**: a sub-package's `paths.mts` `export * from '<rel>/paths.mts'` from the nearest ancestor and adds local overrides below the re-export. Don't re-derive `REPO_ROOT` / `CONFIG_DIR` / `NODE_MODULES_CACHE_DIR` (enforced by `.claude/hooks/paths-mts-inherit-guard/`). -- **Not just build paths**: `paths.mts` is for _every_ path the package constructs (config files (`socket-wheelhouse.json`), lockfiles, cache dirs, manifest files). The fleet ships a starter `template/scripts/paths.mts` that exports the common constants + `loadSocketWheelhouseConfig()`. -- **Workflows / Dockerfiles / shell** can't `import` TS. Construct once, reference by output / `ENV` / variable. - -## Canonical layout - -Build outputs live at `<package-root>/build/<mode>/<platform-arch>/out/Final/<artifact>`, where `mode ∈ {dev, prod}` and `platform-arch` is the Node-style `<process.platform>-<process.arch>` (e.g. `darwin-arm64`, `linux-x64`). socket-btm is the worked example; ultrathink follows it; smaller TS-only repos that don't fork by platform may use `'any'` as the platform-arch sentinel but keep the same nesting. - -Each package's `scripts/paths.mts` exports at minimum: - -- `PACKAGE_ROOT`: absolute path to the package directory -- `BUILD_ROOT`: `<PACKAGE_ROOT>/build` -- `getBuildPaths(mode, platformArch)`: returns at least `outputFinalDir` + `outputFinalFile` or `outputFinalBinary` - -## Enforcement (three levels) - -| Level | Surface | What it catches | -| ----------- | ----------------------------------------------- | ---------------------------------------------------------------------- | -| Edit-time | `.claude/hooks/path-guard/` | Build-path construction outside `paths.mts` | -| Edit-time | `.claude/hooks/paths-mts-inherit-guard/` | Sub-package `paths.mts` that doesn't inherit from the nearest ancestor | -| Commit-time | `scripts/check-paths.mts` (run by `pnpm check`) | Whole-repo path-hygiene scan | -| Audit + fix | `/guarding-paths` skill | Interactive cleanup | - -## Common mistakes - -- **Recomputing a sibling's build dir.** Import from the sibling's `paths.mts` instead. -- **Hard-coding `build/dev/` or `build/prod/`.** Use `getBuildPaths(mode, ...)` so a future `--mode=staging` doesn't require N edits. -- **Constructing the same `~/.socket/...` cache dir in 3 places.** Either it belongs in `scripts/paths.mts` or in `@socketsecurity/lib`'s `paths/` module if it's truly cross-package. - -When in doubt: find the canonical owner and import from it. diff --git a/docs/claude.md/fleet/plan-storage.md b/docs/claude.md/fleet/plan-storage.md deleted file mode 100644 index 0996ba2a5..000000000 --- a/docs/claude.md/fleet/plan-storage.md +++ /dev/null @@ -1,113 +0,0 @@ -# Plan storage - -Companion to the _Plan storage_ fleet rule in `template/CLAUDE.md`. The inline rule is one sentence. This doc carries the rationale, the migration guidance for legacy `docs/plans/` content, and the per-repo extension pattern. - -## What counts as a "plan" - -A design / implementation / migration document that captures **state about -work in progress or work about to start**: - -- Multi-step refactor breakdowns (which files, in what order, how many LOC). -- Cross-package migration playbooks. -- Feature-design docs that enumerate JS surface + C++ binding signatures. -- "Where did we leave off" notes a future session needs to resume. -- LOC estimates, step boundaries, commit-split proposals. - -What is **not** a plan (and belongs elsewhere): - -- Permanent architecture docs: `docs/architecture/` or a top-level `<topic>.md` (tracked). -- API reference: JSDoc / TSDoc / Rustdoc / README. -- Onboarding / contributor docs: `CONTRIBUTING.md` (tracked). -- Incident post-mortems: if the lesson is worth keeping, it goes into CLAUDE.md as a rule with a `**Why:**` line per the _Compound lessons_ rule. The post-mortem itself can stay in `.claude/plans/` as scratch. - -## The canonical location - -`<repo-root>/.claude/plans/<lowercase-hyphenated>.md`. - -One location per repo. Never: - -- `docs/plans/`: tracked; defeats the rule. -- `<pkg>/docs/plans/`: tracked + duplicates the convention per-package. -- `<pkg>/.claude/plans/`: sub-package `.claude/` is a fleet-convention smell; CLAUDE itself reads the repo-root `.claude/` for the operator's current session. - -The path is shared across parallel Claude sessions in the same checkout, so -multiple plans coexist comfortably. Worktrees get their own `.claude/plans/` that disappears when the worktree is removed. That's by design. - -## Untracked-by-default - -The fleet `template/.gitignore` already excludes `/.claude/*` with an -explicit allowlist: - -```gitignore -/.claude/* -!/.claude/agents/ -!/.claude/commands/ -!/.claude/hooks/ -!/.claude/ops/ -!/.claude/settings.json -!/.claude/skills/ -``` - -`plans/` is intentionally absent from the allowlist. A freshly-written plan -is therefore untracked by default. - -Do NOT: - -- Add `!/.claude/plans/` to the gitignore allowlist. -- `git add .claude/plans/<file>.md`. -- Use `git add -A` / `git add .` (which would sweep the plan in; the fleet rule already forbids those flags for unrelated reasons). - -## Why untracked - -Plans capture state: what we're about to do, what we've ruled out, what the LOC estimates are. State decays the moment a commit lands. A plan tracked in git rots into "this file describes what main looked like 4 months ago" lies that future-you trusts. Keeping plans local-only forces the work to live in: - -- The **code** (the actual implementation is the source of truth). -- **Commit messages** (capture the why at the moment the change ships). -- **CHANGELOG** (capture the consumer-visible diff at release time). - -These are the surfaces that actually stay accurate, because they're -written at the moment of the change rather than weeks before it. - -**Past incident:** socket-btm grew three parallel `plans/` directories (`docs/plans/`, `packages/*/docs/plans/`, `.claude/plans/`). Same content type, three locations, all tracked, all drifting. The rule is one location, untracked. - -## Migrating legacy `docs/plans/` content - -If you find a tracked plan in `docs/plans/` or `<pkg>/docs/plans/`: - -1. **Stop and ask the user before relocating.** Moving the file requires - rewriting every reference (test files, READMEs, source comments, - Dockerfiles, build scripts) that cites the old path. Silent migration - is a recipe for broken links. -2. If the user approves migration: - - Inventory references first: `rg -l "docs/plans/<filename>"` and - `rg -l "<pkg>/docs/plans/<filename>"`. - - If the plan is **still active** (work isn't done): move to - `.claude/plans/<same-name>.md` (the destination is untracked, so the - move requires `git rm <old>` + `cp <old> .claude/plans/` + plain - filesystem cp, not `git mv`). Rewrite every reference. - - If the plan is **finished** (work shipped): the plan has served its purpose. `git rm` the tracked copy + delete references that say "see plan X." Don't preserve dead plans as documentation; that turns them back into the rot the rule prevents. -3. Either way, the cleanup is its own commit / PR; don't bundle it with - the work the plan describes. - -## Per-repo extensions - -Downstream repos can add their own plan-storage rules in **their own** -CLAUDE.md (outside the fleet block). Common extensions: - -- A per-repo `.claude/plans/README.md` listing currently-active plans - with a one-line description. That README is also untracked (under - `/.claude/*`) but operators in a fresh worktree won't have it; the - list is regenerable from `ls -1 .claude/plans/`. -- Naming conventions for active vs archived plans (e.g. - `wip-<name>.md` / `done-<name>.md`). -- A repo-specific plans index that the operator maintains by hand. - -These all sit inside the same gitignored `/.claude/plans/` directory and -don't change the fleet rule. - -## How this interacts with other fleet rules - -- **`markdown-filename-guard`**: the hook accepts lowercase-hyphenated `.md` files under either `docs/` or `.claude/` (any depth). It will NOT block a `docs/plans/<name>.md` write; the guard is filename-only, not content-aware. The plan-storage convention is enforced by this rule, not by the filename guard. -- **No fleet fork**: this doc is fleet-canonical (lives under `template/docs/claude.md/fleet/`). Downstream copies are read-only. Edit here and cascade. -- **Drift watch**: if you find a downstream repo carrying its own diverged - copy of this doc, reconcile back to fleet-canonical. diff --git a/docs/claude.md/fleet/plugin-cache-patches.md b/docs/claude.md/fleet/plugin-cache-patches.md deleted file mode 100644 index 9f1f22357..000000000 --- a/docs/claude.md/fleet/plugin-cache-patches.md +++ /dev/null @@ -1,109 +0,0 @@ -# Plugin-cache patches - -Third-party Claude Code plugins (pinned in `.claude-plugin/marketplace.json`) -occasionally ship bugs we've fixed but can't land upstream synchronously. The -plugin install lives in a **cache** at -`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` that Claude Code -**regenerates from the pinned source on every (re)install** — so a hand-edit to -the cache is lost the next time `pnpm run install-claude-plugins` runs. - -The durable fix: keep the change as a checked-in patch in -`scripts/plugin-patches/`, and have `install-claude-plugins.mts` reapply it over -the freshly-installed cache as a post-reconcile pass (`reapplyPluginPatches()`). - -## Smallest patch footprint (prefer a sidecar over inlining) - -🚨 Keep the diff itself as small as possible. When a fix needs more than a few -lines of new logic, **move that logic into a standalone file** and let the diff -just `import` it + swap the call sites — rather than inlining a 30-line function -body as `+` lines. A thin diff (an import + a call-site swap) re-anchors cleanly -across upstream version bumps; a fat inlined diff breaks on the first nearby -edit and is painful to review. - -Mechanism: a patch named `<x>.patch` may ship a companion **`<x>.files/`** -directory whose tree mirrors the plugin cache root. `reapplyPluginPatches()` -copies it into the cache (overwrite) *before* applying the diff, so the thin -diff's `import` of a sidecar module resolves. Example — the codex stdin fix -ships `codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs` (the -30-line `readStdinSync` body) and the `.patch` is a 6-line diff that imports it -in three files. - -This is doable for node-smol-shaped patches (we own the consuming source) and -for plugin-cache patches (we copy the sidecar in). It does NOT apply where the -patch target can't import a sibling we control (e.g. some `pnpm patch` -scenarios that rewrite a published package's internals) — there, inline. - -## Patch format (socket-btm node-smol convention) - -A `# @key: value` provenance header above a **plain `diff -u` body** — never a -`git diff` (git injects `index <hash>` / `new file mode` markers that bare -`patch` doesn't expect). The reapply step strips everything before the first -`--- ` line and pipes the diff to `patch -p1`. Sidecar modules (the -smallest-footprint mechanism above) live in the companion `<x>.files/` dir, not -in the diff. - -``` -# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @upstream: https://github.com/openai/codex-plugin-cc -# @description: One-line summary of what the patch fixes -# -# Optional multi-line detail. Each non-blank line begins with #. -# ---- a/scripts/lib/fs.mjs -+++ b/scripts/lib/fs.mjs -@@ -32,9 +32,39 @@ - context --old -+new - context -``` - -Required header keys: `@plugin`, `@plugin-version`, `@sha`, `@description`. -`@upstream` is recommended. Paths in the diff are plugin-root-relative -(`a/scripts/…`, `b/scripts/…`) so `patch -p1` resolves them inside the cache -dir. No timestamps on the `---`/`+++` lines (`diff -u` adds them; strip with -`grep -v $'^[-+]\\{3\\}.*\\t'`). - -## Filename - -`<plugin>-<version>-<slug>.patch` — e.g. `codex-1.0.1-stdin-eagain.patch`. The -`<plugin>` + `<version>` prefix maps to the cache dir; the version is dotted -semver (`1.0.1`), the slug is freeform lowercase-kebab. `parsePatchFileName` -(in `install-claude-plugins.mts`) parses it; a name that doesn't match is -skipped with a warning. - -## Apply semantics - -`reapplyPluginPatches()` runs after the plugin reconcile: - -1. Parse the filename → `{ plugin, version }`; resolve the cache dir (skip if - the plugin isn't installed on this machine). -2. Strip the `#` header; feed the diff to `patch -p1 --forward --silent` via - stdin. -3. **Idempotency:** a forward `--dry-run` that fails while a reverse `--dry-run` - succeeds means the fix is already present → skip. A patch that applies - neither way (the plugin bumped, the patch went stale) **warns, doesn't - abort** — a stale patch must not wedge the whole reconcile. - -## Lifecycle - -- **Upstream fixes the bug** → bump the SHA pin in `marketplace.json` (+ the - README row) and **delete** the patch + its `manifest.mts` entry. The reapply - step no-ops cleanly when no patch matches an installed plugin. -- **Upstream drifts but the bug persists** → regenerate the patch against the - new pinned source via the `regenerating-plugin-patches` skill, rename to the - new version, update the manifest entry. - -## Why a separate dir (not `.claude-plugin/`, not `/patches/`) - -- `.claude-plugin/` is Claude Code's convention dir (it reads `marketplace.json` - / `plugin.json` from there). Putting our own files inside it risks a future - strict validator and conflates ownership. -- `<root>/patches/` is pnpm's convention for `pnpm patch` npm-dependency - patches (wired via `pnpm-workspace.yaml` `patchedDependencies`). A - plugin-cache patch there would imply pnpm owns it. - -`scripts/plugin-patches/` is plainly ours, next to its only consumer -(`install-claude-plugins.mts`). diff --git a/docs/claude.md/fleet/pull-request-target.md b/docs/claude.md/fleet/pull-request-target.md deleted file mode 100644 index e423fb94b..000000000 --- a/docs/claude.md/fleet/pull-request-target.md +++ /dev/null @@ -1,24 +0,0 @@ -# `pull_request_target` is privileged - -`pull_request_target` runs in the BASE repo's context with the BASE repo's secrets — that's the threat model. Two combinations are forbidden: - -1. **Checkout fork code + execute it.** `actions/checkout` of `${{ github.event.pull_request.head.* }}` followed by any step that runs the checked-out code (`pnpm i`, `npm i`, `pnpm build`, `cargo build`, `make`, `node scripts/*`, etc.) gives the fork's PR author arbitrary code execution in a privileged context. They can exfil the workflow's secrets via the runner. -2. **Even without execution, fork content can shape the workflow.** A fork's `package.json` `scripts.preinstall` or a fork-modified `.npmrc` runs during `pnpm i`. Treat all fork-supplied files as untrusted input. - -## Safer patterns - -### Split-workflow (preferred) - -- A `pull_request` workflow does the build in the fork's context (no BASE secrets). -- It uploads the result as an artifact (`actions/upload-artifact`). -- A `workflow_run` workflow (triggered by the prior workflow's completion) downloads the artifact, optionally re-signs it, and posts the PR comment with the BASE-repo token. - -Crucially: the `workflow_run` step **does not check out fork code**. It only consumes the artifact produced by the unprivileged build. - -### `types: [labeled]` gate - -If you genuinely need `pull_request_target` semantics (e.g. to access a secret-driven comment-poster), gate it on `types: [labeled]` so only a maintainer who manually labels the PR can trigger the privileged run. This shifts the threat model to maintainer review: they MUST read the diff before applying the label. - -## Enforcement - -The `.claude/hooks/pull-request-target-guard/` hook scans workflow YAML for the combo and blocks edits that introduce it. The hook is byte-identical across fleet repos; the rule is the contract, the hook is the enforcer. diff --git a/docs/claude.md/fleet/security-stack.md b/docs/claude.md/fleet/security-stack.md deleted file mode 100644 index de99daf53..000000000 --- a/docs/claude.md/fleet/security-stack.md +++ /dev/null @@ -1,124 +0,0 @@ -# Fleet security stack - -Aggregator doc: every security-relevant hook, scanner, and gate the fleet ships, in one place. Referenced from the discrete rule sections in CLAUDE.md when you need the full picture. - -The stack assumes three threat models in priority order: - -1. **Supply-chain compromise** (the Nx Console pattern: malicious npm package exfiltrates local credentials within seconds of install) -2. **Stolen credential reuse** (a token leaks via a screenshare, an exposed dotfile, a published commit; attacker uses it before rotation) -3. **Operator mistake** (accidentally pushing an unsigned commit, an `.env` with a real token, a workflow with `pull_request_target` misuse) - -Layered enforcement, with each layer catching what the previous one missed. - -## Layer 1: never let secrets touch disk - -| Surface | Hook / mechanism | What it blocks | -| -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Socket API token storage | `.claude/hooks/no-token-in-dotenv-guard/` | Write/Edit of any `.env*`/`.envrc` file containing a real token | -| Keychain read invocations | `.claude/hooks/no-blind-keychain-read-guard/` | Bash calls to `security find-*-password`, `secret-tool lookup`, `Get-StoredCredential`, `keyring get` — these surface UI prompts per call and the token is already cached in-process | -| Token detection in commits | `.git-hooks/pre-commit.mts` + `pre-push.mts` | Staged files containing AWS keys, GitHub tokens (`ghp_`/`gho_`/`ghr_`/`ghs_`/`ghu_`/`github_pat_`), Socket API tokens, or any PEM private key (RSA / EC / DSA / OPENSSH / ENCRYPTED / PGP / generic PKCS#8) | -| gh CLI token storage | `.claude/hooks/gh-token-hygiene-guard/` | Bash invocations of `gh` when the token is in the on-disk `~/.config/gh/hosts.yml` — must be `(keyring)` | - -## Layer 2: gate access to dangerous capabilities - -| Capability | Hook | Gate | -| -------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gh workflow run` / dispatch | `.claude/hooks/gh-token-hygiene-guard/` | Token must have `workflow` scope (off by default) AND a fresh `Allow workflow-scope bypass` chat phrase AND Touch ID / password auth AND unconsumed grant marker. Single-use: each dispatch consumes the grant. | -| GitHub Actions workflow_dispatch | `.claude/hooks/release-workflow-guard/` | Blocks `gh workflow run`/`dispatch` against publish/release workflows. Bypass: `--dry-run=true` (if workflow declares `dry-run:` input) OR `Allow workflow-dispatch bypass: <workflow>` typed verbatim | -| Pre-existing branch protection | `lint-github-settings.mts` | Audits the default branch's protection on GitHub for `required_signatures`, `required_pull_request_reviews` (≥1 + dismiss_stale_reviews), `allow_force_pushes=false`, `allow_deletions=false`, `enforce_admins=true` | -| Commit signing | `.git-hooks/pre-commit.mts` + `.git-hooks/pre-push.mts` | Pre-commit: `commit.gpgsign=true` + `user.signingkey` set. Pre-push: `git log --format='%G?'` excludes `N` and `B` for commits landing on `main`/`master`. | -| Hook bypass attempts | `.claude/hooks/no-revert-guard/` | Blocks `git revert`, `--no-verify`, `DISABLE_PRECOMMIT_*`, `--no-gpg-sign`, force-push — all gated by canonical `Allow X bypass` phrases | - -## Layer 3: enforce token lifetime - -| Token | Mechanism | Window | -| -------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| gh CLI token | `.claude/hooks/gh-token-hygiene-guard/` 8-hour age cap | Errors when token >8h since last `gh auth login` or `gh auth refresh`. Self-recovery: `gh auth refresh` is always allowed. | -| GitHub Actions `GITHUB_TOKEN` | GitHub-provided | 1 hour per workflow run, scope-limited by the workflow's `permissions:` block | -| Authenticated CLIs (npm, pnpm, gcloud, docker, vault, …) | `.claude/hooks/auth-rotation-reminder/` | Stop-hook periodically logs you out of stale long-lived sessions. `gh` is exempt from auto-logout (would break in-session work); its age check lives in `gh-token-hygiene-guard` instead. | - -## Layer 4: workflow + repo audit - -| Surface | Hook / scanner | When it fires | -| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GitHub Actions workflow YAML | `.claude/hooks/actionlint-on-workflow-edit/` | PostToolUse after Edit/Write to `.github/workflows/*.y*ml`. Runs `actionlint` (YAML / shell / SHA-pin) + `zizmor` (security: privilege escalation, secret leaks, untrusted-input-in-script, `pull_request_target` misuse) | -| `pull_request_target` misuse | `.claude/hooks/pull-request-target-guard/` | Blocks Edit/Write that creates a `pull_request_target` workflow checking out the fork head + executing the checked-out code in the same job | -| Workflow `uses:` SHA pinning | `.claude/hooks/workflow-uses-comment-guard/` | Every SHA-pinned `uses:` line needs a `# <tag> (YYYY-MM-DD)` comment for staleness tracking | -| Workflow heredoc bodies | `.claude/hooks/workflow-yaml-multiline-body-guard/` | Blocks `gh ... --body "..."` (multi-line markdown breaks YAML) in favor of `--body-file <path>` | -| GitHub repo settings | `scripts/lint-github-settings.mts` | Audits visibility, merge settings, branch protection, required apps. Weekly cache-gated; CI doesn't burn API quota | -| AgentShield + zizmor | `/scanning-security` skill | A-F graded report on `.claude/` config + workflow YAML. Run after touching `.claude/` or workflows, before releases | - -## Layer 5: catch the operator mistake - -| Mistake | Hook | What it catches | -| -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- | -| Pushing a real customer / company name | `.claude/hooks/private-name-guard/` | Real names in commits / PR text / release notes | -| Linear ticket refs | `.claude/hooks/private-name-guard/` | `SOC-123`, `ENG-456`, Linear URLs in code or PR text | -| External issue refs (auto-link spam) | `.claude/hooks/no-external-issue-ref-guard/` | `<owner>/<repo>#<num>` in commits or PR bodies for non-SocketDev repos | -| Empty commits | `.claude/hooks/no-empty-commit-guard/` | `git commit --allow-empty`, `cherry-pick --allow-empty` | -| `--no-verify` use | `.claude/hooks/no-revert-guard/` | Hook bypass via `--no-verify` without typed bypass phrase | -| Personal paths in code | `pre-commit.mts` / `pre-push.mts` | `/Users/<name>/`, `/home/<name>/`, `C:\Users\<NAME>\` | -| Cross-repo path imports | `.claude/hooks/cross-repo-guard/` + `scanCrossRepoPaths` | `../<fleet-repo>/` and absolute `/projects/<fleet-repo>/` references | - -## Setup helpers - -One-time helpers that configure the local machine to satisfy the layers above: - -```sh -# Master umbrella: runs every installer in sequence -node .claude/hooks/setup-security-tools/install.mts -node .claude/hooks/setup-security-tools/install.mts --rotate # rotate API token - -# Scoped leaves -node .claude/hooks/setup-firewall/install.mts # sfw (Socket Firewall) -node .claude/hooks/setup-claude-scanners/install.mts # AgentShield + zizmor -node .claude/hooks/setup-basics-tools/install.mts # TruffleHog + Trivy + OpenGrep + uv -node .claude/hooks/setup-misc-tools/install.mts # cdxgen + synp + janus -node .claude/hooks/setup-signing/install.mts # commit signing (1Password SSH → ~/.ssh → GPG) -``` - -## Post-hoc forensics - -```sh -node scripts/audit-transcript.mts --recent # scan most recent session -node scripts/audit-transcript.mts <path> # scan a specific transcript -node scripts/audit-transcript.mts --json … # JSON output for tooling -``` - -Read-only diagnostic. Reads the Claude Code transcript JSONL and flags tool-use patterns that touched security-sensitive surfaces: `gh auth` flows, keychain CLI reads, `dscl -authonly` calls, `sudo` invocations, private-key file access, workflow YAML edits, git pushes. Never blocks; surfaces what an agent session did with privileged tooling. - -Useful after a session that touched the security stack, before declaring it "done." The output reads like a security audit log: critical / warn / info tiers, grouped by category, with line-numbered evidence pointing back into the transcript. - -## Detailed specs - -Each layer has a dedicated long-form doc: - -- [`token-hygiene.md`](./token-hygiene.md): Socket API token storage, env-var aliases, keychain mechanics -- [`gh-token-hygiene.md`](./gh-token-hygiene.md): gh CLI specific (Nx-incident response, keyring, workflow scope, 8h cap, Touch ID setup) -- [`commit-signing.md`](./commit-signing.md): three-layer signing enforcement, setup helper, when to use bypass envs -- [`bypass-phrases.md`](./bypass-phrases.md): canonical phrase forms, scope per phrase -- [`public-surface-hygiene.md`](./public-surface-hygiene.md): never-write rules for customer / company / Linear references -- [`pull-request-target.md`](./pull-request-target.md): privileged-context threat model + safer patterns - -## Bypass discipline - -Every bypass mechanism is one-shot. No env var in `~/.zshrc`. No persistent setting. The reason: bypasses exist for exceptional scenarios; if you find yourself reaching for the same bypass repeatedly, the underlying rule is the wrong one and should be fixed at the source. - -When a hook blocks you, the right responses in order of preference: - -1. **Fix the underlying issue.** Sign the commit. Use a `--body-file`. Drop the personal path. Use the canonical fleet helper. -2. **Add a per-line marker** if the rule has one (e.g. `// socket-hook: allow console` for the console-prefer-logger rule). Documents the exemption inline. -3. **Type the canonical bypass phrase** if the operation is exceptional. The phrase is one-shot: typing it again authorizes a second action. -4. **Last resort: edit the hook** to change the rule. If the rule blocks you twice for the same kind of operation, it's the wrong rule, not the wrong commit. Land the change at the source so every fleet repo benefits. - -Persistent env vars (`SOCKET_X_DISABLED=1` in shell rc) defeat the discipline the layered enforcement is meant to provide. The hook authors deliberately removed escape hatches in 2026-05 (`SOCKET_GH_HYGIENE_DISABLED`, `SOCKET_GH_TOKEN_AGE_DISABLED`, and `SOCKET_GH_HYGIENE_TEST_AUTH` were all deleted after the Nx Console breach made the "failsafe-deny on supply-chain credentials" stance non-negotiable). - -## Known gaps (acknowledged, not yet closed) - -The red-hat review (2026-05-26) surfaced two gaps that require off-repo work to close. Documented here so the threat model stays honest: - -1. **Transcript JSONL is unauthenticated.** `bypassPhrasePresent()` trusts the JSONL at `~/.claude/projects/<id>/<session>.jsonl`. A malicious process running as the user can append a forged user turn containing every `Allow X bypass` phrase, and every guard in the fleet will believe it. **Mitigation when ready:** the Claude Code harness must HMAC user turns with a per-session secret; hooks would verify the HMAC and reject unsigned turns. Until then, the bypass model is **trust-on-first-use** at the OS level — any process running as the user can defeat every bypass-gated guard. The defense in depth is the OS-level Touch ID prompt on the gh-token-hygiene-guard workflow scope path, which is the only step that requires physical presence. - -2. **Shell-command detection: shell-quote parser, not a full evaluator (RESOLVED for the common cases).** `gh`/`git` detection in the Bash-allowlist guards (`gh-token-hygiene-guard`, `no-non-fleet-push-guard`) now goes through the shared shell parser (`.claude/hooks/_shared/shell-command.mts`, wrapping `shell-quote`), not regex. This sees through `&&`/`|`/`;` chains, `$(…)` substitution, and quoting, and it killed the regex false positives (a `grep "gh workflow"` is no longer treated as a `gh` invocation). The parser tokenizes rather than evaluates, so a binary fully sourced from a variable (`MYGH=gh; $MYGH …`) still can't be resolved to `gh` — but the parser FLAGS it as opaque (`hasOpaqueInvocation`), and an alias / wrapper script remains out of scope for any static parser. - -Gap 1 is upstream of the per-hook implementation (Claude Code runtime change); gap 2 is now closed for the practical cases via the shared parser. The residual variable/alias indirection is a fundamental static-analysis limit, mitigated by the bypass-phrase + OS-presence layers above. diff --git a/docs/claude.md/fleet/socket-bypass-markers.md b/docs/claude.md/fleet/socket-bypass-markers.md deleted file mode 100644 index fa6149c2c..000000000 --- a/docs/claude.md/fleet/socket-bypass-markers.md +++ /dev/null @@ -1,43 +0,0 @@ -# socket-bypass: in-file marker registry - -Some fleet audits + custom lints recognize an explicit opt-out comment that lives inside the affected file. This is distinct from user-typed bypass _phrases_ (see [bypass-phrases.md](./bypass-phrases.md)), which gate one-time tool invocations from the active conversation. - -The marker shape is: - -``` -# socket-bypass: <name> -- <reason> -``` - -or in TS/JS source: - -```ts -// socket-bypass: <name> -- <reason> -``` - -Conventions: - -- `<name>` is the rule-specific identifier (kebab-case). -- The `--` separator + free-text `<reason>` is encouraged but not parsed. Git blame is the audit trail. -- The marker is matched **case-sensitive**, **substring-based on a line**. Most audits expect the marker as a header comment (top-of-file), but rule-specific positioning is documented below per name. - -## Registered marker names - -| Name | Enforcer | Effect | -| ----------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `workflow-shadow` | `scripts/lint-github-settings.mts` | Suppress the "Local workflow shadows a shared one" finding for the file. Document `<reason>` (e.g. "CLI-specific multi-package publish; does not fit generic shared shape"). Marker must appear as a `#`-comment line in the workflow YAML body — typically near the top, alongside the `name:` line. | - -## When to add a new marker - -When a new audit / custom lint needs an opt-out mechanism: - -1. Pick a `<name>` (kebab-case, rule-scoped — e.g. `provenance-no-attestation`, not just `attestation`). -2. Implement the marker check in the audit (regex pattern: `^[ \t]*[#/]+\s*socket-bypass:\s*<name>\b`). -3. Add a row to the table above with the enforcer + effect. - -## Why a separate registry from `bypass-phrases.md` - -`bypass-phrases.md` documents user-typed phrases (`Allow revert bypass`) that the _active conversation_ must contain for a hook to let a one-time tool-call proceed. Those phrases gate behavior at _invocation time_. - -`socket-bypass:` markers gate behavior at _audit time_ and live inline with the file they exempt. The file's git blame is the accountability trail; the maintainer is committing to the exemption. - -Different lifetimes, different audiences, different review patterns. Two registries. diff --git a/docs/claude.md/fleet/sorting.md b/docs/claude.md/fleet/sorting.md deleted file mode 100644 index 3cd26812c..000000000 --- a/docs/claude.md/fleet/sorting.md +++ /dev/null @@ -1,20 +0,0 @@ -# Sorting reference - -Sort lists alphanumerically (literal byte order, ASCII before letters). - -## Where to sort - -- **Config lists**: `permissions.allow` / `permissions.deny` in `.claude/settings.json`, `external-tools.json` checksum keys, allowlists in workflow YAML. -- **Object key entries**: keys in plain JSON config + return-shape literals + internal-state objects. (Exception: `__proto__: null` always comes first, ahead of any data keys.) -- **Import specifiers**: sort named imports inside a single statement: `import { encrypt, randomDataKey, wrapKey } from './crypto.mts'`. `import type` follows the same rule. Statement _order_ (`node:` → external → local → types) is separate from specifier order _within_ a statement. -- **Method / function placement**: within a module, sort top-level functions alphabetically. Convention: private functions (lowercase / un-exported) sort first, exported functions second. The `export` keyword is the divider. -- **Array literals**: when the array is a config list, allowlist, or set-like collection. Position-bearing arrays (e.g. `argv`, anything where index matters semantically) keep their meaningful order. -- **`Set` constructor arguments**: `new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions. -- **Regex alternation groups**: `(foo|bar|baz)` reads as `(bar|baz|foo)`. Capturing, non-capturing, and named-capture groups all follow the rule. Auto-fixable when every alternative is a simple literal. The exception is order-bearing alternations where the regex engine MUST try one alternative before another (rare; the canonical example is markup parsers where `<!--|-->` would silently mismatch if reordered). Append `// socket-hook: allow regex-alternation-order` on those lines. -- **String-equality disjunctions**: `x === 'a' || x === 'b' || x === 'c'` reads with the comparand strings in alpha order. The De Morgan dual `x !== 'a' && x !== 'b'` (negative-membership check) follows the same rule. The `||` chain short-circuits regardless of operand order; sorting reduces diff churn when adding new comparands and makes "is X in this set?" checks visually consistent. Auto-fixable when every clause has the same left operand and uses string-literal comparands. Mixed shape (different left, different operator, non-string right) is skipped. Those are usually ordering-sensitive predicates and the autofix would change semantics. -- **Boolean identifier chains**: `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when (1) every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations; those have side-effect or short-circuit semantics where order can be observable) AND (2) the chain has **3 or more operands**. Two-operand chains like `useHttp && oauthEnabled` are guard patterns where order carries narrative ("in HTTP mode, did OAuth get enabled?") that alpha-sort would destroy; only length-3+ chains are unambiguously flag lists. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. -- **TypeScript union of string literals**: `type Source = 'download' | 'path' | 'vfs'` (not `'vfs' | 'path' | 'download'`). Members are interchangeable at the type level; alpha order makes "which values can this take?" answerable without scanning. Applies to type aliases, inline parameter unions, and template-literal type alternatives. Position-bearing unions (rare; e.g. a discriminator where order encodes priority) keep their meaningful order; append `// socket-hook: allow union-order` on those lines. - -## Default - -When in doubt, sort. The cost of a sorted list that didn't need to be is approximately zero; the cost of an unsorted list that did need to be is a merge conflict. diff --git a/docs/claude.md/fleet/token-hygiene.md b/docs/claude.md/fleet/token-hygiene.md deleted file mode 100644 index 381b1f74f..000000000 --- a/docs/claude.md/fleet/token-hygiene.md +++ /dev/null @@ -1,55 +0,0 @@ -# Token hygiene - -The CLAUDE.md `### Token hygiene` section is the headline rule plus the canonical env-var name. This file is the full spec and the surrounding placeholder / cross-repo-path conventions. - -## Headline - -Never emit the raw value of any secret to tool output, commits, comments, or replies. The `.claude/hooks/token-guard/` `PreToolUse` hook blocks the deterministic patterns (literal token shapes, env dumps, `.env*` reads, unfiltered `curl -H "Authorization:"`, sensitive-name commands without redaction). When the hook blocks a command, rewrite. Don't bypass. - -Behavior the hook can't catch: redact `token` / `jwt` / `access_token` / `refresh_token` / `api_key` / `secret` / `password` / `authorization` fields when citing API responses. Show key _names_ only when displaying `.env.local`. If a user pastes a secret, treat it as compromised and ask them to rotate. - -Full hook spec in [`.claude/hooks/token-guard/README.md`](../../.claude/hooks/token-guard/README.md). - -## Where tokens live - -Tokens belong in env vars (CI) or the OS keychain (dev local). Nowhere else. Never in `.env` / `.env.local` / `.envrc` / `~/.sfw.config` / `~/.config/socket/*` / any dotfile. Dotfiles leak via accidental commits, file-indexers, backup clients, shell-history dumps. Enforced by `.claude/hooks/no-token-in-dotenv-guard/`. - -## Initial setup + rotation - -- **Initial setup:** `node .claude/hooks/setup-security-tools/install.mts` (prompts + persists via macOS Keychain / Linux libsecret / Windows CredentialManager). -- **Rotation:** `node .claude/hooks/setup-security-tools/install.mts --rotate`. TTY-muted prompt, overwrites the keychain entry unconditionally, ignores stale dotfile / env-var lookup. This is the ONLY correct rotator. Suggesting any other path (`socket login`, hand-editing `~/.sfw.config`, `export SOCKET_API_TOKEN=…` in a shell rc) is a token-hygiene violation. - -The Stop-hook flags broken sfw shims, free-vs-enterprise edition drift, and 401-rejection patterns from the last assistant turn (enforced by `.claude/hooks/setup-security-tools/`). - -### Scoped install entrypoints - -Four entrypoints share the umbrella installer library for operators who want partial installs: - -- `.claude/hooks/setup-firewall/`: sfw only, `--rotate` honored. -- `.claude/hooks/setup-claude-scanners/`: AgentShield + zizmor. -- `.claude/hooks/setup-basics-tools/`: TruffleHog + Trivy + OpenGrep + uv. -- `.claude/hooks/setup-misc-tools/`: cdxgen + synp + janus. - -## Never call platform keychain CLIs from Bash - -`security find-generic-password` (macOS), `secret-tool lookup` (Linux), `Get-StoredCredential` (Windows PowerShell), `keyring get` (cross-platform) all surface a UI auth prompt on the user's screen. That prompt fires _per call_, so a hook chain that reads the keychain three times costs three prompts. The token is already cached in process memory after the first resolution (see [`api-token.mts`](../../.claude/hooks/setup-security-tools/lib/api-token.mts) module-scope cache). Read it from `findApiToken()` or `process.env.SOCKET_API_KEY` / `SOCKET_API_TOKEN` instead. - -Writes (`security add-generic-password`, `secret-tool store`, `New-StoredCredential`) and deletes are allowed. They happen during operator-driven setup / rotation, never on hot paths. Bypass: `Allow blind-keychain-read bypass` (enforced by `.claude/hooks/no-blind-keychain-read-guard/`). - -## Personal-path placeholders - -When a doc / test / comment needs to show an example user-home path, use the canonical platform-specific placeholder so the personal-paths scanner recognizes it as documentation: `/Users/<user>/...` (macOS), `/home/<user>/...` (Linux), `C:\Users\<USERNAME>\...` (Windows). Don't drift to `<name>` / `<me>` / `<USER>` / `<u>` etc. The scanner accepts anything in `<...>`, but a fleet-wide audit relies on the canonical strings being grep-able. Env vars (`$HOME`, `${USER}`, `%USERNAME%`) also satisfy the scanner. - -## Socket API token env var - -Two layers, on purpose: - -1. **Fleet-canonical name (forward-looking): `SOCKET_API_TOKEN`.** This is what new `.env.example` files, fleet docs, workflow inputs, action `env:` blocks, and CI secrets target. `SOCKET_SECURITY_API_TOKEN` and `SOCKET_SECURITY_API_KEY` remain accepted aliases for one cycle (deprecation grace period). - -2. **Local-dev primary slot: `SOCKET_API_KEY`.** Every Socket tool (CLI, SDK, sfw, fleet scripts) reads `SOCKET_API_KEY` without a fallback chain, so picking it as the one stored / exported slot means a single read covers the whole surface. The setup-security-tools install hook stores the token under keychain account `SOCKET_API_KEY` and exports `SOCKET_API_KEY` from the `~/.zshenv` shell-rc-bridge block. Bootstrap hooks read both: `SOCKET_API_KEY` first, `SOCKET_API_TOKEN` as a forward-canonical fallback. A consumer setting either works. - -Don't confuse any of these with `SOCKET_CLI_API_TOKEN` (socket-cli's separate setting). - -## Cross-repo path references - -`../<fleet-repo>/...` (relative escape) and `/<abs-prefix>/projects/<fleet-repo>/...` (absolute sibling-clone) are both forbidden. Either form hardcodes a clone-layout assumption that breaks in CI / fresh clones / non-standard checkouts. Import via the published npm package (`@socketsecurity/lib/<subpath>`, `@socketsecurity/registry/<subpath>`). Every fleet repo is a real workspace dep. The `cross-repo-guard` PreToolUse hook blocks both forms at edit time; the git-side `scanCrossRepoPaths` gate catches commits/pushes too. diff --git a/docs/claude.md/fleet/tooling.md b/docs/claude.md/fleet/tooling.md deleted file mode 100644 index 0d529f0ad..000000000 --- a/docs/claude.md/fleet/tooling.md +++ /dev/null @@ -1,120 +0,0 @@ -# Tooling - -The CLAUDE.md `### Tooling` section is the short list. This file is the full set of rules and their rationale. - -## Package manager - -`pnpm`. Run scripts via `pnpm run foo --flag`, never `foo:bar`. After `package.json` edits, `pnpm install`. - -## No `npx` / `dlx` - -NEVER use `npx`, `pnpm dlx`, or `yarn dlx`. Use `pnpm exec <package>` or `pnpm run <script>` # socket-hook: allow npx - -## Docs lead with pnpm - -User-facing install commands in fenced code blocks must show the pnpm form first (`pnpm install <pkg>`, `pnpm add <pkg>`). npm / yarn fallbacks are fine but come after, or in a separate block introduced as a fallback. The pre-commit `scanDocsPnpmFirst` scanner emits a warning (not a hard fail) for `.md` / `.mdx` blocks that lead with npm or yarn without a pnpm leader. Suppress per-block with `socket-hook: allow pnpm-first` (HTML comment above the fence or any line inside it). - -## New dependencies + soak - -Every new dep added to `package.json` runs a Socket-score check at edit time. Low-scoring deps block (enforced by `.claude/hooks/check-new-deps/`). The 7-day `minimumReleaseAge` soak is malware protection. Never add to `pnpm-workspace.yaml` `minimumReleaseAge.exclude[]` (bypass `Allow minimumReleaseAge bypass` for emergency CVE patches; enforced by `.claude/hooks/minimum-release-age-guard/`). - -Every per-package soak-bypass entry (the `'pkg@1.2.3'` exact-pin form) MUST carry a `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation as the LAST comment line above the bullet. `published` is the version's npm publish date; `removable` is `published + 7d` so a periodic cleanup can drop entries that no longer need the bypass (enforced by `.claude/hooks/soak-exclude-date-annotation-guard/` at edit time + `scripts/check-soak-exclude-dates.mts` at commit time). - -Vitest `include` globs must not match `node:test` files. Mismatched runners produce confusing "no test suite found" errors (enforced by `.claude/hooks/vitest-include-vs-node-test-guard/`). - -## Bundler - -`rolldown`, NOT `esbuild`. The fleet standardizes on rolldown for direct bundling (see `template/.config/rolldown/`). Transitive esbuild deps (e.g. via vitest) are unavoidable today. The rule is no _new direct_ esbuild use anywhere in the fleet. - -## Compile-time defines (`INLINED_*`) - -Build-inlined constants use the `process.env.INLINED_*` naming convention (mirrors socket-cli: `INLINED_VERSION`, `INLINED_NAME`, …). The `INLINED_` prefix flags at a glance that a value is substituted at build time, not read from the real environment at runtime. - -Substitution is done by `template/.config/rolldown/define-guarded.mts` (`defineGuardedPlugin`), an esbuild-`define`-equivalent that only rewrites _read_ positions — it never touches assignment targets, `delete` / `++` / `--` operands, or dynamic `process.env[expr]` access (so `delete process.env.DEBUG` stays valid, unlike oxc's built-in `define`). - -- **Source must use quoted bracket access**: `process.env['INLINED_EXTENSION_VERSION']`. `process.env` is an index-signature type, so TypeScript (TS4111) forbids dot access. The plugin normalizes dot and quoted-bracket access to the same dotted define key, so one `'process.env.INLINED_X'` key matches `process.env.INLINED_X`, `process.env['INLINED_X']`, and `process.env["INLINED_X"]`. -- **Define key is the dotted form**: `defineGuardedPlugin({ 'process.env.INLINED_X': JSON.stringify(value) })`. Values are already-quoted source text (same contract as esbuild / oxc `define`). -- **`magic-string` is the fallback**: `defineGuarded` does its surgical rewrites with MagicString. When the build opts into rolldown's `experimental.nativeMagicString` (set `experimental: { nativeMagicString: true }` + `output.sourcemap: true` in the rolldown config), the `transform` hook receives a Rust-backed native MagicString on `meta.magicString` — same API, no JS `toString()`/`generateMap()` round-trip — and the plugin uses it. Without the flag, `meta.magicString` is absent and it constructs a JS `magic-string` instance. So `magic-string` stays catalog-pinned (`pnpm-workspace.yaml`) and a member adopting the plugin keeps `"magic-string": "catalog:"` in devDependencies as the fallback path. - -## Backward compatibility - -FORBIDDEN to maintain. Remove when encountered. - -## `packageManager` field - -Bare `pnpm@<version>` is correct for pnpm 11+. pnpm 11 stores the integrity hash in `pnpm-lock.yaml` (separate YAML document) instead of inlining it in `packageManager`. On install pnpm rewrites the field to its bare form and migrates legacy inline hashes automatically. Don't fight the strip. Older repos may still ship `pnpm@<version>+sha512.<hex>`. Leave it; pnpm migrates on first install. The lockfile is the integrity source of truth. - -## Bumping a versioned tool fleet-wide (pnpm, zizmor, sfw) - -🚨 **Single entry point: `socket-wheelhouse/scripts/fleet/cascade-fleet.mts`.** Run from the wheelhouse repo: - -```bash -node socket-wheelhouse/scripts/fleet/cascade-fleet.mts \ - --pnpm 11.3.0 \ - [--skip-ci-wait] \ - [--dry-run] -``` - -This is a four-stage orchestrator. Don't reach for any of the lower-level scripts directly unless one of the stages bailed and you're recovering: - -| Stage | Does | Driven by | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| A | Bumps `socket-registry/external-tools.json`: downloads every platform binary from upstream, recomputes sha256 ourselves (integrity model is binary-download + own-checksum, not trust in upstream-published values), writes the file. Commits to registry. | `tools/pnpm.mts#applyToRegistry` (+ `zizmor.mts`, `sfw.mts`) | -| B | Delegates to `socket-registry/scripts/cascade-internal.mts`: recursively bumps every SHA pin in registry's own workflows (`setup-and-install` → `setup` → `checkout`), converging to a fixed point. Commits to registry. | `pipeline.mts#stageB` | -| C | Pushes registry main; polls GitHub Actions for the cascade SHA's CI to land green. Aborts the whole cascade if registry CI fails. Fleet repos must not pin to a broken registry. Skipped via `--skip-ci-wait`. | `pipeline.mts#stageC` | -| D | For every primary fleet checkout: runs `cleanup-stranded.mts --against <stageBSha>` (no-layering rule discards prior unpushed cascade commits), rewrites every `setup-and-install@<old-sha>` reference to the new registry SHA via diff-based pin matching, optionally runs the tool's per-fleet step (pnpm bumps `packageManager` + `engines.pnpm`), runs `pnpm run format` to fold pre-existing drift, commits + pushes. | `pipeline.mts#stageD` | - -### Soak gate - -Stage A honors the 7-day `minimumReleaseAge` cooldown via `--soak-days <n>` (default 7). Pulling a same-day release requires explicit bypass. See `bypass-phrases.md` row `Allow minimumReleaseAge bypass`. - -### Recovery from an interrupted cascade - -If Stage A+B+C landed (registry has a new tip) but Stage D didn't run, pass `--force-fanout` to skip Stages A+B+C and use the current registry HEAD as the propagation SHA. This is the only sanctioned way to "resume" a cascade. Manually invoking `cascade-internal.mts` then `cascade-fleet.mts` without the resume flag would re-run Stages A+B+C and produce a no-op commit / extra runner minutes. - -### What this does NOT do - -- It does NOT bump `socket-wheelhouse/external-tools.json` (the wheelhouse's own at-repo-root copy, consumed by `scripts/install-sfw.mts`). The live source of truth for cascade purposes is `socket-registry/external-tools.json`. The wheelhouse file uses a different schema (tools nested under `.tools.<name>` with `sha256` field; registry uses top-level keys with `integrity` field) and a different consumer (the local SFW installer + zizmor setup). When SFW or zizmor bumps, the wheelhouse file's checksums go stale. Today refreshing them is manual (run `node scripts/update-external-tools.mts` from the wheelhouse repo). Wiring this into the cascade orchestrator is a known gap. For now, treat wheelhouse's external-tools.json as a "sibling source of truth" that needs its own update step after a tool bump. -- It does NOT bump `.node-version`. Node bumps follow a different cadence (the Node ecosystem doesn't ship the same per-platform binary model; `.node-version` is just a string). - -## Monorepo internal `engines.node` - -Only the workspace root needs `engines.node`. Private (`"private": true`) sub-packages in `packages/*` don't need their own `engines.node` field. The field is dead, drift-prone, and removing it is the cleaner play. Public-published sub-packages (the npm-published ones with no `"private": true`) keep their `engines.node` because external consumers see it. - -## Config files in `.config/` - -Place tool / test / build configs in `.config/`: `taze.config.mts`, `vitest.config.mts`, `tsconfig.base.json` (the abstract compiler-options layer, fleet-canonical, byte-identical across the fleet), `esbuild.config.mts`. New abstract configs go in `.config/` by default. - -Repo root keeps only what _must_ be there: package manifests + lockfile (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`), the linter / formatter dotfiles whose tools require root placement (`.oxlintrc.json`, `.oxfmtrc.json`, `.npmrc`, `.gitignore`, `.node-version`), and every **concrete** tsconfig (`tsconfig.json`, `tsconfig.check.json`, `tsconfig.dts.json`, `tsconfig.test.json`, etc.; anything with `include`/`exclude`/`files`). Concrete tsconfigs live at the package root so tsc + IDE language-servers discover them natively at cwd. Burying them in `.config/` breaks the lookup. In monorepos the concrete `tsconfig.json` lives at each `packages/<pkg>/`. Concrete configs `extend` `./.config/tsconfig.base.json` (single-repo at root) or `../../.config/tsconfig.base.json` (monorepo per-package). - -## Runners are `.mts`, not `.sh` - -Every executable script (skill runner, hook handler, fleet automation) is TypeScript via `node <file>.mts`. Bash works on macOS/Linux but breaks on Windows. `bash` isn't on Windows PATH by default and `if [ ... ]` / `${VAR:-default}` aren't portable. The fleet runs on developer machines (mixed macOS / Linux / Windows / WSL) and CI (Linux), so cross-platform is a hard requirement. Use `@socketsecurity/lib/spawn` (`spawn`, `isSpawnError`) instead of `child_process`. It ships consistent error shapes (`SpawnError`), `stdioString: true` for buffered stdout, and integrates with the rest of the lib. Reach for `_shared/scripts/*.mts` for cross-skill helpers (default-branch resolution, report formatting); reach for `<skill>/run.mts` for skill-specific implementation. Reserve `.sh` for tiny one-shot snippets that have no Windows audience (e.g., a `bin/` wrapper). The `lib/` vs `scripts/` distinction matches `@socketsecurity/lib` (public, importable surface) vs per-package `scripts/` (private, internal automation). Skill helpers are internal, hence `scripts/`. - -## Soak time - -(pnpm-workspace.yaml `minimumReleaseAge`, default 7 days). Never add packages to `minimumReleaseAgeExclude` in CI. Locally, ASK before adding (security control). - -## Upstream submodules: always shallow - -Every entry in `.gitmodules` MUST set `shallow = true`. Every `git submodule update --init` call (postinstall.mts, CI, manual) MUST pass `--depth 1 --single-branch`. Upstream repos like yarnpkg/berry, oven-sh/bun, rust-lang/cargo are multi-GB with full history. We only ever need the pinned SHA's tree. A non-shallow init can take 30+ minutes and waste GB of disk on every fresh clone. There is no scenario where the fleet needs upstream submodule history. - -## `npm-run-all2` + `node --run` opt-in - -The fleet pins `npm-run-all2: 9.0.0` in the wheelhouse catalog. Every repo that depends on it MUST also declare the top-level `"npm-run-all2": { "nodeRun": true }` key in its own `package.json`. That key tells npm-run-all2 9.x to execute each script via `node --run` instead of the package manager CLI. `run-s build:*` and `run-p test:*` chains skip the per-script pnpm startup cost, which is non-trivial for N-script fan-outs. Inherited limitations from `node --run` (no `pre`/`post` lifecycle hooks; no `npm_*` env injection: `NODE_RUN_SCRIPT_NAME` + `NODE_RUN_PACKAGE_JSON_PATH` replace them; `node_modules/.bin` still on PATH) are acceptable for the fleet because none of our canonical scripts rely on those features. Enforced by `scripts/sync-scaffolding/checks/package-npm-run-all2-noderun.mts`: `npm_run_all2_node_run_missing` findings auto-fix. - -## Backward compatibility - -FORBIDDEN to maintain. Remove when encountered. - -## `-stable` self-import in tooling - -A fleet repo that publishes `@socketsecurity/<X>` resolves the bare `@socketsecurity/<X>` specifier to its OWN local `src/` (the pnpm workspace link), which is work-in-progress and may be mid-edit or broken. Build scripts and git-hooks must run against a known-good PUBLISHED copy, so the fleet pins a `@socketsecurity/<X>-stable` catalog alias (`npm:@socketsecurity/<X>@<last-published>`). Tooling imports the `-stable` alias; only the package's own source consumers use the bare name. - -Scope: files under `scripts/**` or `.claude/hooks/**` (test files exempt). The owned package name is read from the nearest ancestor `package.json` `name`. Only the repo's OWN package is flagged — e.g. in socket-lib, `@socketsecurity/lib/...` must become `@socketsecurity/lib-stable/...`, but `@socketsecurity/registry/...` is left alone (socket-lib doesn't own registry). - -Bump the `-stable` alias in lockstep with the plain catalog pin on every release — they point at the same package, one tracking workspace/source the other the published snapshot. - -**Why:** Past incident — socket-lib's git-hooks imported `@socketsecurity/lib/logger/default` (bare). In socket-lib that resolves to local `src/`; during a version straddle the `logger/default` subpath didn't exist in the working tree yet, so every commit threw `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `-stable` alias would have resolved to the published package that already had the subpath. - -Enforced by the fixable `socket/prefer-stable-self-import` oxlint rule (rewrites the package segment, preserving the subpath). The deterministic published-dependency surface for scripted/AI-driven tooling follows [Claude prompting best practices](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices) — generated edits build against a stable contract, not a moving local-src target. diff --git a/docs/claude.md/fleet/untracked-by-default.md b/docs/claude.md/fleet/untracked-by-default.md deleted file mode 100644 index 02a9701a5..000000000 --- a/docs/claude.md/fleet/untracked-by-default.md +++ /dev/null @@ -1,43 +0,0 @@ -# Untracked-by-default for vendored / build-copied trees - -Referenced from CLAUDE.md → _Untracked-by-default for vendored / build-copied trees_. - -When an untracked directory appears under a path that looks like vendored upstream source (`additions/source-patched/`, `vendor/`, `third_party/`, `external/`, `upstream/`, `deps/<libname>/`, `pkg-node/`, anything with `-bundled`/`-vendored` in the name), assume **untracked-by-default**. - -## Three commands before staging - -1. **`git status --ignored`**: default `git status` hides ignore matches; only this reveals them. If the path shows under _Ignored files_, stop. -2. **`cat .gitignore` + the package-local `.gitignore`**: read both. Look for directory excludes (`deps/foo/`) AND `!file.ext` allowlist re-includes inside ignored dirs. The allowlists are the only files in those trees that belong to us. -3. **`grep -rln "<dirname>" scripts/ packages/*/scripts/`**: find who creates the directory. If a build script copies it in (e.g. `prepare-external-sources.mts`), the contents are build output, not tracked input. The directory name being something like `source-patched` is itself a tell. - -## The `*` + `!file` allowlist pattern - -When `.gitignore` has the shape: - -``` -deps/<libname>/* -!deps/<libname>/<file> -``` - -…the single allowlisted file is **our custom hand-written glue** that the build script must not clobber. - -**Worked example**: `packages/node-smol-builder/additions/source-patched/deps/libdeflate/`: - -``` -packages/node-smol-builder/additions/source-patched/deps/libdeflate/* -!packages/node-smol-builder/additions/source-patched/deps/libdeflate/libdeflate.gyp -``` - -Upstream `libdeflate` ships only `CMakeLists.txt`; the Node build pipeline needs `gyp`; we hand-wrote `libdeflate.gyp` and tracked it so the build-time copy-in of upstream source doesn't overwrite it. The allowlist within an ignored dir is a signal that the dir is repeatedly overwritten by a build step, and the allowlisted file is the surface we maintain. - -## Language hygiene - -Never use language like "must be" / "definitely is" / "presumably" / "looks like" when handling someone else's tree. Those words are the signature of guessing. When you find yourself reaching for them, stop and run the command that turns the guess into a fact. - -## Volume gate - -For 100+ file or multi-MB untracked drops, ask the user before committing even under a blanket "commit everything" directive. That shape of drop is rarely the intended unit of work. - -## Why this rule exists - -A misread of an `additions/source-patched/deps/` directory led to a 13MB / 406-file commit of upstream LiteSpeed QUIC source (ls-qpack + lsquic) that was meant to be gitignored and re-copied at build time. The missed clue: a tracked sibling (libdeflate) had only ONE file actually tracked (the custom `.gyp`), not the whole tree. The single-file allowlist is the architecture, not a wholesale tracked-vendoring pattern. diff --git a/docs/claude.md/fleet/version-bumps.md b/docs/claude.md/fleet/version-bumps.md deleted file mode 100644 index 961aec2c9..000000000 --- a/docs/claude.md/fleet/version-bumps.md +++ /dev/null @@ -1,96 +0,0 @@ -# Version bumps - -Companion to the `### Version bumps` rule in `template/CLAUDE.md`. The inline section gives the headline. This file is the ordered sequence, the CHANGELOG filter, and the rationale. - -## The sequence (order matters) - -When the user asks for a version bump (`bump to vX.Y.Z`, `tag X.Y.Z`, -`release X`, etc.), follow this exactly. Skipping or reordering produces -broken releases. - -### 1. Pre-bump prep wave - -Each command must finish clean before the next runs: - -```bash -pnpm run update # dependency drift -pnpm i # lockfile alignment -pnpm run fix --all # formatting + autofix-able lint -pnpm run check --all # type + lint + path gates -``` - -If any step surfaces failures, fix them before continuing. Don't bump -a broken tree. - -### 2. CHANGELOG entry: public-facing only - -The new `## [X.Y.Z]` block describes what a downstream consumer needs -to know to upgrade. - -**Include:** - -- New exports -- Removed exports -- Renamed exports -- Signature changes -- Behavioral changes -- Perf characteristics they will measure -- Migration recipes - -**Exclude:** - -- Internal refactors -- File moves -- Test reorg -- Primordials cleanup -- Lint passes -- `chore(wheelhouse)` cascades -- Build-script tweaks - -Use [Keep-a-Changelog](https://keepachangelog.com/) sections (Added / -Changed / Removed / Renamed / Fixed / Performance / Migration). - -Source the raw list with `git log <prev-tag>..HEAD --pretty="%s"` and -filter to consumer-visible commits only. - -### 3. The bump commit is the LAST commit on the release - -If a session has other unrelated work to commit, those land first; the -`chore: bump version to X.Y.Z` commit (carrying both `package.json` and -`CHANGELOG.md`) is the tip of the branch when tagging. - -If a version-bump commit already exists earlier in history, rebase it -forward so it ends up at the tip. - -### 4. Tag at the end - -`git tag vX.Y.Z` at the bump commit, then push the tag. The -`version-bump-order-guard` hook enforces this ordering at commit time. - -### 5. Do NOT dispatch the publish workflow - -Per the [Public-surface hygiene](#public-surface-hygiene) rule (in -CLAUDE.md), releases are user-triggered. Stop after the tag push; -the user runs the publish workflow manually. - -## Why this order - -- **Bisecting from `main` past the tag must not land on a - temporarily-broken state.** If the bump commit is the tip, - `git bisect` between any prior commit and the tag passes through - only known-good states. -- **`git describe` is cleaner when the bump is the tip.** `vX.Y.Z` - matches `git describe --tags --exact-match HEAD` exactly at release - time; downstream tooling that uses `git describe` for version - detection sees clean output. -- **The pre-bump prep wave catches drift consumers would hit on first install.** Dependency drift, formatting drift, type drift; the fleet check passes on your branch but breaks on a clean clone if these aren't run before tagging. -- **The public-facing-only filter is the difference between a - changelog people read and a changelog people skip.** A 200-line - block of `chore(wheelhouse)` entries trains downstream consumers to ignore - CHANGELOG.md entirely. - -## See also - -- `.claude/hooks/version-bump-order-guard/`: enforces the bump-at-tip + tag-after-bump ordering. -- `.claude/hooks/release-workflow-guard/`: blocks `gh workflow run` dispatches that aren't dry-run. -- [`immutable-releases.md`](immutable-releases.md): every GitHub Release that lands as a result of this sequence ships immutable (Sigstore release attestation, asset lock, tag protection). The release workflow MUST use the 3-step draft → upload → publish pattern; single-call `gh release create <tag> <files>` is forbidden. diff --git a/docs/claude.md/fleet/worktree-hygiene.md b/docs/claude.md/fleet/worktree-hygiene.md deleted file mode 100644 index ad6153644..000000000 --- a/docs/claude.md/fleet/worktree-hygiene.md +++ /dev/null @@ -1,19 +0,0 @@ -# Worktree hygiene - -Finish a code change → **commit it**. Don't end a turn with uncommitted edits, untracked files, or staged-but-uncommitted hunks. A dirty worktree is a half-finished job. The next session, the next agent, or your own future `git checkout` trips over it, and the user cleans up after you. - -## Rules - -- **After finishing a logical unit of work, commit it.** Use a Conventional Commits message per the _Commits & PRs_ rule. Never leave the working tree dirty between turns. -- **Surgical staging only.** `git add <specific-file>`, never `-A` / `.` (per the _Parallel Claude sessions_ rule). The dirty-worktree rule is no excuse to sweep in files you didn't touch. `git add -f` is forbidden for paths containing `/node_modules/` or `package-lock.json` under `.claude/hooks/*/` or `.claude/skills/*/`. Past incident: a cascading agent ran `git add -f` on node_modules across 6 fleet repos; recovery needed a force-push (enforced by `.claude/hooks/node-modules-staging-guard/`; bypass: `Allow node-modules-staging bypass`). -- **Stage only when you're about to commit.** Put `git add` and `git commit` on the same line (chained with `&&`) or in the same Bash call. Don't stage as a side-effect of "preparing". Staging belongs at commit time. A turn that ends with staged-but-uncommitted hunks is the failure mode the previous bullet warns against (enforced by `.claude/hooks/no-orphaned-staging/`). -- **If you can't commit yet** (mid-refactor, tests failing, waiting on the user), say so in the turn summary. The user needs to know the dirty state is intentional. Silent dirty worktrees are the failure mode. -- **`git worktree add` worktrees.** Same rule, sharper. Leave the task-worktree clean (committed + pushed) before `git worktree remove`. Otherwise the removal refuses and the work strands. - -## The principle - -The working tree at end-of-turn should match where the user thinks the work is. "Done" means committed. Anything else is paused, and you announce pauses. - -## Parallel sessions amplify the cost - -Multiple Claude sessions can target the same checkout: parallel agents, terminals, worktrees on the same `.git/`. Dirty state compounds across them. A `git add -A` from session A sweeps session B's in-flight edits into session A's commit. Surgical staging plus same-call commits removes the race. diff --git a/docs/claude.md/wheelhouse/no-local-fork-canonical.md b/docs/claude.md/wheelhouse/no-local-fork-canonical.md deleted file mode 100644 index 06f080ea4..000000000 --- a/docs/claude.md/wheelhouse/no-local-fork-canonical.md +++ /dev/null @@ -1,47 +0,0 @@ -# Never fork fleet-canonical files locally - -Fleet-canonical files (anything tracked by `socket-wheelhouse/scripts/sync-scaffolding/manifest.mts`) MUST be edited in `socket-wheelhouse/template/...` and cascaded out. Never branched locally in a downstream fleet repo. - -## Canonical surfaces - -These directories and files cascade fleet-wide. They are **not** repo-local: - -- `.config/oxlint-plugin/`: plugin index + rules -- `.git-hooks/`: commit-msg / pre-commit / pre-push entry shims + .mts helpers (git invokes the shims when `core.hooksPath` is set to this directory; wired by `scripts/install-git-hooks.mts` at `pnpm install` time) -- `.claude/hooks/`: PreToolUse / PostToolUse hooks -- `.claude/skills/_shared/`: shared skill helpers -- `CLAUDE.md` fleet block (between `BEGIN/END FLEET-CANONICAL` markers) -- `docs/claude.md/fleet/`: fleet-canonical CLAUDE.md offshoot references (applies to every socket-\* repo) -- `docs/claude.md/wheelhouse/`: docs about the wheelhouse cascade mechanism itself (this file lives here) -- Downstream repos may add their own `docs/claude.md/<repo>/` subdirectory for repo-specific docs. Those are NOT fleet-canonical. -- Anything else listed in the sync manifest - -If unsure, check `socket-wheelhouse/scripts/sync-scaffolding/manifest.mts`. Tracked = canonical. - -## How to apply - -If a downstream repo needs a behavior change in one of these files: - -1. Edit the file in `socket-wheelhouse/template/...`. -2. Commit the template change. -3. Run `node scripts/sync-scaffolding/cli.mts --target <downstream-repo> --fix` to cascade. - -Do NOT edit the local copy in the downstream repo and rely on cascades to "preserve" your edits via `git checkout HEAD --` workarounds. That creates drift the sync mechanism then has to dance around, blocking other improvements from reaching that file in that repo. - -## Spotting drift to lift - -If you spot a useful predicate / helper / test / behavior in a fleet-canonical file in a downstream repo that is **not** in the template, that is a bug. Lift it up first, then re-cascade. - -The fix is mechanical: - -1. Diff the downstream version vs the template version. -2. Identify the additions (if there are any subtractions, those are also drift; usually they need to be added back to the downstream repo via a cascade). -3. Add the additions to the template. -4. Commit + push the template. -5. Re-cascade the downstream repo (overwrites its local copy with the now-superset canonical version). - -## Why this matters - -Local forks turn into "drift to preserve" hacks. Every cascade subagent has to be told to skip the locally-forked file, which makes the cascade fragile. Worse, those forks block fleet-wide improvements from reaching the forked repo: when the template's version of the file gets a real upgrade (e.g. a new fix predicate, a new exception case), the downstream repo's local copy never gets it. - -The fleet's value is the shared canon. Branching locally splits the canon and erodes the value. diff --git a/docs/references/agent-delegation.md b/docs/references/agent-delegation.md deleted file mode 100644 index 32eccd890..000000000 --- a/docs/references/agent-delegation.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent delegation - -When a task fits one of the patterns below, hand it off instead of doing it in the current session. The point is to get a _different model's_ take or to keep heavy work out of the main context — not to avoid effort. Don't delegate trivial tasks: the round-trip overhead isn't worth it for things you can answer in one or two tool calls. - -There are two delegation surfaces in this fleet. They look similar but are used differently. - -## Surface 1 — CLI subprocess delegation (skills) - -Skills that need multi-model output spawn the agent CLIs (`codex`, `claude`, `kimi`, `opencode`) as subprocesses and fold the results into a report. The contract — backend registry, detection policy, fallback order, attribution — lives in [`_shared/multi-agent-backends.md`](../../.claude/skills/_shared/multi-agent-backends.md). The canonical implementation is [`reviewing-code/run.mts`](../../.claude/skills/reviewing-code/run.mts). - -Use this surface when _the skill itself_ is the orchestrator (multi-pass review, parallel scans, fleet-wide runs). - -## Surface 2 — Subagent delegation (mid-conversation) - -When the _current_ Claude session wants to hand off a single task to another model and consume its result inline, use `Agent(subagent_type=…)`. This is in-conversation delegation, not skill orchestration. - -| Subagent | When to use | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `codex:codex-rescue` | You want GPT-5.4's take or a heavyweight async investigation. Best for: hard debugging you're stuck on, second implementation pass on a tricky design, deep root-cause work. Persistent runtime — check progress with `/codex:status`, get output with `/codex:result`. Also exposed as `/codex:rescue` for user-driven invocation. | -| `delegate` | You want a Fireworks / Synthetic / Kimi open model via [OpenCode](https://opencode.ai). Best for: cheap bulk work (classification, summarization, drafting many things), specialist routing (e.g. Qwen-Coder for code-heavy tasks), second opinions from a non-GPT/non-Claude model. Caller specifies the model in the prompt (e.g. `fireworks/qwen3-coder-480b`). Fire-and-forget. **Optional** — only available if the dev has set up the `delegate` agent locally. Skill code must not depend on it. | -| `Explore` | Codebase search / "where is X defined" / cross-file lookups. Different model isn't the point — context isolation is. | -| `Plan` | Implementation strategy for a non-trivial task before writing code. | -| `general-purpose` | Open-ended research that doesn't fit the above. | - -## Routing heuristics - -- **Stuck after one or two failed attempts** → `codex:codex-rescue`. A different family often breaks the deadlock. -- **About to do 20+ similar small operations** → `delegate` with a cheap model. Keep the main context clean. -- **Want a sanity check on a non-trivial design or diff** → `/codex:adversarial-review` (slash command) _or_ `delegate` to a different family, depending on which perspective is more useful. -- **Big codebase question that'll burn context** → `Explore`. -- **Building a multi-pass workflow** → don't use `Agent(...)` ad hoc; write a skill that uses Surface 1. - -## When the surfaces overlap - -A skill that wants `codex` output should call the CLI (Surface 1) so the result lands in a structured report. A live conversation that wants Codex's opinion on the _current_ problem should use the subagent (Surface 2) so the result flows back into the conversation. Same model, different orchestration. - -## Compatibility note - -Codex is fleet-wide (the `codex` CLI is a fleet plugin). OpenCode and the `delegate` subagent are **per-developer** — they require local setup outside the repo. Skills that automate work across the fleet must not assume `delegate` exists; humans driving Claude in their own checkout can use it freely. diff --git a/docs/references/bypass-phrases.md b/docs/references/bypass-phrases.md deleted file mode 100644 index 7c77795b8..000000000 --- a/docs/references/bypass-phrases.md +++ /dev/null @@ -1,49 +0,0 @@ -# Hook bypass phrases - -Reverting tracked changes or bypassing the fleet's hook chain requires the user to type the canonical phrase verbatim in a recent user turn. Inferring intent from "go ahead", "skip the hook", "fix it", etc. does NOT count. - -The phrase format is `Allow <X> bypass` — case-sensitive, exact match. - -| Operation | Phrase | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| Revert (any of: `git checkout -- <files>`, `git checkout <ref> -- <files>`, `git restore <files>` without `--staged`, `git reset --hard`, `git stash drop` / `pop` / `clear`, `git clean -f`, `git rm -rf`) | `Allow revert bypass` | -| `git --no-verify` (skips husky hooks) | `Allow no-verify bypass` | -| `git --no-gpg-sign` / `-c commit.gpgsign=false` | `Allow gpg bypass` | -| `DISABLE_PRECOMMIT_LINT=1` (skips lint step) | `Allow lint bypass` | -| `DISABLE_PRECOMMIT_TEST=1` (skips test step) | `Allow test bypass` | -| `git push --force` / `-f` | `Allow force-push bypass` | - -## Scope - -A phrase from a previous session does not carry over — only the current conversation's user turns count. The hook reads the active session's transcript (passed by Claude Code as `transcript_path` in the PreToolUse payload) and searches the concatenated user-turn text for the exact phrase. - -The match is **case-sensitive** and **substring-based**: - -- ✓ `Allow revert bypass — please drop my last edit` -- ✓ a multi-line user message with `Allow revert bypass` on its own line -- ✗ `allow revert bypass` (lowercase) -- ✗ `please revert that file` (paraphrase) -- ✗ `--no-verify is fine` (no `Allow ... bypass` shape) - -## Why a phrase - -Without the gate, the assistant has historically reverted whole batches of autofix changes mid-cleanup or used `--no-verify` to push past a failing hook, both of which destroy work and erode trust. The phrase is short enough to type when truly intended and specific enough that no other utterance accidentally triggers it. - -## Defense in depth - -The bypass policy is enforced at three layers: - -- **CLAUDE.md** documents the rule (`### Hook bypasses require the canonical phrase`). -- **Memory** keeps the assistant honest across sessions even before the hook fires. -- **`.claude/hooks/no-revert-guard/`** is the actual enforcement: a `PreToolUse(Bash)` hook that scans the proposed command, parses the transcript, and exits 2 with a clear stderr message naming the phrase the user must type. - -The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. Trade-off: a buggy hook silently allows the destructive command. Acceptable because the alternative (hook crash wedges the session) is worse for development velocity. - -## How to add a new bypass - -When introducing a new destructive flag or hook bypass: - -1. Add a new entry to the `CHECKS` array in `.claude/hooks/no-revert-guard/index.mts`. Each check is `{ pattern: RegExp, bypassPhrase: string, label: string }`. -2. Add a row to this reference's table. -3. Add a test case to `.claude/hooks/no-revert-guard/test/index.test.mts` covering both the blocked-without-phrase and allowed-with-phrase paths. -4. Cascade via `node socket-wheelhouse/scripts/sync-scaffolding.mts --all --fix` so every fleet repo picks up the change. diff --git a/docs/references/error-messages.md b/docs/references/error-messages.md deleted file mode 100644 index d0310d831..000000000 --- a/docs/references/error-messages.md +++ /dev/null @@ -1,166 +0,0 @@ -# Error Messages — Worked Examples - -Companion to the `## Error Messages` section of `CLAUDE.md`. That section -holds the rules; this file holds longer examples and anti-patterns that -would bloat CLAUDE.md if inlined. - -## The four ingredients - -Every message needs, in order: - -1. **What** — the rule that was broken. -2. **Where** — the exact file, line, key, field, or CLI flag. -3. **Saw vs. wanted** — the bad value and the allowed shape or set. -4. **Fix** — one concrete action, in imperative voice. - -## Library API errors (terse) - -Callers may match on the message text, so stability matters. Aim for one -sentence. - -| ✗ / ✓ | Message | Notes | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| ✗ | `Error: invalid component` | No rule, no saw, no where. | -| ✗ | `The "name" component of type "npm" failed validation because the provided value "" is empty, which is not allowed because names are required; please provide a non-empty name.` | Restates the rule three times. | -| ✓ | `npm "name" component is required` | Rule + where + implied saw (missing). Six words. | -| ✗ | `Error: bad name` | No rule. | -| ✓ | `name "__proto__" cannot start with an underscore` | Rule, where (`name`), saw (`__proto__`), fix implied. | - -## Validator / config / build-tool errors (verbose) - -The reader is looking at a file and wants to fix the record without -re-running the tool. Give each ingredient its own words. - -✗ `Error: invalid tour config` - -✓ `tour.json: part 3 ("Parsing & Normalization") is missing "filename". Add a single-word lowercase filename (e.g. "parsing") to this part — one per part is required to route /<slug>/part/3 at publish time.` - -Breakdown: - -- **What**: `is missing "filename"` — the rule is "each part has a filename". -- **Where**: `tour.json: part 3 ("Parsing & Normalization")` — file + record + human label. -- **Saw vs. wanted**: saw = missing; wanted = a single-word lowercase filename, with `"parsing"` as a concrete model. -- **Fix**: `Add … to this part` — imperative, specific. - -The trailing `to route /<slug>/part/3 at publish time` is optional. Include a _why_ clause only when the rule is non-obvious; skip it for rules the reader already knows (e.g. "names can't start with an underscore"). - -## Programmatic errors (terse, rule only) - -Internal assertions and invariant checks. No end user will read them; -terse keeps the assertion readable when you skim the code. - -- ✓ `assert(queue.length > 0)` with message `queue drained before worker exit` -- ✓ `pool size must be positive` -- ✗ `An unexpected error occurred while trying to acquire a connection from the pool because the pool size was not positive.` — nothing a maintainer can act on that the rule itself doesn't already say. - -## Common anti-patterns - -**"Invalid X" with no rule.** - -- ✗ `Invalid filename 'My Part'` -- ✓ `filename 'My Part' must be [a-z]+ (lowercase, no spaces)` - -**Passive voice on the fix.** - -- ✗ `"filename" was missing` -- ✓ `add "filename" to part 3` - -**Naming only one side of a collision.** - -- ✗ `duplicate key "foo"` (which record won, which lost?) -- ✓ `duplicate key "foo" in config.json (lines 12 and 47) — rename one` - -**Silently auto-correcting.** - -- ✗ Stripping a trailing slash from a URL and continuing. The next run will hit the same bug; nothing learned. -- ✓ `url "https://api/" has a trailing slash — remove it`. - -**Bloat that restates the rule.** - -- ✗ `The value provided for "timeout" is invalid because timeouts must be positive numbers and the value you provided was not a positive number.` -- ✓ `timeout must be a positive number (saw: -5)` - -## Formatting lists of values - -When the error needs to show an allowed set, a list of conflicting -records, or multiple missing fields, use the list formatters from -`@socketsecurity/lib/arrays` rather than hand-joining with commas: - -- `joinAnd(['a', 'b', 'c'])` → `"a, b, and c"` — for conjunctions ("missing foo, bar, and baz") -- `joinOr(['npm', 'pypi', 'maven'])` → `"npm, pypi, or maven"` — for disjunctions ("must be one of: …") - -Both wrap `Intl.ListFormat`, so the Oxford comma and one-/two-item cases come out right for free (`joinOr(['a'])` → `"a"`; `joinOr(['a', 'b'])` → `"a or b"`). - -- ✗ `--reach-ecosystems must be one of: npm, pypi, maven (saw: "foo")` — hand-joined, breaks if the list has one or two entries. -- ✓ `` `--reach-ecosystems must be one of: ${joinOr(ALLOWED)} (saw: "foo")` `` -- ✗ `missing keys: filename slug title` — no separators, no grammar. -- ✓ `` `missing keys: ${joinAnd(missing)}` `` → `"missing keys: filename, slug, and title"` - -Use `joinOr` whenever the error is "must be one of X", `joinAnd` whenever it's "all of X are required / missing / in conflict". - -## Working with caught values - -`catch (e)` binds `unknown`. The helpers in `@socketsecurity/lib/errors` cover the four patterns that recur everywhere: - -```ts -import { - errorMessage, - errorStack, - isError, - isErrnoException, -} from '@socketsecurity/lib-stable/errors' -``` - -### `isError(value)` — replaces `value instanceof Error` - -Cross-realm-safe. Uses the native ES2025 `Error.isError` when the engine ships it, falls back to a spec-compliant shim otherwise. Catches Errors from worker threads, `vm` contexts, and iframes that same-realm `instanceof Error` silently misses. - -- ✗ `if (e instanceof Error) { … }` -- ✓ `if (isError(e)) { … }` - -### `isErrnoException(value)` — replaces `'code' in err` guards - -Narrows to `NodeJS.ErrnoException` (an Error with a string `code` set by libuv/syscalls like `ENOENT`, `EACCES`, `EBUSY`, `EPERM`). Builds on `isError`, so it's also cross-realm-safe, and it checks that `code` is a string — a merely branded Error without a real errno code returns `false`. - -- ✗ `if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') { … }` -- ✓ `if (isErrnoException(e) && e.code === 'ENOENT') { … }` - -### `errorMessage(value)` — replaces the `instanceof Error ? e.message : String(e)` pattern - -Walks the `cause` chain via `messageWithCauses`, coerces primitives and objects to string, and returns the shared `UNKNOWN_ERROR` sentinel (the string `'Unknown error'`) for `null`, `undefined`, empty strings, `[object Object]`, or Errors with no message. - -That last bullet is the important one: **every `|| 'Unknown error'` fallback in the fleet should collapse into a single `errorMessage(e)` call.** - -- ✗ `` `Failed: ${e instanceof Error ? e.message : String(e)}` `` -- ✗ `` `Failed: ${(e as Error)?.message ?? 'Unknown error'}` `` -- ✗ `` `Failed: ${e instanceof Error ? e.message : 'Unknown error'}` `` -- ✓ `` `Failed: ${errorMessage(e)}` `` - -When you want to preserve the cause chain upstream (recommended), pair it with `{ cause }`: - -```ts -try { - await readConfig(path) -} catch (e) { - throw new Error(`Failed to read ${path}: ${errorMessage(e)}`, { cause: e }) -} -``` - -### `errorStack(value)` — cause-aware stack, or `undefined` - -Returns the cause-walking stack for Errors; returns `undefined` for non-Errors so logger calls stay safe: - -```ts -logger.error(`rebuild failed: ${errorMessage(e)}`, { stack: errorStack(e) }) -``` - -## Voice & tone - -- Imperative for the fix: `rename`, `add`, `remove`, `set`. -- Present tense for the rule: `must be`, `cannot`, `is required`. -- No apology ("Sorry, …"), no blame ("You provided …"). State the rule and the fix. -- Don't end with "please"; it doesn't add information and it makes the message feel longer than it is. - -## Bloat check - -Before shipping a message, cross out any word that, if removed, leaves the information intact. If only rhythm or politeness disappears, drop it. diff --git a/docs/references/inclusive-language.md b/docs/references/inclusive-language.md deleted file mode 100644 index b34fb85de..000000000 --- a/docs/references/inclusive-language.md +++ /dev/null @@ -1,34 +0,0 @@ -# Inclusive language reference - -The fleet uses precise, neutral terms over historical metaphors that imply hierarchy or exclusion. The substitutes are not euphemisms — they're more _accurate_ (a list of allowed values genuinely is an "allowlist"; "whitelist" is a metaphor that hides what the list does). - -## Substitution table - -| Replace | With | -| -------------------------------- | --------------------------------------------------- | -| `whitelist` / `whitelisted` | `allowlist` / `allowed` / `allowlisted` | -| `blacklist` / `blacklisted` | `denylist` / `denied` / `blocklisted` / `blocked` | -| `master` (branch, process, copy) | `main` (branch); `primary` / `controller` (process) | -| `slave` | `replica`, `worker`, `secondary`, `follower` | -| `grandfathered` | `legacy`, `pre-existing`, `exempted` | -| `sanity check` | `quick check`, `confidence check`, `smoke test` | -| `dummy` (placeholder) | `placeholder`, `stub` | - -## Where to apply - -- **Code**: identifiers, comments, string literals. -- **Docs**: READMEs, CLAUDE.md, markdown. -- **Config**: YAML, JSON. -- **History**: commit messages, PR titles/descriptions. -- **CI logs** you control. - -## Two narrow exceptions - -The legacy term must remain only when changing it would break something external: - -- **Third-party APIs / upstream code**: when interfacing with an external API field literally named `whitelist`, keep the field name; rename your local variable. Example: `const allowedDomains = response.whitelist`. -- **Vendored upstream sources**: don't rewrite vendored code under `vendor/**`, `upstream/**`, or `**/fixtures/**`. Patch around it if needed. - -## When to fix - -When you encounter a legacy term during unrelated work, fix it inline — don't defer. diff --git a/docs/references/sorting.md b/docs/references/sorting.md deleted file mode 100644 index f452f0a6f..000000000 --- a/docs/references/sorting.md +++ /dev/null @@ -1,16 +0,0 @@ -# Sorting reference - -Sort lists alphanumerically (literal byte order, ASCII before letters). - -## Where to sort - -- **Config lists** — `permissions.allow` / `permissions.deny` in `.claude/settings.json`, `external-tools.json` checksum keys, allowlists in workflow YAML. -- **Object key entries** — keys in plain JSON config + return-shape literals + internal-state objects. (Exception: `__proto__: null` always comes first, ahead of any data keys.) -- **Import specifiers** — sort named imports inside a single statement: `import { encrypt, randomDataKey, wrapKey } from './crypto.mts'`. `import type` follows the same rule. Statement _order_ (`node:` → external → local → types) is separate from specifier order _within_ a statement. -- **Method / function placement** — within a module, sort top-level functions alphabetically. Convention: private functions (lowercase / un-exported) sort first, exported functions second. The `export` keyword is the divider. -- **Array literals** — when the array is a config list, allowlist, or set-like collection. Position-bearing arrays (e.g. `argv`, anything where index matters semantically) keep their meaningful order. -- **`Set` constructor arguments** — `new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions. - -## Default - -When in doubt, sort. The cost of a sorted list that didn't need to be is approximately zero; the cost of an unsorted list that did need to be is a merge conflict. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..dc4feef04 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,339 @@ +'use strict' + +const path = require('node:path') + +const { + convertIgnorePatternToMinimatch, + includeIgnoreFile, +} = require('@eslint/compat') +const js = require('@eslint/js') +const tsParser = require('@typescript-eslint/parser') +const { + createTypeScriptImportResolver, +} = require('eslint-import-resolver-typescript') +const importXPlugin = require('eslint-plugin-import-x') +const nodePlugin = require('eslint-plugin-n') +const sortDestructureKeysPlugin = require('eslint-plugin-sort-destructure-keys') +const unicornPlugin = require('eslint-plugin-unicorn') +const globals = require('globals') +const tsEslint = require('typescript-eslint') + +const constants = require('@socketsecurity/registry/lib/constants') +const { BIOME_JSON, GITIGNORE, LATEST, TSCONFIG_JSON } = constants + +const { flatConfigs: origImportXFlatConfigs } = importXPlugin + +const rootPath = __dirname +const rootTsConfigPath = path.join(rootPath, TSCONFIG_JSON) + +const nodeGlobalsConfig = Object.fromEntries( + Object.entries(globals.node).map(([k]) => [k, 'readonly']), +) + +const biomeConfigPath = path.join(rootPath, BIOME_JSON) +const biomeConfig = require(biomeConfigPath) +const biomeIgnores = { + name: 'Imported biome.json ignore patterns', + ignores: biomeConfig.files.includes + .filter(p => p.startsWith('!')) + .map(p => convertIgnorePatternToMinimatch(p.slice(1))), +} + +const gitignorePath = path.join(rootPath, GITIGNORE) +const gitIgnores = includeIgnoreFile(gitignorePath) + +if (process.env.LINT_DIST) { + const isNotDistGlobPattern = p => !/(?:^|[\\/])dist/.test(p) + biomeIgnores.ignores = biomeIgnores.ignores?.filter(isNotDistGlobPattern) + gitIgnores.ignores = gitIgnores.ignores?.filter(isNotDistGlobPattern) +} + +if (process.env.LINT_EXTERNAL) { + const isNotExternalGlobPattern = p => !/(?:^|[\\/])external/.test(p) + biomeIgnores.ignores = biomeIgnores.ignores?.filter(isNotExternalGlobPattern) + gitIgnores.ignores = gitIgnores.ignores?.filter(isNotExternalGlobPattern) +} + +const sharedPlugins = { + 'sort-destructure-keys': sortDestructureKeysPlugin, + unicorn: unicornPlugin, +} + +const sharedRules = { + 'unicorn/consistent-function-scoping': 'error', + curly: 'error', + 'no-await-in-loop': 'error', + 'no-control-regex': 'error', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-new': 'error', + 'no-proto': 'error', + 'no-undef': 'error', + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_|^this$', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + }, + ], + 'no-var': 'error', + 'no-warning-comments': ['warn', { terms: ['fixme'] }], + 'prefer-const': 'error', + 'sort-destructure-keys/sort-destructure-keys': 'error', + 'sort-imports': ['error', { ignoreDeclarationSort: true }], +} + +const sharedRulesForImportX = { + ...origImportXFlatConfigs.recommended.rules, + 'import-x/extensions': [ + 'error', + 'never', + { + cjs: 'ignorePackages', + js: 'ignorePackages', + json: 'always', + mjs: 'ignorePackages', + mts: 'ignorePackages', + ts: 'ignorePackages', + }, + ], + 'import-x/order': [ + 'warn', + { + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling', 'index'], + 'type', + ], + pathGroups: [ + { + pattern: '@socket{registry,security}/**', + group: 'internal', + }, + ], + pathGroupsExcludedImportTypes: ['type'], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + }, + }, + ], +} + +const sharedRulesForNode = { + 'n/exports-style': ['error', 'module.exports'], + 'n/no-missing-require': ['off'], + // The n/no-unpublished-bin rule does does not support non-trivial glob + // patterns used in package.json "files" fields. In those cases we simplify + // the glob patterns used. + 'n/no-unpublished-bin': 'error', + 'n/no-unsupported-features/es-builtins': 'error', + 'n/no-unsupported-features/es-syntax': 'error', + 'n/no-unsupported-features/node-builtins': [ + 'error', + { + ignores: [ + 'fetch', + 'fs.promises.cp', + 'module.enableCompileCache', + 'readline/promises', + 'test', + 'test.describe', + ], + // Lazily access constants.maintainedNodeVersions. + version: constants.maintainedNodeVersions.current, + }, + ], + 'n/prefer-node-protocol': 'error', +} + +function getImportXFlatConfigs(isEsm) { + return { + recommended: { + ...origImportXFlatConfigs.recommended, + languageOptions: { + ...origImportXFlatConfigs.recommended.languageOptions, + ecmaVersion: LATEST, + sourceType: isEsm ? 'module' : 'script', + }, + rules: { + ...sharedRulesForImportX, + 'import-x/no-named-as-default-member': 'off', + }, + }, + typescript: { + ...origImportXFlatConfigs.typescript, + plugins: origImportXFlatConfigs.recommended.plugins, + settings: { + ...origImportXFlatConfigs.typescript.settings, + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ + project: rootTsConfigPath, + }), + ], + }, + rules: { + ...sharedRulesForImportX, + // TypeScript compilation already ensures that named imports exist in + // the referenced module. + 'import-x/named': 'off', + 'import-x/no-named-as-default-member': 'off', + 'import-x/no-unresolved': 'off', + }, + }, + } +} + +const importFlatConfigsForScript = getImportXFlatConfigs(false) +const importFlatConfigsForModule = getImportXFlatConfigs(true) + +module.exports = [ + gitIgnores, + biomeIgnores, + { + files: ['**/*.{cts,mts,ts}'], + ...js.configs.recommended, + ...importFlatConfigsForModule.typescript, + languageOptions: { + ...js.configs.recommended.languageOptions, + ...importFlatConfigsForModule.typescript.languageOptions, + globals: { + ...js.configs.recommended.languageOptions?.globals, + ...importFlatConfigsForModule.typescript.languageOptions?.globals, + ...nodeGlobalsConfig, + BufferConstructor: 'readonly', + BufferEncoding: 'readonly', + NodeJS: 'readonly', + }, + parser: tsParser, + parserOptions: { + ...js.configs.recommended.languageOptions?.parserOptions, + ...importFlatConfigsForModule.typescript.languageOptions?.parserOptions, + projectService: { + ...importFlatConfigsForModule.typescript.languageOptions + ?.parserOptions?.projectService, + allowDefaultProject: [ + // Allow paths like src/utils/*.test.mts. + 'src/*/*.test.mts', + // Allow paths like src/commands/optimize/*.test.mts. + 'src/*/*/*.test.mts', + 'test/*.mts', + 'vitest.config.mts', + ], + defaultProject: 'tsconfig.json', + tsconfigRootDir: rootPath, + // Need this to glob the test files in /src. Otherwise it won't work. + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 1_000_000, + }, + }, + }, + linterOptions: { + ...js.configs.recommended.linterOptions, + ...importFlatConfigsForModule.typescript.linterOptions, + reportUnusedDisableDirectives: 'off', + }, + plugins: { + ...js.configs.recommended.plugins, + ...importFlatConfigsForModule.typescript.plugins, + ...nodePlugin.configs['flat/recommended-module'].plugins, + ...sharedPlugins, + '@typescript-eslint': tsEslint.plugin, + }, + rules: { + ...js.configs.recommended.rules, + ...importFlatConfigsForModule.typescript.rules, + ...nodePlugin.configs['flat/recommended-module'].rules, + ...sharedRulesForNode, + ...sharedRules, + '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], + '@typescript-eslint/consistent-type-assertions': [ + 'error', + { assertionStyle: 'as' }, + ], + '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-this-alias': [ + 'error', + { allowDestructuring: true }, + ], + // Returning unawaited promises in a try/catch/finally is dangerous + // (the `catch` won't catch if the promise is rejected, and the `finally` + // won't wait for the promise to resolve). Returning unawaited promises + // elsewhere is probably fine, but this lint rule doesn't have a way + // to only apply to try/catch/finally (the 'in-try-catch' option *enforces* + // not awaiting promises *outside* of try/catch/finally, which is not what + // we want), and it's nice to await before returning anyways, since you get + // a slightly more comprehensive stack trace upon promise rejection. + '@typescript-eslint/return-await': ['error', 'always'], + // Disable the following rules because they don't play well with TypeScript. + 'n/hashbang': 'off', + 'n/no-extraneous-import': 'off', + 'n/no-missing-import': 'off', + 'no-redeclare': 'off', + 'no-unused-vars': 'off', + }, + }, + { + files: ['**/*.{cjs,js}'], + ...js.configs.recommended, + ...importFlatConfigsForScript.recommended, + ...nodePlugin.configs['flat/recommended-script'], + languageOptions: { + ...js.configs.recommended.languageOptions, + ...importFlatConfigsForModule.recommended.languageOptions, + ...nodePlugin.configs['flat/recommended-script'].languageOptions, + globals: { + ...js.configs.recommended.languageOptions?.globals, + ...importFlatConfigsForModule.recommended.languageOptions?.globals, + ...nodePlugin.configs['flat/recommended-script'].languageOptions + ?.globals, + ...nodeGlobalsConfig, + }, + }, + plugins: { + ...js.configs.recommended.plugins, + ...importFlatConfigsForScript.recommended.plugins, + ...nodePlugin.configs['flat/recommended-script'].plugins, + ...sharedPlugins, + }, + rules: { + ...js.configs.recommended.rules, + ...importFlatConfigsForScript.recommended.rules, + ...nodePlugin.configs['flat/recommended-script'].rules, + ...sharedRulesForNode, + ...sharedRules, + }, + }, + { + files: ['**/*.mjs'], + ...js.configs.recommended, + ...importFlatConfigsForModule.recommended, + ...nodePlugin.configs['flat/recommended-module'], + languageOptions: { + ...js.configs.recommended.languageOptions, + ...importFlatConfigsForModule.recommended.languageOptions, + ...nodePlugin.configs['flat/recommended-module'].languageOptions, + globals: { + ...js.configs.recommended.languageOptions?.globals, + ...importFlatConfigsForModule.recommended.languageOptions?.globals, + ...nodePlugin.configs['flat/recommended-module'].languageOptions + ?.globals, + ...nodeGlobalsConfig, + }, + }, + plugins: { + ...js.configs.recommended.plugins, + ...importFlatConfigsForModule.recommended.plugins, + ...nodePlugin.configs['flat/recommended-module'].plugins, + ...sharedPlugins, + }, + rules: { + ...js.configs.recommended.rules, + ...importFlatConfigsForModule.recommended.rules, + ...nodePlugin.configs['flat/recommended-module'].rules, + ...sharedRulesForNode, + ...sharedRules, + }, + }, +] diff --git a/external-tools.json b/external-tools.json deleted file mode 100644 index d643cf7a2..000000000 --- a/external-tools.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/SocketDev/socket-btm/main/packages/build-infra/lib/external-tools-schema.json", - "description": "External tools required to build + release socket-cli. Wrapped `tools` shape matches the canonical schema every fleet repo now uses. When composite actions or scripts want sha256-verified downloads of pnpm / sfw / zizmor, they read from `config.tools.<name>` in this file.", - "tools": { - "git": { - "description": "Git CLI — checkout, submodule init, tag signing.", - "version": "2.30+", - "notes": [ - "Required: yes (all platforms)", - "Preinstalled on macOS (Xcode CLT) and most Linux distros", - "Windows: https://git-scm.com/download/win or via winget/scoop" - ] - }, - "node": { - "description": "Node.js — runs the CLI and all build scripts.", - "version": "25.9.0", - "notes": [ - "Required: yes", - "Node 25+ runs .mts files natively with no flag", - "The pinned version lives in .node-version at the repo root" - ] - }, - "pnpm": { - "description": "pnpm — the fleet's package manager.", - "version": "11.0.0-rc.5", - "packageManager": "pnpm", - "repository": "github:pnpm/pnpm", - "release": "asset", - "notes": [ - "Required: yes", - "Bootstrap locally via `corepack enable pnpm`", - "CI downloads + sha256-verifies the pinned tarball" - ], - "checksums": { - "darwin-arm64": { - "asset": "pnpm-darwin-arm64.tar.gz", - "sha256": "32a50710ccacfdcf14e6d5995d5368298eec913b0ce3903b9e09b6555f06f4e5" - }, - "darwin-x64": { - "asset": "pnpm-darwin-x64.tar.gz", - "sha256": "71dca33f4275da6b43bf1eb40bdc4d876f59a116716eacbf01079c3d985ff85d" - }, - "linux-arm64": { - "asset": "pnpm-linux-arm64.tar.gz", - "sha256": "2dd04127ff10b1f9dd20bae248b779c77a8ec67e3afa35e7256e5f94abddd493" - }, - "linux-x64": { - "asset": "pnpm-linux-x64.tar.gz", - "sha256": "7ebef4b616ba41fb0d54a207b36508fae3346723283a088b43fc1e038ee6fed0" - }, - "win-arm64": { - "asset": "pnpm-win32-arm64.zip", - "sha256": "e4a39ad4c251db5e34b18b98561ef25bab5506ad65cad2fa3602af58d1972667" - }, - "win-x64": { - "asset": "pnpm-win32-x64.zip", - "sha256": "147485ae2f38c3d1ccf2f5db00d0244416bcd22b9114c02388e6a78f41538fc4" - } - } - }, - "gh": { - "description": "GitHub CLI — workflow dispatch, release downloads, PR creation.", - "version": "2.63+", - "notes": [ - "Required: only in workflows that call `gh api` / `gh pr create`", - "Preinstalled on GitHub-hosted runners", - "Local: `brew install gh` / `winget install gh` / `apt install gh`" - ] - }, - "zizmor": { - "description": "GitHub Actions security linter — audits .github/ for workflow-injection / credential-leak patterns.", - "version": "1.23.1", - "repository": "github:zizmorcore/zizmor", - "release": "asset", - "notes": [ - "Used by the setup-and-install composite action", - "Blocks merges on medium+ findings" - ], - "checksums": { - "darwin-arm64": { - "asset": "zizmor-aarch64-apple-darwin.tar.gz", - "sha256": "2632561b974c69f952258c1ab4b7432d5c7f92e555704155c3ac28a2910bd717" - }, - "darwin-x64": { - "asset": "zizmor-x86_64-apple-darwin.tar.gz", - "sha256": "89d5ed42081dd9d0433a10b7545fac42b35f1f030885c278b9712b32c66f2597" - }, - "linux-arm64": { - "asset": "zizmor-aarch64-unknown-linux-gnu.tar.gz", - "sha256": "3725d7cd7102e4d70827186389f7d5930b6878232930d0a3eb058d7e5b47e658" - }, - "linux-x64": { - "asset": "zizmor-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "67a8df0a14352dd81882e14876653d097b99b0f4f6b6fe798edc0320cff27aff" - }, - "win-x64": { - "asset": "zizmor-x86_64-pc-windows-msvc.zip", - "sha256": "33c2293ff02834720dd7cd8b47348aafb2e95a19bdc993c0ecaca9c804ade92a" - } - } - }, - "sfw-free": { - "description": "Socket Firewall (free tier) — malware gate on dep installs.", - "version": "1.7.2", - "repository": "github:SocketDev/sfw-free", - "release": "asset", - "notes": [ - "Used when SOCKET_API_KEY is not set", - "Shims npm/yarn/pnpm so every install call passes through the firewall" - ], - "checksums": { - "darwin-arm64": { - "asset": "sfw-free-macos-arm64", - "sha256": "248fb588e1e1a27e7192f7b079f739fc29a9de61f0bad7e90928363022dc5643" - }, - "darwin-x64": { - "asset": "sfw-free-macos-x86_64", - "sha256": "a5427d479d440f08e3789fa191ba57599be64997196daf42e67d964fec0382b4" - }, - "linux-arm64": { - "asset": "sfw-free-linux-arm64", - "sha256": "84a045e4e1bb320cc5c0d3929f02e53f199398b5be0637e8846d02d9ef0027b1" - }, - "linux-x64": { - "asset": "sfw-free-linux-x86_64", - "sha256": "93e2d9dfa244b82a74e014dc26b1c6af18b4adec20f35254378943db5fe91411" - }, - "win-x64": { - "asset": "sfw-free-windows-x86_64.exe", - "sha256": "6d333b4cac9d7c5712e2e99677ca634ac8a3020d550c6308312c60bea97f0a28" - } - } - }, - "sfw-enterprise": { - "description": "Socket Firewall (enterprise tier) — selected when SOCKET_API_KEY is set.", - "version": "1.7.2", - "repository": "github:SocketDev/firewall-release", - "release": "asset", - "notes": [ - "Used when SOCKET_API_KEY is set (e.g. via repo secrets in CI)", - "Same shims as sfw-free, broader ecosystem support" - ], - "checksums": { - "darwin-arm64": { - "asset": "sfw-macos-arm64", - "sha256": "b1cdc3bdbd2a3161247bd5cc215eb3c44a90b87fe0b800a33889a14f61bb0d6d" - }, - "darwin-x64": { - "asset": "sfw-macos-x86_64", - "sha256": "da252d2a9a5d0edb271bb771e0d01b9cd6fa1635b6d765f61efd61edb6739f12" - }, - "linux-arm64": { - "asset": "sfw-linux-arm64", - "sha256": "c24a79c27e1a01a59b7a160c165930ae029816c72b141fcfcdb2f73e0774898a" - }, - "linux-x64": { - "asset": "sfw-linux-x86_64", - "sha256": "4482b52e6367bd4610519bfd57a104d5907ec87d5399142ed3bb3d222de1f33d" - }, - "win-x64": { - "asset": "sfw-windows-x86_64.exe", - "sha256": "e52ad806a1c41b440f04098eb1c7e407845f03f5740a6a79006ba6fd172056ec" - } - } - } - } -} diff --git a/install.sh b/install.sh deleted file mode 100755 index ff6642805..000000000 --- a/install.sh +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env bash -# Socket CLI installation script. -# Downloads and installs the appropriate Socket CLI binary for your platform. - -set -euo pipefail - -# Colors for output. -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -PURPLE='\033[0;35m' -BOLD='\033[1m' -NC='\033[0m' # No Color - -# Print colored messages. -info() { - echo -e "${BLUE}ℹ${NC} $1" -} - -success() { - echo -e "${GREEN}✓${NC} $1" -} - -error() { - echo -e "${RED}✗${NC} $1" -} - -warning() { - echo -e "${YELLOW}⚠${NC} $1" -} - -step() { - echo -e "${CYAN}→${NC} $1" -} - -socket_brand() { - echo -e "${PURPLE}⚡${NC} $1" -} - -# Detect if running on musl libc (Alpine Linux, etc.). -detect_musl() { - # Check for Alpine in /etc/os-release. - if [ -f /etc/os-release ]; then - if grep -qi 'alpine' /etc/os-release 2>/dev/null; then - return 0 - fi - fi - - # Check for musl dynamic linker. - if [ -f /lib/ld-musl-x86_64.so.1 ] || [ -f /lib/ld-musl-aarch64.so.1 ]; then - return 0 - fi - - # Check ldd output for musl. - if command -v ldd &> /dev/null; then - if ldd --version 2>&1 | grep -qi musl; then - return 0 - fi - fi - - return 1 -} - -# Detect platform and architecture. -detect_platform() { - local os - local arch - local libc_suffix="" - - # Detect OS. - case "$(uname -s)" in - Linux*) - os="linux" - # Check for musl libc on Linux. - if detect_musl; then - libc_suffix="-musl" - fi - ;; - Darwin*) - os="darwin" - ;; - MINGW*|MSYS*|CYGWIN*) - os="win32" - ;; - *) - error "Unsupported operating system: $(uname -s)" - echo "" - info "Socket CLI supports Linux, macOS, and Windows." - info "If you think this is an error, please open an issue at:" - info "https://github.com/SocketDev/socket-cli/issues" - exit 1 - ;; - esac - - # Detect architecture. - case "$(uname -m)" in - x86_64|amd64) - arch="x64" - ;; - aarch64|arm64) - arch="arm64" - ;; - *) - error "Unsupported architecture: $(uname -m)" - echo "" - info "Socket CLI supports x64 and arm64 architectures." - info "If you think this is an error, please open an issue at:" - info "https://github.com/SocketDev/socket-cli/issues" - exit 1 - ;; - esac - - echo "${os}-${arch}${libc_suffix}" -} - -# Fetch a URL to stdout, enforcing HTTPS. -# -# curl enforces HTTPS via `--proto '=https'`. wget's `--https-only` only -# applies to recursive downloads, so for the single-file fetches we do -# here we disable redirect following (`--max-redirect=0`) — npm's -# registry serves responses directly with no redirect, so this is safe -# AND blocks any MITM attempt to redirect us to http://. -fetch_url() { - local url="$1" - - if command -v curl &> /dev/null; then - curl --proto '=https' --tlsv1.2 -fsSL "$url" - elif command -v wget &> /dev/null; then - wget --max-redirect=0 -qO- "$url" - else - error "Neither curl nor wget found on your system" - echo "" - info "Please install curl or wget to continue:" - info " macOS: brew install curl" - info " Ubuntu: sudo apt-get install curl" - info " Fedora: sudo dnf install curl" - exit 1 - fi -} - -# Download a URL to a file, enforcing HTTPS (see `fetch_url` comment). -fetch_url_to_file() { - local url="$1" - local out="$2" - - if command -v curl &> /dev/null; then - curl --proto '=https' --tlsv1.2 -fsSL -o "$out" "$url" - elif command -v wget &> /dev/null; then - wget --max-redirect=0 -qO "$out" "$url" - else - error "Neither curl nor wget found on your system" - exit 1 - fi -} - -# Parse a JSON string field out of a response body. Tolerates a missing -# field by returning empty, rather than dying under `pipefail`. -parse_json_string() { - local body="$1" - local field="$2" - # Pipe through `cat` so a grep non-match (exit 1) doesn't trip pipefail; - # the final `echo` replaces an empty match with empty string. - printf '%s' "$body" \ - | grep -o "\"${field}\": *\"[^\"]*\"" \ - | head -1 \ - | sed "s/\"${field}\": *\"\\([^\"]*\\)\"/\\1/" \ - || true -} - -# Get the latest version from npm registry. -get_latest_version() { - local package_name="$1" - local body version - - body=$(fetch_url "https://registry.npmjs.org/${package_name}/latest") - version=$(parse_json_string "$body" "version") - - if [ -z "$version" ]; then - error "Failed to fetch latest version from npm registry" - echo "" - info "This might be a temporary network issue. Please try again." - info "If the problem persists, check your internet connection." - exit 1 - fi - - echo "$version" -} - -# Get the npm-published integrity string (SSRI format, e.g. "sha512-...") for -# a specific version. -get_published_integrity() { - local package_name="$1" - local version="$2" - local body - - body=$(fetch_url "https://registry.npmjs.org/${package_name}/${version}") - parse_json_string "$body" "integrity" -} - -# Compute an SSRI-style hash (e.g. "sha512-<base64>") of a file. -# Requires `openssl` — the tool is ubiquitous (macOS, every mainstream -# Linux distro, Alpine's default image, WSL, Git Bash) and gives us a -# one-step hex-less pipeline so we don't depend on `xxd` (not POSIX). -compute_integrity() { - local file="$1" - local algo="$2" - local digest - - if ! command -v openssl &> /dev/null; then - error "openssl not found — required to verify the download integrity" - echo "" - info "Install openssl and re-run:" - info " macOS: already installed (or: brew install openssl)" - info " Alpine: apk add openssl" - info " Debian: sudo apt-get install openssl" - info " Fedora: sudo dnf install openssl" - exit 1 - fi - - digest=$(openssl dgst "-${algo}" -binary "$file" | openssl base64 -A) - echo "${algo}-${digest}" -} - -# Calculate SHA256 hash of a string. -calculate_hash() { - local str="$1" - - if command -v sha256sum &> /dev/null; then - echo -n "$str" | sha256sum | cut -d' ' -f1 - elif command -v shasum &> /dev/null; then - echo -n "$str" | shasum -a 256 | cut -d' ' -f1 - else - error "Neither sha256sum nor shasum found" - exit 1 - fi -} - -# Download and install Socket CLI. -install_socket_cli() { - local platform - local version - local package_name - local download_url - local dlx_dir - local package_hash - local install_dir - local binary_path - local bin_dir - local symlink_path - - step "Detecting your platform..." - platform=$(detect_platform) - success "Platform detected: ${BOLD}$platform${NC}" - - # Construct package name. - package_name="@socketbin/cli-${platform}" - - step "Fetching latest version from npm..." - version=$(get_latest_version "$package_name") - success "Found version ${BOLD}$version${NC}" - - # Construct download URL from npm registry. - download_url="https://registry.npmjs.org/${package_name}/-/cli-${platform}-${version}.tgz" - - socket_brand "Downloading Socket CLI..." - - # Create DLX directory structure. - dlx_dir="${HOME}/.socket/_dlx" - mkdir -p "$dlx_dir" - - # Calculate content hash for the package. - package_hash=$(calculate_hash "${package_name}@${version}") - install_dir="${dlx_dir}/${package_hash}" - - # Create installation directory. - mkdir -p "$install_dir" - - # Look up the integrity string the registry published for this exact version. - step "Fetching published integrity..." - local expected_integrity - expected_integrity=$(get_published_integrity "$package_name" "$version") - if [ -z "$expected_integrity" ]; then - error "No integrity found in the npm registry metadata for ${package_name}@${version}" - info "Refusing to install without a published checksum to verify against." - exit 1 - fi - - # Algorithm prefix from the SSRI string (e.g. "sha512-..." -> "sha512"). - local integrity_algo="${expected_integrity%%-*}" - - # Download tarball to a temporary location outside the install dir so a - # failed verify can't leave a partial blob where future runs might trust it. - local temp_tarball - if command -v mktemp &> /dev/null; then - temp_tarball=$(mktemp -t socket-cli.XXXXXX.tgz 2>/dev/null || mktemp "${TMPDIR:-/tmp}/socket-cli.XXXXXX") - else - temp_tarball="${TMPDIR:-/tmp}/socket-cli.$$.tgz" - fi - trap 'rm -f "$temp_tarball"' EXIT - - fetch_url_to_file "$download_url" "$temp_tarball" - - # Verify integrity against the value npm published for this version. - step "Verifying integrity..." - local actual_integrity - actual_integrity=$(compute_integrity "$temp_tarball" "$integrity_algo") - if [ "$actual_integrity" != "$expected_integrity" ]; then - error "Integrity check failed for ${package_name}@${version}" - info " expected: ${expected_integrity}" - info " got: ${actual_integrity}" - info "Not installing. Please retry; if this persists, open an issue." - exit 1 - fi - success "Integrity verified (${integrity_algo})" - - # Extract tarball. - step "Capturing lightning in a bottle ⚡" - tar -xzf "$temp_tarball" -C "$install_dir" - - # Get Socket CLI version from extracted package. - local cli_version - if [ -f "${install_dir}/package/package.json" ]; then - cli_version=$(grep -o '"version": *"[^"]*"' "${install_dir}/package/package.json" | head -1 | sed 's/"version": *"\([^"]*\)"/\1/') - if [ -n "$cli_version" ]; then - success "Socket CLI ${BOLD}v${cli_version}${NC} (build ${version})" - fi - fi - - # Find the binary (it's in package/bin/socket or package/bin/socket.exe). - if [ "$platform" = "win32-x64" ] || [ "$platform" = "win32-arm64" ]; then - binary_path="${install_dir}/package/bin/socket.exe" - else - binary_path="${install_dir}/package/bin/socket" - fi - - if [ ! -f "$binary_path" ]; then - error "Binary not found at expected path: $binary_path" - echo "" - info "This might be a temporary issue with the package. Try again in a moment." - exit 1 - fi - - # Make binary executable (Unix-like systems). - if [ "$platform" != "win32-x64" ] && [ "$platform" != "win32-arm64" ]; then - chmod +x "$binary_path" - - # Clear macOS quarantine attribute. - if [ "$platform" = "darwin-x64" ] || [ "$platform" = "darwin-arm64" ]; then - xattr -d com.apple.quarantine "$binary_path" 2>/dev/null || true - success "Cleared macOS security restrictions" - fi - fi - - # Clean up tarball (EXIT trap also handles this in error paths). - rm -f "$temp_tarball" - trap - EXIT - - success "Binary ready at ${BOLD}$binary_path${NC}" - - # Create symlink in user's local bin directory. - bin_dir="${HOME}/.local/bin" - mkdir -p "$bin_dir" - symlink_path="${bin_dir}/socket" - - # Remove existing symlink if present. - if [ -L "$symlink_path" ] || [ -f "$symlink_path" ]; then - step "Replacing existing installation..." - rm "$symlink_path" - fi - - # Create symlink. - step "Creating command shortcut..." - ln -s "$binary_path" "$symlink_path" - success "Command ready: ${BOLD}socket${NC}" - - echo "" - - # Check if ~/.local/bin is in PATH. - if [[ ":$PATH:" != *":${bin_dir}:"* ]]; then - warning "Almost there! One more step needed..." - echo "" - echo " Add ${BOLD}~/.local/bin${NC} to your PATH by adding this line to your shell profile:" - echo " ${BOLD}(~/.bashrc, ~/.zshrc, ~/.bash_profile, or ~/.profile)${NC}" - echo "" - echo " ${CYAN}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}" - echo "" - echo " Then restart your shell or run: ${CYAN}source ~/.zshrc${NC} (or your shell config)" - echo "" - else - success "Your PATH is already configured perfectly!" - fi - - echo "" - if [ -n "$cli_version" ]; then - socket_brand "${BOLD}Socket CLI v${cli_version} installed successfully!${NC}" - else - socket_brand "${BOLD}Socket CLI installed successfully!${NC}" - fi - echo "" - info "Quick start:" - echo -e " ${CYAN}socket --help${NC} Get started with Socket" - echo -e " ${CYAN}socket self-update${NC} Update to the latest version" - echo "" - socket_brand "Happy securing!" -} - -# Main execution. -main() { - echo "" - echo -e "${PURPLE}${BOLD}⚡ Socket CLI Installer ⚡${NC}" - echo -e "${BOLD}═══════════════════════════${NC}" - echo "" - echo " Secure your dependencies with Socket Security" - echo "" - - install_socket_cli -} - -main "$@" diff --git a/knip.json b/knip.json new file mode 100644 index 000000000..de712478d --- /dev/null +++ b/knip.json @@ -0,0 +1,20 @@ +{ + "entry": [ + ".config/*.{js,mjs}", + "bin/*.js", + "scripts/**/*.js", + "shadow-bin/**", + "src/**/*.mts", + "test/**/*.test.mts", + "*.js" + ], + "project": [ + ".config/**", + "bin/**", + "scripts/**", + "shadow-bin/**", + "src/**", + "test/**" + ], + "ignore": ["dist/**"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..abbe8ca8f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,17331 @@ +{ + "name": "socket", + "version": "1.0.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "socket", + "version": "1.0.7", + "license": "MIT", + "bin": { + "socket": "bin/cli.js", + "socket-npm": "bin/npm-cli.js", + "socket-npx": "bin/npx-cli.js" + }, + "devDependencies": { + "@babel/core": "7.27.4", + "@babel/plugin-proposal-export-default-from": "7.27.1", + "@babel/plugin-transform-export-namespace-from": "7.27.1", + "@babel/plugin-transform-runtime": "7.27.4", + "@babel/preset-typescript": "7.27.1", + "@babel/runtime": "7.27.6", + "@biomejs/biome": "2.0.5", + "@coana-tech/cli": "14.9.32", + "@cyclonedx/cdxgen": "11.4.1", + "@dotenvx/dotenvx": "1.45.1", + "@eslint/compat": "1.3.1", + "@eslint/js": "9.29.0", + "@npmcli/arborist": "9.1.2", + "@npmcli/config": "10.3.0", + "@octokit/graphql": "9.0.1", + "@octokit/openapi-types": "25.1.0", + "@octokit/request-error": "7.0.0", + "@octokit/rest": "22.0.0", + "@octokit/types": "14.1.0", + "@pnpm/dependency-path": "1001.0.0", + "@pnpm/lockfile.detect-dep-types": "1001.0.10", + "@pnpm/lockfile.fs": "1001.1.14", + "@pnpm/logger": "1001.0.0", + "@rollup/plugin-babel": "6.0.4", + "@rollup/plugin-commonjs": "28.0.6", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.2.0", + "@socketregistry/hyrious__bun.lockb": "1.0.18", + "@socketregistry/indent-string": "1.0.13", + "@socketregistry/is-interactive": "1.0.6", + "@socketregistry/packageurl-js": "1.0.8", + "@socketsecurity/config": "3.0.1", + "@socketsecurity/registry": "1.0.212", + "@socketsecurity/sdk": "1.4.48", + "@types/blessed": "0.1.25", + "@types/cmd-shim": "5.0.2", + "@types/js-yaml": "4.0.9", + "@types/micromatch": "4.0.9", + "@types/mock-fs": "4.13.4", + "@types/node": "24.0.4", + "@types/npmcli__arborist": "6.3.1", + "@types/npmcli__config": "6.0.3", + "@types/proc-log": "3.0.4", + "@types/semver": "7.7.0", + "@types/which": "3.0.4", + "@types/yargs-parser": "21.0.3", + "@typescript-eslint/parser": "8.35.0", + "@typescript/native-preview": "7.0.0-dev.20250625.1", + "@vitest/coverage-v8": "3.2.4", + "blessed": "0.1.81", + "blessed-contrib": "4.11.0", + "browserslist": "4.25.1", + "chalk-table": "1.0.2", + "cmd-shim": "7.0.0", + "custompatch": "1.1.7", + "del-cli": "6.0.0", + "dev-null-cli": "2.0.0", + "eslint": "9.29.0", + "eslint-import-resolver-typescript": "4.4.3", + "eslint-plugin-import-x": "4.16.0", + "eslint-plugin-n": "17.20.0", + "eslint-plugin-sort-destructure-keys": "2.0.0", + "eslint-plugin-unicorn": "56.0.1", + "globals": "16.2.0", + "hpagent": "1.2.0", + "husky": "9.1.7", + "ignore": "7.0.5", + "js-yaml": "npm:@zkochan/js-yaml@0.0.7", + "knip": "5.61.2", + "lint-staged": "16.1.2", + "magic-string": "0.30.17", + "meow": "13.2.0", + "micromatch": "4.0.8", + "mock-fs": "5.5.0", + "nock": "14.0.5", + "node-gyp": "11.2.0", + "npm-package-arg": "12.0.2", + "npm-run-all2": "8.0.4", + "open": "10.1.2", + "oxlint": "1.3.0", + "pony-cause": "2.1.11", + "rollup": "4.44.0", + "semver": "7.7.2", + "synp": "1.9.14", + "terminal-link": "2.1.1", + "tiny-updater": "3.5.3", + "tinyglobby": "0.2.14", + "trash": "9.0.0", + "type-coverage": "2.29.7", + "typescript-eslint": "8.35.0", + "unplugin-purge-polyfills": "0.1.0", + "vitest": "3.2.4", + "which": "5.0.0", + "yaml": "2.8.0", + "yargs-parser": "22.0.0", + "yoctocolors-cjs": "2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@appthreat/atom": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@appthreat/atom/-/atom-2.2.5.tgz", + "integrity": "sha512-k+BUKc6niDkm0JS+NHN1cfFcOVRJGjnGtJNItl/e1deBm37lHO6z5hczRWXMXMNdtg84vhcF0z5xeqxzvL6IeQ==", + "dev": true, + "license": "MIT", + "optional": true, + "workspaces": [ + "packages/atom-parsetools", + "packages/atom-common" + ], + "dependencies": { + "@appthreat/atom-common": "*", + "@appthreat/atom-parsetools": "*" + }, + "bin": { + "astgen": "packages/atom-parsetools/astgen.js", + "atom": "index.js", + "phpastgen": "packages/atom-parsetools/phpastgen.js", + "rbastgen": "packages/atom-parsetools/rbastgen.js", + "scalasem": "packages/atom-parsetools/scalasem.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@appthreat/atom-common": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@appthreat/atom-common/-/atom-common-1.0.4.tgz", + "integrity": "sha512-JpGQm+Zk/Jjq0eERYDvnYjyQ7MsGnWzOyQZMYfgZl3lcM9Zqe6lMLoid8dpdPPT3J/XRyX7ddukhksK9+mClHA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@appthreat/atom-parsetools": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@appthreat/atom-parsetools/-/atom-parsetools-1.0.4.tgz", + "integrity": "sha512-UqX4XuSanD5N2IrLxLIaDEuLM+CJK+aiz+i+FnG/8z0WvkTF16J9NNpaTnegbNyVbcnbdqEuRB5vxzjnGc6bKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@appthreat/atom-common": "^1.0.4", + "@babel/parser": "^7.27.5", + "typescript": "^5.8.3", + "yargs": "^17.7.2" + }, + "bin": { + "astgen": "astgen.js", + "phpastgen": "phpastgen.js", + "rbastgen": "rbastgen.js", + "scalasem": "scalasem.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@appthreat/cdx-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@appthreat/cdx-proto/-/cdx-proto-1.0.1.tgz", + "integrity": "sha512-r/X6RRn3B4hzRmdvuEmVbqfPV2fItY5y6+J3JJO7hrMMT4bMjYAu1J0rNcT1tbQ1yP91MpgJzyoHTzCqpmw5/A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@bufbuild/protobuf": "1.7.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", + "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz", + "integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz", + "integrity": "sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", + "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", + "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.0.5.tgz", + "integrity": "sha512-MztFGhE6cVjf3QmomWu83GpTFyWY8KIcskgRf2AqVEMSH4qI4rNdBLdpAQ11TNK9pUfLGz3IIOC1ZYwgBePtig==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.0.5", + "@biomejs/cli-darwin-x64": "2.0.5", + "@biomejs/cli-linux-arm64": "2.0.5", + "@biomejs/cli-linux-arm64-musl": "2.0.5", + "@biomejs/cli-linux-x64": "2.0.5", + "@biomejs/cli-linux-x64-musl": "2.0.5", + "@biomejs/cli-win32-arm64": "2.0.5", + "@biomejs/cli-win32-x64": "2.0.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.0.5.tgz", + "integrity": "sha512-VIIWQv9Rcj9XresjCf3isBFfWjFStsdGZvm8SmwJzKs/22YQj167ge7DkxuaaZbNf2kmYif0AcjAKvtNedEoEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.0.5.tgz", + "integrity": "sha512-DRpGxBgf5Z7HUFcNUB6n66UiD4VlBlMpngNf32wPraxX8vYU6N9cb3xQWOXIQVBBQ64QfsSLJnjNu79i/LNmSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.0.5.tgz", + "integrity": "sha512-FQTfDNMXOknf8+g9Eede2daaduRjTC2SNbfWPNFMadN9K3UKjeZ62jwiYxztPaz9zQQsZU8VbddQIaeQY5CmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.5.tgz", + "integrity": "sha512-OpflTCOw/ElEs7QZqN/HFaSViPHjAsAPxFJ22LhWUWvuJgcy/Z8+hRV0/3mk/ZRWy5A6fCDKHZqAxU+xB6W4mA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.0.5.tgz", + "integrity": "sha512-znpfydUDPuDkyBTulnODrQVK2FaG/4hIOPcQSsF2GeauQOYrBAOplj0etGB0NUrr0dFsvaQ15nzDXYb60ACoiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.5.tgz", + "integrity": "sha512-9lmjCnajAzpZXbav2P6D87ugkhnaDpJtDvOH5uQbY2RXeW6Rq18uOUltxgacGBP+d8GusTr+s3IFOu7SN0Ok8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.0.5.tgz", + "integrity": "sha512-CP2wKQB+gh8HdJTFKYRFETqReAjxlcN9AlYDEoye8v2eQp+L9v+PUeDql/wsbaUhSsLR0sjj3PtbBtt+02AN3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.0.5.tgz", + "integrity": "sha512-Sw3rz2m6bBADeQpr3+MD7Ch4E1l15DTt/+dfqKnwkm3cn4BrYwnArmvKeZdVsFRDjMyjlKIP88bw1r7o+9aqzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz", + "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)", + "optional": true + }, + "node_modules/@coana-tech/cli": { + "version": "14.9.32", + "resolved": "https://registry.npmjs.org/@coana-tech/cli/-/cli-14.9.32.tgz", + "integrity": "sha512-kPy48n2R+iJmyopYQ+sAFpgYDatCUoKR7z0MIhsDwilLcreds9INU672zfpShLD7NA7My4vW5jiLgb2EarqPrQ==", + "dev": true, + "bin": { + "cli": "cli.mjs" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cyclonedx/cdxgen": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen/-/cdxgen-11.4.1.tgz", + "integrity": "sha512-lr2NndaeyviMgGQwRUx2K8U7tP3HJFkpbepldaOCcFhj6LSQdokDinkmhSoaaXLJeiiwq+T/H2IJ3jm6oBeiAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.27.4", + "@babel/traverse": "^7.27.4", + "@iarna/toml": "2.2.5", + "@npmcli/arborist": "^9.1.2", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "cheerio": "^1.1.0", + "edn-data": "1.1.2", + "glob": "^11.0.3", + "global-agent": "^3.0.0", + "got": "^14.4.7", + "iconv-lite": "^0.6.3", + "jws": "^4.0.0", + "node-stream-zip": "^1.15.0", + "packageurl-js": "1.0.2", + "prettify-xml": "^1.2.0", + "properties-reader": "^2.3.0", + "semver": "^7.7.2", + "ssri": "^12.0.0", + "table": "^6.9.0", + "tar": "^7.4.3", + "uuid": "^11.1.0", + "validate-iri": "^1.0.1", + "xml-js": "^1.6.11", + "yaml": "^2.8.0", + "yargs": "^17.7.2", + "yoctocolors": "^2.1.1" + }, + "bin": { + "cbom": "bin/cdxgen.js", + "cdx-verify": "bin/verify.js", + "cdxgen": "bin/cdxgen.js", + "cdxgen-secure": "bin/cdxgen.js", + "cdxi": "bin/repl.js", + "evinse": "bin/evinse.js", + "obom": "bin/cdxgen.js", + "saasbom": "bin/cdxgen.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@appthreat/atom": "2.2.5", + "@appthreat/cdx-proto": "1.0.1", + "@cyclonedx/cdxgen-plugins-bin": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-darwin-amd64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-darwin-arm64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linux-amd64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linux-arm": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linux-arm64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linux-ppc64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linuxmusl-amd64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-linuxmusl-arm64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-windows-amd64": "1.6.12", + "@cyclonedx/cdxgen-plugins-bin-windows-arm64": "1.6.12", + "body-parser": "^2.2.0", + "compression": "^1.7.5", + "connect": "^3.7.0", + "jsonata": "^2.0.6", + "sequelize": "^6.37.7", + "sqlite3": "npm:@appthreat/sqlite3@^6.0.6" + } + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin/-/cdxgen-plugins-bin-1.6.12.tgz", + "integrity": "sha512-bw+sdaGO54LE5CX+keXXZRNqt6tkwzNctLUmUrNagSP5AWD3U1q3CgWUD55QKOMZcIIrusaFO2vhQZMsTdp0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-darwin-amd64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-darwin-amd64/-/cdxgen-plugins-bin-darwin-amd64-1.6.12.tgz", + "integrity": "sha512-8iVxUFj3DlCcHmA9+n3sRHcf3gq+ohbhNe9uq+ppsMJ5So48DWdVjHveZN7MWpxTnZqk8KJTAYNPFvaLKAFYBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-darwin-arm64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-darwin-arm64/-/cdxgen-plugins-bin-darwin-arm64-1.6.12.tgz", + "integrity": "sha512-m9uObp61BQb+34YDFoITmB8xQiX7mRx8a2EoEEsgcL26I9h2454cXri4ZqfzMej3loCUlJHxspptzXmYrS8cug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linux-amd64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linux-amd64/-/cdxgen-plugins-bin-linux-amd64-1.6.12.tgz", + "integrity": "sha512-D/vdxpvrtkfYITHpiQjlvhZBNkm2GLmm/lZHbSs5R+nghbulTgXR9I8pTALFTBhjHZQu55DTiACmn/irhhotkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linux-arm": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linux-arm/-/cdxgen-plugins-bin-linux-arm-1.6.12.tgz", + "integrity": "sha512-6c7IGJcbZemZ2RCj3r1BbxPfQZPxzQNvuk4er0xLdcompQITvjmWT/aaYFtPLT5ymDjIfDyVbgDin8PXQwWWIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linux-arm64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linux-arm64/-/cdxgen-plugins-bin-linux-arm64-1.6.12.tgz", + "integrity": "sha512-xd9A25e+4SwHwm9FRglMdsfRsTAROTj3C5eiQ501FI+FOxEbnCODNkDFkplqKqDjDDSIPOgc4llmk9Wk+91c/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linux-ppc64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linux-ppc64/-/cdxgen-plugins-bin-linux-ppc64-1.6.12.tgz", + "integrity": "sha512-JaRY6F+3VBHZMZ51DhC8NhHJkwtLOM1Lnh//iAPw5pAxpQdron+YwfcVIKWhBlFx5zxbz2NI5L7QtiEV/iO4PA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linuxmusl-amd64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linuxmusl-amd64/-/cdxgen-plugins-bin-linuxmusl-amd64-1.6.12.tgz", + "integrity": "sha512-X4eHr0WUtBOiX1Yy8hr35JhpUBJYYbZ31v8KCHtuSK8/h5/0gJidg1OvTGxFWoOLKtPCcb3FvqCY+PSrUcxN0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-linuxmusl-arm64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-linuxmusl-arm64/-/cdxgen-plugins-bin-linuxmusl-arm64-1.6.12.tgz", + "integrity": "sha512-smaxqZ2ZmVm04elK9Dydk1IjEcyRuRt9pSBZxK/RhgJojsJGQaeY/boKR0P/46avoNNiZbVkIDAIbBjfJ5Z7RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-windows-amd64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-windows-amd64/-/cdxgen-plugins-bin-windows-amd64-1.6.12.tgz", + "integrity": "sha512-6reJ+EFAR8YIwSRYMZ/dfF5e+o/JGaAa7h8ByHUcDXdVb9AZDyFKEXu0WeH732FJ1rk06GuNBU2ZFonFvt5lqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@cyclonedx/cdxgen-plugins-bin-windows-arm64": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@cyclonedx/cdxgen-plugins-bin-windows-arm64/-/cdxgen-plugins-bin-windows-arm64-1.6.12.tgz", + "integrity": "sha512-MnYmZXiIZNCPZl3F9KGtKdCjOBfBBI866hdKMojdbgL9u5VMfQBrGA8xTAnAoXPK4MAVRelz42NswieW4OCN+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.45.1.tgz", + "integrity": "sha512-wKHPD+/NMMJVBPg3i98uD9jsURDy+Ck6RQRiWf39TlOAzC+Ge1FkmDk3sgeljYZxA3qF6E7SJmvRqC70XQuuVA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^16.4.5", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js", + "git-dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.3.tgz", + "integrity": "sha512-tapn6XhOueMwht3E2UzY0ZZjYokdaw9XtL9kEyjhQ/Fb9vL9xTFbOaI+fV0AWvTpYu4BNloC6getKW6NtSg4mA==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", + "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", + "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", + "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", + "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.14.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.38.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.7.tgz", + "integrity": "sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", + "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", + "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npm/types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@npm/types/-/types-1.0.2.tgz", + "integrity": "sha512-KXZccTDEnWqNrrx6JjpJKU/wJvNeg9BDgjS0XhmlZab7br921HtyVbsYzJr4L+xIvjdJ20Wh9dgxgCI2a5CEQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/arborist": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.1.2.tgz", + "integrity": "sha512-KIuQc8TuMTcL8OTVmOTdVIXmkDFFOHmVlVd94N9wwHjuOA2ZyNsoJPS50Q/irdkS3LF/9BiIcxSIV/ukSjqO6g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^9.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/config": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-10.3.0.tgz", + "integrity": "sha512-52n09DvIdZq3Hd2Uc8OngwEU9PS4MJ439H6TGd10vpPL5Yp9BTw11sbrjxrJsSIz/msxkOPig0UQDjBjsPGr5A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.1.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", + "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-4.0.2.tgz", + "integrity": "sha512-mnuMuibEbkaBTYj9HQ3dMe6L0ylYW+s/gfz7tBDMFY/la0w9Kf44P9aLn4/+/t3aTR3YUHKoT6XQL9rlicIe3Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@npmcli/map-workspaces/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/map-workspaces/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.0.tgz", + "integrity": "sha512-znLKqdy1ZEGNK3VB9j/RzGyb/P0BJb3fGpvEbHIAyBAXsps2l1ce8SVHfsGAFLl9s8072PxafqTn7RC8wSnQPg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-3.0.0.tgz", + "integrity": "sha512-61cDL8LUc9y80fXn+lir+iVt8IS0xHqEKwPu/5jCjxQTVoSCmkXvw4vbMrzAMtmghz3/AkiBjhHkDKUH+kf7kA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", + "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/package-json/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/package-json/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/query": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-4.0.1.tgz", + "integrity": "sha512-4OIPFb4weUUwkDXJf4Hh1inAn8neBGq3xsH4ZsAaN6FK3ldrFkH7jSpCc7N9xesi0Sp+EBXJ9eGMDrEww2Ztqw==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", + "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.2.tgz", + "integrity": "sha512-ODsoD39Lq6vR6aBgvjTnA3nZGliknKboc9Gtxr7E4WDNqY24MxANKcuDQSF0jzapvGb3KWOEDrKfve4HoWGK+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.1", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz", + "integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.0.1.tgz", + "integrity": "sha512-m1KvHlueScy4mQJWvFDCxFBTIdXS0K1SgFGLmqHyX90mZdCIv6gWBbKRhatxRjhGlONuTK/hztYdaqrTXcFZdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz", + "integrity": "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.2.tgz", + "integrity": "sha512-iYj4SJG/2bbhh+iIpFmG5u49DtJ4lipQ+aPakjL9OKpsGY93wM8w06gvFbEQxcMsZcCvk5th5KkIm2m8o14aWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz", + "integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.2", + "@octokit/plugin-paginate-rest": "^13.0.1", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.1.0.tgz", + "integrity": "sha512-n9y3Lb1+BwsOtm3BmXSUPu3iDtTq7Sf0gX4e+izFTfNrj+u6uTKqbmlq8ggV8CRdg1zGUaCvKNvg/9q3C/19gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.1.0.tgz", + "integrity": "sha512-2aJTPN9/lTmq0xw1YYsy5GDPkTyp92EoYRtw9nVgGErwMvA87duuLnIdoztYk66LGa3g5y4RgOaEapZbK7132A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.1.0.tgz", + "integrity": "sha512-GoPEd9GvEyuS1YyqvAhAlccZeBEyHFkrHPEhS/+UTPcrzDzZ16ckJSmZtwOPhci5FWHK/th4L6NPiOnDLGFrqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.1.0.tgz", + "integrity": "sha512-mQdQDTbw2/RcJKvMi8RAmDECuEC4waM5jeUBn8Cz1pLVddH8MfYJgKbZJUATBNNaHjw/u+Sq9Q1tcJbm8dhpYQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.1.0.tgz", + "integrity": "sha512-HDFQiPl7cX2DVXFlulWOinjqXa5Rj4ydFY9xJCwWAHGx2LmqwLDD8MI0UrHVUaHhLLWn54vjGtwsJK94dtkCwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.1.0.tgz", + "integrity": "sha512-0TFcZSVUQPV1r6sFUf7U2fz0mFCaqh5qMlb2zCioZj0C+xUJghC8bz88/qQUc5SA5K4gqg0WEOXzdqz/mXCLLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.1.0.tgz", + "integrity": "sha512-crG0iy5U9ac99Xkt9trWo5YvtCoSpPUrNZMeUVDkIy1qy1znfv66CveOgCm0G5TwooIIWLJrtFUqi0AkazS3fw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.1.0.tgz", + "integrity": "sha512-aPemnsn/FXADFu7/VnSprO8uVb9UhNVdBdrIlAREh3s7LoW1QksKyP8/DlFe0o2E79MRQ3XF1ONOgW5zLcUmzA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.1.0.tgz", + "integrity": "sha512-eMQ0Iue4Bs0jabCIHiEJbZMPoczdx1oBGOiNS/ykCE76Oos/Hb5uD1FB+Vw4agP2cAxzcp8zHO7MpEW450yswg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.1.0.tgz", + "integrity": "sha512-5IjxRv0vWiGb102QmwF+ljutUWA1+BZbdW+58lFOVzVVo29L+m5PrEtijY5kK0FMTDvwb/xFXpGq3/vQx+bpSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.1.0.tgz", + "integrity": "sha512-+yz7LYHKW1GK+fJoHh9JibgIWDeBHf5wiu1tgDD92y5eLFEBxP+CjJ2caTZnVRREH74l03twOfcTR9EaLsEidQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.10" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.1.0.tgz", + "integrity": "sha512-aTF/1TIq9v86Qy3++YFhKJVKXYSTO54yRRWIXwzpgGvZu41acjN/UsNOG7C2QFy/xdkitrZf1awYgawSqNox3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.1.0.tgz", + "integrity": "sha512-CxalsPMU4oSoZviLMaw01RhLglyN7jrUUhTDRv4pYGcsRxxt5S7e/wO9P/lm5BYgAAq4TtP5MkGuGuMrm//a0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.3.0.tgz", + "integrity": "sha512-TcCaETXYfiEfS+u/gZNND4WwEEtnJJjqg8BIC56WiCQDduYTvmmbQ0vxtqdNXlFzlvmRpZCSs7qaqXNy8/8FLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.3.0.tgz", + "integrity": "sha512-REgq9s1ZWuh++Vi+mUPNddLTp/D+iu+T8nLd3QM1dzQoBD/SZ7wRX3Mdv8QGT/m8dknmDBQuKAP6T47ox9HRSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-QAS8AWKDcDeUe8mJaw/pF2D9+js8FbFTo75AiekZKNm9V6QAAiCkyvesmILD8RrStw9aV2D/apOD71vsfcDoGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-rAbz0KFkk5GPdERoFO4ZUZmVkECnHXjRG0O2MeT5zY7ddlyZUjEk1cWjw+HCtWVdKkqhZJeNFMuEiRLkpzBIIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-6uLO1WsJwCtVNGHtjXwg2TRvxQYttYJKMjSdv6RUXGWY1AI+/+yHzvu+phU/F40uNC7CFhFnqWDuPaSZ49hdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-+vrmJUHgtJmgIo+L9eTP04NI/OQNCOZtQo6I49qGWc9cpr+0MnIh9KMcyAOxmzVTF5g+CF1I/1bUz4pk4I3LDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/win32-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.3.0.tgz", + "integrity": "sha512-k+ETUVl+O3b8Rcd2PP5V3LqQ2QoN/TOX2f19XXHZEynbVLY3twLYPb3hLdXqoo7CKRq3RJdTfn1upHH48/qrZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/win32-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.3.0.tgz", + "integrity": "sha512-nWSgK0fT02TQ/BiAUCd13BaobtHySkCDcQaL+NOmhgeb0tNWjtYiktuluahaIqFcYJPWczVlbs8DU/Eqo8vsug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pnpm/constants": { + "version": "1001.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/constants/-/constants-1001.1.0.tgz", + "integrity": "sha512-xb9dfSGi1qfUKY3r4Zy9JdC9+ZeaDxwfE7HrrGIEsBVY1hvIn6ntbR7A97z3nk44yX7vwbINNf9sizTp0WEtEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/crypto.hash": { + "version": "1000.1.1", + "resolved": "https://registry.npmjs.org/@pnpm/crypto.hash/-/crypto.hash-1000.1.1.tgz", + "integrity": "sha512-lb5kwXaOXdIW/4bkLLmtM9HEVRvp2eIvp+TrdawcPoaptgA/5f0/sRG0P52BF8dFqeNDj+1tGdqH89WQEqJnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/crypto.polyfill": "1000.1.0", + "@pnpm/graceful-fs": "1000.0.0", + "ssri": "10.0.5" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/crypto.hash/node_modules/ssri": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", + "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@pnpm/crypto.polyfill": { + "version": "1000.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/crypto.polyfill/-/crypto.polyfill-1000.1.0.tgz", + "integrity": "sha512-tNe7a6U4rCpxLMBaR0SIYTdjxGdL0Vwb3G1zY8++sPtHSvy7qd54u8CIB0Z+Y6t5tc9pNYMYCMwhE/wdSY7ltg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/dependency-path": { + "version": "1001.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/dependency-path/-/dependency-path-1001.0.0.tgz", + "integrity": "sha512-6lDmcQUO87BntRHKmmv1+ZbdZUMsGM6WCFp3y1jEqgvDOaWHM2AgrLeiBs2zf2Wyf4HeijX6rNGohZvgY3uRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/crypto.hash": "1000.1.1", + "@pnpm/types": "1000.6.0", + "semver": "^7.7.1" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/error": { + "version": "1000.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/error/-/error-1000.0.2.tgz", + "integrity": "sha512-2SfE4FFL73rE1WVIoESbqlj4sLy5nWW4M/RVdHvCRJPjlQHa9MH7m7CVJM204lz6I+eHoB+E7rL3zmpJR5wYnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/constants": "1001.1.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/git-utils": { + "version": "1000.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/git-utils/-/git-utils-1000.0.0.tgz", + "integrity": "sha512-W6isNTNgB26n6dZUgwCw6wly+uHQ2Zh5QiRKY1HHMbLAlsnZOxsSNGnuS9euKWHxDftvPfU7uR8XB5x95T5zPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "npm:safe-execa@0.1.2" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/graceful-fs": { + "version": "1000.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/graceful-fs/-/graceful-fs-1000.0.0.tgz", + "integrity": "sha512-RvMEliAmcfd/4UoaYQ93DLQcFeqit78jhYmeJJVPxqFGmj0jEcb9Tu0eAOXr7tGP3eJHpgvPbTU4o6pZ1bJhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/lockfile.detect-dep-types": { + "version": "1001.0.10", + "resolved": "https://registry.npmjs.org/@pnpm/lockfile.detect-dep-types/-/lockfile.detect-dep-types-1001.0.10.tgz", + "integrity": "sha512-br29n+SziImw/IlNvHwWnEu82SeVQ76T9KCV3PIlFQLGULyvVcUJHY7+KpRFWMvhn5a9agUQeJFUjPq9dHeT2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/dependency-path": "1001.0.0", + "@pnpm/lockfile.types": "1001.0.8", + "@pnpm/types": "1000.6.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/lockfile.fs": { + "version": "1001.1.14", + "resolved": "https://registry.npmjs.org/@pnpm/lockfile.fs/-/lockfile.fs-1001.1.14.tgz", + "integrity": "sha512-jQqx6yM8d9OoUP0B9Z7nMljyGNi8Q2RyvE5f9KOZhEmpW6JZ7SKaYTTfcHj33hxcPNx28ppmtgGiRGeKvj/xcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/constants": "1001.1.0", + "@pnpm/dependency-path": "1001.0.0", + "@pnpm/error": "1000.0.2", + "@pnpm/git-utils": "1000.0.0", + "@pnpm/lockfile.merger": "1001.0.8", + "@pnpm/lockfile.types": "1001.0.8", + "@pnpm/lockfile.utils": "1002.0.0", + "@pnpm/object.key-sorting": "1000.0.1", + "@pnpm/types": "1000.6.0", + "@zkochan/rimraf": "^3.0.2", + "comver-to-semver": "^1.0.0", + "js-yaml": "npm:@zkochan/js-yaml@0.0.7", + "normalize-path": "^3.0.0", + "ramda": "npm:@pnpm/ramda@0.28.1", + "semver": "^7.7.1", + "strip-bom": "^4.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + }, + "peerDependencies": { + "@pnpm/logger": ">=1001.0.0 <1002.0.0" + } + }, + "node_modules/@pnpm/lockfile.merger": { + "version": "1001.0.8", + "resolved": "https://registry.npmjs.org/@pnpm/lockfile.merger/-/lockfile.merger-1001.0.8.tgz", + "integrity": "sha512-oB9ABNyxn2yiCr7fGfhrZw2ANXjs9IW9F0y+MyKlryNFHgEsw7972WKOtb1zMRozLg1tU0i+hSLt/Bh8imuTIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/lockfile.types": "1001.0.8", + "@pnpm/types": "1000.6.0", + "comver-to-semver": "^1.0.0", + "ramda": "npm:@pnpm/ramda@0.28.1", + "semver": "^7.7.1" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/lockfile.types": { + "version": "1001.0.8", + "resolved": "https://registry.npmjs.org/@pnpm/lockfile.types/-/lockfile.types-1001.0.8.tgz", + "integrity": "sha512-rKecvWutX7aZPFNyXGnGtiwfmnPRiQyG6AWQ1Ad0djWKbPeccg0s9B7cJqCJ4nEnwzhEvw9UtuofBkU/O0L+bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/patching.types": "1000.1.0", + "@pnpm/types": "1000.6.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/lockfile.utils": { + "version": "1002.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/lockfile.utils/-/lockfile.utils-1002.0.0.tgz", + "integrity": "sha512-eAjMsSDe4tmdWd4dnAxujgHW6ZGlFbswYZQCzqKFyrbqsvU+fqabsxD0+oHUkWjjCgEDZOs5NoMduhyiYuh2jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/dependency-path": "1001.0.0", + "@pnpm/lockfile.types": "1001.0.8", + "@pnpm/pick-fetcher": "1000.0.1", + "@pnpm/resolver-base": "1004.0.0", + "@pnpm/types": "1000.6.0", + "get-npm-tarball-url": "^2.1.0", + "ramda": "npm:@pnpm/ramda@0.28.1" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/logger": { + "version": "1001.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/logger/-/logger-1001.0.0.tgz", + "integrity": "sha512-nj80XtTHHt7T+b5stLWszzd166MbGx4eTOu9+6h6RdelKMlSWhrb7KUb0j90tYk+yoGx8TeMVdJCaoBnkLp8xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bole": "^5.0.17", + "ndjson": "^2.0.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/object.key-sorting": { + "version": "1000.0.1", + "resolved": "https://registry.npmjs.org/@pnpm/object.key-sorting/-/object.key-sorting-1000.0.1.tgz", + "integrity": "sha512-YTJCXyUGOrJuj4QqhSKqZa1vlVAm82h1/uw00ZmD/kL2OViggtyUwWyIe62kpwWVPwEYixfGjfvaFKVJy2mjzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/util.lex-comparator": "^3.0.2", + "sort-keys": "^4.2.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/patching.types": { + "version": "1000.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/patching.types/-/patching.types-1000.1.0.tgz", + "integrity": "sha512-Zib2ysLctRnWM4KXXlljR44qSKwyEqYmLk+8VPBDBEK3l5Gp5mT3N4ix9E4qjYynvFqahumsxzOfxOYQhUGMGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/pick-fetcher": { + "version": "1000.0.1", + "resolved": "https://registry.npmjs.org/@pnpm/pick-fetcher/-/pick-fetcher-1000.0.1.tgz", + "integrity": "sha512-ETF8ZC6lCLbDzMBUX7kX7srn6Wbqsk/m4ecszRJewtXl3ugQkLw1SA1B6FTPc37EJDPjBShmmKrZF2zMXa6uUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/resolver-base": { + "version": "1004.0.0", + "resolved": "https://registry.npmjs.org/@pnpm/resolver-base/-/resolver-base-1004.0.0.tgz", + "integrity": "sha512-hPCwGIDJBRBSojFhyoLFEmzd3TGL4NiFqaDNufdjIr+nK5FhyAPwWEJwCNm4/cHtk91aDkJ3qOpIk9RbdQwC3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/types": "1000.6.0" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/types": { + "version": "1000.6.0", + "resolved": "https://registry.npmjs.org/@pnpm/types/-/types-1000.6.0.tgz", + "integrity": "sha512-6PsMNe98VKPGcg6LnXSW/LE3YfJ77nj+bPKiRjYRWAQLZ+xXjEQRaR0dAuyjCmchlv4wR/hpnMVRS21/fCod5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, + "node_modules/@pnpm/util.lex-comparator": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/util.lex-comparator/-/util.lex-comparator-3.0.2.tgz", + "integrity": "sha512-blFO4Ws97tWv/SNE6N39ZdGmZBrocXnBOfVp0ln4kELmns4pGPZizqyRtR8EjfOLMLstbmNCTReBoDvLz1isVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + } + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", + "integrity": "sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz", + "integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", + "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz", + "integrity": "sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", + "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz", + "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz", + "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz", + "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz", + "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz", + "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz", + "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz", + "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz", + "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz", + "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz", + "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz", + "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sigstore/bundle": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", + "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.1.tgz", + "integrity": "sha512-7MJXQhIm7dWF9zo7rRtMYh8d2gSnc3+JddeQOTIg6gUN7FjcuckZ9EwGq+ReeQtbbl3Tbf5YqRrWxA1DMfIn+w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", + "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", + "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", + "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sindresorhus/chunkify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/chunkify/-/chunkify-1.0.0.tgz", + "integrity": "sha512-YJOcVaEasXWcttXetXn0jd6Gtm9wFHQ1gViTPcxhESwkMCOoA4kwFsNr9EGcmsARGx7jXQZWmOR4zQotRcI9hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-3.1.1.tgz", + "integrity": "sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sindresorhus/df/node_modules/execa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-2.1.0.tgz", + "integrity": "sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^3.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": "^8.12.0 || >=9.7.0" + } + }, + "node_modules/@sindresorhus/df/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df/node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sindresorhus/df/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/df/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@sindresorhus/is": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.1.tgz", + "integrity": "sha512-QWLl2P+rsCJeofkDNIT3WFmb6NrRud1SUYW8dIhXK/46XFV8Q/g7Bsvib0Askb0reRLe+WYPeeE+l5cH7SlkuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@socketregistry/hyrious__bun.lockb": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@socketregistry/hyrious__bun.lockb/-/hyrious__bun.lockb-1.0.18.tgz", + "integrity": "sha512-r1c03syFohMbFXAa3BNe+JyUQhynJmHrK8/6aL8DbTdwGVI0oHSnWxGVHjoPGPINAi+N2J5/CNm8kId3MBwelA==", + "dev": true, + "license": "MIT", + "bin": { + "lockb": "cli.cjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@socketregistry/indent-string": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@socketregistry/indent-string/-/indent-string-1.0.13.tgz", + "integrity": "sha512-h8MfBgjoPFiRYp60S9qzQJrmNIE/jAnqrjWZRGnHeKmpBH5M3DTwblrPG3hqxlu9IDtiu7H9NDvDGfFcM7dirw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@socketregistry/is-interactive": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/is-interactive/-/is-interactive-1.0.6.tgz", + "integrity": "sha512-KbKE6j98nf+cZum6lAO5ubP/Sid5tbbl3S7XYb8VFu3RaHy9I1uIZ/dcM932xYk3+TQuoXgV3pzqAM2ekqA1tA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@socketregistry/packageurl-js": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@socketregistry/packageurl-js/-/packageurl-js-1.0.8.tgz", + "integrity": "sha512-eZkWrz7aufcZ2BQnS9VvMuRiDRXjV1P1mWAlidv9aJJ4qzfWnjUE/bRZvMSTxPrCW4gK9LupJt5KN0ir/7IMmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@socketsecurity/config": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@socketsecurity/config/-/config-3.0.1.tgz", + "integrity": "sha512-kLKdSqi4W7SDSm5z+wYnfVRnZCVhxzbzuKcdOZSrcHoEGOT4Gl844uzoaML+f5eiQMxY+nISiETwRph/aXrIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "pony-cause": "^2.1.8", + "yaml": "^2.2.1" + }, + "engines": { + "node": "18.20.7 || ^20.18.3 || >=22.14.0" + } + }, + "node_modules/@socketsecurity/registry": { + "version": "1.0.212", + "resolved": "https://registry.npmjs.org/@socketsecurity/registry/-/registry-1.0.212.tgz", + "integrity": "sha512-6kXkHIscvs0Er1oaQ5l8IWoQppVJ76N+G2IgndPf2fZZg+LP++r9Esuj6pC60lVN0YMpO+dbREU0bNiwvJ64JA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@socketsecurity/sdk": { + "version": "1.4.48", + "resolved": "https://registry.npmjs.org/@socketsecurity/sdk/-/sdk-1.4.48.tgz", + "integrity": "sha512-4jqp6bqhuy324lncvHreKyj3lV1+9oB2OC6X7N9R5NQUSDSNeIbBfyG/FY1D6NnMj5MWxmcC6fSNA7Q7zzvzZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socketsecurity/registry": "1.0.209" + }, + "engines": { + "node": "18.20.7 || ^20.18.3 || >=22.14.0" + } + }, + "node_modules/@stroncium/procfs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@stroncium/procfs/-/procfs-1.2.1.tgz", + "integrity": "sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/blessed": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.25.tgz", + "integrity": "sha512-kQsjBgtsbJLmG6CJA+Z6Nujj+tq1fcSE3UIowbDvzQI4wWmoTV7djUDhSo5lDjgwpIN0oRvks0SA5mMdKE5eFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cacache": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@types/cacache/-/cacache-17.0.2.tgz", + "integrity": "sha512-IrqHzVX2VRMDQQKa7CtKRnuoCLdRJiLW6hWU+w7i7+AaQ0Ii5bKwJxd5uRK4zBCyrHd3tG6G8zOm2LplxbSfQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/cmd-shim": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/cmd-shim/-/cmd-shim-5.0.2.tgz", + "integrity": "sha512-Pnee6lEDnxqVmV0SBKGmAFKCmdZtI7sIYI3qCo5iNIZ1SYNspDFwWVJll8F3zvl0Ap/a/XllHiaV8sA9UTjdeA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/micromatch": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mock-fs": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", + "integrity": "sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/node": { + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", + "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/npm-package-arg": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/npm-package-arg/-/npm-package-arg-6.1.4.tgz", + "integrity": "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/npm-registry-fetch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@types/npm-registry-fetch/-/npm-registry-fetch-8.0.7.tgz", + "integrity": "sha512-db9iBh7kDDg4lRT4k4XZ6IiecTEgFCID4qk+VDVPbtzU855q3KZLCn08ATr4H27ntRJVhulQ7GWjl24H42x96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/node-fetch": "*", + "@types/npm-package-arg": "*", + "@types/npmlog": "*", + "@types/ssri": "*" + } + }, + "node_modules/@types/npmcli__arborist": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@types/npmcli__arborist/-/npmcli__arborist-6.3.1.tgz", + "integrity": "sha512-CUADRvIKRFwVuiroLQ0wWzOpeOcL8OacCbODtZZxMOA+PBg1au/D8ry/zBnQWdEH+i0IXKeNL2Nt0er30bYWng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@npm/types": "^1", + "@types/cacache": "*", + "@types/node": "*", + "@types/npmcli__package-json": "*", + "@types/pacote": "*" + } + }, + "node_modules/@types/npmcli__config": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/npmcli__config/-/npmcli__config-6.0.3.tgz", + "integrity": "sha512-JasDNjgkmtYWGJxMmhmfc8gRrRgcONd4DRaUTD/jWGhwIJSkUMSGHPatTVfUmD7QopQh93TzDH14FZL5tB2tEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/semver": "*" + } + }, + "node_modules/@types/npmcli__package-json": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/npmcli__package-json/-/npmcli__package-json-4.0.4.tgz", + "integrity": "sha512-6QjlFUSHBmZJWuC08bz1ZCx6tm4t+7+OJXAdvM6tL2pI7n6Bh5SIp/YxQvnOLFf8MzCXs2ijyFgrzaiu1UFBGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/npmlog": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/npmlog/-/npmlog-7.0.0.tgz", + "integrity": "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pacote": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/@types/pacote/-/pacote-11.1.8.tgz", + "integrity": "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/npm-registry-fetch": "*", + "@types/npmlog": "*", + "@types/ssri": "*" + } + }, + "node_modules/@types/proc-log": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/proc-log/-/proc-log-3.0.4.tgz", + "integrity": "sha512-E1DsqzHqsKRkFoY6VFjnU15gOGwyDrCgtcH32X1Uq79E50V4CiMJWF7PRakcdwgGfHJfcGfq+hO8Sk2u1ZFVXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ssri": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/ssri/-/ssri-7.1.5.tgz", + "integrity": "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/which": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", + "integrity": "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.35.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-7781zmsKURCHknc37H4U4la4kZduyxmmUshZLBzNhPHhV5DKo++K8MF69kxhRG3/vS4HBhozf0YI0mZMIbkSDA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20250625.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20250625.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20250625.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20250625.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20250625.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20250625.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20250625.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-JcLCql0O6+0iHIMllvax02kqpNtY1RUckGKomuO5kSbrOo9PsR+6r5MEcspfj47gwOl7AS0vrGhBCFFogF+KGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-0vCkk3FdS92W625JyzA8Slu/0vgkeu10fRQNfgIbf+E29DKMKnwXW56WhHSdGXAivU44Mewwc589+CbsABq3Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-MumU7p+09ikH/x5IOJRV6DUj6N5/0kSlI4IsAUPtpT2WGkQdDtL2CC523/94YvOfWB1/+9r01636LVCGOJ135g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-IgnoWQSKeoeL7Y7tvlbcDQx0nidK3UWa/bbm1zJv+AfQlAGMrEMygp+ZzocmycUCYOVM0dcIbymjoiI/QRHTng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-6fE8piqPfzPPqmQ37ewTSbm4HW0cNqOEhfLG2F37zJd4525mefhIpWvj2iCkEHWp+BDlF2dYCbB4cY2nmfrNNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-ppCkjBAFotPxL8j9Vk5cNSwMreOvAt02AMa5Hko3JQGSVA2TQCIlvTFn+SHSIWzYbzomc9j4j5WOcOR0rmAAHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20250625.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20250625.1.tgz", + "integrity": "sha512-BsnJqso5MKAW4Y7fPmcamJ+EIrWOTqwLjeZP74NNFvTqCsA4RkITCw4NpLwD0lzrv9VsQcQ+bNwB8DrT+oDqoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.0.tgz", + "integrity": "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.0.tgz", + "integrity": "sha512-sG1NHtgXtX8owEkJ11yn34vt0Xqzi3k9TJ8zppDmyG8GZV4kVWw44FHwKwHeEFl07uKPeC4ZoyuQaGh5ruJYPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.0.tgz", + "integrity": "sha512-nJ9z47kfFnCxN1z/oYZS7HSNsFh43y2asePzTEZpEvK7kGyuShSl3RRXnm/1QaqFL+iP+BjMwuB+DYUymOkA5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.0.tgz", + "integrity": "sha512-TK+UA1TTa0qS53rjWn7cVlEKVGz2B6JYe0C++TdQjvWYIyx83ruwh0wd4LRxYBM5HeuAzXcylA9BH2trARXJTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.0.tgz", + "integrity": "sha512-6uZwzMRFcD7CcCd0vz3Hp+9qIL2jseE/bx3ZjaLwn8t714nYGwiE84WpaMCYjU+IQET8Vu/+BNAGtYD7BG/0yA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.0.tgz", + "integrity": "sha512-bPUBksQfrgcfv2+mm+AZinaKq8LCFvt5PThYqRotqSuuZK1TVKkhbVMS/jvSRfYl7jr3AoZLYbDkItxgqMKRkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.0.tgz", + "integrity": "sha512-uT6E7UBIrTdCsFQ+y0tQd3g5oudmrS/hds5pbU3h4s2t/1vsGWbbSKhBSCD9mcqaqkBwoqlECpUrRJCmldl8PA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.0.tgz", + "integrity": "sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.0.tgz", + "integrity": "sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.0.tgz", + "integrity": "sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.0.tgz", + "integrity": "sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.0.tgz", + "integrity": "sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.0.tgz", + "integrity": "sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.0.tgz", + "integrity": "sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.0.tgz", + "integrity": "sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.0.tgz", + "integrity": "sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.0.tgz", + "integrity": "sha512-rknkrTRuvujprrbPmGeHi8wYWxmNVlBoNW8+4XF2hXUnASOjmuC9FNF1tGbDiRQWn264q9U/oGtixyO3BT8adQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.0.tgz", + "integrity": "sha512-Ceymm+iBl+bgAICtgiHyMLz6hjxmLJKqBim8tDzpX61wpZOx2bPK6Gjuor7I2RiUynVjvvkoRIkrPyMwzBzF3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.0.tgz", + "integrity": "sha512-k59o9ZyeyS0hAlcaKFezYSH2agQeRFEB7KoQLXl3Nb3rgkqT1NY9Vwy+SqODiLmYnEjxWJVRE/yq2jFVqdIxZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@zkochan/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@zkochan/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-GBf4ua7ogWTr7fATnzk/JLowZDBnBJMm8RkMaC/KcvxZ9gxbMWix0/jImd815LmqKyIHZ7h7lADRddGMdGBuCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + } + }, + "node_modules/@zkochan/which": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@zkochan/which/-/which-2.0.3.tgz", + "integrity": "sha512-C1ReN7vt2/2O0fyTsx5xnbQuxBrmG5NMSbcIkPKCCfCTJgpZBsuRYzFXHj3nVq8vTfK7vxHUmzfCpSHgO7j4rg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "name": "@socketregistry/aggregate-error", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@socketregistry/aggregate-error/-/aggregate-error-1.0.13.tgz", + "integrity": "sha512-z1yqCyaUko1HXePZD+GZdO4eUa8AnUJmzz3gff4nxDzMYA19B+xca1qXkYqNf1HmQSgTq9k9BGwXu4ZTisEH5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-term": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/ansi-term/-/ansi-term-0.0.2.tgz", + "integrity": "sha512-jLnGE+n8uAjksTJxiWZf/kcUmXq+cRWSl550B9NmQ8YiqaTM+lILcSe5dHdp8QkJPhaOghDjnMKwyYSMjosgAA==", + "dev": true, + "license": "ISC", + "dependencies": { + "x256": ">=0.0.1" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", + "integrity": "sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", + "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.4", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", + "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.4" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/bin-links/node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "dev": true, + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/blessed-contrib": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/blessed-contrib/-/blessed-contrib-4.11.0.tgz", + "integrity": "sha512-P00Xji3xPp53+FdU9f74WpvnOAn/SS0CKLy4vLAf5Ps7FGDOTY711ruJPZb3/7dpFuP+4i7f4a/ZTZdLlKG9WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-term": ">=0.0.2", + "chalk": "^1.1.0", + "drawille-canvas-blessed-contrib": ">=0.1.3", + "lodash": "~>=4.17.21", + "map-canvas": ">=0.1.5", + "marked": "^4.0.12", + "marked-terminal": "^5.1.1", + "memory-streams": "^0.1.0", + "memorystream": "^0.3.1", + "picture-tuber": "^1.0.1", + "sparkline": "^0.1.1", + "strip-ansi": "^3.0.0", + "term-canvas": "0.0.5", + "x256": ">=0.0.1" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bole": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/bole/-/bole-5.0.19.tgz", + "integrity": "sha512-OgMuI8erST2t4K/Y+tSsn4SOxlKj4JR2wluQgLYadQFPIhj0r3jcmnp0OthgiyNO91CnxR8woKeLQmnMPgl1Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "^2.0.7", + "individual": "^3.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bresenham": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/bresenham/-/bresenham-0.0.3.tgz", + "integrity": "sha512-wbMxoJJM1p3+6G7xEFXYNCJ30h2qkwmVxebkbwIl4OcnWtno5R3UT9VuYLfStlVNAQCmRjkGwjPFdfaPd4iNXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "dev": true, + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz", + "integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.4", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.4", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.1", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/chalk-table": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chalk-table/-/chalk-table-1.0.2.tgz", + "integrity": "sha512-lmtmQtr/GCtbiJiiuXPE5lj0arIXJir5hSjIhye/4Uyr7oTQlP+ufPnHzUS3Bre0xS/VWbz9NfeuPnvse9BXoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "strip-ansi": "^5.2.0" + } + }, + "node_modules/chalk-table/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk-table/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk-table/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk-table/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/chalk-table/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/chalk-table/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk-table/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk-table/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/charm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz", + "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==", + "dev": true, + "license": "MIT/X11" + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cheerio": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", + "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.10.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "dev": true, + "license": "ISC" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/comver-to-semver": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/comver-to-semver/-/comver-to-semver-1.0.0.tgz", + "integrity": "sha512-gcGtbRxjwROQOdXLUWH1fQAXqThUVRZ219aAwgtX3KfYw429/Zv6EIJRf5TBSzWdAGwePmqH7w70WTaX4MDqag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/custompatch": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/custompatch/-/custompatch-1.1.7.tgz", + "integrity": "sha512-NjSIHt9lgfCDdy/2Jcenq0vbfx2cciRDfCe82033AEBP53SHA9fHXjz/zPQWmJvrxf8UqPfwggh1+mIetL/g3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^12.1.0", + "diff": "^8.0.2", + "pacote": "^18.0.6" + }, + "bin": { + "custompatch": "index.mjs" + }, + "engines": { + "node": ">= 16.20.0", + "npm": ">= 9.6.7" + } + }, + "node_modules/custompatch/node_modules/@npmcli/agent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", + "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", + "integrity": "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/git": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.8.tgz", + "integrity": "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^7.0.0", + "ini": "^4.1.3", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^4.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/installed-package-contents": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz", + "integrity": "sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", + "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/package-json": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.2.1.tgz", + "integrity": "sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/promise-spawn": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz", + "integrity": "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/redact": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-2.0.1.tgz", + "integrity": "sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@npmcli/run-script": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-8.1.0.tgz", + "integrity": "sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "proc-log": "^4.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/bundle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz", + "integrity": "sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", + "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/protobuf-specs": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.3.tgz", + "integrity": "sha512-RpacQhBlwpBWd7KEJsRKcBQalbV28fvkxwTOJIqhIuDysMMaJW47V4OqW30iJB9uRpqOSxxEAQFdr8tTattReQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/sign": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.2.tgz", + "integrity": "sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/tuf": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.4.tgz", + "integrity": "sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@sigstore/verify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.1.tgz", + "integrity": "sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.1.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/@tufjs/models": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.1.tgz", + "integrity": "sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/cacache": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", + "integrity": "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/custompatch/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/custompatch/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/ignore-walk": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.5.tgz", + "integrity": "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/custompatch/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/custompatch/node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/custompatch/node_modules/make-fetch-happen": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", + "integrity": "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/minipass-fetch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.5.tgz", + "integrity": "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/custompatch/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/custompatch/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/custompatch/node_modules/node-gyp": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.3.1.tgz", + "integrity": "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-bundled": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.1.tgz", + "integrity": "sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-install-checks": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", + "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", + "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-packlist": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", + "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^6.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-pick-manifest": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz", + "integrity": "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/npm-registry-fetch": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz", + "integrity": "sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^2.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/custompatch/node_modules/pacote": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-18.0.6.tgz", + "integrity": "sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/package-json": "^5.1.0", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^8.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^17.0.0", + "proc-log": "^4.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^2.2.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/custompatch/node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/sigstore": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.1.tgz", + "integrity": "sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/ssri": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", + "integrity": "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/custompatch/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/custompatch/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/custompatch/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/custompatch/node_modules/tuf-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", + "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/custompatch/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/del": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-8.0.0.tgz", + "integrity": "sha512-R6ep6JJ+eOBZsBr9esiNN1gxFbZE4Q2cULkUSFumGYecAiS6qodDvcPx/sFuWHMNul7DWmrtoEOpYSm7o6tbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "^14.0.2", + "is-glob": "^4.0.3", + "is-path-cwd": "^3.0.0", + "is-path-inside": "^4.0.0", + "p-map": "^7.0.2", + "slash": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/del-cli/-/del-cli-6.0.0.tgz", + "integrity": "sha512-9nitGV2W6KLFyya4qYt4+9AKQFL+c0Ehj5K7V7IwlxTc6RMCfQUGY9E9pLG6e8TQjtwXpuiWIGGZb3mfVxyZkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^8.0.0", + "meow": "^13.2.0" + }, + "bin": { + "del": "cli.js", + "del-cli": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dev-null-cli": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dev-null-cli/-/dev-null-cli-2.0.0.tgz", + "integrity": "sha512-7wwzBy6Yo0UqCI+mNRtltZxAuqhmDWE4UPA0yiANku4ya6j6ABt1Uf+jpF8kheObKYWLH/r9Q/3gHsHADdduqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^10.1.1", + "noop-stream": "^1.0.0" + }, + "bin": { + "dev-null": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/dev-null-cli/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/dev-null-cli/node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/dev-null-cli/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dev-null-cli/node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dev-null-cli/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/dev-null-cli/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/dev-null-cli/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dir-glob/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/drawille-blessed-contrib": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/drawille-blessed-contrib/-/drawille-blessed-contrib-1.0.0.tgz", + "integrity": "sha512-WnHMgf5en/hVOsFhxLI8ZX0qTJmerOsVjIMQmn4cR1eI8nLGu+L7w5ENbul+lZ6w827A3JakCuernES5xbHLzQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/drawille-canvas-blessed-contrib": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/drawille-canvas-blessed-contrib/-/drawille-canvas-blessed-contrib-0.1.3.tgz", + "integrity": "sha512-bdDvVJOxlrEoPLifGDPaxIzFh3cD7QH05ePoQ4fwnqfi08ZSxzEhOUpI5Z0/SQMlWgcCQOEtuw0zrwezacXglw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-term": ">=0.0.2", + "bresenham": "0.0.3", + "drawille-blessed-contrib": ">=0.0.1", + "gl-matrix": "^2.1.0", + "x256": ">=0.0.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/eciesjs": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz", + "integrity": "sha512-eJAgf9pdv214Hn98FlUzclRMYWF7WfoLlkS9nWMTm1qcCwn6Ad4EGD9lr9HXMBfSrZhYQujRE+p0adPRkctC6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.2", + "@noble/ciphers": "^1.0.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/edn-data": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/edn-data/-/edn-data-1.1.2.tgz", + "integrity": "sha512-RI1i17URvOrBtSNEccbsXkuUZdc67QUBMqXGF62KPek85EdFGS2UKw76hNhOBl5kK4h7V4d32Ut15b/XVwKEXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.174", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.174.tgz", + "integrity": "sha512-HE43yYdUUiJVjewV2A9EP8o89Kb4AqMKplMQP2IxEPUws1Etu/ZkdsgUDabUZ/WmbP4ZbvJDOcunvbBUPPIfmw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eol": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/eol/-/eol-0.10.0.tgz", + "integrity": "sha512-+w3ktYrOphcIqC1XKmhQYvM+o2uxgQFiimL7B6JPZJlWVxf7Lno9e/JWLPIgbHo7DoZ+b7jsf/NzrUcNe6ZTZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ryanve" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "name": "@socketregistry/es-define-property", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/es-define-property/-/es-define-property-1.0.6.tgz", + "integrity": "sha512-GAQnUvZEqut9Rkjg81CYLSa+3gvSle3Lr1VoyVky3xyIkVg2DxY+4a96qa6cdU8bBHSLxebJw1vQyFEHe+vNHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz", + "integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.1.1" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.3.tgz", + "integrity": "sha512-elVDn1eWKFrWlzxlWl9xMt8LltjKl161Ix50JFC50tHXI5/TRP32SNEqlJ/bo/HV+g7Rou/tlPQU2AcRtIhrOg==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.1.1", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.0.tgz", + "integrity": "sha512-g67gvUrgE1VeZ9lFoFM6RfYSh+R3kkxbxDMvNTsz+jxRmj5NA7SHCzhO5O+hDCnSTlLnITMFcl9/hXWudMvX7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.34.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.0.1", + "semver": "^7.7.2", + "stable-hash-x": "^0.1.1", + "unrs-resolver": "^1.9.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.20.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.20.0.tgz", + "integrity": "sha512-IRSoatgB/NQJZG5EeTbv/iAx1byOGdbbyhQrNvWdCfTnmPxUT0ao9/eGOeG7ljD8wJBsxwE8f6tES5Db0FRKEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "@typescript-eslint/utils": "^8.26.1", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "ignore": "^5.3.2", + "minimatch": "^9.0.5", + "semver": "^7.6.3", + "ts-declaration-location": "^1.0.6" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-sort-destructure-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sort-destructure-keys/-/eslint-plugin-sort-destructure-keys-2.0.0.tgz", + "integrity": "sha512-4w1UQCa3o/YdfWaLr9jY8LfGowwjwjmwClyFLxIsToiyIdZMq3x9Ti44nDn34DtTPP7PWg96tUONKVmATKhYGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "natural-compare-lite": "^1.4.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": "5 - 9" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.1.tgz", + "integrity": "sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.24.7", + "@eslint-community/eslint-utils": "^4.4.0", + "ci-info": "^4.0.0", + "clean-regexp": "^1.0.0", + "core-js-compat": "^3.38.1", + "esquery": "^1.6.0", + "globals": "^15.9.0", + "indent-string": "^4.0.0", + "is-builtin-module": "^3.2.1", + "jsesc": "^3.0.2", + "pluralize": "^8.0.0", + "read-pkg-up": "^7.0.1", + "regexp-tree": "^0.1.27", + "regjsparser": "^0.10.0", + "semver": "^7.6.3", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=18.18" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=8.56.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-stream": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-0.9.8.tgz", + "integrity": "sha512-o5h0Mp1bkoR6B0i7pTCAzRy+VzdsRWH997KQD4Psb0EOPoKEIiaRx/EsOdUl7p1Ktjw7aIWvweI/OY1R9XrlUg==", + "dev": true, + "dependencies": { + "optimist": "0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/event-stream/node_modules/optimist": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.2.8.tgz", + "integrity": "sha512-Wy7E3cQDpqsTIFyW7m22hSevyTLxw850ahYv7FWsw4G6MIKVTZ8NSA95KBrQ95a4SMsMr1UGUUnwEFKhVaSzIg==", + "dev": true, + "license": "MIT/X11", + "dependencies": { + "wordwrap": ">=0.0.1 <0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "name": "safe-execa", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/safe-execa/-/safe-execa-0.1.2.tgz", + "integrity": "sha512-vdTshSQ2JsRCgT8eKZWNJIL26C6bVqy1SOmuCMlKHegVeo8KYRobRrefOdUq9OozSPUUiSxrylteeRmLOMFfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zkochan/which": "^2.0.3", + "execa": "^5.1.1", + "path-name": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/execa/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", + "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/formatly": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.2.4.tgz", + "integrity": "sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "name": "@socketregistry/function-bind", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/function-bind/-/function-bind-1.0.6.tgz", + "integrity": "sha512-1MUMHyF83a8UzpWAM2+EXVOGL8nszAzeSOlJsrArFRbQ8RKJRDBPJeRTfUn5VT80/b8HUmfBxr6HjB+qw4e3OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-npm-tarball-url": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/get-npm-tarball-url/-/get-npm-tarball-url-2.1.0.tgz", + "integrity": "sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/gl-matrix": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-2.8.1.tgz", + "integrity": "sha512-0YCjVpE3pS5XWlN3J4X7AiAx65+nqAI54LndtVFnQZB6G/FVLkZH8y8V6R3cIoOQR4pUdfwQGd1iwyoXHJ4Qfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "name": "@socketregistry/globalthis", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/globalthis/-/globalthis-1.0.6.tgz", + "integrity": "sha512-O0V7RhvP685EOFkUYaWzYt1TxIHTkTw3sSBemk/jOEYL35Z6sDvAch+FZ7uc5dgaoBJCfa0UbPri8xe5wDVYDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "name": "@socketregistry/gopd", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/gopd/-/gopd-1.0.6.tgz", + "integrity": "sha512-tGHsIc3RXnPGggroiVGMBluSapd0zbacYdUW6iehjfuEbjYqdH3PJ6pDVEUMcmAEp80qqvZqpsMocBM5SRBcBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/got": { + "version": "14.4.7", + "resolved": "https://registry.npmjs.org/got/-/got-14.4.7.tgz", + "integrity": "sha512-DI8zV1231tqiGzOiOzQWDhsBmncFW7oQDH6Zgy6pDPrqJuVZMtoSgPLLsBZQj8Jg4JFfwoOsDA8NGtLQLnIx2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^7.0.1", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^12.0.1", + "decompress-response": "^6.0.0", + "form-data-encoder": "^4.0.2", + "http2-wrapper": "^2.2.1", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^3.0.0", + "type-fest": "^4.26.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "name": "@socketregistry/has-symbols", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/has-symbols/-/has-symbols-1.0.6.tgz", + "integrity": "sha512-9lcI74QkvF969E23TVE7oXCHA+1x0c9KT2mt0rQ4pzPS5IldqjG/JEQwY75eMV6Zn7g2Hw5S3cPqMu/dOaDtrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "name": "@socketregistry/hasown", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/hasown/-/hasown-1.0.6.tgz", + "integrity": "sha512-zFxNn/rBvJEAzdzDI7Vf2FB3O+OTQUwsrI+E7RpZjhexAwpQbuduFKpOraQe1SdeSBq6P4YHZat5q1oA/rAVEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/here": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/here/-/here-0.0.2.tgz", + "integrity": "sha512-U7VYImCTcPoY27TSmzoiFsmWLEqQFaYNdpsPb9K0dXJhE6kufUqycaz51oR09CW85dDU9iWyy7At8M+p7hb3NQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "name": "@socketregistry/indent-string", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@socketregistry/indent-string/-/indent-string-1.0.12.tgz", + "integrity": "sha512-bYqsp6PvJ0aJNhIM1yAM9gEX04NpRFG5uCCQbPv+1vT2HPWhFZ7ZBcq6Co1BjRV7Kk+ydAfJtJk8Y8a8Eo8Yvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/individual": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz", + "integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==", + "dev": true + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "dev": true, + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ionstore": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ionstore/-/ionstore-1.0.1.tgz", + "integrity": "sha512-g+99vyka3EiNFJCnbq3NxegjV211RzGtkDUMbZGB01Con8ZqUmMx/FpWMeqgDXOqgM7QoVeDhe+CfYCWznaDVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-core-module": { + "name": "@socketregistry/is-core-module", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@socketregistry/is-core-module/-/is-core-module-1.0.8.tgz", + "integrity": "sha512-Mh1h6n3XrVjL8o2zDcWSOOPtrHFxNxOPxUfxqzfGlKSl8zRYZr7X4kOJBxyR7AQBAlgeFJyKeupDQIdqiZnTUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-3.0.0.tgz", + "integrity": "sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "name": "@socketregistry/isarray", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/isarray/-/isarray-1.0.6.tgz", + "integrity": "sha512-BJDjAvuFNSiWJQxkhH9FM8tHvxG8oo5jTNxSpByzJEUc8diGUQSoz0rfPIuvo6Ob/x+RLYGCCmc/YLe5aQVi0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "name": "@zkochan/js-yaml", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz", + "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonata": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.6.tgz", + "integrity": "sha512-WhQB5tXQ32qjkx2GYHFw2XbL90u+LLzjofAYwi+86g6SyZeXHz9F1Q0amy3dWRYczshOC3Haok9J4pOCgHtwyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/knip": { + "version": "5.61.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.61.2.tgz", + "integrity": "sha512-ZBv37zDvZj0/Xwk0e93xSjM3+5bjxgqJ0PH2GlB5tnWV0ktXtmatWLm+dLRUCT/vpO3SdGz2nNAfvVhuItUNcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + }, + { + "type": "polar", + "url": "https://polar.sh/webpro-nl" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.2.4", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "minimist": "^1.2.8", + "oxc-resolver": "^11.1.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.3.4", + "strip-json-comments": "5.0.2", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.3" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4" + } + }, + "node_modules/knip/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.2.tgz", + "integrity": "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz", + "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^8.3.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loupe": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/map-canvas": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/map-canvas/-/map-canvas-0.1.5.tgz", + "integrity": "sha512-f7M3sOuL9+up0NCOZbb1rQpWDLZwR/ftCiNbyscjl9LUUEwrRaoumH4sz6swgs58lF21DQ0hsYOCw5C6Zz7hbg==", + "dev": true, + "license": "ISC", + "dependencies": { + "drawille-canvas-blessed-contrib": ">=0.0.1", + "xml2js": "^0.4.5" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/marked-terminal": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-5.2.0.tgz", + "integrity": "sha512-Piv6yNwAQXGFjZSaiNljyNFw7jKDdGrw70FSbtxEyldLsyeuV5ZHm/1wW++kWbrOF1VPnUgYOhB2oLL0ZpnekA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^6.2.0", + "cardinal": "^2.1.1", + "chalk": "^5.2.0", + "cli-table3": "^0.6.3", + "node-emoji": "^1.11.0", + "supports-hyperlinks": "^2.3.0" + }, + "engines": { + "node": ">=14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "marked": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/marked-terminal/node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked-terminal/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memory-streams": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/memory-streams/-/memory-streams-0.1.3.tgz", + "integrity": "sha512-qVQ/CjkMyMInPaaRMrwWNDvf6boRZXaT/DbQeMYcCWuXPEBf1v8qChOc9OlEVQp2uOvRXa1Qu30fLmKhY6NipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~1.0.2" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minimist-options/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/mlly": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" + } + }, + "node_modules/mock-fs": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", + "integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mount-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mount-point/-/mount-point-3.0.0.tgz", + "integrity": "sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^1.0.1", + "pify": "^2.3.0", + "pinkie-promise": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mount-point/node_modules/@sindresorhus/df": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/df/-/df-1.0.1.tgz", + "integrity": "sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/move-file": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/move-file/-/move-file-3.1.0.tgz", + "integrity": "sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/napi-postinstall": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", + "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ndjson": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-2.0.0.tgz", + "integrity": "sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "json-stringify-safe": "^5.0.1", + "minimist": "^1.2.5", + "readable-stream": "^3.6.0", + "split2": "^3.0.0", + "through2": "^4.0.0" + }, + "bin": { + "ndjson": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ndjson/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ndjson/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nmtree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/nmtree/-/nmtree-1.0.6.tgz", + "integrity": "sha512-SUPCoyX5w/lOT6wD/PZEymR+J899984tYEOYjuDqQlIOeX5NSb1MEsCcT0az+dhZD0MLAj5hGBZEpKQxuDdniA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.11.0" + }, + "bin": { + "nmtree": "bin/nmtree.js" + } + }, + "node_modules/nmtree/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nock": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.5.tgz", + "integrity": "sha512-R49fALR9caB6vxuSWUIaK2eBYeTloZQUFBZ4rHO+TbhMGQHtwnhdqKLYki+o+8qMgLvoBYWrp/2KzGPhxL4S6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.38.7", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", + "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-gyp": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", + "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/noop-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/noop-stream/-/noop-stream-1.0.0.tgz", + "integrity": "sha512-EHpIatM09Pg7dZOsowDwqqdacYpogTBb1BNSMIy8g/J+MGpaxy0k+qmrbYrjLNRPXtW3fqf+Q3b2Q0yFRnQdIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-bundled": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-install-checks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-package-arg": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.0.tgz", + "integrity": "sha512-rht9U6nS8WOBDc53eipZNPo5qkAV4X2rhKE2Oj1DYUQ3DieXfj0mKkVmjnf3iuNdtMd8WfLdi2L6ASkD/8a+Kg==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimist": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", + "integrity": "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==", + "dev": true, + "license": "MIT/X11", + "dependencies": { + "wordwrap": "~0.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/oxc-resolver": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.1.0.tgz", + "integrity": "sha512-/W/9O6m7lkDJMIXtXvNKXE6THIoNWwstsKpR/R8+yI9e7vC9wu92MDqLBxkgckZ2fTFmKEjozTxVibHBaRUgCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-darwin-arm64": "11.1.0", + "@oxc-resolver/binding-darwin-x64": "11.1.0", + "@oxc-resolver/binding-freebsd-x64": "11.1.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.1.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.1.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.1.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.1.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.1.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.1.0", + "@oxc-resolver/binding-linux-x64-musl": "11.1.0", + "@oxc-resolver/binding-wasm32-wasi": "11.1.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.1.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.1.0" + } + }, + "node_modules/oxlint": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.3.0.tgz", + "integrity": "sha512-PzAOmPxnXYpVF1q6h9pkOPH6uJ/44XrtFWJ8JcEMpoEq9HISNelD3lXhACtOAW8CArjLy/qSlu2KkyPxnXgctA==", + "dev": true, + "license": "MIT", + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": ">=8.*" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "1.3.0", + "@oxlint/darwin-x64": "1.3.0", + "@oxlint/linux-arm64-gnu": "1.3.0", + "@oxlint/linux-arm64-musl": "1.3.0", + "@oxlint/linux-x64-gnu": "1.3.0", + "@oxlint/linux-x64-musl": "1.3.0", + "@oxlint/win32-arm64": "1.3.0", + "@oxlint/win32-x64": "1.3.0" + } + }, + "node_modules/p-cancelable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/packageurl-js": { + "name": "@socketregistry/packageurl-js", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@socketregistry/packageurl-js/-/packageurl-js-1.0.8.tgz", + "integrity": "sha512-eZkWrz7aufcZ2BQnS9VvMuRiDRXjV1P1mWAlidv9aJJ4qzfWnjUE/bRZvMSTxPrCW4gK9LupJt5KN0ir/7IMmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pacote": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.0.0.tgz", + "integrity": "sha512-lcqexq73AMv6QNLo7SOpz0JJoaGdS3rBFgF122NZVl1bApo2mfu+XzUBU/X/XsiJu+iUmKpekRayqQYAs+PhkA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^10.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/pacote/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-conflict-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-4.0.0.tgz", + "integrity": "sha512-37CN2VtcuvKgHUs8+0b1uJeEsbGn61GRHz469C94P5xiOoqpDYJYwjg4RY9Vmz39WyZAVkR5++nbJwLMIgOCnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-name": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-name/-/path-name-1.0.0.tgz", + "integrity": "sha512-/dcAb5vMXH0f51yvMuSUqFpxUcA8JelbRmE5mW/p4CUJxrNgK24IkstnV7ENtg2IDGBOu6izKTG6eilbnbNKWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "name": "@socketregistry/path-parse", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@socketregistry/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-5wZrGofkfDporhZ+3dHxmn1rC2oTbdriWYbTZhFgesMpd3svv0jEaV0HgryCzC6Y8Y9928xNvRIYMcGOiMUlkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/picture-tuber": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/picture-tuber/-/picture-tuber-1.0.2.tgz", + "integrity": "sha512-49/xq+wzbwDeI32aPvwQJldM8pr7dKDRuR76IjztrkmiCkAQDaWFJzkmfVqCHmt/iFoPFhHmI9L0oKhthrTOQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "charm": "~0.1.0", + "event-stream": "~0.9.8", + "optimist": "~0.3.4", + "png-js": "~0.1.0", + "x256": "~0.0.1" + }, + "bin": { + "picture-tube": "bin/tube.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/png-js": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-0.1.1.tgz", + "integrity": "sha512-NTtk2SyfjBm+xYl2/VZJBhFnTQ4kU5qWC7VC4/iGbrgiU4FuB4xC+74erxADYJIqZICOR1HCvRA7EBHkpjTg9g==", + "dev": true + }, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettify-xml": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prettify-xml/-/prettify-xml-1.2.0.tgz", + "integrity": "sha512-kuoTbmC+QQUfx45PrdkVzJqrNEp2lhK++WGyiqBx6JrCvZUQDgeYjdV3h53n7p+37s1Iwx6GjAQ7fcIgD8kkLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/proggy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-3.0.0.tgz", + "integrity": "sha512-QE8RApCM3IaRRxVzxrjbgNMpQEX6Wu0p0KBeoSiSEw5/bsGwZHsshF4LCxH2jp/r6BU+bqA3LrMDEYNfJnpD8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/properties-reader": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", + "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ramda": { + "name": "@pnpm/ramda", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@pnpm/ramda/-/ramda-0.28.1.tgz", + "integrity": "sha512-zcAG+lvU0fMziNeGXpPyCyCJYp5ZVrPElEE4t14jAmViaihohocZ+dDkcRIyAomox8pQsuZnv1EyHR+pOhmUWw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", + "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regjsparser": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz", + "integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", + "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.0", + "@rollup/rollup-android-arm64": "4.44.0", + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-freebsd-arm64": "4.44.0", + "@rollup/rollup-freebsd-x64": "4.44.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", + "@rollup/rollup-linux-arm-musleabihf": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-gnu": "4.44.0", + "@rollup/rollup-linux-riscv64-musl": "4.44.0", + "@rollup/rollup-linux-s390x-gnu": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-ia32-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "name": "@socketregistry/safe-buffer", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@socketregistry/safe-buffer/-/safe-buffer-1.0.7.tgz", + "integrity": "sha512-ybEvCjKLAxw269qYGN1Xaitiwfcs0eOuzhgPuvo+K9bP9fF6bHLwdbqXj86u8ESEGrgayKR5symjjiQ6JP953w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/safer-buffer": { + "name": "@socketregistry/safer-buffer", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@socketregistry/safer-buffer/-/safer-buffer-1.0.8.tgz", + "integrity": "sha512-fhzOsFGskb8VZvmoJrF1cEBy3zigwbxL+JPwkeBlF7iCwlPcNrmHAnBc3JwLzypj+1I+815WZ5m9L8h6Iws9vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT" + }, + "node_modules/sequelize": { + "version": "6.37.7", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", + "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "name": "@socketregistry/side-channel", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@socketregistry/side-channel/-/side-channel-1.0.8.tgz", + "integrity": "sha512-BaTxPf2BKb1fsOSTN8nSwMi0WwkmFNv19igyTymlTJI6H9wIo/ZgXXtSGOYFuITNi+0pSAbxMLlVxEXynFrDvA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.20.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", + "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.4.tgz", + "integrity": "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sort-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-4.2.0.tgz", + "integrity": "sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sort-object-keys": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz", + "integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparkline": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/sparkline/-/sparkline-0.1.2.tgz", + "integrity": "sha512-t//aVOiWt9fi/e22ea1vXVWBDX+gp18y+Ch9sKqmHl828bRfvP2VtfTJVEcgWFBQHd0yDPNQRiHdqzCvbcYSDA==", + "dev": true, + "dependencies": { + "here": "0.0.2", + "nopt": "~2.1.2" + }, + "bin": { + "sparkline": "bin/sparkline" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/sparkline/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/sparkline/node_modules/nopt": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-2.1.2.tgz", + "integrity": "sha512-x8vXm7BZ2jE1Txrxh/hO74HTuYZQEbo8edoRcANgdZ4+PCV+pbjd/xdummkmjjC7LU5EjPzlu8zEq/oxWylnKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "license": "ISC", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/split2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/split2/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sqlite3": { + "name": "@appthreat/sqlite3", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@appthreat/sqlite3/-/sqlite3-6.0.6.tgz", + "integrity": "sha512-0nJUe+lLET/Y0bY8j/PXLtozC0DqFBtau3uDXkPugH50jZyd4zd3s7RhR3oDYo0xZEVixcZOqJz+15jbRxmdCg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.3.1", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "node-gyp": "11.x" + }, + "peerDependencies": { + "node-gyp": "11.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stable-hash-x": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz", + "integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synp": { + "version": "1.9.14", + "resolved": "https://registry.npmjs.org/synp/-/synp-1.9.14.tgz", + "integrity": "sha512-0e4u7KtrCrMqvuXvDN4nnHSEQbPlONtJuoolRWzut0PfuT2mEOvIFnYFHEpn5YPIOv7S5Ubher0b04jmYRQOzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "colors": "1.4.0", + "commander": "^7.2.0", + "eol": "^0.10.0", + "fast-glob": "^3.3.2", + "lodash": "4.17.21", + "nmtree": "^1.0.6", + "semver": "^7.6.3", + "sort-object-keys": "^1.1.3" + }, + "bin": { + "synp": "cli/synp.js" + } + }, + "node_modules/synp/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/term-canvas": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/term-canvas/-/term-canvas-0.0.5.tgz", + "integrity": "sha512-eZ3rIWi5yLnKiUcsW8P79fKyooaLmyLWAGqBhFspqMxRNUiB4GmHHk5AzQ4LxvFbJILaXqQZLwbbATLOhCFwkw==", + "dev": true + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tiny-colors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tiny-colors/-/tiny-colors-2.1.2.tgz", + "integrity": "sha512-6peGRBtkYBJpVrQUWOPKrC0ECo6WotUlXxirVTKvihjdgxQETpKtLdCKIb68IHjJYH1AOE7GM7RnxFvkGHsqOg==", + "dev": true + }, + "node_modules/tiny-updater": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/tiny-updater/-/tiny-updater-3.5.3.tgz", + "integrity": "sha512-wEUssfOOkVLg2raSaRbyZDHpVCDj6fnp7UjynpNE4XGuF+Gkj8GRRMoHdfk73VzLQs/AHKsbY8fCxXNz8Hx4Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ionstore": "^1.0.1", + "tiny-colors": "^2.2.2", + "when-exit": "^2.1.4" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/trash": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/trash/-/trash-9.0.0.tgz", + "integrity": "sha512-6U3A0olN4C16iiPZvoF93AcZDNZtv/nI2bHb2m/sO3h/m8VPzg9tPdd3n3LVcYLWz7ui0AHaXYhIuRjzGW9ptg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/chunkify": "^1.0.0", + "@stroncium/procfs": "^1.2.1", + "globby": "^7.1.1", + "is-path-inside": "^4.0.0", + "move-file": "^3.1.0", + "p-map": "^7.0.2", + "xdg-trashdir": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/trash/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/trash/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/trash/node_modules/globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha512-yANWAN2DUcBtuus5Cpd+SKROzXHs2iVXFZt/Ykrfz6SAXqacLX25NZpltE+39ceMexYF4TtEadjuSTw8+3wX4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/trash/node_modules/ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true, + "license": "MIT" + }, + "node_modules/trash/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/trash/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/trash/node_modules/slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-declaration-location": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", + "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==", + "dev": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": ">=4.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-coverage": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage/-/type-coverage-2.29.7.tgz", + "integrity": "sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "minimist": "1", + "type-coverage-core": "^2.29.7" + }, + "bin": { + "type-coverage": "bin/type-coverage" + } + }, + "node_modules/type-coverage-core": { + "version": "2.29.7", + "resolved": "https://registry.npmjs.org/type-coverage-core/-/type-coverage-core-2.29.7.tgz", + "integrity": "sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3", + "minimatch": "6 || 7 || 8 || 9 || 10", + "normalize-path": "3", + "tslib": "1 || 2", + "tsutils": "3" + }, + "peerDependencies": { + "typescript": "2 || 3 || 4 || 5" + } + }, + "node_modules/type-coverage/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/type-coverage/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/type-coverage/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", + "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", + "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.35.0", + "@typescript-eslint/parser": "8.35.0", + "@typescript-eslint/utils": "8.35.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.2.tgz", + "integrity": "sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.1", + "picomatch": "^4.0.2", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-purge-polyfills": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unplugin-purge-polyfills/-/unplugin-purge-polyfills-0.1.0.tgz", + "integrity": "sha512-dHahgAhuzaHZHU65oY7BU24vqH/AtcXppdH1B1SmrBeglyX7NOBtkryjp2F8mOD4tL2RVxfAc41JRqRKTAeAkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "unplugin": "^2.3.2" + } + }, + "node_modules/unrs-resolver": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.0.tgz", + "integrity": "sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.2.2" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.9.0", + "@unrs/resolver-binding-android-arm64": "1.9.0", + "@unrs/resolver-binding-darwin-arm64": "1.9.0", + "@unrs/resolver-binding-darwin-x64": "1.9.0", + "@unrs/resolver-binding-freebsd-x64": "1.9.0", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.0", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.0", + "@unrs/resolver-binding-linux-arm64-gnu": "1.9.0", + "@unrs/resolver-binding-linux-arm64-musl": "1.9.0", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.0", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.0", + "@unrs/resolver-binding-linux-riscv64-musl": "1.9.0", + "@unrs/resolver-binding-linux-s390x-gnu": "1.9.0", + "@unrs/resolver-binding-linux-x64-gnu": "1.9.0", + "@unrs/resolver-binding-linux-x64-musl": "1.9.0", + "@unrs/resolver-binding-wasm32-wasi": "1.9.0", + "@unrs/resolver-binding-win32-arm64-msvc": "1.9.0", + "@unrs/resolver-binding-win32-ia32-msvc": "1.9.0", + "@unrs/resolver-binding-win32-x64-msvc": "1.9.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-homedir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/validate-iri": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/validate-iri/-/validate-iri-1.0.1.tgz", + "integrity": "sha512-gLXi7351CoyVVQw8XE5sgpYawRKatxE7kj/xmCxXOZS1kMdtcqC0ILIqLuVEVnAUQSL/evOGG3eQ+8VgbdnstA==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/when-exit": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", + "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/x256": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/x256/-/x256-0.0.2.tgz", + "integrity": "sha512-ZsIH+sheoF8YG9YG+QKEEIdtqpHRA9FYuD7MqhfyB1kayXU43RUNBFSxBEnF8ywSUxdg+8no4+bPr5qLbyxKgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/xdg-trashdir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xdg-trashdir/-/xdg-trashdir-3.1.0.tgz", + "integrity": "sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/df": "^3.1.1", + "mount-point": "^3.0.0", + "user-home": "^2.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", + "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } + } + } +} diff --git a/package.json b/package.json index f2ae4387a..bd9d8a353 100644 --- a/package.json +++ b/package.json @@ -1,171 +1,238 @@ { - "name": "socket-cli-monorepo", - "version": "0.0.0", - "private": true, + "name": "socket", + "version": "1.0.7", + "description": "CLI for Socket.dev", + "homepage": "https://github.com/SocketDev/socket-cli", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/SocketDev/socket-cli.git" + }, + "author": { + "name": "Socket Inc", + "email": "eng@socket.dev", + "url": "https://socket.dev" + }, + "bin": { + "socket": "bin/cli.js", + "socket-npm": "bin/npm-cli.js", + "socket-npx": "bin/npx-cli.js" + }, + "types": "./dist/types/src/cli.d.ts", + "exports": { + "./bin/cli.js": "./dist/cli.js", + "./bin/npm-cli.js": "./dist/npm-cli.js", + "./bin/npx-cli.js": "./dist/npx-cli.js", + "./package.json": "./package.json", + "./translations.json": "./translations.json" + }, "scripts": { - "// Build": "", - "build": "node scripts/build.mts", - "build:force": "node scripts/build.mts --force", - "build:cli": "pnpm --filter @socketsecurity/cli run build", - "build:watch": "pnpm --filter @socketsecurity/cli run build:watch", - "build:sea": "pnpm --filter @socketsecurity/cli run build:sea", - "build:js": "pnpm --filter @socketsecurity/cli run build:js", - "dev": "pnpm run build:watch", - "prebuild": "node scripts/setup.mts --restore-cache --quiet", - "// Quality Checks": "", - "check": "node scripts/check.mts", - "check:all": "node scripts/check.mts --all", - "check:paths": "node scripts/check-paths.mts", - "fix": "node scripts/fix.mts", - "fix:all": "node scripts/fix.mts --all", - "lint": "node scripts/lint.mts", - "lint:all": "node scripts/lint.mts --all", - "format": "oxfmt -c .config/oxfmtrc.json --write .", - "format:check": "oxfmt -c .config/oxfmtrc.json --check .", - "// Claude": "", - "claude": "pnpm --filter @socketsecurity/cli run claude --", - "// Type Checking": "", - "type": "node scripts/type.mts", - "type:all": "node scripts/type.mts", - "// Testing": "", - "test": "node scripts/test.mts", - "test:all": "node scripts/test.mts --all", - "test:unit": "pnpm --filter @socketsecurity/cli run test:unit", - "pretest:all": "pnpm run build", - "testu": "pnpm --filter @socketsecurity/cli run test:unit:update", - "cover": "pnpm --filter @socketsecurity/cli run test:unit:coverage", - "cover:all": "pnpm --filter @socketsecurity/cli run cover", - "security": "node scripts/security.mts", - "// Maintenance": "", - "clean": "pnpm --filter \"./packages/**\" run clean", - "clean:cache": "node scripts/clean-cache.mts", - "clean:cache:all": "node scripts/clean-cache.mts --all", - "update": "node scripts/update.mts", - "// lockstep": "", - "lockstep": "node scripts/lockstep.mts", - "lockstep:emit-schema": "node scripts/lockstep-emit-schema.mts", - "// Setup": "", - "setup": "node scripts/setup.mts", - "postinstall": "node scripts/setup.mts --install --quiet", - "prepare": "node scripts/install-git-hooks.mts", - "pretest": "pnpm run build:cli", - "setup-security-tools": "node .claude/hooks/setup-security-tools/install.mts" + "build": "npm run build:dist", + "build:dist": "npm run build:dist:src && npm run build:dist:types", + "build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs", + "build:dist:types": "npm run clean:dist:types && tsgo --project tsconfig.dts.json", + "check": "npm run check:lint && npm run check:tsc", + "check:lint": "dotenvx -q run -f .env.local -- eslint --report-unused-disable-directives .", + "check:tsc": "tsgo", + "check-ci": "npm run check:lint", + "coverage": "run-s coverage:*", + "coverage:test": "run-s test:prepare test:unit:coverage", + "coverage:type": "dotenvx -q run -f .env.local -- type-coverage --detail", + "clean": "run-p -c --aggregate-output clean:*", + "clean:cache": "del-cli '.cache'", + "clean:dist": "del-cli 'dist'", + "clean:dist:types": "del-cli 'dist/types'", + "clean:external": "del-cli 'external'", + "clean:node_modules": "del-cli '**/node_modules'", + "fix": "npm run lint:fix", + "knip:dependencies": "knip --dependencies", + "knip:exports": "knip --include exports,duplicates", + "lint": "dotenvx -q run -f .env.local -- oxlint -c=.oxlintrc.json --ignore-path=.oxlintignore --tsconfig=tsconfig.json .", + "lint:dist:fix": "run-s -c lint:dist:fix:*", + "lint:dist:fix:oxlint": "dotenvx -q run -f .env.dist -- oxlint -c=.oxlintrc.json --ignore-path=.oxlintignore --tsconfig=tsconfig.json --silent --fix ./dist | dev-null", + "lint:dist:fix:biome": "dotenvx -q run -f .env.dist -- biome format --log-level=none --fix ./dist | dev-null", + "//lint:dist:fix:eslint": "dotenvx -q run -f .env.dist -- eslint --report-unused-disable-directives --quiet --fix ./dist | dev-null", + "lint:external:fix": "run-s -c lint:external:fix:*", + "lint:external:fix:oxlint": "dotenvx -q run -f .env.external -- oxlint -c=.oxlintrc.json --ignore-path=.oxlintignore --tsconfig=tsconfig.json --silent --fix ./external | dev-null", + "lint:external:fix:biome": "dotenvx -q run -f .env.external -- biome format --log-level=none --fix ./external | dev-null", + "//lint:external:fix:eslint": "dotenvx -q run -f .env.external -- eslint --report-unused-disable-directives --quiet --fix ./external | dev-null", + "lint:fix": "run-s -c lint:fix:*", + "lint:fix:oxlint": "dotenvx -q run -f .env.local -- oxlint -c=.oxlintrc.json --ignore-path=.oxlintignore --tsconfig=tsconfig.json --quiet --fix .", + "lint:fix:biome": "dotenvx -q run -f .env.local -- biome format --log-level=none --fix .", + "lint:fix:eslint": "dotenvx -q run -f .env.local -- eslint --report-unused-disable-directives --fix .", + "lint-staged": "dotenvx -q run -f .env.local -- lint-staged", + "precommit": "dotenvx -q run -f .env.local -- lint-staged", + "prepare": "dotenvx -q run -f .env.local -- husky && custompatch", + "bs": "dotenvx -q run -f .env.local -- npm run build:dist:src; npm exec socket --", + "s": "dotenvx -q run -f .env.local -- npm exec socket --", + "test": "run-s check test:*", + "test:prepare": "dotenvx -q run -f .env.test -- npm run build && del-cli 'test/**/node_modules'", + "test:unit": "dotenvx -q run -f .env.test -- vitest --run", + "test:unit:update": "dotenvx -q run -f .env.test -- vitest --run --update", + "test:unit:coverage": "dotenvx -q run -f .env.test -- vitest run --coverage", + "test-ci": "run-s test:*", + "testu": "dotenvx -q run -f .env.testu -- run-s test:prepare; npm run test:unit:update --", + "testuf": "dotenvx -q run -f .env.testu -- npm run test:unit:update --", + "update": "run-p --aggregate-output update:**", + "update:deps": "npx --yes npm-check-updates" }, "devDependencies": { - "@anthropic-ai/claude-code": "catalog:", - "@babel/core": "catalog:", - "@babel/parser": "catalog:", - "@babel/plugin-proposal-export-default-from": "catalog:", - "@babel/plugin-transform-export-namespace-from": "catalog:", - "@babel/plugin-transform-runtime": "catalog:", - "@babel/preset-react": "catalog:", - "@babel/preset-typescript": "catalog:", - "@babel/runtime": "catalog:", - "@babel/traverse": "catalog:", - "@npmcli/arborist": "catalog:", - "@npmcli/config": "catalog:", - "@octokit/graphql": "catalog:", - "@octokit/openapi-types": "catalog:", - "@octokit/request-error": "catalog:", - "@octokit/rest": "catalog:", - "@octokit/types": "catalog:", - "@pnpm/dependency-path": "catalog:", - "@pnpm/lockfile.detect-dep-types": "catalog:", - "@pnpm/lockfile.fs": "catalog:", - "@pnpm/logger": "catalog:", - "@sinclair/typebox": "catalog:", - "@socketregistry/hyrious__bun.lockb": "catalog:", - "@socketregistry/indent-string": "catalog:", - "@socketregistry/is-interactive": "catalog:", - "@socketregistry/packageurl-js": "catalog:", - "@socketregistry/packageurl-js-stable": "catalog:", - "@socketregistry/yocto-spinner": "catalog:", - "@socketsecurity/lib": "catalog:", - "@socketsecurity/lib-stable": "catalog:", - "@socketsecurity/registry": "catalog:", - "@socketsecurity/registry-stable": "catalog:", - "@socketsecurity/sdk": "catalog:", - "@socketsecurity/sdk-stable": "catalog:", - "@types/cmd-shim": "catalog:", - "@types/js-yaml": "catalog:", - "@types/micromatch": "catalog:", - "@types/mock-fs": "catalog:", - "@types/node": "catalog:", - "@types/npm-package-arg": "catalog:", - "@types/npmcli__arborist": "catalog:", - "@types/npmcli__config": "catalog:", - "@types/proc-log": "catalog:", - "@types/semver": "catalog:", - "@types/which": "catalog:", - "@types/yargs-parser": "catalog:", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "@vitest/coverage-v8": "catalog:", - "@yao-pkg/pkg": "catalog:", - "browserslist": "catalog:", - "chalk-table": "catalog:", - "cmd-shim": "catalog:", - "del-cli": "catalog:", - "dev-null-cli": "catalog:", - "ecc-agentshield": "catalog:", - "fast-glob": "catalog:", - "hpagent": "catalog:", - "ignore": "catalog:", - "js-yaml": "catalog:", - "lint-staged": "catalog:", - "magic-string": "catalog:", - "micromatch": "catalog:", - "mock-fs": "catalog:", - "nanotar": "catalog:", - "nock": "catalog:", - "npm-package-arg": "catalog:", - "npm-run-all2": "catalog:", - "open": "catalog:", - "oxfmt": "0.48.0", - "oxlint": "1.63.0", - "package-builder": "workspace:*", - "postject": "catalog:", - "registry-auth-token": "catalog:", - "registry-url": "catalog:", - "semver": "catalog:", - "ssri": "catalog:", - "taze": "19.11.0", - "terminal-link": "catalog:", - "trash": "catalog:", - "type-coverage": "catalog:", - "typescript": "catalog:", - "unplugin-purge-polyfills": "catalog:", - "vitest": "catalog:", - "yaml": "catalog:", - "yargs-parser": "catalog:", - "yoctocolors-cjs": "catalog:", - "zod": "catalog:" + "@babel/core": "7.27.4", + "@babel/plugin-proposal-export-default-from": "7.27.1", + "@babel/plugin-transform-export-namespace-from": "7.27.1", + "@babel/plugin-transform-runtime": "7.27.4", + "@babel/preset-typescript": "7.27.1", + "@babel/runtime": "7.27.6", + "@biomejs/biome": "2.0.5", + "@coana-tech/cli": "14.9.32", + "@cyclonedx/cdxgen": "11.4.1", + "@dotenvx/dotenvx": "1.45.1", + "@eslint/compat": "1.3.1", + "@eslint/js": "9.29.0", + "@npmcli/arborist": "9.1.2", + "@npmcli/config": "10.3.0", + "@octokit/graphql": "9.0.1", + "@octokit/openapi-types": "25.1.0", + "@octokit/request-error": "7.0.0", + "@octokit/rest": "22.0.0", + "@octokit/types": "14.1.0", + "@pnpm/dependency-path": "1001.0.0", + "@pnpm/lockfile.detect-dep-types": "1001.0.10", + "@pnpm/lockfile.fs": "1001.1.14", + "@pnpm/logger": "1001.0.0", + "@rollup/plugin-babel": "6.0.4", + "@rollup/plugin-commonjs": "28.0.6", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.1", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.2.0", + "@socketregistry/hyrious__bun.lockb": "1.0.18", + "@socketregistry/indent-string": "1.0.13", + "@socketregistry/is-interactive": "1.0.6", + "@socketregistry/packageurl-js": "1.0.8", + "@socketsecurity/config": "3.0.1", + "@socketsecurity/registry": "1.0.212", + "@socketsecurity/sdk": "1.4.48", + "@types/blessed": "0.1.25", + "@types/cmd-shim": "5.0.2", + "@types/js-yaml": "4.0.9", + "@types/micromatch": "4.0.9", + "@types/mock-fs": "4.13.4", + "@types/node": "24.0.4", + "@types/npmcli__arborist": "6.3.1", + "@types/npmcli__config": "6.0.3", + "@types/proc-log": "3.0.4", + "@types/semver": "7.7.0", + "@types/which": "3.0.4", + "@types/yargs-parser": "21.0.3", + "@typescript-eslint/parser": "8.35.0", + "@typescript/native-preview": "7.0.0-dev.20250625.1", + "@vitest/coverage-v8": "3.2.4", + "blessed": "0.1.81", + "blessed-contrib": "4.11.0", + "browserslist": "4.25.1", + "chalk-table": "1.0.2", + "cmd-shim": "7.0.0", + "custompatch": "1.1.7", + "del-cli": "6.0.0", + "dev-null-cli": "2.0.0", + "eslint": "9.29.0", + "eslint-import-resolver-typescript": "4.4.3", + "eslint-plugin-import-x": "4.16.0", + "eslint-plugin-n": "17.20.0", + "eslint-plugin-sort-destructure-keys": "2.0.0", + "eslint-plugin-unicorn": "56.0.1", + "globals": "16.2.0", + "hpagent": "1.2.0", + "husky": "9.1.7", + "ignore": "7.0.5", + "js-yaml": "npm:@zkochan/js-yaml@0.0.7", + "knip": "5.61.2", + "lint-staged": "16.1.2", + "magic-string": "0.30.17", + "meow": "13.2.0", + "micromatch": "4.0.8", + "mock-fs": "5.5.0", + "nock": "14.0.5", + "node-gyp": "11.2.0", + "npm-package-arg": "12.0.2", + "npm-run-all2": "8.0.4", + "open": "10.1.2", + "oxlint": "1.3.0", + "pony-cause": "2.1.11", + "rollup": "4.44.0", + "semver": "7.7.2", + "synp": "1.9.14", + "terminal-link": "2.1.1", + "tiny-updater": "3.5.3", + "tinyglobby": "0.2.14", + "trash": "9.0.0", + "type-coverage": "2.29.7", + "typescript-eslint": "8.35.0", + "unplugin-purge-polyfills": "0.1.0", + "vitest": "3.2.4", + "which": "5.0.0", + "yaml": "2.8.0", + "yargs-parser": "22.0.0", + "yoctocolors-cjs": "2.1.2" + }, + "overrides": { + "@octokit/graphql": "$@octokit/graphql", + "@octokit/request-error": "$@octokit/request-error", + "@socketsecurity/registry": "$@socketsecurity/registry", + "aggregate-error": "npm:@socketregistry/aggregate-error@^1", + "es-define-property": "npm:@socketregistry/es-define-property@^1", + "function-bind": "npm:@socketregistry/function-bind@^1", + "globalthis": "npm:@socketregistry/globalthis@^1", + "gopd": "npm:@socketregistry/gopd@^1", + "has-property-descriptors": "npm:@socketregistry/has-property-descriptors@^1", + "has-proto": "npm:@socketregistry/has-proto@^1", + "has-symbols": "npm:@socketregistry/has-symbols@^1", + "hasown": "npm:@socketregistry/hasown@^1", + "indent-string": "npm:@socketregistry/indent-string@^1", + "is-core-module": "npm:@socketregistry/is-core-module@^1", + "isarray": "npm:@socketregistry/isarray@^1", + "npm-package-arg": "$npm-package-arg", + "packageurl-js": "$@socketregistry/packageurl-js", + "path-parse": "npm:@socketregistry/path-parse@^1", + "safe-buffer": "npm:@socketregistry/safe-buffer@^1", + "safer-buffer": "npm:@socketregistry/safer-buffer@^1", + "semver": "$semver", + "set-function-length": "npm:@socketregistry/set-function-length@^1", + "shell-quote": "npm:shell-quote@^1", + "side-channel": "npm:@socketregistry/side-channel@^1", + "tiny-colors": "$yoctocolors-cjs", + "typedarray": "npm:@socketregistry/typedarray@^1", + "undici": "6.21.3", + "vite": "6.3.5", + "xml2js": "0.6.2", + "yaml": "2.8.0" }, + "engines": { + "node": ">=18" + }, + "files": [ + "bin/**", + "dist/**", + "external/**", + "shadow-bin/**", + "translations.json" + ], "lint-staged": { "*.{cjs,cts,js,json,md,mjs,mts,ts}": [ - "oxfmt --write" + "npm run lint:fix:oxlint", + "npm run lint:fix:biome -- --no-errors-on-unmatched --files-ignore-unknown=true --colors=off" ] }, "typeCoverage": { - "atLeast": 95, "cache": true, - "ignore-files": "test/*", - "ignore-non-null-assertion": true, - "ignore-type-assertion": true, + "atLeast": 95, "ignoreAsAssertion": true, "ignoreCatch": true, "ignoreEmptyType": true, + "ignore-non-null-assertion": true, + "ignore-type-assertion": true, + "ignore-files": "test/*", "strict": true - }, - "engines": { - "node": ">=26.0.0", - "pnpm": ">=11.3.0" - }, - "packageManager": "pnpm@11.3.0", - "npm-run-all2": { - "nodeRun": true - }, - "allowScripts": { - "rolldown": true, - "postject": false } } diff --git a/packages/build-infra/README.md b/packages/build-infra/README.md deleted file mode 100644 index 75b1940af..000000000 --- a/packages/build-infra/README.md +++ /dev/null @@ -1,303 +0,0 @@ -# build-infra - -Shared build infrastructure utilities for Socket CLI. Provides esbuild plugins, GitHub release downloaders, and caching utilities for optimizing build processes. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ build-infra │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ esbuild Plugins GitHub Releases Caching │ -│ ┌───────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ Unicode │ │ API Client │ │ SHA256 │ │ -│ │ Transform │ │ + Download │ │ Content │ │ -│ │ │ │ │ │ Hashing │ │ -│ └───────────────┘ ├──────────────┤ └──────────┤ │ -│ │ Asset Cache │ │ Skip │ │ -│ │ (1hr TTL) │ │ Regen │ │ -│ └──────────────┘ └──────────┘ │ -│ │ -│ Helpers │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ import.meta.url Banner (CommonJS compat) │ │ -│ └───────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────┐ - │ Used By │ - ├──────────────────────────────────────┤ - │ • CLI esbuild configs │ - │ • SEA binary build scripts │ - │ • Asset download scripts │ - └──────────────────────────────────────┘ -``` - -## Purpose - -This package centralizes build-time utilities that are shared across multiple Socket CLI build configurations. It provides: - -1. **esbuild plugins** for code transformations required by SEA (Single Executable Application) binaries -2. **GitHub release utilities** for downloading node-smol and other build dependencies -3. **Extraction caching** to avoid regenerating files when source hasn't changed - -## Modules - -### esbuild Plugins - -#### `unicodeTransformPlugin()` - -Transforms Unicode property escapes (`\p{Property}`) into basic character classes for `--with-intl=none` compatibility. Required because node-smol binaries lack ICU support. - -```javascript -import { unicodeTransformPlugin } from 'build-infra/lib/esbuild-plugin-unicode-transform' - -export default { - plugins: [unicodeTransformPlugin()], -} -``` - -**Transformations:** - -- `/\p{Letter}/u` → `/[A-Za-z\u00AA...]/` (no flags) -- `/\p{ASCII}/u` → `/[\x00-\x7F]/` -- `new RegExp('\\p{Alphabetic}', 'u')` → `new RegExp('[A-Za-z...]', '')` - -**Features:** - -- Babel AST parsing for accurate regex detection -- Handles both regex literals and `RegExp` constructor calls -- Replaces unsupported patterns with `/(?:)/` (no-op) -- Removes `/u` and `/v` flags after transformation - -### esbuild Helpers - -#### `IMPORT_META_URL_BANNER` - -Banner injection for `import.meta.url` polyfill in CommonJS bundles. Converts `__filename` to proper `file://` URL using Node.js `pathToFileURL()`. - -```javascript -import { IMPORT_META_URL_BANNER } from 'build-infra/lib/esbuild-helpers' - -export default { - banner: IMPORT_META_URL_BANNER, - define: { - 'import.meta.url': '__importMetaUrl', - }, -} -``` - -**Generated code:** - -```javascript -const __importMetaUrl = require('node:url').pathToFileURL(__filename).href -``` - -### GitHub Releases - -Downloads assets from SocketDev/socket-btm releases with retry logic and caching. Used for node-smol binaries, AI models, and build tools. - -#### `getLatestRelease(tool, options)` - -Fetches the latest release tag for a tool from socket-btm. - -```javascript -import { getLatestRelease } from 'build-infra/lib/github-releases' - -const tag = await getLatestRelease('node-smol') -// Returns: 'node-smol-20250115-abc1234' -``` - -**Parameters:** - -- `tool` (string) - Tool name prefix (e.g., 'node-smol', 'binject') -- `options.quiet` (boolean) - Suppress log messages - -**Returns:** Latest tag string or `null` if not found - -**Features:** - -- Searches last 100 releases for matching prefix -- 1-hour TTL cache to avoid rate limiting -- 3 retry attempts with 5s backoff -- Respects `GH_TOKEN`/`GITHUB_TOKEN` env vars - -#### `getReleaseAssetUrl(tag, assetName, options)` - -Gets the browser download URL for a specific release asset. - -```javascript -import { getReleaseAssetUrl } from 'build-infra/lib/github-releases' - -const url = await getReleaseAssetUrl( - 'node-smol-20250115-abc1234', - 'node-linux-x64', -) -// Returns: 'https://github.com/SocketDev/socket-btm/releases/download/...' -``` - -**Parameters:** - -- `tag` (string) - Release tag name -- `assetName` (string) - Asset filename -- `options.quiet` (boolean) - Suppress log messages - -**Returns:** Download URL string or `null` if not found - -#### `downloadReleaseAsset(tag, assetName, outputPath, options)` - -Downloads a release asset with automatic redirect following. - -```javascript -import { downloadReleaseAsset } from 'build-infra/lib/github-releases' - -await downloadReleaseAsset( - 'node-smol-20250120-abc1234', - 'node-smol-linux-x64', - '/path/to/output', -) -``` - -**Parameters:** - -- `tag` (string) - Release tag name -- `assetName` (string) - Asset filename -- `outputPath` (string) - Local file path to write -- `options.quiet` (boolean) - Suppress log messages - -**Features:** - -- Automatic directory creation -- Progress logging (10s interval) -- 3 retry attempts with 5s delay -- Uses `browser_download_url` to avoid API quota consumption - -## Usage Examples - -### esbuild Configuration - -```javascript -// .config/esbuild.cli.mjs -import { IMPORT_META_URL_BANNER } from 'build-infra/lib/esbuild-helpers' -import { unicodeTransformPlugin } from 'build-infra/lib/esbuild-plugin-unicode-transform' - -export default { - entryPoints: ['src/cli.mts'], - bundle: true, - outfile: 'build/cli.js', - platform: 'node', - target: 'node18', - format: 'cjs', - - banner: { - js: `#!/usr/bin/env node\n${IMPORT_META_URL_BANNER.js}`, - }, - - define: { - 'import.meta.url': '__importMetaUrl', - }, - - plugins: [unicodeTransformPlugin()], -} -``` - -### Asset Download Script - -```javascript -// scripts/download-node-smol.mjs -import { - getLatestRelease, - downloadReleaseAsset, -} from 'build-infra/lib/github-releases' - -const tag = await getLatestRelease('node-smol') -const platform = process.platform -const arch = process.arch - -await downloadReleaseAsset( - tag, - `node-${platform}-${arch}`, - `build/node-smol-${platform}-${arch}`, -) -``` - -## Code Quality - -### Patterns - -**Consistent structure:** - -- Clear module-level JSDoc comments -- Exported functions first, helpers last -- Descriptive parameter/return type documentation -- Error handling with informative messages - -**Clean implementations:** - -- Single responsibility per function -- Minimal external dependencies -- Pure transformations where possible -- Proper resource cleanup - -**Babel compatibility:** - -- Handles both ESM and CommonJS Babel exports (`traverseImport.default` fallback) -- Uses MagicString for efficient string transformations -- Preserves source positions for accurate replacements - -### Issues Found - -None. Code is clean, well-organized, and follows consistent patterns. - -**Strengths:** - -- Excellent separation of concerns -- Thorough documentation -- Robust error handling -- Smart caching to avoid rate limits -- Type definitions provided for TypeScript consumers - -## Dependencies - -- `@babel/parser` - JavaScript AST parsing -- `@babel/traverse` - AST traversal utilities -- `@socketsecurity/lib` - Logger, HTTP, caching, and fs utilities -- `magic-string` - Efficient string transformations - -## Build Directory - -The `build/downloaded/` directory stores cached GitHub release assets: - -``` -build/downloaded/ -├── binject-{tag}-{platform}-{arch} -├── node-smol-{tag}-{platform}-{arch} -└── models-{tag}.tar.gz -``` - -Assets are cached per tag to avoid re-downloading across builds. - -## Related Files - -**Consumers:** - -- `packages/cli/.config/esbuild.cli.mjs` - Main CLI bundle config -- `packages/cli/scripts/download-assets.mjs` - Unified asset downloader -- `packages/cli/scripts/sea-build-util/builder.mjs` - SEA binary builder - -**Dependencies:** - -- `@socketsecurity/lib` - Socket shared library (logging, HTTP, caching) - -## Environment Variables - -**GitHub API:** - -- `GH_TOKEN` or `GITHUB_TOKEN` - GitHub API authentication (optional but recommended to avoid rate limits) - -**Build configuration:** - -- `SOCKET_BTM_NODE_SMOL_TAG` - Override node-smol release tag -- `SOCKET_BTM_BINJECT_TAG` - Override binject release tag diff --git a/packages/build-infra/lib/constants.mts b/packages/build-infra/lib/constants.mts deleted file mode 100644 index d0b87a87b..000000000 --- a/packages/build-infra/lib/constants.mts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Shared constants for the build-pipeline orchestrator (socket-cli variant). - * - * Mirrors the socket-btm/ultrathink/socket-tui/sdxgen API surface - * (BUILD_STAGES, CHECKPOINTS, CHECKPOINT_CHAINS, validateCheckpointChain, - * getBuildMode). socket-cli doesn't build wasm — it consumes pre-built wasm + - * node binaries from socket-btm — so the orchestrator name ('build-pipeline') - * is historical; the machinery is build-type-agnostic. - */ - -import process from 'node:process' - -import { getCI } from '@socketsecurity/lib-stable/env/ci' - -/** - * Build stage directory names inside build/<mode>/. - */ -const BUILD_STAGES = { - BUNDLED: 'Bundled', - FINAL: 'Final', - OPTIMIZED: 'Optimized', - RELEASE: 'Release', - STRIPPED: 'Stripped', - SEA: 'Sea', - SYNC: 'Sync', - TYPES: 'Types', -} - -/** - * Canonical checkpoint names. Each pipeline stage picks one. - */ -const CHECKPOINTS = { - CLI: 'cli', - FINALIZED: 'finalized', - SEA: 'sea', -} - -const VALID_CHECKPOINT_VALUES = new Set(Object.values(CHECKPOINTS)) - -/** - * Checkpoint chain for socket-cli's build pipeline. Order: newest → oldest - * (matching socket-btm convention). - * - * The SEA binary is built only for --force / --prod today; the chain is - * declared including SEA so --clean-stage=sea works when it runs. - */ -const CHECKPOINT_CHAINS = { - cli: () => [CHECKPOINTS.FINALIZED, CHECKPOINTS.SEA, CHECKPOINTS.CLI], -} - -/** - * Validate a checkpoint chain at runtime. - */ -export function validateCheckpointChain(chain: string[], packageName: string) { - if (!Array.isArray(chain)) { - throw new Error(`${packageName}: Checkpoint chain must be an array`) - } - if (chain.length === 0) { - throw new Error(`${packageName}: Checkpoint chain cannot be empty`) - } - const invalid = chain.filter(cp => !VALID_CHECKPOINT_VALUES.has(cp)) - if (invalid.length) { - throw new Error( - `${packageName}: Invalid checkpoint names in chain: ${invalid.join(', ')}. ` + - `Valid: ${Object.values(CHECKPOINTS).join(', ')}`, - ) - } - const seen = new Set() - for (let i = 0, { length } = chain; i < length; i += 1) { - const cp = chain[i] - if (seen.has(cp)) { - throw new Error(`${packageName}: Duplicate checkpoint in chain: ${cp}`) - } - seen.add(cp) - } -} - -// Validate chain registry at module load. -for (const [name, generator] of Object.entries(CHECKPOINT_CHAINS)) { - validateCheckpointChain(generator(), `CHECKPOINT_CHAINS.${name}`) -} - -/** - * Resolve the build mode from CLI flags, env, or CI autodetect. - */ -// oxlint-disable-next-line socket/sort-source-methods -- grouped by phase (validate → resolve → consume); alphabetizing scatters the build-config lifecycle. -export function getBuildMode(args?: string[] | Set<string>): string { - if (args) { - const has = Array.isArray(args) - ? (flag: string) => args.includes(flag) - : (flag: string) => args.has(flag) - if (has('--prod')) { - return 'prod' - } - if (has('--dev')) { - return 'dev' - } - } - if (process.env['BUILD_MODE']) { - return process.env['BUILD_MODE'] - } - return getCI() ? 'prod' : 'dev' -} - -/** - * Path used by platform-mappings.isMusl() for Alpine detection. - */ -export const ALPINE_RELEASE_FILE = '/etc/alpine-release' diff --git a/packages/build-infra/lib/esbuild-helpers.mts b/packages/build-infra/lib/esbuild-helpers.mts deleted file mode 100644 index 4818c2541..000000000 --- a/packages/build-infra/lib/esbuild-helpers.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Shared esbuild configuration helpers. - */ - -/** - * Banner code to inject import.meta.url polyfill for CommonJS bundles. - * - * Usage: - * - * ```javascript - * import { IMPORT_META_URL_BANNER } from 'build-infra/lib/esbuild-helpers' - * - * export default { - * // ... other config. - * banner: IMPORT_META_URL_BANNER, - * define: { - * 'import.meta.url': '__importMetaUrl', - * }, - * } - * ``` - * - * This injects a simple const statement at the top of the bundle that converts - * __filename to a proper file:// URL using Node.js pathToFileURL(). Handles all - * edge cases (spaces, special chars, proper URL encoding, Windows paths). - */ -export const IMPORT_META_URL_BANNER = { - js: 'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;', -} diff --git a/packages/build-infra/lib/esbuild-plugin-unicode-transform.mts b/packages/build-infra/lib/esbuild-plugin-unicode-transform.mts deleted file mode 100644 index 00eb22cdc..000000000 --- a/packages/build-infra/lib/esbuild-plugin-unicode-transform.mts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @file Shared esbuild plugin for Unicode property escape transformations. This - * plugin applies Unicode property escape transformations to esbuild output - * for --with-intl=none compatibility. Used by both CLI and bootstrap builds. - * - * @example - * import { unicodeTransformPlugin } from 'build-infra/lib/esbuild-plugin-unicode-transform' - * - * export default { - * plugins: [unicodeTransformPlugin()], - * } - */ - -import type { BuildResult, PluginBuild } from 'esbuild' - -import { transformUnicodePropertyEscapes } from './unicode-property-escape-transform.mts' - -/** - * Create esbuild plugin for Unicode property escape transformations. - * - * @returns {import('esbuild').Plugin} Esbuild plugin - */ -export function unicodeTransformPlugin() { - return { - name: 'unicode-transform', - setup(build: PluginBuild) { - build.onEnd((result: BuildResult) => { - const outputs = result.outputFiles - if (!outputs || !outputs.length) { - return - } - - for (let i = 0, { length } = outputs; i < length; i += 1) { - const output = outputs[i] - let content = output.text - - // Transform Unicode property escapes for --with-intl=none compatibility. - content = transformUnicodePropertyEscapes(content) - - // Update the output content. - output.contents = Buffer.from(content, 'utf8') - } - }) - }, - } -} diff --git a/packages/build-infra/lib/external-tools-schema.json b/packages/build-infra/lib/external-tools-schema.json deleted file mode 100644 index ee9d051e8..000000000 --- a/packages/build-infra/lib/external-tools-schema.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Schema for external-tools.json files", - "type": "object", - "properties": { - "$schema": { "type": "string" }, - "description": { "type": "string" }, - "extends": { - "type": "string", - "description": "Path to a base external-tools.json to inherit from" - }, - "tools": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "description": { "type": "string" }, - "version": { "type": "string" }, - "packageManager": { - "type": "string", - "enum": ["npm", "pip", "pnpm"] - }, - "notes": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } - ] - }, - "repository": { "type": "string" }, - "release": { "type": "string", "enum": ["asset", "archive"] }, - "tag": { "type": "string" }, - "checksums": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "object", - "properties": { - "asset": { "type": "string" }, - "sha256": { "type": "string" } - }, - "required": ["asset", "sha256"] - }, - { "type": "string" } - ] - } - } - }, - "additionalProperties": true - } - } - }, - "additionalProperties": true -} diff --git a/packages/build-infra/lib/github-error-utils.mts b/packages/build-infra/lib/github-error-utils.mts deleted file mode 100644 index 5c6df15de..000000000 --- a/packages/build-infra/lib/github-error-utils.mts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Utilities for detecting and reporting GitHub infrastructure errors. - * This module provides helpers to identify transient GitHub errors (502, 503, - * etc.) and fetch GitHub status to help users understand if the issue is - * temporary. - */ - -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -/** - * Error patterns that indicate transient network/infrastructure issues. These - * typically resolve on retry. - */ -const TRANSIENT_ERROR_PATTERNS = [ - /HTTP\s+(?:408|429|5\d{2})/i, - /Bad Gateway/i, - /Service Unavailable/i, - /Gateway Timeout/i, - /ETIMEDOUT/i, - /ECONNRESET/i, - /ECONNREFUSED/i, - /socket hang up/i, -] - -/** - * Fetch GitHub status and return a human-readable summary. - * - * @returns {Promise< - * { status: string; description: string; url: string } | undefined - * >} - */ -export async function checkGitHubStatus() { - try { - const response = await httpRequest( - 'https://www.githubstatus.com/api/v2/status.json', - { timeout: 5000 }, - ) - if (response.ok) { - const data = await response.json() - return { - status: data.status?.indicator || 'unknown', - description: data.status?.description || 'Unknown status', - url: 'https://www.githubstatus.com', - } - } - } catch { - // GitHub status check failed - don't let this block error reporting. - } - return undefined -} - -/** - * Extract error message from various error types. - * - * @param {Error | string | unknown} error - The error to extract message from. - * - * @returns {string} The error message. - */ -export function getErrorMessage(error) { - if (typeof error === 'string') { - return error - } - if (error instanceof Error) { - return error.message - } - return error?.message || 'Unknown error' -} - -/** - * Check if an error indicates a transient GitHub/network issue. - * - * @param {Error | string | unknown} error - The error to check. - * - * @returns {boolean} True if the error appears to be transient. - */ -export function isTransientError(error) { - const message = getErrorMessage(error) - return TRANSIENT_ERROR_PATTERNS.some(pattern => pattern.test(message)) -} - -/** - * Log helpful messages about a transient GitHub error. Call this when a GitHub - * download fails to provide user-friendly guidance. - * - * @param {Error} error - The original error. - * @param {object} [options] - Options. - * @param {boolean} [options.checkStatus=true] - Whether to check GitHub status. - * - * @returns {Promise<void>} - */ -export async function logTransientErrorHelp( - error, - { checkStatus = true } = {}, -) { - if (!isTransientError(error)) { - return - } - - logger.warn('') - logger.warn('This appears to be a transient GitHub infrastructure issue.') - logger.warn( - 'GitHub Releases CDN occasionally returns 502/503 errors during high load.', - ) - - if (checkStatus) { - const ghStatus = await checkGitHubStatus() - if (ghStatus) { - const statusLabel = - ghStatus.status === 'none' - ? 'operational' - : ghStatus.status === 'minor' - ? 'degraded' - : 'major issue' - logger.warn(`GitHub Status: ${statusLabel} - ${ghStatus.description}`) - logger.warn(`Check: ${ghStatus.url}`) - } - } - - logger.warn('') - logger.warn('Recommended action: Re-run the CI job.') - logger.warn( - 'If the issue persists, check https://www.githubstatus.com for outages.', - ) -} diff --git a/packages/build-infra/lib/github-releases.d.mts b/packages/build-infra/lib/github-releases.d.mts deleted file mode 100644 index d660df2c3..000000000 --- a/packages/build-infra/lib/github-releases.d.mts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Type definitions for github-releases module. - */ - -/** - * Get latest release tag for a repository with retry logic. - */ -export function getLatestRelease( - owner: string, - repo: string, - options?: { - prefix?: string | undefined - quiet?: boolean | undefined - }, -): Promise<string | null> - -/** - * Get download URL for a specific release asset. - */ -export function getReleaseAssetUrl( - owner: string, - repo: string, - tag: string, - assetName: string, - options?: { quiet?: boolean | undefined }, -): Promise<string | null> - -/** - * Download a specific release asset. - */ -export function downloadReleaseAsset( - owner: string, - repo: string, - tag: string, - assetName: string, - outputPath: string, - options?: { quiet?: boolean | undefined }, -): Promise<void> diff --git a/packages/build-infra/lib/github-releases.mts b/packages/build-infra/lib/github-releases.mts deleted file mode 100644 index 3d1b2f51a..000000000 --- a/packages/build-infra/lib/github-releases.mts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Shared utilities for fetching GitHub releases. - */ - -import path from 'node:path' - -import { createTtlCache } from '@socketsecurity/lib-stable/cache/ttl/store' -import { safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { httpDownload } from '@socketsecurity/lib-stable/http-request/download' -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { pRetry } from '@socketsecurity/lib-stable/promises/retry' - -const logger = getDefaultLogger() - -// Cache GitHub API responses for 4 hours to reduce API calls and avoid rate limiting. -const cache = createTtlCache({ - memoize: true, - prefix: 'github-releases', - ttl: 4 * 60 * 60 * 1000, // 4 hours. -}) - -/** - * Download a specific release asset. - * - * Uses browser_download_url to avoid consuming GitHub API quota. The - * httpDownload function from @socketsecurity/lib@5.1.3+ automatically follows - * HTTP redirects, eliminating the need for Octokit's getReleaseAsset API. - * - * @param {string} owner - Repository owner. - * @param {string} repo - Repository name. - * @param {string} tag - Release tag name. - * @param {string} assetName - Asset name to download. - * @param {string} outputPath - Path to write the downloaded file. - * @param {object} [options] - Options. - * @param {boolean} [options.quiet] - Suppress log messages. - * - * @returns {Promise<void>} - */ -export async function downloadReleaseAsset( - owner, - repo, - tag, - assetName, - outputPath, - { quiet = false } = {}, -) { - // Get the browser_download_url for the asset (doesn't consume API quota for download). - const downloadUrl = await getReleaseAssetUrl(owner, repo, tag, assetName, { - quiet, - }) - - if (!downloadUrl) { - throw new Error(`Asset ${assetName} not found in release ${tag}`) - } - - // Create output directory. - await safeMkdir(path.dirname(outputPath)) - - // Download using httpDownload which supports redirects and retries. - // This avoids consuming GitHub API quota for the actual download. - await httpDownload(downloadUrl, outputPath, { - logger: quiet ? undefined : logger, - progressInterval: 10, - retries: 2, - retryDelay: 5_000, - }) -} - -/** - * Get GitHub authentication headers if token is available. - * - * @returns {object} - Headers object with Authorization if token exists. - */ -export function getAuthHeaders() { - const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN - const headers = { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - } - if (token) { - headers.Authorization = `Bearer ${token}` - } - return headers -} - -/** - * Get latest release tag for a repository with retry logic. - * - * @param {string} owner - Repository owner. - * @param {string} repo - Repository name. - * @param {object} [options] - Options. - * @param {string} [options.prefix] - Tag prefix to filter by (for socket-btm - * tool releases). - * @param {boolean} [options.quiet] - Suppress log messages. - * - * @returns {Promise<string | null>} - Latest release tag or null if not found. - */ -export async function getLatestRelease( - owner, - repo, - { prefix, quiet = false } = {}, -) { - const cacheKey = `latest-release:${owner}/${repo}:${prefix || 'latest'}` - - return await cache.getOrFetch(cacheKey, async () => { - return await pRetry( - async () => { - const response = await httpRequest( - `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`, - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - throw new Error(`Failed to fetch releases: ${response.status}`) - } - - let releases - try { - releases = JSON.parse(response.body) - } catch (e) { - throw new Error( - `Failed to parse GitHub API response: ${e instanceof Error ? e.message : String(e)}`, - ) - } - - // If no prefix specified, return the first (latest) release. - if (!prefix) { - if (!releases.length) { - if (!quiet) { - logger.info(` No releases found for ${owner}/${repo}`) - } - return undefined - } - const tag = releases[0].tag_name - if (!quiet) { - logger.info(` Found latest release: ${tag}`) - } - return tag - } - - // Find the first release matching the prefix. - for (let i = 0, { length } = releases; i < length; i += 1) { - const release = releases[i] - const { tag_name: tag } = release - if (tag.startsWith(`${prefix}-`)) { - if (!quiet) { - logger.info(` Found release: ${tag}`) - } - return tag - } - } - - // No matching release found in the list. - if (!quiet) { - logger.info(` No ${prefix} release found in latest 100 releases`) - } - return undefined - }, - { - backoffFactor: 2, - baseDelayMs: 3_000, - onRetry: (attempt, error) => { - if (!quiet) { - logger.info( - ` Retry attempt ${attempt + 1}/3 for ${owner}/${repo} release list...`, - ) - logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) - } - }, - retries: 2, - }, - ) - }) -} - -/** - * Get download URL for a specific release asset. - * - * Returns the browser download URL which requires redirect following. For - * public repositories, this URL returns HTTP 302 redirect to CDN. - * - * @param {string} owner - Repository owner. - * @param {string} repo - Repository name. - * @param {string} tag - Release tag name. - * @param {string} assetName - Asset name to download. - * @param {object} [options] - Options. - * @param {boolean} [options.quiet] - Suppress log messages. - * - * @returns {Promise<string | null>} - Download URL or null if not found. - */ -export async function getReleaseAssetUrl( - owner, - repo, - tag, - assetName, - { quiet = false } = {}, -) { - const cacheKey = `asset-url:${owner}/${repo}:${tag}:${assetName}` - - return await cache.getOrFetch(cacheKey, async () => { - return await pRetry( - async () => { - const response = await httpRequest( - `https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`, - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - throw new Error(`Failed to fetch release ${tag}: ${response.status}`) - } - - let release - try { - release = JSON.parse(response.body) - } catch (e) { - throw new Error( - `Failed to parse GitHub release ${tag}: ${e instanceof Error ? e.message : String(e)}`, - ) - } - - // Find the matching asset. - const asset = release.assets.find(a => a.name === assetName) - - if (!asset) { - throw new Error(`Asset ${assetName} not found in release ${tag}`) - } - - if (!quiet) { - logger.info(` Found asset: ${assetName}`) - } - - return asset.browser_download_url - }, - { - backoffFactor: 2, - baseDelayMs: 3_000, - onRetry: (attempt, error) => { - if (!quiet) { - logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`) - logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) - } - }, - retries: 2, - }, - ) - }) -} diff --git a/packages/build-infra/lib/platform-mappings.mts b/packages/build-infra/lib/platform-mappings.mts deleted file mode 100644 index d59f06544..000000000 --- a/packages/build-infra/lib/platform-mappings.mts +++ /dev/null @@ -1,245 +0,0 @@ -import process from 'node:process' - -/** - * Shared platform and architecture mappings for GitHub release assets. - * - * Maps Node.js platform/architecture names to release asset naming conventions. - * Used consistently across all download and build scripts to avoid - * duplication. - */ - -import { existsSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { ALPINE_RELEASE_FILE } from './constants.mts' - -const logger = getDefaultLogger() - -/** - * Maps Node.js platform names to GitHub release platform names. - * - * @type {Readonly<Record<string, string>>} - */ -const RELEASE_PLATFORM_MAP = Object.freeze({ - __proto__: null, - darwin: 'darwin', - linux: 'linux', - win32: 'win', -}) - -/** - * Maps Node.js architecture names to GitHub release architecture names. - * - * @type {Readonly<Record<string, string>>} - */ -const RELEASE_ARCH_MAP = Object.freeze({ - __proto__: null, - arm64: 'arm64', - ia32: 'x86', - x64: 'x64', -}) - -/** - * Get platform-arch string for GitHub release asset naming. Uses shortened - * platform names (win instead of win32). - * - * @param {string} platform - Node.js platform (darwin, linux, win32). - * @param {string} arch - Node.js architecture (arm64, x64, ia32). - * @param {string | undefined} [libc] - C library variant (musl, glibc) - Linux - * only. - * - * @returns {string} Platform-arch string for assets (e.g., 'win-x64', - * 'linux-x64-musl'). - * - * @throws {Error} If platform/arch is unsupported. - */ -export function getAssetPlatformArch(platform, arch, libc) { - const releasePlatform = RELEASE_PLATFORM_MAP[platform] - const releaseArch = RELEASE_ARCH_MAP[arch] - - if (!releasePlatform || !releaseArch) { - throw new Error(`Unsupported platform/arch: ${platform}/${arch}`) - } - - // Validate libc parameter. - if (libc && libc !== 'musl' && libc !== 'glibc') { - throw new Error(`Invalid libc: ${libc}. Valid options: musl, glibc`) - } - if (libc && platform !== 'linux') { - throw new Error( - `libc parameter is only valid for Linux platform (got platform: ${platform})`, - ) - } - // Warn when libc is missing for Linux - this usually indicates a bug. - // Use getCurrentPlatformArch() instead, which auto-detects libc. - if (platform === 'linux' && libc === undefined) { - logger.warn( - 'getAssetPlatformArch() called for Linux without libc parameter. ' + - 'This may cause builds to output to wrong directory (linux-x64 vs linux-x64-musl). ' + - 'Consider using getCurrentPlatformArch() which auto-detects libc.', - ) - } - - // Add musl suffix for Linux musl builds. - const muslSuffix = platform === 'linux' && libc === 'musl' ? '-musl' : '' - // Use shortened platform names for asset names - return `${releasePlatform}-${releaseArch}${muslSuffix}` -} - -/** - * Get platform-arch string for the current platform using shared mapping. - * - * Resolution order: - * - * 1. `PLATFORM_ARCH` env — the explicit value the workflow/Dockerfile injected - * (set by .github/workflows/*.yml build-args and every Dockerfile). - * 2. Cross-compile env (`TARGET_ARCH`, `LIBC`) applied on top of the host's - * platform/arch. - * 3. Full auto-detect via `isMusl()` + `process.arch` + `process.platform`. - * - * @returns {Promise<string>} Platform-arch string (e.g., 'win-x64', - * 'linux-x64-musl'). - */ -export async function getCurrentPlatformArch() { - // If the workflow or Dockerfile set PLATFORM_ARCH explicitly, trust it. - if (process.env.PLATFORM_ARCH) { - return process.env.PLATFORM_ARCH - } - // Respect LIBC environment variable for cross-compilation (set by workflows) - // Falls back to isMusl() for host detection when not cross-compiling. - const libc = process.env.LIBC || ((await isMusl()) ? 'musl' : undefined) - // Respect TARGET_ARCH for cross-compilation (set by workflows/Makefiles) - const arch = process.env.TARGET_ARCH || process.arch - return getAssetPlatformArch(process.platform, arch, libc) -} - -/** - * Get platform-arch string for internal directory paths (download locations). - * Uses Node.js platform naming directly (win32, darwin, linux). - * - * @param {string} platform - Node.js platform (darwin, linux, win32). - * @param {string} arch - Node.js architecture (arm64, x64, ia32). - * @param {string | undefined} [libc] - C library variant (musl, glibc) - Linux - * only. - * - * @returns {string} Platform-arch string (e.g., 'win32-x64', 'linux-x64-musl'). - * - * @throws {Error} If platform/arch is unsupported. - */ -export function getPlatformArch(platform, arch, libc) { - const releaseArch = RELEASE_ARCH_MAP[arch] - - if (!releaseArch) { - throw new Error(`Unsupported arch: ${arch}`) - } - if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { - throw new Error(`Unsupported platform: ${platform}`) - } - - // Validate libc parameter. - if (libc && libc !== 'musl' && libc !== 'glibc') { - throw new Error(`Invalid libc: ${libc}. Valid options: musl, glibc`) - } - if (libc && platform !== 'linux') { - throw new Error( - `libc parameter is only valid for Linux platform (got platform: ${platform})`, - ) - } - - // Add musl suffix for Linux musl builds. - const muslSuffix = platform === 'linux' && libc === 'musl' ? '-musl' : '' - // Use Node.js platform naming directly for directory paths - return `${platform}-${releaseArch}${muslSuffix}` -} - -/** - * Read the requested glibc floor from the GLIBC_FLOOR env var. - * - * Returned value is a string like "2.17" or "2.28", or undefined when unset. No - * behavior change today — this is groundwork for threading a glibc floor - * dimension through cache keys and Docker image selection when we lower the - * floor. See packages/node-smol-builder/docs/plans/glibc-floor-lowering.md. - * - * Callers should treat `undefined` as "fall back to the repo's current default - * build image (glibc 2.28)" so behavior is unchanged until the env is set. - * - * @returns {string | undefined} Requested glibc floor, or undefined. - */ -export function getRequestedGlibcFloor(): string | undefined { - const raw = process.env.GLIBC_FLOOR - if (!raw) { - return undefined - } - const trimmed = raw.trim() - // Accept "2.17" or "2.28". Reject anything else so typos surface loudly. - if (trimmed === '2.17' || trimmed === '2.28') { - return trimmed - } - throw new Error( - `Unrecognized GLIBC_FLOOR="${raw}". Expected "2.17" or "2.28".`, - ) -} - -/** - * Detect if running on musl libc (Alpine Linux). - * - * @returns {Promise<boolean>} True if running on musl libc. - */ -export async function isMusl() { - if (process.platform !== 'linux') { - return false - } - - // Check for Alpine release file. - if (existsSync(ALPINE_RELEASE_FILE)) { - return true - } - - // Check ldd version for musl. - try { - const result = await spawn('ldd', ['--version'], { stdio: 'pipe' }) - const output = result.stdout + result.stderr - return output.includes('musl') - } catch { - // Expected: ldd may not exist in some environments. - return false - } -} - -/** - * Check if tar supports --no-absolute-names (GNU tar has it, busybox tar - * doesn't). - * - * @returns {Promise<boolean>} True if tar supports --no-absolute-names. - */ -export async function tarSupportsNoAbsoluteNames() { - try { - const result = await spawn('tar', ['--help'], { stdio: 'pipe' }) - return (result.stdout || '').includes('--no-absolute-names') - } catch { - return false - } -} - -/** - * Check if tar supports --overwrite (GNU tar has it, BSD/macOS tar doesn't). - * - * @returns {Promise<boolean>} True if tar supports --overwrite. - */ -export async function tarSupportsOverwrite() { - // BSD tar on macOS doesn't support --overwrite. - // Quick platform check to avoid spawning tar unnecessarily. - if (process.platform === 'darwin') { - return false - } - try { - const result = await spawn('tar', ['--help'], { stdio: 'pipe' }) - // Look for the actual --overwrite flag, not just the word "overwrite" - // (BSD tar mentions "overwrite" in the -k flag description but doesn't support --overwrite) - return /^\s*--overwrite\b/m.test(result.stdout || '') - } catch { - return false - } -} diff --git a/packages/build-infra/lib/platform-targets.mts b/packages/build-infra/lib/platform-targets.mts deleted file mode 100644 index 5f16b25c2..000000000 --- a/packages/build-infra/lib/platform-targets.mts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * @file Shared platform target utilities for SEA builds. Provides constants and - * parsing functions for platform/arch/libc combinations. This is the single - * source of truth for all platform definitions. Naming convention: - * - * - `platform`: Node.js process.platform value (darwin, linux, win32) - * - `releasePlatform`: Normalized for file/folder/npm names (darwin, linux, - * win) - */ - -/** - * Complete platform configuration with all metadata. This is the authoritative - * source for platform definitions. - * - * @type {ReadonlyArray<{ - * platform: string - * releasePlatform: string - * arch: string - * libc?: string - * runner: string - * cpu: string - * os: string - * binExt: string - * description: string - * }>} - */ -export const PLATFORM_CONFIGS = Object.freeze([ - { - arch: 'arm64', - binExt: '', - cpu: 'arm64', - description: 'macOS ARM64 (Apple Silicon)', - os: 'darwin', - platform: 'darwin', - releasePlatform: 'darwin', - runner: 'macos-latest', - }, - { - arch: 'x64', - binExt: '', - cpu: 'x64', - description: 'macOS x64 (Intel)', - os: 'darwin', - platform: 'darwin', - releasePlatform: 'darwin', - runner: 'macos-latest', - }, - { - arch: 'arm64', - binExt: '', - cpu: 'arm64', - description: 'Linux ARM64 (glibc)', - os: 'linux', - platform: 'linux', - releasePlatform: 'linux', - runner: 'ubuntu-latest', - }, - { - arch: 'arm64', - binExt: '', - cpu: 'arm64', - description: 'Linux ARM64 (musl/Alpine)', - libc: 'musl', - os: 'linux', - platform: 'linux', - releasePlatform: 'linux', - runner: 'ubuntu-latest', - }, - { - arch: 'x64', - binExt: '', - cpu: 'x64', - description: 'Linux x64 (glibc)', - os: 'linux', - platform: 'linux', - releasePlatform: 'linux', - runner: 'ubuntu-latest', - }, - { - arch: 'x64', - binExt: '', - cpu: 'x64', - description: 'Linux x64 (musl/Alpine)', - libc: 'musl', - os: 'linux', - platform: 'linux', - releasePlatform: 'linux', - runner: 'ubuntu-latest', - }, - { - arch: 'arm64', - binExt: '.exe', - cpu: 'arm64', - description: 'Windows ARM64', - os: 'win32', - platform: 'win32', - releasePlatform: 'win', - runner: 'windows-latest', - }, - { - arch: 'x64', - binExt: '.exe', - cpu: 'x64', - description: 'Windows x64', - os: 'win32', - platform: 'win32', - releasePlatform: 'win', - runner: 'windows-latest', - }, -]) - -/** - * Valid platform targets for SEA builds (using releasePlatform for naming). - * Format: <releasePlatform>-<arch>[-musl] Derived from PLATFORM_CONFIGS. - */ -const PLATFORM_TARGETS = PLATFORM_CONFIGS.map( - c => `${c.releasePlatform}-${c.arch}${c.libc ? `-${c.libc}` : ''}`, -) - -/** - * Get the release platform name for file/folder/npm naming. Converts win32 → - * win, leaves others unchanged. - * - * @param {string} platform - Node.js platform (darwin, linux, win32). - * - * @returns {string} Release platform (darwin, linux, win). - */ -export function getReleasePlatform(platform) { - return platform === 'win32' ? 'win' : platform -} - -/** - * Valid platforms (Node.js process.platform values). - */ -const VALID_PLATFORMS = ['darwin', 'linux', 'win32'] - -/** - * Valid architectures. - */ -const VALID_ARCHS = ['arm64', 'x64'] - -/** - * Parsed platform target information. - * - * @typedef {Object} PlatformTargetInfo - * - * @property {string} platform - Platform (darwin, linux, win32). - * @property {string} arch - Architecture (arm64, x64). - * @property {string} [libc] - Optional libc variant (musl). - */ - -/** - * Parse a platform target string into components. Handles formats: - * darwin-arm64, linux-x64, linux-arm64-musl, win-x64, win32-x64 Accepts both - * 'win' (release naming) and 'win32' (Node.js naming) for Windows. - * - * @example - * parsePlatformTarget('darwin-arm64') - * // { platform: 'darwin', arch: 'arm64' } - * - * @example - * parsePlatformTarget('linux-x64-musl') - * // { platform: 'linux', arch: 'x64', libc: 'musl' } - * - * @example - * parsePlatformTarget('win-x64') - * // { platform: 'win32', arch: 'x64' } - * - * @param {string} target - Target string (e.g., "darwin-arm64" or - * "linux-x64-musl"). - * - * @returns {PlatformTargetInfo | null} Parsed info or null if invalid. - */ -export function parsePlatformTarget(target) { - if (!target || typeof target !== 'string') { - return undefined - } - - // Handle musl suffix (linux-arm64-musl, linux-x64-musl). - if (target.endsWith('-musl')) { - const base = target.slice(0, -5) // Remove '-musl'. - const parts = base.split('-') - if ( - parts.length === 2 && - parts[0] === 'linux' && - VALID_ARCHS.includes(parts[1]) - ) { - return { arch: parts[1], libc: 'musl', platform: 'linux' } - } - return undefined - } - - // Handle standard platform-arch. - const parts = target.split('-') - if (parts.length === 2) { - const [rawPlatform, arch] = parts - // Normalize 'win' to 'win32' for internal use. - const platform = rawPlatform === 'win' ? 'win32' : rawPlatform - if (VALID_PLATFORMS.includes(platform) && VALID_ARCHS.includes(arch)) { - return { arch, platform } - } - } - - return undefined -} - -/** - * Check if a string is a valid platform target. - * - * @param {string} target - Target string to validate. - * - * @returns {boolean} True if valid platform target. - */ -// oxlint-disable-next-line socket/sort-source-methods -- grouped by phase (parse → validate → resolve → format); alphabetizing would scatter the parse-validate-resolve flow. -export function isPlatformTarget(target) { - return PLATFORM_TARGETS.includes(target) -} - -/** - * Get the full platform config for a target string. Accepts both release naming - * (win-x64) and Node.js naming (win32-x64). - * - * @param {string} target - Target string (e.g., "darwin-arm64", "win-x64", or - * "linux-x64-musl"). - * - * @returns {(typeof PLATFORM_CONFIGS)[number] | undefined} Full platform config - * or undefined. - */ -// oxlint-disable-next-line socket/sort-source-methods -- grouped by phase (parse → validate → resolve → format); alphabetizing would scatter the parse-validate-resolve flow. -export function getPlatformConfig(target) { - return PLATFORM_CONFIGS.find( - c => - `${c.releasePlatform}-${c.arch}${c.libc ? `-${c.libc}` : ''}` === - target || - `${c.platform}-${c.arch}${c.libc ? `-${c.libc}` : ''}` === target, - ) -} - -/** - * Format platform info back into a target string. - * - * @param {string} platform - Platform (darwin, linux, win32). - * @param {string} arch - Architecture (arm64, x64). - * @param {string} [libc] - Optional libc variant (musl). - * - * @returns {string} Target string (e.g., "linux-x64-musl"). - */ -// oxlint-disable-next-line socket/sort-source-methods -- grouped by phase (parse → validate → resolve → format); alphabetizing would scatter the parse-validate-resolve flow. -export function formatPlatformTarget(platform, arch, libc) { - const muslSuffix = libc === 'musl' ? '-musl' : '' - return `${platform}-${arch}${muslSuffix}` -} - -/** - * Parsed platform arguments from CLI. - * - * @typedef {Object} PlatformArgs - * - * @property {string | null} platform - Platform or null. - * @property {string | null} arch - Architecture or null. - * @property {string | null} libc - Libc variant or null. - */ - -/** - * Parse CLI arguments for platform/arch/target/libc flags. - * - * @example - * parsePlatformArgs(['--platform=darwin', '--arch=arm64']) - * // { platform: 'darwin', arch: 'arm64', libc: null } - * - * @example - * parsePlatformArgs(['--target=linux-x64-musl']) - * // { platform: 'linux', arch: 'x64', libc: 'musl' } - * - * @param {string[]} args - CLI arguments array. - * - * @returns {PlatformArgs} Parsed platform arguments. - */ -// oxlint-disable-next-line socket/sort-source-methods -- grouped by phase (parse → validate → resolve → format); alphabetizing would scatter the parse-validate-resolve flow. -export function parsePlatformArgs(args) { - const result = { arch: undefined, libc: undefined, platform: undefined } - - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i] - if (arg.startsWith('--platform=')) { - const parts = arg.split('=') - if (parts.length >= 2) { - result.platform = parts[1] - } - } else if (arg.startsWith('--arch=')) { - const parts = arg.split('=') - if (parts.length >= 2) { - result.arch = parts[1] - } - } else if (arg.startsWith('--libc=')) { - const parts = arg.split('=') - if (parts.length >= 2) { - result.libc = parts[1] - } - } else if (arg.startsWith('--target=')) { - const parts = arg.split('=') - if (parts.length >= 2) { - const parsed = parsePlatformTarget(parts[1]) - if (parsed) { - result.platform = parsed.platform - result.arch = parsed.arch - result.libc = parsed.libc ?? undefined - } - } - } - } - - return result -} diff --git a/packages/build-infra/lib/unicode-property-escape-transform.mts b/packages/build-infra/lib/unicode-property-escape-transform.mts deleted file mode 100644 index c922a14fd..000000000 --- a/packages/build-infra/lib/unicode-property-escape-transform.mts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @file Transform Unicode property escapes for --with-intl=none compatibility. - * This module provides transformations to convert Unicode property escapes - * (\p{Property}) into basic character class equivalents that work without ICU - * support. - */ - -import { parse } from '@babel/parser' -import { default as traverseImport } from '@babel/traverse' -import MagicString from 'magic-string' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() -const traverse = - typeof traverseImport === 'function' - ? traverseImport - : (traverseImport as unknown as { default: typeof traverseImport }).default - -/** - * Map of Unicode property escapes to explicit character ranges. These are used - * when Node.js is built without ICU support (--with-intl=none). Based on - * ECMAScript Unicode property escapes specification: - * https://tc39.es/ecma262/#table-binary-unicode-properties - * https://tc39.es/ecma262/#table-binary-unicode-properties-of-strings. - */ -const unicodePropertyMap = { - __proto__: null, - - // Special properties. - Default_Ignorable_Code_Point: - '\\u00AD\\u034F\\u061C\\u115F-\\u1160\\u17B4-\\u17B5\\u180B-\\u180D\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u206F\\u3164\\uFE00-\\uFE0F\\uFEFF\\uFFA0\\uFFF0-\\uFFF8', - ASCII: '\\x00-\\x7F', - ASCII_Hex_Digit: '0-9A-Fa-f', - Alphabetic: - 'A-Za-z\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE', - - // General categories - Letter. - Letter: - 'A-Za-z\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE', - L: 'A-Za-z\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE', - Lowercase_Letter: 'a-z\\u00B5\\u00DF-\\u00F6\\u00F8-\\u00FF', - Ll: 'a-z\\u00B5\\u00DF-\\u00F6\\u00F8-\\u00FF', - Uppercase_Letter: 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE', - Lu: 'A-Z\\u00C0-\\u00D6\\u00D8-\\u00DE', - Titlecase_Letter: '\\u01C5\\u01C8\\u01CB\\u01F2', - Lt: '\\u01C5\\u01C8\\u01CB\\u01F2', - Modifier_Letter: - '\\u02B0-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE', - Lm: '\\u02B0-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE', - Other_Letter: '\\u00AA\\u00BA', - Lo: '\\u00AA\\u00BA', - - // General categories - Mark. - Mark: '\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1-\\u05C2\\u05C4-\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065F\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7-\\u06E8\\u06EA-\\u06ED', - M: '\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1-\\u05C2\\u05C4-\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065F\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7-\\u06E8\\u06EA-\\u06ED', - Nonspacing_Mark: - '\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1-\\u05C2\\u05C4-\\u05C5\\u05C7', - Mn: '\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1-\\u05C2\\u05C4-\\u05C5\\u05C7', - Spacing_Mark: '\\u0903\\u093B\\u093E-\\u0940\\u0949-\\u094C\\u094E-\\u094F', - Mc: '\\u0903\\u093B\\u093E-\\u0940\\u0949-\\u094C\\u094E-\\u094F', - Enclosing_Mark: '\\u0488-\\u0489', - Me: '\\u0488-\\u0489', - - // General categories - Number. - Number: '0-9\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE', - N: '0-9\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE', - Decimal_Number: '0-9', - Nd: '0-9', - Letter_Number: - '\\u16EE-\\u16F0\\u2160-\\u2182\\u2185-\\u2188\\u3007\\u3021-\\u3029\\u3038-\\u303A', - Nl: '\\u16EE-\\u16F0\\u2160-\\u2182\\u2185-\\u2188\\u3007\\u3021-\\u3029\\u3038-\\u303A', - Other_Number: '\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE', - No: '\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE', - - // General categories - Punctuation. - Punctuation: - '!-#%-\\*,-\\/:;\\?@\\[-\\]_\\{\\}\\u00A1\\u00A7\\u00AB\\u00B6-\\u00B7\\u00BB\\u00BF', - P: '!-#%-\\*,-\\/:;\\?@\\[-\\]_\\{\\}\\u00A1\\u00A7\\u00AB\\u00B6-\\u00B7\\u00BB\\u00BF', - Connector_Punctuation: '_\\u203F-\\u2040', - Pc: '_\\u203F-\\u2040', - Dash_Punctuation: '\\-\\u2010-\\u2015', - Pd: '\\-\\u2010-\\u2015', - Open_Punctuation: '\\(\\[\\{', - Ps: '\\(\\[\\{', - Close_Punctuation: '\\)\\]\\}', - Pe: '\\)\\]\\}', - Initial_Punctuation: '\\u00AB', - Pi: '\\u00AB', - Final_Punctuation: '\\u00BB', - Pf: '\\u00BB', - Other_Punctuation: - '!-#%-\\*,\\.\\/:;\\?@\\\\\\u00A1\\u00A7\\u00B6-\\u00B7\\u00BF', - Po: '!-#%-\\*,\\.\\/:;\\?@\\\\\\u00A1\\u00A7\\u00B6-\\u00B7\\u00BF', - - // General categories - Symbol. - Symbol: - '\\$\\+<->\\^`\\|~\\u00A2-\\u00A6\\u00A8-\\u00A9\\u00AC\\u00AE-\\u00B1\\u00B4\\u00B8\\u00D7\\u00F7', - S: '\\$\\+<->\\^`\\|~\\u00A2-\\u00A6\\u00A8-\\u00A9\\u00AC\\u00AE-\\u00B1\\u00B4\\u00B8\\u00D7\\u00F7', - Math_Symbol: '\\+<->\\|~\\u00AC\\u00B1\\u00D7\\u00F7', - Sm: '\\+<->\\|~\\u00AC\\u00B1\\u00D7\\u00F7', - Currency_Symbol: '\\$\\u00A2-\\u00A5', - Sc: '\\$\\u00A2-\\u00A5', - Modifier_Symbol: '\\^`\\u00A8\\u00AF\\u00B4\\u00B8', - Sk: '\\^`\\u00A8\\u00AF\\u00B4\\u00B8', - Other_Symbol: '\\u00A6\\u00A9\\u00AE\\u00B0', - So: '\\u00A6\\u00A9\\u00AE\\u00B0', - - // General categories - Separator. - Separator: - ' \\u00A0\\u1680\\u2000-\\u200A\\u2028-\\u2029\\u202F\\u205F\\u3000', - Z: ' \\u00A0\\u1680\\u2000-\\u200A\\u2028-\\u2029\\u202F\\u205F\\u3000', - Space_Separator: ' \\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000', - Zs: ' \\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000', - Line_Separator: '\\u2028', - Zl: '\\u2028', - Paragraph_Separator: '\\u2029', - Zp: '\\u2029', - - // General categories - Other. - Other: '\\x00-\\x1F\\x7F-\\x9F\\u00AD', - C: '\\x00-\\x1F\\x7F-\\x9F\\u00AD', - Control: '\\x00-\\x1F\\x7F-\\x9F', - Cc: '\\x00-\\x1F\\x7F-\\x9F', - Format: - '\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB', - Cf: '\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u08E2\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB', - Surrogate: '\\uD800-\\uDFFF', - Cs: '\\uD800-\\uDFFF', - Private_Use: '\\uE000-\\uF8FF', - Co: '\\uE000-\\uF8FF', - Unassigned: '\\u0378-\\u0379\\u0380-\\u0383\\u038B\\u038D\\u03A2', - Cn: '\\u0378-\\u0379\\u0380-\\u0383\\u038B\\u038D\\u03A2', - - // Emoji properties. - Extended_Pictographic: - '\\u00A9\\u00AE\\u203C\\u2049\\u2122\\u2139\\u2194-\\u2199\\u21A9-\\u21AA\\u231A-\\u231B\\u2328\\u23CF\\u23E9-\\u23F3\\u23F8-\\u23FA\\u24C2\\u25AA-\\u25AB\\u25B6\\u25C0\\u25FB-\\u25FE\\u2600-\\u2604\\u260E\\u2611\\u2614-\\u2615\\u2618\\u261D\\u2620\\u2622-\\u2623\\u2626\\u262A\\u262E-\\u262F\\u2638-\\u263A\\u2640\\u2642\\u2648-\\u2653\\u265F-\\u2660\\u2663\\u2665-\\u2666\\u2668\\u267B\\u267E-\\u267F\\u2692-\\u2697\\u2699\\u269B-\\u269C\\u26A0-\\u26A1\\u26A7\\u26AA-\\u26AB\\u26B0-\\u26B1\\u26BD-\\u26BE\\u26C4-\\u26C5\\u26C8\\u26CE-\\u26CF\\u26D1\\u26D3-\\u26D4\\u26E9-\\u26EA\\u26F0-\\u26F5\\u26F7-\\u26FA\\u26FD\\u2702\\u2705\\u2708-\\u270D\\u270F\\u2712\\u2714\\u2716\\u271D\\u2721\\u2728\\u2733-\\u2734\\u2744\\u2747\\u274C\\u274E\\u2753-\\u2755\\u2757\\u2763-\\u2764\\u2795-\\u2797\\u27A1\\u27B0\\u27BF\\u2934-\\u2935\\u2B05-\\u2B07\\u2B1B-\\u2B1C\\u2B50\\u2B55\\u3030\\u303D\\u3297\\u3299', - RGI_Emoji: - '\\u00A9\\u00AE\\u203C\\u2049\\u2122\\u2139\\u2194-\\u2199\\u21A9-\\u21AA\\u231A-\\u231B\\u2328\\u23CF\\u23E9-\\u23F3\\u23F8-\\u23FA\\u24C2\\u25AA-\\u25AB\\u25B6\\u25C0\\u25FB-\\u25FE\\u2600-\\u2604\\u260E\\u2611\\u2614-\\u2615\\u2618\\u261D\\u2620\\u2622-\\u2623\\u2626\\u262A\\u262E-\\u262F\\u2638-\\u263A\\u2640\\u2642\\u2648-\\u2653\\u265F-\\u2660\\u2663\\u2665-\\u2666\\u2668\\u267B\\u267E-\\u267F\\u2692-\\u2697\\u2699\\u269B-\\u269C\\u26A0-\\u26A1\\u26A7\\u26AA-\\u26AB\\u26B0-\\u26B1\\u26BD-\\u26BE\\u26C4-\\u26C5\\u26C8\\u26CE-\\u26CF\\u26D1\\u26D3-\\u26D4\\u26E9-\\u26EA\\u26F0-\\u26F5\\u26F7-\\u26FA\\u26FD\\u2702\\u2705\\u2708-\\u270D\\u270F\\u2712\\u2714\\u2716\\u271D\\u2721\\u2728\\u2733-\\u2734\\u2744\\u2747\\u274C\\u274E\\u2753-\\u2755\\u2757\\u2763-\\u2764\\u2795-\\u2797\\u27A1\\u27B0\\u27BF\\u2934-\\u2935\\u2B05-\\u2B07\\u2B1B-\\u2B1C\\u2B50\\u2B55\\u3030\\u303D\\u3297\\u3299', -} - -/** - * Escape a string for insertion into JavaScript string literal context. When we - * get a pattern from Babel's StringLiteral.value, backslashes are interpreted. - * But when writing back into source code, we need to re-escape them. - */ -export function escapeForStringLiteral(str: string) { - return ( - str - // Backslash must be doubled. - .replace(/\\/g, '\\\\') - // Escape quotes if needed (handled by keeping original quotes). - .replace(/"/g, '\\"') - // Escape single quotes if needed. - .replace(/'/g, "\\'") - ) -} - -/** - * Check if a regex pattern has unsupported Unicode features. - */ -export function hasUnsupportedUnicodeFeatures(pattern: string) { - // Check for \u{} escapes (require /u flag). - if (/\\u\{[0-9a-fA-F]+\}/.test(pattern)) { - return true - } - // Check for remaining \p{} or \P{} escapes that we don't support. - if (/\\[pP]\{/.test(pattern)) { - return true - } - return false -} - -/** - * Transform a regex pattern by replacing \p{Property} with character classes. - */ -export function transformRegexPattern(pattern: string) { - let transformed = pattern - - // Replace \p{Property} with character class equivalents. - for (const [prop, replacement] of Object.entries(unicodePropertyMap)) { - const escapedProp = prop.replace(/[\\{}]/g, '\\$&') - // Replace \p{Property} with [replacement]. - transformed = transformed.replace( - new RegExp(`\\\\p\\{${escapedProp}\\}`, 'g'), - `[${replacement}]`, - ) - } - - return transformed -} - -/** - * Transform Unicode property escapes in regex patterns for ICU-free - * environments. - * - * Uses Babel AST parsing to properly identify regex literals and transform - * them. - * - * @param {string} content - Source code to transform. - * - * @returns {string} Transformed source code - */ -export function transformUnicodePropertyEscapes(content: string) { - let ast - try { - ast = parse(content, { - sourceType: 'module', - plugins: [], - }) - } catch (e) { - // If parsing fails, return content unchanged. - logger.warn( - 'Failed to parse code for Unicode transform:', - e instanceof Error ? e.message : e, - ) - return content - } - - const s = new MagicString(content) - - traverse(ast, { - // eslint-disable-next-line typescript-eslint/no-explicit-any -- @babel/traverse types are not installed; visitor path uses dynamic AST node shape. - RegExpLiteral(path: any) { - const { node } = path - const { flags, pattern } = node - const { end, start } = node - - // Check if this regex has /u or /v flags. - const hasUFlag = flags.includes('u') - const hasVFlag = flags.includes('v') - - if (!hasUFlag && !hasVFlag) { - // No Unicode flags, nothing to transform. - return - } - - // Get the original regex literal from source. - const originalRegex = content.slice(start, end) - - // Transform the pattern (using Babel's interpreted pattern for replacements). - const transformedPattern = transformRegexPattern(pattern) - - // Check if transformed pattern still has unsupported Unicode features. - if (hasUnsupportedUnicodeFeatures(transformedPattern)) { - // Replace entire regex with /(?:)/ (no-op regex). - s.overwrite(start, end, '/(?:)/') - return - } - - // If pattern changed, update it by doing string replacement on the original source. - if (transformedPattern !== pattern) { - // Work with the original regex source text, removing opening/closing slashes and flags. - // Extract just the pattern part from /pattern/flags. - const lastSlash = originalRegex.lastIndexOf('/') - const originalPattern = originalRegex.slice(1, lastSlash) - const originalFlags = originalRegex.slice(lastSlash + 1) - - // Do the same transformations on the source text. - let newPattern = originalPattern - for (const [prop, replacement] of Object.entries(unicodePropertyMap)) { - const escapedProp = prop.replace(/[\\{}]/g, '\\$&') - newPattern = newPattern.replace( - new RegExp(`\\\\p\\{${escapedProp}\\}`, 'g'), - `[${replacement}]`, - ) - } - - // Remove /u and /v flags from the original flags. - const newFlags = originalFlags.replace(/[uv]/g, '') - const newRegex = `/${newPattern}/${newFlags}` - s.overwrite(start, end, newRegex) - return - } - - // Pattern unchanged but has Unicode flags - check if safe to remove flags. - // Only remove flags if pattern has no \u{} escapes or other Unicode-specific syntax. - if (!hasUnsupportedUnicodeFeatures(pattern)) { - // Safe to remove Unicode flags - just remove the flags from the original source. - const lastSlash = originalRegex.lastIndexOf('/') - const originalPattern = originalRegex.slice(1, lastSlash) - const originalFlags = originalRegex.slice(lastSlash + 1) - const newFlags = originalFlags.replace(/[uv]/g, '') - const newRegex = `/${originalPattern}/${newFlags}` - s.overwrite(start, end, newRegex) - } else { - // Has unsupported features, replace with no-op. - s.overwrite(start, end, '/(?:)/') - } - }, - - // eslint-disable-next-line typescript-eslint/no-explicit-any -- @babel/traverse types are not installed; visitor path uses dynamic AST node shape. - NewExpression(path: any) { - const { node } = path - - // Check if this is a RegExp constructor. - if (node.callee.type !== 'Identifier' || node.callee.name !== 'RegExp') { - return - } - - // Must have at least 2 arguments (pattern, flags). - if (!node.arguments || node.arguments.length < 2) { - return - } - - const patternArg = node.arguments[0] - const flagsArg = node.arguments[1] - - // Both arguments must be string literals. - if ( - patternArg.type !== 'StringLiteral' || - flagsArg.type !== 'StringLiteral' - ) { - return - } - - const pattern = patternArg.value - const flags = flagsArg.value - - // Check if this regex has u or v flags. - const hasUFlag = flags.includes('u') - const hasVFlag = flags.includes('v') - - if (!hasUFlag && !hasVFlag) { - // No Unicode flags, nothing to transform. - return - } - - // Transform the pattern. - const transformedPattern = transformRegexPattern(pattern) - - // Check if transformed pattern still has unsupported Unicode features. - if (hasUnsupportedUnicodeFeatures(transformedPattern)) { - // Replace with no-op regex: new RegExp('(?:)', ''). - s.overwrite(node.start, node.end, 'new RegExp("(?:)", "")') - return - } - - // If pattern changed or flags need to be removed. - if (transformedPattern !== pattern || hasUFlag || hasVFlag) { - // Remove u and v flags. - const newFlags = flags.replace(/[uv]/g, '') - - // Determine quote character from original code. - const patternQuote = content[patternArg.start] - const flagsQuote = content[flagsArg.start] - - // Escape the transformed pattern for string literal context. - const escapedPattern = escapeForStringLiteral(transformedPattern) - - // Replace pattern. - s.overwrite( - patternArg.start, - patternArg.end, - `${patternQuote}${escapedPattern}${patternQuote}`, - ) - - // Replace flags. - s.overwrite( - flagsArg.start, - flagsArg.end, - `${flagsQuote}${newFlags}${flagsQuote}`, - ) - } - }, - }) - - return s.toString() -} diff --git a/packages/build-infra/package.json b/packages/build-infra/package.json deleted file mode 100644 index 15b481b7c..000000000 --- a/packages/build-infra/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "build-infra", - "version": "1.0.0", - "private": true, - "description": "Shared build infrastructure utilities for Socket CLI", - "type": "module", - "exports": { - "./lib/constants": "./lib/constants.mts", - "./lib/esbuild-helpers": "./lib/esbuild-helpers.mts", - "./lib/esbuild-plugin-unicode-transform": "./lib/esbuild-plugin-unicode-transform.mts", - "./lib/github-error-utils": "./lib/github-error-utils.mts", - "./lib/github-releases": "./lib/github-releases.mts", - "./lib/platform-mappings": "./lib/platform-mappings.mts", - "./lib/platform-targets": "./lib/platform-targets.mts", - "./lib/unicode-property-escape-transform": "./lib/unicode-property-escape-transform.mts" - }, - "dependencies": { - "@babel/parser": "catalog:", - "@babel/traverse": "catalog:", - "@sinclair/typebox": "catalog:", - "@socketsecurity/lib": "catalog:", - "@socketsecurity/lib-stable": "catalog:", - "magic-string": "catalog:" - } -} diff --git a/packages/build-infra/release-assets.schema.json b/packages/build-infra/release-assets.schema.json deleted file mode 100644 index 404465918..000000000 --- a/packages/build-infra/release-assets.schema.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/SocketDev/socket-wheelhouse/main/template/packages/build-infra/release-assets.schema.json", - "title": "Release Assets Manifest", - "description": "Embedded SHA-256 manifest for release artifacts. One block per tool: identifies the upstream release tag and the SHA-256 of every asset that release published. Consumers verify downloads against these hashes before installing or republishing. Producers (e.g. socket-btm) update this file when cutting a new release.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/toolConfig" - }, - "properties": { - "$schema": { - "type": "string", - "description": "Pointer back to this schema for editor hints." - }, - "$comment": { - "type": "string", - "description": "Free-form note. Conventionally explains how to bump entries." - } - }, - "$defs": { - "toolConfig": { - "type": "object", - "additionalProperties": false, - "required": ["tag", "checksums"], - "properties": { - "description": { - "type": "string", - "description": "Human-readable label for this tool." - }, - "tag": { - "type": "string", - "description": "Full release tag, including the tool prefix (e.g. `lief-20260507-76c1796`).", - "minLength": 1 - }, - "checksums": { - "type": "object", - "description": "Asset filename → SHA-256 hex digest. Keys are typically flat filenames; some manifests use relative paths (e.g. `./model/model.onnx`) when the producer ships nested artifacts.", - "additionalProperties": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "minProperties": 1 - } - } - } - } -} diff --git a/packages/cli/.config/rolldown.build.mts b/packages/cli/.config/rolldown.build.mts deleted file mode 100644 index 6b04e81d1..000000000 --- a/packages/cli/.config/rolldown.build.mts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Rolldown build orchestrator for Socket CLI. Builds all variants (CLI bundle + - * entry point). Replaces the esbuild orchestrator. - * - * Usage: node .config/rolldown.build.mts # all variants - * node .config/rolldown.build.mts cli # CLI bundle - * node .config/rolldown.build.mts index # entry point - */ - -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getInlinedEnvVars, runBuild } from '../scripts/rolldown-utils.mts' -import cliConfig from './rolldown.cli.mts' -import indexConfig from './rolldown.index.mts' - -import type { RolldownOptions } from 'rolldown' - -const logger = getDefaultLogger() - -// Per-variant post-write transform options. The CLI bundle needs the -// unicode-property-escape transform (--with-intl=none compat) + env-var -// replacement; the index loader needs env-var replacement only. -const VARIANTS = { - __proto__: null, - cli: { - config: cliConfig, - options: { envVars: getInlinedEnvVars(), unicodeTransform: true }, - }, - index: { - config: indexConfig, - options: { envVars: getInlinedEnvVars() }, - }, -} as unknown as Record< - string, - { - config: RolldownOptions - options: { envVars?: Record<string, string>; unicodeTransform?: boolean } - } -> - -async function main(): Promise<void> { - const variant = process.argv[2] || 'all' - - if (variant !== 'all' && !(variant in VARIANTS)) { - logger.error(`Unknown variant: ${variant}`) - logger.error(`Available variants: all, ${Object.keys(VARIANTS).join(', ')}`) - process.exitCode = 1 - return - } - - const names = variant === 'all' ? Object.keys(VARIANTS) : [variant] - const results = await Promise.allSettled( - names.map(name => { - const { config, options } = VARIANTS[name]! - return runBuild(config, name, options) - }), - ) - if (results.some(r => r.status === 'rejected')) { - process.exitCode = 1 - } -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - main().catch(error => { - logger.error('Build failed:', error) - process.exitCode = 1 - }) -} diff --git a/packages/cli/.config/rolldown.cli.mts b/packages/cli/.config/rolldown.cli.mts deleted file mode 100644 index a2e8a7afe..000000000 --- a/packages/cli/.config/rolldown.cli.mts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Rolldown configuration for building Socket CLI as a single unified file. - * Replaces the esbuild config (fleet "Tooling" rule: bundler = rolldown). - * - * The two output-text transforms esbuild ran as `onEnd` plugins - * (unicode-property-escape + env-var replacement) move to post-write passes in - * `runBuild` (see rolldown-utils.mts). The three resolve/stub plugins port to - * rolldown `resolveId` / `load` hooks below. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { IMPORT_META_URL_BANNER } from 'build-infra/lib/esbuild-helpers' - -import { createBaseConfig, getInlinedEnvVars, runBuild } from '../scripts/rolldown-utils.mts' - -import type { Plugin, RolldownOptions } from 'rolldown' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -const inlinedEnvVars = getInlinedEnvVars() - -// Matches ./external/, ../external/, ../../external/, etc. (forward + back slash). -const socketLibExternalPathRegExp = /^(?:(?:\.\.[/\\])+|\.[/\\])external[/\\]/ - -export function findSocketLibPath(importerPath: string): string | undefined { - const match = importerPath.match(/^(.*\/@socketsecurity\/lib)\b/) - if (match) { - return match[1] - } - const localPath = path.join(rootPath, '..', '..', '..', 'socket-lib') - if (existsSync(localPath)) { - return localPath - } - return undefined -} - -export function resolveSocketLibExternal( - socketLibPath: string, - packageName: string, -): string | undefined { - if (packageName.startsWith('@')) { - const parts = packageName.split('/') - const scope = parts[0]! - const name = parts[1]! - const p = path.join(socketLibPath, 'dist', 'external', scope, `${name}.js`) - return existsSync(p) ? p : undefined - } - const p = path.join( - socketLibPath, - 'dist', - 'external', - `${packageName.split('/')[0]}.js`, - ) - return existsSync(p) ? p : undefined -} - -/** - * Resolve socket-lib's internal `../constants/*` + `../external/*` specifiers - * (and bare package re-exports from inside socket-lib's dist) to the prebuilt - * files in socket-lib's dist tree. Ported from the esbuild onResolve plugin to - * a rolldown `resolveId` hook (importer-aware, same filters). - */ -// An importer is "inside socket-lib's dist" whether it resolved through the -// canonical `@socketsecurity/lib`, the `-stable` npm: alias, or a local -// `/socket-lib/` checkout. rolldown resolves the alias to the real -// `@socketsecurity/lib/dist/` path; esbuild saw the `-stable` form. -function isSocketLibDistImporter(importer: string | undefined): boolean { - return ( - !!importer && - (importer.includes('@socketsecurity/lib/dist/') || - importer.includes('@socketsecurity/lib-stable/dist/') || - importer.includes('/socket-lib/dist/')) - ) -} - -function resolveSocketLibInternalsPlugin(): Plugin { - function resolveConstant( - source: string, - importer: string | undefined, - strip: RegExp, - ): { id: string } | undefined { - if (!isSocketLibDistImporter(importer)) { - return undefined - } - const socketLibPath = findSocketLibPath(importer) - if (!socketLibPath) { - return undefined - } - const p = path.join( - socketLibPath, - 'dist', - 'constants', - `${source.replace(strip, '')}.js`, - ) - return existsSync(p) ? { id: p } : undefined - } - return { - name: 'resolve-socket-lib-internals', - resolveId(source, importer) { - if (/^\.\.\/constants\//.test(source)) { - return resolveConstant(source, importer, /^\.\.\/constants\//) - } - if (/^\.\.\/\.\.\/constants\//.test(source)) { - return resolveConstant(source, importer, /^\.\.\/\.\.\/constants\//) - } - if (socketLibExternalPathRegExp.test(source)) { - if (!isSocketLibDistImporter(importer)) { - return undefined - } - const socketLibPath = findSocketLibPath(importer) - if (!socketLibPath) { - return undefined - } - const externalPath = source - .replace(socketLibExternalPathRegExp, '') - .replace(/\.js$/, '') - const p = resolveSocketLibExternal(socketLibPath, externalPath) - return p ? { id: p } : undefined - } - if (/^(?:@[^/]+\/[^/]+|[^./][^/]*)/.test(source)) { - if (!isSocketLibDistImporter(importer)) { - return undefined - } - const socketLibPath = findSocketLibPath(importer) - if (!socketLibPath) { - return undefined - } - const packageName = source.startsWith('@') - ? source.split('/').slice(0, 2).join('/') - : source.split('/')[0]! - const p = resolveSocketLibExternal(socketLibPath, packageName) - return p ? { id: p } : undefined - } - return undefined - }, - } -} - -/** - * Stub iconv-lite + encoding (bundling-problematic, unused at runtime). Ported - * from the esbuild onResolve+onLoad namespace pattern to rolldown - * `resolveId` (tag with a `\0stub:` id) + `load` (return empty CJS). - */ -function stubProblematicPackagesPlugin(): Plugin { - const prefix = '\0stub-empty:' - return { - name: 'stub-problematic-packages', - resolveId(source) { - if (/^(?:encoding|iconv-lite)(?:$|\/)/.test(source)) { - return { id: `${prefix}${source}` } - } - return undefined - }, - load(id) { - if (id.startsWith(prefix)) { - return { code: 'module.exports = {}', moduleSideEffects: false } - } - return undefined - }, - } -} - -/** - * Mark @npmcli/arborist + node-gyp external (arborist is huge + optionally - * resolved; node-gyp is conditionally required). Ported from the esbuild - * onResolve `external: true` plugin to a rolldown `resolveId` external return. - */ -function ignoreUnsupportedFilesPlugin(): Plugin { - return { - name: 'ignore-unsupported-files', - resolveId(source, importer) { - if (/@npmcli\/arborist/.test(source)) { - // Don't externalize when it comes from socket-lib's own external bundle. - if (importer?.includes('/socket-lib/dist/')) { - return undefined - } - return { id: source, external: true } - } - if (/node-gyp/.test(source)) { - return { id: source, external: true } - } - return undefined - }, - } -} - -const baseConfig = createBaseConfig(inlinedEnvVars) - -const config: RolldownOptions = { - ...baseConfig, - input: path.join(rootPath, 'src/cli-dispatch.mts'), - // .cs files (node-gyp on Windows) resolve to empty. - moduleTypes: { '.cs': 'empty' }, - transform: { - ...baseConfig.transform, - define: { - ...baseConfig.transform?.define, - 'import.meta.url': '__importMetaUrl', - }, - }, - plugins: [ - resolveSocketLibInternalsPlugin(), - stubProblematicPackagesPlugin(), - ignoreUnsupportedFilesPlugin(), - ], - output: { - file: path.join(rootPath, 'build/cli.js'), - format: 'cjs', - minify: false, - sourcemap: false, - keepNames: true, - // Single self-contained CLI file: inline dynamic imports into one chunk so - // `output.file` is valid (esbuild emitted one outfile by default). - codeSplitting: false, - banner: `#!/usr/bin/env node\n"use strict";\n${IMPORT_META_URL_BANNER.js}`, - }, -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - // The unicode + env-var post-write transforms run here (rolldown can't - // express them as config), matching the esbuild onEnd plugin order. - runBuild(config, 'CLI bundle', { - envVars: inlinedEnvVars, - unicodeTransform: true, - }).catch(() => { - process.exitCode = 1 - }) -} - -export default config diff --git a/packages/cli/.config/rolldown.index.mts b/packages/cli/.config/rolldown.index.mts deleted file mode 100644 index 1ce34f448..000000000 --- a/packages/cli/.config/rolldown.index.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Rolldown configuration for the Socket CLI index loader (the entry point that - * executes the CLI). Replaces the esbuild config. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - createIndexConfig, - getInlinedEnvVars, - runBuild, -} from '../scripts/rolldown-utils.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.resolve(__dirname, '..') - -const config = createIndexConfig({ - entryPoint: path.join(rootPath, 'src', 'index.mts'), - outfile: path.join(rootPath, 'dist', 'index.js'), -}) - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - // Index loader has no unicode-escape concerns but still inlines env vars; - // run the env-var post-write pass for the mangled forms. - runBuild(config, 'Entry point', { envVars: getInlinedEnvVars() }).catch( - () => { - process.exitCode = 1 - }, - ) -} - -export default config diff --git a/packages/cli/.config/tsconfig.check.json b/packages/cli/.config/tsconfig.check.json deleted file mode 100644 index 1f85a5d27..000000000 --- a/packages/cli/.config/tsconfig.check.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "declarationMap": false, - "sourceMap": false, - "typeRoots": ["../node_modules/@types"] - }, - "include": ["../src/**/*.mts", "../*.config.mts", "./*.mts"], - "exclude": [ - "../**/*.tsx", - "../**/*.d.mts", - "../src/commands/analytics/output-analytics.mts", - "../src/commands/audit-log/output-audit-log.mts", - "../src/commands/threat-feed/output-threat-feed.mts", - "../src/**/*.test.mts", - "../src/test/**/*.mts", - "../src/util/test-mocks.mts", - "../test/**/*.mts" - ] -} diff --git a/packages/cli/.env.test b/packages/cli/.env.test deleted file mode 100644 index d4f0fdaa7..000000000 --- a/packages/cli/.env.test +++ /dev/null @@ -1,25 +0,0 @@ -# Socket CLI Test Environment Configuration -# Used by unit tests, integration tests, and e2e tests. - -# Node.js Configuration. -NODE_COMPILE_CACHE="./.cache" -NODE_OPTIONS="--max-old-space-size=2048 --unhandled-rejections=warn" - -# Test Framework. -VITEST=1 - -# Test Paths (for local binary testing). -SOCKET_CLI_BIN_PATH="./build/cli.js" -SOCKET_CLI_JS_PATH="./dist/cli.js" -SOCKET_CLI_DISABLE_NODE_FORWARDING=1 - -# E2E Tests (requires Socket API token). -# RUN_E2E_TESTS=1 - -# Alternative Test Binaries (set by e2e.mjs script). -# TEST_SEA_BINARY - Set dynamically for SEA binary tests -# TEST_SMOL_BINARY - Set dynamically for node-smol tests - -# NOTE: INLINED_* values are normally inlined at build time by esbuild. -# In tests, these are loaded programmatically from external-tools.json by test-wrapper.mjs. -# See scripts/test-wrapper.mjs loadExternalToolVersions() function. diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore deleted file mode 100644 index 6f1043967..000000000 --- a/packages/cli/.gitignore +++ /dev/null @@ -1 +0,0 @@ -external/ diff --git a/packages/cli/README.md b/packages/cli/README.md deleted file mode 100644 index a00956b61..000000000 --- a/packages/cli/README.md +++ /dev/null @@ -1,647 +0,0 @@ -# Socket CLI - -[![Socket Badge](https://socket.dev/api/badge/npm/package/socket)](https://socket.dev/npm/package/socket) -[![npm version](https://img.shields.io/npm/v/socket.svg)](https://www.npmjs.com/package/socket) -[![CI](https://github.com/SocketDev/socket-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/SocketDev/socket-cli/actions/workflows/ci.yml) - -Command-line interface for Socket.dev supply chain security analysis. Provides security scanning, package manager wrapping, dependency analysis, and CI/CD integration across 11 language ecosystems. - -## Table of Contents - -- [Architecture Overview](#architecture-overview) -- [Command Pattern Architecture](#command-pattern-architecture) - - [Command Organization](#command-organization) -- [Socket Firewall Architecture](#socket-firewall-architecture) -- [Build System](#build-system) - - [Build Commands](#build-commands) -- [Update Mechanism](#update-mechanism) -- [Utility Modules](#utility-modules) -- [Core Concepts](#core-concepts) - - [Error Handling](#error-handling) - - [Output Modes](#output-modes) - - [Configuration](#configuration) -- [Language Ecosystem Support](#language-ecosystem-support) -- [Testing](#testing) -- [Development Workflow](#development-workflow) -- [Key Statistics](#key-statistics) -- [Performance Features](#performance-features) -- [API Integration](#api-integration) -- [Security Features](#security-features) -- [CI/CD Integration](#cicd-integration) -- [Documentation](#documentation) -- [Module Reference](#module-reference) - - [Command Modules (src/commands/)](#command-modules-srccommands) - - [Utility Modules (src/util/)](#utility-modules-srcutils) -- [Constants (src/constants/)](#constants-srcconstants) -- [Installation](#installation) -- [License](#license) -- [Contributing](#contributing) -- [Support](#support) - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Socket CLI │ -│ │ -│ Entry Points: │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ socket │ │socket-npm│ │socket-npx│ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -│ └─────────────┴─────────────┘ │ -│ │ │ -│ ┌──────▼──────┐ │ -│ │ cli-entry │ Main entry with error handling │ -│ └──────┬──────┘ │ -│ │ │ -│ ┌───────────▼───────────┐ │ -│ │ meowWithSubcommands │ Command routing │ -│ └───────────┬───────────┘ │ -│ │ │ -│ ┌──────────────┼──────────────┐ │ -│ │ │ │ │ -│ ┌───▼───┐ ┌───▼───┐ ┌───▼────┐ │ -│ │ scan │ │ npm │ │ config │ ... 36 commands │ -│ └───┬───┘ └───┬───┘ └───┬────┘ │ -│ │ │ │ │ -│ ┌───▼────┐ ┌───▼────┐ ┌───▼─────┐ │ -│ │ handle │ │ sfw │ │ getters │ Handlers & business │ -│ └───┬────┘ └───┬────┘ └───┬─────┘ logic │ -│ │ │ │ │ -│ ┌───▼────┐ ┌───▼────┐ ┌───▼─────┐ │ -│ │ output │ │firewall│ │ setters │ Output formatters │ -│ └────────┘ └────────┘ └─────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ - │ │ │ - ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ - │Socket │ │ Package │ │ Local │ - │ API/SDK │ │Registries│ │ FS/Git │ - └─────────┘ └─────────┘ └─────────┘ -``` - -## Command Pattern Architecture - -Commands use two patterns based on complexity: - -**Complex commands** (with subcommands or >200 lines) use a 3-layer pattern: - -``` -cmd-{name}.mts Command definition, flags, CLI interface - │ - ├─> handle-{name}.mts Business logic, orchestration - │ │ - │ ├─> fetch-{name}.mts API calls (optional) - │ ├─> validate-{name}.mts Input validation (optional) - │ └─> process logic - │ - └─> output-{name}.mts Output formatting (JSON/Markdown/Text) - -Example: scan create command -├── cmd-scan-create.mts (CLI flags, help text) -├── handle-create-new-scan.mts (main logic) -├── fetch-create-org-full-scan.mts (Socket API calls) -└── output-create-new-scan.mts (format output) -``` - -**Simple commands** (single purpose, <200 lines) use a consolidated single-file pattern: - -- Examples: `whoami`, `logout`, `login` -- All logic in one `cmd-*.mts` file - -### Command Organization - -``` -src/commands/ -├── scan/ Security scanning (11 subcommands) -│ ├── cmd-scan-create.mts -│ ├── cmd-scan-report.mts -│ ├── cmd-scan-reach.mts Reachability analysis -│ └── ... (8 more) -├── organization/ Org management (5 subcommands) -├── npm/ npm wrapper with Socket Firewall -├── npx/ npx wrapper with Socket Firewall -├── raw-npm/ Raw npm passthrough (no firewall) -├── raw-npx/ Raw npx passthrough (no firewall) -├── pnpm/ pnpm wrapper -├── yarn/ yarn wrapper -├── pip/ Python pip wrapper -├── pycli/ Python CLI integration -├── sfw/ Socket Firewall management -├── cargo/ Rust cargo wrapper -├── gem/ Ruby gem wrapper -├── go/ Go module wrapper -├── bundler/ Ruby bundler wrapper -├── nuget/ .NET NuGet wrapper -├── uv/ Python uv wrapper -├── optimize/ Apply Socket registry overrides -├── patch/ Manage custom patches -└── ... (25 more commands) -``` - -## Socket Firewall Architecture - -Package manager wrapping uses Socket Firewall (sfw) for security scanning: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Socket Firewall (sfw) │ -│ │ -│ User runs: socket npm install express │ -│ │ │ -│ ┌──────▼──────┐ │ -│ │ npm-cli │ Entry dispatcher │ -│ └──────┬──────┘ │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ spawnSfw() │ Socket Firewall spawn │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ │ │ │ │ -│ ┌──▼──┐ ┌─────▼─────┐ ┌────▼────┐ │ -│ │ DLX │ │ Security │ │Registry │ │ -│ │Spawn│ │ Scanning │ │Override │ │ -│ └──┬──┘ └─────┬─────┘ └────┬────┘ │ -│ │ │ │ │ -│ ┌──▼───────────────▼───────────────▼────┐ │ -│ │ Package manager with Socket │ │ -│ │ security scanning integration │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ Features: │ -│ - Pre-install security scanning │ -│ - Blocking on critical vulnerabilities │ -│ - Registry override injection │ -│ - SEA and DLX execution modes │ -│ - VFS extraction for bundled tools │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Build System - -Multi-target build system supporting npm distribution and standalone executables: - -``` -Build Pipeline -├── Source Build (esbuild) -│ ├── TypeScript compilation (.mts → .js) -│ ├── Bundle external dependencies -│ ├── Code injection (constants/env vars) -│ └── Output: dist/*.js (273,000+ lines bundled) -│ -├── SEA Build (Single Executable Application) -│ ├── Download node-smol binaries -│ ├── Generate SEA config with update-config -│ ├── Create V8 snapshot blob -│ ├── Inject blob + VFS into node-smol -│ └── Output: dist/sea/socket-{platform}-{arch} -│ -└── Targets - ├── darwin-arm64 (macOS Apple Silicon) - ├── darwin-x64 (macOS Intel) - ├── linux-arm64 (Linux ARM64) - ├── linux-arm64-musl (Alpine Linux ARM64) - ├── linux-x64 (Linux AMD64) - ├── linux-x64-musl (Alpine Linux) - ├── win32-arm64 (Windows ARM64) - └── win32-x64 (Windows AMD64) - -Build Artifacts -├── dist/index.js CLI entry point -├── dist/cli.js Bundled CLI (all commands + utilities) -└── dist/sea/socket-* Platform-specific binaries -``` - -### Build Commands - -```bash -pnpm build # Smart incremental build -pnpm build --force # Force rebuild all -pnpm build --watch # Watch mode for development -pnpm build:sea # Build SEA binaries (all platforms) -``` - -## Update Mechanism - -Dual update system based on installation method: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Update Architecture │ -│ │ -│ SEA Binary Installation │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ node-smol C stub checks GitHub releases on exit │ │ -│ │ Embedded update-config.json (1112 bytes) │ │ -│ │ Tag pattern: socket-cli-* │ │ -│ │ Update: socket self-update (handled by stub) │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ npm/pnpm/yarn Installation │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ TypeScript manager.mts checks npm registry │ │ -│ │ Package: socket │ │ -│ │ Notification shown on CLI exit (non-blocking) │ │ -│ │ Update: npm update -g socket │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ Environment Variables │ -│ - SOCKET_CLI_SKIP_UPDATE_CHECK=1 Disable checks │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Utility Modules - -``` -src/util/ -├── alert/ Alert translations and formatting -├── cli/ CLI framework (meow integration) -├── coana/ Coana reachability analysis -├── command/ Command execution utilities -├── data/ Data manipulation (maps, objects, strings) -├── dlx/ Download and execute (cdxgen, etc) -├── ecosystem/ Multi-ecosystem support (11 languages) -├── error/ Error types and handling -├── fs/ File system operations -├── git/ Git operations (GitHub, GitLab, Bitbucket) -├── npm/ npm-specific utilities -├── output/ Output formatting (JSON/Markdown/Text) -├── pnpm/ pnpm-specific utilities -├── process/ Process spawning and management -├── purl/ Package URL parsing -├── python/ Python standalone runtime -├── sea/ SEA binary detection -├── sfw/ Socket Firewall integration -├── socket/ Socket API integration -├── telemetry/ Analytics and error reporting -├── terminal/ Terminal UI (colors, spinners, tables) -├── update/ Update checking and notification -├── validation/ Input validation -└── yarn/ yarn-specific utilities -``` - -## Core Concepts - -### Error Handling - -Structured error types with recovery suggestions: - -```typescript -// Error types in src/util/error/errors.mts -AuthError 401/403 API authentication failures -InputError User input validation failures -NetworkError Network connectivity issues -RateLimitError 429 API rate limit exceeded -FileSystemError File operation failures (ENOENT, EACCES) -ConfigError Configuration problems -TimeoutError Operation timeouts - -// Usage pattern -throw new InputError('No package.json found', undefined, [ - 'Run this command from a project directory', - 'Create a package.json with `npm init`' -]) -``` - -### Output Modes - -All commands support multiple output formats: - -```typescript -// Controlled by --json, --markdown flags -type OutputKind = 'json' | 'markdown' | 'text' - -// CResult pattern for JSON output -type CResult<T> = - | { ok: true; data: T; message?: string } - | { ok: false; message: string; cause?: string; code?: number } -``` - -### Configuration - -Hierarchical configuration system: - -``` -Priority (highest to lowest): -1. Command-line flags (--org, --config) -2. Environment variables (SOCKET_CLI_API_TOKEN) -3. Config file (~/.config/socket/config.toml) -4. Default values - -Config keys: -- apiToken Socket API authentication token -- apiBaseUrl API endpoint (default: api.socket.dev) -- defaultOrg Default organization slug -- enforcedOrgs Restrict commands to specific orgs -- apiProxy HTTP proxy for API calls -``` - -## Language Ecosystem Support - -Multi-ecosystem architecture supporting 11 package managers: - -``` -JavaScript/TypeScript npm, npx, pnpm, yarn -Python pip, uv -Ruby gem, bundler -Rust cargo -Go go modules -.NET NuGet -``` - -Each ecosystem module provides: - -- Package spec parsing (npm-package-arg style) -- Lockfile parsing -- Manifest file detection -- Requirements file support -- PURL (Package URL) generation - -## Testing - -```bash -# From packages/cli/ directory: -pnpm test # Full test suite -pnpm test:unit # Unit tests only -pnpm test:unit file.test.mts # Single test file -pnpm test:unit --update # Update snapshots -pnpm test:unit --coverage # Coverage report - -# Or from monorepo root: -pnpm --filter @socketsecurity/cli run test:unit -pnpm --filter @socketsecurity/cli run test:unit file.test.mts -``` - -Test structure: - -- `test/unit/` - Unit tests (~270+ test files) -- `test/fixtures/` - Test fixtures and mock data -- `test/helpers/` - Test utilities and helpers -- Vitest framework with snapshot testing - -## Development Workflow - -```bash -# Watch mode - auto-rebuild on changes -pnpm dev - -# Run local build -pnpm build && pnpm exec socket scan - -# Run without build (direct TypeScript) -pnpm dev scan create - -# Specific modes -pnpm dev:npm install express # Test npm with Socket Firewall -pnpm dev:npx cowsay hello # Test npx with Socket Firewall -``` - -## Key Statistics - -- **Total Lines**: 57,000+ lines of TypeScript -- **Commands**: 41 root commands, 235 command files -- **Subcommands**: 160+ total (including nested) -- **Utility Modules**: 28 categories, 100+ files -- **Test Coverage**: 100+ test files -- **Build Targets**: 8 platform/arch combinations -- **Language Support**: 11 package ecosystems -- **Constants**: 15 constant modules - -## Performance Features - -- **Smart caching**: DLX manifest with TTL (15min default) -- **Streaming operations**: Memory-efficient large file handling -- **Parallel operations**: Concurrent API calls with queuing -- **Incremental builds**: Only rebuild changed modules - -## API Integration - -Socket SDK integration: - -```typescript -// src/util/socket/api.mts -import { SocketSdkClient } from '@socketsecurity/sdk' - -// Automatic error handling with spinners -const result = await handleApiCall(sdk => sdk.createFullScan(params), { - cmdPath: 'socket scan:create', -}) - -// Features: -// - Automatic retry on transient failures -// - Permission requirement logging on 403 -// - Detailed error diagnostics -// - Rate limit handling with guidance -``` - -## Security Features - -Built-in security scanning and enforcement: - -- **Pre-install scanning**: Block risky packages before installation -- **Alert detection**: 70+ security issue types -- **Reachability analysis**: Find actually-used vulnerabilities -- **SAST integration**: Static analysis via Coana -- **Secret scanning**: TruffleHog integration -- **Container scanning**: Trivy integration -- **Registry overrides**: Auto-apply safer alternatives - -## CI/CD Integration - -```yaml -# GitHub Actions example -- name: Socket Security - run: | - npm install -g socket - socket ci -``` - -Features: - -- Exit code 1 on critical issues -- JSON output for parsing -- Non-interactive mode detection -- Skip update checks in CI - -## Documentation - -- [Official docs](https://docs.socket.dev/) -- [API reference](https://docs.socket.dev/reference) -- [CLAUDE.md](../../CLAUDE.md) - Development guidelines -- [CHANGELOG.md](./CHANGELOG.md) - Version history - -## Module Reference - -### Command Modules (src/commands/) - -- `scan/` - Security scanning with 11 subcommands (create, report, reach, diff, view, list, delete, metadata, setup, github) -- `organization/` - Organization management (dependencies, quota, policies) -- `npm/npx/pnpm/yarn/` - JavaScript package manager wrappers with Socket Firewall -- `raw-npm/raw-npx/` - Raw npm/npx passthrough without Socket Firewall -- `pip/uv/` - Python package manager wrappers -- `pycli/` - Python CLI integration for security analysis -- `sfw/` - Socket Firewall management -- `cargo/` - Rust package manager wrapper -- `gem/bundler/` - Ruby package manager wrappers -- `go/` - Go module wrapper -- `nuget/` - .NET package manager wrapper -- `optimize/` - Apply Socket registry overrides -- `patch/` - Manage custom package patches -- `install/uninstall/` - Socket integration management -- `config/` - Configuration management -- `login/logout/whoami/` - Authentication -- `ci/` - CI/CD integration -- `fix/` - Auto-fix security issues -- `manifest/` - Generate and manage SBOMs via cdxgen (includes auto, setup, gradle, kotlin, scala, conda subcommands) -- `analytics/` - Package analytics -- `audit-log/` - Organization audit logs -- `threat-feed/` - Security threat intelligence -- `repository/` - Repository management -- `package/` - Package information lookup -- `wrapper/` - Generic command wrapper -- `ask/` - AI-powered security questions -- `json/` - JSON utilities -- `oops/` - Error recovery - -### Utility Modules (src/util/) - -**API & Network** - -- `socket/api.mts` - Socket API communication with error handling -- `socket/sdk.mts` - SDK initialization and configuration -- `socket/alerts.mts` - Security alert processing - -**CLI Framework** - -- `cli/with-subcommands.mts` - Subcommand routing (350+ lines) -- `cli/completion.mts` - Shell completion generation -- `cli/messages.mts` - User-facing messages - -**Data Processing** - -- `data/map-to-object.mts` - Map to object conversion -- `data/objects.mts` - Object utilities -- `data/strings.mts` - String manipulation -- `data/walk-nested-map.mts` - Nested map traversal - -**Ecosystem Support** - -- `ecosystem/types.mts` - PURL types for 11 languages -- `ecosystem/environment.mts` - Runtime environment detection -- `ecosystem/requirements.mts` - API requirements lookup -- `ecosystem/spec.mts` - Package spec parsing - -**Error Handling** - -- `error/errors.mts` - Error types and diagnostics (560+ lines) -- `error/fail-msg-with-badge.mts` - Formatted error messages - -**File Operations** - -- `fs/fs.mts` - Safe file operations -- `fs/home-path.mts` - Home directory resolution -- `fs/path-resolve.mts` - Path resolution for scans -- `fs/find-up.mts` - Find files in parent directories - -**Git Integration** - -- `git/operations.mts` - Git commands (branch, commit, etc) -- `git/github.mts` - GitHub API integration -- `git/providers.mts` - Multi-provider support (GitHub, GitLab, Bitbucket) - -**Output Formatting** - -- `output/formatting.mts` - Help text and flag formatting -- `output/result-json.mts` - JSON serialization -- `output/markdown.mts` - Markdown table generation -- `output/mode.mts` - Output mode detection - -**Package Managers** - -- `npm/config.mts` - npm configuration reading -- `npm/package-arg.mts` - npm package spec parsing -- `npm/paths.mts` - npm path resolution -- `pnpm/lockfile.mts` - pnpm lockfile parsing -- `pnpm/scanning.mts` - pnpm scan integration -- `yarn/paths.mts` - yarn path resolution - -**Process & Spawn** - -- `process/cmd.mts` - Command-line utilities -- `process/os.mts` - OS detection -- `spawn/spawn-node.mts` - Node.js process spawning - -**Security Tools** - -- `coana/extract-scan-id.mts` - Coana reachability integration -- `dlx/cdxgen.mts` - SBOM generation -- `python/standalone.mts` - Python runtime management - -**Terminal UI** - -- `terminal/ascii-header.mts` - ASCII logo rendering -- `terminal/colors.mts` - ANSI color utilities -- `terminal/link.mts` - Hyperlink generation - -**Update System** - -- `update/manager.mts` - Update check orchestration -- `update/checker.mts` - Version comparison logic - -**Validation** - -- `validation/check-input.mts` - Input validation -- `validation/filter-config.mts` - Config validation - -## Constants (src/constants/) - -- `agents.mts` - Package manager constants (npm, pnpm, yarn, etc) -- `alerts.mts` - Security alert type constants -- `build.mts` - Build-time inlined constants -- `cache.mts` - Cache TTL values -- `cli.mts` - CLI flag constants -- `config.mts` - Configuration key constants -- `env.mts` - Environment variable access -- `errors.mts` - Error message constants -- `github.mts` - GitHub API constants -- `http.mts` - HTTP status code constants -- `packages.mts` - Package name constants -- `paths.mts` - Path constants -- `reporting.mts` - Report configuration -- `socket.mts` - Socket API URLs -- `types.mts` - Type constants - -## Installation - -**Requirements:** - -- Node.js >= 24.14.0 -- npm/pnpm/yarn package manager - -**Note:** The published package name is `socket`. The development package `@socketsecurity/cli` is private and used for local development only. - -```bash -# npm -npm install -g socket - -# pnpm -pnpm add -g socket - -# yarn -yarn global add socket -``` - -## License - -MIT - See [LICENSE](./LICENSE) for details. - -## Contributing - -See [CLAUDE.md](../../CLAUDE.md) for development guidelines and code standards. - -## Support - -- GitHub Issues: https://github.com/SocketDev/socket-cli/issues -- Documentation: https://docs.socket.dev/ -- Website: https://socket.dev/ diff --git a/packages/cli/bundle-tools.json b/packages/cli/bundle-tools.json deleted file mode 100644 index 609780051..000000000 --- a/packages/cli/bundle-tools.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "$schema": "Bundle tools configuration for Socket CLI VFS bundling", - "@coana-tech/cli": { - "description": "Coana CLI for static analysis and reachability detection", - "version": "14.12.165", - "packageManager": "npm", - "integrity": "sha512-Fs/gGzBEFl23x0Xw+eBOnyX2WUaoc82ppgZrrDN9hpB84CN8r0ZEw22IQRpiJTmhmOlbSwiArpRw45VkgJY5sw==" - }, - "@cyclonedx/cdxgen": { - "description": "CycloneDX SBOM generator for software bill of materials", - "version": "12.0.0", - "packageManager": "npm", - "integrity": "sha512-RRXEZ1eKHcU+Y/2AnfIg30EQRbOmlEpaJddmMVetpXeYpnxDy/yjBM67jXNKkA4iZYjZzfWe7I5GuxckRmuoqg==" - }, - "opengrep": { - "description": "OpenGrep SAST/code analysis engine (fork of Semgrep)", - "repository": "github:opengrep/opengrep", - "release": "asset", - "version": "v1.16.0", - "checksums": { - "opengrep-core_linux_aarch64.tar.gz": "e6a92e2c465b53284ae326d20b315acbd2eb99bc9ea4b3af48db6379306f3a82", - "opengrep-core_linux_x86.tar.gz": "4d474141329983c4ddd7a6cd586759deecc7f3fa9aee6e6eeab8c55759dc816b", - "opengrep-core_osx_aarch64.tar.gz": "b3d6ff863449014844391ee6b8740683524787da5ab0797f98faa32714e558e9", - "opengrep-core_osx_x86.tar.gz": "2b9f380b5840596ec57f6ead508af7be7bfcac4dbcfe5414dfe495d5f7277887", - "opengrep-core_windows_x86.zip": "d7cae83d95fea6b945a373b800839505bf27770771388514fe17e0f2437e8f71" - } - }, - "python": { - "description": "Python runtime from python-build-standalone", - "repository": "github:astral-sh/python-build-standalone", - "release": "asset", - "version": "3.11.14", - "tag": "20260203", - "checksums": { - "cpython-3.11.14+20260203-aarch64-apple-darwin-install_only.tar.gz": "63e3352fefd3b6494f73f46f51c6581c57a7e0d98775e6e00229d14a67ec3ce9", - "cpython-3.11.14+20260203-aarch64-pc-windows-msvc-install_only.tar.gz": "cb7828c131a005da367f7dba3a561bed91619452de870e531ee03344b2ac346f", - "cpython-3.11.14+20260203-aarch64-unknown-linux-gnu-install_only.tar.gz": "7341a5a0acd65f2c7c7a228d8bafa6561d220ffed26293d6a02c15ae2ee86af5", - "cpython-3.11.14+20260203-aarch64-unknown-linux-musl-install_only.tar.gz": "f0e5988c108187b12eb4d53cbac33a499a8e38e1693104432e1faabbab14c664", - "cpython-3.11.14+20260203-x86_64-apple-darwin-install_only.tar.gz": "f3b63051a9b1ffb4f663d928ebaec4311435cb67f3bdfa5634953df93397f25e", - "cpython-3.11.14+20260203-x86_64-pc-windows-msvc-install_only.tar.gz": "d220beff465bdc97bf5874be8ffbf07278e5bdf9a064cab932b5d93b542e3e86", - "cpython-3.11.14+20260203-x86_64-unknown-linux-gnu-install_only.tar.gz": "67abde21b6e074b58c0f738f0c4802b23827a7d49707dcaf3ed4dadf572f3f37", - "cpython-3.11.14+20260203-x86_64-unknown-linux-musl-install_only.tar.gz": "290de5199a9647d4de4adcf13a79a7c59f060357853bf41fd6d1a69b4b5fd00c" - } - }, - "socket-basics": { - "description": "Socket Basics - integrated SAST, secret scanning, and container analysis", - "repository": "github:SocketDev/socket-basics", - "release": "archive", - "version": "v2.0.2", - "packageManager": "pip", - "checksums": { - "socket-basics-v2.0.2.tar.gz": "ba175171f07ac927eb926387e526283320630e80da42da000ec6894a55adeb13" - } - }, - "socketsecurity": { - "description": "Socket Python CLI (socket-python-cli)", - "version": "2.2.70", - "packageManager": "pip", - "checksums": { - "socketsecurity-2.2.70-py3-none-any.whl": "8633c2a7f204cc5cec18d8ed04cfd09aa448f7e2257345596435493d2102ba5d", - "socketsecurity-2.2.70.tar.gz": "e5212fb9b6b7bee3c5d936efe439508df76a7d0d81b99f84f6eafe760f3d77b7" - } - }, - "socket-patch": { - "description": "Socket Patch CLI for applying security patches (Rust binary)", - "repository": "github:SocketDev/socket-patch", - "release": "asset", - "version": "v2.0.0", - "checksums": { - "socket-patch-aarch64-apple-darwin.tar.gz": "dd8f778aef4db3f2c5000cd870101a31d1bb03822158d76e5bd2e773098428f0", - "socket-patch-aarch64-pc-windows-msvc.zip": "5c0bbfc12d2b6f30a0f79caf4bff85a1eac6baf9541c46d9af4b3f37b05bd574", - "socket-patch-aarch64-unknown-linux-gnu.tar.gz": "baf84c0ec84aa5355ae9d0225ae9199f618014a10af7414947132d326c10cdd5", - "socket-patch-x86_64-apple-darwin.tar.gz": "73db4c70f1810d98f7f81adf94d0068e2d9378dfd8660811fb541751abe0078d", - "socket-patch-x86_64-pc-windows-msvc.zip": "3b980a74621f084ff92126e4e6284f2f742e57e66cf6727e6e010257377017e8", - "socket-patch-x86_64-unknown-linux-musl.tar.gz": "00e7b659c82e863857dc6b1d9721a2719a4a77f981488484e35e998359dc91b0" - } - }, - "sfw": { - "description": "Socket Firewall (sfw) - GitHub binary for SEA, npm package for CLI", - "repository": "github:SocketDev/sfw-free", - "release": "asset", - "version": "v1.6.1", - "checksums": { - "sfw-free-linux-arm64": "df2eedb2daf2572eee047adb8bfd81c9069edcb200fc7d3710fca98ec3ca81a1", - "sfw-free-linux-x86_64": "4a1e8b65e90fce7d5fd066cf0af6c93d512065fa4222a475c8d959a6bc14b9ff", - "sfw-free-macos-arm64": "bf1616fc44ac49f1cb2067fedfa127a3ae65d6ec6d634efbb3098cfa355e5555", - "sfw-free-macos-x86_64": "724ccea19d847b79db8cc8e38f5f18ce2dd32336007f42b11bed7d2e5f4a2566", - "sfw-free-musl-linux-arm64": "41e5ebfe84e33eb7f34846eeb1b0e0c3039b2ba8bcdb87f4a75a5ccb89c64ae1", - "sfw-free-musl-linux-x86_64": "19f26c163311d5d0b184d305304972d26c52e445659c9142cefc7d8a11e06c3a", - "sfw-free-windows-x86_64.exe": "c953e62ad7928d4d8f2302f5737884ea1a757babc26bed6a42b9b6b68a5d54af" - }, - "npm": { - "package": "sfw", - "version": "2.0.4" - } - }, - "synp": { - "description": "Tool for converting between yarn.lock and package-lock.json", - "version": "1.9.14", - "packageManager": "npm", - "integrity": "sha512-0e4u7KtrCrMqvuXvDN4nnHSEQbPlONtJuoolRWzut0PfuT2mEOvIFnYFHEpn5YPIOv7S5Ubher0b04jmYRQOzQ==" - }, - "trivy": { - "description": "Trivy container and filesystem vulnerability scanner", - "repository": "github:aquasecurity/trivy", - "release": "asset", - "version": "v0.69.2", - "checksums": { - "trivy_0.69.2_Linux-64bit.tar.gz": "affa59a1e37d86e4b8ab2cd02f0ab2e63d22f1bf9cf6a7aa326c884e25e26ce3", - "trivy_0.69.2_Linux-ARM64.tar.gz": "c73b97699c317b0d25532b3f188564b4e29d13d5472ce6f8eb078082546a6481", - "trivy_0.69.2_macOS-64bit.tar.gz": "41f6eac3ebe3a00448a16f08038b55ce769fe2d5128cb0d64bdf282cdad4831a", - "trivy_0.69.2_macOS-ARM64.tar.gz": "320c0e6af90b5733b9326da0834240e944c6f44091e50019abdf584237ff4d0c", - "trivy_0.69.2_windows-64bit.zip": "d772fa7c3c1bc52d2914ff78107596fbd20010b5f18bec6f39d63ee3bb31ad45" - } - }, - "trufflehog": { - "description": "TruffleHog secret and credential detection", - "repository": "github:trufflesecurity/trufflehog", - "release": "asset", - "version": "v3.93.1", - "checksums": { - "trufflehog_3.93.1_darwin_amd64.tar.gz": "f1f4ecbda3996b88dc70cf6aef2c469c4902efb591aca86128d6305d606d8e07", - "trufflehog_3.93.1_darwin_arm64.tar.gz": "d65a2ad0f043a9d48a97176f28533890e558817e2fb7dd1e34132653b61be4a0", - "trufflehog_3.93.1_linux_amd64.tar.gz": "2edf991c20fd8e6d2ec5f255b928289156bc1f0640618829c580c6e87e28ff57", - "trufflehog_3.93.1_linux_arm64.tar.gz": "6424e63e0397f7e1b63b880bed6657f76025783738b45868210b445aa5a27b5f", - "trufflehog_3.93.1_windows_amd64.tar.gz": "2add5bcfd2f9b9fd5db721f7d47921e02b3f093838d24551f7cf8d6d66bc023e", - "trufflehog_3.93.1_windows_arm64.tar.gz": "f2d53334a8f6c0c871db1e53defb9ce591a13e1f84d35cb9ca7865255f4fd4ae" - } - } -} diff --git a/packages/cli/data/alert-translations.json b/packages/cli/data/alert-translations.json deleted file mode 100644 index cdae66774..000000000 --- a/packages/cli/data/alert-translations.json +++ /dev/null @@ -1,616 +0,0 @@ -{ - "alerts": { - "badEncoding": { - "description": "Source files are encoded using a non-standard text encoding.", - "suggestion": "Ensure all published files are encoded using a standard encoding such as UTF8, UTF16, UTF32, SHIFT-JIS, etc.", - "title": "Bad text encoding", - "emoji": "⚠️" - }, - "badSemver": { - "description": "Package version is not a valid semantic version (semver).", - "suggestion": "All versions of all packages on npm should use use a valid semantic version. Publish a new version of the package with a valid semantic version. Semantic version ranges do not work with invalid semantic versions.", - "title": "Bad semver", - "emoji": "⚠️" - }, - "badSemverDependency": { - "description": "Package has dependencies with an invalid semantic version. This could be a sign of beta, low quality, or unmaintained dependencies.", - "suggestion": "Switch to a version of the dependency with valid semver or override the dependency version if it is determined to be problematic.", - "title": "Bad dependency semver", - "emoji": "⚠️" - }, - "bidi": { - "description": "Source files contain bidirectional unicode control characters. This could indicate a Trojan source supply chain attack. See: trojansource.codes for more information.", - "suggestion": "Remove bidirectional unicode control characters, or clearly document what they are used for.", - "title": "Bidirectional unicode control characters", - "emoji": "⚠️" - }, - "binScriptConfusion": { - "description": "This package has multiple bin scripts with the same name. This can cause non-deterministic behavior when installing or could be a sign of a supply chain attack.", - "suggestion": "Consider removing one of the conflicting packages. Packages should only export bin scripts with their name.", - "title": "Bin script confusion", - "emoji": "😵‍💫" - }, - "chronoAnomaly": { - "description": "Semantic versions published out of chronological order.", - "suggestion": "This could either indicate dependency confusion or a patched vulnerability.", - "title": "Chronological version anomaly", - "emoji": "⚠️" - }, - "compromisedSSHKey": { - "description": "Project maintainer's SSH key has been compromised.", - "suggestion": "The maintainer should revoke the compromised key and generate a new one.", - "title": "Compromised SSH key", - "emoji": "🔑" - }, - "criticalCVE": { - "description": "Contains a Critical Common Vulnerability and Exposure (CVE).", - "suggestion": "Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", - "title": "Critical CVE", - "emoji": "⚠️" - }, - "cve": { - "description": "Contains a high severity Common Vulnerability and Exposure (CVE).", - "suggestion": "Remove or replace dependencies that include known high severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", - "title": "High CVE", - "emoji": "⚠️" - }, - "debugAccess": { - "description": "Uses debug, reflection and dynamic code execution features.", - "suggestion": "Removing the use of debug will reduce the risk of any reflection and dynamic code execution.", - "title": "Debug access", - "emoji": "⚠️" - }, - "deprecated": { - "description": "The maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.", - "suggestion": "Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution.", - "title": "Deprecated", - "emoji": "⚠️" - }, - "deprecatedException": { - "description": "(Experimental) Contains a known deprecated SPDX license exception.", - "suggestion": "Fix the license so that it no longer contains deprecated SPDX license exceptions.", - "title": "Deprecated SPDX exception", - "emoji": "⚠️" - }, - "explicitlyUnlicensedItem": { - "description": "(Experimental) Something was found which is explicitly marked as unlicensed.", - "suggestion": "Manually review your policy on such materials", - "title": "Explicitly Unlicensed Item", - "emoji": "⚠️" - }, - "unidentifiedLicense": { - "description": "(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license.", - "suggestion": "Manually review the license contents.", - "title": "Unidentified License", - "emoji": "⚠️" - }, - "noLicenseFound": { - "description": "(Experimental) License information could not be found.", - "suggestion": "Manually review the licensing", - "title": "No License Found", - "emoji": "⚠️" - }, - "copyleftLicense": { - "description": "(Experimental) Copyleft license information was found.", - "suggestion": "Determine whether use of copyleft material works for you", - "title": "Copyleft License", - "emoji": "⚠️" - }, - "licenseSpdxDisj": { - "description": "This package is not allowed per your license policy. Review the package's license to ensure compliance.", - "suggestion": "Find a package that does not violate your license policy or adjust your policy to allow this package's license.", - "title": "License Policy Violation", - "emoji": "⚠️" - }, - "nonpermissiveLicense": { - "description": "(Experimental) A license not known to be considered permissive was found.", - "suggestion": "Determine whether use of material not offered under a known permissive license works for you", - "title": "Non-permissive License", - "emoji": "⚠️" - }, - "miscLicenseIssues": { - "description": "(Experimental) A package's licensing information has fine-grained problems.", - "suggestion": "Consult the alert's description and location information for more information", - "title": "Misc. License Issues", - "emoji": "⚠️" - }, - "deprecatedLicense": { - "description": "(Experimental) License is deprecated which may have legal implications regarding the package's use.", - "suggestion": "Update or change the license to a well-known or updated license.", - "title": "Deprecated license", - "emoji": "⚠️" - }, - "didYouMean": { - "description": "Package name is similar to other popular packages and may not be the package you want.", - "suggestion": "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages.", - "title": "Possible typosquat attack", - "emoji": "🧐" - }, - "dynamicRequire": { - "description": "Dynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.", - "suggestion": "Packages should avoid dynamic imports when possible. Audit the use of dynamic require to ensure it is not executing malicious or vulnerable code.", - "title": "Dynamic require", - "emoji": "⚠️" - }, - "emptyPackage": { - "description": "Package does not contain any code. It may be removed, is name squatting, or the result of a faulty package publish.", - "suggestion": "Remove dependencies that do not export any code or functionality and ensure the package version includes all of the files it is supposed to.", - "title": "Empty package", - "emoji": "⚠️" - }, - "envVars": { - "description": "Package accesses environment variables, which may be a sign of credential stuffing or data theft.", - "suggestion": "Packages should be clear about which environment variables they access, and care should be taken to ensure they only access environment variables they claim to.", - "title": "Environment variable access", - "emoji": "⚠️" - }, - "extraneousDependency": { - "description": "Package optionally loads a dependency which is not specified within any of the package.json dependency fields. It may inadvertently be importing dependencies specified by other packages.", - "suggestion": "Specify all optionally loaded dependencies in optionalDependencies within package.json.", - "title": "Extraneous dependency", - "emoji": "⚠️" - }, - "fileDependency": { - "description": "Contains a dependency which resolves to a file. This can obfuscate analysis and serves no useful purpose.", - "suggestion": "Remove the dependency specified by a file resolution string from package.json and update any bare name imports that referenced it before to use relative path strings.", - "title": "File dependency", - "emoji": "⚠️" - }, - "filesystemAccess": { - "description": "Accesses the file system, and could potentially read sensitive data.", - "suggestion": "If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead.", - "title": "Filesystem access", - "emoji": "⚠️" - }, - "floatingDependency": { - "description": "Package has a dependency with a floating version range. This can cause issues if the dependency publishes a new major version.", - "suggestion": "Packages should specify properly semver ranges to avoid version conflicts.", - "title": "Wildcard dependency", - "emoji": "🎈" - }, - "gitDependency": { - "description": "Contains a dependency which resolves to a remote git URL. Dependencies fetched from git URLs are not immutable and can be used to inject untrusted code or reduce the likelihood of a reproducible install.", - "suggestion": "Publish the git dependency to npm or a private package repository and consume it from there.", - "title": "Git dependency", - "emoji": "🍣" - }, - "gitHubDependency": { - "description": "Contains a dependency which resolves to a GitHub URL. Dependencies fetched from GitHub specifiers are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install.", - "suggestion": "Publish the GitHub dependency to npm or a private package repository and consume it from there.", - "title": "GitHub dependency", - "emoji": "⚠️" - }, - "gptAnomaly": { - "description": "AI has identified unusual behaviors that may pose a security risk.", - "suggestion": "An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.", - "title": "AI-detected potential code anomaly", - "emoji": "🤔" - }, - "gptDidYouMean": { - "description": "AI has identified this package as a potential typosquat of a more popular package. This suggests that the package may be intentionally mimicking another package's name, description, or other metadata.", - "suggestion": "Given the AI system's identification of this package as a potential typosquat, please verify that you did not intend to install a different package. Be cautious, as malicious packages often use names similar to popular ones.", - "title": "AI-detected possible typosquat", - "emoji": "🤖" - }, - "gptMalware": { - "description": "AI has identified this package as malware. This is a strong signal that the package may be malicious.", - "suggestion": "Given the AI system's identification of this package as malware, extreme caution is advised. It is recommended to avoid downloading or installing this package until the threat is confirmed or flagged as a false positive.", - "title": "AI-detected potential malware", - "emoji": "🤖" - }, - "gptSecurity": { - "description": "AI has determined that this package may contain potential security issues or vulnerabilities.", - "suggestion": "An AI system identified potential security problems in this package. It is advised to review the package thoroughly and assess the potential risks before installation. You may also consider reporting the issue to the package maintainer or seeking alternative solutions with a stronger security posture.", - "title": "AI-detected potential security risk", - "emoji": "🤖" - }, - "hasNativeCode": { - "description": "Contains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.", - "suggestion": "Verify that the inclusion of native code is expected and necessary for this package's functionality. If it is unnecessary or unexpected, consider using alternative packages without native code to mitigate potential risks.", - "title": "Native code", - "emoji": "🛠️" - }, - "highEntropyStrings": { - "description": "Contains high entropy strings. This could be a sign of encrypted data, leaked secrets or obfuscated code.", - "suggestion": "Please inspect these strings to check if they are benign. Maintainers should clarify the purpose and existence of high entropy strings if there is a legitimate purpose.", - "title": "High entropy strings", - "emoji": "⚠️" - }, - "homoglyphs": { - "description": "Contains unicode homoglyphs which can be used in supply chain confusion attacks.", - "suggestion": "Remove unicode homoglyphs if they are unnecessary, and audit their presence to confirm legitimate use.", - "title": "Unicode homoglyphs", - "emoji": "⚠️" - }, - "httpDependency": { - "description": "Contains a dependency which resolves to a remote HTTP URL which could be used to inject untrusted code and reduce overall package reliability.", - "suggestion": "Publish the HTTP URL dependency to npm or a private package repository and consume it from there.", - "title": "HTTP dependency", - "emoji": "🥩" - }, - "installScripts": { - "description": "Install scripts are run when the package is installed. The majority of malware in npm is hidden in install scripts.", - "suggestion": "Packages should not be running non-essential scripts during install and there are often solutions to problems people solve with install scripts that can be run at publish time instead.", - "title": "Install scripts", - "emoji": "📜" - }, - "invalidPackageJSON": { - "description": "Package has an invalid manifest file and can cause installation problems if you try to use it.", - "suggestion": "Fix syntax errors in the manifest file and publish a new version. Consumers can use npm overrides to force a version that does not have this problem if one exists.", - "title": "Invalid manifest file", - "emoji": "🤒" - }, - "invisibleChars": { - "description": "Source files contain invisible characters. This could indicate source obfuscation or a supply chain attack.", - "suggestion": "Remove invisible characters. If their use is justified, use their visible escaped counterparts.", - "title": "Invisible chars", - "emoji": "⚠️" - }, - "licenseChange": { - "description": "(Experimental) Package license has recently changed.", - "suggestion": "License changes should be reviewed carefully to inform ongoing use. Packages should avoid making major changes to their license type.", - "title": "License change", - "emoji": "⚠️" - }, - "licenseException": { - "description": "(Experimental) Contains an SPDX license exception.", - "suggestion": "License exceptions should be carefully reviewed.", - "title": "License exception", - "emoji": "⚠️" - }, - "longStrings": { - "description": "Contains long string literals, which may be a sign of obfuscated or packed code.", - "suggestion": "Avoid publishing or consuming obfuscated or bundled code. It makes dependencies difficult to audit and undermines the module resolution system.", - "title": "Long strings", - "emoji": "⚠️" - }, - "missingTarball": { - "description": "This package is missing it's tarball. It could be removed from the npm registry or there may have been an error when publishing.", - "suggestion": "This package cannot be analyzed or installed due to missing data.", - "title": "Missing package tarball", - "emoji": "❔" - }, - "majorRefactor": { - "description": "Package has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.", - "suggestion": "Consider waiting before upgrading to see if any issues are discovered, or be prepared to scrutinize any bugs or subtle changes the major refactor may bring. Publishers my consider publishing beta versions of major refactors to limit disruption to parties interested in the new changes.", - "title": "Major refactor", - "emoji": "⚠️" - }, - "malware": { - "description": "This package is identified as malware. It has been flagged either by Socket's AI scanner and confirmed by our threat research team, or is listed as malicious in security databases and other sources.", - "title": "Known malware", - "suggestion": "It is strongly recommended that malware is removed from your codebase.", - "emoji": "☠️" - }, - "manifestConfusion": { - "description": "This package has inconsistent metadata. This could be malicious or caused by an error when publishing the package.", - "title": "Manifest confusion", - "suggestion": "Packages with inconsistent metadata may be corrupted or malicious.", - "emoji": "🥸" - }, - "mediumCVE": { - "description": "Contains a medium severity Common Vulnerability and Exposure (CVE).", - "suggestion": "Remove or replace dependencies that include known medium severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", - "title": "Medium CVE", - "emoji": "⚠️" - }, - "mildCVE": { - "description": "Contains a low severity Common Vulnerability and Exposure (CVE).", - "suggestion": "Remove or replace dependencies that include known low severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", - "title": "Low CVE", - "emoji": "⚠️" - }, - "minifiedFile": { - "description": "This package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.", - "suggestion": "In many cases minified code is harmless, however minified code can be used to hide a supply chain attack. Consider not shipping minified code on npm.", - "title": "Minified code", - "emoji": "⚠️" - }, - "missingAuthor": { - "description": "The package was published by an npm account that no longer exists.", - "suggestion": "Packages should have active and identified authors.", - "title": "Non-existent author", - "emoji": "🫥" - }, - "missingDependency": { - "description": "A required dependency is not declared in package.json and may prevent the package from working.", - "suggestion": "The package should define the missing dependency inside of package.json and publish a new version. Consumers may have to install the missing dependency themselves as long as the dependency remains missing. If the dependency is optional, add it to optionalDependencies and handle the missing case.", - "title": "Missing dependency", - "emoji": "⚠️" - }, - "missingLicense": { - "description": "(Experimental) Package does not have a license and consumption legal status is unknown.", - "suggestion": "A new version of the package should be published that includes a valid SPDX license in a license file, package.json license field or mentioned in the README.", - "title": "Missing license", - "emoji": "⚠️" - }, - "mixedLicense": { - "description": "(Experimental) Package contains multiple licenses.", - "suggestion": "A new version of the package should be published that includes a single license. Consumers may seek clarification from the package author. Ensure that the license details are consistent across the LICENSE file, package.json license field and license details mentioned in the README.", - "title": "Mixed license", - "emoji": "⚠️" - }, - "ambiguousClassifier": { - "description": "(Experimental) An ambiguous license classifier was found.", - "suggestion": "A specific license or licenses should be identified", - "title": "Ambiguous License Classifier", - "emoji": "⚠️" - }, - "modifiedException": { - "description": "(Experimental) Package contains a modified version of an SPDX license exception. Please read carefully before using this code.", - "suggestion": "Packages should avoid making modifications to standard license exceptions.", - "title": "Modified license exception", - "emoji": "⚠️" - }, - "modifiedLicense": { - "description": "(Experimental) Package contains a modified version of an SPDX license. Please read carefully before using this code.", - "suggestion": "Packages should avoid making modifications to standard licenses.", - "title": "Modified license", - "emoji": "⚠️" - }, - "networkAccess": { - "description": "This module accesses the network.", - "suggestion": "Packages should remove all network access that is functionally unnecessary. Consumers should audit network access to ensure legitimate use.", - "title": "Network access", - "emoji": "⚠️" - }, - "newAuthor": { - "description": "A new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.", - "suggestion": "Scrutinize new collaborator additions to packages because they now have the ability to publish code into your dependency tree. Packages should avoid frequent or unnecessary additions or changes to publishing rights.", - "title": "New author", - "emoji": "⚠️" - }, - "noAuthorData": { - "description": "Package does not specify a list of contributors or an author in package.json.", - "suggestion": "Add a author field or contributors array to package.json.", - "title": "No contributors or author data", - "emoji": "⚠️" - }, - "noBugTracker": { - "description": "Package does not have a linked bug tracker in package.json.", - "suggestion": "Add a bugs field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bugs", - "title": "No bug tracker", - "emoji": "⚠️" - }, - "noREADME": { - "description": "Package does not have a README. This may indicate a failed publish or a low quality package.", - "suggestion": "Add a README to to the package and publish a new version.", - "title": "No README", - "emoji": "⚠️" - }, - "noRepository": { - "description": "Package does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.", - "suggestion": "Add a repository field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#repository", - "title": "No repository", - "emoji": "⚠️" - }, - "noTests": { - "description": "Package does not have any tests. This is a strong signal of a poorly maintained or low quality package.", - "suggestion": "Add tests and publish a new version of the package. Consumers may look for an alternative package with better testing.", - "title": "No tests", - "emoji": "⚠️" - }, - "noV1": { - "description": "Package is not semver \u003E=1. This means it is not stable and does not support ^ ranges.", - "suggestion": "If the package sees any general use, it should begin releasing at version 1.0.0 or later to benefit from semver.", - "title": "No v1", - "emoji": "⚠️" - }, - "noWebsite": { - "description": "Package does not have a website.", - "suggestion": "Add a homepage field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#homepage", - "title": "No website", - "emoji": "⚠️" - }, - "nonFSFLicense": { - "description": "(Experimental) Package has a non-FSF-approved license.", - "title": "Non FSF license", - "suggestion": "Consider the terms of the license for your given use case.", - "emoji": "⚠️" - }, - "nonOSILicense": { - "description": "(Experimental) Package has a non-OSI-approved license.", - "title": "Non OSI license", - "suggestion": "Consider the terms of the license for your given use case.", - "emoji": "⚠️" - }, - "nonSPDXLicense": { - "description": "(Experimental) Package contains a non-standard license somewhere. Please read carefully before using.", - "suggestion": "Package should adopt a standard SPDX license consistently across all license locations (LICENSE files, package.json license fields, and READMEs).", - "title": "Non SPDX license", - "emoji": "⚠️" - }, - "notice": { - "description": "(Experimental) Package contains a legal notice. This could increase your exposure to legal risk when using this project.", - "title": "Legal notice", - "suggestion": "Consider the implications of the legal notice for your given use case.", - "emoji": "⚠️" - }, - "obfuscatedFile": { - "description": "Obfuscated files are intentionally packed to hide their behavior. This could be a sign of malware.", - "suggestion": "Packages should not obfuscate their code. Consider not using packages with obfuscated code", - "title": "Obfuscated code", - "emoji": "⚠️" - }, - "obfuscatedRequire": { - "description": "Package accesses dynamic properties of require and may be obfuscating code execution.", - "suggestion": "The package should not access dynamic properties of module. Instead use import or require directly.", - "title": "Obfuscated require", - "emoji": "⚠️" - }, - "peerDependency": { - "description": "Package specifies peer dependencies in package.json.", - "suggestion": "Peer dependencies are fragile and can cause major problems across version changes. Be careful when updating this dependency and its peers.", - "title": "Peer dependency", - "emoji": "⚠️" - }, - "potentialVulnerability": { - "description": "Initial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.", - "suggestion": "It is advisable to proceed with caution. Engage in a review of the package's security aspects and consider reaching out to the package maintainer for the latest information or patches.", - "title": "Potential vulnerability", - "emoji": "🚧" - }, - "semverAnomaly": { - "description": "Package semver skipped several versions, this could indicate a dependency confusion attack or indicate the intention of disruptive breaking changes or major priority shifts for the project.", - "suggestion": "Packages should follow semantic versions conventions by not skipping subsequent version numbers. Consumers should research the purpose of the skipped version number.", - "title": "Semver anomaly", - "emoji": "⚠️" - }, - "shellAccess": { - "description": "This module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.", - "suggestion": "Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced.", - "title": "Shell access", - "emoji": "⚠️" - }, - "shellScriptOverride": { - "description": "This package re-exports a well known shell command via an npm bin script. This is possibly a supply chain attack.", - "suggestion": "Packages should not export bin scripts which conflict with well known shell commands", - "title": "Bin script shell injection", - "emoji": "🦀" - }, - "shrinkwrap": { - "description": "Package contains a shrinkwrap file. This may allow the package to bypass normal install procedures.", - "suggestion": "Packages should never use npm shrinkwrap files due to the dangers they pose.", - "title": "NPM Shrinkwrap", - "emoji": "🧊" - }, - "socketUpgradeAvailable": { - "description": "Package can be replaced with a Socket optimized override.", - "suggestion": "Run `npx socket optimize` in your repository to optimize your dependencies.", - "title": "Socket optimized override available", - "emoji": "🔄" - }, - "suspiciousStarActivity": { - "description": "The GitHub repository of this package may have been artificially inflated with stars (from bots, crowdsourcing, etc.).", - "title": "Suspicious Stars on GitHub", - "suggestion": "This could be a sign of spam, fraud, or even a supply chain attack. The package should be carefully reviewed before installing.", - "emoji": "⚠️" - }, - "suspiciousString": { - "description": "This package contains suspicious text patterns which are commonly associated with bad behavior.", - "suggestion": "The package code should be reviewed before installing.", - "title": "Suspicious strings", - "emoji": "⚠️" - }, - "telemetry": { - "description": "This package contains telemetry which tracks how it is used.", - "title": "Telemetry", - "suggestion": "Most telemetry comes with settings to disable it. Consider disabling telemetry if you do not want to be tracked.", - "emoji": "📞" - }, - "trivialPackage": { - "description": "Packages less than 10 lines of code are easily copied into your own project and may not warrant the additional supply chain risk of an external dependency.", - "suggestion": "Removing this package as a dependency and implementing its logic will reduce supply chain risk.", - "title": "Trivial Package", - "emoji": "⚠️" - }, - "troll": { - "description": "This package is a joke, parody, or includes undocumented or hidden behavior unrelated to its primary function.", - "title": "Protestware or potentially unwanted behavior", - "suggestion": "Consider that consuming this package may come along with functionality unrelated to its primary purpose.", - "emoji": "🧌" - }, - "typeModuleCompatibility": { - "description": "Package is CommonJS, but has a dependency which is type: \"module\". The two are likely incompatible.", - "suggestion": "The package needs to switch to dynamic import on the esmodule dependency, or convert to esm itself. Consumers may experience errors resulting from this incompatibility.", - "title": "CommonJS depending on ESModule", - "emoji": "⚠️" - }, - "uncaughtOptionalDependency": { - "description": "Package uses an optional dependency without handling a missing dependency exception. If you install it without the optional dependencies then it could cause runtime errors.", - "suggestion": "Package should handle the loading of the dependency when it is not present, or convert the optional dependency into a regular dependency.", - "title": "Uncaught optional dependency", - "emoji": "⚠️" - }, - "unclearLicense": { - "description": "Package contains a reference to a license without a matching LICENSE file.", - "suggestion": "Add a LICENSE file that matches the license field in package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#license", - "title": "Unclear license", - "emoji": "⚠️" - }, - "unmaintained": { - "description": "Package has not been updated in more than 5 years and may be unmaintained. Problems with the package may go unaddressed.", - "suggestion": "Package should publish periodic maintenance releases if they are maintained, or deprecate if they have no intention in further maintenance.", - "title": "Unmaintained", - "emoji": "⚠️" - }, - "unpopularPackage": { - "description": "This package is not very popular.", - "suggestion": "Unpopular packages may have less maintenance and contain other problems.", - "title": "Unpopular package", - "emoji": "🏚️" - }, - "unpublished": { - "description": "Package version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.", - "suggestion": "Packages can be removed from the registry by manually un-publishing, a security issue removal, or may simply never have been published to the registry. Reliance on these packages will cause problem when they are not found.", - "title": "Unpublished package", - "emoji": "⚠️" - }, - "unresolvedRequire": { - "description": "Package imports a file which does not exist and may not work as is. It could also be importing a file that will be created at runtime which could be a vector for running malicious code.", - "suggestion": "Fix imports so that they require declared dependencies or existing files.", - "title": "Unresolved require", - "emoji": "🕵️" - }, - "unsafeCopyright": { - "description": "(Experimental) Package contains a copyright but no license. Using this package may expose you to legal risk.", - "suggestion": "Clarify the license type by adding a license field to package.json and a LICENSE file.", - "title": "Unsafe copyright", - "emoji": "⚠️" - }, - "unstableOwnership": { - "description": "A new collaborator has begun publishing package versions. Package stability and security risk may be elevated.", - "suggestion": "Try to reduce the number of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm.", - "title": "Unstable ownership", - "emoji": "⚠️" - }, - "unusedDependency": { - "description": "Package has unused dependencies. This package depends on code that it does not use. This can increase the attack surface for malware and slow down installation.", - "suggestion": "Packages should only specify dependencies that they use directly.", - "title": "Unused dependency", - "emoji": "⚠️" - }, - "urlStrings": { - "description": "Package contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.", - "suggestion": "Review all remote URLs to ensure they are intentional, pointing to trusted sources, and not being used for data exfiltration or loading untrusted code at runtime.", - "title": "URL strings", - "emoji": "⚠️" - }, - "usesEval": { - "description": "Package uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.", - "suggestion": "Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.", - "title": "Uses eval", - "emoji": "⚠️" - }, - "zeroWidth": { - "description": "Package files contain zero width unicode characters. This could indicate a supply chain attack.", - "suggestion": "Packages should remove unnecessary zero width unicode characters and use their visible counterparts.", - "title": "Zero width unicode chars", - "emoji": "⚠️" - }, - "chromePermission": { - "description": "This Chrome extension uses the '{permission}' permission.", - "suggestion": "Does this extensions need these permissions? Read more about what they mean at https://developer.chrome.com/docs/extensions/reference/permissions-list", - "title": "Chrome Extension Permission", - "emoji": "⚠️" - }, - "chromeHostPermission": { - "description": "This Chrome extension requests access to '{host}'.", - "suggestion": "Review the host permission request and ensure it's necessary for the extension's functionality. Consider if the extension could work with more restrictive host permissions.", - "title": "Chrome Extension Host Permission", - "emoji": "⚠️" - }, - "chromeWildcardHostPermission": { - "description": "This Chrome extension requests broad access to websites with the pattern '{host}'.", - "suggestion": "Wildcard host permissions like '*://*/*' give the extension access to all websites. This is a significant security risk and should be carefully reviewed. Consider if the extension could work with more restrictive host permissions.", - "title": "Chrome Extension Wildcard Host Permission", - "emoji": "⚠️" - }, - "chromeContentScript": { - "description": "This Chrome extension includes a content script '{scriptFile}' that runs on websites matching '{matches}'.", - "suggestion": "Content scripts can modify web pages and access page content. Review the content script code to understand what it does on the websites it targets.", - "title": "Chrome Extension Content Script", - "emoji": "⚠️" - } - } -} diff --git a/packages/cli/data/command-api-requirements.json b/packages/cli/data/command-api-requirements.json deleted file mode 100644 index 6ecef5b0d..000000000 --- a/packages/cli/data/command-api-requirements.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "api": { - "analytics": { - "quota": 1, - "permissions": ["report:write"] - }, - "audit-log": { - "quota": 1, - "permissions": ["audit-log:list"] - }, - "fix": { - "quota": 101, - "permissions": ["full-scans:create", "packages:list"] - }, - "login": { - "quota": 1, - "permissions": [] - }, - "npm": { - "quota": 100, - "permissions": ["packages:list"] - }, - "npx": { - "quota": 100, - "permissions": ["packages:list"] - }, - "optimize": { - "quota": 100, - "permissions": ["packages:list"] - }, - "organization:dependencies": { - "quota": 1, - "permissions": [] - }, - "organization:list": { - "quota": 1, - "permissions": [] - }, - "organization:policy:license": { - "quota": 1, - "permissions": ["license-policy:read"] - }, - "organization:policy:security": { - "quota": 1, - "permissions": ["security-policy:read"] - }, - "package:score": { - "quota": 100, - "permissions": ["packages:list"] - }, - "package:shallow": { - "quota": 100, - "permissions": ["packages:list"] - }, - "repository:create": { - "quota": 1, - "permissions": ["repo:create"] - }, - "repository:del": { - "quota": 1, - "permissions": ["repo:delete"] - }, - "repository:list": { - "quota": 1, - "permissions": ["repo:list"] - }, - "repository:update": { - "quota": 1, - "permissions": ["repo:update"] - }, - "repository:view": { - "quota": 1, - "permissions": ["repo:list"] - }, - "scan:create": { - "quota": 1, - "permissions": ["full-scans:create"] - }, - "scan:del": { - "quota": 1, - "permissions": ["full-scans:delete"] - }, - "scan:diff": { - "quota": 1, - "permissions": ["full-scans:list"] - }, - "scan:list": { - "quota": 1, - "permissions": ["full-scans:list"] - }, - "scan:github": { - "quota": 1, - "permissions": ["full-scans:create"] - }, - "scan:metadata": { - "quota": 1, - "permissions": ["full-scans:list"] - }, - "scan:reach": { - "quota": 1, - "permissions": ["full-scans:create"] - }, - "scan:report": { - "quota": 2, - "permissions": ["full-scans:list", "security-policy:read"] - }, - "scan:view": { - "quota": 1, - "permissions": ["full-scans:list"] - }, - "shallow": { - "quota": 100, - "permissions": ["packages:list"] - }, - "threat-feed": { - "quota": 1, - "permissions": ["threat-feed:list"] - } - } -} diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index df7003ad4..000000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "name": "@socketsecurity/cli", - "version": "0.0.0", - "private": true, - "description": "CLI for Socket.dev", - "homepage": "https://github.com/SocketDev/socket-cli", - "license": "MIT", - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git" - }, - "bin": { - "socket": "dist/index.js", - "socket-npm": "dist/index.js", - "socket-npx": "dist/index.js" - }, - "files": [ - "CHANGELOG.md", - "LICENSE", - "data/**", - "dist/**", - "logo-dark.png", - "logo-light.png" - ], - "scripts": { - "build": "node --max-old-space-size=8192 --import=./scripts/load.mts scripts/build.mts", - "build:force": "node --max-old-space-size=8192 --import=./scripts/load.mts scripts/build.mts --force", - "build:watch": "node --max-old-space-size=8192 --import=./scripts/load.mts scripts/build.mts --watch", - "restore-cache": "node --import=./scripts/load.mts scripts/restore-cache.mts", - "build:sea": "node --max-old-space-size=8192 --import=./scripts/load.mts scripts/build-sea.mts", - "build:js": "node scripts/build-js.mts", - "dev:watch": "pnpm run build:watch", - "check": "node ../../scripts/check.mts", - "check-ci": "pnpm run check", - "lint": "oxlint -c ../../.config/oxlintrc.json", - "lint-ci": "pnpm run lint", - "type": "tsc -p tsconfig.json --noEmit", - "type-ci": "pnpm run type", - "sync-checksums": "node scripts/sync-checksums.mts", - "cover": "node --import=./scripts/load.mts scripts/cover.mts", - "clean": "run-p -c --aggregate-output clean:*", - "clean:binject": "del-cli 'build/binject'", - "clean:cache": "del-cli '**/.cache'", - "clean:dist": "del-cli 'dist'", - "clean:node-smol": "del-cli 'build/node-smol'", - "clean:node_modules": "del-cli '**/node_modules'", - "fix": "oxfmt --write . && oxlint --fix -c ../../.oxlintrc.json", - "lint-staged": "lint-staged", - "precommit": "lint-staged", - "prepare": "husky", - "bs": "pnpm run build && pnpm exec socket --", - "s": "pnpm exec socket --", - "dev": "node src/cli-dispatch.mts", - "dev:npm": "cross-env SOCKET_CLI_MODE=npm node src/cli-dispatch.mts", - "dev:npx": "cross-env SOCKET_CLI_MODE=npx node src/cli-dispatch.mts", - "e2e-tests": "vitest run --config vitest.e2e.config.mts", - "e2e:js": "node scripts/e2e.mts --js", - "e2e:sea": "node scripts/e2e.mts --sea", - "e2e:all": "node scripts/e2e.mts --all", - "test": "run-s check test:*", - "test:prepare": "pnpm build && del-cli 'test/**/node_modules'", - "test:unit": "node --import=./scripts/load.mts scripts/test-wrapper.mts", - "test:unit:update": "node --import=./scripts/load.mts scripts/test-wrapper.mts --update", - "test:unit:coverage": "node --import=./scripts/load.mts scripts/test-wrapper.mts --coverage", - "test:validate": "node --import=./scripts/load.mts scripts/validate-tests.mts", - "test-ci": "run-s test:*", - "test-pre-commit": "cross-env PRE_COMMIT=1 pnpm test", - "update": "node ../../scripts/update.mts", - "verify": "node scripts/verify-package.mts", - "wasm": "node scripts/wasm.mts", - "wasm:build": "node scripts/wasm.mts --build", - "wasm:download": "node scripts/wasm.mts --download" - }, - "devDependencies": { - "@babel/generator": "catalog:", - "@babel/parser": "catalog:", - "@babel/traverse": "catalog:", - "@babel/types": "catalog:", - "@gitbeaker/rest": "catalog:", - "@modelcontextprotocol/sdk": "catalog:", - "@npmcli/arborist": "catalog:", - "@octokit/graphql": "catalog:", - "@octokit/request-error": "catalog:", - "@octokit/rest": "catalog:", - "@socketregistry/hyrious__bun.lockb": "catalog:", - "@socketregistry/indent-string": "catalog:", - "@socketregistry/is-interactive": "catalog:", - "@socketregistry/packageurl-js": "catalog:", - "@socketregistry/packageurl-js-stable": "catalog:", - "@socketregistry/yocto-spinner": "catalog:", - "@socketsecurity/lib": "catalog:", - "@socketsecurity/lib-stable": "catalog:", - "@socketsecurity/registry": "catalog:", - "@socketsecurity/registry-stable": "catalog:", - "@socketsecurity/sdk": "catalog:", - "@socketsecurity/sdk-stable": "catalog:", - "@types/adm-zip": "catalog:", - "adm-zip": "catalog:", - "ajv-dist": "catalog:", - "ansi-regex": "catalog:", - "brace-expansion": "catalog:", - "browserslist": "catalog:", - "build-infra": "workspace:*", - "chalk-table": "catalog:", - "cmd-shim": "catalog:", - "compromise": "catalog:", - "cross-env": "10.1.0", - "del-cli": "catalog:", - "emoji-regex": "catalog:", - "fast-glob": "catalog:", - "graceful-fs": "catalog:", - "hpagent": "catalog:", - "https-proxy-agent": "catalog:", - "ignore": "catalog:", - "lru-cache": "11.2.6", - "micromatch": "catalog:", - "nanotar": "catalog:", - "npm-package-arg": "catalog:", - "open": "catalog:", - "package-builder": "workspace:*", - "registry-auth-token": "catalog:", - "registry-url": "catalog:", - "rolldown": "catalog:", - "semver": "catalog:", - "ssri": "catalog:", - "string-width": "catalog:", - "tar-stream": "catalog:", - "terminal-link": "catalog:", - "yaml": "catalog:", - "yargs-parser": "catalog:", - "yoctocolors-cjs": "catalog:", - "zod": "catalog:" - }, - "lint-staged": { - "*.{cjs,cts,js,json,md,mjs,mts,ts}": [ - "oxfmt --write" - ] - }, - "pnpm": { - "overrides": { - "@octokit/graphql": "catalog:", - "@octokit/request-error": "catalog:", - "aggregate-error": "catalog:", - "ansi-regex": "catalog:", - "brace-expansion": "catalog:", - "emoji-regex": "catalog:", - "es-define-property": "catalog:", - "es-set-tostringtag": "catalog:", - "function-bind": "catalog:", - "globalthis": "catalog:", - "gopd": "catalog:", - "graceful-fs": "catalog:", - "has-property-descriptors": "catalog:", - "has-proto": "catalog:", - "has-symbols": "catalog:", - "has-tostringtag": "catalog:", - "hasown": "catalog:", - "https-proxy-agent": "catalog:", - "indent-string": "catalog:", - "is-core-module": "catalog:", - "isarray": "catalog:", - "lodash": "catalog:", - "npm-package-arg": "catalog:", - "packageurl-js": "catalog:", - "path-parse": "catalog:", - "safe-buffer": "catalog:", - "safer-buffer": "catalog:", - "semver": "catalog:", - "set-function-length": "catalog:", - "shell-quote": "catalog:", - "side-channel": "catalog:", - "string_decoder": "catalog:", - "string-width": "catalog:", - "strip-ansi": "catalog:", - "tiny-colors": "catalog:", - "typedarray": "catalog:", - "undici": "catalog:", - "vite": "catalog:", - "wrap-ansi": "catalog:", - "xml2js": "catalog:", - "yaml": "catalog:", - "yargs-parser": "catalog:" - } - } -} diff --git a/packages/cli/scripts/build-js.mts b/packages/cli/scripts/build-js.mts deleted file mode 100644 index 3cc667cf8..000000000 --- a/packages/cli/scripts/build-js.mts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Build script for CLI JavaScript bundle. Orchestrates extraction, - * building, and validation. - */ - -import { copyFileSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -async function main() { - try { - logger.step('Building CLI bundle') - const buildResult = await spawn( - 'node', - ['--max-old-space-size=8192', '.config/rolldown.build.mts', 'cli'], - { stdio: 'inherit' }, - ) - if (!buildResult) { - logger.error('Failed to start CLI build') - process.exitCode = 1 - return - } - if (buildResult.code !== 0) { - process.exitCode = buildResult.code - return - } - - // Step 3: Copy bundle to dist/. - copyFileSync('build/cli.js', 'dist/cli.js') - - // Step 4: Validate bundle. - logger.step('Validating bundle') - const validateResult = await spawn( - 'node', - ['scripts/validate-bundle.mts'], - { stdio: 'inherit' }, - ) - if (validateResult.code !== 0) { - process.exitCode = validateResult.code - return - } - - logger.success('Build completed successfully') - } catch (e) { - logger.error(`Build failed: ${e.message}`) - process.exitCode = 1 - } -} - -main() diff --git a/packages/cli/scripts/build-sea.mts b/packages/cli/scripts/build-sea.mts deleted file mode 100644 index 77a9e326e..000000000 --- a/packages/cli/scripts/build-sea.mts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Build Socket SEA (Single Executable Application) binaries. Uses pre-compiled - * Node.js smol binaries from socket-btm releases. - * - * Options: --target=<target> - Build for specific target (darwin-arm64, - * linux-x64-musl, etc.) --platform=<platform> - Build for specific platform - * (darwin, linux, win32) --arch=<arch> - Build for specific architecture (x64, - * arm64) --libc=<libc> - Build for specific libc (musl, glibc) - Linux only - * --all - Build for all platforms (default if no options) - * - * Environment: SOCKET_CLI_SEA_NODE_VERSION - Node.js version to use (default: - * latest Current) PREBUILT_NODE_DOWNLOAD_URL - Binary source (default: - * 'socket-btm') - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { parsePlatformArgs } from 'build-infra/lib/platform-targets' -import { getSocketbinBinaryPath } from 'package-builder/scripts/paths.mts' - -import { buildTarget } from './sea-build-util/orchestration.mts' -import { - getBuildTargets, - getDefaultNodeVersion, -} from './sea-build-util/targets.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const logger = getDefaultLogger() - -/** - * Filter targets based on CLI arguments. - */ -function filterTargets(targets, options) { - if (options.all) { - return targets - } - - return targets.filter(target => { - if (options.platform && target.platform !== options.platform) { - return false - } - if (options.arch && target.arch !== options.arch) { - return false - } - if (options.libc) { - // Normalize: undefined/null → 'glibc' (default for Linux) - const targetLibc = - target.platform === 'linux' && !target.libc ? 'glibc' : target.libc - if (targetLibc !== options.libc) { - return false - } - } - return true - }) -} - -/** - * Parse CLI arguments. - */ -export function parseArgs() { - const args = process.argv.slice(2) - const platformArgs = parsePlatformArgs(args) - - const options = { - all: args.includes('--all'), - arch: platformArgs.arch, - libc: platformArgs.libc, - platform: platformArgs.platform, - } - - // Default to --all if no specific platform/arch/libc specified. - if (!options.platform && !options.arch && !options.libc) { - options.all = true - } - - return options -} - -/** - * Main build logic. - */ -async function main() { - const options = parseArgs() - - // Validate libc is Linux-only - if (options.libc && options.platform && options.platform !== 'linux') { - logger.fail('Error: --libc parameter is only valid for Linux builds') - logger.fail( - `Specified: --platform=${options.platform} --libc=${options.libc}`, - ) - logger.log('') - process.exitCode = 1 - return - } - - logger.log('') - logger.log('Socket SEA Builder') - logger.log('='.repeat(50)) - logger.log('') - - // Verify CLI bundle exists. - const entryPoint = path.join(rootPath, 'build/cli.js') - if (!existsSync(entryPoint)) { - logger.fail('CLI bundle not found: build/cli.js') - logger.log('') - logger.log('Run build first:') - logger.log(' pnpm --filter @socketsecurity/cli run build') - logger.log('') - process.exitCode = 1 - return - } - - // Get Node.js version. - const nodeVersion = await getDefaultNodeVersion() - logger.log(`Node.js version: ${nodeVersion}`) - logger.log('') - - // Get and filter build targets. - const allTargets = await getBuildTargets() - const targets = filterTargets(allTargets, options) - - if (targets.length === 0) { - logger.fail('No targets match the specified criteria') - logger.log('') - process.exitCode = 1 - return - } - - logger.log( - `Building ${targets.length} target${targets.length > 1 ? 's' : ''}:`, - ) - for (let i = 0, { length } = targets; i < length; i += 1) { - const target = targets[i] - logger.log(` - ${target.platform}-${target.arch}`) - } - logger.log('') - - // Build all targets in parallel. - // Output goes directly to socketbin package directories. - const settled = await Promise.allSettled( - targets.map(async target => { - const targetName = `${target.platform}-${target.arch}${target.libc ? `-${target.libc}` : ''}` - logger.log(`Building ${targetName}...`) - - // Get output path from socketbin package directory. - const outputPath = getSocketbinBinaryPath( - target.platform, - target.arch, - target.libc, - ) - - await buildTarget(target, entryPoint, { outputPath }) - logger.success( - `✓ ${targetName} -> ${path.relative(rootPath, outputPath)}`, - ) - return { outputPath, success: true, target } - }), - ) - - // Process results from Promise.allSettled. - const results = settled.map(result => { - if (result.status === 'fulfilled') { - return result.value - } - const target = result.reason?.target || {} - const targetName = `${target.platform || 'unknown'}-${target.arch || 'unknown'}` - logger.fail( - `${targetName} failed: ${result.reason?.message || result.reason}`, - ) - return { - error: result.reason?.message || String(result.reason), - success: false, - target, - } - }) - - logger.log('') - - // Summary. - logger.log('='.repeat(50)) - logger.log('') - - const successful = results.filter(r => r.success).length - const failed = results.filter(r => !r.success).length - - if (failed === 0) { - logger.success(`All ${successful} builds completed successfully`) - } else { - logger.fail(`${failed} build${failed > 1 ? 's' : ''} failed`) - process.exitCode = 1 - } - - logger.log('') -} - -main().catch(e => { - logger.error('SEA build failed:', e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/build.mts b/packages/cli/scripts/build.mts deleted file mode 100644 index 9bf1685c3..000000000 --- a/packages/cli/scripts/build.mts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Build script for Socket CLI. Options: --quiet, --verbose, --force, --watch. - */ - -import { copyFileSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageRoot = path.resolve(__dirname, '..') -const repoRoot = path.resolve(__dirname, '../../..') - -// Node options for memory allocation. -const NODE_MEMORY_FLAGS = ['--max-old-space-size=8192'] - -// Simple CLI helpers without registry dependencies. -const isQuiet = () => process.argv.includes('--quiet') -const isVerbose = () => process.argv.includes('--verbose') - -const printHeader = title => { - logger.log('') - logger.log(title) - logger.log('='.repeat(title.length)) - logger.log('') -} -const printFooter = () => logger.log('') -const printSuccess = msg => { - logger.log('') - logger.success(msg) - logger.log('') -} -const printError = msg => { - logger.log('') - logger.error(msg) - logger.log('') -} - -/** - * Post-process bundled files to break node-gyp require.resolve strings. This - * prevents esbuild from trying to bundle node-gyp during the build. - * - * @param {string} dir - Directory to process. - * @param {object} options - Options. - * @param {boolean} options.quiet - Suppress output. - * @param {boolean} options.verbose - Show detailed output. - */ -async function fixNodeGypStrings(dir, options = {}) { - const { quiet = false, verbose = false } = options - - // Find all .js files in build directory. - const files = await fs.readdir(dir, { withFileTypes: true }) - - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - const filePath = path.join(dir, file.name) - - if (file.isDirectory()) { - // Recursively process subdirectories. - await fixNodeGypStrings(filePath, options) - } else if (file.name.endsWith('.js')) { - // Read file contents. - const contents = await fs.readFile(filePath, 'utf-8') - - // Check if file contains the problematic pattern. - if (contents.includes('node-gyp/bin/node-gyp.js')) { - // Replace literal string with concatenated version. - const fixed = contents.replace( - /["']node-gyp\/bin\/node-gyp\.js["']/g, - '"node-" + "gyp/bin/node-gyp.js"', - ) - - await fs.writeFile(filePath, fixed, 'utf-8') - - if (!quiet && verbose) { - logger.info( - `Fixed node-gyp string in ${path.relative(packageRoot, filePath)}`, - ) - } - } - } - } -} - -async function main() { - const quiet = isQuiet() - const verbose = isVerbose() - const watch = process.argv.includes('--watch') - const force = process.argv.includes('--force') - - // Pass --force flag via environment variable. - if (force) { - process.env.SOCKET_CLI_FORCE_BUILD = '1' - } - - // Delegate to watch mode. - if (watch) { - if (!quiet) { - logger.info('Starting watch mode...') - } - - const watchResult = await spawn( - 'node', - [...NODE_MEMORY_FLAGS, '.config/rolldown.cli.mts', '--watch'], - { - shell: WIN32, - stdio: 'inherit', - }, - ) - - if (!watchResult || watchResult.code !== 0) { - process.exitCode = watchResult?.code ?? 1 - throw new Error( - `Watch mode failed with exit code ${watchResult?.code ?? 1}`, - ) - } - return - } - - try { - if (!quiet) { - printHeader('Build Runner') - } - - // If force build, always clean first. - const shouldClean = force - - // Phase 1: Clean (if needed). - if (shouldClean) { - if (!quiet) { - logger.step('Phase 1: Cleaning...') - } - const result = await spawn('pnpm', ['run', 'clean:dist'], { - shell: WIN32, - stdio: 'inherit', - }) - if (result.code !== 0) { - if (!quiet) { - logger.error(`Clean failed (exit code: ${result.code})`) - printError('Build failed') - } - process.exitCode = 1 - return - } - if (!quiet && verbose) { - logger.success('Clean completed') - } - } - - // Phase 2: Generate packages and download assets in parallel. - if (!quiet) { - logger.step('Phase 2: Preparing build (parallel)...') - } - - const parallelPrep = await Promise.allSettled([ - spawn('node', ['scripts/generate-packages.mts'], { - shell: WIN32, - stdio: 'inherit', - }).then(result => ({ name: 'Generate Packages', result })), - spawn('node', [...NODE_MEMORY_FLAGS, 'scripts/download-assets.mts'], { - shell: WIN32, - stdio: 'inherit', - }).then(result => ({ name: 'Download Assets', result })), - ]) - - for (let i = 0, { length } = parallelPrep; i < length; i += 1) { - const settled = parallelPrep[i] - if (settled.status === 'rejected') { - if (!quiet) { - logger.error(`Parallel preparation failed: ${settled.reason}`) - printError('Build failed') - } - process.exitCode = 1 - return - } - - const { name, result } = settled.value - - // Check for null spawn result. - if (!result) { - if (!quiet) { - logger.error(`${name} failed to start`) - printError('Build failed') - } - process.exitCode = 1 - return - } - - if (result.code !== 0) { - if (!quiet) { - logger.error(`${name} failed (exit code: ${result.code})`) - printError('Build failed') - } - process.exitCode = result.code ?? 1 - return - } - - if (!quiet && verbose) { - logger.success(`${name} completed`) - } - } - - // Phase 3: Build all variants. - if (!quiet) { - logger.step('Phase 3: Building variants...') - } - - // Ensure dist directory exists before building variants. - await fs.mkdir(path.join(packageRoot, 'dist'), { recursive: true }) - - const buildResult = await spawn( - 'node', - [...NODE_MEMORY_FLAGS, '.config/rolldown.build.mts', 'all'], - { - shell: WIN32, - stdio: 'inherit', - }, - ) - - if (buildResult.code !== 0) { - if (!quiet) { - logger.error(`Build failed (exit code: ${buildResult.code})`) - printError('Build failed') - } - process.exitCode = 1 - return - } - - if (!quiet && verbose) { - logger.success('Build completed') - } - - // Phase 4: Post-processing (parallel). - if (!quiet) { - logger.step('Phase 4: Post-processing (parallel)...') - } - - const postResults = await Promise.allSettled([ - // Copy CLI bundle to dist (required for dist/index.js to work). - (async () => { - copyFileSync('build/cli.js', 'dist/cli.js') - if (!quiet && verbose) { - logger.success('CLI bundle copied') - } - })(), - - // Fix node-gyp strings to prevent bundler issues. - (async () => { - await fixNodeGypStrings(path.join(packageRoot, 'build'), { - quiet, - verbose, - }) - if (!quiet && verbose) { - logger.success('Build output post-processed') - } - })(), - - // Copy CHANGELOG.md from repo root (LICENSE and logos are already in cli package). - (async () => { - await fs.cp( - path.join(repoRoot, 'CHANGELOG.md'), - path.join(packageRoot, 'CHANGELOG.md'), - ) - if (!quiet && verbose) { - logger.success('CHANGELOG.md copied from repo root') - } - })(), - ]) - - const postFailed = postResults.filter(r => r.status === 'rejected') - if (postFailed.length > 0) { - for (let i = 0, { length } = postFailed; i < length; i += 1) { - const r = postFailed[i] - logger.error(`Post-processing failed: ${r.reason?.message ?? r.reason}`) - } - throw new Error('Post-processing step(s) failed') - } - - if (!quiet) { - printSuccess('Build completed') - printFooter() - } - } catch (e) { - if (!quiet) { - printError(`Build failed: ${e.message}`) - } - if (verbose) { - logger.error(e) - } - process.exitCode = 1 - } -} - -main().catch(e => { - logger.error(e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/constants/build.mts b/packages/cli/scripts/constants/build.mts deleted file mode 100644 index 3f9706a4d..000000000 --- a/packages/cli/scripts/constants/build.mts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file Build-related constants for Socket CLI. - */ - -// Encoding constants. -export const UTF8 = 'utf8' - -// Test environment. -export const VITEST = 'VITEST' diff --git a/packages/cli/scripts/constants/env.mts b/packages/cli/scripts/constants/env.mts deleted file mode 100644 index 7f330a72d..000000000 --- a/packages/cli/scripts/constants/env.mts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Environment variable constants for Socket CLI build. - */ - -// Build metadata environment variable names. -export const INLINED_COANA_VERSION = 'INLINED_COANA_VERSION' -export const INLINED_CYCLONEDX_CDXGEN_VERSION = - 'INLINED_CYCLONEDX_CDXGEN_VERSION' -export const INLINED_HOMEPAGE = 'INLINED_HOMEPAGE' -export const INLINED_NAME = 'INLINED_NAME' -export const INLINED_PUBLISHED_BUILD = 'INLINED_PUBLISHED_BUILD' -export const INLINED_PYTHON_BUILD_TAG = 'INLINED_PYTHON_BUILD_TAG' -export const INLINED_PYTHON_VERSION = 'INLINED_PYTHON_VERSION' -export const INLINED_SENTRY_BUILD = 'INLINED_SENTRY_BUILD' -export const INLINED_SYNP_VERSION = 'INLINED_SYNP_VERSION' -export const INLINED_VERSION = 'INLINED_VERSION' -export const INLINED_VERSION_HASH = 'INLINED_VERSION_HASH' diff --git a/packages/cli/scripts/constants/external-tools-platforms.mts b/packages/cli/scripts/constants/external-tools-platforms.mts deleted file mode 100644 index 5f7227915..000000000 --- a/packages/cli/scripts/constants/external-tools-platforms.mts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * @file Platform-specific binary mappings for external security tools. Maps - * Socket CLI platform identifiers to specific binary asset names from each - * tool's GitHub releases. Used by: - * - * - SEA build utils for downloading and packaging security tools - * - External tools downloader scripts - */ - -/** - * Platform-specific binary mappings for external security tools. - * - * Maps Socket CLI platform identifiers (e.g., 'darwin-arm64') to the specific - * binary asset names from each tool's GitHub releases. All binaries are native - * for their target architecture except on windows-arm64, where Trivy and - * OpenGrep use x64 emulation (Windows 11 ARM64 includes transparent x64 - * emulation). - * - * Windows ARM64 Emulation: Trivy and OpenGrep don't provide native ARM64 - * Windows builds. However, Windows 11 ARM64 includes transparent x64 emulation - * (similar to Rosetta on macOS), so we use x64 binaries on windows-arm64 with - * no code changes or special invocation needed. The binaries are marked with - * "(x64 emulated)" comments for clarity. - * - * Socket-Patch Platform Coverage (v2.0.0): socket-patch is a Rust binary from - * https://github.com/SocketDev/socket-patch. As of v2.0.0, the following builds - * are available: - * - * - Socket-patch-aarch64-apple-darwin.tar.gz (darwin-arm64) - * - Socket-patch-x86_64-apple-darwin.tar.gz (darwin-x64) - * - Socket-patch-aarch64-unknown-linux-gnu.tar.gz (linux-arm64 glibc) - * - Socket-patch-x86_64-unknown-linux-musl.tar.gz (linux-x64 musl) - * - Socket-patch-aarch64-pc-windows-msvc.zip (win-arm64) - * - Socket-patch-x86_64-pc-windows-msvc.zip (win-x64) - * - * MISSING BUILDS (using fallbacks): - * - * - Linux-x64 (glibc): Using musl build as fallback. Musl binaries are statically - * linked and run on glibc systems without issues. - * - Linux-arm64-musl: Using glibc build as fallback. This may have compatibility - * issues on Alpine/musl systems. TODO: Request musl build from socket-patch - * team. - * - * Tool Binary Naming Conventions: - * - * - Python: cpython-{version}-{arch}-{os}-{abi}-install_only.tar.gz. - * - Trivy: trivy_{version}_{OS}-{ARCH}.tar.gz or .zip. - * - TruffleHog: trufflehog_{version}_{os}_{arch}.tar.gz. - * - OpenGrep: opengrep-core_{os}_{arch}.tar.gz or .zip. - * - Socket-Patch: socket-patch-{rust-target}.tar.gz or .zip. - */ -export const PLATFORM_MAP_TOOLS = { - __proto__: null, - - // macOS ARM64 (Apple Silicon) - all native arm64. - 'darwin-arm64': { - __proto__: null, - opengrep: 'opengrep-core_osx_aarch64.tar.gz', - python: 'cpython-3.11.14+20260203-aarch64-apple-darwin-install_only.tar.gz', - sfw: 'sfw-free-macos-arm64', - 'socket-patch': 'socket-patch-aarch64-apple-darwin.tar.gz', - trivy: 'trivy_0.69.2_macOS-ARM64.tar.gz', - trufflehog: 'trufflehog_3.93.1_darwin_arm64.tar.gz', - }, - - // macOS Intel - all native x86_64. - 'darwin-x64': { - __proto__: null, - opengrep: 'opengrep-core_osx_x86.tar.gz', - python: 'cpython-3.11.14+20260203-x86_64-apple-darwin-install_only.tar.gz', - sfw: 'sfw-free-macos-x86_64', - 'socket-patch': 'socket-patch-x86_64-apple-darwin.tar.gz', - trivy: 'trivy_0.69.2_macOS-64bit.tar.gz', - trufflehog: 'trufflehog_3.93.1_darwin_amd64.tar.gz', - }, - - // Linux ARM64 (glibc) - all native aarch64. - 'linux-arm64': { - __proto__: null, - opengrep: 'opengrep-core_linux_aarch64.tar.gz', - python: - 'cpython-3.11.14+20260203-aarch64-unknown-linux-gnu-install_only.tar.gz', - sfw: 'sfw-free-linux-arm64', - 'socket-patch': 'socket-patch-aarch64-unknown-linux-gnu.tar.gz', - trivy: 'trivy_0.69.2_Linux-ARM64.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_arm64.tar.gz', - }, - - // Linux ARM64 (musl/Alpine) - all native aarch64. - 'linux-arm64-musl': { - __proto__: null, - opengrep: 'opengrep-core_linux_aarch64.tar.gz', - python: - 'cpython-3.11.14+20260203-aarch64-unknown-linux-musl-install_only.tar.gz', - sfw: 'sfw-free-musl-linux-arm64', - // FALLBACK: socket-patch v2.0.0 doesn't provide aarch64-unknown-linux-musl build. - // Using glibc build as fallback. This may have compatibility issues on Alpine/musl. - // The glibc binary requires glibc to be present, which Alpine doesn't have by default. - // TODO: Request aarch64-unknown-linux-musl build from socket-patch team. - // Tracking: https://github.com/SocketDev/socket-patch/issues/XXX - 'socket-patch': 'socket-patch-aarch64-unknown-linux-gnu.tar.gz', // FALLBACK: glibc build. - trivy: 'trivy_0.69.2_Linux-ARM64.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_arm64.tar.gz', - }, - - // Linux x86_64 (glibc) - all native x86_64. - 'linux-x64': { - __proto__: null, - opengrep: 'opengrep-core_linux_x86.tar.gz', - python: - 'cpython-3.11.14+20260203-x86_64-unknown-linux-gnu-install_only.tar.gz', - sfw: 'sfw-free-linux-x86_64', - // FALLBACK: socket-patch v2.0.0 doesn't provide x86_64-unknown-linux-gnu build. - // Using musl build as fallback. Musl binaries are statically linked and run - // on glibc systems without issues (the reverse is not true). - // This is a safe fallback that works reliably. - // TODO: Request x86_64-unknown-linux-gnu build from socket-patch team for consistency. - 'socket-patch': 'socket-patch-x86_64-unknown-linux-musl.tar.gz', // FALLBACK: musl build (works on glibc). - trivy: 'trivy_0.69.2_Linux-64bit.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_amd64.tar.gz', - }, - - // Linux x86_64 (musl/Alpine) - all native x86_64. - 'linux-x64-musl': { - __proto__: null, - opengrep: 'opengrep-core_linux_x86.tar.gz', - python: - 'cpython-3.11.14+20260203-x86_64-unknown-linux-musl-install_only.tar.gz', - sfw: 'sfw-free-musl-linux-x86_64', - 'socket-patch': 'socket-patch-x86_64-unknown-linux-musl.tar.gz', - trivy: 'trivy_0.69.2_Linux-64bit.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_amd64.tar.gz', - }, - - // Windows ARM64 - Python, TruffleHog, and socket-patch are native arm64. - // Trivy, OpenGrep, and sfw use x64 binaries (Windows 11 ARM64 emulates x64). - 'win-arm64': { - __proto__: null, - opengrep: 'opengrep-core_windows_x86.zip', // x64 emulated. - python: - 'cpython-3.11.14+20260203-aarch64-pc-windows-msvc-install_only.tar.gz', // native arm64. - sfw: 'sfw-free-windows-x86_64.exe', // x64 emulated. - 'socket-patch': 'socket-patch-aarch64-pc-windows-msvc.zip', // native arm64. - trivy: 'trivy_0.69.2_windows-64bit.zip', // x64 emulated. - trufflehog: 'trufflehog_3.93.1_windows_arm64.tar.gz', // native arm64. - }, - - // Windows x86_64 - all native x86_64. - 'win-x64': { - __proto__: null, - opengrep: 'opengrep-core_windows_x86.zip', - python: - 'cpython-3.11.14+20260203-x86_64-pc-windows-msvc-install_only.tar.gz', - sfw: 'sfw-free-windows-x86_64.exe', - 'socket-patch': 'socket-patch-x86_64-pc-windows-msvc.zip', - trivy: 'trivy_0.69.2_windows-64bit.zip', - trufflehog: 'trufflehog_3.93.1_windows_amd64.tar.gz', - }, -} - -/** - * Get platform key for EXTERNAL_TOOLS_BY_PLATFORM lookup. Normalizes - * process.platform (win32) to release naming (win). - * - * @param {string} platform - Process.platform value (darwin, linux, win32). - * @param {string} arch - Process.arch value (arm64, x64). - * - * @returns {string} Normalized platform key (e.g., 'win-x64'). - */ -function getPlatformKey(platform, arch) { - const releasePlatform = platform === 'win32' ? 'win' : platform - return `${releasePlatform}-${arch}` -} diff --git a/packages/cli/scripts/constants/packages.mts b/packages/cli/scripts/constants/packages.mts deleted file mode 100644 index a9fa863e6..000000000 --- a/packages/cli/scripts/constants/packages.mts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @file Package naming constants for Socket CLI. - */ - -// CLI package names. -export const SOCKET_CLI_LEGACY_PACKAGE_NAME = '@socketsecurity/cli' -export const SOCKET_CLI_PACKAGE_NAME = 'socket' -export const SOCKET_CLI_SENTRY_PACKAGE_NAME = '@socketsecurity/cli-with-sentry' - -// CLI binary names. -export const SOCKET_CLI_BIN_NAME = 'socket' -export const SOCKET_CLI_BIN_NAME_ALIAS = 'cli' -export const SOCKET_CLI_NPM_BIN_NAME = 'socket-npm' -export const SOCKET_CLI_NPX_BIN_NAME = 'socket-npx' -export const SOCKET_CLI_PNPM_BIN_NAME = 'socket-pnpm' -export const SOCKET_CLI_YARN_BIN_NAME = 'socket-yarn' - -// Sentry-enabled binary names. -export const SOCKET_CLI_SENTRY_BIN_NAME = 'socket-with-sentry' -export const SOCKET_CLI_SENTRY_BIN_NAME_ALIAS = 'cli-with-sentry' -export const SOCKET_CLI_SENTRY_NPM_BIN_NAME = 'socket-npm-with-sentry' -export const SOCKET_CLI_SENTRY_NPX_BIN_NAME = 'socket-npx-with-sentry' -export const SOCKET_CLI_SENTRY_PNPM_BIN_NAME = 'socket-pnpm-with-sentry' -export const SOCKET_CLI_SENTRY_YARN_BIN_NAME = 'socket-yarn-with-sentry' - -// File and directory names from registry. -export const NODE_MODULES = 'node_modules' -export const PACKAGE_JSON = 'package.json' -export const PNPM_LOCK_YAML = 'pnpm-lock.yaml' diff --git a/packages/cli/scripts/constants/paths.mts b/packages/cli/scripts/constants/paths.mts deleted file mode 100644 index 3656b59a1..000000000 --- a/packages/cli/scripts/constants/paths.mts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @file Path constants for Socket CLI build scripts. - */ - -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { NODE_MODULES } from './packages.mts' - -// Compute root path from this file's location. -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -export const rootPath = path.resolve(__dirname, '../..') - -// Base directory paths (no dist dependency). -export const configPath = path.join(rootPath, '.config') -export const externalPath = path.join(rootPath, 'external') -export const srcPath = path.join(rootPath, 'src') - -// Package and lockfile paths. -export const rootNodeModulesBinPath = path.join(rootPath, NODE_MODULES, '.bin') - -// Cache directory paths. -const SOCKET_CACHE_DIR = path.join(homedir(), '.socket') -export const SOCKET_CLI_SEA_BUILD_DIR = path.join( - tmpdir(), - 'socket-cli-sea-build', -) -const SOCKET_CLI_SEA_BUILD_DIR_FALLBACK = '/tmp/socket-cli-sea-build' - -/** - * Get all global cache directories. - */ -function getGlobalCacheDirs() { - return [ - { name: '~/.socket', path: SOCKET_CACHE_DIR }, - { name: '$TMPDIR/socket-cli-sea-build', path: SOCKET_CLI_SEA_BUILD_DIR }, - { - name: '/tmp/socket-cli-sea-build', - path: SOCKET_CLI_SEA_BUILD_DIR_FALLBACK, - }, - ] -} diff --git a/packages/cli/scripts/constants/platform-mappings.mts b/packages/cli/scripts/constants/platform-mappings.mts deleted file mode 100644 index 3f4385d13..000000000 --- a/packages/cli/scripts/constants/platform-mappings.mts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @file Centralized platform and architecture mappings. Maps Node.js - * identifiers to socket-btm release asset names. Used by: - * - * - AssetManager for binary downloads - * - SEA build utils for target platforms - * - Security tools downloader - */ - -/** - * Architecture mapping from Node.js identifiers to platform-specific arch - * names. Maps process.arch values to socket-btm release asset arch - * identifiers. - */ -export const ARCH_MAP = { - __proto__: null, - arm64: 'arm64', - ia32: 'x86', - x64: 'x64', -} - -/** - * Platform mapping from Node.js identifiers to platform-specific names. Maps - * process.platform values to socket-btm release asset platform identifiers. - */ -export const PLATFORM_MAP = { - __proto__: null, - darwin: 'darwin', - linux: 'linux', - win32: 'win', -} diff --git a/packages/cli/scripts/constants/versions.mts b/packages/cli/scripts/constants/versions.mts deleted file mode 100644 index bbebacbf7..000000000 --- a/packages/cli/scripts/constants/versions.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @file Version and compatibility constants for Socket CLI. - */ - -// Version string constant. -export const LATEST = 'latest' - -// Maintained Node.js versions for testing and compatibility. -// Re-export from registry if needed, or define here. -export const maintainedNodeVersions = [18, 20, 22] diff --git a/packages/cli/scripts/cover.mts b/packages/cli/scripts/cover.mts deleted file mode 100644 index 7589eaa3c..000000000 --- a/packages/cli/scripts/cover.mts +++ /dev/null @@ -1,322 +0,0 @@ - -/** - * @file Unified coverage script - runs tests with coverage reporting. - * Standardized across all socket-* repositories. Usage: node - * scripts/cover.mts [options] Options: --quiet Suppress progress output - * --verbose Show detailed output --open Open coverage report in browser - * --code-only Run only code coverage (skip type coverage) --type-only Run - * only type coverage (skip code coverage) --summary Show only coverage - * summary (hide detailed output) - */ - -import { isQuiet, isVerbose } from '@socketsecurity/lib-stable/argv/flag-predicates' -import { parseArgs } from '@socketsecurity/lib-stable/argv/parse' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { PACKAGE_ROOT } from './paths.mts' - -const logger = getDefaultLogger() - -export function printError(message) { - logger.error(`✖ ${message}`) -} - -export function printHeader(message) { - logger.error('') - logger.error('═══════════════════════════════════════════════════════') - logger.error(` ${message}`) - logger.error('═══════════════════════════════════════════════════════') - logger.error('') -} - -export function printSuccess(message) { - logger.log(`✔ ${message}`) -} - -async function main() { - const quiet = isQuiet() - const verbose = isVerbose() - const open = process.argv.includes('--open') - - // Parse custom coverage flags - const { values } = parseArgs({ - options: { - 'code-only': { type: 'boolean', default: false }, - 'type-only': { type: 'boolean', default: false }, - summary: { type: 'boolean', default: false }, - }, - strict: false, - }) - - try { - if (!quiet) { - printHeader('Test Coverage') - logger.log('') - } - - // Run vitest with coverage enabled, capturing output - // Filter out custom flags that vitest doesn't understand - const customFlags = ['--code-only', '--type-only', '--summary'] - const vitestArgs = [ - 'exec', - 'vitest', - 'run', - '--coverage', - '--passWithNoTests', - ...process.argv.slice(2).filter(arg => !customFlags.includes(arg)), - ] - const typeCoverageArgs = ['exec', 'type-coverage'] - - let exitCode = 0 - let codeCoverageResult - let typeCoverageResult - - // Handle --type-only flag - if (values['type-only']) { - typeCoverageResult = await spawn('pnpm', typeCoverageArgs, { - cwd: PACKAGE_ROOT, - encoding: 'utf8', - shell: WIN32, - stdio: ['pipe', 'pipe', 'pipe'], - }) - exitCode = typeCoverageResult.code - - if (!quiet) { - // Display type coverage only - const typeCoverageOutput = ( - typeCoverageResult.stdout + typeCoverageResult.stderr - ).trim() - const typeCoverageMatch = typeCoverageOutput.match( - /\([\d\s/]+\)\s+([\d.]+)%/, - ) - - if (typeCoverageMatch) { - const typeCoveragePercent = Number.parseFloat(typeCoverageMatch[1]) - logger.log('') - logger.log(' Coverage Summary') - logger.log(' ───────────────────────────────') - logger.log(` Type Coverage: ${typeCoveragePercent.toFixed(2)}%`) - logger.log('') - } - } - - if (exitCode === 0) { - if (!quiet) { - printSuccess('Coverage completed successfully') - } - } else { - if (!quiet) { - printError('Coverage failed') - } - process.exitCode = 1 - } - return - } - - // Handle --code-only flag - if (values['code-only']) { - codeCoverageResult = await spawn('pnpm', vitestArgs, { - cwd: PACKAGE_ROOT, - encoding: 'utf8', - shell: WIN32, - stdio: ['pipe', 'pipe', 'pipe'], - }) - exitCode = codeCoverageResult.code - - if (!quiet) { - // Process code coverage output only - const ansiRegex = new RegExp( - `${String.fromCharCode(27)}\\[[0-9;]*m`, - 'g', - ) - const output = (codeCoverageResult.stdout + codeCoverageResult.stderr) - .replace(ansiRegex, '') - .replace(/(?:⚡|✧|︎)\s*/g, '') - .trim() - - // Extract and display test summary - const testSummaryMatch = output.match( - /Test Files\s+\d+[^\n]*\n[\s\S]*?Duration\s+[\d.]+m?s[^\n]*/, - ) - if (!values.summary && testSummaryMatch) { - logger.log('') - logger.log(testSummaryMatch[0]) - logger.log('') - } - - // Extract and display coverage summary - const coverageHeaderMatch = output.match( - / % Coverage report from v8\n([-|]+)\n([^\n]+)\n\1/, - ) - const allFilesMatch = output.match( - /All files\s+\|\s+([\d.]+)\s+\|[^\n]*/, - ) - - if (coverageHeaderMatch && allFilesMatch) { - if (!values.summary) { - logger.log(' % Coverage report from v8') - logger.log(coverageHeaderMatch[1]) - logger.log(coverageHeaderMatch[2]) - logger.log(coverageHeaderMatch[1]) - logger.log(allFilesMatch[0]) - logger.log(coverageHeaderMatch[1]) - logger.log('') - } - - const codeCoveragePercent = Number.parseFloat(allFilesMatch[1]) - logger.log(' Coverage Summary') - logger.log(' ───────────────────────────────') - logger.log(` Code Coverage: ${codeCoveragePercent.toFixed(2)}%`) - logger.log('') - } else if (exitCode !== 0) { - logger.log('') - logger.log('--- Output ---') - logger.log(output) - } - } - - if (exitCode === 0) { - if (!quiet) { - printSuccess('Coverage completed successfully') - } - } else { - if (!quiet) { - printError('Coverage failed') - } - process.exitCode = 1 - } - return - } - - // Default: run both code and type coverage - codeCoverageResult = await spawn('pnpm', vitestArgs, { - cwd: PACKAGE_ROOT, - encoding: 'utf8', - shell: WIN32, - stdio: ['pipe', 'pipe', 'pipe'], - }) - exitCode = codeCoverageResult.code - - // Run type coverage - typeCoverageResult = await spawn('pnpm', typeCoverageArgs, { - cwd: PACKAGE_ROOT, - encoding: 'utf8', - shell: WIN32, - stdio: ['pipe', 'pipe', 'pipe'], - }) - - // Combine and clean output - remove ANSI color codes and spinner artifacts - const ansiRegex = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') - const output = (codeCoverageResult.stdout + codeCoverageResult.stderr) - // Remove ANSI color codes - .replace(ansiRegex, '') - // Remove spinner artifacts - .replace(/(?:⚡|✧|︎)\s*/g, '') - .trim() - - // Extract test summary (Test Files ... Duration) - const testSummaryMatch = output.match( - /Test Files\s+\d+[^\n]*\n[\s\S]*?Duration\s+[\d.]+m?s[^\n]*/, - ) - - // Extract coverage summary: header + All files row - // Match from "% Coverage" header through the All files line and closing border - const coverageHeaderMatch = output.match( - / % Coverage report from v8\n([-|]+)\n([^\n]+)\n\1/, - ) - const allFilesMatch = output.match(/All files\s+\|\s+([\d.]+)\s+\|[^\n]*/) - - // Extract type coverage percentage - const typeCoverageOutput = ( - typeCoverageResult.stdout + typeCoverageResult.stderr - ).trim() - const typeCoverageMatch = typeCoverageOutput.match( - /\([\d\s/]+\)\s+([\d.]+)%/, - ) - - // Display clean output - if (!quiet) { - if (!values.summary && testSummaryMatch) { - logger.log('') - logger.log(testSummaryMatch[0]) - logger.log('') - } - - if (coverageHeaderMatch && allFilesMatch) { - if (!values.summary) { - logger.log(' % Coverage report from v8') - // Top border - logger.log(coverageHeaderMatch[1]) - // Header row - logger.log(coverageHeaderMatch[2]) - // Middle border - logger.log(coverageHeaderMatch[1]) - // All files row - logger.log(allFilesMatch[0]) - // Bottom border - logger.log(coverageHeaderMatch[1]) - logger.log('') - } - - // Display type coverage and cumulative summary - if (typeCoverageMatch) { - const codeCoveragePercent = Number.parseFloat(allFilesMatch[1]) - const typeCoveragePercent = Number.parseFloat(typeCoverageMatch[1]) - const cumulativePercent = ( - (codeCoveragePercent + typeCoveragePercent) / - 2 - ).toFixed(2) - - logger.log(' Coverage Summary') - logger.log(' ───────────────────────────────') - logger.log(` Type Coverage: ${typeCoveragePercent.toFixed(2)}%`) - logger.log(` Code Coverage: ${codeCoveragePercent.toFixed(2)}%`) - logger.log(' ───────────────────────────────') - logger.log(` Cumulative: ${cumulativePercent}%`) - logger.log('') - } - } - } - - if (exitCode !== 0) { - if (!quiet) { - printError('Coverage failed') - // Show relevant output on failure for debugging - if (!testSummaryMatch && !coverageHeaderMatch) { - logger.log('') - logger.log('--- Output ---') - logger.log(output) - } - } - process.exitCode = 1 - } else { - if (!quiet) { - printSuccess('Coverage completed successfully') - - // Open coverage report if requested - if (open) { - logger.info('Opening coverage report...') - await spawn('open', ['coverage/index.html'], { - shell: WIN32, - stdio: 'ignore', - }) - } - } - } - } catch (e) { - if (!quiet) { - printError(`Coverage failed: ${e.message}`) - } - if (verbose) { - logger.error(e) - } - process.exitCode = 1 - } -} - -main().catch(e => { - logger.error(e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/download-assets.mts b/packages/cli/scripts/download-assets.mts deleted file mode 100644 index 49c3fafa2..000000000 --- a/packages/cli/scripts/download-assets.mts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Unified asset downloader for socket-btm releases. Downloads and extracts all - * required assets from socket-btm GitHub releases. - * - * Usage: node scripts/download-assets.mts [asset-names...] [options] node - * scripts/download-assets.mts # Download all assets (parallel) node - * scripts/download-assets.mts models # Download specific assets (parallel) node - * scripts/download-assets.mts --no-parallel # Download all assets (sequential) - * - * Assets: binject - Binary injection tool. models - AI models tar.gz - * (MiniLM, CodeT5). node-smol - Minimal Node.js binaries. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { logTransientErrorHelp } from 'build-infra/lib/github-error-utils' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { downloadSocketBtmRelease } from '@socketsecurity/lib-stable/releases/socket-btm' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const logger = getDefaultLogger() - -/** - * Asset configuration. Each asset defines how to download and process it. - */ -const ASSETS = { - __proto__: null, - binject: { - description: 'Binary injection tool for SEA builds', - download: { - cwd: rootPath, - downloadDir: '../../packages/build-infra/build/downloaded/binject', - envVar: 'SOCKET_BTM_BINJECT_TAG', - quiet: false, - tool: 'binject', - }, - name: 'binject', - type: 'binary', - }, - models: { - description: 'AI models (MiniLM-L6-v2, CodeT5)', - download: { - asset: 'models-*.tar.gz', - cwd: rootPath, - downloadDir: '../../packages/build-infra/build/downloaded/models', - quiet: false, - tool: 'models', - }, - extract: { - format: 'tar.gz', - outputDir: path.join(rootPath, 'build/models'), - }, - name: 'models', - type: 'archive', - }, - 'node-smol': { - description: 'Minimal Node.js v24.10.0 binaries', - download: { - bin: 'node', - cwd: rootPath, - downloadDir: '../../packages/build-infra/build/downloaded/node-smol', - envVar: 'SOCKET_BTM_NODE_SMOL_TAG', - quiet: false, - tool: 'node-smol', - }, - name: 'node-smol', - type: 'binary', - }, -} - -/** - * Download a single asset. - */ -async function downloadAsset(config) { - const { description, download, extract, name, type } = config - - try { - logger.group(`Extracting ${name} from socket-btm releases...`) - logger.info(description) - - // Download the asset. - let assetPath - try { - // Extract tool name from download config. - const { tool, ...downloadOptions } = download - assetPath = await downloadSocketBtmRelease(tool, downloadOptions) - logger.info(`Downloaded to ${assetPath}`) - } catch (e) { - // Some assets are optional (models). - if (name === 'models') { - logger.warn(`${name} not available: ${e.message}`) - logger.groupEnd() - return { name, ok: true, skipped: true } - } - throw e - } - - // Process based on asset type. - if (type === 'archive' && extract) { - await extractArchive(assetPath, extract, name) - } - - logger.groupEnd() - logger.success(`${name} extraction complete`) - return { name, ok: true } - } catch (e) { - logger.groupEnd() - logger.error(`Failed to extract ${name}: ${e.message}`) - await logTransientErrorHelp(e) - return { error: e, name, ok: false } - } -} - -/** - * Download multiple assets (parallel by default, sequential opt-in). - * - * Parallel mode is optimized for fast builds. Assets are downloaded - * concurrently and have isolated subdirectories to minimize race conditions. - * - * Use --no-parallel flag for sequential mode if filesystem issues occur. - */ -async function downloadAssets(assetNames, parallel = true) { - if (parallel) { - const settled = await Promise.allSettled( - assetNames.map(name => downloadAsset(ASSETS[name])), - ) - - const failed = settled.filter( - r => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.ok), - ) - if (failed.length > 0) { - logger.error('') - logger.error(`${failed.length} asset(s) failed:`) - for (let i = 0, { length } = failed; i < length; i += 1) { - const r = failed[i] - logger.error( - ` - ${r.status === 'rejected' ? (r.reason?.message ?? r.reason) : r.value.name}`, - ) - } - process.exitCode = 1 - } - } else { - for (let i = 0, { length } = assetNames; i < length; i += 1) { - const name = assetNames[i] - const result = await downloadAsset(ASSETS[name]) - if (!result.ok && !result.skipped) { - process.exitCode = 1 - return - } - } - } -} - -/** - * Extract tar.gz archive. - */ -async function extractArchive(tarGzPath, extractConfig, assetName) { - const { outputDir } = extractConfig - - await fs.mkdir(outputDir, { recursive: true }) - - const versionPath = path.join(outputDir, '.version') - const assetDir = path.dirname(tarGzPath) - const sourceVersionPath = path.join(assetDir, '.version') - - // Get release tag for cache validation. - if (!existsSync(sourceVersionPath)) { - throw new Error( - `Source version file not found: ${sourceVersionPath}. ` + - 'Please download assets first using the build system.', - ) - } - - const tag = (await fs.readFile(sourceVersionPath, 'utf8')).trim() - if (!tag || tag.length === 0) { - throw new Error( - `Invalid version file content at ${sourceVersionPath}. ` + - 'Please re-download assets.', - ) - } - - // Check if already extracted and up to date. - if (existsSync(versionPath)) { - const cachedVersion = await fs.readFile(versionPath, 'utf-8') - if (cachedVersion.trim() === tag) { - logger.info(`${assetName} already up to date`) - return - } - logger.info(`${assetName} out of date, re-extracting...`) - } else { - logger.info(`Extracting ${assetName} (this may take a minute)...`) - } - - // Extract tar.gz using tar command. - const result = await spawn('tar', ['-xzf', tarGzPath, '-C', outputDir], { - stdio: 'inherit', - }) - - if (!result) { - throw new Error('Failed to start tar extraction') - } - - if (result.code !== 0) { - throw new Error(`tar extraction failed with code ${result.code}`) - } - - // Write version file with release tag. - await fs.writeFile(versionPath, tag, 'utf-8') -} - -/** - * Main entry point. - */ -async function main() { - // Skip downloads entirely when SKIP_ASSET_DOWNLOAD is set. - // Useful for repeated local builds where assets are already cached, - // or when GitHub API rate limits are exhausted. - if (process.env.SKIP_ASSET_DOWNLOAD) { - logger.info('Skipping asset downloads (SKIP_ASSET_DOWNLOAD is set)') - return - } - - const args = process.argv.slice(2) - const parallel = !args.includes('--no-parallel') - const assetArgs = args.filter(arg => !arg.startsWith('--')) - - // Determine which assets to download. - const assetNames = assetArgs.length > 0 ? assetArgs : Object.keys(ASSETS) - - // Validate asset names. - for (let i = 0, { length } = assetNames; i < length; i += 1) { - const name = assetNames[i] - if (!(name in ASSETS)) { - logger.error(`Unknown asset: ${name}`) - logger.error(`Available assets: ${Object.keys(ASSETS).join(', ')}`) - process.exitCode = 1 - return - } - } - - await downloadAssets(assetNames, parallel) -} - -// Run if invoked directly. -if (fileURLToPath(import.meta.url) === process.argv[1]) { - main().catch(error => { - logger.error('Asset download failed:', error) - process.exitCode = 1 - }) -} diff --git a/packages/cli/scripts/e2e.mts b/packages/cli/scripts/e2e.mts deleted file mode 100644 index e49e5ce4a..000000000 --- a/packages/cli/scripts/e2e.mts +++ /dev/null @@ -1,182 +0,0 @@ - -/** - * E2E test runner. Options: --js, --sea, --all. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import colors from 'yoctocolors-cjs' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { EnvironmentVariables } from './environment-variables.mts' -import { loadEnvFile } from './util/load-env.mts' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT_DIR = path.resolve(__dirname, '..') -const MONOREPO_ROOT = path.resolve(ROOT_DIR, '../..') -const NODE_MODULES_BIN_PATH = path.join(MONOREPO_ROOT, 'node_modules/.bin') - -const BINARY_PATHS = { - __proto__: null, - js: path.join(ROOT_DIR, 'dist/cli.js'), - sea: path.join(ROOT_DIR, 'dist/sea/socket-sea'), -} - -const BINARY_BUILD_COMMANDS = { - __proto__: null, - js: ['pnpm', '--filter', '@socketsecurity/cli', 'run', 'build:js'], - sea: ['pnpm', '--filter', '@socketsecurity/cli', 'run', 'build:sea'], -} - -const BINARY_FLAGS = { - __proto__: null, - all: { - TEST_SEA_BINARY: '1', - }, - js: {}, - sea: { - TEST_SEA_BINARY: '1', - }, -} - -export async function buildBinary(binaryType) { - const buildCommand = BINARY_BUILD_COMMANDS[binaryType] - if (!buildCommand) { - logger.error('No build command defined for binary type:', binaryType) - return false - } - - logger.log(`${colors.blue('⚙')} Building ${binaryType} binary...`) - logger.log(`${colors.dim(` ${buildCommand.join(' ')}`)}`) - logger.log('') - - try { - const result = await spawn(buildCommand[0], buildCommand.slice(1), { - cwd: MONOREPO_ROOT, - stdio: 'inherit', - }) - - if (result.code !== 0) { - logger.error(`${colors.red('✗')} Failed to build ${binaryType} binary`) - return false - } - - logger.log(`${colors.green('✓')} Successfully built ${binaryType} binary`) - logger.log('') - return true - } catch (e) { - logger.error(`${colors.red('✗')} Error building ${binaryType} binary:`, e) - return false - } -} - -export async function checkBinaryExists(binaryType) { - // For explicit binary requests (js, sea), check and auto-build if needed. - if (binaryType === 'js' || binaryType === 'sea') { - const binaryPath = BINARY_PATHS[binaryType] - if (!existsSync(binaryPath)) { - logger.log('') - logger.warn(`${colors.yellow('⚠')} Binary not found: ${binaryPath}`) - logger.log('') - - // Auto-build (builds are fast using prebuilt binaries + binject). - logger.log('Auto-building missing binary...') - const buildSuccess = await buildBinary(binaryType) - - if (!buildSuccess || !existsSync(binaryPath)) { - logger.error(`${colors.red('✗')} Failed to build ${binaryType} binary`) - logger.log('To build manually, run:') - logger.log(` ${BINARY_BUILD_COMMANDS[binaryType].join(' ')}`) - logger.log('') - return false - } - } - logger.log(`${colors.green('✓')} Binary found: ${binaryPath}`) - logger.log('') - } - - // For 'all', we'll skip missing binaries (handled by test suite). - return true -} - -export async function runVitest(binaryType) { - const envVars = BINARY_FLAGS[binaryType] - logger.log( - `${colors.blue('ℹ')} Running e2e tests for ${binaryType} binary...`, - ) - logger.log('') - - // Check if binary exists when explicitly requested. - const binaryExists = await checkBinaryExists(binaryType) - if (!binaryExists) { - throw new Error('Binary not found') - } - - // Load external tool versions for INLINED_* env vars. - // This is required for tests to load external tool versions (coana, cdxgen, synp, etc). - const externalToolVersions = EnvironmentVariables.getTestVariables() - - // Load .env.e2e configuration (falls back gracefully if missing). - const e2eEnv = loadEnvFile(path.join(ROOT_DIR, '.env.e2e')) - - // Resolve vitest path. - const vitestCmd = WIN32 ? 'vitest.cmd' : 'vitest' - const vitestPath = path.join(NODE_MODULES_BIN_PATH, vitestCmd) - - const result = await spawn( - vitestPath, - [ - 'run', - 'test/e2e/binary-test-suite.e2e.test.mts', - '--config', - 'vitest.e2e.config.mts', - ], - { - cwd: ROOT_DIR, - env: { - ...e2eEnv, - ...process.env, - // Automatically enable tests when explicitly running e2e.mts. - RUN_E2E_TESTS: '1', - // Load external tool versions (INLINED_* env vars). - ...externalToolVersions, - // Binary-specific test flags. - ...envVars, - }, - stdio: 'inherit', - }, - ) - - // Pass through vitest's exit code to signal test success/failure to CI. - process.exitCode = result.code ?? 0 -} - -async function main() { - const args = process.argv.slice(2) - const flag = args.find(arg => arg.startsWith('--'))?.slice(2) - - if (!flag || !BINARY_FLAGS[flag]) { - logger.error('Invalid or missing flag') - logger.log('') - logger.log('Usage:') - logger.log(' node scripts/e2e.mts --js # Test JS binary') - logger.log(' node scripts/e2e.mts --sea # Test SEA binary') - logger.log(' node scripts/e2e.mts --all # Test all binaries') - logger.log('') - throw new Error('Invalid or missing flag') - } - - await runVitest(flag) -} - -main().catch(e => { - logger.error('E2E test runner failed:', e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/environment-variables.mts b/packages/cli/scripts/environment-variables.mts deleted file mode 100644 index 6218f53a3..000000000 --- a/packages/cli/scripts/environment-variables.mts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * @file Unified environment variable management for Socket CLI builds and - * tests. Single source of truth for all inlined environment variables. This - * module consolidates environment variable loading that was previously - * duplicated between: - * - * - esbuild-utils.mts (full build-time inlining with 18 variables) - * - test-wrapper.mts (partial test environment with 4 variables) Usage: import - * { EnvironmentVariables } from './environment-variables.mts' const vars = - * EnvironmentVariables.load() const defines = - * EnvironmentVariables.getDefineEntries(vars) const testVars = - * EnvironmentVariables.getTestVariables(vars) - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { readFileSync } from 'node:fs' -import path from 'node:path' -import crypto from 'node:crypto' -import { fileURLToPath } from 'node:url' - -import { getPackageOutDir } from 'package-builder/scripts/paths.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -/** - * Environment variables manager for Socket CLI. Provides unified loading of - * build-time and test-time environment variables. - */ -export class EnvironmentVariables { - /** - * Load all inlined environment variables with their raw values. This is the - * single source of truth for all environment variable data. - * - * @returns {Object} Object with all environment variable values (not - * JSON-stringified) - */ - static load() { - // Read package.json for metadata. - const packageJson = JSON.parse( - readFileSync(path.join(rootPath, 'package.json'), 'utf-8'), - ) - - // Read version from socket package (the published package). - // Uses centralized paths from package-builder. - const socketPackageJson = JSON.parse( - readFileSync(path.join(getPackageOutDir('cli'), 'package.json'), 'utf-8'), - ) - - // Get current git commit hash. - let gitHash = '' - try { - const r = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { - cwd: rootPath, - stdio: 'pipe', - stdioString: true, - }) - if (r.status === 0 && typeof r.stdout === 'string') { - gitHash = r.stdout.trim() - } - } catch {} - - // Get external tool versions from bundle-tools.json. - const externalTools = JSON.parse( - readFileSync(path.join(rootPath, 'bundle-tools.json'), 'utf-8'), - ) - - /** - * Helper to get external tool version with validation. - */ - function getExternalToolVersion(key: string, field = 'version') { - const tool = externalTools[key] - if (!tool) { - throw new Error( - `External tool "${key}" not found in bundle-tools.json. Please add it to the configuration.`, - ) - } - const value = tool[field] - if (!value) { - throw new Error( - `External tool "${key}" is missing required field "${field}" in bundle-tools.json.`, - ) - } - return value - } - - // npm packages use 'version' field. - const cdxgenVersion = getExternalToolVersion('@cyclonedx/cdxgen') - const coanaVersion = getExternalToolVersion('@coana-tech/cli') - const synpVersion = getExternalToolVersion('synp') - // pypi packages use 'version' field. - const pyCliVersion = getExternalToolVersion('socketsecurity') - // GitHub-released tools use 'version' field (release tag, any format). - const opengrepVersion = getExternalToolVersion('opengrep') - const pythonBuildTag = getExternalToolVersion('python', 'tag') - const pythonVersion = getExternalToolVersion('python') - const socketPatchVersion = getExternalToolVersion('socket-patch') - const trivyVersion = getExternalToolVersion('trivy') - const trufflehogVersion = getExternalToolVersion('trufflehog') - // sfw uses both: GitHub binary for SEA, npm package for CLI. - const sfwVersion = getExternalToolVersion('sfw') - const sfwNpmVersion = externalTools['sfw']?.npm?.version - if (!sfwNpmVersion) { - throw new Error( - 'External tool "sfw" is missing required field "npm.version" in bundle-tools.json.', - ) - } - - // Build-time constants that can be overridden by environment variables. - const publishedBuild = process.env['INLINED_PUBLISHED_BUILD'] === '1' - const sentryBuild = process.env['INLINED_SENTRY_BUILD'] === '1' - - // Compute version hash (matches Rollup implementation). - const randUuidSegment = crypto.randomUUID().split('-')[0] - const versionHash = `${packageJson.version}:${gitHash}:${randUuidSegment}${ - publishedBuild ? '' : ':dev' - }` - - // Get checksums for all external tools that have them. - // GitHub-released tools and PyPI packages have checksums for integrity verification. - const opengrepChecksums = externalTools.opengrep?.checksums || {} - const pythonChecksums = externalTools.python?.checksums || {} - const sfwChecksums = externalTools.sfw?.checksums || {} - const socketPatchChecksums = externalTools['socket-patch']?.checksums || {} - const pyCliChecksums = externalTools.socketsecurity?.checksums || {} - const trivyChecksums = externalTools.trivy?.checksums || {} - const trufflehogChecksums = externalTools.trufflehog?.checksums || {} - - // Return all environment variables with raw values. - return { - INLINED_CDXGEN_VERSION: cdxgenVersion, - INLINED_COANA_VERSION: coanaVersion, - INLINED_CYCLONEDX_CDXGEN_VERSION: cdxgenVersion, - INLINED_HOMEPAGE: packageJson.homepage, - INLINED_NAME: packageJson.name, - INLINED_OPENGREP_CHECKSUMS: JSON.stringify(opengrepChecksums), - INLINED_OPENGREP_VERSION: opengrepVersion, - INLINED_PUBLISHED_BUILD: publishedBuild ? '1' : '', - INLINED_PYCLI_VERSION: pyCliVersion, - INLINED_PYTHON_BUILD_TAG: pythonBuildTag, - INLINED_PYTHON_CHECKSUMS: JSON.stringify(pythonChecksums), - INLINED_PYTHON_VERSION: pythonVersion, - INLINED_SENTRY_BUILD: sentryBuild ? '1' : '', - INLINED_SFW_CHECKSUMS: JSON.stringify(sfwChecksums), - INLINED_SFW_NPM_VERSION: sfwNpmVersion, - INLINED_SFW_VERSION: sfwVersion, - INLINED_SOCKET_PATCH_CHECKSUMS: JSON.stringify(socketPatchChecksums), - INLINED_SOCKET_PATCH_VERSION: socketPatchVersion, - INLINED_PYCLI_CHECKSUMS: JSON.stringify(pyCliChecksums), - INLINED_SYNP_VERSION: synpVersion, - INLINED_TRIVY_CHECKSUMS: JSON.stringify(trivyChecksums), - INLINED_TRIVY_VERSION: trivyVersion, - INLINED_TRUFFLEHOG_CHECKSUMS: JSON.stringify(trufflehogChecksums), - INLINED_TRUFFLEHOG_VERSION: trufflehogVersion, - INLINED_VERSION: socketPackageJson.version, - INLINED_VERSION_HASH: versionHash, - } - } - - /** - * Load external tool versions with error handling (for test environment). - * This is a safe subset that won't throw if files are missing. - * - * @returns {Object} Object with tool versions or empty object if loading - * fails. - */ - static loadSafe() { - try { - const externalTools = JSON.parse( - readFileSync(path.join(rootPath, 'bundle-tools.json'), 'utf-8'), - ) - return { - INLINED_COANA_VERSION: externalTools['@coana-tech/cli']?.version || '', - INLINED_PYCLI_VERSION: externalTools.socketsecurity?.version || '', - INLINED_SFW_NPM_VERSION: externalTools.sfw?.npm?.version || '', - INLINED_SFW_VERSION: externalTools.sfw?.version || '', - INLINED_SOCKET_PATCH_VERSION: - externalTools['socket-patch']?.version || '', - } - } catch { - return {} - } - } - - /** - * Get environment variables formatted for esbuild define option. All values - * are JSON-stringified for esbuild compatibility. - * - * @param {Object} [vars] - Pre-loaded variables (optional, will load if not - * provided) - * - * @returns {Record<string, string>} Object with env var names as keys and - * JSON-stringified values. - */ - static getDefineEntries(vars?: Record<string, string>) { - const envVars = vars || EnvironmentVariables.load() - - // Convert all values to JSON-stringified format for esbuild. - const defines: Record<string, string> = {} - for (const [key, value] of Object.entries(envVars)) { - defines[key] = JSON.stringify(value) - } - return defines - } - - /** - * Get subset of environment variables needed for test environment. Returns - * only the tool versions needed by tests, with safe loading. - * - * @returns {Object} Object with test environment variables - */ - static getTestVariables() { - return EnvironmentVariables.loadSafe() - } -} diff --git a/packages/cli/scripts/generate-packages.mts b/packages/cli/scripts/generate-packages.mts deleted file mode 100644 index 6b14e88b8..000000000 --- a/packages/cli/scripts/generate-packages.mts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Generate template-based packages required for CLI build. Runs the package - * generation scripts from package-builder. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageBuilderScripts = path.resolve( - __dirname, - '../../package-builder/scripts', -) - -const scripts = [ - path.join(packageBuilderScripts, 'generate-cli-packages.mts'), - path.join(packageBuilderScripts, 'generate-socketbin-packages.mts'), -] - -for (let i = 0, { length } = scripts; i < length; i += 1) { - const script = scripts[i] - const result = await spawn('node', [script], { stdio: 'inherit' }) - - if (!result) { - process.exitCode = 1 - throw new Error(`Failed to start script: ${script}`) - } - - if (result.code !== 0) { - // Use nullish coalescing to handle signal-killed processes (code is null). - process.exitCode = result.code ?? 1 - throw new Error( - `Package generation failed for ${script} with exit code ${result.code}`, - ) - } -} diff --git a/packages/cli/scripts/integration.mts b/packages/cli/scripts/integration.mts deleted file mode 100644 index b46f24f6a..000000000 --- a/packages/cli/scripts/integration.mts +++ /dev/null @@ -1,144 +0,0 @@ - -/** - * Integration test runner. Options: --js, --sea, --all. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import colors from 'yoctocolors-cjs' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { EnvironmentVariables } from './environment-variables.mts' -import { loadEnvFile } from './util/load-env.mts' - -const logger = getDefaultLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT_DIR = path.resolve(__dirname, '..') -const MONOREPO_ROOT = path.resolve(ROOT_DIR, '../..') -const NODE_MODULES_BIN_PATH = path.join(MONOREPO_ROOT, 'node_modules/.bin') - -const BINARY_PATHS = { - __proto__: null, - js: path.join(ROOT_DIR, 'dist/index.js'), - sea: path.join(ROOT_DIR, 'dist/sea/socket-sea'), -} - -const BINARY_FLAGS = { - __proto__: null, - all: { - TEST_JS_BINARY: '1', - TEST_SEA_BINARY: '1', - }, - js: { - TEST_JS_BINARY: '1', - }, - sea: { - TEST_SEA_BINARY: '1', - }, -} - -export async function checkBinaryExists(binaryType) { - // For explicit binary requests (js, sea), require binary to exist. - if (binaryType === 'js' || binaryType === 'sea') { - const binaryPath = BINARY_PATHS[binaryType] - if (!existsSync(binaryPath)) { - logger.error(`${colors.red('✗')} Binary not found: ${binaryPath}`) - logger.log('') - logger.log('The binary must be built before running integration tests.') - logger.log('Build commands:') - if (binaryType === 'js') { - logger.log(' pnpm run build') - } else if (binaryType === 'sea') { - logger.log(' pnpm --filter @socketsecurity/cli run build:sea') - } - logger.log('') - return false - } - logger.log(`${colors.green('✓')} Binary found: ${binaryPath}`) - logger.log('') - } - - // For 'all', we'll skip missing binaries (handled by test suite). - return true -} - -export async function runVitest(binaryType) { - const envVars = BINARY_FLAGS[binaryType] - logger.log( - `${colors.blue('ℹ')} Running distribution integration tests for ${binaryType}...`, - ) - logger.log('') - - // Check if binary exists when explicitly requested. - const binaryExists = await checkBinaryExists(binaryType) - if (!binaryExists) { - process.exitCode = 1 - return - } - - // Load .env.test configuration. - const testEnv = loadEnvFile(path.join(ROOT_DIR, '.env.test')) - - // Resolve vitest path. - const vitestCmd = WIN32 ? 'vitest.cmd' : 'vitest' - const vitestPath = path.join(NODE_MODULES_BIN_PATH, vitestCmd) - - // Load external tool versions for INLINED_* env vars. - const externalToolVersions = EnvironmentVariables.getTestVariables() - - const result = await spawn( - vitestPath, - [ - 'run', - 'test/integration/binary/', - '--config', - 'vitest.integration.config.mts', - ], - { - cwd: ROOT_DIR, - env: { - ...testEnv, - ...process.env, - // Automatically enable tests when explicitly running integration.mts. - RUN_INTEGRATION_TESTS: '1', - // Inject external tool versions (normally inlined at build time). - ...externalToolVersions, - ...envVars, - }, - stdio: 'inherit', - }, - ) - - process.exitCode = result.code ?? 0 -} - -async function main() { - const args = process.argv.slice(2) - const flag = args.find(arg => arg.startsWith('--'))?.slice(2) - - if (!flag || !BINARY_FLAGS[flag]) { - logger.error('Invalid or missing flag') - logger.log('') - logger.log('Usage:') - logger.log(' node scripts/integration.mts --js # Test JS distribution') - logger.log(' node scripts/integration.mts --sea # Test SEA binary') - logger.log( - ' node scripts/integration.mts --all # Test all distributions', - ) - logger.log('') - process.exitCode = 1 - return - } - - await runVitest(flag) -} - -main().catch(e => { - logger.error('Integration test runner failed:', e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/load.mts b/packages/cli/scripts/load.mts deleted file mode 100644 index bfd1fd6c1..000000000 --- a/packages/cli/scripts/load.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file ESM loader stub for CLI build scripts. This file is used with --import - * flag for Node.js module loading. Previously handled local package aliasing, - * now isolated to use published packages only. Usage: node - * --import=./scripts/load.mts script.mts. - */ - -// Export a no-op resolve function for compatibility. -// Node.js --import expects this export to exist. -export function resolve(specifier, context, nextResolve) { - // Pass through to default resolver - no custom aliasing. - return nextResolve(specifier, context) -} diff --git a/packages/cli/scripts/paths.mts b/packages/cli/scripts/paths.mts deleted file mode 100644 index 45cf60dc4..000000000 --- a/packages/cli/scripts/paths.mts +++ /dev/null @@ -1,25 +0,0 @@ -export * from '../../../scripts/paths.mts' - -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { existsSync } from 'node:fs' - -function resolvePackageRoot(): string { - let cur = path.dirname(fileURLToPath(import.meta.url)) - const root = path.parse(cur).root - while (cur && cur !== root) { - if (existsSync(path.join(cur, 'package.json'))) { - return cur - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - throw new Error( - `Could not resolve package root from ${fileURLToPath(import.meta.url)}.`, - ) -} - -export const PACKAGE_ROOT = resolvePackageRoot() diff --git a/packages/cli/scripts/restore-cache.mts b/packages/cli/scripts/restore-cache.mts deleted file mode 100644 index f199b2e90..000000000 --- a/packages/cli/scripts/restore-cache.mts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * @file Restore build artifacts from GitHub Actions cache. This is a - * nice-to-have optimization that speeds up first build after clone. Usage: - * node scripts/restore-cache.mts [options] Options: --quiet Suppress progress - * output. --verbose Show detailed output. Requirements: - * - * - gh CLI must be installed (https://cli.github.com/). - * - Must be in a git repository. - * - Must have network access to GitHub. Behavior: - * - Checks if build artifacts already exist (skip if present). - * - Computes cache key for current commit. - * - Attempts to download matching cache from GitHub Actions. - * - Silently fails if cache not available (no harm, no foul). - * - Extracts cache to packages/cli/build/ and packages/cli/dist/. - */ - -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageRoot = path.resolve(__dirname, '..') -const repoRoot = path.resolve(__dirname, '../../..') - -const isQuiet = () => process.argv.includes('--quiet') -const isVerbose = () => process.argv.includes('--verbose') - -/** - * Check if cache exists in GitHub Actions. - */ -async function cacheExists(repo, cacheKey) { - try { - const result = await spawn( - 'gh', - [ - 'cache', - 'list', - '--repo', - repo, - '--key', - `cli-build-Linux-${cacheKey}`, - '--json', - 'key', - ], - { - cwd: repoRoot, - stdio: 'pipe', - }, - ) - if (result.code !== 0) { - return false - } - - // Validate stdout before parsing. - if (!result.stdout || result.stdout.trim().length === 0) { - return false - } - - const caches = JSON.parse(result.stdout) - return Array.isArray(caches) && caches.length > 0 - } catch { - return false - } -} - -/** - * Generate CLI build cache key (matches CI workflow). - */ -async function generateCacheKey() { - const pnpmLockHash = await hashFile(path.join(repoRoot, 'pnpm-lock.yaml')) - const srcHash = await hashFiles('packages/cli/src', repoRoot) - const configHash = await hashFiles( - 'packages/cli/.config packages/cli/scripts', - repoRoot, - ) - const combined = `${pnpmLockHash}-${srcHash}-${configHash}` - return createHash('sha256').update(combined).digest('hex') -} - -/** - * Get current git commit SHA. - */ -async function getCurrentCommit() { - try { - const result = await spawn('git', ['rev-parse', 'HEAD'], { - cwd: repoRoot, - stdio: 'pipe', - }) - if (!result || result.code !== 0) { - return undefined - } - return result.stdout.trim() - } catch { - return undefined - } -} - -/** - * Check if gh CLI is available. - */ -async function hasGhCli() { - try { - const result = await spawn('gh', ['--version'], { - stdio: 'pipe', - }) - return result !== null && result.code === 0 - } catch { - return false - } -} - -/** - * Compute hash of file. - */ -async function hashFile(filePath) { - try { - const content = await fs.readFile(filePath, 'utf8') - return createHash('sha256').update(content).digest('hex') - } catch { - return 'none' - } -} - -/** - * Compute hash of all files matching glob pattern. - */ -async function hashFiles(globPattern, cwd) { - try { - const result = await spawn( - 'find', - globPattern - .split(' ') - .concat([ - '-type', - 'f', - '!', - '-path', - '*/node_modules/*', - '!', - '-path', - '*/dist/*', - '!', - '-path', - '*/build/*', - ]), - { - cwd, - stdio: 'pipe', - }, - ) - if (result.code !== 0) { - return 'none' - } - const files = result.stdout.split('\n').filter(Boolean).sort() - if (!files.length) { - return 'none' - } - const hash = createHash('sha256') - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - const content = await fs.readFile(path.join(cwd, file), 'utf8') - hash.update(content) - } - return hash.digest('hex') - } catch { - return 'none' - } -} - -/** - * Download and extract cache from GitHub Actions. - */ -async function restoreCache(repo, cacheKey) { - const tempDir = path.join(packageRoot, 'node_modules', '.cache', 'restore') - await fs.mkdir(tempDir, { recursive: true }) - - try { - // Note: gh cache download is not yet available. - // We'll use the gh actions cache download API instead. - logger.info('Downloading cache from GitHub Actions...') - - // For now, we use gh api to download the cache. - const result = await spawn( - 'gh', - [ - 'api', - `/repos/${repo}/actions/cache`, - '-H', - 'Accept: application/vnd.github+json', - '--jq', - `.actions_caches[] | select(.key == "cli-build-Linux-${cacheKey}") | .id`, - ], - { - cwd: repoRoot, - stdio: 'pipe', - }, - ) - - if (result.code !== 0 || !result.stdout.trim()) { - logger.warn('Cache ID not found.') - return false - } - - const cacheId = result.stdout.trim() - - // Download cache archive. - const downloadResult = await spawn( - 'gh', - [ - 'api', - `/repos/${repo}/actions/caches/${cacheId}/download`, - '-H', - 'Accept: application/octet-stream', - ], - { - cwd: repoRoot, - stdio: 'pipe', - }, - ) - - if (downloadResult.code !== 0) { - logger.warn('Failed to download cache archive.') - return false - } - - // Extract cache (GitHub Actions uses tar + zstd). - const cacheArchive = path.join(tempDir, 'cache.tar.zst') - await fs.writeFile( - cacheArchive, - Buffer.from(downloadResult.stdout, 'binary'), - ) - - // Extract with tar. - const extractResult = await spawn( - 'tar', - ['-xf', cacheArchive, '-C', packageRoot], - { - cwd: tempDir, - stdio: 'pipe', - }, - ) - - if (extractResult.code !== 0) { - logger.warn('Failed to extract cache archive.') - return false - } - - logger.success('Cache restored successfully!') - return true - } catch (e) { - if (isVerbose()) { - logger.error(`Cache restoration failed: ${e.message}`) - } - return false - } finally { - // Clean up temp directory. - await safeDelete(tempDir) - } -} - -/** - * Main entry point. - */ -async function main() { - if (!isQuiet()) { - logger.log('') - logger.log('CLI Build Cache Restoration') - logger.log('===========================') - logger.log('') - } - - // Check if build artifacts already exist. - const buildDir = path.join(packageRoot, 'build') - const distDir = path.join(packageRoot, 'dist') - - if (existsSync(buildDir) && existsSync(distDir)) { - if (!isQuiet()) { - logger.info('Build artifacts already exist, skipping cache restoration.') - } - return 0 - } - - // Check if gh CLI is available. - if (!(await hasGhCli())) { - if (!isQuiet()) { - logger.info('gh CLI not found (optional dependency).') - logger.info('Install from: https://cli.github.com/') - } - return 0 - } - - // Get current commit. - const commit = await getCurrentCommit() - if (!commit) { - if (!isQuiet()) { - logger.info('Not in a git repository, skipping cache restoration.') - } - return 0 - } - - if (!isQuiet()) { - logger.step(`Current commit: ${commit.slice(0, 8)}`) - } - - // Generate cache key. - const cacheKey = await generateCacheKey() - if (!isQuiet()) { - logger.step(`Cache key: cli-build-Linux-${cacheKey.slice(0, 16)}...`) - } - - // Get repository name. - const repoResult = await spawn( - 'git', - ['config', '--get', 'remote.origin.url'], - { - cwd: repoRoot, - stdio: 'pipe', - }, - ) - if (repoResult.code !== 0) { - if (!isQuiet()) { - logger.info('Could not determine repository, skipping cache restoration.') - } - return 0 - } - - const repoUrl = repoResult.stdout.trim() - const repoMatch = repoUrl.match(/github\.com[/:](.+?)(?:\.git)?$/) - if (!repoMatch) { - if (!isQuiet()) { - logger.info('Not a GitHub repository, skipping cache restoration.') - } - return 0 - } - - const repo = repoMatch[1] - if (!isQuiet()) { - logger.step(`Repository: ${repo}`) - } - - // Check if cache exists. - if (!isQuiet()) { - logger.step('Checking if cache exists...') - } - - if (!(await cacheExists(repo, cacheKey))) { - if (!isQuiet()) { - logger.info('Cache not found for this commit.') - logger.info('This is normal for first-time builds or new commits.') - } - return 0 - } - - // Restore cache. - if (!isQuiet()) { - logger.step('Restoring cache...') - } - - const success = await restoreCache(repo, cacheKey) - if (!success) { - if (!isQuiet()) { - logger.warn('Cache restoration failed, will build from scratch.') - } - return 0 - } - - if (!isQuiet()) { - logger.log('') - logger.success('Build cache restored! Builds will be much faster.') - logger.log('') - } - - return 0 -} - -main() - .then(code => { - process.exitCode = code - }) - .catch(error => { - logger.error(error.message) - if (isVerbose()) { - logger.error(error.stack) - } - process.exitCode = 1 - }) diff --git a/packages/cli/scripts/rolldown-utils.mts b/packages/cli/scripts/rolldown-utils.mts deleted file mode 100644 index 10560d8c9..000000000 --- a/packages/cli/scripts/rolldown-utils.mts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Shared rolldown utilities for Socket CLI builds. Helpers for environment - * variable inlining, build metadata, and the post-write text transforms - * (unicode property escapes + env-var replacement) that run over the emitted - * bundle. Replaces the esbuild equivalents (fleet "Tooling" rule: bundler = - * rolldown). - */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { transformUnicodePropertyEscapes } from 'build-infra/lib/unicode-property-escape-transform' - -import { EnvironmentVariables } from './environment-variables.mts' - -import type { RolldownOptions } from 'rolldown' - -const logger = getDefaultLogger() - -/** - * Settings every Socket CLI rolldown config shares. Kept in one place so the - * target/format/minify defaults can't drift between the index loader and the - * main CLI bundle. Callers spread this and add variant-specific fields (input, - * output file, banner, plugins, extra defines). - * - * `transform.define` covers the static `process.env.X` reads; the post-write - * env-var pass in `runBuild` catches the mangled `<id>.env["X"]` forms the - * static define misses (same two-layer approach esbuild used). - */ -export function createBaseConfig( - inlinedEnvVars: Record<string, string>, -): RolldownOptions { - return { - platform: 'node', - transform: { - define: { - 'process.env.NODE_ENV': '"production"', - ...createDefineEntries(inlinedEnvVars), - }, - }, - } -} - -/** - * Dot-notation define keys only. Unlike esbuild, rolldown's oxc define rejects - * bracket-notation keys (`process.env["KEY"]` → INVALID_DEFINE_CONFIG) — it - * requires identifier-shaped keys. The dotted `process.env.KEY` form is AST- - * aware and matches both `.KEY` and `["KEY"]` reads; the mangled - * `<id>.env["KEY"]` forms the static define can't reach are handled by the - * post-write `applyEnvVarReplacement` pass. - */ -function createDefineEntries(envVars: Record<string, string>) { - const entries: Record<string, string> = {} - for (const { 0: key, 1: value } of Object.entries(envVars)) { - entries[`process.env.${key}`] = value - } - return entries -} - -/** - * Standard index loader config. - */ -export function createIndexConfig({ - entryPoint, - outfile, -}: { - entryPoint: string - outfile: string -}): RolldownOptions { - const inlinedEnvVars = getInlinedEnvVars() - const base = createBaseConfig(inlinedEnvVars) - return { - ...base, - input: entryPoint, - output: { - file: outfile, - format: 'cjs', - minify: false, - sourcemap: false, - banner: '#!/usr/bin/env node', - }, - } -} - -/** - * Replace env vars in built output that survived the static define (handles - * mangled identifiers like `import_node_process21.default.env["KEY"]`). - * Operates on the written file text — the post-bundle counterpart of esbuild's - * onEnd buffer mutation. - */ -export function applyEnvVarReplacement( - content: string, - envVars: Record<string, string>, -): string { - let next = content - for (const { 0: key, 1: value } of Object.entries(envVars)) { - const dq = new RegExp(`(\\w+\\.)+env\\["${key}"\\]`, 'g') - const sq = new RegExp(`(\\w+\\.)+env\\['${key}'\\]`, 'g') - next = next.replace(dq, value).replace(sq, value) - } - return next -} - -/** - * Get all inlined environment variables with their JSON-stringified values. - */ -export function getInlinedEnvVars() { - return EnvironmentVariables.getDefineEntries() -} - -interface RunBuildOptions { - // Post-write transforms applied to the emitted output text, in order. The - // unicode-property-escape transform + env-var replacement run here because - // rolldown (like esbuild) can't express them as a pure config option. - envVars?: Record<string, string> | undefined - unicodeTransform?: boolean | undefined -} - -/** - * Run a rolldown config, then apply the post-write text transforms to the - * emitted file. Mirrors esbuild's `write: false` + manual-write flow, but - * rolldown writes the file and we re-read / transform / re-write it. - */ -export async function runBuild( - config: RolldownOptions, - description = 'Build', - options: RunBuildOptions = {}, -): Promise<void> { - const { rolldown } = await import('rolldown') - const { envVars, unicodeTransform = false } = options - try { - if (description) { - logger.info(`Building: ${description}`) - } - const { output, ...inputOptions } = config - if (!output || Array.isArray(output)) { - throw new Error('Expected a single output config') - } - const bundle = await rolldown(inputOptions) - try { - await bundle.write(output) - } finally { - await bundle.close() - } - - // Post-write transforms over the emitted file (unicode escapes first, then - // env-var replacement — order matches the esbuild plugin chain). - const outFile = (output as { file?: string | undefined }).file - if (outFile && (unicodeTransform || envVars)) { - let content = readFileSync(outFile, 'utf8') - if (unicodeTransform) { - content = transformUnicodePropertyEscapes(content) - } - if (envVars) { - content = applyEnvVarReplacement(content, envVars) - } - mkdirSync(path.dirname(outFile), { recursive: true }) - writeFileSync(outFile, content) - } - - if (description) { - logger.success(`${description} complete`) - } - } catch (e) { - logger.error(`Build failed: ${description || 'Unknown'}`) - logger.error(e) - process.exitCode = 1 - throw e - } -} diff --git a/packages/cli/scripts/sea-build-utils/builder.mts b/packages/cli/scripts/sea-build-utils/builder.mts deleted file mode 100644 index 318abc5ea..000000000 --- a/packages/cli/scripts/sea-build-utils/builder.mts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @file SEA binary builder - configuration, blob generation, and injection. - * Consolidated module for all SEA (Single Executable Application) build - * operations. Sections: - * - * 1. SEA Configuration Generation - Creates sea-config.json files. - * 2. SEA Blob Generation - Builds blobs from configuration files. - * 3. Binary Injection - Injects blobs and VFS into Node.js binaries using - * binject. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { - detectMusl, - downloadBinject, - getLatestBinjectVersion, -} from '../util/asset-manager-compat.mts' -import { getRootPath, logger } from './downloads.mts' -import { SOCKET_CLI_SEA_BUILD_DIR } from '../constants/paths.mts' - -// ============================================================================= -// Section 1: SEA Configuration Generation. -// ============================================================================= - -// c8 ignore start -/** - * Generate SEA configuration file for Node.js single executable application. - * Creates sea-config-{name}.json with blob output path and settings. - * - * Configuration includes: - Entry point (main file to bundle). - Output blob - * path. - Code cache enabled for optimization. - Snapshot disabled for - * compatibility. - No bundled assets (minimizes size). - * - * @example - * const configPath = await generateSeaConfig( - * '/path/to/dist/cli.js', - * '/path/to/socket-darwin-arm64', - * ) - * // Returns: /path/to/sea-config-socket-darwin-arm64.json - * - * @param {string} entryPoint - Absolute path to the entry point file. - * @param {string} outputPath - Absolute path to the output binary. - * - * @returns Promise resolving to absolute path of generated config file. - */ -export async function generateSeaConfig(entryPoint, outputPath) { - const outputName = path.basename(outputPath, path.extname(outputPath)) - const configDir = path.dirname(outputPath) - const configPath = normalizePath( - path.join(configDir, `sea-config-${outputName}.json`), - ) - // Use relative paths in sea-config.json (binject requires relative paths). - const blobPathRelative = `sea-blob-${outputName}.blob` - const mainPathRelative = path.relative(configDir, entryPoint) - - const config = { - // No assets to minimize size. - assets: {}, - disableExperimentalSEAWarning: true, - main: mainPathRelative, - output: blobPathRelative, - // Enable code cache for ~13% faster startup (~22ms improvement). - // Pre-compiles JavaScript code during build time for instant execution. - useCodeCache: true, - // Disable snapshots - incompatible with socket-cli's environment variable architecture. - // socket-cli accesses ~70 env vars at module load time (HOME, SOCKET_CLI_API_TOKEN, etc.). - // Snapshots would freeze build-time env values, breaking runtime configuration. - // Code cache + bundling provides ~25-30% startup improvement without restrictions. - useSnapshot: false, - // Update configuration for built-in update checking. - // The node-smol C stub will check for updates on exit and display notifications. - updateConfig: { - // Check GitHub releases API for socket-cli releases. - checkIntervalSeconds: 86400, - tagPrefix: 'socket-cli-', - url: 'https://api.github.com/repos/SocketDev/socket-cli/releases', - }, - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - return configPath -} -// c8 ignore stop - -// ============================================================================= -// Section 2: SEA Blob Generation (handled by binject). -// ============================================================================= - -// Blob generation is now handled automatically by binject when --sea points to -// a .json config file. The previous buildSeaBlob() function has been removed -// because binject can generate the blob using the target binary's Node.js version, -// which is critical for useCodeCache support (code cache is version-specific). -// -// This eliminates the Node.js version mismatch issue where we were using the host -// Node.js to generate blobs for node-smol targets with different Node.js versions. -// -// See injectSeaBlob() below for the config-based blob generation implementation. - -// ============================================================================= -// Section 3: Binary Injection. -// ============================================================================= - -/** - * Inject SEA blob and optional VFS assets into a Node.js binary using binject. - * - * This function performs the core SEA binary build step by: - * - * 1. Invoking binject to inject the SEA blob into the Node.js binary. - * 2. Optionally embedding security tools via VFS compression (binject --vfs). - * - * Config-Based Blob Generation: Instead of pre-generating the SEA blob with - * `node --experimental-sea-config`, binject reads the sea-config.json directly - * and generates the blob automatically. This simplifies the API and reduces - * build steps. - * - * VFS Compression (Optional): If vfsTarGz is provided, binject's --vfs flag - * embeds the compressed tar.gz of security tools into the binary. This achieves - * ~70% compression compared to Node.js SEA assets. If vfsTarGz is omitted, - * --vfs-compat mode is used (no actual VFS bundling). - * - * @example - * await injectSeaBlob( - * 'build-infra/build/downloaded/node-smol/darwin-arm64/node', - * 'dist/sea/sea-config-socket-darwin-arm64.json', - * 'dist/sea/socket-darwin-arm64', - * 'socket-darwin-arm64-abc123', - * 'build-infra/build/external-tools/darwin-arm64.tar.gz', - * ) - * // Creates: dist/sea/socket-darwin-arm64 with CLI + compressed VFS - * - * @example - * await injectSeaBlob( - * 'build-infra/build/downloaded/node-smol/linux-x64/node', - * 'dist/sea/sea-config-socket-linux-x64.json', - * 'dist/sea/socket-linux-x64', - * 'socket-linux-x64-abc123', - * ) - * // Creates: dist/sea/socket-linux-x64 with CLI only (no VFS) - * - * @param {string} nodeBinary - Path to the node-smol binary to inject into. - * @param {string} configPath - Path to the sea-config.json file for - * config-based blob generation. - * @param {string} outputPath - Path to the output SEA binary (may be same as - * nodeBinary). - * @param {string} cacheId - Unique cache identifier for parallel builds - * (prevents interference). - * @param {string} [vfsTarGz] - Optional path to tar.gz file containing security - * tools for VFS bundling. If provided, security tools are compressed and - * embedded in the binary. If omitted, only the CLI code is bundled (no - * additional tools). - * - * @returns Promise that resolves when injection completes. - */ -export async function injectSeaBlob( - nodeBinary, - configPath, - outputPath, - cacheId, - vfsTarGz, -) { - // Get or download binject binary. - let binjectVersion - try { - binjectVersion = await getLatestBinjectVersion() - } catch (e) { - // If we can't fetch the latest version, check if we have a cached version. - const platform = process.platform - const arch = process.arch - // Detect actual libc on Linux (musl for Alpine, glibc for standard distros). - const muslSuffix = detectMusl() ? '-musl' : '' - const platformArch = `${platform}-${arch}${muslSuffix}` - const rootPath = getRootPath() - const binjectDir = normalizePath( - path.join( - rootPath, - `packages/build-infra/build/downloaded/binject/${platformArch}`, - ), - ) - const versionPath = normalizePath(path.join(binjectDir, '.version')) - - if (existsSync(versionPath)) { - const versionContent = (await fs.readFile(versionPath, 'utf8')).trim() - if (!versionContent) { - throw new Error( - `Cached binject version file is empty at ${versionPath}. ` + - 'Please delete the cache directory and try again.', - { cause: e }, - ) - } - binjectVersion = versionContent - logger.warn('Failed to fetch latest binject version from GitHub') - logger.warn(`Using cached binject version ${binjectVersion}`) - } else { - throw new Error( - `Failed to fetch binject version from GitHub and no cached version found: ${e.message}`, - { cause: e }, - ) - } - } - - const binjectPath = await downloadBinject(binjectVersion) - - // Create unique temp directory for this build's extraction cache. - // This prevents parallel builds from interfering with each other. - const env = { ...process.env } - if (cacheId) { - const uniqueCacheDir = normalizePath( - path.join(SOCKET_CLI_SEA_BUILD_DIR, cacheId), - ) - await safeMkdir(uniqueCacheDir) - env['SOCKET_DLX_DIR'] = uniqueCacheDir - } - - // Inject SEA blob into Node binary using binject. - const args = [ - 'inject', - '--executable', - nodeBinary, - '--output', - outputPath, - '--sea', - configPath, - ] - - // Add VFS if provided (compressed tar.gz), otherwise use vfs-compat mode. - if (vfsTarGz && existsSync(vfsTarGz)) { - args.push('--vfs', vfsTarGz) - } else { - args.push('--vfs-compat') - } - - const result = await spawn(binjectPath, args, { env, stdio: 'inherit' }) - - if ( - result && - typeof result === 'object' && - 'code' in result && - result.code !== 0 - ) { - throw new Error(`binject failed with exit code ${result.code}`) - } -} diff --git a/packages/cli/scripts/sea-build-utils/downloads.mts b/packages/cli/scripts/sea-build-utils/downloads.mts deleted file mode 100644 index 1b0702426..000000000 --- a/packages/cli/scripts/sea-build-utils/downloads.mts +++ /dev/null @@ -1,668 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/prefer-exists-sync -- fs.stat()/lstat() calls read .size / .isFile() for cache validation, size reporting, and chmod checks; not existence checks. */ - -/** - * @file Download utilities for SEA build assets. Manages downloads of node-smol - * binaries, binject tool, and security tools from GitHub releases. Sections: - * - * 1. Constants and Utilities - Shared configuration, auth, platform mappings. - * 2. Node and Binject Downloads - Binary downloads for SEA injection. - * 3. External Security Tools - Python, Trivy, TruffleHog, OpenGrep downloads. - */ - -import { existsSync, promises as fs, readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import AdmZip from 'adm-zip' -import { logTransientErrorHelp } from 'build-infra/lib/github-error-utils' -import { downloadReleaseAsset } from 'build-infra/lib/github-releases' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { httpDownload } from '@socketsecurity/lib-stable/http-request/download' -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { ARCH_MAP, PLATFORM_MAP } from '../constants/platform-mappings.mts' -import { PLATFORM_MAP_TOOLS } from '../constants/external-tools-platforms.mts' - -// ============================================================================= -// Section 1: Constants and Utilities. -// ============================================================================= - -/** - * Default logger instance for SEA build operations. - */ -export const logger = getDefaultLogger() - -/** - * External tools configuration loaded from bundle-tools.json. Contains version - * info, GitHub repos, and download metadata for security tools. - */ -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const externalToolsPath = path.join(__dirname, '../../bundle-tools.json') -export const externalTools = JSON.parse(readFileSync(externalToolsPath, 'utf8')) - -/** - * Download and bundle security tools for socket-basics integration into SEA - * binaries. - * - * Downloads platform-specific binaries of security scanning tools from their - * respective GitHub releases, extracts them, and creates a compressed tar.gz - * archive for VFS bundling. The resulting archive is used by binject's --vfs - * flag to embed tools in the SEA binary with ~70% compression. - * - * Bundled Tools: - * - * - Python 3.11: Standalone Python runtime from Astral's python-build-standalone. - * - Trivy v0.69.1: Container and filesystem vulnerability scanner from Aqua - * Security. - * - TruffleHog v3.93.1: Secret and credential detection from Truffle Security. - * - OpenGrep v1.16.0: SAST/code analysis engine (fork of Semgrep). - * - * Platform Coverage (8/8 platforms): - * - * - Darwin-arm64: All native ARM64. - * - Darwin-x64: All native x86_64. - * - Linux-arm64: All native ARM64 (glibc). - * - Linux-arm64-musl: All native ARM64 (musl/Alpine). - * - Linux-x64: All native x86_64 (glibc). - * - Linux-x64-musl: All native x86_64 (musl/Alpine). - * - Windows-x64: All native x86_64. - * - Windows-arm64: Python and TruffleHog native ARM64, Trivy and OpenGrep x64 - * emulated. - * - * Windows ARM64 Emulation: Windows 11 ARM64 has transparent x64 emulation, so - * Trivy and OpenGrep (no native ARM64 builds available) use x64 binaries - * without any code changes or special invocation. - * - * Compression Results: - * - * - Uncompressed tools: ~460 MB. - * - Compressed tar.gz: ~140 MB (70% reduction). - * - Final SEA binary: ~191 MB (includes Node.js base + CLI blob + compressed - * VFS). - * - * @example - * const tarGzPath = await downloadExternalTools('darwin', 'arm64') - * // Returns: '../build-infra/build/external-tools/darwin-arm64.tar.gz' - * - * @example - * const tarGzPath = await downloadExternalTools('linux', 'x64', true) - * // Returns: '../build-infra/build/external-tools/linux-x64-musl.tar.gz' - * - * @param {string} platform - Node.js platform identifier (darwin, linux, - * win32). - * @param {string} arch - Node.js architecture identifier (arm64, x64). - * @param {boolean} [isMusl=false] - Whether to use musl libc binaries for - * Linux. - * - * @returns Promise resolving to path of the generated tar.gz archive, or null - * if platform not supported. - */ -export async function downloadExternalTools(platform, arch, isMusl = false) { - const rootPath = getRootPath() - const muslSuffix = isMusl ? '-musl' : '' - const platformArch = `${platform}-${arch}${muslSuffix}` - - const toolsDir = normalizePath( - path.join( - rootPath, - `packages/build-infra/build/external-tools/${platformArch}`, - ), - ) - const tarGzPath = normalizePath( - path.join( - rootPath, - `packages/build-infra/build/external-tools/${platformArch}.tar.gz`, - ), - ) - - // Check if tar.gz already exists and is valid. - if (existsSync(tarGzPath)) { - const stats = await fs.stat(tarGzPath) - - // Validate cached file is not empty or suspiciously small (> 1KB). - if (stats.size < 1024) { - logger.warn( - `Cached tar.gz is too small (${stats.size} bytes), rebuilding...`, - ) - await safeDelete(tarGzPath) - } else { - logger.log(`External-tools tar.gz already exists: ${tarGzPath}`) - return tarGzPath - } - } - - // Security tool versions and GitHub release info. - // Versions are read from bundle-tools.json for centralized management. - // Repository info is derived from the 'repository' field (format: owner/repo). - const TOOL_REPOS = { - __proto__: null, - } - - // Populate TOOL_REPOS from bundle-tools.json. - // Filter by release === 'asset' to include all GitHub-released tools. - for (const [toolName, toolConfig] of Object.entries(externalTools)) { - if (toolConfig.release === 'asset') { - const repoPath = toolConfig.repository.replace(/^[^:]+:/, '') - const parts = repoPath.split('/') - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid repository format for ${toolName}: expected '<host>:owner/repo', got '${toolConfig.repository}'`, - ) - } - const [owner, repo] = parts - TOOL_REPOS[toolName] = { - owner, - repo, - version: toolConfig.tag ?? toolConfig.version, - } - } - } - - // Platform-specific binary mappings imported from centralized constant. - // See scripts/constants/external-tools-platforms.mts for the full mapping. - - const toolsForPlatform = PLATFORM_MAP_TOOLS[platformArch] - if (!toolsForPlatform) { - logger.warn(`No external-tools available for platform: ${platformArch}`) - return undefined - } - - logger.log(`Downloading external-tools for ${platformArch}...`) - await safeMkdir(toolsDir) - - // Download and extract each tool. - const toolNames = [] - for (const [toolName, assetName] of Object.entries(toolsForPlatform)) { - const config = TOOL_REPOS[toolName] - - // Validate tool exists in TOOL_REPOS (populated from bundle-tools.json). - if (!config) { - throw new Error( - `Tool "${toolName}" is defined in platform mappings but not found in TOOL_REPOS. ` + - `Ensure "${toolName}" exists in bundle-tools.json with release "asset".`, - ) - } - - const isPlatWin = platform === 'win32' - const binaryName = toolName + (isPlatWin ? '.exe' : '') - const binaryPath = normalizePath(path.join(toolsDir, binaryName)) - - // Skip if already downloaded. - if ( - existsSync(binaryPath) || - (toolName === 'python' && existsSync(path.join(toolsDir, 'python'))) - ) { - logger.log(` ✓ ${toolName} already downloaded`) - toolNames.push(toolName === 'python' ? 'python' : binaryName) - continue - } - - logger.log(` Downloading ${toolName}...`) - const archivePath = normalizePath(path.join(toolsDir, assetName)) - - // Download archive directly from GitHub releases. - // Release tags can be any format (v1.6.1, 3.11.14, 20260203, etc.). - const tag = config.version - const url = `https://github.com/${config.owner}/${config.repo}/releases/download/${tag}/${assetName}` - - // Get SHA256 checksum from bundle-tools.json. - // SECURITY: Checksum verification is REQUIRED for all external tool downloads. - // If checksum is missing, the build MUST fail. - const toolConfig = externalTools[toolName] - const sha256 = toolConfig?.checksums?.[assetName] - - if (!sha256) { - throw new Error( - `bundle-tools.json tools["${toolName}"].checksums has no entry for "${assetName}" (seen: ${joinAnd(Object.keys(toolConfig?.checksums ?? {})) || '<empty>'}); run \`pnpm run sync-checksums\` to populate — builds must verify every external download`, - ) - } - - await httpDownload(url, archivePath, { - logger, - progressInterval: 10, - retries: 2, - retryDelay: 5000, - sha256, - }) - - // Extract binary (or handle standalone binaries). - const isZip = assetName.endsWith('.zip') - const isTarGz = assetName.endsWith('.tar.gz') || assetName.endsWith('.tgz') - const isStandalone = !isZip && !isTarGz - - if (isStandalone) { - // Standalone binary - create node_modules structure for VFS compatibility. - // node-smol VFS requires all files to be under node_modules/ for security. - logger.log(` Preparing ${toolName}...`) - - // Create node_modules/@socketsecurity/{toolName}-bin/ structure. - const packageDir = normalizePath( - path.join( - toolsDir, - 'node_modules', - '@socketsecurity', - `${toolName}-bin`, - ), - ) - await safeMkdir(packageDir) - - const packageBinaryPath = normalizePath(path.join(packageDir, binaryName)) - - // Move binary into package directory. - if (archivePath !== packageBinaryPath) { - try { - await fs.rename(archivePath, packageBinaryPath) - } catch (e) { - // Fallback to copy + delete for cross-device moves. - await fs.copyFile(archivePath, packageBinaryPath) - await safeDelete(archivePath) - } - } - - // Make executable on Unix. - if (!isPlatWin) { - await fs.chmod(packageBinaryPath, 0o755) - } - - toolNames.push(`node_modules/@socketsecurity/${toolName}-bin`) - logger.log(` ✓ ${toolName} ready`) - continue - } - - logger.log(` Extracting ${toolName}...`) - - if (isZip) { - // Extract zip archive using adm-zip. - // adm-zip provides cross-platform zip extraction with zero dependencies - // and built-in path traversal protection (fixed in v0.4.9, CVE-2018-1002204). - const zip = new AdmZip(archivePath) - zip.extractAllTo(toolsDir, true) - } else { - // Use tar command. - const tarResult = await spawn('tar', [ - '-xzf', - archivePath, - '-C', - toolsDir, - ]) - if (tarResult && tarResult.code !== 0) { - throw new Error(`Failed to extract ${assetName}`) - } - } - - // Find and move binary to final location. - let extractedBinaryPath - - if (toolName === 'python') { - // Python extracts to different structures on Windows vs Unix. - // Unlike other tools, Python requires its entire directory structure (stdlib, lib, - // include directories) to function. The python-build-standalone package is a - // complete, self-contained Python installation (~19 MB compressed). - // - // Unix directory structure after extraction: - // python/ - // ├── bin/ # Python executable and symlinks. - // ├── lib/ # Standard library and site-packages. - // ├── include/ # C headers for extension modules. - // └── share/ # Documentation and other resources. - // - // Windows directory structure after extraction: - // python/ - // ├── python.exe # Python executable at root. - // ├── DLLs/ # Python DLLs and extensions. - // ├── Lib/ # Standard library and site-packages. - // ├── libs/ # Import libraries for linking. - // └── include/ # C headers for extension modules. - // - // We keep the entire python/ directory in the VFS for socket-basics to use. - const pythonBinPath = normalizePath( - path.join( - toolsDir, - 'python', - isPlatWin ? 'python.exe' : path.join('bin', 'python'), - ), - ) - - // Verify Python installation is complete. - if (!existsSync(pythonBinPath)) { - throw new Error( - `Python binary not found after extraction: ${pythonBinPath}`, - ) - } - - // Make all binaries executable on Unix (python, python3, python3.11, etc.). - if (!isPlatWin) { - const binDir = path.join(toolsDir, 'python', 'bin') - const binFiles = await fs.readdir(binDir) - for (let i = 0, { length } = binFiles; i < length; i += 1) { - const file = binFiles[i] - const filePath = path.join(binDir, file) - const stats = await fs.lstat(filePath) - if (stats.isFile()) { - await fs.chmod(filePath, 0o755) - } - } - } - - // Install socketsecurity (pycli) into the bundled Python environment. - // This pre-installs the package so SEA mode doesn't need network access. - const pyCliConfig = externalTools['socketsecurity'] - if (pyCliConfig) { - const pyCliVersion = pyCliConfig.version - const wheelFilename = `socketsecurity-${pyCliVersion}-py3-none-any.whl` - const wheelSha256 = pyCliConfig.checksums?.[wheelFilename] - - if (!wheelSha256) { - throw new Error( - `bundle-tools.json tools.socketsecurity.checksums has no entry for "${wheelFilename}" (seen: ${joinAnd(Object.keys(pyCliConfig.checksums ?? {})) || '<empty>'}); run \`pnpm run sync-checksums\` to populate from PyPI — builds must verify the wheel hash`, - ) - } - - logger.log(` Installing socketsecurity ${pyCliVersion} into Python...`) - - // Fetch wheel URL from PyPI JSON API. - const pypiResponse = await httpRequest( - `https://pypi.org/pypi/socketsecurity/${pyCliVersion}/json`, - ) - if (!pypiResponse.ok) { - throw new Error( - `Failed to fetch socketsecurity ${pyCliVersion} from PyPI: ${pypiResponse.status}`, - ) - } - const pypiData = JSON.parse(pypiResponse.body.toString('utf8')) - const wheelInfo = pypiData.urls.find(u => u.filename === wheelFilename) - if (!wheelInfo) { - throw new Error( - `Wheel ${wheelFilename} not found in PyPI release ${pyCliVersion}`, - ) - } - - // Download wheel from PyPI. - const wheelPath = normalizePath(path.join(toolsDir, wheelFilename)) - - await httpDownload(wheelInfo.url, wheelPath, { - logger, - progressInterval: 10, - retries: 2, - retryDelay: 5_000, - sha256: wheelSha256, - }) - - // Install wheel into Python's site-packages using pip. - const pipResult = await spawn(pythonBinPath, [ - '-m', - 'pip', - 'install', - '--quiet', - '--no-deps', - wheelPath, - ]) - - if (pipResult && pipResult.code !== 0) { - throw new Error( - `Failed to install socketsecurity into bundled Python: exit code ${pipResult.code}`, - ) - } - - // Clean up wheel file. - await safeDelete(wheelPath) - - logger.log(` ✓ socketsecurity ${pyCliVersion} installed`) - } - - // Install socket_basics from GitHub source (not on PyPI). - // socket_basics orchestrates the security tools (trivy, trufflehog, opengrep). - const socketBasicsConfig = externalTools['socket-basics'] - if (socketBasicsConfig && socketBasicsConfig.release === 'archive') { - const repoPath = socketBasicsConfig.repository.replace(/^[^:]+:/, '') - const releaseVersion = socketBasicsConfig.version - const version = releaseVersion.replace(/^v/, '') // Remove 'v' prefix for version - - // Checksum key matches the local filename convention used for - // archive-style releases (`socket-basics-v<ver>.tar.gz`). - const archiveKey = `socket-basics-${releaseVersion}.tar.gz` - const archiveSha256 = socketBasicsConfig.checksums?.[archiveKey] - if (!archiveSha256) { - throw new Error( - `bundle-tools.json tools["socket-basics"].checksums has no entry for "${archiveKey}" (seen: ${joinAnd(Object.keys(socketBasicsConfig.checksums ?? {})) || '<empty>'}); run \`pnpm run sync-checksums\` to populate from the GitHub release — builds must verify the source tarball hash`, - ) - } - - logger.log(` Installing socket_basics ${version} from GitHub...`) - - // Download source tarball from GitHub. - const tarballUrl = `https://github.com/${repoPath}/archive/refs/tags/${releaseVersion}.tar.gz` - const tarballPath = normalizePath( - path.join(toolsDir, `socket-basics-${version}.tar.gz`), - ) - - await httpDownload(tarballUrl, tarballPath, { - logger, - progressInterval: 10, - retries: 2, - retryDelay: 5_000, - sha256: archiveSha256, - }) - - // Install from tarball using pip (handles building and dependencies). - const pipInstallResult = await spawn(pythonBinPath, [ - '-m', - 'pip', - 'install', - '--quiet', - tarballPath, - ]) - - if (pipInstallResult && pipInstallResult.code !== 0) { - throw new Error( - `Failed to install socket_basics from source: exit code ${pipInstallResult.code}`, - ) - } - - // Clean up tarball. - await safeDelete(tarballPath) - - logger.log(` ✓ socket_basics ${version} installed`) - } - - // Don't clean up - keep the whole python directory. - // We'll include the entire directory in the tar.gz. - toolNames.push('python') - } else if (toolName === 'opengrep') { - // OpenGrep binary is named opengrep-core in the archive. - extractedBinaryPath = normalizePath( - path.join(toolsDir, `opengrep-core${isPlatWin ? '.exe' : ''}`), - ) - - if ( - extractedBinaryPath !== binaryPath && - existsSync(extractedBinaryPath) - ) { - try { - await fs.rename(extractedBinaryPath, binaryPath) - } catch (e) { - // Fallback to copy + delete for cross-device moves. - await fs.copyFile(extractedBinaryPath, binaryPath) - await safeDelete(extractedBinaryPath) - } - } else if (!existsSync(binaryPath)) { - throw new Error( - `Binary not found after extraction: ${extractedBinaryPath}`, - ) - } - - // Make executable on Unix. - if (!isPlatWin) { - await fs.chmod(binaryPath, 0o755) - } - - toolNames.push(binaryName) - } else { - // Other tools extract with their own name. - extractedBinaryPath = normalizePath( - path.join(toolsDir, toolName + (isPlatWin ? '.exe' : '')), - ) - - if ( - extractedBinaryPath !== binaryPath && - existsSync(extractedBinaryPath) - ) { - try { - await fs.rename(extractedBinaryPath, binaryPath) - } catch (e) { - // Fallback to copy + delete for cross-device moves. - await fs.copyFile(extractedBinaryPath, binaryPath) - await safeDelete(extractedBinaryPath) - } - } else if (!existsSync(binaryPath)) { - throw new Error( - `Binary not found after extraction: ${extractedBinaryPath}`, - ) - } - - // Make executable on Unix. - if (!isPlatWin) { - await fs.chmod(binaryPath, 0o755) - } - - toolNames.push(binaryName) - } - - // Clean up archive. - await safeDelete(archivePath) - - logger.log(` ✓ ${toolName} ready`) - } - - // Package into compressed tar.gz. - logger.log(`Creating compressed tar.gz: ${path.basename(tarGzPath)}`) - const tarResult = await spawn('tar', [ - '-czf', - tarGzPath, - '-C', - toolsDir, - ...toolNames, - ]) - - if (tarResult && tarResult.code !== 0) { - throw new Error('Failed to create external-tools tar.gz') - } - - const tarStats = await fs.stat(tarGzPath) - logger.success( - `External-tools packaged: ${(tarStats.size / 1_024 / 1_024).toFixed(2)} MB`, - ) - - return tarGzPath -} - -/** - * Get GitHub API authentication headers. Uses GH_TOKEN or GITHUB_TOKEN - * environment variables if available. - * - * @returns Headers object for GitHub API requests. - */ -export function getAuthHeaders() { - const token = process.env['GH_TOKEN'] || process.env['GITHUB_TOKEN'] - return { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(token && { Authorization: `Bearer ${token}` }), - } -} - -/** - * Get the latest binject release version from socket-btm. Returns the version - * string (e.g., "1.0.0"). - * - * @example - * const version = await getLatestBinjectVersion() - * // "1.0.0" - * - * @returns Promise resolving to binject version string. - * - * @throws {Error} When socket-btm releases cannot be fetched. - */ -export async function getLatestBinjectVersion() { - try { - const response = await httpRequest( - 'https://api.github.com/repos/SocketDev/socket-btm/releases', - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - // Detect specific error types. - if (response.status === 401) { - throw new Error( - 'GitHub API authentication failed. Please check your GH_TOKEN or GITHUB_TOKEN environment variable.', - ) - } - - if (response.status === 403) { - const rateLimitReset = response.headers['x-ratelimit-reset'] - const resetTime = rateLimitReset - ? new Date(Number(rateLimitReset) * 1_000).toLocaleString() - : 'unknown' - throw new Error( - `GitHub API rate limit exceeded. Resets at: ${resetTime}. ` + - 'Set GH_TOKEN or GITHUB_TOKEN environment variable to increase rate limits ' + - '(unauthenticated: 60/hour, authenticated: 5,000/hour).', - ) - } - - throw new Error( - `Failed to fetch socket-btm releases: ${response.status} ${response.statusText}`, - ) - } - - const releases = JSON.parse(response.body.toString('utf8')) - - // Validate API response structure. - if (!Array.isArray(releases) || releases.length === 0) { - throw new Error( - 'Invalid API response: expected non-empty array of releases', - ) - } - - // Find the latest binject release. - const binjectRelease = releases.find(release => - release?.tag_name?.startsWith('binject-'), - ) - - if (!binjectRelease) { - throw new Error('No binject release found in socket-btm') - } - - if (!binjectRelease.tag_name) { - throw new Error('Invalid release data: missing tag_name') - } - - // Extract the version (e.g., "binject-1.0.0" -> "1.0.0"). - return binjectRelease.tag_name.replace('binject-', '') - } catch (e) { - await logTransientErrorHelp(e) - throw new Error('Failed to fetch latest socket-btm binject release', { - cause: e, - }) - } -} - -/** - * Get the monorepo root path. Resolves to socket-cli/ directory regardless of - * where script is run from. - * - * @returns Absolute path to monorepo root. - */ -export function getRootPath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - return path.join(__dirname, '../../../..') -} diff --git a/packages/cli/scripts/sea-build-utils/npm-packages.mts b/packages/cli/scripts/sea-build-utils/npm-packages.mts deleted file mode 100644 index 00076e100..000000000 --- a/packages/cli/scripts/sea-build-utils/npm-packages.mts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * @file Npm package download utilities for VFS bundling. Downloads npm packages - * with full dependency trees using Arborist for SEA VFS embedding. - */ - -// oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: fs.stat() reads .size, not existence; per-call would produce many redundant disables. -// oxlint-disable socket/prefer-exists-sync -- fs.stat() calls read .size for cache validation and reporting; not existence checks. - -import { existsSync, promises as fs, readFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { Arborist } from '@npmcli/arborist' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { getRootPath } from './downloads.mts' - -const logger = getDefaultLogger() - -/** - * External tools configuration loaded from bundle-tools.json. - */ -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const externalToolsPath = path.join(__dirname, '../../bundle-tools.json') -const externalTools = JSON.parse(readFileSync(externalToolsPath, 'utf8')) - -/** - * Combine npm packages and external tools into a single VFS archive. - * - * Creates a unified tar.gz containing both: - * - * - Node_modules/ with npm packages and dependencies. - * - External tool binaries (Python, Trivy, TruffleHog, OpenGrep, socket-patch). - * - * The combined archive is used by binject for VFS embedding into SEA binaries. - * - * Directory structure in combined archive: ./node_modules/ # npm packages with - * dependencies ├── @coana-tech/cli/ ├── @cyclonedx/cdxgen/ └── synp/ ./python/ - * # Python runtime ./trivy # Trivy binary ./trufflehog # TruffleHog binary - * ./opengrep # OpenGrep binary ./socket-patch # Socket Patch Rust binary - * (v2.0.0+) - * - * @example - * const combined = await combineVfsArchives( - * '../build-infra/build/npm-packages/npm-packages.tar.gz', - * '../build-infra/build/external-tools/darwin-arm64.tar.gz', - * 'darwin', - * 'arm64', - * ) - * // Returns: '../build-infra/build/vfs/darwin-arm64.tar.gz' - * - * @param {string} npmPackagesTarGz - Path to npm packages tar.gz. - * @param {string} externalToolsTarGz - Path to external tools tar.gz. - * @param {string} platform - Platform identifier (darwin, linux, win32). - * @param {string} arch - Architecture identifier (arm64, x64). - * @param {boolean} [isMusl=false] - Whether this is musl libc (Linux only). - * - * @returns Promise resolving to path of combined tar.gz. - */ -async function combineVfsArchives( - npmPackagesTarGz, - externalToolsTarGz, - platform, - arch, - isMusl = false, -) { - const rootPath = getRootPath() - const muslSuffix = isMusl ? '-musl' : '' - const platformArch = `${platform}-${arch}${muslSuffix}` - - const vfsDir = normalizePath( - path.join(rootPath, `packages/build-infra/build/vfs/${platformArch}`), - ) - const combinedTarGz = normalizePath( - path.join( - rootPath, - `packages/build-infra/build/vfs/${platformArch}.tar.gz`, - ), - ) - - // Check if combined tar.gz already exists and is valid. - if (existsSync(combinedTarGz)) { - const stats = await fs.stat(combinedTarGz) - - // Validate cached file is not empty or suspiciously small (> 1KB). - if (stats.size < 1024) { - logger.warn( - `Cached combined VFS tar.gz is too small (${stats.size} bytes), rebuilding...`, - ) - await safeDelete(combinedTarGz) - } else { - logger.log(`Combined VFS tar.gz already exists: ${combinedTarGz}`) - return combinedTarGz - } - } - - logger.step('Combining npm packages and external tools into VFS archive') - - // Create temporary directory for extraction and combination. - await safeMkdir(vfsDir) - - try { - // Extract npm packages tar.gz. - if (npmPackagesTarGz && existsSync(npmPackagesTarGz)) { - logger.substep('Extracting npm packages') - const tarResult = await spawn('tar', [ - '-xzf', - npmPackagesTarGz, - '-C', - vfsDir, - ]) - if (tarResult && tarResult.code !== 0) { - throw new Error('Failed to extract npm packages tar.gz') - } - } - - // Extract external tools tar.gz. - if (externalToolsTarGz && existsSync(externalToolsTarGz)) { - logger.substep('Extracting external tools') - const tarResult = await spawn('tar', [ - '-xzf', - externalToolsTarGz, - '-C', - vfsDir, - ]) - if (tarResult && tarResult.code !== 0) { - throw new Error('Failed to extract external tools tar.gz') - } - } - - // List contents for combined archive. - const contents = await fs.readdir(vfsDir) - if (contents.length === 0) { - throw new Error('No files to package in VFS directory') - } - - // Create combined tar.gz. - logger.substep('Creating combined tar.gz') - const tarResult = await spawn('tar', [ - '-czf', - combinedTarGz, - '-C', - vfsDir, - ...contents, - ]) - - if (tarResult && tarResult.code !== 0) { - throw new Error('Failed to create combined VFS tar.gz') - } - - const tarStats = await fs.stat(combinedTarGz) - logger.success( - `Combined VFS archive: ${(tarStats.size / 1_024 / 1_024).toFixed(2)} MB`, - ) - logger.error('') - - return combinedTarGz - } finally { - // Clean up extracted files. - await safeDelete(vfsDir) - } -} - -/** - * Download a single npm package with full dependency tree using Arborist. - * - * Downloads the complete package structure including node_modules/ with all - * production dependencies, ready for VFS bundling. - * - * @example - * await downloadNpmPackage('synp@1.9.14', '/tmp/synp', 'sha512-xxx') - * // Creates: /tmp/synp/node_modules/synp/ with full dependency tree - * - * @param {string} packageSpec - Npm package specifier (e.g., "synp@1.9.14"). - * @param {string} targetDir - Directory to install package into. - * @param {string} [expectedIntegrity] - Expected SRI integrity hash - * (sha512-xxx). - * - * @returns Promise resolving to the target directory path. - */ -async function downloadNpmPackage( - packageSpec, - targetDir, - expectedIntegrity, -) { - logger.substep(`Downloading ${packageSpec} with dependencies`) - - // Ensure target directory exists. - await safeMkdir(targetDir) - - // Configure Arborist with Socket cacache and security settings. - const arb = new Arborist({ - audit: false, - binLinks: true, - cache: getSocketCacacheDir(), - fund: false, - ignoreScripts: true, - omit: ['dev'], - path: targetDir, - silent: true, - }) - - // Download and install package with dependencies. - try { - await arb.reify({ add: [packageSpec], save: false }) - } catch (e) { - throw new Error( - `Failed to download ${packageSpec} with Arborist: ${e.message}`, - ) - } - - // Verify integrity if provided. - if (expectedIntegrity) { - // Extract package name from spec (e.g., "@cyclonedx/cdxgen@12.0.0" -> "@cyclonedx/cdxgen"). - const atIndex = packageSpec.lastIndexOf('@') - const packageName = - atIndex > 0 ? packageSpec.slice(0, atIndex) : packageSpec - - // Find the installed package in node_modules. - const installedPackagePath = path.join( - targetDir, - 'node_modules', - packageName, - 'package.json', - ) - if (!existsSync(installedPackagePath)) { - throw new Error( - `Integrity verification failed: package.json not found at ${installedPackagePath}`, - ) - } - - // Read the installed package.json to get the resolved integrity. - const installedPackage = JSON.parse( - readFileSync(installedPackagePath, 'utf8'), - ) - logger.substep( - `Verified ${packageName}@${installedPackage.version} installed`, - ) - } - - logger.success(`${packageSpec} installed with dependencies`) - logger.error('') - return targetDir -} - -/** - * Download all npm packages with full dependency trees for VFS bundling. - * - * Downloads npm packages specified in bundle-tools.json that have type='npm', - * installs them with full production dependency trees using Arborist, and - * packages them into a compressed tar.gz for VFS embedding. - * - * Npm Packages: - * - * - @coana-tech/cli: Static analysis and reachability detection. - * - @cyclonedx/cdxgen: CycloneDX SBOM generator. - * - Synp: yarn.lock to package-lock.json converter. - * - * Note: socket-patch was migrated from npm to GitHub releases in v2.0.0. It's - * now bundled as a standalone Rust binary via downloads.mts. - * - * Directory Structure: <targetDir>/ └── node_modules/ ├── @coana-tech/cli/ │ - * ├── bin/coana │ ├── package.json │ └── node_modules/ # Dependencies ├── - * @cyclonedx/cdxgen/ │ ├── bin/cdxgen │ ├── package.json │ └── node_modules/ # - * Dependencies └── synp/ ├── bin/synp ├── package.json └── node_modules/ # - * Dependencies. - * - * @example - * const tarGzPath = await downloadNpmPackages() - * // Returns: '../build-infra/build/npm-packages/npm-packages.tar.gz' - * - * @returns Promise resolving to path of tar.gz archive, or null if no npm - * packages defined. - */ -async function downloadNpmPackages() { - const rootPath = getRootPath() - const npmPackagesDir = normalizePath( - path.join(rootPath, 'packages/build-infra/build/npm-packages'), - ) - const tarGzPath = normalizePath( - path.join(npmPackagesDir, 'npm-packages.tar.gz'), - ) - - // Check if tar.gz already exists and is valid. - if (existsSync(tarGzPath)) { - const stats = await fs.stat(tarGzPath) - - // Validate cached file is not empty or suspiciously small (> 1KB). - if (stats.size < 1024) { - logger.warn( - `Cached npm packages tar.gz is too small (${stats.size} bytes), rebuilding...`, - ) - await safeDelete(tarGzPath) - } else { - logger.log(`npm packages tar.gz already exists: ${tarGzPath}`) - return tarGzPath - } - } - - // Collect npm packages from bundle-tools.json. - const npmPackages = [] - for (const [toolName, toolConfig] of Object.entries(externalTools)) { - if (toolConfig.packageManager === 'npm') { - npmPackages.push({ - integrity: toolConfig.integrity, - name: toolName, - package: toolName, - version: toolConfig.version, - }) - } - } - - if (npmPackages.length === 0) { - logger.warn('No npm packages defined in bundle-tools.json') - return undefined - } - - logger.step('Downloading npm packages with full dependency trees') - await safeMkdir(npmPackagesDir) - - // Create unique temporary directory for package installation (prevents parallel build conflicts). - const tempDir = normalizePath( - path.join(npmPackagesDir, `temp-${process.pid}-${Date.now()}`), - ) - await safeMkdir(tempDir) - - try { - // Download all npm packages with dependencies using Arborist. - for (let i = 0, { length } = npmPackages; i < length; i += 1) { - const pkg = npmPackages[i] - const packageSpec = `${pkg.package}@${pkg.version}` - await downloadNpmPackage(packageSpec, tempDir, pkg.integrity) - } - - // Verify node_modules directory exists and has content. - const nodeModulesDir = path.join(tempDir, 'node_modules') - if (!existsSync(nodeModulesDir)) { - throw new Error('node_modules directory not created by Arborist') - } - - // Package node_modules into compressed tar.gz. - logger.substep(`Creating compressed tar.gz: ${path.basename(tarGzPath)}`) - const tarResult = await spawn('tar', [ - '-czf', - tarGzPath, - '-C', - tempDir, - 'node_modules', - ]) - - if (tarResult && tarResult.code !== 0) { - throw new Error('Failed to create npm packages tar.gz') - } - - const tarStats = await fs.stat(tarGzPath) - logger.success( - `npm packages packaged: ${(tarStats.size / 1_024 / 1_024).toFixed(2)} MB`, - ) - logger.error('') - - return tarGzPath - } finally { - // Clean up temporary directory. - await safeDelete(tempDir) - } -} - -/** - * Get Socket cacache directory for Arborist npm package caching. - * - * @returns Path to Socket's cacache directory. - */ -export function getSocketCacacheDir() { - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || tmpdir() - return normalizePath(path.join(homeDir, '.socket', '_cacache')) -} diff --git a/packages/cli/scripts/sea-build-utils/orchestration.mts b/packages/cli/scripts/sea-build-utils/orchestration.mts deleted file mode 100644 index 1d21e481b..000000000 --- a/packages/cli/scripts/sea-build-utils/orchestration.mts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @file High-level SEA build orchestration. Coordinates all SEA build steps for - * a single platform target. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { PACKAGE_ROOT } from '../paths.mts' - -import { generateSeaConfig, injectSeaBlob } from './builder.mts' -import { downloadNodeBinary } from '../util/asset-manager-compat.mts' -import { downloadExternalTools, logger } from './downloads.mts' - -/** - * Build a single SEA target for a specific platform. Orchestrates the complete - * SEA build process: 1. Downloads node-smol binary for target platform. 2. - * Downloads and packages security tools (if available). 3. Generates SEA - * configuration. 4. Injects blob and VFS into binary using binject. - * - * @example - * const target = { - * platform: 'darwin', - * arch: 'arm64', - * outputName: 'socket-darwin-arm64', - * nodeVersion: '20251213-7cf90d2', - * } - * const outputPath = await buildTarget(target, 'dist/cli.js', { - * outputPath: - * 'packages/package-builder/build/dev/out/socketbin-cli-darwin-arm64/socket', - * }) - * - * @param {object} target - Build target configuration. - * @param {string} target.platform - Platform identifier (darwin, linux, win32). - * @param {string} target.arch - Architecture identifier (arm64, x64). - * @param {string} target.outputName - Output binary filename. - * @param {string} target.nodeVersion - Node.js version tag suffix. - * @param {string} [target.libc] - Linux libc variant ('musl' for Alpine). - * @param {string} entryPoint - Absolute path to CLI entry point file. - * @param {object} [options] - Build options. - * @param {string} [options.outputPath] - Full output path for SEA binary. - * @param {string} [options.outputDir] - Output directory (deprecated, use - * outputPath). - * - * @returns Promise resolving to absolute path of built SEA binary. - */ -// c8 ignore start - Requires downloading binaries, building blobs, and binary injection. -export async function buildTarget(target, entryPoint, options) { - const { outputDir, outputPath: providedOutputPath } = { - __proto__: null, - ...options, - } - - // Determine output path. - let outputPath - if (providedOutputPath) { - outputPath = normalizePath(providedOutputPath) - } else { - const dir = outputDir || normalizePath(path.join(PACKAGE_ROOT, 'dist/sea')) - outputPath = normalizePath(path.join(dir, target.outputName)) - } - - // Ensure output directory exists. - const outputDirPath = path.dirname(outputPath) - await safeMkdir(outputDirPath) - - // Download Node.js binary for target platform. - const nodeBinary = await downloadNodeBinary( - target.nodeVersion, - target.platform, - target.arch, - target.libc, - ) - - // Create unique cache ID for parallel builds to prevent extraction cache conflicts. - const cacheId = `${target.platform}-${target.arch}${target.libc ? `-${target.libc}` : ''}` - - // Download and package external security tools for VFS bundling. - let vfsTarGz - try { - vfsTarGz = await downloadExternalTools( - target.platform, - target.arch, - target.libc === 'musl', - ) - } catch (e) { - logger.warn( - `Failed to download security tools for ${cacheId}: ${e.message}`, - ) - logger.warn('Building without security tools VFS') - } - - // Generate SEA configuration. - const configPath = await generateSeaConfig(entryPoint, outputPath) - - try { - // Inject SEA using config-based blob generation. - // binject reads the config, generates the blob, and injects VFS in one operation. - await injectSeaBlob(nodeBinary, configPath, outputPath, cacheId, vfsTarGz) - - // Make executable on Unix. - if (target.platform !== 'win32') { - await fs.chmod(outputPath, 0o755) - } - - // Clean up generated blob file. - // Blob path in config is relative to config directory. - const config = JSON.parse(await fs.readFile(configPath, 'utf8')) - if (config.output) { - const blobPath = path.join(path.dirname(configPath), config.output) - await safeDelete(blobPath).catch(() => {}) - } - } finally { - // Clean up config. - await safeDelete(configPath).catch(() => {}) - } - - return outputPath -} -// c8 ignore stop diff --git a/packages/cli/scripts/sea-build-utils/targets.mts b/packages/cli/scripts/sea-build-utils/targets.mts deleted file mode 100644 index da5f35657..000000000 --- a/packages/cli/scripts/sea-build-utils/targets.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @file Build target selection and platform configuration for SEA builds. - * Manages the list of supported platforms and Node.js version selection. - */ - -import { logTransientErrorHelp } from 'build-infra/lib/github-error-utils' - -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' - -import { getAuthHeaders } from './downloads.mts' - -/** - * Generate build targets for different platforms. Returns array of 8 platform - * targets (darwin, linux, windows × arm64/x64, musl variants). - * - * @example - * const targets = await getBuildTargets() - * // [ - * // { platform: 'win32', arch: 'arm64', nodeVersion: '20251213-7cf90d2', outputName: 'socket-win-arm64.exe' }, - * // ... - * // ] - * - * @returns Array of build target configurations. - */ -export async function getBuildTargets() { - const defaultNodeVersion = await getDefaultNodeVersion() - - return [ - { - arch: 'arm64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-win-arm64.exe', - platform: 'win32', - }, - { - arch: 'x64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-win-x64.exe', - platform: 'win32', - }, - { - arch: 'arm64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-darwin-arm64', - platform: 'darwin', - }, - { - arch: 'x64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-darwin-x64', - platform: 'darwin', - }, - { - arch: 'arm64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-linux-arm64', - platform: 'linux', - }, - { - arch: 'x64', - nodeVersion: defaultNodeVersion, - outputName: 'socket-linux-x64', - platform: 'linux', - }, - { - arch: 'arm64', - libc: 'musl', - nodeVersion: defaultNodeVersion, - outputName: 'socket-linux-arm64-musl', - platform: 'linux', - }, - { - arch: 'x64', - libc: 'musl', - nodeVersion: defaultNodeVersion, - outputName: 'socket-linux-x64-musl', - platform: 'linux', - }, - ] -} - -/** - * Get the default Node.js version for SEA builds. Returns the socket-btm tag - * suffix (e.g., "20251213-7cf90d2"). Prefers SOCKET_CLI_SEA_NODE_VERSION env - * var, falls back to latest socket-btm release. - * - * @example - * const version = await getDefaultNodeVersion() - * // "20251213-7cf90d2" - * - * @returns Node.js version tag suffix. - */ -export async function getDefaultNodeVersion() { - if (process.env['SOCKET_CLI_SEA_NODE_VERSION']) { - return process.env['SOCKET_CLI_SEA_NODE_VERSION'] - } - - // Fetch the latest node-smol release tag from socket-btm. - return await getLatestSocketBtmNodeRelease() -} - -/** - * Fetch the latest node-smol release tag from socket-btm. Returns the tag - * suffix (e.g., "20251213-7cf90d2"). - * - * @example - * const version = await getLatestSocketBtmNodeRelease() - * // "20251213-7cf90d2" - * - * @returns Latest node-smol version tag suffix. - * - * @throws {Error} When socket-btm releases cannot be fetched. - */ -async function getLatestSocketBtmNodeRelease() { - try { - const response = await httpRequest( - 'https://api.github.com/repos/SocketDev/socket-btm/releases', - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - // Detect specific error types. - if (response.status === 401) { - throw new Error( - 'GitHub API authentication failed. Please check your GH_TOKEN or GITHUB_TOKEN environment variable.', - ) - } - - if (response.status === 403) { - const rateLimitReset = response.headers['x-ratelimit-reset'] - const resetTime = rateLimitReset - ? new Date(Number(rateLimitReset) * 1_000).toLocaleString() - : 'unknown' - throw new Error( - `GitHub API rate limit exceeded. Resets at: ${resetTime}. ` + - 'Set GH_TOKEN or GITHUB_TOKEN environment variable to increase rate limits ' + - '(unauthenticated: 60/hour, authenticated: 5,000/hour).', - ) - } - - throw new Error( - `Failed to fetch socket-btm releases: ${response.status} ${response.statusText}`, - ) - } - - const releases = JSON.parse(response.body.toString('utf8')) - - // Validate API response structure. - if (!Array.isArray(releases) || releases.length === 0) { - throw new Error( - 'Invalid API response: expected non-empty array of releases', - ) - } - - // Find the latest node-smol release. - const nodeSmolRelease = releases.find(release => - release?.tag_name?.startsWith('node-smol-'), - ) - - if (!nodeSmolRelease) { - throw new Error('No node-smol release found in socket-btm') - } - - if (!nodeSmolRelease.tag_name) { - throw new Error('Invalid release data: missing tag_name') - } - - // Extract the tag suffix (e.g., "node-smol-20251213-7cf90d2" -> "20251213-7cf90d2"). - return nodeSmolRelease.tag_name.replace('node-smol-', '') - } catch (e) { - await logTransientErrorHelp(e) - throw new Error('Failed to fetch latest socket-btm node-smol release', { - cause: e, - }) - } -} diff --git a/packages/cli/scripts/sync-checksums.mts b/packages/cli/scripts/sync-checksums.mts deleted file mode 100644 index 595c2d8a7..000000000 --- a/packages/cli/scripts/sync-checksums.mts +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node -/** - * Sync checksums from GitHub releases to bundle-tools.json. - * - * For each GitHub-released tool, this script: - * - * 1. Fetches checksums.txt from the release (if available) - * 2. Or downloads each asset and computes SHA-256 checksums - * 3. Updates bundle-tools.json with the new checksums - * - * Usage: node scripts/sync-checksums.mts [--tool=<tool>] [--force] [--dry-run] - * - * Options: --tool=<name> Only sync specific tool --force Force update even if - * checksums haven't changed --dry-run Show what would be updated without - * writing. - */ - -import crypto from 'node:crypto' -import { - createReadStream, - existsSync, - promises as fs, - readFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { pipeline } from 'node:stream/promises' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const packageRoot = path.join(__dirname, '..') - -const EXTERNAL_TOOLS_FILE = path.join(packageRoot, 'bundle-tools.json') - -/** - * Compute SHA-256 hash of a file. - */ -export async function computeFileHash(filePath) { - const hash = createHash('sha256') - const stream = createReadStream(filePath) - for await (const chunk of stream) { - hash.update(chunk) - } - return hash.digest('hex') -} - -/** - * Download a file from a URL. - */ -export async function downloadFile(url, destPath) { - // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- streams response.body into a write stream via pipeline(); httpJson/httpText/httpRequest decode to string/JSON and don't expose the raw response body stream. - const response = await fetch(url, { - headers: { - Accept: 'application/octet-stream', - 'User-Agent': 'socket-cli-sync-checksums', - }, - redirect: 'follow', - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - const fileStream = await fs.open(destPath, 'w') - try { - const writer = fileStream.createWriteStream() - await pipeline(response.body, writer) - } finally { - await fileStream.close() - } -} - -/** - * Fetch checksums for a GitHub release. First tries checksums.txt, then falls - * back to downloading assets. - */ -async function fetchGitHubReleaseChecksums( - repo, - releaseTag, - existingChecksums = {}, -) { - const [owner, repoName] = repo.split('/') - const apiUrl = `https://api.github.com/repos/${owner}/${repoName}/releases/tags/${releaseTag}` - - logger.log(` Fetching release info from ${apiUrl}...`) - - // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- dev script needs response.ok + response.status + response.statusText for diagnostic error; helpers throw HttpError without exposing those fields ergonomically. - const response = await fetch(apiUrl, { - headers: { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'socket-cli-sync-checksums', - }, - }) - - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ) - } - - const release = await response.json() - const assets = release.assets || [] - - // Try to find checksums.txt in assets. - const checksumsAsset = assets.find(a => a.name === 'checksums.txt') - if (checksumsAsset) { - logger.log(` Found checksums.txt, downloading...`) - const tempDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'socket-checksums-'), - ) - const checksumPath = path.join(tempDir, 'checksums.txt') - - try { - await downloadFile(checksumsAsset.browser_download_url, checksumPath) - const content = await fs.readFile(checksumPath, 'utf8') - const checksums = parseChecksums(content) - - // Clean up. - await safeDelete(tempDir) - - logger.log( - ` Parsed ${Object.keys(checksums).length} checksums from checksums.txt`, - ) - return checksums - } catch (e) { - logger.log(` Failed to download checksums.txt: ${e.message}`) - await safeDelete(tempDir).catch(() => {}) - // Fall through to download assets. - } - } - - // No checksums.txt - need to download assets and compute checksums. - // Only download assets that are in existingChecksums (to avoid downloading unnecessary files). - const assetNames = Object.keys(existingChecksums) - if (assetNames.length === 0) { - logger.log(` No existing checksums to update and no checksums.txt found`) - return {} - } - - logger.log( - ` No checksums.txt found, downloading ${assetNames.length} assets to compute checksums...`, - ) - - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-checksums-')) - const checksums = {} - - try { - for (let i = 0, { length } = assetNames; i < length; i += 1) { - const assetName = assetNames[i] - const asset = assets.find(a => a.name === assetName) - if (!asset) { - logger.log(` Warning: Asset ${assetName} not found in release`) - continue - } - - const assetPath = path.join(tempDir, assetName) - logger.log(` Downloading ${assetName}...`) - await downloadFile(asset.browser_download_url, assetPath) - - const hash = await computeFileHash(assetPath) - checksums[assetName] = hash - logger.log(` ${assetName}: ${hash.slice(0, 16)}...`) - - // Clean up as we go to save disk space. - await safeDelete(assetPath) - } - } finally { - await safeDelete(tempDir).catch(() => {}) - } - - return checksums -} - -/** - * Parse checksums.txt content into a map. - */ -export function parseChecksums(content) { - const checksums = {} - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - continue - } - // Format: hash filename (two spaces or whitespace between) - const match = trimmed.match(/^([a-f0-9]{64})\s+(.+)$/) - if (match) { - checksums[match[2]] = match[1] - } - } - return checksums -} - -/** - * Main sync function. - */ -async function main() { - const args = process.argv.slice(2) - const force = args.includes('--force') - const dryRun = args.includes('--dry-run') - const toolArg = args.find(arg => arg.startsWith('--tool=')) - const toolFilter = toolArg ? toolArg.split('=')[1] : undefined - - // Load current bundle-tools.json. - if (!existsSync(EXTERNAL_TOOLS_FILE)) { - logger.fail(`Error: ${EXTERNAL_TOOLS_FILE} not found`) - process.exitCode = 1 - return - } - - const externalTools = JSON.parse(readFileSync(EXTERNAL_TOOLS_FILE, 'utf8')) - - // Find all GitHub-released tools. - const githubTools = Object.entries(externalTools) - .filter(([key, value]) => { - if (key.startsWith('$')) { - return false - } // Skip schema keys - return value.release === 'asset' - }) - .map(([key, value]) => ({ key, ...value })) - - if (toolFilter) { - const filtered = githubTools.filter(t => t.key === toolFilter) - if (filtered.length === 0) { - logger.fail( - `Error: Tool '${toolFilter}' not found or is not a GitHub release tool`, - ) - logger.log( - `Available GitHub release tools: ${githubTools.map(t => t.key).join(', ')}`, - ) - process.exitCode = 1 - return - } - githubTools.length = 0 - githubTools.push(...filtered) - } - - logger.log( - `Syncing checksums for ${githubTools.length} GitHub release tool(s)...`, - ) - logger.log('') - - let updated = 0 - let unchanged = 0 - let failed = 0 - - for (let i = 0, { length } = githubTools; i < length; i += 1) { - const tool = githubTools[i] - const repoPath = tool.repository.replace(/^[^:]+:/, '') - const releaseTag = tool.tag ?? tool.version - logger.log(`[${tool.key}] ${repoPath} @ ${releaseTag}`) - - try { - const newChecksums = await fetchGitHubReleaseChecksums( - repoPath, - releaseTag, - tool.checksums || {}, - ) - - if (Object.keys(newChecksums).length === 0) { - logger.log(` Skipped: No checksums found`) - logger.log('') - unchanged++ - continue - } - - // Check if update is needed. - const oldChecksums = tool.checksums || {} - const checksumChanged = - JSON.stringify(newChecksums) !== JSON.stringify(oldChecksums) - - if (!force && !checksumChanged) { - logger.log(` Unchanged: ${Object.keys(newChecksums).length} checksums`) - logger.log('') - unchanged++ - continue - } - - // Update the data. - externalTools[tool.key].checksums = newChecksums - - const oldCount = Object.keys(oldChecksums).length - const newCount = Object.keys(newChecksums).length - logger.log(` Updated: ${oldCount} -> ${newCount} checksums`) - logger.log('') - updated++ - } catch (e) { - logger.log(` Error: ${e.message}`) - logger.log('') - failed++ - } - } - - // Write updated file. - if (updated > 0 && !dryRun) { - await fs.writeFile( - EXTERNAL_TOOLS_FILE, - JSON.stringify(externalTools, null, 2) + '\n', - 'utf8', - ) - logger.log(`Updated ${EXTERNAL_TOOLS_FILE}`) - } else if (dryRun && updated > 0) { - logger.log('Dry run - no changes written') - } - - // Summary. - logger.log('') - logger.log( - `Summary: ${updated} updated, ${unchanged} unchanged, ${failed} failed`, - ) - - if (failed > 0) { - process.exitCode = 1 - } -} - -main().catch(error => { - logger.fail(`Sync failed: ${error.message}`) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/test-download-external-tools.mts b/packages/cli/scripts/test-download-external-tools.mts deleted file mode 100644 index afa56ea3c..000000000 --- a/packages/cli/scripts/test-download-external-tools.mts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Test script to download external tools for VFS bundling proof-of-concept. - * Downloads Trivy, TruffleHog, and OpenGrep for the current platform. - */ - -// oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: fs.stat() reads .size, not existence; per-call would produce many redundant disables. -// oxlint-disable socket/prefer-exists-sync -- fs.stat() calls read .size for download size reporting; not existence checks. - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const logger = getDefaultLogger() - -// Map current platform to external tool binary names. -const PLATFORM_MAP = { - __proto__: null, - 'darwin-arm64': { - trivy: 'trivy_0.69.1_macOS-ARM64.tar.gz', - trufflehog: 'trufflehog_3.93.1_darwin_arm64.tar.gz', - opengrep: 'opengrep-core_osx_aarch64.tar.gz', - }, - 'darwin-x64': { - trivy: 'trivy_0.69.1_macOS-64bit.tar.gz', - trufflehog: 'trufflehog_3.93.1_darwin_amd64.tar.gz', - opengrep: 'opengrep-core_osx_x86.tar.gz', - }, - 'linux-arm64': { - trivy: 'trivy_0.69.1_Linux-ARM64.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_arm64.tar.gz', - opengrep: 'opengrep-core_linux_aarch64.tar.gz', - }, - 'linux-x64': { - trivy: 'trivy_0.69.1_Linux-64bit.tar.gz', - trufflehog: 'trufflehog_3.93.1_linux_amd64.tar.gz', - opengrep: 'opengrep-core_linux_x86.tar.gz', - }, - 'win-x64': { - trivy: 'trivy_0.69.1_windows-64bit.zip', - trufflehog: 'trufflehog_3.93.1_windows_amd64.tar.gz', - opengrep: 'opengrep-core_windows_x86.zip', - }, -} - -const TOOL_REPOS = { - __proto__: null, - trivy: { owner: 'aquasecurity', repo: 'trivy', version: 'v0.69.1' }, - trufflehog: { - owner: 'trufflesecurity', - repo: 'trufflehog', - version: 'v3.93.1', - }, - opengrep: { owner: 'opengrep', repo: 'opengrep', version: 'v1.16.0' }, -} - -/** - * Download a file from GitHub releases using curl (simpler than handling - * streams). - */ -export async function downloadFile(url, destPath) { - logger.log(`Downloading: ${url}`) - - await safeMkdir(path.dirname(destPath)) - - // Use curl for simplicity. - const curlResult = await spawn('curl', ['-L', '-o', destPath, url], { - stdio: 'pipe', - }) - - if (curlResult.code !== 0) { - throw new Error(`curl failed: ${curlResult.stderr}`) - } - - const stats = await fs.stat(destPath) - logger.log(`Downloaded: ${(stats.size / 1024 / 1024).toFixed(2)} MB`) -} - -/** - * Download and extract an external tool. - */ -async function downloadTool(toolName, platform) { - const config = TOOL_REPOS[toolName] - const assetName = PLATFORM_MAP[platform]?.[toolName] - - if (!assetName) { - logger.warn(`${toolName} not available for platform: ${platform}`) - return undefined - } - - const outputDir = path.join( - __dirname, - '../../build-infra/build/external-tools-test', - platform, - ) - await safeMkdir(outputDir) - - const archivePath = path.join(outputDir, assetName) - // OpenGrep binary is named "opengrep-core" in the archive. - const archiveBinaryName = toolName === 'opengrep' ? 'opengrep-core' : toolName - const binaryName = toolName + (process.platform === 'win32' ? '.exe' : '') - const binaryPath = path.join(outputDir, binaryName) - - // Skip if already downloaded. - if (existsSync(binaryPath)) { - const stats = await fs.stat(binaryPath) - logger.log( - `Already exists: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`, - ) - return binaryPath - } - - // Download archive. - const url = `https://github.com/${config.owner}/${config.repo}/releases/download/${config.version}/${assetName}` - await downloadFile(url, archivePath) - - // Extract binary. - await extractFromTarGz(archivePath, binaryPath, archiveBinaryName) - - // Cleanup archive. - await safeDelete(archivePath) - - return binaryPath -} - -/** - * Extract binary from tar.gz archive using system tar command. - */ -async function extractFromTarGz(archivePath, outputPath, binaryName) { - logger.log(`Extracting ${binaryName} from ${path.basename(archivePath)}...`) - - // Extract to temp directory. - const tempDir = path.join(path.dirname(archivePath), 'temp-extract') - await safeMkdir(tempDir) - - // Use system tar command. - const tarResult = await spawn('tar', ['-xzf', archivePath, '-C', tempDir], { - stdio: 'pipe', - }) - - if (tarResult.code !== 0) { - throw new Error(`tar extraction failed: ${tarResult.stderr}`) - } - - // Find the binary. - const files = await fs.readdir(tempDir, { - recursive: true, - withFileTypes: true, - }) - const binaryFile = files.find( - f => - f.isFile() && (f.name === binaryName || f.name === `${binaryName}.exe`), - ) - - if (!binaryFile) { - throw new Error(`Binary ${binaryName} not found in archive`) - } - - const sourcePath = path.join( - binaryFile.parentPath || binaryFile.path, - binaryFile.name, - ) - await fs.copyFile(sourcePath, outputPath) - - if (process.platform !== 'win32') { - await fs.chmod(outputPath, 0o755) - } - - // Cleanup. - await safeDelete(tempDir) - - const stats = await fs.stat(outputPath) - logger.log( - `Extracted: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`, - ) -} - -/** - * Get current platform identifier (normalized for release naming). Uses 'win' - * instead of 'win32' for Windows. - */ -function getCurrentPlatform() { - const platform = process.platform === 'win32' ? 'win' : process.platform - const arch = process.arch - return `${platform}-${arch}` -} - -/** - * Main function. - */ -async function main() { - const platform = getCurrentPlatform() - - logger.log(`Testing external tool download for platform: ${platform}`) - logger.log('') - - const tools = ['trivy', 'trufflehog', 'opengrep'] - const toolPaths = new Map() - - for (let i = 0, { length } = tools; i < length; i += 1) { - const tool = tools[i] - try { - const toolPath = await downloadTool(tool, platform) - if (toolPath) { - toolPaths.set(tool, toolPath) - } - } catch (e) { - logger.error(`Failed to download ${tool}: ${e.message}`) - } - } - - logger.log('') - logger.log('Downloaded tools:') - let totalSize = 0 - for (const [tool, toolPath] of toolPaths) { - const stats = await fs.stat(toolPath) - const sizeMB = stats.size / 1024 / 1024 - totalSize += stats.size - logger.log(` ${tool}: ${toolPath}`) - logger.log(` Size: ${sizeMB.toFixed(2)} MB`) - } - - logger.log('') - logger.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`) - - // Create a mapping file for build script. - const mappingPath = path.join( - __dirname, - '../../build-infra/build/external-tools-test', - platform, - 'tool-paths.json', - ) - const mapping = { - __proto__: null, - platform, - tools: Object.fromEntries(toolPaths), - } - await fs.writeFile(mappingPath, JSON.stringify(mapping, null, 2)) - logger.log(`Wrote tool paths to: ${mappingPath}`) -} - -main().catch(e => { - logger.fail(e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/test-sea.mts b/packages/cli/scripts/test-sea.mts deleted file mode 100644 index 99117d7e0..000000000 --- a/packages/cli/scripts/test-sea.mts +++ /dev/null @@ -1,593 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Unified SEA test script with multiple execution modes. Consolidates - * test-sea-standalone, test-sea-vfs, and test-sea-with-tools. - * - * Usage: node scripts/test-sea.mts --mode=standalone node scripts/test-sea.mts - * --mode=vfs node scripts/test-sea.mts --mode=with-tools. - */ - -// oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: fs.stat() reads .size, not existence; per-call would produce many redundant disables. -// oxlint-disable socket/prefer-exists-sync -- all fs.stat() calls here read .size for size reporting; not existence checks. - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -/** - * Build SEA blob. - */ -async function buildBlob(configPath) { - logger.log('Generating SEA blob...') - const result = await spawn( - process.execPath, - ['--experimental-sea-config', configPath], - { - stdio: 'inherit', - }, - ) - - if (result.code !== 0) { - throw new Error(`Failed to generate SEA blob: exit code ${result.code}`) - } -} - -/** - * Display tool information. - */ -async function displayToolInfo(toolPaths) { - logger.log('External tools to bundle:') - let totalToolSize = 0 - for (const [toolName, toolPath] of Object.entries(toolPaths)) { - if (existsSync(toolPath)) { - const stats = await fs.stat(toolPath) - const sizeMB = stats.size / 1024 / 1024 - totalToolSize += stats.size - logger.log(` ${toolName}: ${sizeMB.toFixed(2)} MB`) - } - } - logger.log(` Total: ${(totalToolSize / 1024 / 1024).toFixed(2)} MB`) - logger.log('') - return totalToolSize -} - -/** - * Generate SEA configuration. - */ -export async function generateSeaConfig( - entryPoint, - outputPath, - toolPaths, - mode, -) { - const outputName = path.basename(outputPath, path.extname(outputPath)) - const configPath = path.join( - path.dirname(outputPath), - `sea-config-${mode}-${outputName}.json`, - ) - const blobPath = path.join( - path.dirname(outputPath), - `sea-blob-${mode}-${outputName}.blob`, - ) - - // For VFS mode, no assets in config (they come via external tar.gz). - // For other modes, include assets in config. - const assets = - mode === 'vfs' - ? undefined - : Object.fromEntries( - Object.entries(toolPaths) - .filter(([, toolPath]) => existsSync(toolPath)) - .map(([toolName, toolPath]) => [ - `external-tools/${toolName}`, - toolPath, - ]), - ) - - const config = { - ...(assets ? { assets } : {}), - disableExperimentalSEAWarning: true, - main: entryPoint, - output: blobPath, - useCodeCache: true, - useSnapshot: false, - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - return { blobPath, configPath } -} - -/** - * Load tool paths from previous download. - */ -async function loadToolPaths() { - const platform = `${process.platform}-${process.arch}` - const toolPathsFile = path.join( - __dirname, - '../../build-infra/build/external-tools-test', - platform, - 'tool-paths.json', - ) - - if (!existsSync(toolPathsFile)) { - logger.fail(`Tool paths not found: ${toolPathsFile}`) - logger.fail('Run: node scripts/test-download-external-tools.mts') - throw new Error('Tool paths not found') - } - - let toolPathsData - try { - toolPathsData = JSON.parse(await fs.readFile(toolPathsFile, 'utf8')) - } catch (e) { - logger.fail( - `Failed to parse tool paths from ${toolPathsFile}: ${e.message}`, - ) - logger.fail('Run: node scripts/test-download-external-tools.mts') - throw new Error('Invalid tool paths JSON') - } - return { platform, toolPaths: toolPathsData.tools } -} - -/** - * Parse command line arguments. - */ -export function parseArgs() { - const args = process.argv.slice(2) - const mode = - args - .find(a => a.startsWith('--mode=')) - ?.split('=')[1] - ?.toLowerCase() || 'with-tools' - - if (!['standalone', 'vfs', 'with-tools'].includes(mode)) { - logger.fail('Invalid mode. Use: standalone, vfs, or with-tools') - throw new Error('Invalid mode') - } - - return { mode } -} - -/** - * Mode: standalone - Uses standard Node.js + postject. - */ -async function runStandaloneMode(platform, toolPaths) { - logger.log('Mode: standalone (Node.js + postject)') - logger.log('='.repeat(60)) - logger.log('') - - const totalToolSize = await displayToolInfo(toolPaths) - - // Setup output. - const entryPoint = path.join(__dirname, 'test-entry.mts') - const outputDir = path.join(__dirname, '../dist/sea-test') - await fs.mkdir(outputDir, { recursive: true }) - const outputPath = path.join(outputDir, `socket-standalone-${platform}`) - - // Generate SEA config. - const { blobPath, configPath } = await generateSeaConfig( - entryPoint, - outputPath, - toolPaths, - 'standalone', - ) - - // Build blob. - await buildBlob(configPath) - - // Check blob size. - const blobStats = await fs.stat(blobPath) - const blobSizeMB = blobStats.size / 1024 / 1024 - logger.log(`Blob size: ${blobSizeMB.toFixed(2)} MB`) - logger.log('') - - // Copy current node binary as base. - logger.log('Copying Node.js binary as base...') - await fs.copyFile(process.execPath, outputPath) - await fs.chmod(outputPath, 0o755) - - const baseStats = await fs.stat(outputPath) - logger.log(`Base binary: ${(baseStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log('') - - // Inject blob using postject. - logger.log('Injecting blob with postject...') - const injectResult = await spawn( - 'npx', - [ - 'postject', - outputPath, - 'NODE_SEA_BLOB', - blobPath, - '--sentinel-fuse', - 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', - ...(process.platform === 'darwin' - ? ['--macho-segment-name', 'NODE_SEA'] - : []), - ], - { stdio: 'inherit' }, - ) - - if (injectResult.code !== 0) { - throw new Error('Postject injection failed') - } - - // Sign binary (required on macOS). - if (process.platform === 'darwin') { - logger.log('') - logger.log('Signing binary (macOS)...') - const signResult = await spawn('codesign', ['-s', '-', outputPath], { - stdio: 'inherit', - }) - if (signResult.code !== 0) { - throw new Error('Codesign failed') - } - } - - // Results. - const finalStats = await fs.stat(outputPath) - const finalSizeMB = finalStats.size / 1024 / 1024 - const uncompressedTotal = (totalToolSize + baseStats.size) / 1024 / 1024 - const compression = ((1 - finalSizeMB / uncompressedTotal) * 100).toFixed(1) - - logger.log('') - logger.log('='.repeat(60)) - logger.log('RESULTS') - logger.log('='.repeat(60)) - logger.log('') - logger.log( - `Tools (uncompressed): ${(totalToolSize / 1024 / 1024).toFixed(2)} MB`, - ) - logger.log( - `Base Node binary: ${(baseStats.size / 1024 / 1024).toFixed(2)} MB`, - ) - logger.log(`Blob: ${blobSizeMB.toFixed(2)} MB`) - logger.log(`Final SEA binary: ${finalSizeMB.toFixed(2)} MB`) - logger.log(`Compression: ${compression}% reduction`) - logger.log(`Savings: ${(uncompressedTotal - finalSizeMB).toFixed(2)} MB`) - logger.log('') - logger.log(`Output: ${outputPath}`) - logger.log('') - - return outputPath -} - -/** - * Mode: vfs - Uses binject with --vfs compression. - */ -async function runVfsMode(platform) { - logger.log('Mode: vfs (binject with --vfs compression)') - logger.log('='.repeat(60)) - logger.log('') - - const outputDir = path.join(__dirname, '../dist/sea-test') - const vfsTarGz = path.join(outputDir, 'external-tools.tar.gz') - const outputPath = path.join(outputDir, `socket-vfs-${platform}`) - - // Check that tar.gz exists. - if (!existsSync(vfsTarGz)) { - logger.fail(`VFS tar.gz not found: ${vfsTarGz}`) - logger.fail( - 'Create it with: tar -czf packages/cli/dist/sea-test/external-tools.tar.gz -C build-infra/build/external-tools-test/darwin-arm64 trivy trufflehog opengrep', - ) - throw new Error('VFS tar.gz not found') - } - - const vfsStats = await fs.stat(vfsTarGz) - logger.log(`VFS tar.gz: ${(vfsStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log('') - - // Create minimal SEA config (no assets). - const entryPoint = path.join(__dirname, 'test-entry.mts') - const configPath = path.join(outputDir, 'sea-config-vfs.json') - const blobPath = path.join(outputDir, 'sea-blob-vfs.blob') - - const config = { - disableExperimentalSEAWarning: true, - main: entryPoint, - output: blobPath, - useCodeCache: true, - useSnapshot: false, - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - logger.log('Generated minimal SEA config (no assets)') - logger.log('') - - // Build SEA blob. - await buildBlob(configPath) - - const blobStats = await fs.stat(blobPath) - logger.log(`Blob size: ${(blobStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log('') - - // Copy current node binary as base. - logger.log('Copying Node.js binary as base...') - await fs.copyFile(process.execPath, outputPath) - await fs.chmod(outputPath, 0o755) - - const baseStats = await fs.stat(outputPath) - logger.log(`Base binary: ${(baseStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log('') - - // Inject blob + VFS using binject. - logger.log('Injecting blob + VFS with binject...') - const binjectPath = path.join( - __dirname, - `../../build-infra/build/downloaded/binject/${platform}/binject`, - ) - - if (!existsSync(binjectPath)) { - logger.fail(`binject not found: ${binjectPath}`) - logger.fail('Run build or download binject first') - throw new Error('binject not found') - } - - const injectResult = await spawn( - binjectPath, - [ - 'inject', - '--executable', - outputPath, - '--output', - outputPath, - '--sea', - blobPath, - '--vfs', - vfsTarGz, - ], - { stdio: 'inherit' }, - ) - - if (injectResult.code !== 0) { - throw new Error('binject injection failed') - } - - // Check signing (binject may auto-sign). - if (process.platform === 'darwin') { - const checkSign = await spawn('codesign', ['-d', outputPath]) - if (checkSign.code !== 0) { - logger.log('') - logger.log('Signing binary (macOS)...') - const signResult = await spawn('codesign', ['-s', '-', outputPath], { - stdio: 'inherit', - }) - if (signResult.code !== 0) { - throw new Error('Codesign failed') - } - } else { - logger.log('') - logger.log('Binary already signed by binject') - } - } - - // Results. - const finalStats = await fs.stat(outputPath) - const finalSizeMB = finalStats.size / 1024 / 1024 - const uncompressedToolsSize = 460.78 - const uncompressedTotal = - uncompressedToolsSize + - baseStats.size / 1024 / 1024 + - blobStats.size / 1024 / 1024 - const savings = uncompressedTotal - finalSizeMB - const compressionRatio = ( - (1 - finalSizeMB / uncompressedTotal) * - 100 - ).toFixed(1) - - logger.log('') - logger.log('='.repeat(60)) - logger.log('RESULTS (binject --vfs compression)') - logger.log('='.repeat(60)) - logger.log('') - logger.log(`VFS tar.gz: ${(vfsStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log( - `Base Node binary: ${(baseStats.size / 1024 / 1024).toFixed(2)} MB`, - ) - logger.log(`Blob: ${(blobStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log(`Final SEA binary: ${finalSizeMB.toFixed(2)} MB`) - logger.log( - `Uncompressed size (Node SEA assets): ${uncompressedTotal.toFixed(2)} MB`, - ) - logger.log(`Compressed size (binject --vfs): ${finalSizeMB.toFixed(2)} MB`) - logger.log(`Compression: ${compressionRatio}% reduction`) - logger.log(`Savings: ${savings.toFixed(2)} MB`) - logger.log('') - logger.log(`Output: ${outputPath}`) - logger.log('') - - return outputPath -} - -/** - * Mode: with-tools - Uses Socket infrastructure (downloadNodeBinary + - * injectSeaBlob). - */ -async function runWithToolsMode(platform, toolPaths) { - logger.log('Mode: with-tools (Socket infrastructure)') - logger.log('='.repeat(60)) - logger.log('') - - // Dynamic import Socket modules. - const { getDefaultLogger } = await import('@socketsecurity/lib-stable/logger') - const { injectSeaBlob } = await import('./sea-build-util/builder.mts') - const { downloadNodeBinary } = await import('./sea-build-util/downloads.mts') - - const logger = getDefaultLogger() - const totalToolSize = await displayToolInfo(toolPaths) - - // Setup output. - const entryPoint = path.join(__dirname, 'test-entry.mts') - const outputDir = path.join(__dirname, '../dist/sea-test') - await fs.mkdir(outputDir, { recursive: true }) - const outputPath = path.join(outputDir, `socket-with-tools-${platform}`) - - // Generate SEA config. - const outputName = path.basename(outputPath, path.extname(outputPath)) - const configPath = path.join( - path.dirname(outputPath), - `sea-config-test-${outputName}.json`, - ) - const blobPath = path.join( - path.dirname(outputPath), - `sea-blob-test-${outputName}.blob`, - ) - - // Build assets object with security tools. - const assets = { __proto__: null } - for (const [toolName, toolPath] of Object.entries(toolPaths)) { - if (existsSync(toolPath)) { - assets[`external-tools/${toolName}`] = toolPath - const stats = await fs.stat(toolPath) - logger.log( - ` Including ${toolName}: ${(stats.size / 1024 / 1024).toFixed(2)} MB`, - ) - } - } - - const config = { - assets, - disableExperimentalSEAWarning: true, - main: entryPoint, - output: blobPath, - useCodeCache: true, - useSnapshot: false, - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - logger.log(`Wrote SEA config: ${configPath}`) - logger.log('') - - // Download node-smol binary. - logger.log('Downloading node-smol binary...') - const nodeVersion = '20251213-7cf90d2' - const nodeBinary = await downloadNodeBinary( - nodeVersion, - process.platform, - process.arch, - ) - const nodeStats = await fs.stat(nodeBinary) - logger.log( - `Node binary size: ${(nodeStats.size / 1024 / 1024).toFixed(2)} MB`, - ) - logger.log('') - - // Inject blob and VFS into binary. - // binject will generate the blob automatically using the target binary's - // Node.js version when --sea points to a config file. - logger.log('Generating SEA blob and injecting into binary...') - const cacheId = platform - await injectSeaBlob(nodeBinary, configPath, outputPath, cacheId) - - // Get blob size after it's generated by binject. - if (existsSync(blobPath)) { - const blobStats = await fs.stat(blobPath) - logger.log(`Blob size: ${(blobStats.size / 1024 / 1024).toFixed(2)} MB`) - } - - // Results. - const finalStats = await fs.stat(outputPath) - const finalSizeMB = finalStats.size / 1024 / 1024 - const compressionRatio = ( - (1 - finalSizeMB / ((totalToolSize + nodeStats.size) / 1024 / 1024)) * - 100 - ).toFixed(1) - - logger.log('') - logger.log('='.repeat(60)) - logger.log('RESULTS') - logger.log('='.repeat(60)) - logger.log('') - logger.log( - `Tools (uncompressed): ${(totalToolSize / 1024 / 1024).toFixed(2)} MB`, - ) - logger.log(`Node binary: ${(nodeStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log(`Blob: ${(blobStats.size / 1024 / 1024).toFixed(2)} MB`) - logger.log(`Final SEA binary: ${finalSizeMB.toFixed(2)} MB`) - logger.log('') - logger.log(`Output: ${outputPath}`) - logger.log('') - logger.log( - `Compression: ${compressionRatio}% reduction from uncompressed size`, - ) - logger.log('') - - return outputPath -} - -/** - * Spawn a process and return result. - */ -export function spawn(command, args, options = {}) { - return new Promise(resolve => { - const child = nodeSpawn(command, args, options) - - let stdout = '' - let stderr = '' - - if (child.stdout) { - child.stdout.on('data', data => { - stdout += data - }) - } - if (child.stderr) { - child.stderr.on('data', data => { - stderr += data - }) - } - - child.on('close', exitCode => { - resolve({ exitCode, stderr, stdout }) - }) - }) -} - -/** - * Test the generated binary. - */ -async function testBinary(outputPath) { - logger.log('Testing binary...') - logger.log('-'.repeat(60)) - const testResult = await spawn(outputPath, [], { stdio: 'inherit' }) - logger.log('-'.repeat(60)) - - if (testResult.code === 0) { - logger.success('Binary works!') - } else { - logger.fail('Binary test failed') - process.exitCode = 1 - } -} - -/** - * Main function. - */ -async function main() { - const { mode } = parseArgs() - - let outputPath - - if (mode === 'vfs') { - // VFS mode doesn't need tool paths (uses external tar.gz). - const { platform } = await loadToolPaths() - outputPath = await runVfsMode(platform) - } else { - // Other modes need tool paths. - const { platform, toolPaths } = await loadToolPaths() - - if (mode === 'standalone') { - outputPath = await runStandaloneMode(platform, toolPaths) - } else if (mode === 'with-tools') { - outputPath = await runWithToolsMode(platform, toolPaths) - } - } - - await testBinary(outputPath) -} - -main().catch(e => { - logger.fail(e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/test-wrapper.mts b/packages/cli/scripts/test-wrapper.mts deleted file mode 100644 index 40d1dd1ee..000000000 --- a/packages/cli/scripts/test-wrapper.mts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @file Test wrapper for the project. Handles test execution with Vitest, - * including: - * - * - Glob pattern expansion for test file selection - * - Memory optimization for RegExp-heavy tests - * - Cross-platform compatibility (Windows/Unix) - * - Build validation before running tests - * - Environment variable loading from .env.test (via loadEnvFile) - * - Inlined variable injection from bundle-tools.json - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import fastGlob from 'fast-glob' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { EnvironmentVariables } from './environment-variables.mts' -import { loadEnvFile } from './util/load-env.mts' - -const logger = getDefaultLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const rootNodeModulesBinPath = path.join( - rootPath, - '..', - '..', - 'node_modules', - '.bin', -) - -/** - * Check if required build artifacts exist. - */ -function checkBuildArtifacts() { - const requiredArtifacts = ['build/cli.js', 'dist/index.js'] - for (let i = 0, { length } = requiredArtifacts; i < length; i += 1) { - const artifact = requiredArtifacts[i] - const fullPath = path.join(rootPath, artifact) - if (!existsSync(fullPath)) { - logger.error(`Required build artifact missing: ${artifact}`) - logger.error('Run `pnpm build` before running tests') - return false - } - } - - return true -} - -/** - * Main test execution flow. - */ -async function main() { - try { - // Validate build artifacts exist. - if (!checkBuildArtifacts()) { - process.exitCode = 1 - return - } - - // Parse command line arguments. - let args = process.argv.slice(2) - - // Remove the -- separator if it's the first argument. - if (args[0] === '--') { - args = args.slice(1) - } - - // Check for and warn about environment variables that can cause snapshot mismatches. - // These are all aliases for the Socket API token that should not be set during tests. - const problematicEnvVars = [ - 'SOCKET_CLI_API_KEY', - 'SOCKET_CLI_API_TOKEN', - 'SOCKET_API_TOKEN', - 'SOCKET_API_TOKEN', - ] - const foundEnvVars = problematicEnvVars.filter(v => process.env[v]) - if (foundEnvVars.length > 0) { - logger.warn( - `Detected environment variable(s) that may cause snapshot test failures: ${foundEnvVars.join(', ')}`, - ) - logger.warn( - 'These will be cleared for the test run to ensure consistent snapshots.', - ) - logger.warn( - 'Tests use .env.test configuration which should not include real API tokens.', - ) - } - - // Load external tool versions for INLINED_* env vars. - // Delegate to unified EnvironmentVariables module. - const externalToolVersions = EnvironmentVariables.getTestVariables() - - const spawnEnv = { - ...process.env, - // Increase Node.js heap size to prevent out of memory errors. - // Use 8GB in CI, 4GB locally. - // Add --max-semi-space-size for better GC with RegExp-heavy tests. - NODE_OPTIONS: - `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${process.env.CI ? 8192 : 4096} --max-semi-space-size=512`.trim(), - // Clear problematic environment variables that cause snapshot mismatches. - // Tests should use .env.test configuration instead. - SOCKET_CLI_API_KEY: undefined, - SOCKET_CLI_API_TOKEN: undefined, - SOCKET_SECURITY_API_KEY: undefined, - SOCKET_SECURITY_API_TOKEN: undefined, - // Pin timezone for stable date-formatting snapshots. CI runners - // are UTC; without this developers on other timezones see - // shifted dates (a 2025-04-19T04:50Z fixture renders as Apr 18 - // in PDT). Forced here — not in `.env.test` — because the host - // shell's TZ would otherwise override anything from the env - // file. V8 caches TZ after the first Date op per-worker, so - // it must enter the worker via spawn env, not setupFiles. - TZ: 'UTC', - // Inject external tool versions (normally inlined at build time). - ...externalToolVersions, - } - - // Load .env.test configuration. - const testEnv = loadEnvFile(path.join(rootPath, '.env.test')) - - // Handle Windows vs Unix for vitest executable. - const vitestCmd = WIN32 ? 'vitest.cmd' : 'vitest' - const vitestPath = path.join(rootNodeModulesBinPath, vitestCmd) - - // Expand glob patterns in arguments. - const expandedArgs = [] - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i] - // Check if argument looks like a glob pattern. - if (arg.includes('*') && !arg.startsWith('-')) { - const files = fastGlob.sync(arg, { cwd: rootPath }) - if (files.length === 0) { - logger.warn(`No files matched pattern: ${arg}`) - } - expandedArgs.push(...files) - } else { - expandedArgs.push(arg) - } - } - - // On Windows, .cmd files need shell: true. - const spawnOptions = { - cwd: rootPath, - env: { - ...testEnv, - ...spawnEnv, - }, - stdio: 'inherit', - ...(WIN32 ? { shell: true } : {}), - } - - // --passWithNoTests: a scoped run where the expanded args don't - // resolve to any test file should succeed rather than error with - // "No test files found". Keeps pre-commit hooks passing when an edit - // touches only non-testable code. - const result = await spawn( - vitestPath, - ['run', '--passWithNoTests', ...expandedArgs], - spawnOptions, - ) - // `code === null` means the process was killed by a signal — treat - // as a failure so SIGKILL / SIGABRT aren't silently reported as 0. - process.exitCode = typeof result?.code === 'number' ? result.code : 1 - } catch (e) { - logger.error('Failed to spawn test process:', e) - process.exitCode = 1 - } -} - -main().catch(e => { - logger.error('Unexpected error:', e) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/util/asset-manager-compat.mts b/packages/cli/scripts/util/asset-manager-compat.mts deleted file mode 100644 index 6250ca41b..000000000 --- a/packages/cli/scripts/util/asset-manager-compat.mts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * @file Backward-compatible wrappers for AssetManager. Maintains existing API - * signatures from sea-build-util/downloads.mts while using the unified - * AssetManager internally. Phase 1 of AssetManager migration - provides - * drop-in replacements without modifying existing code. - */ - -import { existsSync, readFileSync } from 'node:fs' - -import { AssetManager } from './asset-manager.mts' - -// Cache for libc detection (only need to check once per process). -let cachedLibc - -/** - * Detect if running on musl libc (Alpine Linux, etc.). Uses multiple detection - * methods for reliability. - * - * @returns {boolean} True if running on musl libc. - */ -export function detectMusl() { - // Only check on Linux. - if (process.platform !== 'linux') { - return false - } - - // Check cached result. - if (cachedLibc !== undefined) { - return cachedLibc === 'musl' - } - - // Method 1: Check /etc/os-release for Alpine. - try { - if (existsSync('/etc/os-release')) { - const osRelease = readFileSync('/etc/os-release', 'utf8') - if (osRelease.includes('Alpine') || osRelease.includes('alpine')) { - cachedLibc = 'musl' - return true - } - } - } catch { - // Ignore errors, try next method. - } - - // Method 2: Check if ld-musl dynamic linker exists. - try { - if ( - existsSync('/lib/ld-musl-x86_64.so.1') || - existsSync('/lib/ld-musl-aarch64.so.1') - ) { - cachedLibc = 'musl' - return true - } - } catch { - // Ignore errors. - } - - // Method 3: Check /proc/version for musl indicators. - try { - if (existsSync('/proc/version')) { - const version = readFileSync('/proc/version', 'utf8') - if (version.includes('musl')) { - cachedLibc = 'musl' - return true - } - } - } catch { - // Ignore errors. - } - - cachedLibc = 'glibc' - return false -} - -/** - * Shared AssetManager instance for all wrapper functions. Uses default - * configuration matching downloads.mts behavior. - */ -const assetManager = new AssetManager({ - cacheEnabled: true, - quiet: false, -}) - -/** - * Download Node.js binary for a specific platform (backward-compatible - * wrapper). Maintains exact API signature from sea-build-util/downloads.mts. - * - * @example - * const nodePath = await downloadNodeBinary('20251213-7cf90d2', 'darwin', 'arm64') - * // Returns: /path/to/build-infra/build/downloaded/node-smol/darwin-arm64/node - * - * @param {string} version - Node.js version tag suffix (e.g., - * "20251213-7cf90d2"). - * @param {string} platform - Platform identifier (darwin, linux, win32). - * @param {string} arch - Architecture identifier (arm64, x64). - * @param {string} [libc] - Linux libc variant ('musl' for Alpine, undefined for - * glibc). - * - * @returns {Promise<string>} Absolute path to downloaded node binary. - */ -export async function downloadNodeBinary(version, platform, arch, libc) { - return assetManager.downloadBinary({ - arch, - libc, - localOverride: 'SOCKET_CLI_LOCAL_NODE_SMOL', - platform, - tool: 'node-smol', - version, - }) -} - -/** - * Download binject binary for the current platform (backward-compatible - * wrapper). Maintains exact API signature from sea-build-util/downloads.mts. - * - * @example - * const binjectPath = await downloadBinject('1.0.0') - * // Returns: /path/to/build-infra/build/downloaded/binject/darwin-arm64/binject - * - * @param {string} version - Binject version (e.g., "1.0.0"). - * - * @returns {Promise<string>} Absolute path to downloaded binject binary. - */ -export async function downloadBinject(version) { - const platform = process.platform - const arch = process.arch - - // Detect actual libc on Linux (musl for Alpine, glibc for standard distros). - const libc = detectMusl() ? 'musl' : undefined - - return assetManager.downloadBinary({ - arch, - libc, - platform, - tool: 'binject', - version, - }) -} - -/** - * Get the latest binject release version from socket-btm. Returns the version - * string (e.g., "1.0.0"). - * - * Note: This function currently delegates to the original implementation in - * sea-build-util/downloads.mts. Future enhancement: move to AssetManager. - * - * @example - * const version = await getLatestBinjectVersion() - * // "1.0.0" - * - * @returns {Promise<string>} Binject version string. - * - * @throws {Error} When socket-btm releases cannot be fetched. - */ -export async function getLatestBinjectVersion() { - // Delegate to original implementation for now. - // TODO: Move this to AssetManager in Phase 4. - const { getLatestBinjectVersion: getLatest } = - await import('../sea-build-util/downloads.mts') - return getLatest() -} diff --git a/packages/cli/scripts/util/asset-manager.mts b/packages/cli/scripts/util/asset-manager.mts deleted file mode 100644 index d3d25af22..000000000 --- a/packages/cli/scripts/util/asset-manager.mts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * @file Unified asset manager for socket-btm releases. Consolidates download - * functionality from download-assets.mts and sea-build-util/downloads.mts. - * This module provides: - * - * - Unified binary downloads (node-smol, binject) - * - Version caching and validation - * - Platform/arch normalization - * - GitHub API authentication Phase 1 (Foundation): Core class implementation - * without migration. Existing download functions remain unchanged for - * backward compatibility. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { logTransientErrorHelp } from 'build-infra/lib/github-error-utils' -import { downloadReleaseAsset } from 'build-infra/lib/github-releases' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { ARCH_MAP, PLATFORM_MAP } from '../constants/platform-mappings.mts' - -// ============================================================================= -// Constants and Utilities. -// ============================================================================= - -/** - * Get the monorepo root path. - * - * @returns Absolute path to monorepo root. - */ -export function getRootPath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - return path.join(__dirname, '../../../..') -} - -// ============================================================================= -// AssetManager Class. -// ============================================================================= - -/** - * Unified asset manager for downloading and caching socket-btm releases. - * - * @example - * const manager = new AssetManager() - * const nodePath = await manager.downloadBinary({ - * tool: 'node-smol', - * version: '20251213-7cf90d2', - * platform: 'darwin', - * arch: 'arm64', - * }) - */ -export class AssetManager { - /** - * Create a new AssetManager instance. - * - * @param {Object} [options] - Configuration options. - * @param {string} [options.downloadDir] - Base directory for downloads - * (default: build-infra/build/downloaded). - * @param {boolean} [options.quiet] - Suppress logs (default: false). - * @param {boolean} [options.cacheEnabled] - Enable version caching (default: - * true). - */ - constructor(options = {}) { - const { - cacheEnabled = true, - downloadDir, - quiet = false, - } = { - __proto__: null, - ...options, - } - - this.cacheEnabled = cacheEnabled - this.logger = getDefaultLogger() - this.quiet = quiet - - // Default download directory: socket-cli/packages/build-infra/build/downloaded/ - const rootPath = getRootPath() - this.downloadDir = - downloadDir || - normalizePath( - path.join(rootPath, 'packages/build-infra/build/downloaded'), - ) - } - - /** - * Get GitHub API authentication headers. Uses GH_TOKEN or GITHUB_TOKEN - * environment variables if available. - * - * @returns {Object} Headers object for GitHub API requests. - */ - getAuthHeaders() { - const token = process.env['GH_TOKEN'] || process.env['GITHUB_TOKEN'] - return { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(token && { Authorization: `Bearer ${token}` }), - } - } - - /** - * Get platform-arch identifier with optional libc suffix. - * - * @param {string} platform - Platform identifier (darwin, linux, win32). - * @param {string} arch - Architecture identifier (arm64, x64, ia32). - * @param {string} [libc] - Linux libc variant ('musl' for Alpine). - * - * @returns {string} Platform-arch identifier (e.g., 'darwin-arm64', - * 'linux-x64-musl'). - */ - getPlatformArch(platform, arch, libc) { - const muslSuffix = libc === 'musl' ? '-musl' : '' - return `${platform}-${arch}${muslSuffix}` - } - - /** - * Get download directory for a specific tool and platform. - * - * @param {string} tool - Tool name (node-smol, binject). - * @param {string} platformArch - Platform-arch identifier. - * - * @returns {string} Absolute path to download directory. - */ - getDownloadDir(tool, platformArch) { - return normalizePath(path.join(this.downloadDir, tool, platformArch)) - } - - /** - * Validate cached version matches expected tag. Checks .version file content - * and returns true if valid. - * - * @param {string} versionPath - Path to .version file. - * @param {string} expectedTag - Expected version tag. - * @param {string} tagPrefix - Required tag prefix for validation (e.g., - * 'node-smol-'). - * - * @returns {Promise<boolean>} True if cache is valid. - */ - async validateCache(versionPath, expectedTag, tagPrefix) { - if (!existsSync(versionPath)) { - return false - } - - const content = (await fs.readFile(versionPath, 'utf8')).trim() - - // Validate version format to prevent empty/corrupted version files. - if (!content || content.length === 0) { - this.logger.warn(`Invalid version file at ${versionPath}, clearing cache`) - return false - } - - // Validate tag prefix if provided. - if (tagPrefix && !content.startsWith(tagPrefix)) { - this.logger.warn(`Invalid version file at ${versionPath}, clearing cache`) - return false - } - - return content === expectedTag - } - - /** - * Clear stale cache directory with verification. - * - * @param {string} cacheDir - Directory to clear. - * - * @returns {Promise<void>} - */ - async clearStaleCache(cacheDir) { - if (!existsSync(cacheDir)) { - return - } - - this.logger.log('Clearing stale cache...') - - try { - await safeDelete(cacheDir) - - // Verify deletion succeeded. - if (existsSync(cacheDir)) { - throw new Error(`Failed to clear cache directory: ${cacheDir}`) - } - } catch (e) { - this.logger.error(`Cache clear failed: ${e.message}`) - throw new Error( - `Cannot clear stale cache at ${cacheDir}. ` + - 'Please delete manually or use local override environment variables.', - ) - } - } - - /** - * Download a binary asset (node-smol or binject). - * - * @param {Object} config - Download configuration. - * @param {string} config.tool - Tool name ('node-smol' or 'binject'). - * @param {string} config.version - Version tag suffix (e.g., - * '20251213-7cf90d2'). - * @param {string} config.platform - Platform identifier (darwin, linux, - * win32). - * @param {string} config.arch - Architecture identifier (arm64, x64). - * @param {string} [config.libc] - Linux libc variant ('musl' for Alpine). - * @param {string} [config.localOverride] - Environment variable name for - * local file override. - * - * @returns {Promise<string>} Absolute path to downloaded binary. - */ - async downloadBinary(config) { - const { arch, libc, localOverride, platform, tool, version } = { - __proto__: null, - ...config, - } - - // Check for local override environment variable. - if (localOverride) { - const localPath = process.env[localOverride] - if (localPath && existsSync(localPath)) { - this.logger.log(`Using local ${tool} from: ${localPath}`) - return localPath - } - - if (localPath && !existsSync(localPath)) { - this.logger.warn( - `${localOverride} is set but file not found: ${localPath}`, - ) - this.logger.warn( - `Falling back to downloaded ${tool} from GitHub releases`, - ) - } - } - - const isPlatWin = platform === 'win32' - const platformArch = this.getPlatformArch(platform, arch, libc) - const toolDir = this.getDownloadDir(tool, platformArch) - - // Determine binary filename based on platform. - const isNodeSmol = tool === 'node-smol' - const binaryName = isNodeSmol ? 'node' : tool - const binaryFilename = isPlatWin ? `${binaryName}.exe` : binaryName - const binaryPath = normalizePath(path.join(toolDir, binaryFilename)) - const versionPath = normalizePath(path.join(toolDir, '.version')) - - // Build full tag (e.g., 'node-smol-20251213-7cf90d2'). - const tag = `${tool}-${version}` - - // Create lock file to prevent concurrent downloads (TOCTOU mitigation). - const lockFile = normalizePath(path.join(toolDir, '.downloading')) - - await safeMkdir(toolDir) - - try { - // Try to create lock file atomically (wx = write + exclusive). - await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }) - } catch (e) { - if (e.code === 'EEXIST') { - // Another process is downloading, wait and check for completion. - this.logger.log(`Another process is downloading ${tool}, waiting...`) - for (let i = 0; i < 60; i++) { - await new Promise(resolve => { - setTimeout(resolve, 1_000) - }) - // Check if cached version matches requested version. - const tagPrefix = `${tool}-` - const cacheValid = await this.validateCache( - versionPath, - tag, - tagPrefix, - ) - if (cacheValid && existsSync(binaryPath)) { - return binaryPath - } - } - throw new Error( - `Timeout waiting for another process to download ${tool}`, - ) - } - throw e - } - - try { - // Check if cached version matches requested version. - const tagPrefix = `${tool}-` - const cacheValid = await this.validateCache(versionPath, tag, tagPrefix) - - if (cacheValid && existsSync(binaryPath)) { - return binaryPath - } - - // Clear stale cache if it exists. - if (existsSync(toolDir)) { - // Remove version file and binary, but keep lock file. - if (existsSync(versionPath)) { - await safeDelete(versionPath) - } - if (existsSync(binaryPath)) { - await safeDelete(binaryPath) - } - } - - // Map platform/arch to socket-btm release asset names. - const mappedPlatform = PLATFORM_MAP[platform] - const mappedArch = ARCH_MAP[arch] - - if (!mappedPlatform || !mappedArch) { - throw new Error(`Unsupported platform/arch: ${platform}/${arch}`) - } - - // Build asset filename. - // Format: {tool}-{platform}-{arch}[-musl][.exe] - const muslSuffix = libc === 'musl' ? '-musl' : '' - const assetFilename = `${binaryName}-${mappedPlatform}-${mappedArch}${muslSuffix}${isPlatWin ? '.exe' : ''}` - - this.logger.log(`Downloading ${tool} from socket-btm ${tag}...`) - - // Download using github-releases helper (handles HTTP 302 redirects automatically). - try { - await downloadReleaseAsset( - 'SocketDev', - 'socket-btm', - tag, - assetFilename, - binaryPath, - ) - } catch (e) { - await logTransientErrorHelp(e) - throw e - } - - // Write version file (store full tag for consistency). - await fs.writeFile(versionPath, tag, 'utf8') - - // Make executable on Unix. - if (!isPlatWin) { - await fs.chmod(binaryPath, 0o755) - } - - return binaryPath - } finally { - // Clean up lock file. - try { - if (existsSync(lockFile)) { - await safeDelete(lockFile) - } - } catch { - // Ignore cleanup errors. - } - } - } -} diff --git a/packages/cli/scripts/util/changed-test-mapper.mts b/packages/cli/scripts/util/changed-test-mapper.mts deleted file mode 100644 index f7efcce27..000000000 --- a/packages/cli/scripts/util/changed-test-mapper.mts +++ /dev/null @@ -1,469 +0,0 @@ -/** - * @file Maps changed source files to test files for affected test running. Uses - * git utilities from socket-registry to detect changes. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { getChangedFilesSync } from '@socketsecurity/lib-stable/git/changed' -import { getStagedFilesSync } from '@socketsecurity/lib-stable/git/staged' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { PACKAGE_ROOT } from '../paths.mts' - -const rootPath = PACKAGE_ROOT - -/** - * Core files that require running all tests when changed. - */ -const CORE_FILES = [ - 'src/constants/config.mts', - 'src/constants/errors.mts', - 'src/util/config.mts', - 'src/util/error', -] - -/** - * Get affected test files to run based on changed files. - * - * @param {Object} options - * @param {boolean} options.staged - Use staged files instead of all changes. - * @param {boolean} options.all - Run all tests. - * - * @returns {{ tests: string[] | 'all' | null; reason?: string; mode?: string }} - * Object with test patterns, reason, and mode. - */ -function getTestsToRun(options = {}) { - const { all = false, staged = false } = options - - // All mode runs all tests - if (all || process.env.FORCE_TEST === '1') { - return { tests: 'all', reason: 'explicit --all flag', mode: 'all' } - } - - // CI always runs all tests - if (process.env.CI === 'true') { - return { tests: 'all', reason: 'CI environment', mode: 'all' } - } - - // Get changed files - const changedFiles = staged ? getStagedFilesSync() : getChangedFilesSync() - const mode = staged ? 'staged' : 'changed' - - if (changedFiles.length === 0) { - // No changes, skip tests - return { tests: undefined, mode } - } - - const testFiles = new Set() - let runAllTests = false - let runAllReason = '' - - for (let i = 0, { length } = changedFiles; i < length; i += 1) { - const file = changedFiles[i] - const normalized = normalizePath(file) - - // Test files always run themselves (both in test/ and co-located in src/) - if (normalized.includes('.test.')) { - // Skip deleted files. - if (existsSync(path.join(rootPath, file))) { - testFiles.add(file) - } - continue - } - - // Source files map to test files - if (normalized.startsWith('src/')) { - const tests = mapSourceToTests(normalized) - if (tests.includes('all')) { - runAllTests = true - runAllReason = 'core file changes' - break - } - for (let i = 0, { length } = tests; i < length; i += 1) { - const test = tests[i] - // Skip deleted files. - if (existsSync(path.join(rootPath, test))) { - testFiles.add(test) - } - } - continue - } - - // Config changes run all tests - if (normalized.includes('vitest.config')) { - runAllTests = true - runAllReason = 'vitest config changed' - break - } - - if (normalized.includes('tsconfig')) { - runAllTests = true - runAllReason = 'TypeScript config changed' - break - } - - // Data changes may affect integration tests - if (normalized.startsWith('data/')) { - // Check if integration tests exist in test directory - const integrationDir = path.join(rootPath, 'test/integration') - if (existsSync(integrationDir)) { - testFiles.add('test/integration/**/*.test.mts') - } - } - - // Config file changes - if (normalized.includes('package.json')) { - runAllTests = true - runAllReason = 'package.json changed' - break - } - } - - if (runAllTests) { - return { tests: 'all', reason: runAllReason, mode: 'all' } - } - - if (testFiles.size === 0) { - return { tests: undefined, mode } - } - - return { tests: Array.from(testFiles), mode } -} - -/** - * Map source files to their corresponding test files. - * - * @param {string} filepath - Path to source file. - * - * @returns {string[]} Array of test file paths - */ -function mapSourceToTests(filepath) { - const normalized = normalizePath(filepath) - - // Skip non-code files - const ext = path.extname(normalized) - const codeExtensions = ['.js', '.mjs', '.cjs', '.ts', '.cts', '.mts', '.json'] - if (!codeExtensions.includes(ext)) { - return [] - } - - // Core utilities affect all tests. - if (CORE_FILES.some(f => normalized.includes(f))) { - return ['all'] - } - - // CLI-specific command mappings for files with multiple related tests. - // Commands with malware tests (npm, npx, pnpm, yarn). - if (normalized.includes('src/commands/npm/cmd-npm.mts')) { - return [ - 'src/commands/npm/cmd-npm.test.mts', - 'src/commands/npm/cmd-npm-malware.test.mts', - ] - } - if (normalized.includes('src/commands/npx/cmd-npx.mts')) { - return [ - 'src/commands/npx/cmd-npx.test.mts', - 'src/commands/npx/cmd-npx-malware.test.mts', - ] - } - if (normalized.includes('src/commands/pnpm/cmd-pnpm.mts')) { - return [ - 'src/commands/pnpm/cmd-pnpm.test.mts', - 'src/commands/pnpm/cmd-pnpm-malware.test.mts', - ] - } - if (normalized.includes('src/commands/yarn/cmd-yarn.mts')) { - return [ - 'src/commands/yarn/cmd-yarn.test.mts', - 'src/commands/yarn/cmd-yarn-malware.test.mts', - ] - } - - // Commands with smoke tests. - if (normalized.includes('src/commands/login/cmd-login.mts')) { - return [ - 'src/commands/login/cmd-login.test.mts', - 'src/commands/login/cmd-login-smoke.test.mts', - ] - } - if (normalized.includes('src/commands/repository/cmd-repository.mts')) { - return [ - 'src/commands/repository/cmd-repository.test.mts', - 'src/commands/repository/cmd-repository-smoke.test.mts', - ] - } - - // Commands with e2e tests. - if (normalized.includes('src/commands/fix/cmd-fix.mts')) { - return [ - 'src/commands/fix/cmd-fix.test.mts', - 'src/commands/fix/cmd-fix-e2e.test.mts', - ] - } - - // Commands with additional test files. - if (normalized.includes('src/commands/optimize/cmd-optimize.mts')) { - return [ - 'src/commands/optimize/cmd-optimize.test.mts', - 'src/commands/optimize/cmd-optimize-pnpm-versions.test.mts', - ] - } - - // CLI uses co-located tests - check for test file next to source. - // src/commands/scan.mts → src/commands/scan.test.mts - // src/util/helper.mts → src/util/helper.test.mts - const dir = path.dirname(normalized) - const basename = path.basename(normalized, path.extname(normalized)) - const ext2 = path.extname(basename) - const nameWithoutExt = basename.replace(ext2, '') - const colocatedTestFile = path.join(dir, `${nameWithoutExt}.test.mts`) - - // Check if co-located test exists. - if (existsSync(path.join(rootPath, colocatedTestFile))) { - return [colocatedTestFile] - } - - // Check test directory for separate test files - const testFile = `test/${nameWithoutExt}.test.mts` - if (existsSync(path.join(rootPath, testFile))) { - return [testFile] - } - - // Commands may have multiple related tests - check subdirectory pattern - // src/commands/scan/handler.mts → src/commands/scan/*.test.mts - if (normalized.startsWith('src/commands/')) { - const commandMatch = normalized.match(/src\/commands\/([^/]+)\//) - if (commandMatch) { - const commandName = commandMatch[1] - const commandDir = `src/commands/${commandName}` - // Return pattern to match all tests in command directory - return [`${commandDir}/**/*.test.mts`] - } - } - - // Utils may have related tests in test/utils - if (normalized.startsWith('src/util/')) { - // Specific utility file mappings - if (normalized.includes('src/util/alert/translations.mts')) { - return ['src/util/alert/translations.test.mts'] - } - if (normalized.includes('src/util/cache-strategies.mts')) { - return ['test/util/cache-strategies.test.mts'] - } - if (normalized.includes('src/util/cli/completion.mts')) { - return ['src/util/cli/completion.test.mts'] - } - if (normalized.includes('src/util/cli/messages.mts')) { - return ['src/util/cli/messages.test.mts'] - } - if (normalized.includes('src/util/cli/with-subcommands.mts')) { - return ['src/util/cli/with-subcommands.test.mts'] - } - if (normalized.includes('src/util/coana/extract-scan-id.mts')) { - return ['src/util/coana/extract-scan-id.test.mts'] - } - if (normalized.includes('src/util/command/registry-core.mts')) { - return ['src/util/command/registry-core.test.mts'] - } - if (normalized.includes('src/util/config.mts')) { - return ['src/util/config.test.mts'] - } - if (normalized.includes('src/util/data/map-to-object.mts')) { - return ['src/util/data/map-to-object.test.mts'] - } - if (normalized.includes('src/util/data/objects.mts')) { - return ['src/util/data/objects.test.mts'] - } - if (normalized.includes('src/util/data/strings.mts')) { - return ['src/util/data/strings.test.mts'] - } - if (normalized.includes('src/util/data/walk-nested-map.mts')) { - return ['src/util/data/walk-nested-map.test.mts'] - } - if (normalized.includes('src/util/debug.mts')) { - return ['src/util/debug.test.mts'] - } - if (normalized.includes('src/util/dlx/binary.mts')) { - return ['src/util/dlx/binary.test.mts'] - } - if (normalized.includes('src/util/dlx/detection.mts')) { - return ['src/util/dlx/detection.test.mts'] - } - if (normalized.includes('src/util/dlx/spawn.mts')) { - return ['src/util/dlx/spawn.e2e.test.mts'] - } - if (normalized.includes('src/util/ecosystem/types.mts')) { - return ['src/util/ecosystem/ecosystem.test.mts'] - } - if (normalized.includes('src/util/ecosystem/environment.mts')) { - return ['src/util/ecosystem/environment.test.mts'] - } - if (normalized.includes('src/util/ecosystem/requirements.mts')) { - return ['src/util/ecosystem/requirements.test.mts'] - } - if (normalized.includes('src/util/ecosystem/spec.mts')) { - return ['src/util/ecosystem/spec.test.mts'] - } - if (normalized.includes('src/util/error/errors.mts')) { - return ['src/util/error/errors.test.mts'] - } - if (normalized.includes('src/util/error/fail-msg-with-badge.mts')) { - return ['src/util/error/fail-msg-with-badge.test.mts'] - } - if (normalized.includes('src/util/executable/detect.mts')) { - return ['src/util/executable/detect.test.mts'] - } - if (normalized.includes('src/util/fs/fs.mts')) { - return ['src/util/fs/fs.test.mts'] - } - if (normalized.includes('src/util/fs/home-path.mts')) { - return ['src/util/fs/home-path.test.mts'] - } - if (normalized.includes('src/util/fs/path-resolve.mts')) { - return ['src/util/fs/path-resolve.test.mts'] - } - if (normalized.includes('src/util/git/operations.mts')) { - return ['src/util/git/git.test.mts'] - } - if (normalized.includes('src/util/git/github.mts')) { - return ['src/util/git/github.test.mts'] - } - if (normalized.includes('src/util/home-cache-time.mts')) { - return ['src/util/home-cache-time.test.mts'] - } - if (normalized.includes('src/util/manifest/patch-backup.mts')) { - return ['src/util/manifest/patch-backup.test.mts'] - } - if (normalized.includes('src/util/manifest/patch-hash.mts')) { - return ['src/util/manifest/patch-hash.test.mts'] - } - if (normalized.includes('src/util/manifest/patches.mts')) { - return ['src/util/manifest/patches.test.mts'] - } - if (normalized.includes('src/util/memoization.mts')) { - return ['test/util/memoization.test.mts'] - } - if (normalized.includes('src/util/npm/config.mts')) { - return ['src/util/npm/config.test.mts'] - } - if (normalized.includes('src/util/npm/package-arg.mts')) { - return ['src/util/npm/package-arg.test.mts'] - } - if (normalized.includes('src/util/npm/paths.mts')) { - return ['src/util/npm/paths.test.mts'] - } - if (normalized.includes('src/util/npm/spec.mts')) { - return ['src/util/npm/spec.test.mts'] - } - if (normalized.includes('src/util/organization.mts')) { - return ['src/util/organization.test.mts'] - } - if (normalized.includes('src/util/output/formatting.mts')) { - return ['src/util/output/formatting.test.mts'] - } - if (normalized.includes('src/util/output/markdown.mts')) { - return ['src/util/output/markdown.test.mts'] - } - if (normalized.includes('src/util/output/mode.mts')) { - return ['src/util/output/mode.test.mts'] - } - if (normalized.includes('src/util/output/result-json.mts')) { - return ['src/util/output/result-json.test.mts'] - } - if (normalized.includes('src/util/pnpm/lockfile.mts')) { - return ['src/util/pnpm/lockfile.test.mts'] - } - if (normalized.includes('src/util/pnpm/paths.mts')) { - return ['src/util/pnpm/paths.test.mts'] - } - if (normalized.includes('src/util/process/cmd.mts')) { - return ['src/util/process/cmd.test.mts'] - } - if (normalized.includes('src/util/process/performance.mts')) { - return ['test/util/performance.test.mts'] - } - if (normalized.includes('src/util/promise/queue.mts')) { - return ['src/util/promise/queue.test.mts'] - } - if (normalized.includes('src/util/purl/parse.mts')) { - return ['src/util/purl/parse.test.mts'] - } - if (normalized.includes('src/util/purl/to-ghsa.mts')) { - return ['src/util/purl/to-ghsa.test.mts'] - } - if (normalized.includes('src/util/python/standalone.mts')) { - return ['src/util/python/standalone.test.mts'] - } - if (normalized.includes('src/util/sanitize-names.mts')) { - return ['src/util/sanitize-names.test.mts'] - } - if (normalized.includes('src/util/semver.mts')) { - return ['src/util/semver.test.mts'] - } - if (normalized.includes('src/util/socket/alerts.mts')) { - return ['src/util/socket/alerts.test.mts'] - } - if (normalized.includes('src/util/socket/api.mts')) { - return ['src/util/socket/api.test.mts'] - } - if (normalized.includes('src/util/socket/json.mts')) { - return ['src/util/socket/json.test.mts'] - } - if (normalized.includes('src/util/socket/org-slug.mts')) { - return ['src/util/socket/org-slug.test.mts'] - } - if (normalized.includes('src/util/socket/package-alert.mts')) { - return ['src/util/socket/package-alert.test.mts'] - } - if (normalized.includes('src/util/socket/sdk.mts')) { - return ['src/util/socket/sdk.test.mts'] - } - if (normalized.includes('src/util/socket/url.mts')) { - return ['src/util/socket/url.test.mts'] - } - if (normalized.includes('src/util/terminal/ascii-header.mts')) { - return ['src/util/terminal/ascii-header.test.mts'] - } - if (normalized.includes('src/util/terminal/colors.mts')) { - return ['src/util/terminal/colors.test.mts'] - } - if (normalized.includes('src/util/terminal/link.mts')) { - return ['src/util/terminal/link.test.mts'] - } - if (normalized.includes('src/util/update/checker.mts')) { - return ['src/util/update/checker.test.mts'] - } - if (normalized.includes('src/util/update/manager.mts')) { - return ['src/util/update/manager.test.mts'] - } - if (normalized.includes('src/util/update/store.mts')) { - return ['src/util/update/store.test.mts'] - } - if (normalized.includes('src/util/validation/check-input.mts')) { - return ['src/util/validation/check-input.test.mts'] - } - if (normalized.includes('src/util/validation/filter-config.mts')) { - return ['src/util/validation/filter-config.test.mts'] - } - if (normalized.includes('src/util/wordpiece-tokenizer.mts')) { - return ['src/util/wordpiece-tokenizer.test.mts'] - } - if (normalized.includes('src/util/yarn/paths.mts')) { - return ['src/util/yarn/paths.test.mts'] - } - if (normalized.includes('src/util/yarn/version.mts')) { - return ['src/util/yarn/version.test.mts'] - } - - // Fallback: check test/util/ for separate test file - const utilsTestFile = `test/util/${nameWithoutExt}.test.mts` - if (existsSync(path.join(rootPath, utilsTestFile))) { - return [utilsTestFile] - } - } - - // If no specific mapping, run all tests to be safe - return ['all'] -} diff --git a/packages/cli/scripts/util/fs.mts b/packages/cli/scripts/util/fs.mts deleted file mode 100644 index 60f6574e9..000000000 --- a/packages/cli/scripts/util/fs.mts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file File system utilities for build scripts. - */ - -import { statSync } from 'node:fs' -import path from 'node:path' - -/** - * Find a file or directory by walking up parent directories. Similar to find-up - * but synchronous and minimal. - */ -function findUpSync(name, options) { - const opts = { __proto__: null, ...options } - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- caller-overridable default; callers always pass a script-anchored cwd or accept the cwd they invoke from. - const { cwd = process.cwd() } = opts - let { onlyDirectories = false, onlyFiles = true } = opts - if (onlyDirectories) { - onlyFiles = false - } - if (onlyFiles) { - onlyDirectories = false - } - let dir = path.resolve(cwd) - const { root } = path.parse(dir) - const names = [name].flat() - // Search up to and including root directory. - while (dir) { - for (let i = 0, { length } = names; i < length; i += 1) { - const name = names[i] - const filePath = path.join(dir, name) - try { - const stats = statSync(filePath, { throwIfNoEntry: false }) - if (!onlyDirectories && stats?.isFile()) { - return filePath - } - if (!onlyFiles && stats?.isDirectory()) { - return filePath - } - } catch {} - } - // Stop after checking root directory. - if (dir === root) { - break - } - dir = path.dirname(dir) - } - return undefined -} diff --git a/packages/cli/scripts/util/load-env.mts b/packages/cli/scripts/util/load-env.mts deleted file mode 100644 index ef2b524f6..000000000 --- a/packages/cli/scripts/util/load-env.mts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file Minimal .env file parser for build and test scripts. - */ - -import { readFileSync } from 'node:fs' - -/** - * Parse a .env file and return key-value pairs. Supports comments (#), blank - * lines, KEY=value, KEY="value", KEY='value'. Returns an empty object if the - * file does not exist. - */ -export function loadEnvFile(filePath: string): Record<string, string> { - const env: Record<string, string> = { __proto__: null } as Record< - string, - string - > - let content: string - try { - content = readFileSync(filePath, 'utf-8') - } catch { - return env - } - for (const line of content.split('\n')) { - const trimmed = line.trim() - // Skip comments and blank lines. - if (!trimmed || trimmed.startsWith('#')) { - continue - } - const eqIndex = trimmed.indexOf('=') - if (eqIndex === -1) { - continue - } - const key = trimmed.slice(0, eqIndex).trim() - let value = trimmed.slice(eqIndex + 1).trim() - // Strip surrounding quotes. - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) - } - env[key] = value - } - return env -} diff --git a/packages/cli/scripts/util/patches.mts b/packages/cli/scripts/util/patches.mts deleted file mode 100644 index a32b13162..000000000 --- a/packages/cli/scripts/util/patches.mts +++ /dev/null @@ -1,251 +0,0 @@ - -/** - * @file Utilities for creating pnpm patches using Babel AST + MagicString. - * Provides helpers for transforming node_modules files and generating patch - * files. - */ - -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { parse } from '@babel/core' -import MagicString from 'magic-string' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -/** - * Run pnpm patch-commit command to finalize patch. - * - * @param {string} patchPath - Path to temporary patch directory. - * @param {string} packageName - Package name for logging. - */ -async function commitPatch(patchPath, packageName) { - logger.log(`Committing patch for ${packageName}...`) - const result = await spawn('pnpm', ['patch-commit', patchPath], { - shell: WIN32, - stdio: 'inherit', - }) - - if (result.code !== 0) { - throw new Error(`Failed to commit patch for ${packageName}`) - } - - logger.log(`✓ Patch created for ${packageName}`) -} - -/** - * Create a patch from a patch definition. - * - * @param {object} patchDef - Patch definition object. - * @param {string} patchDef.packageName - Package name (e.g., 'debug'). - * @param {string} patchDef.version - Package version (e.g., '4.4.3'). - * @param {string} patchDef.description - Description of what the patch does. - * @param {string[]} patchDef.files - Array of file paths to transform. - * @param {Function} patchDef.transform - Transform function. - * - * @returns {Promise<void>} - */ -async function createPatch(patchDef) { - const { description, files, packageName, transform, version } = patchDef - const packageSpec = `${packageName}@${version}` - - logger.log('') - logger.log(`=== Creating patch: ${packageName} ===`) - logger.log(`Description: ${description}`) - - let patchPath - try { - // Start pnpm patch. - patchPath = await startPatch(packageSpec) - - // Transform each file. - const utils = { - MagicString, - parseCode, - readFile: filePath => readPatchFile(patchPath, filePath), - writeFile: (filePath, content) => - writePatchFile(patchPath, filePath, content), - } - - let hasChanges = false - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - logger.log(`Transforming ${file}...`) - const changed = await transform(file, utils) - if (changed) { - hasChanges = true - logger.log(`✓ Transformed ${file}`) - } else { - logger.log(`- No changes needed for ${file}`) - } - } - - if (!hasChanges) { - logger.log('No changes made, skipping patch commit') - // Cleanup temp directory. - if (existsSync(patchPath)) { - rmSync(patchPath, { force: true, recursive: true }) - } - return - } - - // Commit the patch. - await commitPatch(patchPath, packageName) - } catch (e) { - logger.error(`Error creating patch for ${packageName}:`, e.message) - // Cleanup temp directory on error. - if (patchPath && existsSync(patchPath)) { - rmSync(patchPath, { force: true, recursive: true }) - } - throw e - } -} - -/** - * Parse JavaScript/TypeScript code into a Babel AST. - * - * @param {string} code - Source code to parse. - * @param {object} [options] - Babel parser options. - * - * @returns {object} Babel AST. - */ -function parseCode(code, options = {}) { - return parse(code, { - sourceType: 'module', - plugins: [], - ...options, - }) -} - -/** - * Prompt user for yes/no confirmation. - * - * @param {string} question - Question to ask the user. - * @param {boolean} [defaultAnswer=false] - Default answer if user just presses - * enter. - * - * @returns {Promise<boolean>} True if user answered yes, false otherwise. - */ -async function promptYesNo(question, defaultAnswer = false) { - const readline = await import('node:readline') - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise(resolve => { - const defaultHint = defaultAnswer ? 'Y/n' : 'y/N' - rl.question(`${question} (${defaultHint}): `, answer => { - rl.close() - const normalized = answer.trim().toLowerCase() - if (normalized === '') { - resolve(defaultAnswer) - } else { - resolve(normalized === 'y' || normalized === 'yes') - } - }) - }) -} - -/** - * Read file from package directory within node_modules. - * - * @param {string} packagePath - Path to package directory. - * @param {string} filePath - Relative file path within package. - * - * @returns {string} File contents. - */ -function readPatchFile(packagePath, filePath) { - const fullPath = path.join(packagePath, filePath) - if (!existsSync(fullPath)) { - throw new Error(`File not found: ${fullPath}`) - } - return readFileSync(fullPath, 'utf-8') -} - -/** - * Run pnpm patch command to prepare package for editing. - * - * @param {string} packageSpec - Package name and version (e.g., 'debug@4.4.3'). - * - * @returns {Promise<string>} Path to temporary patch directory. - */ -async function startPatch(packageSpec) { - logger.log(`Starting patch for ${packageSpec}...`) - - // First, try to run pnpm patch to see if directory already exists. - let result = await spawn('pnpm', ['patch', packageSpec], { - shell: WIN32, - // Capture stdout and stderr. - stdio: ['inherit', 'pipe', 'pipe'], - stdioString: true, - }) - - // Check if the error is about existing patch directory. - // pnpm outputs errors to stdout, not stderr. - if (result.code !== 0 && result.stdout.includes('is not empty')) { - const match = result.stdout.match(/directory (.+?) is not empty/) - const existingPatchDir = match ? match[1] : undefined - - if (existingPatchDir) { - logger.log('') - logger.log(`Existing patch directory found: ${existingPatchDir}`) - const shouldOverwrite = await promptYesNo( - 'Overwrite existing patch directory?', - false, - ) - - if (!shouldOverwrite) { - throw new Error('Patch creation cancelled by user') - } - - // Remove existing patch directory. - logger.log('Removing existing patch directory...') - rmSync(existingPatchDir, { force: true, recursive: true }) - - // Try pnpm patch again. - result = await spawn('pnpm', ['patch', packageSpec], { - shell: WIN32, - stdio: ['inherit', 'pipe', 'inherit'], - stdioString: true, - }) - } - } - - if (result.code !== 0) { - throw new Error(`Failed to start patch for ${packageSpec}`) - } - - // Extract path from output. - // pnpm patch outputs: "Patch: You can now edit the package at:\n\n /path/to/package\n\n..." - // We need to find the line with the path (starts with whitespace and contains the package name). - const lines = result.stdout.split('\n') - const packageNamePart = packageSpec.split('@')[0] - const pathLine = lines.find( - line => line.trim().startsWith('/') && line.includes(packageNamePart), - ) - - if (!pathLine) { - throw new Error( - `Could not find patch directory path in output:\n${result.stdout}`, - ) - } - - return pathLine.trim() -} - -/** - * Write file to package directory within node_modules. - * - * @param {string} packagePath - Path to package directory. - * @param {string} filePath - Relative file path within package. - * @param {string} content - File contents to write. - */ -function writePatchFile(packagePath, filePath, content) { - const fullPath = path.join(packagePath, filePath) - writeFileSync(fullPath, content, 'utf-8') -} diff --git a/packages/cli/scripts/util/socket-btm-releases.mts b/packages/cli/scripts/util/socket-btm-releases.mts deleted file mode 100644 index 966a5d52f..000000000 --- a/packages/cli/scripts/util/socket-btm-releases.mts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Shared utilities for socket-cli build scripts that extract socket-btm assets. - * Contains socket-cli-specific utilities for header generation and file - * hashing. - */ - -import crypto from 'node:crypto' -import { readFile } from 'node:fs/promises' - -/** - * Compute SHA256 hash of file content. - * - * @param {string} filePath - Path to file. - * - * @returns {Promise<string>} - Hex-encoded SHA256 hash - */ -export async function computeFileHash(filePath) { - const content = await readFile(filePath) - return createHash('sha256').update(content).digest('hex') -} - -/** - * Generate file header with metadata. - * - * @param {object} options - Header options. - * @param {string} options.scriptName - Name of generating script. - * @param {string} options.tag - Release tag. - * @param {string} options.assetName - Asset filename. - * @param {string} [options.sourceHash] - Optional source hash. - * - * @returns {string} - File header comment - */ -function generateHeader({ assetName, scriptName, sourceHash, tag }) { - const hashLine = sourceHash ? `\n * Source hash: ${sourceHash}` : '' - - return `/** - * AUTO-GENERATED by ${scriptName} - * DO NOT EDIT MANUALLY - changes will be overwritten on next build. - * - * Source: socket-btm GitHub releases (${tag}) - * Asset: ${assetName}${hashLine} - */` -} diff --git a/packages/cli/scripts/validate-bundle.mts b/packages/cli/scripts/validate-bundle.mts deleted file mode 100644 index 6cf511eca..000000000 --- a/packages/cli/scripts/validate-bundle.mts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @file Validates that the CLI bundle doesn't contain unresolved external - * dependencies. Rules: - * - * - No require("./external/<package>") calls should exist in the bundle. - * - All socket-lib external dependencies should be inlined. - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const buildPath = path.join(__dirname, '..', 'build', 'cli.js') - -/** - * Validate that the bundle doesn't contain unresolved external requires. - */ -function validateBundle() { - let content - try { - content = readFileSync(buildPath, 'utf-8') - } catch (e) { - logger.fail(`Failed to read bundle: ${e.message}`) - return undefined - } - - const violations = [] - - // Check for require("./external/<package>") patterns. - const externalRequirePattern = /require\(["']\.\/external\/([^"']+)["']\)/g - let match - while ((match = externalRequirePattern.exec(content)) !== null) { - violations.push({ - pattern: match[0], - package: match[1], - type: 'unresolved-external-require', - }) - } - - return violations -} - -async function main() { - try { - const violations = validateBundle() - - if (!violations) { - process.exitCode = 1 - return - } - - if (violations.length === 0) { - logger.success('Bundle validation passed') - process.exitCode = 0 - return - } - - logger.fail('Bundle validation failed') - logger.log('') - logger.log('Found unresolved external requires:') - logger.log('') - - for (let i = 0, { length } = violations; i < length; i += 1) { - const violation = violations[i] - logger.log(` ${violation.pattern}`) - logger.log(` Package: ${violation.package}`) - logger.log(` Type: ${violation.type}`) - logger.log('') - } - - logger.log( - 'These require() calls reference relative paths that will fail at runtime.', - ) - logger.log( - 'Socket-lib external dependencies should be bundled into the CLI.', - ) - logger.log('') - - process.exitCode = 1 - } catch (e) { - logger.fail(`Validation failed: ${e.message}`) - process.exitCode = 1 - } -} - -main().catch(e => { - logger.error(`Validation failed: ${e}`) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/validate-tests.mts b/packages/cli/scripts/validate-tests.mts deleted file mode 100644 index aea972b81..000000000 --- a/packages/cli/scripts/validate-tests.mts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * @file Validates test infrastructure to catch issues early before CI. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { pEach } from '@socketsecurity/lib-stable/promises/iterate' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const TEST_DIR = path.join(rootPath, 'test') - -const VALIDATION_CHECKS = { - __proto__: null, - BUILD_ARTIFACTS: 'build-artifacts', - IMPORT_SYNTAX: 'import-syntax', - SNAPSHOT_FILES: 'snapshot-files', - TEST_STRUCTURE: 'test-structure', -} - -/** - * Format validation results for display. - */ -function formatResults(results) { - const errors = [] - const warnings = [] - const infos = [] - - for (let i = 0, { length } = results; i < length; i += 1) { - const result = results[i] - if (result.issues.length === 0) { - continue - } - - for (const issue of result.issues) { - const message = `${result.file}: ${issue.message}` - if (issue.severity === 'error') { - errors.push(message) - const logger = getDefaultLogger() - logger.fail(message) - } else if (issue.severity === 'warning') { - warnings.push(message) - logger.warn(message) - } else { - infos.push(message) - } - } - } - - return { errors, infos, warnings } -} - -/** - * Get list of test files to validate. - */ -async function getTestFiles() { - const files = [] - - /** - * Recursively collect test files. - */ - async function collectFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }) - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i] - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory() && !entry.name.startsWith('.')) { - await collectFiles(fullPath) - } else if ( - entry.isFile() && - /\.test\.(?:js|mjs|mts|ts)$/.test(entry.name) - ) { - files.push(fullPath) - } - } - } - - await collectFiles(TEST_DIR) - return files -} - -/** - * Validate that required build artifacts exist. - */ -async function validateBuildArtifacts() { - const issues = [] - const distPath = path.join(rootPath, 'dist') - - if (!existsSync(distPath)) { - issues.push({ - type: VALIDATION_CHECKS.BUILD_ARTIFACTS, - severity: 'error', - message: 'dist/ directory not found. Run pnpm run build:cli first', - }) - return issues - } - - // Check for key entry points. - const requiredArtifacts = ['build/cli.js', 'dist/index.js'] - - for (let i = 0, { length } = requiredArtifacts; i < length; i += 1) { - const artifact = requiredArtifacts[i] - const fullPath = path.join(rootPath, artifact) - if (!existsSync(fullPath)) { - issues.push({ - type: VALIDATION_CHECKS.BUILD_ARTIFACTS, - severity: 'error', - message: `Required build artifact missing: ${artifact}`, - }) - } - } - - return issues -} - -/** - * Validate import statements in test files. - */ -async function validateImportSyntax(testFile) { - const issues = [] - const relativePath = path.relative(rootPath, testFile) - - try { - const content = await fs.readFile(testFile, 'utf8') - - // Check for problematic import patterns. - const problematicPatterns = [ - { - pattern: /import .+ from ['"]node:/, - fix: 'Always use node: prefix for built-in modules', - severity: 'info', - }, - { - pattern: /require\(/, - fix: 'Use ES modules (import) instead of CommonJS (require)', - severity: 'warning', - }, - { - pattern: /from ['"]\.\.\/..\//, - fix: 'Avoid excessive relative path traversal', - severity: 'info', - }, - ] - - for (const { fix, pattern, severity } of problematicPatterns) { - if (pattern.test(content)) { - issues.push({ - type: VALIDATION_CHECKS.IMPORT_SYNTAX, - severity, - message: `${fix} in ${relativePath}`, - }) - } - } - - // Check for missing @fileoverview. - if (!content.includes('@fileoverview')) { - issues.push({ - type: VALIDATION_CHECKS.IMPORT_SYNTAX, - severity: 'warning', - message: `Missing @fileoverview header in ${relativePath}`, - }) - } - } catch (e) { - issues.push({ - type: VALIDATION_CHECKS.IMPORT_SYNTAX, - severity: 'error', - message: `Failed to read ${relativePath}: ${e.message}`, - }) - } - - return issues -} - -/** - * Check for orphaned snapshot files. - */ -async function validateSnapshotFiles(testFile) { - const issues = [] - const relativePath = path.relative(rootPath, testFile) - const snapshotDir = path.join(path.dirname(testFile), '__snapshots__') - - if (!existsSync(snapshotDir)) { - return issues - } - - const testFileName = path.basename(testFile) - const snapshotFile = path.join( - snapshotDir, - testFileName.replace(/\.mts$/, '.mts.snap'), - ) - - if (!existsSync(snapshotFile)) { - // Check if snapshot directory exists but has no matching snapshot. - const entries = await fs.readdir(snapshotDir) - if (entries.length > 0) { - issues.push({ - type: VALIDATION_CHECKS.SNAPSHOT_FILES, - severity: 'info', - message: `Snapshot directory exists but no snapshot for ${relativePath}`, - }) - } - } - - return issues -} - -/** - * Run all validations for a test file. - */ -async function validateTestFile(testFile) { - const allIssues = [] - - const validations = [ - validateTestStructure(testFile), - validateImportSyntax(testFile), - validateSnapshotFiles(testFile), - ] - - const results = await Promise.allSettled(validations) - for (let i = 0, { length } = results; i < length; i += 1) { - const result = results[i] - if (result.status === 'fulfilled') { - allIssues.push(...result.value) - } - } - - return { - file: path.relative(rootPath, testFile), - issues: allIssues, - hasErrors: allIssues.some(issue => issue.severity === 'error'), - hasWarnings: allIssues.some(issue => issue.severity === 'warning'), - } -} - -/** - * Validate test file structure and naming. - */ -async function validateTestStructure(testFile) { - const issues = [] - const relativePath = path.relative(rootPath, testFile) - - // Check naming convention. - if (!testFile.endsWith('.test.mts')) { - issues.push({ - type: VALIDATION_CHECKS.TEST_STRUCTURE, - severity: 'warning', - message: `Test file should use .test.mts extension: ${relativePath}`, - }) - } - - // Check if corresponding source file exists for unit tests. - if (relativePath.includes('test/unit')) { - const sourceFile = testFile - .replace('/test/unit/', '/src/') - .replace('.test.mts', '.mts') - - if (!existsSync(sourceFile)) { - issues.push({ - type: VALIDATION_CHECKS.TEST_STRUCTURE, - severity: 'info', - message: `No corresponding source file found for ${relativePath}`, - }) - } - } - - return issues -} - -/** - * Main validation flow. - */ -async function main() { - logger.info('Starting test validation...') - logger.error('') - - // Validate build artifacts first. - const buildIssues = await validateBuildArtifacts() - if (buildIssues.some(issue => issue.severity === 'error')) { - for (let i = 0, { length } = buildIssues; i < length; i += 1) { - const issue = buildIssues[i] - logger.fail(issue.message) - } - logger.error('') - logger.fail('Build artifacts validation failed. Run build before testing.') - process.exitCode = 1 - return - } - - const testFiles = await getTestFiles() - logger.info(`Found ${testFiles.length} test files to validate`) - logger.error('') - - const results = [] - await pEach( - testFiles, - async file => { - const result = await validateTestFile(file) - results.push(result) - }, - { concurrency: 10 }, - ) - - logger.error('') - logger.info('--- Validation Results ---') - logger.error('') - const { errors, infos, warnings } = formatResults(results) - - logger.error('') - logger.info('--- Summary ---') - logger.info(`Total test files: ${testFiles.length}`) - logger.info(`Passed: ${results.filter(r => r.issues.length === 0).length}`) - logger.info( - `With warnings: ${results.filter(r => r.hasWarnings && !r.hasErrors).length}`, - ) - logger.info(`With errors: ${results.filter(r => r.hasErrors).length}`) - - if (errors.length > 0) { - logger.error('') - logger.fail(`${errors.length} error(s) found`) - process.exitCode = 1 - } else if (warnings.length > 0) { - logger.error('') - logger.warn(`${warnings.length} warning(s) found`) - if (infos.length > 0) { - logger.info(`${infos.length} info message(s)`) - } - } else { - logger.error('') - logger.success('All tests validated successfully!') - if (infos.length > 0) { - logger.info(`${infos.length} info message(s)`) - } - } -} - -main().catch(e => { - logger.fail(`Validation failed: ${e.message}`) - logger.fail(e.stack) - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/verify-package.mts b/packages/cli/scripts/verify-package.mts deleted file mode 100644 index eb0dffac6..000000000 --- a/packages/cli/scripts/verify-package.mts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageRoot = path.resolve(__dirname, '..') - -const logger = getDefaultLogger() - -export async function validate() { - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('CLI Package Validation')}`) - logger.log('='.repeat(60)) - logger.log('') - - const errors = [] - - // Check package.json exists and has correct files array. - logger.info('Checking package.json...') - const pkgPath = path.join(packageRoot, 'package.json') - if (!existsSync(pkgPath)) { - errors.push('package.json does not exist') - } else { - logger.success('package.json exists') - - // Validate files array. - let pkg - try { - pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) - } catch (e) { - errors.push(`Failed to parse package.json: ${e.message}`) - return errors - } - const requiredInFiles = [ - 'CHANGELOG.md', - 'LICENSE', - 'data/**', - 'dist/**', - 'logo-dark.png', - 'logo-light.png', - ] - for (let i = 0, { length } = requiredInFiles; i < length; i += 1) { - const required = requiredInFiles[i] - if (!pkg.files?.includes(required)) { - errors.push(`package.json files array missing: ${required}`) - } - } - if (errors.length === 0) { - logger.success('package.json files array is correct') - } - } - - // Check root files exist (LICENSE, CHANGELOG.md). - const rootFiles = ['LICENSE', 'CHANGELOG.md'] - for (let i = 0, { length } = rootFiles; i < length; i += 1) { - const file = rootFiles[i] - logger.info(`Checking ${file}...`) - const filePath = path.join(packageRoot, file) - if (!existsSync(filePath)) { - errors.push(`${file} does not exist`) - } else { - logger.success(`${file} exists`) - } - } - - // Check dist files exist. - const distFiles = ['index.js', 'cli.js'] - for (let i = 0, { length } = distFiles; i < length; i += 1) { - const file = distFiles[i] - logger.info(`Checking dist/${file}...`) - const filePath = path.join(packageRoot, 'dist', file) - if (!existsSync(filePath)) { - errors.push(`dist/${file} does not exist`) - } else { - logger.success(`dist/${file} exists`) - } - } - - // Check data directory exists. - logger.info('Checking data directory...') - const dataPath = path.join(packageRoot, 'data') - if (!existsSync(dataPath)) { - errors.push('data directory does not exist') - } else { - logger.success('data directory exists') - - // Check data files. - const dataFiles = [ - 'alert-translations.json', - 'command-api-requirements.json', - ] - for (let i = 0, { length } = dataFiles; i < length; i += 1) { - const file = dataFiles[i] - logger.info(`Checking data/${file}...`) - const filePath = path.join(dataPath, file) - if (!existsSync(filePath)) { - errors.push(`data/${file} does not exist`) - } else { - logger.success(`data/${file} exists`) - } - } - } - - // Print summary. - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('Validation Summary')}`) - logger.log('='.repeat(60)) - logger.log('') - - if (errors.length > 0) { - logger.log(`${colors.red('Errors:')}`) - for (let i = 0, { length } = errors; i < length; i += 1) { - const err = errors[i] - logger.log(` ${err}`) - } - logger.log('') - logger.fail('Package validation FAILED') - logger.log('') - throw new Error('Package validation failed') - } - - logger.success('Package validation PASSED') - logger.log('') -} - -// Run validation. -validate().catch(e => { - logger.error('') - logger.fail(`Unexpected error: ${e.message}`) - logger.error('') - process.exitCode = 1 -}) diff --git a/packages/cli/scripts/wasm.mts b/packages/cli/scripts/wasm.mts deleted file mode 100644 index 3a4a036e3..000000000 --- a/packages/cli/scripts/wasm.mts +++ /dev/null @@ -1,407 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/prefer-exists-sync -- fs.stat() calls read .size for WASM bundle size reporting; not existence checks. */ - -/** - * Socket CLI WASM Bundle Manager. - * - * Unified script for building and downloading the unified WASM bundle - * containing all AI models (MiniLM, CodeT5 encoder/decoder, ONNX Runtime, - * Yoga). - * - * COMMANDS: - --build: Build WASM bundle from source (requires Python, Rust, - * wasm-pack) - --dev: Fast dev build (3-5x faster, use with --build) - - * --download: Download pre-built WASM bundle from GitHub releases - --help: - * Show this help message. - * - * USAGE: node scripts/wasm.mts --build # Production build node scripts/wasm.mts - * --build --dev # Fast dev build node scripts/wasm.mts --download node - * scripts/wasm.mts --help. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const externalDir = path.join(rootPath, 'external') -const outputFile = path.join(externalDir, 'socket-ai-sync.mjs') - -const GITHUB_REPO = 'SocketDev/socket-cli' -const WASM_ASSET_NAME = 'socket-ai-sync.mjs' - -/** - * Build WASM bundle from source. - */ -async function buildWasm() { - const isDev = process.argv.includes('--dev') - - logger.info('╔═══════════════════════════════════════════════════╗') - if (isDev) { - logger.info('║ Building WASM Bundle (Dev Mode) ║') - logger.info('║ 3-5x faster builds with minimal optimization ║') - } else { - logger.info('║ Building WASM Bundle from Source ║') - } - logger.info('╚═══════════════════════════════════════════════════╝') - logger.error('') - - const convertScript = path.join(__dirname, 'wasm', 'convert-codet5.mts') - const buildScript = path.join(__dirname, 'wasm', 'build-unified-wasm.mts') - - // Step 1: Convert CodeT5 models to INT4. - logger.info('Step 1: Converting CodeT5 models to ONNX INT4...') - logger.error('') - try { - await exec('node', [convertScript], { stdio: 'inherit' }) - } catch (e) { - logger.error('') - logger.fail('❌ CodeT5 conversion failed') - logger.error(`Error: ${e.message}`) - throw new Error('CodeT5 conversion failed') - } - - // Step 2: Build unified WASM bundle. - logger.error('') - logger.info('Step 2: Building unified WASM bundle...') - logger.error('') - try { - const buildArgs = [buildScript] - if (isDev) { - buildArgs.push('--dev') - } - await exec('node', buildArgs, { stdio: 'inherit' }) - } catch (e) { - logger.error('') - logger.fail('❌ WASM bundle build failed') - logger.error(`Error: ${e.message}`) - throw new Error('WASM bundle build failed') - } - - // Verify output file exists. - if (!existsSync(outputFile)) { - logger.error('') - logger.fail(`❌ Output file not found: ${outputFile}`) - throw new Error(`Output file not found: ${outputFile}`) - } - - const stats = await fs.stat(outputFile) - logger.error('') - logger.info('╔═══════════════════════════════════════════════════╗') - logger.info('║ Build Complete ║') - logger.info('╚═══════════════════════════════════════════════════╝') - logger.error('') - logger.done(' WASM bundle built successfully') - logger.info(`✓ Output: ${outputFile}`) - logger.info(`✓ Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`) - logger.error('') -} - -/** - * Check Node.js version requirement. - */ -function checkNodeVersion() { - const nodeVersion = process.versions.node - const major = Number.parseInt(nodeVersion.split('.')[0], 10) - - if (major < 18) { - logger.error(' Node.js version 18 or higher is required') - logger.error(`Current version: ${nodeVersion}`) - logger.error('Please upgrade: https://nodejs.org/') - throw new Error('Node.js version 18 or higher is required') - } -} - -/** - * Download file with progress. - */ -export async function downloadFile(url, outputPath, expectedSize) { - logger.progress(' Downloading from GitHub...') - logger.substep(`URL: ${url}`) - logger.substep(`Size: ${(expectedSize / 1024 / 1024).toFixed(2)} MB`) - logger.error('') - - try { - // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- needs response.arrayBuffer() to write binary WASM bundle; helpers only return decoded string/json. - const response = await fetch(url, { - headers: { - Accept: 'application/octet-stream', - 'User-Agent': 'socket-cli-wasm-downloader', - }, - }) - - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`) - } - - const buffer = await response.arrayBuffer() - await fs.writeFile(outputPath, Buffer.from(buffer)) - - const stats = await fs.stat(outputPath) - logger.info(`✓ Downloaded ${(stats.size / 1024 / 1024).toFixed(2)} MB`) - logger.info(`✓ Saved to ${outputPath}`) - logger.error('') - } catch (e) { - logger.error(' Download failed') - logger.error(`Error: ${e.message}`) - logger.error('') - logger.error('Try building from source instead:') - logger.error('node scripts/wasm.mts --build') - logger.error('') - throw new Error('Download failed') - } -} - -/** - * Download pre-built WASM bundle from GitHub releases. - */ -async function downloadWasm() { - logger.info('╔═══════════════════════════════════════════════════╗') - logger.info('║ Downloading Pre-built WASM Bundle ║') - logger.info('╚═══════════════════════════════════════════════════╝') - logger.error('') - - // Check if output file already exists. - if (existsSync(outputFile)) { - const stats = await fs.stat(outputFile) - logger.warn(' WASM bundle already exists:') - logger.substep(`${outputFile}`) - logger.substep(`Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`) - logger.error('') - - // Ask user if they want to overwrite (simple y/n). - logger.info('Overwrite? (y/N): ') - const answer = await new Promise(resolve => { - process.stdin.once('data', data => { - resolve(data.toString().trim().toLowerCase()) - }) - }) - - if (answer !== 'y' && answer !== 'yes') { - logger.success('Keeping existing file') - return - } - - logger.info() - } - - // Get latest release info. - const release = await getLatestWasmRelease() - logger.info(`✓ Found release: ${release.name}`) - logger.substep(`Tag: ${release.tagName}`) - logger.error('') - - // Ensure output directory exists. - await fs.mkdir(externalDir, { recursive: true }) - - // Download the file. - await downloadFile(release.url, outputFile, release.asset.size) - - logger.info('╔═══════════════════════════════════════════════════╗') - logger.info('║ Download Complete ║') - logger.info('╚═══════════════════════════════════════════════════╝') - logger.error('') - logger.done(' WASM bundle downloaded successfully') - logger.info(`✓ Output: ${outputFile}`) - logger.error('') -} - -/** - * Execute command and wait for completion. - */ -export async function exec(command, args, options = {}) { - const result = await spawn(command, args, { - stdio: options.stdio || 'pipe', - stdioString: true, - stripAnsi: false, - ...options, - }) - - if (result.code !== 0) { - throw new Error(`Command failed with exit code ${result.code}`) - } - - return { - code: result.code ?? 0, - stderr: result.stderr ?? '', - stdout: result.stdout ?? '', - } -} - -/** - * Get latest WASM build release from GitHub. - */ -async function getLatestWasmRelease() { - logger.info('📡 Fetching latest WASM build from GitHub...') - logger.error('') - - try { - const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases` - // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- dev script wants response.statusText in diagnostic error; helpers throw HttpError without that exact field. - const response = await fetch(apiUrl, { - headers: { - Accept: 'application/vnd.github+json', - 'User-Agent': 'socket-cli-wasm-downloader', - }, - }) - - if (!response.ok) { - throw new Error(`GitHub API request failed: ${response.statusText}`) - } - - const releases = await response.json() - - // Validate API response structure. - if (!Array.isArray(releases) || releases.length === 0) { - throw new Error( - 'Invalid API response: expected non-empty array of releases', - ) - } - - // Find the latest WASM build release (tagged with wasm-build-*). - const wasmRelease = releases.find(r => - r?.tag_name?.startsWith('wasm-build-'), - ) - - if (!wasmRelease) { - throw new Error('No WASM build releases found') - } - - if (!wasmRelease.tag_name) { - throw new Error('Invalid release data: missing tag_name') - } - - if (!Array.isArray(wasmRelease.assets)) { - throw new Error(`Release ${wasmRelease.tag_name} has no assets`) - } - - // Find the asset. - const asset = wasmRelease.assets.find(a => a?.name === WASM_ASSET_NAME) - - if (!asset) { - throw new Error( - `Asset "${WASM_ASSET_NAME}" not found in release ${wasmRelease.tag_name}`, - ) - } - - if (!asset.browser_download_url) { - throw new Error( - `Asset "${WASM_ASSET_NAME}" missing browser_download_url in release ${wasmRelease.tag_name}`, - ) - } - - return { - asset, - name: wasmRelease.name, - tagName: wasmRelease.tag_name, - url: asset.browser_download_url, - } - } catch (e) { - logger.error(' Failed to fetch release information') - logger.error(`Error: ${e.message}`) - logger.error('') - logger.error('Try building from source instead:') - logger.error('node scripts/wasm.mts --build') - logger.error('') - throw new Error('Failed to fetch release information') - } -} - -/** - * Show help message. - */ -export function showHelp() { - logger.info(` -╔═══════════════════════════════════════════════════╗ -║ Socket CLI WASM Bundle Manager ║ -╚═══════════════════════════════════════════════════╝ - -Commands: - --build Build WASM bundle from source - Requirements: Python 3.8+, Rust, wasm-pack, binaryen - Time: ~10-20 minutes (first run), ~5 minutes (subsequent) - Size: ~115MB output - - --dev Fast dev build (use with --build) - Optimizations: Minimal (opt-level=1, no LTO) - Time: ~2-5 minutes (3-5x faster than production) - Size: Similar to production (stripped) - - --download Download pre-built WASM bundle from GitHub releases - Requirements: Internet connection - Time: ~1-2 minutes - Size: ~115MB download - - --help Show this help message - -Usage: - node scripts/wasm.mts --build # Production build - node scripts/wasm.mts --build --dev # Fast dev build - node scripts/wasm.mts --download - node scripts/wasm.mts --help - -Examples: - # Build from source for production - node scripts/wasm.mts --build - - # Fast dev build for iteration (3-5x faster) - node scripts/wasm.mts --build --dev - - # Download pre-built bundle (for quick setup) - node scripts/wasm.mts --download - -Optimizations: - - Cargo profiles: dev-wasm (fast) vs release (optimized) - - Thin LTO: 5-10% faster builds than full LTO - - Strip symbols: 5-10% size reduction - - wasm-opt -Oz: 5-15% additional size reduction - - Brotli compression: ~70% final size reduction - -Notes: - - The WASM bundle contains all AI models with INT4 quantization - - INT4 provides 50% size reduction with only 1-2% quality loss - - Output location: external/socket-ai-sync.mjs (~115MB) -`) -} - -/** - * Main entry point. - */ -async function main() { - // Check Node.js version first. - checkNodeVersion() - - const args = process.argv.slice(2) - - if (args.length === 0 || args.includes('--help') || args.includes('-h')) { - showHelp() - return - } - - if (args.includes('--build')) { - await buildWasm() - return - } - - if (args.includes('--download')) { - await downloadWasm() - return - } - - logger.error(' Unknown command') - logger.error('') - showHelp() - throw new Error('Unknown command') -} - -main().catch(e => { - logger.error(' Unexpected error:', e) - process.exitCode = 1 -}) diff --git a/packages/cli/src/bootstrap/node.mts b/packages/cli/src/bootstrap/node.mts deleted file mode 100644 index f4de91ee7..000000000 --- a/packages/cli/src/bootstrap/node.mts +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env node -/** - * Node.js Internal Bootstrap. - * - * This file is loaded by the custom Node.js binary at startup via - * internal/bootstrap/socketsecurity module. - * - * Responsibilities: - * - * - Check if @socketsecurity/cli is installed in ~/.socket/_dlx/cli/ - * - If not installed: download and extract from npm - * - Spawn the CLI with current arguments - * - * Size target: <2KB after minification + brotli compression Build output: - * dist/bootstrap/node.js (copied to Node.js source) - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { getNodeDisableSigusr1Flags } from './shared/node-flags.mjs' -import { - getCliEntryPoint, - getCliPackageDir, - getCliPackageName, - getDlxDir, -} from './shared/paths.mjs' - -const logger = getDefaultLogger() - -/** - * Download CLI using npm pack command. This delegates to npm which handles - * downloading and extracting the latest version. - */ -export async function downloadCli(): Promise<void> { - const packageName = getCliPackageName() - const dlxDir = getDlxDir() - const cliDir = getCliPackageDir() - - await safeMkdir(dlxDir, { recursive: true }) - - logger.error(`Downloading ${packageName}...`) - - return new Promise((resolve, reject) => { - const npmPackProcess = spawn( - 'npm', - ['pack', packageName, '--pack-destination', dlxDir], - { - stdio: ['ignore', 'pipe', 'inherit'], - }, - ) - - let tarballName = '' - npmPackProcess.process.stdout?.on('data', (data: Buffer) => { - tarballName += data.toString() - }) - - npmPackProcess.process.on('error', (e: Error) => { - reject(new Error(`Failed to run npm pack: ${e}`)) - }) - - npmPackProcess.process.on('exit', async (code: number | null) => { - if (code !== 0) { - reject(new Error(`npm pack exited with code ${code}`)) - return - } - - try { - const tarballPath = path.join(dlxDir, tarballName.trim()) - - await safeMkdir(cliDir, { recursive: true }) - - const tarExtractProcess = spawn( - 'tar', - ['-xzf', tarballPath, '-C', cliDir, '--strip-components=1'], - { - stdio: 'inherit', - }, - ) - - tarExtractProcess.process.on('error', (e: Error) => { - reject(new Error(`Failed to extract tarball: ${e}`)) - }) - - tarExtractProcess.process.on( - 'exit', - async (extractCode: number | null) => { - if (extractCode !== 0) { - reject( - new Error(`tar extraction exited with code ${extractCode}`), - ) - return - } - - await safeDelete(tarballPath, { force: true }) - - logger.error('Socket CLI installed successfully') - resolve() - }, - ) - } catch (e) { - reject(e) - } - }) - }) -} - -/** - * Check if CLI is installed. - */ -export function isCliInstalled(): boolean { - const entryPoint = getCliEntryPoint() - const packageJson = `${getCliPackageDir()}/package.json` - return existsSync(entryPoint) && existsSync(packageJson) -} - -/** - * Main entry point. - */ -async function main(): Promise<void> { - // Check if CLI is already installed. - if (!isCliInstalled()) { - logger.error('Socket CLI not installed yet.') - try { - await downloadCli() - } catch (e) { - logger.error('Failed to download Socket CLI:', e) - process.exit(1) - } - } - - // CLI is installed, delegate to it. - const cliPath = getCliEntryPoint() - const args = process.argv.slice(2) - - const child = spawn( - process.execPath, - [...getNodeDisableSigusr1Flags(), cliPath, ...args], - { - stdio: 'inherit', - env: process.env, - }, - ) - - child.process.on('error', (error: Error) => { - logger.error('Failed to spawn CLI:', error) - process.exit(1) - }) - - child.process.on( - 'exit', - (code: number | null, signal: NodeJS.Signals | null) => { - process.exit(code ?? (signal ? 1 : 0)) - }, - ) -} - -// Only run if executed directly (not when loaded as module). -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch(error => { - logger.error('Bootstrap error:', error) - process.exit(1) - }) -} diff --git a/packages/cli/src/bootstrap/shared/node-flags.mts b/packages/cli/src/bootstrap/shared/node-flags.mts deleted file mode 100644 index 7b09b89bc..000000000 --- a/packages/cli/src/bootstrap/shared/node-flags.mts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Node.js flags for bootstrap (minimal implementation for size). This file is - * bundled into bootstrap, not imported at runtime. - */ - -/** - * Get flags to disable SIGUSR1 debugger signal handling. Returns - * --disable-sigusr1 for newer Node, --no-inspect for older versions. - */ -export function getNodeDisableSigusr1Flags(): string[] { - return supportsDisableSigusr1() ? ['--disable-sigusr1'] : ['--no-inspect'] -} - -/** - * Get Node major version number. - */ -export function getNodeMajorVersion(): number { - return Number.parseInt(process.version.slice(1).split('.')[0] || '0', 10) -} - -/** - * Get Node minor version number. - */ -export function getNodeMinorVersion(): number { - return Number.parseInt(process.version.slice(1).split('.')[1] || '0', 10) -} - -/** - * Check if --disable-sigusr1 flag is supported. Supported in v22.14.0+, - * v23.7.0+, v24.8.0+ (stable in v22.20.0+, v24.8.0+). - */ -export function supportsDisableSigusr1(): boolean { - const major = getNodeMajorVersion() - const minor = getNodeMinorVersion() - - if (major >= 24) { - return minor >= 8 - } - if (major === 23) { - return minor >= 7 - } - if (major === 22) { - return minor >= 14 - } - return false -} diff --git a/packages/cli/src/bootstrap/shared/paths.mts b/packages/cli/src/bootstrap/shared/paths.mts deleted file mode 100644 index 479862fe3..000000000 --- a/packages/cli/src/bootstrap/shared/paths.mts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Shared path resolution for all bootstrap implementations. This file is - * bundled into each bootstrap, not imported at runtime. - * - * IMPORTANT: This bootstrap code runs BEFORE the main CLI loads. We CANNOT use - * the centralized ENV module here because: 1. Bootstrap needs to set up paths - * before ENV module can be imported 2. ENV module depends on constants that - * need these paths 3. This creates a circular dependency Therefore, we use - * direct process.env access for bootstrap-specific env vars. - */ - -import os from 'node:os' -import path from 'node:path' - -/** - * Get the CLI entry point path. - */ -export function getCliEntryPoint(): string { - return path.join(getCliPackageDir(), 'dist', 'cli.js') -} - -/** - * Get the CLI package directory within DLX cache. - */ -export function getCliPackageDir(): string { - return path.join(getDlxDir(), 'cli') -} - -/** - * Get package name to download. Direct process.env access required - bootstrap - * runs before ENV module loads. - */ -export function getCliPackageName(): string { - return process.env['SOCKET_CLI_PACKAGE'] || '@socketsecurity/cli' -} - -/** - * Get the DLX cache directory for downloaded packages. This is where - * @socketsecurity/cli and other packages are installed. - */ -export function getDlxDir(): string { - return path.join(getSocketHome(), '_dlx') -} - -/** - * Get the Socket home directory path. Supports SOCKET_HOME environment variable - * override. Direct process.env access required - bootstrap runs before ENV - * module loads. - */ -export function getSocketHome(): string { - return process.env['SOCKET_HOME'] || path.join(os.homedir(), '.socket') -} diff --git a/packages/cli/src/cli-dispatch-with-sentry.mts b/packages/cli/src/cli-dispatch-with-sentry.mts deleted file mode 100644 index 79aaa2112..000000000 --- a/packages/cli/src/cli-dispatch-with-sentry.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file CLI dispatch entry point with Sentry telemetry. Imports Sentry - * instrumentation before running the CLI dispatcher. This ensures Sentry is - * initialized before any CLI code runs. - */ - -// CRITICAL: Import Sentry instrumentation FIRST (before any other CLI code). -// This must be the first import to ensure Sentry captures all errors. -import './instrument-with-sentry.mts' - -// Import and run the normal CLI dispatch. -// The dispatch handles routing to the appropriate CLI based on invocation mode. -import './cli-dispatch.mts' diff --git a/packages/cli/src/cli-dispatch.mts b/packages/cli/src/cli-dispatch.mts deleted file mode 100755 index 540c89210..000000000 --- a/packages/cli/src/cli-dispatch.mts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Unified Socket CLI entry point. - * - * This single file handles all Socket CLI commands by detecting how it was - * invoked: - socket (main CLI) - socket-npm (npm wrapper) - socket-npx (npx - * wrapper) - * - * Perfect for SEA packaging and single-file distribution. - * - * Bootstrap Logic: When running as a SEA binary, we use IPC handshake to detect - * subprocess mode: - Initial entry (no IPC): Bootstrap to system Node.js or - * self with IPC - Subprocess entry (has IPC): Bypass bootstrap, act as regular - * Node.js. - */ - -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { waitForBootstrapHandshake } from './util/sea/boot.mjs' - -const logger = getDefaultLogger() - -// Detect how this binary was invoked. -export function getInvocationMode(): string { - // Check environment variable first (for explicit mode). - const envMode = process.env['SOCKET_CLI_MODE'] - if (envMode) { - return envMode - } - - // Check process.argv[1] for the actual script name. - const scriptPath = process.argv[1] - if (scriptPath) { - const scriptName = path - .basename(scriptPath) - .replace(/\.(cjs|exe|js|mjs)$/i, '') - - // Map script names to modes. - if (scriptName.endsWith('-npm') || scriptName === 'npm') { - return 'npm' - } - if (scriptName.endsWith('-npx') || scriptName === 'npx') { - return 'npx' - } - if (scriptName.endsWith('-pnpm') || scriptName === 'pnpm') { - return 'pnpm' - } - if (scriptName.endsWith('-yarn') || scriptName === 'yarn') { - return 'yarn' - } - // For 'cli' or anything containing 'socket', default to socket mode. - if (scriptName.includes('socket') || scriptName === 'cli') { - return 'socket' - } - } - - // Check process.argv0 as fallback. - const argv0 = path - .basename(process.argv0 || process.execPath) - .replace(/\.exe$/i, '') - - if (argv0.endsWith('npm')) { - return 'npm' - } - if (argv0.endsWith('npx')) { - return 'npx' - } - if (argv0.endsWith('pnpm')) { - return 'pnpm' - } - if (argv0.endsWith('yarn')) { - return 'yarn' - } - - // Default to main Socket CLI. - return 'socket' -} - -// Route to the appropriate CLI based on invocation mode. -async function main() { - // If we're a subprocess with IPC, wait for handshake. - // This validates we're running in the correct context. - // Note: The handshake is used by Socket Firewall (sfw) operations to pass - // configuration (API token, bin name, etc.) to the subprocess. - try { - await waitForBootstrapHandshake(1000) // 1 second timeout. - // Handshake received - we're a validated subprocess. - } catch { - // No handshake received, or we're not a subprocess. - // This is normal for initial entry. - } - - const mode = getInvocationMode() - - // Set environment variable for child processes. - process.env['SOCKET_CLI_MODE'] = mode - - // Import and run the appropriate CLI function. - // All wrapper modes now route through the main CLI entry with the mode set. - // The CLI will detect the mode and run the appropriate command. - await import('./cli-entry.mjs') -} - -// Run the appropriate CLI. -main().catch(error => { - logger.error('Socket CLI Error:', error) - process.exit(1) -}) diff --git a/packages/cli/src/cli-entry.mts b/packages/cli/src/cli-entry.mts deleted file mode 100755 index 5f9c94c26..000000000 --- a/packages/cli/src/cli-entry.mts +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env node - -// Set global Socket theme for consistent CLI branding. -import { isError } from '@socketsecurity/lib-stable/errors/predicates' -import { setTheme } from '@socketsecurity/lib-stable/themes/context' -setTheme('socket') - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import url, { fileURLToPath } from 'node:url' - -// Suppress MaxListenersExceeded warning for AbortSignal. -// The Socket SDK properly manages listeners but may exceed the default limit of 30 -// during high-concurrency batch operations. -const originalEmitWarning = process.emitWarning -process.emitWarning = function (warning, ...args) { - if ( - (typeof warning === 'string' && - warning.includes('MaxListenersExceededWarning') && - warning.includes('AbortSignal')) || - (args[0] === 'MaxListenersExceededWarning' && - typeof warning === 'string' && - warning.includes('AbortSignal')) - ) { - // Suppress the specific MaxListenersExceeded warning for AbortSignal. - return - } - return Reflect.apply(originalEmitWarning, this, [warning, ...args]) -} - -import lookupRegistryAuthToken from 'registry-auth-token' -import lookupRegistryUrl from 'registry-url' - -import { - debug as debugNs, - debugDir, - debugDirNs, -} from '@socketsecurity/lib-stable/debug/output' -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import { - getSocketCliBootstrapCacheDir, - getSocketCliBootstrapSpec, -} from '@socketsecurity/lib-stable/env/socket-cli' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { rootAliases, rootCommandBuckets, rootCommands } from './commands.mts' -import { SOCKET_CLI_BIN_NAME } from './constants/packages.mts' -import { getCliName } from './env/cli-name.mts' -import { getCliVersion } from './env/cli-version.mts' -import { SOCKET_CLI_SKIP_UPDATE_CHECK } from './env/socket-cli-skip-update-check.mts' -import { VITEST } from './env/vitest.mts' -import { meow } from './meow.mts' -import { meowWithSubcommands } from './util/cli/with-subcommands.mts' -import { - formatErrorForJson, - formatErrorForTerminal, -} from './util/error/display.mts' -import { captureException } from './util/error/errors.mts' -import { serializeResultJson } from './util/output/result-json.mts' -import { runPreflightDownloads } from './util/preflight/downloads.mts' -import { isSeaBinary } from './util/sea/detect.mts' -import { - finalizeTelemetry, - setupTelemetryExitHandlers, - trackCliComplete, - trackCliError, - trackCliStart, -} from './util/telemetry/integration.mts' -import { scheduleUpdateCheck } from './util/update/manager.mts' - -import { dlxManifest } from '@socketsecurity/lib-stable/dlx/manifest' - -const logger = getDefaultLogger() - -// Debug logger for manifest operations. -const debug = debugNs - -const __filename = fileURLToPath(import.meta.url) - -// Capture CLI start time at module level for global error handlers. -const cliStartTime = Date.now() - -// Set up telemetry exit handlers early to catch all exit scenarios. -setupTelemetryExitHandlers() - -/** - * Write manifest entry for CLI installed via bootstrap. Bootstrap passes spec - * and cache dir via environment variables. - */ -export async function writeBootstrapManifestEntry(): Promise<void> { - const spec = getSocketCliBootstrapSpec() - const cacheDir = getSocketCliBootstrapCacheDir() - - if (!spec || !cacheDir) { - // Not launched via bootstrap, skip. - return - } - - try { - // Extract cache key from path (last segment) - const cacheKey = path.basename(cacheDir) - - // Read package.json to get installed version - const pkgJsonPath = path.join( - cacheDir, - 'node_modules', - '@socketsecurity', - 'cli', - 'package.json', - ) - - let installedVersion = '0.0.0' - try { - const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) - installedVersion = pkgJson.version || '0.0.0' - } catch { - // Failed to read version, use default - } - - // Write manifest entry. - await dlxManifest.setPackageEntry(spec, cacheKey, { - installed_version: installedVersion, - }) - } catch (e) { - // Silently ignore manifest write errors - not critical - debug(`Failed to write bootstrap manifest entry: ${e}`) - } -} - -void (async () => { - // Track CLI start for telemetry. - await trackCliStart(process.argv) - - // Skip update checks in test environments or when explicitly disabled. - // Note: Update checks create HTTP connections that may delay process exit by up to 30s - // due to keep-alive timeouts. Set SOCKET_CLI_SKIP_UPDATE_CHECK=1 to disable. - if (!VITEST && !getCI() && !SOCKET_CLI_SKIP_UPDATE_CHECK) { - const registryUrl = lookupRegistryUrl() - // Unified update notifier handles both SEA and npm automatically. - // Fire-and-forget: Don't await to avoid blocking on HTTP keep-alive timeouts. - scheduleUpdateCheck({ - authInfo: lookupRegistryAuthToken(registryUrl, { recursive: true }), - name: isSeaBinary() - ? SOCKET_CLI_BIN_NAME - : getCliName() || SOCKET_CLI_BIN_NAME, - registryUrl, - version: getCliVersion() || '0.0.0', - }) - - // Write manifest entry if launched via bootstrap (SEA/smol). - // Bootstrap passes spec and cache dir via env vars. - // Fire-and-forget: Don't await to avoid blocking. - writeBootstrapManifestEntry() - - // Background preflight downloads for optional dependencies. - // This silently downloads @coana-tech/cli and @socketbin/cli-ai in the - // background to ensure they're cached for future use. - runPreflightDownloads() - } - - try { - await meowWithSubcommands( - { - name: SOCKET_CLI_BIN_NAME, - argv: process.argv.slice(2), - importMeta: { url: `${url.pathToFileURL(__filename)}` } as ImportMeta, - subcommands: rootCommands, - }, - { aliases: rootAliases, buckets: rootCommandBuckets }, - ) - - // Track successful CLI completion. - await trackCliComplete(process.argv, cliStartTime, process.exitCode) - } catch (e) { - process.exitCode = 1 - - // Stop any active spinner before emitting error output, otherwise - // its animation clashes with the error text on the same line. - // Spinner-wrapped command paths stop their own on catch, but any - // exception that bypasses those handlers reaches us here. - getDefaultSpinner()?.stop() - - // Track CLI error for telemetry. - await trackCliError(process.argv, cliStartTime, e, process.exitCode) - debug('CLI uncaught error') - debugDir(e) - - // Try to parse the flags, find out if --json is set. - const isJson = (() => { - const cli = meow({ - argv: process.argv.slice(2), - // Prevent meow from potentially exiting early. - autoHelp: false, - autoVersion: false, - allowUnknownFlags: true, - flags: { - json: { type: 'boolean' }, - }, - importMeta: { url: `${url.pathToFileURL(__filename)}` } as ImportMeta, - }) - return !!cli.flags.json - })() - - if (isJson) { - logger.log(serializeResultJson(formatErrorForJson(e))) - } else { - logger.error(formatErrorForTerminal(e)) - debugDirNs('inspect', { error: e }) - } - - await captureException(e) - } -})().catch(async err => { - // Fatal error in main async function. - try { - logger.error('Fatal error:', err) - } catch { - // Last-ditch fallback when logger itself throws — the catch - // ensures we still report the original error before exit. - logger.fail('Fatal error:', err) // # socket-hook: allow logger - } - - // Track CLI error for fatal exceptions. - await trackCliError(process.argv, cliStartTime, err, 1) - - // Finalize telemetry before fatal exit. - await finalizeTelemetry() - - process.exit(1) -}) - -// Handle uncaught exceptions. -process.on('uncaughtException', async err => { - try { - try { - logger.error('Uncaught exception:', err) - } catch { - // Last-ditch fallback when logger itself throws. - logger.fail('Uncaught exception:', err) // # socket-hook: allow logger - } - - // Track CLI error for uncaught exception. - await trackCliError(process.argv, cliStartTime, err, 1) - - // Finalize telemetry before exit. - await finalizeTelemetry() - } catch (e) { - // Prevent double unhandled rejection in error handler. - try { - logger.error('Error in uncaughtException handler:', e) - } catch { - // Last-ditch fallback when logger itself throws. - logger.fail('Error in uncaughtException handler:', e) // # socket-hook: allow logger - } - } finally { - process.exit(1) - } -}) - -// Handle unhandled promise rejections. -process.on('unhandledRejection', async (reason, promise) => { - try { - try { - logger.error('Unhandled rejection at:', promise, 'reason:', reason) - } catch { - // Last-ditch fallback when logger itself throws. - logger.fail('Unhandled rejection at:', promise, 'reason:', reason) // # socket-hook: allow logger - } - - // Track CLI error for unhandled rejection. - const error = isError(reason) ? reason : new Error(String(reason)) - await trackCliError(process.argv, cliStartTime, error, 1) - - // Finalize telemetry before exit. - await finalizeTelemetry() - } catch (e) { - // Prevent double unhandled rejection in error handler. - try { - logger.error('Error in unhandledRejection handler:', e) - } catch { - // Last-ditch fallback when logger itself throws. - logger.fail('Error in unhandledRejection handler:', e) // # socket-hook: allow logger - } - } finally { - process.exit(1) - } -}) diff --git a/packages/cli/src/commands.mts b/packages/cli/src/commands.mts deleted file mode 100755 index 676222713..000000000 --- a/packages/cli/src/commands.mts +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env node - -import { cmdAnalytics } from './commands/analytics/cmd-analytics.mts' -import { cmdAsk } from './commands/ask/cmd-ask.mts' -import { cmdAuditLog } from './commands/audit-log/cmd-audit-log.mts' -import { cmdBundler } from './commands/bundler/cmd-bundler.mts' -import { cmdCargo } from './commands/cargo/cmd-cargo.mts' -import { cmdCI } from './commands/ci/cmd-ci.mts' -import { cmdConfig } from './commands/config/cmd-config.mts' -import { cmdFix } from './commands/fix/cmd-fix.mts' -import { cmdGem } from './commands/gem/cmd-gem.mts' -import { cmdGo } from './commands/go/cmd-go.mts' -import { cmdInstall } from './commands/install/cmd-install.mts' -import { cmdJson } from './commands/json/cmd-json.mts' -import { cmdLogin } from './commands/login/cmd-login.mts' -import { cmdLogout } from './commands/logout/cmd-logout.mts' -import { cmdManifestCdxgen } from './commands/manifest/cmd-manifest-cdxgen.mts' -import { cmdManifest } from './commands/manifest/cmd-manifest.mts' -import { cmdMcp } from './commands/mcp/cmd-mcp.mts' -import { cmdNpm } from './commands/npm/cmd-npm.mts' -import { cmdNpx } from './commands/npx/cmd-npx.mts' -import { cmdNuget } from './commands/nuget/cmd-nuget.mts' -import { cmdOops } from './commands/oops/cmd-oops.mts' -import { cmdOptimize } from './commands/optimize/cmd-optimize.mts' -import { cmdOrganizationDependencies } from './commands/organization/cmd-organization-dependencies.mts' -import { cmdOrganizationPolicyLicense } from './commands/organization/cmd-organization-policy-license.mts' -import { cmdOrganizationPolicySecurity } from './commands/organization/cmd-organization-policy-security.mts' -import { cmdOrganization } from './commands/organization/cmd-organization.mts' -import { cmdPackage } from './commands/package/cmd-package.mts' -import { cmdPatch } from './commands/patch/cmd-patch.mts' -import { cmdPip } from './commands/pip/cmd-pip.mts' -import { cmdPnpm } from './commands/pnpm/cmd-pnpm.mts' -import { cmdPyCli } from './commands/pycli/cmd-pycli.mts' -import { cmdRawNpm } from './commands/raw-npm/cmd-raw-npm.mts' -import { cmdRawNpx } from './commands/raw-npx/cmd-raw-npx.mts' -import { cmdRepository } from './commands/repository/cmd-repository.mts' -import { cmdScan } from './commands/scan/cmd-scan.mts' -import { cmdSfw } from './commands/sfw/cmd-sfw.mts' -import { cmdThreatFeed } from './commands/threat-feed/cmd-threat-feed.mts' -import { cmdUninstall } from './commands/uninstall/cmd-uninstall.mts' -import { cmdUv } from './commands/uv/cmd-uv.mts' -import { cmdWhoami } from './commands/whoami/cmd-whoami.mts' -import { cmdWrapper } from './commands/wrapper/cmd-wrapper.mts' -import { cmdYarn } from './commands/yarn/cmd-yarn.mts' - -export const rootCommands = { - analytics: cmdAnalytics, - ask: cmdAsk, - 'audit-log': cmdAuditLog, - bundler: cmdBundler, - cargo: cmdCargo, - cdxgen: cmdManifestCdxgen, - ci: cmdCI, - config: cmdConfig, - dependencies: cmdOrganizationDependencies, - fix: cmdFix, - gem: cmdGem, - go: cmdGo, - install: cmdInstall, - json: cmdJson, - license: cmdOrganizationPolicyLicense, - login: cmdLogin, - logout: cmdLogout, - manifest: cmdManifest, - mcp: cmdMcp, - npm: cmdNpm, - npx: cmdNpx, - nuget: cmdNuget, - oops: cmdOops, - optimize: cmdOptimize, - organization: cmdOrganization, - package: cmdPackage, - patch: cmdPatch, - pip: cmdPip, - pnpm: cmdPnpm, - pycli: cmdPyCli, - 'raw-npm': cmdRawNpm, - 'raw-npx': cmdRawNpx, - repository: cmdRepository, - scan: cmdScan, - security: cmdOrganizationPolicySecurity, - sfw: cmdSfw, - 'threat-feed': cmdThreatFeed, - uninstall: cmdUninstall, - uv: cmdUv, - whoami: cmdWhoami, - wrapper: cmdWrapper, - yarn: cmdYarn, -} - -/** - * Bucket assignments for the `socket --help` layout. - * - * Each public command can opt into one of four display buckets, or stay - * unbucketed (registered + reachable, but not surfaced in the top-level help - * text — useful for ecosystem-specific commands that are documented elsewhere - * or experimental commands not yet ready for prominent placement). - * - * The help builder reads this map to render the bucketed sections. Adding a new - * public command = (a) register it in `rootCommands`, (b) optionally add a - * bucket here. No parallel hand-maintained list to drift. - * - * Drift is impossible-by-construction: - A command in this map but not in - * `rootCommands` would be a compile error (TypeScript narrows the keys). - A - * command in `rootCommands` but not here = unbucketed, which is a valid state. - */ -type RootCommandBucket = 'main' | 'api' | 'tools' | 'config' - -export const rootCommandBuckets: Readonly< - Partial<Record<keyof typeof rootCommands, RootCommandBucket>> -> = { - // Main commands — the "hero" actions surfaced first in `socket --help`. - fix: 'main', - optimize: 'main', - cdxgen: 'main', - ci: 'main', - // Socket API — commands that hit the Socket.dev REST API. - analytics: 'api', - 'audit-log': 'api', - organization: 'api', - package: 'api', - repository: 'api', - scan: 'api', - 'threat-feed': 'api', - // Local tools — commands that wrap a local toolchain (npm, pip, …) - // or operate on the local filesystem without API calls. - manifest: 'tools', - npm: 'tools', - npx: 'tools', - pycli: 'tools', - 'raw-npm': 'tools', - 'raw-npx': 'tools', - sfw: 'tools', - // CLI configuration — login / logout / install / etc. - config: 'config', - install: 'config', - login: 'config', - logout: 'config', - uninstall: 'config', - whoami: 'config', - wrapper: 'config', -} - -export const rootAliases = { - audit: { - description: `${cmdAuditLog.description} (alias)`, - hidden: false, - argv: ['audit-log'], - }, - auditLog: { - description: cmdAuditLog.description, - hidden: true, - argv: ['audit-log'], - }, - auditLogs: { - description: cmdAuditLog.description, - hidden: true, - argv: ['audit-log'], - }, - 'audit-logs': { - description: cmdAuditLog.description, - hidden: true, - argv: ['audit-log'], - }, - deps: { - description: `${cmdOrganizationDependencies.description} (alias)`, - hidden: false, - argv: ['dependencies'], - }, - feed: { - description: `${cmdThreatFeed.description} (alias)`, - hidden: false, - argv: ['threat-feed'], - }, - firewall: { - description: `${cmdSfw.description} (alias)`, - hidden: false, - argv: ['sfw'], - }, - pip3: { - description: `${cmdPip.description} (alias)`, - hidden: true, - argv: ['pip'], - }, - org: { - description: `${cmdOrganization.description} (alias)`, - hidden: false, - argv: ['organization'], - }, - orgs: { - description: cmdOrganization.description, - hidden: true, - argv: ['organization'], - }, - organizations: { - description: cmdOrganization.description, - hidden: true, - argv: ['organization'], - }, - organisation: { - description: cmdOrganization.description, - hidden: true, - argv: ['organization'], - }, - organisations: { - description: cmdOrganization.description, - hidden: true, - argv: ['organization'], - }, - pkg: { - description: `${cmdPackage.description} (alias)`, - hidden: false, - argv: ['package'], - }, - repo: { - description: `${cmdRepository.description} (alias)`, - hidden: false, - argv: ['repository'], - }, - repos: { - description: cmdRepository.description, - hidden: true, - argv: ['repository'], - }, - repositories: { - description: cmdRepository.description, - hidden: true, - argv: ['repository'], - }, -} diff --git a/packages/cli/src/commands/README.md b/packages/cli/src/commands/README.md deleted file mode 100644 index 5b0442b89..000000000 --- a/packages/cli/src/commands/README.md +++ /dev/null @@ -1,334 +0,0 @@ -# Socket CLI Command Architecture - -Complete reference for all Socket CLI commands, subcommands, and their integrations. - -## Command Hierarchy - -### 76 Total Commands - -- 39 Root commands (including parent commands) -- 37 Subcommands - -## Root Commands (39) - -### Core Commands (14) - -| Command | Module | Integrates With | Subcommands | -| ----------- | --------------------------------- | --------------------------------- | ----------- | -| analytics | `analytics/cmd-analytics.mts` | Socket Analytics Dashboard API | - | -| ask | `ask/cmd-ask.mts` | Socket AI Assistant API | - | -| audit-log | `audit-log/cmd-audit-log.mts` | Socket Audit Log API | - | -| ci | `ci/cmd-ci.mts` | CI/CD Integration (Socket API) | - | -| fix | `fix/cmd-fix.mts` | Socket Fix API (security patches) | - | -| json | `json/cmd-json.mts` | JSON output formatter wrapper | - | -| login | `login/cmd-login.mts` | Socket Authentication API | - | -| logout | `logout/cmd-logout.mts` | Local credential cleanup | - | -| oops | `oops/cmd-oops.mts` | Error reporting/feedback | - | -| optimize | `optimize/cmd-optimize.mts` | Socket Registry Overrides | - | -| patch | `patch/cmd-patch.mts` | @socketsecurity/socket-patch | - | -| threat-feed | `threat-feed/cmd-threat-feed.mts` | Socket Threat Intelligence API | - | -| whoami | `whoami/cmd-whoami.mts` | Socket User API | - | -| wrapper | `wrapper/cmd-wrapper.mts` | Package manager wrapper config | - | - -### Config Commands (1 parent + 5 subcommands) - -| Command | Module | Integrates With | Type | -| --------------- | ----------------------------- | ------------------------------- | ---------- | -| **config** | `config/cmd-config.mts` | Parent command | Parent | -| ├─ config auto | `config/cmd-config-auto.mts` | Auto-configure from environment | Subcommand | -| ├─ config get | `config/cmd-config-get.mts` | Read ~/.socket/config | Subcommand | -| ├─ config list | `config/cmd-config-list.mts` | List configuration values | Subcommand | -| ├─ config set | `config/cmd-config-set.mts` | Write to ~/.socket/config | Subcommand | -| └─ config unset | `config/cmd-config-unset.mts` | Remove config values | Subcommand | - -### Install Commands (2 parents + 2 subcommands) - -| Command | Module | Integrates With | Type | -| ----------------------- | ---------------------------------------- | -------------------------------- | ---------- | -| **install** | `install/cmd-install.mts` | System-wide CLI installation | Parent | -| └─ install completion | `install/cmd-install-completion.mts` | Shell completion (bash/zsh/fish) | Subcommand | -| **uninstall** | `uninstall/cmd-uninstall.mts` | Remove CLI from system | Parent | -| └─ uninstall completion | `uninstall/cmd-uninstall-completion.mts` | Remove shell completion | Subcommand | - -### Manifest Commands (1 parent + 7 subcommands) - -| Command | Module | Integrates With | Type | -| ------------------ | ---------------------------------- | ---------------------------- | ---------- | -| **manifest** | `manifest/cmd-manifest.mts` | Parent command | Parent | -| ├─ manifest auto | `manifest/cmd-manifest-auto.mts` | Auto-detect manifests | Subcommand | -| ├─ manifest cdxgen | `manifest/cmd-manifest-cdxgen.mts` | @cyclonedx/cdxgen (SBOM) | Subcommand | -| ├─ manifest conda | `manifest/cmd-manifest-conda.mts` | conda.yml → requirements.txt | Subcommand | -| ├─ manifest gradle | `manifest/cmd-manifest-gradle.mts` | Gradle → pom.xml | Subcommand | -| ├─ manifest kotlin | `manifest/cmd-manifest-kotlin.mts` | Kotlin (Gradle) → pom.xml | Subcommand | -| ├─ manifest scala | `manifest/cmd-manifest-scala.mts` | Scala SBT → pom.xml | Subcommand | -| └─ manifest setup | `manifest/cmd-manifest-setup.mts` | Interactive manifest config | Subcommand | - -### Organization Commands (1 parent + 6 subcommands, including nested) - -| Command | Module | Integrates With | Type | -| --------------------------------- | --------------------------------------------------- | ----------------------------- | ------------------- | -| **organization** | `organization/cmd-organization.mts` | Socket Org API | Parent | -| ├─ organization dependencies | `organization/cmd-organization-dependencies.mts` | Socket Org Dependencies API | Subcommand | -| ├─ organization list | `organization/cmd-organization-list.mts` | Socket Org List API | Subcommand | -| ├─ **organization policy** | `organization/cmd-organization-policy.mts` | Parent for policy subcommands | Subcommand (Parent) | -| │ ├─ organization policy license | `organization/cmd-organization-policy-license.mts` | Socket License Policy API | Nested Subcommand | -| │ └─ organization policy security | `organization/cmd-organization-policy-security.mts` | Socket Security Policy API | Nested Subcommand | -| └─ organization quota | `organization/cmd-organization-quota.mts` | Socket Quota API | Subcommand | - -### Package Commands (1 parent + 2 subcommands) - -| Command | Module | Integrates With | Type | -| ------------------ | --------------------------------- | ---------------------------------- | ---------- | -| **package** | `package/cmd-package.mts` | Parent command | Parent | -| ├─ package score | `package/cmd-package-score.mts` | Socket Package Score API (deep) | Subcommand | -| └─ package shallow | `package/cmd-package-shallow.mts` | Socket Package Score API (shallow) | Subcommand | - -### Package Manager Wrappers (13) - -All connect via Socket Firewall (sfw) except raw-npm and raw-npx which bypass Socket entirely. - -| Command | Module | Integrates With | Subcommands | -| ------- | ------------------------- | ----------------------- | ----------- | -| bundler | `bundler/cmd-bundler.mts` | sfw → Bundler (Ruby) | - | -| cargo | `cargo/cmd-cargo.mts` | sfw → Cargo (Rust) | - | -| gem | `gem/cmd-gem.mts` | sfw → RubyGems | - | -| go | `go/cmd-go.mts` | sfw → Go modules | - | -| npm | `npm/cmd-npm.mts` | sfw → npm | - | -| npx | `npx/cmd-npx.mts` | sfw → npx | - | -| nuget | `nuget/cmd-nuget.mts` | sfw → NuGet (.NET) | - | -| pip | `pip/cmd-pip.mts` | sfw → pip/pip3 (Python) | - | -| pnpm | `pnpm/cmd-pnpm.mts` | sfw → pnpm | - | -| raw-npm | `raw-npm/cmd-raw-npm.mts` | Direct npm (no Socket) | - | -| raw-npx | `raw-npx/cmd-raw-npx.mts` | Direct npx (no Socket) | - | -| uv | `uv/cmd-uv.mts` | sfw → uv (Python) | - | -| yarn | `yarn/cmd-yarn.mts` | sfw → Yarn | - | - -### Repository Commands (1 parent + 5 subcommands) - -| Command | Module | Integrates With | Type | -| -------------------- | -------------------------------------- | ------------------------------ | ---------- | -| **repository** | `repository/cmd-repository.mts` | Socket Repository API | Parent | -| ├─ repository create | `repository/cmd-repository-create.mts` | Socket Repository API (create) | Subcommand | -| ├─ repository del | `repository/cmd-repository-del.mts` | Socket Repository API (delete) | Subcommand | -| ├─ repository list | `repository/cmd-repository-list.mts` | Socket Repository API (list) | Subcommand | -| ├─ repository update | `repository/cmd-repository-update.mts` | Socket Repository API (update) | Subcommand | -| └─ repository view | `repository/cmd-repository-view.mts` | Socket Repository API (view) | Subcommand | - -### Scan Commands (1 parent + 10 subcommands) - -| Command | Module | Integrates With | Type | -| ---------------- | ---------------------------- | ------------------------------ | ---------- | -| **scan** | `scan/cmd-scan.mts` | Socket Scan API | Parent | -| ├─ scan create | `scan/cmd-scan-create.mts` | Socket Scan API (create) | Subcommand | -| ├─ scan del | `scan/cmd-scan-del.mts` | Socket Scan API (delete) | Subcommand | -| ├─ scan diff | `scan/cmd-scan-diff.mts` | Socket Scan API (diff) | Subcommand | -| ├─ scan github | `scan/cmd-scan-github.mts` | GitHub API + Socket Scan API | Subcommand | -| ├─ scan list | `scan/cmd-scan-list.mts` | Socket Scan API (list) | Subcommand | -| ├─ scan metadata | `scan/cmd-scan-metadata.mts` | Socket Scan API (metadata) | Subcommand | -| ├─ scan reach | `scan/cmd-scan-reach.mts` | @coana-tech/cli (reachability) | Subcommand | -| ├─ scan report | `scan/cmd-scan-report.mts` | Socket Scan API (report) | Subcommand | -| ├─ scan setup | `scan/cmd-scan-setup.mts` | Interactive scan config | Subcommand | -| └─ scan view | `scan/cmd-scan-view.mts` | Socket Scan API (view) | Subcommand | - -## Command File Structure - -Each command follows a consistent pattern: - -``` -src/commands/<command>/ -├── cmd-<command>.mts # Command definition (meow config) -├── handle-<command>.mts # Business logic -├── output-<command>.mts # Output formatting (JSON/markdown) -├── fetch-<command>.mts # API calls (if applicable) -└── types.mts # TypeScript types -``` - -### Example: Package Score Command - -``` -src/commands/package/ -├── cmd-package.mts # Parent command -├── cmd-package-score.mts # Subcommand definition -├── handle-purl-deep-score.mts # Business logic -├── output-purls-deep-score.mts # Output formatting -├── fetch-purl-deep-score.mts # Socket API calls -└── parse-package-specifiers.mts # Package parsing utilities -``` - -## Integration Map - -### Socket API Services - -| Service | Commands Using It | -| ----------------------- | ----------------------------------------------------------- | -| Analytics API | analytics | -| Ask API | ask | -| Audit Log API | audit-log | -| Authentication API | login | -| Dependencies API | organization dependencies | -| Fix API | fix | -| Organization API | organization, organization list, organization quota | -| Package Score API | package score, package shallow | -| Policy API | organization policy license, organization policy security | -| Repository API | repository create/del/list/update/view | -| Scan API | scan create/del/diff/github/list/metadata/report/setup/view | -| Threat Intelligence API | threat-feed | -| User API | whoami | - -### Third-Party Tools - -| Tool | Commands Using It | -| ---------------------------- | ------------------------------------------------------------- | -| @coana-tech/cli | scan reach | -| @cyclonedx/cdxgen | manifest cdxgen | -| @socketsecurity/socket-patch | patch | -| Socket Firewall (sfw) | bundler, cargo, gem, go, npm, npx, nuget, pip, pnpm, uv, yarn | -| synp | (internal converter usage) | - -### System Integrations - -| Integration | Commands Using It | -| ------------------------ | ---------------------------------------- | -| File System (~/.socket/) | config get/set/unset/list/auto | -| GitHub API | scan github | -| Shell Completion | install completion, uninstall completion | - -## Command Registration - -Commands are exported from `src/commands.mts`: - -```typescript -export const rootCommands = { - analytics: cmdAnalytics, - ask: cmdAsk, - 'audit-log': cmdAuditLog, - // ... all root commands -} -``` - -Parent commands register subcommands using `meowWithSubcommands()`: - -```typescript -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -export const cmdScan: CliSubcommand = { - description: 'Manage Socket scans', - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} scan`, - importMeta, - subcommands: { - create: cmdScanCreate, - del: cmdScanDel, - diff: cmdScanDiff, - // ... all subcommands - }, - }, - { - aliases: { - // Optional aliases configuration - }, - }, - ) - }, -} -``` - -## Command Aliases - -Several commands have aliases defined in `src/commands.mts`: - -| Alias | Points To | Visibility | -| ------------- | ------------ | ---------- | -| audit | audit-log | Visible | -| deps | dependencies | Visible | -| feed | threat-feed | Visible | -| org | organization | Visible | -| pkg | package | Visible | -| repo | repository | Visible | -| auditLog | audit-log | Hidden | -| auditLogs | audit-log | Hidden | -| audit-logs | audit-log | Hidden | -| orgs | organization | Hidden | -| organizations | organization | Hidden | -| organisation | organization | Hidden | -| organisations | organization | Hidden | -| pip3 | pip | Hidden | -| repos | repository | Hidden | -| repositories | repository | Hidden | - -## Adding a New Command - -### 1. Create Command Directory - -```bash -mkdir -p src/commands/mycommand -``` - -### 2. Create Command Definition - -**`src/commands/mycommand/cmd-mycommand.mts`:** - -```typescript -import type { - CliCommandConfig, - CliCommandContext, -} from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'mycommand' -const description = 'My command description' - -export const cmdMyCommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[], - importMeta: ImportMeta, - context: CliCommandContext, -): Promise<void> { - // Implementation -} -``` - -### 3. Register Command - -**`src/commands.mts`:** - -```typescript -import { cmdMyCommand } from './commands/mycommand/cmd-mycommand.mts' - -export const rootCommands = { - // ... existing commands - mycommand: cmdMyCommand, -} -``` - -### 4. Add E2E Test - -**`test/e2e/binary-test-suite.e2e.test.mts`:** - -```typescript -const commands = [ - // ... existing commands - 'mycommand', -] -``` - -### 5. Update This README - -Add your command to the appropriate category above. - -## Architecture Principles - -1. **Separation of Concerns**: Command definition, business logic, output formatting, and API calls are separate -2. **Type Safety**: All commands use TypeScript with strict types -3. **Consistent Patterns**: All commands follow the same file structure and naming conventions -4. **Testability**: E2E tests for all commands, unit tests for handlers -5. **Modularity**: Subcommands are separate modules registered with parent commands -6. **Error Handling**: Custom `InputError` and `AuthError` types for consistent error reporting -7. **Output Flexibility**: Commands support JSON and markdown output formats via `--json` flag diff --git a/packages/cli/src/commands/analytics/cmd-analytics.mts b/packages/cli/src/commands/analytics/cmd-analytics.mts deleted file mode 100644 index 243703157..000000000 --- a/packages/cli/src/commands/analytics/cmd-analytics.mts +++ /dev/null @@ -1,198 +0,0 @@ -import { handleAnalytics } from './handle-analytics.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { V1_MIGRATION_GUIDE_URL } from '../../constants/socket.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' - -import type { MeowFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { webLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -// Flags interface for type safety. -interface AnalyticsFlags { - file: string - json: boolean - markdown: boolean -} - -export const CMD_NAME = 'analytics' - -const description = 'Look up analytics data' - -const hidden = false - -export const cmdAnalytics = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - file: { - type: 'string', - default: '', - description: 'Path to store result, only valid with --json/--markdown', - }, - }), - help: (command: string, { flags }: { flags: MeowFlags }) => - ` - Usage - $ ${command} [options] [ "org" | "repo" <reponame>] [TIME] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - The scope is either org or repo level, defaults to org. - - When scope is repo, a repo slug must be given as well. - - The TIME argument must be number 7, 30, or 90 and defaults to 30. - - Options - ${getFlagListOutput(flags)} - - Examples - $ ${command} org 7 - $ ${command} repo test-repo 30 - $ ${command} 90 - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - // Supported inputs: - // - [] (no args) - // - ['org'] - // - ['org', '30'] - // - ['repo', 'name'] - // - ['repo', 'name', '30'] - // - ['30'] - // Validate final values in the next step - let scope = 'org' - let time = '30' - let repoName = '' - - if (cli.input[0] === 'org') { - if (cli.input[1]) { - time = cli.input[1] - } - } else if (cli.input[0] === 'repo') { - scope = 'repo' - if (cli.input[1]) { - repoName = cli.input[1] - } - if (cli.input[2]) { - time = cli.input[2] - } - } else if (cli.input[0]) { - time = cli.input[0] - } - - const { - file: filepath, - json, - markdown, - } = cli.flags as unknown as AnalyticsFlags - - const dryRun = !!cli.flags['dryRun'] - - const noLegacy = - !cli.flags['scope'] && !cli.flags['repo'] && !cli.flags['time'] - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: noLegacy, - message: `Legacy flags are no longer supported. See the ${webLink(V1_MIGRATION_GUIDE_URL, 'v1 migration guide')}.`, - fail: 'received legacy flags', - }, - { - nook: true, - test: scope === 'org' || !!repoName, - message: 'When scope=repo, repo name should be the second argument', - fail: 'missing', - }, - { - nook: true, - test: - scope === 'org' || - (repoName !== '30' && repoName !== '7' && repoName !== '90'), - message: 'When scope is repo, the second arg should be repo, not time', - fail: 'missing', - }, - { - test: time === '30' || time === '7' || time === '90', - message: 'The time filter must either be 7, 30 or 90', - fail: 'invalid range set, see --help for command arg details.', - }, - { - nook: true, - test: !filepath || !!json || !!markdown, - message: `The \`--file\` flag is only valid when using \`${FLAG_JSON}\` or \`${FLAG_MARKDOWN}\``, - fail: 'bad', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('analytics data', { - scope, - repo: repoName || undefined, - time: `${time} days`, - }) - return - } - - return await handleAnalytics({ - filepath, - outputKind, - repo: repoName, - scope, - time: time === '90' ? 90 : time === '30' ? 30 : 7, - }) -} diff --git a/packages/cli/src/commands/analytics/fetch-org-analytics.mts b/packages/cli/src/commands/analytics/fetch-org-analytics.mts deleted file mode 100644 index ee3694720..000000000 --- a/packages/cli/src/commands/analytics/fetch-org-analytics.mts +++ /dev/null @@ -1,35 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchOrgAnalyticsDataOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchOrgAnalyticsData( - time: number, - options?: FetchOrgAnalyticsDataOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getOrgAnalytics'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchOrgAnalyticsDataOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getOrgAnalytics'>( - sockSdk.getOrgAnalytics(time.toString()), - { - commandPath, - description: 'analytics data', - }, - ) -} diff --git a/packages/cli/src/commands/analytics/fetch-repo-analytics.mts b/packages/cli/src/commands/analytics/fetch-repo-analytics.mts deleted file mode 100644 index be24834bb..000000000 --- a/packages/cli/src/commands/analytics/fetch-repo-analytics.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type RepoAnalyticsDataOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchRepoAnalyticsData( - repo: string, - time: number, - options?: RepoAnalyticsDataOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getRepoAnalytics'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as RepoAnalyticsDataOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getRepoAnalytics'>( - sockSdk.getRepoAnalytics(repo, time.toString()), - { - commandPath, - description: 'analytics data', - }, - ) -} diff --git a/packages/cli/src/commands/analytics/handle-analytics.mts b/packages/cli/src/commands/analytics/handle-analytics.mts deleted file mode 100644 index cdaf804d2..000000000 --- a/packages/cli/src/commands/analytics/handle-analytics.mts +++ /dev/null @@ -1,56 +0,0 @@ -import { fetchOrgAnalyticsData } from './fetch-org-analytics.mts' -import { fetchRepoAnalyticsData } from './fetch-repo-analytics.mts' -import { outputAnalytics } from './output-analytics.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type HandleAnalyticsConfig = { - filepath: string - outputKind: OutputKind - repo: string - scope: string - time: number -} - -export async function handleAnalytics({ - filepath, - outputKind, - repo, - scope, - time, -}: HandleAnalyticsConfig) { - let result: CResult< - | SocketSdkSuccessResult<'getOrgAnalytics'>['data'] - | SocketSdkSuccessResult<'getRepoAnalytics'>['data'] - > - if (scope === 'org') { - result = await fetchOrgAnalyticsData(time, { - commandPath: 'socket analytics', - }) - } else if (repo) { - result = await fetchRepoAnalyticsData(repo, time, { - commandPath: 'socket analytics', - }) - } else { - result = { - ok: false, - message: 'Missing repository name in command', - } - } - if (result.ok && !result.data.length) { - result = { - ok: true, - message: `The analytics data for this ${scope === 'org' ? 'organization' : 'repository'} is not yet available.`, - data: [], - } - } - - await outputAnalytics(result, { - filepath, - outputKind, - repo, - scope, - time, - }) -} diff --git a/packages/cli/src/commands/analytics/output-analytics.mts b/packages/cli/src/commands/analytics/output-analytics.mts deleted file mode 100644 index aca7187a8..000000000 --- a/packages/cli/src/commands/analytics/output-analytics.mts +++ /dev/null @@ -1,306 +0,0 @@ -import fs from 'node:fs/promises' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { debugFileOp } from '../../util/debug.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTableStringNumber } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { fileLink } from '../../util/terminal/link.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -const logger = getDefaultLogger() - -const METRICS = [ - 'total_critical_alerts', - 'total_high_alerts', - 'total_medium_alerts', - 'total_low_alerts', - 'total_critical_added', - 'total_medium_added', - 'total_low_added', - 'total_high_added', - 'total_critical_prevented', - 'total_high_prevented', - 'total_medium_prevented', - 'total_low_prevented', -] as const - -// Note: This maps `new Date(date).getMonth()` to English three letters -const Months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -] as const - -export function formatDataOrg( - data: SocketSdkSuccessResult<'getOrgAnalytics'>['data'], -): FormattedData { - const sortedTopFiveAlerts: Record<string, number> = {} - const totalTopAlerts: Record<string, number> = {} - - const formattedData = {} as Omit<FormattedData, 'top_five_alert_types'> - for (let i = 0, { length } = METRICS; i < length; i += 1) { - const metric = METRICS[i]! - formattedData[metric] = {} - } - - for (let i = 0, { length } = data; i < length; i += 1) { - const entry = data[i]! - const topFiveAlertTypes = entry.top_five_alert_types - for (const type of Object.keys(topFiveAlertTypes)) { - const count = topFiveAlertTypes[type] ?? 0 - if (totalTopAlerts[type]) { - totalTopAlerts[type] += count - } else { - totalTopAlerts[type] = count - } - } - } - - for (let i = 0, { length } = METRICS; i < length; i += 1) { - const metric = METRICS[i]! - const formatted = formattedData[metric] - for (let i = 0, { length } = data; i < length; i += 1) { - const entry = data[i]! - const date = formatDate(entry.created_at) - if (formatted[date]) { - formatted[date] += entry[metric]! - } else { - formatted[date] = entry[metric]! - } - } - } - - const topFiveAlertEntries = Object.entries(totalTopAlerts) - .sort(([_keya, a], [_keyb, b]) => b - a) - .slice(0, 5) - for (const { 0: key, 1: value } of topFiveAlertEntries) { - sortedTopFiveAlerts[key] = value - } - - return { - ...formattedData, - top_five_alert_types: sortedTopFiveAlerts, - } -} - -export function formatDataRepo( - data: SocketSdkSuccessResult<'getRepoAnalytics'>['data'], -): FormattedData { - const sortedTopFiveAlerts: Record<string, number> = {} - const totalTopAlerts: Record<string, number> = {} - - const formattedData = {} as Omit<FormattedData, 'top_five_alert_types'> - for (let i = 0, { length } = METRICS; i < length; i += 1) { - const metric = METRICS[i]! - formattedData[metric] = {} - } - - // Aggregate alert counts: sum across time entries (consistent with formatDataOrg). - for (let i = 0, { length } = data; i < length; i += 1) { - const entry = data[i]! - const topFiveAlertTypes = entry.top_five_alert_types - for (const type of Object.keys(topFiveAlertTypes)) { - const count = topFiveAlertTypes[type] ?? 0 - if (totalTopAlerts[type]) { - totalTopAlerts[type] += count - } else { - totalTopAlerts[type] = count - } - } - } - for (let i = 0, { length } = data; i < length; i += 1) { - const entry = data[i]! - for (let i = 0, { length } = METRICS; i < length; i += 1) { - const metric = METRICS[i]! - formattedData[metric]![formatDate(entry.created_at)] = entry[metric] - } - } - - const topFiveAlertEntries = Object.entries(totalTopAlerts) - .sort(([_keya, a], [_keyb, b]) => b - a) - .slice(0, 5) - for (const { 0: key, 1: value } of topFiveAlertEntries) { - sortedTopFiveAlerts[key] = value - } - - return { - ...formattedData, - top_five_alert_types: sortedTopFiveAlerts, - } -} - -export function formatDate(date: string): string { - const dateObj = new Date(date) - const month = dateObj.getMonth() - const day = dateObj.getDate() - if (Number.isNaN(month) || month < 0 || month > 11 || Number.isNaN(day)) { - return date.slice(0, 10) - } - return `${Months[month]} ${day}` -} - -type OutputAnalyticsConfig = { - filepath: string - outputKind: OutputKind - repo: string - scope: string - time: number -} - -export async function outputAnalytics( - result: CResult< - | SocketSdkSuccessResult<'getOrgAnalytics'>['data'] - | SocketSdkSuccessResult<'getRepoAnalytics'>['data'] - >, - { filepath, outputKind, repo, scope, time }: OutputAnalyticsConfig, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'json') { - const serialized = serializeResultJson(result) - - if (filepath) { - try { - await fs.writeFile(filepath, serialized, 'utf8') - debugFileOp('write', filepath) - logger.success(`Data successfully written to ${fileLink(filepath)}`) - } catch (e) { - debugFileOp('write', filepath, e) - process.exitCode = 1 - logger.log( - serializeResultJson({ - ok: false, - message: 'File Write Failure', - cause: 'There was an error trying to write the json to disk', - }), - ) - } - } else { - logger.log(serialized) - } - - return - } - - const fdata = - scope === 'org' ? formatDataOrg(result.data) : formatDataRepo(result.data) - - // Default + OUTPUT_MARKDOWN: render the markdown report. The - // previous default branched through an iocraft TUI renderer; the - // renderer was retired alongside iocraft itself, and markdown is the - // natural plain-text fallback. - const serialized = renderMarkdown(fdata, time, repo) - - // Write markdown output to file if filepath is specified. - if (filepath) { - try { - await fs.writeFile(filepath, serialized, 'utf8') - debugFileOp('write', filepath) - logger.success(`Data successfully written to ${fileLink(filepath)}`) - } catch (e) { - debugFileOp('write', filepath, e) - logger.error(e) - } - } else { - logger.log(serialized) - } -} - -export interface FormattedData { - top_five_alert_types: Record<string, number> - total_critical_alerts: Record<string, number> - total_high_alerts: Record<string, number> - total_medium_alerts: Record<string, number> - total_low_alerts: Record<string, number> - total_critical_added: Record<string, number> - total_medium_added: Record<string, number> - total_low_added: Record<string, number> - total_high_added: Record<string, number> - total_critical_prevented: Record<string, number> - total_high_prevented: Record<string, number> - total_medium_prevented: Record<string, number> - total_low_prevented: Record<string, number> -} - -export function renderMarkdown( - data: FormattedData, - days: number, - repoSlug: string, -): string { - return `${` -# Socket Alert Analytics - -These are the Socket.dev analytics for the ${repoSlug ? `${repoSlug} repo` : 'org'} of the past ${days} days - -${[ - [ - 'Total critical alerts', - mdTableStringNumber('Date', 'Counts', data.total_critical_alerts), - ], - [ - 'Total high alerts', - mdTableStringNumber('Date', 'Counts', data.total_high_alerts), - ], - [ - 'Total critical alerts added to the main branch', - mdTableStringNumber('Date', 'Counts', data.total_critical_added), - ], - [ - 'Total high alerts added to the main branch', - mdTableStringNumber('Date', 'Counts', data.total_high_added), - ], - [ - 'Total critical alerts prevented from the main branch', - mdTableStringNumber('Date', 'Counts', data.total_critical_prevented), - ], - [ - 'Total high alerts prevented from the main branch', - mdTableStringNumber('Date', 'Counts', data.total_high_prevented), - ], - [ - 'Total medium alerts prevented from the main branch', - mdTableStringNumber('Date', 'Counts', data.total_medium_prevented), - ], - [ - 'Total low alerts prevented from the main branch', - mdTableStringNumber('Date', 'Counts', data.total_low_prevented), - ], -] - .map(([title, table]) => - ` -## ${title} - -${table} -`.trim(), - ) - .join('\n\n')} - -## Top 5 alert types - -${mdTableStringNumber('Name', 'Counts', data.top_five_alert_types)} -`.trim()}\n` -} diff --git a/packages/cli/src/commands/ask/cmd-ask.mts b/packages/cli/src/commands/ask/cmd-ask.mts deleted file mode 100644 index e528816b9..000000000 --- a/packages/cli/src/commands/ask/cmd-ask.mts +++ /dev/null @@ -1,97 +0,0 @@ -import { handleAsk } from './handle-ask.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { InputError } from '../../util/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'ask' - -const description = 'Ask in plain English' - -const hidden = false - -export const cmdAsk = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - execute: { - type: 'boolean', - shortFlag: 'e', - default: false, - description: 'Execute the command directly', - }, - explain: { - type: 'boolean', - default: false, - description: 'Show detailed explanation', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} "<question>" [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} "scan for vulnerabilities" - $ ${command} "is express safe to use" - $ ${command} "fix critical issues" --execute - $ ${command} "show production vulnerabilities" --explain - $ ${command} "optimize my dependencies" - - Tips - - Be specific about what you want - - Mention "production" or "dev" to filter - - Use severity levels: critical, high, medium, low - - Say "dry run" to preview changes - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const query = cli.input[0] - - if (!query) { - throw new InputError( - 'socket ask requires a QUERY positional argument; pass a question like `socket ask "scan for vulnerabilities"`', - ) - } - - const execute = !!cli.flags['execute'] - const explain = !!cli.flags['explain'] - - await handleAsk({ - query, - execute, - explain, - }) -} diff --git a/packages/cli/src/commands/ask/handle-ask.mts b/packages/cli/src/commands/ask/handle-ask.mts deleted file mode 100644 index 76bfe3270..000000000 --- a/packages/cli/src/commands/ask/handle-ask.mts +++ /dev/null @@ -1,424 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { onnxSemanticMatch } from './onnx-match.mts' -import { outputAskCommand } from './output-ask.mts' -import { normalizeQuery, wordOverlapMatch } from './word-overlap-match.mts' - -// Re-export the matchers + helpers so existing import paths keep working. -export { - cosineSimilarity, - ensureCommandEmbeddings, - getEmbedding, - getEmbeddingPipeline, - onnxSemanticMatch, -} from './onnx-match.mts' -export { - extractWords, - loadSemanticIndex, - normalizeQuery, - wordOverlap, - wordOverlapMatch, -} from './word-overlap-match.mts' - -const logger = getDefaultLogger() - -// Confidence threshold: pattern-match scores below this trigger the ONNX -// semantic-match fallback (currently a no-op; see onnx-match.mts). -const PATTERN_MATCH_THRESHOLD = 0.6 - -interface HandleAskOptions { - query: string - execute: boolean - explain: boolean -} - -interface ParsedIntent { - action: string - command: string[] - confidence: number - explanation: string - packageName?: string | undefined - severity?: string | undefined - environment?: string | undefined - isDryRun?: boolean | undefined -} - -/** - * Pattern matching rules for natural language. - */ -const PATTERNS = { - __proto__: null, - // Fix patterns (highest priority - action words). - fix: { - keywords: ['fix', 'resolve', 'repair', 'remediate', 'update', 'upgrade'], - command: ['fix'], - explanation: 'Applying package updates to fix GitHub security alerts', - priority: 3, - }, - // Patch patterns (high priority - specific action). - patch: { - keywords: ['patch', 'apply patch'], - command: ['patch'], - explanation: 'Directly patching code to remove CVEs', - priority: 3, - }, - // Optimize patterns (high priority - action words). - optimize: { - keywords: [ - 'optimize', - 'enhance', - 'improve', - 'replace', - 'alternative', - 'better', - ], - command: ['optimize'], - explanation: 'Replacing dependencies with Socket registry alternatives', - priority: 3, - }, - // Package safety patterns (medium priority). - package: { - keywords: [ - 'safe', - 'trust', - 'score', - 'rating', - 'quality', - 'package', - 'dependency', - ], - command: ['package', 'score'], - explanation: 'Checking package security score', - priority: 2, - }, - // Scan patterns (medium priority). - scan: { - keywords: [ - 'scan', - 'check', - 'vulnerabilit', - 'audit', - 'analyze', - 'inspect', - 'review', - ], - command: ['scan', 'create'], - explanation: 'Scanning your project for security vulnerabilities', - priority: 2, - }, - // Issues patterns (lowest priority - descriptive words). - issues: { - keywords: ['problem', 'alert', 'warning', 'concern'], - command: ['scan', 'create'], - explanation: 'Finding issues in your dependencies', - priority: 1, - }, -} as const - -/** - * Severity levels mapping. - */ -const SEVERITY_KEYWORDS = { - __proto__: null, - critical: ['critical', 'severe', 'urgent', 'blocker'], - high: ['high', 'important', 'major'], - medium: ['medium', 'moderate', 'normal'], - low: ['low', 'minor', 'trivial'], -} as const - -/** - * Environment keywords. - */ -const ENVIRONMENT_KEYWORDS = { - __proto__: null, - production: ['production', 'prod'], - development: ['development', 'dev'], -} as const - -/** - * Read package.json to get context. - */ -export async function getProjectContext(cwd: string): Promise<{ - hasPackageJson: boolean - dependencies?: Record<string, string> | undefined - devDependencies?: Record<string, string> | undefined -}> { - try { - const pkgPath = path.join(cwd, 'package.json') - const content = await fs.readFile(pkgPath, 'utf8') - const pkg = JSON.parse(content) - return { - hasPackageJson: true, - dependencies: pkg.dependencies || {}, - devDependencies: pkg.devDependencies || {}, - } - } catch (_e) { - return { hasPackageJson: false } - } -} - -/** - * Main handler for ask command. - */ -export async function handleAsk(options: HandleAskOptions): Promise<void> { - const { execute, explain, query } = options - - // Parse the intent. - const intent = await parseIntent(query) - - // Get project context. - const context = await getProjectContext(process.cwd()) - - // Show what we understood. - outputAskCommand({ - query, - intent, - context, - explain, - }) - - // If not executing, just show the command. - if (!execute) { - logger.log('') - logger.log('💡 Tip: Add --execute or -e to run this command directly') - return - } - - // Execute the command. - logger.log('') - logger.log('🚀 Executing...') - logger.log('') - - const result = await spawn('socket', intent.command, { - stdio: 'inherit', - cwd: process.cwd(), - }) - - if (!result) { - logger.error('Failed to execute command') - process.exit(1) - } - - if (result.code !== 0) { - logger.error(`Command failed with exit code ${result.code}`) - process.exit(result.code) - } -} - -/** - * Parse natural language query into structured intent. - */ -export async function parseIntent(query: string): Promise<ParsedIntent> { - // Normalize the query to handle verb tenses, plurals, etc. - const lowerQuery = normalizeQuery(query) - - // Check for dry run. - const isDryRun = - lowerQuery.includes('dry run') || lowerQuery.includes('preview') - - // Extract package name from original query (not normalized). - let packageName: string | undefined - const quotedMatch = query.match(/['"]([^'"]+)['"]/) - if (quotedMatch) { - packageName = quotedMatch[1] - } else { - // Try to find package name after "is", "check", "about", "with". - // Must look like a real package (has @, /, or contains common package patterns). - const pkgMatch = query - .toLowerCase() - .match(/(?:about|check|is|with)\s+([a-z0-9-@/]+)/i) - if (pkgMatch) { - const candidate = pkgMatch[1] - // Only accept if it looks like a real package name (not common words). - if ( - candidate && - (candidate.includes('@') || - candidate.includes('/') || - candidate.match(/^[a-z0-9-]+$/)) - ) { - // Reject common command words. - const commonWords = [ - 'scan', - 'fix', - 'patch', - 'optimize', - 'vulnerabilities', - 'issues', - 'problems', - 'alerts', - 'security', - 'safe', - 'check', - ] - if (!commonWords.includes(candidate)) { - packageName = candidate - } - } - } - } - - // Detect severity. - let severity: string | undefined - for (const [level, keywords] of Object.entries(SEVERITY_KEYWORDS)) { - if ( - Array.isArray(keywords) && - keywords.some(kw => lowerQuery.includes(kw)) - ) { - severity = level - break - } - } - - // Detect environment. - let environment: string | undefined - for (const [env, keywords] of Object.entries(ENVIRONMENT_KEYWORDS)) { - if ( - Array.isArray(keywords) && - keywords.some(kw => lowerQuery.includes(kw)) - ) { - environment = env - break - } - } - - // Match against patterns. - let bestMatch: - | { - action: string - command: string[] - explanation: string - confidence: number - score: number - } - | undefined = undefined - - for (const [action, pattern] of Object.entries(PATTERNS)) { - if (!pattern) { - continue - } - const matchCount = pattern.keywords.filter(kw => - lowerQuery.includes(kw), - ).length - - if (matchCount > 0) { - const confidence = matchCount / pattern.keywords.length - // Priority-weighted score: higher priority patterns win ties. - const score = confidence * (pattern.priority || 1) - - if (!bestMatch || score > bestMatch.score) { - bestMatch = { - action, - command: [...pattern.command], - explanation: pattern.explanation, - confidence, - score, - } - } - } - } - - // Hybrid semantic matching: try multiple strategies if confidence is low. - if (!bestMatch || bestMatch.confidence < PATTERN_MATCH_THRESHOLD) { - // Strategy 1: Fast word-overlap matching (~0ms, 80-90% accuracy). - const wordMatch = await wordOverlapMatch(query) - - if (wordMatch && wordMatch.confidence > (bestMatch?.confidence || 0)) { - // Use word-overlap match. - /* c8 ignore start - word-overlap match selected branch; requires wordOverlapMatch to return a specific PATTERNS-keyed action that beats the current pattern-match confidence; tests cover the matchers in isolation */ - const pattern = PATTERNS[wordMatch.action as keyof typeof PATTERNS] - if (pattern) { - bestMatch = { - action: wordMatch.action, - command: [...pattern.command], - explanation: pattern.explanation, - confidence: wordMatch.confidence, - score: wordMatch.confidence, - } - } - /* c8 ignore stop */ - } - - // Strategy 2: ONNX semantic matching (50-80ms, 95-98% accuracy). - // Only try if still low confidence. - if (!bestMatch || bestMatch.confidence < 0.5) { - const onnxMatch = await onnxSemanticMatch(query) - - if (onnxMatch && onnxMatch.confidence > (bestMatch?.confidence || 0)) { - // Use ONNX semantic match. - /* c8 ignore start - ONNX match selected branch; requires onnxSemanticMatch to return a specific PATTERNS-keyed action that beats the current confidence; tests cover the matchers in isolation */ - const pattern = PATTERNS[onnxMatch.action as keyof typeof PATTERNS] - if (pattern) { - bestMatch = { - action: onnxMatch.action, - command: [...pattern.command], - explanation: pattern.explanation, - confidence: onnxMatch.confidence, - score: onnxMatch.confidence, - } - } - /* c8 ignore stop */ - } - } - } - - // Default to scan if still no match. - if (!bestMatch) { - bestMatch = { - action: 'scan', - command: ['scan', 'create'], - explanation: 'Scanning your project', - confidence: 0.5, - score: 0.5, - } - } - - // Build final command with modifiers. - const command = [...bestMatch.command] - - // Add package name if detected and command supports it. - if (packageName && bestMatch.action === 'package') { - command.push(packageName) - } - - // Add severity flag. - if (severity && (bestMatch.action === 'fix' || bestMatch.action === 'scan')) { - command.push(`--severity=${severity}`) - } - - // Add environment flag. - if (environment === 'production' && bestMatch.action === 'scan') { - command.push('--prod') - } - - // Add dry run flag for destructive commands. - if ( - isDryRun || - (bestMatch.action === 'fix' && !lowerQuery.includes('execute')) - ) { - command.push('--dry-run') - } - - const result: ParsedIntent = { - action: bestMatch.action, - command, - confidence: bestMatch.confidence, - explanation: bestMatch.explanation, - isDryRun, - } - - if (packageName !== undefined) { - result.packageName = packageName - } - if (severity !== undefined) { - result.severity = severity - } - if (environment !== undefined) { - result.environment = environment - } - - return result -} diff --git a/packages/cli/src/commands/ask/onnx-match.mts b/packages/cli/src/commands/ask/onnx-match.mts deleted file mode 100644 index ea31355e9..000000000 --- a/packages/cli/src/commands/ask/onnx-match.mts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * ONNX-embedding-based command matching for `socket ask`. - * - * Extracted from handle-ask.mts to keep that file under the 1000-line cap. The - * ONNX matcher is the slow path: it lazily loads a ~17MB MiniLM model - * (currently disabled — see getEmbeddingPipeline body) and scores command - * descriptions against the query using cosine similarity over the embedded - * vectors. Used as a fallback when both pattern matching and word-overlap - * matching score below their respective confidence thresholds. - */ - -// Lazy-loaded ONNX embedding pipeline (~17MB model when enabled). -type EmbeddingPipeline = { - embed(text: string): Promise<{ embedding: Float32Array }> -} -const embeddingPipeline: EmbeddingPipeline | undefined = undefined -let embeddingPipelineFailure = false -const commandEmbeddings: Record<string, Float32Array> = {} - -/** - * Compute cosine similarity between two vectors. Since our embeddings are - * normalized, cosine similarity reduces to a dot product. Returns 0 when the - * vectors have different lengths. - */ -export function cosineSimilarity(a: Float32Array, b: Float32Array): number { - if (a.length !== b.length) { - return 0 - } - let dotProduct = 0 - for (let i = 0; i < a.length; i++) { - dotProduct += (a[i] ?? 0) * (b[i] ?? 0) - } - return dotProduct -} - -/** - * Pre-compute embeddings for the canonical command descriptions. Idempotent: - * skips work after the first successful pass. Reads through getEmbedding so a - * disabled pipeline is a silent no-op. - */ -export async function ensureCommandEmbeddings(): Promise<void> { - /* c8 ignore start -- defensive: commandEmbeddings only populates when the ONNX pipeline is enabled, which is currently disabled (see getEmbeddingPipeline). */ - if (Object.keys(commandEmbeddings).length > 0) { - return - } - /* c8 ignore stop */ - - const commandDescriptions = { - __proto__: null, - fix: 'fix vulnerabilities by updating packages to secure versions', - patch: 'apply patches to remove CVEs from code', - optimize: - 'replace dependencies with better alternatives from Socket registry', - package: 'check safety score and rating of a package', - scan: 'scan project for security vulnerabilities and issues', - } as const - - for (const [action, description] of Object.entries(commandDescriptions)) { - if (description) { - const embedding = await getEmbedding(description) - /* c8 ignore start -- defensive: getEmbedding always returns undefined while the ONNX pipeline is disabled. */ - if (embedding) { - commandEmbeddings[action] = embedding - } - /* c8 ignore stop */ - } - } -} - -/** - * Get the embedding for a text string. Returns null when the pipeline is - * unavailable or the underlying call throws. - */ -export async function getEmbedding( - text: string, -): Promise<Float32Array | undefined> { - const model = await getEmbeddingPipeline() - if (!model) { - return undefined - } - /* c8 ignore start -- defensive: model is always undefined while the ONNX pipeline is disabled, so this branch is unreachable. */ - try { - const result = await model.embed(text) - return result.embedding - } catch (_e) { - // Silently fail — pattern matching will handle the query. - return undefined - } - /* c8 ignore stop */ -} - -/** - * Lazily load the ONNX embedding pipeline. Currently disabled due to ONNX - * Runtime build issues — always returns null and marks the pipeline as - * permanently failed so subsequent calls short-circuit. - * - * Re-enabling requires uncommenting the MiniLMInference import below and - * verifying the WASM bundle ships with the SEA build. - */ -export async function getEmbeddingPipeline() { - /* c8 ignore start -- defensive: embeddingPipeline is a constant `undefined` while the ONNX pipeline is disabled. */ - if (embeddingPipeline) { - return embeddingPipeline - } - /* c8 ignore stop */ - if (embeddingPipelineFailure) { - return undefined - } - try { - // TEMPORARILY DISABLED: ONNX Runtime build issues. - // Load our custom MiniLM inference engine. - // This uses direct ONNX Runtime + embedded WASM (no transformers.js). - // Note: model is optional — pattern matching works fine without it. - // const { MiniLMInference } = await import('../../util/minilm-inference.mts') - // embeddingPipeline = await MiniLMInference.create() - // return embeddingPipeline - - // Temporarily fall back to pattern matching only. - embeddingPipelineFailure = true - return undefined - } /* c8 ignore start -- defensive: the try block above only contains synchronous assignments and a return, so the catch is unreachable. */ catch (_e) { - // Model not available — silently fall back to pattern matching. - embeddingPipelineFailure = true - return undefined - } - /* c8 ignore stop */ -} - -/** - * Score the query against pre-computed command embeddings and return the best - * match if it clears 0.5 cosine similarity. Returns null when the embedding - * pipeline is unavailable, the query embeds to null, or no command meets the - * threshold. - */ -export async function onnxSemanticMatch(query: string): Promise< - | { - action: string - confidence: number - } - | undefined -> { - await ensureCommandEmbeddings() - - const queryEmbedding = await getEmbedding(query) - if (!queryEmbedding || !Object.keys(commandEmbeddings).length) { - return undefined - } - - /* c8 ignore start -- defensive: queryEmbedding is always undefined and commandEmbeddings always empty while the ONNX pipeline is disabled, so the early-return above always fires. */ - let bestAction = '' - let bestScore = 0 - - for (const [action, embedding] of Object.entries(commandEmbeddings)) { - const similarity = cosineSimilarity(queryEmbedding, embedding) - if (similarity > bestScore) { - bestScore = similarity - bestAction = action - } - } - - // Require minimum 0.5 similarity to use ONNX match. - if (bestScore < 0.5) { - return undefined - } - - return { action: bestAction, confidence: bestScore } - /* c8 ignore stop */ -} diff --git a/packages/cli/src/commands/ask/output-ask.mts b/packages/cli/src/commands/ask/output-ask.mts deleted file mode 100644 index 89735f8a8..000000000 --- a/packages/cli/src/commands/ask/output-ask.mts +++ /dev/null @@ -1,195 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-status-emoji -- TUI / custom output formatter; emojis are part of the visual contract. */ - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -const logger = getDefaultLogger() - -interface OutputAskCommandOptions { - query: string - intent: { - action: string - command: string[] - confidence: number - explanation: string - packageName?: string | undefined - severity?: string | undefined - environment?: string | undefined - isDryRun?: boolean | undefined - } - context: { - hasPackageJson: boolean - dependencies?: Record<string, string> | undefined - devDependencies?: Record<string, string> | undefined - } - explain: boolean -} - -/** - * Explain what the command does. - */ -export function explainCommand(intent: { - action: string - command: string[] - severity?: string | undefined - environment?: string | undefined - isDryRun?: boolean | undefined -}): string { - const parts = [] - - switch (intent.action) { - case 'scan': - parts.push(' • Creates a new security scan of your project') - parts.push(' • Analyzes all dependencies for vulnerabilities') - parts.push(' • Checks for supply chain attacks, typosquatting, etc.') - if (intent.severity) { - parts.push( - ` • Filters results to show only ${intent.severity} severity issues`, - ) - } - if (intent.environment === 'production') { - parts.push( - ' • Scans only production dependencies (not dev dependencies)', - ) - } - break - - case 'package': - parts.push(' • Checks the security score of a specific package') - parts.push(' • Shows alerts, vulnerabilities, and quality metrics') - parts.push(' • Provides a 0-100 score based on multiple factors') - break - - case 'fix': - parts.push(' • Applies package updates to fix GitHub security alerts') - parts.push(' • Updates vulnerable packages to safe versions') - if (intent.isDryRun) { - parts.push( - ' • Preview mode: shows what would change without making changes', - ) - } else { - parts.push( - ' • WARNING: This will modify your package.json and lockfile', - ) - } - if (intent.severity) { - parts.push(` • Only fixes ${intent.severity} severity issues`) - } - break - - case 'patch': - parts.push(' • Directly patches code to remove CVEs') - parts.push(' • Applies surgical fixes to vulnerable code paths') - parts.push(' • Creates patch files in your project') - if (intent.isDryRun) { - parts.push( - ' • Preview mode: shows available patches without applying them', - ) - } - break - - case 'optimize': - parts.push(' • Replaces dependencies with Socket registry alternatives') - parts.push( - ' • Uses enhanced versions with better security and performance', - ) - parts.push(' • Adds overrides to your package.json') - if (intent.isDryRun) { - parts.push( - ' • Preview mode: shows recommendations without making changes', - ) - } - break - - case 'issues': - parts.push(' • Lists all detected issues in your dependencies') - parts.push(' • Shows severity, type, and affected packages') - if (intent.severity) { - parts.push(` • Filtered to ${intent.severity} severity issues only`) - } - break - - default: - parts.push(' • Runs the interpreted command') - } - - return parts.join('\n') -} - -/** - * Format the ask command output. - */ -export function outputAskCommand(options: OutputAskCommandOptions): void { - const { context, explain, intent, query } = options - - // Show the query. - logger.log('') - logger.log(colors.bold(colors.magenta('❯ You asked:'))) - logger.log(` "${colors.cyan(query)}"`) - logger.log('') - - // Show interpretation. - logger.log(colors.bold(colors.magenta('🤖 I understood:'))) - logger.log(` ${intent.explanation}`) - - // Show extracted details if present. - const details = [] - if (intent.packageName) { - details.push(`Package: ${colors.cyan(intent.packageName)}`) - } - if (intent.severity) { - const severityColor = - intent.severity === 'critical' || intent.severity === 'high' - ? colors.red - : intent.severity === 'medium' - ? colors.yellow - : colors.blue - details.push(`Severity: ${severityColor(intent.severity)}`) - } - if (intent.environment) { - details.push(`Environment: ${colors.green(intent.environment)}`) - } - if (intent.isDryRun) { - details.push(`Mode: ${colors.yellow('dry-run (preview only)')}`) - } - - if (details.length > 0) { - logger.log(` ${details.join(', ')}`) - } - - // Show confidence if low. - if (intent.confidence < 0.6) { - logger.log('') - logger.log( - colors.yellow( - '⚠️ Low confidence - the command might not match your intent exactly', - ), - ) - } - - logger.log('') - - // Show the command. - logger.log(colors.bold(colors.magenta('📝 Command:'))) - logger.log( - ` ${colors.green('$')} socket ${colors.cyan(intent.command.join(' '))}`, - ) - - // Show explanation if requested. - if (explain) { - logger.log('') - logger.log(colors.bold(colors.magenta('💡 Explanation:'))) - logger.log(explainCommand(intent)) - } - - // Show context. - if (context.hasPackageJson && explain) { - logger.log('') - logger.log(colors.bold(colors.magenta('📦 Project Context:'))) - const depCount = Object.keys(context.dependencies || {}).length - const devDepCount = Object.keys(context.devDependencies || {}).length - logger.log(` Dependencies: ${depCount} packages`) - logger.log(` Dev Dependencies: ${devDepCount} packages`) - } -} diff --git a/packages/cli/src/commands/ask/word-overlap-match.mts b/packages/cli/src/commands/ask/word-overlap-match.mts deleted file mode 100644 index 515d50044..000000000 --- a/packages/cli/src/commands/ask/word-overlap-match.mts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Word-overlap-based command matching for `socket ask`. - * - * Extracted from handle-ask.mts to keep that file under the 1000-line cap. The - * word-overlap matcher is the fast path: ~3KB of pure JavaScript with no ML - * model. It loads a pre-computed semantic index from disk lazily and scores - * each command's word list against the query using Jaccard similarity. - * - * If the best score clears WORD_OVERLAP_THRESHOLD, the matcher returns the - * winning action; otherwise it returns null and the caller falls back to - * pattern matching or the ONNX fallback. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import nlp from 'compromise' - -import { getHome } from '@socketsecurity/lib-stable/env/home' - -// Minimum Jaccard similarity for word-overlap matching to win. -const WORD_OVERLAP_THRESHOLD = 0.3 - -// Lazy-loaded ~3KB semantic index. `null` until loadSemanticIndex resolves. -type SemanticIndex = { - commands?: Record<string, unknown> | undefined -} -let semanticIndex: SemanticIndex | undefined = undefined - -/** - * Extract meaningful words from text: lowercase, stripped of punctuation, - * filtered to length > 2. Used both as the matcher's tokenizer and exposed as a - * utility for tests. - */ -export function extractWords(text: string): string[] { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .split(/\s+/) - .filter(w => w.length > 2) -} - -/** - * Lazily load the pre-computed semantic index from disk. Returns null if HOME - * is unset or the file is unreadable — both treated as "no index, fall through - * to pattern matching" rather than fatal errors. - */ -export async function loadSemanticIndex() { - if (semanticIndex) { - return semanticIndex - } - - try { - const homeDir = getHome() - if (!homeDir) { - return undefined - } - const indexPath = path.join( - homeDir, - '.claude/skills/socket-cli/semantic-index.json', - ) - - const content = await fs.readFile(indexPath, 'utf-8') - semanticIndex = JSON.parse(content) - return semanticIndex - } catch (_e) { - // Semantic index not available — not a critical error. - return undefined - } -} - -/** - * Normalize query using NLP to handle variations in phrasing. Verbs become - * infinitive ("fixing" → "fix"), nouns become singular ("vulnerabilities" → - * "vulnerability"). Falls back to plain lowercase if compromise throws. - */ -export function normalizeQuery(query: string): string { - try { - const doc = nlp(query) - doc.verbs().toInfinitive() - doc.nouns().toSingular() - return doc.out('text').toLowerCase() - /* c8 ignore start - defensive fallback when compromise NLP library throws unexpectedly */ - } catch (_e) { - return query.toLowerCase() - } - /* c8 ignore stop */ -} - -/** - * Compute word overlap score between query and command using Jaccard - * similarity: |intersection| / |union|. Returns 0 when both sides are empty. - */ -export function wordOverlap( - queryWords: Set<string>, - commandWords: string[], -): number { - const commandSet = new Set(commandWords) - const intersection = new Set([...queryWords].filter(w => commandSet.has(w))) - const union = new Set([...queryWords, ...commandWords]) - return union.size === 0 ? 0 : intersection.size / union.size -} - -/** - * Score every command in the semantic index against the query and return the - * best match if its score clears WORD_OVERLAP_THRESHOLD. Returns null when the - * index isn't loaded, the query has no scoring tokens, or no command meets the - * threshold. - */ -export async function wordOverlapMatch(query: string): Promise< - | { - action: string - confidence: number - } - | undefined -> { - const index = await loadSemanticIndex() - if (!index || !index.commands) { - return undefined - } - - const queryWords = new Set(extractWords(query)) - if (queryWords.size === 0) { - return undefined - } - - let bestAction = '' - let bestScore = 0 - - for (const [commandName, commandData] of Object.entries(index.commands)) { - if ( - !commandData || - typeof commandData !== 'object' || - !('words' in commandData) || - !Array.isArray(commandData.words) - ) { - continue - } - const score = wordOverlap(queryWords, commandData.words) - if (score > bestScore) { - bestScore = score - bestAction = commandName - } - } - - if (bestScore < WORD_OVERLAP_THRESHOLD) { - return undefined - } - - return { action: bestAction, confidence: bestScore } -} diff --git a/packages/cli/src/commands/audit-log/cmd-audit-log.mts b/packages/cli/src/commands/audit-log/cmd-audit-log.mts deleted file mode 100644 index 606614b3b..000000000 --- a/packages/cli/src/commands/audit-log/cmd-audit-log.mts +++ /dev/null @@ -1,214 +0,0 @@ -import { handleAuditLog } from './handle-audit-log.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { V1_MIGRATION_GUIDE_URL } from '../../constants/socket.mjs' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { webLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface AuditLogFlags { - interactive: boolean - json: boolean - markdown: boolean - org: string - page: number - perPage: number -} - -export const CMD_NAME = 'audit-log' - -const description = 'Look up the audit log for an organization' - -const hidden = false - -export const cmdAuditLog = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input.\nUse --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - page: { - type: 'number', - description: 'Result page to fetch', - }, - perPage: { - type: 'number', - default: 30, - description: 'Results per page - default is 30', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [FILTER] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - This feature requires an Enterprise Plan. To learn more about getting access - to this feature and many more, please visit the ${webLink(`${'https://socket.dev'}/pricing`, 'Socket pricing page')}. - - The type FILTER arg is an enum. Defaults to any. It should be one of these: - associateLabel, cancelInvitation, changeMemberRole, changePlanSubscriptionSeats, - createApiToken, createLabel, deleteLabel, deleteLabelSetting, deleteReport, - deleteRepository, disassociateLabel, joinOrganization, removeMember, - resetInvitationLink, resetOrganizationSettingToDefault, rotateApiToken, - sendInvitation, setLabelSettingToDefault, syncOrganization, transferOwnership, - updateAlertTriage, updateApiTokenCommitter, updateApiTokenMaxQuota, - updateApiTokenName', updateApiTokenScopes, updateApiTokenVisibility, - updateLabelSetting, updateOrganizationSetting, upgradeOrganizationPlan - - The page arg should be a positive integer, offset 1. Defaults to 1. - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} deleteReport --page 2 --per-page 10 - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { - interactive, - json, - markdown, - org: orgFlag, - page, - perPage, - } = cli.flags as unknown as AuditLogFlags - - const dryRun = !!cli.flags['dryRun'] - - const noLegacy = !cli.flags['type'] - - let [typeFilter = ''] = cli.input - - typeFilter = String(typeFilter) - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: noLegacy, - message: `Legacy flags are no longer supported. See the ${webLink(V1_MIGRATION_GUIDE_URL, 'v1 migration guide')}.`, - fail: 'received legacy flags', - }, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: /^[a-zA-Z]*$/.test(typeFilter), - message: 'The filter must be an a-zA-Z string, it is an enum', - fail: 'it was given but not a-zA-Z', - }, - ) - if (!wasValidInput) { - return - } - - // Validate numeric pagination parameters. - const validatedPage = Number(page || 0) - const validatedPerPage = Number(perPage || 0) - - if (dryRun) { - outputDryRunFetch('audit log entries', { - organization: orgSlug, - filter: typeFilter || 'any', - page: validatedPage || 1, - perPage: validatedPerPage || 30, - }) - return - } - - if (Number.isNaN(validatedPage) || validatedPage < 0) { - throw new InputError( - `--page must be a non-negative integer (saw: "${page}"); pass a number like --page=1`, - ) - } - if (Number.isNaN(validatedPerPage) || validatedPerPage < 0) { - throw new InputError( - `--per-page must be a non-negative integer (saw: "${perPage}"); pass a number like --per-page=30`, - ) - } - - await handleAuditLog({ - orgSlug, - outputKind, - page: validatedPage, - perPage: validatedPerPage, - logType: - typeFilter && typeFilter.length > 0 - ? typeFilter.charAt(0).toUpperCase() + typeFilter.slice(1) - : '', - }) -} diff --git a/packages/cli/src/commands/audit-log/fetch-audit-log.mts b/packages/cli/src/commands/audit-log/fetch-audit-log.mts deleted file mode 100644 index 9bccf7f1d..000000000 --- a/packages/cli/src/commands/audit-log/fetch-audit-log.mts +++ /dev/null @@ -1,57 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchAuditLogsConfig = { - logType: string - orgSlug: string - outputKind: OutputKind - page: number - perPage: number -} - -type FetchAuditLogOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchAuditLog( - config: FetchAuditLogsConfig, - options?: FetchAuditLogOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getAuditLogEvents'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchAuditLogOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - const { logType, orgSlug, outputKind, page, perPage } = { - __proto__: null, - ...config, - } as FetchAuditLogsConfig - - return await handleApiCall<'getAuditLogEvents'>( - sockSdk.getAuditLogEvents(orgSlug, { - // I'm not sure this is used at all. - outputJson: outputKind === 'json', - // I'm not sure this is used at all. - outputMarkdown: outputKind === 'markdown', - orgSlug, - type: logType, - page, - per_page: perPage, - }), - { - commandPath, - description: `audit log for ${orgSlug}`, - }, - ) -} diff --git a/packages/cli/src/commands/audit-log/handle-audit-log.mts b/packages/cli/src/commands/audit-log/handle-audit-log.mts deleted file mode 100644 index 507c1a78f..000000000 --- a/packages/cli/src/commands/audit-log/handle-audit-log.mts +++ /dev/null @@ -1,39 +0,0 @@ -import { fetchAuditLog } from './fetch-audit-log.mts' -import { outputAuditLog } from './output-audit-log.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleAuditLog({ - logType, - orgSlug, - outputKind, - page, - perPage, -}: { - logType: string - outputKind: OutputKind - orgSlug: string - page: number - perPage: number -}): Promise<void> { - const auditLogs = await fetchAuditLog( - { - logType, - orgSlug, - outputKind, - page, - perPage, - }, - { - commandPath: 'socket audit-log', - }, - ) - - await outputAuditLog(auditLogs, { - logType, - orgSlug, - outputKind, - page, - perPage, - }) -} diff --git a/packages/cli/src/commands/audit-log/output-audit-log.mts b/packages/cli/src/commands/audit-log/output-audit-log.mts deleted file mode 100644 index 3251e8db6..000000000 --- a/packages/cli/src/commands/audit-log/output-audit-log.mts +++ /dev/null @@ -1,168 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { FLAG_JSON, OUTPUT_JSON, REDACTED } from '../../constants/cli.mts' -import { VITEST } from '../../env/vitest.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTable } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type AuditLogEvent = - SocketSdkSuccessResult<'getAuditLogEvents'>['data']['results'][number] - -export async function outputAsJson( - auditLogs: CResult<SocketSdkSuccessResult<'getAuditLogEvents'>['data']>, - { - logType, - orgSlug, - page, - perPage, - }: { - logType: string - orgSlug: string - page: number - perPage: number - }, -): Promise<string> { - if (!auditLogs.ok) { - return serializeResultJson(auditLogs) - } - - return serializeResultJson({ - ok: true, - data: { - desc: 'Audit logs for given query', - generated: VITEST ? REDACTED : new Date().toISOString(), - logType, - nextPage: auditLogs.data.nextPage, - org: orgSlug, - page, - perPage, - logs: auditLogs.data.results.map((log: AuditLogEvent) => { - // Note: The subset is pretty arbitrary - const { - created_at, - event_id, - ip_address, - type, - user_agent, - user_email, - } = log - return { - event_id, - created_at, - ip_address, - type, - user_agent, - user_email, - } - }), - }, - }) -} - -export async function outputAsMarkdown( - auditLogs: SocketSdkSuccessResult<'getAuditLogEvents'>['data'], - { - logType, - orgSlug, - page, - perPage, - }: { - orgSlug: string - page: number - perPage: number - logType: string - }, -): Promise<string> { - try { - const table = mdTable( - auditLogs.results as unknown as Array<Record<string, string>>, - [ - 'event_id', - 'created_at', - 'type', - 'user_email', - 'ip_address', - 'user_agent', - ], - ) - - return ` -# Socket Audit Logs - -These are the Socket.dev audit logs as per requested query. -- org: ${orgSlug} -- type filter: ${logType || '(none)'} -- page: ${page} -- next page: ${auditLogs.nextPage} -- per page: ${perPage} -- generated: ${VITEST ? REDACTED : new Date().toISOString()} - -${table} -` - } catch (e) { - process.exitCode = 1 - logger.fail( - `There was a problem converting the logs to Markdown, please try the \`${FLAG_JSON}\` flag`, - ) - debug('Markdown conversion failed') - debugDir(e) - return 'Failed to generate the markdown report' - } -} - -export async function outputAuditLog( - result: CResult<SocketSdkSuccessResult<'getAuditLogEvents'>['data']>, - { - logType, - orgSlug, - outputKind, - page, - perPage, - }: { - logType: string - outputKind: OutputKind - orgSlug: string - page: number - perPage: number - }, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === OUTPUT_JSON) { - logger.log( - await outputAsJson(result, { - logType, - orgSlug, - page, - perPage, - }), - ) - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - // Default + OUTPUT_MARKDOWN: render the markdown table. (Previously - // OUTPUT_MARKDOWN and the default branched separately, with the - // default going through an iocraft TUI renderer; the renderer was - // retired alongside iocraft itself, and markdown is the natural - // plain-text fallback.) - logger.log( - await outputAsMarkdown(result.data, { - logType, - orgSlug, - page, - perPage, - }), - ) -} diff --git a/packages/cli/src/commands/bundler/cmd-bundler.mts b/packages/cli/src/commands/bundler/cmd-bundler.mts deleted file mode 100644 index 281a078af..000000000 --- a/packages/cli/src/commands/bundler/cmd-bundler.mts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Socket bundler command — forwards bundler operations to Socket Firewall - * (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdBundler = defineHandoffCommand({ - name: 'bundler', - description: 'Run bundler with Socket Firewall security', - spawnMode: 'dlx', - examples: ['install', 'update', 'exec rake'], - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/cargo/cmd-cargo.mts b/packages/cli/src/commands/cargo/cmd-cargo.mts deleted file mode 100644 index 855431268..000000000 --- a/packages/cli/src/commands/cargo/cmd-cargo.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Socket cargo command — forwards cargo operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`, which collapses the standard parse-flags - * / filter-flags / spawn-sfw / forward-exit pattern into a single declarative - * spec. See `util/cli/define-handoff.mts`. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdCargo = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo with Socket Firewall security', - spawnMode: 'dlx', - examples: ['install ripgrep', 'build', 'add serde'], - // cargo did not previously emit telemetry or support --dry-run. - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/ci/cmd-ci.mts b/packages/cli/src/commands/ci/cmd-ci.mts deleted file mode 100644 index 1b686c46c..000000000 --- a/packages/cli/src/commands/ci/cmd-ci.mts +++ /dev/null @@ -1,98 +0,0 @@ -import { getDefaultOrgSlug } from './fetch-default-org-slug.mts' -import { handleCi } from './handle-ci.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunUpload } from '../../util/dry-run/output.mts' -import { - detectDefaultBranch, - getRepoName, - gitBranch, -} from '../../util/git/operations.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'ci', - description: - 'Alias for `socket scan create --report` (creates report and exits with error if unhealthy)', - hidden: false, - flags: defineFlags({ - ...commonFlags, - autoManifest: { - type: 'boolean', - // Dev tools in CI environments are not likely to be set up, so this is safer. - default: false, - description: - 'Auto generate manifest files where detected? See autoManifest flag in `socket scan create`', - }, - }), - help: (command: string, _config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - Options - ${getFlagListOutput(config.flags)} - - This command is intended to use in CI runs to allow automated systems to - accept or reject a current build. It will use the default org of the - Socket API token. The exit code will be non-zero when the scan does not pass - your security policy. - - The --auto-manifest flag does the same as the one from \`socket scan create\` - but is not enabled by default since the CI is less likely to be set up with - all the necessary dev tooling. Enable it if you want the scan to include - locally generated manifests like for gradle and sbt. - - Examples - $ ${command} - $ ${command} --auto-manifest - `, -} - -export const cmdCI = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const dryRun = !!cli.flags['dryRun'] - const autoManifest = Boolean(cli.flags['autoManifest']) - - if (dryRun) { - const orgSlugCResult = await getDefaultOrgSlug() - const cwd = process.cwd() - const branchName = - (await gitBranch(cwd)) || (await detectDefaultBranch(cwd)) - const repoName = await getRepoName(cwd) - - outputDryRunUpload('CI scan', { - autoManifest, - branchName: branchName || '(default)', - cwd, - organizationSlug: orgSlugCResult.ok - ? orgSlugCResult.data - : '(from API token)', - repoName: repoName || '(auto-detected)', - report: true, - targets: ['.'], - }) - return - } - - await handleCi(autoManifest) -} diff --git a/packages/cli/src/commands/ci/fetch-default-org-slug.mts b/packages/cli/src/commands/ci/fetch-default-org-slug.mts deleted file mode 100644 index 56e763b65..000000000 --- a/packages/cli/src/commands/ci/fetch-default-org-slug.mts +++ /dev/null @@ -1,58 +0,0 @@ -import { debug } from '@socketsecurity/lib-stable/debug/output' - -import { SOCKET_CLI_ORG_SLUG } from '../../env/socket-cli-org-slug.mts' -import { getConfigValueOrUndef } from '../../util/config.mts' -import { fetchOrganization } from '../organization/fetch-organization-list.mts' - -import type { CResult } from '../../types.mts' - -// Use the config defaultOrg when set, otherwise discover from remote. -export async function getDefaultOrgSlug(): Promise<CResult<string>> { - const defaultOrgResult = getConfigValueOrUndef('defaultOrg') - if (defaultOrgResult) { - debug( - `use: org from "defaultOrg" value of socket/settings local app data: ${defaultOrgResult}`, - ) - return { ok: true, data: defaultOrgResult } - } - - if (SOCKET_CLI_ORG_SLUG) { - debug( - `use: org from SOCKET_CLI_ORG_SLUG environment variable: ${SOCKET_CLI_ORG_SLUG}`, - ) - return { ok: true, data: SOCKET_CLI_ORG_SLUG } - } - - const orgsCResult = await fetchOrganization() - if (!orgsCResult.ok) { - return orgsCResult - } - - const { organizations } = orgsCResult.data - if (!organizations.length) { - return { - ok: false, - message: 'Failed to establish identity', - data: 'No organization associated with the Socket API token. Unable to continue.', - } - } - - // Use `.slug` (URL-safe) — `.name` is the display label and may - // contain spaces ("Example Org Ltd") that break API URLs. - const slug = organizations[0]?.slug - if (!slug) { - return { - ok: false, - message: 'Failed to establish identity', - data: 'Cannot determine the default organization for the API token. Unable to continue.', - } - } - - debug(`resolve: org from Socket API: ${slug}`) - - return { - ok: true, - message: 'Retrieved default org from server', - data: slug, - } -} diff --git a/packages/cli/src/commands/ci/handle-ci.mts b/packages/cli/src/commands/ci/handle-ci.mts deleted file mode 100644 index fadaaa6d9..000000000 --- a/packages/cli/src/commands/ci/handle-ci.mts +++ /dev/null @@ -1,81 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getDefaultOrgSlug } from './fetch-default-org-slug.mts' -import { REPORT_LEVEL_ERROR } from '../../constants/reporting.mts' -import { - detectDefaultBranch, - getRepoName, - gitBranch, -} from '../../util/git/operations.mjs' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { handleCreateNewScan } from '../scan/handle-create-new-scan.mts' - -const logger = getDefaultLogger() - -export async function handleCi(autoManifest: boolean): Promise<void> { - debug('Starting CI scan') - debugDir({ autoManifest }) - - const orgSlugCResult = await getDefaultOrgSlug() - if (!orgSlugCResult.ok) { - debug('Failed to get default org slug') - debugDir({ orgSlugCResult }) - process.exitCode = orgSlugCResult.code ?? 1 - // Always assume json mode. - logger.log(serializeResultJson(orgSlugCResult)) - return - } - - const orgSlug = orgSlugCResult.data - const cwd = process.cwd() - const branchName = (await gitBranch(cwd)) || (await detectDefaultBranch(cwd)) - const repoName = await getRepoName(cwd) - - debug(`CI scan for ${orgSlug}/${repoName} on branch ${branchName}`) - debugDir({ orgSlug, cwd, branchName, repoName }) - - await handleCreateNewScan({ - autoManifest, - basics: false, - branchName, - commitMessage: '', - commitHash: '', - committers: '', - cwd, - defaultBranch: false, - interactive: false, - orgSlug, - outputKind: 'json', - // When 'pendingHead' is true, it requires 'branchName' set and 'tmp' false. - pendingHead: true, - pullRequest: 0, - reach: { - excludePaths: [], - reachAnalysisMemoryLimit: 0, - reachAnalysisTimeout: 0, - reachConcurrency: 1, - reachDebug: false, - reachDetailedAnalysisLogFile: false, - reachDisableAnalytics: false, - reachDisableExternalToolChecks: false, - reachEnableAnalysisSplitting: false, - reachEcosystems: [], - reachExcludePaths: [], - reachLazyMode: false, - reachMinSeverity: '', - reachSkipCache: false, - reachUseOnlyPregeneratedSboms: false, - reachUseUnreachableFromPrecomputation: false, - reachVersion: undefined, - runReachabilityAnalysis: false, - }, - repoName, - readOnly: false, - report: true, - reportLevel: REPORT_LEVEL_ERROR, - targets: ['.'], - // Don't set 'tmp' when 'pendingHead' is true. - tmp: false, - }) -} diff --git a/packages/cli/src/commands/config/cmd-config-auto.mts b/packages/cli/src/commands/config/cmd-config-auto.mts deleted file mode 100644 index e93bc5656..000000000 --- a/packages/cli/src/commands/config/cmd-config-auto.mts +++ /dev/null @@ -1,121 +0,0 @@ -import { handleConfigAuto } from './handle-config-auto.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getSupportedConfigEntries, - isSupportedConfigKey, -} from '../../util/config.mts' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { LocalConfig } from '../../util/config.mts' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface ConfigAutoFlags { - json: boolean - markdown: boolean -} - -export const CMD_NAME = 'auto' - -const description = - 'Automatically discover and set the correct value config item' - -const hidden = false - -export const cmdConfigAuto = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] KEY - - Options - ${getFlagListOutput(config.flags)} - - Attempt to automatically discover the correct value for a given config KEY. - - Examples - $ ${command} defaultOrg - - Keys: -${getSupportedConfigEntries() - .map(({ 0: key, 1: description }) => ` - ${key} -- ${description}`) - .join('\n')} - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown } = cli.flags as unknown as ConfigAutoFlags - - const dryRun = !!cli.flags['dryRun'] - - const [key = ''] = cli.input - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: key !== 'test' && isSupportedConfigKey(key), - message: 'Config key should be the first arg', - fail: key ? 'invalid config key' : 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - // Runtime read so tests that mutate process.env['HOME'] pick up changes. - const configPath = `${process.env['HOME']}/.config/socket/config.json` - outputDryRunWrite( - configPath, - `auto-discover and set config value for "${key}"`, - [ - `Discover the correct value for config key: ${key}`, - `Update config file with discovered value`, - ], - ) - return - } - - await handleConfigAuto({ - key: key as keyof LocalConfig, - outputKind, - }) -} diff --git a/packages/cli/src/commands/config/cmd-config-get.mts b/packages/cli/src/commands/config/cmd-config-get.mts deleted file mode 100644 index 18f86b300..000000000 --- a/packages/cli/src/commands/config/cmd-config-get.mts +++ /dev/null @@ -1,15 +0,0 @@ -import { createConfigCommand } from './config-command-factory.mts' -import { handleConfigGet } from './handle-config-get.mts' - -export const cmdConfigGet = createConfigCommand({ - commandName: 'get', - description: 'Get the value of a local CLI config item', - hidden: false, - helpUsage: 'KEY', - helpDescription: `Retrieve the value for given KEY at this time. If you have overridden the - config then the value will come from that override. - - KEY is an enum. Valid keys:`, - helpExamples: ['defaultOrg'], - handler: handleConfigGet, -}) diff --git a/packages/cli/src/commands/config/cmd-config-list.mts b/packages/cli/src/commands/config/cmd-config-list.mts deleted file mode 100644 index 17b07aa3f..000000000 --- a/packages/cli/src/commands/config/cmd-config-list.mts +++ /dev/null @@ -1,84 +0,0 @@ -import { outputConfigList } from './output-config-list.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mjs' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'list', - description: 'Show all local CLI config items and their values', - hidden: false, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - full: { - type: 'boolean', - default: false, - description: 'Show full tokens in plaintext (unsafe)', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - `, -} - -export const cmdConfigList = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { full, json, markdown } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('configuration settings', { - showFullTokens: full ? 'yes' : 'no (masked)', - }) - return - } - - await outputConfigList({ - full: !!full, - outputKind, - }) -} diff --git a/packages/cli/src/commands/config/cmd-config-set.mts b/packages/cli/src/commands/config/cmd-config-set.mts deleted file mode 100644 index b3ed0a7ae..000000000 --- a/packages/cli/src/commands/config/cmd-config-set.mts +++ /dev/null @@ -1,24 +0,0 @@ -import { createConfigCommand } from './config-command-factory.mts' -import { handleConfigSet } from './handle-config-set.mts' - -export const CMD_NAME = 'set' - -export const cmdConfigSet = createConfigCommand({ - commandName: CMD_NAME, - description: 'Update the value of a local CLI config item', - hidden: false, - needsValue: true, - helpUsage: '<KEY> <VALUE>', - helpDescription: `This is a crude way of updating the local configuration for this CLI tool. - - Note that updating a value here is nothing more than updating a key/value - store entry. No validation is happening. The server may reject your values - in some cases. Use at your own risk. - - Note: use \`socket config unset\` to restore to defaults. Setting a key - to \`undefined\` will not allow default values to be set on it. - - Keys:`, - helpExamples: ['apiProxy https://example.com'], - handler: handleConfigSet, -}) diff --git a/packages/cli/src/commands/config/cmd-config-unset.mts b/packages/cli/src/commands/config/cmd-config-unset.mts deleted file mode 100644 index 8e3d6586f..000000000 --- a/packages/cli/src/commands/config/cmd-config-unset.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { createConfigCommand } from './config-command-factory.mts' -import { handleConfigUnset } from './handle-config-unset.mts' - -export const CMD_NAME = 'unset' - -export const cmdConfigUnset = createConfigCommand({ - commandName: CMD_NAME, - description: 'Clear the value of a local CLI config item', - hidden: false, - helpUsage: '<KEY> <VALUE>', - helpDescription: `Removes a value from a config key, allowing the default value to be used - for it instead. - - Keys:`, - helpExamples: ['defaultOrg'], - handler: handleConfigUnset, -}) diff --git a/packages/cli/src/commands/config/cmd-config.mts b/packages/cli/src/commands/config/cmd-config.mts deleted file mode 100644 index 12d748597..000000000 --- a/packages/cli/src/commands/config/cmd-config.mts +++ /dev/null @@ -1,32 +0,0 @@ -import { cmdConfigAuto } from './cmd-config-auto.mts' -import { cmdConfigGet } from './cmd-config-get.mts' -import { cmdConfigList } from './cmd-config-list.mts' -import { cmdConfigSet } from './cmd-config-set.mts' -import { cmdConfigUnset } from './cmd-config-unset.mts' -import { meowWithSubcommands } from '../../util/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -const description = 'Manage Socket CLI configuration' - -export const cmdConfig: CliSubcommand = { - description, - hidden: false, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} config`, - importMeta, - subcommands: { - auto: cmdConfigAuto, - get: cmdConfigGet, - list: cmdConfigList, - set: cmdConfigSet, - unset: cmdConfigUnset, - }, - }, - { description }, - ) - }, -} diff --git a/packages/cli/src/commands/config/config-command-factory.mts b/packages/cli/src/commands/config/config-command-factory.mts deleted file mode 100644 index 70ffb2157..000000000 --- a/packages/cli/src/commands/config/config-command-factory.mts +++ /dev/null @@ -1,167 +0,0 @@ -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mjs' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getSupportedConfigEntries, - isSupportedConfigKey, -} from '../../util/config.mts' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { MeowFlags } from '../../flags.mts' -import type { OutputKind } from '../../types.mjs' -import type { - CliCommandConfig, - CliCommandContext, -} from '../../util/cli/with-subcommands.mjs' -import type { LocalConfig } from '../../util/config.mts' - -type ConfigCommandSpec = { - commandName: string - description: string - hidden?: boolean | undefined - flags?: MeowFlags | undefined - needsValue?: boolean | undefined - helpUsage: string - helpDescription: string - helpExamples: string[] - validate?: - | ((cli: { - input: readonly string[] - flags: Record<string, unknown> - }) => Array<{ - test: boolean - message: string - fail: string - nook?: boolean | undefined - pass?: string | undefined - }>) - | undefined - handler: (params: { - key: keyof LocalConfig - value?: string | undefined - outputKind: OutputKind - }) => Promise<void> -} - -export function createConfigCommand(spec: ConfigCommandSpec) { - const config: CliCommandConfig = { - commandName: spec.commandName, - description: spec.description, - hidden: spec.hidden ?? false, - flags: spec.flags ?? { - ...commonFlags, - ...outputFlags, - }, - help: (command, config) => ` - Usage - $ ${command} [options] ${spec.helpUsage} - - Options - ${getFlagListOutput(config.flags)} - - ${spec.helpDescription} - - Keys: - -${getSupportedConfigEntries() - .map(({ 0: key, 1: description }) => ` - ${key} -- ${description}`) - .join('\n')} - - Examples -${spec.helpExamples.map(ex => ` $ ${command} ${ex}`).join('\n')} - `, - } - - return { - description: config.description, - hidden: config.hidden, - run: async ( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, - ): Promise<void> => { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown } = cli.flags - const dryRun = !!cli.flags['dryRun'] - const [key = '', ...rest] = cli.input - const value = rest.join(' ') - const outputKind = getOutputKind(json, markdown) - - // Build validation checks. The shape matches `checkCommandInput`'s - // param exactly so spec.validate() output (which may include - // `pass?`) appends cleanly without the inferred discriminated-union - // narrowing kicking in. - type Validation = { - test: boolean - message: string - fail: string - nook?: boolean | undefined - pass?: string | undefined - } - const validations: Validation[] = [ - { - test: key === 'test' || isSupportedConfigKey(key), - message: 'Config key should be the first arg', - fail: key ? 'invalid config key' : 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - ] - - // Add value validation if needed. - if (spec.needsValue) { - validations.splice(1, 0, { - test: !!value, - message: - 'Key value should be the remaining args (use `unset` to unset a value)', - fail: 'missing', - }) - } - - // Add custom validations if provided. - if (spec.validate) { - validations.push(...spec.validate(cli)) - } - - const wasValidInput = checkCommandInput(outputKind, ...validations) - if (!wasValidInput) { - return - } - - if (dryRun) { - // Runtime read so tests that mutate process.env['HOME'] pick up changes. - const configPath = `${process.env['HOME']}/.config/socket/config.json` - const changes = spec.needsValue - ? [`Set "${key}" to: ${value}`] - : [`Remove "${key}" from config`] - outputDryRunWrite( - configPath, - spec.needsValue - ? `set config value for "${key}"` - : `unset config value for "${key}"`, - changes, - ) - return - } - - await spec.handler({ - key: key as keyof LocalConfig, - ...(spec.needsValue && value !== undefined ? { value } : {}), - outputKind, - }) - }, - } -} diff --git a/packages/cli/src/commands/config/discover-config-value.mts b/packages/cli/src/commands/config/discover-config-value.mts deleted file mode 100644 index 366ae03eb..000000000 --- a/packages/cli/src/commands/config/discover-config-value.mts +++ /dev/null @@ -1,160 +0,0 @@ -import { isSupportedConfigKey } from '../../util/config.mts' -import { getOrgSlugs } from '../../util/organization.mts' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { fetchOrganization } from '../organization/fetch-organization-list.mts' - -import type { CResult } from '../../types.mts' - -export async function discoverConfigValue( - key: string, -): Promise<CResult<unknown>> { - // This will have to be a specific implementation per key because certain - // keys should request information from particular API endpoints while - // others should simply return their default value, like endpoint URL. - - if (key !== 'test' && !isSupportedConfigKey(key)) { - return { - ok: false, - message: 'Auto discover failed', - cause: 'Requested key is not a valid config key.', - } - } - - if (key === 'apiBaseUrl') { - // Return the default value - return { - ok: false, - message: 'Auto discover failed', - cause: - "If you're unsure about the base endpoint URL then simply unset it.", - } - } - - if (key === 'apiProxy') { - // I don't think we can auto-discover this with any order of reliability..? - return { - ok: false, - message: 'Auto discover failed', - cause: - 'When uncertain, unset this key. Otherwise ask your network administrator', - } - } - - if (key === 'apiToken') { - return { - ok: false, - message: 'Auto discover failed', - cause: - 'You can find/create your API token in your Socket dashboard > settings > API tokens.\nYou should then use `socket login` to login instead of this command.', - } - } - - if (key === 'defaultOrg') { - const hasApiToken = hasDefaultApiToken() - if (!hasApiToken) { - return { - ok: false, - message: 'Auto discover failed', - cause: - 'No API token set, must have a token to resolve its default org.', - } - } - - const org = await getDefaultOrgFromToken() - if (!org?.length) { - return { - ok: false, - message: 'Auto discover failed', - cause: 'Was unable to determine default org for the current API token.', - } - } - - if (Array.isArray(org)) { - return { - ok: true, - data: org, - message: 'These are the orgs that the current API token can access.', - } - } - - return { - ok: true, - data: org, - message: 'This is the org that belongs to the current API token.', - } - } - - if (key === 'enforcedOrgs') { - const hasApiToken = hasDefaultApiToken() - if (!hasApiToken) { - return { - ok: false, - message: 'Auto discover failed', - cause: - 'No API token set, must have a token to resolve orgs to enforce.', - } - } - - const orgs = await getEnforceableOrgsFromToken() - if (!orgs?.length) { - return { - ok: false, - message: 'Auto discover failed', - cause: - 'Was unable to determine any orgs to enforce for the current API token.', - } - } - - return { - ok: true, - data: orgs, - message: 'These are the orgs whose security policy you can enforce.', - } - } - - if (key === 'test') { - return { - ok: false, - message: 'Auto discover failed', - cause: 'congrats, you found the test key', - } - } - - // Mostly to please TS, because we're not telling it `key` is keyof LocalConfig - return { - ok: false, - message: 'Auto discover failed', - cause: 'unreachable?', - } -} - -export async function getDefaultOrgFromToken(): Promise< - string[] | string | undefined -> { - const orgsCResult = await fetchOrganization() - if (!orgsCResult.ok) { - return undefined - } - - const { organizations } = orgsCResult.data - if (!organizations.length) { - return undefined - } - const slugs = getOrgSlugs(organizations) - if (slugs.length === 1) { - return slugs[0] - } - return slugs -} - -export async function getEnforceableOrgsFromToken(): Promise< - string[] | undefined -> { - const orgsCResult = await fetchOrganization() - if (!orgsCResult.ok) { - return undefined - } - - const { organizations } = orgsCResult.data - return organizations.length ? getOrgSlugs(organizations) : undefined -} diff --git a/packages/cli/src/commands/config/handle-config-get.mts b/packages/cli/src/commands/config/handle-config-get.mts deleted file mode 100644 index 6b69016a7..000000000 --- a/packages/cli/src/commands/config/handle-config-get.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { outputConfigGet } from './output-config-get.mts' -import { getConfigValue } from '../../util/config.mts' - -import type { OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' - -export async function handleConfigGet({ - key, - outputKind, -}: { - key: keyof LocalConfig - outputKind: OutputKind -}) { - const result = getConfigValue(key) - - await outputConfigGet(key, result, outputKind) -} diff --git a/packages/cli/src/commands/config/handle-config-set.mts b/packages/cli/src/commands/config/handle-config-set.mts deleted file mode 100644 index 19971ac23..000000000 --- a/packages/cli/src/commands/config/handle-config-set.mts +++ /dev/null @@ -1,34 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { outputConfigSet } from './output-config-set.mts' -import { updateConfigValue } from '../../util/config.mts' -import { InputError } from '../../util/error/errors.mts' - -import type { OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' - -export async function handleConfigSet({ - key, - outputKind, - value, -}: { - key: keyof LocalConfig - value?: string | undefined - outputKind: OutputKind -}) { - if (value === undefined) { - throw new InputError( - `socket config set ${key} requires a VALUE argument; pass the value as the second positional (e.g. \`socket config set ${key} my-value\`)`, - ) - } - - debug(`Setting config ${key} = ${value}`) - debugDir({ key, value, outputKind }) - - const result = updateConfigValue(key, value) - - debug(`Config update ${result.ok ? 'succeeded' : 'failed'}`) - debugDir({ result }) - - await outputConfigSet(result, outputKind) -} diff --git a/packages/cli/src/commands/config/handle-config-unset.mts b/packages/cli/src/commands/config/handle-config-unset.mts deleted file mode 100644 index d75690b95..000000000 --- a/packages/cli/src/commands/config/handle-config-unset.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { outputConfigUnset } from './output-config-unset.mts' -import { updateConfigValue } from '../../util/config.mts' - -import type { OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' - -export async function handleConfigUnset({ - key, - outputKind, -}: { - key: keyof LocalConfig - outputKind: OutputKind -}) { - const updateResult = updateConfigValue(key, undefined) - - await outputConfigUnset(updateResult, outputKind) -} diff --git a/packages/cli/src/commands/config/output-config-auto.mts b/packages/cli/src/commands/config/output-config-auto.mts deleted file mode 100644 index 0aa20fc19..000000000 --- a/packages/cli/src/commands/config/output-config-auto.mts +++ /dev/null @@ -1,118 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { select } from '@socketsecurity/lib-stable/stdio/prompts' - -import { isConfigFromFlag, updateConfigValue } from '../../util/config.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' -const logger = getDefaultLogger() - -export async function outputConfigAuto( - key: keyof LocalConfig, - result: CResult<unknown>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'markdown') { - logger.log(mdHeader('Auto discover config value')) - logger.log('') - logger.log( - `Attempted to automatically discover the value for config key: "${key}"`, - ) - logger.log('') - if (result.ok) { - logger.log(`The discovered value is: "${result.data}"`) - if (result.message) { - logger.log('') - logger.log(result.message) - } - } - logger.log('') - } else { - if (result.message) { - logger.log(result.message) - logger.log('') - } - logger.log(`- ${key}: ${result.data}`) - logger.log('') - - if (isConfigFromFlag()) { - logger.log( - '(Unable to persist this value because the config is in read-only mode, meaning it was overridden through env or flag.)', - ) - } else if (key === 'defaultOrg') { - const proceed = await select({ - message: - 'Would you like to update the default org in local config to this value?', - choices: (Array.isArray(result.data) ? result.data : [result.data]) - .map(slug => ({ - name: `Yes [${slug}]`, - value: slug, - description: `Use "${slug}" as the default organization`, - })) - .concat({ - name: 'No', - value: '', - description: 'Do not use any of these organizations', - }), - }) - if (proceed) { - logger.log(`Setting defaultOrg to "${proceed}"...`) - const updateResult = updateConfigValue('defaultOrg', proceed) - if (updateResult.ok) { - logger.log( - `OK. Updated defaultOrg to "${proceed}".\nYou should no longer need to add the org to commands that normally require it.`, - ) - } else { - logger.log(failMsgWithBadge(updateResult.message, updateResult.cause)) - } - } else { - logger.log('OK. No changes made.') - } - } else if (key === 'enforcedOrgs') { - const proceed = await select({ - message: - 'Would you like to update the enforced orgs in local config to this value?', - choices: (Array.isArray(result.data) ? result.data : [result.data]) - .map(slug => ({ - name: `Yes [${slug}]`, - value: slug, - description: `Enforce the security policy of "${slug}" on this machine`, - })) - .concat({ - name: 'No', - value: '', - description: 'Do not use any of these organizations', - }), - }) - if (proceed) { - logger.log(`Setting enforcedOrgs key to "${proceed}"...`) - const updateResult = updateConfigValue('defaultOrg', proceed) - if (updateResult.ok) { - logger.log(`OK. Updated enforcedOrgs to "${proceed}".`) - } else { - logger.log(failMsgWithBadge(updateResult.message, updateResult.cause)) - } - } else { - logger.log('OK. No changes made.') - } - } - } -} diff --git a/packages/cli/src/commands/config/output-config-get.mts b/packages/cli/src/commands/config/output-config-get.mts deleted file mode 100644 index 56a96df8b..000000000 --- a/packages/cli/src/commands/config/output-config-get.mts +++ /dev/null @@ -1,53 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { isConfigFromFlag } from '../../util/config.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' -const logger = getDefaultLogger() - -export async function outputConfigGet( - key: keyof LocalConfig, - result: CResult<LocalConfig[keyof LocalConfig]>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const readOnly = isConfigFromFlag() - - if (outputKind === 'markdown') { - logger.log(mdHeader('Config Value')) - logger.log('') - logger.log(`Config key '${key}' has value '${result.data}`) - if (readOnly) { - logger.log('') - logger.log( - 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.', - ) - } - } else { - logger.log(`${key}: ${result.data}`) - if (readOnly) { - logger.log('') - logger.log( - 'Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag.', - ) - } - } -} diff --git a/packages/cli/src/commands/config/output-config-list.mts b/packages/cli/src/commands/config/output-config-list.mts deleted file mode 100644 index ece7f69a8..000000000 --- a/packages/cli/src/commands/config/output-config-list.mts +++ /dev/null @@ -1,102 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - getConfigValue, - getSupportedConfigKeys, - isConfigFromFlag, - isSensitiveConfigKey, -} from '../../util/config.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputConfigList({ - full, - outputKind, -}: { - full: boolean - outputKind: OutputKind -}) { - const readOnly = isConfigFromFlag() - const supportedConfigKeys = getSupportedConfigKeys() - if (outputKind === 'json') { - let failed = false - const obj: Record<string, unknown> = {} - for (let i = 0, { length } = supportedConfigKeys; i < length; i += 1) { - const key = supportedConfigKeys[i]! - const result = getConfigValue(key) - let value = result.data - if (!result.ok) { - value = `Failed to retrieve: ${result.message}` - failed = true - } else if (!full && isSensitiveConfigKey(key)) { - value = '********' - } - if (full || value !== undefined) { - obj[key] = value ?? '<none>' - } - } - if (failed) { - process.exitCode = 1 - } - logger.log( - serializeResultJson( - failed - ? { - ok: false, - message: 'At least one config key failed to be fetched...', - data: JSON.stringify({ - full, - config: obj, - readOnly, - }), - } - : { - ok: true, - data: { - full, - config: obj, - readOnly, - }, - }, - ), - ) - } else { - const maxWidth = supportedConfigKeys.reduce( - (a, b) => Math.max(a, b.length), - 0, - ) - - logger.log(mdHeader('Local CLI Config')) - logger.log('') - logger.log(`This is the local CLI config (full=${!!full}):`) - logger.log('') - for (let i = 0, { length } = supportedConfigKeys; i < length; i += 1) { - const key = supportedConfigKeys[i]! - const result = getConfigValue(key) - if (!result.ok) { - logger.log(`- ${key}: failed to read: ${result.message}`) - } else { - let value = result.data - if (!full && isSensitiveConfigKey(key)) { - value = '********' - } - if (full || value !== undefined) { - logger.log( - `- ${key}:${' '.repeat(Math.max(0, maxWidth - key.length + 3))} ${Array.isArray(value) ? value.join(', ') || '<none>' : (value ?? '<none>')}`, - ) - } - } - } - if (readOnly) { - logger.log('') - logger.log( - 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.', - ) - } - } -} diff --git a/packages/cli/src/commands/config/output-config-set.mts b/packages/cli/src/commands/config/output-config-set.mts deleted file mode 100644 index 31a51a2c9..000000000 --- a/packages/cli/src/commands/config/output-config-set.mts +++ /dev/null @@ -1,43 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputConfigSet( - result: CResult<undefined | string>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'markdown') { - logger.log(mdHeader('Update config')) - logger.log('') - logger.log(result.message) - if (result.data) { - logger.log('') - logger.log(result.data) - } - } else { - logger.log('OK') - logger.log(result.message) - if (result.data) { - logger.log('') - logger.log(result.data) - } - } -} diff --git a/packages/cli/src/commands/config/output-config-unset.mts b/packages/cli/src/commands/config/output-config-unset.mts deleted file mode 100644 index 4b20c4c82..000000000 --- a/packages/cli/src/commands/config/output-config-unset.mts +++ /dev/null @@ -1,43 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputConfigUnset( - updateResult: CResult<undefined | string>, - outputKind: OutputKind, -) { - if (!updateResult.ok) { - process.exitCode = updateResult.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(updateResult)) - return - } - if (!updateResult.ok) { - logger.fail(failMsgWithBadge(updateResult.message, updateResult.cause)) - return - } - - if (outputKind === 'markdown') { - logger.log(mdHeader('Update config')) - logger.log('') - logger.log(updateResult.message) - if (updateResult.data) { - logger.log('') - logger.log(updateResult.data) - } - } else { - logger.log('OK') - logger.log(updateResult.message) - if (updateResult.data) { - logger.log('') - logger.log(updateResult.data) - } - } -} diff --git a/packages/cli/src/commands/fix/branch-cleanup.mts b/packages/cli/src/commands/fix/branch-cleanup.mts deleted file mode 100644 index c57b40125..000000000 --- a/packages/cli/src/commands/fix/branch-cleanup.mts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Branch cleanup utilities for socket fix command. Manages local and remote - * branch lifecycle during PR creation. - * - * Critical distinction: Remote branches are sacred when a PR exists, disposable - * when they don't. - */ - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - gitDeleteBranch, - gitDeleteRemoteBranch, -} from '../../util/git/operations.mjs' - -const logger = getDefaultLogger() - -/** - * Clean up branches in catch block after unexpected error. Safe to delete both - * remote and local since no PR was created. - */ -export async function cleanupErrorBranches( - branch: string, - cwd: string, - remoteBranchExists: boolean, -): Promise<void> { - // Clean up remote branch if it exists (push may have succeeded before error). - // Safe to delete both remote and local since no PR was created. - if (remoteBranchExists) { - await gitDeleteRemoteBranch(branch, cwd) - } - await gitDeleteBranch(branch, cwd) -} - -/** - * Clean up branches after PR creation failure. Safe to delete both remote and - * local since no PR was created. - */ -export async function cleanupFailedPrBranches( - branch: string, - cwd: string, -): Promise<void> { - // Clean up pushed branch since PR creation failed. - // Safe to delete both remote and local since no PR exists. - await gitDeleteRemoteBranch(branch, cwd) - await gitDeleteBranch(branch, cwd) -} - -/** - * Clean up a stale branch (both remote and local). Safe to delete both since no - * PR exists for this branch. - * - * Returns true if cleanup succeeded or should continue, false if should skip - * GHSA. - */ -export async function cleanupStaleBranch( - branch: string, - ghsaId: string, - cwd: string, -): Promise<boolean> { - logger.warn(`Stale branch ${branch} found without open PR, cleaning up...`) - debug(`cleanup: deleting stale branch ${branch}`) - - const deleted = await gitDeleteRemoteBranch(branch, cwd) - if (!deleted) { - logger.error( - `Failed to delete stale remote branch ${branch}, skipping ${ghsaId}.`, - ) - debug(`cleanup: remote deletion failed for ${branch}`) - return false - } - - // Clean up local branch too to avoid conflicts. - await gitDeleteBranch(branch, cwd) - return true -} - -/** - * Clean up local branch after successful PR creation. Keeps remote branch - PR - * needs it to be mergeable. - */ -export async function cleanupSuccessfulPrLocalBranch( - branch: string, - cwd: string, -): Promise<void> { - // Clean up local branch only - keep remote branch for PR merge. - await gitDeleteBranch(branch, cwd) -} diff --git a/packages/cli/src/commands/fix/cmd-fix.mts b/packages/cli/src/commands/fix/cmd-fix.mts deleted file mode 100644 index ad2355f41..000000000 --- a/packages/cli/src/commands/fix/cmd-fix.mts +++ /dev/null @@ -1,563 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import terminalLink from 'terminal-link' - -import { joinAnd, joinOr } from '@socketsecurity/lib-stable/arrays/join' -import { arrayUnique } from '@socketsecurity/lib-stable/arrays/unique' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -import { handleFix } from './handle-fix.mts' -import { FLAG_ID } from '../../constants/cli.mts' -import { ERROR_UNABLE_RESOLVE_ORG } from '../../constants/errors.mts' -import * as constants from '../../constants.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunPreview } from '../../util/dry-run/output.mts' -import { getEcosystemChoicesForMeow } from '../../util/ecosystem/types.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { cmdFlagValueToArray } from '../../util/process/cmd.mts' -import { RangeStyles } from '../../util/semver.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' -import { getDefaultOrgSlug } from '../ci/fetch-default-org-slug.mts' - -import type { DryRunAction } from '../../util/dry-run/output.mts' - -import type { MeowFlag, MeowFlags } from '../../flags.mts' -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { PURL_Type } from '../../util/ecosystem/types.mts' -import type { RangeStyle } from '../../util/semver.mts' -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface FixFlags { - all: boolean - applyFixes: boolean - autopilot: boolean - debug: boolean - disableExternalToolChecks: boolean - ecosystems: string[] - exclude: string[] - fixVersion: string | undefined - include: string[] - json: boolean - majorUpdates: boolean - markdown: boolean - maxSatisfying: boolean - minSatisfying: boolean - minimumReleaseAge: string - outputFile: string - prCheck: boolean - prLimit: number - rangeStyle: RangeStyle - showAffectedDirectDependencies: boolean - silence: boolean - unknownFlags?: string[] | undefined -} - -export const CMD_NAME = 'fix' - -const DEFAULT_LIMIT = 10 - -const description = 'Fix CVEs in dependencies' - -const hidden = false - -export const cmdFix = { - description, - hidden, - run, -} - -const generalFlags: MeowFlags = { - autopilot: { - type: 'boolean', - default: false, - description: `Enable auto-merge for pull requests that Socket opens.\nSee ${terminalLink( - 'GitHub documentation', - 'https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository', - )} for managing auto-merge for pull requests in your repository.`, - }, - batch: { - type: 'boolean', - default: false, - description: - 'Create a single PR for all fixes instead of one PR per GHSA (CI mode only)', - hidden: true, - }, - applyFixes: { - aliases: ['onlyCompute'], - type: 'boolean', - default: true, - description: - 'Compute fixes only, do not apply them. Logs what upgrades would be applied. If combined with --output-file, the output file will contain the upgrades that would be applied.', - // Hidden to allow custom documenting of the negated `--no-apply-fixes` variant. - hidden: true, - }, - majorUpdates: { - type: 'boolean', - default: true, - description: - 'Allow major version updates. Use --no-major-updates to disable.', - // Hidden to allow custom documenting the negated `--no-major-updates` variant. - hidden: true, - }, - all: { - type: 'boolean', - default: false, - description: - 'Process all discovered vulnerabilities in local mode. Cannot be used with --id.', - }, - ecosystems: { - type: 'string', - default: [], - description: - 'Limit fix analysis to specific ecosystems. Can be provided as comma separated values or as multiple flags. Defaults to all ecosystems.', - isMultiple: true, - }, - fixVersion: { - type: 'string', - description: `Override the version of @coana-tech/cli used for fix analysis. Default: ${constants.ENV.INLINED_COANA_VERSION}.`, - }, - id: { - type: 'string', - default: [], - description: `Provide a list of vulnerability identifiers to compute fixes for: - - ${terminalLink( - 'GHSA IDs', - 'https://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories-from-the-github-advisory-database/about-the-github-advisory-database#about-ghsa-ids', - )} (e.g., GHSA-xxxx-xxxx-xxxx) - - ${terminalLink( - 'CVE IDs', - 'https://cve.mitre.org/cve/identifiers/', - )} (e.g., CVE-${new Date().getFullYear()}-1234) - automatically converted to GHSA - - ${terminalLink( - 'PURLs', - 'https://github.com/package-url/purl-spec', - )} (e.g., pkg:npm/package@1.0.0) - automatically converted to GHSA - Can be provided as comma separated values or as multiple flags. Cannot be used with --all.`, - isMultiple: true, - }, - prLimit: { - aliases: ['limit'], - type: 'number', - default: DEFAULT_LIMIT, - description: `Maximum number of pull requests to create in CI mode (default ${DEFAULT_LIMIT}). Has no effect in local mode.`, - }, - rangeStyle: { - type: 'string', - default: 'preserve', - description: ` -Define how dependency version ranges are updated in package.json (default 'preserve'). -Available styles: - * pin - Use the exact version (e.g. 1.2.3) - * preserve - Retain the existing version range style as-is - `.trim(), - }, - outputFile: { - type: 'string', - default: '', - description: 'Path to store upgrades as a JSON file at this path.', - }, - minimumReleaseAge: { - type: 'string', - default: '', - description: - 'Set a minimum age requirement for suggested upgrade versions (e.g., 1h, 2d, 3w). A higher age requirement reduces the risk of upgrading to malicious versions. For example, setting the value to 1 week (1w) gives ecosystem maintainers one week to remove potentially malicious versions.', - }, - debug: { - type: 'boolean', - default: false, - description: - 'Enable debug logging in the Coana-based Socket Fix CLI invocation.', - shortFlag: 'd', - }, - disableExternalToolChecks: { - type: 'boolean', - default: false, - description: 'Disable external tool checks during fix analysis.', - hidden: true, - }, - showAffectedDirectDependencies: { - type: 'boolean', - default: false, - description: - 'List the direct dependencies responsible for introducing transitive vulnerabilities and list the updates required to resolve the vulnerabilities', - }, - silence: { - type: 'boolean', - default: false, - description: 'Silence all output except the final result', - }, - exclude: { - type: 'string', - default: [], - description: - 'Exclude workspaces matching these glob patterns. Can be provided as comma separated values or as multiple flags', - isMultiple: true, - }, - include: { - type: 'string', - default: [], - description: - 'Include workspaces matching these glob patterns. Can be provided as comma separated values or as multiple flags', - isMultiple: true, - }, -} - -const hiddenFlags: MeowFlags = { - autoMerge: { - ...generalFlags['autopilot'], - hidden: true, - } as MeowFlag, - ghsa: { - ...generalFlags['id'], - hidden: true, - } as MeowFlag, - maxSatisfying: { - type: 'boolean', - default: true, - description: 'Use the maximum satisfying version for dependency updates', - hidden: true, - }, - minSatisfying: { - type: 'boolean', - default: false, - description: - 'Constrain dependency updates to the minimum satisfying version', - hidden: true, - }, - prCheck: { - type: 'boolean', - default: true, - description: 'Check for an existing PR before attempting a fix', - hidden: true, - }, - purl: { - type: 'string', - default: [], - description: `Provide a list of ${terminalLink( - 'PURLs', - 'https://github.com/package-url/purl-spec?tab=readme-ov-file#purl', - )} to compute fixes for, as either a comma separated value or as\nmultiple flags`, - isMultiple: true, - shortFlag: 'p', - hidden: true, - }, - test: { - type: 'boolean', - default: false, - description: 'Verify the fix by running unit tests', - hidden: true, - }, - testScript: { - type: 'string', - default: 'test', - description: "The test script to run for fix attempts (default 'test')", - hidden: true, - }, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - ...generalFlags, - ...hiddenFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput({ - ...config.flags, - // Explicitly document the negated --no-apply-fixes variant. - noApplyFixes: { - ...config.flags['applyFixes'], - hidden: false, - } as MeowFlag, - // Explicitly document the negated --no-major-updates variant. - noMajorUpdates: { - ...config.flags['majorUpdates'], - description: - 'Do not suggest or apply fixes that require major version updates of direct or transitive dependencies', - hidden: false, - } as MeowFlag, - })} - - Environment Variables (for CI/PR mode) - CI Set to enable CI mode - SOCKET_CLI_GITHUB_TOKEN GitHub token for PR creation (or GITHUB_TOKEN) - SOCKET_CLI_GIT_USER_NAME Git username for commits - SOCKET_CLI_GIT_USER_EMAIL Git email for commits - - Examples - $ ${command} - $ ${command} ${FLAG_ID} CVE-2021-23337 - $ ${command} ./path/to/project --range-style pin - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: true }, - ) - - const { - all, - applyFixes, - autopilot, - debug, - disableExternalToolChecks, - ecosystems, - exclude, - fixVersion, - include, - json, - majorUpdates, - markdown, - maxSatisfying, - minimumReleaseAge, - outputFile, - prCheck, - prLimit, - rangeStyle, - showAffectedDirectDependencies, - silence, - // We patched in this feature with `npx custompatch meow` at - // socket-cli/patches/meow#13.2.0.patch. - unknownFlags = [], - } = cli.flags as unknown as FixFlags - - const dryRun = !!cli.flags['dryRun'] - - const minSatisfying = - (cli.flags as unknown as FixFlags).minSatisfying || !maxSatisfying - - const disableMajorUpdates = !majorUpdates - - const outputKind = getOutputKind(json, markdown) - - // Process comma-separated values for ecosystems flag. - const ecosystemsRaw = cmdFlagValueToArray(ecosystems) - - // Validate ecosystem values early, before dry-run check. - const validatedEcosystems: PURL_Type[] = [] - const validEcosystemChoices = getEcosystemChoicesForMeow() - for (let i = 0, { length } = ecosystemsRaw; i < length; i += 1) { - const ecosystem = ecosystemsRaw[i]! - if (!validEcosystemChoices.includes(ecosystem)) { - logger.fail( - `--ecosystems must be one of: ${joinAnd(validEcosystemChoices)} (saw: "${ecosystem}"); pass a supported ecosystem like --ecosystems=${validEcosystemChoices[0]}`, - ) - process.exitCode = 1 - return - } - validatedEcosystems.push(ecosystem as PURL_Type) - } - - const ghsas = arrayUnique([ - ...cmdFlagValueToArray(cli.flags['id']), - ...cmdFlagValueToArray(cli.flags['ghsa']), - ...cmdFlagValueToArray(cli.flags['purl']), - ]) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: RangeStyles.includes(rangeStyle), - message: `Expecting range style of ${joinOr(RangeStyles)}`, - fail: 'invalid', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: !all || !ghsas.length, - message: 'The --all and --id flags cannot be used together', - fail: 'omit one', - }, - ) - if (!wasValidInput) { - return - } - - // Detect the common mistake of passing a vulnerability ID (GHSA / CVE / - // PURL) as a positional argument when the user meant to use `--id`. - // Without this guard we treat the ID as a directory path, resolve to cwd, - // and eventually fail with a confusing upload error. Run this before - // `getDefaultOrgSlug()` so users still get the helpful message when no - // API token is configured. - const rawInput = cli.input[0] - if (rawInput) { - const upperInput = rawInput.toUpperCase() - const isGhsa = upperInput.startsWith('GHSA-') - const isCve = upperInput.startsWith('CVE-') - const isPurl = rawInput.startsWith('pkg:') - if (isCve || isGhsa || isPurl) { - // `handle-fix.mts` validates IDs with case-sensitive format regexes: - // * GHSA — prefix must be uppercase, body segments lowercase [a-z0-9] - // * CVE — prefix must be uppercase, body is all digits (case-free) - // PURLs are intentionally lowercase and validated separately. - let suggestion: string - if (isGhsa) { - suggestion = 'GHSA-' + rawInput.slice(5).toLowerCase() - } else if (isCve) { - suggestion = 'CVE-' + rawInput.slice(4) - } else { - suggestion = rawInput - } - logger.fail( - `"${rawInput}" looks like a vulnerability identifier, not a directory path.\nDid you mean: socket fix ${FLAG_ID} ${suggestion}`, - ) - process.exitCode = 1 - return - } - } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - // Validate the target directory exists so we fail fast with a clear - // message instead of the API's "Need at least one file to be uploaded". - // Also runs before the org-slug resolution so the user sees a clearer - // error when pointing at a typo'd path without an API token set. - if (!existsSync(cwd)) { - logger.fail(`Target directory does not exist: ${cwd}`) - process.exitCode = 1 - return - } - - const orgSlugCResult = await getDefaultOrgSlug() - if (!orgSlugCResult.ok) { - process.exitCode = orgSlugCResult.code ?? 1 - logger.fail( - `${ERROR_UNABLE_RESOLVE_ORG}.\nEnsure a Socket API token is specified for the organization using the SOCKET_CLI_API_TOKEN environment variable.`, - ) - return - } - - const orgSlug = orgSlugCResult.data - - const spinner = undefined - - const includePatterns = cmdFlagValueToArray(include) - const excludePatterns = cmdFlagValueToArray(exclude) - - if (dryRun) { - const actions: DryRunAction[] = [ - { - type: 'fetch', - description: 'Scan project dependencies for vulnerabilities', - target: cwd, - details: { - organization: orgSlug, - ecosystems: validatedEcosystems.length - ? validatedEcosystems.join(', ') - : 'all', - }, - }, - { - type: 'fetch', - description: 'Analyze vulnerability fix options', - details: { - targets: all - ? 'all vulnerabilities' - : ghsas.length - ? ghsas.join(', ') - : 'auto-discovered', - majorUpdates: disableMajorUpdates ? 'disabled' : 'enabled', - rangeStyle, - }, - }, - ] - - if (applyFixes) { - actions.push({ - type: 'modify', - description: 'Update package manifest files with fixes', - target: 'package.json and lock files', - }) - actions.push({ - type: 'execute', - description: 'Run package manager to install updated dependencies', - }) - } - - const targetDescription = all - ? 'all vulnerabilities' - : ghsas.length - ? `${ghsas.length} specified ${pluralize('vulnerability', { count: ghsas.length })}` - : 'discovered vulnerabilities' - - const fixModeDescription = applyFixes - ? 'compute and apply fixes' - : 'compute fixes only (not applying)' - - outputDryRunPreview({ - summary: `Analyze and ${fixModeDescription} for ${targetDescription}`, - actions, - wouldSucceed: true, - }) - return - } - - await handleFix({ - all, - applyFixes, - autopilot, - coanaVersion: fixVersion, - cwd, - debug, - disableExternalToolChecks: Boolean(disableExternalToolChecks), - disableMajorUpdates, - ecosystems: validatedEcosystems, - exclude: excludePatterns, - ghsas, - include: includePatterns, - minimumReleaseAge, - minSatisfying, - orgSlug, - outputFile, - outputKind, - prCheck, - prLimit, - rangeStyle, - showAffectedDirectDependencies, - silence, - spinner, - unknownFlags, - }) -} diff --git a/packages/cli/src/commands/fix/coana-fix.mts b/packages/cli/src/commands/fix/coana-fix.mts deleted file mode 100644 index d7ef0ac4f..000000000 --- a/packages/cli/src/commands/fix/coana-fix.mts +++ /dev/null @@ -1,723 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -import { - cleanupErrorBranches, - cleanupFailedPrBranches, - cleanupStaleBranch, - cleanupSuccessfulPrLocalBranch, -} from './branch-cleanup.mts' -import { - checkCiEnvVars, - getCiEnvInstructions, - getFixEnv, -} from './env-helpers.mts' -import { isGhsaFixed, markGhsaFixed } from './ghsa-tracker.mts' -import { getSocketFixBranchName, getSocketFixCommitMessage } from './git.mts' -import { logPrEvent } from './pr-lifecycle-logger.mts' -import { - cleanupSocketFixPrs, - getSocketFixPrs, - openSocketFixPr, -} from './pull-request.mts' -import { FLAG_DRY_RUN } from '../../constants/cli.mts' -import { GQL_PR_STATE_OPEN } from '../../constants/github.mts' -import { DOT_SOCKET_DOT_FACTS_JSON } from '../../constants/paths.mts' -import { findSocketYmlSync } from '../../util/config.mts' -import { spawnCoanaDlx } from '../../util/dlx/spawn.mjs' -import { getErrorCause } from '../../util/error/errors.mjs' -import { getPackageFilesForScan } from '../../util/fs/path-resolve.mjs' -import { - enablePrAutoMerge, - fetchGhsaDetails, - getOctokit, - setGitRemoteGithubRepoUrl, -} from '../../util/git/github.mts' -import { - gitCheckoutBranch, - gitCommit, - gitCreateBranch, - gitPushBranch, - gitRemoteBranchExists, - gitResetAndClean, - gitUnstagedModifiedFiles, -} from '../../util/git/operations.mjs' -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' -import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts' - -import type { FixConfig } from './types.mts' -import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() - -type GhsaFixResult = { - ghsaId: string - fixed: boolean - pullRequestLink?: string | undefined - pullRequestNumber?: number | undefined -} - -export async function coanaFix( - fixConfig: FixConfig, -): Promise<CResult<{ fixedAll: boolean; ghsaDetails: GhsaFixResult[] }>> { - const { - all, - applyFixes, - autopilot, - coanaVersion, - cwd, - debug: debugFlag, - disableExternalToolChecks, - disableMajorUpdates, - ecosystems, - exclude, - ghsas, - include, - minimumReleaseAge, - orgSlug, - outputFile, - outputKind, - prLimit, - showAffectedDirectDependencies, - spinner, - } = fixConfig - - // Under json/markdown mode we route coana's chatter away from our - // stdout (its JSON report comes from --output-file, not stdout, so - // coana stdout is entirely informational). 'ignore' drops it; that - // was the previous behavior and it remains safe. When interactive we - // inherit so the user sees coana progress in real-time. - const coanaStdio = outputKind === 'json' ? 'ignore' : 'inherit' - // Ask coana to silence its own Winston logger under json mode. Belt - // and braces with stdio:'ignore' and harmless if coana ignores the - // flag. - const coanaSilenceArgs = outputKind === 'json' ? ['--silent'] : [] - - const fixEnv = await getFixEnv() - debugDir({ fixEnv }) - - spinner?.start() - - const sockSdkCResult = await setupSdk() - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - - const sockSdk = sockSdkCResult.data - - const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner }) - if (!supportedFilesCResult.ok) { - return supportedFilesCResult - } - - const supportedFiles = supportedFilesCResult.data - - // Load socket.yml so projectIgnorePaths is respected when collecting files. - const socketYmlResult = findSocketYmlSync(cwd) - const socketConfig = socketYmlResult.ok - ? socketYmlResult.data?.parsed - : undefined - - const scanFilepaths = await getPackageFilesForScan(['.'], supportedFiles, { - config: socketConfig, - cwd, - }) - - // Exclude any .socket.facts.json files that happen to be in the scan - // folder before the analysis was run. - const filepathsToUpload = scanFilepaths.filter( - p => path.basename(p).toLowerCase() !== DOT_SOCKET_DOT_FACTS_JSON, - ) - const uploadCResult = (await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, filepathsToUpload, { - pathsRelativeTo: cwd, - }), - { - commandPath: 'socket fix', - description: 'upload manifests', - spinner, - }, - )) as CResult<{ tarHash?: string | undefined }> - - if (!uploadCResult.ok) { - return uploadCResult - } - - const tarHash: string | undefined = uploadCResult.data.tarHash - if (!tarHash) { - spinner?.stop() - return { - ok: false, - message: - 'No tar hash returned from Socket API upload-manifest-files endpoint', - data: uploadCResult.data, - } - } - - const shouldDiscoverGhsaIds = - all || !ghsas.length || (ghsas.length === 1 && ghsas[0] === 'all') - - const shouldOpenPrs = fixEnv.isCi && fixEnv.repoInfo - - if (!shouldOpenPrs) { - // In local mode, if neither --all nor --id is provided, show deprecation warning. - if (shouldDiscoverGhsaIds && !all) { - logger.warn( - 'Implicit --all is deprecated in local mode and will be removed in a future release. Please use --all explicitly.', - ) - } - - // Inform user about local mode when fixes will be applied. - if (applyFixes && ghsas.length) { - const envCheck = checkCiEnvVars() - if (envCheck.present.length) { - // Some CI vars are set but not all - show what's missing. - if (envCheck.missing.length) { - logger.info( - 'Running in local mode - fixes will be applied directly to your working directory.\n' + - `Missing environment variables for PR creation: ${joinAnd(envCheck.missing)}`, - ) - } - } else { - // No CI vars are present - show general local mode message. - logger.info( - 'Running in local mode - fixes will be applied directly to your working directory.\n' + - getCiEnvInstructions(), - ) - } - } - - // In local mode, apply limit to provided IDs. - const idsToProcess = shouldDiscoverGhsaIds - ? ['all'] - : ghsas.slice(0, prLimit) - if (!idsToProcess.length) { - spinner?.stop() - return { ok: true, data: { fixedAll: false, ghsaDetails: [] } } - } - - // Create a temporary file for the output. - const tmpDir = os.tmpdir() - const tmpFile = path.join(tmpDir, `socket-fix-${Date.now()}.json`) - - try { - const fixCResult = await spawnCoanaDlx( - [ - ...coanaSilenceArgs, - 'compute-fixes-and-upgrade-purls', - cwd, - '--manifests-tar-hash', - tarHash, - '--apply-fixes-to', - ...idsToProcess, - ...(fixConfig.rangeStyle - ? ['--range-style', fixConfig.rangeStyle] - : []), - ...(minimumReleaseAge - ? ['--minimum-release-age', minimumReleaseAge] - : []), - ...(include.length ? ['--include', ...include] : []), - ...(exclude.length ? ['--exclude', ...exclude] : []), - ...(ecosystems.length ? ['--purl-types', ...ecosystems] : []), - ...(!applyFixes ? [FLAG_DRY_RUN] : []), - '--output-file', - tmpFile, - ...(debugFlag ? ['--debug'] : []), - ...(disableExternalToolChecks - ? ['--disable-external-tool-checks'] - : []), - ...(disableMajorUpdates ? ['--disable-major-updates'] : []), - ...(showAffectedDirectDependencies - ? ['--show-affected-direct-dependencies'] - : []), - ...fixConfig.unknownFlags, - ], - fixConfig.orgSlug, - { coanaVersion, cwd, spinner, stdio: coanaStdio }, - ) - - spinner?.stop() - - if (!fixCResult.ok) { - return fixCResult - } - - // Copy to outputFile if provided. - if (outputFile) { - // Status message — belongs on stderr so stdout stays payload-only - // when a consumer is piping `socket fix --json`. - logger.error(`Copying fixes result to ${outputFile}`) - const tmpContent = await fs.readFile(tmpFile, 'utf8') - await fs.writeFile(outputFile, tmpContent, 'utf8') - } - - return { - ok: true, - data: { - fixedAll: true, - ghsaDetails: idsToProcess.map(id => ({ - ghsaId: id, - fixed: true, - })), - }, - } - } finally { - // Clean up the temporary file. - await safeDelete(tmpFile, { force: true }) - } - } - - // Adjust PR limit based on open Socket Fix PRs. - let adjustedLimit = prLimit - if (shouldOpenPrs && fixEnv.repoInfo) { - try { - const openPrs = await getSocketFixPrs( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - { states: GQL_PR_STATE_OPEN }, - ) - const openPrCount = openPrs.length - // Reduce limit by number of open PRs to avoid creating too many. - adjustedLimit = Math.max(0, prLimit - openPrCount) - if (openPrCount > 0) { - debug( - `prLimit: adjusted from ${prLimit} to ${adjustedLimit} (${openPrCount} open Socket Fix ${pluralize('PR', { count: openPrCount })}`, - ) - } - } catch (e) { - debug('Failed to count open PRs, using original limit') - debugDir(e) - } - } - - const shouldSpawnCoana = adjustedLimit > 0 - - let ids: string[] | undefined - - // When shouldDiscoverGhsaIds is true, discover vulnerabilities using find-vulnerabilities command. - // This gives us the GHSA IDs needed to create individual PRs in CI mode. - if (shouldSpawnCoana && shouldDiscoverGhsaIds) { - try { - const discoverCResult = await spawnCoanaDlx( - [ - 'find-vulnerabilities', - cwd, - '--manifests-tar-hash', - tarHash, - ...(ecosystems.length ? ['--purl-types', ...ecosystems] : []), - ], - fixConfig.orgSlug, - { coanaVersion, cwd, spinner }, - { stdio: 'pipe' }, - ) - - if (discoverCResult.ok) { - // Coana prints ghsaIds as json-formatted string on the final line of the output. - const discoveredIds: string[] = [] - try { - const lines = discoverCResult.data - .trim() - .split('\n') - .filter(line => line.trim()) - const ghsaIdsRaw = lines.length > 0 ? lines[lines.length - 1] : '' - if (ghsaIdsRaw && ghsaIdsRaw.trim()) { - const parsed = JSON.parse(ghsaIdsRaw) - if (!Array.isArray(parsed)) { - throw new Error( - `coana find-vulnerabilities returned non-array JSON on last line (got: ${typeof parsed}); expected an array of GHSA ID strings`, - ) - } - discoveredIds.push(...parsed) - } - } catch (e) { - debug('Failed to parse GHSA IDs from find-vulnerabilities output') - debugDir(e) - } - ids = discoveredIds.slice(0, adjustedLimit) - } - } catch (e) { - debug('Failed to discover vulnerabilities') - debugDir(e) - } - } else if (shouldSpawnCoana) { - ids = ghsas.slice(0, adjustedLimit) - } - - if (!ids?.length) { - debug('miss: no GHSA IDs to process') - } - - /* c8 ignore start -- defensive: shouldOpenPrs requires repoInfo truthy at line 168, so reaching this branch with repoInfo undefined is unreachable. */ - if (!fixEnv.repoInfo) { - debug('miss: no repo info detected') - } - /* c8 ignore stop */ - - if (!ids?.length || !fixEnv.repoInfo) { - spinner?.stop() - return { ok: true, data: { fixedAll: false, ghsaDetails: [] } } - } - - const displayIds = - ids.length > 3 - ? `${ids.slice(0, 3).join(', ')} … and ${ids.length - 3} more` - : joinAnd(ids) - debug(`fetch: ${ids.length} GHSA details for ${displayIds}`) - - const ghsaDetails = await fetchGhsaDetails(ids) - const scanBaseNames = new Set(scanFilepaths.map(p => path.basename(p))) - - debug(`found: ${ghsaDetails.size} GHSA details`) - - // Filter out already-fixed GHSAs to avoid duplicate work. - const unprocessedIds: string[] = [] - for (let i = 0, { length } = ids; i < length; i += 1) { - const ghsaId = ids[i]! - const alreadyFixed = await isGhsaFixed(cwd, ghsaId) - if (!alreadyFixed) { - unprocessedIds.push(ghsaId) - } - } - - const skippedCount = ids.length - unprocessedIds.length - if (skippedCount > 0) { - logger.info( - `Skipping ${skippedCount} already-fixed ${pluralize('GHSA', { count: skippedCount })}`, - ) - } - - // Clean up stale and merged Socket Fix PRs before creating new ones. - if (shouldOpenPrs && fixEnv.repoInfo) { - logger.substep('Cleaning up stale and merged Socket Fix PRs...') - - for (let i = 0, { length } = unprocessedIds; i < length; i += 1) { - const ghsaId = unprocessedIds[i]! - try { - const cleaned = await cleanupSocketFixPrs( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - ghsaId, - ) - if (cleaned.length) { - debug(`pr: cleaned ${cleaned.length} PRs for ${ghsaId}`) - } - } catch (e) { - debug(`pr: cleanup failed for ${ghsaId}`) - debugDir(e) - } - } - } - - let count = 0 - let overallFixed = false - const ghsaFixResults: GhsaFixResult[] = [] - - // Process each GHSA ID individually. - // Use unprocessedIds instead of ids to skip already-fixed GHSAs. - for (let i = 0, { length } = unprocessedIds; i < length; i += 1) { - const ghsaId = unprocessedIds[i]! - debug(`check: ${ghsaId}`) - - // Apply fix for single GHSA ID. - const fixCResult = await spawnCoanaDlx( - [ - ...coanaSilenceArgs, - 'compute-fixes-and-upgrade-purls', - cwd, - '--manifests-tar-hash', - tarHash, - '--apply-fixes-to', - ghsaId, - ...(fixConfig.rangeStyle - ? ['--range-style', fixConfig.rangeStyle] - : []), - ...(minimumReleaseAge - ? ['--minimum-release-age', minimumReleaseAge] - : []), - ...(include.length ? ['--include', ...include] : []), - ...(exclude.length ? ['--exclude', ...exclude] : []), - ...(ecosystems.length ? ['--purl-types', ...ecosystems] : []), - ...(debugFlag ? ['--debug'] : []), - ...(disableExternalToolChecks - ? ['--disable-external-tool-checks'] - : []), - ...(disableMajorUpdates ? ['--disable-major-updates'] : []), - ...(showAffectedDirectDependencies - ? ['--show-affected-direct-dependencies'] - : []), - ...fixConfig.unknownFlags, - ], - fixConfig.orgSlug, - { coanaVersion, cwd, spinner, stdio: coanaStdio }, - ) - - if (!fixCResult.ok) { - logger.error(`Update failed for ${ghsaId}: ${getErrorCause(fixCResult)}`) - continue - } - - // Check for modified files after applying the fix. - const unstagedCResult = await gitUnstagedModifiedFiles(cwd) - const modifiedFiles = unstagedCResult.ok - ? unstagedCResult.data.filter(relPath => - scanBaseNames.has(path.basename(relPath)), - ) - : [] - - if (!modifiedFiles.length) { - debug(`skip: no changes for ${ghsaId}`) - continue - } - - overallFixed = true - - const branch = getSocketFixBranchName(ghsaId) - - try { - // Check for existing open PRs for this GHSA before creating a new one. - const existingPrs = await getSocketFixPrs( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - { ghsaId, states: GQL_PR_STATE_OPEN }, - ) - - if (existingPrs.length) { - debug(`pr: found ${existingPrs.length} existing open PRs for ${ghsaId}`) - - // Close outdated PRs with explanatory comment. - for ( - let j = 0, { length: prLength } = existingPrs; - j < prLength; - j += 1 - ) { - const pr = existingPrs[j]! - try { - const octokit = getOctokit() - await octokit.issues.createComment({ - owner: fixEnv.repoInfo.owner, - repo: fixEnv.repoInfo.repo, - issue_number: pr.number, - body: 'Closing this PR as a newer fix is available.', - }) - - await octokit.pulls.update({ - owner: fixEnv.repoInfo.owner, - repo: fixEnv.repoInfo.repo, - pull_number: pr.number, - state: 'closed', - }) - - debug(`pr: closed superseded PR #${pr.number} for ${ghsaId}`) - logPrEvent('superseded', pr.number, ghsaId) - } catch (e) { - debug(`pr: failed to close superseded PR #${pr.number}`) - debugDir(e) - } - } - } - - // Check if an open PR already exists for this GHSA. - const existingOpenPrs = await getSocketFixPrs( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - { ghsaId, states: GQL_PR_STATE_OPEN }, - ) - - if (existingOpenPrs.length > 0) { - const [firstPr] = existingOpenPrs - const prNum = firstPr?.number - if (prNum) { - logger.info(`PR #${prNum} already exists for ${ghsaId}, skipping.`) - debug(`skip: open PR #${prNum} exists for ${ghsaId}`) - } - continue - } - - // If branch exists but no open PR, delete the stale branch. - // This handles cases where PR creation failed but branch was pushed. - if (await gitRemoteBranchExists(branch, cwd)) { - const shouldContinue = await cleanupStaleBranch(branch, ghsaId, cwd) - if (!shouldContinue) { - continue - } - } - - // Check for GitHub token before doing any git operations. - if (!fixEnv.githubToken) { - logger.error( - 'Cannot create pull request: SOCKET_CLI_GITHUB_TOKEN environment variable is not set.\n' + - 'Set SOCKET_CLI_GITHUB_TOKEN or GITHUB_TOKEN to enable PR creation.', - ) - debug(`skip: missing GitHub token for ${ghsaId}`) - continue - } - - debug(`pr: creating for ${ghsaId}`) - - const details = ghsaDetails.get(ghsaId) - debug(`ghsa: ${ghsaId} details ${details ? 'found' : 'missing'}`) - - const pushed = - (await gitCreateBranch(branch, cwd)) && - (await gitCheckoutBranch(branch, cwd)) && - (await gitCommit( - getSocketFixCommitMessage(ghsaId, details), - modifiedFiles, - { - cwd, - email: fixEnv.gitEmail, - user: fixEnv.gitUser, - }, - )) && - (await gitPushBranch(branch, cwd)) - - if (!pushed) { - logger.warn(`Push failed for ${ghsaId}, skipping PR creation.`) - // Clean up branches after push failure. - try { - const remoteBranchExists = await gitRemoteBranchExists(branch, cwd) - await cleanupErrorBranches(branch, cwd, remoteBranchExists) - } catch (e) { - debug('pr: failed to cleanup branches after push failure') - debugDir(e) - } - // Clean up local state. - await gitResetAndClean(fixEnv.baseBranch, cwd) - await gitCheckoutBranch(fixEnv.baseBranch, cwd) - continue - } - - // Set up git remote. - await setGitRemoteGithubRepoUrl( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - fixEnv.githubToken, - cwd, - ) - - const prResult = await openSocketFixPr( - fixEnv.repoInfo.owner, - fixEnv.repoInfo.repo, - branch, - // Single GHSA ID. - [ghsaId], - { - baseBranch: fixEnv.baseBranch, - cwd, - ghsaDetails, - }, - ) - - if (prResult.ok) { - const { data } = prResult.pr - const prRef = `PR #${data.number}` - - logger.success(`Opened ${prRef} for ${ghsaId}.`) - logger.info(`PR URL: ${data.html_url}`) - logPrEvent('created', data.number, ghsaId, data.html_url) - - ghsaFixResults.push({ - fixed: true, - ghsaId, - pullRequestLink: data.html_url, - pullRequestNumber: data.number, - }) - - // Mark GHSA as fixed in tracker. - await markGhsaFixed(cwd, ghsaId, data.number, branch) - - if (autopilot) { - logger.indent() - spinner?.indent() - const { details, enabled } = await enablePrAutoMerge(data) - if (enabled) { - logger.info(`Auto-merge enabled for ${prRef}.`) - } else { - const message = `Failed to enable auto-merge for ${prRef}${ - details ? `:\n${details.map(d => ` - ${d}`).join('\n')}` : '.' - }` - logger.error(message) - } - logger.dedent() - spinner?.dedent() - } - - // Clean up local branch only - keep remote branch for PR merge. - await cleanupSuccessfulPrLocalBranch(branch, cwd) - } else { - // Handle PR creation failures. - if (prResult.reason === 'already_exists') { - logger.info( - `PR already exists for ${ghsaId} (this should not happen due to earlier check).`, - ) - // Don't delete branch - PR exists and needs it. - } else if (prResult.reason === 'validation_error') { - logger.error( - `Failed to create PR for ${ghsaId}:\n${prResult.details}`, - ) - await cleanupFailedPrBranches(branch, cwd) - } else if (prResult.reason === 'permission_denied') { - logger.error( - `Failed to create PR for ${ghsaId}: Permission denied. Check SOCKET_CLI_GITHUB_TOKEN permissions.`, - ) - await cleanupFailedPrBranches(branch, cwd) - } else if (prResult.reason === 'network_error') { - logger.error( - `Failed to create PR for ${ghsaId}: Network error. Please try again.`, - ) - await cleanupFailedPrBranches(branch, cwd) - } else { - logger.error( - `Failed to create PR for ${ghsaId}: ${prResult.error.message}`, - ) - await cleanupFailedPrBranches(branch, cwd) - } - } - - // Reset back to base branch for next iteration. - await gitResetAndClean(fixEnv.baseBranch, cwd) - await gitCheckoutBranch(fixEnv.baseBranch, cwd) - } catch (e) { - logger.warn( - `Unexpected condition: Push failed for ${ghsaId}, skipping PR creation.`, - ) - debugDir(e) - // Clean up branches after unexpected error. - try { - const remoteBranchExists = await gitRemoteBranchExists(branch, cwd) - await cleanupErrorBranches(branch, cwd, remoteBranchExists) - } catch (cleanupError) { - debug('pr: failed to cleanup branches during exception cleanup') - debugDir(cleanupError) - } - // Clean up local state. - await gitResetAndClean(fixEnv.baseBranch, cwd) - await gitCheckoutBranch(fixEnv.baseBranch, cwd) - } - - count += 1 - debug( - `increment: count ${count}/${Math.min(adjustedLimit, unprocessedIds.length)}`, - ) - if (count >= adjustedLimit) { - break - } - } - - spinner?.stop() - - return { - ok: true, - data: { fixedAll: overallFixed, ghsaDetails: ghsaFixResults }, - } -} diff --git a/packages/cli/src/commands/fix/env-helpers.mts b/packages/cli/src/commands/fix/env-helpers.mts deleted file mode 100644 index 5f6f76bf7..000000000 --- a/packages/cli/src/commands/fix/env-helpers.mts +++ /dev/null @@ -1,150 +0,0 @@ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { isDebug } from '@socketsecurity/lib-stable/debug/namespace' -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import { getSocketCliGithubToken } from '@socketsecurity/lib-stable/env/socket-cli' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getSocketFixPrs } from './pull-request.mts' -import { GITHUB_REPOSITORY } from '../../env/github-repository.mts' -import { SOCKET_CLI_GIT_USER_EMAIL } from '../../env/socket-cli-git-user-email.mts' -import { SOCKET_CLI_GIT_USER_NAME } from '../../env/socket-cli-git-user-name.mts' -import { getBaseBranch, getRepoInfo } from '../../util/git/operations.mjs' - -import type { PrMatch } from './pull-request.mts' -import type { RepoInfo } from '../../util/git/operations.mjs' - -/** - * Check which required CI environment variables are missing. Returns lists of - * missing and present variables. - */ -export function checkCiEnvVars(): MissingEnvVars { - const missing: string[] = [] - const present: string[] = [] - - // Helper to categorize env var as present or missing. - const checkVar = (value: unknown, name: string) => { - if (value) { - present.push(name) - } else { - missing.push(name) - } - } - - checkVar(getCI(), 'CI') - checkVar(SOCKET_CLI_GIT_USER_EMAIL, 'SOCKET_CLI_GIT_USER_EMAIL') - checkVar(SOCKET_CLI_GIT_USER_NAME, 'SOCKET_CLI_GIT_USER_NAME') - checkVar( - getSocketCliGithubToken(), - 'SOCKET_CLI_GITHUB_TOKEN (or GITHUB_TOKEN)', - ) - - return { missing, present } -} - -export function ciRepoInfo(): RepoInfo | undefined { - if (!GITHUB_REPOSITORY) { - debug('miss: GITHUB_REPOSITORY env var') - return undefined - } - const ownerSlashRepo = GITHUB_REPOSITORY - const slashIndex = ownerSlashRepo.indexOf('/') - if (slashIndex === -1) { - return undefined - } - return { - owner: ownerSlashRepo.slice(0, slashIndex), - repo: ownerSlashRepo.slice(slashIndex + 1), - } -} - -interface FixEnv { - baseBranch: string - gitEmail: string | undefined - githubToken: string | undefined - gitUser: string | undefined - isCi: boolean - prs: PrMatch[] - repoInfo: RepoInfo | undefined -} - -interface MissingEnvVars { - missing: string[] - present: string[] -} - -/** - * Get formatted instructions for setting CI environment variables. - */ -export function getCiEnvInstructions(): string { - return ( - 'To enable automatic pull request creation, run in CI with these environment variables:\n' + - ' - CI=1\n' + - ' - SOCKET_CLI_GITHUB_TOKEN=<your-github-token>\n' + - ' - SOCKET_CLI_GIT_USER_NAME=<git-username>\n' + - ' - SOCKET_CLI_GIT_USER_EMAIL=<git-email>' - ) -} - -export async function getFixEnv(): Promise<FixEnv> { - const baseBranch = await getBaseBranch() - const gitEmail = SOCKET_CLI_GIT_USER_EMAIL - const gitUser = SOCKET_CLI_GIT_USER_NAME - const githubToken = getSocketCliGithubToken() - const isCi = !!(getCI() && gitEmail && gitUser && githubToken) - - const envCheck = checkCiEnvVars() - - // Provide clear feedback about missing environment variables. - if (getCI() && envCheck.missing.length) { - // CI is set but other required vars are missing. - const missingExceptCi = envCheck.missing.filter(v => v !== 'CI') - if (missingExceptCi.length) { - const logger = getDefaultLogger() - logger.warn( - 'CI mode detected, but pull request creation is disabled due to missing environment variables:\n' + - ` Missing: ${joinAnd(missingExceptCi)}\n` + - ' Set these variables to enable automatic pull request creation.', - ) - } - } else if ( - // If not in CI but some CI-related env vars are set. - !getCI() && - envCheck.present.length && - // then log about it when in debug mode. - isDebug() - ) { - debug( - `miss: fixEnv.isCi is false, expected ${joinAnd(envCheck.missing)} to be set`, - ) - } - - let repoInfo: RepoInfo | undefined - if (isCi) { - repoInfo = ciRepoInfo() - } - if (!repoInfo) { - if (isCi) { - debug('falling back to `git remote get-url origin`') - } - repoInfo = await getRepoInfo() - } - - const prs = - isCi && repoInfo - ? await getSocketFixPrs(repoInfo.owner, repoInfo.repo, { - author: gitUser, - states: 'all', - }) - : [] - - return { - baseBranch, - gitEmail, - githubToken, - gitUser, - isCi, - prs, - repoInfo, - } -} diff --git a/packages/cli/src/commands/fix/ghsa-tracker.mts b/packages/cli/src/commands/fix/ghsa-tracker.mts deleted file mode 100644 index 1a8868d68..000000000 --- a/packages/cli/src/commands/fix/ghsa-tracker.mts +++ /dev/null @@ -1,175 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { readJson } from '@socketsecurity/lib-stable/fs/read-json' -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { writeJson } from '@socketsecurity/lib-stable/fs/write-json' - -import { getSocketFixBranchName } from './git.mts' - -type GhsaFixRecord = { - branch: string - fixedAt: string // ISO 8601 - ghsaId: string - prNumber?: number | undefined -} - -export type GhsaTracker = { - fixed: GhsaFixRecord[] - version: 1 -} - -const TRACKER_FILE = '.socket/fixed-ghsas.json' - -/** - * Check if a GHSA has been fixed according to the tracker. - */ -export async function isGhsaFixed( - cwd: string, - ghsaId: string, -): Promise<boolean> { - try { - const tracker = await loadGhsaTracker(cwd) - return tracker.fixed.some(r => r.ghsaId === ghsaId) - } catch (e) { - debug(`ghsa-tracker: failed to check if ${ghsaId} is fixed`) - debugDir(e) - return false - } -} - -/** - * Check if a process with the given PID is still running. - */ -export function isPidAlive(pid: number): boolean { - try { - // Signal 0 checks process existence without sending actual signal. - process.kill(pid, 0) - return true - } catch (e) { - const err = e as NodeJS.ErrnoException - // EPERM means process exists but no permission (treat as alive). - // ESRCH means process doesn't exist (dead). - // All other errors (EINVAL, etc.) treat as dead to be safe. - return err.code === 'EPERM' - } -} - -/** - * Load the GHSA tracker from the repository. Creates a new tracker if the file - * doesn't exist. - */ -export async function loadGhsaTracker(cwd: string): Promise<GhsaTracker> { - const trackerPath = path.join(cwd, TRACKER_FILE) - - try { - const data = await readJson(trackerPath) - return (data as GhsaTracker) ?? { version: 1, fixed: [] } - } catch (_e) { - debug(`ghsa-tracker: creating new tracker at ${trackerPath}`) - return { version: 1, fixed: [] } - } -} - -/** - * Mark a GHSA as fixed in the tracker. Removes any existing record for the same - * GHSA before adding the new one. Uses file locking to prevent race conditions - * with concurrent operations. - */ -export async function markGhsaFixed( - cwd: string, - ghsaId: string, - prNumber?: number, - branch?: string, -): Promise<void> { - const trackerPath = path.join(cwd, TRACKER_FILE) - const lockFile = `${trackerPath}.lock` - - // Acquire lock with exponential backoff and stale lock detection. - let lockAcquired = false - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.writeFile(lockFile, String(process.pid), { flag: 'wx' }) - lockAcquired = true - break - } catch (e) { - const err = e as NodeJS.ErrnoException - if (err.code === 'EEXIST' && attempt < 4) { - // Lock exists, check if it's stale. - try { - const lockContent = await fs.readFile(lockFile, 'utf8') - const lockPid = Number.parseInt(lockContent.trim(), 10) - if (!Number.isNaN(lockPid) && !isPidAlive(lockPid)) { - // Stale lock detected, remove and retry immediately. - debug( - `ghsa-tracker: removing stale lock from dead process ${lockPid}`, - ) - await safeDelete(lockFile, { force: true }) - continue - } - } catch { - // Could not read lock file, may have been removed. - } - // Lock exists and process is alive, wait with exponential backoff. - // Delays: 100ms, 200ms, 400ms, 800ms, capped at 10s to prevent overflow. - await new Promise(resolve => - setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 10_000)), - ) - continue - } - // If not EEXIST or last attempt, proceed without lock. - debug(`ghsa-tracker: could not acquire lock, proceeding anyway`) - break - } - } - - try { - const tracker = await loadGhsaTracker(cwd) - - // Remove any existing record for this GHSA. - tracker.fixed = tracker.fixed.filter(r => r.ghsaId !== ghsaId) - - // Add new record. - const record: GhsaFixRecord = { - branch: branch ?? getSocketFixBranchName(ghsaId), - fixedAt: new Date().toISOString(), - ghsaId, - } - if (prNumber !== undefined) { - record.prNumber = prNumber - } - tracker.fixed.push(record) - - // Sort by fixedAt descending (most recent first). - tracker.fixed.sort((a, b) => b.fixedAt.localeCompare(a.fixedAt)) - - await saveGhsaTracker(cwd, tracker) - debug(`ghsa-tracker: marked ${ghsaId} as fixed`) - } catch (e) { - debug(`ghsa-tracker: failed to mark ${ghsaId} as fixed`) - debugDir(e) - } finally { - // Release lock. - if (lockAcquired) { - await safeDelete(lockFile, { force: true }) - } - } -} - -/** - * Save the GHSA tracker to the repository. Creates the .socket directory if it - * doesn't exist. - */ -export async function saveGhsaTracker( - cwd: string, - tracker: GhsaTracker, -): Promise<void> { - const trackerPath = path.join(cwd, TRACKER_FILE) - - // Ensure .socket directory exists. - await safeMkdir(path.dirname(trackerPath), { recursive: true }) - - await writeJson(trackerPath, tracker, { spaces: 2 }) - debug(`ghsa-tracker: saved ${tracker.fixed.length} records to ${trackerPath}`) -} diff --git a/packages/cli/src/commands/fix/git.mts b/packages/cli/src/commands/fix/git.mts deleted file mode 100644 index ac7b9cb1b..000000000 --- a/packages/cli/src/commands/fix/git.mts +++ /dev/null @@ -1,94 +0,0 @@ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' - -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mts' - -import type { GhsaDetails } from '../../util/git/github.mts' - -const GITHUB_ADVISORIES_URL = 'https://github.com/advisories' - -// GHSA ID pattern: GHSA-xxxx-xxxx-xxxx (4 alphanumeric segments). -const GHSA_ID_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i - -export function getSocketFixBranchName(ghsaId: string): string { - return `socket/fix/${ghsaId}` -} - -export function getSocketFixBranchPattern(ghsaId?: string | undefined): RegExp { - // Escape special regex characters to prevent ReDoS attacks. - const pattern = ghsaId - ? GHSA_ID_PATTERN.test(ghsaId) - ? ghsaId - : ghsaId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - : '.+' - return new RegExp(`^socket/fix/(${pattern})$`) -} - -export function getSocketFixCommitMessage( - ghsaId: string, - details?: GhsaDetails | undefined, -): string { - const summary = details?.summary - return `fix: ${ghsaId}${summary ? ` - ${summary}` : ''}` -} - -export function getSocketFixPullRequestBody( - ghsaIds: string[], - ghsaDetails?: Map<string, GhsaDetails> | undefined, -): string { - const vulnCount = ghsaIds.length - const firstGhsa = ghsaIds[0] - if (vulnCount === 1 && firstGhsa) { - const ghsaId = firstGhsa - const details = ghsaDetails?.get(ghsaId) - const body = `[Socket](${SOCKET_WEBSITE_URL}) fix for [${ghsaId}](${GITHUB_ADVISORIES_URL}/${ghsaId}).` - if (!details) { - return body - } - const packages = getUniquePackages(details) - return [ - body, - '', - '', - `**Vulnerability Summary:** ${details.summary}`, - '', - `**Severity:** ${details.severity}`, - '', - `**Affected Packages:** ${joinAnd(packages)}`, - ].join('\n') - } - return [ - `[Socket](${SOCKET_WEBSITE_URL}) fixes for ${vulnCount} GHSAs.`, - '', - '**Fixed Vulnerabilities:**', - ...ghsaIds.map(id => { - const details = ghsaDetails?.get(id) - const item = `- [${id}](${GITHUB_ADVISORIES_URL}/${id})` - if (details) { - const packages = getUniquePackages(details) - return `${item} - ${details.summary} (${joinAnd(packages)})` - } - return item - }), - ].join('\n') -} - -export function getSocketFixPullRequestTitle(ghsaIds: string[]): string { - const vulnCount = ghsaIds.length - const firstGhsa = ghsaIds[0] - return vulnCount === 1 && firstGhsa - ? `Fix for ${firstGhsa}` - : `Fixes for ${vulnCount} GHSAs` -} - -/** - * Extract unique package names with ecosystems from vulnerability details. - */ -export function getUniquePackages(details: GhsaDetails): string[] { - return [ - ...new Set( - details.vulnerabilities.nodes.map( - v => `${v.package.name} (${v.package.ecosystem})`, - ), - ), - ] -} diff --git a/packages/cli/src/commands/fix/handle-fix.mts b/packages/cli/src/commands/fix/handle-fix.mts deleted file mode 100644 index d589b1479..000000000 --- a/packages/cli/src/commands/fix/handle-fix.mts +++ /dev/null @@ -1,188 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { coanaFix } from './coana-fix.mts' -import { outputFixResult } from './output-fix-result.mts' -import { convertCveToGhsa } from '../../util/cve-to-ghsa.mts' -import { convertPurlToGhsas } from '../../util/purl/to-ghsa.mts' - -import type { FixConfig } from './types.mts' -import type { OutputKind } from '../../types.mts' -import type { Remap } from '@socketsecurity/lib-stable/objects/types' -const logger = getDefaultLogger() - -const GHSA_FORMAT_REGEXP = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ -const CVE_FORMAT_REGEXP = /^CVE-\d{4}-\d{4,}$/ - -type HandleFixConfig = Remap< - FixConfig & { - applyFixes: boolean - ghsas: string[] - orgSlug: string - outputKind: OutputKind - unknownFlags: string[] - outputFile: string - minimumReleaseAge: string - silence: boolean - } -> - -/** - * Converts mixed CVE/GHSA/PURL IDs to GHSA IDs only. Filters out invalid IDs - * and logs conversion results. - */ -export async function convertIdsToGhsas(ids: string[]): Promise<string[]> { - debug(`Converting ${ids.length} IDs to GHSA format`) - debugDir({ ids }) - - const validGhsas: string[] = [] - const errors: string[] = [] - - for (let i = 0, { length } = ids; i < length; i += 1) { - const id = ids[i]! - const trimmedId = id.trim() - - if (trimmedId.startsWith('GHSA-')) { - // Already a GHSA ID, validate format - if (GHSA_FORMAT_REGEXP.test(trimmedId)) { - validGhsas.push(trimmedId) - } else { - errors.push(`Invalid GHSA format: ${trimmedId}`) - } - } else if (trimmedId.startsWith('CVE-')) { - // Convert CVE to GHSA - if (!CVE_FORMAT_REGEXP.test(trimmedId)) { - errors.push(`Invalid CVE format: ${trimmedId}`) - continue - } - - const conversionResult = await convertCveToGhsa(trimmedId) - if (conversionResult.ok) { - validGhsas.push(conversionResult.data) - logger.info(`Converted ${trimmedId} to ${conversionResult.data}`) - } else { - errors.push(`${trimmedId}: ${conversionResult.message}`) - } - } else if (trimmedId.startsWith('pkg:')) { - // Convert PURL to GHSAs - const conversionResult = await convertPurlToGhsas(trimmedId) - if (conversionResult.ok && conversionResult.data.length) { - validGhsas.push(...conversionResult.data) - const displayGhsas = - conversionResult.data.length > 3 - ? `${conversionResult.data.slice(0, 3).join(', ')} … and ${conversionResult.data.length - 3} more` - : joinAnd(conversionResult.data) - logger.info( - `Converted ${trimmedId} to ${conversionResult.data.length} GHSA(s): ${displayGhsas}`, - ) - } else { - errors.push( - `${trimmedId}: ${conversionResult.message || 'No GHSAs found'}`, - ) - } - } else { - // Neither CVE, GHSA, nor PURL, skip - errors.push( - `Unsupported ID format (expected CVE, GHSA, or PURL): ${trimmedId}`, - ) - } - } - - if (errors.length) { - logger.warn( - `Skipped ${errors.length} invalid IDs:\n${errors.map(e => ` - ${e}`).join('\n')}`, - ) - debugDir({ errors }) - } - - debug(`Converted to ${validGhsas.length} valid GHSA IDs`) - debugDir({ validGhsas }) - - return validGhsas -} - -export async function handleFix({ - all, - applyFixes, - autopilot, - coanaVersion, - cwd, - debug: debugFlag, - disableExternalToolChecks, - disableMajorUpdates, - ecosystems, - exclude, - ghsas, - include, - minSatisfying, - minimumReleaseAge, - orgSlug, - outputFile, - outputKind, - prCheck, - prLimit, - rangeStyle, - showAffectedDirectDependencies, - silence, - spinner, - unknownFlags, -}: HandleFixConfig) { - debug(`Starting fix command for ${orgSlug}`) - debugDir({ - all, - applyFixes, - autopilot, - coanaVersion, - cwd, - debug: debugFlag, - disableExternalToolChecks, - disableMajorUpdates, - ecosystems, - exclude, - ghsas, - include, - minSatisfying, - minimumReleaseAge, - outputFile, - outputKind, - prCheck, - prLimit, - rangeStyle, - showAffectedDirectDependencies, - unknownFlags, - }) - - await outputFixResult( - await coanaFix({ - all, - applyFixes, - autopilot, - coanaVersion, - cwd, - debug: debugFlag, - disableExternalToolChecks, - disableMajorUpdates, - ecosystems, - exclude, - // Convert mixed CVE/GHSA/PURL inputs to GHSA IDs only. - ghsas: await convertIdsToGhsas(ghsas), - include, - minimumReleaseAge, - minSatisfying, - orgSlug, - outputFile, - outputKind, - prCheck, - prLimit, - rangeStyle, - showAffectedDirectDependencies, - silence, - spinner, - unknownFlags, - }), - outputKind, - ) -} diff --git a/packages/cli/src/commands/fix/output-fix-result.mts b/packages/cli/src/commands/fix/output-fix-result.mts deleted file mode 100644 index 5898b0439..000000000 --- a/packages/cli/src/commands/fix/output-fix-result.mts +++ /dev/null @@ -1,41 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdError, mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputFixResult( - result: CResult<unknown>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (outputKind === 'markdown') { - if (!result.ok) { - logger.log(mdError(result.message, result.cause)) - } else { - logger.log(mdHeader('Fix Completed')) - logger.log('') - logger.success('Finished!') - } - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.log('') - logger.success('Finished!') -} diff --git a/packages/cli/src/commands/fix/pr-lifecycle-logger.mts b/packages/cli/src/commands/fix/pr-lifecycle-logger.mts deleted file mode 100644 index 215f2dfb1..000000000 --- a/packages/cli/src/commands/fix/pr-lifecycle-logger.mts +++ /dev/null @@ -1,66 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-status-emoji -- TUI / custom output formatter; emojis are part of the visual contract. */ - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -const logger = getDefaultLogger() - -type PrLifecycleEvent = - | 'created' - | 'closed' - | 'failed' - | 'merged' - | 'superseded' - | 'updated' - -/** - * Log PR lifecycle events with consistent formatting and color-coding. - * - * @param event - The lifecycle event type. - * @param prNumber - The pull request number. - * @param ghsaId - The GHSA ID associated with the PR. - * @param details - Optional additional details to include in the log message. - */ -export function logPrEvent( - event: PrLifecycleEvent, - prNumber: number, - ghsaId: string, - details?: string, -): void { - const prRef = `PR #${prNumber}` - const detailsSuffix = details ? `: ${details}` : '' - - switch (event) { - case 'created': - logger.success( - `${colors.green('✓')} Created ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - case 'merged': - logger.success( - `${colors.green('✓')} Merged ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - case 'closed': - logger.info( - `${colors.blue('ℹ')} Closed ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - case 'updated': - logger.info( - `${colors.cyan('→')} Updated ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - case 'superseded': - logger.warn( - `${colors.yellow('⚠')} Superseded ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - case 'failed': - logger.error( - `${colors.red('✗')} Failed to create ${prRef} for ${ghsaId}${detailsSuffix}`, - ) - break - } -} diff --git a/packages/cli/src/commands/fix/pull-request.mts b/packages/cli/src/commands/fix/pull-request.mts deleted file mode 100644 index 930f6f1e3..000000000 --- a/packages/cli/src/commands/fix/pull-request.mts +++ /dev/null @@ -1,472 +0,0 @@ -import { RequestError } from '@octokit/request-error' - -import { UNKNOWN_VALUE } from '@socketsecurity/lib-stable/constants/sentinels' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { - getSocketFixBranchPattern, - getSocketFixPullRequestBody, - getSocketFixPullRequestTitle, -} from './git.mts' -import { logPrEvent } from './pr-lifecycle-logger.mts' -import { - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, -} from '../../constants/github.mts' -import { formatErrorWithDetail } from '../../util/error/errors.mjs' -import { - cacheFetch, - getOctokit, - getOctokitGraphql, - handleGraphqlError, - withGitHubRetry, - writeCache, -} from '../../util/git/github.mts' -import type { GhsaDetails, Pr } from '../../util/git/github.mts' -import { createPrProvider } from '../../util/git/provider-factory.mts' - -import type { OctokitResponse } from '@octokit/types' -import type { JsonContent } from '@socketsecurity/lib-stable/fs/types' - -type GQL_MERGE_STATE_STATUS = - | 'BEHIND' - | 'BLOCKED' - | 'CLEAN' - | 'DIRTY' - | 'DRAFT' - | 'HAS_HOOKS' - | 'UNKNOWN' - | 'UNSTABLE' - -type GQL_PR_STATE = 'OPEN' | 'CLOSED' | 'MERGED' - -export type PrMatch = { - author: string - baseRefName: string - headRefName: string - mergeStateStatus: GQL_MERGE_STATE_STATUS - number: number - state: GQL_PR_STATE - title: string -} - -export async function cleanupSocketFixPrs( - owner: string, - repo: string, - ghsaId: string, -): Promise<PrMatch[]> { - const contextualMatches = await getSocketFixPrsWithContext(owner, repo, { - ghsaId, - }) - - if (!contextualMatches.length) { - return [] - } - - const cachesToSave = new Map<string, JsonContent>() - const provider = createPrProvider() - - const settledMatches = await Promise.allSettled( - contextualMatches.map(async ({ context, match }) => { - // Update stale PRs. - // https://docs.github.com/en/graphql/reference/enums#mergestatestatus - if (match.mergeStateStatus === 'BEHIND') { - const { number: prNum } = match - const prRef = `PR #${prNum}` - try { - // Update the PR using the provider. - await provider.updatePr({ - owner, - repo, - prNumber: prNum, - head: match.headRefName, - base: match.baseRefName, - }) - - debug(`pr: updated stale ${prRef}`) - logPrEvent('updated', prNum, ghsaId, 'Updated from base branch') - - // Update cache entry - only GraphQL is used now. - context.entry.mergeStateStatus = 'CLEAN' - // Mark cache to be saved. - cachesToSave.set(context.cacheKey, context.data) - } catch (e) { - debug(formatErrorWithDetail(`pr: failed to update ${prRef}`, e)) - debugDir(e) - } - } - - // Clean up merged PR branches. - if (match.state === GQL_PR_STATE_MERGED) { - const { number: prNum } = match - const prRef = `PR #${prNum}` - try { - const success = await provider.deleteBranch(match.headRefName) - if (success) { - debug(`pr: deleted merged branch ${match.headRefName} for ${prRef}`) - logPrEvent('merged', prNum, ghsaId, 'Branch cleaned up') - /* c8 ignore start - branch-delete failure path; depends on remote git state we don't control in tests */ - } else { - debug( - `pr: failed to delete branch ${match.headRefName} for ${prRef}`, - ) - } - /* c8 ignore stop */ - } catch (e) { - // Don't treat this as a hard error - branch might already be deleted. - debug( - formatErrorWithDetail( - `pr: failed to delete branch ${match.headRefName} for ${prRef}`, - e, - ), - ) - debugDir(e) - } - } - - return match - }), - ) - - if (cachesToSave.size) { - await Promise.allSettled( - Array.from(cachesToSave).map(({ 0: key, 1: data }) => - writeCache(key, data), - ), - ) - } - - const fulfilledMatches = settledMatches.filter( - (r): r is PromiseFulfilledResult<PrMatch> => r.status === 'fulfilled', - ) - - return fulfilledMatches.map(r => r.value) -} - -export type PrAutoMergeState = { - enabled: boolean - details?: string[] | undefined -} - -type SocketPrsOptions = { - author?: string | undefined - ghsaId?: string | undefined - states?: 'all' | GQL_PR_STATE | GQL_PR_STATE[] | undefined -} - -export async function getSocketFixPrs( - owner: string, - repo: string, - options?: SocketPrsOptions | undefined, -): Promise<PrMatch[]> { - return (await getSocketFixPrsWithContext(owner, repo, options)).map( - d => d.match, - ) -} - -type GqlPrNode = { - author?: - | { - login: string - } - | undefined - baseRefName: string - headRefName: string - mergeStateStatus: GQL_MERGE_STATE_STATUS - number: number - state: GQL_PR_STATE - title: string -} - -type GqlPullRequestsResponse = { - repository: { - pullRequests: { - pageInfo: { - hasNextPage: boolean - endCursor: string | undefined - } - nodes: GqlPrNode[] - } - } -} - -type ContextualPrMatch = { - context: { - apiType: 'graphql' | 'rest' - cacheKey: string - data: JsonContent - entry: GqlPrNode - index: number - parent: GqlPrNode[] - } - match: PrMatch -} - -export async function getSocketFixPrsWithContext( - owner: string, - repo: string, - options?: SocketPrsOptions | undefined, -): Promise<ContextualPrMatch[]> { - const { - author, - ghsaId, - states: statesValue = 'all', - } = { - __proto__: null, - ...options, - } as SocketPrsOptions - const branchPattern = getSocketFixBranchPattern(ghsaId) - const checkAuthor = isNonEmptyString(author) - const octokitGraphql = getOctokitGraphql() - const contextualMatches: ContextualPrMatch[] = [] - const states = ( - typeof statesValue === 'string' - ? statesValue.toLowerCase() === 'all' - ? [GQL_PR_STATE_OPEN, GQL_PR_STATE_CLOSED, GQL_PR_STATE_MERGED] - : [statesValue] - : statesValue - ).map(s => s.toUpperCase()) - - try { - let hasNextPage = true - let cursor: string | undefined = undefined - let pageIndex = 0 - // Include owner in cache key to avoid collisions with same repo name. - const gqlCacheKey = `${owner}::${repo}-pr-graphql-snapshot-${states.join('-').toLowerCase()}` - while (hasNextPage) { - const gqlResp = (await cacheFetch( - `${gqlCacheKey}-page-${pageIndex}`, - /* c8 ignore start - cacheFetch factory only fires on cache miss; tests pass mocked cached values directly */ - () => - octokitGraphql( - ` - query($owner: String!, $repo: String!, $states: [PullRequestState!], $after: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 100, states: $states, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { - pageInfo { - hasNextPage - endCursor - } - nodes { - author { - login - } - baseRefName - headRefName - mergeStateStatus - number - state - title - } - } - } - } - `, - { - owner, - repo, - states, - after: cursor, - }, - ), - /* c8 ignore stop */ - )) as GqlPullRequestsResponse - - const { nodes, pageInfo } = gqlResp?.repository?.pullRequests ?? { - nodes: [], - pageInfo: { hasNextPage: false, endCursor: undefined }, - } - - for (let i = 0, { length } = nodes; i < length; i += 1) { - const node = nodes[i]! - const login = node.author?.login - const matchesAuthor = checkAuthor ? login === author : true - const matchesBranch = branchPattern.test(node.headRefName) - if (matchesAuthor && matchesBranch) { - contextualMatches.push({ - context: { - apiType: 'graphql', - cacheKey: `${gqlCacheKey}-page-${pageIndex}`, - data: gqlResp, - entry: node, - index: i, - parent: nodes, - }, - match: { - ...node, - author: login ?? UNKNOWN_VALUE, - }, - }) - } - } - - // Continue to next page. - hasNextPage = pageInfo.hasNextPage - cursor = pageInfo.endCursor - pageIndex += 1 - - /* c8 ignore start - GQL_PAGE_SENTINEL safety limit; tests page through at most a few pages */ - if (pageIndex === GQL_PAGE_SENTINEL) { - debug( - `GraphQL pagination reached safety limit (${GQL_PAGE_SENTINEL} pages) for ${owner}/${repo}`, - ) - break - } - /* c8 ignore stop */ - - // Early exit optimization: if we found matches and only looking for specific GHSA, - // we can stop pagination since we likely found what we need. - if (contextualMatches.length > 0 && ghsaId) { - break - } - } - } catch (e) { - // Use centralized error handling for better error messages. - const errorResult = handleGraphqlError( - e, - `listing PRs for ${owner}/${repo}`, - ) - // errorResult is always ok: false from handleGraphqlError. - if (!errorResult.ok) { - debug(errorResult.cause ?? errorResult.message) - } - } - - return contextualMatches -} - -type OpenSocketFixPrOptions = { - baseBranch?: string | undefined - cwd?: string | undefined - ghsaDetails?: Map<string, GhsaDetails> | undefined - retries?: number | undefined -} - -type OpenPrResult = - | { ok: true; pr: OctokitResponse<Pr> } - | { ok: false; reason: 'already_exists'; error: RequestError } - | { - ok: false - reason: 'validation_error' - error: RequestError - details: string - } - | { ok: false; reason: 'permission_denied'; error: RequestError } - | { ok: false; reason: 'network_error'; error: RequestError } - | { ok: false; reason: 'unknown'; error: Error } - -export async function openSocketFixPr( - owner: string, - repo: string, - branch: string, - ghsaIds: string[], - options?: OpenSocketFixPrOptions | undefined, -): Promise<OpenPrResult> { - const { - baseBranch = 'main', - ghsaDetails, - retries = 3, - } = { - __proto__: null, - ...options, - } as OpenSocketFixPrOptions - - const provider = createPrProvider() - - try { - const result = await provider.createPr({ - owner, - repo, - title: getSocketFixPullRequestTitle(ghsaIds), - head: branch, - base: baseBranch, - body: getSocketFixPullRequestBody(ghsaIds, ghsaDetails), - retries, - }) - - // Convert provider response to Octokit format for backward compatibility. - const octokit = getOctokit() - const prDetailsResult = await withGitHubRetry( - () => - octokit.pulls.get({ - owner, - repo, - pull_number: result.number, - }), - `fetching PR #${result.number} details`, - ) - - if (!prDetailsResult.ok) { - return { - ok: false, - reason: 'network_error', - error: new Error( - prDetailsResult.cause || prDetailsResult.message, - ) as RequestError, - } - } - - return { ok: true, pr: prDetailsResult.data } - } catch (e) { - debug(formatErrorWithDetail('Failed to create pull request', e)) - debugDir(e) - - // Handle RequestError from Octokit/provider. - if (e instanceof RequestError) { - const errors = ( - e.response?.data as { errors?: unknown | undefined } | undefined - )?.errors - const errorMessages = Array.isArray(errors) - ? errors.map( - (d: { - message?: string | undefined - resource?: string | undefined - field?: string | undefined - code?: string | undefined - }) => d.message?.trim() ?? `${d.resource}.${d.field} (${d.code})`, - ) - : [] - - // Check for "PR already exists" error. - if ( - errorMessages.some((msg: string) => - msg.toLowerCase().includes('pull request already exists'), - ) - ) { - debug('Failed to create pull request: already exists') - return { ok: false, reason: 'already_exists', error: e } - } - - // Check for validation errors (e.g., no commits between branches). - if (Array.isArray(errors) && errors.length > 0) { - const details = errorMessages.map((d: string) => `- ${d}`).join('\n') - debug(`Failed to create pull request:\n${details}`) - return { - ok: false, - reason: 'validation_error', - error: e, - details, - } - } - - // Check HTTP status codes for permission errors. - if (e.status === 403 || e.status === 401) { - debug('Failed to create pull request: permission denied') - return { ok: false, reason: 'permission_denied', error: e } - } - - // Check for server errors. - if (e.status && e.status >= 500) { - debug('Failed to create pull request: network error') - return { ok: false, reason: 'network_error', error: e } - } - } - - // Unknown error. - debug(`Failed to create pull request: ${e}`) - return { ok: false, reason: 'unknown', error: e as Error } - } -} diff --git a/packages/cli/src/commands/fix/types.mts b/packages/cli/src/commands/fix/types.mts deleted file mode 100644 index 3b047916d..000000000 --- a/packages/cli/src/commands/fix/types.mts +++ /dev/null @@ -1,31 +0,0 @@ -import type { OutputKind } from '../../types.mts' -import type { PURL_Type } from '../../util/ecosystem/types.mts' -import type { RangeStyle } from '../../util/semver.mts' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -export type FixConfig = { - all: boolean - applyFixes: boolean - autopilot: boolean - coanaVersion: string | undefined - cwd: string - debug: boolean - disableExternalToolChecks: boolean - disableMajorUpdates: boolean - ecosystems: PURL_Type[] - exclude: string[] - ghsas: string[] - include: string[] - minimumReleaseAge: string - minSatisfying: boolean - orgSlug: string - outputFile: string - outputKind: OutputKind - prCheck: boolean - prLimit: number - rangeStyle: RangeStyle - showAffectedDirectDependencies: boolean - silence: boolean - spinner: SpinnerInstance | undefined - unknownFlags: string[] -} diff --git a/packages/cli/src/commands/gem/cmd-gem.mts b/packages/cli/src/commands/gem/cmd-gem.mts deleted file mode 100644 index 35a08ebe2..000000000 --- a/packages/cli/src/commands/gem/cmd-gem.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Socket gem command — forwards gem operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdGem = defineHandoffCommand({ - name: 'gem', - description: 'Run gem with Socket Firewall security', - spawnMode: 'dlx', - examples: ['install rails', 'list', 'update'], - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/go/cmd-go.mts b/packages/cli/src/commands/go/cmd-go.mts deleted file mode 100644 index adeb3aba5..000000000 --- a/packages/cli/src/commands/go/cmd-go.mts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Socket go command — forwards go operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdGo = defineHandoffCommand({ - name: 'go', - description: 'Run go with Socket Firewall security', - spawnMode: 'dlx', - examples: [ - 'get github.com/gin-gonic/gin', - 'install golang.org/x/tools/cmd/goimports', - 'mod download', - ], - helpNotes: [ - 'Wrapper mode works best on Linux (macOS may have keychain issues).', - ], - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/install/cmd-install-completion.mts b/packages/cli/src/commands/install/cmd-install-completion.mts deleted file mode 100644 index 52e197a73..000000000 --- a/packages/cli/src/commands/install/cmd-install-completion.mts +++ /dev/null @@ -1,84 +0,0 @@ -import { handleInstallCompletion } from './handle-install-completion.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'completion', - description: 'Install bash completion for Socket CLI', - hidden: false, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [NAME=socket] - - Installs bash completion for the Socket CLI. This will: - 1. Source the completion script in your current shell - 2. Add the source command to your ~/.bashrc if it's not already there - - This command will only setup tab completion, nothing else. - - Afterwards you should be able to type \`socket \` and then press tab to - have bash auto-complete/suggest the sub/command or flags. - - Currently only supports bash. - - The optional name argument allows you to enable tab completion on a command - name other than "socket". Mostly for debugging but also useful if you use a - different alias for socket on your system. - - Options - ${getFlagListOutput(config.flags)} - - Examples - - $ ${command} - $ ${command} sd - $ ${command} ./sd - `, -} - -export const cmdInstallCompletion = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const dryRun = !!cli.flags['dryRun'] - const targetName = cli.input[0] || 'socket' - - if (dryRun) { - // Runtime read so tests that mutate process.env['HOME'] pick up changes. - const bashRcPath = `${process.env['HOME']}/.bashrc` - outputDryRunWrite( - bashRcPath, - `install bash completion for "${targetName}"`, - [ - 'Add completion script source command to ~/.bashrc', - 'Enable tab completion in current shell', - ], - ) - return - } - - await handleInstallCompletion(String(targetName)) -} diff --git a/packages/cli/src/commands/install/cmd-install.mts b/packages/cli/src/commands/install/cmd-install.mts deleted file mode 100644 index 5d60b00b1..000000000 --- a/packages/cli/src/commands/install/cmd-install.mts +++ /dev/null @@ -1,24 +0,0 @@ -import { cmdInstallCompletion } from './cmd-install-completion.mts' -import { meowWithSubcommands } from '../../util/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -const description = 'Install Socket CLI tab completion' - -export const cmdInstall: CliSubcommand = { - description, - hidden: false, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} install`, - importMeta, - subcommands: { - completion: cmdInstallCompletion, - }, - }, - { description }, - ) - }, -} diff --git a/packages/cli/src/commands/install/setup-tab-completion.mts b/packages/cli/src/commands/install/setup-tab-completion.mts deleted file mode 100644 index 569206789..000000000 --- a/packages/cli/src/commands/install/setup-tab-completion.mts +++ /dev/null @@ -1,138 +0,0 @@ -import { - appendFileSync, - existsSync, - readFileSync, - writeFileSync, -} from 'node:fs' -import { createRequire } from 'node:module' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' - -import { getCliVersionHash } from '../../env/cli-version-hash.mts' -import { homePath } from '../../constants/paths.mts' -import { getBashrcDetails } from '../../util/cli/completion.mts' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const require = createRequire(import.meta.url) - -import type { CResult } from '../../types.mts' - -export function getTabCompletionScriptRaw(): CResult<string> { - // Resolve the @socketsecurity/cli package root to find the data directory. - // This works whether running from source, installed globally, or via npx/dlx. - let sourcePath: string - try { - const cliPackageJson = require.resolve('@socketsecurity/cli/package.json') - const cliPackageRoot = path.dirname(cliPackageJson) - sourcePath = path.join(cliPackageRoot, 'data', 'socket-completion.bash') - /* c8 ignore start - fallback for source-tree development; require.resolve always succeeds in tests because the workspace package is installed */ - } catch { - sourcePath = path.resolve(__dirname, '../../../data/socket-completion.bash') - } - /* c8 ignore stop */ - - if (!existsSync(sourcePath)) { - return { - ok: false, - message: 'Source not found.', - cause: `Unable to find the source tab completion bash script that Socket should ship. Expected to find it in \`${sourcePath}\` but it was not there.`, - } - } - - return { ok: true, data: readFileSync(sourcePath, 'utf8') } -} - -export async function setupTabCompletion(targetName: string): Promise< - CResult<{ - actions: string[] - bashrcPath: string - bashrcUpdated: boolean - completionCommand: string - foundBashrc: boolean - sourcingCommand: string - targetName: string - targetPath: string - }> -> { - const result = getBashrcDetails(targetName) - if (!result.ok) { - return result - } - - const { completionCommand, sourcingCommand, targetPath, toAddToBashrc } = - result.data - - // Target dir is something like ~/.local/share/socket/settings/completion (linux) - const targetDir = path.dirname(targetPath) - debug(`target: path + dir ${targetPath} ${targetDir}`) - - if (!existsSync(targetDir)) { - debug('create: target dir') - safeMkdirSync(targetDir, { recursive: true }) - } - - updateInstalledTabCompletionScript(targetPath) - - let bashrcUpdated = false - - // Add to ~/.bashrc if not already there - const bashrcPath = homePath ? path.join(homePath, '.bashrc') : '' - - const foundBashrc = Boolean(bashrcPath && existsSync(bashrcPath)) - - if (foundBashrc) { - try { - const content = readFileSync(bashrcPath, 'utf8') - if (!content.includes(sourcingCommand)) { - appendFileSync(bashrcPath, toAddToBashrc) - bashrcUpdated = true - } - } catch { - // File may have been deleted or become unreadable between check and read. - } - } - - return { - ok: true, - data: { - actions: [ - `Installed the tab completion script in ${targetPath}`, - bashrcUpdated - ? 'Added tab completion loader to ~/.bashrc' - : foundBashrc - ? 'Tab completion already found in ~/.bashrc' - : 'No ~/.bashrc found so tab completion was not completely installed', - ], - bashrcPath, - bashrcUpdated, - completionCommand, - foundBashrc, - sourcingCommand, - targetName, - targetPath, - }, - } -} - -export function updateInstalledTabCompletionScript( - targetPath: string, -): CResult<undefined> { - const content = getTabCompletionScriptRaw() - if (!content.ok) { - return content - } - - // When installing set the current package.json version. - // Later, we can call _socket_completion_version to get the installed version. - writeFileSync( - targetPath, - content.data.replaceAll('%SOCKET_VERSION_TOKEN%', getCliVersionHash()), - 'utf8', - ) - - return { ok: true, data: undefined } -} diff --git a/packages/cli/src/commands/json/cmd-json.mts b/packages/cli/src/commands/json/cmd-json.mts deleted file mode 100644 index 6b1457521..000000000 --- a/packages/cli/src/commands/json/cmd-json.mts +++ /dev/null @@ -1,54 +0,0 @@ -import path from 'node:path' - -import { handleCmdJson } from './handle-cmd-json.mts' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -const config = { - commandName: 'json', - description: `Display the \`${SOCKET_JSON}\` that would be applied for target folder`, - hidden: true, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string) => ` - Usage - $ ${command} [options] [CWD=.] - - Display the \`${SOCKET_JSON}\` file that would apply when running relevant commands - in the target directory. - - Examples - $ ${command} - `, -} - -export const cmdJson = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - await handleCmdJson(cwd) -} diff --git a/packages/cli/src/commands/json/output-cmd-json.mts b/packages/cli/src/commands/json/output-cmd-json.mts deleted file mode 100644 index 831d6101b..000000000 --- a/packages/cli/src/commands/json/output-cmd-json.mts +++ /dev/null @@ -1,39 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { safeStatSync } from '@socketsecurity/lib-stable/fs/inspect' -import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { REDACTED } from '../../constants/cli.mts' -import { VITEST } from '../../env/vitest.mts' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { tildify } from '../../util/fs/home-path.mjs' -const logger = getDefaultLogger() - -export async function outputCmdJson(cwd: string) { - logger.info('Target cwd:', VITEST ? REDACTED : tildify(cwd)) - - const sockJsonPath = path.join(cwd, SOCKET_JSON) - const tildeSockJsonPath = VITEST ? REDACTED : tildify(sockJsonPath) - - if (!existsSync(sockJsonPath)) { - logger.fail(`Not found: ${tildeSockJsonPath}`) - process.exitCode = 1 - return - } - - if (!safeStatSync(sockJsonPath)?.isFile()) { - logger.fail( - `This is not a regular file (maybe a directory?): ${tildeSockJsonPath}`, - ) - process.exitCode = 1 - return - } - - logger.success(`This is the contents of ${tildeSockJsonPath}:`) - logger.error('') - - const data = safeReadFileSync(sockJsonPath) - logger.log(data) -} diff --git a/packages/cli/src/commands/login/apply-login.mts b/packages/cli/src/commands/login/apply-login.mts deleted file mode 100644 index 699e5ce97..000000000 --- a/packages/cli/src/commands/login/apply-login.mts +++ /dev/null @@ -1,21 +0,0 @@ -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_ENFORCED_ORGS, -} from '../../constants/config.mts' -import { updateConfigValue } from '../../util/config.mts' -import { invalidateDefaultApiToken } from '../../util/socket/sdk.mts' - -export function applyLogin( - apiToken: string, - enforcedOrgs: string[], - apiBaseUrl: string | undefined, - apiProxy: string | undefined, -) { - updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, enforcedOrgs) - updateConfigValue(CONFIG_KEY_API_TOKEN, apiToken) - updateConfigValue(CONFIG_KEY_API_BASE_URL, apiBaseUrl) - updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy) - invalidateDefaultApiToken() -} diff --git a/packages/cli/src/commands/login/attempt-login.mts b/packages/cli/src/commands/login/attempt-login.mts deleted file mode 100644 index d3d51cd5d..000000000 --- a/packages/cli/src/commands/login/attempt-login.mts +++ /dev/null @@ -1,184 +0,0 @@ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { SOCKET_PUBLIC_API_TOKEN } from '@socketsecurity/lib-stable/constants/socket' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { confirm, password, select } from '@socketsecurity/lib-stable/stdio/prompts' - -import { applyLogin } from './apply-login.mts' -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, -} from '../../constants/config.mts' -import { - getConfigValueOrUndef, - isConfigFromFlag, - updateConfigValue, -} from '../../util/config.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { getEnterpriseOrgs, getOrgSlugs } from '../../util/organization.mts' -import { setupSdk } from '../../util/socket/sdk.mjs' -import { socketDocsLink } from '../../util/terminal/link.mts' -import { setupTabCompletion } from '../install/setup-tab-completion.mts' -import { fetchOrganization } from '../organization/fetch-organization-list.mts' - -import type { Choice } from '@socketsecurity/lib-stable/stdio/prompts' -const logger = getDefaultLogger() - -type OrgChoice = Choice<string> -type OrgChoices = OrgChoice[] - -export async function attemptLogin( - apiBaseUrl: string | undefined, - apiProxy: string | undefined, -) { - apiBaseUrl ??= getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) ?? undefined - apiProxy ??= getConfigValueOrUndef(CONFIG_KEY_API_PROXY) ?? undefined - const apiTokenInput = await password({ - message: `Enter your ${socketDocsLink('/docs/api-keys', 'Socket.dev API token')} (leave blank to use a limited public token)`, - }) - - if (apiTokenInput === undefined) { - logger.fail('Canceled by user') - return { ok: false, message: 'Canceled', cause: 'Canceled by user' } - } - - const apiToken = apiTokenInput || SOCKET_PUBLIC_API_TOKEN - - const sockSdkCResult = await setupSdk({ apiBaseUrl, apiProxy, apiToken }) - if (!sockSdkCResult.ok) { - process.exitCode = 1 - logger.fail(failMsgWithBadge(sockSdkCResult.message, sockSdkCResult.cause)) - return - } - - const sockSdk = sockSdkCResult.data - - const orgsCResult = await fetchOrganization({ - description: 'token verification', - sdk: sockSdk, - }) - if (!orgsCResult.ok) { - process.exitCode = 1 - logger.fail(failMsgWithBadge(orgsCResult.message, orgsCResult.cause)) - return - } - - const { organizations } = orgsCResult.data - - const orgSlugs = getOrgSlugs(organizations) - - if (!orgSlugs.length) { - logger.fail('No organizations found for this account') - return { - ok: false, - message: - 'No organizations found. Please contact Socket support to set up your account.', - } - } - - logger.success(`API token verified: ${joinAnd(orgSlugs)}`) - - const enterpriseOrgs = getEnterpriseOrgs(organizations) - - const enforcedChoices: OrgChoices = enterpriseOrgs.map(org => ({ - name: org['name'] ?? 'undefined', - value: org['id'], - })) - - let enforcedOrgs: string[] = [] - if (enforcedChoices.length > 1) { - const id = await select({ - message: - "Which organization's policies should Socket enforce system-wide?", - choices: [ - ...enforcedChoices, - { - name: 'None', - value: '', - description: 'Pick "None" if this is a personal device', - }, - ], - }) - if (id === undefined) { - logger.fail('Canceled by user') - return { ok: false, message: 'Canceled', cause: 'Canceled by user' } - } - if (id) { - enforcedOrgs = [id] - } - } else if (enforcedChoices.length) { - const [firstChoice] = enforcedChoices - if (firstChoice?.name) { - const shouldEnforce = await confirm({ - message: `Should Socket enforce ${firstChoice.name}'s security policies system-wide?`, - default: true, - }) - if (shouldEnforce === undefined) { - logger.fail('Canceled by user') - return { ok: false, message: 'Canceled', cause: 'Canceled by user' } - } - if (shouldEnforce && firstChoice.value) { - enforcedOrgs = [firstChoice.value] - } - } - } - - const wantToComplete = await select({ - message: 'Would you like to install bash tab completion?', - choices: [ - { - name: 'Yes', - value: true, - description: - 'Sets up tab completion for "socket" in your bash env. If you\'re unsure, this is probably what you want.', - }, - { - name: 'No', - value: false, - description: - 'Will skip tab completion setup. Does not change how Socket works.', - }, - ], - }) - if (wantToComplete === undefined) { - logger.fail('Canceled by user') - return { ok: false, message: 'Canceled', cause: 'Canceled by user' } - } - if (wantToComplete) { - logger.log('') - logger.log('Setting up tab completion...') - const setupCResult = await setupTabCompletion('socket') - if (setupCResult.ok) { - logger.success( - 'Tab completion will be enabled after restarting your terminal', - ) - } else { - logger.fail( - 'Failed to install tab completion script. Try `socket install completion` later.', - ) - } - } - - const defaultOrg = orgSlugs[0]?.trim() - if (defaultOrg) { - updateConfigValue(CONFIG_KEY_DEFAULT_ORG, defaultOrg) - } - - const previousPersistedToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) - try { - applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy) - logger.success( - `API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`, - ) - if (isConfigFromFlag()) { - logger.log('') - logger.warn( - 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the login was not persisted!', - ) - } - } catch { - process.exitCode = 1 - logger.fail('API login failed') - } -} diff --git a/packages/cli/src/commands/login/cmd-login.mts b/packages/cli/src/commands/login/cmd-login.mts deleted file mode 100644 index 16cd2c219..000000000 --- a/packages/cli/src/commands/login/cmd-login.mts +++ /dev/null @@ -1,107 +0,0 @@ -import isInteractive from '@socketregistry/is-interactive/index.cjs' - -import { attemptLogin } from './attempt-login.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { InputError } from '../../util/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface LoginFlags { - apiBaseUrl?: string | undefined - apiProxy?: string | undefined -} - -export const CMD_NAME = 'login' - -const description = 'Setup Socket CLI with an API token and defaults' - -const hidden = false - -export const cmdLogin = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - apiBaseUrl: { - type: 'string', - default: '', - description: 'API server to connect to for login', - }, - apiProxy: { - type: 'string', - default: '', - description: 'Proxy to use when making connection to API server', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Logs into the Socket API by prompting for an API token - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --api-proxy=http://localhost:1234 - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const dryRun = !!cli.flags['dryRun'] - - if (dryRun) { - // Runtime read so tests that mutate process.env['HOME'] pick up changes. - const configPath = `${process.env['HOME']}/.config/socket/config.json` - const changes = [ - 'Prompt for Socket API token', - 'Verify token with Socket API', - 'Save API token to config', - 'Optionally set default organization', - 'Optionally install bash completion', - ] - outputDryRunWrite(configPath, 'authenticate with Socket API', changes) - return - } - - if (!isInteractive()) { - throw new InputError( - 'socket login needs an interactive TTY to prompt for credentials (stdin/stdout is not a TTY); set SOCKET_CLI_API_TOKEN in the environment instead', - ) - } - - const { apiBaseUrl, apiProxy } = cli.flags as unknown as LoginFlags - - await attemptLogin(apiBaseUrl, apiProxy) -} diff --git a/packages/cli/src/commands/logout/cmd-logout.mts b/packages/cli/src/commands/logout/cmd-logout.mts deleted file mode 100644 index 502be7846..000000000 --- a/packages/cli/src/commands/logout/cmd-logout.mts +++ /dev/null @@ -1,102 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { outputDryRunDelete } from '../../util/dry-run/output.mts' -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_ENFORCED_ORGS, -} from '../../constants/config.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { isConfigFromFlag, updateConfigValue } from '../../util/config.mts' -import { invalidateDefaultApiToken } from '../../util/socket/sdk.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -export const CMD_NAME = 'logout' - -const description = 'Socket API logout' - -const hidden = false - -// Helper functions. - -export function applyLogout(): void { - updateConfigValue(CONFIG_KEY_API_TOKEN, undefined) - updateConfigValue(CONFIG_KEY_API_BASE_URL, undefined) - updateConfigValue(CONFIG_KEY_API_PROXY, undefined) - updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, undefined) - invalidateDefaultApiToken() -} - -export function attemptLogout(): void { - try { - applyLogout() - logger.success('Successfully logged out') - if (isConfigFromFlag()) { - logger.log('') - logger.warn( - 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!', - ) - } - } catch { - logger.fail('Failed to complete logout steps') - } -} - -// Command handler. - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string, _config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - Logs out of the Socket API and clears all Socket credentials from disk - - Examples - $ ${command} - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const dryRun = !!cli.flags['dryRun'] - - if (dryRun) { - // Runtime read so tests that mutate process.env['HOME'] pick up changes. - const configPath = `${process.env['HOME']}/.config/socket/config.json` - outputDryRunDelete('Socket API credentials', configPath) - return - } - - attemptLogout() -} - -// Exported command. - -export const cmdLogout = { - description, - hidden, - run, -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-auto.mts b/packages/cli/src/commands/manifest/cmd-manifest-auto.mts deleted file mode 100644 index ccb227684..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-auto.mts +++ /dev/null @@ -1,139 +0,0 @@ -import path from 'node:path' - -import { debugDirNs } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { detectManifestActions } from './detect-manifest-actions.mts' -import { generateAutoManifest } from './generate_auto_manifest.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -const config = { - commandName: 'auto', - description: 'Auto-detect build and attempt to generate manifest file', - hidden: false, - flags: defineFlags({ - ...commonFlags, - verbose: { - type: 'boolean', - default: false, - description: - 'Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - Tries to figure out what language your target repo uses. If it finds a - supported case then it will try to generate the manifest file for that - language with the default or detected settings. - - Note: you can exclude languages from being auto-generated if you don't want - them to. Run \`socket manifest setup\` in the same dir to disable it. - - Examples - - $ ${command} - $ ${command} ./project/foo - `, -} - -export const cmdManifestAuto = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - // Feature request: Pass outputKind to manifest generators for json/md output support. - const { json, markdown, verbose: verboseFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const verbose = !!verboseFlag - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const outputKind = getOutputKind(json, markdown) - - if (verbose) { - logger.group('- ', parentName, config.commandName, ':') - logger.group('- flags:', cli.flags) - logger.groupEnd() - logger.log('- input:', cli.input) - logger.log('- cwd:', cwd) - logger.groupEnd() - } - - const sockJson = readOrDefaultSocketJson(cwd) - - const detected = await detectManifestActions(sockJson, cwd) - debugDirNs('inspect', { detected }) - - if (dryRun) { - if (detected.count > 0) { - outputDryRunExecute( - 'manifest generators', - [cwd], - `auto-detect and generate ${detected.count} manifest file(s)`, - ) - } else { - logger.log('No manifest targets detected in the specified directory.') - } - return - } - - if (!detected.count) { - logger.fail( - 'Was unable to discover any targets for which we can generate manifest files...', - ) - logger.log('') - logger.log( - '- Make sure this script would work with your target build (see `socket manifest --help` for your target).', - ) - logger.log( - '- Make sure to run it from the correct dir (use --cwd to target another dir)', - ) - logger.log('- Make sure the necessary build tools are available (`PATH`)') - process.exitCode = 1 - return - } - - await generateAutoManifest({ - detected, - cwd, - outputKind, - verbose, - }) - - logger.success( - `Finished. Should have attempted to generate manifest files for ${detected.count} targets.`, - ) -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts b/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts deleted file mode 100644 index 73caf7edf..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts +++ /dev/null @@ -1,350 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/sort-source-methods -- `arrayToLower` / `toLower` helpers are kept together at the top (alphabetical anchor for the cdxgen flag mapping below); `run` is the command entry point and lives near its config + cmdManifestCdxgen export, not interleaved with helpers. */ -import terminalLink from 'terminal-link' -import yargsParse from 'yargs-parser' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { isPath } from '@socketsecurity/lib-stable/paths/normalize' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -import { - detectNodejsCdxgenSources, - isNodejsCdxgenType, - runCdxgen, -} from './run-cdxgen.mts' -import { FLAG_HELP } from '../../constants/cli.mjs' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { filterFlags, isHelpFlag } from '../../util/process/cmd.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface CdxgenFlags { - dryRun: boolean -} - -// Technical debt: cdxgen uses yargs for arg parsing internally. Converting to -// Socket CLI's custom meow implementation would provide consistency with other -// commands but requires significant work to map all cdxgen flags and maintain -// compatibility with cdxgen's complex option structure. -export function arrayToLower(arg: string[]): string[] { - return arg.map(toLower) -} -export function toLower(arg: string): string { - return arg.toLowerCase() -} - -// npx @cyclonedx/cdxgen@11.2.7 --help -// -// Options: -// -o, --output Output file. Default bom.json [default: "bom.json"] -// -t, --type Project type. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TYPES for supp -// orted languages/platforms. [array] -// --exclude-type Project types to exclude. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TY -// PES for supported languages/platforms. -// -r, --recurse Recurse mode suitable for mono-repos. Defaults to true. Pass --no-recurse to disable. -// [boolean] [default: true] -// -p, --print Print the SBOM as a table with tree. [boolean] -// -c, --resolve-class Resolve class names for packages. jars only for now. [boolean] -// --deep Perform deep searches for components. Useful while scanning C/C++ apps, live OS and oci i -// mages. [boolean] -// --server-url Dependency track url. Eg: https://deptrack.cyclonedx.io -// --skip-dt-tls-check Skip TLS certificate check when calling Dependency-Track. [boolean] [default: false] -// --api-key Dependency track api key -// --project-group Dependency track project group -// --project-name Dependency track project name. Default use the directory name -// --project-version Dependency track project version [string] [default: ""] -// --project-id Dependency track project id. Either provide the id or the project name and version togeth -// er [string] -// --parent-project-id Dependency track parent project id [string] -// --required-only Include only the packages with required scope on the SBOM. Would set compositions.aggrega -// te to incomplete unless --no-auto-compositions is passed. [boolean] -// --fail-on-error Fail if any dependency extractor fails. [boolean] -// --no-babel Do not use babel to perform usage analysis for JavaScript/TypeScript projects. [boolean] -// --generate-key-and-sign Generate an RSA public/private key pair and then sign the generated SBOM using JSON Web S -// ignatures. [boolean] -// --server Run cdxgen as a server [boolean] -// --server-host Listen address [default: "127.0.0.1"] -// --server-port Listen port [default: "9090"] -// --install-deps Install dependencies automatically for some projects. Defaults to true but disabled for c -// ontainers and oci scans. Use --no-install-deps to disable this feature. -// [boolean] [default: true] -// --validate Validate the generated SBOM using json schema. Defaults to true. Pass --no-validate to di -// sable. [boolean] [default: true] -// --evidence Generate SBOM with evidence for supported languages. [boolean] [default: false] -// --spec-version CycloneDX Specification version to use. Defaults to 1.6 -// [number] [choices: 1.4, 1.5, 1.6, 1.7] [default: 1.6] -// --filter Filter components containing this word in purl or component.properties.value. Multiple va -// lues allowed. [array] -// --only Include components only containing this word in purl. Useful to generate BOM with first p -// arty components alone. Multiple values allowed. [array] -// --author The person(s) who created the BOM. Set this value if you're intending the modify the BOM -// and claim authorship. [array] [default: "OWASP Foundation"] -// --profile BOM profile to use for generation. Default generic. -// [choices: "appsec", "research", "operational", "threat-modeling", "license-compliance", "generic", "machine-learning", -// "ml", "deep-learning", "ml-deep", "ml-tiny"] [default: "generic"] -// --exclude Additional glob pattern(s) to ignore [array] -// --export-proto Serialize and export BOM as protobuf binary. [boolean] [default: false] -// --proto-bin-file Path for the serialized protobuf binary. [default: "bom.cdx"] -// --include-formulation Generate formulation section with git metadata and build tools. Defaults to false. -// [boolean] [default: false] -// --include-crypto Include crypto libraries as components. [boolean] [default: false] -// --standard The list of standards which may consist of regulations, industry or organizational-specif -// ic standards, maturity models, best practices, or any other requirements which can be eva -// luated against or attested to. -// [array] [choices: "asvs-5.0", "asvs-4.0.3", "bsimm-v13", "masvs-2.0.0", "nist_ssdf-1.1", "pcissc-secure-slc-1.1", "scv -// s-1.0.0", "ssaf-DRAFT-2023-11"] -// --json-pretty Pretty-print the generated BOM json. [boolean] [default: false] -// --min-confidence Minimum confidence needed for the identity of a component from 0 - 1, where 1 is 100% con -// fidence. [number] [default: 0] -// --technique Analysis technique to use -// [array] [choices: "auto", "source-code-analysis", "binary-analysis", "manifest-analysis", "hash-comparison", "instrume -// ntation", "filename"] -// --auto-compositions Automatically set compositions when the BOM was filtered. Defaults to true -// [boolean] [default: true] -// -h, --help Show help [boolean] -// -v, --version Show version number [boolean] - -// isSecureMode defined at: -// https://github.com/CycloneDX/cdxgen/blob/v11.2.7/lib/helpers/utils.js#L66 -// const isSecureMode = -// ['true', '1'].includes(process.env?.CDXGEN_SECURE_MODE) || -// process.env?.NODE_OPTIONS?.includes('--permission') - -// Yargs CDXGEN configuration defined at: -// https://github.com/CycloneDX/cdxgen/blob/v11.2.7/bin/cdxgen.js#L64 -const yargsConfig = { - configuration: { - 'camel-case-expansion': false, - 'greedy-arrays': false, - 'parse-numbers': false, - 'populate--': true, - 'short-option-groups': false, - 'strip-aliased': true, - 'unknown-options-as-args': true, - }, - coerce: { - 'exclude-type': arrayToLower, - 'feature-flags': arrayToLower, - filter: arrayToLower, - only: arrayToLower, - profile: toLower, - standard: arrayToLower, - technique: arrayToLower, - type: arrayToLower, - }, - default: { - type: ['js'], - }, - alias: { - help: ['h'], - output: ['o'], - print: ['p'], - recurse: ['r'], - 'resolve-class': ['c'], - type: ['t'], - version: ['v'], - }, - array: [ - { key: 'author', type: 'string' }, - { key: 'exclude', type: 'string' }, - { key: 'exclude-type', type: 'string' }, - { key: 'feature-flags', type: 'string' }, // hidden - { key: 'filter', type: 'string' }, - { key: 'only', type: 'string' }, - { key: 'standard', type: 'string' }, - { key: 'technique', type: 'string' }, - { key: 'type', type: 'string' }, - ], - boolean: [ - 'auto-compositions', - 'babel', - 'banner', // hidden - 'deep', - 'evidence', - 'export-proto', - 'fail-on-error', - 'generate-key-and-sign', - 'help', - 'include-crypto', - 'include-formulation', - 'install-deps', - 'json-pretty', - 'print', - 'recurse', - 'required-only', - 'resolve-class', - 'skip-dt-tls-check', - 'server', - 'validate', - 'version', - ], - string: [ - 'api-key', - 'data-flow-slices-file', // hidden - 'deps-slices-file', // hidden - 'evinse-output', // hidden - 'lifecycle', - 'min-confidence', // number - 'openapi-spec-file', // hidden - 'output', - 'parent-project-id', - 'profile', - 'project-group', - 'project-name', - 'project-version', - 'project-id', - 'proto-bin-file', - 'reachables-slices-file', // hidden - 'semantics-slices-file', // hidden - 'server-host', - 'server-port', - 'server-url', - 'spec-version', // number - 'usages-slices-file', // hidden - ], -} - -const config = { - commandName: 'cdxgen', - description: 'Run cdxgen for SBOM generation', - hidden: false, - // Stub out flags and help since cdxgen uses yargs internally. - // Socket CLI uses custom meow - see note above about conversion complexity. - flags: {}, - help: () => '', -} - -export const cmdManifestCdxgen = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: CliCommandContext, -): Promise<void> { - const { parentName } = { - __proto__: null, - ...context, - } as CliCommandContext - const cli = meowOrExit({ - // Don't let meow take over --help. - argv: argv.filter(a => !isHelpFlag(a)), - config, - importMeta, - parentName, - }) - - const { dryRun } = cli.flags as unknown as CdxgenFlags - - // Filter Socket flags from argv but keep --no-banner and --help for cdxgen. - const argsToProcess = filterFlags(argv, { ...commonFlags, ...outputFlags }, [ - '--no-banner', - FLAG_HELP, - '-h', - ]) - const yargv = { - ...yargsParse(argsToProcess as string[], yargsConfig), - // eslint-disable-next-line typescript-eslint/no-explicit-any -- yargs-parser returns a dynamic flag bag; downstream code reads .help/.lifecycle/.output/.type/_/--. - } as any - - const pathArgs: string[] = [] - const unknowns: string[] = [] - const positionals = yargv._ as string[] - for (let i = 0, { length } = positionals; i < length; i += 1) { - const a = positionals[i]! - if (isPath(a)) { - pathArgs.push(a) - } else { - unknowns.push(a) - } - } - - yargv._ = pathArgs - - const { length: unknownsCount } = unknowns - if (unknownsCount) { - // Use exit status of 2 to indicate incorrect usage, generally invalid - // options or missing arguments. - // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html - process.exitCode = 2 - logger.fail( - `Unknown ${pluralize('argument', { count: unknownsCount })}: ${joinAnd(unknowns)}`, - ) - return - } - - if (dryRun) { - const cdxgenArgs = argsToProcess.filter( - arg => arg !== '--dry-run' && !arg.startsWith('--dry-run='), - ) - outputDryRunExecute('cdxgen', cdxgenArgs, 'SBOM generation') - return - } - - // Change defaults when not passing the --help flag. - if (!yargv.help) { - // Make 'lifecycle' default to 'pre-build', which also sets 'install-deps' to `false`, - // to avoid arbitrary code execution on the cdxgen scan. - // https://github.com/CycloneDX/cdxgen/issues/1328 - const lifecycleWasDefaulted = yargv.lifecycle === undefined - if (lifecycleWasDefaulted) { - yargv.lifecycle = 'pre-build' - yargv['install-deps'] = false - logger.info( - `Setting cdxgen --lifecycle to "${yargv.lifecycle}" to avoid arbitrary code execution on this scan.\n Pass "--lifecycle build" to generate a BOM consisting of information obtained during the build process.\n See cdxgen ${terminalLink( - 'BOM lifecycles documentation', - 'https://cyclonedx.github.io/cdxgen/#/ADVANCED?id=bom-lifecycles', - )} for more details.\n`, - ) - } - if (yargv.output === undefined) { - yargv.output = 'socket-cdx.json' - } - - // Hard gate: in the default pre-build + install-deps=false path, cdxgen - // needs either a lockfile or an installed node_modules/ to produce any - // Node.js components. Without both, it emits a valid CycloneDX doc with - // "components": []. Refuse with an actionable error instead of shipping - // an empty SBOM. - if ( - lifecycleWasDefaulted && - isNodejsCdxgenType(yargv.type) && - !yargv['filter'] && - !yargv['only'] - ) { - const { hasLockfile, hasNodeModules } = await detectNodejsCdxgenSources() - if (!hasLockfile && !hasNodeModules) { - process.exitCode = 2 - logger.fail( - `socket cdxgen found no lockfile (pnpm-lock.yaml / package-lock.json / yarn.lock) or node_modules/ at or above ${process.cwd()}.\n` + - ' The default --lifecycle pre-build with --no-install-deps needs one of them to resolve components; otherwise the SBOM ships with "components": [].\n' + - ' Fix: install dependencies first (e.g. `npm install`, `pnpm install`, `yarn install`), or re-run with `--lifecycle build` to let cdxgen resolve during the build.', - ) - return - } - } - } - - process.exitCode = 1 - - const { spawnPromise } = await runCdxgen(yargv) - - // Wait for the spawn promise to resolve and handle the result. - const result = await spawnPromise - if (result.signal) { - process.kill(process.pid, result.signal) - } else if (typeof result.code === 'number') { - process.exit(result.code) - } -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-conda.mts b/packages/cli/src/commands/manifest/cmd-manifest-conda.mts deleted file mode 100644 index a257681b0..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-conda.mts +++ /dev/null @@ -1,222 +0,0 @@ -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { handleManifestConda } from './handle-manifest-conda.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mjs' -import { - ENVIRONMENT_YAML, - ENVIRONMENT_YML, - REQUIREMENTS_TXT, -} from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface CondaFlags { - dryRun: boolean - file: string - json: boolean - markdown: boolean - out: string - stdin: boolean | undefined - stdout: boolean | undefined - verbose: boolean | undefined -} - -const config = { - commandName: 'conda', - description: `[beta] Convert a Conda ${ENVIRONMENT_YML} file to a python ${REQUIREMENTS_TXT}`, - hidden: false, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - file: { - type: 'string', - default: '', - description: `Input file name (by default for Conda this is "${ENVIRONMENT_YML}"), relative to cwd`, - }, - stdin: { - type: 'boolean', - description: 'Read the input from stdin (supersedes --file)', - }, - out: { - type: 'string', - default: '', - description: 'Output path (relative to cwd)', - }, - stdout: { - type: 'boolean', - description: `Print resulting ${REQUIREMENTS_TXT} to stdout (supersedes --out)`, - }, - verbose: { - type: 'boolean', - description: 'Print debug messages', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Warning: While we don't support Conda necessarily, this tool extracts the pip - block from an ${ENVIRONMENT_YML} and outputs it as a ${REQUIREMENTS_TXT} - which you can scan as if it were a PyPI package. - - USE AT YOUR OWN RISK - - Note: FILE can be a dash (-) to indicate stdin. This way you can pipe the - contents of a file to have it processed. - - Options - ${getFlagListOutput(config.flags)} - - Examples - - $ ${command} - $ ${command} ./project/foo --file ${ENVIRONMENT_YAML} - `, -} - -export const cmdManifestConda = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { dryRun, json, markdown } = cli.flags as unknown as CondaFlags - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const sockJson = readOrDefaultSocketJson(cwd) - - let { - file: filename, - out, - stdin, - stdout, - verbose, - } = cli.flags as unknown as CondaFlags - - // Set defaults for any flag/arg that is not given. Check socket.json first. - if ( - stdin === undefined && - sockJson.defaults?.manifest?.conda?.stdin !== undefined - ) { - stdin = sockJson.defaults?.manifest?.conda?.stdin - logger.info(`Using default --stdin from ${SOCKET_JSON}:`, stdin) - } - if (stdin) { - filename = '-' - } else if (!filename) { - if (sockJson.defaults?.manifest?.conda?.infile) { - filename = sockJson.defaults?.manifest?.conda?.infile - logger.info(`Using default --file from ${SOCKET_JSON}:`, filename) - } else { - filename = ENVIRONMENT_YML - } - } - if ( - stdout === undefined && - sockJson.defaults?.manifest?.conda?.stdout !== undefined - ) { - stdout = sockJson.defaults?.manifest?.conda?.stdout - logger.info(`Using default --stdout from ${SOCKET_JSON}:`, stdout) - } - if (stdout) { - out = '-' - } else if (!out) { - if (sockJson.defaults?.manifest?.conda?.outfile) { - out = sockJson.defaults?.manifest?.conda?.outfile - logger.info(`Using default --out from ${SOCKET_JSON}:`, out) - } else { - out = REQUIREMENTS_TXT - } - } - if ( - verbose === undefined && - sockJson.defaults?.manifest?.conda?.verbose !== undefined - ) { - verbose = sockJson.defaults?.manifest?.conda?.verbose - logger.info(`Using default --verbose from ${SOCKET_JSON}:`, verbose) - } else if (verbose === undefined) { - verbose = false - } - - if (verbose) { - logger.group('- ', parentName, config.commandName, ':') - logger.group('- flags:', cli.flags) - logger.groupEnd() - logger.log('- target:', cwd) - logger.log('- output:', out) - logger.groupEnd() - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: cli.input.length <= 1, - message: 'Can only accept one DIR (make sure to escape spaces!)', - fail: `received ${cli.input.length}`, - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - ) - if (!wasValidInput) { - return - } - - logger.warn( - 'Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk.', - ) - - if (dryRun) { - outputDryRunExecute( - 'conda converter', - [filename, out], - `convert Conda ${ENVIRONMENT_YML} to ${REQUIREMENTS_TXT}`, - ) - return - } - - await handleManifestConda({ - cwd, - filename, - out, - outputKind, - verbose, - }) -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-gradle.mts b/packages/cli/src/commands/manifest/cmd-manifest-gradle.mts deleted file mode 100644 index 6c86043f2..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-gradle.mts +++ /dev/null @@ -1,210 +0,0 @@ -import path from 'node:path' - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { convertGradleToMaven } from './convert-gradle-to-maven.mts' -import { outputManifest } from './output-manifest.mts' -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface GradleFlags { - bin: string | undefined - gradleOpts: string | undefined - verbose: boolean | undefined -} - -const config = { - commandName: 'gradle', - description: - '[beta] Use Gradle to generate a manifest file (`pom.xml`) for a Gradle/Java/Kotlin/etc project', - hidden: false, - flags: defineFlags({ - ...commonFlags, - bin: { - type: 'string', - description: 'Location of gradlew binary to use, default: CWD/gradlew', - }, - gradleOpts: { - type: 'string', - description: - 'Additional options to pass on to ./gradlew, see `./gradlew --help`', - }, - verbose: { - type: 'boolean', - description: 'Print debug messages', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - Uses gradle, preferably through your local project \`gradlew\`, to generate a - \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the - global \`gradle\` binary but that may not work (hard to predict). - - The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or - or ${REQUIREMENTS_TXT} for PyPi), but specifically for Maven, which is Java's - dependency repository. Languages like Kotlin and Scala piggy back on it too. - - There are some caveats with the gradle to \`pom.xml\` conversion: - - - each task will generate its own xml file and by default it generates one xml - for every task. (This may be a good thing!) - - - it's possible certain features don't translate well into the xml. If you - think something is missing that could be supported please reach out. - - - it works with your \`gradlew\` from your repo and local settings and config - - Support is beta. Please report issues or give us feedback on what's missing. - - Examples - - $ ${command} . - $ ${command} --bin=../gradlew . - `, -} - -export const cmdManifestGradle = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json = false, markdown = false } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - // Feature request: Pass outputKind to convertGradleToMaven for json/md output support. - const outputKind = getOutputKind(json, markdown) - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const sockJson = readOrDefaultSocketJson(cwd) - - debug( - `override: ${SOCKET_JSON} gradle: ${sockJson?.defaults?.manifest?.gradle}`, - ) - - let { bin, gradleOpts, verbose } = cli.flags as unknown as GradleFlags - - // Set defaults for any flag/arg that is not given. Check socket.json first. - if (!bin) { - if (sockJson.defaults?.manifest?.gradle?.bin) { - bin = sockJson.defaults?.manifest?.gradle?.bin - logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) - } else { - bin = path.join(cwd, 'gradlew') - } - } - if (!gradleOpts) { - if (sockJson.defaults?.manifest?.gradle?.gradleOpts) { - gradleOpts = sockJson.defaults?.manifest?.gradle?.gradleOpts - logger.info( - `Using default --gradle-opts from ${SOCKET_JSON}:`, - gradleOpts, - ) - } else { - gradleOpts = '' - } - } - if (verbose === undefined) { - if (sockJson.defaults?.manifest?.gradle?.verbose !== undefined) { - verbose = sockJson.defaults?.manifest?.gradle?.verbose - logger.info(`Using default --verbose from ${SOCKET_JSON}:`, verbose) - } else { - verbose = false - } - } - - if (verbose) { - logger.group('- ', parentName, config.commandName, ':') - logger.group('- flags:', cli.flags) - logger.groupEnd() - logger.log('- input:', cli.input) - logger.groupEnd() - } - - // Note: stdin input not supported. Gradle manifest generation requires a directory - // context with build files (build.gradle, settings.gradle, etc.) that can't be - // meaningfully provided via stdin. - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: cli.input.length <= 1, - message: 'Can only accept one DIR (make sure to escape spaces!)', - fail: `received ${cli.input.length}`, - }) - if (!wasValidInput) { - return - } - - if (verbose) { - logger.group() - logger.info('- cwd:', cwd) - logger.info('- gradle bin:', bin) - logger.groupEnd() - } - - if (dryRun) { - const args = [cwd] - if (bin) { - args.push('--bin', String(bin)) - } - if (gradleOpts) { - args.push('--gradle-opts', String(gradleOpts)) - } - outputDryRunExecute('gradlew', args, 'generate pom.xml from Gradle project') - return - } - - const result = await convertGradleToMaven({ - bin: String(bin), - cwd, - gradleOpts: String(gradleOpts || '') - .split(' ') - .map(s => s.trim()) - .filter(Boolean), - outputKind, - verbose: Boolean(verbose), - }) - - // In text mode, output is already handled by convertGradleToMaven. - // For json/markdown modes, we need to call the output helper. - if (outputKind !== 'text') { - await outputManifest(result, outputKind, '-') - } -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-kotlin.mts b/packages/cli/src/commands/manifest/cmd-manifest-kotlin.mts deleted file mode 100644 index c6b50d97b..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-kotlin.mts +++ /dev/null @@ -1,215 +0,0 @@ -import path from 'node:path' - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { convertGradleToMaven } from './convert-gradle-to-maven.mts' -import { outputManifest } from './output-manifest.mts' -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface KotlinFlags { - bin: string | undefined - gradleOpts: string | undefined - verbose: boolean | undefined -} - -// Design note: Gradle language commands (gradle, kotlin, scala) share similar code -// but maintain separate commands for clarity. This allows language-specific help text -// and clearer user experience (e.g., "socket manifest kotlin" shows Kotlin-specific -// help rather than generic gradle help). Future refactoring could extract shared logic -// while preserving separate command interfaces. -const config = { - commandName: 'kotlin', - description: - '[beta] Use Gradle to generate a manifest file (`pom.xml`) for a Kotlin project', - hidden: false, - flags: defineFlags({ - ...commonFlags, - bin: { - type: 'string', - description: 'Location of gradlew binary to use, default: CWD/gradlew', - }, - gradleOpts: { - type: 'string', - description: - 'Additional options to pass on to ./gradlew, see `./gradlew --help`', - }, - verbose: { - type: 'boolean', - description: 'Print debug messages', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - Uses gradle, preferably through your local project \`gradlew\`, to generate a - \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the - global \`gradle\` binary but that may not work (hard to predict). - - The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or - or ${REQUIREMENTS_TXT} for PyPi), but specifically for Maven, which is Java's - dependency repository. Languages like Kotlin and Scala piggy back on it too. - - There are some caveats with the gradle to \`pom.xml\` conversion: - - - each task will generate its own xml file and by default it generates one xml - for every task. (This may be a good thing!) - - - it's possible certain features don't translate well into the xml. If you - think something is missing that could be supported please reach out. - - - it works with your \`gradlew\` from your repo and local settings and config - - Support is beta. Please report issues or give us feedback on what's missing. - - Examples - - $ ${command} . - $ ${command} --bin=../gradlew . - `, -} - -export const cmdManifestKotlin = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json = false, markdown = false } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - // Feature request: Pass outputKind to convertGradleToMaven for json/md output support. - const outputKind = getOutputKind(json, markdown) - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const sockJson = readOrDefaultSocketJson(cwd) - - debug( - `override: ${SOCKET_JSON} gradle: ${sockJson?.defaults?.manifest?.gradle}`, - ) - - let { bin, gradleOpts, verbose } = cli.flags as unknown as KotlinFlags - - // Set defaults for any flag/arg that is not given. Check socket.json first. - if (!bin) { - if (sockJson.defaults?.manifest?.gradle?.bin) { - bin = sockJson.defaults?.manifest?.gradle?.bin - logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) - } else { - bin = path.join(cwd, 'gradlew') - } - } - if (!gradleOpts) { - if (sockJson.defaults?.manifest?.gradle?.gradleOpts) { - gradleOpts = sockJson.defaults?.manifest?.gradle?.gradleOpts - logger.info( - `Using default --gradle-opts from ${SOCKET_JSON}:`, - gradleOpts, - ) - } else { - gradleOpts = '' - } - } - if (verbose === undefined) { - if (sockJson.defaults?.manifest?.gradle?.verbose !== undefined) { - verbose = sockJson.defaults?.manifest?.gradle?.verbose - logger.info(`Using default --verbose from ${SOCKET_JSON}:`, verbose) - } else { - verbose = false - } - } - - if (verbose) { - logger.group('- ', parentName, config.commandName, ':') - logger.group('- flags:', cli.flags) - logger.groupEnd() - logger.log('- input:', cli.input) - logger.groupEnd() - } - - // Note: stdin input not supported. Gradle manifest generation requires a directory - // context with build files (build.gradle.kts, settings.gradle.kts, etc.) that can't be - // meaningfully provided via stdin. - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: cli.input.length <= 1, - message: 'Can only accept one DIR (make sure to escape spaces!)', - fail: `received ${cli.input.length}`, - }) - if (!wasValidInput) { - return - } - - if (verbose) { - logger.group() - logger.info('- cwd:', cwd) - logger.info('- gradle bin:', bin) - logger.groupEnd() - } - - if (dryRun) { - const args = [cwd] - if (bin) { - args.push('--bin', String(bin)) - } - if (gradleOpts) { - args.push('--gradle-opts', String(gradleOpts)) - } - outputDryRunExecute('gradlew', args, 'generate pom.xml from Kotlin project') - return - } - - const result = await convertGradleToMaven({ - bin: String(bin), - cwd, - gradleOpts: String(gradleOpts || '') - .split(' ') - .map(s => s.trim()) - .filter(Boolean), - outputKind, - verbose: Boolean(verbose), - }) - - // In text mode, output is already handled by convertGradleToMaven. - // For json/markdown modes, we need to call the output helper. - if (outputKind !== 'text') { - await outputManifest(result, outputKind, '-') - } -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-scala.mts b/packages/cli/src/commands/manifest/cmd-manifest-scala.mts deleted file mode 100644 index 314506b93..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-scala.mts +++ /dev/null @@ -1,244 +0,0 @@ -import path from 'node:path' - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { convertSbtToMaven } from './convert-sbt-to-maven.mts' -import { outputManifest } from './output-manifest.mts' -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface ScalaFlags { - bin: string | undefined - out: string | undefined - sbtOpts: string | undefined - stdout: boolean | undefined - verbose: boolean | undefined -} - -const config = { - commandName: 'scala', - description: - "[beta] Generate a manifest file (`pom.xml`) from Scala's `build.sbt` file", - hidden: false, - flags: defineFlags({ - ...commonFlags, - bin: { - type: 'string', - description: 'Location of sbt binary to use', - }, - out: { - type: 'string', - description: - 'Path of output file; where to store the resulting manifest, see also --stdout', - }, - stdout: { - type: 'boolean', - description: 'Print resulting pom.xml to stdout (supersedes --out)', - }, - sbtOpts: { - type: 'string', - description: 'Additional options to pass on to sbt, as per `sbt --help`', - }, - verbose: { - type: 'boolean', - description: 'Print debug messages', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file. - This xml file is the dependency manifest (like a package.json - for Node.js or ${REQUIREMENTS_TXT} for PyPi), but specifically for Scala. - - There are some caveats with \`build.sbt\` to \`pom.xml\` conversion: - - - the xml is exported as socket.pom.xml as to not confuse existing build tools - but it will first hit your /target/sbt<version> folder (as a different name) - - - the pom.xml format (standard by Scala) does not support certain sbt features - - \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\` - - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html - - - it uses your sbt settings and local configuration verbatim - - - it can only export one target per run, so if you have multiple targets like - development and production, you must run them separately. - - You can specify --bin to override the path to the \`sbt\` binary to invoke. - - Support is beta. Please report issues or give us feedback on what's missing. - - This is only for SBT. If your Scala setup uses gradle, please see the help - sections for \`socket manifest gradle\` or \`socket cdxgen\`. - - Examples - - $ ${command} - $ ${command} ./proj --bin=/usr/bin/sbt --file=boot.sbt - `, -} - -export const cmdManifestScala = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json = false, markdown = false } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - // Feature request: Pass outputKind to convertSbtToMaven for json/md output support. - const outputKind = getOutputKind(json, markdown) - - const sockJson = readOrDefaultSocketJson(cwd) - - debug(`override: ${SOCKET_JSON} sbt: ${sockJson?.defaults?.manifest?.sbt}`) - - let { bin, out, sbtOpts, stdout, verbose } = - cli.flags as unknown as ScalaFlags - - // Set defaults for any flag/arg that is not given. Check socket.json first. - if (!bin) { - if (sockJson.defaults?.manifest?.sbt?.bin) { - bin = sockJson.defaults?.manifest?.sbt?.bin - logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) - } else { - bin = 'sbt' - } - } - if ( - stdout === undefined && - sockJson.defaults?.manifest?.sbt?.stdout !== undefined - ) { - stdout = sockJson.defaults?.manifest?.sbt?.stdout - logger.info(`Using default --stdout from ${SOCKET_JSON}:`, stdout) - } - if (stdout) { - out = '-' - } else if (!out) { - if (sockJson.defaults?.manifest?.sbt?.outfile) { - out = sockJson.defaults?.manifest?.sbt?.outfile - logger.info(`Using default --out from ${SOCKET_JSON}:`, out) - } else { - out = './socket.pom.xml' - } - } - if (!sbtOpts) { - if (sockJson.defaults?.manifest?.sbt?.sbtOpts) { - sbtOpts = sockJson.defaults?.manifest?.sbt?.sbtOpts - logger.info(`Using default --sbt-opts from ${SOCKET_JSON}:`, sbtOpts) - } else { - sbtOpts = '' - } - } - if ( - verbose === undefined && - sockJson.defaults?.manifest?.sbt?.verbose !== undefined - ) { - verbose = sockJson.defaults?.manifest?.sbt?.verbose - logger.info(`Using default --verbose from ${SOCKET_JSON}:`, verbose) - } else if (verbose === undefined) { - verbose = false - } - - if (verbose) { - logger.group('- ', parentName, config.commandName, ':') - logger.group('- flags:', cli.flags) - logger.groupEnd() - logger.log('- input:', cli.input) - logger.groupEnd() - } - - // Note: stdin input not supported. SBT manifest generation requires a directory - // context with build files (build.sbt, project/, etc.) that can't be meaningfully - // provided via stdin. - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: cli.input.length <= 1, - message: 'Can only accept one DIR (make sure to escape spaces!)', - fail: `received ${cli.input.length}`, - }) - if (!wasValidInput) { - return - } - - if (verbose) { - logger.group() - logger.log('- target:', cwd) - logger.log('- sbt bin:', bin) - logger.log('- out:', out) - logger.groupEnd() - } - - if (dryRun) { - const args = [cwd] - if (bin) { - args.push('--bin', String(bin)) - } - if (out) { - args.push('--out', String(out)) - } - if (sbtOpts) { - args.push('--sbt-opts', String(sbtOpts)) - } - outputDryRunExecute('sbt', args, 'generate pom.xml from Scala project') - return - } - - const result = await convertSbtToMaven({ - bin: String(bin), - cwd: cwd, - out: String(out), - outputKind, - sbtOpts: String(sbtOpts) - .split(' ') - .map(s => s.trim()) - .filter(Boolean), - verbose: Boolean(verbose), - }) - - // In text mode, output is already handled by convertSbtToMaven. - // For json/markdown modes, we need to call the output helper. - if (outputKind !== 'text') { - await outputManifest(result, outputKind, String(out)) - } -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest-setup.mts b/packages/cli/src/commands/manifest/cmd-manifest-setup.mts deleted file mode 100644 index da7289589..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest-setup.mts +++ /dev/null @@ -1,99 +0,0 @@ -import path from 'node:path' - -import { handleManifestSetup } from './handle-manifest-setup.mts' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'setup', - description: - 'Start interactive configurator to customize default flag values for `socket manifest` in this dir', - hidden: false, - flags: defineFlags({ - ...commonFlags, - defaultOnReadError: { - type: 'boolean', - description: `If reading the ${SOCKET_JSON} fails, just use a default config? Warning: This might override the existing json file!`, - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - This command will try to detect all supported ecosystems in given CWD. Then - it starts a configurator where you can setup default values for certain flags - when creating manifest files in that dir. These configuration details are - then stored in a local \`${SOCKET_JSON}\` file (which you may or may not commit - to the repo). Next time you run \`socket manifest ...\` it will load this - json file and any flags which are not explicitly set in the command but which - have been registered in the json file will get the default value set to that - value you stored rather than the hardcoded defaults. - - This helps with for example when your build binary is in a particular path - or when your build tool needs specific opts and you don't want to specify - them when running the command every time. - - You can also disable manifest generation for certain ecosystems. - - This generated configuration file will only be used locally by the CLI. You - can commit it to the repo (useful for collaboration) or choose to add it to - your .gitignore all the same. Only this CLI will use it. - - Examples - $ ${command} - $ ${command} ./proj - `, -} - -export const cmdManifestSetup = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { defaultOnReadError = false } = cli.flags - const dryRun = !!cli.flags['dryRun'] - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - if (dryRun) { - const socketJsonPath = path.join(cwd, SOCKET_JSON) - outputDryRunWrite( - socketJsonPath, - 'create or update manifest configuration', - [ - 'Detect supported ecosystems', - 'Configure manifest generation defaults', - 'Enable/disable specific ecosystems', - ], - ) - return - } - - await handleManifestSetup(cwd, Boolean(defaultOnReadError)) -} diff --git a/packages/cli/src/commands/manifest/cmd-manifest.mts b/packages/cli/src/commands/manifest/cmd-manifest.mts deleted file mode 100644 index 742a57882..000000000 --- a/packages/cli/src/commands/manifest/cmd-manifest.mts +++ /dev/null @@ -1,33 +0,0 @@ -import { cmdManifestAuto } from './cmd-manifest-auto.mts' -import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts' -import { cmdManifestConda } from './cmd-manifest-conda.mts' -import { cmdManifestGradle } from './cmd-manifest-gradle.mts' -import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' -import { cmdManifestScala } from './cmd-manifest-scala.mts' -import { cmdManifestSetup } from './cmd-manifest-setup.mts' -import { defineSubcommandGroup } from '../../util/cli/define-subcommand-group.mts' - -const description = 'Generate a dependency manifest for certain ecosystems' - -export const cmdManifest = defineSubcommandGroup({ - name: 'manifest', - description, - hidden: false, - passCommonFlags: true, - subcommands: { - auto: cmdManifestAuto, - cdxgen: cmdManifestCdxgen, - conda: cmdManifestConda, - gradle: cmdManifestGradle, - kotlin: cmdManifestKotlin, - scala: cmdManifestScala, - setup: cmdManifestSetup, - }, - aliases: { - yolo: { - description, - hidden: true, - argv: ['auto'], - }, - }, -}) diff --git a/packages/cli/src/commands/manifest/convert-conda-to-requirements.mts b/packages/cli/src/commands/manifest/convert-conda-to-requirements.mts deleted file mode 100644 index 0b3791851..000000000 --- a/packages/cli/src/commands/manifest/convert-conda-to-requirements.mts +++ /dev/null @@ -1,176 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { stripAnsi } from '@socketsecurity/lib-stable/ansi/strip' - -import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() - -export async function convertCondaToRequirements( - filename: string, - cwd: string, - verbose: boolean, -): Promise<CResult<{ content: string; pip: string }>> { - let content: string - if (filename === '-') { - if (verbose) { - logger.info('[VERBOSE] reading input from stdin') - } - - const strings: string[] = [] - content = await new Promise((resolve, reject) => { - const cleanup = () => { - process.stdin.off('data', dataHandler) - process.stdin.off('end', endHandler) - process.stdin.off('error', errorHandler) - process.stdin.off('close', closeHandler) - } - - const dataHandler = (chunk: Buffer) => { - strings.push(chunk.toString()) - } - - const endHandler = () => { - cleanup() - resolve(prepareContent(strings.join(''))) - } - - const errorHandler = (e: Error) => { - cleanup() - if (verbose) { - logger.error('Unexpected error while reading from stdin:', e) - } - reject(e) - } - - const closeHandler = () => { - cleanup() - if (strings.length) { - if (verbose) { - logger.error( - 'warning: stdin closed explicitly with some data received', - ) - } - resolve(prepareContent(strings.join(''))) - } else { - if (verbose) { - logger.error('stdin closed explicitly without data received') - } - reject(new Error('No data received from stdin')) - } - } - - process.stdin.on('data', dataHandler) - process.stdin.on('end', endHandler) - process.stdin.on('error', errorHandler) - process.stdin.on('close', closeHandler) - }) - - if (!content) { - return { - ok: false, - message: 'Manifest Generation Failed', - cause: 'No data received from stdin', - } - } - } else { - const filepath = path.join(cwd, filename) - - if (verbose) { - logger.info(`[VERBOSE] target: ${filepath}`) - } - - if (!existsSync(filepath)) { - return { - ok: false, - message: 'Manifest Generation Failed', - cause: `The file was not found at ${filepath}`, - } - } - - content = readFileSync(filepath, 'utf8') - - if (!content) { - return { - ok: false, - message: 'Manifest Generation Failed', - cause: `File at ${filepath} is empty`, - } - } - } - - return { - ok: true, - data: { - content, - pip: convertCondaToRequirementsFromInput(content), - }, - } -} - -// Just extract the first pip block, if one exists at all. -export function convertCondaToRequirementsFromInput(input: string): string { - let collecting = false - let delim = '-' - let indent = '' - const keeping: string[] = [] - for (const line of input.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - // Ignore empty lines. - continue - } - if (collecting) { - if (line.startsWith('#')) { - // Ignore comment lines (keep?). - continue - } - if (line.startsWith(delim)) { - // In this case we have a line with the same indentation as the - // `- pip:` line, so we have reached the end of the pip block. - break - } - if (!indent) { - // Store the indentation of the block. - if (trimmed.startsWith('-') && line.includes('-')) { - const parts = line.split('-') - /* c8 ignore start - String.split always returns ≥1 element */ - if (!parts.length) { - break - } - /* c8 ignore stop */ - indent = `${parts[0]}-` - if (indent.length <= delim.length) { - // The first line after the `pip:` line does not indent further - // than that so the block is empty? - break - } - } - } - if (line.startsWith(indent)) { - keeping.push(line.slice(indent.length).trim()) - } else { - // Unexpected input. bail. - break - } - } - // Note: the line may end with a line comment so don't === it. - else if (trimmed.startsWith('- pip:') && line.includes('-')) { - const parts = line.split('-') - /* c8 ignore start - String.split always returns ≥1 element */ - if (!parts.length) { - continue - } - /* c8 ignore stop */ - delim = `${parts[0]}-` - collecting = true - } - } - - return prepareContent(keeping.join('\n')) -} - -export function prepareContent(content: string): string { - return stripAnsi(content.trim()) -} diff --git a/packages/cli/src/commands/manifest/convert-gradle-to-maven.mts b/packages/cli/src/commands/manifest/convert-gradle-to-maven.mts deleted file mode 100644 index c744bbd2d..000000000 --- a/packages/cli/src/commands/manifest/convert-gradle-to-maven.mts +++ /dev/null @@ -1,203 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { distPath } from '../../constants/paths.mjs' - -import type { ManifestResult } from './output-manifest.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function convertGradleToMaven({ - bin, - cwd, - gradleOpts, - outputKind = 'text', - verbose, -}: { - bin: string - cwd: string - gradleOpts: string[] - outputKind?: OutputKind | undefined - verbose: boolean -}): Promise<CResult<ManifestResult>> { - // Note: Resolve bin relative to cwd (or use absolute path if provided). - // We don't resolve against $PATH since gradlew is typically a local wrapper script. - // Users can provide absolute paths if they need to reference system-wide installations. - const rBin = path.resolve(cwd, bin) - const binExists = existsSync(rBin) - const cwdExists = existsSync(cwd) - - // Only show logging in text mode. - const isTextMode = outputKind === 'text' - - if (isTextMode) { - logger.group('gradle2maven:') - logger.info(`- executing: \`${rBin}\``) - if (!binExists) { - logger.warn( - 'Warning: It appears the executable could not be found. An error might be printed later because of that.', - ) - } - logger.info(`- src dir: \`${cwd}\``) - if (!cwdExists) { - logger.warn( - 'Warning: It appears the src dir could not be found. An error might be printed later because of that.', - ) - } - logger.groupEnd() - } - - try { - // Run gradlew with the init script we provide which should yield zero or more - // pom files. We have to figure out where to store those pom files such that - // we can upload them and predict them through the GitHub API. We could do a - // .socket folder. We could do a socket.pom.gz with all the poms, although - // I'd prefer something plain-text if it is to be committed. - // Note: init.gradle will be exported by .config/rollup.cli-js.config.mjs - const initLocation = path.join(distPath, 'init.gradle') - const commandArgs = ['--init-script', initLocation, ...gradleOpts, 'pom'] - if (verbose && isTextMode) { - logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) - } - if (isTextMode) { - logger.log(`Converting gradle to maven from \`${bin}\` on \`${cwd}\` ...`) - } - const output = await execGradleWithSpinner( - rBin, - commandArgs, - cwd, - isTextMode, - ) - if (verbose && isTextMode) { - logger.group('[VERBOSE] gradle stdout:') - logger.log(output) - logger.groupEnd() - } - if (output.code) { - if (isTextMode) { - process.exitCode = 1 - logger.fail(`Gradle exited with exit code ${output.code}`) - // (In verbose mode, stderr was printed above, no need to repeat it) - if (!verbose) { - logger.group('stderr:') - logger.error(output.stderr) - logger.groupEnd() - } - } - return { - ok: false, - code: output.code, - message: `Gradle exited with exit code ${output.code}`, - cause: output.stderr, - } - } - - // Extract file paths from output. - const files: string[] = [] - output.stdout.replace( - // oxlint-disable-next-line socket/prefer-non-capturing-group -- the capture is consumed as `fn` in the replace callback below. - /^POM file copied to: (.*)/gm, - (_all: string, fn: string) => { - files.push(fn) - if (isTextMode) { - logger.log('- ', fn) - } - return fn - }, - ) - - if (isTextMode) { - logger.success('Executed gradle successfully') - logger.log('Reported exports:') - // oxlint-disable-next-line socket/prefer-cached-for-loop -- callback uses expression body - files.forEach(fn => logger.log('- ', fn)) - logger.log('') - logger.log( - 'Next step is to generate a Scan by running the `socket scan create` command on the same directory', - ) - } - - return { - ok: true, - data: { - files, - type: 'gradle', - success: true, - }, - } - } catch (e) { - const summary = - 'There was an unexpected error while generating manifests' + - (verbose ? '' : ' (use --verbose for details)') - - if (isTextMode) { - process.exitCode = 1 - logger.fail(summary) - if (verbose) { - logger.group('[VERBOSE] error:') - logger.log(e) - logger.groupEnd() - } - } - - return { - ok: false, - message: summary, - cause: errorMessage(e), - } - } -} - -export async function execGradleWithSpinner( - bin: string, - commandArgs: string[], - cwd: string, - showSpinner: boolean, -): Promise<{ code: number; stdout: string; stderr: string }> { - let pass = false - const spinner = showSpinner ? getDefaultSpinner() : undefined - try { - if (showSpinner) { - logger.info( - '(Running gradle can take a while, it depends on how long gradlew has to run)', - ) - logger.info( - '(It will show no output, you can use --verbose to see its output)', - ) - spinner?.start('Running gradlew...') - } - - const output = await spawn(bin, commandArgs, { - // We can pipe the output through to have the user see the result - // of running gradlew, but then we can't (easily) gather the output - // to discover the generated files... probably a flag we should allow? - // stdio: isDebug() ? 'inherit' : undefined, - cwd, - }) - - if (!output) { - throw new Error( - `spawn returned no output for gradle (bin: ${bin}); check that the gradlew wrapper is executable and re-run with --verbose`, - ) - } - - pass = true - const { code, stderr, stdout } = output - return { - code, - stdout: stdout, - stderr: stderr, - } - } finally { - if (pass) { - spinner?.successAndStop('Gracefully completed gradlew execution.') - } else { - spinner?.failAndStop('There was an error while trying to run gradlew.') - } - } -} diff --git a/packages/cli/src/commands/manifest/convert-sbt-to-maven.mts b/packages/cli/src/commands/manifest/convert-sbt-to-maven.mts deleted file mode 100644 index 89810729d..000000000 --- a/packages/cli/src/commands/manifest/convert-sbt-to-maven.mts +++ /dev/null @@ -1,159 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeReadFile } from '@socketsecurity/lib-stable/fs/read-file' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import type { ManifestResult } from './output-manifest.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function convertSbtToMaven({ - bin, - cwd, - out, - outputKind = 'text', - sbtOpts, - verbose, -}: { - bin: string - cwd: string - out: string - outputKind?: OutputKind | undefined - sbtOpts: string[] - verbose: boolean -}): Promise<CResult<ManifestResult>> { - const isTextMode = outputKind === 'text' - - if (isTextMode) { - logger.group('sbt2maven:') - logger.info(`- executing: \`${bin}\``) - logger.info(`- src dir: \`${cwd}\``) - logger.groupEnd() - } - - const spinner = isTextMode ? getDefaultSpinner() : undefined - try { - spinner?.start(`Converting sbt to maven from \`${bin}\` on \`${cwd}\`...`) - - // Run sbt with the init script we provide which should yield zero or more - // pom files. We have to figure out where to store those pom files such that - // we can upload them and predict them through the GitHub API. We could do a - // .socket folder. We could do a socket.pom.gz with all the poms, although - // I'd prefer something plain-text if it is to be committed. - const output = await spawn(bin, ['makePom', ...sbtOpts], { cwd }) - - spinner?.stop() - - if (verbose && isTextMode) { - logger.group('[VERBOSE] sbt stdout:') - logger.log(output) - logger.groupEnd() - } - if (output.stderr) { - if (isTextMode) { - process.exitCode = 1 - logger.fail('There were errors while running sbt') - // (In verbose mode, stderr was printed above, no need to repeat it) - if (!verbose) { - logger.group('[VERBOSE] stderr:') - logger.error(output.stderr) - logger.groupEnd() - } - } - return { - ok: false, - message: 'There were errors while running sbt', - cause: - output.stderr, - } - } - const poms: string[] = [] - const stdoutStr = - output.stdout - stdoutStr.replace(/Wrote (.*?.pom)\n/g, (_all: string, fn: string) => { - poms.push(fn) - return fn - }) - if (!poms.length) { - const message = - 'There were no errors from sbt but it seems to not have generated any poms either' - if (isTextMode) { - process.exitCode = 1 - logger.fail(message) - } - return { - ok: false, - message, - } - } - // Handle stdout output: Only supported for single file output. - // Note: Multiple file stdout output could be supported in the future with separators - // or a flag to select specific files, but currently errors out for clarity. - if (out === '-' && poms.length === 1 && isTextMode) { - logger.log('Result:\n```') - logger.log(await safeReadFile(poms[0]!)) - logger.log('```') - logger.success('OK') - } else if (out === '-') { - const message = - 'Requested output target was stdout but there are multiple generated files' - if (isTextMode) { - process.exitCode = 1 - logger.error('') - logger.fail(message) - logger.error('') - // oxlint-disable-next-line socket/prefer-cached-for-loop -- callback uses expression body - poms.forEach(fn => logger.info('-', fn)) - if (poms.length > 10) { - logger.error('') - logger.fail(message) - } - logger.error('') - logger.info('Exiting now...') - } - return { - ok: false, - message, - data: { files: poms }, - } - } else if (isTextMode) { - logger.success(`Generated ${poms.length} pom files`) - // oxlint-disable-next-line socket/prefer-cached-for-loop -- callback uses expression body - poms.forEach(fn => logger.log('-', fn)) - logger.success('OK') - } - - return { - ok: true, - data: { - files: poms, - type: 'sbt', - success: true, - }, - } - } catch (e) { - const summary = - 'There was an unexpected error while running this' + - (verbose ? '' : ' (use --verbose for details)') - - if (isTextMode) { - process.exitCode = 1 - spinner?.stop() - logger.fail(summary) - if (verbose) { - logger.group('[VERBOSE] error:') - logger.log(e) - logger.groupEnd() - } - } - - return { - ok: false, - message: summary, - cause: errorMessage(e), - } - } -} diff --git a/packages/cli/src/commands/manifest/detect-manifest-actions.mts b/packages/cli/src/commands/manifest/detect-manifest-actions.mts deleted file mode 100644 index 4568258e4..000000000 --- a/packages/cli/src/commands/manifest/detect-manifest-actions.mts +++ /dev/null @@ -1,77 +0,0 @@ -// The point here is to attempt to detect the various supported manifest files -// the CLI can generate. This would be environments that we can't do server side - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { debugLog } from '@socketsecurity/lib-stable/debug/output' - -import { ENVIRONMENT_YAML, ENVIRONMENT_YML } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' - -import type { SocketJson } from '../../util/socket/json.mts' - -export interface GeneratableManifests { - cdxgen: boolean - count: number - conda: boolean - gradle: boolean - sbt: boolean -} - -export async function detectManifestActions( - // Passing in undefined means we attempt detection for every supported - // language regardless of local socket.json status. Sometimes we want that. - sockJson: SocketJson | undefined, - cwd = process.cwd(), -): Promise<GeneratableManifests> { - const output = { - cdxgen: false, - count: 0, - conda: false, - gradle: false, - sbt: false, - } - - if (sockJson?.defaults?.manifest?.sbt?.disabled) { - debugLog( - 'notice', - `[DEBUG] - sbt auto-detection is disabled in ${SOCKET_JSON}`, - ) - } else if (existsSync(path.join(cwd, 'build.sbt'))) { - debugLog('notice', '[DEBUG] - Detected a Scala sbt build file') - - output.sbt = true - output.count += 1 - } - - if (sockJson?.defaults?.manifest?.gradle?.disabled) { - debugLog( - 'notice', - `[DEBUG] - gradle auto-detection is disabled in ${SOCKET_JSON}`, - ) - } else if (existsSync(path.join(cwd, 'gradlew'))) { - debugLog('notice', '[DEBUG] - Detected a gradle build file') - output.gradle = true - output.count += 1 - } - - if (sockJson?.defaults?.manifest?.conda?.disabled) { - debugLog( - 'notice', - `[DEBUG] - conda auto-detection is disabled in ${SOCKET_JSON}`, - ) - } else { - const envyml = path.join(cwd, ENVIRONMENT_YML) - const hasEnvyml = existsSync(envyml) - const envyaml = path.join(cwd, ENVIRONMENT_YAML) - const hasEnvyaml = !hasEnvyml && existsSync(envyaml) - if (hasEnvyml || hasEnvyaml) { - debugLog('notice', '[DEBUG] - Detected an environment.yml Conda file') - output.conda = true - output.count += 1 - } - } - - return output -} diff --git a/packages/cli/src/commands/manifest/generate_auto_manifest.mts b/packages/cli/src/commands/manifest/generate_auto_manifest.mts deleted file mode 100644 index 13b0d2338..000000000 --- a/packages/cli/src/commands/manifest/generate_auto_manifest.mts +++ /dev/null @@ -1,89 +0,0 @@ -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { convertGradleToMaven } from './convert-gradle-to-maven.mts' -import { convertSbtToMaven } from './convert-sbt-to-maven.mts' -import { handleManifestConda } from './handle-manifest-conda.mts' -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' - -import type { GeneratableManifests } from './detect-manifest-actions.mts' -import type { OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function generateAutoManifest({ - cwd, - detected, - outputKind, - verbose, -}: { - detected: GeneratableManifests - cwd: string - outputKind: OutputKind - verbose: boolean -}) { - const sockJson = readOrDefaultSocketJson(cwd) - - if (verbose) { - logger.info(`Using this ${SOCKET_JSON} for defaults:`, sockJson) - } - - if (!sockJson?.defaults?.manifest?.sbt?.disabled && detected.sbt) { - const isTextMode = outputKind === 'text' - if (isTextMode) { - logger.log('Detected a Scala sbt build, generating pom files with sbt...') - } - await convertSbtToMaven({ - // Note: `sbt` is more likely to be resolved against PATH env - bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', - cwd, - out: sockJson.defaults?.manifest?.sbt?.outfile ?? './socket.sbt.pom.xml', - outputKind, - sbtOpts: - sockJson.defaults?.manifest?.sbt?.sbtOpts - ?.split(' ') - .map(s => s.trim()) - .filter(Boolean) ?? [], - verbose: Boolean(sockJson.defaults?.manifest?.sbt?.verbose), - }) - } - - if (!sockJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) { - const isTextMode = outputKind === 'text' - if (isTextMode) { - logger.log( - 'Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...', - ) - } - await convertGradleToMaven({ - // Note: Resolve bin relative to cwd (path.resolve handles absolute paths correctly). - // We don't resolve against $PATH since gradlew is typically a local wrapper script. - bin: sockJson.defaults?.manifest?.gradle?.bin - ? path.resolve(cwd, sockJson.defaults.manifest.gradle.bin) - : path.join(cwd, 'gradlew'), - cwd, - outputKind, - verbose: Boolean(sockJson.defaults?.manifest?.gradle?.verbose), - gradleOpts: - sockJson.defaults?.manifest?.gradle?.gradleOpts - ?.split(' ') - .map(s => s.trim()) - .filter(Boolean) ?? [], - }) - } - - if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) { - logger.log( - 'Detected an environment.yml file, running default Conda generator...', - ) - await handleManifestConda({ - cwd, - filename: sockJson.defaults?.manifest?.conda?.infile ?? 'environment.yml', - outputKind, - out: sockJson.defaults?.manifest?.conda?.outfile ?? REQUIREMENTS_TXT, - verbose: Boolean(sockJson.defaults?.manifest?.conda?.verbose), - }) - } -} diff --git a/packages/cli/src/commands/manifest/output-manifest-setup.mts b/packages/cli/src/commands/manifest/output-manifest-setup.mts deleted file mode 100644 index ce373a4ba..000000000 --- a/packages/cli/src/commands/manifest/output-manifest-setup.mts +++ /dev/null @@ -1,19 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' - -import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputManifestSetup(result: CResult<unknown>) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.success('Setup complete') -} diff --git a/packages/cli/src/commands/manifest/output-manifest.mts b/packages/cli/src/commands/manifest/output-manifest.mts deleted file mode 100644 index 954c8481f..000000000 --- a/packages/cli/src/commands/manifest/output-manifest.mts +++ /dev/null @@ -1,87 +0,0 @@ -import { writeFileSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export type ManifestResult = { - files: string[] - type: 'gradle' | 'sbt' - success: boolean -} - -export async function outputManifest( - result: CResult<ManifestResult>, - outputKind: OutputKind, - out: string, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'json') { - const json = serializeResultJson(result) - - if (out === '-') { - logger.log(json) - } else { - writeFileSync(out, json, 'utf8') - } - - return - } - - if (outputKind === 'markdown') { - const arr = [] - const { files, type } = result.data - const typeName = type === 'gradle' ? 'Gradle' : 'SBT' - - arr.push(mdHeader(`${typeName} Manifest Generation`)) - arr.push('') - arr.push( - `Successfully generated ${files.length} POM file${files.length === 1 ? '' : 's'} from ${typeName} project:`, - ) - arr.push('') - - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - arr.push(`- \`${file}\``) - } - - arr.push('') - arr.push(mdHeader('Next Steps', 2)) - arr.push('') - arr.push('Generate a security scan by running:') - arr.push('') - arr.push('```bash') - arr.push('socket scan create') - arr.push('```') - arr.push('') - - const md = arr.join('\n') - - if (out === '-') { - logger.log(md) - } else { - writeFileSync(out, md, 'utf8') - } - return - } - - // Text mode output - this is handled by the converter functions themselves. - // This path shouldn't normally be reached as text mode logs directly. -} diff --git a/packages/cli/src/commands/manifest/output-requirements.mts b/packages/cli/src/commands/manifest/output-requirements.mts deleted file mode 100644 index a24558fe3..000000000 --- a/packages/cli/src/commands/manifest/output-requirements.mts +++ /dev/null @@ -1,71 +0,0 @@ -import { writeFileSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputRequirements( - result: CResult<{ content: string; pip: string }>, - outputKind: OutputKind, - out: string, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'json') { - const json = serializeResultJson(result) - - if (out === '-') { - logger.log(json) - } else { - writeFileSync(out, json, 'utf8') - } - - return - } - - if (outputKind === 'markdown') { - const arr = [] - arr.push(mdHeader('Converted Conda file')) - arr.push('') - arr.push( - `This is the Conda \`environment.yml\` file converted to python \`${REQUIREMENTS_TXT}\`:`, - ) - arr.push('') - arr.push(`\`\`\`file=${REQUIREMENTS_TXT}`) - arr.push(result.data.pip) - arr.push('```') - arr.push('') - const md = arr.join('\n') - - if (out === '-') { - logger.log(md) - } else { - writeFileSync(out, md, 'utf8') - } - return - } - - if (out === '-') { - logger.log(result.data.pip) - logger.log('') - } else { - writeFileSync(out, result.data.pip, 'utf8') - } -} diff --git a/packages/cli/src/commands/manifest/run-cdxgen.mts b/packages/cli/src/commands/manifest/run-cdxgen.mts deleted file mode 100644 index c6664f6eb..000000000 --- a/packages/cli/src/commands/manifest/run-cdxgen.mts +++ /dev/null @@ -1,255 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import colors from 'yoctocolors-cjs' - -import { NPM, PNPM, YARN } from '@socketsecurity/lib-stable/constants/agents' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { FLAG_HELP } from '../../constants/cli.mjs' -import { NODE_MODULES } from '../../constants/packages.mts' -import { - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - YARN_LOCK, -} from '../../constants/paths.mts' -import { spawnCdxgenDlx, spawnSynpDlx } from '../../util/dlx/spawn.mjs' -import { findUp } from '../../util/fs/find-up.mjs' -import { isYarnBerry } from '../../util/yarn/version.mts' - -import type { DlxOptions, DlxSpawnResult } from '../../util/dlx/spawn.mjs' - -const logger = getDefaultLogger() - -// oxlint-disable-next-line socket/sort-set-args -- alphabetical; NPM and PNPM constants sit at their alphabetical positions. -const nodejsPlatformTypes = new Set([ - 'javascript', - 'js', - 'nodejs', - NPM, - PNPM, - 'ts', - 'tsx', - 'typescript', -]) - -type ArgvObject = { - [key: string]: boolean | null | number | string | Array<string | number> -} - -export function argvObjectToArray(argvObj: ArgvObject): string[] { - if (argvObj['help']) { - return [FLAG_HELP] - } - const result = [] - for (const { 0: key, 1: value } of Object.entries(argvObj)) { - if (key === '--' || key === '_') { - continue - } - if (key === 'babel' || key === 'install-deps' || key === 'validate') { - // cdxgen documents no-babel, no-install-deps, and no-validate flags so - // use them when relevant. - result.push(`--${value ? key : `no-${key}`}`) - } else if (value === true) { - result.push(`--${key}`) - } else if (typeof value === 'string') { - result.push(`--${key}`, String(value)) - } else if (Array.isArray(value)) { - result.push(`--${key}`, ...value.map(String)) - } - } - const pathArgs = argvObj['_'] as string[] - if (Array.isArray(pathArgs)) { - result.push(...pathArgs) - } - const argsAfterDoubleHyphen = argvObj['--'] as string[] - if (Array.isArray(argsAfterDoubleHyphen)) { - result.push('--', ...argsAfterDoubleHyphen) - } - return result -} - -/** - * Result of probing a cwd for Node.js SBOM inputs that cdxgen needs in the - * default `pre-build` + `install-deps: false` mode. - */ -type NodejsCdxgenSources = { - hasLockfile: boolean - hasNodeModules: boolean -} - -/** - * Probe upward from cwd for a recognized lockfile and for a co-located - * `node_modules/` directory. cdxgen's `pre-build` lifecycle needs at least one - * of these to produce a non-empty `components` array for a Node.js project. - */ -export async function detectNodejsCdxgenSources( - cwd: string = process.cwd(), -): Promise<NodejsCdxgenSources> { - const [pnpmLockPath, npmLockPath, yarnLockPath, nodeModulesPath] = - await Promise.all([ - findUp(PNPM_LOCK_YAML, { cwd, onlyFiles: true }), - findUp(PACKAGE_LOCK_JSON, { cwd, onlyFiles: true }), - findUp(YARN_LOCK, { cwd, onlyFiles: true }), - findUp(NODE_MODULES, { cwd, onlyDirectories: true }), - ]) - return { - hasLockfile: Boolean(npmLockPath || pnpmLockPath || yarnLockPath), - hasNodeModules: Boolean(nodeModulesPath), - } -} - -/** - * True when the argv `type` resolves to a Node.js platform (the cdxgen default - * when the user does not pass `--type`). - */ -export function isNodejsCdxgenType(argvType: unknown): boolean { - if (argvType === undefined || argvType === null) { - return true - } - if (typeof argvType === 'string') { - return nodejsPlatformTypes.has(argvType) - } - if (Array.isArray(argvType)) { - return argvType.some( - t => typeof t === 'string' && nodejsPlatformTypes.has(t), - ) - } - return false -} - -export async function runCdxgen(argvObj: ArgvObject): Promise<DlxSpawnResult> { - const argvMutable = { __proto__: null, ...argvObj } as ArgvObject - - const dlxOpts: DlxOptions = { - stdio: 'inherit', - } - - // Detect package manager based on lockfiles. - const pnpmLockPath = await findUp(PNPM_LOCK_YAML, { onlyFiles: true }) - - const npmLockPath = pnpmLockPath - ? undefined - : await findUp(PACKAGE_LOCK_JSON, { onlyFiles: true }) - - const yarnLockPath = - pnpmLockPath || npmLockPath - ? undefined - : await findUp(YARN_LOCK, { onlyFiles: true }) - - const agent = pnpmLockPath ? PNPM : yarnLockPath && isYarnBerry() ? YARN : NPM - - let cleanupPackageLock = false - if ( - yarnLockPath && - argvMutable['type'] !== YARN && - nodejsPlatformTypes.has(argvMutable['type'] as string) - ) { - // yarnLockPath is only resolved when neither pnpmLockPath nor npmLockPath - // are set, so the only branch here is to use synp to create a - // package-lock.json from the yarn.lock for a more accurate SBOM. - try { - const synpResult = await spawnSynpDlx( - ['--source-file', `./${YARN_LOCK}`], - { - ...dlxOpts, - agent, - }, - ) - await synpResult.spawnPromise - argvMutable['type'] = NPM - cleanupPackageLock = true - } catch {} - } - - // Use appropriate package manager for cdxgen. - const cdxgenResult = await spawnCdxgenDlx(argvObjectToArray(argvMutable), { - ...dlxOpts, - agent, - }) - - // Post-run cleanup + empty-BOM warning. We replace spawnPromise with a - // chained promise so the caller's `await spawnPromise` also awaits this - // work — otherwise the caller's continuation (e.g. `process.exit`) races - // the first `await` inside the finally body and the warning never prints. - const chainedSpawnPromise = cdxgenResult.spawnPromise.finally(async () => { - if (cleanupPackageLock) { - try { - // This removes the temporary package-lock.json we created for cdxgen. - // Using safeDeleteSync - no force needed since file is in cwd. - safeDeleteSync(`./${PACKAGE_LOCK_JSON}`) - } catch {} - } - - const outputPath = argvMutable['output'] as string - if (outputPath) { - const cwd = process.cwd() - const fullOutputPath = path.resolve(cwd, outputPath) - // Validate that the resolved path is within the current working directory. - // Normalize both paths to handle edge cases and ensure proper comparison. - const normalizedOutput = path.normalize(fullOutputPath) - const normalizedCwd = path.normalize(cwd) - if ( - !normalizedOutput.startsWith(normalizedCwd + path.sep) && - normalizedOutput !== normalizedCwd - ) { - logger.error( - `Output path "${outputPath}" resolves outside the current working directory`, - ) - return - } - if (existsSync(fullOutputPath)) { - logger.log(colors.cyanBright(`${outputPath} created!`)) - await warnIfEmptyComponents(fullOutputPath, argvMutable) - } - } - }) - - // Cast back to SpawnResult: .finally() returns plain Promise<T> which - // drops the `process` / `stdin` extras SpawnResult carries. Callers of - // runCdxgen only use `await spawnPromise` for the result, not those - // extras, so the shape loss is safe. - return { - ...cdxgenResult, - spawnPromise: chainedSpawnPromise as typeof cdxgenResult.spawnPromise, - } -} - -/** - * Read a generated CycloneDX BOM and warn when its `components` array is empty. - * An empty components array parses as valid CycloneDX but carries no dependency - * data, so the Socket dashboard cannot surface alerts for it. This catches - * configurations we did not hard-gate (non-default lifecycle, custom - * `--filter`/`--only` wiping all components, ecosystem mismatch, etc.). - */ -export async function warnIfEmptyComponents( - outputPath: string, - argvMutable: ArgvObject, -): Promise<void> { - let raw: string - try { - raw = await fs.readFile(outputPath, 'utf8') - } catch { - return - } - let bom: { components?: unknown | undefined } | undefined - try { - bom = JSON.parse(raw) - } catch { - return - } - if (!bom || !Array.isArray(bom.components) || bom.components.length > 0) { - return - } - const lifecycle = argvMutable['lifecycle'] - const lifecycleHint = - lifecycle === 'pre-build' || lifecycle === undefined - ? ' Pass --lifecycle build to resolve components during the build, or run a package install first so node_modules/ exists.\n' - : ' Re-check --type, --filter, and --only — a filter may be excluding every component.\n' - logger.warn( - `${outputPath} has an empty "components" array — the generated SBOM contains no dependencies and the Socket dashboard will show no alerts for it.\n${lifecycleHint}`, - ) -} diff --git a/packages/cli/src/commands/manifest/setup-manifest-config.mts b/packages/cli/src/commands/manifest/setup-manifest-config.mts deleted file mode 100644 index 62b0f4173..000000000 --- a/packages/cli/src/commands/manifest/setup-manifest-config.mts +++ /dev/null @@ -1,519 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { debugDirNs } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { input, select } from '@socketsecurity/lib-stable/stdio/prompts' - -import { detectManifestActions } from './detect-manifest-actions.mts' -import { REQUIREMENTS_TXT } from '../../constants/paths.mjs' -import { SOCKET_JSON } from '../../constants/socket.mts' -import { readSocketJsonSync, writeSocketJson } from '../../util/socket/json.mts' - -import type { CResult } from '../../types.mts' -import type { SocketJson } from '../../util/socket/json.mts' -const logger = getDefaultLogger() - -export async function askForBin(defaultName = ''): Promise<string | undefined> { - return await input({ - message: - '(--bin) What should be the command to execute? Usually your build binary.' + - (defaultName ? ' (Backspace to leave default)' : ''), - default: defaultName, - required: false, - // validate: async string => bool - }) -} - -export async function askForEnabled( - defaultValue: boolean | undefined, -): Promise<boolean | undefined> { - return await select({ - message: - 'Do you want to enable or disable auto generating manifest files for this language in this dir?', - choices: [ - { - name: 'Enable', - value: true, - description: 'Generate manifest files for this language when detected', - }, - { - name: 'Disable', - value: false, - description: - 'Do not generate manifest files for this language when detected, unless explicitly asking for it', - }, - { - name: 'Cancel', - value: undefined, - description: 'Exit configurator', - }, - ], - default: - defaultValue === true - ? 'enable' - : defaultValue === false - ? 'disable' - : '', - }) -} - -export async function askForInputFile( - defaultName = '', -): Promise<string | undefined> { - return await input({ - message: - '(--file) What should be the default file name to read? Should be an absolute path or relative to the cwd. Use `-` to read from stdin instead.' + - (defaultName ? ' (Backspace to leave default)' : ''), - default: defaultName, - required: false, - // validate: async string => bool - }) -} - -export async function askForOutputFile( - defaultName = '', -): Promise<string | undefined> { - return await input({ - message: - '(--out) What should be the default output file? Should be absolute path or relative to cwd.' + - (defaultName ? ' (Backspace to leave default)' : ''), - default: defaultName, - required: false, - // validate: async string => bool - }) -} - -export async function askForStdout( - defaultValue: boolean | undefined, -): Promise<string | undefined> { - return await select({ - message: '(--stdout) Print the resulting pom.xml to stdout?', - choices: [ - { - name: 'no', - value: 'no', - description: 'Write output to a file, not stdout', - }, - { - name: 'yes', - value: 'yes', - description: 'Print in stdout (this will supersede --out)', - }, - { - name: '(leave default)', - value: '', - description: 'Do not store a setting for this', - }, - ], - default: defaultValue === true ? 'yes' : defaultValue === false ? 'no' : '', - }) -} - -export async function askForVerboseFlag( - current: boolean | undefined, -): Promise<string | undefined> { - return await select({ - message: '(--verbose) Should this run in verbose mode by default?', - choices: [ - { - name: 'no', - value: 'no', - description: 'Do not run this manifest in verbose mode', - }, - { - name: 'yes', - value: 'yes', - description: 'Run this manifest in verbose mode', - }, - { - name: '(leave default)', - value: '', - description: 'Do not store a setting for this', - }, - ], - default: current === true ? 'yes' : current === false ? 'no' : '', - }) -} - -export function canceledByUser(): CResult<{ canceled: boolean }> { - logger.log('') - logger.info('User canceled') - logger.log('') - return { ok: true, data: { canceled: true } } -} - -export function notCanceled(): CResult<{ canceled: boolean }> { - return { ok: true, data: { canceled: false } } -} - -export async function setupConda( - config: NonNullable< - NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['conda'] - >, -): Promise<CResult<{ canceled: boolean }>> { - const on = await askForEnabled(!config.disabled) - if (on === undefined) { - return canceledByUser() - } - if (on) { - delete config.disabled - } else { - config.disabled = true - } - - const infile = await askForInputFile(config.infile || 'environment.yml') - if (infile === undefined) { - return canceledByUser() - } - if (infile === '-') { - config.stdin = true - } else { - delete config.stdin - if (infile) { - config.infile = infile - /* c8 ignore start - interactive prompt clearing infile (empty input) requires raw inquirer mock setup */ - } else { - delete config.infile - } - /* c8 ignore stop */ - } - - const stdout = await askForStdout(config.stdout) - if (stdout === undefined) { - return canceledByUser() - } - if (stdout === 'yes') { - config.stdout = true - } else if (stdout === 'no') { - config.stdout = false - } else { - delete config.stdout - } - - if (!config.stdout) { - const out = await askForOutputFile(config.outfile || REQUIREMENTS_TXT) - if (out === undefined) { - return canceledByUser() - } - if (out === '-') { - config.stdout = true - } else { - delete config.stdout - if (out) { - config.outfile = out - /* c8 ignore start - interactive prompt clearing outfile (empty input) requires raw inquirer mock setup */ - } else { - delete config.outfile - } - /* c8 ignore stop */ - } - } - - const verbose = await askForVerboseFlag(config.verbose) - /* c8 ignore start - interactive prompt cancellation (undefined return) requires raw inquirer mock setup */ - if (verbose === undefined) { - return canceledByUser() - } - /* c8 ignore stop */ - if (verbose === 'no' || verbose === 'yes') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose - } - - return notCanceled() -} - -export async function setupGradle( - config: NonNullable< - NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['gradle'] - >, -): Promise<CResult<{ canceled: boolean }>> { - const bin = await askForBin(config.bin || './gradlew') - if (bin === undefined) { - return canceledByUser() - } - if (bin) { - config.bin = bin - } else { - delete config.bin - } - - const opts = await input({ - message: '(--gradle-opts) Enter gradle options to pass through', - default: config.gradleOpts || '', - required: false, - // validate: async string => bool - }) - if (opts === undefined) { - return canceledByUser() - } - if (opts) { - config.gradleOpts = opts - } else { - delete config.gradleOpts - } - - const verbose = await askForVerboseFlag(config.verbose) - /* c8 ignore start - interactive prompt cancellation (undefined return) requires raw inquirer mock setup */ - if (verbose === undefined) { - return canceledByUser() - } - /* c8 ignore stop */ - if (verbose === 'no' || verbose === 'yes') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose - } - - return notCanceled() -} - -export async function setupManifestConfig( - cwd: string, - defaultOnReadError = false, -): Promise<CResult<unknown>> { - const detected = await detectManifestActions(undefined, cwd) - debugDirNs('inspect', { detected }) - - // - repeat - // - give the user an option to configure one of the supported targets - // - run through an interactive prompt for selected target - // - each target will have its own specific options - // - record them to the socket.yml (or socket-cli.yml ? or just socket.json ?) - - const jsonPath = path.join(cwd, SOCKET_JSON) - if (existsSync(jsonPath)) { - logger.info(`Found ${SOCKET_JSON} at ${jsonPath}`) - } else { - logger.info(`No ${SOCKET_JSON} found at ${cwd}, will generate a new one`) - } - - logger.log('') - logger.log( - 'Note: This tool will set up flag and argument defaults for certain', - ) - logger.log(' CLI commands. You can still override them by explicitly') - logger.log(' setting the flag. It is meant to be a convenience tool.') - logger.log('') - logger.log( - `This command will generate a ${SOCKET_JSON} file in the target cwd.`, - ) - logger.log( - 'You can choose to add this file to your repo (handy for collaboration)', - ) - logger.log('or to add it to the ignored files, or neither. This file is only') - logger.log('used in CLI workflows.') - logger.log('') - - const choices = [ - { - name: 'Conda'.padEnd(30, ' '), - value: 'conda', - description: `Generate ${REQUIREMENTS_TXT} from a Conda environment.yml`, - }, - { - name: 'Gradle'.padEnd(30, ' '), - value: 'gradle', - description: 'Generate pom.xml files through gradle', - }, - { - name: 'Kotlin (gradle)'.padEnd(30, ' '), - value: 'gradle', - description: 'Generate pom.xml files (for Kotlin) through gradle', - }, - { - name: 'Scala (gradle)'.padEnd(30, ' '), - value: 'gradle', - description: 'Generate pom.xml files (for Scala) through gradle', - }, - { - name: 'Scala (sbt)'.padEnd(30, ' '), - value: 'sbt', - description: 'Generate pom.xml files through sbt', - }, - ] - - for (let i = 0, { length } = choices; i < length; i += 1) { - const obj = choices[i]! - if (detected[obj.value as keyof typeof detected]) { - obj.name += ' [detected]' - } - } - - // Surface detected language first, then by alphabet - choices.sort((a, b) => { - if ( - detected[a.value as keyof typeof detected] && - !detected[b.value as keyof typeof detected] - ) { - return -1 - } - if ( - !detected[a.value as keyof typeof detected] && - detected[b.value as keyof typeof detected] - ) { - return 1 - } - return a.value < b.value ? -1 : a.value > b.value ? 1 : 0 - }) - - // Make exit the last entry... - choices.push({ - name: 'None, exit configurator', - value: '', - description: 'Exit setup', - }) - - const targetEco = (await select({ - message: 'Select ecosystem manifest generator to configure', - choices, - })) as string | null - - const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) - if (!sockJsonCResult.ok) { - return sockJsonCResult - } - const sockJson = sockJsonCResult.data - - if (!sockJson.defaults) { - sockJson.defaults = {} - } - if (!sockJson.defaults.manifest) { - sockJson.defaults.manifest = {} - } - - let result: CResult<{ canceled: boolean }> - switch (targetEco) { - case 'conda': { - if (!sockJson.defaults.manifest.conda) { - sockJson.defaults.manifest.conda = {} - } - result = await setupConda(sockJson.defaults.manifest.conda) - break - } - case 'gradle': { - if (!sockJson.defaults.manifest.gradle) { - sockJson.defaults.manifest.gradle = {} - } - result = await setupGradle(sockJson.defaults.manifest.gradle) - break - } - case 'sbt': { - if (!sockJson.defaults.manifest.sbt) { - sockJson.defaults.manifest.sbt = {} - } - result = await setupSbt(sockJson.defaults.manifest.sbt) - break - } - default: { - result = canceledByUser() - } - } - - if (!result.ok || result.data.canceled) { - return result - } - - logger.log('') - logger.log(`Setup complete. Writing ${SOCKET_JSON}`) - logger.log('') - - if ( - await select({ - message: `Do you want to write the new config to ${jsonPath} ?`, - choices: [ - { - name: 'yes', - value: true, - description: 'Update config', - }, - { - name: 'no', - value: false, - description: 'Do not update the config', - }, - ], - }) - ) { - return await writeSocketJson(cwd, sockJson) - } - - return canceledByUser() -} - -export async function setupSbt( - config: NonNullable< - NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['sbt'] - >, -): Promise<CResult<{ canceled: boolean }>> { - const bin = await askForBin(config.bin || 'sbt') - if (bin === undefined) { - return canceledByUser() - } - if (bin) { - config.bin = bin - } else { - delete config.bin - } - - const opts = await input({ - message: '(--sbt-opts) Enter sbt options to pass through', - default: config.sbtOpts || '', - required: false, - // validate: async string => bool - }) - if (opts === undefined) { - return canceledByUser() - } - if (opts) { - config.sbtOpts = opts - } else { - delete config.sbtOpts - } - - const stdout = await askForStdout(config.stdout) - if (stdout === undefined) { - return canceledByUser() - } - if (stdout === 'yes') { - config.stdout = true - } else if (stdout === 'no') { - config.stdout = false - } else { - delete config.stdout - } - - if (config.stdout !== true) { - const out = await askForOutputFile(config.outfile || 'sbt.pom.xml') - if (out === undefined) { - return canceledByUser() - } - if (out === '-') { - config.stdout = true - } else { - delete config.stdout - if (out) { - config.outfile = out - } else { - delete config.outfile - } - } - } - - const verbose = await askForVerboseFlag(config.verbose) - /* c8 ignore start - interactive prompt cancellation (undefined return) requires raw inquirer mock setup */ - if (verbose === undefined) { - return canceledByUser() - } - /* c8 ignore stop */ - if (verbose === 'no' || verbose === 'yes') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose - } - - return notCanceled() -} diff --git a/packages/cli/src/commands/mcp/cmd-mcp.mts b/packages/cli/src/commands/mcp/cmd-mcp.mts deleted file mode 100644 index 482946649..000000000 --- a/packages/cli/src/commands/mcp/cmd-mcp.mts +++ /dev/null @@ -1,174 +0,0 @@ -import { handleMcp } from './handle-mcp.mts' -import { getMcpHttpMode } from '../../env/mcp-http-mode.mts' -import { getMcpPort } from '../../env/mcp-port.mts' -import { getSocketOauthIntrospectionClientId } from '../../env/socket-oauth-introspection-client-id.mts' -import { getSocketOauthIntrospectionClientSecret } from '../../env/socket-oauth-introspection-client-secret.mts' -import { getSocketOauthIssuer } from '../../env/socket-oauth-issuer.mts' -import { getSocketOauthRequiredScopes } from '../../env/socket-oauth-required-scopes.mts' -import { getTrustProxy } from '../../env/trust-proxy.mts' -import { commonFlags } from '../../flags.mts' -import { defineFlags } from '../../meow.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'mcp' - -const description = 'Run the Socket MCP server (Model Context Protocol)' - -const hidden = false - -export const cmdMcp = { - description, - hidden, - run, -} - -const DEFAULT_PORT = 3000 - -export function parseRequiredScopes(raw: string): string[] { - return raw - .split(/\s+/u) - .map(value => value.trim()) - .filter(Boolean) -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - http: { - type: 'boolean', - default: false, - description: - 'Start the MCP server in Streamable HTTP mode (default: stdio)', - }, - 'oauth-client-id': { - type: 'string', - default: '', - description: - 'OAuth introspection client ID (HTTP mode only; falls back to env SOCKET_OAUTH_INTROSPECTION_CLIENT_ID)', - }, - 'oauth-client-secret': { - type: 'string', - default: '', - description: - 'OAuth introspection client secret (HTTP mode only; falls back to env SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET)', - }, - 'oauth-issuer': { - type: 'string', - default: '', - description: - 'OAuth issuer URL (HTTP mode only; falls back to env SOCKET_OAUTH_ISSUER)', - }, - 'oauth-required-scopes': { - type: 'string', - default: '', - description: - 'Whitespace-separated OAuth scopes required to access the server (default: packages:list; falls back to env SOCKET_OAUTH_REQUIRED_SCOPES)', - }, - port: { - type: 'number', - default: DEFAULT_PORT, - description: - 'Port to bind for HTTP mode (default: 3000; falls back to env MCP_PORT)', - }, - 'trust-proxy': { - type: 'boolean', - default: false, - description: - 'Honor X-Forwarded-Proto / X-Forwarded-Host headers (HTTP mode only; falls back to env TRUST_PROXY=true)', - }, - }), - help: (command: string) => ` - Usage - $ ${command} [options] - - The Socket MCP server exposes the \`depscore\` tool: AI clients can ask - for dependency security scores across npm, PyPI, RubyGems, Go modules, - Maven, NuGet, and Cargo without leaving the chat. - - Modes - stdio (default) Speak JSON-RPC over stdin/stdout — suitable for - Claude Desktop, Cursor, etc. Auth is the local - Socket API token (run \`socket login\`). - - --http Speak Streamable HTTP on --port. Auth can be - either the local Socket API token (single-user) - or OAuth introspection (multi-user). Configure - OAuth via the --oauth-* flags or matching - SOCKET_OAUTH_* env vars. - - Environment variables - SOCKET_API_TOKEN Stdio + HTTP fallback auth - MCP_HTTP_MODE=true Equivalent to --http - MCP_PORT Equivalent to --port - SOCKET_OAUTH_ISSUER OAuth issuer URL (HTTP) - SOCKET_OAUTH_INTROSPECTION_CLIENT_ID OAuth introspection client ID - SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET OAuth introspection client secret - SOCKET_OAUTH_REQUIRED_SCOPES Whitespace-separated scopes - TRUST_PROXY=true Honor X-Forwarded-* headers - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} # stdio mode (default) - $ ${command} --http # HTTP mode on :3000 - $ ${command} --http --port 4000 # HTTP mode on :4000 - $ ${command} --http \\ # HTTP mode with OAuth - --oauth-issuer https://auth.example.com \\ - --oauth-client-id abc \\ - --oauth-client-secret xyz - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const http = cli.flags.http || getMcpHttpMode() - - const portFlag = cli.flags.port - const portRaw = - portFlag && portFlag > 0 - ? portFlag - : Number.parseInt(getMcpPort() || `${DEFAULT_PORT}`, 10) - const port = Number.isFinite(portRaw) && portRaw > 0 ? portRaw : DEFAULT_PORT - - const oauthIssuer = cli.flags['oauth-issuer'] || getSocketOauthIssuer() || '' - const oauthClientId = - cli.flags['oauth-client-id'] || getSocketOauthIntrospectionClientId() || '' - const oauthClientSecret = - cli.flags['oauth-client-secret'] || - getSocketOauthIntrospectionClientSecret() || - '' - const oauthRequiredScopesRaw = - cli.flags['oauth-required-scopes'] || getSocketOauthRequiredScopes() || '' - const oauthRequiredScopes = oauthRequiredScopesRaw - ? parseRequiredScopes(oauthRequiredScopesRaw) - : undefined - - const trustProxy = cli.flags['trust-proxy'] || getTrustProxy() - - await handleMcp({ - http, - oauthClientId, - oauthClientSecret, - oauthIssuer, - oauthRequiredScopes, - port, - trustProxy, - }) -} diff --git a/packages/cli/src/commands/mcp/depscore.mts b/packages/cli/src/commands/mcp/depscore.mts deleted file mode 100644 index a44640670..000000000 --- a/packages/cli/src/commands/mcp/depscore.mts +++ /dev/null @@ -1,224 +0,0 @@ -import { Type } from '@sinclair/typebox' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { setupSdk } from '../../util/socket/sdk.mts' -import { deduplicateArtifacts } from './lib/artifacts.mts' -import { buildPurl } from './lib/purl.mts' - -import type { ArtifactData } from './lib/artifacts.mts' -import type { SocketSdk } from '@socketsecurity/sdk-stable' -import type { Static } from '@sinclair/typebox' - -const logger = getDefaultLogger() - -// JSON Schema for the depscore tool input. Authored in TypeBox so the -// schema is type-safe at the call site; emitted as plain JSON Schema for -// the MCP wire (no zod, no zod-to-json-schema round-trip). -export const DepscoreInputSchema = Type.Object({ - packages: Type.Array( - Type.Object({ - depname: Type.String({ - description: 'The name of the dependency', - }), - ecosystem: Type.Optional( - Type.String({ - default: 'npm', - description: - 'The package ecosystem (e.g., npm, pypi, gem, golang, maven, nuget, cargo)', - }), - ), - version: Type.Optional( - Type.String({ - default: 'unknown', - description: - "The version of the dependency, use 'unknown' if not known", - }), - ), - }), - { - description: 'Array of packages to check', - }, - ), - platform: Type.Optional( - Type.String({ - description: - "Optional OS-architecture hint (e.g., 'linux-x64', 'darwin-arm64', 'win32-x64'). Used to select the most relevant artifact when a package has platform-specific builds.", - }), - ), -}) - -export type DepscoreInput = Static<typeof DepscoreInputSchema> - -export const DEPSCORE_TOOL_NAME = 'depscore' - -export const DEPSCORE_TOOL_DESCRIPTION = - "Get the dependency score of packages with the `depscore` tool from Socket. Use 'unknown' for version if not known. Use this tool to scan dependencies for their quality and security on existing code or when code is generated. Stop generating code and ask the user how to proceed when any of the scores are low. When checking dependencies, make sure to also check the imports in the code, not just the manifest files (pyproject.toml, package.json, etc)." - -interface DepscoreToolResult { - content: Array<{ text: string; type: 'text' }> - isError?: boolean | undefined -} - -interface DepscoreOptions { - apiToken: string -} - -export function formatScore(jsonData: ArtifactData): string { - const ns = jsonData.namespace ? `${jsonData.namespace}/` : '' - const purl = `pkg:${jsonData.type || 'unknown'}/${ns}${jsonData.name || 'unknown'}@${jsonData.version || 'unknown'}` - if ( - jsonData.score && - (jsonData.score as Record<string, unknown>)['overall'] !== undefined - ) { - const scoreEntries = Object.entries( - jsonData.score as Record<string, unknown>, - ) - .filter(([key]) => key !== 'overall' && key !== 'uuid') - .map(([key, value]) => { - const numValue = Number(value) - const displayValue = - numValue <= 1 ? Math.round(numValue * 100) : numValue - return `${key}: ${displayValue}` - }) - .join(', ') - return `${purl}: ${scoreEntries}` - } - return `${purl}: No score found` -} - -// Memoize SDK clients per token. Stdio mode shares one client across all -// tool calls; HTTP+OAuth mode constructs one per distinct token. -const sdkCache = new Map<string, SocketSdk>() - -export async function getSdk(apiToken: string): Promise<SocketSdk> { - const cached = sdkCache.get(apiToken) - if (cached) { - return cached - } - const result = await setupSdk({ apiToken }) - if (!result.ok) { - throw new Error( - result.cause || result.message || 'Failed to set up Socket SDK', - ) - } - sdkCache.set(apiToken, result.data) - return result.data -} - -export async function runDepscore( - input: DepscoreInput, - opts: DepscoreOptions, -): Promise<DepscoreToolResult> { - const { packages, platform } = input - logger.info(`Received request for ${packages.length} packages`) - - const components = packages.map(pkg => { - const cleanedVersion = (pkg.version ?? 'unknown').replace(/[\^~]/g, '') - const ecosystem = pkg.ecosystem ?? 'npm' - const purl = buildPurl(ecosystem, pkg.depname, cleanedVersion) - if ( - cleanedVersion !== '1.0.0' && - cleanedVersion !== 'unknown' && - cleanedVersion - ) { - logger.info(`Using version ${cleanedVersion} for ${pkg.depname}`) - } - return { purl } - }) - - let sdk: SocketSdk - try { - sdk = await getSdk(opts.apiToken) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`SDK setup failed: ${message}`) - return { - content: [{ text: `SDK setup failed: ${message}`, type: 'text' }], - isError: true, - } - } - - let response - try { - response = await sdk.batchPackageFetch( - { components }, - { - alerts: false, - compact: false, - fixable: false, - licenseattrib: false, - licensedetails: false, - }, - ) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Error processing packages: ${message}`) - return { - content: [{ text: 'Error connecting to Socket API', type: 'text' }], - isError: true, - } - } - - if (!response.success) { - const status = response.status - const cause = response.cause || response.error - if (status === 401) { - const errorMsg = `Socket authentication failed [401]. Re-authenticate and retry. ${cause ?? ''}` - logger.error(errorMsg) - return { - content: [{ text: errorMsg, type: 'text' }], - isError: true, - } - } - if (status === 403) { - const errorMsg = `Socket denied access [403]. Re-authenticate with the correct organization or repository permissions and retry. ${cause ?? ''}` - logger.error(errorMsg) - return { - content: [{ text: errorMsg, type: 'text' }], - isError: true, - } - } - const errorMsg = `Error processing packages: [${status}] ${cause ?? response.error}` - logger.error(errorMsg) - return { - content: [{ text: errorMsg, type: 'text' }], - isError: true, - } - } - - const artifacts = (response.data || []) as ArtifactData[] - if (!artifacts.length) { - const errorMsg = 'No packages were found.' - logger.error(errorMsg) - return { - content: [{ text: errorMsg, type: 'text' }], - isError: true, - } - } - - const filtered = artifacts.filter(a => !a['_type']) - if (!filtered.length) { - return { - content: [ - { - text: 'No valid artifact records returned by Socket API', - type: 'text', - }, - ], - isError: true, - } - } - - const deduplicated = deduplicateArtifacts(filtered, platform) - const results = deduplicated.map(formatScore) - - return { - content: [ - { - text: `Dependency scores:\n${results.join('\n')}`, - type: 'text', - }, - ], - } -} diff --git a/packages/cli/src/commands/mcp/handle-mcp.mts b/packages/cli/src/commands/mcp/handle-mcp.mts deleted file mode 100644 index f3eb2b25e..000000000 --- a/packages/cli/src/commands/mcp/handle-mcp.mts +++ /dev/null @@ -1,74 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getDefaultApiToken } from '../../util/socket/sdk.mts' -import { runHttpTransport } from './transport-http.mts' -import { runStdioTransport } from './transport-stdio.mts' -import { constants } from '../../constants.mts' - -import type { ServerConfig } from './server.mts' - -const logger = getDefaultLogger() - -interface HandleMcpOptions { - http: boolean - oauthClientId?: string | undefined - oauthClientSecret?: string | undefined - oauthIssuer?: string | undefined - oauthRequiredScopes?: readonly string[] | undefined - port: number - trustProxy: boolean -} - -const DEFAULT_OAUTH_REQUIRED_SCOPES = ['packages:list'] as const - -export async function handleMcp(opts: HandleMcpOptions): Promise<void> { - const ENV = constants['ENV'] as { INLINED_VERSION?: string | undefined } - const version = ENV.INLINED_VERSION || '0.0.0' - - const baseConfig: ServerConfig = { - getApiToken: () => getDefaultApiToken(), - serverName: 'socket', - version, - } - - if (opts.http) { - const issuer = opts.oauthIssuer ?? '' - const clientId = opts.oauthClientId ?? '' - const clientSecret = opts.oauthClientSecret ?? '' - const partial = - (clientId || clientSecret || issuer) && - !(clientId && clientSecret && issuer) - if (partial) { - logger.error( - 'Incomplete OAuth configuration for HTTP mode. Set SOCKET_OAUTH_ISSUER, SOCKET_OAUTH_INTROSPECTION_CLIENT_ID, and SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET together.', - ) - process.exit(1) - } - const oauthEnabled = Boolean(clientId && clientSecret && issuer) - if (!oauthEnabled && !baseConfig.getApiToken()) { - logger.error( - 'No SOCKET_API_TOKEN configured and OAuth is not enabled. Run `socket login` or set OAuth env vars (SOCKET_OAUTH_ISSUER, SOCKET_OAUTH_INTROSPECTION_CLIENT_ID, SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET) before starting HTTP mode.', - ) - process.exit(1) - } - await runHttpTransport({ - ...baseConfig, - oauthClientId: clientId, - oauthClientSecret: clientSecret, - oauthIssuer: issuer, - oauthRequiredScopes: - opts.oauthRequiredScopes ?? DEFAULT_OAUTH_REQUIRED_SCOPES, - port: opts.port, - trustProxy: opts.trustProxy, - }) - return - } - - if (!baseConfig.getApiToken()) { - logger.error( - 'No SOCKET_API_TOKEN configured. Run `socket login` or set SOCKET_API_TOKEN before starting stdio mode.', - ) - process.exit(1) - } - await runStdioTransport(baseConfig) -} diff --git a/packages/cli/src/commands/mcp/lib/artifacts.mts b/packages/cli/src/commands/mcp/lib/artifacts.mts deleted file mode 100644 index 28e99e8b6..000000000 --- a/packages/cli/src/commands/mcp/lib/artifacts.mts +++ /dev/null @@ -1,95 +0,0 @@ -export interface ArtifactData { - __proto__?: null | undefined - _type?: string | undefined - name?: string | undefined - namespace?: string | undefined - release?: string | undefined - score?: Record<string, unknown> | undefined - type?: string | undefined - version?: string | undefined - [key: string]: unknown -} - -const PLATFORM_PATTERNS = { - __proto__: null, - 'darwin-arm64': [/macosx.*arm64/i], - 'darwin-x64': [/macosx.*x86_64/i], - 'linux-arm64': [/(linux|manylinux).*(aarch64|arm64)/i], - 'linux-x64': [/(linux|manylinux).*x86_64/i], - 'win32-ia32': [/win.*win32/i], - 'win32-x64': [/win.*(amd64|x86_64)/i], -} as unknown as Record<string, RegExp[]> - -export function artifactGroupKey(artifact: ArtifactData): string { - const ns = artifact.namespace || '' - return `${artifact.type || ''}/${ns}/${artifact.name || ''}@${artifact.version || ''}` -} - -export function deduplicateArtifacts( - artifacts: ArtifactData[], - platform?: string | undefined, -): ArtifactData[] { - const groups = new Map<string, ArtifactData[]>() - for (let i = 0, { length } = artifacts; i < length; i += 1) { - const artifact = artifacts[i]! - const key = artifactGroupKey(artifact) - let group = groups.get(key) - if (!group) { - group = [] - groups.set(key, group) - } - group.push(artifact) - } - const results: ArtifactData[] = [] - for (const group of groups.values()) { - results.push(selectBestArtifact(group, platform)) - } - return results -} - -export function isSourceDist(release: string): boolean { - return /\.(tar\.bz2|tar\.gz|zip)$/i.test(release) || /sdist/i.test(release) -} - -export function isUniversalWheel(release: string): boolean { - return ( - /[-_]none[-_]any\.whl$/i.test(release) || - /py3[-_]none[-_]any/i.test(release) - ) -} - -export function matchesPlatform(release: string, platform: string): boolean { - const patterns = PLATFORM_PATTERNS[platform] - if (patterns) { - return patterns.some(p => p.test(release)) - } - return release.toLowerCase().includes(platform.toLowerCase()) -} - -export function selectBestArtifact( - artifacts: ArtifactData[], - platform?: string | undefined, -): ArtifactData { - if (artifacts.length === 1) { - return artifacts[0]! - } - if (platform) { - const match = artifacts.find( - a => a.release && matchesPlatform(a.release, platform), - ) - if (match) { - return match - } - } - const sdist = artifacts.find(a => a.release && isSourceDist(a.release)) - if (sdist) { - return sdist - } - const universal = artifacts.find( - a => a.release && isUniversalWheel(a.release), - ) - if (universal) { - return universal - } - return artifacts[0]! -} diff --git a/packages/cli/src/commands/mcp/lib/purl.mts b/packages/cli/src/commands/mcp/lib/purl.mts deleted file mode 100644 index 367e69e8c..000000000 --- a/packages/cli/src/commands/mcp/lib/purl.mts +++ /dev/null @@ -1,50 +0,0 @@ -import { PackageURL } from '@socketregistry/packageurl-js-stable' - -/** - * Build a PURL using packageurl-js for correct encoding across all ecosystems. - * Handles namespace/name splitting per ecosystem (npm scoped @scope/name, maven - * groupId:artifactId, golang module/path). - */ -export function buildPurl( - ecosystem: string, - depname: string, - version: string, -): string { - const type = ecosystem.toLowerCase() - let namespace: string | undefined - let name: string - - if (type === 'npm' && depname.startsWith('@') && depname.includes('/')) { - const slash = depname.indexOf('/') - namespace = depname.slice(0, slash) - name = depname.slice(slash + 1) - } else if ( - type === 'maven' && - (depname.includes(':') || depname.includes('/')) - ) { - const sep = depname.includes(':') ? ':' : '/' - const idx = depname.indexOf(sep) - namespace = depname.slice(0, idx) - name = depname.slice(idx + 1) - } else if (type === 'golang' && depname.includes('/')) { - const lastSlash = depname.lastIndexOf('/') - namespace = depname.slice(0, lastSlash) - name = depname.slice(lastSlash + 1) - } else { - name = depname - } - - const purlVersion = - version === 'unknown' || version === '1.0.0' || !version - ? undefined - : version - const purl = new PackageURL( - type, - namespace, - name, - purlVersion, - undefined, - undefined, - ) - return purl.toString() -} diff --git a/packages/cli/src/commands/mcp/server.mts b/packages/cli/src/commands/mcp/server.mts deleted file mode 100644 index 1f3bfddd2..000000000 --- a/packages/cli/src/commands/mcp/server.mts +++ /dev/null @@ -1,113 +0,0 @@ -import { TypeCompiler } from '@sinclair/typebox/compiler' - -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js' - -import { - DEPSCORE_TOOL_DESCRIPTION, - DEPSCORE_TOOL_NAME, - DepscoreInputSchema, - runDepscore, -} from './depscore.mts' - -import type { DepscoreInput } from './depscore.mts' - -export interface ServerConfig { - getApiToken: () => string | undefined - serverName: string - version: string -} - -const depscoreInputCheck = TypeCompiler.Compile(DepscoreInputSchema) - -export function createConfiguredServer(config: ServerConfig): Server { - const server = new Server( - { - name: config.serverName, - version: config.version, - }, - { - capabilities: { - tools: {}, - }, - }, - ) - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - annotations: { - readOnlyHint: true, - }, - description: DEPSCORE_TOOL_DESCRIPTION, - inputSchema: schemaToJsonSchema(DepscoreInputSchema), - name: DEPSCORE_TOOL_NAME, - title: 'Dependency Score Tool', - }, - ], - })) - - server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - const { arguments: args, name } = request.params - - if (name !== DEPSCORE_TOOL_NAME) { - return { - content: [{ text: `Unknown tool: ${name}`, type: 'text' as const }], - isError: true, - } - } - - if (!depscoreInputCheck.Check(args)) { - const errors = [...depscoreInputCheck.Errors(args)] - .map(e => `${e.path}: ${e.message}`) - .join('; ') - return { - content: [ - { - text: `Invalid arguments for ${DEPSCORE_TOOL_NAME}: ${errors}`, - type: 'text' as const, - }, - ], - isError: true, - } - } - - const authToken = - (extra.authInfo?.token as string | undefined) || config.getApiToken() - - if (!authToken) { - return { - content: [ - { - text: 'Authentication is required. Configure SOCKET_API_TOKEN for stdio mode or connect through OAuth-enabled HTTP mode.', - type: 'text' as const, - }, - ], - isError: true, - } - } - - const result = await runDepscore(args as DepscoreInput, { - apiToken: authToken, - }) - return { - content: result.content.map(c => ({ - text: c.text, - type: 'text' as const, - })), - isError: result.isError, - } - }) - - return server -} - -// Convert TypeBox schema to a JSON Schema literal for MCP wire output. -// TypeBox values are JSON Schema natively; cloning produces a plain -// object the SDK serializes without zod-specific machinery. -export function schemaToJsonSchema(schema: object): Record<string, unknown> { - return JSON.parse(JSON.stringify(schema)) -} diff --git a/packages/cli/src/commands/mcp/transport-http-helpers.mts b/packages/cli/src/commands/mcp/transport-http-helpers.mts deleted file mode 100644 index facf08005..000000000 --- a/packages/cli/src/commands/mcp/transport-http-helpers.mts +++ /dev/null @@ -1,428 +0,0 @@ -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' - -import type { HttpResponse } from '@socketsecurity/lib-stable/http-request/response-types' -import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' -import type { IncomingMessage, ServerResponse } from 'node:http' - -export interface OAuthMetadata { - authorization_endpoint: string - introspection_endpoint: string - issuer: string - token_endpoint: string - [key: string]: unknown -} - -export const OAUTH_WELL_KNOWN_PATH = '/.well-known/oauth-authorization-server' -export const OAUTH_PROTECTED_RESOURCE_METADATA_PATH = - '/.well-known/oauth-protected-resource' - -type AuthenticatedRequest = IncomingMessage & { auth?: AuthInfo | undefined } - -export class OAuthIntrospector { - private metadataPromise: Promise<OAuthMetadata> | undefined - private readonly clientId: string - private readonly clientSecret: string - private readonly issuer: string - private readonly requiredScopes: readonly string[] - private readonly log: { error: (msg: string) => void } - constructor( - issuer: string, - clientId: string, - clientSecret: string, - requiredScopes: readonly string[], - log: { error: (msg: string) => void }, - ) { - this.clientId = clientId - this.clientSecret = clientSecret - this.issuer = issuer - this.requiredScopes = requiredScopes - this.log = log - } - - async loadMetadata(): Promise<OAuthMetadata> { - if (!this.metadataPromise) { - const promise = (async () => { - const issuerUrl = new URL(this.issuer) - const url = new URL(OAUTH_WELL_KNOWN_PATH, issuerUrl).href - const response: HttpResponse = await httpRequest(url, { method: 'GET' }) - const responseText = response.text() - if (response.status < 200 || response.status >= 300) { - throw new Error( - `OAuth metadata discovery failed with status ${response.status}: ${responseText}`, - ) - } - const metadata = parseJsonObject( - responseText, - 'OAuth metadata discovery', - ) - for (const field of [ - 'authorization_endpoint', - 'introspection_endpoint', - 'issuer', - 'token_endpoint', - ] as const) { - if (typeof metadata[field] !== 'string' || !metadata[field]) { - throw new Error(`OAuth metadata missing required field: ${field}`) - } - } - return metadata as OAuthMetadata - })() - this.metadataPromise = promise.catch(error => { - // Failure invalidates the cache so the next call retries. - // Safe in single-threaded JS: no other code can replace - // `this.metadataPromise` between this catch and the next call. - this.metadataPromise = undefined - throw error - }) - } - return await this.metadataPromise - } - - async verifyAccessToken(token: string): Promise<AuthInfo | undefined> { - const metadata = await this.loadMetadata() - const basicAuth = Buffer.from( - `${this.clientId}:${this.clientSecret}`, - ).toString('base64') - const response: HttpResponse = await httpRequest( - metadata.introspection_endpoint, - { - body: new URLSearchParams({ token }).toString(), - headers: { - authorization: `Basic ${basicAuth}`, - 'content-type': 'application/x-www-form-urlencoded', - }, - method: 'POST', - }, - ) - const responseText = response.text() - if (response.status < 200 || response.status >= 300) { - throw new Error( - `Token introspection failed with status ${response.status}: ${responseText}`, - ) - } - const introspection = parseJsonObject(responseText, 'Token introspection') - if (!introspection['active']) { - return undefined - } - const expRaw = introspection['exp'] - const expiresAt = typeof expRaw === 'number' ? expRaw : Number(expRaw) - return { - clientId: - typeof introspection['client_id'] === 'string' - ? introspection['client_id'] - : 'unknown', - extra: introspection, - scopes: splitScopes(introspection['scope']), - token, - ...(Number.isFinite(expiresAt) ? { expiresAt } : {}), - } - } - - async authenticateRequest( - req: AuthenticatedRequest, - res: ServerResponse, - resourceMetadataUrl: string, - ): Promise<{ authInfo: AuthInfo; ok: true } | { ok: false }> { - const authHeader = getRequestHeaderValue(req.headers.authorization).trim() - if (!authHeader) { - writeOAuthError( - res, - 401, - 'invalid_request', - 'Missing Authorization header', - resourceMetadataUrl, - ) - return { ok: false } - } - // `authHeader` is non-empty (guarded above), so split always - // yields at least one element — `parts[0]` is always a string. - const parts = authHeader.split(/\s+/u) - const type = parts[0]! - const token = parts[1] - if (type.toLowerCase() !== 'bearer' || !token) { - writeOAuthError( - res, - 401, - 'invalid_request', - "Invalid Authorization header format, expected 'Bearer TOKEN'", - resourceMetadataUrl, - ) - return { ok: false } - } - let authInfo: AuthInfo | undefined - try { - authInfo = await this.verifyAccessToken(token) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - this.log.error(`Token verification failed: ${message}`) - writeJson(res, 500, { - error: 'server_error', - error_description: 'Token verification failed', - }) - return { ok: false } - } - if (!authInfo) { - writeOAuthError( - res, - 401, - 'invalid_token', - 'Invalid or expired token', - resourceMetadataUrl, - ) - return { ok: false } - } - if ( - typeof authInfo.expiresAt === 'number' && - authInfo.expiresAt < Date.now() / 1000 - ) { - writeOAuthError( - res, - 401, - 'invalid_token', - 'Token has expired', - resourceMetadataUrl, - ) - return { ok: false } - } - const missing = this.requiredScopes.filter( - s => !authInfo!.scopes.includes(s), - ) - if (missing.length > 0) { - writeOAuthError( - res, - 403, - 'insufficient_scope', - `Missing required scopes: ${missing.join(', ')}`, - resourceMetadataUrl, - ) - return { ok: false } - } - req.auth = authInfo - return { authInfo, ok: true } - } -} - -export function buildProtectedResourceMetadata( - baseUrl: URL, - oauthMetadata: OAuthMetadata, - requiredScopes: readonly string[], -): Record<string, unknown> { - return { - authorization_servers: [oauthMetadata.issuer], - resource: new URL('/', baseUrl).href, - resource_name: 'Socket MCP Server', - scopes_supported: requiredScopes, - } -} - -/** - * Destroy a session by id. Closes the transport (catching synchronous throws) - * and the server (swallowing async rejections), then deletes the session entry - * and logs. - * - * The transport-close try/catch and the server-close `.catch()` are here - * because the SDK's close path can fault when called during an already-closing - * connection (e.g. client disconnect mid-stream); we want destroySession to be - * safe to call repeatedly without propagating those races. - */ -export interface SessionLike { - lastActivity: number - server: { close(): Promise<unknown> } - transport: { close(): void } -} - -export function destroySessionEntry<T extends SessionLike>( - id: string, - sessions: Map<string, T>, - log: { info: (msg: string) => void }, -): void { - const s = sessions.get(id) - if (!s) { - return - } - sessions.delete(id) - try { - s.transport.close() - } catch {} - s.server.close().catch(() => {}) - log.info(`Session ${id} destroyed`) -} - -export function getForwardedHeaderValue( - header: string | string[] | undefined, -): string { - return getRequestHeaderValue(header).split(',', 1)[0]?.trim() || '' -} - -export function getProtectedResourceMetadataUrl(baseUrl: URL): string { - return new URL(OAUTH_PROTECTED_RESOURCE_METADATA_PATH, baseUrl).href -} - -export function getRequestBaseUrl( - req: IncomingMessage, - fallbackPort: number, - trustProxy: boolean, -): URL { - const forwardedProto = trustProxy - ? getForwardedHeaderValue(req.headers['x-forwarded-proto']).toLowerCase() - : '' - const forwardedHost = trustProxy - ? getForwardedHeaderValue(req.headers['x-forwarded-host']) - : '' - const host = - forwardedHost || - getRequestHeaderValue(req.headers.host).trim() || - `localhost:${fallbackPort}` - const socketWithTls = req.socket as { encrypted?: boolean | undefined } - const protocol = - forwardedProto === 'http' || forwardedProto === 'https' - ? forwardedProto - : socketWithTls.encrypted - ? 'https' - : 'http' - return new URL(`${protocol}://${host}/`) -} - -export function getRequestHeaderValue( - header: string | string[] | undefined, -): string { - if (Array.isArray(header)) { - return header[0] || '' - } - return header || '' -} - -/** - * Run a request handler, surfacing failures as a JSON-RPC -32603 (Internal - * server error). Used by the GET / DELETE / POST flows so a transport-level - * exception doesn't kill the connection without a client-readable response. If - * the response has already started streaming (`res.headersSent`), nothing is - * written — the SDK is in the middle of producing output and another writeHead - * would crash the worker. - */ -export async function handleRequestSafely( - label: string, - res: ServerResponse, - log: { error: (msg: string) => void }, - fn: () => Promise<void>, -): Promise<void> { - try { - await fn() - } catch (e) { - log.error(`Error processing ${label} request: ${e}`) - if (!res.headersSent) { - writeJson(res, 500, { - error: { code: -32603, message: 'Internal server error' }, - id: undefined, - jsonrpc: '2.0', - }) - } - } -} - -export function isLocalhostOrigin(originUrl: string): boolean { - try { - const u = new URL(originUrl) - return u.hostname === '127.0.0.1' || u.hostname === 'localhost' - } catch { - return false - } -} - -/** - * Build the `transport.onclose` handler that destroys the session keyed by the - * transport's sessionId. The `if (sessionId)` guard matters because onclose can - * fire before onsessioninitialized has assigned a sessionId (e.g. SDK init - * failure on a brand-new transport). - */ -export function makeOnTransportClose( - getSessionId: () => string | undefined, - destroy: (id: string) => void, -): () => void { - return () => { - const id = getSessionId() - if (id) { - destroy(id) - } - } -} - -export function parseJsonObject( - responseText: string, - context: string, -): Record<string, unknown> { - let parsed: unknown - try { - parsed = JSON.parse(responseText) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - throw new Error(`${context} returned invalid JSON: ${message}`) - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${context} returned invalid JSON: expected a JSON object`) - } - return parsed as Record<string, unknown> -} - -/** - * Walk a session map and destroy entries whose lastActivity is older than - * `ttlMs`. Used by the periodic reaper interval. - */ -export function reapIdleSessions<T extends { lastActivity: number }>( - now: number, - ttlMs: number, - sessions: Map<string, T>, - destroy: (id: string) => void, - log: { info: (msg: string) => void }, -): void { - for (const [id, session] of sessions.entries()) { - if (now - session.lastActivity > ttlMs) { - log.info(`Reaping idle session ${id}`) - destroy(id) - } - } -} - -export function splitScopes(scope: unknown): string[] { - if (typeof scope !== 'string') { - return [] - } - return scope - .split(/\s+/u) - .map(value => value.trim()) - .filter(Boolean) -} - -export function writeJson( - res: ServerResponse, - statusCode: number, - body: unknown, - headers: Record<string, string> = {}, -): void { - res.writeHead(statusCode, { - 'Content-Type': 'application/json', - ...headers, - }) - res.end(JSON.stringify(body)) -} - -export function writeOAuthError( - res: ServerResponse, - statusCode: number, - errorCode: string, - message: string, - resourceMetadataUrl?: string | undefined, -): void { - const authenticateValue = resourceMetadataUrl - ? `Bearer error="${errorCode}", error_description="${message}", resource_metadata="${resourceMetadataUrl}"` - : `Bearer error="${errorCode}", error_description="${message}"` - writeJson( - res, - statusCode, - { - error: errorCode, - error_description: message, - }, - { 'WWW-Authenticate': authenticateValue }, - ) -} diff --git a/packages/cli/src/commands/mcp/transport-http.mts b/packages/cli/src/commands/mcp/transport-http.mts deleted file mode 100644 index 175d5253f..000000000 --- a/packages/cli/src/commands/mcp/transport-http.mts +++ /dev/null @@ -1,383 +0,0 @@ -import crypto from 'node:crypto' -import { createServer } from 'node:http' - -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { createConfiguredServer } from './server.mts' -import { - OAUTH_PROTECTED_RESOURCE_METADATA_PATH, - OAuthIntrospector, - buildProtectedResourceMetadata, - destroySessionEntry, - getProtectedResourceMetadataUrl, - getRequestBaseUrl, - getRequestHeaderValue, - handleRequestSafely, - isLocalhostOrigin, - makeOnTransportClose, - reapIdleSessions, - writeJson, -} from './transport-http-helpers.mts' - -import type { Server } from '@modelcontextprotocol/sdk/server/index.js' -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' -import type { ServerConfig } from './server.mts' -import type { IncomingMessage } from 'node:http' - -const logger = getDefaultLogger() - -const SESSION_TTL_MS = 30 * 60 * 1000 -const SESSION_REAP_INTERVAL_MS = 60_000 - -// Our internal type accepts `auth: undefined` explicitly so callers can -// pass an undefined-stamped request without ceremony (spread, conditional -// assignment, etc.). -type AuthenticatedRequest = IncomingMessage & { auth?: AuthInfo | undefined } - -// MCP's `transport.handleRequest()` parameter is the stricter -// `auth?: AuthInfo` (no `| undefined`) under our -// exactOptionalPropertyTypes. Cast our internal type to this at the -// call boundary when handing off; that's the narrow constraint, not -// our internal shape. -// oxlint-disable-next-line socket/optional-explicit-undefined -- SDK target type uses `auth?: AuthInfo` (no `| undefined`); under exactOptionalPropertyTypes the bare-undefined form rejects this assignment. Pair to the SDK shape, not the local AuthenticatedRequest. -type McpHandleRequest = IncomingMessage & { auth?: AuthInfo } - -interface Session { - lastActivity: number - server: Server - transport: StreamableHTTPServerTransport -} - -interface HttpTransportConfig extends ServerConfig { - oauthClientId: string - oauthClientSecret: string - oauthIssuer: string - oauthRequiredScopes: readonly string[] - port: number - trustProxy: boolean -} - -export async function runHttpTransport( - config: HttpTransportConfig, -): Promise<void> { - const oauthEnabled = Boolean( - config.oauthIssuer && config.oauthClientId && config.oauthClientSecret, - ) - const introspector = oauthEnabled - ? new OAuthIntrospector( - config.oauthIssuer, - config.oauthClientId, - config.oauthClientSecret, - config.oauthRequiredScopes, - logger, - ) - : undefined - - if (introspector) { - try { - await introspector.loadMetadata() - logger.info( - `Enabled OAuth-backed MCP auth with issuer ${config.oauthIssuer}`, - ) - } catch (e) { - // loadMetadata only throws Error subclasses (httpRequest / - // parseJsonObject / explicit throws); read .message directly. - logger.error( - `Failed to initialize OAuth metadata: ${(e as Error).message}`, - ) - throw e - } - } - - const sessions = new Map<string, Session>() - - const destroySession = (id: string): void => - destroySessionEntry(id, sessions, logger) - - const tickReaper = () => - reapIdleSessions( - Date.now(), - SESSION_TTL_MS, - sessions, - destroySession, - logger, - ) - // First tick runs immediately — the session map is empty so this is - // a no-op, but it gives the test surface a deterministic way to - // invoke the reaper (and gives coverage tools a one-shot through - // the function body). - tickReaper() - const reapInterval = setInterval(tickReaper, SESSION_REAP_INTERVAL_MS) - reapInterval.unref() - - const allowedOrigins = [ - 'https://mcp.socket.dev', - 'https://mcp.socket-staging.dev', - ] as const - const allowedHosts = allowedOrigins.map(o => new URL(o).hostname) - - const httpServer = createServer(async (req, res) => { - const authenticatedReq = req as AuthenticatedRequest - let url: URL - try { - url = new URL(req.url!, `http://localhost:${config.port}`) - } catch (e) { - logger.warn(`Invalid URL in request: ${req.url} - ${e}`) - writeJson(res, 400, { - error: { code: -32000, message: 'Bad Request: Invalid URL' }, - id: undefined, - jsonrpc: '2.0', - }) - return - } - - if (url.pathname === '/health') { - writeJson(res, 200, { - service: 'socket-mcp', - status: 'healthy', - timestamp: new Date().toISOString(), - version: config.version, - }) - return - } - - const origin = getRequestHeaderValue(req.headers.origin).trim() - const host = getRequestHeaderValue(req.headers.host).trim() - const isAllowedHost = - host === `localhost:${config.port}` || - host === `127.0.0.1:${config.port}` || - host === 'localhost' || - host === '127.0.0.1' || - allowedHosts.includes(host) - const isValidOrigin = origin - ? isLocalhostOrigin(origin) || - (allowedOrigins as readonly string[]).includes(origin) - : isAllowedHost - - if (!isValidOrigin) { - logger.warn( - `Rejected request from invalid origin: ${origin || 'missing'} (host: ${host})`, - ) - writeJson(res, 403, { - error: { code: -32000, message: 'Forbidden: Invalid origin' }, - id: undefined, - jsonrpc: '2.0', - }) - return - } - - if (origin) { - res.setHeader('Access-Control-Allow-Origin', origin) - res.setHeader( - 'Access-Control-Allow-Methods', - 'GET, POST, DELETE, OPTIONS', - ) - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization, Content-Type, Accept, Mcp-Session-Id', - ) - res.setHeader( - 'Access-Control-Expose-Headers', - 'Mcp-Session-Id, WWW-Authenticate', - ) - } - - if (req.method === 'OPTIONS') { - res.writeHead(200) - res.end() - return - } - - const baseUrl = getRequestBaseUrl(req, config.port, config.trustProxy) - - if ( - introspector && - url.pathname === OAUTH_PROTECTED_RESOURCE_METADATA_PATH - ) { - // loadMetadata is memoized after the successful startup probe, - // so this resolves synchronously from cache. - const metadata = await introspector.loadMetadata() - writeJson( - res, - 200, - buildProtectedResourceMetadata( - baseUrl, - metadata, - config.oauthRequiredScopes, - ), - ) - return - } - - if (url.pathname !== '/') { - res.writeHead(404) - res.end('Not found') - return - } - - // Some clients (e.g. Cursor) omit the required Accept value; patch it - // before the SDK rejects with 406. - const accept = req.headers.accept || '' - if ( - !accept.includes('application/json') || - !accept.includes('text/event-stream') - ) { - const requiredAccept = 'application/json, text/event-stream' - req.headers.accept = requiredAccept - const idx = req.rawHeaders.findIndex(h => h.toLowerCase() === 'accept') - if (idx !== -1) { - req.rawHeaders[idx + 1] = requiredAccept - } else { - req.rawHeaders.push('Accept', requiredAccept) - } - } - - if (introspector) { - const authResult = await introspector.authenticateRequest( - authenticatedReq, - res, - getProtectedResourceMetadataUrl(baseUrl), - ) - if (!authResult.ok) { - return - } - } - - if (req.method === 'POST') { - let body = '' - req.on('data', (chunk: string | Buffer) => { - body += chunk.toString() - }) - req.on('end', () => - handleRequestSafely('POST', res, logger, async () => { - const jsonData = JSON.parse(body) - const sessionId = - getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const session = sessionId ? sessions.get(sessionId) : undefined - let transport = session?.transport - - if (!transport && isInitializeRequest(jsonData)) { - const clientInfo = jsonData.params?.clientInfo - logger.info( - `Client connected: ${clientInfo?.name || 'unknown'} v${clientInfo?.version || 'unknown'} from ${origin || host}`, - ) - const server = createConfiguredServer(config) - const newTransport = new StreamableHTTPServerTransport({ - enableJsonResponse: true, - onsessionclosed: id => { - destroySession(id) - }, - onsessioninitialized: id => { - sessions.set(id, { - lastActivity: Date.now(), - server, - transport: newTransport, - }) - }, - sessionIdGenerator: () => crypto.randomUUID(), - }) - // eslint-disable-next-line unicorn/prefer-add-event-listener -- MCP SDK exposes onclose as a setter, not an EventTarget. - newTransport.onclose = makeOnTransportClose( - () => newTransport.sessionId, - destroySession, - ) - transport = newTransport - await server.connect(transport as Transport) - } - - if (!transport) { - writeJson(res, 400, { - error: { - code: -32000, - message: - 'Bad Request: No valid session. Send initialize first.', - }, - id: undefined, - jsonrpc: '2.0', - }) - return - } - - if (sessionId) { - const activeSession = sessions.get(sessionId) - if (activeSession) { - activeSession.lastActivity = Date.now() - } - } - - await transport.handleRequest( - authenticatedReq as McpHandleRequest, - res, - jsonData, - ) - }), - ) - return - } - - if (req.method === 'GET') { - const sessionId = - getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const session = sessionId ? sessions.get(sessionId) : undefined - if (!session) { - writeJson(res, 404, { - error: { - code: -32000, - message: 'Not Found: Invalid or expired session. Re-initialize.', - }, - id: undefined, - jsonrpc: '2.0', - }) - return - } - await handleRequestSafely('GET', res, logger, async () => { - session.lastActivity = Date.now() - await session.transport.handleRequest( - authenticatedReq as McpHandleRequest, - res, - ) - }) - return - } - - if (req.method === 'DELETE') { - const sessionId = - getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const transport = sessionId - ? sessions.get(sessionId)?.transport - : undefined - if (!transport) { - writeJson(res, 404, { - error: { - code: -32000, - message: 'Not Found: Invalid or expired session.', - }, - id: undefined, - jsonrpc: '2.0', - }) - return - } - await handleRequestSafely('DELETE', res, logger, async () => { - await transport.handleRequest(authenticatedReq as McpHandleRequest, res) - }) - return - } - - res.writeHead(405) - res.end('Method not allowed') - }) - - await new Promise<void>(resolve => { - httpServer.listen(config.port, () => { - logger.info( - `Socket MCP HTTP server version ${config.version} started successfully on port ${config.port}`, - ) - logger.info(`Connect to: http://localhost:${config.port}/`) - resolve() - }) - }) -} diff --git a/packages/cli/src/commands/mcp/transport-stdio.mts b/packages/cli/src/commands/mcp/transport-stdio.mts deleted file mode 100644 index 8c55b59fe..000000000 --- a/packages/cli/src/commands/mcp/transport-stdio.mts +++ /dev/null @@ -1,19 +0,0 @@ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { createConfiguredServer } from './server.mts' - -import type { ServerConfig } from './server.mts' - -const logger = getDefaultLogger() - -export async function runStdioTransport(config: ServerConfig): Promise<void> { - logger.info('Starting Socket MCP server in stdio mode') - const server = createConfiguredServer(config) - const transport = new StdioServerTransport() - await server.connect(transport) - logger.info( - `Socket MCP server version ${config.version} started successfully (stdio)`, - ) -} diff --git a/packages/cli/src/commands/npm/cmd-npm.mts b/packages/cli/src/commands/npm/cmd-npm.mts deleted file mode 100644 index 725daf0e9..000000000 --- a/packages/cli/src/commands/npm/cmd-npm.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Socket npm command — forwards npm operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { NPM } from '@socketsecurity/lib-stable/constants/agents' - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const CMD_NAME = NPM - -export const cmdNpm = defineHandoffCommand({ - name: NPM, - description: 'Run npm with Socket Firewall security', - // Use `auto` so SEA builds extract the npm shim from VFS while CLI - // installs fall back to the dlx download path. - spawnMode: 'auto', - examples: ['', 'install cowsay', 'install -g cowsay'], - showApiRequirements: true, - wrapperHint: true, -}) diff --git a/packages/cli/src/commands/npx/cmd-npx.mts b/packages/cli/src/commands/npx/cmd-npx.mts deleted file mode 100644 index 539dd963e..000000000 --- a/packages/cli/src/commands/npx/cmd-npx.mts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Socket npx command — forwards npx operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { NPX } from '@socketsecurity/lib-stable/constants/agents' - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdNpx = defineHandoffCommand({ - name: NPX, - description: 'Run pnpm exec with Socket Firewall security', // socket-hook: allow npx - spawnMode: 'auto', - examples: ['cowsay', 'cowsay@1.6.0 hello'], - showApiRequirements: true, - wrapperHint: true, -}) diff --git a/packages/cli/src/commands/nuget/cmd-nuget.mts b/packages/cli/src/commands/nuget/cmd-nuget.mts deleted file mode 100644 index 6ec745b5e..000000000 --- a/packages/cli/src/commands/nuget/cmd-nuget.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Socket nuget command — forwards nuget operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdNuget = defineHandoffCommand({ - name: 'nuget', - description: 'Run nuget with Socket Firewall security', - spawnMode: 'dlx', - examples: ['install Newtonsoft.Json', 'restore', 'list'], - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/oops/cmd-oops.mts b/packages/cli/src/commands/oops/cmd-oops.mts deleted file mode 100644 index 9dfb3e6ee..000000000 --- a/packages/cli/src/commands/oops/cmd-oops.mts +++ /dev/null @@ -1,116 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { DRY_RUN_LABEL } from '../../constants/cli.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -export const CMD_NAME = 'oops' - -const description = 'Trigger an intentional error (for development)' - -const hidden = true - -// Command handler. - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - throw: { - type: 'boolean', - default: false, - description: - 'Throw an explicit error even if --json or --markdown are set', - }, - }), - help: ( - parentName: string, - config: { commandName: string; flags: MeowFlags }, - ) => ` - Usage - $ ${parentName} ${config.commandName} - - Don't run me. - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown, throw: justThrow } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - if (dryRun) { - // Dry-run previews are contextual output; route to stderr per the - // stream discipline rule so stdout stays payload-only. - logger.error('') - logger.error(`${DRY_RUN_LABEL}: Would trigger an intentional error`) - logger.error('') - logger.error( - ' This command throws an error for development/testing purposes.', - ) - logger.error(` Error message: "This error was intentionally left blank."`) - logger.error('') - if (json && !justThrow) { - logger.error(' Output format: JSON error response') - } else if (markdown && !justThrow) { - logger.error(' Output format: Markdown error message') - } else { - logger.error(' Output format: Thrown Error exception') - } - logger.error('') - logger.error(' Run without --dry-run to trigger the error.') - logger.error('') - return - } - - if (json && !justThrow) { - process.exitCode = 1 - logger.log( - serializeResultJson({ - ok: false, - message: 'Oops', - cause: 'This error was intentionally left blank', - }), - ) - } - - if (markdown && !justThrow) { - process.exitCode = 1 - logger.fail( - failMsgWithBadge('Oops', 'This error was intentionally left blank'), - ) - return - } - - throw new Error('This error was intentionally left blank.') -} - -// Exported command. - -export const cmdOops = { - description, - hidden, - run, -} diff --git a/packages/cli/src/commands/optimize/add-overrides.mts b/packages/cli/src/commands/optimize/add-overrides.mts deleted file mode 100644 index eed127d3e..000000000 --- a/packages/cli/src/commands/optimize/add-overrides.mts +++ /dev/null @@ -1,327 +0,0 @@ -import path from 'node:path' - -import semver from 'semver' - -import { NPM, PNPM } from '@socketsecurity/lib-stable/constants/agents' -import { hasOwn } from '@socketsecurity/lib-stable/objects/predicates' -import { toSortedObject } from '@socketsecurity/lib-stable/objects/sort' -import { fetchPackageManifest } from '@socketsecurity/lib-stable/packages/manifest' -import { pEach } from '@socketsecurity/lib-stable/promises/iterate' -import { getManifestData } from '@socketsecurity/registry-stable' - -import { lsStdoutIncludes } from './deps-includes-by-agent.mts' -import { getDependencyEntries } from './get-dependency-entries.mts' -import { - getOverridesData, - getOverridesDataNpm, - getOverridesDataYarnClassic, -} from './get-overrides-by-agent.mts' -import { lockSrcIncludes } from './lockfile-includes-by-agent.mts' -import { listPackages } from './ls-by-agent.mts' -import { CMD_NAME } from './shared.mts' -import { updateManifest } from './update-manifest-by-agent.mts' -import { globWorkspace } from '../../util/fs/glob.mts' -import { safeNpa } from '../../util/npm/package-arg.mts' -import { cmdPrefixMessage } from '../../util/process/cmd.mts' -import { getMajor } from '../../util/semver.mts' - -import type { GetOverridesResult } from './get-overrides-by-agent.mts' -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' -import type { AliasResult } from '../../util/npm/package-arg.mts' -import type { Logger } from '@socketsecurity/lib-stable/logger/types' -import type { PackageJson } from '@socketsecurity/lib-stable/packages/operations' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -type AddOverridesOptions = { - logger?: Logger | undefined - pin?: boolean | undefined - prod?: boolean | undefined - spinner?: SpinnerInstance | undefined - state?: AddOverridesState | undefined -} -type AddOverridesState = { - added: Set<string> - addedInWorkspaces: Set<string> - updated: Set<string> - updatedInWorkspaces: Set<string> - warnedPnpmWorkspaceRequiresNpm: boolean -} - -const manifestNpmOverrides = getManifestData(NPM) ?? [] - -export async function addOverrides( - pkgEnvDetails: EnvDetails, - pkgPath: string, - options?: AddOverridesOptions | undefined, -): Promise<AddOverridesState> { - const { - agent, - lockName, - lockSrc, - npmExecPath, - pkgPath: rootPath, - } = pkgEnvDetails - const { - logger, - pin, - prod, - spinner, - state = { - added: new Set(), - addedInWorkspaces: new Set(), - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - }, - } = { __proto__: null, ...options } as AddOverridesOptions - const workspacePkgJsonPaths = await globWorkspace(agent, pkgPath) - const isPnpm = agent === PNPM - const isWorkspace = workspacePkgJsonPaths.length > 0 - const isWorkspaceRoot = pkgPath === rootPath - const isLockScanned = isWorkspaceRoot && !prod - const workspace = isWorkspaceRoot ? 'root' : path.relative(rootPath, pkgPath) - if ( - isWorkspace && - isPnpm && - // npmExecPath will === the agent name IF it CANNOT be resolved. - npmExecPath === NPM && - !state.warnedPnpmWorkspaceRequiresNpm - ) { - state.warnedPnpmWorkspaceRequiresNpm = true - spinner?.stop() - logger?.warn( - cmdPrefixMessage( - CMD_NAME, - `${agent} workspace support requires \`npm ls\`, falling back to \`${agent} list\``, - ), - ) - spinner?.start() - } - - const overridesDataObjects = [] as GetOverridesResult[] - if (isWorkspace || pkgEnvDetails.editablePkgJson.content.private) { - overridesDataObjects.push(getOverridesData(pkgEnvDetails)) - } else { - overridesDataObjects.push( - getOverridesDataNpm(pkgEnvDetails), - getOverridesDataYarnClassic(pkgEnvDetails), - ) - } - - const depAliasMap = new Map<string, string>() - const depEntries = getDependencyEntries(pkgEnvDetails) - - const addingText = `Adding overrides to ${workspace}...` - let loggedAddingText = false - - // Chunk package names to process them in parallel 3 at a time. - await pEach( - manifestNpmOverrides, - async ({ - 1: data, - }: { - 1: { name: string; package: string; version: string } - }) => { - const { name: sockRegPkgName, package: origPkgName, version } = data - const major = getMajor(version) - if (major === undefined) { - return - } - const sockOverridePrefix = `npm:${sockRegPkgName}@` - const sockOverrideSpec = `${sockOverridePrefix}${pin ? version : `^${major}`}` - for (const { 1: depObj } of depEntries) { - const sockSpec = hasOwn(depObj, sockRegPkgName) - ? (depObj[sockRegPkgName] as string) - : undefined - if (sockSpec) { - depAliasMap.set(sockRegPkgName, sockSpec) - } - const origSpec = hasOwn(depObj, origPkgName) - ? (depObj[origPkgName] as string) - : undefined - if (origSpec) { - let thisSpec = origSpec - // Add package aliases for direct dependencies to avoid npm EOVERRIDE - // errors... - // https://docs.npmjs.com/cli/v8/using-npm/package-spec#aliases - if ( - // ...if the spec doesn't start with a valid Socket override. - !( - thisSpec.startsWith(sockOverridePrefix) && - (() => { - // Check the validity of the spec by parsing it with npm-package-arg - // and seeing if it will coerce to a version. - const parsed = safeNpa(thisSpec) - if (!parsed || parsed.type !== 'alias') { - return false - } - return semver.coerce((parsed as AliasResult).subSpec.rawSpec) - ?.version - })() - ) - ) { - thisSpec = sockOverrideSpec - depObj[origPkgName] = thisSpec - state.added.add(sockRegPkgName) - if (!isWorkspaceRoot) { - state.addedInWorkspaces.add(workspace) - } - if (!loggedAddingText) { - spinner?.text(addingText) - loggedAddingText = true - } - } - depAliasMap.set(origPkgName, thisSpec) - } - } - if (isWorkspaceRoot) { - // The lockSrcIncludes and lsStdoutIncludes functions overlap in their - // first two parameters. lockSrcIncludes accepts an optional third parameter - // which lsStdoutIncludes will ignore. - const thingScanner = ( - isLockScanned ? lockSrcIncludes : lsStdoutIncludes - ) as typeof lockSrcIncludes - - const thingToScan = isLockScanned - ? lockSrc - : await listPackages(pkgEnvDetails, { cwd: pkgPath, npmExecPath }) - // Chunk package names to process them in parallel 3 at a time. - await pEach( - overridesDataObjects, - async ({ overrides, type }) => { - const overrideExists = hasOwn(overrides, origPkgName) - if ( - overrideExists || - thingScanner(pkgEnvDetails, thingToScan, origPkgName, lockName) - ) { - const oldSpec = overrideExists - ? overrides[origPkgName]! - : undefined - const origDepAlias = depAliasMap.get(origPkgName) - const sockRegDepAlias = depAliasMap.get(sockRegPkgName) - const depAlias = sockRegDepAlias ?? origDepAlias - let newSpec = sockOverrideSpec - if (type === NPM && depAlias) { - // With npm one may not set an override for a package that one directly - // depends on unless both the dependency and the override itself share - // the exact same spec. To make this limitation easier to deal with, - // overrides may also be defined as a reference to a spec for a direct - // dependency by prefixing the name of the package to match the version - // of with a $. - // https://docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides - newSpec = `$${sockRegDepAlias ? sockRegPkgName : origPkgName}` - } else if (typeof oldSpec === 'string') { - const thisSpec = oldSpec.startsWith('$') - ? depAlias || newSpec - : oldSpec || newSpec - if (thisSpec.startsWith(sockOverridePrefix)) { - if ( - pin && - getMajor( - // Check the validity of the spec by parsing it with npm-package-arg - // and seeing if it will coerce to a version. semver.coerce - // will strip leading v's, carets (^), comparators (<,<=,>,>=,=), - // and tildes (~). If not coerced to a valid version then - // default to the manifest entry version. - (() => { - const parsed = safeNpa(thisSpec) - /* c8 ignore start - defensive: alias-spec path with semver.coerce returning falsy is unreachable in current optimize tests */ - if (parsed && parsed.type === 'alias') { - return ( - semver.coerce( - (parsed as AliasResult).subSpec.rawSpec, - )?.version ?? version - ) - } - /* c8 ignore stop */ - return version - })(), - ) !== major - ) { - const manifest = await fetchPackageManifest(thisSpec) - const otherVersion = ( - manifest as { version?: string | undefined } - )?.version - if (otherVersion && otherVersion !== version) { - const otherMajor = getMajor(otherVersion) - if (otherMajor !== undefined) { - newSpec = `${sockOverridePrefix}${pin ? otherVersion : `^${otherMajor}`}` - } - } - } - } else { - newSpec = oldSpec - } - } - if (newSpec !== oldSpec) { - overrides[origPkgName] = newSpec - const addedOrUpdated = overrideExists ? 'updated' : 'added' - state[addedOrUpdated].add(sockRegPkgName) - if (!loggedAddingText) { - spinner?.text(addingText) - loggedAddingText = true - } - } - } - }, - { concurrency: 3 }, - ) - } - }, - { concurrency: 3 }, - ) - - if (isWorkspace) { - // Chunk package names to process them in parallel 3 at a time. - await pEach( - workspacePkgJsonPaths, - async workspacePkgJsonPath => { - const otherState = await addOverrides( - pkgEnvDetails, - path.dirname(workspacePkgJsonPath), - { - logger, - pin, - prod, - spinner, - }, - ) - for (const key of [ - 'added', - 'addedInWorkspaces', - 'updated', - 'updatedInWorkspaces', - ] satisfies Array< - // of the type and that they're all Set<string> props. // Here we're just telling TS that we're looping over key names - keyof Pick< - AddOverridesState, - 'added' | 'addedInWorkspaces' | 'updated' | 'updatedInWorkspaces' - > - >) { - for (const value of otherState[key]) { - state[key].add(value) - } - } - }, - { concurrency: 3 }, - ) - } - - if (state.added.size > 0 || state.updated.size > 0) { - pkgEnvDetails.editablePkgJson.update( - Object.fromEntries(depEntries) as PackageJson, - ) - if (isWorkspaceRoot) { - for (const { overrides, type } of overridesDataObjects) { - // updateManifest is async because the pnpm 11+ path writes to - // pnpm-workspace.yaml; older pnpm and other agents resolve - // synchronously inside this call. - // each agent's overrides write must complete before the next. - await updateManifest(type, pkgEnvDetails, toSortedObject(overrides)) - } - } - await pkgEnvDetails.editablePkgJson.save() - } - - return state -} diff --git a/packages/cli/src/commands/optimize/agent-installer.mts b/packages/cli/src/commands/optimize/agent-installer.mts deleted file mode 100644 index 7e5f0b049..000000000 --- a/packages/cli/src/commands/optimize/agent-installer.mts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Package manager agent installation utilities for optimize command. Manages - * package installation via different package managers during optimization. - * - * Key Functions: - runAgentInstall: Execute package installation with detected - * agent. - * - * Supported Agents: - npm: Node Package Manager - pnpm: Fast, disk space - * efficient package manager - yarn: Alternative package manager. - * - * Features: - Automatic agent detection - Spinner support for progress - * indication - CI-mode configuration for non-interactive execution. - */ - -import { NPM, PNPM } from '@socketsecurity/lib-stable/constants/agents' -import { - getNodeDisableSigusr1Flags, - getNodeHardenFlags, - getNodeNoWarningsFlags, -} from '@socketsecurity/lib-stable/constants/node' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getOwn } from '@socketsecurity/lib-stable/objects/inspect' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { cmdFlagsToString } from '../../util/process/cmd.mts' - -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -type SpawnOption = Exclude<Parameters<typeof spawn>[2], undefined> - -interface AgentInstallOptions extends SpawnOption { - args?: string[] | readonly string[] | undefined - spinner?: SpinnerInstance | undefined -} - -type AgentSpawnResult = ReturnType<typeof spawn> - -/** - * Execute package installation with the detected package manager agent. Handles - * different package managers with appropriate configuration for optimization. - */ -export function runAgentInstall( - pkgEnvDetails: EnvDetails, - options?: AgentInstallOptions | undefined, -): AgentSpawnResult { - const { agent, agentExecPath, pkgPath } = pkgEnvDetails - const isNpm = agent === NPM - const isPnpm = agent === PNPM - - const { - args = [], - spinner, - ...spawnOpts - } = { __proto__: null, ...options } as AgentInstallOptions - - // Skip harden flags for older pnpm versions. - const skipNodeHardenFlags = isPnpm && pkgEnvDetails.agentVersion.major < 11 - - // Configure package manager specific install arguments. - let installArgs: string[] - if (isNpm) { - installArgs = [ - 'install', - // Avoid code paths for 'audit' and 'fund'. - '--no-audit', - '--no-fund', - ...args, - ] - } else if (isPnpm) { - installArgs = [ - 'install', - // Prevent interactive prompts in CI environments. - '--config.confirmModulesPurge=false', - // Allow lockfile updates (required for optimization). - '--no-frozen-lockfile', - ...args, - ] - } else { - installArgs = ['install', ...args] - } - - return spawn(agentExecPath, installArgs, { - cwd: pkgPath, - // Package managers on Windows often require shell execution. - shell: WIN32, - spinner, - stdio: 'inherit', - ...spawnOpts, - env: { - ...process.env, - // Set CI mode for pnpm to ensure consistent behavior. - ...(isPnpm ? { CI: '1' } : {}), - NODE_OPTIONS: cmdFlagsToString([ - ...(skipNodeHardenFlags ? [] : getNodeHardenFlags()), - ...getNodeNoWarningsFlags(), - ...getNodeDisableSigusr1Flags(), - ]), - // @ts-expect-error - getOwn may return undefined, but spread handles it - ...getOwn(spawnOpts, 'env'), - }, - }) -} diff --git a/packages/cli/src/commands/optimize/apply-optimization.mts b/packages/cli/src/commands/optimize/apply-optimization.mts deleted file mode 100644 index 6d967c3d5..000000000 --- a/packages/cli/src/commands/optimize/apply-optimization.mts +++ /dev/null @@ -1,68 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { addOverrides } from './add-overrides.mts' -import { CMD_NAME } from './shared.mts' -import { updateDependencies } from './update-dependencies.mts' - -import type { CResult } from '../../types.mts' -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' - -type OptimizeConfig = { - pin: boolean - prod: boolean -} - -export async function applyOptimization( - pkgEnvDetails: EnvDetails, - { pin, prod }: OptimizeConfig, -): Promise< - CResult<{ - addedCount: number - updatedCount: number - pkgJsonChanged: boolean - updatedInWorkspaces: number - addedInWorkspaces: number - }> -> { - const logger = getDefaultLogger() - const spinner = getDefaultSpinner() - - spinner?.start() - - const state = await addOverrides(pkgEnvDetails, pkgEnvDetails.pkgPath, { - logger, - pin, - prod, - spinner: spinner ?? undefined, - }) - - const addedCount = state.added.size - const updatedCount = state.updated.size - const pkgJsonChanged = addedCount > 0 || updatedCount > 0 - - if (pkgJsonChanged || pkgEnvDetails.features.npmBuggyOverrides) { - const result = await updateDependencies(pkgEnvDetails, { - cmdName: CMD_NAME, - logger, - spinner: spinner ?? undefined, - }) - - if (!result.ok) { - spinner?.stop() - return result - } - } - - spinner?.stop() - return { - ok: true, - data: { - addedCount, - addedInWorkspaces: state.addedInWorkspaces.size, - pkgJsonChanged, - updatedCount, - updatedInWorkspaces: state.updatedInWorkspaces.size, - }, - } -} diff --git a/packages/cli/src/commands/optimize/cmd-optimize.mts b/packages/cli/src/commands/optimize/cmd-optimize.mts deleted file mode 100644 index 824e5a573..000000000 --- a/packages/cli/src/commands/optimize/cmd-optimize.mts +++ /dev/null @@ -1,157 +0,0 @@ -import path from 'node:path' - -import { handleOptimize } from './handle-optimize.mts' -import { CMD_NAME as CMD_NAME_FULL } from './shared.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunPreview } from '../../util/dry-run/output.mts' -import { detectAndValidatePackageEnvironment } from '../../util/ecosystem/environment.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { DryRunAction } from '../../util/dry-run/output.mts' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'optimize' - -const description = 'Optimize dependencies with @socketregistry overrides' - -const hidden = false - -export const cmdOptimize = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - pin: { - type: 'boolean', - default: false, - description: 'Pin overrides to latest version', - }, - prod: { - type: 'boolean', - default: false, - description: 'Add overrides for production dependencies only', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} ./path/to/project --pin - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const dryRun = !!cli.flags['dryRun'] - - const { json, markdown, pin, prod } = cli.flags - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const outputKind = getOutputKind(json, markdown) - - if (dryRun) { - // Detect package environment to show meaningful dry-run output. - const pkgEnvCResult = await detectAndValidatePackageEnvironment(cwd, { - cmdName: CMD_NAME_FULL, - prod: Boolean(prod), - }) - - if (!pkgEnvCResult.ok) { - outputDryRunPreview({ - summary: 'Optimize dependencies with @socketregistry overrides', - actions: [ - { - type: 'fetch', - description: 'Detect package environment', - target: cwd, - }, - ], - wouldSucceed: false, - }) - return - } - - const pkgEnvDetails = pkgEnvCResult.data - const { agent, agentVersion, pkgPath } = pkgEnvDetails - - const actions: DryRunAction[] = [ - { - type: 'fetch', - description: `Detected ${agent} v${agentVersion}`, - target: pkgPath, - }, - { - type: 'fetch', - description: 'Analyze dependencies against @socketregistry overrides', - target: 'package.json and lockfile', - }, - { - type: 'modify', - description: 'Add or update overrides section in package.json', - target: path.join(pkgPath, 'package.json'), - details: { - pin: pin - ? 'Yes - pin to specific versions' - : 'No - use version ranges', - prod: prod - ? 'Yes - production dependencies only' - : 'No - all dependencies', - }, - }, - { - type: 'execute', - description: `Run ${agent} to install optimized dependencies`, - }, - ] - - outputDryRunPreview({ - summary: `Optimize dependencies with @socketregistry overrides (${agent} v${agentVersion})`, - actions, - wouldSucceed: true, - }) - return - } - - await handleOptimize({ - cwd, - pin: Boolean(pin), - outputKind, - prod: Boolean(prod), - }) -} diff --git a/packages/cli/src/commands/optimize/deps-includes-by-agent.mts b/packages/cli/src/commands/optimize/deps-includes-by-agent.mts deleted file mode 100644 index 1d7f1907a..000000000 --- a/packages/cli/src/commands/optimize/deps-includes-by-agent.mts +++ /dev/null @@ -1,30 +0,0 @@ -import { - BUN, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' - -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' - -export function lsStdoutIncludes( - pkgEnvDetails: EnvDetails, - stdout: string, - name: string, -): boolean { - switch (pkgEnvDetails.agent) { - case BUN: - case YARN_BERRY: - case YARN_CLASSIC: - return matchLsCmdViewHumanStdout(stdout, name) - default: - return matchQueryCmdStdout(stdout, name) - } -} - -export function matchLsCmdViewHumanStdout(stdout: string, name: string) { - return stdout.includes(` ${name}@`) -} - -export function matchQueryCmdStdout(stdout: string, name: string) { - return stdout.includes(`"${name}"`) -} diff --git a/packages/cli/src/commands/optimize/handle-optimize.mts b/packages/cli/src/commands/optimize/handle-optimize.mts deleted file mode 100644 index 95391aa2a..000000000 --- a/packages/cli/src/commands/optimize/handle-optimize.mts +++ /dev/null @@ -1,95 +0,0 @@ -import { VLT } from '@socketsecurity/lib-stable/constants/agents' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { applyOptimization } from './apply-optimization.mts' -import { outputOptimizeResult } from './output-optimize-result.mts' -import { CMD_NAME } from './shared.mts' -import { detectAndValidatePackageEnvironment } from '../../util/ecosystem/environment.mjs' -import { cmdPrefixMessage } from '../../util/process/cmd.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleOptimize({ - cwd, - outputKind, - pin, - prod, -}: { - cwd: string - outputKind: OutputKind - pin: boolean - prod: boolean -}) { - const logger = getDefaultLogger() - - debug(`Starting optimization for ${cwd}`) - debugDir({ cwd, outputKind, pin, prod }) - - const pkgEnvCResult = await detectAndValidatePackageEnvironment(cwd, { - cmdName: CMD_NAME, - logger, - prod, - }) - if (!pkgEnvCResult.ok) { - process.exitCode = pkgEnvCResult.code ?? 1 - debug('Package environment validation failed') - debugDir({ pkgEnvCResult }) - await outputOptimizeResult(pkgEnvCResult, outputKind) - return - } - - const pkgEnvDetails = pkgEnvCResult.data - if (!pkgEnvDetails) { - process.exitCode = 1 - debug('No package environment details found') - await outputOptimizeResult( - { - ok: false, - message: 'No package found.', - cause: `No valid package environment found for project path: ${cwd}`, - }, - outputKind, - ) - return - } - - debug( - `Detected package manager: ${pkgEnvDetails.agent} v${pkgEnvDetails.agentVersion}`, - ) - debugDir({ pkgEnvDetails }) - - const { agent, agentVersion } = pkgEnvDetails - if (agent === VLT) { - process.exitCode = 1 - debug(`${agent} does not support overrides`) - await outputOptimizeResult( - { - ok: false, - message: 'Unsupported', - cause: cmdPrefixMessage( - CMD_NAME, - `${agent} v${agentVersion} does not support overrides.`, - ), - }, - outputKind, - ) - return - } - - logger.info(`Optimizing packages for ${agent} v${agentVersion}.`) - logger.error('') - - debug('Applying optimization') - const optimizationResult = await applyOptimization(pkgEnvDetails, { - pin, - prod, - }) - - if (!optimizationResult.ok) { - process.exitCode = optimizationResult.code ?? 1 - } - debug(`Optimization ${optimizationResult.ok ? 'succeeded' : 'failed'}`) - debugDir({ optimizationResult }) - await outputOptimizeResult(optimizationResult, outputKind) -} diff --git a/packages/cli/src/commands/optimize/lockfile-includes-by-agent.mts b/packages/cli/src/commands/optimize/lockfile-includes-by-agent.mts deleted file mode 100644 index 5a35246b2..000000000 --- a/packages/cli/src/commands/optimize/lockfile-includes-by-agent.mts +++ /dev/null @@ -1,88 +0,0 @@ -import { - BUN, - PNPM, - VLT, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' -import { EXT_LOCK } from '@socketsecurity/lib-stable/paths/exts' -import { escapeRegExp } from '@socketsecurity/lib-stable/regexps/escape' - -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' - -export function bunLockSrcIncludes( - lockSrc: string, - name: string, - lockName?: string | undefined, -) { - // This is a bit counterintuitive. When lockName ends with a .lockb - // we treat it as a yarn.lock. When lockName ends with a .lock we - // treat it as a package-lock.json. The bun.lock format is not identical - // package-lock.json, however it close enough for npmLockIncludes to work. - const lockfileScanner = lockName?.endsWith(EXT_LOCK) - ? npmLockSrcIncludes - : yarnLockSrcIncludes - return lockfileScanner(lockSrc, name) -} - -export function lockSrcIncludes( - pkgEnvDetails: EnvDetails, - lockSrc: string, - name: string, - lockName?: string | undefined, -): boolean { - switch (pkgEnvDetails.agent) { - case BUN: - return bunLockSrcIncludes(lockSrc, name, lockName) - case PNPM: - return pnpmLockSrcIncludes(lockSrc, name) - case VLT: - return vltLockSrcIncludes(lockSrc, name) - case YARN_BERRY: - return yarnLockSrcIncludes(lockSrc, name) - case YARN_CLASSIC: - return yarnLockSrcIncludes(lockSrc, name) - default: - return npmLockSrcIncludes(lockSrc, name) - } -} - -export function npmLockSrcIncludes(lockSrc: string, name: string) { - // Detects the package name in the following cases: - // "name": - return lockSrc.includes(`"${name}":`) -} - -export function pnpmLockSrcIncludes(lockSrc: string, name: string) { - const escapedName = escapeRegExp(name) - return new RegExp( - // Detects the package name. - // v9.0 and v6.0 lockfile patterns: - // 'name' - // name: - // name@ - // v6.0 lockfile patterns: - // /name@ - `(?<=^\\s*)(?:'${escapedName}'|/?${escapedName}(?=[:@]))`, - 'm', - ).test(lockSrc) -} - -export function vltLockSrcIncludes(lockSrc: string, name: string) { - // Detects the package name in the following cases: - // "name" - return lockSrc.includes(`"${name}"`) -} - -export function yarnLockSrcIncludes(lockSrc: string, name: string) { - const escapedName = escapeRegExp(name) - return new RegExp( - // Detects the package name in the following cases: - // "name@ - // , "name@ - // name@ - // , name@ - `(?<=(?:^\\s*|,\\s*)"?)${escapedName}(?=@)`, - 'm', - ).test(lockSrc) -} diff --git a/packages/cli/src/commands/optimize/ls-by-agent.mts b/packages/cli/src/commands/optimize/ls-by-agent.mts deleted file mode 100644 index 762de30ed..000000000 --- a/packages/cli/src/commands/optimize/ls-by-agent.mts +++ /dev/null @@ -1,258 +0,0 @@ -import { - BUN, - NPM, - PNPM, - VLT, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { FLAG_PROD } from '../../constants/cli.mts' - -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' - -export function cleanupQueryStdout(stdout: string): string { - if (stdout === '') { - return '' - } - let pkgs: unknown - try { - pkgs = JSON.parse(stdout) - } catch { - // Malformed JSON from package manager, return empty. - return '' - } - if (!Array.isArray(pkgs) || !pkgs.length) { - return '' - } - const names = new Set<string>() - for (const { _id, name, pkgid } of pkgs) { - // `npm query` results may not have a "name" property, in which case we - // fallback to "_id" and then "pkgid". - // `vlt ls --view json` results always have a "name" property. - const fallback = _id ?? pkgid ?? '' - const atIndex = fallback.indexOf('@', 1) - const resolvedName = - name ?? (atIndex === -1 ? fallback : fallback.slice(0, atIndex)) - // Add package names, except for those under the `@types` scope as those - // are known to only be dev dependencies. - if (resolvedName && !resolvedName.startsWith('@types/')) { - names.add(resolvedName) - } - } - return JSON.stringify(Array.from(names), null, 2) -} - -type AgentListDepsOptions = { - cwd?: string | undefined - npmExecPath?: string | undefined -} - -export async function listPackages( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - switch (pkgEnvDetails.agent) { - case BUN: - return await lsBun(pkgEnvDetails, options) - case PNPM: - return await lsPnpm(pkgEnvDetails, options) - case VLT: - return await lsVlt(pkgEnvDetails, options) - case YARN_BERRY: - return await lsYarnBerry(pkgEnvDetails, options) - case YARN_CLASSIC: - return await lsYarnClassic(pkgEnvDetails, options) - default: - return await lsNpm(pkgEnvDetails, options) - } -} - -export async function lsBun( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd() } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - try { - // Bun does not support filtering by production packages yet. - // https://github.com/oven-sh/bun/issues/8283 - const result = await spawn( - pkgEnvDetails.agentExecPath, - ['pm', 'ls', '--all'], - { - cwd, - // On Windows, bun is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }, - ) - return result.stdout - } catch {} - return '' -} - -export async function lsNpm( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd() } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - return await npmQuery(pkgEnvDetails.agentExecPath, cwd) -} - -export async function lsPnpm( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd(), npmExecPath } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - if (npmExecPath && npmExecPath !== NPM) { - const result = await npmQuery(npmExecPath, cwd) - if (result) { - return result - } - } - let stdout = '' - try { - const result = await spawn( - pkgEnvDetails.agentExecPath, - // Pnpm uses the alternative spelling of parsable. - // https://en.wiktionary.org/wiki/parsable - ['ls', '--parseable', FLAG_PROD, '--depth', 'Infinity'], - { - cwd, - // On Windows, pnpm is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }, - ) - stdout = - result.stdout - } catch {} - return parsableToQueryStdout(stdout) -} - -export async function lsVlt( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd() } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - let stdout = '' - try { - // See https://docs.vlt.sh/cli/commands/list#options. - const result = await spawn( - pkgEnvDetails.agentExecPath, - ['ls', '--view', 'human', ':not(.dev)'], - { - cwd, - // On Windows, pnpm is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }, - ) - stdout = - result.stdout - } catch {} - return cleanupQueryStdout(stdout) -} - -export async function lsYarnBerry( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd() } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - try { - // Yarn Berry does not support filtering by production packages yet. - // https://github.com/yarnpkg/berry/issues/5117 - const result = await spawn( - pkgEnvDetails.agentExecPath, - ['info', '--recursive', '--name-only'], - { - cwd, - // On Windows, yarn is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }, - ) - return result.stdout - } catch {} - return '' -} - -export async function lsYarnClassic( - pkgEnvDetails: EnvDetails, - options?: AgentListDepsOptions | undefined, -): Promise<string> { - const { cwd = process.cwd() } = { - __proto__: null, - ...options, - } as AgentListDepsOptions - try { - // However, Yarn Classic does support it. - // https://github.com/yarnpkg/yarn/releases/tag/v1.0.0 - // > Fix: Excludes dev dependencies from the yarn list output when the - // environment is production - const result = await spawn( - pkgEnvDetails.agentExecPath, - ['list', FLAG_PROD], - { - cwd, - // On Windows, yarn is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }, - ) - return result.stdout - } catch {} - return '' -} - -export async function npmQuery( - npmExecPath: string, - cwd: string, -): Promise<string> { - let stdout = '' - try { - const result = await spawn(npmExecPath, ['query', ':not(.dev)'], { - cwd, - // On Windows, npm is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }) - stdout = - result.stdout - } catch {} - return cleanupQueryStdout(stdout) -} - -export function parsableToQueryStdout(stdout: string) { - if (stdout === '') { - return '' - } - // Convert the parsable stdout into a json array of unique names. - // The matchAll regexp looks for a forward (posix) or backward (win32) slash - // and matches one or more non-slashes until the newline. - const names = new Set(stdout.matchAll(/(?<=[/\\])[^/\\]+(?=\n)/g)) - return JSON.stringify(Array.from(names), null, 2) -} diff --git a/packages/cli/src/commands/optimize/output-optimize-result.mts b/packages/cli/src/commands/optimize/output-optimize-result.mts deleted file mode 100644 index b618b9ccb..000000000 --- a/packages/cli/src/commands/optimize/output-optimize-result.mts +++ /dev/null @@ -1,91 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdError, mdHeader, mdList } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' - -export function createActionMessage( - verb: string, - overrideCount: number, - workspaceCount: number, -): string { - return `${verb} ${overrideCount} Socket.dev optimized ${pluralize('override', { count: overrideCount })}${workspaceCount ? ` in ${workspaceCount} ${pluralize('workspace', { count: workspaceCount })}` : ''}` -} - -export async function outputOptimizeResult( - result: CResult<{ - addedCount: number - updatedCount: number - pkgJsonChanged: boolean - updatedInWorkspaces: number - addedInWorkspaces: number - }>, - outputKind: OutputKind, -) { - const logger = getDefaultLogger() - - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (outputKind === 'markdown') { - if (!result.ok) { - logger.log(mdError(result.message, result.cause)) - return - } - - const data = result.data - logger.log(mdHeader('Optimize Complete')) - logger.log('') - - if (data.pkgJsonChanged) { - const changes = [] - if (data.updatedCount > 0) { - const updatedText = `**Updated**: ${data.updatedCount} ${pluralize('override', { count: data.updatedCount })}${data.updatedInWorkspaces ? ` in ${data.updatedInWorkspaces} ${pluralize('workspace', { count: data.updatedInWorkspaces })}` : ''}` - changes.push(updatedText) - } - if (data.addedCount > 0) { - const addedText = `**Added**: ${data.addedCount} ${pluralize('override', { count: data.addedCount })}${data.addedInWorkspaces ? ` in ${data.addedInWorkspaces} ${pluralize('workspace', { count: data.addedInWorkspaces })}` : ''}` - changes.push(addedText) - } - logger.log(mdList(changes)) - logger.success('Finished!') - } else { - logger.log('No Socket.dev optimized overrides applied.') - } - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const data = result.data - - if (data.updatedCount > 0) { - logger?.log( - `${createActionMessage('Updated', data.updatedCount, data.updatedInWorkspaces)}${data.addedCount ? '.' : '🚀'}`, - ) - } - if (data.addedCount > 0) { - logger?.log( - `${createActionMessage('Added', data.addedCount, data.addedInWorkspaces)} 🚀`, - ) - } - if (!data.pkgJsonChanged) { - logger?.log('Scan complete. No Socket.dev optimized overrides applied.') - } - - logger.log('') - logger.success('Finished!') - logger.log('') -} diff --git a/packages/cli/src/commands/optimize/update-dependencies.mts b/packages/cli/src/commands/optimize/update-dependencies.mts deleted file mode 100644 index 3093cfeae..000000000 --- a/packages/cli/src/commands/optimize/update-dependencies.mts +++ /dev/null @@ -1,76 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { runAgentInstall } from './agent-installer.mts' -import { NPM_BUGGY_OVERRIDES_PATCHED_VERSION } from '../../constants/packages.mts' -import { cmdPrefixMessage } from '../../util/process/cmd.mts' - -import type { CResult } from '../../types.mts' -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' -import type { Logger } from '@socketsecurity/lib-stable/logger/types' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -type UpdateDependenciesOptions = { - cmdName?: string | undefined - logger?: Logger | undefined - spinner?: SpinnerInstance | undefined -} - -export async function updateDependencies( - pkgEnvDetails: EnvDetails, - options: UpdateDependenciesOptions, -): Promise<CResult<unknown>> { - const { - cmdName = '', - logger, - spinner, - } = { - __proto__: null, - ...options, - } as UpdateDependenciesOptions - - const wasSpinning = !!spinner?.isSpinning - - spinner?.start(`Updating ${pkgEnvDetails.lockName}...`) - - try { - await runAgentInstall(pkgEnvDetails, { spinner }) - - if (pkgEnvDetails.features.npmBuggyOverrides) { - spinner?.stop() - logger?.log( - `💡 Re-run ${cmdName ? `${cmdName} ` : ''}whenever ${pkgEnvDetails.lockName} changes.\n This can be skipped for ${pkgEnvDetails.agent} >=${NPM_BUGGY_OVERRIDES_PATCHED_VERSION}.`, - ) - } - } catch (e) { - spinner?.stop() - - debug('Dependencies update failed') - debugDir(e) - - if (wasSpinning) { - getDefaultSpinner().start() - } - - return { - ok: false, - message: 'Dependencies update failed', - cause: cmdPrefixMessage( - cmdName, - `${pkgEnvDetails.agent} install failed to update ${pkgEnvDetails.lockName}. ` + - `Check that ${pkgEnvDetails.agent} is properly installed and your project configuration is valid. ` + - `Run '${pkgEnvDetails.agent} install' manually to see detailed error information.`, - ), - } - } - - spinner?.stop() - - if (wasSpinning) { - getDefaultSpinner().start() - } - - return { ok: true, data: undefined } -} diff --git a/packages/cli/src/commands/optimize/update-manifest-by-agent.mts b/packages/cli/src/commands/optimize/update-manifest-by-agent.mts deleted file mode 100644 index 2ea310599..000000000 --- a/packages/cli/src/commands/optimize/update-manifest-by-agent.mts +++ /dev/null @@ -1,223 +0,0 @@ -import { - BUN, - OVERRIDES, - PNPM, - RESOLUTIONS, - VLT, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' -import { hasKeys, isObject } from '@socketsecurity/lib-stable/objects/predicates' - -import { updatePnpmWorkspaceYamlOverrides } from './update-pnpm-workspace-yaml.mts' - -import type { Overrides } from './types.mts' -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' -import type { Agent } from '../../util/ecosystem/environment.mjs' -import type { EditablePackageJson } from '@socketsecurity/lib-stable/packages/types' - -const depFields = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'peerDependenciesMeta', - 'optionalDependencies', - 'bundleDependencies', -] - -export function getEntryIndexes( - entries: Array<[string | symbol, unknown]>, - keys: Array<string | symbol>, -): number[] { - return keys - .map(n => entries.findIndex(p => p[0] === n)) - .filter(n => n !== -1) - .sort((a, b) => a - b) -} - -export function getHighestEntryIndex( - entries: Array<[string | symbol, unknown]>, - keys: Array<string | symbol>, -) { - return getEntryIndexes(entries, keys)?.at(-1) ?? -1 -} - -export function getLowestEntryIndex( - entries: Array<[string | symbol, unknown]>, - keys: Array<string | symbol>, -) { - return getEntryIndexes(entries, keys)?.[0] ?? -1 -} - -/** - * Apply overrides to the host repo's manifest, picking the correct destination - * based on agent + version: - * - * - Pnpm 11+ → pnpm-workspace.yaml `overrides:` block (async write, preserves - * comments via the `yaml` package's Document API). - * - Pnpm < 11 → package.json `pnpm.overrides`. - * - Bun / yarn-classic / yarn-berry → package.json `resolutions`. - * - Vlt / npm / fallback → package.json `overrides`. - * - * The `pkgEnvDetails` parameter carries `agentVersion` (a SemVer instance) - * needed to disambiguate pnpm versions. Callers reach this via - * `applyOptimization()` which already has the env in scope. - */ -export async function updateManifest( - agent: Agent, - pkgEnvDetails: EnvDetails, - overrides: Overrides, -): Promise<void> { - const { editablePkgJson } = pkgEnvDetails - switch (agent) { - case BUN: - updateResolutionsField(editablePkgJson, overrides) - return - case PNPM: - if (usesPnpmWorkspaceOverrides(pkgEnvDetails)) { - // Route to pnpm-workspace.yaml. Also clear any stale - // `pnpm.overrides` in package.json — pnpm 11 ignores it, but - // leaving it there is misleading + drift-prone. - updatePnpmField(editablePkgJson, {}) - await updatePnpmWorkspaceYamlOverrides(pkgEnvDetails.pkgPath, overrides) - } else { - updatePnpmField(editablePkgJson, overrides) - } - return - case VLT: - updateOverridesField(editablePkgJson, overrides) - return - case YARN_BERRY: - updateResolutionsField(editablePkgJson, overrides) - return - case YARN_CLASSIC: - updateResolutionsField(editablePkgJson, overrides) - return - default: - updateOverridesField(editablePkgJson, overrides) - return - } -} - -export function updateOverridesField( - editablePkgJson: EditablePackageJson, - overrides: Overrides, -) { - updatePkgJsonField(editablePkgJson, OVERRIDES, overrides) -} - -export function updatePkgJsonField( - editablePkgJson: EditablePackageJson, - field: string, - value: unknown, -) { - const oldValue = editablePkgJson.content[field] - if (oldValue) { - // The field already exists so we simply update the field value. - if (field === PNPM) { - const isPnpmObj = isObject(oldValue) - if (hasKeys(value)) { - editablePkgJson.update({ - [field]: { - ...(isPnpmObj ? oldValue : {}), - [OVERRIDES]: value, - }, - } as typeof editablePkgJson.content) - } else if (isPnpmObj) { - // Drop the overrides key but keep the rest of the pnpm config. - const { overrides: _omitted, ...rest } = oldValue as Record< - string, - unknown - > - editablePkgJson.update({ - [field]: hasKeys(rest) ? rest : undefined, - } as typeof editablePkgJson.content) - } else { - editablePkgJson.update({ - [field]: undefined, - } as typeof editablePkgJson.content) - } - } else if (field === OVERRIDES || field === RESOLUTIONS) { - // Properties with undefined values are deleted when saved as JSON. - editablePkgJson.update({ - [field]: hasKeys(value) ? value : undefined, - } as typeof editablePkgJson.content) - } else { - editablePkgJson.update({ [field]: value }) - } - return - } - if ( - (field === OVERRIDES || field === PNPM || field === RESOLUTIONS) && - !hasKeys(value) - ) { - return - } - // Since the field doesn't exist we want to insert it into the package.json - // in a place that makes sense, e.g. close to the "dependencies" field. If - // we can't find a place to insert the field we'll add it to the bottom. - const entries = Object.entries(editablePkgJson.content) - let insertIndex = -1 - let isPlacingHigher = false - if (field === OVERRIDES) { - insertIndex = getLowestEntryIndex(entries, [RESOLUTIONS]) - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, [...depFields, PNPM]) - } - } else if (field === RESOLUTIONS) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, [...depFields, OVERRIDES, PNPM]) - } else if (field === PNPM) { - insertIndex = getLowestEntryIndex(entries, [OVERRIDES, RESOLUTIONS]) - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, depFields) - } - } - if (insertIndex === -1) { - insertIndex = getLowestEntryIndex(entries, ['engines', 'files']) - } - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, ['exports', 'imports', 'main']) - } - if (insertIndex === -1) { - insertIndex = entries.length - } else if (isPlacingHigher) { - insertIndex += 1 - } - entries.splice(insertIndex, 0, [ - field, - field === PNPM ? { [OVERRIDES]: value } : value, - ]) - editablePkgJson.fromJSON( - `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n`, - ) -} - -export function updatePnpmField( - editablePkgJson: EditablePackageJson, - overrides: Overrides, -) { - updatePkgJsonField(editablePkgJson, PNPM, overrides) -} - -export function updateResolutionsField( - editablePkgJson: EditablePackageJson, - overrides: Overrides, -) { - updatePkgJsonField(editablePkgJson, RESOLUTIONS, overrides) -} - -/** - * Pnpm 11+ reads `overrides:` from `pnpm-workspace.yaml`. The `pnpm.overrides` - * block in package.json is silently ignored. Returns true when the host repo's - * `packageManager` field declares pnpm 11+, meaning we should write to the YAML - * file instead of package.json. - */ -export function usesPnpmWorkspaceOverrides( - pkgEnvDetails: Pick<EnvDetails, 'agent' | 'agentVersion'>, -): boolean { - return pkgEnvDetails.agent === PNPM && pkgEnvDetails.agentVersion.major >= 11 -} diff --git a/packages/cli/src/commands/optimize/update-pnpm-workspace-yaml.mts b/packages/cli/src/commands/optimize/update-pnpm-workspace-yaml.mts deleted file mode 100644 index 691caf6ac..000000000 --- a/packages/cli/src/commands/optimize/update-pnpm-workspace-yaml.mts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @file Update overrides in pnpm-workspace.yaml (pnpm 11+). pnpm 11+ reads - * `overrides:` from `pnpm-workspace.yaml` only — `package.json`'s - * `pnpm.overrides` is ignored. socket-cli's optimize command historically - * wrote to package.json; this helper provides the YAML-write path used when - * the host repo declares pnpm@11+ in its `packageManager` field. Comment - * preservation: uses the `yaml` package's Document API so existing - * `pnpm-workspace.yaml` formatting (comments, ordering, non-overrides keys) - * survives merges. The `overrides:` block is created when missing. - */ - -import { existsSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { safeReadFile } from '@socketsecurity/lib-stable/fs/read-file' -import { isMap, parseDocument } from 'yaml' -import type { Document, YAMLMap } from 'yaml' - -import type { Overrides } from './types.mts' - -/** - * Merge `overrides` into `pnpm-workspace.yaml` at - * `<repoRoot>/pnpm-workspace.yaml`. - * - * - Existing `overrides:` block is updated in-place (entries with the same key - * are overwritten with the new value; new entries are appended). - * - When the file lacks an `overrides:` block, one is added. - * - When the file is missing entirely, a minimal one is created. - * - Comments and other keys (catalog, packages, minimumReleaseAge, etc.) are - * preserved. - */ -export async function updatePnpmWorkspaceYamlOverrides( - repoRoot: string, - overrides: Overrides, -): Promise<void> { - const yamlPath = path.join(repoRoot, 'pnpm-workspace.yaml') - const existing = existsSync(yamlPath) - ? await safeReadFile(yamlPath, { encoding: 'utf8' }) - : undefined - - let doc: Document - if (existing) { - doc = parseDocument(existing, { keepSourceTokens: true }) - } else { - // Minimal new file. The Document is empty until we add `overrides:`. - doc = parseDocument('', { keepSourceTokens: true }) - doc.contents = doc.createNode({}) as ReturnType<Document['createNode']> - } - - // Locate or create the `overrides:` map. - let overridesNode = doc.get('overrides', true) as unknown - if (!isMap(overridesNode)) { - doc.set('overrides', overrides) - overridesNode = doc.get('overrides', true) - } else { - const map = overridesNode as YAMLMap<unknown, unknown> - for (const [key, value] of Object.entries(overrides)) { - map.set(key, value) - } - } - - const output = doc.toString({ - // Preserve typical pnpm-workspace.yaml conventions: 2-space indent, - // double-quoted strings only when necessary. - indent: 2, - lineWidth: 0, - minContentWidth: 0, - }) - - writeFileSync(yamlPath, output, 'utf8') -} diff --git a/packages/cli/src/commands/organization/cmd-organization-dependencies.mts b/packages/cli/src/commands/organization/cmd-organization-dependencies.mts deleted file mode 100644 index d1bc72996..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-dependencies.mts +++ /dev/null @@ -1,133 +0,0 @@ -import { handleDependencies } from './handle-dependencies.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'dependencies' - -const description = - 'Search for any dependency that is being used in your organization' - -const hidden = false - -export const cmdOrganizationDependencies = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - limit: { - type: 'number', - default: 50, - description: 'Maximum number of dependencies returned', - }, - offset: { - type: 'number', - default: 0, - description: 'Page number', - }, - ...outputFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - ${command} - ${command} --limit 20 --offset 10 - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { json, limit, markdown, offset } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - // Validate numeric pagination parameters. - const validatedLimit = Number(limit || 0) - const validatedOffset = Number(offset || 0) - - if (dryRun) { - outputDryRunFetch('organization dependencies', { - limit: validatedLimit || 50, - offset: validatedOffset, - }) - return - } - - if (Number.isNaN(validatedLimit) || validatedLimit < 0) { - throw new InputError( - `--limit must be a non-negative integer (saw: "${limit}"); pass a number like --limit=50`, - ) - } - if (Number.isNaN(validatedOffset) || validatedOffset < 0) { - throw new InputError( - `--offset must be a non-negative integer (saw: "${offset}"); pass a number like --offset=0`, - ) - } - - await handleDependencies({ - limit: validatedLimit, - offset: validatedOffset, - outputKind, - }) -} diff --git a/packages/cli/src/commands/organization/cmd-organization-list.mts b/packages/cli/src/commands/organization/cmd-organization-list.mts deleted file mode 100644 index 4f33606b1..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-list.mts +++ /dev/null @@ -1,99 +0,0 @@ -import { handleOrganizationList } from './handle-organization-list.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'list' - -const description = 'List organizations associated with the Socket API token' - -const hidden = false - -export const cmdOrganizationList = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - }), - help: (command: string, _config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --json - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { json, markdown } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('organizations') - return - } - - await handleOrganizationList(outputKind) -} diff --git a/packages/cli/src/commands/organization/cmd-organization-policy-license.mts b/packages/cli/src/commands/organization/cmd-organization-policy-license.mts deleted file mode 100644 index 2f38a04f7..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-policy-license.mts +++ /dev/null @@ -1,122 +0,0 @@ -import { handleLicensePolicy } from './handle-license-policy.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'license' - -const description = 'Retrieve the license policy of an organization' - -const hidden = false - -export const cmdOrganizationPolicyLicense = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string) => ` - Usage - $ ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Your API token will need the \`license-policy:read\` permission otherwise - the request will fail with an authentication error. - - Examples - $ ${command} - $ ${command} --json - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('organization license policy', { - organization: orgSlug || '(will be determined)', - }) - return - } - - await handleLicensePolicy(orgSlug, outputKind) -} diff --git a/packages/cli/src/commands/organization/cmd-organization-policy-security.mts b/packages/cli/src/commands/organization/cmd-organization-policy-security.mts deleted file mode 100644 index 9e4f01143..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-policy-security.mts +++ /dev/null @@ -1,123 +0,0 @@ -import { handleSecurityPolicy } from './handle-security-policy.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'security' - -const description = 'Retrieve the security policy of an organization' - -const hidden = true - -export const cmdOrganizationPolicySecurity = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string, _config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Your API token will need the \`security-policy:read\` permission otherwise - the request will fail with an authentication error. - - Examples - $ ${command} - $ ${command} --json - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('organization security policy', { - organization: orgSlug || '(will be determined)', - }) - return - } - - await handleSecurityPolicy(orgSlug, outputKind) -} diff --git a/packages/cli/src/commands/organization/cmd-organization-policy.mts b/packages/cli/src/commands/organization/cmd-organization-policy.mts deleted file mode 100644 index 7d0b62d3f..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-policy.mts +++ /dev/null @@ -1,33 +0,0 @@ -import { cmdOrganizationPolicyLicense } from './cmd-organization-policy-license.mts' -import { cmdOrganizationPolicySecurity } from './cmd-organization-policy-security.mts' -import { meowWithSubcommands } from '../../util/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -const description = 'Organization policy details' - -export const cmdOrganizationPolicy: CliSubcommand = { - description, - // Hidden because it was broken all this time (nobody could be using it) - // and we're not sure if it's useful to anyone in its current state. - // Until we do, we'll hide this to keep the help tidier. - // And later, we may simply move this under `scan`, anyways. - hidden: false, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} policy`, - importMeta, - subcommands: { - security: cmdOrganizationPolicySecurity, - license: cmdOrganizationPolicyLicense, - }, - }, - { - description, - defaultSub: 'list', // Backwards compat - }, - ) - }, -} diff --git a/packages/cli/src/commands/organization/cmd-organization-quota.mts b/packages/cli/src/commands/organization/cmd-organization-quota.mts deleted file mode 100644 index ce6d95e9e..000000000 --- a/packages/cli/src/commands/organization/cmd-organization-quota.mts +++ /dev/null @@ -1,89 +0,0 @@ -import { handleQuota } from './handle-quota.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'quota', - description: - 'Show remaining Socket API quota for the current token, plus refresh window', - hidden: false, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - }), - help: (command: string, _config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --json - `, -} - -export const cmdOrganizationQuota = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const dryRun = !!cli.flags['dryRun'] - - const json = Boolean(cli.flags['json']) - - const markdown = Boolean(cli.flags['markdown']) - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('organization quota') - return - } - - await handleQuota(outputKind) -} diff --git a/packages/cli/src/commands/organization/cmd-organization.mts b/packages/cli/src/commands/organization/cmd-organization.mts deleted file mode 100644 index 214719e7a..000000000 --- a/packages/cli/src/commands/organization/cmd-organization.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { cmdOrganizationDependencies } from './cmd-organization-dependencies.mts' -import { cmdOrganizationList } from './cmd-organization-list.mts' -import { cmdOrganizationPolicyLicense } from './cmd-organization-policy-license.mts' -import { cmdOrganizationPolicySecurity } from './cmd-organization-policy-security.mts' -import { cmdOrganizationPolicy } from './cmd-organization-policy.mts' -import { cmdOrganizationQuota } from './cmd-organization-quota.mts' -import { defineSubcommandGroup } from '../../util/cli/define-subcommand-group.mts' - -export const cmdOrganization = defineSubcommandGroup({ - name: 'organization', - description: 'Manage Socket organization account details', - hidden: false, - subcommands: { - dependencies: cmdOrganizationDependencies, - list: cmdOrganizationList, - quota: cmdOrganizationQuota, - policy: cmdOrganizationPolicy, - }, - aliases: { - deps: { - description: cmdOrganizationDependencies.description, - hidden: true, - argv: ['dependencies'], - }, - license: { - description: cmdOrganizationPolicyLicense.description, - hidden: true, - argv: ['policy', 'license'], - }, - security: { - description: cmdOrganizationPolicySecurity.description, - hidden: true, - argv: ['policy', 'security'], - }, - }, -}) diff --git a/packages/cli/src/commands/organization/fetch-dependencies.mts b/packages/cli/src/commands/organization/fetch-dependencies.mts deleted file mode 100644 index fe7448150..000000000 --- a/packages/cli/src/commands/organization/fetch-dependencies.mts +++ /dev/null @@ -1,45 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchDependenciesConfig = { - limit: number - offset: number -} - -type FetchDependenciesOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchDependencies( - config: FetchDependenciesConfig, - options?: FetchDependenciesOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'searchDependencies'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchDependenciesOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - const { limit, offset } = { - __proto__: null, - ...config, - } as FetchDependenciesConfig - - return await handleApiCall<'searchDependencies'>( - sockSdk.searchDependencies({ limit, offset }), - { - commandPath, - description: 'organization dependencies', - }, - ) -} diff --git a/packages/cli/src/commands/organization/fetch-license-policy.mts b/packages/cli/src/commands/organization/fetch-license-policy.mts deleted file mode 100644 index 3d74a15f4..000000000 --- a/packages/cli/src/commands/organization/fetch-license-policy.mts +++ /dev/null @@ -1,35 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchLicensePolicyOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchLicensePolicy( - orgSlug: string, - options?: FetchLicensePolicyOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getOrgLicensePolicy'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchLicensePolicyOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getOrgLicensePolicy'>( - sockSdk.getOrgLicensePolicy(orgSlug), - { - commandPath, - description: 'organization license policy', - }, - ) -} diff --git a/packages/cli/src/commands/organization/fetch-organization-list.mts b/packages/cli/src/commands/organization/fetch-organization-list.mts deleted file mode 100644 index efb976dbb..000000000 --- a/packages/cli/src/commands/organization/fetch-organization-list.mts +++ /dev/null @@ -1,69 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdk, SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchOrganizationOptions = { - commandPath?: string | undefined - description?: string | undefined - sdk?: SocketSdk | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -type EnterpriseOrganization = Omit<Organization, 'plan'> & { - plan: `enterprise${string}` -} - -export type EnterpriseOrganizations = EnterpriseOrganization[] - -export type Organization = - SocketSdkSuccessResult<'listOrganizations'>['data']['organizations'][string] - -export type Organizations = Organization[] - -type OrganizationsData = { organizations: Organizations } - -export type OrganizationsCResult = CResult<OrganizationsData> - -export async function fetchOrganization( - options?: FetchOrganizationOptions | undefined, -): Promise<OrganizationsCResult> { - const { - commandPath, - description = 'organization list', - sdk, - sdkOpts, - } = { - __proto__: null, - ...options, - } as FetchOrganizationOptions - - let sockSdk = sdk - if (!sockSdk) { - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - sockSdk = sockSdkCResult.data - } - - const orgsCResult = await handleApiCall<'listOrganizations'>( - sockSdk.listOrganizations(), - { - commandPath, - description, - }, - ) - if (!orgsCResult.ok) { - return orgsCResult - } - - return { - ...orgsCResult, - data: { - organizations: Object.values(orgsCResult.data.organizations), - }, - } -} diff --git a/packages/cli/src/commands/organization/fetch-quota.mts b/packages/cli/src/commands/organization/fetch-quota.mts deleted file mode 100644 index 55fd2d626..000000000 --- a/packages/cli/src/commands/organization/fetch-quota.mts +++ /dev/null @@ -1,26 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchQuotaOptions = { - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchQuota( - options?: FetchQuotaOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getQuota'>['data']>> { - const { sdkOpts } = { __proto__: null, ...options } as FetchQuotaOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getQuota'>(sockSdk.getQuota(), { - description: 'token quota', - }) -} diff --git a/packages/cli/src/commands/organization/fetch-security-policy.mts b/packages/cli/src/commands/organization/fetch-security-policy.mts deleted file mode 100644 index 502faeae2..000000000 --- a/packages/cli/src/commands/organization/fetch-security-policy.mts +++ /dev/null @@ -1,35 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchSecurityPolicyOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchSecurityPolicy( - orgSlug: string, - options?: FetchSecurityPolicyOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchSecurityPolicyOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getOrgSecurityPolicy'>( - sockSdk.getOrgSecurityPolicy(orgSlug), - { - commandPath, - description: 'organization security policy', - }, - ) -} diff --git a/packages/cli/src/commands/organization/handle-dependencies.mts b/packages/cli/src/commands/organization/handle-dependencies.mts deleted file mode 100644 index 84adfb035..000000000 --- a/packages/cli/src/commands/organization/handle-dependencies.mts +++ /dev/null @@ -1,31 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchDependencies } from './fetch-dependencies.mts' -import { outputDependencies } from './output-dependencies.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleDependencies({ - limit, - offset, - outputKind, -}: { - limit: number - offset: number - outputKind: OutputKind -}): Promise<void> { - debug(`Fetching dependencies with limit=${limit}, offset=${offset}`) - debugDir({ limit, offset, outputKind }) - - const result = await fetchDependencies( - { limit, offset }, - { - commandPath: 'socket organization dependencies', - }, - ) - - debug(`Dependencies ${result.ok ? 'fetched successfully' : 'fetch failed'}`) - debugDir({ result }) - - await outputDependencies(result, { limit, offset, outputKind }) -} diff --git a/packages/cli/src/commands/organization/handle-organization-list.mts b/packages/cli/src/commands/organization/handle-organization-list.mts deleted file mode 100644 index 7b9c350e0..000000000 --- a/packages/cli/src/commands/organization/handle-organization-list.mts +++ /dev/null @@ -1,24 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchOrganization } from './fetch-organization-list.mts' -import { outputOrganizationList } from './output-organization-list.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleOrganizationList( - outputKind: OutputKind = 'text', -): Promise<void> { - debug('Fetching organization list') - debugDir({ outputKind }) - - const data = await fetchOrganization({ - commandPath: 'socket organization list', - }) - - debug( - `Organization list ${data.ok ? 'fetched successfully' : 'fetch failed'}`, - ) - debugDir({ data }) - - await outputOrganizationList(data, outputKind) -} diff --git a/packages/cli/src/commands/organization/output-dependencies.mts b/packages/cli/src/commands/organization/output-dependencies.mts deleted file mode 100644 index ca48bfc72..000000000 --- a/packages/cli/src/commands/organization/output-dependencies.mts +++ /dev/null @@ -1,73 +0,0 @@ -import chalkTable from 'chalk-table' -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputDependencies( - result: CResult<SocketSdkSuccessResult<'searchDependencies'>['data']>, - { - limit, - offset, - outputKind, - }: { - limit: number - offset: number - outputKind: OutputKind - }, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - outputMarkdown(result.data, { limit, offset }) -} - -export function outputMarkdown( - result: SocketSdkSuccessResult<'searchDependencies'>['data'], - { - limit, - offset, - }: { - limit: number - offset: number - }, -) { - logger.log(mdHeader('Organization dependencies')) - logger.log('') - logger.log('Request details:') - logger.log('- Offset:', offset) - logger.log('- Limit:', limit) - logger.log('- Is there more data after this?', result.end ? 'no' : 'yes') - logger.log('') - - const options = { - columns: [ - { field: 'type', name: colors.cyan('Ecosystem') }, - { field: 'namespace', name: colors.cyan('Namespace') }, - { field: 'name', name: colors.cyan('Name') }, - { field: 'version', name: colors.cyan('Version') }, - { field: 'repository', name: colors.cyan('Repository') }, - { field: 'branch', name: colors.cyan('Branch') }, - { field: 'direct', name: colors.cyan('Direct') }, - ], - } - - logger.log(chalkTable(options, result.rows)) -} diff --git a/packages/cli/src/commands/organization/output-license-policy.mts b/packages/cli/src/commands/organization/output-license-policy.mts deleted file mode 100644 index 28c242f91..000000000 --- a/packages/cli/src/commands/organization/output-license-policy.mts +++ /dev/null @@ -1,47 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader, mdTableOfPairs } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputLicensePolicy( - result: CResult<SocketSdkSuccessResult<'getOrgLicensePolicy'>['data']>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.info('Use --json to get the full result') - logger.log(mdHeader('License policy')) - logger.log('') - logger.log('This is the license policy for your organization:') - logger.log('') - const rules = result.data['license_policy']! - const entries = rules ? Object.entries(rules) : [] - const mapped: Array<[string, string]> = entries.map( - ({ 0: key, 1: value }) => - [ - key, - (value as { allowed?: boolean | undefined } | undefined)?.allowed - ? ' yes' - : ' no', - ] as const, - ) - mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - logger.log(mdTableOfPairs(mapped, ['License Name', 'Allowed'])) - logger.log('') -} diff --git a/packages/cli/src/commands/organization/output-organization-list.mts b/packages/cli/src/commands/organization/output-organization-list.mts deleted file mode 100644 index d3b7d606a..000000000 --- a/packages/cli/src/commands/organization/output-organization-list.mts +++ /dev/null @@ -1,80 +0,0 @@ -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { getVisibleTokenPrefix } from '../../util/socket/sdk.mjs' - -import type { OrganizationsCResult } from './fetch-organization-list.mts' -import type { OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputOrganizationList( - orgsCResult: OrganizationsCResult, - outputKind: OutputKind = 'text', -): Promise<void> { - if (!orgsCResult.ok) { - process.exitCode = orgsCResult.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(orgsCResult)) - return - } - - if (!orgsCResult.ok) { - logger.fail(failMsgWithBadge(orgsCResult.message, orgsCResult.cause)) - return - } - - const { organizations } = orgsCResult.data - const visibleTokenPrefix = getVisibleTokenPrefix() - - if (outputKind !== 'markdown') { - logger.log( - `List of organizations associated with your API token, starting with: ${colors.italic(visibleTokenPrefix)}`, - ) - logger.log('') - // Just dump. - for (let i = 0, { length } = organizations; i < length; i += 1) { - const o = organizations[i]! - logger.log( - `- Name: ${colors.bold(o.name ?? 'undefined')}, ID: ${colors.bold(o.id)}, Plan: ${colors.bold(o.plan)}`, - ) - } - return - } - - // | Syntax | Description | - // | ----------- | ----------- | - // | Header | Title | - // | Paragraph | Text | - let mw1 = 4 - let mw2 = 2 - let mw3 = 4 - for (let i = 0, { length } = organizations; i < length; i += 1) { - const o = organizations[i]! - mw1 = Math.max(mw1, o.name?.length ?? 0) - mw2 = Math.max(mw2, o.id.length) - mw3 = Math.max(mw3, o.plan.length) - } - logger.log(`${mdHeader('Organizations')}`) - logger.log('') - logger.log( - `List of organizations associated with your API token, starting with: ${colors.italic(visibleTokenPrefix)}`, - ) - logger.log('') - logger.log( - `| Name${' '.repeat(mw1 - 4)} | ID${' '.repeat(mw2 - 2)} | Plan${' '.repeat(mw3 - 4)} |`, - ) - logger.log(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} | ${'-'.repeat(mw3)} |`) - for (let i = 0, { length } = organizations; i < length; i += 1) { - const o = organizations[i]! - logger.log( - `| ${(o.name || '').padEnd(mw1, ' ')} | ${(o.id || '').padEnd(mw2, ' ')} | ${(o.plan || '').padEnd(mw3, ' ')} |`, - ) - } - logger.log(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} | ${'-'.repeat(mw3)} |`) -} diff --git a/packages/cli/src/commands/organization/output-quota.mts b/packages/cli/src/commands/organization/output-quota.mts deleted file mode 100644 index 9f0862b01..000000000 --- a/packages/cli/src/commands/organization/output-quota.mts +++ /dev/null @@ -1,92 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { emitPayload } from '../../util/output/emit-payload.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type QuotaData = SocketSdkSuccessResult<'getQuota'>['data'] - -export function formatRefresh( - nextWindowRefresh: string | null | undefined, -): string { - if (!nextWindowRefresh) { - return 'unknown' - } - const ts = Date.parse(nextWindowRefresh) - if (Number.isNaN(ts)) { - return nextWindowRefresh - } - const now = Date.now() - const diffMs = ts - now - const date = new Date(ts).toISOString() - if (diffMs <= 0) { - return `${date} (due now)` - } - // Under a minute, say "<1 min" rather than the misleading "in 0 min". - if (diffMs < 60_000) { - return `${date} (in <1 min)` - } - // Thresholds promote one unit early (59.5 min → "in 1 h") to avoid - // degenerate displays like "in 60 min" from naive rounding. - if (diffMs < 3_570_000) { - return `${date} (in ${Math.round(diffMs / 60_000)} min)` - } - if (diffMs < 171_000_000) { - return `${date} (in ${Math.round(diffMs / 3_600_000)} h)` - } - return `${date} (in ${Math.round(diffMs / 86_400_000)} d)` -} - -export function formatUsageLine(data: QuotaData): string { - const remaining = data.quota - const max = data.maxQuota - if (!max) { - return `Quota remaining: ${remaining}` - } - const used = Math.max(0, max - remaining) - const pct = Math.round((used / max) * 100) - return `Quota remaining: ${remaining} / ${max} (${pct}% used)` -} - -export async function outputQuota( - result: CResult<QuotaData>, - outputKind: OutputKind = 'text', -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - // Sentinel-wrap the JSON so pipe-safety is preserved even if a - // downstream spawn in the same process writes to stdout. - emitPayload(serializeResultJson(result), { flags: { json: true } }) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const usageLine = formatUsageLine(result.data) - const refreshLine = `Next refresh: ${formatRefresh(result.data.nextWindowRefresh)}` - - if (outputKind === 'markdown') { - const md = [ - mdHeader('Quota'), - '', - `- ${usageLine}`, - `- ${refreshLine}`, - ].join('\n') - emitPayload(md, { flags: { markdown: true } }) - return - } - - logger.log(usageLine) - logger.log(refreshLine) - logger.log('') -} diff --git a/packages/cli/src/commands/organization/output-security-policy.mts b/packages/cli/src/commands/organization/output-security-policy.mts deleted file mode 100644 index fd625ceee..000000000 --- a/packages/cli/src/commands/organization/output-security-policy.mts +++ /dev/null @@ -1,48 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader, mdTableOfPairs } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputSecurityPolicy( - result: CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.log(mdHeader('Security policy')) - logger.log('') - logger.log( - `The default security policy setting is: "${result.data.securityPolicyDefault}"`, - ) - logger.log('') - logger.log( - 'These are the security policies per setting for your organization:', - ) - logger.log('') - const rules = result.data.securityPolicyRules - const entries: Array< - [string, { action: 'defer' | 'error' | 'warn' | 'monitor' | 'ignore' }] - > = rules ? Object.entries(rules) : [] - const mapped: Array<[string, string]> = entries.map( - ({ 0: key, 1: value }) => [key, value.action], - ) - mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - logger.log(mdTableOfPairs(mapped, ['name', 'action'])) - logger.log('') -} diff --git a/packages/cli/src/commands/package/cmd-package-score.mts b/packages/cli/src/commands/package/cmd-package-score.mts deleted file mode 100644 index ffe9c82df..000000000 --- a/packages/cli/src/commands/package/cmd-package-score.mts +++ /dev/null @@ -1,137 +0,0 @@ -import { handlePurlDeepScore } from './handle-purl-deep-score.mts' -import { parsePackageSpecifiers } from './parse-package-specifiers.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'score' - -const description = - 'Look up score for one package which reflects all of its transitive dependencies as well' - -const hidden = false - -export const cmdPackageScore = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <<ECOSYSTEM> <NAME> | <PURL>> - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Show deep scoring details for one package. The score will reflect the package - itself, any of its dependencies, and any of its transitive dependencies. - - When you want to know whether to trust a package, this is the command to run. - - See also the \`socket package shallow\` command, which returns the shallow - score for any number of packages. That will not reflect the dependency scores. - - Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. - - A "purl" is a standard package name formatting: \`pkg:eco/name@version\` - This command will automatically prepend "pkg:" when not present. - - The version is optional but when given should be a direct match. The \`pkg:\` - prefix is optional. - - Note: if a package cannot be found it may be too old or perhaps was removed - before we had the opportunity to process it. - - Examples - $ ${command} npm babel-cli - $ ${command} npm eslint@1.0.0 --json - $ ${command} pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60 - $ ${command} nuget/needpluscommonlibrary@1.0.0 --markdown - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const [ecosystem = '', purl] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const { purls, valid } = parsePackageSpecifiers(ecosystem, purl ? [purl] : []) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: valid, - message: 'First parameter must be an ecosystem or the whole purl', - fail: 'bad', - }, - { - test: purls.length === 1, - message: 'Expecting at least one package', - fail: !purls.length ? 'missing' : 'too many', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('package score', { - package: purls[0] || '(invalid)', - }) - return - } - - await handlePurlDeepScore(purls[0] || '', outputKind) -} diff --git a/packages/cli/src/commands/package/cmd-package-shallow.mts b/packages/cli/src/commands/package/cmd-package-shallow.mts deleted file mode 100644 index a64b02f6d..000000000 --- a/packages/cli/src/commands/package/cmd-package-shallow.mts +++ /dev/null @@ -1,139 +0,0 @@ -import { handlePurlsShallowScore } from './handle-purls-shallow-score.mts' -import { parsePackageSpecifiers } from './parse-package-specifiers.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'shallow' - -const description = - 'Look up info regarding one or more packages but not their transitives' - -const hidden = false - -export const cmdPackageShallow = { - description, - hidden, - alias: { - shallowScore: { - description, - hidden: true, - argv: [], - }, - }, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <<ECOSYSTEM> <PKGNAME> [<PKGNAME> ...] | <PURL> [<PURL> ...]> - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Show scoring details for one or more packages purely based on their own package. - This means that any dependency scores are not reflected by the score. You can - use the \`socket package score <pkg>\` command to get its full transitive score. - - Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. - - A "purl" is a standard package name formatting: \`pkg:eco/name@version\` - This command will automatically prepend "pkg:" when not present. - - If the first arg is an ecosystem, remaining args that are not a purl are - assumed to be scoped to that ecosystem. The \`pkg:\` prefix is optional. - - Note: if a package cannot be found, it may be too old or perhaps was removed - before we had the opportunity to process it. - - Examples - $ ${command} npm webtorrent - $ ${command} npm webtorrent@1.9.1 - $ ${command} npm/webtorrent@1.9.1 - $ ${command} pkg:npm/webtorrent@1.9.1 - $ ${command} maven webtorrent babel - $ ${command} npm/webtorrent golang/babel - $ ${command} npm npm/webtorrent@1.0.1 babel - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const [ecosystem = '', ...pkgs] = cli.input - - const outputKind = getOutputKind(json, markdown) - - const { purls, valid } = parsePackageSpecifiers(ecosystem, pkgs) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: valid, - message: - 'First parameter should be an ecosystem or all args must be purls', - fail: 'bad', - }, - { - test: purls.length > 0, - message: 'Expecting at least one package', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('package information', { - packages: purls.length ? purls.join(', ') : '(none)', - count: purls.length, - }) - return - } - - await handlePurlsShallowScore({ - outputKind, - purls, - }) -} diff --git a/packages/cli/src/commands/package/cmd-package.mts b/packages/cli/src/commands/package/cmd-package.mts deleted file mode 100644 index 466a60ef6..000000000 --- a/packages/cli/src/commands/package/cmd-package.mts +++ /dev/null @@ -1,22 +0,0 @@ -import { cmdPackageScore } from './cmd-package-score.mts' -import { cmdPackageShallow } from './cmd-package-shallow.mts' -import { defineSubcommandGroup } from '../../util/cli/define-subcommand-group.mts' - -const description = 'Look up published package details' - -export const cmdPackage = defineSubcommandGroup({ - name: 'package', - description, - hidden: false, - subcommands: { - score: cmdPackageScore, - shallow: cmdPackageShallow, - }, - aliases: { - deep: { - description, - hidden: true, - argv: ['score'], - }, - }, -}) diff --git a/packages/cli/src/commands/package/fetch-purls-shallow-score.mts b/packages/cli/src/commands/package/fetch-purls-shallow-score.mts deleted file mode 100644 index c5ec1f47a..000000000 --- a/packages/cli/src/commands/package/fetch-purls-shallow-score.mts +++ /dev/null @@ -1,61 +0,0 @@ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchPurlsShallowScoreOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchPurlsShallowScore( - purls: string[], - options?: FetchPurlsShallowScoreOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'batchPackageFetch'>>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchPurlsShallowScoreOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - const displayPurls = - purls.length > 3 - ? `${purls.slice(0, 3).join(', ')} … and ${purls.length - 3} more` - : joinAnd(purls) - const logger = getDefaultLogger() - logger.info( - `Requesting shallow score data for ${purls.length} package urls (purl): ${displayPurls}`, - ) - - const batchPackageCResult = await handleApiCall<'batchPackageFetch'>( - sockSdk.batchPackageFetch( - { components: purls.map(purl => ({ purl })) }, - { - alerts: 'true', - }, - ), - { - commandPath, - description: 'looking up package', - }, - ) - if (!batchPackageCResult.ok) { - return batchPackageCResult - } - - // Type assertion needed due to SDK result type mismatch. - return { - ok: true, - data: batchPackageCResult.data as SocketSdkSuccessResult<'batchPackageFetch'>, - } -} diff --git a/packages/cli/src/commands/package/fixtures/npm_malware.json b/packages/cli/src/commands/package/fixtures/npm_malware.json deleted file mode 100644 index cec30beeb..000000000 --- a/packages/cli/src/commands/package/fixtures/npm_malware.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "desc": "(2025-09) This fixture is a dummy malware package for testing", - " $": "Mock API response for testing malware and gptMalware detection", - " ": "A test package flagged as malware and gptMalware.", - - "ok": true, - "data": [ - { - "id": "99999999999", - "size": 1024, - "type": "npm", - "name": "evil-test-package", - "version": "1.0.0", - "alerts": [ - { - "key": "QTEST_MALWARE_KEY_12345678901234567890", - "type": "malware", - "severity": "critical", - "category": "supplyChainRisk", - "file": "evil-test-package-1.0.0/index.js", - "props": { - "id": 999999, - "note": "This package contains malicious code that attempts to steal credentials and execute remote commands. DO NOT USE." - }, - "action": "error", - "fix": { - "type": "remove", - "description": "Remove this package immediately and audit your system for compromise." - } - }, - { - "key": "QTEST_GPTMALWARE_KEY_98765432109876543210", - "type": "gptMalware", - "severity": "critical", - "category": "supplyChainRisk", - "file": "evil-test-package-1.0.0/index.js", - "props": { - "notes": "AI analysis detected highly suspicious patterns including credential harvesting, data exfiltration, and backdoor installation. This package poses an extreme security risk.", - "severity": 0.99, - "confidence": 0.98 - }, - "action": "error" - }, - { - "key": "QTEST_NETWORK_ACCESS_KEY_11111111111111111111", - "type": "networkAccess", - "severity": "high", - "category": "supplyChainRisk", - "file": "evil-test-package-1.0.0/index.js", - "action": "warn" - }, - { - "key": "QTEST_OBFUSCATED_KEY_22222222222222222222", - "type": "obfuscatedFile", - "severity": "high", - "category": "supplyChainRisk", - "file": "evil-test-package-1.0.0/obfuscated.js", - "props": { - "notes": "Code is heavily obfuscated to hide malicious behavior.", - "confidence": 0.95 - }, - "action": "warn" - } - ], - "score": { - "license": 0, - "maintenance": 0, - "overall": 0.01, - "quality": 0, - "supplyChain": 0.01, - "vulnerability": 0 - }, - "batchIndex": 0, - "license": "UNKNOWN", - "licenseDetails": [] - } - ] -} diff --git a/packages/cli/src/commands/package/handle-purl-deep-score.mts b/packages/cli/src/commands/package/handle-purl-deep-score.mts deleted file mode 100644 index ebe1bc756..000000000 --- a/packages/cli/src/commands/package/handle-purl-deep-score.mts +++ /dev/null @@ -1,21 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchPurlDeepScore } from './fetch-purl-deep-score.mts' -import { outputPurlsDeepScore } from './output-purls-deep-score.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handlePurlDeepScore( - purl: string, - outputKind: OutputKind, -) { - debug(`Fetching deep score for ${purl}`) - debugDir({ purl, outputKind }) - - const result = await fetchPurlDeepScore(purl) - - debug(`Deep score ${result.ok ? 'fetched successfully' : 'fetch failed'}`) - debugDir({ result }) - - await outputPurlsDeepScore(purl, result, outputKind) -} diff --git a/packages/cli/src/commands/package/handle-purls-shallow-score.mts b/packages/cli/src/commands/package/handle-purls-shallow-score.mts deleted file mode 100644 index ed8401b3c..000000000 --- a/packages/cli/src/commands/package/handle-purls-shallow-score.mts +++ /dev/null @@ -1,33 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchPurlsShallowScore } from './fetch-purls-shallow-score.mts' -import { outputPurlsShallowScore } from './output-purls-shallow-score.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' - -export async function handlePurlsShallowScore({ - outputKind, - purls, -}: { - outputKind: OutputKind - purls: string[] -}) { - debug(`Fetching shallow scores for ${purls.length} packages`) - debugDir({ purls, outputKind }) - - const packageData = await fetchPurlsShallowScore(purls, { - commandPath: 'socket package shallow', - }) - - debug( - `Shallow scores ${packageData.ok ? 'fetched successfully' : 'fetch failed'}`, - ) - debugDir({ packageData }) - - outputPurlsShallowScore( - purls, - packageData as CResult<SocketArtifact[]>, - outputKind, - ) -} diff --git a/packages/cli/src/commands/package/output-purls-deep-score.mts b/packages/cli/src/commands/package/output-purls-deep-score.mts deleted file mode 100644 index f10646c34..000000000 --- a/packages/cli/src/commands/package/output-purls-deep-score.mts +++ /dev/null @@ -1,218 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTable } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { PurlDataResponse } from './fetch-purl-deep-score.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export function createMarkdownReport(data: PurlDataResponse): string { - const { - self: { - alerts: selfAlerts, - capabilities: selfCaps, - purl, - score: selfScore, - }, - transitively: { - alerts, - capabilities, - dependencyCount, - func, - lowest, - score, - }, - } = data - - const o: string[] = ['# Complete Package Score', ''] - if (dependencyCount) { - o.push( - `This is a Socket report for the package *"${purl}"* and its *${dependencyCount}* direct/transitive dependencies.`, - ) - } else { - o.push( - `This is a Socket report for the package *"${purl}"*. It has *no dependencies*.`, - ) - } - o.push('') - if (dependencyCount) { - o.push( - 'It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it.', - ) - } else { - o.push( - 'It will show you the shallow score for the package itself, which capabilities were found, and its top alerts.', - ) - o.push('') - o.push( - 'Since it has no dependencies, the shallow score is also the deep score.', - ) - } - o.push('') - if (dependencyCount) { - // This doesn't make much sense if there are no dependencies. Better to omit it. - o.push( - 'The report should give you a good insight into the status of this package.', - ) - o.push('') - o.push('## Package itself') - o.push('') - o.push( - 'Here are results for the package itself (excluding data from dependencies).', - ) - } else { - o.push('## Report') - o.push('') - o.push( - 'The report should give you a good insight into the status of this package.', - ) - } - o.push('') - o.push('### Shallow Score') - o.push('') - o.push('This score is just for the package itself:') - o.push('') - o.push(`- Overall: ${selfScore.overall}`) - o.push(`- Maintenance: ${selfScore.maintenance}`) - o.push(`- Quality: ${selfScore.quality}`) - o.push(`- Supply Chain: ${selfScore.supplyChain}`) - o.push(`- Vulnerability: ${selfScore.vulnerability}`) - o.push(`- License: ${selfScore.license}`) - o.push('') - o.push('### Capabilities') - o.push('') - if (selfCaps.length) { - o.push('These are the capabilities detected in the package itself:') - o.push('') - for (let i = 0, { length } = selfCaps; i < length; i += 1) { - const cap = selfCaps[i] - o.push(`- ${cap}`) - } - } else { - o.push('No capabilities were found in the package.') - } - o.push('') - o.push('### Alerts for this package') - o.push('') - if (selfAlerts.length) { - if (dependencyCount) { - o.push('These are the alerts found for the package itself:') - } else { - o.push('These are the alerts found for this package:') - } - o.push('') - o.push( - mdTable(selfAlerts, ['severity', 'name'], ['Severity', 'Alert Name']), - ) - } else { - o.push('There are currently no alerts for this package.') - } - o.push('') - if (dependencyCount) { - o.push('## Transitive Package Results') - o.push('') - o.push( - 'Here are results for the package and its direct/transitive dependencies.', - ) - o.push('') - o.push('### Deep Score') - o.push('') - o.push( - 'This score represents the package and and its direct/transitive dependencies:', - ) - o.push( - `The function used to calculate the values in aggregate is: *"${func}"*`, - ) - o.push('') - o.push(`- Overall: ${score.overall}`) - o.push(`- Maintenance: ${score.maintenance}`) - o.push(`- Quality: ${score.quality}`) - o.push(`- Supply Chain: ${score.supplyChain}`) - o.push(`- Vulnerability: ${score.vulnerability}`) - o.push(`- License: ${score.license}`) - o.push('') - o.push('### Capabilities') - o.push('') - o.push( - 'These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores.', - ) - o.push('') - o.push(`- Overall: ${lowest.overall}`) - o.push(`- Maintenance: ${lowest.maintenance}`) - o.push(`- Quality: ${lowest.quality}`) - o.push(`- Supply Chain: ${lowest.supplyChain}`) - o.push(`- Vulnerability: ${lowest.vulnerability}`) - o.push(`- License: ${lowest.license}`) - o.push('') - o.push('### Capabilities') - o.push('') - if (capabilities.length) { - o.push('These are the capabilities detected in at least one package:') - o.push('') - for (let i = 0, { length } = capabilities; i < length; i += 1) { - const cap = capabilities[i] - o.push(`- ${cap}`) - } - } else { - o.push( - 'This package had no capabilities and neither did any of its direct/transitive dependencies.', - ) - } - o.push('') - o.push('### Alerts') - o.push('') - if (alerts.length) { - o.push('These are the alerts found:') - o.push('') - - o.push( - mdTable( - alerts, - ['severity', 'name', 'example'], - ['Severity', 'Alert Name', 'Example package reporting it'], - ), - ) - } else { - o.push( - 'This package had no alerts and neither did any of its direct/transitive dependencies', - ) - } - o.push('') - } - return o.join('\n') -} - -export async function outputPurlsDeepScore( - purl: string, - result: CResult<PurlDataResponse>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'markdown') { - const md = createMarkdownReport(result.data) - logger.success(`Score report for "${result.data.purl}" ("${purl}"):`) - logger.error('') - logger.log(md) - return - } - - logger.log( - `Score report for "${purl}" (use --json for raw and --markdown for formatted reports):`, - ) - logger.log(result.data) - logger.log('') -} diff --git a/packages/cli/src/commands/package/output-purls-shallow-score.mts b/packages/cli/src/commands/package/output-purls-shallow-score.mts deleted file mode 100644 index f235f3481..000000000 --- a/packages/cli/src/commands/package/output-purls-shallow-score.mts +++ /dev/null @@ -1,349 +0,0 @@ -import colors from 'yoctocolors-cjs' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' -const logger = getDefaultLogger() - -export function formatReportCard( - artifact: DedupedArtifact, - colorize: boolean, -): string { - const scoreResult = { - 'Supply Chain Risk': Math.floor((artifact.score?.supplyChain ?? 0) * 100), - Maintenance: Math.floor((artifact.score?.maintenance ?? 0) * 100), - Quality: Math.floor((artifact.score?.quality ?? 0) * 100), - Vulnerabilities: Math.floor((artifact.score?.vulnerability ?? 0) * 100), - License: Math.floor((artifact.score?.license ?? 0) * 100), - } - const alertString = getAlertString(artifact.alerts, { colorize }) - /* c8 ignore start - artifact.ecosystem is required by the SDK contract */ - if (!artifact.ecosystem) { - debug(`miss: artifact ecosystem ${JSON.stringify(artifact)}`) - } - /* c8 ignore stop */ - const purl = `pkg:${artifact.ecosystem}/${artifact.name}${artifact.version ? `@${artifact.version}` : ''}` - - // Calculate proper padding based on longest label. - const maxLabelLength = Math.max( - ...Object.keys(scoreResult).map(label => label.length), - ) - const labelPadding = maxLabelLength + 2 // +2 for ": " - - return [ - `Package: ${colorize ? colors.bold(purl) : purl}`, - '', - ...Object.entries(scoreResult).map( - score => - `- ${score[0]}:`.padEnd(labelPadding, ' ') + - ` ${formatScore(score[1], { colorize })}`, - ), - alertString, - ].join('\n') -} - -type FormatScoreOptions = { - colorize?: boolean | undefined - padding?: number | undefined -} - -export function formatScore( - score: number, - options?: FormatScoreOptions | undefined, -): string { - const { colorize, padding = 3 } = { - __proto__: null, - ...options, - } as FormatScoreOptions - const padded = String(score).padStart(padding, ' ') - if (!colorize) { - return padded - } - if (score >= 80) { - return colors.green(padded) - } - if (score >= 60) { - return colors.yellow(padded) - } - return colors.red(padded) -} - -export function generateMarkdownReport( - artifacts: Map<string, DedupedArtifact>, - missing: string[], -): string { - const blocks: string[] = [] - const dupes: Set<string> = new Set() - for (const artifact of artifacts.values()) { - const block = `## ${formatReportCard(artifact, false)}` - if (dupes.has(block)) { - // Omit duplicate blocks. - continue - } - dupes.add(block) - blocks.push(block) - } - return ` -# Shallow Package Report - -This report contains the response for requesting data on some package url(s). - -Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - -${missing.length ? `\n## Missing response\n\nAt least one package had no response or the purl was not canonical:\n\n${missing.map(purl => `- ${purl}\n`).join('')}` : ''} - -${blocks.join('\n\n\n')} - `.trim() -} - -export function generateTextReport( - artifacts: Map<string, DedupedArtifact>, - missing: string[], -): string { - const o: string[] = [] - o.push(`\n${colors.bold('Shallow Package Score')}\n`) - o.push( - 'Please note: The listed scores are ONLY for the package itself. It does NOT\n' + - ' reflect the scores of any dependencies, transitive or otherwise.', - ) - if (missing.length) { - o.push( - `\nAt least one package had no response or the purl was not canonical:\n${missing.map(purl => `\n- ${colors.bold(purl)}`).join('')}`, - ) - } - const dupes: Set<string> = new Set() - for (const artifact of artifacts.values()) { - const block = formatReportCard(artifact, true) - if (dupes.has(block)) { - // Omit duplicate blocks. - continue - } - dupes.add(block) - o.push('\n') - o.push(block) - } - o.push('') - - return o.join('\n') -} - -type AlertStringOptions = { - colorize?: boolean | undefined -} - -export function getAlertString( - alerts: DedupedArtifact['alerts'], - options?: AlertStringOptions | undefined, -): string { - const { colorize } = { __proto__: null, ...options } as AlertStringOptions - - if (!alerts.size) { - return `- Alerts: ${colorize ? colors.green('none') : 'none'}!` - } - - const o = Array.from(alerts.values()) - - const bad = o - .filter(alert => alert.severity !== 'low' && alert.severity !== 'middle') - .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) - - const mid = o - .filter(alert => alert.severity === 'middle') - .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) - - const low = o - .filter(alert => alert.severity === 'low') - .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) - - // We need to create the no-color string regardless because the actual string - // contains a bunch of invisible ANSI chars which would screw up length checks. - const colorless = `- Alerts (${bad.length}/${mid.length}/${low.length}):` - const padding = ` ${' '.repeat(Math.max(0, 20 - colorless.length))}` - - if (colorize) { - return `- Alerts (${colors.red(String(bad.length))}/${colors.yellow(String(mid.length))}/${low.length}):${ - padding - }${joinAnd([ - ...bad.map(a => colors.red(`${colors.dim(`[${a.severity}] `)}${a.type}`)), - ...mid.map(a => - colors.yellow(`${colors.dim(`[${a.severity}] `)}${a.type}`), - ), - ...low.map(a => `${colors.dim(`[${a.severity}] `)}${a.type}`), - ])}` - } - return `${colorless}${padding}${joinAnd([ - ...bad.map(a => `[${a.severity}] ${a.type}`), - ...mid.map(a => `[${a.severity}] ${a.type}`), - ...low.map(a => `[${a.severity}] ${a.type}`), - ])}` -} - -// This is a simplified view of an artifact. Potentially merged with other artifacts. -interface DedupedArtifact { - ecosystem: string // artifact.type - namespace: string - name: string - version: string - score: { - supplyChain: number - maintenance: number - quality: number - vulnerability: number - license: number - } - alerts: Map< - string, - { - type: string - severity: string - } - > -} - -export function outputPurlsShallowScore( - purls: string[], - result: CResult<SocketArtifact[]>, - outputKind: OutputKind, -): void { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { missing, rows } = preProcess(result.data, purls) - - if (outputKind === 'markdown') { - const md = generateMarkdownReport(rows, missing) - logger.log(md) - return - } - - const txt = generateTextReport(rows, missing) - logger.log(txt) -} - -export function preProcess( - artifacts: SocketArtifact[], - requestedPurls: string[], -): { rows: Map<string, DedupedArtifact>; missing: string[] } { - // Dedupe results (for example, PyPI will emit one package for each system release (win/mac/cpu) even if it's - // the same package version with same results. The duplication is irrelevant and annoying to the user. - - // Make some effort to match the requested data with the response - // Dedupe and merge results when only the .release value is different - - // API does not tell us which purls were not found. - // Generate all purls to try so we can try to match search request. - const purls: Set<string> = new Set() - for (let i = 0, { length } = artifacts; i < length; i += 1) { - const data = artifacts[i]! - purls.add( - `pkg:${data.type}/${data.namespace ? `${data.namespace}/` : ''}${data.name}@${data.version}`, - ) - purls.add(`pkg:${data.type}/${data.name}@${data.version}`) - purls.add(`pkg:${data.type}/${data.name}`) - purls.add( - `pkg:${data.type}/${data.namespace ? `${data.namespace}/` : ''}${data.name}`, - ) - } - // Try to match the searched purls against this list - const missing = requestedPurls.filter(purl => { - if (purls.has(purl)) { - return false - } - if ( - purl.endsWith('@latest') && - purls.has(purl.slice(0, -'@latest'.length)) - ) { - return false - } - // Not found. - return true - }) - - // Create a unique set of rows which represents each artifact that is returned - // while deduping when the artifact (main) meta data only differs due to the - // .release field (observed with python, at least). - // Merge the alerts for duped packages. Use lowest score between all of them. - const rows: Map<string, DedupedArtifact> = new Map() - for (let i = 0, { length } = artifacts; i < length; i += 1) { - const artifact = artifacts[i]! - const purl = `pkg:${artifact.type}/${artifact.namespace ? `${artifact.namespace}/` : ''}${artifact.name}${artifact.version ? `@${artifact.version}` : ''}` - if (rows.has(purl)) { - const row = rows.get(purl) - /* c8 ignore start - rows.has just confirmed; .get cannot return undefined here */ - if (!row) { - continue - } - /* c8 ignore stop */ - if ((artifact.score?.supplyChain ?? 100) < row.score.supplyChain) { - row.score.supplyChain = artifact.score?.supplyChain ?? 100 - } - if ((artifact.score?.maintenance ?? 100) < row.score.maintenance) { - row.score.maintenance = artifact.score?.maintenance ?? 100 - } - if ((artifact.score?.quality ?? 100) < row.score.quality) { - row.score.quality = artifact.score?.quality ?? 100 - } - if ((artifact.score?.vulnerability ?? 100) < row.score.vulnerability) { - row.score.vulnerability = artifact.score?.vulnerability ?? 100 - } - if ((artifact.score?.license ?? 100) < row.score.license) { - row.score.license = artifact.score?.license ?? 100 - } - - // oxlint-disable-next-line socket/prefer-cached-for-loop -- call result is consumed (not a standalone statement) - artifact.alerts?.forEach((alert: { type: string; severity?: string | undefined }) => { - const severity = alert.severity ?? '' - const { type } = alert - row.alerts.set(`${type}:${severity}`, { - type, - severity, - }) - }) - } else { - const alerts = new Map<string, { type: string; severity: string }>() - // oxlint-disable-next-line socket/prefer-cached-for-loop -- call result is consumed (not a standalone statement) - artifact.alerts?.forEach((alert: { type: string; severity?: string | undefined }) => { - const severity = alert.severity ?? '' - const { type } = alert - alerts.set(`${type}:${severity}`, { - type, - severity, - }) - }) - - rows.set(purl, { - ecosystem: artifact.type, - namespace: artifact.namespace || '', - name: artifact.name!, - version: artifact.version || '', - score: { - supplyChain: artifact.score?.supplyChain ?? 100, - maintenance: artifact.score?.maintenance ?? 100, - quality: artifact.score?.quality ?? 100, - vulnerability: artifact.score?.vulnerability ?? 100, - license: artifact.score?.license ?? 100, - }, - alerts, - }) - } - } - - return { rows, missing } -} diff --git a/packages/cli/src/commands/patch/cmd-patch.mts b/packages/cli/src/commands/patch/cmd-patch.mts deleted file mode 100644 index 7fc7ade65..000000000 --- a/packages/cli/src/commands/patch/cmd-patch.mts +++ /dev/null @@ -1,76 +0,0 @@ -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { spawnSocketPatchDlx } from '../../util/dlx/spawn.mjs' - -import type { - CliCommandContext, - CliSubcommand, -} from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'patch' - -const description = 'Manage CVE patches for dependencies' - -const hidden = false - -export const cmdPatch: CliSubcommand = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: CliCommandContext, -): Promise<void> { - const { parentName } = { __proto__: null, ...context } as CliCommandContext - - // Check if there are any non-flag arguments (subcommands). - const hasSubcommand = argv.some(arg => !arg.startsWith('-')) - - // Only show Socket CLI help if no subcommand is provided. - // If a subcommand is present (like 'list', 'info'), forward to socket-patch. - if (!hasSubcommand) { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: {}, - help: (command: string) => ` - Usage - $ ${command} ... - - Note: All arguments are forwarded to socket-patch. - - Examples - $ ${command} list - $ ${command} get <package> - $ ${command} apply - `, - } - - // Parse arguments to handle --help for patch-level help. - meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - } - - process.exitCode = 1 - - // Forward all arguments to socket-patch via DLX. - const { spawnPromise } = await spawnSocketPatchDlx([...argv], { - stdio: 'inherit', - }) - - // Wait for the spawn to complete and set exit code. - const result = await spawnPromise - - if (result.code != null && result.code !== 0) { - process.exitCode = result.code - } else if (result.code === 0) { - process.exitCode = 0 - } -} diff --git a/packages/cli/src/commands/pip/cmd-pip.mts b/packages/cli/src/commands/pip/cmd-pip.mts deleted file mode 100644 index 2cc92339e..000000000 --- a/packages/cli/src/commands/pip/cmd-pip.mts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Socket pip command — forwards pip operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. The pip-specific binary picker (pip ↔ - * pip3 with auto-fallback when one is missing) is wired through the factory's - * `binaryPicker` hook. - * - * See util/cli/define-handoff.mts. - */ - -import { whichReal } from '@socketsecurity/lib-stable/bin/which' - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -/** - * Determine the pip binary name to use based on invocation and availability. - * - * Priority: 1. If invoked as `socket pip3`, use 'pip3'. 2. If invoked as - * `socket pip`, use 'pip' if it exists, else fall back to 'pip3'. 3. If pip3 - * was requested but is missing, fall back to 'pip'. - * - * If neither binary is available, return the originally-requested name and let - * the spawn fail naturally — the user gets a recognizable PATH error instead of - * an opaque fallback. - * - * @param invokedAs - The alias name used to invoke the command (e.g., 'pip3'). - */ -export async function getPipBinName(invokedAs?: string): Promise<string> { - const requested = invokedAs === 'pip3' ? invokedAs : 'pip' - const fallback = requested === 'pip' ? 'pip3' : 'pip' - - if (await whichReal(requested, { nothrow: true })) { - return requested - } - if (await whichReal(fallback, { nothrow: true })) { - return fallback - } - return requested -} - -export const cmdPip = defineHandoffCommand({ - name: 'pip', - description: 'Run pip with Socket Firewall security', - spawnMode: 'dlx', - examples: ['install flask', 'install -r requirements.txt', 'list'], - binaryPicker: ctx => getPipBinName(ctx.invokedAs), - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/pnpm/cmd-pnpm.mts b/packages/cli/src/commands/pnpm/cmd-pnpm.mts deleted file mode 100644 index 279459142..000000000 --- a/packages/cli/src/commands/pnpm/cmd-pnpm.mts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Socket pnpm command — forwards pnpm operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { PNPM } from '@socketsecurity/lib-stable/constants/agents' - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const CMD_NAME = PNPM - -export const cmdPnpm = defineHandoffCommand({ - name: PNPM, - description: 'Run pnpm with Socket Firewall security', - spawnMode: 'dlx', - hidden: true, - examples: ['', 'install', 'add package-name', 'dlx package-name'], - showApiRequirements: true, - wrapperHint: true, -}) diff --git a/packages/cli/src/commands/pycli/cmd-pycli.mts b/packages/cli/src/commands/pycli/cmd-pycli.mts deleted file mode 100644 index f66aa96c7..000000000 --- a/packages/cli/src/commands/pycli/cmd-pycli.mts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Socket Python CLI (pycli) command. - * - * Explicit passthrough to the Socket Python CLI (socketsecurity) for features - * not yet available in the Node.js CLI. This replaces implicit fallback - * behavior with an explicit command that makes it clear when Python CLI is - * being used. - * - * Features available via Python CLI: - --generate-license: Generate license - * metadata for packages - --enable-sarif: Output in SARIF format - - * --strict-blocking: Fail on any policy violations (not just new ones) - - * --disable-blocking: Always exit 0 - --enable-gitlab-security: GitLab - * Dependency Scanning format - --slack-webhook: Send notifications to Slack - - * --save-manifest-tar: Archive manifests for audit trail. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { filterFlags, isHelpFlag } from '../../util/process/cmd.mts' -import { spawnSocketPyCli } from '../../util/python/standalone.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface PycliFlags { - dryRun: boolean -} - -const config = { - commandName: 'pycli', - description: 'Run Socket Python CLI (socketsecurity) directly', - hidden: false, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string) => ` - Usage - $ ${command} [python-cli-options] [TARGET...] - - Options - ${getFlagListOutput(commonFlags)} - - This command passes all arguments directly to the Socket Python CLI - (socketsecurity). Use this for features not yet available in the - Node.js CLI. - - Python CLI Features: - --generate-license Generate license metadata for all packages - --license-file-name Output file for license data - --enable-sarif Output in SARIF format - --enable-gitlab-security GitLab Dependency Scanning report format - --strict-blocking Fail on ANY policy violations (not just new) - --disable-blocking Always exit 0 regardless of findings - --save-manifest-tar Archive manifests for audit trail - --slack-webhook Send notifications to Slack webhook - - Common Options (passed to Python CLI): - --repo <owner/repo> Repository name - --branch <name> Branch name - --commit-sha <sha> Commit SHA - --target-path <path> Path to scan - --pr-number <n> Pull request number - - Examples - $ ${command} --help - $ ${command} --generate-license --repo owner/repo . - $ ${command} --enable-sarif --strict-blocking . - $ ${command} --slack-webhook https://hooks.slack.com/... . - `, -} - -export const cmdPyCli = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: CliCommandContext, -): Promise<void> { - const { parentName } = { - __proto__: null, - ...context, - } as CliCommandContext - - // Check for help flag - if present, show our help first then Python CLI help. - const hasHelpFlag = argv.some(a => isHelpFlag(a)) - - if (hasHelpFlag) { - // Show Socket CLI wrapper help. - meowOrExit({ - argv: ['--help'], - config, - importMeta, - parentName, - }) - // meowOrExit will exit here. - return - } - - const cli = meowOrExit({ - argv: argv.filter(a => !isHelpFlag(a)), - config, - importMeta, - parentName, - }) - - const { dryRun } = cli.flags as unknown as PycliFlags - - // Filter Socket-specific flags from argv, pass rest to Python CLI. - const pyCliArgs = filterFlags(argv, commonFlags, []) - - if (dryRun) { - outputDryRunExecute('socketsecurity', pyCliArgs, 'Python CLI') - return - } - - logger.info('Invoking Socket Python CLI...') - - const result = await spawnSocketPyCli(pyCliArgs, { - stdio: 'inherit', - }) - - if (!result.ok) { - process.exitCode = 1 - if (result.message) { - logger.fail(result.message) - } - } -} diff --git a/packages/cli/src/commands/raw-npm/cmd-raw-npm.mts b/packages/cli/src/commands/raw-npm/cmd-raw-npm.mts deleted file mode 100644 index 0b24c7ea4..000000000 --- a/packages/cli/src/commands/raw-npm/cmd-raw-npm.mts +++ /dev/null @@ -1,100 +0,0 @@ -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { FLAG_DRY_RUN, FLAG_HELP } from '../../constants/cli.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { getNpmBinPath } from '../../util/npm/paths.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'raw-npm' - -const description = 'Run npm without the Socket wrapper' - -const hidden = false - -// Helper functions. - -export async function run( - argv: readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string) => ` - Usage - $ ${command} ... - - Execute \`npm\` without gating installs through the Socket API. - Useful when \`socket wrapper on\` is enabled and you want to bypass - the Socket wrapper. Use at your own risk. - - Note: Everything after "raw-npm" is passed to the npm command. - Only the \`${FLAG_DRY_RUN}\` and \`${FLAG_HELP}\` flags are caught here. - - Examples - $ ${command} install -g cowsay - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const dryRun = !!cli.flags['dryRun'] - - if (dryRun) { - outputDryRunExecute(getNpmBinPath(), argv as string[], 'raw npm command') - return - } - - await runRawNpm(argv) -} - -export async function runRawNpm( - argv: string[] | readonly string[], -): Promise<void> { - process.exitCode = 1 - - const spawnPromise = spawn(getNpmBinPath(), argv as string[], { - // On Windows, npm is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - stdio: 'inherit', - }) - - // See https://nodejs.org/api/child_process.html#event-exit. - spawnPromise.process.on( - 'exit', - (code: number | null, signalName: string | null) => { - if (signalName) { - process.kill(process.pid, signalName) - } else if (typeof code === 'number') { - process.exit(code) - } - }, - ) - - await spawnPromise -} - -// Exported command. - -export const cmdRawNpm = { - description, - hidden, - run, -} diff --git a/packages/cli/src/commands/raw-npx/cmd-raw-npx.mts b/packages/cli/src/commands/raw-npx/cmd-raw-npx.mts deleted file mode 100644 index 0aa857a1d..000000000 --- a/packages/cli/src/commands/raw-npx/cmd-raw-npx.mts +++ /dev/null @@ -1,107 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-npx-dlx -- product feature name / command wrapping npx; the literal is intentional. */ - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { FLAG_DRY_RUN, FLAG_HELP } from '../../constants/cli.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { getNpxBinPath } from '../../util/npm/paths.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' - -export const CMD_NAME = 'raw-npx' - -const description = 'Run pnpm exec without the Socket wrapper' - -const hidden = false - -// Helper functions. - -export async function run( - argv: readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string) => ` - Usage - $ ${command} ... - - Execute \`npx\` without gating installs through the Socket API. - Useful when \`socket wrapper on\` is enabled and you want to bypass - the Socket wrapper. Use at your own risk. - - Note: Everything after "raw-npx" is passed to the npx command. - Only the \`${FLAG_DRY_RUN}\` and \`${FLAG_HELP}\` flags are caught here. - - Examples - $ ${command} cowsay - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const dryRun = !!cli.flags['dryRun'] - - if (dryRun) { - outputDryRunExecute( - getNpxBinPath(), - argv as string[], - 'raw pnpm exec command', - ) - return - } - - await runRawNpx(argv) -} - -export async function runRawNpx( - argv: string[] | readonly string[], -): Promise<void> { - process.exitCode = 1 - - const spawnPromise = spawn(getNpxBinPath(), argv as string[], { - // On Windows, npx is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - stdio: 'inherit', - }) - - // See https://nodejs.org/api/child_process.html#event-exit. - spawnPromise.process.on( - 'exit', - (code: number | null, signalName: string | null) => { - if (signalName) { - process.kill(process.pid, signalName) - } else if (typeof code === 'number') { - process.exit(code) - } - }, - ) - - await spawnPromise -} - -// Exported command. - -export const cmdRawNpx = { - description, - hidden, - run, -} diff --git a/packages/cli/src/commands/repository/cmd-repository-create.mts b/packages/cli/src/commands/repository/cmd-repository-create.mts deleted file mode 100644 index ccca0078d..000000000 --- a/packages/cli/src/commands/repository/cmd-repository-create.mts +++ /dev/null @@ -1,51 +0,0 @@ -import { handleCreateRepo } from './handle-create-repo.mts' -import { createRepositoryCommand } from './repository-command-factory.mts' - -export const CMD_NAME = 'create' - -export const cmdRepositoryCreate = createRepositoryCommand({ - commandName: CMD_NAME, - description: 'Create a repository in an organization', - extraFlags: { - defaultBranch: { - default: 'main', - description: 'Repository default branch. Defaults to "main"', - type: 'string', - }, - homepage: { - default: '', - description: 'Repository url', - type: 'string', - }, - repoDescription: { - default: '', - description: 'Repository description', - type: 'string', - }, - visibility: { - default: 'private', - description: 'Repository visibility (Default Private)', - type: 'string', - }, - }, - handler: async ({ flags, orgSlug, outputKind, repoName }) => { - const visibility = String(flags['visibility'] || 'private') - await handleCreateRepo( - { - defaultBranch: String(flags['defaultBranch'] || ''), - description: String(flags['repoDescription'] || ''), - homepage: String(flags['homepage'] || ''), - orgSlug, - repoName: String(repoName), - visibility: visibility === 'public' ? 'public' : 'private', - }, - outputKind, - ) - }, - helpDescription: - 'The REPO name should be a "slug". Follows the same naming convention as GitHub.', - helpExamples: [ - 'test-repo', - 'our-repo --homepage=socket.dev --default-branch=trunk', - ], -}) diff --git a/packages/cli/src/commands/repository/cmd-repository-del.mts b/packages/cli/src/commands/repository/cmd-repository-del.mts deleted file mode 100644 index 9e232c3d7..000000000 --- a/packages/cli/src/commands/repository/cmd-repository-del.mts +++ /dev/null @@ -1,13 +0,0 @@ -import { handleDeleteRepo } from './handle-delete-repo.mts' -import { createRepositoryCommand } from './repository-command-factory.mts' - -export const CMD_NAME = 'del' - -export const cmdRepositoryDel = createRepositoryCommand({ - commandName: CMD_NAME, - description: 'Delete a repository in an organization', - handler: async ({ orgSlug, outputKind, repoName }) => { - await handleDeleteRepo(orgSlug, repoName, outputKind) - }, - helpExamples: ['test-repo'], -}) diff --git a/packages/cli/src/commands/repository/cmd-repository-list.mts b/packages/cli/src/commands/repository/cmd-repository-list.mts deleted file mode 100644 index aff350553..000000000 --- a/packages/cli/src/commands/repository/cmd-repository-list.mts +++ /dev/null @@ -1,194 +0,0 @@ -import { handleListRepos } from './handle-list-repos.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mjs' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { Direction } from './types.mts' -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface RepositoryListFlags { - all: boolean - direction: Direction - dryRun: boolean - interactive: boolean - json: boolean - markdown: boolean - org: string - page: number - perPage: number - sort: string -} - -export const CMD_NAME = 'list' - -const description = 'List repositories in an organization' - -const hidden = false - -export const cmdRepositoryList = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - all: { - type: 'boolean', - default: false, - description: - 'By default view shows the last n repos. This flag allows you to fetch the entire list. Will ignore --page and --per-page.', - }, - direction: { - type: 'string', - default: 'desc', - description: 'Direction option', - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - default: '', - description: - 'Force override the organization slug, overrides the default org from config', - }, - perPage: { - type: 'number', - default: 30, - description: 'Number of results per page', - shortFlag: 'pp', - }, - page: { - type: 'number', - default: 1, - description: 'Page number', - shortFlag: 'p', - }, - sort: { - type: 'string', - default: 'created_at', - description: 'Sorting option', - shortFlag: 's', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --json - `, - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { - all, - direction = 'desc', - dryRun, - interactive, - json, - markdown, - org: orgFlag, - page, - perPage, - sort, - } = cli.flags as unknown as RepositoryListFlags - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug(orgFlag, interactive, dryRun) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: direction === 'asc' || direction === 'desc', - message: 'The --direction value must be "asc" or "desc"', - fail: 'unexpected value', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('repositories', { - organization: orgSlug, - all: all || undefined, - sort, - direction, - page: all ? undefined : page, - perPage: all ? undefined : perPage, - }) - return - } - - await handleListRepos({ - all, - direction, - orgSlug, - outputKind, - page, - perPage, - sort, - }) -} diff --git a/packages/cli/src/commands/repository/cmd-repository-update.mts b/packages/cli/src/commands/repository/cmd-repository-update.mts deleted file mode 100644 index 107ced2a5..000000000 --- a/packages/cli/src/commands/repository/cmd-repository-update.mts +++ /dev/null @@ -1,49 +0,0 @@ -import { handleUpdateRepo } from './handle-update-repo.mts' -import { createRepositoryCommand } from './repository-command-factory.mts' - -export const CMD_NAME = 'update' - -export const cmdRepositoryUpdate = createRepositoryCommand({ - commandName: CMD_NAME, - description: 'Update a repository in an organization', - extraFlags: { - defaultBranch: { - default: 'main', - description: 'Repository default branch', - shortFlag: 'b', - type: 'string', - }, - homepage: { - default: '', - description: 'Repository url', - shortFlag: 'h', - type: 'string', - }, - repoDescription: { - default: '', - description: 'Repository description', - shortFlag: 'd', - type: 'string', - }, - visibility: { - default: 'private', - description: 'Repository visibility (Default Private)', - shortFlag: 'v', - type: 'string', - }, - }, - handler: async ({ flags, orgSlug, outputKind, repoName }) => { - await handleUpdateRepo( - { - defaultBranch: String(flags['defaultBranch'] || ''), - description: String(flags['repoDescription'] || ''), - homepage: String(flags['homepage'] || ''), - orgSlug, - repoName: String(repoName), - visibility: String(flags['visibility'] || 'private'), - }, - outputKind, - ) - }, - helpExamples: ['test-repo', 'test-repo --homepage https://example.com'], -}) diff --git a/packages/cli/src/commands/repository/cmd-repository-view.mts b/packages/cli/src/commands/repository/cmd-repository-view.mts deleted file mode 100644 index 2ec1db8f4..000000000 --- a/packages/cli/src/commands/repository/cmd-repository-view.mts +++ /dev/null @@ -1,13 +0,0 @@ -import { handleViewRepo } from './handle-view-repo.mts' -import { createRepositoryCommand } from './repository-command-factory.mts' - -export const CMD_NAME = 'view' - -export const cmdRepositoryView = createRepositoryCommand({ - commandName: CMD_NAME, - description: 'View repositories in an organization', - handler: async ({ orgSlug, outputKind, repoName }) => { - await handleViewRepo(orgSlug, String(repoName), outputKind) - }, - helpExamples: ['test-repo', 'test-repo --json'], -}) diff --git a/packages/cli/src/commands/repository/cmd-repository.mts b/packages/cli/src/commands/repository/cmd-repository.mts deleted file mode 100644 index 026cb2281..000000000 --- a/packages/cli/src/commands/repository/cmd-repository.mts +++ /dev/null @@ -1,31 +0,0 @@ -import { cmdRepositoryCreate } from './cmd-repository-create.mts' -import { cmdRepositoryDel } from './cmd-repository-del.mts' -import { cmdRepositoryList } from './cmd-repository-list.mts' -import { cmdRepositoryUpdate } from './cmd-repository-update.mts' -import { cmdRepositoryView } from './cmd-repository-view.mts' -import { meowWithSubcommands } from '../../util/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -const description = 'Manage registered repositories' - -export const cmdRepository: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} repository`, - importMeta, - subcommands: { - create: cmdRepositoryCreate, - view: cmdRepositoryView, - list: cmdRepositoryList, - del: cmdRepositoryDel, - update: cmdRepositoryUpdate, - }, - }, - { description }, - ) - }, -} diff --git a/packages/cli/src/commands/repository/fetch-create-repo.mts b/packages/cli/src/commands/repository/fetch-create-repo.mts deleted file mode 100644 index 20ee2434e..000000000 --- a/packages/cli/src/commands/repository/fetch-create-repo.mts +++ /dev/null @@ -1,58 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchCreateRepoConfig = { - defaultBranch: string - description: string - homepage: string - orgSlug: string - repoName: string - visibility: 'private' | 'public' -} - -type FetchCreateRepoOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchCreateRepo( - config: FetchCreateRepoConfig, - options?: FetchCreateRepoOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'createRepository'>['data']>> { - const { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - } = config - - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchCreateRepoOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'createRepository'>( - sockSdk.createRepository(orgSlug, repoName, { - default_branch: defaultBranch, - description, - homepage, - visibility, - }), - { - commandPath, - description: 'to create a repository', - }, - ) -} diff --git a/packages/cli/src/commands/repository/fetch-delete-repo.mts b/packages/cli/src/commands/repository/fetch-delete-repo.mts deleted file mode 100644 index 063dc0557..000000000 --- a/packages/cli/src/commands/repository/fetch-delete-repo.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchDeleteRepoOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchDeleteRepo( - orgSlug: string, - repoName: string, - options?: FetchDeleteRepoOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'deleteRepository'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchDeleteRepoOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'deleteRepository'>( - sockSdk.deleteRepository(orgSlug, repoName), - { - commandPath, - description: 'to delete a repository', - }, - ) -} diff --git a/packages/cli/src/commands/repository/fetch-list-all-repos.mts b/packages/cli/src/commands/repository/fetch-list-all-repos.mts deleted file mode 100644 index 3cb6450f6..000000000 --- a/packages/cli/src/commands/repository/fetch-list-all-repos.mts +++ /dev/null @@ -1,69 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchListAllReposOptions = { - commandPath?: string | undefined - direction?: string | undefined - sdkOpts?: SetupSdkOptions | undefined - sort?: string | undefined -} - -export async function fetchListAllRepos( - orgSlug: string, - options?: FetchListAllReposOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'listRepositories'>['data']>> { - const { commandPath, direction, sdkOpts, sort } = { - __proto__: null, - ...options, - } as FetchListAllReposOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - const rows: SocketSdkSuccessResult<'listRepositories'>['data']['results'] = [] - let protection = 0 - let nextPage = 0 - while (nextPage >= 0) { - if (++protection > 100) { - return { - ok: false, - message: 'Infinite loop detected', - cause: `Either there are over 100 pages of results or the fetch has run into an infinite loop. Breaking it off now. nextPage=${nextPage}`, - } - } - const orgRepoListCResult = await handleApiCall<'listRepositories'>( - sockSdk.listRepositories(orgSlug, { - ...(sort ? { sort: sort as 'name' | 'created_at' } : {}), - ...(direction ? { direction: direction as 'asc' | 'desc' } : {}), - per_page: 100, // max - page: nextPage, - }), - { - commandPath, - description: 'list of repositories', - }, - ) - if (!orgRepoListCResult.ok) { - return orgRepoListCResult - } - - rows.push(...orgRepoListCResult.data.results) - nextPage = orgRepoListCResult.data.nextPage ?? -1 - } - - return { - ok: true, - data: { - results: rows, - // oxlint-disable-next-line socket/prefer-undefined-over-null -- SDK schema uses `nextPage: string | null` for the GitHub-style pagination sentinel. - nextPage: null, - }, - } -} diff --git a/packages/cli/src/commands/repository/fetch-list-repos.mts b/packages/cli/src/commands/repository/fetch-list-repos.mts deleted file mode 100644 index ded4b920e..000000000 --- a/packages/cli/src/commands/repository/fetch-list-repos.mts +++ /dev/null @@ -1,53 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchListReposConfig = { - direction: string - orgSlug: string - page: number - perPage: number - sort: string -} - -type FetchListReposOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchListRepos( - config: FetchListReposConfig, - options?: FetchListReposOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'listRepositories'>['data']>> { - const { direction, orgSlug, page, perPage, sort } = { - __proto__: null, - ...config, - } as FetchListReposConfig - - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchListReposOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'listRepositories'>( - sockSdk.listRepositories(orgSlug, { - ...(sort ? { sort: sort as 'name' | 'created_at' } : {}), - ...(direction ? { direction: direction as 'asc' | 'desc' } : {}), - per_page: perPage, - page, - }), - { - commandPath, - description: 'list of repositories', - }, - ) -} diff --git a/packages/cli/src/commands/repository/fetch-update-repo.mts b/packages/cli/src/commands/repository/fetch-update-repo.mts deleted file mode 100644 index 199d5252c..000000000 --- a/packages/cli/src/commands/repository/fetch-update-repo.mts +++ /dev/null @@ -1,60 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchUpdateRepoConfig = { - defaultBranch: string - description: string - homepage: string - orgSlug: string - repoName: string - visibility: string -} - -type FetchUpdateRepoOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchUpdateRepo( - config: FetchUpdateRepoConfig, - options?: FetchUpdateRepoOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'updateRepository'>['data']>> { - const { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - } = { __proto__: null, ...config } as FetchUpdateRepoConfig - - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchUpdateRepoOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'updateRepository'>( - sockSdk.updateRepository(orgSlug, repoName, { - default_branch: defaultBranch, - description, - homepage, - name: repoName, - orgSlug, - visibility, - }), - { - commandPath, - description: 'to update a repository', - }, - ) -} diff --git a/packages/cli/src/commands/repository/fetch-view-repo.mts b/packages/cli/src/commands/repository/fetch-view-repo.mts deleted file mode 100644 index b12ca2bc8..000000000 --- a/packages/cli/src/commands/repository/fetch-view-repo.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchViewRepoOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchViewRepo( - orgSlug: string, - repoName: string, - options?: FetchViewRepoOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getRepository'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchViewRepoOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getRepository'>( - sockSdk.getRepository(orgSlug, repoName), - { - commandPath, - description: 'repository data', - }, - ) -} diff --git a/packages/cli/src/commands/repository/handle-create-repo.mts b/packages/cli/src/commands/repository/handle-create-repo.mts deleted file mode 100644 index 49b4ede45..000000000 --- a/packages/cli/src/commands/repository/handle-create-repo.mts +++ /dev/null @@ -1,55 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchCreateRepo } from './fetch-create-repo.mts' -import { outputCreateRepo } from './output-create-repo.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleCreateRepo( - { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - }: { - orgSlug: string - repoName: string - description: string - homepage: string - defaultBranch: string - visibility: 'private' | 'public' - }, - outputKind: OutputKind, -): Promise<void> { - debug(`Creating repository ${orgSlug}/${repoName}`) - debugDir({ - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - outputKind, - }) - - const data = await fetchCreateRepo( - { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - }, - { - commandPath: 'socket repository create', - }, - ) - - debug(`Repository creation ${data.ok ? 'succeeded' : 'failed'}`) - debugDir({ data }) - - outputCreateRepo(data, repoName, outputKind) -} diff --git a/packages/cli/src/commands/repository/handle-list-repos.mts b/packages/cli/src/commands/repository/handle-list-repos.mts deleted file mode 100644 index 81768fd21..000000000 --- a/packages/cli/src/commands/repository/handle-list-repos.mts +++ /dev/null @@ -1,70 +0,0 @@ -import { fetchListAllRepos } from './fetch-list-all-repos.mts' -import { fetchListRepos } from './fetch-list-repos.mts' -import { outputListRepos } from './output-list-repos.mts' - -import type { Direction } from './types.mts' -import type { OutputKind } from '../../types.mts' - -export async function handleListRepos({ - all, - direction, - orgSlug, - outputKind, - page, - perPage, - sort, -}: { - all: boolean - direction: Direction - orgSlug: string - outputKind: OutputKind - page: number - perPage: number - sort: string -}): Promise<void> { - if (all) { - const data = await fetchListAllRepos(orgSlug, { - commandPath: 'socket repository list', - direction, - sort, - }) - - await outputListRepos( - data, - outputKind, - 0, - 0, - sort, - Number.POSITIVE_INFINITY, - direction, - ) - } else { - const data = await fetchListRepos( - { - direction, - orgSlug, - page, - perPage, - sort, - }, - { - commandPath: 'socket repository list', - }, - ) - - if (!data.ok) { - await outputListRepos(data, outputKind, 0, 0, '', 0, direction) - } else { - // Note: nextPage defaults to 0, is null when there's no next page - await outputListRepos( - data, - outputKind, - page, - data.data.nextPage, - sort, - perPage, - direction, - ) - } - } -} diff --git a/packages/cli/src/commands/repository/handle-update-repo.mts b/packages/cli/src/commands/repository/handle-update-repo.mts deleted file mode 100644 index 1fca72fb5..000000000 --- a/packages/cli/src/commands/repository/handle-update-repo.mts +++ /dev/null @@ -1,39 +0,0 @@ -import { fetchUpdateRepo } from './fetch-update-repo.mts' -import { outputUpdateRepo } from './output-update-repo.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleUpdateRepo( - { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - }: { - orgSlug: string - repoName: string - description: string - homepage: string - defaultBranch: string - visibility: string - }, - outputKind: OutputKind, -): Promise<void> { - const data = await fetchUpdateRepo( - { - defaultBranch, - description, - homepage, - orgSlug, - repoName, - visibility, - }, - { - commandPath: 'socket repository update', - }, - ) - - await outputUpdateRepo(data, repoName, outputKind) -} diff --git a/packages/cli/src/commands/repository/output-create-repo.mts b/packages/cli/src/commands/repository/output-create-repo.mts deleted file mode 100644 index 3129421ab..000000000 --- a/packages/cli/src/commands/repository/output-create-repo.mts +++ /dev/null @@ -1,30 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export function outputCreateRepo( - result: CResult<SocketSdkSuccessResult<'createRepository'>['data']>, - requestedName: string, - outputKind: OutputKind, -): void { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - const { slug } = result.data - logger.success( - `OK. Repository created successfully, slug: \`${slug}\`${slug !== requestedName ? ' (Warning: slug is not the same as name that was requested!)' : ''}`, - ) -} diff --git a/packages/cli/src/commands/repository/output-delete-repo.mts b/packages/cli/src/commands/repository/output-delete-repo.mts deleted file mode 100644 index 4685d0385..000000000 --- a/packages/cli/src/commands/repository/output-delete-repo.mts +++ /dev/null @@ -1,29 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputDeleteRepo( - result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']>, - repoName: string, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.success(`OK. Repository \`${repoName}\` deleted successfully`) -} diff --git a/packages/cli/src/commands/repository/output-list-repos.mts b/packages/cli/src/commands/repository/output-list-repos.mts deleted file mode 100644 index cb49af74f..000000000 --- a/packages/cli/src/commands/repository/output-list-repos.mts +++ /dev/null @@ -1,81 +0,0 @@ -import chalkTable from 'chalk-table' -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mts' - -import type { Direction } from './types.mts' -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputListRepos( - result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']>, - outputKind: OutputKind, - page: number, - nextPage: number | null, - sort: string, - perPage: number, - direction: Direction, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - if (result.ok) { - logger.log( - serializeResultJson({ - ok: true, - data: { - data: result.data, - direction, - nextPage: nextPage ?? 0, - page, - perPage, - sort, - }, - }), - ) - } else { - logger.log(serializeResultJson(result)) - } - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.log( - `Result page: ${page}, results per page: ${perPage === Number.POSITIVE_INFINITY ? 'all' : perPage}, sorted by: ${sort}, direction: ${direction}`, - ) - - const options = { - columns: [ - { field: 'id', name: colors.magenta('ID') }, - { field: 'name', name: colors.magenta('Name') }, - { field: 'visibility', name: colors.magenta('Visibility') }, - { field: 'default_branch', name: colors.magenta('Default branch') }, - { field: 'archived', name: colors.magenta('Archived') }, - ], - } - - logger.log(chalkTable(options, result.data.results)) - if (nextPage) { - logger.info( - `This is page ${page}. Server indicated there are more results available on page ${nextPage}...`, - ) - logger.info( - `(Hint: you can use \`socket repository list --page ${nextPage}\`)`, - ) - } else if (perPage === Number.POSITIVE_INFINITY) { - logger.info('This should be the entire list available on the server.') - } else { - logger.info( - `This is page ${page}. Server indicated this is the last page with results.`, - ) - } -} diff --git a/packages/cli/src/commands/repository/output-update-repo.mts b/packages/cli/src/commands/repository/output-update-repo.mts deleted file mode 100644 index c6c0e08c6..000000000 --- a/packages/cli/src/commands/repository/output-update-repo.mts +++ /dev/null @@ -1,29 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputUpdateRepo( - result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']>, - repoName: string, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.success(`Repository \`${repoName}\` updated successfully`) -} diff --git a/packages/cli/src/commands/repository/output-view-repo.mts b/packages/cli/src/commands/repository/output-view-repo.mts deleted file mode 100644 index 383c3625b..000000000 --- a/packages/cli/src/commands/repository/output-view-repo.mts +++ /dev/null @@ -1,43 +0,0 @@ -import chalkTable from 'chalk-table' -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputViewRepo( - result: CResult<SocketSdkSuccessResult<'createRepository'>['data']>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const options = { - columns: [ - { field: 'id', name: colors.magenta('ID') }, - { field: 'name', name: colors.magenta('Name') }, - { field: 'visibility', name: colors.magenta('Visibility') }, - { field: 'default_branch', name: colors.magenta('Default branch') }, - { field: 'homepage', name: colors.magenta('Homepage') }, - { field: 'archived', name: colors.magenta('Archived') }, - { field: 'created_at', name: colors.magenta('Created at') }, - ], - } - - logger.log(chalkTable(options, [result.data])) -} diff --git a/packages/cli/src/commands/repository/repository-command-factory.mts b/packages/cli/src/commands/repository/repository-command-factory.mts deleted file mode 100644 index d47a0395e..000000000 --- a/packages/cli/src/commands/repository/repository-command-factory.mts +++ /dev/null @@ -1,234 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { V1_MIGRATION_GUIDE_URL } from '../../constants/socket.mts' -import { - outputDryRunDelete, - outputDryRunFetch, - outputDryRunUpload, -} from '../../util/dry-run/output.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { webLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { MeowFlags } from '../../flags.mts' -import type { OutputKind } from '../../types.mjs' -import type { - CliCommandConfig, - CliCommandContext, -} from '../../util/cli/with-subcommands.mjs' - -const logger = getDefaultLogger() - -type RepositoryCommandSpec = { - commandName: string - description: string - extraFlags?: MeowFlags | undefined - handler: (params: { - orgSlug: string - repoName: string - outputKind: OutputKind - flags: Record<string, unknown> - }) => Promise<void> - helpDescription?: string | undefined - helpExamples: string[] - hidden?: boolean | undefined - needsRepoName?: boolean | undefined -} - -export function createRepositoryCommand(spec: RepositoryCommandSpec) { - return { - description: spec.description, - hidden: spec.hidden ?? false, - async run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, - ): Promise<void> { - // Only guard the commands that actually accept `--default-branch` - // as a string (create / update). The list/view/delete commands - // don't, so the check is a no-op for them. - if ( - (spec.commandName === 'create' || spec.commandName === 'update') && - spec.extraFlags?.['defaultBranch'] - ) { - const emptyShape = findEmptyDefaultBranch(argv) - if (emptyShape) { - logger.fail( - emptyShape === 'empty-value' - ? '--default-branch requires a value (e.g. --default-branch=main). Leaving it empty would persist a blank default-branch name on the repo record.' - : '--default-branch requires a value (e.g. --default-branch=main). Bare --default-branch with no value would persist a blank default-branch name on the repo record.', - ) - process.exitCode = 2 - return - } - } - const config: CliCommandConfig = { - commandName: spec.commandName, - description: spec.description, - flags: { - ...commonFlags, - ...outputFlags, - interactive: { - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - type: 'boolean', - }, - org: { - description: - 'Force override the organization slug, overrides the default org from config', - type: 'string', - }, - ...(spec.extraFlags || {}), - }, - help: (command, config) => ` - Usage - $ ${command} [options]${spec.needsRepoName !== false ? ' <REPO>' : ''} - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${spec.commandName}`)} -${spec.helpDescription ? `\n ${spec.helpDescription}\n` : ''} - Options - ${getFlagListOutput(config.flags)} - - Examples -${spec.helpExamples.map(ex => ` $ ${command} ${ex}`).join('\n')} - `, - hidden: spec.hidden ?? false, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const noLegacy = !cli.flags['repoName'] - - const [repoName = ''] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const validations = [ - { - fail: 'received legacy flags', - message: `Legacy flags are no longer supported. See the ${webLink(V1_MIGRATION_GUIDE_URL, 'v1 migration guide')}.`, - nook: true, - test: noLegacy, - }, - { - fail: 'missing', - message: 'Org name by default setting, --org, or auto-discovered', - nook: true, - test: !!orgSlug, - }, - ] - - if (spec.needsRepoName !== false) { - validations.push({ - fail: 'missing', - message: 'Repository name as first argument', - nook: false, - test: !!repoName, - }) - } - - validations.push( - { - fail: 'bad', - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - nook: true, - test: !json || !markdown, - }, - { - fail: 'try `socket login`', - message: 'This command requires a Socket API token for access', - nook: true, - test: hasApiToken, - }, - ) - - const wasValidInput = checkCommandInput(outputKind, ...validations) - if (!wasValidInput) { - return - } - - if (dryRun) { - const identifier = repoName ? `${orgSlug}/${repoName}` : orgSlug - if (spec.commandName === 'create') { - outputDryRunUpload('repository', { - organization: orgSlug, - repository: repoName, - }) - } else if (spec.commandName === 'update') { - outputDryRunUpload('repository (update)', { - organization: orgSlug, - repository: repoName, - }) - } else if (spec.commandName === 'del') { - outputDryRunDelete('repository', identifier) - } else { - outputDryRunFetch(`repository ${identifier}`, { - organization: orgSlug, - repository: repoName || undefined, - }) - } - return - } - - await spec.handler({ - flags: cli.flags, - orgSlug, - outputKind, - repoName, - }) - }, - } -} - -// If the user wrote `--default-branch` (bare, no value) or -// `--default-branch=`, meow would coerce it to an empty string and -// silently persist a blank default-branch name on the repo record. -// Detect that before meow parses so we can stop with an actionable -// error instead of saving junk data. -export function findEmptyDefaultBranch( - argv: readonly string[], -): 'bare' | 'empty-value' | undefined { - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]! - if (arg === '--default-branch=' || arg === '--defaultBranch=') { - return 'empty-value' - } - if (arg === '--default-branch' || arg === '--defaultBranch') { - const next = argv[i + 1] - if (next === undefined || next.startsWith('-')) { - return 'bare' - } - } - } - return undefined -} diff --git a/packages/cli/src/commands/repository/types.mts b/packages/cli/src/commands/repository/types.mts deleted file mode 100644 index 8a0acbdc2..000000000 --- a/packages/cli/src/commands/repository/types.mts +++ /dev/null @@ -1 +0,0 @@ -export type Direction = 'asc' | 'desc' diff --git a/packages/cli/src/commands/scan/cmd-scan-create-flags.mts b/packages/cli/src/commands/scan/cmd-scan-create-flags.mts deleted file mode 100644 index b56867f07..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-create-flags.mts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Flag schema for `socket scan create`. - * - * Extracted from cmd-scan-create.mts to keep that file under the 1000-line - * File-size hard cap. Defining the (large) flag set here lets the main command - * file focus on the run() orchestration logic. - */ - -import { constants } from '../../constants.mts' -import { commonFlags, outputFlags } from '../../flags.mts' - -import type { MeowFlags } from '../../flags.mts' - -export const generalFlags: MeowFlags = { - ...commonFlags, - ...outputFlags, - autoManifest: { - type: 'boolean', - description: - 'Run `socket manifest auto` before collecting manifest files. This is necessary for languages like Scala, Gradle, and Kotlin, See `socket manifest auto --help`.', - }, - basics: { - type: 'boolean', - default: false, - description: - 'Run comprehensive security scanning (SAST, secrets, containers) via socket-basics. Requires Python, Trivy, TruffleHog, and OpenGrep to be available.', - }, - branch: { - type: 'string', - default: '', - description: 'Branch name', - shortFlag: 'b', - }, - commitHash: { - type: 'string', - default: '', - description: 'Commit hash', - shortFlag: 'ch', - }, - commitMessage: { - type: 'string', - default: '', - description: 'Commit message', - shortFlag: 'm', - }, - committers: { - type: 'string', - default: '', - description: 'Committers', - shortFlag: 'c', - }, - cwd: { - type: 'string', - default: '', - description: 'working directory, defaults to process.cwd()', - }, - makeDefaultBranch: { - type: 'boolean', - default: false, - description: - "Reassign the repo's default-branch pointer at Socket to the branch of this scan. The previous default-branch designation is replaced. Mirrors the `make_default_branch` API field.", - }, - // Deprecated alias for `--make-default-branch`. Declared as its own - // boolean flag (rather than via meow `aliases`) because meow's alias - // forwarding doesn't reliably propagate values in this command's - // large flag set. We merge it onto `makeDefaultBranch` after parsing. - defaultBranch: { - type: 'boolean', - default: false, - description: - 'Deprecated alias for --make-default-branch. Kept working for back-compat; emits a deprecation warning on use.', - hidden: true, - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - pullRequest: { - type: 'number', - default: 0, - description: 'Pull request number', - shortFlag: 'pr', - }, - org: { - type: 'string', - default: '', - description: - 'Force override the organization slug, overrides the default org from config', - }, - reach: { - type: 'boolean', - default: false, - description: 'Run tier 1 full application reachability analysis', - }, - readOnly: { - type: 'boolean', - default: false, - description: - 'Similar to --dry-run except it can read from remote, stops before it would create an actual report', - }, - repo: { - type: 'string', - shortFlag: 'r', - description: 'Repository name', - }, - report: { - type: 'boolean', - description: - 'Wait for the scan creation to complete, then basically run `socket scan report` on it', - }, - reportLevel: { - type: 'string', - default: constants.REPORT_LEVEL_ERROR, - description: `Which policy level alerts should be reported (default '${constants.REPORT_LEVEL_ERROR}')`, - }, - setAsAlertsPage: { - type: 'boolean', - default: true, - description: - 'When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true.', - aliases: ['pendingHead'], - }, - tmp: { - type: 'boolean', - default: false, - description: - 'Set the visibility (true/false) of the scan in your dashboard.', - shortFlag: 't', - }, - workspace: { - type: 'string', - default: '', - description: - 'The workspace in the Socket Organization that the repository is in to associate with the full scan.', - }, -} diff --git a/packages/cli/src/commands/scan/cmd-scan-create-validation.mts b/packages/cli/src/commands/scan/cmd-scan-create-validation.mts deleted file mode 100644 index 6c9d856e4..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-create-validation.mts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Validation helpers for `socket scan create`. - * - * Extracted from cmd-scan-create.mts to keep that file under the 1000-line - * File-size hard cap. These helpers detect common mistakes around the - * `--default-branch` / `--make-default-branch` flag pair, where the legacy flag - * was a _boolean_ but users often tried to pass a value (e.g. - * `--default-branch=main` or `--default-branch main`). The handler emits a - * friendly error pointing at the right flag (`--branch <name>`) when one of - * these misuses is detected. - */ - -const LEGACY_DEFAULT_BRANCH_FLAGS = ['--default-branch', '--defaultBranch'] -const LEGACY_DEFAULT_BRANCH_PREFIXES = LEGACY_DEFAULT_BRANCH_FLAGS.map( - f => `${f}=`, -) -const DEFAULT_BRANCH_FLAGS = [ - '--make-default-branch', - '--makeDefaultBranch', - ...LEGACY_DEFAULT_BRANCH_FLAGS, -] -const DEFAULT_BRANCH_PREFIXES = DEFAULT_BRANCH_FLAGS.map(f => `${f}=`) - -export function findDefaultBranchValueMisuse( - argv: readonly string[], -): { form: string; value: string } | undefined { - // `--default-branch=main` — unambiguous: the `=` form attaches a - // value to what meow treats as a boolean flag, so the value is - // silently dropped. - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i]! - const prefix = DEFAULT_BRANCH_PREFIXES.find(p => arg.startsWith(p)) - if (!prefix) { - continue - } - const value = arg.slice(prefix.length) - const normalized = value.toLowerCase() - if (normalized === 'true' || normalized === 'false' || value === '') { - continue - } - return { form: `${prefix}${value}`, value } - } - // `--default-branch main` — ambiguous in general (the next token - // could be a positional target path), but if the next token is a - // bare identifier (no `/`, `.`, `:`) AND the user didn't also pass - // `--branch` / `-b`, it's almost certainly a mis-typed branch name. - const hasBranchFlag = argv.some( - arg => - arg === '--branch' || - arg === '-b' || - arg.startsWith('--branch=') || - arg.startsWith('-b='), - ) - if (hasBranchFlag) { - return undefined - } - for (let i = 0; i < argv.length - 1; i += 1) { - const arg = argv[i]! - if (!DEFAULT_BRANCH_FLAGS.includes(arg)) { - continue - } - const next = argv[i + 1]! - if (next.startsWith('-') || !isBareIdentifier(next)) { - continue - } - return { form: `${arg} ${next}`, value: next } - } - return undefined -} - -export function hasLegacyDefaultBranchFlag(argv: readonly string[]): boolean { - return argv.some( - arg => - LEGACY_DEFAULT_BRANCH_FLAGS.includes(arg) || - LEGACY_DEFAULT_BRANCH_PREFIXES.some(p => arg.startsWith(p)), - ) -} - -export function isBareIdentifier(token: string): boolean { - // Accept only tokens that look like a plain branch name. Anything - // with a path separator, dot, or colon is almost certainly a target - // path, URL, or something else the user meant as a positional arg. - return /^[A-Za-z0-9_-]+$/.test(token) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-create.mts b/packages/cli/src/commands/scan/cmd-scan-create.mts deleted file mode 100644 index 969b2da26..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-create.mts +++ /dev/null @@ -1,641 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import path from 'node:path' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -import { assertNoNegationPatterns } from './exclude-paths.mts' -import { handleCreateNewScan } from './handle-create-new-scan.mts' -import { outputCreateNewScan } from './output-create-new-scan.mts' -import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' -import { suggestOrgSlug } from './suggest-org-slug.mts' -import { suggestTarget } from './suggest_target.mts' -import { validateReachabilityTarget } from './validate-reachability-target.mts' -import { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' -import { outputDryRunUpload } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { defineFlags } from '../../meow.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mts' -import { getEcosystemChoicesForMeow } from '../../util/ecosystem/types.mts' -import { - detectDefaultBranch, - getRepoName, - gitBranch, -} from '../../util/git/operations.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mts' -import { cmdFlagValueToArray } from '../../util/process/cmd.mts' -import { readOrDefaultSocketJsonUp } from '../../util/socket/json.mts' -import { determineOrgSlug } from '../../util/socket/org-slug.mts' -import { hasDefaultApiToken } from '../../util/socket/sdk.mts' -import { socketDashboardLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' -import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' - -import type { REPORT_LEVEL } from './types.mts' -import type { CliCommandContext } from '../../util/cli/with-subcommands.mts' -import type { PURL_Type } from '../../util/ecosystem/types.mts' - -// Flags interface for type safety. -interface ScanCreateFlags { - autoManifest?: boolean | undefined - basics?: boolean | undefined - branch: string - commitHash: string - commitMessage: string - committers: string - cwd: string - defaultBranch: boolean - makeDefaultBranch: boolean - interactive: boolean - json: boolean - markdown: boolean - org: string - pullRequest: number - reach: boolean - reachAnalysisMemoryLimit: number - reachAnalysisTimeout: number - reachConcurrency: number - reachDebug: boolean - reachDetailedAnalysisLogFile: boolean - reachDisableAnalytics: boolean - reachDisableExternalToolChecks: boolean - reachEnableAnalysisSplitting: boolean - reachLazyMode: boolean - reachMinSeverity: string - reachSkipCache: boolean - reachUseOnlyPregeneratedSboms: boolean - reachUseUnreachableFromPrecomputation: boolean - reachVersion: string - readOnly: boolean - repo: string - report?: boolean | undefined - reportLevel: REPORT_LEVEL - setAsAlertsPage: boolean - tmp: boolean - workspace: string -} - -export const CMD_NAME = 'create' - -const description = 'Create a new Socket scan and report' - -const hidden = false - -// Flag schema extracted to keep this file under the 1000-line File-size cap. -import { generalFlags } from './cmd-scan-create-flags.mts' - -// Legacy flag names kept working via meow aliases on `makeDefaultBranch`. -// Detected here so we can warn on use and keep the misuse heuristic -// working against both the primary and legacy names. -// --default-branch / --make-default-branch validation helpers extracted -// to keep this file under the 1000-line File-size cap. -import { - findDefaultBranchValueMisuse, - hasLegacyDefaultBranchFlag, - isBareIdentifier, -} from './cmd-scan-create-validation.mts' - -export { - findDefaultBranchValueMisuse, - hasLegacyDefaultBranchFlag, - isBareIdentifier, -} - -export const cmdScanCreate = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...generalFlags, - ...excludePathsFlag, - ...reachabilityFlags, - }), - help: (command: string) => ` - Usage - $ ${command} [options] [TARGET...] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })} - - Reachability Options (when --reach is used) - ${getFlagListOutput(reachabilityFlags)} - - Uploads the specified dependency manifest files for Go, Gradle, JavaScript, - Kotlin, Python, and Scala. Files like "package.json" and "${REQUIREMENTS_TXT}". - If any folder is specified, the ones found in there recursively are uploaded. - - Details on TARGET: - - - Defaults to the current dir (cwd) if none given - - Multiple targets can be specified - - If a target is a file, only that file is checked - - If it is a dir, the dir is scanned for any supported manifest files - - Dirs MUST be within the current dir (cwd), you can use --cwd to change it - - Supports globbing such as "**/package.json", "**/${REQUIREMENTS_TXT}", etc. - - Ignores files specified in your project's ".gitignore" - - Ignores files specified in your "socket.yml" file's "projectIgnorePaths" - - Also a sensible set of default ignores from the "ignore-by-default" module - - The --repo and --branch flags tell Socket to associate this Scan with that - repo/branch. The names will show up on your dashboard on the Socket website. - - Note: on a first scan you probably want to pass --make-default-branch so - Socket records this branch ("main", "master", etc.) as your repo's - default branch. Subsequent scans don't need the flag unless you're - reassigning the default-branch pointer to a different branch. - - The ${socketDashboardLink('/org/YOURORG/alerts', '"alerts page"')} will show - the results from the last scan designated as the "pending head" on the branch - configured on Socket to be the "default branch". When creating a scan the - --set-as-alerts-page flag will default to true to update this. You can prevent - this by using --no-set-as-alerts-page. This flag is ignored for any branch that - is not designated as the "default branch". It is disabled when using --tmp. - - You can use \`socket scan setup\` to configure certain repo flag defaults. - - Examples - $ ${command} - $ ${command} ./proj --json - $ ${command} --repo=test-repo --branch=main ./package.json - `, - } - - // `--make-default-branch` (and its deprecated alias `--default-branch`) - // is a boolean flag, so meow/yargs-parser silently drops any value - // attached to it — the resulting scan is untagged and invisible in the - // Main/PR dashboard tabs. Catch that shape before meow parses so the - // user sees an actionable error instead of a mysteriously-mislabelled - // scan hours later. - const defaultBranchMisuse = findDefaultBranchValueMisuse(argv) - if (defaultBranchMisuse) { - const { form, value } = defaultBranchMisuse - logger.fail( - `"${form}" looks like you meant to name the branch "${value}", but --make-default-branch is a boolean flag (no value).\n\n` + - `To scan "${value}" as the default branch, use --branch for the name and --make-default-branch as a flag:\n` + - ` socket scan create --branch ${value} --make-default-branch\n\n` + - `To scan a non-default branch, drop --make-default-branch:\n` + - ` socket scan create --branch ${value}`, - ) - process.exitCode = 2 - return - } - - // `--default-branch` / `--defaultBranch` is kept working via meow's - // aliases, but nudge callers to migrate so we can eventually retire - // the legacy name. - if (hasLegacyDefaultBranchFlag(argv)) { - logger.warn( - '--default-branch is deprecated on `socket scan create`; use --make-default-branch instead. The old flag still works for now.', - ) - } - - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const { - commitHash, - commitMessage, - committers, - cwd: cwdOverride, - defaultBranch: legacyDefaultBranch, - interactive = true, - makeDefaultBranch: makeDefaultBranchFlag, - json, - markdown, - org: orgFlag, - pullRequest, - reach, - reachAnalysisMemoryLimit, - reachAnalysisTimeout, - reachConcurrency, - reachDebug, - reachDetailedAnalysisLogFile, - reachDisableAnalytics, - reachDisableExternalToolChecks, - reachEnableAnalysisSplitting, - reachLazyMode, - reachMinSeverity, - reachSkipCache, - reachUseOnlyPregeneratedSboms, - reachUseUnreachableFromPrecomputation, - reachVersion, - readOnly, - reportLevel, - setAsAlertsPage: pendingHeadFlag, - tmp, - } = cli.flags as unknown as ScanCreateFlags - - // Merge the legacy --default-branch flag into the primary. Both are - // declared as separate boolean flags in the config (see the comment - // on the `defaultBranch` flag definition above). - const makeDefaultBranch = makeDefaultBranchFlag || legacyDefaultBranch - - // Validate ecosystem values. - const reachEcosystems: PURL_Type[] = [] - const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) - const validEcosystems = getEcosystemChoicesForMeow() - for (let i = 0, { length } = reachEcosystemsRaw; i < length; i += 1) { - const ecosystem = reachEcosystemsRaw[i]! - if (!validEcosystems.includes(ecosystem)) { - throw new InputError( - `--reach-ecosystems must be one of: ${joinAnd(validEcosystems)} (saw: "${ecosystem}"); pass a supported ecosystem like --reach-ecosystems=${validEcosystems[0]}`, - ) - } - reachEcosystems.push(ecosystem as PURL_Type) - } - - const dryRun = !!cli.flags['dryRun'] - - const { basics } = cli.flags as unknown as ScanCreateFlags - - let { - autoManifest, - branch: branchName, - repo: repoName, - report, - workspace, - } = cli.flags as unknown as ScanCreateFlags - - let { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const processCwd = process.cwd() - const cwd = - cwdOverride && cwdOverride !== '.' && cwdOverride !== processCwd - ? path.resolve(processCwd, cwdOverride) - : processCwd - - const sockJson = await readOrDefaultSocketJsonUp(cwd) - - // Note: This needs meow booleanDefault=undefined. - if (typeof autoManifest !== 'boolean') { - if (sockJson.defaults?.scan?.create?.autoManifest !== undefined) { - autoManifest = sockJson.defaults.scan.create.autoManifest - logger.info( - `Using default --auto-manifest from ${SOCKET_JSON}:`, - autoManifest, - ) - } else { - autoManifest = false - } - } - if (!branchName) { - if (sockJson.defaults?.scan?.create?.branch) { - branchName = sockJson.defaults.scan.create.branch - logger.info(`Using default --branch from ${SOCKET_JSON}:`, branchName) - } else { - branchName = (await gitBranch(cwd)) || (await detectDefaultBranch(cwd)) - } - } - if (!repoName) { - if (sockJson.defaults?.scan?.create?.repo) { - repoName = sockJson.defaults.scan.create.repo - logger.info(`Using default --repo from ${SOCKET_JSON}:`, repoName) - } else { - repoName = await getRepoName(cwd) - } - } - if (!workspace && sockJson.defaults?.scan?.create?.workspace) { - workspace = sockJson.defaults.scan.create.workspace - logger.info(`Using default --workspace from ${SOCKET_JSON}:`, workspace) - } - if (typeof report !== 'boolean') { - if (sockJson.defaults?.scan?.create?.report !== undefined) { - report = sockJson.defaults.scan.create.report - logger.info(`Using default --report from ${SOCKET_JSON}:`, report) - } else { - report = false - } - } - - // If we updated any inputs then we should print the command line to repeat - // the command without requiring user input, as a suggestion. - let updatedInput = false - - // Accept zero or more paths. Default to cwd() if none given. - let targets = cli.input.length ? [...cli.input] : [cwd] - - /* c8 ignore start - defensive: targets always has at least [cwd] from the line above */ - if (!targets.length && !dryRun && interactive) { - targets = await suggestTarget() - updatedInput = true - } - /* c8 ignore stop */ - - // We're going to need an api token to suggest data because those suggestions - // must come from data we already know. Don't error on missing api token yet. - // If the api-token is not set, ignore it for the sake of suggestions. - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - const pendingHead = tmp ? false : pendingHeadFlag - - // If the current cwd is unknown and is used as a repo slug anyways, we will - // first need to register the slug before we can use it. - // Only do suggestions with an apiToken and when not in dryRun mode - if (hasApiToken && !dryRun && interactive) { - if (!orgSlug) { - const suggestion = await suggestOrgSlug() - if (suggestion === undefined) { - await outputCreateNewScan( - { - ok: false, - message: 'Canceled by user', - cause: 'Org selector was canceled by user', - }, - { - interactive: false, - outputKind, - }, - ) - return - } - if (suggestion) { - orgSlug = suggestion - } - updatedInput = true - } - } - - const detected = await detectManifestActions(sockJson, cwd) - if (detected.count > 0 && !autoManifest) { - logger.info( - `Detected ${detected.count} manifest targets we could try to generate. Please set the --auto-manifest flag if you want to include languages covered by \`socket manifest auto\` in the Scan.`, - ) - } - - if (updatedInput && orgSlug && targets.length) { - logger.info( - 'Note: You can invoke this command next time to skip the interactive questions:', - ) - logger.error('```') - logger.error( - ` socket scan create [other flags...] ${orgSlug} ${targets.join(' ')}`, - ) - logger.error('```') - logger.error('') - logger.info( - `You can also run \`socket scan setup\` to persist these flag defaults to a ${SOCKET_JSON} file.`, - ) - logger.error('') - } - - const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) - assertNoNegationPatterns(excludePaths) - - const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) - - // Validation helpers for better readability. - const hasReachEcosystems = reachEcosystems.length > 0 - - const hasReachExcludePaths = reachExcludePaths.length > 0 - - const isUsingNonDefaultMemoryLimit = - reachAnalysisMemoryLimit !== - reachabilityFlags['reachAnalysisMemoryLimit']?.default - - const isUsingNonDefaultTimeout = - reachAnalysisTimeout !== reachabilityFlags['reachAnalysisTimeout']?.default - - const isUsingNonDefaultConcurrency = - reachConcurrency !== reachabilityFlags['reachConcurrency']?.default - - const isUsingNonDefaultAnalytics = - reachDisableAnalytics !== - reachabilityFlags['reachDisableAnalytics']?.default - - const isUsingAnyReachabilityFlags = - hasReachEcosystems || - hasReachExcludePaths || - isUsingNonDefaultAnalytics || - isUsingNonDefaultConcurrency || - isUsingNonDefaultMemoryLimit || - isUsingNonDefaultTimeout || - reachEnableAnalysisSplitting || - reachLazyMode || - reachSkipCache - - // Validate target constraints when --reach is enabled. - const reachTargetValidation = reach - ? await validateReachabilityTarget(targets, cwd) - : { - isDirectory: false, - isInsideCwd: false, - isValid: true, - targetExists: false, - } - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - test: !!targets.length, - message: 'At least one TARGET (e.g. `.` or `./package.json`)', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: !makeDefaultBranch || !!branchName, - message: 'When --make-default-branch is set, --branch is mandatory', - fail: 'missing branch name', - }, - { - nook: true, - test: !pendingHead || !!branchName, - message: 'When --pending-head is set, --branch is mandatory', - fail: 'missing branch name', - }, - { - nook: true, - test: reach || !isUsingAnyReachabilityFlags, - message: 'Reachability analysis flags require --reach to be enabled', - fail: 'add --reach flag to use --reach-* options', - }, - { - nook: true, - test: !reach || reachTargetValidation.isValid, - message: - 'Reachability analysis requires exactly one target directory when --reach is enabled', - fail: 'provide exactly one directory path', - }, - { - nook: true, - test: !reach || reachTargetValidation.isDirectory, - message: - 'Reachability analysis target must be a directory when --reach is enabled', - fail: 'provide a directory path, not a file', - }, - { - nook: true, - test: !reach || reachTargetValidation.targetExists, - message: 'Target directory must exist when --reach is enabled', - fail: 'provide an existing directory path', - }, - { - nook: true, - test: !reach || reachTargetValidation.isInsideCwd, - message: - 'Target directory must be inside the current working directory when --reach is enabled', - fail: 'provide a path inside the working directory', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - const details: Record<string, unknown> = { - organization: orgSlug, - targets: targets.join(', '), - } - if (repoName) { - details['repository'] = repoName - } - if (branchName) { - details['branch'] = branchName - } - if (reach) { - details['reachabilityAnalysis'] = 'enabled' - if (reachEcosystems.length > 0) { - details['ecosystems'] = reachEcosystems.join(', ') - } - } - outputDryRunUpload('scan', details) - return - } - - // Validate numeric flag conversions. - const validatedPullRequest = Number(pullRequest) - if ( - pullRequest !== undefined && - (Number.isNaN(validatedPullRequest) || - !Number.isInteger(validatedPullRequest) || - validatedPullRequest < 0) - ) { - throw new InputError( - `--pull-request must be a non-negative integer (saw: "${pullRequest}"); pass a number like --pull-request=42`, - ) - } - - const validatedReachAnalysisMemoryLimit = Number(reachAnalysisMemoryLimit) - if ( - reachAnalysisMemoryLimit !== undefined && - Number.isNaN(validatedReachAnalysisMemoryLimit) - ) { - throw new InputError( - `--reach-analysis-memory-limit must be a number of megabytes (saw: "${reachAnalysisMemoryLimit}"); pass an integer like --reach-analysis-memory-limit=4096`, - ) - } - - const validatedReachAnalysisTimeout = Number(reachAnalysisTimeout) - if ( - reachAnalysisTimeout !== undefined && - Number.isNaN(validatedReachAnalysisTimeout) - ) { - throw new InputError( - `--reach-analysis-timeout must be a number of seconds (saw: "${reachAnalysisTimeout}"); pass an integer like --reach-analysis-timeout=300`, - ) - } - - const validatedReachConcurrency = Number(reachConcurrency) - if ( - reachConcurrency !== undefined && - (Number.isNaN(validatedReachConcurrency) || - !Number.isInteger(validatedReachConcurrency) || - validatedReachConcurrency <= 0) - ) { - throw new InputError( - `--reach-concurrency must be a positive integer (saw: "${reachConcurrency}"); pass a number like --reach-concurrency=4`, - ) - } - - await handleCreateNewScan({ - autoManifest: Boolean(autoManifest), - basics: Boolean(basics), - branchName: branchName as string, - commitHash: (commitHash && String(commitHash)) || '', - commitMessage: (commitMessage && String(commitMessage)) || '', - committers: (committers && String(committers)) || '', - cwd, - defaultBranch: Boolean(makeDefaultBranch), - interactive: Boolean(interactive), - orgSlug, - outputKind, - pendingHead: Boolean(pendingHead), - pullRequest: validatedPullRequest, - reach: { - excludePaths, - runReachabilityAnalysis: Boolean(reach), - reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit, - reachAnalysisTimeout: validatedReachAnalysisTimeout, - reachConcurrency: validatedReachConcurrency, - reachDebug: Boolean(reachDebug), - reachDetailedAnalysisLogFile: Boolean(reachDetailedAnalysisLogFile), - reachDisableAnalytics: Boolean(reachDisableAnalytics), - reachDisableExternalToolChecks: Boolean(reachDisableExternalToolChecks), - reachEnableAnalysisSplitting: Boolean(reachEnableAnalysisSplitting), - reachEcosystems, - reachExcludePaths, - reachLazyMode: Boolean(reachLazyMode), - reachMinSeverity: String(reachMinSeverity), - reachSkipCache: Boolean(reachSkipCache), - reachUseOnlyPregeneratedSboms: Boolean(reachUseOnlyPregeneratedSboms), - reachUseUnreachableFromPrecomputation: Boolean( - reachUseUnreachableFromPrecomputation, - ), - reachVersion: reachVersion || undefined, - }, - readOnly: Boolean(readOnly), - repoName, - report, - reportLevel, - targets, - tmp: Boolean(tmp), - workspace: (workspace && String(workspace)) || '', - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-del.mts b/packages/cli/src/commands/scan/cmd-scan-del.mts deleted file mode 100644 index 0ebacdfd8..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-del.mts +++ /dev/null @@ -1,125 +0,0 @@ -import { handleDeleteScan } from './handle-delete-scan.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { outputDryRunDelete } from '../../util/dry-run/output.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'del' - -const description = 'Delete a scan' - -const hidden = false - -export const cmdScanDel = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <SCAN_ID> - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const [scanId = ''] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const [orgSlug, defaultOrgSlug] = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: !!defaultOrgSlug, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - test: !!scanId, - message: 'Scan ID to delete', - fail: 'missing', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunDelete('scan', `${orgSlug}/${scanId}`) - return - } - - await handleDeleteScan(orgSlug, scanId, outputKind) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-diff.mts b/packages/cli/src/commands/scan/cmd-scan-diff.mts deleted file mode 100644 index 421cd5fca..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-diff.mts +++ /dev/null @@ -1,197 +0,0 @@ -import { handleDiffScan } from './handle-diff-scan.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface ScanDiffFlags { - depth: number - dryRun: boolean - file: string - json: boolean - markdown: boolean - org: string -} - -export const CMD_NAME = 'diff' - -const description = 'See what changed between two Scans' - -const hidden = false - -export const cmdScanDiff = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - depth: { - type: 'number', - default: 2, - description: - 'Max depth of JSON to display before truncating, use zero for no limit (without --json/--file)', - }, - file: { - type: 'string', - shortFlag: 'f', - default: '', - description: - 'Path to a local file where the output should be saved. Use `-` to force stdout.', - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <SCAN_ID1> <SCAN_ID2> - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - This command displays the package changes between two scans. The full output - can be pretty large depending on the size of your repo and time range. It is - best stored to disk (with --json) to be further analyzed by other tools. - - Note: While it will work in any order, the first Scan ID is assumed to be the - older ID, even if it is a newer Scan. This is only relevant for the - added/removed list (similar to diffing two files with git). - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 - $ ${command} aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 --json - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const SOCKET_SBOM_URL_PREFIX = `${SOCKET_WEBSITE_URL}/dashboard/org/SocketDev/sbom/` - const SOCKET_SBOM_URL_PREFIX_LENGTH = SOCKET_SBOM_URL_PREFIX.length - - const { - depth, - dryRun, - file, - json, - markdown, - org: orgFlag, - } = cli.flags as unknown as ScanDiffFlags - - const interactive = !!cli.flags['interactive'] - - let [id1 = '', id2 = ''] = cli.input - // Support dropping in full socket urls to an sbom. - if (id1.startsWith(SOCKET_SBOM_URL_PREFIX)) { - id1 = id1.slice(SOCKET_SBOM_URL_PREFIX_LENGTH) - } - if (id2.startsWith(SOCKET_SBOM_URL_PREFIX)) { - id2 = id2.slice(SOCKET_SBOM_URL_PREFIX_LENGTH) - } - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: !!(id1 && id2), - message: - 'Specify two Scan IDs.\nA Scan ID looks like `aaa0aa0a-aaaa-0000-0a0a-0000000a00a0`.', - fail: - !id1 && !id2 - ? 'missing both Scan IDs' - : !id1 - ? 'missing first Scan ID' - : 'missing second Scan ID', - }, - { - test: !!orgSlug, - nook: true, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('scan differences', { - organization: orgSlug, - scanId1: id1, - scanId2: id2, - depth, - }) - return - } - - await handleDiffScan({ - id1, - id2, - depth, - orgSlug, - outputKind, - file, - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-github.mts b/packages/cli/src/commands/scan/cmd-scan-github.mts deleted file mode 100644 index 7ce018944..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-github.mts +++ /dev/null @@ -1,280 +0,0 @@ -import path from 'node:path' - -import { getSocketCliGithubToken } from '@socketsecurity/lib-stable/env/socket-cli' - -import { handleCreateGithubScan } from './handle-create-github-scan.mts' -import { outputScanGithub } from './output-scan-github.mts' -import { suggestOrgSlug } from './suggest-org-slug.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { outputDryRunUpload } from '../../util/dry-run/output.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface ScanGithubFlags { - all: boolean | undefined - githubApiUrl: string - githubToken: string - interactive: boolean - json: boolean - markdown: boolean - org: string - orgGithub: string - repos: string -} - -export const CMD_NAME = 'github' - -const DEFAULT_GITHUB_URL = 'https://api.github.com' - -const description = 'Create a scan for given GitHub repo' - -const hidden = true - -export const cmdScanGithub = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - all: { - type: 'boolean', - description: - 'Apply for all known repositories reported by the Socket API. Supersedes `repos`.', - }, - githubToken: { - type: 'string', - default: getSocketCliGithubToken(), - description: - 'Required GitHub token for authentication.\nMay set environment variable GITHUB_TOKEN or SOCKET_CLI_GITHUB_TOKEN instead.', - }, - githubApiUrl: { - type: 'string', - default: DEFAULT_GITHUB_URL, - description: `Base URL of the GitHub API (default: ${DEFAULT_GITHUB_URL})`, - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - default: '', - description: - 'Force override the organization slug, overrides the default org from config', - }, - orgGithub: { - type: 'string', - default: '', - description: - 'Alternate GitHub Org if the name is different than the Socket Org', - }, - repos: { - type: 'string', - default: '', - description: - 'List of repos to target in a comma-separated format (e.g., repo1,repo2). If not specified, the script will pull the list from Socket and ask you to pick one. Use --all to use them all.', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - This is similar to the \`socket scan create\` command except it pulls the files - from GitHub. See the help for that command for more details. - - A GitHub Personal Access Token (PAT) will at least need read access to the repo - ("contents", read-only) for this command to work. - - Note: This command cannot run the \`socket manifest auto\` things because that - requires local access to the repo while this command runs entirely through the - GitHub for file access. - - You can use \`socket scan setup\` to configure certain repo flag defaults. - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} ./proj - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { - githubToken = getSocketCliGithubToken(), - interactive = true, - json, - markdown, - org: orgFlag, - } = cli.flags as unknown as ScanGithubFlags - - const dryRun = !!cli.flags['dryRun'] - - let { all, githubApiUrl, orgGithub, repos } = - cli.flags as unknown as ScanGithubFlags - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - let { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - const sockJson = readOrDefaultSocketJson(cwd) - - if (all === undefined) { - if (sockJson.defaults?.scan?.github?.all !== undefined) { - all = sockJson.defaults?.scan?.github?.all - } else { - all = false - } - } - /* c8 ignore start - githubApiUrl flag has DEFAULT_GITHUB_URL as its default, so this block only runs when both the flag default AND CLI input are empty */ - if (!githubApiUrl) { - if (sockJson.defaults?.scan?.github?.githubApiUrl !== undefined) { - githubApiUrl = sockJson.defaults.scan.github.githubApiUrl - } else { - githubApiUrl = DEFAULT_GITHUB_URL - } - } - /* c8 ignore stop */ - if (!orgGithub) { - if (sockJson.defaults?.scan?.github?.orgGithub !== undefined) { - orgGithub = sockJson.defaults.scan.github.orgGithub - } else { - // Default to Socket org slug. Often that's fine. Vanity and all that. - orgGithub = orgSlug - } - } - if (!all && !repos) { - if (sockJson.defaults?.scan?.github?.repos !== undefined) { - repos = sockJson.defaults.scan.github.repos - } else { - repos = '' - } - } - - // We will also be needing that GitHub token. - const hasGithubApiToken = !!githubToken - - // We're going to need an api token to suggest data because those suggestions - // must come from data we already know. Don't error on missing api token yet. - // If the api-token is not set, ignore it for the sake of suggestions. - const hasSocketApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - // If the current cwd is unknown and is used as a repo slug anyways, we will - // first need to register the slug before we can use it. - // Only do suggestions with an apiToken and when not in dryRun mode - if (hasSocketApiToken && !dryRun && interactive) { - if (!orgSlug) { - const suggestion = await suggestOrgSlug() - if (suggestion === undefined) { - await outputScanGithub( - { - ok: false, - message: 'Canceled by user', - cause: 'Org selector was canceled by user', - }, - outputKind, - ) - return - } - if (suggestion) { - orgSlug = suggestion - } - } - } - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasSocketApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - test: hasGithubApiToken, - message: 'This command requires a GitHub API token for access', - fail: 'missing', - }, - ) - if (!wasValidInput) { - return - } - - // Note exiting earlier to skirt a hidden auth requirement - if (dryRun) { - const details: Record<string, unknown> = { - organization: orgSlug, - githubOrganization: orgGithub, - githubApiUrl, - } - if (all) { - details['scope'] = 'all repositories' - } else if (repos) { - details['repositories'] = repos - } - outputDryRunUpload('GitHub scan', details) - return - } - - await handleCreateGithubScan({ - all: Boolean(all), - githubApiUrl, - githubToken: githubToken || '', - interactive: Boolean(interactive), - orgSlug, - orgGithub, - outputKind, - repos, - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-list.mts b/packages/cli/src/commands/scan/cmd-scan-list.mts deleted file mode 100644 index d8ba81b71..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-list.mts +++ /dev/null @@ -1,227 +0,0 @@ -import { handleListScans } from './handle-list-scans.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { V1_MIGRATION_GUIDE_URL } from '../../constants/socket.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { webLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { - CliCommandContext, - CliSubcommand, -} from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'list' - -const description = 'List the scans for an organization' - -const hidden = false - -export const cmdScanList: CliSubcommand = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - branch: { - type: 'string', - description: 'Filter to show only scans with this branch name', - }, - direction: { - type: 'string', - shortFlag: 'd', - default: 'desc', - description: 'Direction option (`desc` or `asc`) - Default is `desc`', - }, - fromTime: { - type: 'string', - shortFlag: 'f', - default: '', - description: 'From time - as a unix timestamp', - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - page: { - type: 'number', - shortFlag: 'p', - default: 1, - description: 'Page number - Default is 1', - }, - perPage: { - type: 'number', - shortFlag: 'pp', - default: 30, - description: 'Results per page - Default is 30', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - sort: { - type: 'string', - shortFlag: 's', - default: 'created_at', - description: - 'Sorting option (`name` or `created_at`) - default is `created_at`', - }, - untilTime: { - type: 'string', - shortFlag: 'u', - default: '', - description: 'Until time - as a unix timestamp', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [REPO [BRANCH]] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Optionally filter by REPO. If you specify a repo, you can also specify a - branch to filter by. (Note: If you don't specify a repo then you must use - \`--branch\` to filter by branch across all repos). - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} webtools badbranch --markdown - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { branch: branchFlag, json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const noLegacy = !cli.flags['repo'] - - const [repo = '', branchArg = ''] = cli.input - - const branch = String(branchFlag || branchArg || '') - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: noLegacy, - message: `Legacy flags are no longer supported. See the ${webLink(V1_MIGRATION_GUIDE_URL, 'v1 migration guide')}.`, - fail: 'received legacy flags', - }, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'dot is an invalid org, most likely you forgot the org name here?', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: !branchFlag || !branchArg, - message: - 'You should not set --branch and also give a second arg for branch name', - fail: 'received flag and second arg', - }, - ) - if (!wasValidInput) { - return - } - - // Validate numeric pagination parameters. - const validatedPage = Number(cli.flags['page'] || 1) - const validatedPerPage = Number(cli.flags['perPage'] || 30) - - if (dryRun) { - outputDryRunFetch('scans', { - organization: orgSlug, - repo: repo || undefined, - branch: branch || undefined, - sort: String(cli.flags['sort'] || 'created_at'), - direction: String(cli.flags['direction'] || 'desc'), - page: validatedPage, - perPage: validatedPerPage, - }) - return - } - - if (Number.isNaN(validatedPage) || validatedPage < 1) { - throw new InputError( - `--page must be a positive integer (saw: "${cli.flags['page']}"); pass a number like --page=1`, - ) - } - if (Number.isNaN(validatedPerPage) || validatedPerPage < 1) { - throw new InputError( - `--per-page must be a positive integer (saw: "${cli.flags['perPage']}"); pass a number like --per-page=30`, - ) - } - - await handleListScans({ - branch: branch ? String(branch) : '', - direction: String(cli.flags['direction'] || ''), - from_time: String(cli.flags['fromTime'] || ''), - orgSlug, - outputKind, - page: validatedPage, - perPage: validatedPerPage, - repo: repo ? String(repo) : '', - sort: String(cli.flags['sort'] || ''), - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-metadata.mts b/packages/cli/src/commands/scan/cmd-scan-metadata.mts deleted file mode 100644 index f6ac30b3a..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-metadata.mts +++ /dev/null @@ -1,140 +0,0 @@ -import { handleOrgScanMetadata } from './handle-scan-metadata.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { - CliCommandContext, - CliSubcommand, -} from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'metadata' - -const description = "Get a scan's metadata" - -const hidden = false - -export const cmdScanMetadata: CliSubcommand = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <SCAN_ID> - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown, org: orgFlag } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const [scanId = ''] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: - orgSlug === '.' - ? 'dot is an invalid org, most likely you forgot the org name here?' - : 'missing', - }, - { - test: !!scanId, - message: 'Scan ID to inspect as argument', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('scan metadata', { - organization: orgSlug, - scanId, - }) - return - } - - await handleOrgScanMetadata(orgSlug, scanId, outputKind) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-reach.mts b/packages/cli/src/commands/scan/cmd-scan-reach.mts deleted file mode 100644 index d86fba40f..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-reach.mts +++ /dev/null @@ -1,341 +0,0 @@ -import path from 'node:path' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' - -import { assertNoNegationPatterns } from './exclude-paths.mts' -import { handleScanReach } from './handle-scan-reach.mts' -import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' -import { suggestTarget } from './suggest_target.mts' -import { validateReachabilityTarget } from './validate-reachability-target.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mts' -import { getEcosystemChoicesForMeow } from '../../util/ecosystem/types.mts' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mts' -import { cmdFlagValueToArray } from '../../util/process/cmd.mts' -import { determineOrgSlug } from '../../util/socket/org-slug.mts' -import { hasDefaultApiToken } from '../../util/socket/sdk.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { MeowFlags } from '../../flags.mts' -import type { CliCommandContext } from '../../util/cli/with-subcommands.mts' -import type { PURL_Type } from '../../util/ecosystem/types.mts' - -// Flags interface for type safety. -interface ScanReachFlags { - cwd: string - interactive: boolean - json: boolean - markdown: boolean - org: string - output: string - reachAnalysisMemoryLimit: number - reachAnalysisTimeout: number - reachConcurrency: number - reachDebug: boolean - reachDetailedAnalysisLogFile: boolean - reachDisableAnalytics: boolean - reachDisableExternalToolChecks: boolean - reachEnableAnalysisSplitting: boolean - reachLazyMode: boolean - reachMinSeverity: string - reachSkipCache: boolean - reachUseOnlyPregeneratedSboms: boolean - reachUseUnreachableFromPrecomputation: boolean - reachVersion: string -} - -export const CMD_NAME = 'reach' - -const description = 'Compute tier 1 reachability' - -const hidden = true - -const generalFlags: MeowFlags = { - ...commonFlags, - ...outputFlags, - cwd: { - type: 'string', - default: '', - description: 'working directory, defaults to process.cwd()', - }, - org: { - type: 'string', - default: '', - description: - 'Force override the organization slug, overrides the default org from config', - }, - output: { - type: 'string', - default: '', - description: - 'Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory.', - shortFlag: 'o', - }, -} - -export const cmdScanReach = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...generalFlags, - ...excludePathsFlag, - ...reachabilityFlags, - }), - help: (command: string) => - ` - Usage - $ ${command} [options] [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(generalFlags)} - - Reachability Options - ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })} - - Runs the Socket reachability analysis without creating a scan in Socket. - The output is written to .socket.facts.json in the current working directory - unless the --output flag is specified. - - Note: Manifest files are uploaded to Socket's backend services because the - reachability analysis requires creating a Software Bill of Materials (SBOM) - from these files before the analysis can run. - - Examples - $ ${command} - $ ${command} ./proj - $ ${command} ./proj --reach-ecosystems npm,pypi - $ ${command} --output custom-report.json - $ ${command} ./proj --output ./reports/analysis.json - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { - cwd: cwdOverride, - interactive = true, - json, - markdown, - org: orgFlag, - output: outputPath, - reachAnalysisMemoryLimit, - reachAnalysisTimeout, - reachConcurrency, - reachDebug, - reachDetailedAnalysisLogFile, - reachDisableAnalytics, - reachDisableExternalToolChecks, - reachEnableAnalysisSplitting, - reachLazyMode, - reachMinSeverity, - reachSkipCache, - reachUseOnlyPregeneratedSboms, - reachUseUnreachableFromPrecomputation, - reachVersion, - } = cli.flags as unknown as ScanReachFlags - - const dryRun = !!cli.flags['dryRun'] - - // Process comma-separated values for isMultiple flags. - const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) - const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) - const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) - assertNoNegationPatterns(excludePaths) - - // Validate ecosystem values. - const reachEcosystems: PURL_Type[] = [] - const validEcosystems = getEcosystemChoicesForMeow() - for (let i = 0, { length } = reachEcosystemsRaw; i < length; i += 1) { - const ecosystem = reachEcosystemsRaw[i]! - if (!validEcosystems.includes(ecosystem)) { - throw new InputError( - `--reach-ecosystems must be one of: ${joinAnd(validEcosystems)} (saw: "${ecosystem}"); pass a supported ecosystem like --reach-ecosystems=${validEcosystems[0]}`, - ) - } - reachEcosystems.push(ecosystem as PURL_Type) - } - - const processCwd = process.cwd() - const cwd = - cwdOverride && cwdOverride !== '.' && cwdOverride !== processCwd - ? path.resolve(processCwd, cwdOverride) - : processCwd - - // Accept zero or more paths. Default to cwd() if none given. - let targets = cli.input.length ? [...cli.input] : [cwd] - - /* c8 ignore start - defensive: targets always has at least [cwd] from the line above, so this branch never fires in practice */ - if (!targets.length && !dryRun && interactive) { - targets = await suggestTarget() - } - /* c8 ignore stop */ - - const { 0: orgSlug } = await determineOrgSlug(orgFlag, interactive, dryRun) - - const hasApiToken = hasDefaultApiToken() - - const outputKind = getOutputKind(json, markdown) - - // Validate target constraints for reachability analysis. - const targetValidation = await validateReachabilityTarget(targets, cwd) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires an API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: !outputPath || outputPath.endsWith('.json'), - message: 'The --output path must end with .json', - fail: 'use a path ending with .json', - }, - { - nook: true, - test: targetValidation.isValid, - message: 'Reachability analysis requires exactly one target directory', - fail: 'provide exactly one directory path', - }, - { - nook: true, - test: targetValidation.isDirectory, - message: 'Reachability analysis target must be a directory', - fail: 'provide a directory path, not a file', - }, - { - nook: true, - test: targetValidation.targetExists, - message: 'Target directory must exist', - fail: 'provide an existing directory path', - }, - { - nook: true, - test: targetValidation.isInsideCwd, - message: 'Target directory must be inside the current working directory', - fail: 'provide a path inside the working directory', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - const args: string[] = [] - if (targets[0]) { - args.push('--target', targets[0]) - } - if (orgSlug) { - args.push('--org', orgSlug) - } - if (reachEcosystems.length > 0) { - args.push('--ecosystems', reachEcosystems.join(',')) - } - outputDryRunExecute('coana', args, 'reachability analysis') - return - } - - // Validate numeric flag conversions. - const validatedReachAnalysisMemoryLimit = Number(reachAnalysisMemoryLimit) - if ( - reachAnalysisMemoryLimit !== undefined && - Number.isNaN(validatedReachAnalysisMemoryLimit) - ) { - throw new InputError( - `--reach-analysis-memory-limit must be a number of megabytes (saw: "${reachAnalysisMemoryLimit}"); pass an integer like --reach-analysis-memory-limit=4096`, - ) - } - - const validatedReachAnalysisTimeout = Number(reachAnalysisTimeout) - if ( - reachAnalysisTimeout !== undefined && - Number.isNaN(validatedReachAnalysisTimeout) - ) { - throw new InputError( - `--reach-analysis-timeout must be a number of seconds (saw: "${reachAnalysisTimeout}"); pass an integer like --reach-analysis-timeout=300`, - ) - } - - const validatedReachConcurrency = Number(reachConcurrency) - if ( - reachConcurrency !== undefined && - (Number.isNaN(validatedReachConcurrency) || - !Number.isInteger(validatedReachConcurrency) || - validatedReachConcurrency <= 0) - ) { - throw new InputError( - `--reach-concurrency must be a positive integer (saw: "${reachConcurrency}"); pass a number like --reach-concurrency=4`, - ) - } - - await handleScanReach({ - cwd, - interactive, - orgSlug, - outputKind, - outputPath: outputPath || '', - targets, - reachabilityOptions: { - excludePaths, - reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit, - reachAnalysisTimeout: validatedReachAnalysisTimeout, - reachConcurrency: validatedReachConcurrency, - reachDebug: Boolean(reachDebug), - reachDetailedAnalysisLogFile: Boolean(reachDetailedAnalysisLogFile), - reachDisableAnalytics: Boolean(reachDisableAnalytics), - reachDisableExternalToolChecks: Boolean(reachDisableExternalToolChecks), - reachEnableAnalysisSplitting: Boolean(reachEnableAnalysisSplitting), - reachEcosystems, - reachExcludePaths, - reachLazyMode: Boolean(reachLazyMode), - reachMinSeverity: String(reachMinSeverity), - reachSkipCache: Boolean(reachSkipCache), - reachUseOnlyPregeneratedSboms: Boolean(reachUseOnlyPregeneratedSboms), - reachUseUnreachableFromPrecomputation: Boolean( - reachUseUnreachableFromPrecomputation, - ), - reachVersion: reachVersion || undefined, - }, - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-report.mts b/packages/cli/src/commands/scan/cmd-scan-report.mts deleted file mode 100644 index fb61acc66..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-report.mts +++ /dev/null @@ -1,218 +0,0 @@ -import { handleScanReport } from './handle-scan-report.mts' -import { FOLD_SETTING_NONE } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { REPORT_LEVEL_WARN } from '../../constants/reporting.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { FOLD_SETTING, REPORT_LEVEL } from './types.mts' -import type { - CliCommandContext, - CliSubcommand, -} from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -// Flags interface for type safety. -interface ScanReportFlags { - fold: FOLD_SETTING - json: boolean - markdown: boolean - org: string - reportLevel: REPORT_LEVEL -} - -export const CMD_NAME = 'report' - -const description = - 'Check whether a scan result passes the organizational policies (security, license)' - -const hidden = false - -export const cmdScanReport: CliSubcommand = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - fold: { - type: 'string', - default: FOLD_SETTING_NONE, - description: `Fold reported alerts to some degree (default '${FOLD_SETTING_NONE}')`, - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - reportLevel: { - type: 'string', - default: REPORT_LEVEL_WARN, - description: `Which policy level alerts should be reported (default '${REPORT_LEVEL_WARN}')`, - }, - short: { - type: 'boolean', - default: false, - description: 'Report only the healthy status', - }, - license: { - type: 'boolean', - default: false, - description: 'Also report the license policy status. Default: false', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <SCAN_ID> [OUTPUT_PATH] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - When no output path is given the contents is sent to stdout. - - By default the result is a nested object that looks like this: - \`{ - [ecosystem]: { - [pkgName]: { - [version]: { - [file]: { - [line:col]: alert - }}}}\` - So one alert for each occurrence in every file, version, etc, a huge response. - - You can --fold these up to given level: 'pkg', 'version', 'file', and 'none'. - For example: \`socket scan report --fold=version\` will dedupe alerts to only - show one alert of a particular kind, no matter how often it was found in a - file or in how many files it was found. At most one per version that has it. - - By default only the warn and error policy level alerts are reported. You can - override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') - - Short responses look like this: - --json: \`{healthy:bool}\` - --markdown: \`healthy = bool\` - neither: \`OK/ERR\` - - Examples - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --license --markdown --short - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { - fold, - json, - markdown, - org: orgFlag, - reportLevel, - } = cli.flags as unknown as ScanReportFlags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const includeLicensePolicy = !!cli.flags['license'] - - const short = !!cli.flags['short'] - - const [scanId = '', filepath = ''] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'dot is an invalid org, most likely you forgot the org name here?', - }, - { - test: !!scanId, - message: 'Scan ID to report on', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('scan report', { - organization: orgSlug, - scanId, - fold, - reportLevel, - includeLicense: includeLicensePolicy, - short, - }) - return - } - - await handleScanReport({ - orgSlug, - scanId, - includeLicensePolicy, - outputKind, - filepath, - fold, - short, - reportLevel, - }) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-setup.mts b/packages/cli/src/commands/scan/cmd-scan-setup.mts deleted file mode 100644 index 36af89130..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-setup.mts +++ /dev/null @@ -1,88 +0,0 @@ -import path from 'node:path' - -import { handleScanConfig } from './handle-scan-config.mts' -import { SOCKET_JSON } from '../../constants/paths.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'setup', - description: - 'Start interactive configurator to customize default flag values for `socket scan` in this dir', - hidden: false, - flags: defineFlags({ - ...commonFlags, - defaultOnReadError: { - type: 'boolean', - description: `If reading the ${SOCKET_JSON} fails, just use a default config? Warning: This might override the existing json file!`, - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [CWD=.] - - Options - ${getFlagListOutput(config.flags)} - - Interactive configurator to create a local json file in the target directory - that helps to set flag defaults for \`socket scan create\`. - - This helps to configure the (Socket reported) repo and branch names, as well - as which branch name is the "default branch" (main, master, etc). This way - you don't have to specify these flags when creating a scan in this dir. - - This generated configuration file will only be used locally by the CLI. You - can commit it to the repo (useful for collaboration) or choose to add it to - your .gitignore all the same. Only this CLI will use it. - - Examples - - $ ${command} - $ ${command} ./proj - `, -} - -export const cmdScanSetup = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - - const dryRun = !!cli.flags['dryRun'] - const { defaultOnReadError = false } = cli.flags - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - if (dryRun) { - const socketJsonPath = path.join(cwd, SOCKET_JSON) - outputDryRunWrite(socketJsonPath, 'create or update scan configuration', [ - 'Set default repository name', - 'Set default branch name', - 'Configure scan options', - ]) - return - } - - await handleScanConfig(cwd, Boolean(defaultOnReadError)) -} diff --git a/packages/cli/src/commands/scan/cmd-scan-view.mts b/packages/cli/src/commands/scan/cmd-scan-view.mts deleted file mode 100644 index 82598771f..000000000 --- a/packages/cli/src/commands/scan/cmd-scan-view.mts +++ /dev/null @@ -1,161 +0,0 @@ -import { handleScanView } from './handle-scan-view.mts' -import { streamScan } from './stream-scan.mts' -import { FLAG_JSON, FLAG_MARKDOWN } from '../../constants/cli.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { - CliCommandContext, - CliSubcommand, -} from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -export const CMD_NAME = 'view' - -const description = 'View the raw results of a scan' - -const hidden = false - -export const cmdScanView: CliSubcommand = { - description, - hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - stream: { - type: 'boolean', - default: false, - description: - 'Only valid with --json. Streams the response as "ndjson" (chunks of valid json blobs).', - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] <SCAN_ID> [OUTPUT_FILE] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - When no output path is given the contents is sent to stdout. - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 - $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 ./stream.txt - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { json, markdown, org: orgFlag, stream } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - const [scanId = '', file = ''] = cli.input - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'dot is an invalid org, most likely you forgot the org name here?', - }, - { - test: !!scanId, - message: 'Scan ID to view', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, - fail: 'bad', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - { - nook: true, - test: !stream || !!json, - message: 'You can only use --stream when using --json', - fail: 'Either remove --stream or add --json', - }, - ) - if (!wasValidInput) { - return - } - - if (dryRun) { - outputDryRunFetch('scan details', { - organization: orgSlug, - scanId, - stream: !!stream || undefined, - }) - return - } - - if (json && stream) { - await streamScan(orgSlug, scanId, { - commandPath: 'socket scan view', - file, - }) - } else { - await handleScanView(orgSlug, scanId, file, outputKind) - } -} diff --git a/packages/cli/src/commands/scan/cmd-scan.mts b/packages/cli/src/commands/scan/cmd-scan.mts deleted file mode 100644 index e92662425..000000000 --- a/packages/cli/src/commands/scan/cmd-scan.mts +++ /dev/null @@ -1,40 +0,0 @@ -import { cmdScanCreate } from './cmd-scan-create.mts' -import { cmdScanDel } from './cmd-scan-del.mts' -import { cmdScanDiff } from './cmd-scan-diff.mts' -import { cmdScanGithub } from './cmd-scan-github.mts' -import { cmdScanList } from './cmd-scan-list.mts' -import { cmdScanMetadata } from './cmd-scan-metadata.mts' -import { cmdScanReach } from './cmd-scan-reach.mts' -import { cmdScanReport } from './cmd-scan-report.mts' -import { cmdScanSetup } from './cmd-scan-setup.mts' -import { cmdScanView } from './cmd-scan-view.mts' -import { defineSubcommandGroup } from '../../util/cli/define-subcommand-group.mts' - -export const cmdScan = defineSubcommandGroup({ - name: 'scan', - description: 'Manage Socket scans', - subcommands: { - create: cmdScanCreate, - del: cmdScanDel, - diff: cmdScanDiff, - github: cmdScanGithub, - list: cmdScanList, - metadata: cmdScanMetadata, - reach: cmdScanReach, - report: cmdScanReport, - setup: cmdScanSetup, - view: cmdScanView, - }, - aliases: { - meta: { - description: cmdScanMetadata.description, - hidden: true, - argv: ['metadata'], - }, - reachability: { - description: cmdScanReach.description, - hidden: true, - argv: ['reach'], - }, - }, -}) diff --git a/packages/cli/src/commands/scan/create-scan-from-github-api.mts b/packages/cli/src/commands/scan/create-scan-from-github-api.mts deleted file mode 100644 index a134f9f1b..000000000 --- a/packages/cli/src/commands/scan/create-scan-from-github-api.mts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * GitHub API helpers for `socket scan github`. - * - * Extracted from create-scan-from-github.mts to keep that file under the - * 1000-line File-size cap. These wrap octokit.repos.* / .git.* / - * .repos.listCommits with the project's CResult contract and friendly error - * messages for empty repos / missing default branch. - */ - -import { debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getOctokit, withGitHubRetry } from '../../util/git/github.mts' - -import type { CResult } from '../../types.mts' - -const logger = getDefaultLogger() - -/** - * Fetch the latest commit on the given branch (SHA, message, committer). - */ -export async function getLastCommitDetails({ - defaultBranch, - orgGithub, - repoSlug, -}: { - defaultBranch: string - orgGithub: string - repoSlug: string -}): Promise< - CResult<{ - lastCommitMessage: string - lastCommitSha: string - lastCommitter: string | undefined - }> -> { - logger.info( - `Requesting last commit for default branch ${defaultBranch} for ${orgGithub}/${repoSlug}...`, - ) - - const octokit = getOctokit() - - const result = await withGitHubRetry( - /* c8 ignore start - withGitHubRetry mock returns cached value; the inner factory only runs on cache miss */ - async () => { - const { data } = await octokit.repos.listCommits({ - owner: orgGithub, - repo: repoSlug, - sha: defaultBranch, - per_page: 1, - }) - return data - }, - /* c8 ignore stop */ - `fetching latest commit SHA for ${orgGithub}/${repoSlug}`, - ) - - if (!result.ok) { - return result - } - - const commits = result.data - debugDir({ commits }) - - if (!commits.length) { - return { - ok: false, - message: 'No commits found', - cause: - `No commits found on branch ${defaultBranch} for ${orgGithub}/${repoSlug}. ` + - 'The repository may be empty.', - } - } - - const [lastCommit] = commits - const lastCommitSha = lastCommit?.sha - - if (!lastCommitSha) { - return { - ok: false, - message: 'Missing commit SHA', - cause: - `Unable to get last commit SHA for ${orgGithub}/${repoSlug}. ` + - 'The GitHub API response was missing the SHA field.', - } - } - - // Extract committer information. - const authorName = lastCommit?.commit?.author?.name - const committerName = lastCommit?.commit?.committer?.name - const lastCommitter = authorName || committerName - const lastCommitMessage = lastCommit?.commit?.message || '' - - return { ok: true, data: { lastCommitMessage, lastCommitSha, lastCommitter } } -} - -/** - * Fetch the recursive file tree of a branch — returns a flat list of blob - * paths. - * - * Treats a `GitHub resource not found` error as an empty repo (returns []), - * since the most common cause is a freshly-created repo with no commits. - */ -export async function getRepoBranchTree({ - defaultBranch, - orgGithub, - repoSlug, -}: { - defaultBranch: string - orgGithub: string - repoSlug: string -}): Promise<CResult<string[]>> { - logger.info( - `Requesting default branch file tree; branch \`${defaultBranch}\`, repo \`${orgGithub}/${repoSlug}\`...`, - ) - - const octokit = getOctokit() - - const result = await withGitHubRetry( - /* c8 ignore start - withGitHubRetry mock returns cached value; the inner factory only runs on cache miss */ - async () => { - const { data } = await octokit.git.getTree({ - owner: orgGithub, - repo: repoSlug, - tree_sha: defaultBranch, - recursive: 'true', - }) - return data - }, - /* c8 ignore stop */ - `fetching file tree for branch ${defaultBranch} in ${orgGithub}/${repoSlug}`, - ) - - if (!result.ok) { - // Check if it's an empty repo error (404 with specific message). - if (result.message === 'GitHub resource not found') { - logger.warn( - `GitHub reports the default branch of repo ${repoSlug} may be empty or not found. Moving on to next repo.`, - ) - return { ok: true, data: [] } - } - return result - } - - const treeDetails = result.data - debugDir({ treeDetails }) - - if (!treeDetails.tree || !Array.isArray(treeDetails.tree)) { - debugDir({ treeDetails: { tree: treeDetails.tree } }) - - return { - ok: false, - message: 'Invalid tree response', - cause: - `Tree response for default branch ${defaultBranch} for ${orgGithub}/${repoSlug} was not a list. ` + - 'The repository may be empty or in an unexpected state.', - } - } - - const files = treeDetails.tree - .filter(obj => obj.type === 'blob') - .map(obj => obj.path) - .filter((p): p is string => typeof p === 'string' && p.length > 0) - - return { ok: true, data: files } -} - -/** - * Fetch repo metadata (default branch, plus the raw repo details payload). - */ -export async function getRepoDetails({ - orgGithub, - repoSlug, -}: { - orgGithub: string - repoSlug: string - githubApiUrl: string - githubToken: string -}): Promise<CResult<{ defaultBranch: string; repoDetails: unknown }>> { - const octokit = getOctokit() - - const result = await withGitHubRetry( - /* c8 ignore start - withGitHubRetry mock returns cached value; the inner factory only runs on cache miss */ - async () => { - const { data } = await octokit.repos.get({ - owner: orgGithub, - repo: repoSlug, - }) - return data - }, - /* c8 ignore stop */ - `fetching repository details for ${orgGithub}/${repoSlug}`, - ) - - if (!result.ok) { - return result - } - - const repoDetails = result.data - logger.success('Request completed.') - debugDir({ repoDetails }) - - const defaultBranch = repoDetails.default_branch - if (!defaultBranch) { - return { - ok: false, - message: 'Default branch not found', - cause: - `Repository ${orgGithub}/${repoSlug} does not have a default branch set. ` + - 'This can happen with empty repositories or misconfigured repo settings.', - } - } - - return { ok: true, data: { defaultBranch, repoDetails } } -} diff --git a/packages/cli/src/commands/scan/create-scan-from-github-prompts.mts b/packages/cli/src/commands/scan/create-scan-from-github-prompts.mts deleted file mode 100644 index 8c25cf06a..000000000 --- a/packages/cli/src/commands/scan/create-scan-from-github-prompts.mts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Interactive prompts for `socket scan github`. - * - * Extracted from create-scan-from-github.mts to keep that file under the - * 1000-line File-size cap. These helpers wrap @socketsecurity/lib prompt - * primitives with a confirm-or-cancel CResult contract that the orchestration - * code expects. - */ - -import { confirm, select } from '@socketsecurity/lib-stable/stdio/prompts' - -import type { CResult } from '../../types.mts' - -/** - * Confirm a bulk action ("are you sure you want to run this for N repos?"). - */ -export async function makeSure(count: number): Promise<CResult<undefined>> { - if ( - !(await confirm({ - message: `Are you sure you want to run this for ${count} repos?`, - default: false, - })) - ) { - return { - ok: false, - message: 'User canceled', - cause: 'Action canceled by user', - } - } - return { ok: true, data: undefined } -} - -/** - * Ask the user to pick a single repo from a list. Returns ok:false with - * cause='User chose to cancel the action' when the user picks the synthetic - * '(Exit)' choice. - */ -export async function selectFocus(repos: string[]): Promise<CResult<string[]>> { - const proceed = await select({ - message: 'Please select the repo to process:', - choices: repos - .map(slug => ({ - name: slug, - value: slug, - description: `Create scan for the ${slug} repo through GitHub`, - })) - .concat({ - name: '(Exit)', - value: '', - description: 'Cancel this action and exit', - }), - }) - if (!proceed) { - return { - ok: false, - message: 'Canceled by user', - cause: 'User chose to cancel the action', - } - } - return { ok: true, data: [proceed] } -} diff --git a/packages/cli/src/commands/scan/create-scan-from-github.mts b/packages/cli/src/commands/scan/create-scan-from-github.mts deleted file mode 100644 index 3cdc837da..000000000 --- a/packages/cli/src/commands/scan/create-scan-from-github.mts +++ /dev/null @@ -1,585 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import { existsSync, mkdtempSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { safeDelete, safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' -import { handleCreateNewScan } from './handle-create-new-scan.mts' -import { REPORT_LEVEL_ERROR } from '../../constants/reporting.mjs' -import { formatErrorWithDetail } from '../../util/error/errors.mjs' -import { socketHttpRequest } from '../../util/socket/api.mjs' -import { isReportSupportedFile } from '../../util/fs/glob.mts' -import { - GITHUB_ERR_ABUSE_DETECTION, - GITHUB_ERR_AUTH_FAILED, - GITHUB_ERR_GRAPHQL_RATE_LIMIT, - GITHUB_ERR_RATE_LIMIT, - getOctokit, - withGitHubRetry, -} from '../../util/git/github.mts' -import { fetchListAllRepos } from '../repository/fetch-list-all-repos.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type RepoListItem = - SocketSdkSuccessResult<'listRepositories'>['data']['results'][number] - -export async function createScanFromGithub({ - all, - githubApiUrl, - githubToken, - interactive, - orgGithub, - orgSlug, - outputKind, - repos, -}: { - all: boolean - githubApiUrl: string - githubToken: string - interactive: boolean - orgSlug: string - orgGithub: string - outputKind: OutputKind - repos: string -}): Promise<CResult<undefined>> { - let targetRepos: string[] = repos - .trim() - .split(',') - .map(r => r.trim()) - .filter(Boolean) - if (all || !targetRepos.length) { - // Fetch from Socket API - const result = await fetchListAllRepos(orgSlug, { - direction: 'asc', - sort: 'name', - }) - if (!result.ok) { - return result - } - targetRepos = result.data.results.map((obj: RepoListItem) => obj.slug || '') - } - - targetRepos = targetRepos.map(s => s.trim()).filter(Boolean) - - logger.info(`Have ${targetRepos.length} repo names to Scan!`) - logger.log('') - - if (!targetRepos.length) { - return { - ok: false, - message: 'No repo found', - cause: - 'You did not set the --repos value and/or the server responded with zero repos when asked for some. Unable to proceed.', - } - } - - // Non-interactive or explicitly requested; just do it. - if (interactive && targetRepos.length > 1 && !all && !repos) { - const result = await selectFocus(targetRepos) - if (!result.ok) { - return result - } - targetRepos = result.data - } - - // 10 is an arbitrary number. Maybe confirm whenever count>1 ? - // Do not ask to confirm when the list was given explicit. - if (interactive && (all || !repos) && targetRepos.length > 10) { - const sure = await makeSure(targetRepos.length) - if (!sure.ok) { - return sure - } - } - - let scansCreated = 0 - let reposScanned = 0 - // Track a blocking error (rate limit / auth) so we can surface it - // instead of reporting silent success with "0 manifests". Without - // this, a rate-limited GitHub token made every repo fail its tree - // fetch, the outer loop swallowed each error, and the final summary - // ("N repos / 0 manifests") misled users into thinking the scan - // worked. - let blockingError: CResult<undefined> | undefined - const perRepoFailures: Array<{ repo: string; message: string }> = [] - for (let i = 0, { length } = targetRepos; i < length; i += 1) { - const repoSlug = targetRepos[i]! - reposScanned += 1 - const scanCResult = await scanRepo(repoSlug, { - githubApiUrl, - githubToken, - orgSlug, - orgGithub, - outputKind, - repos, - }) - if (scanCResult.ok) { - const { scanCreated } = scanCResult.data - if (scanCreated) { - scansCreated += 1 - } - continue - } - perRepoFailures.push({ - repo: repoSlug, - message: scanCResult.message, - }) - // Stop on rate-limit / auth failures: every subsequent repo will - // fail for the same reason and continuing only burns more quota - // while delaying the real error. - if ( - scanCResult.message === GITHUB_ERR_ABUSE_DETECTION || - scanCResult.message === GITHUB_ERR_AUTH_FAILED || - scanCResult.message === GITHUB_ERR_GRAPHQL_RATE_LIMIT || - scanCResult.message === GITHUB_ERR_RATE_LIMIT - ) { - blockingError = { - ok: false, - message: scanCResult.message, - cause: scanCResult.cause, - } - break - } - } - - if (blockingError) { - logger.fail(blockingError.message) - return blockingError - } - - logger.success(reposScanned, 'GitHub repos processed') - logger.success(scansCreated, 'with supported Manifest files') - - // If every repo failed but not for a known-blocking reason, treat - // the run as an error so scripts know something went wrong instead - // of inferring success from an ok: true with 0 scans. - if ( - reposScanned > 0 && - scansCreated === 0 && - perRepoFailures.length === reposScanned - ) { - const firstFailure = perRepoFailures[0]! - return { - ok: false, - message: 'All repos failed to scan', - cause: - `All ${reposScanned} repos failed to scan. First failure for ${firstFailure.repo}: ${firstFailure.message}. ` + - 'Check the log above for per-repo details.', - } - } - - return { - ok: true, - data: undefined, - } -} - -export async function downloadManifestFile({ - defaultBranch, - file, - orgGithub, - repoSlug, - tmpDir, -}: { - defaultBranch: string - file: string - orgGithub: string - repoSlug: string - tmpDir: string -}): Promise<CResult<undefined>> { - debug('request: file content from GitHub') - - const octokit = getOctokit() - - const result = await withGitHubRetry(async () => { - const { data } = await octokit.repos.getContent({ - owner: orgGithub, - repo: repoSlug, - path: file, - ref: defaultBranch, - }) - return data - }, `fetching file content for ${file} in ${orgGithub}/${repoSlug}`) - - if (!result.ok) { - logger.fail(`Failed to get file content for: ${file}`) - return result - } - - const fileData = result.data as { - type?: string | undefined - size?: number | undefined - download_url?: string | null | undefined - } - debug('complete: request') - debugDir({ - fileData: { type: fileData.type, size: fileData.size }, - }) - - // Check if it's a file (not a directory). - if (Array.isArray(fileData) || fileData.type !== 'file') { - return { - ok: false, - message: 'Not a file', - cause: `Path ${file} is not a file in ${orgGithub}/${repoSlug}.`, - } - } - - const downloadUrl = fileData.download_url - if (!downloadUrl) { - return { - ok: false, - message: 'Missing download URL', - cause: - `GitHub did not provide a download URL for ${file} in ${orgGithub}/${repoSlug}. ` + - 'The file may be too large or in an unsupported format.', - } - } - - const localPath = path.join(tmpDir, file) - debug(`download: manifest file started ${downloadUrl} -> ${localPath}`) - - // Now stream the file to that file. - const downloadResult = await streamDownloadWithFetch(localPath, downloadUrl) - if (!downloadResult.ok) { - logger.fail( - `Failed to download manifest file, skipping to next file. File: ${file}`, - ) - return downloadResult - } - - debug('download: manifest file completed') - - return { ok: true, data: undefined } -} - -export async function scanOneRepo( - repoSlug: string, - { - orgGithub, - orgSlug, - outputKind, - }: { - githubApiUrl: string - githubToken: string - orgSlug: string - orgGithub: string - outputKind: OutputKind - repos: string - }, -): Promise<CResult<{ scanCreated: boolean }>> { - const repoResult = await getRepoDetails({ - orgGithub, - repoSlug, - githubApiUrl: '', - githubToken: '', - }) - if (!repoResult.ok) { - return repoResult - } - const { defaultBranch } = repoResult.data - - logger.info(`Default branch: \`${defaultBranch}\``) - - const treeResult = await getRepoBranchTree({ - defaultBranch, - orgGithub, - repoSlug, - }) - if (!treeResult.ok) { - return treeResult - } - const files = treeResult.data - - if (!files.length) { - logger.warn( - 'No files were reported for the default branch. Moving on to next repo.', - ) - return { ok: true, data: { scanCreated: false } } - } - - const tmpDir = mkdtempSync(path.join(os.tmpdir(), repoSlug)) - debug(`init: temp dir for scan root ${tmpDir}`) - - const downloadResult = await testAndDownloadManifestFiles({ - defaultBranch, - files, - orgGithub, - repoSlug, - tmpDir, - }) - if (!downloadResult.ok) { - return downloadResult - } - - const commitResult = await getLastCommitDetails({ - defaultBranch, - orgGithub, - repoSlug, - }) - if (!commitResult.ok) { - return commitResult - } - - const { lastCommitMessage, lastCommitSha, lastCommitter } = commitResult.data - - // Make request for full scan - // I think we can just kick off the socket scan create command now... - - await handleCreateNewScan({ - autoManifest: false, - basics: false, - branchName: defaultBranch, - commitHash: lastCommitSha, - commitMessage: lastCommitMessage || '', - committers: lastCommitter || '', - cwd: tmpDir, - defaultBranch: true, - interactive: false, - orgSlug, - outputKind, - pendingHead: true, - pullRequest: 0, - reach: { - excludePaths: [], - runReachabilityAnalysis: false, - reachAnalysisMemoryLimit: 0, - reachAnalysisTimeout: 0, - reachConcurrency: 1, - reachDebug: false, - reachDetailedAnalysisLogFile: false, - reachDisableAnalytics: false, - reachDisableExternalToolChecks: false, - reachEnableAnalysisSplitting: false, - reachEcosystems: [], - reachExcludePaths: [], - reachLazyMode: false, - reachMinSeverity: '', - reachSkipCache: false, - reachUseOnlyPregeneratedSboms: false, - reachUseUnreachableFromPrecomputation: false, - reachVersion: undefined, - }, - readOnly: false, - repoName: repoSlug, - report: false, - reportLevel: REPORT_LEVEL_ERROR, - targets: ['.'], - tmp: false, - }) - - return { ok: true, data: { scanCreated: true } } -} - -export async function scanRepo( - repoSlug: string, - { - githubApiUrl, - githubToken, - orgGithub, - orgSlug, - outputKind, - repos, - }: { - githubApiUrl: string - githubToken: string - orgSlug: string - orgGithub: string - outputKind: OutputKind - repos: string - }, -): Promise<CResult<{ scanCreated: boolean }>> { - logger.info( - `Requesting repo details from GitHub API for: \`${orgGithub}/${repoSlug}\`...`, - ) - logger.group() - const result = await scanOneRepo(repoSlug, { - githubApiUrl, - githubToken, - orgSlug, - orgGithub, - outputKind, - repos, - }) - logger.groupEnd() - logger.log('') - return result -} - -// Courtesy of gemini: -export async function streamDownloadWithFetch( - localPath: string, - downloadUrl: string, -): Promise<CResult<string>> { - try { - // Use longer timeout for file downloads (5 minutes). - const response = await socketHttpRequest(downloadUrl, { - timeout: 300_000, - }) - - if (!response.ok) { - const errorMsg = `Download failed due to bad server response: ${response.status} ${response.statusText} for ${downloadUrl}` - logger.fail(errorMsg) - return { ok: false, message: 'Download Failed', cause: errorMsg } - } - - // Make sure the dir exists. It may be nested and we need to construct that - // before starting the download. - const dir = path.dirname(localPath) - if (!existsSync(dir)) { - safeMkdirSync(dir, { recursive: true }) - } - - await fs.writeFile(localPath, response.body) - return { ok: true, data: localPath } - } catch (e) { - logger.fail( - 'An error was thrown while trying to download a manifest file... url:', - downloadUrl, - ) - debugDir(e) - - // If an error occurs and fileStream was created, attempt to clean up. - try { - await safeDelete(localPath, { force: true }) - } catch (e) { - logger.fail( - formatErrorWithDetail( - `Error deleting partial file ${localPath}`, - e as NodeJS.ErrnoException, - ), - ) - } - // Construct a more informative error message - let detailedError = `Error during download of ${downloadUrl}: ${(e as { message: string }).message}` - if ((e as { cause: string }).cause) { - // Include cause if available (e.g., from network errors) - detailedError += `\nCause: ${(e as { cause: string }).cause}` - } - debug(detailedError) - return { ok: false, message: 'Download Failed', cause: detailedError } - } -} - -export async function testAndDownloadManifestFile({ - defaultBranch, - file, - orgGithub, - repoSlug, - supportedFiles, - tmpDir, -}: { - defaultBranch: string - file: string - orgGithub: string - repoSlug: string - supportedFiles: - | SocketSdkSuccessResult<'getSupportedFiles'>['data'] - | undefined - tmpDir: string -}): Promise<CResult<{ isManifest: boolean }>> { - debug(`testing: file ${file}`) - - if (!supportedFiles || !isReportSupportedFile(file, supportedFiles)) { - debug('skip: not a known pattern') - // Not an error. - return { ok: true, data: { isManifest: false } } - } - - debug(`found: manifest file, going to attempt to download it; ${file}`) - - const result = await downloadManifestFile({ - defaultBranch, - file, - orgGithub, - repoSlug, - tmpDir, - }) - - return result.ok ? { ok: true, data: { isManifest: true } } : result -} - -export async function testAndDownloadManifestFiles({ - defaultBranch, - files, - orgGithub, - repoSlug, - tmpDir, -}: { - defaultBranch: string - files: string[] - orgGithub: string - repoSlug: string - tmpDir: string -}): Promise<CResult<unknown>> { - logger.info( - `File tree for ${defaultBranch} contains`, - files.length, - 'entries. Searching for supported manifest files...', - ) - - // Fetch supported files once for all file checks (avoid repeated API calls). - const supportedFilesCResult = await fetchSupportedScanFileNames() - const supportedFiles = supportedFilesCResult.ok - ? supportedFilesCResult.data - : undefined - - logger.group() - let fileCount = 0 - let firstFailureResult: CResult<never> | undefined - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i]! - const result = await testAndDownloadManifestFile({ - defaultBranch, - file, - orgGithub, - repoSlug, - supportedFiles, - tmpDir, - }) - if (result.ok) { - if (result.data.isManifest) { - fileCount += 1 - } - } else if (!firstFailureResult) { - firstFailureResult = result - } - } - logger.groupEnd() - logger.info('Found and downloaded', fileCount, 'manifest files') - - if (!fileCount) { - if (firstFailureResult) { - logger.fail( - 'While no supported manifest files were downloaded, at least one error encountered trying to do so. Showing the first error.', - ) - return firstFailureResult - } - return { - ok: false, - message: 'No manifest files found', - cause: `No supported manifest files were found in the latest commit on the branch ${defaultBranch} for repo ${orgGithub}/${repoSlug}. Skipping full scan.`, - } - } - - return { ok: true, data: undefined } -} - -// Interactive prompts extracted to keep this file under the 1000-line File-size cap. -import { makeSure, selectFocus } from './create-scan-from-github-prompts.mts' - -export { makeSure, selectFocus } - -// GitHub API helpers extracted to keep this file under the 1000-line File-size cap. -import { - getLastCommitDetails, - getRepoBranchTree, - getRepoDetails, -} from './create-scan-from-github-api.mts' - -export { getLastCommitDetails, getRepoBranchTree, getRepoDetails } diff --git a/packages/cli/src/commands/scan/exclude-paths.mts b/packages/cli/src/commands/scan/exclude-paths.mts deleted file mode 100644 index 678b2506c..000000000 --- a/packages/cli/src/commands/scan/exclude-paths.mts +++ /dev/null @@ -1,190 +0,0 @@ -import path from 'node:path' - -import { InputError } from '../../util/error/errors.mts' - -import type { ReachabilityOptions } from './perform-reachability-analysis.mts' -import type { SocketYml } from '../../util/socket-yaml.mts' - -type ApplyFullExcludePathsOptions = { - cwd: string - reachabilityOptions: ReachabilityOptions - socketConfig: SocketYml | undefined - target: string -} - -type ApplyFullExcludePathsResult = { - effectiveSocketConfig: SocketYml | undefined - mergedReachabilityOptions: ReachabilityOptions -} - -/** - * Applies --exclude-paths consistently to SCA manifest discovery and Coana. SCA - * exclusion always applies when paths are provided. The reachability options - * are merged unconditionally; callers decide whether to actually run - * reachability and consume them. - */ -export function applyFullExcludePaths({ - cwd, - reachabilityOptions, - socketConfig, - target, -}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { - const { excludePaths } = reachabilityOptions - const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) - const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( - scaExcludeGlobs, - { - cwd, - target, - }, - ) - const socketConfigReachExcludeGlobs = excludePaths.length - ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { - cwd, - target, - }) - : [] - const effectiveSocketConfig = scaExcludeGlobs.length - ? { - ...socketConfig, - version: socketConfig?.version ?? 2, - issueRules: socketConfig?.issueRules ?? {}, - githubApp: socketConfig?.githubApp ?? {}, - projectIgnorePaths: [ - ...(socketConfig?.projectIgnorePaths ?? []), - ...scaExcludeGlobs, - ], - } - : socketConfig - const mergedReachabilityOptions = excludePaths.length - ? { - ...reachabilityOptions, - reachExcludePaths: [ - ...socketConfigReachExcludeGlobs, - ...reachabilityOptions.reachExcludePaths, - ...coanaExcludeGlobs, - ], - } - : reachabilityOptions - - return { effectiveSocketConfig, mergedReachabilityOptions } -} - -/** - * Rejects gitignore-style negation patterns for --exclude-paths because the - * flag is a positive full-exclusion list, not a complete ignore language. - */ -export function assertNoNegationPatterns(paths: readonly string[]): void { - for (let i = 0, { length } = paths; i < length; i += 1) { - const path = paths[i]! - if (path.startsWith('!')) { - throw new InputError( - `--exclude-paths does not support negation patterns. Got: '${path}'.`, - ) - } - } -} - -/** - * Converts a user-facing full-scan exclude path into the socket.yml - * projectIgnorePaths shape used by SCA manifest discovery. - */ -export function excludePathToProjectIgnorePath(path: string): string { - const stripped = stripTrailingSlash(path) - return stripped.endsWith('/**') ? stripped : `${stripped}/**` -} - -export function expandReachExcludePath(path: string): string[] { - if (path === '**') { - return ['**'] - } - const firstSlash = path.indexOf('/') - const prefix = - firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' - const normalized = stripTrailingSlash( - path.startsWith('/') ? path.slice(1) : path, - ) - const pattern = `${prefix}${normalized}` - return pattern.endsWith('/*') || pattern.endsWith('/**') - ? [pattern] - : [pattern, `${pattern}/**`] -} - -export function normalizeProjectIgnorePath(path: string): string { - return stripTrailingSlash( - toPosixPath(path.startsWith('/') ? path.slice(1) : path), - ) -} - -export function pathRelativeToTarget( - path: string, - target: string, -): string | undefined { - const normalized = normalizeProjectIgnorePath(path) - if (target === '' || target === '.') { - return normalized - } - - // Ignore paths outside the analysis target. They still affect SCA manifest - // discovery through projectIgnorePaths, but Coana cannot exclude directories - // outside the target it is analyzing. - if (normalized === target) { - return '**' - } - const targetPrefix = `${target}/` - if (normalized.startsWith(targetPrefix)) { - return normalized.slice(targetPrefix.length) - } - /* c8 ignore start - unreachable: recursiveTargetPrefix = `${targetPrefix}**\/` so any startsWith(recursiveTargetPrefix) match would have been caught by the startsWith(targetPrefix) check above. */ - const recursiveTargetPrefix = `${targetPrefix}**/` - if (normalized.startsWith(recursiveTargetPrefix)) { - return normalized.slice(targetPrefix.length) - } - /* c8 ignore stop */ - return undefined -} - -export function projectIgnorePathToReachExcludePaths( - path: string, - targetPattern: string, -): string[] { - const reachPath = pathRelativeToTarget(path, targetPattern) - if (!reachPath) { - return [] - } - return expandReachExcludePath(reachPath) -} - -/** - * Translates project-root projectIgnorePaths into Coana --exclude-dirs values, - * which are interpreted relative to the current reachability analysis target. - */ -export function projectIgnorePathsToReachExcludePaths( - paths: readonly string[] | undefined, - options: { cwd: string; target: string }, -): string[] { - // GitHub App-style projectIgnorePaths support negation. Coana's - // --exclude-dirs does not, so keep the existing Coana behavior and let it - // infer config ignores itself when any negation is present. - if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { - return [] - } - - // projectIgnorePaths are rooted at the project cwd. Coana receives excludes - // relative to its analysis target, so nested target scans need translation. - const targetPath = path.isAbsolute(options.target) - ? path.relative(options.cwd, options.target) - : options.target - const targetPattern = toPosixPath(stripTrailingSlash(targetPath)) - return paths.flatMap(path => - projectIgnorePathToReachExcludePaths(path, targetPattern), - ) -} - -export function stripTrailingSlash(path: string): string { - return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path -} - -export function toPosixPath(path: string): string { - return path.replaceAll('\\', '/') -} diff --git a/packages/cli/src/commands/scan/fetch-create-org-full-scan.mts b/packages/cli/src/commands/scan/fetch-create-org-full-scan.mts deleted file mode 100644 index c39debbb9..000000000 --- a/packages/cli/src/commands/scan/fetch-create-org-full-scan.mts +++ /dev/null @@ -1,89 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchCreateOrgFullScanConfigs = { - branchName: string - commitHash: string - commitMessage: string - committers: string - pullRequest: number - repoName: string - scanType: string | undefined - workspace?: string | undefined -} - -type FetchCreateOrgFullScanOptions = { - commandPath?: string | undefined - cwd?: string | undefined - defaultBranch?: boolean | undefined - pendingHead?: boolean | undefined - sdkOpts?: SetupSdkOptions | undefined - spinner?: SpinnerInstance | undefined - tmp?: boolean | undefined -} - -export async function fetchCreateOrgFullScan( - packagePaths: string[], - orgSlug: string, - config: FetchCreateOrgFullScanConfigs, - options?: FetchCreateOrgFullScanOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']>> { - const { - branchName, - commitHash, - commitMessage, - committers, - pullRequest, - repoName, - scanType, - workspace, - } = { __proto__: null, ...config } as FetchCreateOrgFullScanConfigs - - const { - commandPath, - cwd = process.cwd(), - defaultBranch, - pendingHead, - sdkOpts, - spinner, - tmp, - } = { __proto__: null, ...options } as FetchCreateOrgFullScanOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'createFullScan'>( - sockSdk.createFullScan(orgSlug, packagePaths, { - pathsRelativeTo: cwd, - ...(branchName ? { branch: branchName } : {}), - ...(commitHash ? { commit_hash: commitHash } : {}), - ...(commitMessage ? { commit_message: commitMessage } : {}), - ...(committers ? { committers } : {}), - ...(defaultBranch !== undefined - ? { make_default_branch: Boolean(defaultBranch) } - : {}), - ...(pullRequest ? { pull_request: String(pullRequest) } : {}), - ...(repoName ? { repo: repoName } : {}), - ...(scanType ? { scan_type: scanType } : {}), - ...(workspace ? { workspace } : {}), - ...(pendingHead !== undefined - ? { set_as_pending_head: Boolean(pendingHead) } - : {}), - ...(tmp !== undefined ? { tmp: Boolean(tmp) } : {}), - // eslint-disable-next-line typescript-eslint/no-explicit-any -- SDK option shape varies by spread; downstream validates against canonical API contract. - } as any), - { - commandPath, - description: 'to create a scan', - spinner, - }, - ) -} diff --git a/packages/cli/src/commands/scan/fetch-delete-org-full-scan.mts b/packages/cli/src/commands/scan/fetch-delete-org-full-scan.mts deleted file mode 100644 index 2e823c5b3..000000000 --- a/packages/cli/src/commands/scan/fetch-delete-org-full-scan.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchDeleteOrgFullScanOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchDeleteOrgFullScan( - orgSlug: string, - scanId: string, - options?: FetchDeleteOrgFullScanOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'deleteFullScan'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchDeleteOrgFullScanOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'deleteFullScan'>( - sockSdk.deleteFullScan(orgSlug, scanId), - { - commandPath, - description: 'to delete a scan', - }, - ) -} diff --git a/packages/cli/src/commands/scan/fetch-diff-scan.mts b/packages/cli/src/commands/scan/fetch-diff-scan.mts deleted file mode 100644 index c05c88fda..000000000 --- a/packages/cli/src/commands/scan/fetch-diff-scan.mts +++ /dev/null @@ -1,28 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { queryApiSafeJson } from '../../util/socket/api.mjs' - -import type { CResult } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function fetchDiffScan({ - id1, - id2, - orgSlug, -}: { - id1: string - id2: string - orgSlug: string -}): Promise<CResult<SocketSdkSuccessResult<'GetOrgDiffScan'>['data']>> { - logger.info('Scan ID 1:', id1) - logger.info('Scan ID 2:', id2) - logger.info('Note: this request may take some time if the scans are big') - - return await queryApiSafeJson< - SocketSdkSuccessResult<'GetOrgDiffScan'>['data'] - >( - `orgs/${orgSlug}/full-scans/diff?before=${encodeURIComponent(id1)}&after=${encodeURIComponent(id2)}`, - 'a scan diff', - ) -} diff --git a/packages/cli/src/commands/scan/fetch-list-scans.mts b/packages/cli/src/commands/scan/fetch-list-scans.mts deleted file mode 100644 index a84635392..000000000 --- a/packages/cli/src/commands/scan/fetch-list-scans.mts +++ /dev/null @@ -1,59 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchOrgFullScanListConfig = { - branch: string - direction: string - from_time: string - orgSlug: string - page: number - perPage: number - repo: string - sort: string -} - -type FetchOrgFullScanListOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchOrgFullScanList( - config: FetchOrgFullScanListConfig, - options?: FetchOrgFullScanListOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'listFullScans'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchOrgFullScanListOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - const { branch, direction, from_time, orgSlug, page, perPage, repo, sort } = { - __proto__: null, - ...config, - } as FetchOrgFullScanListConfig - - return await handleApiCall<'listFullScans'>( - sockSdk.listFullScans(orgSlug, { - ...(branch ? { branch } : {}), - ...(repo ? { repo } : {}), - ...(sort ? { sort: sort as 'name' | 'created_at' } : {}), - ...(direction ? { direction: direction as 'asc' | 'desc' } : {}), - from: from_time, - page, - per_page: perPage, - }), - { - commandPath, - description: 'list of scans', - }, - ) -} diff --git a/packages/cli/src/commands/scan/fetch-report-data.mts b/packages/cli/src/commands/scan/fetch-report-data.mts deleted file mode 100644 index 1cfc51d80..000000000 --- a/packages/cli/src/commands/scan/fetch-report-data.mts +++ /dev/null @@ -1,198 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { formatErrorWithDetail } from '../../util/error/errors.mjs' -import { - handleApiCallNoSpinner, - queryApiSafeText, -} from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -const logger = getDefaultLogger() -const spinner = getDefaultSpinner() - -type FetchScanData = { - includeLicensePolicy?: boolean | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -/** - * This fetches all the relevant pieces of data to generate a report, given a - * full scan ID. - */ -export async function fetchScanData( - orgSlug: string, - scanId: string, - options?: FetchScanData | undefined, -): Promise< - CResult<{ - scan: SocketArtifact[] - securityPolicy: SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - }> -> { - const { includeLicensePolicy, sdkOpts } = { - __proto__: null, - ...options, - } as FetchScanData - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - let policyStatus = 'requested...' - let scanStatus = 'requested...' - let finishedFetching = false - - function updateScan(status: string) { - scanStatus = status - updateProgress() - } - - function updatePolicy(status: string) { - policyStatus = status - updateProgress() - } - - function updateProgress() { - if (finishedFetching) { - spinner.stop() - logger.info( - `Scan result: ${scanStatus}. Security policy: ${policyStatus}.`, - ) - } else { - spinner.start( - `Scan result: ${scanStatus}. Security policy: ${policyStatus}.`, - ) - } - } - - async function fetchScanResult(): Promise<CResult<SocketArtifact[]>> { - const result = await queryApiSafeText( - `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${includeLicensePolicy ? '?include_license_details=true' : ''}`, - ) - - updateScan('response received') - - if (!result.ok) { - return result - } - - const ndJsonString = result.data - - // This is nd-json; each line is a json object. - const lines = ndJsonString.split('\n').filter(Boolean) - const data: SocketArtifact[] = [] - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - try { - data.push(JSON.parse(line)) - } catch (e) { - debug('Failed to parse report data line as JSON') - debugDir({ error: e, line }) - updateScan('received invalid JSON response') - return { - ok: false, - message: 'Invalid Socket API response', - cause: - 'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.', - } - } - } - - updateScan('success') - return { ok: true, data } - } - - async function fetchSecurityPolicy(): Promise< - CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']> - > { - const result = (await handleApiCallNoSpinner( - sockSdk.getOrgSecurityPolicy(orgSlug), - 'GetOrgSecurityPolicy', - )) as CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']> - - updatePolicy('received policy') - - return result - } - - updateProgress() - - const results = await Promise.allSettled([ - fetchScanResult().catch(e => { - updateScan('failure; unknown blocking error occurred') - return { - ok: false as const, - message: 'Socket API error', - cause: - formatErrorWithDetail('Error requesting scan', e) || - 'Error requesting scan: (no error message found)', - } - }), - fetchSecurityPolicy().catch(e => { - updatePolicy('failure; unknown blocking error occurred') - return { - ok: false as const, - message: 'Socket API error', - cause: - formatErrorWithDetail('Error requesting policy', e) || - 'Error requesting policy: (no error message found)', - } - }), - ]).finally(() => { - finishedFetching = true - updateProgress() - }) - - const scan: CResult<SocketArtifact[]> = - results[0].status === 'fulfilled' - ? results[0].value - : { - ok: false as const, - message: 'Unexpected error', - cause: 'Promise rejected unexpectedly', - } - - const securityPolicy: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = - results[1].status === 'fulfilled' - ? results[1].value - : { - ok: false as const, - message: 'Unexpected error', - cause: 'Promise rejected unexpectedly', - } - - if (!scan.ok) { - return scan - } - if (!securityPolicy.ok) { - return securityPolicy - } - - /* c8 ignore start - defensive: scan.data is always SocketArtifact[] from the loop above */ - if (!Array.isArray(scan.data)) { - return { - ok: false, - message: 'Failed to fetch', - cause: 'Was unable to fetch scan result, bailing', - } - } - /* c8 ignore stop */ - - return { - ok: true, - data: { - scan: scan.data satisfies SocketArtifact[], - securityPolicy: securityPolicy.data, - }, - } -} diff --git a/packages/cli/src/commands/scan/fetch-scan-metadata.mts b/packages/cli/src/commands/scan/fetch-scan-metadata.mts deleted file mode 100644 index 50dd2ef9e..000000000 --- a/packages/cli/src/commands/scan/fetch-scan-metadata.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchScanMetadataOptions = { - commandPath?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function fetchScanMetadata( - orgSlug: string, - scanId: string, - options?: FetchScanMetadataOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getFullScanMetadata'>['data']>> { - const { commandPath, sdkOpts } = { - __proto__: null, - ...options, - } as FetchScanMetadataOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - return await handleApiCall<'getFullScanMetadata'>( - sockSdk.getFullScanMetadata(orgSlug, scanId), - { - commandPath, - description: 'meta data for a full scan', - }, - ) -} diff --git a/packages/cli/src/commands/scan/fetch-scan.mts b/packages/cli/src/commands/scan/fetch-scan.mts deleted file mode 100644 index 45369cbef..000000000 --- a/packages/cli/src/commands/scan/fetch-scan.mts +++ /dev/null @@ -1,44 +0,0 @@ -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { queryApiSafeText } from '../../util/socket/api.mjs' - -import type { CResult } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' - -export async function fetchScan( - orgSlug: string, - scanId: string, -): Promise<CResult<SocketArtifact[]>> { - const result = await queryApiSafeText( - `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}`, - 'a scan', - ) - - if (!result.ok) { - return result - } - - const jsonsString = result.data - - // This is nd-json; each line is a json object. - const lines = jsonsString.split('\n').filter(Boolean) - const data: SocketArtifact[] = [] - - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - try { - data.push(JSON.parse(line)) - } catch (e) { - debug('Failed to parse scan result line as JSON') - debugDir({ error: e, line }) - return { - ok: false, - message: 'Invalid Socket API response', - cause: - 'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.', - } - } - } - - return { ok: true, data } -} diff --git a/packages/cli/src/commands/scan/fetch-supported-scan-file-names.mts b/packages/cli/src/commands/scan/fetch-supported-scan-file-names.mts deleted file mode 100644 index 07be16e83..000000000 --- a/packages/cli/src/commands/scan/fetch-supported-scan-file-names.mts +++ /dev/null @@ -1,49 +0,0 @@ -import { getDefaultOrgSlug } from '../ci/fetch-default-org-slug.mjs' -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type FetchSupportedScanFileNamesOptions = { - orgSlug?: string | undefined - sdkOpts?: SetupSdkOptions | undefined - spinner?: SpinnerInstance | undefined -} - -export async function fetchSupportedScanFileNames( - options?: FetchSupportedScanFileNamesOptions | undefined, -): Promise<CResult<SocketSdkSuccessResult<'getSupportedFiles'>['data']>> { - const { orgSlug, sdkOpts, spinner } = { - __proto__: null, - ...options, - } as FetchSupportedScanFileNamesOptions - - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - // Use provided orgSlug or discover it. - let resolvedOrgSlug = orgSlug - /* c8 ignore start -- defensive: getDefaultOrgSlug discovery path; all unit-test callers pass orgSlug explicitly, and the .mjs/.mts boundary makes mocking getDefaultOrgSlug unreliable in this test file */ - if (!resolvedOrgSlug) { - const orgSlugCResult = await getDefaultOrgSlug() - if (!orgSlugCResult.ok) { - return orgSlugCResult - } - resolvedOrgSlug = orgSlugCResult.data - } - /* c8 ignore stop */ - - return await handleApiCall<'getSupportedFiles'>( - sockSdk.getSupportedFiles(resolvedOrgSlug), - { - description: 'supported scan file types', - spinner, - }, - ) -} diff --git a/packages/cli/src/commands/scan/finalize-tier1-scan.mts b/packages/cli/src/commands/scan/finalize-tier1-scan.mts deleted file mode 100644 index 8264b63d7..000000000 --- a/packages/cli/src/commands/scan/finalize-tier1-scan.mts +++ /dev/null @@ -1,23 +0,0 @@ -import { sendApiRequest } from '../../util/socket/api.mjs' - -import type { CResult } from '../../types.mts' - -/** - * Finalize a tier1 reachability scan. - Associates the tier1 reachability scan - * metadata with the full scan. - Sets the tier1 reachability scan to - * "finalized" state. - */ -export async function finalizeTier1Scan( - tier1ReachabilityScanId: string, - scanId: string, -): Promise<CResult<unknown>> { - // we do not use the SDK here because the tier1-reachability-scan/finalize is a hidden - // endpoint that is not part of the OpenAPI specification. - return await sendApiRequest('tier1-reachability-scan/finalize', { - method: 'POST', - body: { - tier1_reachability_scan_id: tier1ReachabilityScanId, - report_run_id: scanId, - }, - }) -} diff --git a/packages/cli/src/commands/scan/generate-report.mts b/packages/cli/src/commands/scan/generate-report.mts deleted file mode 100644 index 1947a5127..000000000 --- a/packages/cli/src/commands/scan/generate-report.mts +++ /dev/null @@ -1,358 +0,0 @@ -import { UNKNOWN_VALUE } from '@socketsecurity/lib-stable/constants/sentinels' - -import { - FOLD_SETTING_FILE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, -} from '../../constants/cli.mts' -import { - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, -} from '../../constants/reporting.mts' -import { getSocketDevPackageOverviewUrlFromPurl } from '../../util/socket/url.mts' - -import type { FOLD_SETTING, REPORT_LEVEL } from './types.mts' -import type { CResult } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type AlertKey = string -type EcoMap = Map<string, ReportLeafNode | PackageMap> -type FileMap = Map<string, ReportLeafNode | Map<AlertKey, ReportLeafNode>> -type PackageMap = Map<string, ReportLeafNode | VersionMap> -type VersionMap = Map<string, ReportLeafNode | FileMap> - -type ViolationsMap = Map<string, EcoMap> - -export interface ScanReport { - orgSlug: string - scanId: string - options: { - fold: FOLD_SETTING - reportLevel: REPORT_LEVEL - } - healthy: boolean - alerts: ViolationsMap -} - -export type ReportLeafNode = { - type: string - policy: REPORT_LEVEL - url: string - manifest: string[] -} - -export function addAlert( - art: SocketArtifact, - violations: ViolationsMap, - fold: FOLD_SETTING, - ecosystem: string, - pkgName: string, - version: string, - alert: NonNullable<SocketArtifact['alerts']>[number], - policyAction: REPORT_LEVEL, -): void { - if (!violations.has(ecosystem)) { - violations.set(ecosystem, new Map()) - } - const ecoMap: EcoMap = violations.get(ecosystem)! - if (fold === FOLD_SETTING_PKG) { - const existing = ecoMap.get(pkgName) as ReportLeafNode | undefined - if (!existing || isStricterPolicy(existing.policy, policyAction)) { - ecoMap.set(pkgName, createLeaf(art, alert, policyAction)) - } - } else { - if (!ecoMap.has(pkgName)) { - ecoMap.set(pkgName, new Map()) - } - const pkgMap = ecoMap.get(pkgName) as PackageMap - if (fold === FOLD_SETTING_VERSION) { - const existing = pkgMap.get(version) as ReportLeafNode | undefined - if (!existing || isStricterPolicy(existing.policy, policyAction)) { - pkgMap.set(version, createLeaf(art, alert, policyAction)) - } - } else { - if (!pkgMap.has(version)) { - pkgMap.set(version, new Map()) - } - const file = alert.file || UNKNOWN_VALUE - const verMap = pkgMap.get(version) as VersionMap - - if (fold === FOLD_SETTING_FILE) { - const existing = verMap.get(file) as ReportLeafNode | undefined - if (!existing || isStricterPolicy(existing.policy, policyAction)) { - verMap.set(file, createLeaf(art, alert, policyAction)) - } - } else { - if (!verMap.has(file)) { - verMap.set(file, new Map()) - } - const key = `${alert.type} at ${alert.start}:${alert.end}` - const fileMap: FileMap = verMap.get(file) as FileMap - const existing = fileMap.get(key) as ReportLeafNode | undefined - if (!existing || isStricterPolicy(existing.policy, policyAction)) { - fileMap.set(key, createLeaf(art, alert, policyAction)) - } - } - } - } -} - -export function createLeaf( - art: SocketArtifact, - alert: NonNullable<SocketArtifact['alerts']>[number], - policyAction: REPORT_LEVEL, -): ReportLeafNode { - const leaf: ReportLeafNode = { - type: alert.type, - policy: policyAction, - url: getSocketDevPackageOverviewUrlFromPurl(art), - manifest: art.manifestFiles?.map((o: { file: string }) => o.file) ?? [], - } - return leaf -} - -// Note: The returned cResult will only be ok:false when the generation -// failed. It won't reflect the healthy state. -export function generateReport( - scan: SocketArtifact[], - securityPolicy: SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'], - { - fold, - orgSlug, - reportLevel, - scanId, - short, - spinner, - }: { - fold: FOLD_SETTING - orgSlug: string - reportLevel: REPORT_LEVEL - scanId: string - short?: boolean | undefined - spinner?: SpinnerInstance | undefined - }, -): CResult<ScanReport | { healthy: boolean }> { - const now = Date.now() - - spinner?.start('Generating report...') - - // Create an object that includes: - // healthy: boolean - // worst violation level; - // per eco - // per package - // per version - // per offending file - // reported issue -> policy action - - // In the context of a report; - // - the alert.severity is irrelevant - // - the securityPolicyDefault is irrelevant - // - the report defaults to healthy:true with no alerts - // - the appearance of an alert will trigger the policy action; - // - error: healthy will end up as false, add alerts to report - // - warn: healthy unchanged, add alerts to report - // - monitor/ignore: no action - // - defer: unknown (no action) - - // Note: the server will emit alerts for license policy violations but - // those are only included if you set the flag when requesting the scan - // data. The alerts map to a single security policy key that determines - // what to do with any violation, regardless of the concrete license. - // That rule is called "License Policy Violation". - // The license policy part is implicitly handled here. Either they are - // included and may show up, or they are not and won't show up. - - const violations = new Map() - - let healthy = true - - const securityRules = securityPolicy.securityPolicyRules - if (securityRules) { - // Note: reportLevel: error > warn > monitor > ignore > defer - for (let i = 0, { length } = scan; i < length; i += 1) { - const artifact = scan[i]! - const { - alerts, - name: pkgName = UNKNOWN_VALUE, - type: ecosystem, - version = UNKNOWN_VALUE, - } = artifact - - // oxlint-disable-next-line socket/prefer-cached-for-loop -- call result is consumed (not a standalone statement) - alerts?.forEach( - (alert: NonNullable<SocketArtifact['alerts']>[number]) => { - const alertName = alert.type as keyof typeof securityRules // => policy[type] - const action = (securityRules[alertName]?.action || - '') as REPORT_LEVEL - switch (action) { - case REPORT_LEVEL_ERROR: { - healthy = false - if (!short) { - addAlert( - artifact, - violations, - fold, - ecosystem, - pkgName, - version, - alert, - action, - ) - } - break - } - case REPORT_LEVEL_WARN: { - if (!short && reportLevel !== REPORT_LEVEL_ERROR) { - addAlert( - artifact, - violations, - fold, - ecosystem, - pkgName, - version, - alert, - action, - ) - } - break - } - case REPORT_LEVEL_MONITOR: { - if ( - !short && - reportLevel !== REPORT_LEVEL_WARN && - reportLevel !== REPORT_LEVEL_ERROR - ) { - addAlert( - artifact, - violations, - fold, - ecosystem, - pkgName, - version, - alert, - action, - ) - } - break - } - - case REPORT_LEVEL_IGNORE: { - if ( - !short && - reportLevel !== REPORT_LEVEL_MONITOR && - reportLevel !== REPORT_LEVEL_WARN && - reportLevel !== REPORT_LEVEL_ERROR - ) { - addAlert( - artifact, - violations, - fold, - ecosystem, - pkgName, - version, - alert, - action, - ) - } - break - } - - case REPORT_LEVEL_DEFER: { - // Not sure but ignore for now. Defer to later ;) - if (!short && reportLevel === REPORT_LEVEL_DEFER) { - addAlert( - artifact, - violations, - fold, - ecosystem, - pkgName, - version, - alert, - action, - ) - } - break - } - - default: { - // This value was not emitted from the Socket API at the time of writing. - } - } - }, - ) - } - } - - spinner?.successAndStop(`Generated reported in ${Date.now() - now} ms`) - - if (short) { - return { - ok: true, - data: { healthy }, - } - } - - const report = { - healthy, - orgSlug, - scanId, - options: { fold, reportLevel }, - alerts: violations, - } - - if (!healthy) { - return { - ok: true, - message: - 'The report contains at least one alert that violates the policies set by your organization', - data: report, - } - } - - return { - ok: true, - data: report, - } -} - -export function isStricterPolicy(was: REPORT_LEVEL, is: REPORT_LEVEL): boolean { - // error > warn > monitor > ignore > defer > {unknown} - if (was === REPORT_LEVEL_ERROR) { - return false - } - if (is === REPORT_LEVEL_ERROR) { - return true - } - if (was === REPORT_LEVEL_WARN) { - return false - } - if (is === REPORT_LEVEL_WARN) { - return true - } - if (was === REPORT_LEVEL_MONITOR) { - return false - } - if (is === REPORT_LEVEL_MONITOR) { - return true - } - if (was === REPORT_LEVEL_IGNORE) { - return false - } - if (is === REPORT_LEVEL_IGNORE) { - return true - } - if (was === REPORT_LEVEL_DEFER) { - return false - } - if (is === REPORT_LEVEL_DEFER) { - return false - } - // unreachable? - return false -} diff --git a/packages/cli/src/commands/scan/handle-create-new-scan.mts b/packages/cli/src/commands/scan/handle-create-new-scan.mts deleted file mode 100644 index 58baf90ed..000000000 --- a/packages/cli/src/commands/scan/handle-create-new-scan.mts +++ /dev/null @@ -1,381 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -const logger = getDefaultLogger() - -import { applyFullExcludePaths } from './exclude-paths.mts' -import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' -import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' -import { finalizeTier1Scan } from './finalize-tier1-scan.mts' -import { handleScanReport } from './handle-scan-report.mts' -import { outputCreateNewScan } from './output-create-new-scan.mts' -import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' -import { - DOT_SOCKET_DOT_FACTS_JSON, - FOLD_SETTING_VERSION, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, -} from '../../constants.mts' -import { runSocketBasics } from '../../util/basics/spawn.mts' - -/** - * Filter out .socket.facts.json files from scan paths to avoid duplicates. - * - * @param paths - Array of file paths to filter. - * - * @returns Filtered paths without .socket.facts.json files. - */ -export function excludeFactsJson(paths: string[]): string[] { - return paths.filter(p => path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) -} -import { compressSocketFactsForUpload } from '../../util/coana/compress-facts.mts' -import { findSocketYmlSync } from '../../util/config.mts' -import { getPackageFilesForScan } from '../../util/fs/path-resolve.mts' -import { readOrDefaultSocketJson } from '../../util/socket/json.mts' -import { socketDocsLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' -import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' -import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' - -import type { ReachabilityOptions } from './perform-reachability-analysis.mts' -import type { REPORT_LEVEL } from './types.mts' -import type { OutputKind } from '../../types.mts' -import type { Remap } from '@socketsecurity/lib-stable/objects/types' - -type HandleCreateNewScanConfig = { - autoManifest: boolean - basics: boolean - branchName: string - commitHash: string - commitMessage: string - committers: string - cwd: string - defaultBranch: boolean - interactive: boolean - orgSlug: string - pendingHead: boolean - pullRequest: number - outputKind: OutputKind - reach: Remap< - ReachabilityOptions & { - runReachabilityAnalysis: boolean - } - > - readOnly: boolean - repoName: string - report: boolean - reportLevel: REPORT_LEVEL - targets: string[] - tmp: boolean - workspace?: string | undefined -} - -export async function handleCreateNewScan({ - autoManifest, - basics, - branchName, - commitHash, - commitMessage, - committers, - cwd, - defaultBranch, - interactive, - orgSlug, - outputKind, - pendingHead, - pullRequest, - reach, - readOnly, - repoName, - report, - reportLevel, - targets, - tmp, - workspace, -}: HandleCreateNewScanConfig): Promise<void> { - debug( - 'notice', - `Creating new scan for ${orgSlug}/${workspace ? `${workspace}/` : ''}${repoName}`, - ) - debugDir('inspect', { - autoManifest, - branchName, - commitHash, - defaultBranch, - interactive, - pendingHead, - pullRequest, - readOnly, - report, - reportLevel, - targets, - tmp, - workspace, - }) - - if (autoManifest) { - logger.info('Auto-generating manifest files ...') - debug('notice', 'Auto-manifest mode enabled') - const sockJson = readOrDefaultSocketJson(cwd) - const detected = await detectManifestActions(sockJson, cwd) - debugDir('inspect', { detected }) - await generateAutoManifest({ - detected, - cwd, - outputKind, - verbose: false, - }) - logger.info('Auto-generation finished. Proceeding with Scan creation.') - } - - const spinner = getDefaultSpinner() - - const supportedFilesCResult = await fetchSupportedScanFileNames({ - orgSlug, - spinner, - }) - if (!supportedFilesCResult.ok) { - debug('warn', 'Failed to fetch supported scan file names') - debugDir('inspect', { supportedFilesCResult }) - await outputCreateNewScan(supportedFilesCResult, { - interactive, - outputKind, - }) - return - } - debug( - 'notice', - `Fetched ${supportedFilesCResult.data['size']} supported file types`, - ) - - spinner.start('Searching for local files to include in scan...') - - const supportedFiles = supportedFilesCResult.data - - // Load socket.yml so projectIgnorePaths is respected when collecting files. - const socketYmlResult = findSocketYmlSync(cwd) - const socketConfig = socketYmlResult.ok - ? socketYmlResult.data?.parsed - : undefined - - const { effectiveSocketConfig, mergedReachabilityOptions } = - applyFullExcludePaths({ - cwd, - reachabilityOptions: reach, - socketConfig, - target: targets[0]!, - }) - - const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: effectiveSocketConfig, - cwd, - }) - - spinner.successAndStop( - `Found ${packagePaths.length} ${pluralize('file', { count: packagePaths.length })} to include in scan.`, - ) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: packagePaths.length > 0, - fail: `found no eligible files to scan. See supported manifest files at ${socketDocsLink('/docs/manifest-file-detection-in-socket', 'docs.socket.dev')}`, - message: - 'TARGET (file/dir) must contain matching / supported file types for a scan', - }) - if (!wasValidInput) { - debug('warn', 'No eligible files found to scan') - return - } - - logger.success( - `Found ${packagePaths.length} local ${pluralize('file', { count: packagePaths.length })}`, - ) - - debugDir('inspect', { packagePaths }) - - if (readOnly) { - logger.log('[ReadOnly] Bailing now') - debug('notice', 'Read-only mode, exiting early') - return - } - - let scanPaths: string[] = packagePaths - let tier1ReachabilityScanId: string | undefined - - // If reachability is enabled, perform reachability analysis. - if (reach.runReachabilityAnalysis) { - /* c8 ignore start - defensive: empty targets crashes earlier at applyFullExcludePaths({ target: targets[0]! }) — this guard is unreachable in practice. */ - if (!targets.length) { - logger.fail('Reachability analysis requires at least one target') - return - } - /* c8 ignore stop */ - - const [firstTarget] = targets - if (!firstTarget) { - logger.fail('Reachability analysis requires at least one valid target') - return - } - - logger.error('') - logger.info('Starting reachability analysis...') - debug('notice', 'Reachability analysis enabled') - debugDir('inspect', { reachabilityOptions: mergedReachabilityOptions }) - - spinner.start() - - const reachResult = await performReachabilityAnalysis({ - branchName, - cwd, - orgSlug, - packagePaths, - reachabilityOptions: mergedReachabilityOptions, - repoName, - spinner, - target: firstTarget, - }) - - spinner.stop() - - if (!reachResult.ok) { - await outputCreateNewScan(reachResult, { interactive, outputKind }) - return - } - - logger.success('Reachability analysis completed successfully') - - const reachabilityReport = reachResult.data?.reachabilityReport - - scanPaths = [ - ...excludeFactsJson(packagePaths), - ...(reachabilityReport ? [reachabilityReport] : []), - ] - - tier1ReachabilityScanId = reachResult.data?.tier1ReachabilityScanId - } - - // Run socket-basics comprehensive security scanning if --basics flag is set. - if (basics) { - logger.error('') - logger.info('Starting comprehensive security scan (socket-basics)...') - debug('notice', 'Socket-basics enabled') - - spinner.start() - - const basicsResult = await runSocketBasics({ - cwd, - orgSlug, - repoName, - spinner, - }) - - spinner.stop() - - if (!basicsResult.ok) { - logger.warn( - 'Socket-basics scan failed, continuing without SAST/secrets findings', - ) - debug('error', 'socket-basics error:', basicsResult.message) - } else { - logger.success('Comprehensive security scan completed successfully') - - const basicsReport = basicsResult.data?.factsPath - - if (basicsReport && existsSync(basicsReport)) { - // Add .socket.facts.json from socket-basics to scan paths. - scanPaths = [...excludeFactsJson(packagePaths), basicsReport] - - const findings = basicsResult.data?.findings || {} - if (findings.sast) { - logger.info(` Found ${findings.sast} SAST issues`) - } - if (findings.secrets) { - logger.info(` Found ${findings.secrets} exposed secrets`) - } - if (findings.containers) { - logger.info( - ` Found ${findings.containers} container vulnerabilities`, - ) - } - } - } - } - - // Brotli-compress any .socket.facts.json paths in scanPaths just before - // upload. depscan's api-v0 multipart boundary streams brotli decode based - // on the .br filename suffix. Coana keeps writing plain .socket.facts.json - // on disk, so the local read path (extractTier1ReachabilityScanId) stays - // correct. The cleanup() in the finally block removes the sibling .br - // files whether the upload succeeded or threw. - const compressed = await compressSocketFactsForUpload(scanPaths) - let fullScanCResult: Awaited<ReturnType<typeof fetchCreateOrgFullScan>> - try { - fullScanCResult = await fetchCreateOrgFullScan( - compressed.paths, - orgSlug, - { - commitHash, - commitMessage, - committers, - pullRequest, - repoName, - branchName, - scanType: reach.runReachabilityAnalysis - ? SCAN_TYPE_SOCKET_TIER1 - : SCAN_TYPE_SOCKET, - workspace, - }, - { - cwd, - defaultBranch, - pendingHead, - tmp, - }, - ) - } finally { - await compressed.cleanup() - } - - const scanId = fullScanCResult.ok ? fullScanCResult.data?.id : undefined - - if (reach && scanId && tier1ReachabilityScanId) { - await finalizeTier1Scan(tier1ReachabilityScanId, scanId) - } - - if (report && fullScanCResult.ok) { - if (scanId) { - await handleScanReport({ - filepath: '-', - fold: FOLD_SETTING_VERSION, - includeLicensePolicy: true, - orgSlug, - outputKind, - reportLevel, - scanId, - short: false, - }) - } else { - await outputCreateNewScan( - { - ok: false, - message: 'Missing Scan ID', - cause: 'Server did not respond with a scan ID', - data: fullScanCResult.data, - }, - { - interactive, - outputKind, - }, - ) - } - } else { - spinner.stop() - - await outputCreateNewScan(fullScanCResult, { interactive, outputKind }) - } -} diff --git a/packages/cli/src/commands/scan/handle-list-scans.mts b/packages/cli/src/commands/scan/handle-list-scans.mts deleted file mode 100644 index 97838c9e7..000000000 --- a/packages/cli/src/commands/scan/handle-list-scans.mts +++ /dev/null @@ -1,44 +0,0 @@ -import { fetchOrgFullScanList } from './fetch-list-scans.mts' -import { outputListScans } from './output-list-scans.mts' - -import type { OutputKind } from '../../types.mts' - -export async function handleListScans({ - branch, - direction, - from_time, - orgSlug, - outputKind, - page, - perPage, - repo, - sort, -}: { - branch: string - direction: string - from_time: string - orgSlug: string - outputKind: OutputKind - page: number - perPage: number - repo: string - sort: string -}): Promise<void> { - const data = await fetchOrgFullScanList( - { - branch, - direction, - from_time, - orgSlug, - page, - perPage, - repo, - sort, - }, - { - commandPath: 'socket scan list', - }, - ) - - await outputListScans(data, outputKind) -} diff --git a/packages/cli/src/commands/scan/handle-scan-reach.mts b/packages/cli/src/commands/scan/handle-scan-reach.mts deleted file mode 100644 index 4e5e45b35..000000000 --- a/packages/cli/src/commands/scan/handle-scan-reach.mts +++ /dev/null @@ -1,113 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -const logger = getDefaultLogger() - -import { applyFullExcludePaths } from './exclude-paths.mts' -import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' -import { outputScanReach } from './output-scan-reach.mts' -import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' -import { findSocketYmlSync } from '../../util/config.mts' -import { getPackageFilesForScan } from '../../util/fs/path-resolve.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { ReachabilityOptions } from './perform-reachability-analysis.mts' -import type { OutputKind } from '../../types.mts' - -type HandleScanReachConfig = { - cwd: string - interactive: boolean - orgSlug: string - outputKind: OutputKind - outputPath: string - reachabilityOptions: ReachabilityOptions - targets: string[] -} - -export async function handleScanReach({ - cwd, - interactive: _interactive, - orgSlug, - outputKind, - outputPath, - reachabilityOptions, - targets, -}: HandleScanReachConfig) { - const spinner = getDefaultSpinner() - - // Get supported file names - const supportedFilesCResult = await fetchSupportedScanFileNames({ spinner }) - if (!supportedFilesCResult.ok) { - await outputScanReach(supportedFilesCResult, { - outputKind, - outputPath: '', - }) - return - } - - spinner.start( - 'Searching for local manifest files to include in reachability analysis...', - ) - - const supportedFiles = supportedFilesCResult.data - - // Load socket.yml so projectIgnorePaths is respected when collecting files. - const socketYmlResult = findSocketYmlSync(cwd) - const socketConfig = socketYmlResult.ok - ? socketYmlResult.data?.parsed - : undefined - - const { effectiveSocketConfig, mergedReachabilityOptions } = - applyFullExcludePaths({ - cwd, - reachabilityOptions, - socketConfig, - target: targets[0]!, - }) - - const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: effectiveSocketConfig, - cwd, - }) - - spinner.successAndStop( - `Found ${packagePaths.length} ${pluralize('manifest file', { count: packagePaths.length })} for reachability analysis.`, - ) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: packagePaths.length > 0, - fail: 'found no eligible files to analyze', - message: - 'TARGET (file/dir) must contain matching / supported file types for reachability analysis', - }) - if (!wasValidInput) { - return - } - - logger.success( - `Found ${packagePaths.length} local ${pluralize('file', { count: packagePaths.length })}`, - ) - - spinner.start('Running reachability analysis...') - - const result = await performReachabilityAnalysis({ - cwd, - orgSlug, - outputPath, - packagePaths, - reachabilityOptions: mergedReachabilityOptions, - spinner, - target: targets[0]!, - uploadManifests: true, - }) - - spinner.stop() - - const resolvedOutputPath = result.ok ? result.data.reachabilityReport : '' - await outputScanReach(result, { - outputKind, - outputPath: resolvedOutputPath || outputPath, - }) -} diff --git a/packages/cli/src/commands/scan/handle-scan-report.mts b/packages/cli/src/commands/scan/handle-scan-report.mts deleted file mode 100644 index c5a2ea908..000000000 --- a/packages/cli/src/commands/scan/handle-scan-report.mts +++ /dev/null @@ -1,42 +0,0 @@ -import { fetchScanData } from './fetch-report-data.mts' -import { outputScanReport } from './output-scan-report.mts' - -import type { FOLD_SETTING, REPORT_LEVEL } from './types.mts' -import type { OutputKind } from '../../types.mts' - -type HandleScanReportConfig = { - orgSlug: string - scanId: string - includeLicensePolicy: boolean - outputKind: OutputKind - filepath: string - fold: FOLD_SETTING - reportLevel: REPORT_LEVEL - short: boolean -} - -export async function handleScanReport({ - filepath, - fold, - includeLicensePolicy, - orgSlug, - outputKind, - reportLevel, - scanId, - short, -}: HandleScanReportConfig): Promise<void> { - const scanDataCResult = await fetchScanData(orgSlug, scanId, { - includeLicensePolicy, - }) - - await outputScanReport(scanDataCResult, { - filepath, - fold, - scanId: scanId, - includeLicensePolicy, - orgSlug, - outputKind, - reportLevel, - short, - }) -} diff --git a/packages/cli/src/commands/scan/output-create-new-scan.mts b/packages/cli/src/commands/scan/output-create-new-scan.mts deleted file mode 100644 index 43db918d8..000000000 --- a/packages/cli/src/commands/scan/output-create-new-scan.mts +++ /dev/null @@ -1,106 +0,0 @@ -import open from 'open' -import terminalLink from 'terminal-link' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' -import { confirm } from '@socketsecurity/lib-stable/stdio/prompts' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type CreateNewScanOptions = { - interactive?: boolean | undefined - outputKind?: OutputKind | undefined - spinner?: SpinnerInstance | undefined -} - -export async function outputCreateNewScan( - result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']>, - options?: CreateNewScanOptions | undefined, -) { - const { - interactive = false, - outputKind = 'text', - spinner = getDefaultSpinner(), - } = { __proto__: null, ...options } as CreateNewScanOptions - - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - const wasSpinning = !!spinner?.isSpinning - - spinner?.stop() - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - if (wasSpinning) { - spinner?.start() - } - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - if (wasSpinning) { - spinner?.start() - } - return - } - - if (!result.data.id) { - logger.fail('Did not receive a scan ID from the API.') - process.exitCode = 1 - } - - if (outputKind === 'markdown') { - logger.log(mdHeader('Create New Scan')) - logger.log('') - if (result.data.id) { - logger.log( - `A [new Scan](${result.data.html_report_url}) was created with ID: ${result.data.id}`, - ) - logger.log('') - } else { - logger.log( - 'The server did not return a Scan ID while trying to create a new Scan. This could be an indication something went wrong.', - ) - } - logger.log('') - if (wasSpinning) { - spinner?.start() - } - return - } - - logger.log('') - logger.success('Scan completed successfully!') - - const htmlReportUrl = result.data.html_report_url - if (htmlReportUrl) { - logger.log(`View report at: ${terminalLink(htmlReportUrl, htmlReportUrl)}`) - } else { - logger.log('No report available.') - } - - if ( - interactive && - htmlReportUrl && - (await confirm({ - message: 'Would you like to open it in your browser?', - default: false, - })) - ) { - await open(htmlReportUrl) - } - - if (wasSpinning) { - spinner?.start() - } -} diff --git a/packages/cli/src/commands/scan/output-delete-scan.mts b/packages/cli/src/commands/scan/output-delete-scan.mts deleted file mode 100644 index 5a783fe0a..000000000 --- a/packages/cli/src/commands/scan/output-delete-scan.mts +++ /dev/null @@ -1,28 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputDeleteScan( - result: CResult<SocketSdkSuccessResult<'deleteFullScan'>['data']>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.success('Scan deleted successfully') -} diff --git a/packages/cli/src/commands/scan/output-diff-scan.mts b/packages/cli/src/commands/scan/output-diff-scan.mts deleted file mode 100644 index 932d55026..000000000 --- a/packages/cli/src/commands/scan/output-diff-scan.mts +++ /dev/null @@ -1,228 +0,0 @@ -import { promises as fs } from 'node:fs' -import util from 'node:util' - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { fileLink } from '../../util/terminal/link.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function handleJson( - data: CResult<SocketSdkSuccessResult<'GetOrgDiffScan'>['data']>, - file: string, - dashboardMessage: string, -) { - const json = serializeResultJson(data) - - if (file && file !== '-') { - logger.log(`Writing json to \`${file}\``) - try { - await fs.writeFile(file, json, 'utf8') - logger.success(`Data successfully written to \`${fileLink(file)}\``) - } catch (e) { - logger.fail(`Writing to \`${file}\` failed...`) - logger.error(e) - process.exitCode = 1 - } - logger.info(dashboardMessage) - } else { - // only .log goes to stdout - logger.error('') - logger.info(' Diff scan result: ') - logger.error('') - logger.log(json) - logger.info(dashboardMessage) - } -} - -export async function handleMarkdown( - data: SocketSdkSuccessResult<'GetOrgDiffScan'>['data'], -) { - const SOCKET_SBOM_URL_PREFIX = `${SOCKET_WEBSITE_URL}/dashboard/org/SocketDev/sbom/` - - logger.log(mdHeader('Scan diff result')) - logger.log('') - logger.log('This Socket.dev report shows the changes between two scans:') - logger.log( - `- [${data.before.id}](${SOCKET_SBOM_URL_PREFIX}${data.before.id})`, - ) - logger.log(`- [${data.after.id}](${SOCKET_SBOM_URL_PREFIX}${data.after.id})`) - logger.log('') - logger.log( - `You can [view this report in your dashboard](${data.diff_report_url})`, - ) - logger.log('') - logger.log(mdHeader('Changes', 2)) - logger.log('') - logger.log(`- directDependenciesChanged: ${data.directDependenciesChanged}`) - logger.log(`- Added packages: ${data.artifacts.added.length}`) - - if (data.artifacts.added.length > 0) { - const addedHead = data.artifacts.added.slice(0, 10) - for (let i = 0, { length } = addedHead; i < length; i += 1) { - const artifact = addedHead[i]! - logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) - } - if (data.artifacts.added.length > 10) { - logger.log(` … and ${data.artifacts.added.length - 10} more`) - } - } - - logger.log(`- Removed packages: ${data.artifacts.removed.length}`) - if (data.artifacts.removed.length > 0) { - const removedHead = data.artifacts.removed.slice(0, 10) - for (let i = 0, { length } = removedHead; i < length; i += 1) { - const artifact = removedHead[i]! - logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) - } - if (data.artifacts.removed.length > 10) { - logger.log(` … and ${data.artifacts.removed.length - 10} more`) - } - } - - logger.log(`- Replaced packages: ${data.artifacts.replaced.length}`) - if (data.artifacts.replaced.length > 0) { - const replacedHead = data.artifacts.replaced.slice(0, 10) - for (let i = 0, { length } = replacedHead; i < length; i += 1) { - const artifact = replacedHead[i]! - logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) - } - if (data.artifacts.replaced.length > 10) { - logger.log(` … and ${data.artifacts.replaced.length - 10} more`) - } - } - - logger.log(`- Updated packages: ${data.artifacts.updated.length}`) - if (data.artifacts.updated.length > 0) { - const updatedHead = data.artifacts.updated.slice(0, 10) - for (let i = 0, { length } = updatedHead; i < length; i += 1) { - const artifact = updatedHead[i]! - logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) - } - if (data.artifacts.updated.length > 10) { - logger.log(` … and ${data.artifacts.updated.length - 10} more`) - } - } - - const unchanged = data.artifacts.unchanged ?? [] - logger.log(`- Unchanged packages: ${unchanged.length}`) - if (unchanged.length > 0) { - const firstUpToTen = unchanged.slice(0, 10) - for (let i = 0, { length } = firstUpToTen; i < length; i += 1) { - const artifact = firstUpToTen[i]! - logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) - } - if (unchanged.length > 10) { - logger.log(` … and ${unchanged.length - 10} more`) - } - } - - logger.log('') - logger.log(`## Scan ${data.before.id}`) - logger.log('') - logger.log( - 'This Scan was considered to be the "base" / "from" / "before" Scan.', - ) - logger.log('') - for (const { 0: key, 1: value } of Object.entries(data.before)) { - if (key === 'pull_request' && !value) { - continue - } - if (!['id', 'organization_id', 'repository_id'].includes(key)) { - logger.group( - `- ${key === 'repository_slug' ? 'repo' : key === 'organization_slug' ? 'org' : key}: ${value}`, - ) - logger.groupEnd() - } - } - - logger.log('') - logger.log(`## Scan ${data.after.id}`) - logger.log('') - logger.log('This Scan was considered to be the "head" / "to" / "after" Scan.') - logger.log('') - for (const { 0: key, 1: value } of Object.entries(data.after)) { - if (key === 'pull_request' && !value) { - continue - } - if (!['id', 'organization_id', 'repository_id'].includes(key)) { - logger.group( - `- ${key === 'repository_slug' ? 'repo' : key === 'organization_slug' ? 'org' : key}: ${value}`, - ) - logger.groupEnd() - } - } - - logger.log('') -} - -export async function outputDiffScan( - result: CResult<SocketSdkSuccessResult<'GetOrgDiffScan'>['data']>, - { - depth, - file, - outputKind, - }: { - depth: number - file: string - outputKind: OutputKind - }, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const dashboardUrl = result.data.diff_report_url - const dashboardMessage = dashboardUrl - ? `\n View this diff scan in the Socket dashboard: ${colors.cyan(dashboardUrl)}` - : '' - - // When forcing json, or dumping to file, serialize to string such that it - // won't get truncated. The only way to dump the full raw JSON to stdout is - // to use `--json --file -` (the dash is a standard notation for stdout) - if (outputKind === 'json' || file) { - await handleJson(result, file, dashboardMessage) - return - } - - if (outputKind === 'markdown') { - await handleMarkdown(result.data) - return - } - - // In this case neither the --json nor the --file flag was passed - // Dump the JSON to CLI and let NodeJS deal with truncation - - logger.log('Diff scan result:') - logger.log( - util.inspect(result.data, { - showHidden: false, - depth: depth > 0 ? depth : undefined, - colors: true, - maxArrayLength: undefined, - }), - ) - logger.error('') - logger.info( - ' 📝 To display the detailed report in the terminal, use the --json flag. For a friendlier report, use the --markdown flag.', - ) - logger.error('') - logger.info(dashboardMessage) -} diff --git a/packages/cli/src/commands/scan/output-list-scans.mts b/packages/cli/src/commands/scan/output-list-scans.mts deleted file mode 100644 index 0a938b018..000000000 --- a/packages/cli/src/commands/scan/output-list-scans.mts +++ /dev/null @@ -1,61 +0,0 @@ -import chalkTable from 'chalk-table' -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type ScanListItem = - SocketSdkSuccessResult<'listFullScans'>['data']['results'][number] - -export async function outputListScans( - result: CResult<SocketSdkSuccessResult<'listFullScans'>['data']>, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const options = { - columns: [ - { field: 'id', name: colors.magenta('ID') }, - { field: 'report_url', name: colors.magenta('Scan URL') }, - { field: 'repo', name: colors.magenta('Repo') }, - { field: 'branch', name: colors.magenta('Branch') }, - { field: 'created_at', name: colors.magenta('Created at') }, - ], - } - - const formattedResults = result.data.results.map((d: ScanListItem) => { - return { - id: d.id, - report_url: colors.underline(`${d.html_report_url}`), - created_at: d.created_at - ? new Date(d.created_at).toLocaleDateString('en-us', { - year: 'numeric', - month: 'numeric', - day: 'numeric', - timeZone: 'UTC', - }) - : '', - repo: d.repo, - branch: d.branch, - } - }) - - logger.log(chalkTable(options, formattedResults)) -} diff --git a/packages/cli/src/commands/scan/output-scan-config-result.mts b/packages/cli/src/commands/scan/output-scan-config-result.mts deleted file mode 100644 index 94a7dedd5..000000000 --- a/packages/cli/src/commands/scan/output-scan-config-result.mts +++ /dev/null @@ -1,21 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' - -import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputScanConfigResult(result: CResult<unknown>) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.log('') - logger.log('Finished') - logger.log('') -} diff --git a/packages/cli/src/commands/scan/output-scan-github.mts b/packages/cli/src/commands/scan/output-scan-github.mts deleted file mode 100644 index 9b16295ae..000000000 --- a/packages/cli/src/commands/scan/output-scan-github.mts +++ /dev/null @@ -1,25 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputScanGithub( - result: CResult<unknown>, - outputKind: OutputKind, -) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - logger.log('') - logger.success('Finished!') -} diff --git a/packages/cli/src/commands/scan/output-scan-metadata.mts b/packages/cli/src/commands/scan/output-scan-metadata.mts deleted file mode 100644 index a786450b6..000000000 --- a/packages/cli/src/commands/scan/output-scan-metadata.mts +++ /dev/null @@ -1,76 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdHeader, mdKeyValue } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -export async function outputScanMetadata( - result: CResult<SocketSdkSuccessResult<'getFullScanMetadata'>['data']>, - scanId: string, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (outputKind === 'markdown') { - logger.log(`${mdHeader('Scan meta data')}`) - logger.log('') - logger.log(`${mdKeyValue('Scan ID', scanId)}`) - logger.log('') - for (const { 0: key, 1: value } of Object.entries(result.data)) { - if ( - [ - 'id', - 'updated_at', - 'organization_id', - 'repository_id', - 'commit_hash', - 'html_report_url', - ].includes(key) - ) { - continue - } - logger.log(`- ${key}: ${value}`) - } - logger.log( - `\nYou can view this report at: [${result.data.html_report_url}](${result.data.html_report_url})\n`, - ) - } else { - logger.log(`Scan ID: ${scanId}`) - logger.log('') - for (const { 0: key, 1: value } of Object.entries(result.data)) { - if ( - [ - 'id', - 'updated_at', - 'organization_id', - 'repository_id', - 'commit_hash', - 'html_report_url', - ].includes(key) - ) { - continue - } - logger.log(`- ${key}:`, value) - } - logger.log( - `\nYou can view this report at: ${result.data.html_report_url}]\n`, - ) - } -} diff --git a/packages/cli/src/commands/scan/output-scan-reach.mts b/packages/cli/src/commands/scan/output-scan-reach.mts deleted file mode 100644 index 0b35ae7d6..000000000 --- a/packages/cli/src/commands/scan/output-scan-reach.mts +++ /dev/null @@ -1,35 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { DOT_SOCKET_DOT_FACTS_JSON } from '../../constants/paths.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { ReachabilityAnalysisResult } from './perform-reachability-analysis.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputScanReach( - result: CResult<ReachabilityAnalysisResult>, - { outputKind, outputPath }: { outputKind: OutputKind; outputPath: string }, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const actualOutputPath = outputPath?.trim() - ? outputPath - : DOT_SOCKET_DOT_FACTS_JSON - - logger.log('') - logger.success('Reachability analysis completed successfully!') - logger.info(`Reachability report has been written to: ${actualOutputPath}`) -} diff --git a/packages/cli/src/commands/scan/output-scan-report.mts b/packages/cli/src/commands/scan/output-scan-report.mts deleted file mode 100644 index bc82f717d..000000000 --- a/packages/cli/src/commands/scan/output-scan-report.mts +++ /dev/null @@ -1,241 +0,0 @@ -import fs from 'node:fs/promises' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' - -import { generateReport } from './generate-report.mts' -import { - FOLD_SETTING_NONE, - OUTPUT_JSON, - OUTPUT_TEXT, -} from '../../constants/cli.mts' -import { REPORT_LEVEL_DEFER } from '../../constants/reporting.mts' -import { mapToObject } from '../../util/data/map-to-object.mjs' -import { walkNestedMap } from '../../util/data/walk-nested-map.mjs' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTable } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' - -import type { ReportLeafNode, ScanReport } from './generate-report.mts' -import type { FOLD_SETTING, REPORT_LEVEL } from './types.mts' -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -type OutputScanReportConfig = { - orgSlug: string - scanId: string - includeLicensePolicy: boolean - outputKind: OutputKind - filepath: string - fold: FOLD_SETTING - reportLevel: REPORT_LEVEL - short: boolean -} - -export async function outputScanReport( - result: CResult<{ - scan: SocketArtifact[] - securityPolicy: SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - }>, - { - filepath, - fold, - includeLicensePolicy, - orgSlug, - outputKind, - reportLevel, - scanId, - short, - }: OutputScanReportConfig, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const spinner = getDefaultSpinner()! - const scanReport = generateReport( - result.data.scan, - result.data.securityPolicy, - { - orgSlug, - scanId, - fold, - reportLevel, - short, - spinner, - }, - ) - - if (!scanReport.ok) { - // Note: This means generation failed, it does not reflect the healthy state. - process.exitCode = scanReport.code ?? 1 - - // If report generation somehow failed then .data should not be set. - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(scanReport)) - return - } - logger.fail(failMsgWithBadge(scanReport.message, scanReport.cause)) - return - } - - if (!scanReport.data.healthy) { - // When report contains healthy: false, process should exit with non-zero code. - process.exitCode = 1 - } - - // I don't think we emit the default error message with banner for an unhealthy report, do we? - // if (!scanReport.data.healthy) { - // logger.fail(failMsgWithBadge(scanReport.message, scanReport.cause)) - // return - // } - - if ( - outputKind === OUTPUT_JSON || - (outputKind === OUTPUT_TEXT && filepath && filepath.endsWith('.json')) - ) { - const json = short - ? serializeResultJson(scanReport) - : toJsonReport(scanReport.data as ScanReport, includeLicensePolicy) - - if (filepath && filepath !== '-') { - logger.error('Writing json report to', filepath) - return await fs.writeFile(filepath, json) - } - - logger.log(json) - return - } - - if (outputKind === 'markdown' || filepath?.endsWith('.md')) { - const md = short - ? `healthy = ${scanReport.data.healthy}` - : toMarkdownReport( - // Not short so must be a regular report. - scanReport.data as ScanReport, - includeLicensePolicy, - ) - - if (filepath && filepath !== '-') { - logger.error('Writing markdown report to', filepath) - return await fs.writeFile(filepath, md) - } - - logger.log(md) - logger.log('') - return - } - - if (short) { - logger.log(scanReport.data.healthy ? 'OK' : 'ERR') - } else { - logger.dir(scanReport.data, { depth: undefined }) - } -} - -export function toJsonReport( - report: ScanReport, - includeLicensePolicy?: boolean | undefined, -): string { - const obj = mapToObject(report.alerts) - - const newReport = { - includeLicensePolicy, - ...report, - alerts: obj, - } - - return serializeResultJson({ - ok: true, - data: newReport, - }) -} - -export function toMarkdownReport( - report: ScanReport, - includeLicensePolicy?: boolean | undefined, -): string { - const reportLevel = report.options.reportLevel - - const alertFolding = - report.options.fold === FOLD_SETTING_NONE - ? 'none' - : `up to ${report.options.fold}` - - const flatData = Array.from(walkNestedMap(report.alerts)).map( - ({ keys, value }: { keys: string[]; value: ReportLeafNode }) => { - const { manifest, policy, type, url } = value - return { - 'Alert Type': type, - Package: keys[1] || '<unknown>', - 'Introduced by': keys[2] || '<unknown>', - url, - 'Manifest file': joinAnd(manifest), - Policy: policy, - } - }, - ) - - const minPolicyLevel = - reportLevel === REPORT_LEVEL_DEFER ? 'everything' : reportLevel - - const md = `${` -# Scan Policy Report - -This report tells you whether the results of a Socket scan results violate the -security${includeLicensePolicy ? ' or license' : ''} policy set by your organization. - -## Health status - -${ - report.healthy - ? `The scan *PASSES* all requirements set by your security${includeLicensePolicy ? ' and license' : ''} policy.` - : 'The scan *VIOLATES* one or more policies set to the "error" level.' -} - -## Settings - -Configuration used to generate this report: - -- Organization: ${report.orgSlug} -- Scan ID: ${report.scanId} -- Alert folding: ${alertFolding} -- Minimal policy level for alert to be included in report: ${minPolicyLevel} -- Include license alerts: ${includeLicensePolicy ? 'yes' : 'no'} - -## Alerts - -${ - report.alerts.size - ? `All the alerts from the scan with a policy set to at least "${reportLevel}".` - : `The scan contained no alerts with a policy set to at least "${reportLevel}".` -} - -${ - !report.alerts.size - ? '' - : mdTable(flatData, [ - 'Policy', - 'Alert Type', - 'Package', - 'Introduced by', - 'url', - 'Manifest file', - ]) -} - `.trim()}\n` - - return md -} diff --git a/packages/cli/src/commands/scan/output-scan-view.mts b/packages/cli/src/commands/scan/output-scan-view.mts deleted file mode 100644 index 9a29f8b9c..000000000 --- a/packages/cli/src/commands/scan/output-scan-view.mts +++ /dev/null @@ -1,113 +0,0 @@ -import fs from 'node:fs/promises' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mts' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTable } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { fileLink } from '../../util/terminal/link.mts' - -import type { CResult, OutputKind } from '../../types.mts' -import type { SocketArtifact } from '../../util/alert/artifact.mts' -const logger = getDefaultLogger() - -export async function outputScanView( - result: CResult<SocketArtifact[]>, - orgSlug: string, - scanId: string, - filePath: string, - outputKind: OutputKind, -): Promise<void> { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (!result.ok) { - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if ( - outputKind === 'json' || - (outputKind === 'text' && filePath && filePath.endsWith('.json')) - ) { - const json = serializeResultJson(result) - - if (filePath && filePath !== '-') { - logger.info('Writing json results to', filePath) - try { - await fs.writeFile(filePath, json, 'utf8') - logger.info(`Data successfully written to ${fileLink(filePath)}`) - } catch (e) { - process.exitCode = 1 - logger.fail('There was an error trying to write the markdown to disk') - logger.error(e) - logger.log( - serializeResultJson({ - ok: false, - message: 'File Write Failure', - cause: 'Failed to write json to disk', - }), - ) - } - return - } - - logger.log(json) - return - } - - const display = result.data.map(art => { - const author = - Array.isArray(art.author) && art.author.length > 0 - ? `${art.author[0]}${art.author.length > 1 ? ' et.al.' : ''}` - : Array.isArray(art.author) - ? '' - : art.author - return { - type: art.type, - name: art.name, - version: art.version, - author, - score: JSON.stringify(art.score), - } - }) - - const md = mdTable(display as Array<Record<string, string>>, [ - 'type', - 'version', - 'name', - 'author', - 'score', - ]) - - const report = `${` -# Scan Details - -These are the artifacts and their scores found. - -Scan ID: ${scanId} - -${md} - -View this report at: ${SOCKET_WEBSITE_URL}/dashboard/org/${orgSlug}/sbom/${scanId} - `.trim()}\n` - - if (filePath && filePath !== '-') { - try { - await fs.writeFile(filePath, report, 'utf8') - logger.log(`Data successfully written to ${fileLink(filePath)}`) - } catch (e) { - process.exitCode = 1 - logger.fail('There was an error trying to write the markdown to disk') - logger.error(e) - } - } else { - logger.log(report) - } -} diff --git a/packages/cli/src/commands/scan/perform-reachability-analysis.mts b/packages/cli/src/commands/scan/perform-reachability-analysis.mts deleted file mode 100644 index 6cd821e70..000000000 --- a/packages/cli/src/commands/scan/perform-reachability-analysis.mts +++ /dev/null @@ -1,290 +0,0 @@ -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { HTTP_STATUS_UNAUTHORIZED } from '../../constants/http.mts' -import { DOT_SOCKET_DOT_FACTS_JSON } from '../../constants/paths.mts' -import { - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, -} from '../../constants/socket.mts' -import { extractTier1ReachabilityScanId } from '../../util/coana/extract-scan-id.mjs' -import { spawnCoanaDlx } from '../../util/dlx/spawn.mjs' -import { getMachineOutputMode } from '../../util/output/ambient-mode.mts' -import { hasEnterpriseOrgPlan } from '../../util/organization.mts' -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' -import { socketDevLink } from '../../util/terminal/link.mts' -import { fetchOrganization } from '../organization/fetch-organization-list.mts' - -import type { CResult } from '../../types.mts' -import type { PURL_Type } from '../../util/ecosystem/types.mjs' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -export type ReachabilityOptions = { - excludePaths: string[] - reachAnalysisMemoryLimit: number - reachAnalysisTimeout: number - reachConcurrency: number - reachDebug: boolean - reachDetailedAnalysisLogFile: boolean - reachDisableAnalytics: boolean - reachDisableExternalToolChecks: boolean - reachEnableAnalysisSplitting: boolean - reachEcosystems: PURL_Type[] - reachExcludePaths: string[] - reachLazyMode: boolean - reachMinSeverity: string - reachSkipCache: boolean - reachUseOnlyPregeneratedSboms: boolean - reachUseUnreachableFromPrecomputation: boolean - reachVersion: string | undefined -} - -type ReachabilityAnalysisOptions = { - branchName?: string | undefined - cwd?: string | undefined - orgSlug?: string | undefined - outputPath?: string | undefined - packagePaths?: string[] | undefined - reachabilityOptions: ReachabilityOptions - repoName?: string | undefined - spinner?: SpinnerInstance | undefined - target: string - uploadManifests?: boolean | undefined -} - -export type ReachabilityAnalysisResult = { - reachabilityReport: string - tier1ReachabilityScanId: string | undefined -} - -export async function performReachabilityAnalysis( - options?: ReachabilityAnalysisOptions | undefined, -): Promise<CResult<ReachabilityAnalysisResult>> { - const { - branchName, - cwd = process.cwd(), - orgSlug, - outputPath, - packagePaths, - reachabilityOptions, - repoName, - spinner, - target, - uploadManifests = true, - } = { __proto__: null, ...options } as ReachabilityAnalysisOptions - - // Determine the analysis target - make it relative to cwd if absolute. - let analysisTarget = target - if (path.isAbsolute(analysisTarget)) { - analysisTarget = path.relative(cwd, analysisTarget) || '.' - } - - // Check if user has enterprise plan for reachability analysis. - const orgsCResult = await fetchOrganization() - if (!orgsCResult.ok) { - const httpCode = ( - orgsCResult as { data?: { code?: number | undefined } | undefined } - ).data?.code - if (httpCode === HTTP_STATUS_UNAUTHORIZED) { - return { - ok: false, - message: 'Authentication failed', - cause: - 'Your API token appears to be invalid, expired, or revoked. Please check your token and try again.', - } - } - return { - ok: false, - message: 'Unable to verify plan permissions', - cause: - 'Failed to fetch organization information to verify enterprise plan access', - } - } - - const { organizations } = orgsCResult.data - - if (!hasEnterpriseOrgPlan(organizations)) { - return { - ok: false, - message: 'Tier 1 Reachability analysis requires an enterprise plan', - cause: `Please ${socketDevLink('upgrade your plan', '/pricing')}. This feature is only available for organizations with an enterprise plan.`, - } - } - - const wasSpinning = !!spinner?.isSpinning - - let tarHash: string | undefined - - if (orgSlug && packagePaths && uploadManifests) { - // Setup SDK for uploading manifests - const sockSdkCResult = await setupSdk() - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - - const sockSdk = sockSdkCResult.data - - // Exclude any .socket.facts.json files that happen to be in the scan - // folder before the analysis was run. - const filepathsToUpload = packagePaths.filter( - p => path.basename(p).toLowerCase() !== DOT_SOCKET_DOT_FACTS_JSON, - ) - - spinner?.start('Uploading manifests for reachability analysis...') - - // Ensure uploaded manifest files are relative to analysis target as coana resolves SBOM manifest files relative to this path. - const uploadCResult = (await handleApiCall( - sockSdk.uploadManifestFiles(orgSlug, filepathsToUpload, { - pathsRelativeTo: path.resolve(cwd, analysisTarget), - }), - { - description: 'upload manifests', - spinner, - }, - )) as CResult<{ tarHash?: string | undefined }> - - spinner?.stop() - - if (!uploadCResult.ok) { - /* c8 ignore start - wasSpinning only set when caller passes a running spinner; unit tests pass undefined */ - if (wasSpinning) { - spinner?.start() - } - /* c8 ignore stop */ - return uploadCResult - } - - tarHash = (uploadCResult.data as { tarHash?: string | undefined })?.tarHash - if (!tarHash) { - /* c8 ignore start - wasSpinning only set when caller passes a running spinner; unit tests pass undefined */ - if (wasSpinning) { - spinner?.start() - } - /* c8 ignore stop */ - return { - ok: false, - message: 'Failed to get manifest tar hash', - cause: 'Server did not return a tar hash for the uploaded manifests', - } - } - - spinner?.start() - spinner?.success(`Manifests uploaded successfully. Tar hash: ${tarHash}`) - } - - spinner?.start() - spinner?.infoAndStop('Running reachability analysis with Coana...') - - const outputFilePath = outputPath?.trim() - ? outputPath - : DOT_SOCKET_DOT_FACTS_JSON - // Build Coana arguments. - // Under machine-output mode, --silent suppresses coana's Winston - // logger entirely; the report still lands in --socket-mode's file. - const machineMode = getMachineOutputMode() - const coanaArgs = [ - ...(machineMode ? ['--silent'] : []), - 'run', - analysisTarget, - '--output-dir', - path.dirname(outputFilePath), - '--socket-mode', - outputFilePath, - '--disable-report-submission', - ...(reachabilityOptions.reachAnalysisTimeout - ? ['--analysis-timeout', `${reachabilityOptions.reachAnalysisTimeout}`] - : []), - ...(reachabilityOptions.reachAnalysisMemoryLimit - ? ['--memory-limit', `${reachabilityOptions.reachAnalysisMemoryLimit}`] - : []), - ...(reachabilityOptions.reachConcurrency - ? ['--concurrency', `${reachabilityOptions.reachConcurrency}`] - : []), - ...(reachabilityOptions.reachDebug ? ['--debug'] : []), - ...(reachabilityOptions.reachDetailedAnalysisLogFile - ? ['--detailed-analysis-log-file'] - : []), - ...(reachabilityOptions.reachDisableAnalytics - ? ['--disable-analytics-sharing'] - : []), - ...(reachabilityOptions.reachDisableExternalToolChecks - ? ['--disable-external-tool-checks'] - : []), - // Analysis splitting is disabled by default; only skip the flag when explicitly enabled. - ...(reachabilityOptions.reachEnableAnalysisSplitting - ? [] - : ['--disable-analysis-splitting']), - ...(tarHash - ? ['--run-without-docker', '--manifests-tar-hash', tarHash] - : []), - // Empty reachEcosystems implies scanning all ecosystems. - ...(reachabilityOptions.reachEcosystems.length - ? ['--purl-types', ...reachabilityOptions.reachEcosystems] - : []), - ...(reachabilityOptions.reachExcludePaths.length - ? ['--exclude-dirs', ...reachabilityOptions.reachExcludePaths] - : []), - ...(reachabilityOptions.reachLazyMode ? ['--lazy-mode'] : []), - ...(reachabilityOptions.reachMinSeverity - ? ['--min-severity', reachabilityOptions.reachMinSeverity] - : []), - ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), - ...(reachabilityOptions.reachUseOnlyPregeneratedSboms - ? ['--use-only-pregenerated-sboms'] - : []), - ...(reachabilityOptions.reachUseUnreachableFromPrecomputation - ? ['--use-unreachable-from-precomputation'] - : []), - ] - - // Build environment variables. - const coanaEnv: Record<string, string> = {} - // do not pass default repo and branch name to coana to avoid mixing - // buckets (cached configuration) from projects that are likely very different. - if (repoName && repoName !== SOCKET_DEFAULT_REPOSITORY) { - coanaEnv['SOCKET_REPO_NAME'] = repoName - } - if (branchName && branchName !== SOCKET_DEFAULT_BRANCH) { - coanaEnv['SOCKET_BRANCH_NAME'] = branchName - } - - // Run Coana with the manifests tar hash. Under machine mode we drop - // coana stdout; --silent plus 'ignore' ensures our own stdout stays - // pipe-safe for --json consumers. - const coanaResult = await spawnCoanaDlx(coanaArgs, orgSlug, { - coanaVersion: reachabilityOptions.reachVersion || undefined, - cwd, - env: coanaEnv, - spinner, - stdio: machineMode ? 'ignore' : 'inherit', - }) - - /* c8 ignore start - wasSpinning only set when caller passes a running spinner; unit tests pass undefined */ - if (wasSpinning) { - spinner?.start() - } - /* c8 ignore stop */ - - if (!coanaResult.ok) { - const logger = getDefaultLogger() - logger.error('Reachability analysis failed') - logger.error(` target: ${analysisTarget}, cwd: ${cwd}`) - if (coanaResult.message) { - logger.error(` ${coanaResult.message}`) - } - } - - return coanaResult.ok - ? { - ok: true, - data: { - // Use the actual output filename for the scan. - reachabilityReport: outputFilePath, - tier1ReachabilityScanId: - extractTier1ReachabilityScanId(outputFilePath), - }, - } - : coanaResult -} diff --git a/packages/cli/src/commands/scan/reachability-flags.mts b/packages/cli/src/commands/scan/reachability-flags.mts deleted file mode 100644 index 09c3d1804..000000000 --- a/packages/cli/src/commands/scan/reachability-flags.mts +++ /dev/null @@ -1,117 +0,0 @@ -import type { MeowFlags } from '../../flags.mts' - -export const excludePathsFlag: MeowFlags = { - excludePaths: { - type: 'string', - isMultiple: true, - description: - 'List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', - }, -} - -export const reachabilityFlags: MeowFlags = { - reachAnalysisMemoryLimit: { - type: 'number', - default: 8192, - description: - 'The maximum memory in MB to use for the reachability analysis. The default is 8192MB.', - }, - reachAnalysisTimeout: { - type: 'number', - default: 0, - description: - 'Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly.', - }, - reachConcurrency: { - type: 'number', - default: 1, - description: - 'Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. NPM reachability analysis does not support concurrent execution, so the concurrency level is ignored for NPM.', - }, - reachDisableExternalToolChecks: { - type: 'boolean', - default: false, - description: 'Disable external tool checks during reachability analysis.', - hidden: true, - }, - reachDebug: { - type: 'boolean', - default: false, - description: - 'Enable debug mode for reachability analysis. Provides verbose logging from the reachability CLI.', - }, - reachDisableAnalytics: { - type: 'boolean', - default: false, - description: - 'Disable reachability analytics sharing with Socket. Also disables caching-based optimizations.', - }, - reachDetailedAnalysisLogFile: { - type: 'boolean', - default: false, - description: 'Write a detailed analysis log file alongside the output.', - hidden: true, - }, - reachDisableAnalysisSplitting: { - type: 'boolean', - default: false, - description: - 'Deprecated: Analysis splitting is now disabled by default. Use --reach-enable-analysis-splitting to enable it.', - hidden: true, - }, - reachEnableAnalysisSplitting: { - type: 'boolean', - default: false, - description: - 'Enable analysis splitting, allowing Coana to split reachability analysis into multiple runs per workspace.', - }, - reachEcosystems: { - type: 'string', - isMultiple: true, - description: - 'List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems.', - }, - reachExcludePaths: { - type: 'string', - isMultiple: true, - description: - 'List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags.', - }, - reachLazyMode: { - type: 'boolean', - default: false, - description: 'Enable lazy mode for reachability analysis.', - hidden: true, - }, - reachMinSeverity: { - type: 'string', - default: '', - description: - 'Set the minimum severity of vulnerabilities to analyze. Supported severities are info, low, moderate, high and critical.', - }, - reachSkipCache: { - type: 'boolean', - default: false, - description: - 'Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis.', - }, - reachUseOnlyPregeneratedSboms: { - type: 'boolean', - default: false, - description: - 'When using this option, the scan is created based only on pre-generated CDX and SPDX files in your project.', - }, - reachUseUnreachableFromPrecomputation: { - type: 'boolean', - default: false, - description: - 'Use unreachable information from precomputation to improve analysis accuracy.', - }, - reachVersion: { - type: 'string', - default: '', - description: - 'Override the default @coana-tech/cli version used for reachability analysis.', - hidden: true, - }, -} diff --git a/packages/cli/src/commands/scan/setup-scan-config.mts b/packages/cli/src/commands/scan/setup-scan-config.mts deleted file mode 100644 index dafb6bc67..000000000 --- a/packages/cli/src/commands/scan/setup-scan-config.mts +++ /dev/null @@ -1,379 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { getGithubApiUrl } from '@socketsecurity/lib-stable/env/github' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { input, select } from '@socketsecurity/lib-stable/stdio/prompts' - -import { SOCKET_JSON } from '../../constants/paths.mts' -import { - detectDefaultBranch, - getRepoName, - getRepoOwner, - gitBranch, -} from '../../util/git/operations.mjs' -import { readSocketJsonSync, writeSocketJson } from '../../util/socket/json.mts' - -import type { CResult } from '../../types.mts' -import type { SocketJson } from '../../util/socket/json.mts' -const logger = getDefaultLogger() - -export function canceledByUser(): CResult<{ canceled: boolean }> { - logger.log('') - logger.info('User canceled') - logger.log('') - return { ok: true, data: { canceled: true } } -} - -export async function configureGithub( - config: NonNullable< - NonNullable<NonNullable<SocketJson['defaults']>['scan']>['github'] - >, -): Promise<CResult<{ canceled: boolean }>> { - // Do not store the GitHub API token. Just leads to a security rabbit hole. - - const all = await select({ - message: - '(--all) Do you by default want to fetch all repos from the GitHub API and scan all known repos?', - choices: [ - { - name: 'no', - value: 'no', - description: 'Fetch repos if not given and ask which repo to run on', - }, - { - name: 'yes', - value: 'yes', - description: 'Run on all remote repos by default', - }, - { - name: '(leave default)', - value: '', - description: 'Do not store a setting for this', - }, - ], - default: config.all === true ? 'yes' : config.all === false ? 'no' : '', - }) - if (all === undefined) { - return canceledByUser() - } - if (all === 'yes') { - config.all = true - } else if (all === 'no') { - config.all = false - } else { - delete config.all - } - - if (!all) { - const defaultRepos = await input({ - message: - '(--repos) Please enter the default repos to run this on, leave empty (backspace) to fetch from GitHub and ask interactive', - default: config.repos, - required: false, - // validate: async string => bool - // eslint-disable-next-line typescript-eslint/no-explicit-any -- @inquirer/prompts InputConfig schema is narrower than runtime input; cast satisfies it. - } as any) - if (defaultRepos === undefined) { - return canceledByUser() - } - if (defaultRepos) { - config.repos = defaultRepos - /* c8 ignore start - interactive prompt returning empty-but-defined string requires raw inquirer mock setup not provided by unit tests */ - } else { - delete config.repos - } - /* c8 ignore stop */ - } - - const defaultGithubApiUrl = await input({ - message: - '(--github-api-url) Do you want to override the default github url?', - - default: config.githubApiUrl || getGithubApiUrl() || '', - required: false, - // validate: async string => bool - }) - /* c8 ignore start - interactive prompt cancellation (undefined return) requires raw inquirer mock setup not provided by unit tests */ - if (defaultGithubApiUrl === undefined) { - return canceledByUser() - } - /* c8 ignore stop */ - if (defaultGithubApiUrl && defaultGithubApiUrl !== getGithubApiUrl()) { - config.githubApiUrl = defaultGithubApiUrl - } else { - delete config.githubApiUrl - } - - const defaultOrgGithub = await input({ - message: - '(--org-github) Do you want to change the org slug that is used when talking to the GitHub API? Defaults to your Socket org slug.', - default: config.orgGithub || '', - required: false, - // validate: async string => bool - }) - if (defaultOrgGithub === undefined) { - return canceledByUser() - } - if (defaultOrgGithub) { - config.orgGithub = defaultOrgGithub - } else { - delete config.orgGithub - } - - return notCanceled() -} - -export async function configureScan( - config: NonNullable< - NonNullable<NonNullable<SocketJson['defaults']>['scan']>['create'] - >, - cwd = process.cwd(), -): Promise<CResult<{ canceled: boolean }>> { - const defaultRepoName = await input({ - message: - '(--repo) What repo name (slug) should be reported to Socket for this dir?', - default: config.repo || (await getRepoName(cwd)), - required: false, - // validate: async string => bool - }) - if (defaultRepoName === undefined) { - return canceledByUser() - } - if (defaultRepoName) { - // Store it even if it's constants.SOCKET_DEFAULT_REPOSITORY because if we - // change this default then an existing user probably would not expect the change. - config.repo = defaultRepoName - } else { - delete config.repo - } - - const defaultWorkspace = await input({ - message: - '(--workspace) The workspace in the Socket Organization that the repository is in to associate with the full scan.', - default: config.workspace || (await getRepoOwner(cwd)) || '', - required: false, - }) - if (defaultWorkspace === undefined) { - return canceledByUser() - } - if (defaultWorkspace) { - config.workspace = defaultWorkspace - } else { - delete config.workspace - } - - const defaultBranchName = await input({ - message: - '(--branch) What branch name (slug) should be reported to Socket for this dir?', - default: - config.branch || - (await gitBranch(cwd)) || - (await detectDefaultBranch(cwd)), - required: false, - // validate: async string => bool - }) - if (defaultBranchName === undefined) { - return canceledByUser() - } - if (defaultBranchName) { - // Store it even if it's constants.SOCKET_DEFAULT_BRANCH because if we change - // this default then an existing user probably would not expect the change. - config.branch = defaultBranchName - } else { - delete config.branch - } - - const autoManifest = await select({ - message: - '(--auto-manifest) Do you want to run `socket manifest auto` before creating a scan? You would need this for sbt, gradle, etc.', - choices: [ - { - name: 'no', - value: 'no', - description: 'Do not generate local manifest files', - }, - { - name: 'yes', - value: 'yes', - description: - 'Locally generate manifest files for languages like gradle, sbt, and conda (see `socket manifest auto`), before creating a scan', - }, - { - name: '(leave default)', - value: '', - description: 'Do not store a setting for this', - }, - ], - default: - config.autoManifest === true - ? 'yes' - : config.autoManifest === false - ? 'no' - : '', - }) - if (autoManifest === undefined) { - return canceledByUser() - } - if (autoManifest === 'yes') { - config.autoManifest = true - } else if (autoManifest === 'no') { - config.autoManifest = false - } else { - delete config.autoManifest - } - - const alwaysReport = await select({ - message: '(--report) Do you want to enable --report by default?', - choices: [ - { - name: 'no', - value: 'no', - description: 'Do not wait for Scan result and report by default', - }, - { - name: 'yes', - value: 'yes', - description: - 'After submitting a Scan request, wait for scan to complete, then show a report (like --report would)', - }, - { - name: '(leave default)', - value: '', - description: 'Do not store a setting for this', - }, - ], - default: - config.report === true ? 'yes' : config.report === false ? 'no' : '', - }) - if (alwaysReport === undefined) { - return canceledByUser() - } - if (alwaysReport === 'yes') { - config.report = true - } else if (alwaysReport === 'no') { - config.report = false - } else { - delete config.report - } - - return notCanceled() -} - -export function notCanceled(): CResult<{ canceled: boolean }> { - return { ok: true, data: { canceled: false } } -} - -export async function setupScanConfig( - cwd: string, - defaultOnReadError = false, -): Promise<CResult<unknown>> { - const jsonPath = path.join(cwd, SOCKET_JSON) - if (existsSync(jsonPath)) { - logger.info(`Found ${SOCKET_JSON} at ${jsonPath}`) - } else { - logger.info(`No ${SOCKET_JSON} found at ${cwd}, will generate a new one`) - } - - logger.log('') - logger.log( - 'Note: This tool will set up flag and argument defaults for certain', - ) - logger.log(' CLI commands. You can still override them by explicitly') - logger.log(' setting the flag. It is meant to be a convenience tool.') - logger.log('') - logger.log( - `This command will generate a \`${SOCKET_JSON}\` file in the target cwd.`, - ) - logger.log('You can choose to add this file to your repo (handy for collab)') - logger.log('or to add it to the ignored files, or neither. This file is only') - logger.log('used in CLI workflows.') - logger.log('') - logger.log('Note: For details on a flag you can run `socket <cmd> --help`') - logger.log('') - - const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) - if (!sockJsonCResult.ok) { - return sockJsonCResult - } - - const sockJson = sockJsonCResult.data - if (!sockJson.defaults) { - sockJson.defaults = {} - } - if (!sockJson.defaults.scan) { - sockJson.defaults.scan = {} - } - - const targetCommand = await select({ - message: 'Which scan command do you want to configure?', - choices: [ - { - name: 'socket scan create', - value: 'create', - }, - { - name: 'socket scan github', - value: 'github', - }, - { - name: '(cancel)', - value: '', - description: 'Exit configurator, make no changes', - }, - ], - }) - switch (targetCommand) { - case 'create': { - if (!sockJson.defaults.scan.create) { - sockJson.defaults.scan.create = {} - } - const result = await configureScan(sockJson.defaults.scan.create, cwd) - if (!result.ok || result.data.canceled) { - return result - } - break - } - case 'github': { - if (!sockJson.defaults.scan.github) { - sockJson.defaults.scan.github = {} - } - const result = await configureGithub(sockJson.defaults.scan.github) - /* c8 ignore start - github sub-configuration cancellation requires nested inquirer mock setup not provided by unit tests */ - if (!result.ok || result.data.canceled) { - return result - } - /* c8 ignore stop */ - break - } - default: { - return canceledByUser() - } - } - - logger.log('') - logger.log(`Setup complete. Writing ${SOCKET_JSON}`) - logger.log('') - - if ( - await select({ - message: `Do you want to write the new config to ${jsonPath} ?`, - choices: [ - { - name: 'yes', - value: true, - description: 'Update config', - }, - { - name: 'no', - value: false, - description: 'Do not update the config', - }, - ], - }) - ) { - return await writeSocketJson(cwd, sockJson) - } - - return canceledByUser() -} diff --git a/packages/cli/src/commands/scan/stream-scan.mts b/packages/cli/src/commands/scan/stream-scan.mts deleted file mode 100644 index a8307c2b6..000000000 --- a/packages/cli/src/commands/scan/stream-scan.mts +++ /dev/null @@ -1,43 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { handleApiCall } from '../../util/socket/api.mjs' -import { setupSdk } from '../../util/socket/sdk.mjs' - -import type { SetupSdkOptions } from '../../util/socket/sdk.mjs' - -const logger = getDefaultLogger() - -type StreamScanOptions = { - commandPath?: string | undefined - file?: string | undefined - sdkOpts?: SetupSdkOptions | undefined -} - -export async function streamScan( - orgSlug: string, - scanId: string, - options?: StreamScanOptions | undefined, -) { - const { commandPath, file, sdkOpts } = { - __proto__: null, - ...options, - } as StreamScanOptions - const sockSdkCResult = await setupSdk(sdkOpts) - if (!sockSdkCResult.ok) { - return sockSdkCResult - } - const sockSdk = sockSdkCResult.data - - logger.info('Requesting data from API...') - - // Note: This will write to stdout or target file. It is not a noop. - return await handleApiCall<'getOrgFullScan'>( - sockSdk.streamFullScan(orgSlug, scanId, { - output: file === '-' ? undefined : file, - }), - { - commandPath, - description: 'a scan', - }, - ) -} diff --git a/packages/cli/src/commands/scan/suggest-org-slug.mts b/packages/cli/src/commands/scan/suggest-org-slug.mts deleted file mode 100644 index f99929cbe..000000000 --- a/packages/cli/src/commands/scan/suggest-org-slug.mts +++ /dev/null @@ -1,44 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { select } from '@socketsecurity/lib-stable/stdio/prompts' - -import { fetchOrganization } from '../organization/fetch-organization-list.mts' - -export async function suggestOrgSlug(): Promise<string | undefined> { - const orgsCResult = await fetchOrganization() - if (!orgsCResult.ok) { - const logger = getDefaultLogger() - logger.fail( - 'Failed to lookup organization list from API, unable to suggest', - ) - return undefined - } - - const { organizations } = orgsCResult.data - const proceed = await select({ - message: - 'Missing org name; do you want to use any of these orgs for this scan?', - choices: [ - ...organizations.map(o => { - // Display the human-readable name but route with the slug — - // display names may contain spaces that break API URLs. - const display = o.name ?? o.slug - return { - name: `Yes [${display}]`, - value: o.slug, - description: `Use "${display}" as the organization`, - } - }), - { - name: 'No', - value: '', - description: - 'Do not use any of these organizations (will end in a no-op)', - }, - ], - }) - - if (proceed) { - return proceed - } - return undefined -} diff --git a/packages/cli/src/commands/scan/suggest_target.mts b/packages/cli/src/commands/scan/suggest_target.mts deleted file mode 100644 index b809a1de2..000000000 --- a/packages/cli/src/commands/scan/suggest_target.mts +++ /dev/null @@ -1,23 +0,0 @@ -import { select } from '@socketsecurity/lib-stable/stdio/prompts' - -export async function suggestTarget(): Promise<string[]> { - // We could prefill this with sub-dirs of the current - // dir ... but is that going to be useful? - const proceed = await select({ - message: 'No TARGET given. Do you want to use the current directory?', - choices: [ - { - name: 'Yes', - value: true, - description: 'Target the current directory', - }, - { - name: 'No', - value: false, - description: - 'Do not use the current directory (this will end in a no-op)', - }, - ], - }) - return proceed ? ['.'] : [] -} diff --git a/packages/cli/src/commands/scan/types.mts b/packages/cli/src/commands/scan/types.mts deleted file mode 100644 index 0bdf26c45..000000000 --- a/packages/cli/src/commands/scan/types.mts +++ /dev/null @@ -1,3 +0,0 @@ -export type FOLD_SETTING = 'pkg' | 'version' | 'file' | 'none' - -export type REPORT_LEVEL = 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' diff --git a/packages/cli/src/commands/scan/validate-reachability-target.mts b/packages/cli/src/commands/scan/validate-reachability-target.mts deleted file mode 100644 index d1da88061..000000000 --- a/packages/cli/src/commands/scan/validate-reachability-target.mts +++ /dev/null @@ -1,56 +0,0 @@ -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -type ReachabilityTargetValidation = { - isDirectory: boolean - isInsideCwd: boolean - isValid: boolean - targetExists: boolean -} - -/** - * Validates that a target directory meets the requirements for reachability - * analysis. - * - * @param targets - Array of target paths to validate. - * @param cwd - Current working directory. - * - * @returns Validation result object with boolean flags. - */ -export async function validateReachabilityTarget( - targets: string[], - cwd: string, -): Promise<ReachabilityTargetValidation> { - const result: ReachabilityTargetValidation = { - isDirectory: false, - isInsideCwd: false, - isValid: targets.length === 1, - targetExists: false, - } - - if (!result.isValid || !targets[0]) { - return result - } - - // Resolve cwd to absolute path to handle relative cwd values. - const absoluteCwd = path.resolve(cwd) - - // Resolve target path to absolute for validation. - const targetPath = path.isAbsolute(targets[0]) - ? targets[0] - : path.resolve(absoluteCwd, targets[0]) - - // Check if target is inside cwd. - const relativePath = path.relative(absoluteCwd, targetPath) - result.isInsideCwd = - !relativePath.startsWith('..') && !path.isAbsolute(relativePath) - - result.targetExists = existsSync(targetPath) - if (result.targetExists) { - // oxlint-disable-next-line socket/prefer-exists-sync -- reads .isDirectory() metadata, not just existence. - const targetStat = await fs.stat(targetPath) - result.isDirectory = targetStat.isDirectory() - } - - return result -} diff --git a/packages/cli/src/commands/sfw/cmd-sfw.mts b/packages/cli/src/commands/sfw/cmd-sfw.mts deleted file mode 100644 index 3f9728ad6..000000000 --- a/packages/cli/src/commands/sfw/cmd-sfw.mts +++ /dev/null @@ -1,138 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-npx-dlx -- product feature name / command wrapping npx; the literal is intentional. */ - -/** - * Socket Firewall (sfw) command. - * - * Explicit passthrough to the Socket Firewall tool for direct invocation. - * Socket Firewall intercepts package manager commands to provide security - * scanning before installation. - * - * While `socket npm`, `socket npx`, etc. use sfw internally, this command - * allows direct access to sfw for advanced use cases and troubleshooting. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mts' -import { spawnSfw } from '../../util/dlx/spawn.mts' -import { outputDryRunExecute } from '../../util/dry-run/output.mts' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { filterFlags, isHelpFlag } from '../../util/process/cmd.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mts' - -const logger = getDefaultLogger() - -// Flags interface for type safety. -interface SfwFlags { - dryRun: boolean -} - -const config = { - commandName: 'sfw', - description: 'Run Socket Firewall directly (alias: firewall)', - hidden: false, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string) => ` - Usage - $ ${command} <package-manager> [args...] - - Options - ${getFlagListOutput(commonFlags)} - - Socket Firewall intercepts package manager commands to scan packages - before installation. This command allows direct access to sfw. - - Supported Package Managers: - npm, npx, pnpm, yarn, pip, pip3, uv, cargo, go, gem, bundler, nuget - - Note: For most use cases, prefer the dedicated commands: - socket npm install <package> - socket npx <package> - socket pip install <package> - etc. - - Examples - $ ${command} npm install lodash - $ ${command} npx cowsay hello - $ ${command} pip install requests - $ ${command} --help - `, -} - -export const cmdSfw = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: CliCommandContext, -): Promise<void> { - const { parentName } = { - __proto__: null, - ...context, - } as CliCommandContext - - // Check for help flag. - const hasHelpFlag = argv.some(a => isHelpFlag(a)) - - if (hasHelpFlag) { - // Show Socket CLI wrapper help. - meowOrExit({ - argv: ['--help'], - config, - importMeta, - parentName, - }) - // meowOrExit will exit here. - return - } - - const cli = meowOrExit({ - argv: argv.filter(a => !isHelpFlag(a)), - config, - importMeta, - parentName, - }) - - // Extract typed flags (commonFlags defines dryRun as boolean). - const { dryRun } = cli.flags as unknown as SfwFlags - - // Filter Socket-specific flags from argv, pass rest to sfw. - const sfwArgs = filterFlags(argv, commonFlags, []) - - if (!sfwArgs.length) { - logger.fail('No package manager command specified.') - logger.info('Usage: socket sfw <package-manager> [args...]') - logger.info('Example: socket sfw npm install lodash') - process.exitCode = 2 - return - } - - if (dryRun) { - outputDryRunExecute('sfw', sfwArgs, 'Socket Firewall (sfw)') - return - } - - logger.info(`Invoking Socket Firewall: sfw ${sfwArgs.join(' ')}`) - - const { spawnPromise } = await spawnSfw(sfwArgs, { - stdio: 'inherit', - }) - - const result = await spawnPromise - - if (result.signal) { - process.kill(process.pid, result.signal) - } else if (typeof result.code === 'number') { - process.exitCode = result.code - } -} diff --git a/packages/cli/src/commands/threat-feed/cmd-threat-feed.mts b/packages/cli/src/commands/threat-feed/cmd-threat-feed.mts deleted file mode 100644 index 8b32faded..000000000 --- a/packages/cli/src/commands/threat-feed/cmd-threat-feed.mts +++ /dev/null @@ -1,308 +0,0 @@ -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { NPM } from '@socketsecurity/lib-stable/constants/agents' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { handleThreatFeed } from './handle-threat-feed.mts' -import { outputDryRunFetch } from '../../util/dry-run/output.mts' -import { InputError } from '../../util/error/errors.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { determineOrgSlug } from '../../util/socket/org-slug.mjs' -import { hasDefaultApiToken } from '../../util/socket/sdk.mjs' -import { mailtoLink } from '../../util/terminal/link.mts' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -export const CMD_NAME = 'threat-feed' - -// oxlint-disable-next-line socket/sort-set-args -- alphabetical by ecosystem name; NPM constant sits between 'maven' and 'nuget' which would be its sort position if inlined. -const ECOSYSTEMS = new Set(['gem', 'golang', 'maven', NPM, 'nuget', 'pypi']) - -const TYPE_FILTERS = new Set([ - 'anom', - 'c', - 'fp', - 'joke', - 'mal', - 'secret', - 'spy', - 'tp', - 'typo', - 'u', - 'vuln', -]) - -const description = '[Beta] View the threat-feed' - -const hidden = false - -export const cmdThreatFeed = { - description, - hidden, - run, -} - -export async function run( - argv: readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - ...outputFlags, - direction: { - type: 'string', - default: 'desc', - description: 'Order asc or desc by the createdAt attribute', - }, - eco: { - type: 'string', - default: '', - description: 'Only show threats for a particular ecosystem', - }, - filter: { - type: 'string', - default: 'mal', - description: 'Filter what type of threats to return', - }, - interactive: { - type: 'boolean', - default: true, - description: - 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', - }, - org: { - type: 'string', - description: - 'Force override the organization slug, overrides the default org from config', - }, - page: { - type: 'string', - default: '1', - description: 'Page token', - }, - perPage: { - type: 'number', - shortFlag: 'pp', - default: 30, - description: 'Number of items per page', - }, - pkg: { - type: 'string', - default: '', - description: 'Filter by this package name', - }, - version: { - type: 'string', - default: '', - description: 'Filter by this package version', - }, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [ECOSYSTEM] [TYPE_FILTER] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Special access - - This feature requires a Threat Feed license. Please contact - ${mailtoLink('sales@socket.dev')} if you are interested in purchasing this access. - - Options - ${getFlagListOutput(config.flags)} - - Valid ecosystems: - - - gem - - golang - - maven - - npm - - nuget - - pypi - - Valid type filters: - - - anom Anomaly - - c Do not filter - - fp False Positives - - joke Joke / Fake - - mal Malware and Possible Malware [default] - - secret Secrets - - spy Telemetry - - tp False Positives and Unreviewed - - typo Typo-squat - - u Unreviewed - - vuln Vulnerability - - Note: if you filter by package name or version, it will do so for anything - unless you also filter by that ecosystem and/or package name. When in - doubt, look at the threat-feed and see the names in the name/version - column. That's what you want to search for. - - You can put filters as args instead, we'll try to match the strings with the - correct filter type but since this would not allow you to search for a package - called "mal", you can also specify the filters through flags. - - First arg that matches a typo, eco, or version enum is used as such. First arg - that matches none of them becomes the package name filter. Rest is ignored. - - Note: The version filter is a prefix search, pkg name is a substring search. - - Examples - $ ${command} - $ ${command} maven --json - $ ${command} typo - $ ${command} npm joke 1.0.0 --per-page=5 --page=2 --direction=asc - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const { - eco, - json, - markdown, - org: orgFlag, - pkg, - type: typef, - version, - } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - const interactive = !!cli.flags['interactive'] - - let ecoFilter = String(eco || '') - let versionFilter = String(version || '') - let typeFilter = String(typef || '') - let nameFilter = String(pkg || '') - - const argSet = new Set(cli.input) - cli.input.some(str => { - if (ECOSYSTEMS.has(str)) { - ecoFilter = str - argSet.delete(str) - return true - } - }) - - cli.input.some(str => { - if (/^v?\d+\.\d+\.\d+$/.test(str)) { - versionFilter = str - argSet.delete(str) - return true - } - }) - - cli.input.some(str => { - if (TYPE_FILTERS.has(str)) { - typeFilter = str - argSet.delete(str) - return true - } - }) - - const haves = new Set([ecoFilter, versionFilter, typeFilter]) - cli.input.some(str => { - if (!haves.has(str)) { - nameFilter = str - argSet.delete(str) - return true - } - }) - - if (argSet.size) { - logger.info( - `Warning: ignoring these excessive args: ${joinAnd(Array.from(argSet))}`, - ) - } - - const hasApiToken = hasDefaultApiToken() - - const { 0: orgSlug } = await determineOrgSlug( - String(orgFlag || ''), - interactive, - dryRun, - ) - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - nook: true, - test: !!orgSlug, - message: 'Org name by default setting, --org, or auto-discovered', - fail: 'missing', - }, - { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }, - { - nook: true, - test: hasApiToken, - message: 'This command requires a Socket API token for access', - fail: 'try `socket login`', - }, - ) - if (!wasValidInput) { - return - } - - // Validate numeric pagination parameter. - const validatedPerPage = Number(cli.flags['perPage']) || 30 - - if (dryRun) { - outputDryRunFetch('threat feed data', { - organization: orgSlug, - ecosystem: ecoFilter || 'all', - type: typeFilter || 'mal (default)', - package: nameFilter || undefined, - version: versionFilter || undefined, - perPage: validatedPerPage, - page: String(cli.flags['page'] || '1'), - direction: String(cli.flags['direction'] || 'desc'), - }) - return - } - if (Number.isNaN(validatedPerPage) || validatedPerPage < 1) { - throw new InputError( - `--per-page must be a positive integer (saw: "${cli.flags['perPage']}"); pass a number like --per-page=30`, - ) - } - - await handleThreatFeed({ - direction: String(cli.flags['direction'] || 'desc'), - ecosystem: ecoFilter, - filter: typeFilter, - outputKind, - orgSlug, - page: String(cli.flags['page'] || '1'), - perPage: validatedPerPage, - pkg: nameFilter, - version: versionFilter, - }) -} diff --git a/packages/cli/src/commands/threat-feed/output-threat-feed.mts b/packages/cli/src/commands/threat-feed/output-threat-feed.mts deleted file mode 100644 index 4e90d26fd..000000000 --- a/packages/cli/src/commands/threat-feed/output-threat-feed.mts +++ /dev/null @@ -1,58 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' -import { mdTable } from '../../util/output/markdown.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { getPurlObject } from '../../util/purl/parse.mts' - -import type { ThreadFeedResponse } from './types.mts' -import type { CResult, OutputKind } from '../../types.mts' - -const logger = getDefaultLogger() - -export function formatThreatFeedTable(data: ThreadFeedResponse): string { - const rows = data.results.map(r => { - const purlObj = getPurlObject(r.purl, { throws: false }) - return { - created: r.createdAt, - ecosystem: purlObj?.type ?? '', - name: purlObj?.name ?? '', - version: purlObj?.version ?? '', - threat: r.threatType, - description: r.description, - } - }) - return mdTable(rows as unknown as Array<Record<string, string>>, [ - 'created', - 'ecosystem', - 'name', - 'version', - 'threat', - 'description', - ]) -} - -export async function outputThreatFeed( - result: CResult<ThreadFeedResponse>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - if (!result.data?.results?.length) { - logger.warn('Did not receive any data to display.') - return - } - - logger.log(formatThreatFeedTable(result.data)) -} diff --git a/packages/cli/src/commands/uninstall/cmd-uninstall-completion.mts b/packages/cli/src/commands/uninstall/cmd-uninstall-completion.mts deleted file mode 100644 index 18ee26ab8..000000000 --- a/packages/cli/src/commands/uninstall/cmd-uninstall-completion.mts +++ /dev/null @@ -1,69 +0,0 @@ -import { handleUninstallCompletion } from './handle-uninstall-completion.mts' -import { outputDryRunDelete } from '../../util/dry-run/output.mts' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const config = { - commandName: 'completion', - description: 'Uninstall bash completion for Socket CLI', - hidden: false, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} [options] [COMMAND_NAME=socket] - - Uninstalls bash tab completion for the Socket CLI. This will: - 1. Remove tab completion from your current shell for given command - 2. Remove the setup for given command from your ~/.bashrc - - The optional name is required if you installed tab completion for an alias - other than the default "socket". This will NOT remove the command, only the - tab completion that is registered for it in bash. - - Options - ${getFlagListOutput(config.flags)} - - Examples - - $ ${command} - $ ${command} sd - `, -} - -export const cmdUninstallCompletion = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const cli = meowOrExit({ - argv, - config, - parentName, - importMeta, - }) - const dryRun = !!cli.flags['dryRun'] - const targetName = cli.input[0] || 'socket' - - if (dryRun) { - outputDryRunDelete( - 'bash completion', - `completion for "${targetName}" from ~/.bashrc`, - ) - return - } - - await handleUninstallCompletion(String(targetName)) -} diff --git a/packages/cli/src/commands/uninstall/cmd-uninstall.mts b/packages/cli/src/commands/uninstall/cmd-uninstall.mts deleted file mode 100644 index 50d90c5e8..000000000 --- a/packages/cli/src/commands/uninstall/cmd-uninstall.mts +++ /dev/null @@ -1,24 +0,0 @@ -import { cmdUninstallCompletion } from './cmd-uninstall-completion.mts' -import { meowWithSubcommands } from '../../util/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../util/cli/with-subcommands.mjs' - -const description = 'Uninstall Socket CLI tab completion' - -export const cmdUninstall: CliSubcommand = { - description, - hidden: false, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} uninstall`, - importMeta, - subcommands: { - completion: cmdUninstallCompletion, - }, - }, - { description }, - ) - }, -} diff --git a/packages/cli/src/commands/uninstall/teardown-tab-completion.mts b/packages/cli/src/commands/uninstall/teardown-tab-completion.mts deleted file mode 100644 index 3ac456fe4..000000000 --- a/packages/cli/src/commands/uninstall/teardown-tab-completion.mts +++ /dev/null @@ -1,70 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { homePath } from '../../constants/paths.mts' -import { - COMPLETION_CMD_PREFIX, - getBashrcDetails, -} from '../../util/cli/completion.mts' - -import type { CResult } from '../../types.mts' - -export function findRemainingCompletionSetups(bashrc: string): string[] { - return bashrc - .split('\n') - .map(s => s.trim()) - .filter(s => s.startsWith(COMPLETION_CMD_PREFIX)) - .map(s => s.slice(COMPLETION_CMD_PREFIX.length).trim()) -} - -export async function teardownTabCompletion( - targetName: string, -): Promise<CResult<{ action: string; left: string[] }>> { - const result = getBashrcDetails(targetName) - if (!result.ok) { - return result - } - - const { completionCommand, sourcingCommand, toAddToBashrc } = result.data - - // Remove from ~/.bashrc if found - const bashrc = homePath ? path.join(homePath, '.bashrc') : '' - - if (bashrc && existsSync(bashrc)) { - const content = readFileSync(bashrc, 'utf8') - - if (content.includes(toAddToBashrc)) { - const newContent = content - // Try to remove the whole thing with comment first - .replaceAll(toAddToBashrc, '') - // Comment may have been edited away, try to remove the command at least - .replaceAll(sourcingCommand, '') - .replaceAll(completionCommand, '') - - writeFileSync(bashrc, newContent, 'utf8') - - return { - ok: true, - data: { - action: 'removed', - left: findRemainingCompletionSetups(newContent), - }, - message: 'Removed completion from ~/.bashrc', - } - } - const left = findRemainingCompletionSetups(content) - return { - ok: true, - data: { - action: 'missing', - left, - }, - message: `Completion was not found in ~/.bashrc${left.length ? ' (you may need to manually edit your .bashrc to clean this up...)' : ''}`, - } - } - return { - ok: true, // Eh. I think this makes most sense. - data: { action: 'not found', left: [] }, - message: '~/.bashrc not found, skipping', - } -} diff --git a/packages/cli/src/commands/uv/cmd-uv.mts b/packages/cli/src/commands/uv/cmd-uv.mts deleted file mode 100644 index f135f9303..000000000 --- a/packages/cli/src/commands/uv/cmd-uv.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Socket uv command — forwards uv operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const cmdUv = defineHandoffCommand({ - name: 'uv', - description: 'Run uv with Socket Firewall security', - spawnMode: 'dlx', - examples: ['pip install flask', 'pip sync', 'run script.py'], - trackTelemetry: false, - supportDryRun: false, -}) diff --git a/packages/cli/src/commands/whoami/cmd-whoami.mts b/packages/cli/src/commands/whoami/cmd-whoami.mts deleted file mode 100644 index cf2a5e849..000000000 --- a/packages/cli/src/commands/whoami/cmd-whoami.mts +++ /dev/null @@ -1,139 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { CONFIG_KEY_API_TOKEN } from '../../constants/config.mjs' -import { SOCKET_CLI_API_TOKEN } from '../../env/socket-cli-api-token.mts' -import { TOKEN_PREFIX } from '../../constants/socket.mjs' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getConfigValueOrUndef } from '../../util/config.mts' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { serializeResultJson } from '../../util/output/result-json.mjs' -import { - getDefaultApiToken, - getVisibleTokenPrefix, -} from '../../util/socket/sdk.mjs' - -import type { CResult } from '../../types.mts' -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -export const CMD_NAME = 'whoami' - -const description = 'Check Socket CLI authentication status' - -const hidden = false - -// Types. - -interface WhoamiStatus { - authenticated: boolean - location: string | undefined - token: string | undefined -} - -// Helper functions. - -export function getTokenLocation(): string { - // Check environment variable first. - if (SOCKET_CLI_API_TOKEN) { - return 'Environment variable (SOCKET_SECURITY_API_KEY)' - } - - // Check config file. - const configToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) - if (configToken) { - return 'Config file (~/.config/socket/config.toml)' - } - - return 'Unknown' -} - -export function outputWhoami(status: WhoamiStatus): void { - const result: CResult<WhoamiStatus> = { - ok: true, - data: status, - } - logger.log(serializeResultJson(result)) -} - -export async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - const config = { - commandName: CMD_NAME, - description, - hidden, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} - - Check if you are authenticated with Socket - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --json - `, - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - const flags = cli.flags - - const apiToken = getDefaultApiToken() - const tokenLocation = getTokenLocation() - - if (apiToken) { - const visiblePrefix = getVisibleTokenPrefix() - const tokenDisplay = `${TOKEN_PREFIX}${visiblePrefix}...` - - if (flags['json']) { - outputWhoami({ - authenticated: true, - location: tokenLocation, - token: tokenDisplay, - }) - } else { - logger.success('Authenticated with Socket') - logger.log(` Token: ${tokenDisplay}`) - logger.log(` Source: ${tokenLocation}`) - } - } else { - if (flags['json']) { - outputWhoami({ - authenticated: false, - location: undefined, - token: undefined, - }) - } else { - logger.fail('Not authenticated with Socket') - logger.log('') - logger.log('To authenticate, run one of:') - logger.log(' socket login') - logger.log(' export SOCKET_SECURITY_API_KEY=<your-token>') - } - } -} - -// Exported command. - -export const cmdWhoami = { - description, - hidden, - run, -} diff --git a/packages/cli/src/commands/wrapper/add-socket-wrapper.mts b/packages/cli/src/commands/wrapper/add-socket-wrapper.mts deleted file mode 100644 index a12b082ae..000000000 --- a/packages/cli/src/commands/wrapper/add-socket-wrapper.mts +++ /dev/null @@ -1,42 +0,0 @@ -import { promises as fs } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { FileSystemError, getErrorCause } from '../../util/error/errors.mts' - -const logger = getDefaultLogger() - -export async function addSocketWrapper(file: string): Promise<void> { - try { - await fs.appendFile( - file, - 'alias npm="socket npm"\nalias npx="socket npx"\n', - ) - } catch (e) { - // Don't include `file` in the message: display.formatErrorForDisplay - // appends `(${error.path})` automatically when FileSystemError carries - // a path, so embedding it here would show the filename twice. - throw new FileSystemError( - `failed to append socket aliases (${getErrorCause(e)}); check that the file exists and is writable`, - file, - (e as NodeJS.ErrnoException)?.code, - ) - } - logger.success( - `The alias was added to ${file}. Running 'npm install' will now be wrapped in Socket's "safe npm" 🎉`, - ) - logger.log( - ' If you want to disable it at any time, run `socket wrapper --disable`', - ) - logger.log('') - logger.info( - 'This will only be active in new terminal sessions going forward.', - ) - logger.log( - ' You will need to restart your terminal or run this command to activate the alias in the current session:', - ) - logger.log('') - logger.log(` source ${file}`) - logger.log('') - logger.log('(You only need to do this once)') -} diff --git a/packages/cli/src/commands/wrapper/check-socket-wrapper-setup.mts b/packages/cli/src/commands/wrapper/check-socket-wrapper-setup.mts deleted file mode 100644 index d33c41c12..000000000 --- a/packages/cli/src/commands/wrapper/check-socket-wrapper-setup.mts +++ /dev/null @@ -1,39 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-npx-dlx -- product feature name / command wrapping npx; the literal is intentional. */ - -import { readFileSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -const logger = getDefaultLogger() - -export function checkSocketWrapperSetup(file: string): boolean { - let fileContent: string - try { - fileContent = readFileSync(file, 'utf8') - } catch { - // File may have been deleted or become unreadable. - return false - } - - const linesWithSocketAlias = fileContent - .split('\n') - .filter( - l => l === 'alias npm="socket npm"' || l === 'alias npx="socket npx"', - ) - - if (linesWithSocketAlias.length) { - logger.log( - `The Socket npm/npx wrapper is set up in your bash profile (${file}).`, - ) - logger.log('') - logger.log( - `If you haven't already since enabling; Restart your terminal or run this command to activate it in the current session:`, - ) - logger.log('') - logger.log(` source ${file}`) - logger.log('') - - return true - } - return false -} diff --git a/packages/cli/src/commands/wrapper/cmd-wrapper.mts b/packages/cli/src/commands/wrapper/cmd-wrapper.mts deleted file mode 100644 index ad866d382..000000000 --- a/packages/cli/src/commands/wrapper/cmd-wrapper.mts +++ /dev/null @@ -1,219 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-npx-dlx -- product feature name / command wrapping npx; the literal is intentional. */ - -import { existsSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { addSocketWrapper } from './add-socket-wrapper.mts' -import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.mts' -import { postinstallWrapper } from './postinstall-wrapper.mts' -import { removeSocketWrapper } from './remove-socket-wrapper.mts' -import { outputDryRunWrite } from '../../util/dry-run/output.mts' -import { getBashRcPath, getZshRcPath } from '../../constants/paths.mjs' -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from '../../util/cli/with-subcommands.mjs' -import { getFlagListOutput } from '../../util/output/formatting.mts' -import { getOutputKind } from '../../util/output/mode.mjs' -import { checkCommandInput } from '../../util/validation/check-input.mts' - -import type { CliCommandContext } from '../../util/cli/with-subcommands.mjs' -import type { MeowFlags } from '../../flags.mts' - -const logger = getDefaultLogger() - -const config = { - commandName: 'wrapper', - description: 'Enable or disable the Socket npm/pnpm exec wrapper', - hidden: false, - flags: defineFlags({ - ...commonFlags, - }), - help: (command: string, config: { flags: MeowFlags }) => ` - Usage - $ ${command} <"on" | "off"> - - Options - ${getFlagListOutput(config.flags)} - - While enabled, the wrapper makes it so that when you call npm/npx on your - machine, it will automatically actually run \`socket npm\` / \`socket npx\` - instead. - - Examples - $ ${command} on - $ ${command} off - `, -} - -export const cmdWrapper = { - description: config.description, - hidden: config.hidden, - run, -} - -export async function run( - argv: readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise<void> { - // I don't think meow would mess with this but ... - if (argv[0] === '--postinstall') { - await postinstallWrapper() - return - } - - const cli = meowOrExit({ - argv, - config, - importMeta, - parentName, - }) - - // Feature request: Implement json/markdown output for wrapper command status. - const { json, markdown } = cli.flags - - const dryRun = !!cli.flags['dryRun'] - - let enable = false - let disable = false - const [arg] = cli.input - if (arg === 'enable' || arg === 'enabled' || arg === 'on') { - enable = true - disable = false - } else if (arg === 'disable' || arg === 'disabled' || arg === 'off') { - enable = false - disable = true - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput( - outputKind, - { - test: enable || disable, - message: 'Must specify "on" or "off" argument', - fail: 'missing', - }, - { - nook: true, - test: cli.input.length <= 1, - message: 'expecting exactly one argument', - fail: 'got multiple', - }, - ) - if (!wasValidInput) { - return - } - - const bashRcPath = getBashRcPath() - const zshRcPath = getZshRcPath() - - if (dryRun) { - const files = [] - if (existsSync(bashRcPath)) { - files.push(bashRcPath) - } - if (existsSync(zshRcPath)) { - files.push(zshRcPath) - } - const changes = enable - ? [ - 'Add shell aliases/functions to wrap npm/pnpm exec commands', - 'Redirect npm/pnpm exec calls to socket npm/socket npx', - ] - : [ - 'Remove Socket wrapper aliases/functions from shell config', - 'Restore original npm/pnpm exec behavior', - ] - outputDryRunWrite( - files.join(', '), - enable - ? 'enable Socket npm/pnpm exec wrapper' - : 'disable Socket npm/pnpm exec wrapper', - changes, - ) - return - } - const modifiedFiles: string[] = [] - const skippedFiles: string[] = [] - - if (enable) { - if (existsSync(bashRcPath)) { - if (!checkSocketWrapperSetup(bashRcPath)) { - await addSocketWrapper(bashRcPath) - modifiedFiles.push(bashRcPath) - } else { - skippedFiles.push(bashRcPath) - } - } - if (existsSync(zshRcPath)) { - if (!checkSocketWrapperSetup(zshRcPath)) { - await addSocketWrapper(zshRcPath) - modifiedFiles.push(zshRcPath) - } else { - skippedFiles.push(zshRcPath) - } - } - } else { - if (existsSync(bashRcPath)) { - removeSocketWrapper(bashRcPath) - modifiedFiles.push(bashRcPath) - } - if (existsSync(zshRcPath)) { - removeSocketWrapper(zshRcPath) - modifiedFiles.push(zshRcPath) - } - } - - if (!existsSync(bashRcPath) && !existsSync(zshRcPath)) { - logger.fail('There was an issue setting up the alias in your bash profile') - return - } - - // Output results in requested format. - if (outputKind === 'json') { - const result = { - action: enable ? 'enabled' : 'disabled', - modifiedFiles, - skippedFiles, - success: modifiedFiles.length > 0 || skippedFiles.length > 0, - } - logger.log(JSON.stringify(result, null, 2)) - } else if (outputKind === 'markdown') { - const arr = [] - arr.push(`# Socket Wrapper ${enable ? 'Enabled' : 'Disabled'}`) - arr.push('') - - if (modifiedFiles.length > 0) { - arr.push('## Modified Files') - arr.push('') - for (let i = 0, { length } = modifiedFiles; i < length; i += 1) { - const file = modifiedFiles[i] - arr.push(`- \`${file}\``) - } - arr.push('') - } - - if (skippedFiles.length > 0) { - arr.push('## Skipped Files (already configured)') - arr.push('') - for (let i = 0, { length } = skippedFiles; i < length; i += 1) { - const file = skippedFiles[i] - arr.push(`- \`${file}\``) - } - arr.push('') - } - - arr.push('## Status') - arr.push('') - arr.push( - `Socket npm/npx wrapper has been **${enable ? 'enabled' : 'disabled'}**.`, - ) - arr.push('') - - logger.log(arr.join('\n')) - } - // Text mode output is already handled by add/remove functions. -} diff --git a/packages/cli/src/commands/wrapper/postinstall-wrapper.mts b/packages/cli/src/commands/wrapper/postinstall-wrapper.mts deleted file mode 100644 index d40575313..000000000 --- a/packages/cli/src/commands/wrapper/postinstall-wrapper.mts +++ /dev/null @@ -1,97 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -import { existsSync } from 'node:fs' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { confirm } from '@socketsecurity/lib-stable/stdio/prompts' - -import { addSocketWrapper } from './add-socket-wrapper.mts' -import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.mts' -import { getBashRcPath, getZshRcPath } from '../../constants/paths.mts' -import { getBashrcDetails } from '../../util/cli/completion.mts' -import { FileSystemError, getErrorCause } from '../../util/error/errors.mjs' -import { updateInstalledTabCompletionScript } from '../install/setup-tab-completion.mts' -const logger = getDefaultLogger() - -export async function postinstallWrapper() { - const bashRcPath = getBashRcPath() - const zshRcPath = getZshRcPath() - const socketWrapperEnabled = - (existsSync(bashRcPath) && checkSocketWrapperSetup(bashRcPath)) || - (existsSync(zshRcPath) && checkSocketWrapperSetup(zshRcPath)) - - if (!socketWrapperEnabled) { - await setupSocketWrapper( - ` -The Socket CLI is now successfully installed! 🎉 - -To better protect yourself against supply-chain attacks, our Socket npm wrapper can warn you about malicious packages whenever you run 'npm install'. - -Do you want to install the Socket npm wrapper (this will create an alias to the \`socket npm\` command)? - `.trim(), - ) - } - - // Attempt to update the existing tab completion - let updatedTabCompletion = false - try { - const details = getBashrcDetails('') // Note: command is not relevant, we just want the config path - if (details.ok) { - if (existsSync(details.data.targetPath)) { - // Replace the file with the one from this installation - const result = updateInstalledTabCompletionScript( - details.data.targetPath, - ) - if (result.ok) { - // This will work no matter what alias(es) were registered since that - // is controlled by bashrc and they all share the same tab script. - logger.success('Updated the installed Socket tab completion script') - updatedTabCompletion = true - } - } - } - } catch (e) { - debug('Tab completion setup failed (non-fatal)') - debugDir(e) - // Ignore. Skip tab completion setup. - } - if (!updatedTabCompletion) { - // Setting up tab completion requires bashrc modification. I'm not sure if - // it's cool to just do that from an npm install... - logger.log('Run `socket install completion` to setup bash tab completion') - } -} - -export async function setupSocketWrapper(query: string): Promise<void> { - logger.log(` - _____ _ _ -| __|___ ___| |_ ___| |_ -|__ | . | _| '_| -_| _| -|_____|___|___|_,_|___|_| - -`) - if ( - await confirm({ - message: query, - default: true, - }) - ) { - const bashRcPath = getBashRcPath() - const zshRcPath = getZshRcPath() - try { - if (existsSync(bashRcPath)) { - await addSocketWrapper(bashRcPath) - } - if (existsSync(zshRcPath)) { - await addSocketWrapper(zshRcPath) - } - } catch (e) { - throw new FileSystemError( - `failed to add socket aliases to ${bashRcPath} / ${zshRcPath} (${getErrorCause(e)}); check that your shell rc files exist and are writable`, - undefined, - (e as NodeJS.ErrnoException)?.code, - ) - } - } -} diff --git a/packages/cli/src/commands/wrapper/remove-socket-wrapper.mts b/packages/cli/src/commands/wrapper/remove-socket-wrapper.mts deleted file mode 100644 index b2d12e30d..000000000 --- a/packages/cli/src/commands/wrapper/remove-socket-wrapper.mts +++ /dev/null @@ -1,40 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -const logger = getDefaultLogger() - -export function removeSocketWrapper(filepath: string): void { - let content: string | undefined - try { - content = readFileSync(filepath, 'utf8') - } catch (e) { - logger.fail(`There was an error removing the alias${e ? ':' : '.'}`) - if (e) { - logger.error(e) - } - return - } - - const linesWithoutSocketAlias = content - .split('\n') - .filter( - l => l !== 'alias npm="socket npm"' && l !== 'alias npx="socket npx"', - ) - const updatedContent = linesWithoutSocketAlias.join('\n') - try { - writeFileSync(filepath, updatedContent, 'utf8') - } catch (e) { - if (e) { - logger.error(e) - } - return - } - - logger.success( - `The alias was removed from ${filepath}. Running 'npm install' will now run the standard npm command in new terminals going forward.`, - ) - logger.log('') - logger.info( - 'Note: We cannot deactivate the alias from current terminal sessions. You have to restart existing terminal sessions to finalize this step.', - ) -} diff --git a/packages/cli/src/commands/yarn/cmd-yarn.mts b/packages/cli/src/commands/yarn/cmd-yarn.mts deleted file mode 100644 index 0973417a5..000000000 --- a/packages/cli/src/commands/yarn/cmd-yarn.mts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Socket yarn command — forwards yarn operations to Socket Firewall (sfw). - * - * Defined via `defineHandoffCommand`. See util/cli/define-handoff.mts. - */ - -import { YARN } from '@socketsecurity/lib-stable/constants/agents' - -import { defineHandoffCommand } from '../../util/cli/define-handoff.mts' - -export const CMD_NAME = YARN - -export const cmdYarn = defineHandoffCommand({ - name: YARN, - description: 'Run yarn with Socket Firewall security', - spawnMode: 'dlx', - hidden: true, - examples: ['', 'install', 'add package-name'], - showApiRequirements: true, - wrapperHint: true, -}) diff --git a/packages/cli/src/constants.mts b/packages/cli/src/constants.mts deleted file mode 100644 index f2bc272b4..000000000 --- a/packages/cli/src/constants.mts +++ /dev/null @@ -1,667 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Barrel file that re-exports all constants from the /src/constants/ directory. - * This provides a single entry point for accessing all Socket CLI constants. - */ - -// Import everything we need to re-export. -import { UTF8 } from '@socketsecurity/lib-stable/constants/encoding' -import { - SOCKET_IPC_HANDSHAKE, - SOCKET_PUBLIC_API_TOKEN, -} from '@socketsecurity/lib-stable/constants/socket' - -import { - BUN, - NPM, - NPX, - PNPM, - VLT, - YARN, - YARN_BERRY, - YARN_CLASSIC, - getMinimumVersionByAgent, - getNpmExecPath, - getPnpmExecPath, -} from './constants/agents.mts' -import { - ALERT_TYPE_CRITICAL_CVE, - ALERT_TYPE_CVE, - ALERT_TYPE_MEDIUM_CVE, - ALERT_TYPE_MILD_CVE, -} from './constants/alerts.mts' -import { - INLINED_COANA_VERSION, - INLINED_CYCLONEDX_CDXGEN_VERSION, - INLINED_HOMEPAGE, - INLINED_NAME, - INLINED_PUBLISHED_BUILD, - INLINED_PYTHON_BUILD_TAG, - INLINED_PYTHON_VERSION, - INLINED_SENTRY_BUILD, - INLINED_SYNP_VERSION, - INLINED_VERSION, - INLINED_VERSION_HASH, -} from './constants/build.mts' -import { - DLX_BINARY_CACHE_TTL, - UPDATE_CHECK_TTL, - UPDATE_NOTIFIER_TIMEOUT, -} from './constants/cache.mts' -import { - DRY_RUN_BAILING_NOW, - DRY_RUN_LABEL, - DRY_RUN_NOT_SAVING, - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_HELP_FULL, - FLAG_ID, - FLAG_JSON, - FLAG_LOGLEVEL, - FLAG_MARKDOWN, - FLAG_ORG, - FLAG_PIN, - FLAG_PROD, - FLAG_QUIET, - FLAG_SILENT, - FLAG_TEXT, - FLAG_VERBOSE, - FLAG_VERSION, - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - REDACTED, - SEA_UPDATE_COMMAND, -} from './constants/cli.mts' -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, -} from './constants/config.mts' -import { - DISABLE_GITHUB_CACHE, - ENV, - GITHUB_API_URL, - GITHUB_BASE_REF, - GITHUB_REF_NAME, - GITHUB_REF_TYPE, - GITHUB_SERVER_URL, - NODE_ENV, - NODE_OPTIONS, - SOCKET_CLI_ACCEPT_RISKS, - SOCKET_CLI_API_BASE_URL, - SOCKET_CLI_API_PROXY, - SOCKET_CLI_API_TIMEOUT, - SOCKET_CLI_API_TOKEN, - SOCKET_CLI_BIN_PATH, - SOCKET_CLI_COANA_LOCAL_PATH, - SOCKET_CLI_CONFIG, - SOCKET_CLI_DEBUG, - SOCKET_CLI_FIX, - SOCKET_CLI_GITHUB_TOKEN, - SOCKET_CLI_GIT_USER_EMAIL, - SOCKET_CLI_GIT_USER_NAME, - SOCKET_CLI_JS_PATH, - SOCKET_CLI_MODE, - SOCKET_CLI_NO_API_TOKEN, - SOCKET_CLI_NPM_PATH, - SOCKET_CLI_OPTIMIZE, - SOCKET_CLI_VIEW_ALL_RISKS, - VITEST, - getCdxgenVersion, - getCliHomepage, - getCliName, - getCliVersion, - getCliVersionHash, - getCoanaVersion, - getPythonBuildTag, - getPythonVersion, - getSynpVersion, - isPublishedBuild, - isSentryBuild, - npm_config_cache, - npm_config_user_agent, - processEnv, -} from './constants/env.mts' -import { - ERROR_NO_MANIFEST_FILES, - ERROR_NO_PACKAGE_JSON, - ERROR_NO_REPO_FOUND, - ERROR_NO_SOCKET_DIR, - ERROR_UNABLE_RESOLVE_ORG, - LOOP_SENTINEL, -} from './constants/errors.mts' -import { - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, - SOCKET_CLI_GITHUB_REPO, -} from './constants/github.mts' -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, - NPM_REGISTRY_URL, -} from './constants/http.mts' -import { - BLESSED, - BLESSED_CONTRIB, - EXT_LOCK, - EXT_LOCKB, - NODE_MODULES, - NPM_BUGGY_OVERRIDES_PATCHED_VERSION, - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - PYTHON_MIN_VERSION, - SENTRY_NODE, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_BIN_NAME_ALIAS, - SOCKET_CLI_LEGACY_PACKAGE_NAME, - SOCKET_CLI_NPM_BIN_NAME, - SOCKET_CLI_NPX_BIN_NAME, - SOCKET_CLI_PACKAGE_NAME, - SOCKET_CLI_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, - SOCKET_CLI_SENTRY_NPM_BIN_NAME, - SOCKET_CLI_SENTRY_NPX_BIN_NAME, - SOCKET_CLI_SENTRY_PACKAGE_NAME, - SOCKET_CLI_SENTRY_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_YARN_BIN_NAME, - SOCKET_CLI_YARN_BIN_NAME, - SOCKET_DESCRIPTION, - SOCKET_DESCRIPTION_WITH_SENTRY, - SOCKET_SECURITY_REGISTRY, - YARN_LOCK, -} from './constants/packages.mts' -import { - DOT_SOCKET_DOT_FACTS_JSON, - ENVIRONMENT_YAML, - ENVIRONMENT_YML, - REQUIREMENTS_TXT, - SOCKET_JSON, - UPDATE_STORE_DIR, - UPDATE_STORE_FILE_NAME, - configPath, - distPath, - execPath, - externalPath, - getBashRcPath, - getBinCliPath, - getBinPath, - getBlessedContribPath, - getBlessedOptions, - getBlessedPath, - getDistBinPath, - getDistPackageJsonPath, - getDistPath, - getExecPath, - getGithubCachePath, - getNmBunPath, - getNmNodeGypPath, - getNmNpmPath, - getNmPnpmPath, - getNmYarnPath, - getNodeHardenFlags, - getNodeNoWarningsFlags, - getPackageJsonPath, - getSocketAppDataPath, - getSocketCachePath, - getSocketRegistryPath, - getZshRcPath, - homePath, - nodeHardenFlags, - nodeNoWarningsFlags, - rootPath, - srcPath, -} from './constants/paths.mts' -import { - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, -} from './constants/reporting.mts' -import { - API_V0_URL, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, - SOCKET_CLI_ISSUES_URL, - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, - SOCKET_WEBSITE_URL, - SOCKET_YAML, - SOCKET_YML, - TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH, - V1_MIGRATION_GUIDE_URL, -} from './constants/socket.mts' -import { WIN32 } from './constants/types.mts' - -// Export types. -export type { - Agent, - ProcessEnv, - RegistryEnv, - Remap, - SpawnOptions, -} from './constants/types.mts' - -// Named exports for all constants. -export { - ALERT_TYPE_CRITICAL_CVE, - ALERT_TYPE_CVE, - ALERT_TYPE_MEDIUM_CVE, - ALERT_TYPE_MILD_CVE, - API_V0_URL, - BLESSED, - BLESSED_CONTRIB, - BUN, - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, - configPath, - DISABLE_GITHUB_CACHE, - distPath, - DLX_BINARY_CACHE_TTL, - DOT_SOCKET_DOT_FACTS_JSON, - DRY_RUN_BAILING_NOW, - DRY_RUN_LABEL, - DRY_RUN_NOT_SAVING, - ENVIRONMENT_YAML, - ENVIRONMENT_YML, - ENV, - ERROR_NO_MANIFEST_FILES, - ERROR_NO_PACKAGE_JSON, - ERROR_NO_REPO_FOUND, - ERROR_NO_SOCKET_DIR, - ERROR_UNABLE_RESOLVE_ORG, - execPath, - externalPath, - EXT_LOCK, - EXT_LOCKB, - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_HELP_FULL, - FLAG_ID, - FLAG_JSON, - FLAG_LOGLEVEL, - FLAG_MARKDOWN, - FLAG_ORG, - FLAG_PIN, - FLAG_PROD, - FLAG_QUIET, - FLAG_SILENT, - FLAG_TEXT, - FLAG_VERBOSE, - FLAG_VERSION, - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, - getBashRcPath, - getBinCliPath, - getBinPath, - getBlessedContribPath, - getBlessedOptions, - getBlessedPath, - getCdxgenVersion, - getCliHomepage, - getCliName, - getCliVersion, - getCliVersionHash, - getCoanaVersion, - getDistBinPath, - getDistPackageJsonPath, - getDistPath, - getExecPath, - getGithubCachePath, - getMinimumVersionByAgent, - getNmBunPath, - getNmNodeGypPath, - getNmNpmPath, - getNmPnpmPath, - getNmYarnPath, - getNodeHardenFlags, - getNodeNoWarningsFlags, - getNpmExecPath, - getPackageJsonPath, - getPnpmExecPath, - getPythonBuildTag, - getPythonVersion, - getSocketAppDataPath, - getSocketCachePath, - getSocketRegistryPath, - getSynpVersion, - getZshRcPath, - GITHUB_API_URL, - GITHUB_BASE_REF, - GITHUB_REF_NAME, - GITHUB_REF_TYPE, - GITHUB_SERVER_URL, - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, - homePath, - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, - INLINED_COANA_VERSION, - INLINED_CYCLONEDX_CDXGEN_VERSION, - INLINED_HOMEPAGE, - INLINED_NAME, - INLINED_PUBLISHED_BUILD, - INLINED_PYTHON_BUILD_TAG, - INLINED_PYTHON_VERSION, - INLINED_SENTRY_BUILD, - INLINED_SYNP_VERSION, - INLINED_VERSION, - INLINED_VERSION_HASH, - isPublishedBuild, - isSentryBuild, - LOOP_SENTINEL, - NODE_ENV, - NODE_MODULES, - nodeHardenFlags, - nodeNoWarningsFlags, - NODE_OPTIONS, - NPM, - npm_config_cache, - npm_config_user_agent, - NPM_BUGGY_OVERRIDES_PATCHED_VERSION, - NPM_REGISTRY_URL, - NPX, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM, - PNPM_LOCK_YAML, - processEnv, - PYTHON_MIN_VERSION, - REDACTED, - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, - REQUIREMENTS_TXT, - rootPath, - SEA_UPDATE_COMMAND, - SENTRY_NODE, - SOCKET_CLI_ACCEPT_RISKS, - SOCKET_CLI_API_BASE_URL, - SOCKET_CLI_API_PROXY, - SOCKET_CLI_API_TIMEOUT, - SOCKET_CLI_API_TOKEN, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_BIN_NAME_ALIAS, - SOCKET_CLI_BIN_PATH, - SOCKET_CLI_COANA_LOCAL_PATH, - SOCKET_CLI_CONFIG, - SOCKET_CLI_DEBUG, - SOCKET_CLI_FIX, - SOCKET_CLI_GIT_USER_EMAIL, - SOCKET_CLI_GIT_USER_NAME, - SOCKET_CLI_GITHUB_REPO, - SOCKET_CLI_GITHUB_TOKEN, - SOCKET_CLI_ISSUES_URL, - SOCKET_CLI_JS_PATH, - SOCKET_CLI_LEGACY_PACKAGE_NAME, - SOCKET_CLI_MODE, - SOCKET_CLI_NO_API_TOKEN, - SOCKET_CLI_NPM_BIN_NAME, - SOCKET_CLI_NPM_PATH, - SOCKET_CLI_NPX_BIN_NAME, - SOCKET_CLI_OPTIMIZE, - SOCKET_CLI_PACKAGE_NAME, - SOCKET_CLI_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, - SOCKET_CLI_SENTRY_NPM_BIN_NAME, - SOCKET_CLI_SENTRY_NPX_BIN_NAME, - SOCKET_CLI_SENTRY_PACKAGE_NAME, - SOCKET_CLI_SENTRY_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_YARN_BIN_NAME, - SOCKET_CLI_VIEW_ALL_RISKS, - SOCKET_CLI_YARN_BIN_NAME, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, - SOCKET_DESCRIPTION, - SOCKET_DESCRIPTION_WITH_SENTRY, - SOCKET_IPC_HANDSHAKE, - SOCKET_JSON, - SOCKET_PUBLIC_API_TOKEN, - SOCKET_SECURITY_REGISTRY, - SOCKET_WEBSITE_URL, - SOCKET_YAML, - SOCKET_YML, - srcPath, - TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH, - UPDATE_CHECK_TTL, - UPDATE_NOTIFIER_TIMEOUT, - UPDATE_STORE_DIR, - UPDATE_STORE_FILE_NAME, - UTF8, - V1_MIGRATION_GUIDE_URL, - VITEST, - VLT, - WIN32, - YARN, - YARN_BERRY, - YARN_CLASSIC, - YARN_LOCK, -} - -// Bundle export that includes all constants from ENV plus additional constants. -export const constants = { - ...ENV, - ENV, - ALERT_TYPE_CRITICAL_CVE, - ALERT_TYPE_CVE, - ALERT_TYPE_MEDIUM_CVE, - ALERT_TYPE_MILD_CVE, - API_V0_URL, - BLESSED, - BLESSED_CONTRIB, - BUN, - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, - configPath, - distPath, - DLX_BINARY_CACHE_TTL, - DOT_SOCKET_DOT_FACTS_JSON, - DRY_RUN_BAILING_NOW, - DRY_RUN_LABEL, - DRY_RUN_NOT_SAVING, - ENVIRONMENT_YAML, - ENVIRONMENT_YML, - ERROR_NO_MANIFEST_FILES, - ERROR_NO_PACKAGE_JSON, - ERROR_NO_REPO_FOUND, - ERROR_NO_SOCKET_DIR, - ERROR_UNABLE_RESOLVE_ORG, - execPath, - externalPath, - EXT_LOCK, - EXT_LOCKB, - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_HELP_FULL, - FLAG_ID, - FLAG_JSON, - FLAG_LOGLEVEL, - FLAG_MARKDOWN, - FLAG_ORG, - FLAG_PIN, - FLAG_PROD, - FLAG_QUIET, - FLAG_SILENT, - FLAG_TEXT, - FLAG_VERBOSE, - FLAG_VERSION, - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, - getBashRcPath, - getBinCliPath, - getBinPath, - getBlessedContribPath, - getBlessedOptions, - getBlessedPath, - getCdxgenVersion, - getCliHomepage, - getCliName, - getCliVersion, - getCliVersionHash, - getCoanaVersion, - getDistBinPath, - getDistPackageJsonPath, - getDistPath, - getExecPath, - getGithubCachePath, - getMinimumVersionByAgent, - getNmBunPath, - getNmNodeGypPath, - getNmNpmPath, - getNmPnpmPath, - getNmYarnPath, - getNodeHardenFlags, - getNodeNoWarningsFlags, - getNpmExecPath, - getPackageJsonPath, - getPnpmExecPath, - getPythonBuildTag, - getPythonVersion, - getSocketAppDataPath, - getSocketCachePath, - getSocketRegistryPath, - getSynpVersion, - getZshRcPath, - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, - homePath, - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, - INLINED_COANA_VERSION, - INLINED_CYCLONEDX_CDXGEN_VERSION, - INLINED_HOMEPAGE, - INLINED_NAME, - INLINED_PUBLISHED_BUILD, - INLINED_PYTHON_BUILD_TAG, - INLINED_PYTHON_VERSION, - INLINED_SENTRY_BUILD, - INLINED_SYNP_VERSION, - INLINED_VERSION, - INLINED_VERSION_HASH, - isPublishedBuild, - isSentryBuild, - LOOP_SENTINEL, - NODE_MODULES, - nodeHardenFlags, - nodeNoWarningsFlags, - NPM, - NPM_BUGGY_OVERRIDES_PATCHED_VERSION, - NPM_REGISTRY_URL, - NPX, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM, - PNPM_LOCK_YAML, - PYTHON_MIN_VERSION, - REDACTED, - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, - REQUIREMENTS_TXT, - rootPath, - SEA_UPDATE_COMMAND, - SENTRY_NODE, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_BIN_NAME_ALIAS, - SOCKET_CLI_GITHUB_REPO, - SOCKET_CLI_ISSUES_URL, - SOCKET_CLI_LEGACY_PACKAGE_NAME, - SOCKET_CLI_NPM_BIN_NAME, - SOCKET_CLI_NPX_BIN_NAME, - SOCKET_CLI_PACKAGE_NAME, - SOCKET_CLI_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, - SOCKET_CLI_SENTRY_NPM_BIN_NAME, - SOCKET_CLI_SENTRY_NPX_BIN_NAME, - SOCKET_CLI_SENTRY_PACKAGE_NAME, - SOCKET_CLI_SENTRY_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_YARN_BIN_NAME, - SOCKET_CLI_YARN_BIN_NAME, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, - SOCKET_DESCRIPTION, - SOCKET_DESCRIPTION_WITH_SENTRY, - SOCKET_IPC_HANDSHAKE, - SOCKET_JSON, - SOCKET_PUBLIC_API_TOKEN, - SOCKET_SECURITY_REGISTRY, - SOCKET_WEBSITE_URL, - SOCKET_YAML, - SOCKET_YML, - srcPath, - TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH, - UPDATE_CHECK_TTL, - UPDATE_NOTIFIER_TIMEOUT, - UPDATE_STORE_DIR, - UPDATE_STORE_FILE_NAME, - UTF8, - V1_MIGRATION_GUIDE_URL, - VLT, - WIN32, - YARN, - YARN_BERRY, - YARN_CLASSIC, - YARN_LOCK, -} diff --git a/packages/cli/src/constants/agents.mts b/packages/cli/src/constants/agents.mts deleted file mode 100644 index 94c8d522a..000000000 --- a/packages/cli/src/constants/agents.mts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Agent-specific constants and utilities. Functions for package manager version - * requirements and execution paths. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { whichReal } from '@socketsecurity/lib-stable/bin/which' -import { - BUN, - NPM, - NPX, - PNPM, - VLT, - YARN, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' - -import type { Agent } from '../util/ecosystem/environment.mjs' - -// Re-export agent constants for backward compatibility. -export { BUN, NPM, NPX, PNPM, VLT, YARN, YARN_BERRY, YARN_CLASSIC } - -/** - * Minimum supported versions for each package manager agent. These are the - * minimum versions required by Socket CLI. - */ -const MINIMUM_VERSIONS_BY_AGENT = { - __proto__: undefined as unknown as null, - // Bun >=1.1.39 supports the text-based lockfile. - [BUN]: '1.1.39', - // The npm version bundled with Node 18. - [NPM]: '10.8.2', - // 8.x is the earliest version to support Node 18. - [PNPM]: '8.15.7', - // 4.x supports >= Node 18.12.0 - [YARN_BERRY]: '4.0.0', - // Latest 1.x. - [YARN_CLASSIC]: '1.22.22', - // vlt does not support overrides so we don't gate on it. - [VLT]: '*', -} - -/** - * Get the minimum supported version for a package manager agent. - * - * @param agent - The package manager agent name. - * - * @returns The minimum version string (e.g., "10.8.2") or "*" for any version - */ -export function getMinimumVersionByAgent(agent: Agent): string { - return MINIMUM_VERSIONS_BY_AGENT[agent] ?? '*' -} - -/** - * Get the execution path for npm. Checks in order: node directory, PATH via - * which. - * - * @returns The npm executable path - */ -export async function getNpmExecPath(): Promise<string> { - // Check npm in the same directory as node. - const nodeDir = path.dirname(process.execPath) - const npmInNodeDir = path.join(nodeDir, NPM) - if (existsSync(npmInNodeDir)) { - return npmInNodeDir - } - // Fall back to whichReal. - const whichRealResult = await whichReal(NPM, { nothrow: true }) - return ( - (Array.isArray(whichRealResult) ? whichRealResult[0] : whichRealResult) ?? - NPM - ) -} - -/** - * Get the execution path for pnpm. Uses whichReal to locate pnpm in PATH. - * - * @returns The pnpm executable path - */ -export async function getPnpmExecPath(): Promise<string> { - const whichRealResult = await whichReal(PNPM, { nothrow: true }) - return ( - (Array.isArray(whichRealResult) ? whichRealResult[0] : whichRealResult) ?? - PNPM - ) -} diff --git a/packages/cli/src/constants/alerts.mts b/packages/cli/src/constants/alerts.mts deleted file mode 100644 index 3d9cf360c..000000000 --- a/packages/cli/src/constants/alerts.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Security alert type constants. - */ - -export const ALERT_TYPE_CRITICAL_CVE = 'criticalCVE' -export const ALERT_TYPE_CVE = 'cve' -export const ALERT_TYPE_MEDIUM_CVE = 'mediumCVE' -export const ALERT_TYPE_MILD_CVE = 'mildCVE' diff --git a/packages/cli/src/constants/build.mts b/packages/cli/src/constants/build.mts deleted file mode 100644 index 72347e262..000000000 --- a/packages/cli/src/constants/build.mts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Build-time inlined constant names for Socket CLI. These are environment - * variable names that get inlined during build. - */ - -export const INLINED_COANA_VERSION = 'INLINED_COANA_VERSION' -export const INLINED_CYCLONEDX_CDXGEN_VERSION = - 'INLINED_CYCLONEDX_CDXGEN_VERSION' -export const INLINED_HOMEPAGE = 'INLINED_HOMEPAGE' -export const INLINED_NAME = 'INLINED_NAME' -export const INLINED_PUBLISHED_BUILD = 'INLINED_PUBLISHED_BUILD' -export const INLINED_PYTHON_BUILD_TAG = 'INLINED_PYTHON_BUILD_TAG' -export const INLINED_PYTHON_VERSION = 'INLINED_PYTHON_VERSION' -export const INLINED_SENTRY_BUILD = 'INLINED_SENTRY_BUILD' -export const INLINED_SYNP_VERSION = 'INLINED_SYNP_VERSION' -export const INLINED_VERSION = 'INLINED_VERSION' -export const INLINED_VERSION_HASH = 'INLINED_VERSION_HASH' diff --git a/packages/cli/src/constants/cache.mts b/packages/cli/src/constants/cache.mts deleted file mode 100644 index 77f449406..000000000 --- a/packages/cli/src/constants/cache.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Caching, TTL, and timeout constants for Socket CLI. - */ - -// Cache TTL (Time To Live) in milliseconds -export const DLX_BINARY_CACHE_TTL = 7 * 24 * 60 * 60 * 1_000 // 7 days -export const UPDATE_CHECK_TTL = 24 * 60 * 60 * 1_000 // 24 hours - -// Timeouts in milliseconds -export const UPDATE_NOTIFIER_TIMEOUT = 10_000 // 10 seconds diff --git a/packages/cli/src/constants/cli.mts b/packages/cli/src/constants/cli.mts deleted file mode 100644 index c26710fe5..000000000 --- a/packages/cli/src/constants/cli.mts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * CLI interface constants - flags, output formats, commands, and UI elements. - */ - -// CLI Flags -export const FLAG_CONFIG = '--config' -export const FLAG_DRY_RUN = '--dry-run' -export const FLAG_HELP = '--help' -export const FLAG_HELP_FULL = '--help-full' -export const FLAG_ID = '--id' -export const FLAG_JSON = '--json' -export const FLAG_LOGLEVEL = '--loglevel' -export const FLAG_MARKDOWN = '--markdown' -export const FLAG_ORG = '--org' -export const FLAG_PIN = '--pin' -export const FLAG_PROD = '--prod' -export const FLAG_QUIET = '--quiet' -export const FLAG_SILENT = '--silent' -export const FLAG_TEXT = '--text' -export const FLAG_VERBOSE = '--verbose' -export const FLAG_VERSION = '--version' - -// Output Formats -export const OUTPUT_JSON = 'json' -export const OUTPUT_MARKDOWN = 'markdown' -export const OUTPUT_TEXT = 'text' - -// Fold Settings -export const FOLD_SETTING_FILE = 'file' -export const FOLD_SETTING_NONE = 'none' -export const FOLD_SETTING_PKG = 'pkg' -export const FOLD_SETTING_VERSION = 'version' - -// Dry Run Labels -export const DRY_RUN_LABEL = '[DryRun]' -export const DRY_RUN_BAILING_NOW = `${DRY_RUN_LABEL}: Bailing now` -export const DRY_RUN_NOT_SAVING = `${DRY_RUN_LABEL}: Not saving` - -// Commands -export const SEA_UPDATE_COMMAND = 'self-update' - -// Redaction -export const REDACTED = '<redacted>' diff --git a/packages/cli/src/constants/config.mts b/packages/cli/src/constants/config.mts deleted file mode 100644 index 848ded6c5..000000000 --- a/packages/cli/src/constants/config.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Configuration key constants for Socket CLI settings. - */ - -export const CONFIG_KEY_API_BASE_URL = 'apiBaseUrl' -export const CONFIG_KEY_API_PROXY = 'apiProxy' -export const CONFIG_KEY_API_TOKEN = 'apiToken' -export const CONFIG_KEY_DEFAULT_ORG = 'defaultOrg' -export const CONFIG_KEY_ENFORCED_ORGS = 'enforcedOrgs' -export const CONFIG_KEY_ORG = 'org' diff --git a/packages/cli/src/constants/env.mts b/packages/cli/src/constants/env.mts deleted file mode 100644 index 2b81b228d..000000000 --- a/packages/cli/src/constants/env.mts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Environment variable configuration using direct imports from env modules. - * This provides centralized access to environment variables without proxies. - */ - -import process, { env } from 'node:process' - -// Import CLI-specific env modules. -import { getCdxgenVersion } from '../env/cdxgen-version.mts' -import { CI } from '../env/ci.mts' -import { getCliHomepage } from '../env/cli-homepage.mts' -import { getCliName } from '../env/cli-name.mts' -import { getCliVersionHash } from '../env/cli-version-hash.mts' -import { getCliVersion } from '../env/cli-version.mts' -import { getCoanaVersion } from '../env/coana-version.mts' -import { DISABLE_GITHUB_CACHE } from '../env/disable-github-cache.mts' -import { GITHUB_API_URL } from '../env/github-api-url.mts' -import { GITHUB_BASE_REF } from '../env/github-base-ref.mts' -import { GITHUB_REF_NAME } from '../env/github-ref-name.mts' -import { GITHUB_REF_TYPE } from '../env/github-ref-type.mts' -import { GITHUB_REPOSITORY } from '../env/github-repository.mts' -import { GITHUB_SERVER_URL } from '../env/github-server-url.mts' -import { HOME } from '../env/home.mts' -import { isPublishedBuild } from '../env/is-published-build.mts' -import { isSentryBuild } from '../env/is-sentry-build.mts' -import { LOCALAPPDATA } from '../env/localappdata.mts' -import { NODE_ENV } from '../env/node-env.mts' -import { NODE_OPTIONS } from '../env/node-options.mts' -import { npm_config_cache } from '../env/npm-config-cache.mts' -import { npm_config_user_agent } from '../env/npm-config-user-agent.mts' -import { PREBUILT_NODE_DOWNLOAD_URL } from '../env/prebuilt-node-download-url.mts' -import { getPyCliVersion } from '../env/pycli-version.mts' -import { getPythonBuildTag } from '../env/python-build-tag.mts' -import { getPythonVersion } from '../env/python-version.mts' -import { RUN_E2E_TESTS } from '../env/run-e2e-tests.mts' -import { getSwfVersion } from '../env/sfw-version.mts' -import { SOCKET_CLI_ACCEPT_RISKS } from '../env/socket-cli-accept-risks.mts' -import { SOCKET_CLI_API_BASE_URL } from '../env/socket-cli-api-base-url.mts' -import { SOCKET_CLI_API_PROXY } from '../env/socket-cli-api-proxy.mts' -import { SOCKET_CLI_API_TIMEOUT } from '../env/socket-cli-api-timeout.mts' -import { SOCKET_CLI_API_TOKEN } from '../env/socket-cli-api-token.mts' -import { SOCKET_CLI_BIN_PATH } from '../env/socket-cli-bin-path.mts' -import { SOCKET_CLI_BOOTSTRAP_CACHE_DIR } from '../env/socket-cli-bootstrap-cache-dir.mts' -import { SOCKET_CLI_BOOTSTRAP_SPEC } from '../env/socket-cli-bootstrap-spec.mts' -import { SOCKET_CLI_CDXGEN_LOCAL_PATH } from '../env/socket-cli-cdxgen-local-path.mts' -import { SOCKET_CLI_COANA_LOCAL_PATH } from '../env/socket-cli-coana-local-path.mts' -import { SOCKET_CLI_CONFIG } from '../env/socket-cli-config.mts' -import { SOCKET_CLI_DEBUG } from '../env/socket-cli-debug.mts' -import { SOCKET_CLI_FIX } from '../env/socket-cli-fix.mts' -import { SOCKET_CLI_GIT_USER_EMAIL } from '../env/socket-cli-git-user-email.mts' -import { SOCKET_CLI_GIT_USER_NAME } from '../env/socket-cli-git-user-name.mts' -import { SOCKET_CLI_GITHUB_TOKEN } from '../env/socket-cli-github-token.mts' -import { SOCKET_CLI_JS_PATH } from '../env/socket-cli-js-path.mts' -import { SOCKET_CLI_LOCAL_NODE_SMOL } from '../env/socket-cli-local-node-smol.mts' -import { SOCKET_CLI_LOCAL_PATH } from '../env/socket-cli-local-path.mts' -import { SOCKET_CLI_MODE } from '../env/socket-cli-mode.mts' -import { SOCKET_CLI_MODELS_PATH } from '../env/socket-cli-models-path.mts' -import { SOCKET_CLI_NO_API_TOKEN } from '../env/socket-cli-no-api-token.mts' -import { SOCKET_CLI_NPM_PATH } from '../env/socket-cli-npm-path.mts' -import { SOCKET_CLI_OPTIMIZE } from '../env/socket-cli-optimize.mts' -import { SOCKET_CLI_ORG_SLUG } from '../env/socket-cli-org-slug.mts' -import { SOCKET_CLI_PYCLI_LOCAL_PATH } from '../env/socket-cli-pycli-local-path.mts' -import { SOCKET_CLI_PYTHON_PATH } from '../env/socket-cli-python-path.mts' -import { SOCKET_CLI_SEA_NODE_VERSION } from '../env/socket-cli-sea-node-version.mts' -import { SOCKET_CLI_SFW_LOCAL_PATH } from '../env/socket-cli-sfw-local-path.mts' -import { SOCKET_CLI_SKIP_UPDATE_CHECK } from '../env/socket-cli-skip-update-check.mts' -import { SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH } from '../env/socket-cli-socket-patch-local-path.mts' -import { SOCKET_CLI_VIEW_ALL_RISKS } from '../env/socket-cli-view-all-risks.mts' -import { getSocketPatchVersion } from '../env/socket-patch-version.mts' -import { getSynpVersion } from '../env/synp-version.mts' -import { TEMP } from '../env/temp.mts' -import { TERM } from '../env/term.mts' -import { TMP } from '../env/tmp.mts' -import { USERPROFILE } from '../env/userprofile.mts' -import { VITEST } from '../env/vitest.mts' -import { XDG_CACHE_HOME } from '../env/xdg-cache-home.mts' -import { XDG_DATA_HOME } from '../env/xdg-data-home.mts' - -// Import build metadata getter functions. - -// Re-export CLI-specific env variables. -export { - CI, - DISABLE_GITHUB_CACHE, - GITHUB_API_URL, - GITHUB_BASE_REF, - GITHUB_REF_NAME, - GITHUB_REF_TYPE, - GITHUB_REPOSITORY, - GITHUB_SERVER_URL, - HOME, - LOCALAPPDATA, - NODE_ENV, - NODE_OPTIONS, - npm_config_cache, - npm_config_user_agent, - PREBUILT_NODE_DOWNLOAD_URL, - RUN_E2E_TESTS, - SOCKET_CLI_ACCEPT_RISKS, - SOCKET_CLI_API_BASE_URL, - SOCKET_CLI_API_PROXY, - SOCKET_CLI_API_TIMEOUT, - SOCKET_CLI_API_TOKEN, - SOCKET_CLI_BIN_PATH, - SOCKET_CLI_BOOTSTRAP_CACHE_DIR, - SOCKET_CLI_BOOTSTRAP_SPEC, - SOCKET_CLI_CDXGEN_LOCAL_PATH, - SOCKET_CLI_COANA_LOCAL_PATH, - SOCKET_CLI_CONFIG, - SOCKET_CLI_DEBUG, - SOCKET_CLI_FIX, - SOCKET_CLI_GIT_USER_EMAIL, - SOCKET_CLI_GIT_USER_NAME, - SOCKET_CLI_GITHUB_TOKEN, - SOCKET_CLI_JS_PATH, - SOCKET_CLI_LOCAL_NODE_SMOL, - SOCKET_CLI_LOCAL_PATH, - SOCKET_CLI_MODE, - SOCKET_CLI_MODELS_PATH, - SOCKET_CLI_NO_API_TOKEN, - SOCKET_CLI_NPM_PATH, - SOCKET_CLI_OPTIMIZE, - SOCKET_CLI_ORG_SLUG, - SOCKET_CLI_PYCLI_LOCAL_PATH, - SOCKET_CLI_PYTHON_PATH, - SOCKET_CLI_SEA_NODE_VERSION, - SOCKET_CLI_SFW_LOCAL_PATH, - SOCKET_CLI_SKIP_UPDATE_CHECK, - SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH, - SOCKET_CLI_VIEW_ALL_RISKS, - TEMP, - TERM, - TMP, - USERPROFILE, - VITEST, - XDG_CACHE_HOME, - XDG_DATA_HOME, -} - -// Re-export build metadata getter functions. -export { - getCdxgenVersion, - getCliHomepage, - getCliName, - getCliVersion, - getCliVersionHash, - getCoanaVersion, - getPyCliVersion, - getPythonBuildTag, - getPythonVersion, - getSocketPatchVersion, - getSynpVersion, - isPublishedBuild, - isSentryBuild, -} - -// Export processEnv for spawned processes that need environment access. -export const processEnv = env - -// Legacy default export. -// Note: Use named exports (processEnv) for new code. - -// Create a snapshot of environment variables for production use. -const envSnapshot = { - CI, - DISABLE_GITHUB_CACHE, - GITHUB_API_URL, - GITHUB_BASE_REF, - GITHUB_REF_NAME, - GITHUB_REF_TYPE, - GITHUB_REPOSITORY, - GITHUB_SERVER_URL, - HOME, - LOCALAPPDATA, - NODE_ENV, - NODE_OPTIONS, - npm_config_cache, - npm_config_user_agent, - PREBUILT_NODE_DOWNLOAD_URL, - RUN_E2E_TESTS, - SOCKET_CLI_ACCEPT_RISKS, - SOCKET_CLI_API_BASE_URL, - SOCKET_CLI_API_PROXY, - SOCKET_CLI_API_TIMEOUT, - SOCKET_CLI_API_TOKEN, - SOCKET_CLI_BIN_PATH, - SOCKET_CLI_BOOTSTRAP_CACHE_DIR, - SOCKET_CLI_BOOTSTRAP_SPEC, - SOCKET_CLI_CDXGEN_LOCAL_PATH, - SOCKET_CLI_COANA_LOCAL_PATH, - SOCKET_CLI_CONFIG, - SOCKET_CLI_DEBUG, - SOCKET_CLI_FIX, - SOCKET_CLI_GIT_USER_EMAIL, - SOCKET_CLI_GIT_USER_NAME, - SOCKET_CLI_GITHUB_TOKEN, - SOCKET_CLI_JS_PATH, - SOCKET_CLI_LOCAL_NODE_SMOL, - SOCKET_CLI_LOCAL_PATH, - SOCKET_CLI_MODE, - SOCKET_CLI_MODELS_PATH, - SOCKET_CLI_NO_API_TOKEN, - SOCKET_CLI_NPM_PATH, - SOCKET_CLI_OPTIMIZE, - SOCKET_CLI_ORG_SLUG, - SOCKET_CLI_PYCLI_LOCAL_PATH, - SOCKET_CLI_PYTHON_PATH, - SOCKET_CLI_SEA_NODE_VERSION, - SOCKET_CLI_SFW_LOCAL_PATH, - SOCKET_CLI_SKIP_UPDATE_CHECK, - SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH, - SOCKET_CLI_VIEW_ALL_RISKS, - TEMP, - TERM, - TMP, - USERPROFILE, - VITEST, - XDG_CACHE_HOME, - XDG_DATA_HOME, - // Build metadata (inlined by esbuild define). - INLINED_CDXGEN_VERSION: getCdxgenVersion(), - INLINED_COANA_VERSION: getCoanaVersion(), - INLINED_CYCLONEDX_CDXGEN_VERSION: getCdxgenVersion(), - INLINED_HOMEPAGE: getCliHomepage(), - INLINED_NAME: getCliName(), - INLINED_PUBLISHED_BUILD: isPublishedBuild(), - INLINED_PYTHON_BUILD_TAG: getPythonBuildTag(), - INLINED_PYTHON_VERSION: getPythonVersion(), - INLINED_PYCLI_VERSION: getPyCliVersion(), - INLINED_SENTRY_BUILD: isSentryBuild(), - INLINED_SFW_VERSION: getSwfVersion(), - INLINED_SOCKET_PATCH_VERSION: getSocketPatchVersion(), - INLINED_SYNP_VERSION: getSynpVersion(), - INLINED_VERSION: getCliVersion(), - INLINED_VERSION_HASH: getCliVersionHash(), -} - -// Create a Proxy that uses live process.env in VITEST mode and snapshot in production. -// This allows tests to manipulate process.env and see those changes reflected in ENV, -// while production builds use the more efficient snapshot. -// Check if we're in VITEST mode once at module load time. -const isVitestMode = !!VITEST - -const ENV = new Proxy(envSnapshot, { - get(target, prop) { - // In VITEST mode, prefer process.env for dynamic test scenarios. - // Fall back to snapshot for build-time values (INLINED_*) and other non-env properties. - if (isVitestMode && typeof prop === 'string') { - // Check if the property exists in process.env. - // If it does, use it (allows tests to manipulate env vars). - // If not, fall back to snapshot (for INLINED_* and other values). - if (prop in process.env) { - return process.env[prop] - } - } - /* c8 ignore start - vitest sets all INLINED_* in process.env so the snapshot-fallback path is never reached in tests */ - return Reflect.get(target, prop) - /* c8 ignore stop */ - }, - has(target, prop) { - if (isVitestMode && typeof prop === 'string') { - return prop in process.env || Reflect.has(target, prop) - } - /* c8 ignore start - non-vitest fallback unreachable from tests */ - return Reflect.has(target, prop) - /* c8 ignore stop */ - }, - ownKeys(target) { - if (isVitestMode) { - // Merge keys from both process.env and snapshot. - const envKeys = Reflect.ownKeys(process.env) - const snapshotKeys = Reflect.ownKeys(target) - return [...new Set([...envKeys, ...snapshotKeys])] - } - /* c8 ignore start - non-vitest fallback unreachable from tests */ - return Reflect.ownKeys(target) - /* c8 ignore stop */ - }, - getOwnPropertyDescriptor(target, prop) { - if (isVitestMode && typeof prop === 'string') { - if (prop in process.env) { - return { - configurable: true, - enumerable: true, - writable: true, - value: process.env[prop], - } - } - } - return Reflect.getOwnPropertyDescriptor(target, prop) - }, - set(_target, prop, value) { - // In VITEST mode, allow setting values to process.env. - // This enables tests to modify environment variables dynamically. - if (isVitestMode && typeof prop === 'string') { - process.env[prop] = value - return true - } - /* c8 ignore start - non-vitest path; production ENV is read-only */ - return false - /* c8 ignore stop */ - }, -}) - -// Named export for ES module imports. -export { ENV } diff --git a/packages/cli/src/constants/errors.mts b/packages/cli/src/constants/errors.mts deleted file mode 100644 index f9df328f0..000000000 --- a/packages/cli/src/constants/errors.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Error message constants for Socket CLI. - */ - -export const ERROR_NO_MANIFEST_FILES = 'No manifest files found' -export const ERROR_NO_PACKAGE_JSON = 'No package.json found' -export const ERROR_NO_REPO_FOUND = 'No repo found' -export const ERROR_NO_SOCKET_DIR = 'No .socket directory found' -export const ERROR_UNABLE_RESOLVE_ORG = - 'Unable to resolve a Socket account organization' - -/** - * Sentinel value to detect infinite loops during tree traversal. Used as a - * safety check when walking dependency trees. - */ -export const LOOP_SENTINEL = 50_000 diff --git a/packages/cli/src/constants/github.mts b/packages/cli/src/constants/github.mts deleted file mode 100644 index 0a3aba716..000000000 --- a/packages/cli/src/constants/github.mts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * GitHub and GraphQL constants specific to Socket CLI. - */ - -// GraphQL Pagination -export const GQL_PAGE_SENTINEL = 100 - -// GraphQL PR States -export const GQL_PR_STATE_CLOSED = 'CLOSED' -export const GQL_PR_STATE_MERGED = 'MERGED' -export const GQL_PR_STATE_OPEN = 'OPEN' - -// Socket CLI GitHub Repository -export const SOCKET_CLI_GITHUB_REPO = 'socket-cli' diff --git a/packages/cli/src/constants/http.mts b/packages/cli/src/constants/http.mts deleted file mode 100644 index e26c6fc64..000000000 --- a/packages/cli/src/constants/http.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * HTTP status code constants and registry URLs. - */ - -// Re-export NPM registry URL from registry for backward compatibility. -export { NPM_REGISTRY_URL } from '@socketsecurity/lib-stable/constants/agents' - -export const HTTP_STATUS_BAD_REQUEST = 400 -export const HTTP_STATUS_UNAUTHORIZED = 401 -export const HTTP_STATUS_FORBIDDEN = 403 -export const HTTP_STATUS_NOT_FOUND = 404 -export const HTTP_STATUS_TOO_MANY_REQUESTS = 429 -export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 diff --git a/packages/cli/src/constants/packages.mts b/packages/cli/src/constants/packages.mts deleted file mode 100644 index ef92b742f..000000000 --- a/packages/cli/src/constants/packages.mts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Package and binary name constants for Socket CLI. - */ - -// Re-export lockfile constants from registry -export { - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - YARN_LOCK, -} from '@socketsecurity/lib-stable/constants/agents' - -// Package manifest files -export const PACKAGE_JSON = 'package.json' - -// Directory names -export const NODE_MODULES = 'node_modules' - -// File extensions -export const EXT_LOCK = '.lock' -export const EXT_LOCKB = '.lockb' - -// NPM Package Versions (CLI-specific) -export const NPM_BUGGY_OVERRIDES_PATCHED_VERSION = '11.2.0' - -// External Package Names -export const BLESSED = 'blessed' -export const BLESSED_CONTRIB = 'blessed-contrib' -export const SENTRY_NODE = '@sentry/node' -export const SOCKET_SECURITY_REGISTRY = '@socketsecurity/registry-stable' - -// Socket CLI Package Names -export const SOCKET_CLI_PACKAGE_NAME = 'socket' -export const SOCKET_CLI_LEGACY_PACKAGE_NAME = 'socket-npm' -export const SOCKET_CLI_SENTRY_PACKAGE_NAME = '@socketsecurity/cli-with-sentry' - -// Socket CLI Binary Names -export const SOCKET_CLI_BIN_NAME = 'socket' -export const SOCKET_CLI_BIN_NAME_ALIAS = 'socket-dev' -export const SOCKET_CLI_NPM_BIN_NAME = 'socket-npm' -export const SOCKET_CLI_NPX_BIN_NAME = 'socket-npx' -export const SOCKET_CLI_PNPM_BIN_NAME = 'socket-pnpm' -export const SOCKET_CLI_YARN_BIN_NAME = 'socket-yarn' - -// Socket CLI Sentry Binary Names -export const SOCKET_CLI_SENTRY_BIN_NAME = '@socketsecurity/cli-with-sentry' -export const SOCKET_CLI_SENTRY_BIN_NAME_ALIAS = 'socket-dev-with-sentry' -export const SOCKET_CLI_SENTRY_NPM_BIN_NAME = - '@socketsecurity/cli-with-sentry-npm' -export const SOCKET_CLI_SENTRY_NPX_BIN_NAME = - '@socketsecurity/cli-with-sentry-npx' -export const SOCKET_CLI_SENTRY_PNPM_BIN_NAME = - '@socketsecurity/cli-with-sentry-pnpm' -export const SOCKET_CLI_SENTRY_YARN_BIN_NAME = - '@socketsecurity/cli-with-sentry-yarn' - -// Descriptions -export const SOCKET_DESCRIPTION = 'CLI for Socket.dev' -export const SOCKET_DESCRIPTION_WITH_SENTRY = `${SOCKET_DESCRIPTION}, includes Sentry error handling` - -// Python minimum version. -export const PYTHON_MIN_VERSION = '3.9.0' diff --git a/packages/cli/src/constants/paths.mts b/packages/cli/src/constants/paths.mts deleted file mode 100644 index 8b652866f..000000000 --- a/packages/cli/src/constants/paths.mts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * File path and directory constants for Socket CLI. Consolidates both static - * paths and lazy-loaded path computations. - */ - -import { realpathSync } from 'node:fs' -import { createRequire } from 'node:module' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - getExecPath, - getNodeHardenFlags, - getNodeNoWarningsFlags, -} from '@socketsecurity/lib-stable/constants/node' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib-stable/paths/dirnames' - -import { ENV } from './env.mts' - -// Import socket constants for re-export. -import { SOCKET_JSON } from './socket.mts' - -// Re-export socket constants for backward compatibility. -export { SOCKET_JSON } - -// Re-export node-related constants from registry for convenience. -export { getExecPath, getNodeHardenFlags, getNodeNoWarningsFlags } - -// Export as non-function constants for backward compatibility. -export const execPath = getExecPath() -export const nodeHardenFlags = getNodeHardenFlags() -export const nodeNoWarningsFlags = getNodeNoWarningsFlags() - -// Get base paths relative to this file's location -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Static Base Paths (eagerly computed) -// In unified build, this file is bundled into dist/cli.js or build/cli.js, so __dirname will be the dist or build directory. -// In normal build, this file stays in src/constants, so __dirname is src/constants. -export const srcPath = path.resolve(__dirname, '..') -// If __dirname ends with 'dist' or 'build', we're in the bundled CLI, so rootPath is srcPath (one level up from dist/build). -// Otherwise, we're in source code where srcPath is 'src', so rootPath is one level up from src. -export const rootPath = - __dirname.endsWith('dist') || __dirname.endsWith('build') - ? srcPath - : path.resolve(srcPath, '..') -export const distPath = path.join(rootPath, 'dist') -export const configPath = path.join(rootPath, '.config') -export const externalPath = path.join(rootPath, 'external') -export const homePath = os.homedir() - -// Configuration File Names (CLI-specific) -export const ENVIRONMENT_YAML = 'environment.yaml' -export const ENVIRONMENT_YML = 'environment.yml' -export const REQUIREMENTS_TXT = 'requirements.txt' - -// Lockfile Names (CLI-specific) -export const PACKAGE_LOCK_JSON = 'package-lock.json' -export const PNPM_LOCK_YAML = 'pnpm-lock.yaml' -export const YARN_LOCK = 'yarn.lock' - -// Directory Names (CLI-specific) -export const UPDATE_STORE_DIR = '.socket/_dlx' - -// Derived Paths (CLI-specific) -export const DOT_SOCKET_DOT_FACTS_JSON = `${DOT_SOCKET_DIR}.facts.json` - -// Update Store -export const UPDATE_STORE_FILE_NAME = '.dlx-manifest.json' - -// Lazy Path Getters (computed on first access) - -// Package Manager Resolution Paths -// Helper for creating require. -const require = createRequire(import.meta.url) - -export function getBashRcPath(): string { - return path.join(os.homedir(), '.bashrc') -} - -export function getBinCliPath(): string { - // Allow overriding CLI binary path for testing built binaries (SEA, yao-pkg, etc). - const binPath = ENV.SOCKET_CLI_BIN_PATH - if (binPath) { - // Resolve relative paths against project root to support cwd changes in tests. - return path.isAbsolute(binPath) ? binPath : path.join(rootPath, binPath) - } - /* c8 ignore start - .env.test always sets SOCKET_CLI_BIN_PATH so the fallback is unreachable in unit tests */ - return path.join(rootPath, 'dist/index.js') - /* c8 ignore stop */ -} - -export function getBinPath(): string { - return path.join(rootPath, 'bin') -} - -export function getBlessedContribPath(): string { - return path.join(externalPath, 'blessed-contrib') -} - -export function getBlessedOptions() { - const blessedColorDepth = (ENV.TERM ?? '').includes('256color') ? 256 : 8 - return { - __proto__: null, - fullUnicode: true, - // https://github.com/chjj/blessed/issues/327 - titleShrink: true, - // See https://github.com/chjj/blessed/pull/219 - input: process.stdin, - output: process.stdout, - terminal: blessedColorDepth === 256 ? 'xterm-256color' : 'xterm', - } -} - -export function getBlessedPath(): string { - return path.join(externalPath, 'blessed') -} - -export function getDistBinPath(): string { - return path.join(distPath, 'bin') -} - -export function getDistPackageJsonPath(): string { - return path.join(distPath, 'package.json') -} - -export function getDistPath(): string { - return distPath -} - -export function getGithubCachePath(): string { - return path.join(getSocketCachePath(), 'github') -} - -export function getNmBunPath(): string | undefined { - try { - return realpathSync(require.resolve('bun/package.json')) - } catch { - return undefined - } -} - -export function getNmNodeGypPath(): string | undefined { - try { - /* c8 ignore start - node-gyp is not installed in tests; require.resolve throws before realpathSync runs */ - return realpathSync(require.resolve('node-gyp/package.json')) - /* c8 ignore stop */ - } catch { - return undefined - } -} - -export function getNmNpmPath(): string { - try { - return realpathSync(require.resolve('npm/package.json')) - } catch { - return 'npm' - } -} - -export function getNmPnpmPath(): string | undefined { - try { - return realpathSync(require.resolve('pnpm/package.json')) - } catch { - return undefined - } -} - -export function getNmYarnPath(): string | undefined { - try { - return realpathSync(require.resolve('yarn/package.json')) - } catch { - return undefined - } -} - -export function getPackageJsonPath(): string { - return path.join(rootPath, 'package.json') -} - -export function getSocketAppDataPath(): string | undefined { - // Get the OS app data directory: - // - Win: %LOCALAPPDATA% or fallback to %USERPROFILE%/AppData/Local - // - Mac: %XDG_DATA_HOME% or fallback to "~/Library/Application Support/" - // - Linux: %XDG_DATA_HOME% or fallback to "~/.local/share/" - // Note: LOCALAPPDATA typically points to user's AppData\Local directory. - // Note: XDG stands for "X Desktop Group", nowadays "freedesktop.org" - // On most systems that path is: $HOME/.local/share - // Then append `socket/settings`, so: - // - Win: %LOCALAPPDATA%\socket\settings or %USERPROFILE%\AppData\Local\socket\settings - // - Mac: %XDG_DATA_HOME%/socket/settings or "~/Library/Application Support/socket/settings" - // - Linux: %XDG_DATA_HOME%/socket/settings or "~/.local/share/socket/settings" - const isWin32 = process.platform === 'win32' - let dataHome: string | undefined = isWin32 - ? ENV.LOCALAPPDATA - : ENV.XDG_DATA_HOME - if (!dataHome) { - const home = os.homedir() - /* c8 ignore start - WIN32-only fallback when LOCALAPPDATA env var missing; tests run on macOS/Linux */ - if (isWin32) { - dataHome = path.join(home, 'AppData', 'Local') - const logger = getDefaultLogger() - logger.warn('LOCALAPPDATA not set, using fallback path.') - /* c8 ignore stop */ - } else { - const isDarwin = process.platform === 'darwin' - dataHome = path.join( - home, - isDarwin ? 'Library/Application Support' : '.local/share', - ) - } - } - return dataHome ? path.join(dataHome, 'socket', 'settings') : undefined -} - -export function getSocketCachePath(): string { - const xdgCacheHome = ENV.XDG_CACHE_HOME - if (xdgCacheHome) { - return path.join(xdgCacheHome, 'socket') - } - const platform = process.platform - const home = os.homedir() - switch (platform) { - case 'darwin': - return path.join(home, 'Library', 'Caches', 'socket') - case 'win32': { - const tempDir = - ENV.TEMP || ENV.TMP || path.join(home, 'AppData', 'Local', 'Temp') - return path.join(tempDir, 'socket') - } - default: - return path.join(home, '.cache', 'socket') - } -} - -export function getSocketRegistryPath(): string { - const appDataPath = getSocketAppDataPath() - /* c8 ignore start - HOME/USERPROFILE/LOCALAPPDATA/XDG_DATA_HOME all unset is essentially impossible in any real environment */ - if (!appDataPath) { - throw new Error( - `could not determine the Socket app-data directory: getSocketAppDataPath() returned undefined because none of HOME, USERPROFILE, LOCALAPPDATA, or XDG_DATA_HOME are set; export one of those env vars (typically HOME on macOS/Linux or LOCALAPPDATA on Windows) and retry`, - ) - } - /* c8 ignore stop */ - return path.join(appDataPath, 'registry') -} - -export function getZshRcPath(): string { - return path.join(os.homedir(), '.zshrc') -} diff --git a/packages/cli/src/constants/reporting.mts b/packages/cli/src/constants/reporting.mts deleted file mode 100644 index 5b72ceb6b..000000000 --- a/packages/cli/src/constants/reporting.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Report level constants for security issue severity. - */ - -export const FOLD_SETTING_VERSION = 'version' -export const REPORT_LEVEL_DEFER = 'defer' -export const REPORT_LEVEL_ERROR = 'error' -export const REPORT_LEVEL_IGNORE = 'ignore' -export const REPORT_LEVEL_MONITOR = 'monitor' -export const REPORT_LEVEL_WARN = 'warn' diff --git a/packages/cli/src/constants/socket.mts b/packages/cli/src/constants/socket.mts deleted file mode 100644 index 65583a74e..000000000 --- a/packages/cli/src/constants/socket.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Socket.dev specific constants for the CLI (extends registry socket - * constants). - */ - -// Re-export NPM registry URL from registry for backward compatibility. -export { NPM_REGISTRY_URL } from '@socketsecurity/lib-stable/constants/agents' - -// Socket API URLs -export const API_V0_URL = 'https://api.socket.dev/v0/' -export const SOCKET_WEBSITE_URL = 'https://socket.dev' -export const SOCKET_DASHBOARD_URL = 'https://socket.dev/dashboard' -export const SOCKET_PRICING_URL = 'https://socket.dev/pricing' -export const SOCKET_SETTINGS_API_TOKENS_URL = - 'https://socket.dev/settings/api-tokens' -export const SOCKET_STATUS_URL = 'https://status.socket.dev' - -// Socket Configuration Files -export const SOCKET_JSON = 'socket.json' -export const SOCKET_YAML = 'socket.yaml' -export const SOCKET_YML = 'socket.yml' - -// Socket Repository Metadata -export const SOCKET_DEFAULT_BRANCH = 'socket-default-branch' -export const SOCKET_DEFAULT_REPOSITORY = 'socket-default-repository' - -// Socket Scan Types -export const SCAN_TYPE_SOCKET = 'socket' -export const SCAN_TYPE_SOCKET_TIER1 = 'socket_tier1' - -// Token -export const TOKEN_PREFIX = 'sktsec_' -export const TOKEN_PREFIX_LENGTH = TOKEN_PREFIX.length - -// Documentation -export const V1_MIGRATION_GUIDE_URL = - 'https://docs.socket.dev/docs/v1-migration-guide' - -// GitHub -export const SOCKET_CLI_ISSUES_URL = - 'https://github.com/SocketDev/socket-cli/issues' diff --git a/packages/cli/src/constants/types.mts b/packages/cli/src/constants/types.mts deleted file mode 100644 index 82c6ee447..000000000 --- a/packages/cli/src/constants/types.mts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Type definitions for constants module. - */ - -import type { ENV } from './env.mts' - -// Re-export platform constants from registry -export { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -export type { Remap } from '@socketsecurity/lib-stable/objects/types' -export type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' -export type { Agent } from '../util/ecosystem/environment.mjs' - -export type RegistryEnv = typeof ENV - -export type ProcessEnv = { - [K in keyof typeof ENV]?: string | undefined -} diff --git a/packages/cli/src/env/cdxgen-version.mts b/packages/cli/src/env/cdxgen-version.mts deleted file mode 100644 index 73cc76469..000000000 --- a/packages/cli/src/env/cdxgen-version.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * CDXGen version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getCdxgenVersion(): string { - return process.env['INLINED_CYCLONEDX_CDXGEN_VERSION']! -} diff --git a/packages/cli/src/env/checksum-utils.mts b/packages/cli/src/env/checksum-utils.mts deleted file mode 100644 index a79ef1403..000000000 --- a/packages/cli/src/env/checksum-utils.mts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Shared utilities for checksum modules. - * - * NOTE: Each tool-specific module MUST use direct - * process.env['INLINED_*_CHECKSUMS'] access because esbuild's define plugin can - * only replace direct references. This module provides shared parsing and - * validation logic. - */ - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' - -export type Checksums = Record<string, string> - -/** - * Parse checksums from a JSON string. Returns empty object if parsing fails or - * input is empty. - * - * @param jsonString - JSON string of checksums (or undefined/empty). - * @param toolName - Tool name for error messages. - * - * @returns Parsed checksums or empty object. - * - * @throws Error if JSON is malformed (not empty). - */ -export function parseChecksums( - jsonString: string | undefined, - toolName: string, -): Checksums { - if (!jsonString) { - // In development mode (not inlined), return empty object. - // Build validation will catch missing checksums at build time. - return {} - } - try { - return JSON.parse(jsonString) as Checksums - } catch (e) { - throw new Error( - `inlined checksums for ${toolName} are not valid JSON at runtime (JSON.parse threw: ${e instanceof Error ? e.message : String(e)}); the build-time inline step produced corrupt data — rebuild socket-cli (\`pnpm run build:cli\`) and verify the matching checksums entry in bundle-tools.json`, - ) - } -} - -/** - * Require a checksum for an asset. In production builds (checksums inlined), - * throws a hard error if asset is missing. In dev mode (checksums not inlined), - * returns undefined to allow development. - * - * @param checksums - Parsed checksums object. - * @param assetName - The asset filename to look up. - * @param toolName - Tool name for error messages. - * - * @returns The SHA-256 hex checksum, or undefined in dev mode. - * - * @throws Error if checksum is not found in production builds. - */ -export function requireChecksum( - checksums: Checksums, - assetName: string, - toolName: string, -): string | undefined { - // In dev mode, checksums are not inlined so the object is empty. - // Allow downloads without verification during development. - if (Object.keys(checksums).length === 0) { - return undefined - } - - // In production mode, checksums are inlined. - // Require checksum for every asset - missing checksum is a HARD ERROR. - const sha256 = checksums[assetName] - if (!sha256) { - throw new Error( - `${toolName} has no SHA-256 checksum for asset "${assetName}" (known assets: ${joinAnd(Object.keys(checksums)) || '<empty>'}); add it to the matching entry in bundle-tools.json via \`pnpm run sync-checksums\` — do NOT ship without verification`, - ) - } - return sha256 -} diff --git a/packages/cli/src/env/ci.mts b/packages/cli/src/env/ci.mts deleted file mode 100644 index a5bf4662e..000000000 --- a/packages/cli/src/env/ci.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * CI environment variable. Set to true/1 when running in a continuous - * integration environment. - */ - -import { getCI } from '@socketsecurity/lib-stable/env/ci' - -export const CI = getCI() diff --git a/packages/cli/src/env/cli-homepage.mts b/packages/cli/src/env/cli-homepage.mts deleted file mode 100644 index 62cbcd1bd..000000000 --- a/packages/cli/src/env/cli-homepage.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * CLI homepage getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding build metadata into the binary. - */ - -import process from 'node:process' - -export function getCliHomepage(): string { - return process.env['INLINED_HOMEPAGE']! -} diff --git a/packages/cli/src/env/cli-name.mts b/packages/cli/src/env/cli-name.mts deleted file mode 100644 index 4b2601872..000000000 --- a/packages/cli/src/env/cli-name.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * CLI name getter function. Uses direct process.env access so esbuild define - * can inline values. IMPORTANT: esbuild's define plugin can only replace direct - * process.env['KEY'] references. If we imported from env modules, esbuild - * couldn't inline the values at build time. This is critical for embedding - * build metadata into the binary. - */ - -import process from 'node:process' - -export function getCliName(): string { - return process.env['INLINED_NAME']! -} diff --git a/packages/cli/src/env/cli-version-hash.mts b/packages/cli/src/env/cli-version-hash.mts deleted file mode 100644 index b6342fb18..000000000 --- a/packages/cli/src/env/cli-version-hash.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * CLI version hash getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getCliVersionHash(): string { - return process.env['INLINED_VERSION_HASH']! -} diff --git a/packages/cli/src/env/cli-version.mts b/packages/cli/src/env/cli-version.mts deleted file mode 100644 index 218742960..000000000 --- a/packages/cli/src/env/cli-version.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * CLI version getter function. Uses direct process.env access so esbuild define - * can inline values. IMPORTANT: esbuild's define plugin can only replace direct - * process.env['KEY'] references. If we imported from env modules, esbuild - * couldn't inline the values at build time. This is critical for embedding - * version info into the binary. - */ - -import process from 'node:process' - -export function getCliVersion(): string { - return process.env['INLINED_VERSION']! -} diff --git a/packages/cli/src/env/coana-version.mts b/packages/cli/src/env/coana-version.mts deleted file mode 100644 index c73a1a3eb..000000000 --- a/packages/cli/src/env/coana-version.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Coana version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getCoanaVersion(): string { - const version = process.env['INLINED_COANA_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_COANA_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools["@coana-tech/cli"].version — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/disable-github-cache.mts b/packages/cli/src/env/disable-github-cache.mts deleted file mode 100644 index 51ff7e406..000000000 --- a/packages/cli/src/env/disable-github-cache.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * DISABLE_GITHUB_CACHE environment variable snapshot. Disables GitHub API - * caching in Socket CLI. - */ - -import { env } from 'node:process' - -import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean' - -export const DISABLE_GITHUB_CACHE = envAsBoolean(env['DISABLE_GITHUB_CACHE']) diff --git a/packages/cli/src/env/github-api-url.mts b/packages/cli/src/env/github-api-url.mts deleted file mode 100644 index 8e00ad1c8..000000000 --- a/packages/cli/src/env/github-api-url.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_API_URL environment variable. - */ - -import { getGithubApiUrl } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_API_URL = getGithubApiUrl() diff --git a/packages/cli/src/env/github-base-ref.mts b/packages/cli/src/env/github-base-ref.mts deleted file mode 100644 index 488d77fe8..000000000 --- a/packages/cli/src/env/github-base-ref.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_BASE_REF environment variable. - */ - -import { getGithubBaseRef } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_BASE_REF = getGithubBaseRef() diff --git a/packages/cli/src/env/github-ref-name.mts b/packages/cli/src/env/github-ref-name.mts deleted file mode 100644 index 2e1b4af7b..000000000 --- a/packages/cli/src/env/github-ref-name.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_REF_NAME environment variable. - */ - -import { getGithubRefName } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_REF_NAME = getGithubRefName() diff --git a/packages/cli/src/env/github-ref-type.mts b/packages/cli/src/env/github-ref-type.mts deleted file mode 100644 index f74718161..000000000 --- a/packages/cli/src/env/github-ref-type.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_REF_TYPE environment variable. - */ - -import { getGithubRefType } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_REF_TYPE = getGithubRefType() diff --git a/packages/cli/src/env/github-repository.mts b/packages/cli/src/env/github-repository.mts deleted file mode 100644 index 6b43880b5..000000000 --- a/packages/cli/src/env/github-repository.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_REPOSITORY environment variable. - */ - -import { getGithubRepository } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_REPOSITORY = getGithubRepository() diff --git a/packages/cli/src/env/github-server-url.mts b/packages/cli/src/env/github-server-url.mts deleted file mode 100644 index c3b617ecd..000000000 --- a/packages/cli/src/env/github-server-url.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file GITHUB_SERVER_URL environment variable. - */ - -import { getGithubServerUrl } from '@socketsecurity/lib-stable/env/github' - -export const GITHUB_SERVER_URL = getGithubServerUrl() diff --git a/packages/cli/src/env/home.mts b/packages/cli/src/env/home.mts deleted file mode 100644 index a737469f3..000000000 --- a/packages/cli/src/env/home.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * HOME environment variable. User home directory (Unix systems). - */ - -import { getHome } from '@socketsecurity/lib-stable/env/home' - -export const HOME = getHome() diff --git a/packages/cli/src/env/is-published-build.mts b/packages/cli/src/env/is-published-build.mts deleted file mode 100644 index 9578a56e8..000000000 --- a/packages/cli/src/env/is-published-build.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Published build flag getter function. Uses direct process.env access so - * esbuild define can inline values. IMPORTANT: esbuild's define plugin can only - * replace direct process.env['KEY'] references. If we imported from env - * modules, esbuild couldn't inline the values at build time. This is critical - * for embedding build flags into the binary. - */ - -import process from 'node:process' - -import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean' - -export function isPublishedBuild(): boolean { - return envAsBoolean(process.env['INLINED_PUBLISHED_BUILD']) -} diff --git a/packages/cli/src/env/is-sentry-build.mts b/packages/cli/src/env/is-sentry-build.mts deleted file mode 100644 index ecf07a4a6..000000000 --- a/packages/cli/src/env/is-sentry-build.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Sentry build flag getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding build flags into the binary. - */ - -import process from 'node:process' - -import { envAsBoolean } from '@socketsecurity/lib-stable/env/boolean' - -export function isSentryBuild(): boolean { - return envAsBoolean(process.env['INLINED_SENTRY_BUILD']) -} diff --git a/packages/cli/src/env/localappdata.mts b/packages/cli/src/env/localappdata.mts deleted file mode 100644 index 302c2bc38..000000000 --- a/packages/cli/src/env/localappdata.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * LOCALAPPDATA environment variable. Local application data directory (Windows - * systems). - */ - -import { env } from 'node:process' - -export const LOCALAPPDATA = env['LOCALAPPDATA'] diff --git a/packages/cli/src/env/mcp-http-mode.mts b/packages/cli/src/env/mcp-http-mode.mts deleted file mode 100644 index dc68b3f8e..000000000 --- a/packages/cli/src/env/mcp-http-mode.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * MCP_HTTP_MODE environment variable. - * - * When set to the literal string "true", forces the `socket mcp` command to - * serve over HTTP instead of stdio. Useful in container / CI setups where stdio - * binding isn't available. - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getMcpHttpMode(): boolean { - return process.env['MCP_HTTP_MODE'] === 'true' -} diff --git a/packages/cli/src/env/mcp-port.mts b/packages/cli/src/env/mcp-port.mts deleted file mode 100644 index 21586c983..000000000 --- a/packages/cli/src/env/mcp-port.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * MCP_PORT environment variable. - * - * Port the `socket mcp` HTTP server should listen on when running in HTTP mode. - * Empty string when unset; the caller decides the default. - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getMcpPort(): string { - return process.env['MCP_PORT'] ?? '' -} diff --git a/packages/cli/src/env/node-env.mts b/packages/cli/src/env/node-env.mts deleted file mode 100644 index 637bf4825..000000000 --- a/packages/cli/src/env/node-env.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file NODE_ENV environment variable. - */ - -import { env } from 'node:process' - -export const NODE_ENV = env['NODE_ENV'] diff --git a/packages/cli/src/env/node-options.mts b/packages/cli/src/env/node-options.mts deleted file mode 100644 index 2d739b32b..000000000 --- a/packages/cli/src/env/node-options.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * NODE_OPTIONS environment variable snapshot. Used to pass options to Node.js - * runtime. - */ - -import { env } from 'node:process' - -export const NODE_OPTIONS = env['NODE_OPTIONS'] diff --git a/packages/cli/src/env/npm-config-cache.mts b/packages/cli/src/env/npm-config-cache.mts deleted file mode 100644 index 355782ce8..000000000 --- a/packages/cli/src/env/npm-config-cache.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Npm_config_cache environment variable snapshot. Points to the npm cache - * directory. - */ - -import { env } from 'node:process' - -export const npm_config_cache = env['npm_config_cache'] diff --git a/packages/cli/src/env/npm-config-user-agent.mts b/packages/cli/src/env/npm-config-user-agent.mts deleted file mode 100644 index 465c18516..000000000 --- a/packages/cli/src/env/npm-config-user-agent.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file Npm_config_user_agent environment variable. - */ - -import { getNpmConfigUserAgent } from '@socketsecurity/lib-stable/env/npm' - -export const npm_config_user_agent = getNpmConfigUserAgent() diff --git a/packages/cli/src/env/opengrep-checksums.mts b/packages/cli/src/env/opengrep-checksums.mts deleted file mode 100644 index 6a49fd7e3..000000000 --- a/packages/cli/src/env/opengrep-checksums.mts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * OpenGrep SHA-256 checksums getter function. Uses direct process.env access so - * esbuild define can inline values. IMPORTANT: esbuild's define plugin can only - * replace direct process.env['KEY'] references. - */ - -import process from 'node:process' - -import type { OpengrepChecksums } from '../types.mjs' - -import { parseChecksums, requireChecksum } from './checksum-utils.mjs' - -const TOOL_NAME = 'OpenGrep' - -/** - * Get OpenGrep checksums from inlined environment variable. Returns a map of - * asset filename to SHA-256 hex checksum. - */ -export function getOpengrepChecksums(): OpengrepChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums(process.env['INLINED_OPENGREP_CHECKSUMS'], TOOL_NAME) -} - -/** - * Lookup an OpenGrep checksum by asset name. In production builds, throws if - * asset is missing. In dev mode, returns undefined to allow development. - */ -export function requireOpengrepChecksum(assetName: string): string | undefined { - return requireChecksum(getOpengrepChecksums(), assetName, TOOL_NAME) -} diff --git a/packages/cli/src/env/opengrep-version.mts b/packages/cli/src/env/opengrep-version.mts deleted file mode 100644 index eec9153dd..000000000 --- a/packages/cli/src/env/opengrep-version.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * OpenGrep version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getOpengrepVersion(): string { - const version = process.env['INLINED_OPENGREP_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_OPENGREP_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.opengrep.version — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/prebuilt-node-download-url.mts b/packages/cli/src/env/prebuilt-node-download-url.mts deleted file mode 100644 index 4d355f623..000000000 --- a/packages/cli/src/env/prebuilt-node-download-url.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Base URL for downloading Node.js binaries for SEA builds. Default: - * 'socket-btm' (uses smol binaries from socket-btm releases) Can be set to - * 'https://nodejs.org/download/release' or custom URL. - */ - -import { env } from 'node:process' - -export const PREBUILT_NODE_DOWNLOAD_URL = - env['PREBUILT_NODE_DOWNLOAD_URL'] || 'socket-btm' diff --git a/packages/cli/src/env/pycli-checksums.mts b/packages/cli/src/env/pycli-checksums.mts deleted file mode 100644 index 5248db37e..000000000 --- a/packages/cli/src/env/pycli-checksums.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * PyCLI (socketsecurity) PyPI package SHA-256 checksums getter function. Uses - * direct process.env access so esbuild define can inline values. IMPORTANT: - * esbuild's define plugin can only replace direct process.env['KEY'] - * references. - */ - -import process from 'node:process' - -import type { PyCliChecksums } from '../types.mjs' - -import { parseChecksums } from './checksum-utils.mjs' - -const TOOL_NAME = 'PyCLI' - -/** - * Get PyCLI checksums from inlined environment variable. Returns a map of asset - * filename to SHA-256 hex checksum. - */ -export function getPyCliChecksums(): PyCliChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums(process.env['INLINED_PYCLI_CHECKSUMS'], TOOL_NAME) -} - diff --git a/packages/cli/src/env/pycli-version.mts b/packages/cli/src/env/pycli-version.mts deleted file mode 100644 index 900395e69..000000000 --- a/packages/cli/src/env/pycli-version.mts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * PyCLI version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -/** - * Get the Socket Python CLI version (socketsecurity package) that should be - * installed. This version is inlined at build time from bundle-tools.json. - * - * @returns Socket Python CLI version string (e.g., "0.8.0"). - * - * @throws Error if version is not inlined at build time. - */ -export function getPyCliVersion(): string { - const version = process.env['INLINED_PYCLI_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_PYCLI_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.socketsecurity.version (PyPI package) — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/python-build-tag.mts b/packages/cli/src/env/python-build-tag.mts deleted file mode 100644 index 0f605adc9..000000000 --- a/packages/cli/src/env/python-build-tag.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Python build tag getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding build metadata into the binary. - */ - -import process from 'node:process' - -export function getPythonBuildTag(): string { - return process.env['INLINED_PYTHON_BUILD_TAG']! -} diff --git a/packages/cli/src/env/python-checksums.mts b/packages/cli/src/env/python-checksums.mts deleted file mode 100644 index b22e8fa52..000000000 --- a/packages/cli/src/env/python-checksums.mts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Python SHA-256 checksums getter function. Uses direct process.env access so - * esbuild define can inline values. IMPORTANT: esbuild's define plugin can only - * replace direct process.env['KEY'] references. - */ - -import process from 'node:process' - -import type { PythonChecksums } from '../types.mjs' - -import { parseChecksums, requireChecksum } from './checksum-utils.mjs' - -const TOOL_NAME = 'Python' - -/** - * Get Python checksums from inlined environment variable. Returns a map of - * asset filename to SHA-256 hex checksum. - */ -export function getPythonChecksums(): PythonChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums(process.env['INLINED_PYTHON_CHECKSUMS'], TOOL_NAME) -} - -/** - * Lookup a Python checksum by asset name. In production builds, throws if asset - * is missing. In dev mode, returns undefined to allow development. - */ -export function requirePythonChecksum(assetName: string): string | undefined { - return requireChecksum(getPythonChecksums(), assetName, TOOL_NAME) -} diff --git a/packages/cli/src/env/python-version.mts b/packages/cli/src/env/python-version.mts deleted file mode 100644 index b60a5dd89..000000000 --- a/packages/cli/src/env/python-version.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Python version getter functions. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -/** - * Get the full Python version (e.g., "3.11.14"). - */ -export function getPythonVersion(): string { - return process.env['INLINED_PYTHON_VERSION']! -} diff --git a/packages/cli/src/env/run-e2e-tests.mts b/packages/cli/src/env/run-e2e-tests.mts deleted file mode 100644 index 3a053b7ab..000000000 --- a/packages/cli/src/env/run-e2e-tests.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * RUN_E2E_TESTS environment variable. Set to enable end-to-end tests that - * require Socket API access. - */ - -import { env } from 'node:process' - -export const RUN_E2E_TESTS = env['RUN_E2E_TESTS'] diff --git a/packages/cli/src/env/run-integration-tests.mts b/packages/cli/src/env/run-integration-tests.mts deleted file mode 100644 index ba1ae637b..000000000 --- a/packages/cli/src/env/run-integration-tests.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * RUN_INTEGRATION_TESTS environment variable. Set to enable integration tests - * that require Socket API access. - */ - -import { env } from 'node:process' - -export const RUN_INTEGRATION_TESTS = env['RUN_INTEGRATION_TESTS'] diff --git a/packages/cli/src/env/sfw-version.mts b/packages/cli/src/env/sfw-version.mts deleted file mode 100644 index 7cee006b5..000000000 --- a/packages/cli/src/env/sfw-version.mts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Socket Firewall (sfw) version getter functions. Uses direct process.env - * access so esbuild define can inline values. IMPORTANT: esbuild's define - * plugin can only replace direct process.env['KEY'] references. If we imported - * from env modules, esbuild couldn't inline the values at build time. This is - * critical for embedding version info into the binary. - * - * Sfw uses two different distributions: - GitHub binary (SocketDev/sfw-free): - * Used for SEA builds, version like "v1.6.1" - npm package (sfw): Used for CLI - * dlx, version like "2.0.4" - */ - -import process from 'node:process' - -/** - * Get the npm package version for sfw (used in CLI dlx). - */ -export function getSfwNpmVersion(): string { - const version = process.env['INLINED_SFW_NPM_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_SFW_NPM_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.sfw.npm.version (npm package semver) — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} - -/** - * Get the GitHub release version for sfw (used in SEA builds). - */ -export function getSwfVersion(): string { - const version = process.env['INLINED_SFW_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_SFW_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.sfw.version (GitHub release tag) — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/socket-cli-accept-risks.mts b/packages/cli/src/env/socket-cli-accept-risks.mts deleted file mode 100644 index d5257e61a..000000000 --- a/packages/cli/src/env/socket-cli-accept-risks.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_ACCEPT_RISKS environment variable. - */ - -import { getSocketCliAcceptRisks } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_ACCEPT_RISKS = getSocketCliAcceptRisks() diff --git a/packages/cli/src/env/socket-cli-api-base-url.mts b/packages/cli/src/env/socket-cli-api-base-url.mts deleted file mode 100644 index aa05a7c96..000000000 --- a/packages/cli/src/env/socket-cli-api-base-url.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_API_BASE_URL environment variable. - */ - -import { getSocketCliApiBaseUrl } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_API_BASE_URL = getSocketCliApiBaseUrl() diff --git a/packages/cli/src/env/socket-cli-api-proxy.mts b/packages/cli/src/env/socket-cli-api-proxy.mts deleted file mode 100644 index f4842ce5a..000000000 --- a/packages/cli/src/env/socket-cli-api-proxy.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_API_PROXY environment variable. - */ - -import { getSocketCliApiProxy } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_API_PROXY = getSocketCliApiProxy() diff --git a/packages/cli/src/env/socket-cli-api-timeout.mts b/packages/cli/src/env/socket-cli-api-timeout.mts deleted file mode 100644 index bcfd3c63e..000000000 --- a/packages/cli/src/env/socket-cli-api-timeout.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_API_TIMEOUT environment variable. - */ - -import { getSocketCliApiTimeout } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_API_TIMEOUT = getSocketCliApiTimeout() diff --git a/packages/cli/src/env/socket-cli-api-token.mts b/packages/cli/src/env/socket-cli-api-token.mts deleted file mode 100644 index 364339edd..000000000 --- a/packages/cli/src/env/socket-cli-api-token.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_API_TOKEN environment variable. - */ - -import { getSocketApiToken } from '@socketsecurity/lib-stable/env/socket' - -export const SOCKET_CLI_API_TOKEN = getSocketApiToken() diff --git a/packages/cli/src/env/socket-cli-bin-path.mts b/packages/cli/src/env/socket-cli-bin-path.mts deleted file mode 100644 index 89c0944d4..000000000 --- a/packages/cli/src/env/socket-cli-bin-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_BIN_PATH environment variable snapshot. Overrides the default - * Socket CLI binary path. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_BIN_PATH = env['SOCKET_CLI_BIN_PATH'] diff --git a/packages/cli/src/env/socket-cli-bootstrap-cache-dir.mts b/packages/cli/src/env/socket-cli-bootstrap-cache-dir.mts deleted file mode 100644 index fdf473029..000000000 --- a/packages/cli/src/env/socket-cli-bootstrap-cache-dir.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_BOOTSTRAP_CACHE_DIR environment variable. Cache directory path - * passed from bootstrap wrappers. - */ - -import { getSocketCliBootstrapCacheDir } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_BOOTSTRAP_CACHE_DIR = getSocketCliBootstrapCacheDir() diff --git a/packages/cli/src/env/socket-cli-bootstrap-spec.mts b/packages/cli/src/env/socket-cli-bootstrap-spec.mts deleted file mode 100644 index 4dcafa37b..000000000 --- a/packages/cli/src/env/socket-cli-bootstrap-spec.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_BOOTSTRAP_SPEC environment variable. Package spec passed from - * bootstrap wrappers (e.g., @socketsecurity/cli@^2.0.11). - */ - -import { getSocketCliBootstrapSpec } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_BOOTSTRAP_SPEC = getSocketCliBootstrapSpec() diff --git a/packages/cli/src/env/socket-cli-cdxgen-local-path.mts b/packages/cli/src/env/socket-cli-cdxgen-local-path.mts deleted file mode 100644 index c62f2c3dd..000000000 --- a/packages/cli/src/env/socket-cli-cdxgen-local-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Local path override for cdxgen binary. Useful for local development and - * testing with custom cdxgen builds. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_CDXGEN_LOCAL_PATH = env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-coana-local-path.mts b/packages/cli/src/env/socket-cli-coana-local-path.mts deleted file mode 100644 index 402d53245..000000000 --- a/packages/cli/src/env/socket-cli-coana-local-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_COANA_LOCAL_PATH environment variable snapshot. Overrides the - * default Coana CLI path for local development. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_COANA_LOCAL_PATH = env['SOCKET_CLI_COANA_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-config.mts b/packages/cli/src/env/socket-cli-config.mts deleted file mode 100644 index 7941d1b98..000000000 --- a/packages/cli/src/env/socket-cli-config.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_CONFIG environment variable. - */ - -import { getSocketCliConfig } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_CONFIG = getSocketCliConfig() diff --git a/packages/cli/src/env/socket-cli-debug.mts b/packages/cli/src/env/socket-cli-debug.mts deleted file mode 100644 index 97cf20307..000000000 --- a/packages/cli/src/env/socket-cli-debug.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_DEBUG environment variable snapshot. Controls Socket CLI-specific - * debug output. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_DEBUG = env['SOCKET_CLI_DEBUG'] diff --git a/packages/cli/src/env/socket-cli-fix.mts b/packages/cli/src/env/socket-cli-fix.mts deleted file mode 100644 index 10a46f4c6..000000000 --- a/packages/cli/src/env/socket-cli-fix.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_FIX environment variable snapshot. Enables automatic fix mode in - * Socket CLI. - */ - -import { getSocketCliFix } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_FIX = getSocketCliFix() diff --git a/packages/cli/src/env/socket-cli-git-user-email.mts b/packages/cli/src/env/socket-cli-git-user-email.mts deleted file mode 100644 index ea345dbf8..000000000 --- a/packages/cli/src/env/socket-cli-git-user-email.mts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * SOCKET_CLI_GIT_USER_EMAIL environment variable snapshot. Overrides git user - * email for Socket CLI operations. Falls back to - * 'github-actions[bot]@users.noreply.github.com' if not set. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_GIT_USER_EMAIL = - env['SOCKET_CLI_GIT_USER_EMAIL'] || - 'github-actions[bot]@users.noreply.github.com' diff --git a/packages/cli/src/env/socket-cli-git-user-name.mts b/packages/cli/src/env/socket-cli-git-user-name.mts deleted file mode 100644 index 84958996d..000000000 --- a/packages/cli/src/env/socket-cli-git-user-name.mts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * SOCKET_CLI_GIT_USER_NAME environment variable snapshot. Overrides git user - * name for Socket CLI operations. Checks SOCKET_CLI_GIT_USER_NAME, - * SOCKET_CLI_GIT_USERNAME, then falls back to 'github-actions[bot]'. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_GIT_USER_NAME = - env['SOCKET_CLI_GIT_USER_NAME'] || - env['SOCKET_CLI_GIT_USERNAME'] || - 'github-actions[bot]' diff --git a/packages/cli/src/env/socket-cli-github-token.mts b/packages/cli/src/env/socket-cli-github-token.mts deleted file mode 100644 index 5c80915e7..000000000 --- a/packages/cli/src/env/socket-cli-github-token.mts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * SOCKET_CLI_GITHUB_TOKEN environment variable snapshot. Overrides GitHub token - * for Socket CLI operations. Falls back to GITHUB_TOKEN, then GH_TOKEN if not - * set. - */ - -import { getSocketCliGithubToken } from '@socketsecurity/lib-stable/env/socket-cli' - -export function getGithubToken(): string { - // Try Socket-specific env var first. - const socketCliToken = getSocketCliGithubToken() - if (socketCliToken) { - return socketCliToken - } - - // Fall back to standard GitHub env vars. - return process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN'] || '' -} - -export const SOCKET_CLI_GITHUB_TOKEN = getGithubToken() diff --git a/packages/cli/src/env/socket-cli-js-path.mts b/packages/cli/src/env/socket-cli-js-path.mts deleted file mode 100644 index 696edc357..000000000 --- a/packages/cli/src/env/socket-cli-js-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_JS_PATH environment variable snapshot. Overrides the default - * Socket CLI JavaScript entry path. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_JS_PATH = env['SOCKET_CLI_JS_PATH'] diff --git a/packages/cli/src/env/socket-cli-local-node-smol.mts b/packages/cli/src/env/socket-cli-local-node-smol.mts deleted file mode 100644 index 762d85a50..000000000 --- a/packages/cli/src/env/socket-cli-local-node-smol.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Path to local node-smol binary for development. When set, this binary will be - * used instead of downloading from GitHub releases. The binary must exist at - * the specified path or a warning will be shown. - */ - -import process from 'node:process' - -export const SOCKET_CLI_LOCAL_NODE_SMOL: string | undefined = - process.env['SOCKET_CLI_LOCAL_NODE_SMOL'] diff --git a/packages/cli/src/env/socket-cli-local-path.mts b/packages/cli/src/env/socket-cli-local-path.mts deleted file mode 100644 index 8739ec508..000000000 --- a/packages/cli/src/env/socket-cli-local-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Local path override for Socket CLI binary. Useful for E2E testing different - * build variants (bin/cli.js, smol, SEA, etc). - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_LOCAL_PATH = env['SOCKET_CLI_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-mode.mts b/packages/cli/src/env/socket-cli-mode.mts deleted file mode 100644 index 35b919b5c..000000000 --- a/packages/cli/src/env/socket-cli-mode.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_MODE environment variable snapshot. Controls Socket CLI - * operational mode. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_MODE = env['SOCKET_CLI_MODE'] diff --git a/packages/cli/src/env/socket-cli-models-path.mts b/packages/cli/src/env/socket-cli-models-path.mts deleted file mode 100644 index a42c2c373..000000000 --- a/packages/cli/src/env/socket-cli-models-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_MODELS_PATH environment variable snapshot. Specifies the directory - * containing NLP model files (ONNX models and tokenizers). - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_MODELS_PATH = env['SOCKET_CLI_MODELS_PATH'] diff --git a/packages/cli/src/env/socket-cli-no-api-token.mts b/packages/cli/src/env/socket-cli-no-api-token.mts deleted file mode 100644 index c4f3eaf3f..000000000 --- a/packages/cli/src/env/socket-cli-no-api-token.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_NO_API_TOKEN environment variable. - */ - -import { getSocketCliNoApiToken } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_NO_API_TOKEN = getSocketCliNoApiToken() diff --git a/packages/cli/src/env/socket-cli-npm-path.mts b/packages/cli/src/env/socket-cli-npm-path.mts deleted file mode 100644 index ea73f8267..000000000 --- a/packages/cli/src/env/socket-cli-npm-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_NPM_PATH environment variable snapshot. Overrides the default npm - * binary path. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_NPM_PATH = env['SOCKET_CLI_NPM_PATH'] diff --git a/packages/cli/src/env/socket-cli-optimize.mts b/packages/cli/src/env/socket-cli-optimize.mts deleted file mode 100644 index 7911a68cc..000000000 --- a/packages/cli/src/env/socket-cli-optimize.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_OPTIMIZE environment variable snapshot. Enables automatic - * optimization mode in Socket CLI. - */ - -import { getSocketCliOptimize } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_OPTIMIZE = getSocketCliOptimize() diff --git a/packages/cli/src/env/socket-cli-org-slug.mts b/packages/cli/src/env/socket-cli-org-slug.mts deleted file mode 100644 index 3d80fa16f..000000000 --- a/packages/cli/src/env/socket-cli-org-slug.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_ORG_SLUG environment variable. Default organization slug for - * Socket CLI operations. - */ - -import { getSocketCliOrgSlug } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_ORG_SLUG = getSocketCliOrgSlug() diff --git a/packages/cli/src/env/socket-cli-pycli-local-path.mts b/packages/cli/src/env/socket-cli-pycli-local-path.mts deleted file mode 100644 index 0d9618642..000000000 --- a/packages/cli/src/env/socket-cli-pycli-local-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Local path override for Socket Python CLI binary. Useful for local - * development and testing with custom Python CLI builds. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_PYCLI_LOCAL_PATH = env['SOCKET_CLI_PYCLI_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-python-path.mts b/packages/cli/src/env/socket-cli-python-path.mts deleted file mode 100644 index 0bbb92e21..000000000 --- a/packages/cli/src/env/socket-cli-python-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Local path override for Python executable. Useful for local development and - * testing with custom Python installations. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_PYTHON_PATH = env['SOCKET_CLI_PYTHON_PATH'] diff --git a/packages/cli/src/env/socket-cli-sea-node-version.mts b/packages/cli/src/env/socket-cli-sea-node-version.mts deleted file mode 100644 index 0c0126748..000000000 --- a/packages/cli/src/env/socket-cli-sea-node-version.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * SOCKET_CLI_SEA_NODE_VERSION environment variable snapshot. Specifies the - * Node.js version to use for Single Executable Application (SEA) builds. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_SEA_NODE_VERSION = env['SOCKET_CLI_SEA_NODE_VERSION'] diff --git a/packages/cli/src/env/socket-cli-sfw-local-path.mts b/packages/cli/src/env/socket-cli-sfw-local-path.mts deleted file mode 100644 index e47051a6f..000000000 --- a/packages/cli/src/env/socket-cli-sfw-local-path.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Local path override for socket-firewall binary. Useful for local development - * and testing with custom firewall builds. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_SFW_LOCAL_PATH = env['SOCKET_CLI_SFW_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-skip-update-check.mts b/packages/cli/src/env/socket-cli-skip-update-check.mts deleted file mode 100644 index 71e026523..000000000 --- a/packages/cli/src/env/socket-cli-skip-update-check.mts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * SOCKET_CLI_SKIP_UPDATE_CHECK environment variable snapshot. When set to a - * truthy value, disables background update checks. This prevents 30-second - * delays caused by HTTP keep-alive connections. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_SKIP_UPDATE_CHECK = env['SOCKET_CLI_SKIP_UPDATE_CHECK'] diff --git a/packages/cli/src/env/socket-cli-socket-patch-local-path.mts b/packages/cli/src/env/socket-cli-socket-patch-local-path.mts deleted file mode 100644 index f78a835f0..000000000 --- a/packages/cli/src/env/socket-cli-socket-patch-local-path.mts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Local path override for socket-patch binary. Useful for local development and - * testing with custom socket-patch builds. - */ - -import { env } from 'node:process' - -export const SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH = - env['SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-cli-view-all-risks.mts b/packages/cli/src/env/socket-cli-view-all-risks.mts deleted file mode 100644 index 335aabad5..000000000 --- a/packages/cli/src/env/socket-cli-view-all-risks.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file SOCKET_CLI_VIEW_ALL_RISKS environment variable. - */ - -import { getSocketCliViewAllRisks } from '@socketsecurity/lib-stable/env/socket-cli' - -export const SOCKET_CLI_VIEW_ALL_RISKS = getSocketCliViewAllRisks() diff --git a/packages/cli/src/env/socket-oauth-introspection-client-id.mts b/packages/cli/src/env/socket-oauth-introspection-client-id.mts deleted file mode 100644 index 659480830..000000000 --- a/packages/cli/src/env/socket-oauth-introspection-client-id.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * SOCKET_OAUTH_INTROSPECTION_CLIENT_ID environment variable. - * - * Client ID used by the `socket mcp` HTTP server when calling the OAuth - * introspection endpoint. Empty string when unset. - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getSocketOauthIntrospectionClientId(): string { - return process.env['SOCKET_OAUTH_INTROSPECTION_CLIENT_ID'] ?? '' -} diff --git a/packages/cli/src/env/socket-oauth-introspection-client-secret.mts b/packages/cli/src/env/socket-oauth-introspection-client-secret.mts deleted file mode 100644 index 37da62638..000000000 --- a/packages/cli/src/env/socket-oauth-introspection-client-secret.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET environment variable. - * - * Client secret paired with SOCKET_OAUTH_INTROSPECTION_CLIENT_ID for the - * `socket mcp` OAuth introspection call. Empty string when unset. - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getSocketOauthIntrospectionClientSecret(): string { - return process.env['SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET'] ?? '' -} diff --git a/packages/cli/src/env/socket-oauth-issuer.mts b/packages/cli/src/env/socket-oauth-issuer.mts deleted file mode 100644 index 027e19310..000000000 --- a/packages/cli/src/env/socket-oauth-issuer.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * SOCKET_OAUTH_ISSUER environment variable. - * - * Issuer URL the `socket mcp` HTTP server uses to validate inbound OAuth - * tokens. Empty string when unset. - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getSocketOauthIssuer(): string { - return process.env['SOCKET_OAUTH_ISSUER'] ?? '' -} diff --git a/packages/cli/src/env/socket-oauth-required-scopes.mts b/packages/cli/src/env/socket-oauth-required-scopes.mts deleted file mode 100644 index 1d706aea5..000000000 --- a/packages/cli/src/env/socket-oauth-required-scopes.mts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * SOCKET_OAUTH_REQUIRED_SCOPES environment variable. - * - * Whitespace-separated list of OAuth scopes the `socket mcp` HTTP server - * requires on every inbound token. Empty string when unset (no scope check). - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getSocketOauthRequiredScopes(): string { - return process.env['SOCKET_OAUTH_REQUIRED_SCOPES'] ?? '' -} diff --git a/packages/cli/src/env/socket-patch-checksums.mts b/packages/cli/src/env/socket-patch-checksums.mts deleted file mode 100644 index 94ba22a6c..000000000 --- a/packages/cli/src/env/socket-patch-checksums.mts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Socket Patch SHA-256 checksums getter function. Uses direct process.env - * access so esbuild define can inline values. IMPORTANT: esbuild's define - * plugin can only replace direct process.env['KEY'] references. - */ - -import process from 'node:process' - -import type { SocketPatchChecksums } from '../types.mjs' - -import { parseChecksums, requireChecksum } from './checksum-utils.mjs' - -const TOOL_NAME = 'Socket Patch' - -/** - * Get Socket Patch checksums from inlined environment variable. Returns a map - * of asset filename to SHA-256 hex checksum. - */ -export function getSocketPatchChecksums(): SocketPatchChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums( - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'], - TOOL_NAME, - ) -} - -/** - * Lookup a Socket Patch checksum by asset name. In production builds, throws if - * asset is missing. In dev mode, returns undefined to allow development. - */ -export function requireSocketPatchChecksum( - assetName: string, -): string | undefined { - return requireChecksum(getSocketPatchChecksums(), assetName, TOOL_NAME) -} diff --git a/packages/cli/src/env/socket-patch-version.mts b/packages/cli/src/env/socket-patch-version.mts deleted file mode 100644 index 24a2a3e74..000000000 --- a/packages/cli/src/env/socket-patch-version.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Socket Patch version getter function. Uses direct process.env access so - * esbuild define can inline values. IMPORTANT: esbuild's define plugin can only - * replace direct process.env['KEY'] references. If we imported from env - * modules, esbuild couldn't inline the values at build time. This is critical - * for embedding version info into the binary. - */ - -import process from 'node:process' - -export function getSocketPatchVersion(): string { - const version = process.env['INLINED_SOCKET_PATCH_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_SOCKET_PATCH_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools["socket-patch"].version — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/synp-version.mts b/packages/cli/src/env/synp-version.mts deleted file mode 100644 index da5ceef67..000000000 --- a/packages/cli/src/env/synp-version.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Synp version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getSynpVersion(): string { - return process.env['INLINED_SYNP_VERSION']! -} diff --git a/packages/cli/src/env/temp.mts b/packages/cli/src/env/temp.mts deleted file mode 100644 index e0dd6b4fd..000000000 --- a/packages/cli/src/env/temp.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * TEMP environment variable. Temporary directory path (Windows systems). - */ - -import { getTemp } from '@socketsecurity/lib-stable/env/temp-dir' - -export const TEMP = getTemp() diff --git a/packages/cli/src/env/term.mts b/packages/cli/src/env/term.mts deleted file mode 100644 index 6dced414a..000000000 --- a/packages/cli/src/env/term.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * TERM environment variable. Terminal type for Unix-based systems (e.g., - * "xterm-256color"). - */ - -import { env } from 'node:process' - -export const TERM = env['TERM'] diff --git a/packages/cli/src/env/tmp.mts b/packages/cli/src/env/tmp.mts deleted file mode 100644 index 4cd036dd7..000000000 --- a/packages/cli/src/env/tmp.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * TMP environment variable. Alternative temporary directory path (Windows/Unix - * systems). - */ - -import { getTmp } from '@socketsecurity/lib-stable/env/temp-dir' - -export const TMP = getTmp() diff --git a/packages/cli/src/env/trivy-checksums.mts b/packages/cli/src/env/trivy-checksums.mts deleted file mode 100644 index ae38b140b..000000000 --- a/packages/cli/src/env/trivy-checksums.mts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Trivy SHA-256 checksums getter function. Uses direct process.env access so - * esbuild define can inline values. IMPORTANT: esbuild's define plugin can only - * replace direct process.env['KEY'] references. - */ - -import process from 'node:process' - -import type { TrivyChecksums } from '../types.mjs' - -import { parseChecksums, requireChecksum } from './checksum-utils.mjs' - -const TOOL_NAME = 'Trivy' - -/** - * Get Trivy checksums from inlined environment variable. Returns a map of asset - * filename to SHA-256 hex checksum. - */ -export function getTrivyChecksums(): TrivyChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums(process.env['INLINED_TRIVY_CHECKSUMS'], TOOL_NAME) -} - -/** - * Lookup a Trivy checksum by asset name. In production builds, throws if asset - * is missing. In dev mode, returns undefined to allow development. - */ -export function requireTrivyChecksum(assetName: string): string | undefined { - return requireChecksum(getTrivyChecksums(), assetName, TOOL_NAME) -} diff --git a/packages/cli/src/env/trivy-version.mts b/packages/cli/src/env/trivy-version.mts deleted file mode 100644 index fdc62fa6c..000000000 --- a/packages/cli/src/env/trivy-version.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Trivy version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getTrivyVersion(): string { - const version = process.env['INLINED_TRIVY_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_TRIVY_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.trivy.version — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/trufflehog-checksums.mts b/packages/cli/src/env/trufflehog-checksums.mts deleted file mode 100644 index 867b981cf..000000000 --- a/packages/cli/src/env/trufflehog-checksums.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * TruffleHog SHA-256 checksums getter function. Uses direct process.env access - * so esbuild define can inline values. IMPORTANT: esbuild's define plugin can - * only replace direct process.env['KEY'] references. - */ - -import process from 'node:process' - -import type { TrufflehogChecksums } from '../types.mjs' - -import { parseChecksums, requireChecksum } from './checksum-utils.mjs' - -const TOOL_NAME = 'TruffleHog' - -/** - * Get TruffleHog checksums from inlined environment variable. Returns a map of - * asset filename to SHA-256 hex checksum. - */ -export function getTrufflehogChecksums(): TrufflehogChecksums { - // MUST use direct process.env access for esbuild inlining. - return parseChecksums(process.env['INLINED_TRUFFLEHOG_CHECKSUMS'], TOOL_NAME) -} - -/** - * Lookup a TruffleHog checksum by asset name. In production builds, throws if - * asset is missing. In dev mode, returns undefined to allow development. - */ -export function requireTrufflehogChecksum( - assetName: string, -): string | undefined { - return requireChecksum(getTrufflehogChecksums(), assetName, TOOL_NAME) -} diff --git a/packages/cli/src/env/trufflehog-version.mts b/packages/cli/src/env/trufflehog-version.mts deleted file mode 100644 index 1bda3cdc1..000000000 --- a/packages/cli/src/env/trufflehog-version.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * TruffleHog version getter function. Uses direct process.env access so esbuild - * define can inline values. IMPORTANT: esbuild's define plugin can only replace - * direct process.env['KEY'] references. If we imported from env modules, - * esbuild couldn't inline the values at build time. This is critical for - * embedding version info into the binary. - */ - -import process from 'node:process' - -export function getTrufflehogVersion(): string { - const version = process.env['INLINED_TRUFFLEHOG_VERSION'] - if (!version) { - throw new Error( - `process.env.INLINED_TRUFFLEHOG_VERSION is empty at runtime; this value should be inlined at build time from bundle-tools.json tools.trufflehog.version — rebuild socket-cli (\`pnpm run build:cli\`) or check that esbuild's define step ran`, - ) - } - return version -} diff --git a/packages/cli/src/env/trust-proxy.mts b/packages/cli/src/env/trust-proxy.mts deleted file mode 100644 index c135cf475..000000000 --- a/packages/cli/src/env/trust-proxy.mts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * TRUST_PROXY environment variable. - * - * When set to the literal string "true", the `socket mcp` HTTP server trusts - * X-Forwarded-* headers from upstream proxies (e.g., when running behind a load - * balancer that terminates TLS). - * - * Read lazily so tests that mutate process.env after module load see the latest - * value. - */ - -import process from 'node:process' - -export function getTrustProxy(): boolean { - return process.env['TRUST_PROXY'] === 'true' -} diff --git a/packages/cli/src/env/userprofile.mts b/packages/cli/src/env/userprofile.mts deleted file mode 100644 index dfe284bf6..000000000 --- a/packages/cli/src/env/userprofile.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * USERPROFILE environment variable. User profile directory (Windows systems). - */ - -import { env } from 'node:process' - -export const USERPROFILE = env['USERPROFILE'] diff --git a/packages/cli/src/env/vitest.mts b/packages/cli/src/env/vitest.mts deleted file mode 100644 index 47b9bacbf..000000000 --- a/packages/cli/src/env/vitest.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * VITEST environment variable snapshot. Indicates whether code is running under - * Vitest test runner. - */ - -import { getVitest } from '@socketsecurity/lib-stable/env/test' - -export const VITEST = getVitest() diff --git a/packages/cli/src/env/xdg-cache-home.mts b/packages/cli/src/env/xdg-cache-home.mts deleted file mode 100644 index 008a597f9..000000000 --- a/packages/cli/src/env/xdg-cache-home.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * XDG_CACHE_HOME environment variable. User-specific cache directory following - * XDG Base Directory specification (Unix systems). - */ - -import { getXdgCacheHome } from '@socketsecurity/lib-stable/env/xdg' - -export const XDG_CACHE_HOME = getXdgCacheHome() diff --git a/packages/cli/src/env/xdg-data-home.mts b/packages/cli/src/env/xdg-data-home.mts deleted file mode 100644 index b878ef5a3..000000000 --- a/packages/cli/src/env/xdg-data-home.mts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * XDG_DATA_HOME environment variable. User-specific data directory following - * XDG Base Directory specification (Unix systems). - */ - -import { getXdgDataHome } from '@socketsecurity/lib-stable/env/xdg' - -export const XDG_DATA_HOME = getXdgDataHome() diff --git a/packages/cli/src/flags.mts b/packages/cli/src/flags.mts deleted file mode 100644 index 9dece5aba..000000000 --- a/packages/cli/src/flags.mts +++ /dev/null @@ -1,307 +0,0 @@ -import os from 'node:os' - -import { NODE_OPTIONS } from './env/node-options.mts' -import { defineFlags, meow } from './meow.mts' - -import type { MeowFlag as Flag } from './meow.mts' - -// Meow doesn't expose this. -type AnyFlag = StringFlag | BooleanFlag | NumberFlag - -type BooleanFlag = Flag & { type: 'boolean' } - -type NumberFlag = Flag & { type: 'number' } - -type StringFlag = Flag & { type: 'string' } - -export type MeowFlag = AnyFlag & { - description: string - hidden?: boolean | undefined -} - -// We use this description in getFlagListOutput, meow doesn't care. -export type MeowFlags = Record<string, MeowFlag> - -type RawSpaceSizeFlags = { - maxOldSpaceSize: number - maxSemiSpaceSize: number -} - -let rawSpaceSizeFlags: RawSpaceSizeFlags | undefined - -let maxOldSpaceSizeFlag: number | undefined - -// Ensure export because dist/flags.js is required in src/constants.mts. -if (typeof exports === 'object' && exports !== null) { - exports.getMaxOldSpaceSizeFlag = getMaxOldSpaceSizeFlag -} - -let maxSemiSpaceSizeFlag: number | undefined - -export function getMaxOldSpaceSizeFlag(): number { - if (maxOldSpaceSizeFlag === undefined) { - const rawFlag = getRawSpaceSizeFlags().maxOldSpaceSize - // Check if flag was explicitly set (> 0). - if (rawFlag > 0) { - maxOldSpaceSizeFlag = rawFlag - } else { - const match = /(?<=--max-old-space-size=)\d+/.exec( - NODE_OPTIONS ?? '', - )?.[0] - if (match) { - const parsed = Number(match) - /* c8 ignore start - regex (\d+) guarantees a numeric string; defensive guard */ - if (Number.isNaN(parsed) || parsed < 0) { - maxOldSpaceSizeFlag = 0 - /* c8 ignore stop */ - } else { - maxOldSpaceSizeFlag = parsed - } - } - } - // Only apply default if no value was set (null/undefined, not 0). - if (maxOldSpaceSizeFlag == null) { - // Default value determined by available system memory. - maxOldSpaceSizeFlag = Math.floor( - // Total system memory in MiB. - (os.totalmem() / 1_024 / 1_024) * - // Set 75% of total memory (safe buffer to avoid system pressure). - 0.75, - ) - } - } - return maxOldSpaceSizeFlag -} - -export function getMaxSemiSpaceSizeFlag(): number { - if (maxSemiSpaceSizeFlag === undefined) { - maxSemiSpaceSizeFlag = getRawSpaceSizeFlags().maxSemiSpaceSize - if (!maxSemiSpaceSizeFlag) { - const match = /(?<=--max-semi-space-size=)\d+/.exec( - NODE_OPTIONS ?? '', - )?.[0] - if (match) { - const parsed = Number(match) - /* c8 ignore start - regex (\d+) guarantees a numeric string; defensive guard */ - if (Number.isNaN(parsed) || parsed < 0) { - maxSemiSpaceSizeFlag = 0 - /* c8 ignore stop */ - } else { - maxSemiSpaceSizeFlag = parsed - } - } else { - maxSemiSpaceSizeFlag = 0 - } - } - if (!maxSemiSpaceSizeFlag) { - const maxOldSpaceSize = getMaxOldSpaceSizeFlag() - // Dynamically scale semi-space size based on max-old-space-size. - // https://nodejs.org/api/cli.html#--max-semi-space-sizesize-in-mib - if (maxOldSpaceSize <= 8_192) { - // Use tiered values for smaller heaps to avoid excessive young - // generation size. This helps stay within safe memory limits on - // constrained systems or CI. - if (maxOldSpaceSize <= 512) { - maxSemiSpaceSizeFlag = 4 - } else if (maxOldSpaceSize <= 1_024) { - maxSemiSpaceSizeFlag = 8 - } else if (maxOldSpaceSize <= 2_048) { - maxSemiSpaceSizeFlag = 16 - } else if (maxOldSpaceSize <= 4_096) { - maxSemiSpaceSizeFlag = 32 - } else { - maxSemiSpaceSizeFlag = 64 - } - } else { - // For large heaps (> 8 GiB), compute semi-space size using a log-scaled - // function. - // - // The idea: - // - log2(16_384 MiB) = 14 → semi = 14 * 8 = 112 - // - log2(32_768 MiB) = 15 → semi = 15 * 8 = 120 - // - Scales gradually as heap increases, avoiding overly large jumps - // - // Each 1 MiB of semi-space adds ~3 MiB to the total young generation - // (V8 uses 3 spaces). So this keeps semi-space proportional, without - // over committing. - // - // Also note: V8 won’t benefit much from >256 MiB semi-space unless - // you’re allocating large short-lived objects very frequently - // (e.g. large arrays, buffers). - const log2OldSpace = Math.log2(maxOldSpaceSize) - const scaledSemiSpace = Math.floor(log2OldSpace) * 8 - maxSemiSpaceSizeFlag = scaledSemiSpace - } - } - } - return maxSemiSpaceSizeFlag -} - -export function getRawSpaceSizeFlags(): RawSpaceSizeFlags { - if (rawSpaceSizeFlags === undefined) { - const cli = meow({ - argv: process.argv.slice(2), - // Prevent meow from potentially exiting early. - autoHelp: false, - autoVersion: false, - flags: { - maxOldSpaceSize: { - type: 'number', - default: 0, - }, - maxSemiSpaceSize: { - type: 'number', - default: 0, - }, - }, - importMeta: { url: import.meta.url } as ImportMeta, - }) - const maxOldSpaceSize = Number(cli.flags['maxOldSpaceSize']) - const maxSemiSpaceSize = Number(cli.flags['maxSemiSpaceSize']) - - /* c8 ignore start - meow type='number' guarantees numeric values; these guards are belt-and-suspenders */ - if (Number.isNaN(maxOldSpaceSize) || maxOldSpaceSize < 0) { - throw new Error( - `--max-old-space-size must be a non-negative integer in megabytes (saw: "${cli.flags['maxOldSpaceSize']}"); pass a whole number like --max-old-space-size=4096 for 4GB`, - ) - } - if (Number.isNaN(maxSemiSpaceSize) || maxSemiSpaceSize < 0) { - throw new Error( - `--max-semi-space-size must be a non-negative integer in megabytes (saw: "${cli.flags['maxSemiSpaceSize']}"); pass a whole number like --max-semi-space-size=128`, - ) - } - /* c8 ignore stop */ - - rawSpaceSizeFlags = { - maxOldSpaceSize, - maxSemiSpaceSize, - } - } - return rawSpaceSizeFlags! -} - -/** - * Reset cached flag values. Test-only — the V8 space-size flag getters - * memoize their first read of process.execArgv + process.env; tests need - * a way to clear the cache between assertions over different env values. - * - * @internal - */ -export function resetFlagCache(): void { - rawSpaceSizeFlags = undefined - maxOldSpaceSizeFlag = undefined - maxSemiSpaceSizeFlag = undefined -} - -// Ensure export because dist/flags.js is required in src/constants.mts. -if (typeof exports === 'object' && exports !== null) { - exports.getMaxSemiSpaceSizeFlag = getMaxSemiSpaceSizeFlag -} - -export const commonFlags = defineFlags({ - animateHeader: { - type: 'boolean', - default: true, - description: 'Disable animated header shimmer effect', - // Hidden to allow custom documenting of the negated `--no-animate-header` variant. - hidden: true, - }, - banner: { - type: 'boolean', - default: true, - description: 'Hide the Socket banner', - // Hidden to allow custom documenting of the negated `--no-banner` variant. - hidden: true, - }, - compactHeader: { - type: 'boolean', - default: false, - description: 'Use compact single-line header format (auto-enabled in CI)', - // Only show in root command. - hidden: true, - }, - headerTheme: { - type: 'string', - default: 'default', - description: - 'Header color theme (default, cyberpunk, forest, ocean, sunset)', - hidden: true, - }, - config: { - type: 'string', - default: '', - description: 'Override the local config with this JSON', - shortFlag: 'c', - // Only show in root command. - hidden: true, - }, - dryRun: { - type: 'boolean', - default: false, - description: 'Run without uploading', - // Only show in root command. - hidden: true, - }, - help: { - type: 'boolean', - default: false, - description: 'Show help', - shortFlag: 'h', - // Only show in root command. - hidden: true, - }, - helpFull: { - type: 'boolean', - default: false, - description: 'Show full help including environment variables', - // Only show in root command. - hidden: true, - }, - maxOldSpaceSize: { - type: 'number', - get default() { - return getMaxOldSpaceSizeFlag() - }, - description: 'Set Node.js memory limit', - // Only show in root command in debug mode. - hidden: true, - }, - maxSemiSpaceSize: { - type: 'number', - get default() { - return getMaxSemiSpaceSizeFlag() - }, - description: 'Set Node.js heap size', - // Only show in root command in debug mode. - hidden: true, - }, - quiet: { - type: 'boolean', - default: false, - description: - 'Route non-essential output (status, progress, warnings) to stderr so stdout carries only the payload. Implied by --json and --markdown.', - }, - spinner: { - type: 'boolean', - default: true, - description: 'Hide the console spinner', - // Hidden to allow custom documenting of the negated `--no-spinner` variant. - hidden: true, - }, -}) - -export const outputFlags = defineFlags({ - json: { - type: 'boolean', - default: false, - description: 'Output as JSON', - shortFlag: 'j', - }, - markdown: { - type: 'boolean', - default: false, - description: 'Output as Markdown', - shortFlag: 'm', - }, -}) - diff --git a/packages/cli/src/index.mts b/packages/cli/src/index.mts deleted file mode 100644 index ffa6b3576..000000000 --- a/packages/cli/src/index.mts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * CLI loader. Loads the built CLI from dist/cli.js. - * - * Note: Written as CommonJS to avoid import.meta issues. Shebang added by - * esbuild banner. - */ - -const path = require('node:path') - -// Load CLI from dist directory -const cliPath = path.join(__dirname, 'cli.js') -require(cliPath) diff --git a/packages/cli/src/instrument-with-sentry.mts b/packages/cli/src/instrument-with-sentry.mts deleted file mode 100644 index d12614ef6..000000000 --- a/packages/cli/src/instrument-with-sentry.mts +++ /dev/null @@ -1,50 +0,0 @@ -// This should ONLY be included in the special Sentry build! -// Otherwise the Sentry dependency won't even be present in the manifest. - -import { createRequire } from 'node:module' - -import { kInternalsSymbol } from '@socketsecurity/lib-stable/constants/sentinels' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getCliVersionHash } from './env/cli-version-hash.mts' -import { isPublishedBuild } from './env/is-published-build.mts' -import { isSentryBuild } from './env/is-sentry-build.mts' -import { NODE_ENV } from './env/node-env.mts' -import { SOCKET_CLI_DEBUG } from './env/socket-cli-debug.mts' - -const logger = getDefaultLogger() - -if (isSentryBuild()) { - const require = createRequire(import.meta.url) - const Sentry = /*@__PURE__*/ require('@sentry/node') - Sentry.init({ - onFatalError(error: Error) { - // Defer module loads until after Sentry.init is called. - if (SOCKET_CLI_DEBUG) { - logger.fail('[DEBUG] [Sentry onFatalError]:', error) - } - }, - dsn: 'https://66736701db8e4ffac046bd09fa6aaced@o555220.ingest.us.sentry.io/4508846967619585', - enabled: true, - integrations: [], - }) - Sentry.setTag('environment', isPublishedBuild() ? 'pub' : NODE_ENV) - Sentry.setTag('version', getCliVersionHash()) - if (SOCKET_CLI_DEBUG) { - Sentry.setTag('debugging', true) - logger.info('[DEBUG] Set up Sentry.') - } else { - Sentry.setTag('debugging', false) - } - const internals = ( - global as unknown as Record< - symbol, - { setSentry?: ((s: typeof Sentry) => void) | undefined } | undefined - > - )[kInternalsSymbol] - if (internals?.setSentry) { - internals.setSentry(Sentry) - } -} else if (SOCKET_CLI_DEBUG) { - logger.info('[DEBUG] Sentry disabled explicitly.') -} diff --git a/packages/cli/src/meow.mts b/packages/cli/src/meow.mts deleted file mode 100644 index c84b201d6..000000000 --- a/packages/cli/src/meow.mts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Simplified meow-like CLI helper for Socket CLI. Uses socket-registry's - * parseArgs for argument parsing. - */ - -import { parseArgs } from '@socketsecurity/lib-stable/argv/parse' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { readPackageJsonSync } from '@socketsecurity/lib-stable/packages/operations' - -import type { - ParseArgsConfig, - ParseArgsOptionsConfig, -} from '@socketsecurity/lib-stable/argv/parse' - -const logger = getDefaultLogger() - -export interface MeowFlag { - readonly type?: 'string' | 'boolean' | 'number' | undefined - readonly shortFlag?: string | undefined - readonly alias?: string | readonly string[] | undefined - readonly aliases?: readonly string[] | undefined - readonly default?: unknown | undefined - readonly isRequired?: - | boolean - | ((flags: Record<string, unknown>, input: readonly string[]) => boolean) - | undefined - readonly isMultiple?: boolean | undefined -} - -export type MeowFlags = Record<string, MeowFlag> - -// Identity helper that preserves the literal flag-schema type so callers -// can write a plain object literal (no `as const`) and still benefit -// from the type narrowing in `InferFlagValues`. The constraint also -// catches typos at the schema definition site. -// -// Usage: -// const flags = defineFlags({ -// http: { type: 'boolean', default: false, description: '…' }, -// port: { type: 'number', default: 3000, description: '…' }, -// }) -// // …pass `flags` into the command config; cli.flags.http is `boolean`, -// // cli.flags.port is `number`, no casts. -export function defineFlags<const F extends MeowFlags>(flags: F): F { - return flags -} - -// Map a flag's schema entry to the runtime value type for that flag. -// - `type: 'boolean'` → boolean -// - `type: 'string'` → string -// - `type: 'number'` → number -// - `isMultiple: true` → array of the above -// - `default` set → value is required (no `| undefined`) -// - otherwise → value | undefined -// -// Using mapped + conditional types lets each callsite write -// `cli.flags.http` and get back `boolean` (not `MeowFlag | undefined`) -// without any String() / Boolean() / cast machinery. -// When `type` is not narrowed (e.g. the wide default `MeowFlag`), fall -// through to `unknown` rather than `boolean` so callers reading -// `cli.flags.someFlag` from a wide-typed result don't get the wrong -// runtime shape narrowed away. Concrete schemas with literal `type` -// strings still resolve to the precise primitive. -type ValueOfFlagType<F extends MeowFlag> = F['type'] extends 'string' - ? string - : F['type'] extends 'number' - ? number - : F['type'] extends 'boolean' - ? boolean - : unknown -type ValueOrArray<F extends MeowFlag, V> = F['isMultiple'] extends true - ? V[] - : V -type ValueOrUndefined<F extends MeowFlag, V> = F['default'] extends undefined - ? V | undefined - : F extends { default: infer D } - ? D extends undefined - ? V | undefined - : V - : V | undefined -type InferFlagValue<F extends MeowFlag> = ValueOrUndefined< - F, - ValueOrArray<F, ValueOfFlagType<F>> -> -// The known-key map from the schema, plus a `[unknown]: unknown` index -// signature so callers can still bracket-access flags that aren't in the -// schema (e.g. `cli.flags['json']` on a command whose schema only spreads -// `commonFlags`). The index signature returns `unknown`, preserving the -// old runtime behavior; the known-key entries get the precise primitive. -type InferFlagValues<F extends MeowFlags> = { - [K in keyof F]: InferFlagValue<F[K]> -} & { - [extraKey: string]: unknown -} - -export interface MeowOptions<F extends MeowFlags = MeowFlags> { - readonly argv?: readonly string[] | undefined - readonly description?: string | false | undefined - readonly help?: string | undefined - readonly flags?: F | undefined - readonly importMeta?: ImportMeta | undefined - readonly autoHelp?: boolean | undefined - readonly autoVersion?: boolean | undefined - readonly allowUnknownFlags?: boolean | undefined - readonly collectUnknownFlags?: boolean | undefined - readonly booleanDefault?: boolean | null | undefined - readonly hardRejection?: boolean | undefined - readonly helpIndent?: number | undefined -} - -interface MeowResult<F extends MeowFlags = MeowFlags> { - readonly input: readonly string[] - readonly flags: InferFlagValues<F> - readonly unknownFlags: readonly string[] - readonly unnormalizedFlags?: InferFlagValues<F> | undefined - readonly pkg: Record<string, unknown> - readonly help: string - showHelp: (exitCode?: number) => void - showVersion: () => void -} - -// Type aliases for compatibility. -export type Flag = MeowFlag -export type Options<F extends MeowFlags = MeowFlags> = MeowOptions<F> -export type Result<F extends MeowFlags = MeowFlags> = MeowResult<F> - -/** - * Parse command-line arguments meow-style. - */ -export function meow<const F extends MeowFlags = MeowFlags>( - options: MeowOptions<F> = {}, -): MeowResult<F> { - const { - argv = process.argv.slice(2), - autoHelp = false, - autoVersion = false, - booleanDefault, - collectUnknownFlags = false, - description, - flags = {}, - help: helpText = '', - helpIndent = 2, - importMeta, - } = options - - // Read package.json. - let pkg: Record<string, unknown> = {} - if (importMeta?.url) { - try { - const url = new URL(importMeta.url) - const packageJsonPath = url.pathname.replace(/\/[^/]+$/, '/package.json') - pkg = readPackageJsonSync(packageJsonPath) || {} - } catch { - // Fallback to empty object. - } - } - - // Convert meow flags to parseArgs options. - const parseArgsOptions: Record<string, ParseArgsOptionsConfig> = {} - const flagEntries = Object.entries(flags as MeowFlags) - for (const [name, flag] of flagEntries) { - const type = flag.type === 'number' ? 'string' : flag.type || 'boolean' - parseArgsOptions[name] = { - type, - short: flag.shortFlag, - default: flag.default, - multiple: flag.isMultiple, - } - - // Handle aliases. - const aliases = flag.aliases || (flag.alias ? [flag.alias].flat() : []) - for (let i = 0, { length } = aliases; i < length; i += 1) { - const alias = aliases[i] - parseArgsOptions[alias as string] = { - type, - default: flag.default, - } - } - } - - // Parse arguments. - const config: ParseArgsConfig = { - args: argv as string[], - options: parseArgsOptions, - strict: !collectUnknownFlags, - allowPositionals: true, - } - - const parsed = parseArgs(config) - const input = parsed.positionals - const flagValues = parsed.values as InferFlagValues<F> - - // Convert number flags. - for (const [name, flag] of flagEntries) { - if ( - flag.type === 'number' && - typeof flagValues[name as keyof InferFlagValues<F>] === 'string' - ) { - const numValue = Number(flagValues[name as keyof InferFlagValues<F>]) - if (!Number.isNaN(numValue)) { - ;(flagValues as Record<string, unknown>)[name] = numValue - } - } - } - - // Handle boolean defaults. - if (booleanDefault !== undefined) { - for (const [name, flag] of flagEntries) { - if (flag.type === 'boolean' && !(name in flagValues)) { - ;(flagValues as Record<string, unknown>)[name] = booleanDefault - } - } - } - - // Build help text. - let fullHelp = '' - if (description !== false && description) { - fullHelp += `\n${description}\n` - } - if (helpText) { - const trimmed = helpText.trim() - if (trimmed.includes('\n')) { - fullHelp += - '\n' + - trimmed - .split('\n') - .map(line => ' '.repeat(helpIndent) + line) - .join('\n') - } else { - fullHelp += `\n${trimmed}` - } - } - fullHelp += '\n' - - // Collect unknown flags. - const unknownFlags: string[] = [] - if (collectUnknownFlags) { - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i] - if (typeof arg === 'string' && arg.startsWith('-')) { - const flagName = arg.replace(/^-+/, '').split('=')[0] || '' - if (flagName && !(flagName in flags)) { - unknownFlags.push(arg) - } - } - } - } - - const showHelp = (exitCode = 2) => { - logger.log(fullHelp) - process.exit(exitCode) - } - - const showVersion = () => { - logger.log(pkg['version'] || '0.0.0') - process.exit(0) - } - - // Auto help/version. - if (!input.length && argv.length === 1) { - if ( - flagValues['version' as keyof InferFlagValues<F>] === true && - autoVersion - ) { - showVersion() - } else if ( - flagValues['help' as keyof InferFlagValues<F>] === true && - autoHelp - ) { - showHelp(0) - } - } - - return { - flags: flagValues, - help: fullHelp, - input, - pkg, - showHelp, - showVersion, - unknownFlags, - } -} diff --git a/packages/cli/src/types.mts b/packages/cli/src/types.mts deleted file mode 100644 index 99d523b2b..000000000 --- a/packages/cli/src/types.mts +++ /dev/null @@ -1,39 +0,0 @@ -export type StringKeyValueObject = { [key: string]: string } - -export type OutputKind = 'json' | 'markdown' | 'text' - -// Checksum types for external tool integrity verification. -// Maps asset filename to SHA-256 hex checksum. -export type OpengrepChecksums = Record<string, string> -export type PyCliChecksums = Record<string, string> -export type PythonChecksums = Record<string, string> -export type SocketPatchChecksums = Record<string, string> -export type TrivyChecksums = Record<string, string> -export type TrufflehogChecksums = Record<string, string> - -// CResult is akin to the "Result" or "Outcome" or "Either" pattern. -// Main difference might be that it's less strict about the error side of -// things, but still assumes a message is returned explaining the error. -// "CResult" is easier to grep for than "result". Short for CliJsonResult. -export type CResult<T> = - | { - ok: true - data: T - // The message prop may contain warnings that we want to convey. - message?: string | undefined - } - | { - ok: false - // This should be set to process.exitCode if this - // payload is actually displayed to the user. - // Defaults to 1 if not set. - code?: number | undefined - // Short message, for non-json this would show in - // the red banner part of an error message. - message: string - // Full explanation. Shown after the red banner of - // a non-json error message. Optional. - cause?: string | undefined - // If set, this may conform to the actual payload. - data?: unknown | undefined - } diff --git a/packages/cli/src/types/babel-traverse.d.ts b/packages/cli/src/types/babel-traverse.d.ts deleted file mode 100644 index 37a86776c..000000000 --- a/packages/cli/src/types/babel-traverse.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file Type declarations for @babel/traverse module. - */ - -declare module '@babel/traverse' { - import type { File } from '@babel/types' - - interface NodePath<T = any> { - node: T - } - - interface TraverseOptions { - [key: string]: ((path: NodePath) => void) | undefined - } - - function traverse(ast: File, opts: TraverseOptions): void - - export default traverse -} diff --git a/packages/cli/src/types/chalk-table.d.ts b/packages/cli/src/types/chalk-table.d.ts deleted file mode 100644 index 6db384c06..000000000 --- a/packages/cli/src/types/chalk-table.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @file Type declarations for chalk-table module. - */ - -declare module 'chalk-table' { - interface TableOptions { - columns?: Array<{ - field: string - name?: string - }> - leftPad?: number - intersectionCharacter?: string - } - - function chalkTable(options: TableOptions | null, data: any[]): string - - export = chalkTable -} diff --git a/packages/cli/src/types/registry.d.ts b/packages/cli/src/types/registry.d.ts deleted file mode 100644 index c534b0d57..000000000 --- a/packages/cli/src/types/registry.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file Type declarations for @socketsecurity/registry when using local builds. - * These declarations suppress module resolution errors during development. At - * runtime, the Node.js loader resolves these imports correctly. - */ - -// Declare the registry module and all its subpaths as valid modules -declare module '@socketsecurity/lib-stable/constants/*' -declare module '@socketsecurity/registry-stable/*' diff --git a/packages/cli/src/util/alert/artifact.mts b/packages/cli/src/util/alert/artifact.mts deleted file mode 100755 index 4599c7adf..000000000 --- a/packages/cli/src/util/alert/artifact.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @file Socket artifact and alert type definitions. - */ - -import type { - ALERT_ACTION, - ALERT_TYPE, - CompactSocketArtifact, - CompactSocketArtifactAlert, - SocketArtifact, - SocketArtifactAlert, -} from '@socketsecurity/sdk-stable' - -export type { - ALERT_ACTION, - ALERT_TYPE, - CompactSocketArtifact, - CompactSocketArtifactAlert, - SocketArtifact, - SocketArtifactAlert, -} - -export type CveProps = { - firstPatchedVersionIdentifier?: string | undefined - vulnerableVersionRange: string - [key: string]: unknown -} - diff --git a/packages/cli/src/util/basics/spawn.mts b/packages/cli/src/util/basics/spawn.mts deleted file mode 100644 index 25b7ef0fa..000000000 --- a/packages/cli/src/util/basics/spawn.mts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * Socket-basics spawning utilities for comprehensive security scanning. - * - * Spawns socket-basics (Python orchestration tool) with extracted security - * tools to perform SAST, secret detection, and container scanning. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -import { - areBasicsToolsAvailable, - extractBasicsTools, - getBasicsToolPaths, -} from './vfs-extract.mts' -import { DOT_SOCKET_DOT_FACTS_JSON } from '../../constants.mts' -import { getPyCliVersion } from '../../env/pycli-version.mts' - -import type { CResult } from '../../types.mts' - -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -/** - * Check if socket_basics is installed in the Python environment. - */ -export async function isSocketBasicsInstalled( - pythonBin: string, -): Promise<boolean> { - try { - const result = await spawn(pythonBin, ['-c', 'import socket_basics'], { - shell: WIN32, - stdio: 'pipe', - }) - return result.code === 0 - } catch { - return false - } -} - -/** - * Check if socketsecurity is installed in the Python environment. - */ -export async function isSocketPyCliInstalled( - pythonBin: string, -): Promise<boolean> { - try { - const result = await spawn( - pythonBin, - ['-c', 'import socketsecurity.socketcli'], - { shell: WIN32, stdio: 'pipe' }, - ) - return result.code === 0 - } catch { - return false - } -} - -/** - * Parse .socket.facts.json to extract finding counts. - * - * @param factsPath - Path to .socket.facts.json file. - * - * @returns Object with finding counts by category, or error if parsing failed. - */ -export async function parseSocketFacts(factsPath: string): Promise<{ - containers?: number | undefined - error?: string | undefined - sast?: number | undefined - secrets?: number | undefined -}> { - try { - const factsContent = await fs.readFile(factsPath, 'utf8') - - if (!factsContent || factsContent.trim() === '') { - debug('error', 'Socket facts file is empty') - return { - error: 'Facts file is empty', - } - } - - let facts: { - findings?: - | { - containers?: unknown[] | undefined - sast?: unknown[] | undefined - secrets?: unknown[] | undefined - } - | undefined - } - try { - facts = JSON.parse(factsContent) - } catch (parseError) { - debug('error', 'Failed to parse socket facts JSON:', parseError) - return { - error: `Invalid JSON: ${errorMessage(parseError)}`, - } - } - - // Extract finding counts from socket-basics output format. - // The exact structure depends on socket-basics implementation. - return { - containers: facts.findings?.containers?.length || 0, - sast: facts.findings?.sast?.length || 0, - secrets: facts.findings?.secrets?.length || 0, - } - } catch (e) { - debug('error', 'Failed to read socket facts file:', e) - return { - error: `File read error: ${errorMessage(e)}`, - } - } -} - -type SocketBasicsOptions = { - cacheDir?: string | undefined - cwd: string - languages?: string[] | undefined - orgSlug: string - outputPath?: string | undefined - repoName: string - scanContainers?: boolean | undefined - scanSecrets?: boolean | undefined - spinner?: SpinnerInstance | undefined - timeout?: number | undefined -} - -type SocketBasicsResult = { - factsPath: string | null - findings: { - containers?: number | undefined - sast?: number | undefined - secrets?: number | undefined - } -} - -/** - * Run socket-basics comprehensive security scanning. - * - * Spawns socket-basics (Python tool) to perform: - * - * - SAST (Static Application Security Testing) via OpenGrep - * - Secret detection via TruffleHog - * - Container scanning via Trivy (if images are specified) - * - * Environment Variables Set: - * - * - SKIP_SOCKET_REACH=1 - Skip reachability analysis (handled separately by CLI) - * - SKIP_SOCKET_SUBMISSION=1 - Skip socket-basics submitting to Socket API - * - PATH - Updated to include extracted tool directories - * - * @example - * const result = await runSocketBasics({ - * cwd: '/path/to/project', - * orgSlug: 'my-org', - * repoName: 'my-repo', - * languages: ['python', 'javascript'], - * scanSecrets: true, - * }) - * - * if (result.ok && result.data.factsPath) { - * logger.log('Socket facts:', result.data.factsPath) - * logger.log('SAST findings:', result.data.findings.sast) - * logger.log('Secrets found:', result.data.findings.secrets) - * } - * - * @param options - Socket-basics configuration options. - * - * @returns Result with path to .socket.facts.json and finding counts. - */ -export async function runSocketBasics( - options: SocketBasicsOptions, -): Promise<CResult<SocketBasicsResult>> { - const { - cacheDir, - cwd, - languages = [], - orgSlug, - outputPath, - repoName, - scanContainers = false, - scanSecrets = true, - spinner, - timeout = 600_000, // 10 minutes default. - } = options - - // Check if basics tools are available. - const toolsAvailable = areBasicsToolsAvailable() - if (!toolsAvailable) { - return { - ok: false, - message: 'Basics tools not available', - cause: - 'Socket-basics requires Python, Trivy, TruffleHog, and OpenGrep to be bundled in the SEA binary', - } - } - - // Extract basics tools from VFS. - // Pass cacheDir to isolate parallel builds. - spinner?.start('Extracting basics tools...') - const toolsDir = await extractBasicsTools(cacheDir) - if (!toolsDir) { - spinner?.fail('Failed to extract basics tools') - return { - ok: false, - message: 'Failed to extract basics tools from VFS', - cause: 'VFS extraction returned null', - } - } - - const toolPaths = getBasicsToolPaths(toolsDir) - - // Verify Python is available. - if (!existsSync(toolPaths.python)) { - spinner?.fail('Python not found after extraction') - return { - ok: false, - message: 'Python not found', - cause: `Expected Python at: ${toolPaths.python}`, - } - } - - /* c8 ignore start - spinner only when caller passes one; unit tests omit it */ - if (spinner) { - spinner.stop() - spinner.success('Security tools extracted') - } - /* c8 ignore stop */ - - // Determine output path for .socket.facts.json. - const factsPath = - outputPath || normalizePath(path.join(cwd, DOT_SOCKET_DOT_FACTS_JSON)) - - // Check if socketsecurity is already pre-installed (SEA build-time bundling). - const pyCliAlreadyInstalled = await isSocketPyCliInstalled(toolPaths.python) - const pyCliVersion = getPyCliVersion() - - if (pyCliAlreadyInstalled) { - debug('notice', 'Socket Python CLI already installed (pre-bundled)') - } else { - // Install socketsecurity package via pip. - spinner?.start('Installing Socket Python CLI...') - const pipInstallResult = await spawn( - toolPaths.python, - ['-m', 'pip', 'install', '--quiet', `socketsecurity==${pyCliVersion}`], - { stdio: 'pipe' }, - ) - - // Check spawn result - it can be null if process failed to start. - if (!pipInstallResult) { - /* c8 ignore start - spinner only when caller passes one */ - if (spinner) { - spinner.stop() - spinner.fail('Failed to start pip install') - } - /* c8 ignore stop */ - return { - ok: false, - message: 'Failed to start pip install process', - cause: 'spawn() returned null', - } - } - - if (pipInstallResult.code !== 0) { - /* c8 ignore start - spinner only when caller passes one */ - if (spinner) { - spinner.stop() - spinner.fail('Failed to install Socket Python CLI') - } - /* c8 ignore stop */ - debug('error', 'pip install failed:', pipInstallResult.stderr) - return { - ok: false, - message: 'Failed to install Socket Python CLI', - cause: String( - pipInstallResult.stderr || 'pip install exited with non-zero code', - ), - } - } - - /* c8 ignore start - spinner only when caller passes one */ - if (spinner) { - spinner.stop() - spinner.success('Socket Python CLI installed') - } - /* c8 ignore stop */ - - // Verify installed version matches expected version. - const verifyResult = await spawn( - toolPaths.python, - ['-m', 'pip', 'show', 'socketsecurity'], - { stdio: 'pipe' }, - ) - - if (!verifyResult || verifyResult.code !== 0) { - /* c8 ignore start - spinner only when caller passes one */ - if (spinner) { - spinner.stop() - spinner.fail('Failed to verify Socket Python CLI installation') - } - /* c8 ignore stop */ - return { - ok: false, - message: 'Failed to verify Socket Python CLI installation', - cause: String( - verifyResult?.stderr || 'pip show exited with non-zero code', - ), - } - } - - const output = String(verifyResult.stdout || '') - const versionMatch = output.match(/^Version:\s*(.+)$/m) - const installedVersion = - versionMatch && versionMatch.length > 1 && versionMatch[1] - ? versionMatch[1].trim() - : undefined - - /* c8 ignore start - version-mismatch path; tests install the expected version */ - if (installedVersion !== pyCliVersion) { - if (spinner) { - spinner.stop() - spinner.fail( - `Socket Python CLI version mismatch: expected ${pyCliVersion}, got ${installedVersion}`, - ) - } - return { - ok: false, - message: 'Socket Python CLI version mismatch', - cause: `Expected version ${pyCliVersion} but got ${installedVersion}. This may cause compatibility issues.`, - } - } - /* c8 ignore stop */ - - debug('notice', `Socket Python CLI version verified: ${installedVersion}`) - } - - // Check if socket_basics is already pre-installed (SEA build-time bundling). - const basicsAlreadyInstalled = await isSocketBasicsInstalled(toolPaths.python) - if (!basicsAlreadyInstalled) { - // socket_basics should be pre-installed in SEA mode. - // For dev mode, this would need runtime installation, but socket_basics is not on PyPI. - debug( - 'warn', - 'socket_basics not found - should be pre-installed in SEA builds', - ) - return { - ok: false, - message: 'socket_basics package not installed', - cause: - 'socket_basics must be pre-bundled at SEA build time (not available on PyPI)', - } - } - debug('notice', 'socket_basics already installed (pre-bundled)') - - // Construct socket-basics command. - // socket-basics is a separate PyPI package (socket_basics). - const args = [ - '-m', - 'socket_basics', - '--org', - orgSlug, - '--repo', - repoName, - '--output', - factsPath, - ] - - // Add language filters if specified. - if (languages.length > 0) { - args.push('--languages', languages.join(',')) - } - - // Enable/disable scanning features. - if (scanSecrets) { - args.push('--secrets') - } - - if (scanContainers) { - args.push('--containers') - } - - // Set up environment variables. - const env = { - ...process.env, - // Skip reachability analysis (handled by CLI's --reach flag). - SKIP_SOCKET_REACH: '1', - // Skip socket-basics submitting to Socket API (CLI handles unified submission). - SKIP_SOCKET_SUBMISSION: '1', - // Set PATH to only include extracted tool directories (security: don't append user's PATH). - // The extracted tools are self-contained and don't need system PATH. - PATH: `${path.dirname(toolPaths.python)}:${toolsDir}`, - } - - // Run socket-basics. - spinner?.start('Running comprehensive security scan...') - debug( - 'notice', - `Running socket-basics: ${toolPaths.python} ${args.join(' ')}`, - ) - - const startTime = Date.now() - const basicsResult = await spawn(toolPaths.python, args, { - cwd, - env, - stdio: 'pipe', - timeout, - }) - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) - - // Check spawn result - it can be null if process failed to start. - if (!basicsResult) { - if (spinner) { - spinner.stop() - spinner.fail('Failed to start socket-basics process') - } - return { - ok: false, - message: 'Failed to start socket-basics process', - cause: 'spawn() returned null', - } - } - - if (basicsResult.code !== 0) { - if (spinner) { - spinner.stop() - spinner.fail(`Socket-basics scan failed (${elapsed}s)`) - } - debug('error', 'socket-basics failed:', basicsResult.stderr) - return { - ok: false, - message: 'Socket-basics scan failed', - cause: String( - basicsResult.stderr || 'socket-basics exited with non-zero code', - ), - } - } - - if (spinner) { - spinner.stop() - spinner.success(`Security scan completed (${elapsed}s)`) - } - - // Verify .socket.facts.json was created. - if (!existsSync(factsPath)) { - return { - ok: false, - message: 'Socket facts file not created', - cause: `Expected .socket.facts.json at: ${factsPath}`, - } - } - - // Parse findings from .socket.facts.json. - const findings = await parseSocketFacts(factsPath) - - // Check if parsing failed. - if (findings.error) { - debug('warn', `Failed to parse facts JSON: ${findings.error}`) - // Return success but with empty findings - the file exists so scan succeeded. - return { - ok: true, - data: { - factsPath, - findings: {}, - }, - } - } - - return { - ok: true, - data: { - factsPath, - findings, - }, - } -} diff --git a/packages/cli/src/util/basics/vfs-extract.mts b/packages/cli/src/util/basics/vfs-extract.mts deleted file mode 100644 index cca707934..000000000 --- a/packages/cli/src/util/basics/vfs-extract.mts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * VFS extraction utilities for socket-basics tools bundled in SEA binaries. - * - * Extracts Python, Trivy, TruffleHog, and OpenGrep from the VFS (Virtual File - * System) embedded in SEA binaries and caches them for socket-basics - * execution. - * - * Extraction paths (all under ~/.socket/_dlx/<hash>/): - * - * - Python/ # Python runtime - * - Python/lib/python3.11/site-packages/ # Python packages (socketsecurity) - * - Trivy # Standalone binary - * - Trufflehog # Standalone binary - * - Opengrep # Standalone binary - */ - -import crypto from 'node:crypto' -import os from 'node:os' -import path from 'node:path' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { UPDATE_STORE_DIR } from '../../constants/paths.mts' -import { isSeaBinary } from '../sea/detect.mts' - -const logger = getDefaultLogger() - -// Basics tool names bundled in VFS. -const BASICS_TOOLS = ['opengrep', 'python', 'trivy', 'trufflehog'] as const - -// VFS paths for basics tools (relative to /snapshot/). -// These are mounted from the VFS filesystem embedded in the SEA binary. -const BASICS_TOOL_VFS_PATHS: Record<(typeof BASICS_TOOLS)[number], string> = { - opengrep: 'opengrep', - python: 'python', - trivy: 'trivy', - trufflehog: 'trufflehog', -} - -/** - * Check if basics tools are available for socket-basics. - * - * Returns true if: 1. Running in SEA mode with process.smol.mount available. - * - * @returns True if basics tools are available for socket-basics. - */ -export function areBasicsToolsAvailable(): boolean { - const processWithSmol = process as unknown as { - smol?: - | { mount?: ((vfsPath: string) => Promise<string>) | undefined } - | undefined - } - - // Check if running in SEA mode with process.smol.mount available. - if (isSeaBinary() && processWithSmol.smol?.mount) { - return true - } - - // Not in SEA mode - tools not available. - return false -} - -/** - * Extract basics tools from VFS using process.smol.mount(). - * - * Extracts Python, Trivy, TruffleHog, and OpenGrep binaries from the SEA's VFS. - * process.smol.mount() handles caching, locking, and extraction automatically. - * - * Extraction is managed by node-smol and tools are cached persistently. - * - * @example - * const toolsDir = await extractBasicsTools() - * if (toolsDir) { - * const paths = getBasicsToolPaths(toolsDir) - * // Use paths.python, paths.trivy, etc. - * } - * - * @param _cacheDir - Unused, kept for API compatibility. process.smol.mount() - * manages paths. - * - * @returns Path to the extracted Python directory, or null if extraction - * failed. - */ -export async function extractBasicsTools( - _cacheDir?: string, -): Promise<string | undefined> { - if (!isSeaBinary()) { - logger.warn('Not running in SEA mode - cannot extract basics tools') - return undefined - } - - // Check if process.smol.mount is available. - const processWithSmol = process as unknown as { - smol?: - | { mount?: ((vfsPath: string) => Promise<string>) | undefined } - | undefined - } - - if (typeof processWithSmol.smol?.mount !== 'function') { - logger.warn( - 'process.smol.mount not available - cannot extract basics tools', - ) - return undefined - } - - logger.group('Extracting basics tools from VFS...') - - const isPlatWin = process.platform === 'win32' - const tools = BASICS_TOOLS - const extractedPaths: Record<string, string> = {} - - try { - // Extract all tools using async process.smol.mount(). - // mount() manages caching, locking, and extraction automatically. - // Async mount() is non-blocking for large extractions (Python with 3000+ files). - for (let i = 0, { length } = tools; i < length; i += 1) { - const tool = tools[i]! - const vfsRelativePath = BASICS_TOOL_VFS_PATHS[tool] - const vfsPath = `/snapshot/${vfsRelativePath}` - - const mountedPath = await processWithSmol.smol.mount(vfsPath) - - logger.success(`${tool}`) - extractedPaths[tool] = mountedPath - } - logger.groupEnd() - - // Verify all tools were extracted. - const missingTools = tools.filter(t => !extractedPaths[t]) - if (missingTools.length) { - throw new Error( - `socket-basics VFS extraction returned ${Object.keys(extractedPaths).length}/${tools.length} tools (missing: ${joinAnd(missingTools)}); the SEA bundle is incomplete — rebuild with all basics tools included`, - ) - } - - // Validate all extracted binaries work after extraction. - logger.group('Validating extracted basics tools...') - - const pythonExe = isPlatWin ? 'python3.exe' : 'python3' - // The missing-tools check above already throws when python is absent. - const pythonDir = extractedPaths['python']! - const pythonPath = normalizePath(path.join(pythonDir, 'bin', pythonExe)) - - const validateResult = await spawn(pythonPath, ['--version'], { - stdio: 'pipe', - timeout: 5_000, - }) - - if (!validateResult || validateResult.code !== 0) { - throw new Error( - `extracted Python at ${pythonPath} failed to run with exit code ${validateResult?.code ?? 'null'} (stderr: ${validateResult?.stderr || '<none>'}); the extracted binary may be corrupt or missing a shared lib — rebuild the SEA binary`, - ) - } - - const pythonVersion = String(validateResult.stdout || '').trim() - logger.success(`Python: ${pythonVersion}`) - - // Validate other security tools. - const toolsToValidate = ['trivy', 'trufflehog', 'opengrep'] as const - for (let i = 0, { length } = toolsToValidate; i < length; i += 1) { - const tool = toolsToValidate[i]! - const toolPath = extractedPaths[tool] - /* c8 ignore start - defensive: extraction populates all toolsToValidate keys before this loop */ - if (!toolPath) { - continue - } - /* c8 ignore stop */ - - const toolValidateResult = await spawn(toolPath, ['--version'], { - stdio: 'pipe', - timeout: 5_000, - }) - - if (!toolValidateResult || toolValidateResult.code !== 0) { - throw new Error( - `extracted ${tool} at ${toolPath} failed to run with exit code ${toolValidateResult?.code ?? 'null'} (stderr: ${toolValidateResult?.stderr || '<none>'}); the extracted binary may be corrupt or missing a shared lib — rebuild the SEA binary`, - ) - } - - const toolVersion = String(toolValidateResult.stdout || '').trim() - logger.success(`${tool}: ${toolVersion}`) - } - logger.groupEnd() - - logger.success('Basics tools extracted and validated') - // Return the Python directory path for backward compatibility. - return extractedPaths['python'] ?? undefined - } catch (e) { - logger.error('VFS extraction failed') - throw e - } -} - -/** - * Get paths to extracted basics tools. - * - * Note: toolsDir is expected to be the Python directory path returned by - * extractBasicsTools(). For standalone binaries (trivy, trufflehog, opengrep), - * this function constructs paths based on the Python directory's parent - * structure. - * - * @example - * const toolsDir = await extractBasicsTools() - * if (toolsDir) { - * const paths = getBasicsToolPaths(toolsDir) - * logger.log('Python:', paths.python) - * logger.log('Trivy:', paths.trivy) - * } - * - * @param toolsDir - Python directory path from extractBasicsTools(). - * - * @returns Object with paths to each tool binary. - */ -export function getBasicsToolPaths(toolsDir: string): { - opengrep: string - python: string - trivy: string - trufflehog: string -} { - const isPlatWin = process.platform === 'win32' - const pythonExe = isPlatWin ? 'python3.exe' : 'python3' - - // toolsDir is the Python directory from process.smol.mount(). - // Standalone binaries are extracted to sibling directories by process.smol.mount(). - const baseDlxDir = path.dirname(toolsDir) - - return { - opengrep: normalizePath( - path.join( - baseDlxDir, - 'opengrep', - isPlatWin ? 'opengrep.exe' : 'opengrep', - ), - ), - python: normalizePath(path.join(toolsDir, 'bin', pythonExe)), - trivy: normalizePath( - path.join(baseDlxDir, 'trivy', isPlatWin ? 'trivy.exe' : 'trivy'), - ), - trufflehog: normalizePath( - path.join( - baseDlxDir, - 'trufflehog', - isPlatWin ? 'trufflehog.exe' : 'trufflehog', - ), - ), - } -} - -/** - * Get the base dlx directory path for node-smol. This is the shared extraction - * directory: ~/.socket/_dlx/<node-smol-hash>/ - * - * @returns Path to node-smol's dlx directory. - */ -export function getNodeSmolBasePath(): string { - let nodeSmolHash = 'node-smol-placeholder' - - try { - // Try to get hash from process.smol API (if available in future node-smol). - const processWithSmol = process as unknown as { - smol?: { getHash?: (() => string) | undefined } | undefined - } - if (typeof processWithSmol.smol?.getHash === 'function') { - nodeSmolHash = processWithSmol.smol.getHash() - } else { - // Fallback: hash based on Node.js version and platform. - const hashInput = `${process.version}-${process.platform}-${process.arch}` - const hash = crypto.createHash('sha256').update(hashInput).digest('hex') - nodeSmolHash = hash.slice(0, 16) - } - } catch { - // Fallback to versioned hash. - const hashInput = `${process.version}-${process.platform}-${process.arch}` - const hash = crypto.createHash('sha256').update(hashInput).digest('hex') - nodeSmolHash = hash.slice(0, 16) - } - - return normalizePath(path.join(os.homedir(), UPDATE_STORE_DIR, nodeSmolHash)) -} - diff --git a/packages/cli/src/util/cli/completion.mts b/packages/cli/src/util/cli/completion.mts deleted file mode 100644 index 18e9b2192..000000000 --- a/packages/cli/src/util/cli/completion.mts +++ /dev/null @@ -1,86 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { getSocketAppDataPath, rootPath } from '../../constants/paths.mts' - -import type { CResult } from '../../types.mjs' - -export const COMPLETION_CMD_PREFIX = 'complete -F _socket_completion' - -export function getBashrcDetails(targetCommandName: string): CResult<{ - completionCommand: string - sourcingCommand: string - toAddToBashrc: string - targetName: string - targetPath: string -}> { - const sourcingCommand = getCompletionSourcingCommand() - if (!sourcingCommand.ok) { - return sourcingCommand - } - - const socketAppDataPath = getSocketAppDataPath() - if (!socketAppDataPath) { - return { - ok: false, - message: 'Could not determine config directory', - cause: 'Failed to get config path', - } - } - - // _socket_completion is the function defined in our completion bash script - const completionCommand = `${COMPLETION_CMD_PREFIX} ${targetCommandName}` - - // Location of completion script in config after installing - const completionScriptPath = path.join( - path.dirname(socketAppDataPath), - 'completion', - 'socket-completion.bash', - ) - - // Bash scripts always use forward slashes, even on Windows. - const bashCompletionPath = completionScriptPath.replace(/\\/g, '/') - - const bashrcContent = `# Socket CLI completion for "${targetCommandName}" -if [ -f "${bashCompletionPath}" ]; then - # Load the tab completion script - source "${bashCompletionPath}" - # Tell bash to use this function for tab completion of this function - ${completionCommand} -fi -` - - return { - ok: true, - data: { - sourcingCommand: sourcingCommand.data, - completionCommand, - toAddToBashrc: bashrcContent, - targetName: targetCommandName, - targetPath: bashCompletionPath, - }, - } -} - -export function getCompletionSourcingCommand(): CResult<string> { - // Bash completion script lives in data directory. - const completionScriptPath = path.join( - rootPath, - 'data', - 'socket-completion.bash', - ) - - if (!existsSync(completionScriptPath)) { - return { - ok: false, - message: 'Tab Completion script not found', - cause: `Expected to find completion script at \`${completionScriptPath.replace(/\\/g, '/')}\` but it was not there`, - } - } - - // Bash scripts always use forward slashes, even on Windows. - return { - ok: true, - data: `source ${completionScriptPath.replace(/\\/g, '/')}`, - } -} diff --git a/packages/cli/src/util/cli/define-handoff.mts b/packages/cli/src/util/cli/define-handoff.mts deleted file mode 100644 index 92264f8aa..000000000 --- a/packages/cli/src/util/cli/define-handoff.mts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Factory for "ecosystem hand-off" commands like `socket npm`, `socket pip`, - * `socket cargo`, etc. These commands all share the same shape: - * - * 1. Parse Socket CLI flags with meow (mostly to handle `--help`). - * 2. Filter Socket-only flags out of argv. - * 3. Optionally render dry-run output and bail. - * 4. Optionally start a telemetry span for the subprocess. - * 5. Spawn Socket Firewall (sfw) with the forwarded args. - * 6. Forward the child's exit code / signal. - * 7. Optionally end the telemetry span before exiting. - * - * Defining each wrapper through this helper kills ~100 lines of copy-paste per - * ecosystem and makes future improvements (signal handling, telemetry, dry-run - * formatting) ship to every wrapper at once. - * - * Usage: export const cmdCargo = defineHandoffCommand({ name: 'cargo', - * description: 'Run cargo with Socket Firewall security', spawnMode: 'dlx', - * examples: ['install ripgrep', 'build', 'add serde'], }) - */ - -import { defineFlags } from '../../meow.mts' -import { commonFlags } from '../../flags.mts' -import { meowOrExit } from './with-subcommands.mts' -import { spawnSfw, spawnSfwDlx } from '../dlx/spawn.mjs' -import { outputDryRunExecute } from '../dry-run/output.mts' -import { getFlagApiRequirementsOutput } from '../output/formatting.mts' -import { filterFlags } from '../process/cmd.mts' -import { - trackSubprocessExit, - trackSubprocessStart, -} from '../telemetry/integration.mts' - -import type { CliCommandContext } from './with-subcommands.mts' -import type { CliSubcommand } from './with-subcommands-shared.mts' - -interface DefineHandoffCommandOptions { - /** - * Command name as it appears under `socket`. Forwarded to sfw as the first - * arg unless `binaryPicker` overrides it. - */ - name: string - /** - * One-line description for the help bucket and `socket --help` listing. - */ - description: string - /** - * Hide the command from `socket --help`. Defaults to false. - */ - hidden?: boolean | undefined - /** - * Spawn strategy: - 'auto' (= spawnSfw): VFS-extract in SEA mode, - * dlx-download otherwise. Used by npm/npx because those binaries are bundled - * in the SEA. - 'dlx' (= spawnSfwDlx): always pnpm-dlx-download. Used by yarn - * / pip / cargo / go / etc. where the SEA doesn't bundle the binary. - */ - spawnMode: 'auto' | 'dlx' - /** - * Examples to render under "Examples" in the help text. Each line is - * automatically prefixed with "$ ${command} ". Pass the args portion only. - */ - examples: readonly string[] - /** - * Optional dynamic binary picker. If provided, runs after flag parsing and - * its return value replaces `name` as the first arg passed to sfw. Used by - * `socket pip` to fall back to `pip3` when `pip` is missing. - */ - binaryPicker?: - | ((context: CliCommandContext) => Promise<string> | string) - | undefined - /** - * Extra free-form notes appended after the standard "Note: Everything after X - * is forwarded…" line. Each entry becomes one indented line. - */ - helpNotes?: readonly string[] | undefined - /** - * If true, emit the "API Token Requirements" section in help by looking up - * the cmdPath `<parent>:<name>` in the requirements registry. - */ - showApiRequirements?: boolean | undefined - /** - * If true, append the "Use `socket wrapper on` to alias this command as X" - * hint after the forwarding note. - */ - wrapperHint?: boolean | undefined - /** - * If true, support `--dry-run` (renders sfw invocation and bails). Default - * true. - */ - supportDryRun?: boolean | undefined - /** - * If true, surround the spawn with `trackSubprocessStart` / - * `trackSubprocessExit` telemetry. Default true. - */ - trackTelemetry?: boolean | undefined -} - -const DEFAULT_HIDDEN = false -const DEFAULT_SUPPORT_DRY_RUN = true -const DEFAULT_TRACK_TELEMETRY = true - -/** - * Build the help-text generator function used by meow. - */ -export function buildHelp( - opts: DefineHandoffCommandOptions, - parentName: string, -): (command: string) => string { - const { examples, helpNotes, name, showApiRequirements, wrapperHint } = opts - - return (command: string) => { - const lines: string[] = [] - lines.push('', ' Usage', ` $ ${command} ...`) - - if (showApiRequirements) { - lines.push( - '', - ' API Token Requirements', - ` ${getFlagApiRequirementsOutput(`${parentName}:${name}`)}`, - ) - } - - lines.push( - '', - ` Note: Everything after "${name}" is forwarded to Socket Firewall (sfw).`, - ` Socket Firewall provides real-time security scanning for ${name} packages.`, - ) - - if (helpNotes && helpNotes.length) { - for (let i = 0, { length } = helpNotes; i < length; i += 1) { - const note = helpNotes[i] - lines.push(` ${note}`) - } - } - - if (wrapperHint) { - lines.push( - '', - ` Use \`socket wrapper on\` to alias this command as \`${name}\`.`, - ) - } - - if (examples.length) { - lines.push('', ' Examples') - for (let i = 0, { length } = examples; i < length; i += 1) { - const example = examples[i] - // Trim trailing whitespace so a bare-command example renders as - // `$ socket npm` (no trailing space) instead of `$ socket npm `. - lines.push(` $ ${command} ${example}`.trimEnd()) - } - } - - lines.push('') - return lines.join('\n') - } -} - -/** - * Define a "hand-off" subcommand that proxies to Socket Firewall. - * - * Returns a CliSubcommand-shaped object ready to plug into the meow router. - */ -export function defineHandoffCommand( - opts: DefineHandoffCommandOptions, -): CliSubcommand { - const { - description, - hidden = DEFAULT_HIDDEN, - name, - spawnMode, - supportDryRun = DEFAULT_SUPPORT_DRY_RUN, - trackTelemetry = DEFAULT_TRACK_TELEMETRY, - } = opts - - async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: CliCommandContext, - ): Promise<void> { - const { parentName } = { - __proto__: null, - ...context, - } as CliCommandContext - - const config = { - commandName: name, - description, - hidden, - flags: defineFlags({ ...commonFlags }), - help: buildHelp(opts, parentName), - } - - const cli = meowOrExit({ argv, config, importMeta, parentName }) - - // Pass an explicit empty `exceptions` array so test-side assertions - // that match the legacy 3-arg call shape stay green. - const filteredArgv = filterFlags(argv, config.flags, []) - - if (supportDryRun && cli.flags['dryRun']) { - outputDryRunExecute( - 'sfw', - [name, ...filteredArgv], - `${name} with Socket security scanning`, - ) - return - } - - // Resolve the actual binary to forward (pip → pip/pip3 fallback, etc.). - const binaryName = opts.binaryPicker - ? await opts.binaryPicker(context) - : name - - // Default to failure; child's exit listener overwrites on success. - process.exitCode = 1 - - const subprocessStartTime = trackTelemetry - ? await trackSubprocessStart(name) - : undefined - - const spawnFn = spawnMode === 'auto' ? spawnSfw : spawnSfwDlx - const { spawnPromise } = await spawnFn([binaryName, ...filteredArgv], { - stdio: 'inherit', - }) - - const { process: childProcess } = spawnPromise as unknown as { - process: NodeJS.Process & { - on: (event: string, listener: (...args: unknown[]) => void) => void - } - } - wireChildExit(childProcess, { - name, - subprocessStartTime, - trackTelemetry, - }) - - await spawnPromise - } - - return { description, hidden, run } -} - -/** - * Wire the child process's exit/signal back to the parent. Optionally flushes - * telemetry first. Centralized so all wrappers share the same lifecycle. - */ -export function wireChildExit( - childProcess: NodeJS.Process & { - on: (event: string, listener: (...args: unknown[]) => void) => void - }, - options: { - name: string - trackTelemetry: boolean - subprocessStartTime: number | undefined - }, -): void { - const { name, subprocessStartTime, trackTelemetry } = options - childProcess.on( - 'exit', - (code: number | null, signalName: NodeJS.Signals | null) => { - const exitProcess = () => { - if (signalName) { - process.kill(process.pid, signalName) - } else if (typeof code === 'number') { - process.exit(code) - } - } - if (trackTelemetry && subprocessStartTime !== undefined) { - // .then/.catch so the exit happens even when telemetry flush fails. - void trackSubprocessExit(name, subprocessStartTime, code) - .then(exitProcess) - .catch(exitProcess) - } else { - exitProcess() - } - }, - ) -} diff --git a/packages/cli/src/util/cli/define-subcommand-group.mts b/packages/cli/src/util/cli/define-subcommand-group.mts deleted file mode 100644 index 4d2c3c271..000000000 --- a/packages/cli/src/util/cli/define-subcommand-group.mts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Factory for "subcommand group" routers like `socket scan`, `socket package`, - * `socket manifest`, `socket organization`. These commands are pure routers: - * they delegate to a static map of child subcommands and optional aliases. - * - * Pre-factory the four router files used three subtly different shapes — one - * declared a top-level `config` object whose flags were never consumed, another - * inlined the meow call, the third had a `hidden: false` field that only - * existed to satisfy the CliSubcommand type. This helper unifies the shape into - * a single declarative spec: - * - * Export const cmdScan = defineSubcommandGroup({ name: 'scan', description: - * 'Manage Socket scans', subcommands: { create: cmdScanCreate, … }, aliases: { - * meta: { argv: ['metadata'], hidden: true, … } }, }) - */ - -import { commonFlags } from '../../flags.mts' -import { defineFlags } from '../../meow.mts' -import { meowWithSubcommands } from './with-subcommands.mts' - -import type { MeowFlags } from '../../flags.mts' -import type { CliAliases, CliSubcommand } from './with-subcommands-shared.mts' - -interface DefineSubcommandGroupOptions { - /** - * Group name as it appears under `socket`. Used as the second token of the - * usage string (`socket <name> <subcommand>`). - */ - name: string - /** - * One-line description for the parent command's help bucket and the `socket - * --help` listing. - */ - description: string - /** - * Hide the group from `socket --help`. Defaults to false. - */ - hidden?: boolean | undefined - /** - * Map of subcommand name → CliSubcommand. The router routes the first - * positional arg to the matching entry. - */ - subcommands: Record<string, CliSubcommand> - /** - * Optional aliases. Each key is an alternative name for the group; its `argv` - * is the canonical command tokens to invoke (e.g. `aliases: { deps: { argv: - * ['dependencies'], … } }`). - */ - aliases?: CliAliases | undefined - /** - * If true, pass the standard `commonFlags` (--dry-run, --help, --json, - * --markdown, etc.) to meowWithSubcommands so the group's `--help` page lists - * them. Defaults to false (no flags surface). - * - * Routers that previously declared `config = { flags: defineFlags({ - * ...commonFlags }) }` should pass `passCommonFlags: true` to preserve the - * existing help output and any test assertions that inspect the outgoing - * `config.flags`. - */ - passCommonFlags?: boolean | undefined - /** - * Override the flags passed to meowWithSubcommands. Takes precedence over - * `passCommonFlags`. Useful for groups that need extra flags beyond the - * common set. - */ - flags?: MeowFlags | undefined -} - -/** - * Define a subcommand-group router. Returns a CliSubcommand-shaped object ready - * to plug into the parent meow router. - * - * The returned object only includes a `hidden` field when the caller explicitly - * passes one — this preserves the shape of the pre-refactor routers (which - * never had a `hidden` field at all) and keeps existing test assertions about - * object identity / strict shape working. - */ -export function defineSubcommandGroup( - opts: DefineSubcommandGroupOptions, -): CliSubcommand { - const { - aliases, - description, - flags, - hidden, - name, - passCommonFlags, - subcommands, - } = opts - - const effectiveFlags = - flags ?? (passCommonFlags ? defineFlags({ ...commonFlags }) : undefined) - - const result: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} ${name}`, - importMeta, - subcommands, - }, - { - ...(aliases ? { aliases } : {}), - description, - ...(effectiveFlags ? { flags: effectiveFlags } : {}), - }, - ) - }, - } - - if (hidden !== undefined) { - result.hidden = hidden - } - - return result -} diff --git a/packages/cli/src/util/cli/with-subcommands-banner.mts b/packages/cli/src/util/cli/with-subcommands-banner.mts deleted file mode 100644 index b215f9c9f..000000000 --- a/packages/cli/src/util/cli/with-subcommands-banner.mts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * ASCII banner rendering for the Socket CLI top-of-command output. - * - * Extracted from with-subcommands.mts to keep that file under the 1000-line - * File size hard cap. The banner functions are a cohesive unit: getAsciiHeader - * composes the logo + info lines, emitBanner writes the result to stderr, - * shouldAnimateHeader / getHeaderTheme / getTokenOrigin / shouldSuppressBanner - * are read-only helpers that feed into it. - */ - -import colors from 'yoctocolors-cjs' - -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import { getSocketApiToken } from '@socketsecurity/lib-stable/env/socket' -import { getSocketCliNoApiToken } from '@socketsecurity/lib-stable/env/socket-cli' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { FLAG_ORG, REDACTED } from '../../constants/cli.mts' -import { - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, -} from '../../constants/config.mts' -import { getCliVersion } from '../../env/cli-version.mts' -import { getCliVersionHash } from '../../env/cli-version-hash.mts' -import { VITEST } from '../../env/vitest.mts' -import { getConfigValueOrUndef, isConfigFromFlag } from '../config.mts' -import { isDebug } from '../debug.mts' -import { tildify } from '../fs/home-path.mts' -import { getVisibleTokenPrefix } from '../socket/sdk.mjs' -import { - renderLogoWithFallback, - supportsFullColor, -} from '../terminal/ascii-header.mts' - -import type { HeaderTheme } from '../terminal/ascii-header.mts' - -const logger = getDefaultLogger() - -/** - * Emit the Socket CLI banner to stderr for branding and debugging. - */ -export function emitBanner( - name: string, - orgFlag: string | undefined, - compactMode = false, - flags?: Record<string, unknown>, -) { - // Print a banner at the top of each command. - // This helps with brand recognition and marketing. - // It also helps with debugging since it contains version and command details. - // Note: print over stderr to preserve stdout for flags like --json and - // --markdown. If we don't do this, you can't use --json in particular - // and pipe the result to other tools. By emitting the banner over stderr - // you can do something like `socket scan view xyz | jq | process`. - // The spinner also emits over stderr for example. - logger.error(getAsciiHeader(name, orgFlag, compactMode, flags)) -} - -/** - * Generate the ASCII banner header for Socket CLI commands. - */ -export function getAsciiHeader( - command: string, - orgFlag: string | undefined, - compactMode = false, - flags?: Record<string, unknown>, -) { - // Note: In tests we return <redacted> because otherwise snapshots will fail. - const redacting = VITEST - - // Version display: show hash in debug mode, otherwise show semantic version. - const fullVersion = getCliVersion() - const versionHash = getCliVersionHash() - const cliVersion = redacting - ? REDACTED - : isDebug() - ? versionHash - : `v${fullVersion}` - - const nodeVersion = redacting ? REDACTED : process.version - const showNodeVersion = !redacting && isDebug() - const defaultOrg = getConfigValueOrUndef(CONFIG_KEY_DEFAULT_ORG) - - // Token display with origin indicator. - const tokenPrefix = getVisibleTokenPrefix() - const tokenOrigin = redacting ? '' : getTokenOrigin() - const noApiToken = getSocketCliNoApiToken() - const shownToken = redacting - ? REDACTED - : noApiToken - ? colors.red('(disabled)') - : tokenPrefix - ? `${colors.green(tokenPrefix)}***${tokenOrigin ? ` ${tokenOrigin}` : ''}` - : colors.yellow('(not set)') - - const relCwd = redacting ? REDACTED : normalizePath(tildify(process.cwd())) - - // Consolidated org display format. - const orgPart = redacting - ? `org: ${REDACTED}` - : orgFlag - ? `org: ${colors.cyan(orgFlag)} (${FLAG_ORG} flag)` - : defaultOrg && defaultOrg !== 'null' - ? `org: ${colors.cyan(defaultOrg)} (config)` - : colors.yellow('org: (not set)') - - // Compact mode for CI/automation. - if (compactMode) { - const compactToken = noApiToken - ? '(disabled)' - : tokenPrefix - ? `${tokenPrefix}***${tokenOrigin ? ` ${tokenOrigin}` : ''}` - : '(not set)' - const compactOrg = - orgFlag || - (defaultOrg && defaultOrg !== 'null' ? defaultOrg : '(not set)') - return `CLI: ${cliVersion} | cmd: ${command} | org: ${compactOrg} | token: ${compactToken}` - } - - // Get theme for header styling. - const theme = getHeaderTheme(flags) - const animate = shouldAnimateHeader(flags) - - // Render animated logo if supported, otherwise static. - // Use frame 0 for static render in non-animated mode. - const frame = animate ? Math.floor(Date.now() / 100) % 20 : undefined - const logo = renderLogoWithFallback(frame, theme) - - // Build info lines. - const infoLines = [ - '/---------------', - `| CLI: ${cliVersion}`, - `| ${showNodeVersion ? `Node: ${nodeVersion}, ` : ''}token: ${shownToken}, ${orgPart}`, - `| Command: \`${command}\`, cwd: ${relCwd}`, - ] - - // Combine logo and info side-by-side. - const logoLines = logo.split('\n') - const combinedLines: string[] = [] - - for (let i = 0; i < Math.max(logoLines.length, infoLines.length); i++) { - const logoLine = logoLines[i] || '' - const infoLine = infoLines[i] || '' - // Pad logo line to consistent width (36 chars for the ASCII art). - const paddedLogo = - logoLine + ' '.repeat(Math.max(0, 36 - stripAnsi(logoLine).length)) - combinedLines.push(` ${paddedLogo}${infoLine}`) - } - - return combinedLines.join('\n') -} - -/** - * Get header theme from flags or use default. - */ -export function getHeaderTheme(flags?: Record<string, unknown>): HeaderTheme { - const theme = flags?.['headerTheme'] - const validThemes: HeaderTheme[] = [ - 'default', - 'cyberpunk', - 'forest', - 'ocean', - 'sunset', - ] - return validThemes.includes(theme as HeaderTheme) - ? (theme as HeaderTheme) - : 'default' -} - -/** - * Determine the origin of the API token (env var, config, --config flag, or - * none). Used in the banner to show the user where the active token is coming - * from. - */ -export function getTokenOrigin(): string { - if (getSocketCliNoApiToken()) { - return '' - } - if (getSocketApiToken()) { - return '(env)' - } - const configToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) - if (configToken) { - return isConfigFromFlag() ? '(--config flag)' : '(config)' - } - return '' -} - -/** - * Determine if header should animate (shimmer effect). - */ -export function shouldAnimateHeader(flags?: Record<string, unknown>): boolean { - // Disable animation in CI, tests, or when explicitly disabled. - if (getCI() || VITEST || !process.stdout.isTTY || !supportsFullColor()) { - return false - } - /* c8 ignore start - VITEST is true under tests so the early-return above always fires; the flag-check + default-true paths require an interactive TTY */ - if (flags && 'animateHeader' in flags) { - return Boolean(flags['animateHeader']) - } - return true - /* c8 ignore stop */ -} - -/** - * Determine if the banner should be suppressed based on output flags. - */ -export function shouldSuppressBanner(flags: Record<string, unknown>): boolean { - return Boolean( - flags['json'] || flags['markdown'] || flags['banner'] === false, - ) -} - -/** - * Strip ANSI codes for length calculation. - */ -export function stripAnsi(str: string): string { - return str.replace(/\x1b\[[0-9;]*m/g, '') -} diff --git a/packages/cli/src/util/cli/with-subcommands-help.mts b/packages/cli/src/util/cli/with-subcommands-help.mts deleted file mode 100644 index 51154afc2..000000000 --- a/packages/cli/src/util/cli/with-subcommands-help.mts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Help-text rendering for `meowWithSubcommands`. - * - * Extracted from with-subcommands.mts to keep that file under the 1000-line - * File-size cap. The function builds the `lines: string[]` passed to meow's - * `help` option, with a "bucketed" layout for the root socket command and a - * flat alphabetised list for sub-commands. - * - * Buckets are read from `opts.buckets` (a per-app `Record<commandName, - * CliBucket>` map); the help builder iterates the registered `subcommands` and - * groups each by its bucket. There is no parallel hand-maintained list — the - * source of truth for "which bucket does X go in?" is one place: the - * application's bucket map (e.g. `rootCommandBuckets` in `src/commands.mts`). - * - * Commands without a bucket assignment are valid but unsurfaced in the root - * help (still reachable via name + still appear in sub-command help). Useful - * for ecosystem-specific or experimental commands documented elsewhere. - */ - -import terminalLink from 'terminal-link' -import colors from 'yoctocolors-cjs' - -import { toSortedObject } from '@socketsecurity/lib-stable/objects/sort' -import { naturalCompare } from '@socketsecurity/lib-stable/sorts/natural' - -import { - FLAG_HELP_FULL, - FLAG_JSON, - FLAG_MARKDOWN, -} from '../../constants/cli.mts' -import { NPM } from '../../constants/agents.mts' -import { API_V0_URL } from '../../constants/socket.mts' -import { getFlagListOutput, getHelpListOutput } from '../output/formatting.mts' -import { socketPackageLink } from '../terminal/link.mts' - -import { description } from './with-subcommands-shared.mts' - -import type { - CliAliases, - CliBucket, - CliBuckets, - CliSubcommand, -} from './with-subcommands-shared.mts' -import type { MeowFlag, MeowFlags } from '../../flags.mts' - -const HELP_INDENT = 2 -const HELP_PAD_NAME = 28 - -interface BuildHelpLinesOptions { - aliases: Record<string, CliAliases[string]> - argv: readonly string[] - /** - * Per-subcommand bucket assignments. Only consumed for the root-command - * layout; ignored for sub-commands. - */ - buckets?: CliBuckets | undefined - flags: MeowFlags - isRootCommand: boolean - name: string - subcommands: Record<string, CliSubcommand> -} - -interface BucketSection { - readonly heading: string - readonly bucket: CliBucket -} - -/** - * Display order + heading text for each bucket. Adding a new bucket = (a) add - * the literal to `CliBucket` in with-subcommands-shared.mts, (b) add an entry - * here. The compiler enforces both halves match. - */ -const BUCKET_SECTIONS: readonly BucketSection[] = [ - { heading: 'Main commands', bucket: 'main' }, - { heading: 'Socket API', bucket: 'api' }, - { heading: 'Local tools', bucket: 'tools' }, - { heading: 'CLI configuration', bucket: 'config' }, -] - -/** - * Build the help-text lines passed to meow as the `help` option. - * - * For root `socket`: a bucketed layout (Main commands, Socket API, Local tools, - * CLI configuration) plus optional environment-variable docs gated on - * --help-full. - * - * For sub-commands (`socket scan`, `socket package`, …): a flat alphabetised - * list of the subcommand's own children + aliases. - */ -export function buildHelpLines(opts: BuildHelpLinesOptions): string[] { - const { aliases, argv, buckets, flags, isRootCommand, name, subcommands } = - opts - - const lines = ['', 'Usage', ` $ ${name} <command>`] - if (isRootCommand) { - lines.push( - ` $ ${name} scan create ${FLAG_JSON}`, - ` $ ${name} package score ${NPM} lodash ${FLAG_MARKDOWN}`, - ) - } - lines.push('') - - if (isRootCommand) { - pushRootBucketedLayout(lines, subcommands, buckets ?? {}) - } else { - pushSubcommandFlatList(lines, subcommands, aliases) - } - - lines.push('', 'Options') - if (isRootCommand) { - lines.push( - ' Note: All commands have these flags even when not displayed in their help', - '', - ) - } else { - lines.push('') - } - lines.push( - ` ${getFlagListOutput( - { - ...flags, - // Explicitly document the negated --no-banner variant. - noBanner: { - ...flags['banner'], - hidden: false, - } as MeowFlag, - // Explicitly document the negated --no-spinner variant. - noSpinner: { - ...flags['spinner'], - hidden: false, - } as MeowFlag, - }, - { indent: HELP_INDENT, padName: HELP_PAD_NAME }, - )}`, - ) - if (isRootCommand) { - pushEnvironmentVariables(lines, argv) - } - - return lines -} - -export function describeOrFallback( - cmd: CliSubcommand | undefined, - fallback: string, -): string { - return cmd ? description(cmd) : fallback -} - -/** - * Group registered subcommands by their bucket. Returns a Map keyed by bucket → - * array of command names sorted naturally for display. - * - * Hidden commands and commands without a bucket assignment are excluded. - */ -export function groupCommandsByBucket( - subcommands: Record<string, CliSubcommand>, - buckets: CliBuckets, -): Map<CliBucket, string[]> { - const grouped = new Map<CliBucket, string[]>() - for (const [cmdName, cmd] of Object.entries(subcommands)) { - if (cmd.hidden) { - continue - } - const bucket = buckets[cmdName] - if (!bucket) { - continue - } - let bucketNames = grouped.get(bucket) - if (!bucketNames) { - bucketNames = [] - grouped.set(bucket, bucketNames) - } - bucketNames.push(cmdName) - } - for (const names of grouped.values()) { - names.sort(naturalCompare) - } - return grouped -} - -export function hasHeroRows(bucket: CliBucket): boolean { - return bucket === 'main' -} - -export function pushEnvironmentVariables( - lines: string[], - argv: readonly string[], -): void { - // Check if we should show full help with environment variables. - const showFullHelp = argv.includes(FLAG_HELP_FULL) - - if (showFullHelp) { - // Show full help with environment variables. - lines.push( - '', - 'Environment variables', - ' SOCKET_CLI_API_TOKEN Set the Socket API token', - ' SOCKET_CLI_CONFIG A JSON stringified Socket configuration object', - ' GITHUB_API_URL Change the base URL for GitHub REST API calls', - ' SOCKET_CLI_GIT_USER_EMAIL The git config `user.email` used by Socket CLI', - ` ${colors.italic('Defaults:')} github-actions[bot]@users.noreply.github.com`, - ' SOCKET_CLI_GIT_USER_NAME The git config `user.name` used by Socket CLI', - ` ${colors.italic('Defaults:')} github-actions[bot]`, - ` SOCKET_CLI_GITHUB_TOKEN A classic or fine-grained ${terminalLink('GitHub personal access token', 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens')}`, - ` ${colors.italic('Aliases:')} GITHUB_TOKEN`, - ' SOCKET_CLI_NO_API_TOKEN Make the default API token `undefined`', - ' SOCKET_CLI_NPM_PATH The absolute location of the npm directory', - ' SOCKET_CLI_ORG_SLUG Specify the Socket organization slug', - '', - ' SOCKET_CLI_ACCEPT_RISKS Accept risks of a Socket wrapped npm/pnpm exec run', - ' SOCKET_CLI_VIEW_ALL_RISKS View all risks of a Socket wrapped npm/pnpm exec run', - '', - 'Environment variables for development', - ' SOCKET_CLI_API_BASE_URL Change the base URL for Socket API calls', - ` ${colors.italic('Defaults:')} The "apiBaseUrl" value of socket/settings local app data`, - ` if present, else ${API_V0_URL}`, - ' SOCKET_CLI_API_PROXY Set the proxy Socket API requests are routed through, e.g. if set to', - ` ${terminalLink('http://127.0.0.1:9090', 'https://docs.proxyman.io/troubleshooting/couldnt-see-any-requests-from-3rd-party-network-libraries')} then all request are passed through that proxy`, - ` ${colors.italic('Aliases:')} HTTPS_PROXY, https_proxy, HTTP_PROXY, and http_proxy`, - ' SOCKET_CLI_API_TIMEOUT Set the timeout in milliseconds for Socket API requests', - ' SOCKET_CLI_DEBUG Enable debug logging in Socket CLI', - ` DEBUG Enable debug logging based on the ${socketPackageLink('npm', 'debug', undefined, 'debug')} package`, - ) - } else { - // Show condensed help with hint about --help-full. - lines.push( - '', - 'Environment variables [more…]', - ` Use ${colors.bold(FLAG_HELP_FULL)} to view all environment variables`, - ) - } -} - -/** - * Render the root help: header lines + each bucket section in order + static - * "hero" rows in the Main bucket that aren't standalone commands (e.g. `socket - * scan create`, `socket npm/<purl>`). - */ -export function pushRootBucketedLayout( - lines: string[], - subcommands: Record<string, CliSubcommand>, - buckets: CliBuckets, -): void { - const grouped = groupCommandsByBucket(subcommands, buckets) - - lines.push('Note: All commands have their own --help', '') - - for (const { heading, bucket } of BUCKET_SECTIONS) { - const names = grouped.get(bucket) ?? [] - if (names.length === 0 && !hasHeroRows(bucket)) { - continue - } - lines.push(heading) - if (bucket === 'main') { - // Hero rows: static lines that aren't tied to a single command - // entry but anchor the user's mental model. Order matches the - // historical layout. - lines.push( - ` socket login ${describeOrFallback(subcommands['login'], 'Socket API login and CLI setup')}`, - ' socket scan create Create a new Socket scan and report', - ' socket npm/lodash@4.17.21 Request the Socket score of a package', - ) - } - for (let i = 0, { length } = names; i < length; i += 1) { - const cmdName = names[i]! - // Skip commands already covered by hero rows in `main`. - if (bucket === 'main' && cmdName === 'login') { - continue - } - const cmd = subcommands[cmdName] - /* c8 ignore start - defensive: cmdName comes from grouped subcommands so the lookup always resolves */ - if (!cmd) { - continue - } - /* c8 ignore stop */ - lines.push(` ${cmdName.padEnd(HELP_PAD_NAME)}${description(cmd)}`) - } - lines.push('') - } -} - -export function pushSubcommandFlatList( - lines: string[], - subcommands: Record<string, CliSubcommand>, - aliases: Record<string, CliAliases[string]>, -): void { - lines.push('Commands') - lines.push( - ` ${getHelpListOutput( - { - ...toSortedObject( - Object.fromEntries( - Object.entries(subcommands).filter( - ({ 1: subcommand }) => !subcommand.hidden, - ), - ), - ), - ...toSortedObject( - Object.fromEntries( - Object.entries(aliases).filter(({ 1: alias }) => { - const { hidden } = alias - const cmdName = hidden ? '' : alias.argv[0] - const subcommand = cmdName ? subcommands[cmdName] : undefined - return subcommand && !subcommand.hidden - }), - ), - ), - }, - { indent: HELP_INDENT, padName: HELP_PAD_NAME }, - )}`, - ) -} diff --git a/packages/cli/src/util/cli/with-subcommands-shared.mts b/packages/cli/src/util/cli/with-subcommands-shared.mts deleted file mode 100644 index 5d1e7989f..000000000 --- a/packages/cli/src/util/cli/with-subcommands-shared.mts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Shared types + the small `description()` helper for the CLI sub-command - * router. - * - * Extracted from with-subcommands.mts so help-rendering and the router can each - * pull in only what they need without circular imports between - * with-subcommands.mts and with-subcommands-help.mts. - */ - -import { indentString } from '@socketsecurity/lib-stable/strings/format' - -import type { Options } from '../../meow.mts' - -export interface CliAlias { - description: string - argv: readonly string[] - hidden?: boolean | undefined -} - -export type CliAliases = Record<string, CliAlias> - -export type CliSubcommandRun = ( - argv: string[] | readonly string[], - importMeta: ImportMeta, - context: { parentName: string; rawArgv?: readonly string[] | undefined }, -) => Promise<void> | void - -export interface CliSubcommand { - description: string - hidden?: boolean | undefined - run: CliSubcommandRun -} - -/** - * Bucket assignments used by the root-help layout. The keys are subcommand - * names that appear in the `subcommands` map; each value is the bucket the - * command belongs to. Commands without an entry here are reachable but not - * surfaced in the bucketed `socket --help` layout — useful for - * ecosystem-specific or experimental commands. - * - * The application owns its bucket map (e.g. `rootCommandBuckets` in - * `src/commands.mts`); the help builder reads it through `MeowOptions.buckets` - * so this generic util doesn't need to import the concrete registry. - */ -export type CliBucket = 'main' | 'api' | 'tools' | 'config' - -export type CliBuckets = Readonly<Record<string, CliBucket>> - -export interface MeowOptions extends Omit<Options, 'argv' | 'importMeta'> { - aliases?: CliAliases | undefined - /** - * Per-subcommand bucket assignments for the root-help layout. Only consumed - * by the root-command help text; ignored for sub-commands. - */ - buckets?: CliBuckets | undefined - // When no sub-command is given, default to this sub-command. - defaultSub?: string | undefined -} - -const HELP_PAD_NAME = 28 - -/** - * Format a command description for help output. - */ -export function description(command: CliSubcommand | undefined): string { - const description = command?.description - const str = - typeof description === 'string' ? description : String(description) - return indentString(str, { count: HELP_PAD_NAME }).trimStart() -} diff --git a/packages/cli/src/util/cli/with-subcommands.mts b/packages/cli/src/util/cli/with-subcommands.mts deleted file mode 100644 index d39b00f7b..000000000 --- a/packages/cli/src/util/cli/with-subcommands.mts +++ /dev/null @@ -1,638 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-logger-newline-literal -- CLI output formatting: multi-line user-facing messages where embedded \n produces the intended layout. Splitting into logger.log("") + logger.log(...) pairs is the canonical rewrite but doesnt preserve the visual flow for these specific outputs. */ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import { getCI } from '@socketsecurity/lib-stable/env/ci' -import { getSocketApiToken } from '@socketsecurity/lib-stable/env/socket' -import { - getSocketCliConfig, - getSocketCliNoApiToken, -} from '@socketsecurity/lib-stable/env/socket-cli' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getOwn } from '@socketsecurity/lib-stable/objects/inspect' -import { hasOwn } from '@socketsecurity/lib-stable/objects/predicates' -import { indentString } from '@socketsecurity/lib-stable/strings/format' -import { trimNewlines } from '@socketsecurity/lib-stable/strings/transform' - -import { DRY_RUN_LABEL } from '../../constants/cli.mts' -import { VITEST } from '../../env/vitest.mts' -import { commonFlags } from '../../flags.mts' -import { meow } from '../../meow.mts' -import { overrideCachedConfig, overrideConfigApiToken } from '../config.mts' -import { isDebug } from '../debug.mts' -import { - resetMachineOutputMode, - setMachineOutputMode, -} from '../output/ambient-mode.mts' - -import { buildHelpLines } from './with-subcommands-help.mts' - -import type { MeowFlag, MeowFlags } from '../../flags.mts' -import type { Result } from '../../meow.mts' - -const HELP_INDENT = 2 - -const logger = getDefaultLogger() - -// Shared types + the `description` helper extracted to keep this -// file under the 1000-line File-size cap. -export { - description, - type CliAlias, - type CliAliases, - type CliSubcommand, - type CliSubcommandRun, - type MeowOptions, -} from './with-subcommands-shared.mts' - -import type { CliSubcommand, MeowOptions } from './with-subcommands-shared.mts' - -// Property names are picked such that the name is at the top when the props -// get ordered by alphabet while flags is near the bottom and the help text -// at the bottom, because they tend ot occupy the most lines of code. -export interface CliCommandConfig<F extends MeowFlags = MeowFlags> { - commandName: string - description: string - hidden: boolean - flags: F - help: (command: string, config: CliCommandConfig<F>) => string -} - -export interface CliCommandContext { - parentName: string - rawArgv?: string[] | readonly string[] | undefined - invokedAs?: string | undefined -} - -interface MeowConfig { - name: string - argv: string[] | readonly string[] - importMeta: ImportMeta - subcommands: Record<string, CliSubcommand> -} - -// Banner / ASCII-header rendering helpers extracted to keep this file -// under the 1000-line File size cap. See with-subcommands-banner.mts. -import { - emitBanner, - getAsciiHeader, - getHeaderTheme, - getTokenOrigin, - shouldAnimateHeader, - shouldSuppressBanner, - stripAnsi, -} from './with-subcommands-banner.mts' - -export { - emitBanner, - getAsciiHeader, - getHeaderTheme, - getTokenOrigin, - shouldAnimateHeader, - shouldSuppressBanner, - stripAnsi, -} - -/** - * Find the best matching command name for a typo. - */ -export function findBestCommandMatch( - input: string, - subcommands: Record<string, unknown>, - aliases: Record<string, unknown>, -): string | undefined { - let bestMatch = undefined - let bestScore = Number.POSITIVE_INFINITY - const allCommands = [...Object.keys(subcommands), ...Object.keys(aliases)] - for (let i = 0, { length } = allCommands; i < length; i += 1) { - const command = allCommands[i]! - const distance = levenshteinDistance( - input.toLowerCase(), - command.toLowerCase(), - ) - const maxLength = Math.max(input.length, command.length) - // Only suggest if the similarity is reasonable (more than 50% similar). - if (distance < maxLength * 0.5 && distance < bestScore) { - bestScore = distance - bestMatch = command - } - } - return bestMatch -} - -/** - * Calculate Levenshtein distance between two strings for fuzzy matching. - */ -export function levenshteinDistance(a: string, b: string): number { - const matrix = Array.from({ length: a.length + 1 }, () => - Array(b.length + 1).fill(0), - ) - for (let i = 0; i <= a.length; i++) { - matrix[i]![0] = i - } - for (let j = 0; j <= b.length; j++) { - matrix[0]![j] = j - } - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1 - matrix[i]![j] = Math.min( - // Deletion. - matrix[i - 1]?.[j]! + 1, - // Insertion. - matrix[i]?.[j - 1]! + 1, - // Substitution. - matrix[i - 1]?.[j - 1]! + cost, - ) - } - } - return matrix[a.length]?.[b.length]! -} - -interface MeowOrExitConfig<F extends MeowFlags = MeowFlags> { - argv: string[] | readonly string[] - config: CliCommandConfig<F> - parentName: string - importMeta: ImportMeta -} - -type MeowOrExitOptions = { - allowUnknownFlags?: boolean | undefined -} - -/** - * Create meow CLI instance or exit with help/error (meow will exit immediately - * if it calls .showHelp()). - * - * @example - * meowOrExit( - * { argv, config, parentName, importMeta }, - * { allowUnknownFlags: false }, - * ) - * - * @param config Configuration object with argv, config, parentName, and - * importMeta. - * @param options Optional settings like allowUnknownFlags. - */ -export function meowOrExit<const F extends MeowFlags = MeowFlags>( - config: MeowOrExitConfig<F>, - options?: MeowOrExitOptions | undefined, -): Result<F> { - const { - argv, - config: cliConfig, - importMeta, - parentName, - } = { __proto__: null, ...config } as MeowOrExitConfig<F> - const { allowUnknownFlags = true } = { - __proto__: null, - ...options, - } as MeowOrExitOptions - const command = `${parentName} ${cliConfig.commandName}` - - // This exits if .printHelp() is called either by meow itself or by us. - const cli = meow({ - argv, - // Prevent meow from potentially exiting early. - autoHelp: false, - autoVersion: false, - // We want to detect whether a bool flag is given at all. - booleanDefault: undefined, - description: cliConfig.description, - flags: cliConfig.flags, - help: trimNewlines(cliConfig.help(command, cliConfig)), - importMeta, - }) - - const { - compactHeader: compactHeaderFlag, - help: helpFlag, - json: jsonFlag, - markdown: markdownFlag, - org: orgFlag, - quiet: quietFlag, - spinner: spinnerFlag, - version: versionFlag, - } = cli.flags as { - compactHeader: boolean - help: boolean - json: boolean | undefined - markdown: boolean | undefined - org: string - quiet: boolean | undefined - spinner: boolean - version: boolean | undefined - } - - // Apply machine-output mode from this command's flags. Reset first - // so prior in-worker state doesn't leak across sequential invocations. - resetMachineOutputMode() - setMachineOutputMode({ - json: jsonFlag, - markdown: markdownFlag, - quiet: quietFlag, - }) - - const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST) - const noSpinner = spinnerFlag === false || isDebug() - - // Use CI spinner style when --no-spinner is passed. - // This prevents the spinner from interfering with debug output. - if (noSpinner) { - // Note: Spinner configuration skipped here to avoid circular dependency with - // constants barrel. Spinner is managed via terminal/spinner state. - // Refactoring opportunity: Extract spinner to standalone module. - } - - if (!shouldSuppressBanner(cli.flags)) { - emitBanner(command, orgFlag, compactMode, cli.flags) - // Add newline in stderr. - // Meow help adds a newline too so we do it here. - logger.error('') - } - - // As per https://github.com/sindresorhus/meow/issues/178 - // Setting `allowUnknownFlags: false` makes it reject camel cased flags. - // if (!allowUnknownFlags) { - // // Run meow specifically with the flag setting. It will exit(2) if an - // // invalid flag is set and print a message. - // meow({ - // argv, - // allowUnknownFlags: false, - // // Prevent meow from potentially exiting early. - // autoHelp: false, - // autoVersion: false, - // description: config.description, - // flags: config.flags, - // help: trimNewlines(config.help(command, config)), - // importMeta, - // }) - // } - - if (helpFlag) { - cli.showHelp(0) - } - - // Meow doesn't detect 'version' as an unknown flag, so we do the leg work here. - if (versionFlag && !hasOwn(cliConfig.flags, 'version')) { - logger.error('Unknown flag\n--version') - process.exit(2) - // This line is never reached in production, but helps tests. - throw new Error('process.exit called') - } - - // Now test for help state. Run Meow again. If it exits now, it must be due - // to wanting to print the help screen. But it would exit(0) and we want a - // consistent exit(2) for that case (missing input). - process.exitCode = 2 - meow({ - argv, - // As per https://github.com/sindresorhus/meow/issues/178 - // Setting `allowUnknownFlags: false` makes it reject camel cased flags. - allowUnknownFlags: Boolean(allowUnknownFlags), - // Prevent meow from potentially exiting early. - autoHelp: false, - autoVersion: false, - description: cliConfig.description, - help: trimNewlines(cliConfig.help(command, cliConfig)), - importMeta, - flags: cliConfig.flags, - }) - // Ok, no help, reset to default. - process.exitCode = 0 - - return cli as unknown as Result<F> -} - -/** - * Main function for handling CLI with subcommands using meow. - * - * @example - * meowWithSubcommands( - * { name, argv, importMeta, subcommands }, - * { aliases, defaultSub }, - * ) - * - * @param config Configuration object with name, argv, importMeta, and - * subcommands. - * @param options Optional settings like aliases and defaultSub. - */ -export async function meowWithSubcommands( - config: MeowConfig, - options?: MeowOptions | undefined, -): Promise<void> { - const { argv, importMeta, name, subcommands } = { - __proto__: null, - ...config, - } as MeowConfig - const { - aliases = {}, - buckets, - defaultSub, - ...additionalOptions - } = { __proto__: null, ...options } as MeowOptions - const flags: MeowFlags = { - ...commonFlags, - version: { - type: 'boolean', - hidden: true, - description: 'Print the app version', - }, - // @ts-expect-error - getOwn may return undefined, but spread handles it - ...getOwn(additionalOptions, 'flags'), - } - - const [commandOrAliasName_, ...rawCommandArgv] = argv - let commandOrAliasName = commandOrAliasName_ - if (!commandOrAliasName && defaultSub) { - commandOrAliasName = defaultSub - } - - // No further args or first arg is a flag (shrug). - const isRootCommand = - name === 'socket' && - (!commandOrAliasName || commandOrAliasName?.startsWith('-')) - - // Try to support `socket <purl>` as a shorthand for `socket package score <purl>`. - if (!isRootCommand) { - if (commandOrAliasName?.startsWith('pkg:')) { - logger.info('Invoking `socket package score`.') - return await meowWithSubcommands( - { name, argv: ['package', 'deep', ...argv], importMeta, subcommands }, - options, - ) - } - // Support `socket npm/lodash` or whatever as a shorthand, too. - // Accept any ecosystem and let the remote sort it out. - if (/^[a-z]+\//.test(commandOrAliasName || '')) { - logger.info('Invoking `socket package score`.') - return await meowWithSubcommands( - { - name, - argv: [ - 'package', - 'deep', - `pkg:${commandOrAliasName}`, - ...rawCommandArgv, - ], - importMeta, - subcommands, - }, - options, - ) - } - } - - if (isRootCommand) { - const hiddenDebugFlag = !isDebug() - - flags['compactHeader'] = { - ...flags['compactHeader'], - hidden: false, - } as MeowFlag - - flags['config'] = { - ...flags['config'], - hidden: false, - } as MeowFlag - - flags['dryRun'] = { - ...flags['dryRun'], - hidden: false, - } as MeowFlag - - flags['help'] = { - ...flags['help'], - hidden: false, - } as MeowFlag - - flags['helpFull'] = { - ...flags['helpFull'], - hidden: false, - } as MeowFlag - - flags['maxOldSpaceSize'] = { - ...flags['maxOldSpaceSize'], - hidden: hiddenDebugFlag, - } as MeowFlag - - flags['maxSemiSpaceSize'] = { - ...flags['maxSemiSpaceSize'], - hidden: hiddenDebugFlag, - } as MeowFlag - - flags['version'] = { - ...flags['version'], - hidden: false, - } as MeowFlag - - delete flags['json'] - delete flags['markdown'] - } else { - delete flags['help'] - delete flags['helpFull'] - delete flags['version'] - } - - // This is basically a dry-run parse of cli args and flags. We use this to - // determine config overrides and expected output mode. - const cli1 = meow({ - argv, - importMeta, - ...additionalOptions, - flags, - // Ensure we don't check unknown flags. - allowUnknownFlags: true, - // Prevent meow from potentially exiting early. - autoHelp: false, - autoVersion: false, - // We want to detect whether a bool flag is given at all. - booleanDefault: undefined, - }) - - const { - compactHeader: compactHeaderFlag, - config: configFlag, - json: jsonFlag, - markdown: markdownFlag, - org: orgFlag, - quiet: quietFlag, - spinner: spinnerFlag, - } = cli1.flags as { - compactHeader: boolean - config: string - json: boolean | undefined - markdown: boolean | undefined - org: string - quiet: boolean | undefined - spinner: boolean - } - - // Re-derive from the current argv so ambient mode doesn't leak across - // sequential invocations (e.g. multiple vitest cases in one worker). - resetMachineOutputMode() - setMachineOutputMode({ - json: jsonFlag, - markdown: markdownFlag, - quiet: quietFlag, - }) - - const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST) - const noSpinner = spinnerFlag === false || isDebug() - - // Use CI spinner style when --no-spinner is passed or debug mode is enabled. - // This prevents the spinner from interfering with debug output. - if (noSpinner) { - // Note: Spinner configuration skipped here to avoid circular dependency with - // constants barrel. Spinner is managed via terminal/spinner state. - // Refactoring opportunity: Extract spinner to standalone module. - } - // Hard override the config if instructed to do so. - // The env var overrides the --flag, which overrides the persisted config - // Also, when either of these are used, config updates won't persist. - let configOverrideResult: ReturnType<typeof overrideCachedConfig> | undefined - const socketCliConfig = getSocketCliConfig() - if (socketCliConfig) { - configOverrideResult = overrideCachedConfig(socketCliConfig) - } else if (configFlag) { - configOverrideResult = overrideCachedConfig(configFlag) - } - - if (getSocketCliNoApiToken()) { - // This overrides the config override and even the explicit token env var. - // The config will be marked as readOnly to prevent persisting it. - overrideConfigApiToken(undefined) - } else { - const tokenOverride = getSocketApiToken() - if (tokenOverride) { - // This will set the token (even if there was a config override) and - // set it to readOnly, making sure the temp token won't be persisted. - overrideConfigApiToken(tokenOverride) - } - } - - if (configOverrideResult?.ok === false) { - if (!shouldSuppressBanner(cli1.flags)) { - emitBanner(name, orgFlag, compactMode, cli1.flags) - // Add newline in stderr. - logger.error('') - } - logger.fail(configOverrideResult.message) - process.exitCode = 2 - return - } - - // If we have got some args, then lets find out if we can find a command. - // Skip command lookup if first arg is a flag (starts with -) - if (commandOrAliasName && !commandOrAliasName.startsWith('-')) { - const alias = aliases[commandOrAliasName] - // First: Resolve argv data from alias if its an alias that's been given. - const [commandName, ...commandArgv] = alias - ? [...alias.argv, ...rawCommandArgv] - : [commandOrAliasName, ...rawCommandArgv] - // Second: Find a command definition using that data. - const commandDefinition = commandName ? subcommands[commandName] : undefined - // Third: If a valid command has been found, then we run it... - if (commandDefinition) { - // Extract the original command arguments from the full argv - // by skipping the command name - const context: CliCommandContext = { parentName: name } - if (alias) { - context.invokedAs = commandOrAliasName - } - return await commandDefinition.run(commandArgv, importMeta, context) - } - - // If no command found but defaultSub exists, use it as the command. - // This treats the first arg as an argument to the default subcommand. - if (!commandDefinition && defaultSub && subcommands[defaultSub]) { - return await subcommands[defaultSub].run( - [commandOrAliasName, ...rawCommandArgv], - importMeta, - { - parentName: name, - }, - ) - } - - // Suggest similar commands for typos. - if (commandName && !commandDefinition) { - const suggestion = findBestCommandMatch(commandName, subcommands, aliases) - if (suggestion) { - process.exitCode = 2 - logger.fail( - `Unknown command "${commandName}". Did you mean "${suggestion}"?`, - ) - return - } - - // Unknown command with no suggestion - show error and fall through to help. - process.exitCode = 2 - logger.fail(`Unknown command "${commandName}".`) - logger.info('Tip: Use `socket pycli` to invoke the Python CLI directly.') - return - } - } - - const lines = buildHelpLines({ - aliases, - argv, - buckets, - flags, - isRootCommand, - name, - subcommands, - }) - - // Parse it again. Config overrides should now be applied (may affect help). - // Note: this is displayed as help screen if the command does not override it - // (which is the case for most sub-commands with sub-commands). - const cli2 = meow({ - argv, - importMeta, - ...additionalOptions, - flags, - // Do not strictly check for flags here. - allowUnknownFlags: true, - // We will emit help when we're ready. - // Plus, if we allow this then meow may exit here. - autoHelp: false, - autoVersion: false, - // We want to detect whether a bool flag is given at all. - booleanDefault: undefined, - help: lines.map(l => indentString(l, { count: HELP_INDENT })).join('\n'), - }) - - const { - dryRun, - help: helpFlag, - version: versionFlag, - } = cli2.flags as { - dryRun: boolean - help: boolean - version: boolean - } - - // Handle --version flag at root level. - /* c8 ignore start - --version causes meow to print and exit; tests avoid invoking this path to prevent process.exit */ - if (versionFlag) { - cli2.showVersion() - } - /* c8 ignore stop */ - - // ...else we provide basic instructions and help. - if (!shouldSuppressBanner(cli2.flags)) { - emitBanner(name, orgFlag, compactMode, cli2.flags) - // Meow will add newline so don't add stderr spacing here. - } - /* c8 ignore start - dry-run process.exit branch; tests avoid invoking this to prevent process termination */ - if (!helpFlag && dryRun) { - logger.log(`${DRY_RUN_LABEL}: No-op, call a sub-command; ok`) - // Exit immediately to prevent tests from hanging waiting for stdin. - process.exit(0) - /* c8 ignore stop */ - } else { - // When you explicitly request --help, the command should be successful - // so we exit(0). If we do it because we need more input, we exit(2). - cli2.showHelp(helpFlag ? 0 : 2) - } -} diff --git a/packages/cli/src/util/coana/compress-facts.mts b/packages/cli/src/util/coana/compress-facts.mts deleted file mode 100644 index ce675e4ea..000000000 --- a/packages/cli/src/util/coana/compress-facts.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Brotli compression for Coana facts files prior to upload. - * - * Key Functions: - compressSocketFactsForUpload: Brotli-compress any - * .socket.facts.json entries in scanPaths just before upload, returning swapped - * paths plus a cleanup callback. Coana keeps writing plain JSON; the - * on-the-wire form to depscan is brotli (api-v0 decodes at the multipart - * boundary). - * - * Integration: - Called from handleCreateNewScan immediately before - * fetchCreateOrgFullScan. - Sibling .br files live next to the source so the - * multipart entry name stays inside cwd (depscan strips .. traversal entries). - */ - -import { createReadStream, createWriteStream, existsSync } from 'node:fs' -import path from 'node:path' -import { pipeline } from 'node:stream/promises' -import { createBrotliCompress } from 'node:zlib' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -import { DOT_SOCKET_DOT_FACTS_JSON } from '../../constants.mts' - -type CompressedScanPaths = { - cleanup: () => Promise<void> - paths: string[] -} - -/** - * For each `.socket.facts.json` in `scanPaths`, stream-brotli-compress a - * sibling `.socket.facts.json.br` next to the original file and swap its path - * in. Other paths pass through unchanged. Missing files also pass through - * unchanged (the upload will fail downstream with the same error it would - * have). - * - * Streaming + worker-thread compression keeps the event loop responsive: - * default brotli quality (11) on a 60+MB facts file takes multiple seconds of - * CPU, which would otherwise freeze the spinner / signal handlers / any - * concurrent work. - * - * The `.br` lives next to the source rather than under the OS temp dir because - * depscan's multipart ingest (`addStreamEntry`) rejects entries whose names - * contain `..` traversal segments. The SDK computes the multipart entry name - * via `path.relative(cwd, brPath)`, so an OS-tmpdir temp path turns into - * `../../../var/folders/...` and gets dropped as `unmatchedFiles`. - * Sibling-write keeps the relative path inside cwd, and keeps the directory - * shape symmetric with the plain `.socket.facts.json` upload (depscan strips - * only the `.br` suffix at ingest, so `<dir>/.socket.facts.json.br` and - * `<dir>/.socket.facts.json` resolve to the same storage path). - * - * Concurrent scans against the same source directory are already racy on - * `.socket.facts.json` itself (coana writes to a single path), so the sibling - * `.br` doesn't introduce a new race. - * - * Caller MUST `await cleanup()` (typically in a `finally` block) once the - * upload completes — successful or not — to remove the sibling files. - */ -export async function compressSocketFactsForUpload( - scanPaths: string[], -): Promise<CompressedScanPaths> { - const brPaths: string[] = [] - const paths = await Promise.all( - scanPaths.map(async p => { - if (path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) { - return p - } - if (!existsSync(p)) { - return p - } - const brPath = `${p}.br` - await pipeline( - createReadStream(p), - createBrotliCompress(), - createWriteStream(brPath), - ) - brPaths.push(brPath) - return brPath - }), - ) - const cleanup = async () => { - const targets = brPaths.splice(0) - if (targets.length === 0) { - return - } - await safeDelete(targets, { force: true }) - } - return { __proto__: null, cleanup, paths } as CompressedScanPaths -} diff --git a/packages/cli/src/util/coana/extract-scan-id.mts b/packages/cli/src/util/coana/extract-scan-id.mts deleted file mode 100644 index 63a52fa04..000000000 --- a/packages/cli/src/util/coana/extract-scan-id.mts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Coana integration utilities for Socket CLI. Manages reachability analysis via - * Coana tech CLI. - * - * Key Functions: - extractTier1ReachabilityScanId: Extract scan ID from socket - * facts file. - * - * Integration: - Works with @coana-tech/cli for reachability analysis - - * Processes socket facts JSON files - Extracts tier 1 reachability scan - * identifiers. - */ - -import { readJsonSync } from '@socketsecurity/lib-stable/fs/read-json' - -export function extractTier1ReachabilityScanId( - socketFactsFile: string, -): string | undefined { - const json = readJsonSync(socketFactsFile, { throws: false }) - if ( - !json || - typeof json !== 'object' || - !('tier1ReachabilityScanId' in json) - ) { - return undefined - } - const rawValue = json['tier1ReachabilityScanId'] - if (rawValue == null) { - return undefined - } - const tier1ReachabilityScanId = String(rawValue).trim() - return tier1ReachabilityScanId.length > 0 - ? tier1ReachabilityScanId - : undefined -} diff --git a/packages/cli/src/util/command/registry-core.mts b/packages/cli/src/util/command/registry-core.mts deleted file mode 100644 index 922b68384..000000000 --- a/packages/cli/src/util/command/registry-core.mts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * @file Command registry implementation for Socket CLI. Manages command - * registration, execution, middleware, and plugin support. - */ - -import type { - CommandContext, - CommandDefinition, - CommandPlugin, - CommandRegistry as ICommandRegistry, - FlagValues, - MiddlewareFn, -} from './registry-types.mjs' -import type { CResult } from '../../types.mts' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { errorStack } from '@socketsecurity/lib-stable/errors/stack' -/** - * Central registry for CLI commands. Handles registration, discovery, - * execution, and middleware. - */ -export class CommandRegistry implements ICommandRegistry { - private commands = new Map<string, CommandDefinition>() - private middleware: MiddlewareFn[] = [] - private plugins: CommandPlugin[] = [] - - /** - * Register a command definition. - */ - register(command: CommandDefinition): void { - if (this.commands.has(command.name)) { - const existing = this.commands.get(command.name) - throw new Error( - `cannot register command "${command.name}": already registered (existing definition has name="${existing?.name}"); call registry.unregister("${command.name}") first or pick a different name`, - ) - } - - this.commands.set(command.name, command) - - // Register aliases. - if (command.aliases) { - for (const alias of command.aliases) { - if (this.commands.has(alias)) { - throw new Error( - `cannot register command "${command.name}" alias "${alias}": conflicts with command "${this.commands.get(alias)?.name}"; rename the alias or unregister the conflicting command first`, - ) - } - // Store alias pointing to main command. - this.commands.set(alias, command) - } - } - } - - /** - * Unregister a command by name. Also removes any aliases associated with the - * command. - */ - unregister(commandName: string): boolean { - const command = this.commands.get(commandName) - if (!command) { - return false - } - - // Remove the command itself. - this.commands.delete(command.name) - - // Remove all aliases. - if (command.aliases) { - for (const alias of command.aliases) { - this.commands.delete(alias) - } - } - - return true - } - - /** - * Get a registered command by name. - */ - get(commandName: string): CommandDefinition | undefined { - return this.commands.get(commandName) - } - - /** - * Check if a command is registered. - */ - has(commandName: string): boolean { - return this.commands.has(commandName) - } - - /** - * List all registered commands, optionally filtered by parent. - */ - list(parent?: string): CommandDefinition[] { - const commands = Array.from(this.commands.values()) - - // Remove duplicates (aliases point to same command) - const unique = Array.from( - new Map(commands.map(cmd => [cmd.name, cmd])).values(), - ) - - if (parent !== undefined) { - return unique.filter(cmd => cmd.parent === parent) - } - - return unique - } - - /** - * Install middleware or plugin. - */ - use(middlewareOrPlugin: MiddlewareFn | CommandPlugin): void { - if (typeof middlewareOrPlugin === 'function') { - this.middleware.push(middlewareOrPlugin) - } else { - // Plugin - this.plugins.push(middlewareOrPlugin) - middlewareOrPlugin.install(this) - } - } - - /** - * Execute a command by name with given arguments. - */ - async execute( - commandName: string, - args: string[], - ): Promise<CResult<unknown>> { - const command = this.commands.get(commandName) - - if (!command) { - return { - ok: false, - message: `Unknown command: ${commandName}`, - cause: `Command "${commandName}" is not registered. Run "socket --help" to see available commands.`, - } - } - - // Parse flags from args (wrap in try/catch for validation errors) - let flags: FlagValues - try { - flags = await this.parseFlags(command, args) - } catch (e) { - return { - ok: false, - message: errorMessage(e), - cause: errorStack(e), - } - } - - // Build context - const context: CommandContext = { - command, - flags, - args, - } - - // Validate - if (command.validate) { - const validation = await command.validate(flags) - if (!validation.ok) { - return { - ok: false, - message: 'Validation failed', - cause: validation.errors?.join('\n'), - } - } - } - - // Execute with middleware chain - try { - await this.executeWithMiddleware(context) - - // If handler returned a result, we'd have it in context - // For now, return success - return { ok: true, data: undefined } - } catch (e) { - return { - ok: false, - message: errorMessage(e), - cause: errorStack(e), - } - } - } - - /** - * Execute command handler through middleware chain. - */ - private async executeWithMiddleware(context: CommandContext): Promise<void> { - const { command } = context - - // Build middleware chain including hooks - const chain: MiddlewareFn[] = [] - - // Add registered global middleware - chain.push(...this.middleware) - - // Add before hook as middleware - if (command.before) { - chain.push(async (ctx, next) => { - await command.before?.(ctx) - await next() - }) - } - - // Add main handler with after hook wrapper - chain.push(async ctx => { - await ctx.command.handler(ctx) - // Execute after hook immediately after handler - if (command.after) { - await command.after(ctx) - } - }) - - // Execute chain - await this.composeMiddleware(chain, context) - } - - /** - * Compose middleware into execution chain. - */ - private async composeMiddleware( - middleware: MiddlewareFn[], - context: CommandContext, - ): Promise<void> { - let index = -1 - - const dispatch = async (i: number): Promise<void> => { - if (i <= index) { - // Each middleware[k] receives a next() closure that calls dispatch(k + 1); - // if that closure was invoked twice, dispatch(i) is re-entered and the - // offender is the middleware that held the closure — position i - 1. - throw new Error( - `middleware at index ${i - 1} called next() more than once (each middleware may invoke next() at most once); remove the extra next() call or split the middleware`, - ) - } - - index = i - - const fn = middleware[i] - - /* c8 ignore start - defensive: dispatch terminates when i >= middleware.length, so this lookup never yields undefined */ - if (!fn) { - return - } - /* c8 ignore stop */ - - await fn(context, () => dispatch(i + 1)) - } - - await dispatch(0) - } - - /** - * Parse command-line arguments into flag values. Basic implementation - can - * be enhanced with a proper parser. - */ - private async parseFlags( - command: CommandDefinition, - args: string[], - ): Promise<FlagValues> { - const flags: FlagValues = {} - - if (!command.flags) { - return flags - } - - // Initialize with defaults - for (const [name, def] of Object.entries(command.flags)) { - if (def.default !== undefined) { - flags[name] = def.default - } - } - - // Parse arguments - for (let i = 0; i < args.length; i++) { - const arg = args[i] - - if (!arg?.startsWith('--')) { - continue - } - - // Handle --flag=value - const [flagName, ...valueParts] = arg.slice(2).split('=') - const flagDef = command.flags[flagName!] - - if (!flagDef) { - // Unknown flag - skip for now (could warn) - continue - } - - let value: unknown - - if (valueParts.length > 0) { - // --flag=value format - value = valueParts.join('=') - } else if (flagDef.type === 'boolean') { - // --flag format for boolean - value = true - } else { - // --flag value format. - if (i + 1 >= args.length) { - throw new Error( - `flag --${flagName} requires a ${flagDef.type} value but none was provided; pass it as --${flagName}=<value> or --${flagName} <value>`, - ) - } - value = args[++i] - } - - // Type conversion - switch (flagDef.type) { - case 'number': { - const raw = value - value = Number(value) - if (Number.isNaN(value)) { - throw new Error( - `flag --${flagName} requires a numeric value (saw: "${String(raw)}"); pass an integer or decimal like --${flagName}=42`, - ) - } - break - } - case 'boolean': { - value = value === 'true' || value === true - break - } - case 'array': { - if (!Array.isArray(flags[flagName!])) { - flags[flagName!] = [] - } - ;(flags[flagName!] as unknown[]).push(value) - continue - } - // string: no conversion needed - } - - flags[flagName!] = value - } - - // Validate required flags - for (const [name, def] of Object.entries(command.flags)) { - if (def.isRequired && flags[name] === undefined) { - throw new Error( - `command "${command.name}" requires --${name} but it was not provided; pass --${name}=<${def.type}-value>`, - ) - } - } - - return flags - } -} - -/** - * Global registry instance. - */ -export const registry = new CommandRegistry() diff --git a/packages/cli/src/util/command/registry-define.mts b/packages/cli/src/util/command/registry-define.mts deleted file mode 100644 index 301a9f5c3..000000000 --- a/packages/cli/src/util/command/registry-define.mts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @file Helper for defining CLI commands in a declarative way. Provides - * defineCommand function that registers commands with the global registry. - */ - -import { registry } from './registry.mts' - -import type { CommandDefinition } from './registry-types.mjs' - -/** - * Define and register a command with the global registry. This is the primary - * API for creating new commands. - * - * @example - * ```typescript - * export default defineCommand({ - * name: 'scan', - * description: 'Scan a project for security issues', - * flags: { - * dir: { - * type: 'string', - * description: 'Directory to scan', - * default: '.', - * }, - * }, - * async handler({ flags }) { - * const result = await scanProject(flags.dir) - * return { ok: true, data: result } - * }, - * }) - * ``` - */ -export function defineCommand( - definition: CommandDefinition, -): CommandDefinition { - registry.register(definition) - return definition -} diff --git a/packages/cli/src/util/command/registry-help.mts b/packages/cli/src/util/command/registry-help.mts deleted file mode 100644 index e88713529..000000000 --- a/packages/cli/src/util/command/registry-help.mts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @file Help text generation for Socket CLI commands. Automatically generates - * formatted help output from CommandDefinition metadata. - */ - -import type { CommandDefinition, FlagDefinition } from './registry-types.mjs' -import type { CommandRegistry } from './registry.mts' - -/** - * Format a flag definition for help output. - * - * @param name - Flag name. - * @param def - Flag definition. - * - * @returns Formatted flag line - */ -export function formatFlag(name: string, def: FlagDefinition): string { - const parts: string[] = [] - - // Flag name with alias - const flagName = def.alias ? `--${name}, -${def.alias}` : `--${name}` - parts.push(flagName.padEnd(20)) - - // Type indicator - const typeIndicator = getTypeIndicator(def) - if (typeIndicator) { - parts.push(typeIndicator) - } - - // Description - parts.push(def.description) - - // Default value - if (def.default !== undefined) { - parts.push(`(default: ${JSON.stringify(def.default)})`) - } - - // Required indicator - if (def.isRequired) { - parts.push('[required]') - } - - // Choices - if (def.choices && def.choices.length > 0) { - parts.push(`[choices: ${def.choices.join(', ')}]`) - } - - return parts.join(' ') -} - -/** - * Generate help text for a command. - * - * @param command - Command definition to generate help for. - * - * @returns Formatted help text string - */ -export function generateCommandHelp(command: CommandDefinition): string { - const lines: string[] = [] - - // Name and description - lines.push(`${command.name}`) - lines.push('') - lines.push(` ${command.description}`) - lines.push('') - - // Usage - const flagsText = command.flags ? ' [options]' : '' - lines.push('Usage:') - lines.push(` socket ${command.name}${flagsText}`) - lines.push('') - - // Aliases - if (command.aliases && command.aliases.length > 0) { - lines.push('Aliases:') - lines.push(` ${command.aliases.join(', ')}`) - lines.push('') - } - - // Flags - if (command.flags) { - lines.push('Options:') - const flagEntries = Object.entries(command.flags) - - for (const [name, def] of flagEntries) { - const flagLine = formatFlag(name, def) - lines.push(` ${flagLine}`) - } - lines.push('') - } - - // Examples - if (command.examples && command.examples.length > 0) { - lines.push('Examples:') - for (const example of command.examples) { - lines.push(` ${example}`) - } - lines.push('') - } - - return lines.join('\n') -} - -/** - * Generate help text listing all available commands. - * - * @param registry - Command registry instance. - * - * @returns Formatted help text for all commands - */ -export function generateGlobalHelp(registry: CommandRegistry): string { - const lines: string[] = [] - - lines.push('Socket CLI - Security analysis for npm packages') - lines.push('') - lines.push('Usage:') - lines.push(' socket <command> [options]') - lines.push('') - - // Group commands by parent - const topLevelCommands = registry.list(undefined) - const visibleTopLevel = topLevelCommands.filter( - (cmd: CommandDefinition) => !cmd.hidden && !cmd.parent, - ) - - if (visibleTopLevel.length > 0) { - lines.push('Commands:') - for (let i = 0, { length } = visibleTopLevel; i < length; i += 1) { - const cmd = visibleTopLevel[i]! - const cmdLine = ` ${cmd.name.padEnd(20)} ${cmd.description}` - lines.push(cmdLine) - - // Show subcommands - const subcommands = registry.list(cmd.name) - for (let i = 0, { length } = subcommands; i < length; i += 1) { - const sub = subcommands[i]! - if (!sub.hidden) { - const subLine = ` ${sub.name.padEnd(18)} ${sub.description}` - lines.push(subLine) - } - } - } - lines.push('') - } - - lines.push('Run "socket <command> --help" for more information on a command.') - lines.push('') - - return lines.join('\n') -} - -/** - * Get type indicator for flag definition. - * - * @param def - Flag definition. - * - * @returns Type indicator string or undefined - */ -export function getTypeIndicator(def: FlagDefinition): string | undefined { - switch (def.type) { - case 'string': - return '<string>' - case 'number': - return '<number>' - case 'array': - return '<value...>' - case 'boolean': - // Boolean flags don't need type indicator - return undefined - default: - return undefined - } -} - -/** - * Check if help was requested for a command. - * - * @param args - Command-line arguments. - * - * @returns True if help flag is present - */ -export function isHelpRequested(args: string[]): boolean { - return args.includes('--help') || args.includes('-h') -} diff --git a/packages/cli/src/util/command/registry-types.mts b/packages/cli/src/util/command/registry-types.mts deleted file mode 100644 index 2ccc9503c..000000000 --- a/packages/cli/src/util/command/registry-types.mts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @file Core types for Socket CLI command registry system. Defines command - * definitions, flags, validation, middleware, and execution context - * interfaces. - */ - -import type { CResult } from '../../types.mts' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' - -/** - * Flag type definitions for command arguments. - */ -export type FlagType = 'string' | 'boolean' | 'number' | 'array' - -/** - * Definition for a single command flag/option. - */ -export interface FlagDefinition { - type: FlagType - description: string - alias?: string | undefined - default?: unknown | undefined - isRequired?: boolean | undefined - choices?: readonly string[] | undefined -} - -/** - * Parsed flag values from command invocation. - */ -export type FlagValues = Record<string, unknown> - -/** - * Validation result for command input. - */ -export interface ValidationResult { - ok: boolean - errors?: string[] | undefined -} - -/** - * Context provided to command handlers during execution. - */ -export interface CommandContext { - command: CommandDefinition - flags: FlagValues - args: string[] - spinner?: SpinnerInstance | undefined - outputKind?: string | undefined -} - -/** - * Middleware function signature for command processing. - */ -export type MiddlewareFn = ( - context: CommandContext, - next: () => Promise<void>, -) => Promise<void> - -/** - * Hook function signature for before/after command execution. - */ -export type HookFn = (context: CommandContext) => Promise<void> - -/** - * Complete command definition. - */ -export interface CommandDefinition { - /** - * Command name (e.g., 'scan', 'repository:create') - */ - name: string - - /** - * Human-readable description. - */ - description: string - - /** - * Parent command for subcommands (e.g., 'repository' for 'repository:create') - */ - parent?: string | undefined - - /** - * Command aliases. - */ - aliases?: string[] | undefined - - /** - * Hide from help output. - */ - hidden?: boolean | undefined - - /** - * Flag definitions. - */ - flags?: Record<string, FlagDefinition> | undefined - - /** - * Main command handler. - */ - handler: (context: CommandContext) => Promise<CResult<unknown>> - - /** - * Pre-execution hook. - */ - before?: HookFn | undefined - - /** - * Post-execution hook. - */ - after?: HookFn | undefined - - /** - * Custom validation logic. - */ - validate?: - | ((flags: FlagValues) => ValidationResult | Promise<ValidationResult>) - | undefined - - /** - * Examples for help text. - */ - examples?: string[] | undefined -} - -/** - * Plugin interface for extending registry. - */ -export interface CommandPlugin { - name: string - install: (registry: CommandRegistry) => void | Promise<void> -} - -/** - * Command registry interface. - */ -export interface CommandRegistry { - register(command: CommandDefinition): void - execute(commandName: string, args: string[]): Promise<CResult<unknown>> - get(commandName: string): CommandDefinition | undefined - list(parent?: string): CommandDefinition[] - has(commandName: string): boolean - use(middleware: MiddlewareFn | CommandPlugin): void -} diff --git a/packages/cli/src/util/command/registry.mts b/packages/cli/src/util/command/registry.mts deleted file mode 100644 index 3ebc9b078..000000000 --- a/packages/cli/src/util/command/registry.mts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file Command registry system for Socket CLI. Provides declarative command - * definitions, middleware, and plugin support. - */ - -export { CommandRegistry, registry } from './registry-core.mjs' -export { defineCommand } from './registry-define.mjs' -export { - generateCommandHelp, - generateGlobalHelp, - isHelpRequested, -} from './registry-help.mjs' - -export type { - CommandContext, - CommandDefinition, - CommandPlugin, - CommandRegistry as ICommandRegistry, - FlagDefinition, - FlagType, - FlagValues, - HookFn, - MiddlewareFn, - ValidationResult, -} from './registry-types.mjs' diff --git a/packages/cli/src/util/config.mts b/packages/cli/src/util/config.mts deleted file mode 100644 index ff6500317..000000000 --- a/packages/cli/src/util/config.mts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Configuration utilities for Socket CLI. Manages CLI configuration including - * API tokens, org settings, and preferences. - * - * Configuration Hierarchy (highest priority first): - * - * 1. Environment variables (SOCKET_CLI_*) - * 2. Command-line --config flag - * 3. Persisted config file (base64 encoded JSON) - * - * Supported Config Keys: - * - * - ApiBaseUrl: Socket API endpoint URL - * - ApiProxy: Proxy for API requests - * - ApiToken: Authentication token for Socket API - * - DefaultOrg/org: Default organization slug - * - EnforcedOrgs: Organizations with enforced security policies - * - * Key Functions: - * - * - FindSocketYmlSync: Locate socket.yml configuration file - * - GetConfigValue: Retrieve configuration value by key - * - OverrideCachedConfig: Apply temporary config overrides - * - UpdateConfigValue: Persist configuration changes - */ - -import { statSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' -import { safeReadFileSync } from '@socketsecurity/lib-stable/fs/read-file' -import { safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' -import { getEditableJsonClass } from '@socketsecurity/lib-stable/json/edit' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { naturalCompare } from '@socketsecurity/lib-stable/sorts/natural' - -import { debugConfig } from './debug.mts' -import { parseSocketConfig } from './socket-yaml.mts' -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, -} from '../constants/config.mts' -import { getSocketAppDataPath } from '../constants/paths.mts' -import { SOCKET_YAML, SOCKET_YML } from '../constants/socket.mts' -import { getErrorCause } from './error/errors.mts' - -import type { CResult } from '../types.mts' -import type { SocketYml } from './socket-yaml.mts' - -const logger = getDefaultLogger() - -export interface LocalConfig { - apiBaseUrl?: string | null | undefined - apiProxy?: string | null | undefined - apiToken?: string | null | undefined - defaultOrg?: string | undefined - enforcedOrgs?: string[] | readonly string[] | null | undefined - skipAskToPersistDefaultOrg?: boolean | undefined - // Convenience alias for defaultOrg. - org?: string | undefined -} - -const sensitiveConfigKeyLookup: Set<keyof LocalConfig> = new Set([ - CONFIG_KEY_API_TOKEN, -]) - -const supportedConfig: Map<keyof LocalConfig, string> = new Map([ - [CONFIG_KEY_API_BASE_URL, 'Base URL of the Socket API endpoint'], - [CONFIG_KEY_API_PROXY, 'A proxy through which to access the Socket API'], - [ - CONFIG_KEY_API_TOKEN, - 'The Socket API token required to access most Socket API endpoints', - ], - [ - CONFIG_KEY_DEFAULT_ORG, - 'The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value.', - ], - [ - CONFIG_KEY_ENFORCED_ORGS, - 'Orgs in this list have their security policies enforced on this machine', - ], - [ - 'skipAskToPersistDefaultOrg', - 'This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively', - ], - [CONFIG_KEY_ORG, 'Alias for defaultOrg'], -]) - -const supportedConfigEntries = [...supportedConfig.entries()].sort((a, b) => - naturalCompare(a[0], b[0]), -) -const supportedConfigKeys = supportedConfigEntries.map(p => p[0]) - -const MAX_CONFIG_READ_RETRIES = 3 - -// Ensure export because dist/utils.js is required in src/constants.mts. -if (typeof exports === 'object' && exports !== null) { - exports.getConfigValueOrUndef = getConfigValueOrUndef -} - -let cachedConfig: LocalConfig | undefined - -let cachedConfigMtime: number | undefined - -let cachedConfigPath: string | undefined - -// When using --config or SOCKET_CLI_CONFIG, do not persist the config. -let configFromFlag = false - -let pendingSave = false - -type FoundSocketYml = { - path: string - parsed: SocketYml -} - -export function findSocketYmlSync( - dir = process.cwd(), -): CResult<FoundSocketYml | undefined> { - let prevDir = undefined - while (dir !== prevDir) { - let ymlPath = path.join(dir, SOCKET_YML) - let yml = safeReadFileSync(ymlPath) - if (yml === undefined) { - ymlPath = path.join(dir, SOCKET_YAML) - yml = safeReadFileSync(ymlPath) - } - if (yml !== undefined) { - try { - const ymlString = Buffer.isBuffer(yml) ? yml.toString('utf8') : yml - return { - ok: true, - data: { - path: ymlPath, - parsed: parseSocketConfig(ymlString), - }, - } - } catch (e) { - debugNs('error', `Failed to parse config file: ${ymlPath}`) - debugDirNs('error', e) - return { - ok: false, - message: `Found file but was unable to parse ${ymlPath}`, - cause: getErrorCause(e), - } - } - } - prevDir = dir - dir = path.join(dir, '..') - } - return { ok: true, data: undefined } -} - -export function getConfigValue<Key extends keyof LocalConfig>( - key: Key, -): CResult<LocalConfig[Key]> { - const localConfig = getConfigValues() - const keyResult = normalizeConfigKey(key) - if (!keyResult.ok) { - return keyResult - } - return { ok: true, data: localConfig[keyResult.data as Key] } -} - -// This version squashes errors, returning undefined instead. -// Should be used when we can reasonably predict the call can't fail. -export function getConfigValueOrUndef<Key extends keyof LocalConfig>( - key: Key, -): LocalConfig[Key] | undefined { - const localConfig = getConfigValues() - const keyResult = normalizeConfigKey(key) - if (!keyResult.ok) { - return undefined - } - return localConfig[keyResult.data as Key] -} - -export function getConfigValues(retryCount = 0): LocalConfig { - // Order: env var > --config flag > file. - // If config is from flag/env override, skip file-based caching. - if (configFromFlag && cachedConfig !== undefined) { - return cachedConfig - } - - const socketAppDataPath = getSocketAppDataPath() - if (socketAppDataPath) { - const configFilePath = path.join(socketAppDataPath, 'config.json') - - try { - const stats = statSync(configFilePath) - const currentMtime = stats.mtimeMs - - // Invalidate cache if not yet loaded, file modified, or path changed. - // On first run, cachedConfig is undefined, triggering initial load. - if ( - cachedConfig === undefined || - cachedConfigMtime !== currentMtime || - cachedConfigPath !== configFilePath - ) { - cachedConfig = {} as LocalConfig - const raw = safeReadFileSync(configFilePath) - - // Verify mtime hasn't changed during read to prevent TOCTOU race. - const statsAfter = statSync(configFilePath) - if (statsAfter.mtimeMs !== currentMtime) { - // File was modified during read, retry with limit. - if (retryCount >= MAX_CONFIG_READ_RETRIES) { - // Intentional: After exhausting retries, log warning and continue. - // This prevents CLI failure when config file is rapidly changing - // (e.g., editor auto-save). Better to warn than hard fail. - logger.warn( - `Config file modified ${retryCount} times during read, using potentially stale data`, - ) - } else { - return getConfigValues(retryCount + 1) - } - } - - if (raw !== undefined) { - try { - const rawString = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw - const decoded = Buffer.from(rawString, 'base64').toString('utf8') - // Check for invalid UTF-8 sequences (replacement character). - if (decoded.includes('\ufffd')) { - throw new Error( - `SOCKET_CLI_CONFIG contains invalid UTF-8 after base64-decode (replacement-character in output); the env var may have been truncated or double-encoded — re-export it with \`echo '{...}' | base64\``, - ) - } - const parsed = JSON.parse(decoded) - // Only copy supported config keys to prevent prototype pollution. - if (parsed && typeof parsed === 'object') { - for (const key of Object.keys(parsed)) { - if (isSupportedConfigKey(key)) { - ;(cachedConfig as Record<string, unknown>)[key] = parsed[key] - } - } - } - debugConfig(configFilePath, true) - /* c8 ignore start - config parse failure path; tests pass valid JSON or use empty config */ - } catch (e) { - logger.warn(`Failed to parse config at ${configFilePath}`) - debugConfig(configFilePath, false, e) - } - /* c8 ignore stop */ - } - cachedConfigMtime = currentMtime - cachedConfigPath = configFilePath - } - } catch { - // File doesn't exist - clear cache and create directory. - if (cachedConfig === undefined || cachedConfigPath !== configFilePath) { - cachedConfig = {} as LocalConfig - cachedConfigMtime = undefined - cachedConfigPath = configFilePath - safeMkdirSync(socketAppDataPath, { recursive: true }) - } - } - /* c8 ignore start - socketAppDataPath undefined fallback; tests always have HOME set so getSocketAppDataPath returns a path */ - } else if (cachedConfig === undefined) { - cachedConfig = {} as LocalConfig - } - /* c8 ignore stop */ - return cachedConfig -} - -export function getSupportedConfigEntries() { - return [...supportedConfigEntries] -} - -export function getSupportedConfigKeys() { - return [...supportedConfigKeys] -} - -export function isConfigFromFlag() { - return configFromFlag -} - -export function isSensitiveConfigKey(key: string): key is keyof LocalConfig { - return sensitiveConfigKeyLookup.has(key as keyof LocalConfig) -} - -export function isSupportedConfigKey(key: string): key is keyof LocalConfig { - return supportedConfig.has(key as keyof LocalConfig) -} - -export function normalizeConfigKey( - key: keyof LocalConfig, -): CResult<keyof LocalConfig> { - // Note: `org` is a convenience alias for `defaultOrg` - const normalizedKey = key === CONFIG_KEY_ORG ? CONFIG_KEY_DEFAULT_ORG : key - if (!isSupportedConfigKey(normalizedKey)) { - return { - ok: false, - message: `Invalid config key: ${normalizedKey}`, - data: undefined, - } - } - return { ok: true, data: normalizedKey } -} - -export function overrideCachedConfig(jsonConfig: unknown): CResult<undefined> { - debugNs('notice', 'override: full config (not stored)') - - let config: unknown - try { - config = JSON.parse(String(jsonConfig)) - if (!config || typeof config !== 'object') { - // `null` is valid json, so are primitive values. - // They're not valid config objects :) - return { - ok: false, - message: 'Could not parse Config as JSON', - cause: - "Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.", - } - } - } catch { - // Force set an empty config to prevent accidentally using system settings. - cachedConfig = {} as LocalConfig - configFromFlag = true - - return { - ok: false, - message: 'Could not parse Config as JSON', - cause: - "Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.", - } - } - - // Only copy supported config keys to prevent prototype pollution. - cachedConfig = {} as LocalConfig - const configObj = config as Record<string, unknown> - for (const key of Object.keys(configObj)) { - if (isSupportedConfigKey(key)) { - ;(cachedConfig as Record<string, unknown>)[key] = configObj[key] - } - } - configFromFlag = true - - return { ok: true, data: undefined } -} - -export function overrideConfigApiToken(apiToken: unknown) { - debugNs('notice', 'override: Socket API token (not stored)') - // Set token to the local cached config and mark it read-only so it doesn't persist. - cachedConfig = { - ...cachedConfig, - ...(apiToken === undefined ? {} : { apiToken: String(apiToken) }), - } as LocalConfig - configFromFlag = true -} - -/** - * Reset config cache for testing purposes. This allows tests to start with a - * fresh config state. - * - * @internal - */ -export function resetConfigForTesting(): void { - cachedConfig = undefined - cachedConfigMtime = undefined - cachedConfigPath = undefined - configFromFlag = false -} - -export function updateConfigValue<Key extends keyof LocalConfig>( - configKey: keyof LocalConfig, - value: LocalConfig[Key], -): CResult<undefined | string> { - const localConfig = getConfigValues() - const keyResult = normalizeConfigKey(configKey) - if (!keyResult.ok) { - return keyResult - } - const key: Key = keyResult.data as Key - // Implicitly deleting when serializing. - let wasDeleted = value === undefined - if (key === 'skipAskToPersistDefaultOrg') { - if (value === 'false' || value === 'true') { - localConfig.skipAskToPersistDefaultOrg = value === 'true' - } else { - delete localConfig.skipAskToPersistDefaultOrg - wasDeleted = true - } - } else { - if (value === 'false' || value === 'true' || value === 'undefined') { - logger.warn( - `Note: The value is set to "${value}", as a string (!). Use \`socket config unset\` to reset a key.`, - ) - } - localConfig[key] = value - } - - if (configFromFlag) { - return { - ok: true, - message: `Config key '${key}' was ${wasDeleted ? 'deleted' : 'updated'}`, - data: 'Change applied but not persisted; current config is overridden through env var or flag', - } - } - - if (!pendingSave) { - pendingSave = true - process.nextTick(() => { - pendingSave = false - const socketAppDataPath = getSocketAppDataPath() - if (socketAppDataPath) { - safeMkdirSync(socketAppDataPath, { recursive: true }) - const configFilePath = path.join(socketAppDataPath, 'config.json') - // Read existing file to preserve formatting, then update with new values. - const existingRaw = safeReadFileSync(configFilePath) - const EditableJson = getEditableJsonClass<LocalConfig>() - const editor = new EditableJson() - if (existingRaw !== undefined) { - const rawString = Buffer.isBuffer(existingRaw) - ? existingRaw.toString('utf8') - : existingRaw - try { - const decoded = Buffer.from(rawString, 'base64').toString('utf8') - editor.fromJSON(decoded) - } catch { - // If decoding fails, start fresh. - } - } - editor.update(localConfig) - const jsonContent = JSON.stringify(editor.content) - writeFileSync( - configFilePath, - Buffer.from(jsonContent).toString('base64'), - ) - // Invalidate mtime cache AFTER write completes to prevent stale reads. - cachedConfigMtime = undefined - // Update mtime cache with new value. - try { - const stats = statSync(configFilePath) - cachedConfigMtime = stats.mtimeMs - cachedConfigPath = configFilePath - } catch { - // Keep mtime undefined if stat fails. - } - } - }) - } - - return { - ok: true, - message: `Config key '${key}' was ${wasDeleted ? 'deleted' : 'updated'}`, - data: undefined, - } -} diff --git a/packages/cli/src/util/cve-to-ghsa.mts b/packages/cli/src/util/cve-to-ghsa.mts deleted file mode 100644 index 69686823e..000000000 --- a/packages/cli/src/util/cve-to-ghsa.mts +++ /dev/null @@ -1,80 +0,0 @@ -import { - cacheFetch, - getOctokit, - handleGitHubApiError, - withGitHubRetry, -} from './git/github.mjs' - -import type { CResult } from '../types.mjs' - -// 30 days in milliseconds for CVE to GHSA cache. -const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 - -/** - * Converts CVE IDs to GHSA IDs using GitHub API. CVE to GHSA mappings are - * permanent, so we cache for 30 days. - */ -export async function convertCveToGhsa( - cveId: string, -): Promise<CResult<string>> { - const cacheKey = `cve-to-ghsa::${cveId}` - - // Check cache first before making API call. - try { - const octokit = getOctokit() - - const response = await cacheFetch( - cacheKey, - async () => { - const result = await withGitHubRetry( - () => - octokit.rest.securityAdvisories.listGlobalAdvisories({ - cve_id: cveId, - per_page: 1, - }), - `converting CVE ${cveId} to GHSA`, - ) - - if (!result.ok) { - throw result - } - - return result.data - }, - THIRTY_DAYS_MS, - ) - - if (!response.data.length) { - return { - ok: false, - message: `No GHSA found for CVE ${cveId}`, - } - } - - const ghsaId = response.data[0]?.ghsa_id - if (!ghsaId) { - return { - ok: false, - message: `No GHSA ID found in response for CVE ${cveId}`, - } - } - - return { - ok: true, - data: ghsaId, - } - } catch (e) { - // If the error is already a CResult, return it directly. - if ( - e && - typeof e === 'object' && - 'ok' in e && - (e as { ok: unknown }).ok === false - ) { - return e as CResult<string> - } - - // Otherwise, convert the error to a user-friendly message. - return handleGitHubApiError(e, `converting CVE ${cveId} to GHSA`) - } -} diff --git a/packages/cli/src/util/data/strings.mts b/packages/cli/src/util/data/strings.mts deleted file mode 100644 index ef68a4d99..000000000 --- a/packages/cli/src/util/data/strings.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * String manipulation utilities for Socket CLI. Provides common string - * transformations and formatting. - * - * Key Functions: - camelToKebab: Convert camelCase to kebab-case. - * - * Usage: - Command name transformations - Flag name conversions - Consistent - * string formatting. - */ - -export function camelToKebab(str: string): string { - return str === '' ? '' : str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() -} diff --git a/packages/cli/src/util/data/walk-nested-map.mts b/packages/cli/src/util/data/walk-nested-map.mts deleted file mode 100644 index 47f786bbb..000000000 --- a/packages/cli/src/util/data/walk-nested-map.mts +++ /dev/null @@ -1,14 +0,0 @@ -type NestedMap<T> = Map<string, T | NestedMap<T>> - -export function* walkNestedMap<T>( - map: NestedMap<T>, - keys: string[] = [], -): Generator<{ keys: string[]; value: T }> { - for (const { 0: key, 1: value } of map.entries()) { - if (value instanceof Map) { - yield* walkNestedMap(value as NestedMap<T>, [...keys, key]) - } else { - yield { keys: [...keys, key], value: value } - } - } -} diff --git a/packages/cli/src/util/debug.mts b/packages/cli/src/util/debug.mts deleted file mode 100644 index e1bf94663..000000000 --- a/packages/cli/src/util/debug.mts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Debug utilities for Socket CLI. Provides structured debugging with - * categorized levels and helpers. - * - * Debug Categories: DEFAULT (shown with SOCKET_CLI_DEBUG=1): - * - * - 'error': Critical errors that prevent operation - * - 'warn': Important warnings that may affect behavior - * - 'notice': Notable events and state changes - * - 'silly': Very verbose debugging info - * - * OPT-IN ONLY (require explicit DEBUG='category' even with SOCKET_CLI_DEBUG=1): - * - * - 'inspect': Detailed object inspection (DEBUG='inspect' or DEBUG='*') - * - 'stdio': Command execution logs (DEBUG='stdio' or DEBUG='*') - * - * These opt-in categories are intentionally excluded from default debug output - * to reduce noise. Enable them explicitly when needed for deep debugging. - */ - -import { - debug, - debugCache, - debugDir, - debugDirNs, - debugNs, -} from '@socketsecurity/lib-stable/debug/output' -import { isDebug, isDebugNs } from '@socketsecurity/lib-stable/debug/namespace' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -type ApiRequestDebugInfo = { - durationMs?: number | undefined - headers?: Record<string, string> | undefined - method?: string | undefined - // ISO-8601 timestamp of when the request was initiated. Useful when - // correlating failures with server-side logs. - requestedAt?: string | undefined - // Response body string; truncated by the helper to a safe length so - // logs don't balloon on megabyte payloads. - responseBody?: string | undefined - // Response headers from the failed request. The helper extracts the - // cf-ray trace id as a first-class field so support can look it up in - // the Cloudflare dashboard without eyeballing the whole header dump. - responseHeaders?: Record<string, string> | undefined - url?: string | undefined -} - -const RESPONSE_BODY_TRUNCATE_LENGTH = 2_000 - -/** - * Build the structured debug payload shared by the error + failure-status - * branches of `debugApiResponse`. Extracted so both paths log the same shape. - */ -export function buildApiDebugDetails( - base: Record<string, unknown>, - requestInfo?: ApiRequestDebugInfo | undefined, -): Record<string, unknown> { - // `__proto__: null` keeps the payload free of prototype-chain keys - // when callers iterate over the debug output. - const details: Record<string, unknown> = { - __proto__: null, - ...base, - } as Record<string, unknown> - if (!requestInfo) { - return details - } - if (requestInfo.requestedAt) { - details['requestedAt'] = requestInfo.requestedAt - } - if (requestInfo.method) { - details['method'] = requestInfo.method - } - if (requestInfo.url) { - details['url'] = requestInfo.url - } - if (requestInfo.durationMs !== undefined) { - details['durationMs'] = requestInfo.durationMs - } - if (requestInfo.headers) { - details['headers'] = sanitizeHeaders(requestInfo.headers) - } - if (requestInfo.responseHeaders) { - const cfRay = - requestInfo.responseHeaders['cf-ray'] ?? - requestInfo.responseHeaders['CF-Ray'] - if (cfRay) { - // First-class field so it's obvious when filing a support ticket - // that points at a Cloudflare trace. - details['cfRay'] = cfRay - } - details['responseHeaders'] = sanitizeHeaders(requestInfo.responseHeaders) - } - if (requestInfo.responseBody !== undefined) { - const body = requestInfo.responseBody - // `.length` / `.slice` operate on UTF-16 code units, not bytes, so - // the counter and truncation are both reported in "chars" to stay - // consistent with what we actually measured. - details['responseBody'] = - body.length > RESPONSE_BODY_TRUNCATE_LENGTH - ? `${body.slice(0, RESPONSE_BODY_TRUNCATE_LENGTH)}… (truncated, ${body.length} chars)` - : body - } - return details -} - -/** - * Debug an API request start. Logs essential info without exposing sensitive - * data. - */ -export function debugApiRequest( - method: string, - endpoint: string, - timeout?: number | undefined, -): void { - if (isDebugNs('silly')) { - const timeoutStr = timeout !== undefined ? ` (timeout: ${timeout}ms)` : '' - debugNs( - 'silly', - `[${new Date().toISOString()}] request started: ${method} ${endpoint}${timeoutStr}`, - ) - } -} - -/** - * Debug an API response. Failed requests (error or status >= 400) log under the - * `error` namespace; successful responses optionally log a one-liner under - * `notice`. - * - * Request and response headers are sanitized via `sanitizeHeaders` so - * Authorization and `*api-key*` values are redacted. - */ -export function debugApiResponse( - endpoint: string, - status?: number | undefined, - error?: unknown | undefined, - requestInfo?: ApiRequestDebugInfo | undefined, -): void { - if (error) { - debugDirNs( - 'error', - buildApiDebugDetails( - { - endpoint, - error: errorMessage(error), - }, - requestInfo, - ), - ) - } else if (status && status >= 400) { - if (requestInfo) { - debugDirNs( - 'error', - buildApiDebugDetails({ endpoint, status }, requestInfo), - ) - } else { - debugNs('error', `API ${endpoint}: HTTP ${status}`) - } - /* c8 ignore start - notice-level debug ns rarely enabled in tests */ - } else if (isDebugNs('notice')) { - debugNs('notice', `API ${endpoint}: ${status || 'pending'}`) - } - /* c8 ignore stop */ -} - -/** - * Debug configuration loading. - */ -export function debugConfig( - source: string, - found: boolean, - error?: unknown | undefined, -): void { - if (error) { - debugDir({ - source, - error: errorMessage(error), - }) - } else if (found) { - debug(`Config loaded: ${source}`) - /* c8 ignore start - silly-level debug ns rarely enabled in tests */ - } else if (isDebugNs('silly')) { - debugNs('silly', `Config not found: ${source}`) - } - /* c8 ignore stop */ -} - -/** - * Debug file operation. Logs file operations with appropriate level. - */ -export function debugFileOp( - operation: 'read' | 'write' | 'delete' | 'create', - filepath: string, - error?: unknown | undefined, -): void { - if (error) { - debugDir({ - operation, - filepath, - error: errorMessage(error), - }) - /* c8 ignore start - silly-level debug ns rarely enabled in tests */ - } else if (isDebugNs('silly')) { - debugNs('silly', `File ${operation}: ${filepath}`) - } - /* c8 ignore stop */ -} - -/** - * Debug git operations. Only logs important git operations, not every command. - */ -export function debugGit( - operation: string, - success: boolean, - details?: Record<string, unknown> | undefined, -): void { - if (!success) { - debugDir({ - git_op: operation, - ...details, - }) - } else if ( - (isDebugNs('notice') && operation.includes('push')) || - operation.includes('commit') - ) { - // Only log important operations like push and commit. - debugNs('notice', `Git ${operation} succeeded`) - } else if (isDebugNs('silly')) { - debugNs('silly', `Git ${operation}`) - } -} - -/** - * Sanitize headers to remove sensitive information. Redacts Authorization and - * API key headers. - * - * Callers must gate truthy — passing an empty/undefined map skips the loop. - */ -export function sanitizeHeaders( - headers: Record<string, string>, -): Record<string, string> { - const sanitized: Record<string, string> = Object.create(null) - for (const [key, value] of Object.entries(headers)) { - const lowerKey = key.toLowerCase() - if (lowerKey === 'authorization' || lowerKey.includes('api-key')) { - sanitized[key] = '[REDACTED]' - } else { - sanitized[key] = value - } - } - return sanitized -} - -export { debug, debugCache, debugDir, debugDirNs, debugNs, isDebug, isDebugNs } diff --git a/packages/cli/src/util/dlx/define-tool-spawn.mts b/packages/cli/src/util/dlx/define-tool-spawn.mts deleted file mode 100644 index 456410fb1..000000000 --- a/packages/cli/src/util/dlx/define-tool-spawn.mts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Factory for "tool spawner" functions in the dlx/spawn-* family. - * - * Every per-tool wrapper exposes the same triple: - * - * - Spawn{Tool}Dlx — npm-CLI mode (download / local override / GitHub release) - * - Spawn{Tool}Vfs — SEA mode (extract from VFS bundle) - * - Spawn{Tool} — auto-dispatch between the two based on isSeaBinary() - * - * The auto-dispatch and the GitHub-release flow are identical across the - * pure-binary tools (trufflehog, trivy, opengrep). This factory encapsulates - * both so per-tool files can declare just `name + resolver` and get the rest. - * - * Hybrid tools that need local-path overrides or extra wiring (cdxgen, sfw, - * socket-patch) keep their bespoke Dlx implementations and only call - * `defineAutoDispatch` for the auto-dispatcher. - */ - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { downloadGitHubReleaseBinary, spawnToolVfs } from './spawn.mts' -import { areExternalToolsAvailable } from './vfs-extract.mjs' -import { isSeaBinary } from '../sea/detect.mts' - -import type { DlxOptions, DlxSpawnResult } from './spawn.mts' -import type { BinaryResolution } from './resolve-binary.mts' -import type { ExternalTool } from './vfs-extract.mts' -import type { StdioOptions } from 'node:child_process' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Argument shape for every spawn function the factory emits. - */ -type ToolSpawnFn = ( - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -) => Promise<DlxSpawnResult> - -export function capitalize(s: string): string { - return s.length ? s[0]!.toUpperCase() + s.slice(1) : s -} - -/** - * Build the standard auto-dispatcher: in SEA mode use VFS, otherwise use Dlx. - * - * Used by every tool wrapper in the dlx/spawn-* family. - */ -export function defineAutoDispatch(opts: { - vfs: ToolSpawnFn - dlx: ToolSpawnFn -}): ToolSpawnFn { - const { dlx, vfs } = opts - return async (args, options, spawnExtra) => { - if (isSeaBinary() && areExternalToolsAvailable()) { - return await vfs(args, options, spawnExtra) - } - return await dlx(args, options, spawnExtra) - } -} - -/** - * Build a npm-CLI-mode spawner for a tool that ships strictly via GitHub - * releases (trufflehog, trivy, opengrep). Throws a clearly-attributed - * resolver-contract error if the resolver returns a non-github-release type. - */ -export function defineGitHubReleaseSpawn(opts: { - toolName: string - resolve: () => BinaryResolution -}): ToolSpawnFn { - const { resolve, toolName } = opts - return async (args, options, spawnExtra) => { - const resolution = resolve() - - if (resolution.type !== 'github-release') { - throw new Error( - `internal: resolve${capitalize(toolName)} returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`, - ) - } - - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as DlxOptions - - const binaryPath = await downloadGitHubReleaseBinary(resolution.details) - - const spawnPromise = spawn(binaryPath, args, { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { spawnPromise } - } -} - -/** - * Build the full spawn-* triple for a pure-GitHub-release tool. - * - * Returns `{ Dlx, Vfs, auto }` where `auto` is the public spawnFoo() dispatcher - * and Dlx/Vfs are the underlying spawners. - */ -export function defineToolSpawn(opts: { - toolName: string - vfsName: ExternalTool - resolve: () => BinaryResolution -}): { - Dlx: ToolSpawnFn - Vfs: ToolSpawnFn - auto: ToolSpawnFn -} { - const Dlx = defineGitHubReleaseSpawn({ - toolName: opts.toolName, - resolve: opts.resolve, - }) - const Vfs = defineVfsSpawn(opts.vfsName) - const auto = defineAutoDispatch({ vfs: Vfs, dlx: Dlx }) - return { Dlx, Vfs, auto } -} - -/** - * Build the standard SEA-mode VFS spawner for a tool. - * - * The VFS name (e.g. 'trufflehog') is the directory key under the SEA bundle. - */ -export function defineVfsSpawn(vfsName: ExternalTool): ToolSpawnFn { - return async (args, options, spawnExtra) => { - return await spawnToolVfs(vfsName, args, options, spawnExtra) - } -} diff --git a/packages/cli/src/util/dlx/resolve-binary.mts b/packages/cli/src/util/dlx/resolve-binary.mts deleted file mode 100644 index 627379d8a..000000000 --- a/packages/cli/src/util/dlx/resolve-binary.mts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * Binary path resolution utilities for external tools. Determines whether to - * use local path overrides, download from npm, or GitHub releases. - */ - -import os from 'node:os' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' - -import { getCdxgenVersion } from '../../env/cdxgen-version.mts' -import { getCoanaVersion } from '../../env/coana-version.mts' -import { requireOpengrepChecksum } from '../../env/opengrep-checksums.mts' -import { getOpengrepVersion } from '../../env/opengrep-version.mts' -import { SOCKET_CLI_CDXGEN_LOCAL_PATH } from '../../env/socket-cli-cdxgen-local-path.mts' -import { SOCKET_CLI_COANA_LOCAL_PATH } from '../../env/socket-cli-coana-local-path.mts' -import { SOCKET_CLI_PYCLI_LOCAL_PATH } from '../../env/socket-cli-pycli-local-path.mts' -import { SOCKET_CLI_SFW_LOCAL_PATH } from '../../env/socket-cli-sfw-local-path.mts' -import { SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH } from '../../env/socket-cli-socket-patch-local-path.mts' -import { getSfwNpmVersion } from '../../env/sfw-version.mts' -import { requireSocketPatchChecksum } from '../../env/socket-patch-checksums.mts' -import { getSocketPatchVersion } from '../../env/socket-patch-version.mts' -import { requireTrivyChecksum } from '../../env/trivy-checksums.mts' -import { getTrivyVersion } from '../../env/trivy-version.mts' -import { requireTrufflehogChecksum } from '../../env/trufflehog-checksums.mts' -import { getTrufflehogVersion } from '../../env/trufflehog-version.mts' - -import type { DlxPackageSpec } from './spawn.mjs' - -/** - * GitHub release binary specification. - */ -export type GitHubReleaseSpec = { - assetName: string - binaryName: string - owner: string - repo: string - /** - * Optional SHA-256 hex checksum for integrity verification. If provided, - * downloads will be verified against this checksum. - */ - sha256?: string | undefined - version: string -} - -/** - * Result of binary resolution. - local: Use a local path override (environment - * variable). - dlx: Download from npm registry via dlx. - github-release: - * Download from GitHub releases. - */ -export type BinaryResolution = - | { type: 'local'; path: string } - | { type: 'dlx'; details: DlxPackageSpec } - | { type: 'github-release'; details: GitHubReleaseSpec } - -/** - * Platform-specific asset names for socket-patch GitHub releases. Maps Node.js - * platform/arch to GitHub release asset names. - * - * Socket-Patch v2.0.0+ Platform Coverage: - * - * - Darwin-arm64: socket-patch-aarch64-apple-darwin.tar.gz - * - Darwin-x64: socket-patch-x86_64-apple-darwin.tar.gz - * - Linux-arm64: socket-patch-aarch64-unknown-linux-gnu.tar.gz - * - Linux-x64: socket-patch-x86_64-unknown-linux-musl.tar.gz (musl works on - * glibc) - * - Win32-arm64: socket-patch-aarch64-pc-windows-msvc.zip - * - Win32-x64: socket-patch-x86_64-pc-windows-msvc.zip - */ -const SOCKET_PATCH_ASSETS: Record<string, string> = { - __proto__: undefined as unknown as string, - 'darwin-arm64': 'socket-patch-aarch64-apple-darwin.tar.gz', - 'darwin-x64': 'socket-patch-x86_64-apple-darwin.tar.gz', - 'linux-arm64': 'socket-patch-aarch64-unknown-linux-gnu.tar.gz', - // FALLBACK: musl build works on glibc systems (statically linked). - 'linux-x64': 'socket-patch-x86_64-unknown-linux-musl.tar.gz', - 'win32-arm64': 'socket-patch-aarch64-pc-windows-msvc.zip', - 'win32-x64': 'socket-patch-x86_64-pc-windows-msvc.zip', -} - -/** - * Platform-specific asset name patterns for Trivy GitHub releases. Maps Node.js - * platform/arch to GitHub release asset name generator functions. - * - * Trivy Platform Coverage: - * - * - Darwin-arm64: trivy_{version}_macOS-ARM64.tar.gz - * - Darwin-x64: trivy_{version}_macOS-64bit.tar.gz - * - Linux-arm64: trivy_{version}_Linux-ARM64.tar.gz - * - Linux-x64: trivy_{version}_Linux-64bit.tar.gz - * - Win32-x64: trivy_{version}_windows-64bit.zip - */ -const TRIVY_ASSET_PATTERNS: Record<string, (v: string) => string> = { - __proto__: undefined as unknown as (v: string) => string, - 'darwin-arm64': (v: string) => `trivy_${v}_macOS-ARM64.tar.gz`, - 'darwin-x64': (v: string) => `trivy_${v}_macOS-64bit.tar.gz`, - 'linux-arm64': (v: string) => `trivy_${v}_Linux-ARM64.tar.gz`, - 'linux-x64': (v: string) => `trivy_${v}_Linux-64bit.tar.gz`, - 'win32-x64': (v: string) => `trivy_${v}_windows-64bit.zip`, -} - -/** - * Platform-specific asset name patterns for TruffleHog GitHub releases. Maps - * Node.js platform/arch to GitHub release asset name generator functions. - * - * TruffleHog Platform Coverage: - * - * - Darwin-arm64: trufflehog_{version}_darwin_arm64.tar.gz - * - Darwin-x64: trufflehog_{version}_darwin_amd64.tar.gz - * - Linux-arm64: trufflehog_{version}_linux_arm64.tar.gz - * - Linux-x64: trufflehog_{version}_linux_amd64.tar.gz - * - Win32-arm64: trufflehog_{version}_windows_arm64.tar.gz - * - Win32-x64: trufflehog_{version}_windows_amd64.tar.gz - */ -const TRUFFLEHOG_ASSET_PATTERNS: Record<string, (v: string) => string> = { - __proto__: undefined as unknown as (v: string) => string, - 'darwin-arm64': (v: string) => `trufflehog_${v}_darwin_arm64.tar.gz`, - 'darwin-x64': (v: string) => `trufflehog_${v}_darwin_amd64.tar.gz`, - 'linux-arm64': (v: string) => `trufflehog_${v}_linux_arm64.tar.gz`, - 'linux-x64': (v: string) => `trufflehog_${v}_linux_amd64.tar.gz`, - 'win32-arm64': (v: string) => `trufflehog_${v}_windows_arm64.tar.gz`, - 'win32-x64': (v: string) => `trufflehog_${v}_windows_amd64.tar.gz`, -} - -/** - * Platform-specific asset names for OpenGrep GitHub releases. Maps Node.js - * platform/arch to GitHub release asset names. - * - * OpenGrep Platform Coverage: - * - * - Darwin-arm64: opengrep-core_osx_aarch64.tar.gz - * - Darwin-x64: opengrep-core_osx_x86.tar.gz - * - Linux-arm64: opengrep-core_linux_aarch64.tar.gz - * - Linux-x64: opengrep-core_linux_x86.tar.gz - * - Win32-x64: opengrep-core_windows_x86.zip - */ -const OPENGREP_ASSETS: Record<string, string> = { - __proto__: undefined as unknown as string, - 'darwin-arm64': 'opengrep-core_osx_aarch64.tar.gz', - 'darwin-x64': 'opengrep-core_osx_x86.tar.gz', - 'linux-arm64': 'opengrep-core_linux_aarch64.tar.gz', - 'linux-x64': 'opengrep-core_linux_x86.tar.gz', - 'win32-x64': 'opengrep-core_windows_x86.zip', -} - -export function getTrivyAssetName(version: string): string | undefined { - const platform = os.platform() - const arch = os.arch() - const platformKey = `${platform}-${arch}` - - const pattern = TRIVY_ASSET_PATTERNS[platformKey] - return pattern ? pattern(version) : undefined -} - -export function getTrufflehogAssetName(version: string): string | undefined { - const platform = os.platform() - const arch = os.arch() - const platformKey = `${platform}-${arch}` - - const pattern = TRUFFLEHOG_ASSET_PATTERNS[platformKey] - return pattern ? pattern(version) : undefined -} - -/** - * Resolve path for cdxgen binary. Checks SOCKET_CLI_CDXGEN_LOCAL_PATH - * environment variable first. - */ -export function resolveCdxgen(): BinaryResolution { - if (SOCKET_CLI_CDXGEN_LOCAL_PATH) { - return { type: 'local', path: SOCKET_CLI_CDXGEN_LOCAL_PATH } - } - - return { - type: 'dlx', - details: { - name: '@cyclonedx/cdxgen', - version: getCdxgenVersion(), - binaryName: 'cdxgen', - }, - } -} - -/** - * Resolve path for Coana CLI binary. Checks SOCKET_CLI_COANA_LOCAL_PATH - * environment variable first. - */ -export function resolveCoana(): BinaryResolution { - if (SOCKET_CLI_COANA_LOCAL_PATH) { - return { type: 'local', path: SOCKET_CLI_COANA_LOCAL_PATH } - } - - return { - type: 'dlx', - details: { - name: '@coana-tech/cli', - version: getCoanaVersion(), - binaryName: 'coana', - }, - } -} - -/** - * Resolve path for OpenGrep binary. Downloads from GitHub releases - * (opengrep/opengrep). - */ -export function resolveOpengrep(): BinaryResolution { - const platform = os.platform() - const arch = os.arch() - const platformKey = `${platform}-${arch}` - const assetName = OPENGREP_ASSETS[platformKey] - - if (!assetName) { - throw new Error( - `OpenGrep has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(OPENGREP_ASSETS))}); run socket-cli on a supported platform or install OpenGrep manually and point \`opengrep\` at it on PATH`, - ) - } - - const sha256 = requireOpengrepChecksum(assetName) - - return { - type: 'github-release', - details: { - assetName, - // OpenGrep extracts to 'osemgrep' binary. - binaryName: 'osemgrep', - owner: 'opengrep', - repo: 'opengrep', - sha256, - version: getOpengrepVersion(), - }, - } -} - -/** - * Resolve path for Python CLI binary. Checks SOCKET_CLI_PYCLI_LOCAL_PATH - * environment variable first. - */ -export function resolvePyCli(): BinaryResolution | { type: 'python' } { - if (SOCKET_CLI_PYCLI_LOCAL_PATH) { - return { type: 'local', path: SOCKET_CLI_PYCLI_LOCAL_PATH } - } - - // Python CLI uses managed Python + pip install, not dlx. - return { type: 'python' } -} - -/** - * Resolve path for Socket Firewall (sfw) binary. Checks - * SOCKET_CLI_SFW_LOCAL_PATH environment variable first. - * - * Note: This returns the npm package version for dlx usage. SEA builds use the - * GitHub binary directly via VFS extraction. - */ -export function resolveSfw(): BinaryResolution { - if (SOCKET_CLI_SFW_LOCAL_PATH) { - return { type: 'local', path: SOCKET_CLI_SFW_LOCAL_PATH } - } - - return { - type: 'dlx', - details: { - name: 'sfw', - version: getSfwNpmVersion(), - binaryName: 'sfw', - }, - } -} - -/** - * Resolve path for Socket Patch binary. Checks - * SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH environment variable first. - * - * Note: As of v2.0.0, socket-patch is a Rust binary downloaded from GitHub - * releases, not an npm package. Uses platform-specific asset names from - * SOCKET_PATCH_ASSETS. - */ -export function resolveSocketPatch(): BinaryResolution { - if (SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH) { - return { type: 'local', path: SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH } - } - - const platform = os.platform() - const arch = os.arch() - const platformKey = `${platform}-${arch}` - const assetName = SOCKET_PATCH_ASSETS[platformKey] - - if (!assetName) { - throw new Error( - `socket-patch has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(SOCKET_PATCH_ASSETS))}); upgrade socket-cli, build socket-patch from source, or set SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH to point at a local build`, - ) - } - - // Get SHA-256 checksum for integrity verification. - // In dev mode (checksums not inlined), returns undefined to allow development. - // In production builds, missing checksums throw a HARD ERROR. - const sha256 = requireSocketPatchChecksum(assetName) - - return { - type: 'github-release', - details: { - assetName, - binaryName: 'socket-patch', - owner: 'SocketDev', - repo: 'socket-patch', - sha256, - version: getSocketPatchVersion(), - }, - } -} - - -/** - * Resolve path for Trivy binary. Downloads from GitHub releases - * (aquasecurity/trivy). - */ -export function resolveTrivy(): BinaryResolution { - const version = getTrivyVersion() - const assetName = getTrivyAssetName(version) - - if (!assetName) { - const platform = os.platform() - const arch = os.arch() - throw new Error( - `Trivy has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64); run socket-cli on a supported platform or install Trivy manually and point \`trivy\` at it on PATH`, - ) - } - - const sha256 = requireTrivyChecksum(assetName) - - return { - type: 'github-release', - details: { - assetName, - binaryName: 'trivy', - owner: 'aquasecurity', - repo: 'trivy', - sha256, - // Trivy uses 'v' prefix for release tags. - version: `v${version}`, - }, - } -} - -/** - * Resolve path for TruffleHog binary. Downloads from GitHub releases - * (trufflesecurity/trufflehog). - */ -export function resolveTrufflehog(): BinaryResolution { - const version = getTrufflehogVersion() - const assetName = getTrufflehogAssetName(version) - - if (!assetName) { - const platform = os.platform() - const arch = os.arch() - throw new Error( - `TruffleHog has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64); run socket-cli on a supported platform or install TruffleHog manually and point \`trufflehog\` at it on PATH`, - ) - } - - const sha256 = requireTrufflehogChecksum(assetName) - - return { - type: 'github-release', - details: { - assetName, - binaryName: 'trufflehog', - owner: 'trufflesecurity', - repo: 'trufflehog', - sha256, - // TruffleHog uses 'v' prefix for release tags. - version: `v${version}`, - }, - } -} diff --git a/packages/cli/src/util/dlx/spawn-cdxgen.mts b/packages/cli/src/util/dlx/spawn-cdxgen.mts deleted file mode 100644 index 335ca8859..000000000 --- a/packages/cli/src/util/dlx/spawn-cdxgen.mts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Spawn cdxgen (CycloneDX SBOM generator). - * - * - SpawnCdxgenDlx: local override > Socket dlx download. - * - SpawnCdxgenVfs: extract from SEA bundle, then exec. - * - SpawnCdxgen: auto-detect SEA vs npm-CLI mode and dispatch. - * - * The local-override path is bespoke (cdxgen is a JS file when local), so Dlx - * stays hand-rolled here. Vfs + auto-dispatch use the shared helpers from - * define-tool-spawn. - */ - -import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts' -import { spawnDlx } from './spawn.mts' -import { resolveCdxgen } from './resolve-binary.mjs' - -import type { DlxOptions, DlxSpawnResult } from './spawn.mts' -import type { StdioOptions } from 'node:child_process' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Helper to spawn cdxgen with dlx. If SOCKET_CLI_CDXGEN_LOCAL_PATH environment - * variable is set, uses the local cdxgen binary at that path instead of - * downloading from npm. - */ -export async function spawnCdxgenDlx( - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - const resolution = resolveCdxgen() - - // Use local cdxgen if available. - if (resolution.type === 'local') { - const detection = detectExecutableType(resolution.path) - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as DlxOptions - - const spawnArgs = - detection.type === 'binary' ? args : [resolution.path, ...args] - const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' - - const spawnPromise = spawn(spawnCommand, spawnArgs, { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { - spawnPromise, - } - } - - // Use dlx version (resolveCdxgen only returns 'local' or 'dlx' types). - if (resolution.type !== 'dlx') { - throw new Error( - `internal: resolveCdxgen returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, - ) - } - return await spawnDlx( - resolution.details, - args, - { force: false, ...options }, - spawnExtra, - ) -} - -export const spawnCdxgenVfs = defineVfsSpawn('cdxgen') - -export const spawnCdxgen = defineAutoDispatch({ - vfs: spawnCdxgenVfs, - dlx: spawnCdxgenDlx, -}) diff --git a/packages/cli/src/util/dlx/spawn-coana.mts b/packages/cli/src/util/dlx/spawn-coana.mts deleted file mode 100644 index a7a5c3fca..000000000 --- a/packages/cli/src/util/dlx/spawn-coana.mts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Spawn Coana CLI for reachability analysis. - * - * - SpawnCoanaDlx: local override > Socket dlx download. Mixes Socket env vars - * (CLI version, API token, org slug, proxy) into the child env so Coana can - * call back to the Socket API. - * - SpawnCoanaVfs: extract from SEA bundle, then exec. - * - SpawnCoana: auto-detect SEA vs npm-CLI mode and dispatch. - */ - -import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { spawnDlx, spawnToolVfs } from './spawn.mts' -import { resolveCoana } from './resolve-binary.mjs' -import { areExternalToolsAvailable } from './vfs-extract.mjs' -import { getDefaultOrgSlug } from '../../commands/ci/fetch-default-org-slug.mjs' -import { getCliVersion } from '../../env/cli-version.mts' -import { getErrorCause } from '../error/errors.mts' -import { isSeaBinary } from '../sea/detect.mts' -import { getDefaultApiToken, getDefaultProxyUrl } from '../socket/sdk.mjs' - -import type { CoanaDlxOptions, DlxSpawnResult } from './spawn.mts' -import type { CResult } from '../../types.mjs' -import type { StdioOptions } from 'node:child_process' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Spawn Coana CLI. Auto-detects SEA mode and uses appropriate spawn method. - */ -export async function spawnCoana( - args: string[] | readonly string[], - orgSlug?: string, - options?: CoanaDlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<CResult<string>> { - if (isSeaBinary() && areExternalToolsAvailable()) { - return await spawnCoanaVfs(args, options, spawnExtra) - } - return await spawnCoanaDlx(args, orgSlug, options, spawnExtra) -} - -/** - * Helper to spawn Coana with dlx. - * - * Returns a CResult with stdout extraction for backward compatibility. - * - * If SOCKET_CLI_COANA_LOCAL_PATH environment variable is set, uses the local - * Coana CLI at that path instead of downloading from npm. - */ -export async function spawnCoanaDlx( - args: string[] | readonly string[], - orgSlug?: string, - options?: CoanaDlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<CResult<string>> { - const { - coanaVersion, - env: spawnEnv, - ...dlxOptions - } = { - __proto__: null, - ...options, - } as CoanaDlxOptions - - const mixinsEnv: Record<string, string> = { - SOCKET_CLI_VERSION: getCliVersion(), - } - const defaultApiToken = getDefaultApiToken() - if (defaultApiToken) { - mixinsEnv['SOCKET_CLI_API_TOKEN'] = defaultApiToken - } - - if (orgSlug) { - mixinsEnv['SOCKET_ORG_SLUG'] = orgSlug - } else { - const orgSlugCResult = await getDefaultOrgSlug() - if (orgSlugCResult.ok) { - mixinsEnv['SOCKET_ORG_SLUG'] = orgSlugCResult.data - } - } - - const proxyUrl = getDefaultProxyUrl() - if (proxyUrl) { - mixinsEnv['SOCKET_CLI_API_PROXY'] = proxyUrl - } - - try { - const resolution = resolveCoana() - - // Use local Coana CLI if available. - if (resolution.type === 'local') { - const detection = detectExecutableType(resolution.path) - - const finalEnv = { - ...process.env, - ...mixinsEnv, - ...spawnEnv, - } - - const spawnArgs = - detection.type === 'binary' ? args : [resolution.path, ...args] - const spawnCommand = - detection.type === 'binary' ? resolution.path : 'node' - - const spawnPromise = spawn(spawnCommand, spawnArgs, { - ...dlxOptions, - env: finalEnv, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - const output = await spawnPromise - - return { - ok: true, - data: output.stdout?.toString() ?? '', - } - } - - // Use dlx version (resolveCoana only returns 'local' or 'dlx' types). - if (resolution.type !== 'dlx') { - throw new Error( - `internal: resolveCoana returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, - ) - } - const result: DlxSpawnResult = await spawnDlx( - { - ...resolution.details, - version: coanaVersion || resolution.details.version, - }, - args, - { - force: true, - ...dlxOptions, - env: { - ...process.env, - ...mixinsEnv, - ...spawnEnv, - }, - }, - spawnExtra, - ) - const output = await result.spawnPromise - return { - ok: true, - data: output.stdout?.toString() ?? '', - } - } catch (e) { - const stderr = (e as { stderr?: string | undefined } | undefined)?.stderr - const cause = getErrorCause(e) - const message = stderr || cause - return { - ok: false, - data: e, - message, - } - } -} - -/** - * Helper to spawn Coana from VFS. Used when running in SEA mode. - */ -export async function spawnCoanaVfs( - args: string[] | readonly string[], - options?: CoanaDlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<CResult<string>> { - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as CoanaDlxOptions - - const mixinsEnv: Record<string, string> = { - SOCKET_CLI_VERSION: getCliVersion(), - } - const defaultApiToken = getDefaultApiToken() - if (defaultApiToken) { - mixinsEnv['SOCKET_CLI_API_TOKEN'] = defaultApiToken - } - - const orgSlugCResult = await getDefaultOrgSlug() - if (orgSlugCResult.ok) { - mixinsEnv['SOCKET_ORG_SLUG'] = orgSlugCResult.data - } - - const proxyUrl = getDefaultProxyUrl() - if (proxyUrl) { - mixinsEnv['SOCKET_CLI_API_PROXY'] = proxyUrl - } - - try { - const result = await spawnToolVfs( - 'coana', - args, - { - ...dlxOptions, - env: { - ...process.env, - ...mixinsEnv, - ...spawnEnv, - }, - }, - spawnExtra, - ) - - const output = await result.spawnPromise - return { - ok: true, - data: output.stdout?.toString() ?? '', - } - } catch (e) { - const stderr = (e as { stderr?: string | undefined } | undefined)?.stderr - const cause = getErrorCause(e) - const message = stderr || cause - return { - ok: false, - data: e, - message, - } - } -} diff --git a/packages/cli/src/util/dlx/spawn-opengrep.mts b/packages/cli/src/util/dlx/spawn-opengrep.mts deleted file mode 100644 index cd3465be2..000000000 --- a/packages/cli/src/util/dlx/spawn-opengrep.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Spawn OpenGrep for AST-based code-pattern scanning. - * - * - SpawnOpengrepDlx: download from GitHub releases, then exec. - * - SpawnOpengrepVfs: extract from SEA bundle, then exec. - * - SpawnOpengrep: auto-detect SEA vs npm-CLI mode and dispatch. - * - * Defined via `defineToolSpawn`. See util/dlx/define-tool-spawn.mts. - */ - -import { defineToolSpawn } from './define-tool-spawn.mts' -import { resolveOpengrep } from './resolve-binary.mjs' - -const triple = defineToolSpawn({ - toolName: 'opengrep', - vfsName: 'opengrep', - resolve: resolveOpengrep, -}) - -export const spawnOpengrepDlx = triple.Dlx -export const spawnOpengrepVfs = triple.Vfs -export const spawnOpengrep = triple.auto diff --git a/packages/cli/src/util/dlx/spawn-pycli.mts b/packages/cli/src/util/dlx/spawn-pycli.mts deleted file mode 100644 index 585f6946c..000000000 --- a/packages/cli/src/util/dlx/spawn-pycli.mts +++ /dev/null @@ -1,779 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Python CLI spawn utilities. These use bundled Python from SEA VFS or download - * portable Python via DLX. - * - * Resolution order for both Python and socketcli: - * - * 1. SOCKET_CLI_PYTHON_PATH / SOCKET_CLI_PYCLI_LOCAL_PATH env vars (local dev). - * 2. Bundled Python from SEA VFS (SEA binary installations). - * 3. Portable Python download via DLX (npm/pnpm/yarn installations). - */ - -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { downloadBinary, getDlxCachePath } from '@socketsecurity/lib-stable/dlx/binary' -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { whichReal } from '@socketsecurity/lib-stable/bin/which' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -import { resolvePyCli } from './resolve-binary.mjs' -import { - areBasicsToolsAvailable, - extractBasicsTools, - getBasicsToolPaths, -} from '../basics/vfs-extract.mts' -import { getPyCliChecksums } from '../../env/pycli-checksums.mts' -import { getPyCliVersion } from '../../env/pycli-version.mts' -import { getPythonBuildTag } from '../../env/python-build-tag.mts' -import { requirePythonChecksum } from '../../env/python-checksums.mts' -import { getPythonVersion } from '../../env/python-version.mts' -import { SOCKET_CLI_PYTHON_PATH } from '../../env/socket-cli-python-path.mts' -import { InputError, getErrorCause } from '../error/errors.mts' -import { isSeaBinary } from '../sea/detect.mts' -import { socketHttpRequest } from '../socket/api.mjs' -import { spawnNode } from '../spawn/spawn-node.mjs' - -import type { DlxOptions } from './spawn.mts' -import type { SpawnNodeOptions } from '../spawn/spawn-node.mts' -import type { CResult } from '../../types.mjs' - -/** - * Convert npm caret range (^2.2.15) to pip version specifier (>=2.2.15,<3.0.0). - */ -export function convertCaretToPipRange(caretRange: string): string { - if (!caretRange) { - return '' - } - - if (!caretRange.startsWith('^')) { - return `==${caretRange}` - } - - const version = caretRange.slice(1) // Remove '^'. - - // Handle malformed caret range (just "^" with no version). - if (!version) { - return '' - } - - const parts = version.split('.') - const major = Number.parseInt(parts[0] || '0', 10) - // Handle non-numeric major version (e.g., "^x.2.3"). - if (Number.isNaN(major)) { - return `==${version}` - } - const nextMajor = major + 1 - - return `>=${version},<${nextMajor}.0.0` -} - -/** - * Download a PyPI wheel with SHA-256 verification. Fetches the wheel URL from - * PyPI JSON API and downloads with integrity check. - * - * @param packageName - PyPI package name (e.g., 'socketsecurity'). - * @param version - Exact version to download. - * @param sha256 - Expected SHA-256 checksum (hex string). - * - * @returns Path to the downloaded wheel file, or null if download fails. - */ -export async function downloadPyPiWheel( - packageName: string, - version: string, - sha256: string | undefined, -): Promise<string | undefined> { - // Cache path: ~/.socket/_dlx/pypi/{package}/{version}/ - const cacheDir = path.join(getDlxCachePath(), 'pypi', packageName, version) - const wheelFilename = `${packageName}-${version}-py3-none-any.whl` - const wheelPath = path.join(cacheDir, wheelFilename) - - // Return cached wheel if already downloaded. - if (existsSync(wheelPath)) { - return wheelPath - } - - await safeMkdir(cacheDir) - - // Fetch wheel URL from PyPI JSON API. - const pypiUrl = `https://pypi.org/pypi/${packageName}/${version}/json` - let wheelUrl: string | undefined = undefined - - try { - const response = await socketHttpRequest(pypiUrl) - if (!response.ok) { - throw new Error( - `PyPI returned HTTP ${response.status} for ${pypiUrl} (expected 200); check the package name and version, or retry if the registry is rate-limiting`, - ) - } - const data = response.json() as { - urls?: Array<{ filename: string; url: string }> | undefined - } - - // Find the wheel URL (prefer py3-none-any wheel). - const wheelInfo = data.urls?.find( - u => - u.filename.endsWith('-py3-none-any.whl') || u.filename.endsWith('.whl'), - ) - if (wheelInfo) { - wheelUrl = wheelInfo.url - } - } catch (e) { - // If we can't fetch from API, construct URL directly (may not work for all packages). - // This is a fallback; the API approach is more reliable. - throw new InputError( - `could not fetch PyPI metadata for ${packageName}==${version} from ${pypiUrl} (${getErrorCause(e)}); check your network or proxy settings, or try again if PyPI is rate-limiting`, - ) - } - - if (!wheelUrl) { - throw new InputError( - `${packageName}==${version} has no py3-none-any wheel on PyPI (only sdist available); pin to a version that ships a wheel or install from source manually`, - ) - } - - // Download wheel with SHA-256 verification. - const result = await downloadBinary({ - name: wheelFilename, - sha256, - url: wheelUrl, - }) - - // Copy to our cache directory (downloadBinary uses its own cache). - await fs.copyFile(result.binaryPath, wheelPath) - - return wheelPath -} - -/** - * Download and extract Python from python-build-standalone using - * downloadBinary. - */ -export async function downloadPython(pythonDir: string): Promise<void> { - const { assetName, url } = getPythonStandaloneInfo() - const tarballName = 'python-standalone.tar.gz' - - // Get SHA-256 checksum for integrity verification. - // In dev mode (checksums not inlined), returns undefined to allow development. - // In production builds, missing checksums throw a HARD ERROR. - const sha256 = requirePythonChecksum(assetName) - - await safeMkdir(pythonDir, { recursive: true }) - - const result = await downloadBinary({ - name: tarballName, - sha256, - url, - }) - - // Extract the tarball to pythonDir. - const tarPath = await whichReal('tar', { nothrow: true }) - if (!tarPath || Array.isArray(tarPath)) { - throw new InputError( - `tar is required to extract the Python standalone archive but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`, - ) - } - await spawn(tarPath, ['-xzf', result.binaryPath, '-C', pythonDir], {}) -} - -/** - * Ensure Python is available (local override, SEA bundled, or DLX downloaded). - * Returns the path to the Python executable. - */ -export async function ensurePython(): Promise<string> { - // Check for local Python path override. - if (SOCKET_CLI_PYTHON_PATH) { - return SOCKET_CLI_PYTHON_PATH - } - - // Use bundled Python from VFS in SEA mode. - if (isSeaBinary() && areBasicsToolsAvailable()) { - const toolsDir = await extractBasicsTools() - if (toolsDir) { - const toolPaths = getBasicsToolPaths(toolsDir) - return toolPaths.python - } - } - - // Fallback to DLX-downloaded Python. - return await ensurePythonDlx() -} - -/** - * Ensure Python is available via DLX download. Uses a lock file to prevent - * concurrent downloads (TOCTOU protection). - * - * @param retryCount Internal retry counter to prevent unbounded recursion. - */ -export async function ensurePythonDlx(retryCount = 0): Promise<string> { - const MAX_RETRIES = 3 - - const pythonDir = getPythonCachePath() - const pythonBin = getPythonBinPath(pythonDir) - const lockFile = path.join(pythonDir, '.downloading') - - if (retryCount >= MAX_RETRIES) { - throw new InputError( - `could not acquire the Python install lock after ${MAX_RETRIES} retries at ${lockFile}; another socket process may be stuck, or the lock file is stale — remove it manually and retry, or check that ${pythonDir} is writable`, - ) - } - - if (!existsSync(pythonBin)) { - await safeMkdir(pythonDir, { recursive: true }) - - // Try to acquire lock atomically. - try { - await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }) - } catch (e: unknown) { - const error = e as NodeJS.ErrnoException - if (error.code === 'EEXIST') { - // Check if lock is stale by reading PID. - let isStale = false - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - // Signal 0 checks process existence without sending actual signal. - process.kill(pid, 0) - // Process exists, lock is valid. - } catch (pidError) { - const pidErr = pidError as NodeJS.ErrnoException - // EPERM means process exists but no permission (treat as alive). - // ESRCH means process doesn't exist (dead). - if (pidErr.code !== 'EPERM') { - isStale = true - } - } - } else { - isStale = true - } - } catch { - // Could not read lock file, may have been removed. - isStale = true - } - - if (isStale) { - // Stale lock detected, remove and retry. - await safeDelete(lockFile, { force: true }) - return ensurePythonDlx(retryCount + 1) - } - - // Lock is valid, wait for download to complete. - for (let i = 0; i < 60; i++) { - await new Promise(resolve => { - setTimeout(resolve, 1_000) - }) - if (existsSync(pythonBin)) { - return pythonBin - } - } - throw new InputError( - `timed out after 60s waiting for another socket process to finish downloading Python to ${pythonDir}; if no other socket process is running, remove ${lockFile} and retry`, - ) - } - throw e - } - - try { - await downloadPython(pythonDir) - - if (!existsSync(pythonBin)) { - throw new InputError( - `Python archive extracted but ${pythonBin} does not exist; the standalone archive layout may have changed — check the asset contents under ${pythonDir} and update the bin-path logic in spawn.mts`, - ) - } - - // Make executable on POSIX. - if (!WIN32) { - await fs.chmod(pythonBin, 0o755) - } - } finally { - // Clean up lock file. - await safeDelete(lockFile, { force: true }) - } - } - - return pythonBin -} - -/** - * Install socketsecurity package into the Python environment. Uses a lock file - * to prevent races when multiple processes install concurrently. - * - * @param pythonBin Path to Python executable. - * @param retryCount Internal retry counter to prevent unbounded recursion. - */ -export async function ensureSocketPyCli( - pythonBin: string, - retryCount = 0, -): Promise<void> { - const MAX_RETRIES = 3 - - if (retryCount >= MAX_RETRIES) { - throw new InputError( - `could not acquire the Socket Python CLI install lock after ${MAX_RETRIES} retries; another socket process may be stuck, or the lock file is stale — check for stale lock files under the Python cache dir and retry`, - ) - } - - if (await isSocketPyCliInstalled(pythonBin)) { - return - } - - // Create lock file to prevent concurrent installation. - const pythonDir = path.dirname(pythonBin) - const lockFile = path.join(pythonDir, '.installing-socketcli') - - try { - await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }) - } catch (e: unknown) { - const error = e as NodeJS.ErrnoException - if (error.code === 'EEXIST') { - // Check if lock is stale by reading PID. - let isStale = false - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - // Signal 0 checks process existence. - process.kill(pid, 0) - // Process exists, lock is valid. - } catch (pidError) { - const pidErr = pidError as NodeJS.ErrnoException - // EPERM means process exists but no permission (treat as alive). - // ESRCH means process doesn't exist (dead). - if (pidErr.code !== 'EPERM') { - isStale = true - } - } - } else { - isStale = true - } - } catch { - // Could not read lock file, may have been removed. - isStale = true - } - - if (isStale) { - // Stale lock detected, remove and retry immediately. - await safeDelete(lockFile, { force: true }) - return ensureSocketPyCli(pythonBin, retryCount + 1) - } - - // Lock is valid, wait for installation to complete. - for (let i = 0; i < 30; i++) { - await new Promise(resolve => { - setTimeout(resolve, 1_000) - }) - if (await isSocketPyCliInstalled(pythonBin)) { - return - } - // Periodically re-check if lock holder is still alive. - if (i % 5 === 4) { - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - process.kill(pid, 0) - } catch (pidError) { - const pidErr = pidError as NodeJS.ErrnoException - if (pidErr.code !== 'EPERM') { - // Lock holder died during wait, retry. - await safeDelete(lockFile, { force: true }) - return ensureSocketPyCli(pythonBin, retryCount + 1) - } - } - } - } catch { - // Lock file gone, retry. - return ensureSocketPyCli(pythonBin, retryCount + 1) - } - } - } - // Timeout after 30 seconds, retry anyway. - return ensureSocketPyCli(pythonBin, retryCount + 1) - } - throw e - } - - try { - const pyCliVersion = getPyCliVersion() - - // Get checksum for integrity verification. - // Checksums are keyed by wheel filename in bundle-tools.json. - const wheelFilename = `socketsecurity-${pyCliVersion}-py3-none-any.whl` - const checksums = getPyCliChecksums() - const sha256 = checksums[wheelFilename] - - // If checksums are available, download verified wheel and install from local file. - // Otherwise fall back to pip install (dev mode or missing checksums). - if (sha256) { - const wheelPath = await downloadPyPiWheel( - 'socketsecurity', - pyCliVersion, - sha256, - ) - if (wheelPath) { - await spawn(pythonBin, ['-m', 'pip', 'install', '--quiet', wheelPath], { - shell: WIN32, - stdio: 'inherit', - }) - /* c8 ignore start */ - } else { - throw new InputError( - `could not download the verified socketsecurity==${pyCliVersion} wheel (downloadPyPiWheel returned null — likely a checksum mismatch or missing wheel asset); re-run with --debug for details, or bump the version in bundle-tools.json if the checksum needs refreshing`, - ) - } - /* c8 ignore stop */ - } else { - // Dev mode: no checksums inlined, install directly from PyPI. - const versionSpec = convertCaretToPipRange(pyCliVersion) - const packageSpec = versionSpec - ? `socketsecurity${versionSpec}` - : 'socketsecurity' - - await spawn(pythonBin, ['-m', 'pip', 'install', '--quiet', packageSpec], { - shell: WIN32, - stdio: 'inherit', - }) - } - } finally { - // Clean up lock file. - await safeDelete(lockFile, { force: true }) - } -} - -/** - * Get the path to the Python executable within the installation. - */ -export function getPythonBinPath(pythonDir: string): string { - /* c8 ignore start - Windows-only branch; CI/test env mocks WIN32=false */ - if (WIN32) { - return path.join(pythonDir, 'python', 'python.exe') - } - /* c8 ignore stop */ - return path.join(pythonDir, 'python', 'bin', 'python3') -} - -/** - * Get the path to the cached Python installation directory. - */ -export function getPythonCachePath(): string { - const version = getPythonVersion() - const tag = getPythonBuildTag() - const platform = os.platform() - const arch = os.arch() - - return path.join( - getDlxCachePath(), - 'python', - `${version}-${tag}-${platform}-${arch}`, - ) -} - -/** - * Get the download URL and asset name for python-build-standalone based on - * platform and architecture. - */ -export function getPythonStandaloneInfo(): { assetName: string; url: string } { - const version = getPythonVersion() - const tag = getPythonBuildTag() - const platform = os.platform() - const arch = os.arch() - - let platformTriple: string - - if (platform === 'darwin') { - platformTriple = - arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin' - } else if (platform === 'linux') { - platformTriple = - arch === 'arm64' - ? 'aarch64-unknown-linux-gnu' - : 'x86_64-unknown-linux-gnu' - } else if (platform === 'win32') { - // Windows ARM64 can use native ARM64 Python for better performance. - platformTriple = - arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc' - } else { - throw new InputError( - `python-build-standalone does not ship a prebuilt for os.platform()="${platform}" (supported: darwin, linux, win32); install Python manually and point socket at it via PATH`, - ) - } - - // Asset name format matches checksums in bundle-tools.json. - const assetName = `cpython-${version}+${tag}-${platformTriple}-install_only.tar.gz` - // URL encoding for the '+' in version string. - const encodedVersion = `${version}%2B${tag}` - const url = `https://github.com/astral-sh/python-build-standalone/releases/download/${tag}/cpython-${encodedVersion}-${platformTriple}-install_only.tar.gz` - - return { assetName, url } -} - -/** - * Check if socketcli is installed in the Python environment. - */ -export async function isSocketPyCliInstalled( - pythonBin: string, -): Promise<boolean> { - try { - const result = await spawn( - pythonBin, - ['-c', 'import socketsecurity.socketcli'], - { shell: WIN32 }, - ) - return result.code === 0 - } catch { - return false - } -} - -/** - * Spawn socketcli (Socket Python CLI). Ensures Python is available (SEA bundled - * or DLX downloaded) before spawning. - */ -export async function spawnSocketPyCli( - args: string[] | readonly string[], - options?: SocketPyCliDlxOptions | undefined, -): Promise<CResult<string>> { - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as SocketPyCliDlxOptions - - const finalEnv: Record<string, string | undefined> = { - ...process.env, - ...spawnEnv, - } - - try { - // Check for local path override first. - const resolution = resolvePyCli() - if (resolution.type === 'local') { - const spawnNodeOpts: SpawnNodeOptions = { - ...(dlxOptions.cwd ? { cwd: dlxOptions.cwd } : {}), - env: finalEnv, - shell: WIN32, - stdio: 'inherit', - } - const spawnResult = await spawnNode( - [resolution.path, ...args], - spawnNodeOpts, - ) - - return { - data: spawnResult.stdout?.toString() ?? '', - ok: true, - } - } - - // Ensure Python is available (SEA bundled or DLX downloaded). - const pythonBin = await ensurePython() - - // Ensure socketsecurity package is installed. - await ensureSocketPyCli(pythonBin) - - // Build environment - isolate PATH for SEA mode. - const spawnEnvFinal = - isSeaBinary() && areBasicsToolsAvailable() - ? { - ...finalEnv, - // Isolate PATH to bundled tools directory for SEA. - PATH: `${path.dirname(pythonBin)}:${path.dirname(path.dirname(pythonBin))}`, - } - : finalEnv - - // Run socketcli via python -m. - const spawnResult = await spawn( - pythonBin, - ['-m', 'socketsecurity.socketcli', ...args], - { - ...dlxOptions, - env: spawnEnvFinal, - shell: WIN32, - stdio: 'inherit', - }, - ) - - return { - data: spawnResult.stdout?.toString() ?? '', - ok: true, - } - } catch (e) { - const cause = getErrorCause(e) - return { - data: e, - message: cause, - ok: false, - } - } -} - -/** - * Spawn socketcli via DLX-downloaded Python. Downloads portable Python from - * python-build-standalone and installs socketsecurity. - */ -export async function spawnSocketPyCliDlx( - args: string[] | readonly string[], - options?: SocketPyCliDlxOptions | undefined, -): Promise<CResult<string>> { - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as SocketPyCliDlxOptions - - const resolution = resolvePyCli() - - const finalEnv: Record<string, string | undefined> = { - ...process.env, - ...spawnEnv, - } - - try { - // Use local Python CLI if available. - if (resolution.type === 'local') { - const spawnNodeOpts: SpawnNodeOptions = { - ...(dlxOptions.cwd ? { cwd: dlxOptions.cwd } : {}), - env: finalEnv, - shell: WIN32, - stdio: 'inherit', - } - const spawnResult = await spawnNode( - [resolution.path, ...args], - spawnNodeOpts, - ) - - return { - data: spawnResult.stdout?.toString() ?? '', - ok: true, - } - } - - // Download portable Python via DLX infrastructure. - const pythonBin = await ensurePythonDlx() - - // Ensure socketsecurity is installed. - await ensureSocketPyCli(pythonBin) - - // Run socketcli via python -m. - const spawnResult = await spawn( - pythonBin, - ['-m', 'socketsecurity.socketcli', ...args], - { - ...dlxOptions, - env: finalEnv, - shell: WIN32, - stdio: 'inherit', - }, - ) - - return { - data: spawnResult.stdout?.toString() ?? '', - ok: true, - } - } catch (e) { - const cause = getErrorCause(e) - return { - data: e, - message: cause, - ok: false, - } - } -} - -export type SocketPyCliDlxOptions = DlxOptions - -/** - * Spawn socketcli via bundled Python from SEA VFS. Uses the same Python as - * socket-basics for consistency. - */ -export async function spawnSocketPyCliVfs( - args: string[] | readonly string[], - options?: SocketPyCliDlxOptions | undefined, -): Promise<CResult<string>> { - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as SocketPyCliDlxOptions - - try { - const toolsDir = await extractBasicsTools() - if (!toolsDir) { - return { - data: new Error('Failed to extract basics tools from VFS'), - message: 'Failed to extract basics tools from VFS', - ok: false, - } - } - - const toolPaths = getBasicsToolPaths(toolsDir) - const pythonBin = toolPaths.python - - // Ensure socketsecurity package is installed with integrity verification. - const pyCliVersion = getPyCliVersion() - const wheelFilename = `socketsecurity-${pyCliVersion}-py3-none-any.whl` - const checksums = getPyCliChecksums() - const sha256 = checksums[wheelFilename] - - if (sha256) { - // Download verified wheel and install from local file. - const wheelPath = await downloadPyPiWheel( - 'socketsecurity', - pyCliVersion, - sha256, - ) - if (wheelPath) { - await spawn(pythonBin, ['-m', 'pip', 'install', '--quiet', wheelPath], { - stdio: 'pipe', - }) - /* c8 ignore start - defensive: downloadPyPiWheel returns a string or throws */ - } else { - throw new Error( - `failed to download socketsecurity==${pyCliVersion} wheel from PyPI (downloadPyPiWheel returned null — likely a checksum mismatch or missing py3-none-any wheel); re-run with --debug for details`, - ) - } - /* c8 ignore stop */ - } else { - // Dev mode: install directly from PyPI. - await spawn( - pythonBin, - ['-m', 'pip', 'install', '--quiet', `socketsecurity==${pyCliVersion}`], - { stdio: 'pipe' }, - ) - } - - // Run socketcli with isolated PATH. - const spawnResult = await spawn( - pythonBin, - ['-m', 'socketsecurity.socketcli', ...args], - { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - // Isolate PATH to bundled tools only. - PATH: `${path.dirname(pythonBin)}:${toolsDir}`, - }, - shell: WIN32, - stdio: 'inherit', - }, - ) - - return { - data: spawnResult.stdout?.toString() ?? '', - ok: true, - } - } catch (e) { - const cause = getErrorCause(e) - return { - data: e, - message: cause, - ok: false, - } - } -} diff --git a/packages/cli/src/util/dlx/spawn-sfw.mts b/packages/cli/src/util/dlx/spawn-sfw.mts deleted file mode 100644 index 5f931179c..000000000 --- a/packages/cli/src/util/dlx/spawn-sfw.mts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Spawn Socket Firewall (sfw) — the transparent proxy for npm/yarn/pnpm/etc. - * - * - SpawnSfwDlx: local override > Socket dlx download. - * - SpawnSfwVfs: extract from SEA bundle, then exec. - * - SpawnSfw: auto-detect SEA vs npm-CLI mode and dispatch. - * - * Sfw is a transparent proxy: args is [innerTool, innerSubcommand?, ...rest]. - * Machine-mode flags forward to the inner tool so its stdout stays pipe-safe - * under --json. The Dlx flow stays bespoke (machine-mode + local-override both - * apply); Vfs + auto-dispatch use the shared helpers. - */ - -import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts' -import { spawnDlx } from './spawn.mts' -import { resolveSfw } from './resolve-binary.mjs' -import { - applyMachineModeIfActive, - inferSubcommand, -} from '../spawn/apply-machine-mode.mts' - -import type { DlxOptions, DlxSpawnResult } from './spawn.mts' -import type { StdioOptions } from 'node:child_process' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Helper to spawn Socket Firewall (sfw) with dlx. If SOCKET_CLI_SFW_LOCAL_PATH - * environment variable is set, uses the local sfw binary at that path instead - * of downloading from npm. - */ -export async function spawnSfwDlx( - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - const [innerTool, ...innerArgs] = args - const innerSubcommand = inferSubcommand(innerArgs) - const innerApplied = innerTool - ? applyMachineModeIfActive({ - args: innerArgs, - env: undefined, - subcommand: innerSubcommand, - tool: innerTool, - }) - : { args: [...innerArgs], env: {} } - const effectiveArgs = innerTool - ? [innerTool, ...innerApplied.args] - : [...args] - - const resolution = resolveSfw() - - // Use local sfw if available. - if (resolution.type === 'local') { - const detection = detectExecutableType(resolution.path) - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as DlxOptions - - const spawnArgs = - detection.type === 'binary' - ? effectiveArgs - : [resolution.path, ...effectiveArgs] - const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' - - const spawnPromise = spawn(spawnCommand, spawnArgs, { - ...dlxOptions, - env: { - ...process.env, - ...innerApplied.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { - spawnPromise, - } - } - - // Use dlx version (resolveSfw only returns 'local' or 'dlx' types). - if (resolution.type !== 'dlx') { - throw new Error( - `internal: resolveSfw returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, - ) - } - return await spawnDlx( - resolution.details, - effectiveArgs, - { - force: false, - ...options, - env: { - ...innerApplied.env, - ...options?.env, - }, - }, - spawnExtra, - ) -} - -export const spawnSfwVfs = defineVfsSpawn('sfw') - -export const spawnSfw = defineAutoDispatch({ - vfs: spawnSfwVfs, - dlx: spawnSfwDlx, -}) diff --git a/packages/cli/src/util/dlx/spawn-socket-patch.mts b/packages/cli/src/util/dlx/spawn-socket-patch.mts deleted file mode 100644 index 6bdbac5d8..000000000 --- a/packages/cli/src/util/dlx/spawn-socket-patch.mts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Spawn socket-patch (Rust binary) for applying Socket-managed patches. - * - * - SpawnSocketPatchDlx: local override > GitHub release download > legacy npm - * dlx fallback. - * - SpawnSocketPatchVfs: extract from SEA bundle, then exec. - * - SpawnSocketPatch: auto-detect SEA vs npm-CLI mode and dispatch. - * - * The Dlx flow is bespoke (three-way dispatch local / GitHub-release / legacy - * npm fallback). Vfs + auto-dispatch use the shared helpers. - */ - -import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts' -import { downloadGitHubReleaseBinary, spawnDlx } from './spawn.mts' -import { resolveSocketPatch } from './resolve-binary.mjs' - -import type { DlxOptions, DlxSpawnResult } from './spawn.mts' -import type { StdioOptions } from 'node:child_process' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Spawn socket-patch via dlx (npm CLI mode). - * - * If SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH is set in the environment, runs the - * socket-patch binary at that path instead of downloading. - * - * Note: As of v2.0.0, socket-patch is a Rust binary downloaded from GitHub - * releases, not an npm package. This function handles both local overrides and - * GitHub downloads. - */ -export async function spawnSocketPatchDlx( - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - const resolution = resolveSocketPatch() - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as DlxOptions - - // Use local socket-patch if available. - if (resolution.type === 'local') { - const detection = detectExecutableType(resolution.path) - - const spawnArgs = - detection.type === 'binary' ? args : [resolution.path, ...args] - const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' - - const spawnPromise = spawn(spawnCommand, spawnArgs, { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { - spawnPromise, - } - } - - // Download from GitHub releases (socket-patch v2.0.0+). - if (resolution.type === 'github-release') { - const binaryPath = await downloadGitHubReleaseBinary(resolution.details) - - const spawnPromise = spawn(binaryPath, args, { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { - spawnPromise, - } - } - - // Fallback to dlx for npm packages (not used for socket-patch v2.0.0+). - return await spawnDlx( - resolution.details, - args, - { force: false, ...options }, - spawnExtra, - ) -} - -export const spawnSocketPatchVfs = defineVfsSpawn('socket-patch') - -export const spawnSocketPatch = defineAutoDispatch({ - vfs: spawnSocketPatchVfs, - dlx: spawnSocketPatchDlx, -}) diff --git a/packages/cli/src/util/dlx/spawn-synp.mts b/packages/cli/src/util/dlx/spawn-synp.mts deleted file mode 100644 index 6ee8bd41a..000000000 --- a/packages/cli/src/util/dlx/spawn-synp.mts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Spawn synp for converting between yarn.lock and package-lock.json. - * - * - SpawnSynpDlx: install via Socket dlx, then exec. - * - SpawnSynpVfs: extract from SEA bundle, then exec. - * - SpawnSynp: auto-detect SEA vs npm-CLI mode and dispatch. - * - * Synp is a pure-npm package (no GitHub release / no local override), so the - * Dlx flow is just `spawnDlx` with the synp version pin. Vfs and auto-dispatch - * use the standard helpers from define-tool-spawn. - */ - -import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts' -import { spawnDlx } from './spawn.mts' -import { getSynpVersion } from '../../env/synp-version.mts' - -import type { DlxOptions, DlxSpawnResult } from './spawn.mts' -import type { SpawnExtra } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Helper to spawn synp with dlx. - */ -export async function spawnSynpDlx( - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - return await spawnDlx( - { - name: 'synp', - version: getSynpVersion(), - }, - args, - { force: false, ...options }, - spawnExtra, - ) -} - -export const spawnSynpVfs = defineVfsSpawn('synp') - -export const spawnSynp = defineAutoDispatch({ - vfs: spawnSynpVfs, - dlx: spawnSynpDlx, -}) diff --git a/packages/cli/src/util/dlx/spawn-trivy.mts b/packages/cli/src/util/dlx/spawn-trivy.mts deleted file mode 100644 index 41e17afce..000000000 --- a/packages/cli/src/util/dlx/spawn-trivy.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Spawn Trivy for image / IaC vulnerability scanning. - * - * - SpawnTrivyDlx: download from GitHub releases, then exec. - * - SpawnTrivyVfs: extract from SEA bundle, then exec. - * - SpawnTrivy: auto-detect SEA vs npm-CLI mode and dispatch. - * - * Defined via `defineToolSpawn`. See util/dlx/define-tool-spawn.mts. - */ - -import { defineToolSpawn } from './define-tool-spawn.mts' -import { resolveTrivy } from './resolve-binary.mjs' - -const triple = defineToolSpawn({ - toolName: 'trivy', - vfsName: 'trivy', - resolve: resolveTrivy, -}) - -export const spawnTrivyDlx = triple.Dlx -export const spawnTrivyVfs = triple.Vfs -export const spawnTrivy = triple.auto diff --git a/packages/cli/src/util/dlx/spawn-trufflehog.mts b/packages/cli/src/util/dlx/spawn-trufflehog.mts deleted file mode 100644 index e019f38a8..000000000 --- a/packages/cli/src/util/dlx/spawn-trufflehog.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Spawn TruffleHog for secret-scanning runs. - * - * - SpawnTrufflehogDlx: download from GitHub releases, then exec. - * - SpawnTrufflehogVfs: extract from SEA bundle, then exec. - * - SpawnTrufflehog: auto-detect SEA vs npm-CLI mode and dispatch. - * - * Defined via `defineToolSpawn`. See util/dlx/define-tool-spawn.mts. - */ - -import { defineToolSpawn } from './define-tool-spawn.mts' -import { resolveTrufflehog } from './resolve-binary.mjs' - -const triple = defineToolSpawn({ - toolName: 'trufflehog', - vfsName: 'trufflehog', - resolve: resolveTrufflehog, -}) - -export const spawnTrufflehogDlx = triple.Dlx -export const spawnTrufflehogVfs = triple.Vfs -export const spawnTrufflehog = triple.auto diff --git a/packages/cli/src/util/dlx/spawn.mts b/packages/cli/src/util/dlx/spawn.mts deleted file mode 100644 index 98985ba7e..000000000 --- a/packages/cli/src/util/dlx/spawn.mts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * DLX execution utilities for Socket CLI. Manages package execution using - * Socket's own dlx implementation. - * - * Key Functions: - * - * - SpawnCdxgenDlx: Execute CycloneDX generator via dlx - * - SpawnCoanaDlx: Execute Coana CLI tool via dlx - * - SpawnDlx: Execute packages using Socket's dlx - * - SpawnSfwDlx: Execute Socket Firewall via dlx - * - SpawnSocketPyCli: Execute Socket Python CLI - * - SpawnSocketPatchDlx: Execute Socket Patch via dlx - * - SpawnSynpDlx: Execute Synp converter via dlx - * - * Implementation: - * - * - Uses @socketsecurity/lib/dlx/package for direct package installation - * - Installs packages to ~/.socket/_dlx directory - * - Executes binaries directly without package manager commands - */ - -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import AdmZip from 'adm-zip' -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { downloadBinary, getDlxCachePath } from '@socketsecurity/lib-stable/dlx/binary' -import { dlxPackage } from '@socketsecurity/lib-stable/dlx/package' -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { whichReal } from '@socketsecurity/lib-stable/bin/which' - -import type { GitHubReleaseSpec } from './resolve-binary.mjs' -import { - areExternalToolsAvailable, - extractExternalTools, -} from './vfs-extract.mjs' -import { InputError } from '../error/errors.mts' - -import type { IpcObject } from '../ipc.mts' -import type { ExternalTool } from './vfs-extract.mjs' -import type { StdioOptions } from 'node:child_process' -import type { - SpawnExtra, - SpawnOptions, - SpawnResult, -} from '@socketsecurity/lib-stable/process/spawn/types' - -type DlxSpawnOptions = SpawnOptions & { - ipc?: IpcObject | undefined -} - -export type DlxSpawnResult = { - spawnPromise: SpawnResult -} - -export type DlxOptions = DlxSpawnOptions & { - agent?: 'npm' | 'pnpm' | 'yarn' | undefined - force?: boolean | undefined - silent?: boolean | undefined -} - -export type CoanaDlxOptions = DlxOptions & { - coanaVersion?: string | undefined -} - -export type DlxPackageSpec = { - binaryName?: string | undefined - name: string - version: string -} - -/** - * Helper to spawn Coana with dlx. Returns a CResult with stdout extraction for - * backward compatibility. - * - * If SOCKET_CLI_COANA_LOCAL_PATH environment variable is set, uses the local - * Coana CLI at that path instead of downloading from npm. - */ -export { spawnCoanaDlx } from './spawn-coana.mts' - -export { spawnCdxgenDlx } from './spawn-cdxgen.mts' - -export { spawnSfwDlx } from './spawn-sfw.mts' - -/** - * Helper to spawn Socket Patch. If SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH - * environment variable is set, uses the local socket-patch binary at that path - * instead of downloading. - * - * Note: As of v2.0.0, socket-patch is a Rust binary downloaded from GitHub - * releases, not an npm package. This function handles both local overrides and - * GitHub downloads. - */ -export { spawnSocketPatchDlx } from './spawn-socket-patch.mts' - -/** - * Download and cache a binary from GitHub releases. Handles both .tar.gz and - * .zip archives, extracting the binary to the dlx cache. - * - * Security: - Uses lock files to prevent TOCTOU race conditions during - * concurrent downloads. - Validates zip entries for path traversal attacks - * before extraction. - Verifies SHA-256 checksum if provided in spec. - * - * @param spec - GitHub release specification. - * - * @returns Path to the downloaded binary. - */ -export async function downloadGitHubReleaseBinary( - spec: GitHubReleaseSpec, -): Promise<string> { - const { assetName, binaryName, owner, repo, sha256, version } = spec - const isPlatWin = os.platform() === 'win32' - const binaryFileName = binaryName + (isPlatWin ? '.exe' : '') - - // Cache path: ~/.socket/_dlx/github/{owner}/{repo}/{version}/ - const cacheDir = path.join(getDlxCachePath(), 'github', owner, repo, version) - const normalizedCacheDir = path.resolve(cacheDir) - const binaryPath = path.join(cacheDir, binaryFileName) - const lockFile = path.join(cacheDir, '.downloading') - - // Check if already downloaded. - if (existsSync(binaryPath)) { - return binaryPath - } - - await safeMkdir(cacheDir) - - // TOCTOU protection: use lock file to prevent concurrent downloads. - try { - await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }) - } catch (e: unknown) { - const error = e as NodeJS.ErrnoException - if (error.code === 'EEXIST') { - // Another process is downloading; wait for completion. - for (let i = 0; i < 60; i++) { - await new Promise(resolve => { - setTimeout(resolve, 1_000) - }) - if (existsSync(binaryPath)) { - return binaryPath - } - // Check if lock holder is still alive. - if (i % 5 === 4) { - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - process.kill(pid, 0) - } catch { - // Process died, lock is stale - remove and retry. - await safeDelete(lockFile, { force: true }) - return downloadGitHubReleaseBinary(spec) - } - } - } catch { - // Lock file gone, retry. - return downloadGitHubReleaseBinary(spec) - } - } - } - throw new InputError( - `timed out waiting for another socket process to finish downloading ${owner}/${repo}@${version} (${assetName}); if no other socket process is running, remove stale lock files under ${path.dirname(binaryPath)} and retry`, - ) - } - throw e - } - - try { - // Re-check after acquiring lock (another process may have finished). - if (existsSync(binaryPath)) { - return binaryPath - } - - // Download the archive using downloadBinary (handles caching internally). - const url = `https://github.com/${owner}/${repo}/releases/download/${version}/${assetName}` - - const result = await downloadBinary({ - name: `${owner}-${repo}-${version}-${assetName}`, - sha256, - url, - }) - - // Extract based on archive type. - const isZip = assetName.endsWith('.zip') - const isTarGz = assetName.endsWith('.tar.gz') || assetName.endsWith('.tgz') - - if (isZip) { - // Extract zip using adm-zip (cross-platform, zero dependencies). - const zip = new AdmZip(result.binaryPath) - - // Security: validate all entries for path traversal before extraction. - const entries = zip.getEntries() - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const entryPath = path.resolve(path.join(cacheDir, entry.entryName)) - if (!entryPath.startsWith(normalizedCacheDir)) { - throw new InputError( - `archive entry "${entry.entryName}" resolves outside the cache dir (${normalizedCacheDir}) — this looks like a zip-slip attack; do NOT trust this release asset, report it to the upstream project, and delete ${result.binaryPath}`, - ) - } - } - - zip.extractAllTo(cacheDir, true) - - // Security: validate no symlinks escape the cache directory after extraction. - const extractedFiles = await fs.readdir(cacheDir, { recursive: true }) - for (let i = 0, { length } = extractedFiles; i < length; i += 1) { - const file = extractedFiles[i]! - const fullPath = path.join(cacheDir, file) - // oxlint-disable-next-line socket/prefer-exists-sync -- reads .isSymbolicLink() metadata for symlink escape validation. - const stats = await fs.lstat(fullPath) - if (stats.isSymbolicLink()) { - const target = await fs.readlink(fullPath) - const resolvedTarget = path.resolve(path.dirname(fullPath), target) - if (!resolvedTarget.startsWith(normalizedCacheDir)) { - await safeDelete(fullPath, { force: true }) - throw new InputError( - `extracted symlink ${file} targets ${resolvedTarget} which is outside the cache dir (${normalizedCacheDir}); do NOT trust this release asset, report it to the upstream project, and delete ${cacheDir}`, - ) - } - } - } - } else if (isTarGz) { - // Extract tar.gz using system tar. - // Note: tar has built-in path traversal protection by default. - const tarPath = await whichReal('tar', { nothrow: true }) - if (!tarPath || Array.isArray(tarPath)) { - throw new InputError( - `tar is required to extract ${assetName} but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`, - ) - } - await spawn(tarPath, ['-xzf', result.binaryPath, '-C', cacheDir], {}) - } else { - throw new InputError( - `archive format of ${assetName} is not supported (expected .zip or .tar.gz / .tgz); check the asset name in bundle-tools.json and the release's actual asset list`, - ) - } - - // Verify binary was extracted. - if (!existsSync(binaryPath)) { - throw new InputError( - `archive ${assetName} extracted but ${binaryFileName} was not found inside (expected at ${binaryPath}); the release's archive layout may have changed — verify asset contents and update bundle-tools.json`, - ) - } - - // Make executable on Unix. - if (!isPlatWin) { - await fs.chmod(binaryPath, 0o755) - } - - return binaryPath - } finally { - // Clean up lock file. - await safeDelete(lockFile, { force: true }) - } -} - -/** - * Spawns a package using Socket's dlx implementation. Installs packages to - * ~/.socket/_dlx and executes them directly. - */ -export async function spawnDlx( - packageSpec: DlxPackageSpec, - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - const { force = false, ...spawnOpts } = options ?? {} - - // Validate package name for security. - validatePackageName(packageSpec.name) - - const packageString = `${packageSpec.name}@${packageSpec.version}` - - // Use Socket's dlxPackage to install and execute. - const result = await dlxPackage( - args, - { - package: packageString, - binaryName: packageSpec.binaryName, - force, - spawnOptions: spawnOpts, - }, - spawnExtra, - ) - - return { - spawnPromise: result.spawnPromise as unknown as SpawnResult, - } -} - -/** - * Helper to spawn a tool from VFS extraction. Used when running in SEA mode. - */ -export async function spawnToolVfs( - tool: ExternalTool, - args: string[] | readonly string[], - options?: DlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise<DlxSpawnResult> { - if (!areExternalToolsAvailable()) { - throw new Error( - `cannot spawn ${tool} from VFS: external tools were not bundled into this SEA binary; rebuild the SEA with INLINED_SOCKET_CLI_INCLUDE_EXTERNAL_TOOLS=1 or run the non-SEA CLI`, - ) - } - - // Extract tools from VFS (returns paths directly). - const toolPaths = await extractExternalTools() - if (!toolPaths) { - throw new Error( - `failed to extract ${tool} from VFS (extractExternalTools returned null); the embedded tool archive may be corrupt — rebuild the SEA binary`, - ) - } - - // Get tool path. - const toolPath = toolPaths[tool] - - if (!toolPath) { - throw new Error( - `VFS extraction succeeded but ${tool} was not in the output map (got: ${joinAnd(Object.keys(toolPaths)) || 'empty'}); the SEA bundle is missing ${tool} — rebuild with it included`, - ) - } - - const { env: spawnEnv, ...dlxOptions } = { - __proto__: null, - ...options, - } as DlxOptions - - // Spawn tool directly. - const spawnPromise = spawn(toolPath, args, { - ...dlxOptions, - env: { - ...process.env, - ...spawnEnv, - }, - stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit', - }) - - return { - spawnPromise, - } -} - -/** - * Validate package name to prevent command injection. Package names must follow - * npm naming rules. - */ -export function validatePackageName(name: string): void { - // Basic validation: no shell metacharacters, must be valid npm package name. - // npm package names can contain: lowercase letters, numbers, hyphens, underscores, dots, and @ for scopes. - const validNamePattern = - /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/ - - if (!validNamePattern.test(name)) { - throw new InputError( - `package name "${name}" must match /^(@scope\\/)?[a-z0-9-~][a-z0-9-._~]*$/ (lowercase letters, digits, -, _, ., ~, with optional @scope/); rename the package or check for typos`, - ) - } - - // Check for path traversal attempts. - if (name.includes('..') || (name.includes('/') && !name.startsWith('@'))) { - throw new InputError( - `package name "${name}" contains path traversal characters (".." or a "/" outside of @scope/); pass a plain name like "lodash" or "@org/pkg"`, - ) - } -} - -export { spawnSfwVfs } from './spawn-sfw.mts' - -export { spawnCdxgenVfs } from './spawn-cdxgen.mts' - -export { spawnCoanaVfs } from './spawn-coana.mts' - -export { spawnSocketPatchVfs } from './spawn-socket-patch.mts' - -/** - * High-level spawn functions that auto-detect SEA vs npm CLI mode. These choose - * between VFS extraction (SEA) and dlx download (npm CLI). - */ - -export { spawnSfw } from './spawn-sfw.mts' - -export { spawnCdxgen } from './spawn-cdxgen.mts' - -export { spawnCoana } from './spawn-coana.mts' - -export { spawnSocketPatch } from './spawn-socket-patch.mts' - -export { spawnSynp, spawnSynpDlx, spawnSynpVfs } from './spawn-synp.mts' - -/** - * Python CLI spawn utilities. Re-exported from spawn-pycli.mts (extracted from - * spawn.mts to keep this file under the 1000-line File size cap). - */ -export { - convertCaretToPipRange, - downloadPyPiWheel, - downloadPython, - ensurePython, - ensurePythonDlx, - ensureSocketPyCli, - getPythonBinPath, - getPythonCachePath, - getPythonStandaloneInfo, - isSocketPyCliInstalled, - spawnSocketPyCli, - spawnSocketPyCliDlx, - spawnSocketPyCliVfs, -} from './spawn-pycli.mts' - -export type { SocketPyCliDlxOptions } from './spawn-pycli.mts' - -/** - * Security scanning tool spawn utilities. These tools are used by socket-basics - * for comprehensive scanning. In SEA mode, they're extracted from VFS. In npm - * CLI mode, they're downloaded from GitHub. - */ - -export { spawnTrivy, spawnTrivyDlx, spawnTrivyVfs } from './spawn-trivy.mts' - -/** - * Spawn TruffleHog via GitHub download (npm CLI mode). Downloads from GitHub - * releases (trufflesecurity/trufflehog). - */ -export { - spawnTrufflehog, - spawnTrufflehogDlx, - spawnTrufflehogVfs, -} from './spawn-trufflehog.mts' - -export { - spawnOpengrep, - spawnOpengrepDlx, - spawnOpengrepVfs, -} from './spawn-opengrep.mts' diff --git a/packages/cli/src/util/dlx/vfs-extract.mts b/packages/cli/src/util/dlx/vfs-extract.mts deleted file mode 100644 index dacfd5929..000000000 --- a/packages/cli/src/util/dlx/vfs-extract.mts +++ /dev/null @@ -1,687 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-status-emoji -- TUI / custom output formatter; emojis are part of the visual contract. */ - -/** - * VFS extraction utilities for external tools bundled in SEA binaries. - * - * Extracts external tools from the VFS (Virtual File System) embedded in SEA - * binaries and caches them for execution. - * - * Tool types: - * - * - Standalone binaries (GitHub releases): sfw, socket-patch - * - Npm packages (with dependencies): cdxgen, coana, synp - * - * Build-time package preparation: npm packages use @npmcli/arborist to download - * complete packages with node_modules/ and all production dependencies. See - * scripts/sea-build-util/npm-packages.mts: - * - * ```javascript - * import { Arborist } from '@npmcli/arborist' - * - * const arb = new Arborist({ - * audit: false, - * binLinks: true, - * cache: getSocketCacacheDir(), // ~/.socket/_cacache - * fund: false, - * ignoreScripts: true, // Security: no install scripts - * omit: ['dev'], // Production deps only - * path: packageDir, - * silent: true, - * }) - * await arb.reify({ add: [packageSpec], save: false }) - * ``` - * - * VFS structure in SEA binaries: socket-patch # Standalone Rust binary from - * GitHub release node_modules/ ├── @cyclonedx/cdxgen/ # Full package with - * dependencies │ ├── bin/cdxgen │ ├── package.json │ └── node_modules/ # - * Dependencies ├── @coana-tech/cli/ │ ├── bin/coana │ ├── package.json │ └── - * node_modules/ ├── @socketsecurity/sfw-bin/ # Standalone binary from GitHub - * release │ └── sfw └── synp/ ├── bin/synp ├── package.json └── node_modules/ - * - * VFS Extraction with Full Directory Support: Uses process.smol.mount() API - * from node-smol to extract both single files and complete directory trees with - * dependencies from the embedded VFS. - * - * For npm packages: - * - * - Extracts entire package directory (node_modules/@package/name/) - * - Includes all production dependencies and subdirectories - * - Preserves file permissions and directory structure - * - * For standalone binaries: - * - * - Extracts individual binary file from VFS root - * - * See socket-btm/docs/vfs-runtime-api.md for full documentation. - */ - -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { UPDATE_STORE_DIR } from '../../constants/paths.mts' -import { getErrorCause } from '../error/errors.mts' -import { isSeaBinary } from '../sea/detect.mts' - -const logger = getDefaultLogger() - -// External tool names bundled in VFS. -// Includes standalone binaries and npm packages that are packaged in the VFS tarball. -export const EXTERNAL_TOOLS = [ - 'cdxgen', - 'coana', - 'opengrep', - 'python', - 'sfw', - 'socket-patch', - 'synp', - 'trivy', - 'trufflehog', -] as const - -export type ExternalTool = (typeof EXTERNAL_TOOLS)[number] - -// Map of npm package tools to their node_modules/ paths. -// These are full npm packages with dependencies and node_modules/ subdirectories. -// Note: sfw uses GitHub binary for SEA (standalone), npm package for CLI (dlx). -const TOOL_NPM_PATHS: Partial< - Record<ExternalTool, { packageName: string; binPath: string }> -> = { - cdxgen: { - packageName: '@cyclonedx/cdxgen', - binPath: 'node_modules/@cyclonedx/cdxgen/bin/cdxgen', - }, - coana: { - packageName: '@coana-tech/cli', - binPath: 'node_modules/@coana-tech/cli/bin/coana', - }, - synp: { - packageName: 'synp', - binPath: 'node_modules/synp/bin/synp', - }, -} - -// Map of standalone binary tools to their VFS paths. -// These tools are single binaries from GitHub releases without npm dependencies. -// sfw is stored under node_modules/@socketsecurity/sfw-bin/ for VFS structure. -const TOOL_STANDALONE_PATHS: Partial<Record<ExternalTool, string>> = { - // opengrep is a SAST/code analysis engine from GitHub releases (opengrep/opengrep). - opengrep: 'opengrep', - // python is a standalone runtime from GitHub releases (astral-sh/python-build-standalone). - // Entire python/ directory is extracted, binary is at python/bin/python (Unix) or python/python.exe (Windows). - python: 'python', - // sfw is a standalone binary from GitHub releases (SocketDev/sfw-free). - // Note: npm CLI uses the sfw npm package via dlx instead. - sfw: 'node_modules/@socketsecurity/sfw-bin/sfw', - // socket-patch is a Rust binary downloaded from GitHub releases. - // As of v2.0.0, it's bundled directly (not as an npm package). - 'socket-patch': 'socket-patch', - // trivy is a container/filesystem vulnerability scanner from GitHub releases (aquasecurity/trivy). - trivy: 'trivy', - // trufflehog is a secret/credential detector from GitHub releases (trufflesecurity/trufflehog). - trufflehog: 'trufflehog', -} - -/** - * Extract external tools from VFS to node-smol's dlx directory. - * - * Extracts external tools from the SEA's VFS and writes them to node-smol's - * shared dlx directory (~/.socket/_dlx/<node-smol-hash>/). - * - * Tool extraction paths: - * - * - Standalone binaries: ~/.socket/_dlx/<hash>/{tool} - * - Npm packages: - * ~/.socket/_dlx/<hash>/node_modules/{packageName}/bin/{binaryName} - * - * @example - * const toolPaths = await extractExternalTools() - * if (toolPaths) { - * const sfwPath = toolPaths.sfw // ~/.socket/_dlx/<hash>/sfw - * const cdxgenPath = toolPaths.cdxgen // ~/.socket/_dlx/<hash>/node_modules/@cyclonedx/cdxgen/bin/cdxgen - * } - * - * @returns Record of tool names to their extracted paths, or null if extraction - * failed. - */ -// Maximum recursion depth for extraction retries. -const MAX_EXTRACTION_DEPTH = 5 - -/** - * Check if external tools are available in VFS. - * - * Returns true if: 1. Running in SEA mode with process.smol.mount available. - * - * @returns True if external tools are available in VFS. - */ -export function areExternalToolsAvailable(): boolean { - const processWithSmol = process as unknown as { - smol?: - | { mount?: ((vfsPath: string) => Promise<string>) | undefined } - | undefined - } - - // Check if running in SEA mode with process.smol.mount available. - if (isSeaBinary() && processWithSmol.smol?.mount) { - return true - } - - // Not in SEA mode - tools will be downloaded via dlx. - return false -} - -export async function extractExternalTools( - depth = 0, -): Promise<Record<ExternalTool, string> | undefined> { - // Prevent unbounded recursion from pathological scenarios. - if (depth >= MAX_EXTRACTION_DEPTH) { - logger.error( - `Max extraction retry limit (${MAX_EXTRACTION_DEPTH}) exceeded`, - ) - return undefined - } - - const processWithSmol = process as unknown as { - smol?: - | { mount?: ((vfsPath: string) => Promise<string>) | undefined } - | undefined - } - - if (!isSeaBinary() || !processWithSmol.smol?.mount) { - debug('notice', 'Not running in SEA mode - cannot extract VFS tools') - return undefined - } - - logger.info('Extracting external tools from VFS...') - - const nodeSmolBase = getNodeSmolBasePath() - const isPlatWin = process.platform === 'win32' - - // Create lock file to prevent concurrent extraction (TOCTOU mitigation). - const lockFile = normalizePath(path.join(nodeSmolBase, '.extracting')) - const cacheMarker = normalizePath(path.join(nodeSmolBase, '.extracted')) - - await safeMkdir(nodeSmolBase) - - try { - // Try to create lock file atomically (wx = write + exclusive). - await fs.writeFile(lockFile, process.pid.toString(), { flag: 'wx' }) - } catch (e: unknown) { - const error = e as NodeJS.ErrnoException - if (error.code === 'EEXIST') { - // Check if lock is stale by reading PID and checking if process exists. - let isStale = false - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - // Signal 0 checks if process exists without killing it. - process.kill(pid, 0) - // Process exists, lock is valid. - } catch { - // Process doesn't exist, lock is stale. - isStale = true - debug('notice', `Stale lock file detected (PID ${pid} not running)`) - } - } else { - // Invalid PID in lock file, treat as stale. - isStale = true - } - } catch { - // Can't read lock file, treat as stale. - isStale = true - } - - if (isStale) { - // Clean up stale lock and partial extraction. - logger.warn('Cleaning up stale extraction lock...') - await safeDelete(lockFile, { force: true }) - // Retry extraction by calling ourselves recursively. - return await extractExternalTools(depth + 1) - } - - // Another process is extracting, wait and check for completion. - logger.info('Another process is extracting external tools, waiting...') - for (let i = 0; i < 60; i++) { - await new Promise(resolve => { - setTimeout(resolve, 1_000) - }) - if (existsSync(cacheMarker)) { - debug('notice', 'External tools extracted by another process') - // Build and validate toolPaths from cache. - const toolPaths: Partial<Record<ExternalTool, string>> = {} - let allValid = true - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i]! - const toolPath = getToolFilePath(tool, nodeSmolBase) - const toolPathWithExt = isPlatWin ? `${toolPath}.exe` : toolPath - // Validate tool exists and is executable. - if (!existsSync(toolPathWithExt)) { - allValid = false - debug( - 'notice', - `Tool ${tool} missing after extraction by other process`, - ) - break - } - toolPaths[tool] = toolPathWithExt - } - if (allValid) { - // TOCTOU mitigation: Final atomic verification pass. - const stillValid = EXTERNAL_TOOLS.every(tool => { - const p = toolPaths[tool] - return p && existsSync(p) - }) - if (stillValid) { - return toolPaths as Record<ExternalTool, string> - } - debug('notice', 'Tool(s) disappeared during validation') - allValid = false - } - // Extraction incomplete, clean up and retry. - debug('notice', 'Incomplete extraction detected, cleaning up...') - await safeDelete([cacheMarker, lockFile], { force: true }) - return await extractExternalTools(depth + 1) - } - - // Check if lock process is still alive every 5 iterations. - if (i % 5 === 4) { - // Check if extraction completed first before PID validation. - if (existsSync(cacheMarker)) { - debug('notice', 'Extraction completed during wait') - return await extractExternalTools(depth + 1) - } - // Then check if lock holder is still alive. - try { - const lockPid = await fs.readFile(lockFile, 'utf8') - const pid = Number.parseInt(lockPid.trim(), 10) - if (!Number.isNaN(pid) && pid > 0) { - try { - process.kill(pid, 0) - } catch { - // Process died, lock is stale. - debug('notice', `Lock holder (PID ${pid}) died during wait`) - await safeDelete(lockFile, { force: true }) - return await extractExternalTools(depth + 1) - } - } - } catch { - // Lock file gone, retry. - return await extractExternalTools(depth + 1) - } - } - } - // Final check before throwing timeout - extraction may have completed just now. - if (existsSync(cacheMarker)) { - debug('notice', 'External tools extracted just before timeout') - const toolPaths: Partial<Record<ExternalTool, string>> = {} - let allValid = true - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i]! - const toolPath = getToolFilePath(tool, nodeSmolBase) - const toolPathWithExt = isPlatWin ? `${toolPath}.exe` : toolPath - if (!existsSync(toolPathWithExt)) { - allValid = false - break - } - toolPaths[tool] = toolPathWithExt - } - if (allValid) { - // TOCTOU mitigation: Final atomic verification pass. - const stillValid = EXTERNAL_TOOLS.every(tool => { - const p = toolPaths[tool] - return p && existsSync(p) - }) - if (stillValid) { - return toolPaths as Record<ExternalTool, string> - } - } - } - throw new Error( - `timed out waiting for another socket process to finish extracting external tools from the SEA VFS; if no other socket process is running, remove any stale lock files under the node-smol base dir and retry`, - ) - } - throw e - } - - try { - // Check if already extracted (cache marker exists). - if (existsSync(cacheMarker)) { - debug('notice', 'External tools already extracted (cache marker found)') - const toolPaths: Partial<Record<ExternalTool, string>> = {} - let allValid = true - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i]! - const toolPath = getToolFilePath(tool, nodeSmolBase) - const toolPathWithExt = isPlatWin ? `${toolPath}.exe` : toolPath - // Validate tool exists before adding to paths. - if (!existsSync(toolPathWithExt)) { - debug('notice', `Cached tool ${tool} missing at ${toolPathWithExt}`) - allValid = false - break - } - toolPaths[tool] = toolPathWithExt - } - if (allValid) { - // TOCTOU mitigation: Final atomic verification pass. - // Re-check all tools still exist right before returning to minimize race window. - const stillValid = EXTERNAL_TOOLS.every(tool => { - const p = toolPaths[tool] - return p && existsSync(p) - }) - if (stillValid) { - return toolPaths as Record<ExternalTool, string> - } - // Tools disappeared during validation - cleanup and retry extraction. - debug( - 'notice', - 'Tool(s) disappeared during validation, re-extracting...', - ) - await safeDelete(cacheMarker, { force: true }) - return await extractExternalTools(depth + 1) - } - // Cache marker exists but tools missing, remove marker and re-extract. - debug('notice', 'Cache validation failed, re-extracting...') - await safeDelete(cacheMarker, { force: true }) - } - - const toolPaths: Partial<Record<ExternalTool, string>> = {} - - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i]! - const toolPath = getToolFilePath(tool, nodeSmolBase) - const toolPathWithExt = isPlatWin ? `${toolPath}.exe` : toolPath - - // Check if tool already exists and is executable. - if (existsSync(toolPathWithExt)) { - try { - // Quick validation - check if executable. - // oxlint-disable-next-line socket/prefer-exists-sync -- fs.access(X_OK) checks executable permission, not existence. - await fs.access(toolPathWithExt, fs.constants.X_OK) - debug( - 'notice', - `Tool ${tool} already extracted at ${toolPathWithExt}`, - ) - toolPaths[tool] = toolPathWithExt - continue - } catch { - // File exists but not executable or accessible, re-extract. - debug( - 'notice', - `Tool ${tool} exists but not executable, re-extracting...`, - ) - } - } - - // Extract tool from VFS. - const extractedPath = await extractTool(tool) - toolPaths[tool] = extractedPath - } - - // Verify all tools were extracted. - /* c8 ignore start -- defensive: the for-loop above unconditionally assigns toolPaths[tool] for every entry unless extractTool throws (which already aborts via the outer catch), so this length-mismatch branch is unreachable from tests. */ - if (Object.keys(toolPaths).length !== EXTERNAL_TOOLS.length) { - const missingTools = EXTERNAL_TOOLS.filter(t => !toolPaths[t]) - throw new Error( - `SEA VFS extraction returned ${Object.keys(toolPaths).length}/${EXTERNAL_TOOLS.length} tools (missing: ${joinAnd(missingTools)}); the SEA bundle is incomplete — rebuild with all external tools included`, - ) - } - /* c8 ignore stop */ - - // Create cache marker to signal successful extraction. - await fs.writeFile(cacheMarker, '', 'utf8') - - logger.success('External tools extracted successfully') - return toolPaths as Record<ExternalTool, string> - } catch (e) { - logger.error('VFS extraction failed:', e) - throw e - } finally { - // Clean up lock file. - try { - await safeDelete(lockFile, { force: true }) - } catch (e) { - const error = e as NodeJS.ErrnoException - logger.warn(`Failed to cleanup lock file ${lockFile}: ${error.message}`) - } - } -} - -/** - * Extract a single external tool from VFS to node-smol's dlx directory using - * process.smol.mount(). Extracts to - * ~/.socket/_dlx/<node-smol-hash>/node_modules/{packageName}/bin/{binaryName} - * - * Implementation: - * - * - Npm packages: Uses process.smol.mount() to extract entire directory with - * dependencies - * - Standalone binaries: Uses process.smol.mount() to extract single file - * - Automatically handles file permissions and directory structure - * - Supports caching to avoid re-extraction - * - * @param tool - Name of the tool to extract. - * - * @returns Path to the extracted tool binary. - */ -export async function extractTool(tool: ExternalTool): Promise<string> { - // Check if process.smol.mount is available. - const processWithSmol = process as unknown as { - smol?: - | { mount?: ((vfsPath: string) => Promise<string>) | undefined } - | undefined - } - - if (!processWithSmol.smol?.mount) { - throw new Error( - `process.smol.mount is undefined — extractTool("${tool}") requires a node-smol SEA build; this code path should only run inside the SEA. Check isSeaBinary() / areExternalToolsAvailable() upstream`, - ) - } - - const isPlatWin = process.platform === 'win32' - const nodeSmolBase = getNodeSmolBasePath() - const npmPath = TOOL_NPM_PATHS[tool] - - // For npm packages, check if already extracted with dependencies. - if (npmPath) { - const packageDir = normalizePath( - path.join(nodeSmolBase, 'node_modules', npmPath.packageName), - ) - - if (await isNpmPackageExtracted(packageDir)) { - const toolPath = normalizePath(path.join(nodeSmolBase, npmPath.binPath)) - const toolPathWithExt = isPlatWin ? `${toolPath}.exe` : toolPath - - if (existsSync(toolPathWithExt)) { - debug( - 'notice', - `Tool ${tool} already extracted with dependencies at ${packageDir}`, - ) - return toolPathWithExt - } - } - } - - // Extract from VFS using process.smol.mount(). - try { - let extractedPath: string - - if (npmPath) { - // Extract entire npm package directory with dependencies. - const vfsPackagePath = `/snapshot/node_modules/${npmPath.packageName}` - const packageDir = await processWithSmol.smol.mount(vfsPackagePath) - - logger.info( - ` ✓ Extracted ${tool} package with dependencies to ${packageDir}`, - ) - - // Return path to binary within extracted package. - const toolPath = normalizePath(path.join(nodeSmolBase, npmPath.binPath)) - extractedPath = isPlatWin ? `${toolPath}.exe` : toolPath - } else { - // Extract standalone binary - check if it's under node_modules/ or VFS root. - const standalonePath = TOOL_STANDALONE_PATHS[tool] - const vfsBinaryPath = standalonePath - ? `/snapshot/${standalonePath}` - : `/snapshot/${tool}` - const binaryPath = await processWithSmol.smol.mount(vfsBinaryPath) - - logger.info(` ✓ Extracted ${tool} binary to ${binaryPath}`) - - extractedPath = isPlatWin ? `${binaryPath}.exe` : binaryPath - - // Make executable on Unix. - if (!isPlatWin && existsSync(extractedPath)) { - try { - await fs.chmod(extractedPath, 0o755) - } catch { - // Ignore chmod errors - file might already be executable. - } - } - } - - if (!existsSync(extractedPath)) { - throw new Error( - `process.smol.mount returned but ${extractedPath} does not exist; the VFS layout for ${tool} may have changed — check the SEA build config and the tool's expected path`, - ) - } - - return extractedPath - } catch (e) { - throw new Error( - `failed to extract ${tool} from the SEA VFS (${getErrorCause(e)}); the embedded tool archive may be corrupt — rebuild the SEA binary`, - ) - } -} - -/** - * Get the base dlx directory path for node-smol. This is where both - * VFS-extracted tools and npm-installed packages live. - * - * Structure: ~/.socket/_dlx/<node-smol-hash>/ ├── node/node # Node binary ├── - * socket-patch # Standalone Rust binary (GitHub release) └── node_modules/ # - * npm packages with dependencies ├── @cyclonedx/cdxgen/ │ ├── bin/cdxgen │ └── - * node_modules/ ├── @coana-tech/cli/ │ ├── bin/coana │ └── node_modules/ ├── - * @socketsecurity/sfw-bin/ # Standalone sfw binary (GitHub release) │ └── sfw - * └── synp/ ├── bin/synp └── node_modules/ - * - * @returns Path to node-smol's dlx directory. - */ -export function getNodeSmolBasePath(): string { - // Get actual hash from process.smol if available, otherwise use process version. - let nodeSmolHash = 'node-smol-placeholder' - - try { - // Try to get hash from process.smol API (if available in future node-smol). - const processWithSmol = process as unknown as { - smol?: { getHash?: (() => string) | undefined } | undefined - } - if (typeof processWithSmol.smol?.getHash === 'function') { - nodeSmolHash = processWithSmol.smol.getHash() - } else { - // Fallback: hash based on Node.js version and platform. - const hashInput = `${process.version}-${process.platform}-${process.arch}` - const hash = crypto.createHash('sha256').update(hashInput).digest('hex') - nodeSmolHash = hash.slice(0, 16) - } - } catch { - // Fallback to versioned hash. - const hashInput = `${process.version}-${process.platform}-${process.arch}` - const hash = crypto.createHash('sha256').update(hashInput).digest('hex') - nodeSmolHash = hash.slice(0, 16) - } - - return normalizePath(path.join(os.homedir(), UPDATE_STORE_DIR, nodeSmolHash)) -} - -/** - * Get the file system path for a tool based on its type (npm package or - * standalone binary). - * - * @param tool - Tool name. - * @param nodeSmolBase - Base dlx directory path. - * - * @returns Path to the tool binary (without .exe extension). - */ -export function getToolFilePath( - tool: ExternalTool, - nodeSmolBase: string, -): string { - const npmPath = TOOL_NPM_PATHS[tool] - const standalonePath = TOOL_STANDALONE_PATHS[tool] - - // For npm packages, use node_modules/ path with binPath. - // For standalone binaries under node_modules/, use standalonePath. - // For other standalone binaries, use direct tool name. - return npmPath - ? normalizePath(path.join(nodeSmolBase, npmPath.binPath)) - : standalonePath - ? normalizePath(path.join(nodeSmolBase, standalonePath)) - : normalizePath(path.join(nodeSmolBase, tool)) -} - -/** - * Get paths to extracted external tools in node-smol's dlx directory. npm - * packages are in node_modules/{packageName}/bin/{binaryName}. Standalone - * binaries are in the base directory. - * - * @example - * const paths = getToolPaths() - * logger.log('sfw:', paths.sfw) // ~/.socket/_dlx/<hash>/node_modules/@socketsecurity/sfw-bin/sfw - * logger.log('cdxgen:', paths.cdxgen) // ~/.socket/_dlx/<hash>/node_modules/@cyclonedx/cdxgen/bin/cdxgen - * - * @returns Object with paths to each tool binary. - */ -export function getToolPaths(): Record<ExternalTool, string> { - const isPlatWin = process.platform === 'win32' - const nodeSmolBase = getNodeSmolBasePath() - - const paths: Partial<Record<ExternalTool, string>> = {} - - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i]! - const toolPath = getToolFilePath(tool, nodeSmolBase) - paths[tool] = isPlatWin ? `${toolPath}.exe` : toolPath - } - - return paths as Record<ExternalTool, string> -} - -/** - * Check if npm package directory with dependencies exists and is valid. - * - * @param packagePath - Path to npm package directory. - * - * @returns True if package directory exists with node_modules/ and binary. - */ -export async function isNpmPackageExtracted( - packagePath: string, -): Promise<boolean> { - if (!existsSync(packagePath)) { - return false - } - - const packageJsonPath = path.join(packagePath, 'package.json') - if (!existsSync(packageJsonPath)) { - return false - } - - // node_modules/ directory should exist for packages with dependencies. - const nodeModulesPath = path.join(packagePath, 'node_modules') - if (!existsSync(nodeModulesPath)) { - debug('notice', `Package ${packagePath} exists but missing node_modules/`) - return false - } - - return true -} diff --git a/packages/cli/src/util/dry-run/output.mts b/packages/cli/src/util/dry-run/output.mts deleted file mode 100644 index 9ac5f046e..000000000 --- a/packages/cli/src/util/dry-run/output.mts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Dry-run output utilities for Socket CLI commands. - * - * Dry-run previews are contextual output — they describe what WOULD happen, - * they are not the command's payload. Per the stream discipline rule - * (CLAUDE.md: SHARED STANDARDS), context belongs on stderr. This keeps `command - * --json --dry-run` pipe-safe and also keeps dry-run previews visible to humans - * running `command > file` (where stderr still goes to the terminal). - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { DRY_RUN_LABEL } from '../../constants/cli.mts' - -const logger = getDefaultLogger() - -export function out(message: string): void { - logger.error(message) -} - -/** - * Output dry-run for delete operations. - */ -export function outputDryRunDelete( - resourceType: string, - identifier: string, -): void { - out('') - out(`${DRY_RUN_LABEL}: Would delete ${resourceType}`) - out('') - out(` Target: ${identifier}`) - out('') - out(' This action cannot be undone.') - out(' Run without --dry-run to perform this deletion.') - out('') -} - -/** - * Output dry-run for commands that execute external tools. - */ -export function outputDryRunExecute( - command: string, - args: string[], - description?: string, -): void { - out('') - out(`${DRY_RUN_LABEL}: Would execute ${description || 'external command'}`) - out('') - out(` Command: ${command}`) - if (args.length > 0) { - out(` Arguments: ${args.join(' ')}`) - } - out('') - out(' Run without --dry-run to execute this command.') - out('') -} - -/** - * Output a simple dry-run message for commands that just fetch/display data. - * These commands don't really need dry-run since they're read-only, but showing - * computed query parameters helps users verify their input. - */ -export function outputDryRunFetch( - resourceName: string, - queryParams?: Record<string, string | number | boolean | undefined>, -): void { - out('') - out(`${DRY_RUN_LABEL}: Would fetch ${resourceName}`) - out('') - - if (queryParams && Object.keys(queryParams).length > 0) { - out(' Query parameters:') - for (const [key, value] of Object.entries(queryParams)) { - if (value !== undefined && value !== '') { - out(` ${key}: ${value}`) - } - } - out('') - } - - out(' This is a read-only operation that does not modify any data.') - out(' Run without --dry-run to fetch and display the data.') - out('') -} - -export interface DryRunAction { - type: - | 'create' - | 'delete' - | 'execute' - | 'fetch' - | 'modify' - | 'upload' - | 'write' - description: string - target?: string | undefined - details?: Record<string, unknown> | undefined -} - -interface DryRunPreview { - summary: string - actions: DryRunAction[] - wouldSucceed?: boolean | undefined -} - -/** - * Format and output a dry-run preview. - */ -export function outputDryRunPreview(preview: DryRunPreview): void { - out('') - out(`${DRY_RUN_LABEL}: ${preview.summary}`) - out('') - - if (!preview.actions.length) { - out(' No actions would be performed.') - } else { - out(' Actions that would be performed:') - for (const action of preview.actions) { - const targetStr = action.target ? ` → ${action.target}` : '' - out(` - [${action.type}] ${action.description}${targetStr}`) - if (action.details) { - for (const [key, value] of Object.entries(action.details)) { - out(` ${key}: ${JSON.stringify(value)}`) - } - } - } - } - - out('') - if (preview.wouldSucceed !== undefined) { - out( - preview.wouldSucceed - ? ' Would complete successfully.' - : ' Would fail (see details above).', - ) - } - out('') - out(' Run without --dry-run to execute these actions.') - out('') -} - -/** - * Output dry-run for API upload operations. - */ -export function outputDryRunUpload( - resourceType: string, - details: Record<string, unknown>, -): void { - out('') - out(`${DRY_RUN_LABEL}: Would upload ${resourceType}`) - out('') - out(' Details:') - for (const [key, value] of Object.entries(details)) { - if (typeof value === 'object' && value !== null) { - out(` ${key}:`) - for (const [subKey, subValue] of Object.entries( - value as Record<string, unknown>, - )) { - out(` ${subKey}: ${JSON.stringify(subValue)}`) - } - } else { - out(` ${key}: ${JSON.stringify(value)}`) - } - } - out('') - out(' Run without --dry-run to perform this upload.') - out('') -} - -/** - * Output dry-run for file write operations. - */ -export function outputDryRunWrite( - filePath: string, - description: string, - changes?: string[], -): void { - out('') - out(`${DRY_RUN_LABEL}: Would ${description}`) - out('') - out(` Target file: ${filePath}`) - if (changes && changes.length > 0) { - out(' Changes:') - for (let i = 0, { length } = changes; i < length; i += 1) { - const change = changes[i] - out(` - ${change}`) - } - } - out('') - out(' Run without --dry-run to apply these changes.') - out('') -} diff --git a/packages/cli/src/util/ecosystem/environment.mts b/packages/cli/src/util/ecosystem/environment.mts deleted file mode 100644 index ccc989c86..000000000 --- a/packages/cli/src/util/ecosystem/environment.mts +++ /dev/null @@ -1,560 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Package environment detection utilities for Socket CLI. Analyzes project - * environment and package manager configuration. - * - * Key Functions: - getPackageEnvironment: Detect package manager and project - * details - makeConcurrentExecLimit: Calculate concurrent execution limits. - * - * Environment Detection: - Detects npm, pnpm, yarn, bun package managers - - * Analyzes lockfiles for version information - Determines Node.js and engine - * requirements - Identifies workspace configurations. - * - * Features: - Browser target detection via browserslist - Engine compatibility - * checking - Package manager version detection - Workspace and monorepo - * support. - * - * Usage: - Auto-detecting appropriate package manager - Validating environment - * compatibility - Configuring concurrent execution limits. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import browserslist from 'browserslist' -import semver from 'semver' - -import { whichReal } from '@socketsecurity/lib-stable/bin/which' -import { - BUN, - NPM, - PNPM, - VLT, - YARN, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' -import { getMaintainedNodeVersions } from '@socketsecurity/lib-stable/constants/node' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' -import { readPackageJson } from '@socketsecurity/lib-stable/packages/operations' -import { toEditablePackageJson } from '@socketsecurity/lib-stable/packages/edit' -import { naturalCompare } from '@socketsecurity/lib-stable/sorts/natural' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { - getMinimumVersionByAgent, - getNpmExecPath, - getPnpmExecPath, -} from '../../constants/agents.mts' -import { FLAG_VERSION } from '../../constants/cli.mts' -import { VITEST } from '../../env/vitest.mts' -import { - NPM_BUGGY_OVERRIDES_PATCHED_VERSION, - PACKAGE_JSON, -} from '../../constants/packages.mts' -import { execPath, nodeNoWarningsFlags } from '../../constants/paths.mts' -import { findUp } from '../fs/find-up.mts' -import { cmdPrefixMessage } from '../process/cmd.mts' - -import type { CResult } from '../../types.mjs' -import type { Logger } from '@socketsecurity/lib-stable/logger/types' -import type { Remap } from '@socketsecurity/lib-stable/objects/types' -import type { EditablePackageJson } from '@socketsecurity/lib-stable/packages/types' -import type { SemVer } from 'semver' - -const DOT_PACKAGE_LOCK_JSON = '.package-lock.json' - -export const AGENTS = [BUN, NPM, PNPM, YARN_BERRY, YARN_CLASSIC, VLT] as const - -const binByAgent = new Map<Agent, string>([ - [BUN, BUN], - [NPM, NPM], - [PNPM, PNPM], - [YARN_BERRY, YARN], - [YARN_CLASSIC, YARN], - [VLT, VLT], -]) - -export type Agent = (typeof AGENTS)[number] - -type EnvBase = { - agent: Agent - agentExecPath: string - agentSupported: boolean - features: { - // Fixed by https://github.com/npm/cli/pull/8089. - // Landed in npm v11.2.0. - npmBuggyOverrides: boolean - } - nodeSupported: boolean - nodeVersion: SemVer - npmExecPath: string - pkgRequirements: { - agent: string - node: string - } - pkgSupports: { - agent: boolean - node: boolean - } -} - -export type EnvDetails = Readonly< - Remap< - EnvBase & { - agentVersion: SemVer - editablePkgJson: EditablePackageJson - lockName: string - lockPath: string - lockSrc: string - pkgPath: string - } - > -> - -type DetectAndValidateOptions = { - cmdName?: string | undefined - logger?: Logger | undefined - prod?: boolean | undefined -} - -type DetectOptions = { - cwd?: string | undefined - onUnknown?: ((pkgManager: string | undefined) => void) | undefined -} - -type PartialEnvDetails = Readonly< - Remap< - EnvBase & { - agentVersion: SemVer | undefined - editablePkgJson: EditablePackageJson | undefined - lockName: string | undefined - lockPath: string | undefined - lockSrc: string | undefined - pkgPath: string | undefined - } - > -> - -// Lockfile registration + per-agent reader Map extracted to keep this file -// under the 1000-line cap. Re-export ReadLockFile for back-compat. -import { LOCKS, readLockFileByAgent } from './lockfile-readers.mts' - -export type { ReadLockFile } from './lockfile-readers.mts' - -// Windows-shim helpers extracted to keep this file under the 1000-line cap. -// Imported for local use AND re-exported so existing import paths keep working. -import { preferWindowsCmdShim, resolveBinPathSync } from './windows-shims.mts' - -export { preferWindowsCmdShim, resolveBinPathSync } - -export async function detectAndValidatePackageEnvironment( - cwd: string, - options?: DetectAndValidateOptions | undefined, -): Promise<CResult<EnvDetails>> { - const { - cmdName = '', - logger, - prod, - } = { - __proto__: null, - ...options, - } as DetectAndValidateOptions - const details = await detectPackageEnvironment({ - cwd, - onUnknown(pkgManager: string | undefined) { - logger?.warn( - cmdPrefixMessage( - cmdName, - `Unknown package manager${pkgManager ? ` ${pkgManager}` : ''}, defaulting to ${NPM}`, - ), - ) - }, - }) - const { agent, nodeVersion, pkgRequirements } = details - const agentVersion = details.agentVersion ?? 'unknown' - if (!details.agentSupported) { - const minVersion = getMinimumVersionByAgent(agent) - return { - ok: false, - message: 'Version mismatch', - cause: cmdPrefixMessage( - cmdName, - `Requires ${agent} >=${minVersion}. Current version: ${agentVersion}.`, - ), - } - } - if (!details.nodeSupported) { - const minVersion = getMaintainedNodeVersions().last - return { - ok: false, - message: 'Version mismatch', - cause: cmdPrefixMessage( - cmdName, - `Requires Node >=${minVersion}. Current version: ${nodeVersion}.`, - ), - } - } - if (!details.pkgSupports.agent) { - return { - ok: false, - message: 'Engine mismatch', - cause: cmdPrefixMessage( - cmdName, - `Package engine "${agent}" requires ${pkgRequirements.agent}. Current version: ${agentVersion}`, - ), - } - } - if (!details.pkgSupports.node) { - return { - ok: false, - message: 'Version mismatch', - cause: cmdPrefixMessage( - cmdName, - `Package engine "node" requires ${pkgRequirements.node}. Current version: ${nodeVersion}`, - ), - } - } - const lockName = details.lockName ?? 'lockfile' - if (details.lockName === undefined || details.lockSrc === undefined) { - return { - ok: false, - message: 'Missing lockfile', - cause: cmdPrefixMessage(cmdName, `No ${lockName} found`), - } - } - if (details.lockSrc.trim() === '') { - return { - ok: false, - message: 'Empty lockfile', - cause: cmdPrefixMessage(cmdName, `${lockName} is empty`), - } - } - if (details.pkgPath === undefined) { - return { - ok: false, - message: 'Missing package.json', - cause: cmdPrefixMessage(cmdName, `No ${PACKAGE_JSON} found`), - } - } - if (prod && (agent === BUN || agent === YARN_BERRY)) { - return { - ok: false, - message: 'Bad input', - cause: cmdPrefixMessage( - cmdName, - `--prod not supported for ${agent}${agentVersion ? `@${agentVersion}` : ''}`, - ), - } - } - if ( - details.lockPath && - path.relative(cwd, details.lockPath).startsWith('.') - ) { - // Note: In tests we return <redacted> because otherwise snapshots will fail. - logger?.warn( - cmdPrefixMessage( - cmdName, - `Package ${lockName} found at ${VITEST ? '[REDACTED]' : details.lockPath}`, - ), - ) - } - return { ok: true, data: details as EnvDetails } -} - -export async function detectPackageEnvironment({ - cwd = process.cwd(), - onUnknown, -}: DetectOptions = {}): Promise<EnvDetails | PartialEnvDetails> { - let lockPath = await findUp(Object.keys(LOCKS), { cwd }) - let lockName = lockPath ? path.basename(lockPath) : undefined - const isHiddenLockFile = lockName === DOT_PACKAGE_LOCK_JSON - const pkgJsonPath = lockPath - ? path.resolve( - lockPath, - `${isHiddenLockFile ? '../' : ''}../${PACKAGE_JSON}`, - ) - : await findUp(PACKAGE_JSON, { cwd }) - const pkgPath = - pkgJsonPath && existsSync(pkgJsonPath) - ? path.dirname(pkgJsonPath) - : undefined - const pkgJson = pkgPath ? await readPackageJson(pkgPath) : undefined - const editablePkgJson = ( - pkgJson ? await toEditablePackageJson(pkgJson) : undefined - ) as EditablePackageJson | undefined - // Read Corepack `packageManager` field in package.json: - // https://nodejs.org/api/packages.html#packagemanager - const pkgManager = isNonEmptyString(editablePkgJson?.content?.packageManager) - ? editablePkgJson?.content.packageManager - : undefined - - let agent: Agent | undefined - if (pkgManager) { - // A valid "packageManager" field value is "<package manager name>@<version>". - // https://nodejs.org/api/packages.html#packagemanager - const atSignIndex = pkgManager.lastIndexOf('@') - // Use > 0 to ensure there's a name before the @. - if (atSignIndex > 0) { - const name = pkgManager.slice(0, atSignIndex) as Agent - const version = pkgManager.slice(atSignIndex + 1) - if (version && AGENTS.includes(name)) { - agent = name - } - } - } - if ( - agent === undefined && - !isHiddenLockFile && - typeof pkgJsonPath === 'string' && - typeof lockName === 'string' - ) { - agent = LOCKS[lockName] as Agent - } - if (agent === undefined) { - agent = NPM - onUnknown?.(pkgManager) - } - const agentExecPath = await getAgentExecPath(agent) - const agentVersion = await getAgentVersion(agent, agentExecPath, cwd) - if (agent === YARN_CLASSIC && (agentVersion?.major ?? 0) > 1) { - agent = YARN_BERRY - } - const maintainedNodeVersions = getMaintainedNodeVersions() - const minSupportedAgentVersion = getMinimumVersionByAgent(agent) - const minSupportedNodeMajor = semver.major(maintainedNodeVersions.last) - const minSupportedNodeVersion = `${minSupportedNodeMajor}.0.0` - const minSupportedNodeRange = `>=${minSupportedNodeMajor}` - const nodeVersion = semver.coerce(process.version)! - let lockSrc: string | undefined - let pkgAgentRange: string | undefined - let pkgNodeRange: string | undefined - let pkgMinAgentVersion = minSupportedAgentVersion - let pkgMinNodeVersion = minSupportedNodeVersion - if (editablePkgJson?.content) { - const { engines } = editablePkgJson.content - const engineAgentRange = engines?.[agent] - const engineNodeRange = engines?.['node'] - if (isNonEmptyString(engineAgentRange)) { - pkgAgentRange = engineAgentRange - // Roughly check agent range as semver.coerce will strip leading - // v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~). - const coerced = semver.coerce(pkgAgentRange) - if (coerced && semver.lt(coerced, pkgMinAgentVersion)) { - pkgMinAgentVersion = coerced.version - } - } - if (isNonEmptyString(engineNodeRange)) { - pkgNodeRange = engineNodeRange - // Roughly check Node range as semver.coerce will strip leading - // v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~). - const coerced = semver.coerce(pkgNodeRange) - if (coerced && semver.lt(coerced, pkgMinNodeVersion)) { - pkgMinNodeVersion = coerced.version - } - } - const browserslistQuery = editablePkgJson.content['browserslist'] as - | string[] - | undefined - if (Array.isArray(browserslistQuery)) { - // List Node targets in ascending version order. - const browserslistNodeTargets = browserslist(browserslistQuery) - .filter(v => /^node /i.test(v)) - .map(v => v.slice(5 /*'node '.length*/)) - .sort(naturalCompare) - if (browserslistNodeTargets.length) { - // browserslistNodeTargets[0] is the lowest Node target version. - const coerced = semver.coerce(browserslistNodeTargets[0]) - if (coerced && semver.lt(coerced, pkgMinNodeVersion)) { - pkgMinNodeVersion = coerced.version - } - } - } - const rawLockSrc = - typeof lockPath === 'string' - ? await readLockFileByAgent.get(agent)?.(lockPath, agentExecPath, cwd) - : undefined - lockSrc = - typeof rawLockSrc === 'string' - ? rawLockSrc - : rawLockSrc instanceof Buffer - ? rawLockSrc.toString() - : undefined - } else { - lockName = undefined - lockPath = undefined - } - - // Does the system agent version meet our minimum supported agent version? - const agentSupported = - !!agentVersion && - semver.satisfies(agentVersion, `>=${minSupportedAgentVersion}`) - // Does the system Node version meet our minimum supported Node version? - const nodeSupported = semver.satisfies(nodeVersion, minSupportedNodeRange) - - const npmExecPath = - agent === NPM ? agentExecPath : await getAgentExecPath(NPM) - const npmBuggyOverrides = - agent === NPM && - !!agentVersion && - semver.lt(agentVersion, NPM_BUGGY_OVERRIDES_PATCHED_VERSION) - - const pkgMinAgentRange = `>=${pkgMinAgentVersion}` - const pkgMinNodeRange = `>=${semver.major(pkgMinNodeVersion)}` - - return { - agent, - agentExecPath, - agentSupported, - agentVersion, - editablePkgJson, - features: { npmBuggyOverrides }, - lockName, - lockPath, - lockSrc, - nodeSupported, - nodeVersion, - npmExecPath, - pkgPath, - pkgRequirements: { - agent: pkgAgentRange ?? pkgMinAgentRange, - node: pkgNodeRange ?? pkgMinNodeRange, - }, - pkgSupports: { - // Does our minimum supported agent version meet the package's requirements? - agent: semver.satisfies(minSupportedAgentVersion, pkgMinAgentRange), - // Does our supported Node versions meet the package's requirements? - node: maintainedNodeVersions.some((v: string) => - semver.satisfies(v, pkgMinNodeRange), - ), - }, - } -} - -export async function getAgentExecPath(agent: Agent): Promise<string> { - const binName = binByAgent.get(agent)! - if (binName === NPM) { - // Try to use getNpmExecPath() first, but verify it exists. - const npmPath = preferWindowsCmdShim(await getNpmExecPath(), NPM) - if (existsSync(npmPath)) { - return npmPath - } - // If getNpmExecPath() doesn't exist, try common locations. - // Check npm in the same directory as node. - const nodeDir = path.dirname(process.execPath) - /* c8 ignore start - WIN32-only branch and existsSync(npm-in-node-dir) hit; tests run on macOS/Linux against test fixtures, not a real node install dir */ - if (WIN32) { - const npmCmdInNodeDir = path.join(nodeDir, `${NPM}.cmd`) - if (existsSync(npmCmdInNodeDir)) { - return npmCmdInNodeDir - } - } - const npmInNodeDir = path.join(nodeDir, NPM) - if (existsSync(npmInNodeDir)) { - return preferWindowsCmdShim(npmInNodeDir, NPM) - } - /* c8 ignore stop */ - // Fall back to which. - const whichRealResult = await whichReal(binName, { nothrow: true }) - return ( - (Array.isArray(whichRealResult) ? whichRealResult[0] : whichRealResult) ?? - binName - ) - } - if (binName === PNPM) { - // Try to use getPnpmExecPath() first, but verify it exists. - const pnpmPath = await getPnpmExecPath() - if (existsSync(pnpmPath)) { - return pnpmPath - } - // Fall back to which. - const whichRealResult = await whichReal(binName, { nothrow: true }) - return ( - (Array.isArray(whichRealResult) ? whichRealResult[0] : whichRealResult) ?? - binName - ) - } - const whichRealResult = await whichReal(binName, { nothrow: true }) - return ( - (Array.isArray(whichRealResult) ? whichRealResult[0] : whichRealResult) ?? - binName - ) -} - -export async function getAgentVersion( - agent: Agent, - agentExecPath: string, - cwd: string, -): Promise<SemVer | undefined> { - let result: SemVer | undefined - const quotedCmd = `\`${agent} ${FLAG_VERSION}\`` - debugNs('stdio', `spawn: ${quotedCmd}`) - try { - let stdout: string - - // Some package manager "executables" may resolve to non-executable wrapper scripts - // (e.g. the extensionless `npm` shim on Windows). Resolve the underlying entrypoint - // and run it with Node when it is a JS file. - let shouldRunWithNode: string | undefined = undefined - /* c8 ignore start - WIN32-only branch for resolving JS shim entrypoints; tests run on macOS/Linux */ - if (WIN32) { - try { - const resolved = resolveBinPathSync(agentExecPath) - const ext = path.extname(resolved).toLowerCase() - if (ext === '.cjs' || ext === '.js' || ext === '.mjs') { - shouldRunWithNode = resolved - } - } catch (e) { - debugNs( - 'warn', - `Failed to resolve bin path for ${agentExecPath}, falling back to direct spawn.`, - ) - debugDirNs('error', e) - } - } - - if (shouldRunWithNode) { - const result = await spawn( - execPath, - [...nodeNoWarningsFlags, shouldRunWithNode, FLAG_VERSION], - { cwd }, - ) - - if (!result) { - return undefined - } - - stdout = - result.stdout - /* c8 ignore stop */ - } else { - const result = await spawn(agentExecPath, [FLAG_VERSION], { - cwd, - // On Windows, package managers are often .cmd files that require shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }) - - if (!result) { - return undefined - } - - stdout = - result.stdout - } - - result = - // Coerce version output into a valid semver version by passing it through - // semver.coerce which strips leading v's, carets (^), comparators (<,<=,>,>=,=), - // and tildes (~). - semver.coerce(stdout) ?? undefined - } catch (e) { - debugNs('error', `Package manager command failed: ${quotedCmd}`) - debugDirNs('inspect', { cmd: quotedCmd }) - debugDirNs('error', e) - } - return result -} diff --git a/packages/cli/src/util/ecosystem/lockfile-readers.mts b/packages/cli/src/util/ecosystem/lockfile-readers.mts deleted file mode 100644 index be198cb6a..000000000 --- a/packages/cli/src/util/ecosystem/lockfile-readers.mts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Lockfile registration + per-agent reader Map. - * - * Extracted from `environment.mts` to keep that file under the 1000-line - * File-size cap. The `LOCKS` map names every lockfile filename Socket knows - * about and the agent that owns it; `readLockFileByAgent` maps an Agent to a - * reader that returns the lockfile contents (binary or utf8) — bun gets a - * special reader that handles `.lockb` via the parser or shells out to `bun - * bun.lockb` as a last resort. - */ - -import path from 'node:path' - -import { parse as parseBunLockb } from '@socketregistry/hyrious__bun.lockb/index.cjs' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { readFileBinary, readFileUtf8 } from '@socketsecurity/lib-stable/fs/read-file' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { - BUN, - BUN_LOCK, - BUN_LOCKB, - NPM, - NPM_SHRINKWRAP_JSON, - PACKAGE_LOCK_JSON, - PNPM, - PNPM_LOCK_YAML, - VLT, - VLT_LOCK_JSON, - YARN_BERRY, - YARN_CLASSIC, - YARN_LOCK, -} from '@socketsecurity/lib-stable/constants/agents' -import { EXT_LOCK, EXT_LOCKB, NODE_MODULES } from '../../constants/packages.mts' - -// `.package-lock.json` is the npm "hidden lockfile" name. Defined locally -// because @socketsecurity/lib doesn't export this constant. -const DOT_PACKAGE_LOCK_JSON = '.package-lock.json' - -import type { Agent } from './environment.mts' - -export type ReadLockFile = - | ((lockPath: string) => Promise<string | Buffer | undefined>) - | (( - lockPath: string, - agentExecPath: string, - ) => Promise<string | Buffer | undefined>) - | (( - lockPath: string, - agentExecPath: string, - cwd: string, - ) => Promise<string | Buffer | undefined>) - -/** - * Per-agent reader Map. Wraps each reader so any thrown error becomes - * `undefined` — the caller treats that as "couldn't read this lockfile, fall - * through" rather than aborting detection. - */ -export const readLockFileByAgent: Map<Agent, ReadLockFile> = (() => { - function wrapReader<T extends (...args: never[]) => Promise<unknown>>( - reader: T, - ): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined> { - return async (...args: Parameters<T>) => { - try { - return (await reader(...args)) as Awaited<ReturnType<T>> - } catch {} - return undefined - } - } - - const binaryReader = wrapReader(readFileBinary) - - const defaultReader = wrapReader( - async (lockPath: string) => await readFileUtf8(lockPath), - ) - - return new Map([ - [ - BUN, - wrapReader( - async ( - lockPath: string, - agentExecPath: string, - cwd = process.cwd(), - ) => { - const ext = path.extname(lockPath) - if (ext === EXT_LOCK) { - return await defaultReader(lockPath) - } - if (ext === EXT_LOCKB) { - const lockBuffer = await binaryReader(lockPath) - if (lockBuffer) { - try { - return parseBunLockb(lockBuffer) - } catch {} - } - // To print a Yarn lockfile to your console without writing it to - // disk use `bun bun.lockb`. - // https://bun.sh/guides/install/yarnlock - return ( - await spawn(agentExecPath, [lockPath], { - cwd, - // On Windows, bun is often a .cmd file that requires shell - // execution. The spawn helper handles that when shell is true. - shell: WIN32, - }) - ).stdout - } - return undefined - }, - ), - ], - [NPM, defaultReader], - [PNPM, defaultReader], - [VLT, defaultReader], - [YARN_BERRY, defaultReader], - [YARN_CLASSIC, defaultReader], - ]) -})() - -/** - * Lockfile filename → owning Agent. Iteration order is significant — keys - * earlier in the object win when multiple lockfiles coexist. The hidden - * `node_modules/.package-lock.json` is intentionally last (treated as a - * fallback for repos that disable lockfile generation via `.npmrc`). - */ -export const LOCKS: Record<string, Agent> = { - [BUN_LOCK]: BUN, - [BUN_LOCKB]: BUN, - // If both package-lock.json and npm-shrinkwrap.json are present at the root - // of a project, npm-shrinkwrap.json takes precedence and package-lock.json - // is ignored. - // https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#package-lockjson-vs-npm-shrinkwrapjson - [NPM_SHRINKWRAP_JSON]: NPM, - [PACKAGE_LOCK_JSON]: NPM, - [PNPM_LOCK_YAML]: PNPM, - [YARN_LOCK]: YARN_CLASSIC, - [VLT_LOCK_JSON]: VLT, - // Lastly, look for a hidden lockfile which is present if .npmrc has - // package-lock=false: - // https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#hidden-lockfiles - // - // Unlike the other LOCKS keys this key contains a directory AND filename - // so it must be matched differently. - [`${NODE_MODULES}/${DOT_PACKAGE_LOCK_JSON}`]: NPM, -} diff --git a/packages/cli/src/util/ecosystem/requirements.mts b/packages/cli/src/util/ecosystem/requirements.mts deleted file mode 100644 index 9a05d8559..000000000 --- a/packages/cli/src/util/ecosystem/requirements.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Requirements configuration utilities for Socket CLI. Manages API permissions - * and quota requirements for commands. - * - * Key Functions: - getRequirements: Load requirements configuration - - * getRequirementsKey: Convert command path to requirements key. - * - * Configuration: - Loads from data/command-api-requirements.json - Maps command - * paths to permission requirements - Used for permission validation and help - * text. - */ - -import requirements from '../../../data/command-api-requirements.json' with { type: 'json' } - -export function getRequirements() { - return requirements -} - -/** - * Convert command path to requirements key. - */ -export function getRequirementsKey(cmdPath: string): string { - return cmdPath.replace(/^socket[: ]/, '').replace(/ +/g, ':') -} diff --git a/packages/cli/src/util/ecosystem/types.mts b/packages/cli/src/util/ecosystem/types.mts deleted file mode 100644 index 7cedc977a..000000000 --- a/packages/cli/src/util/ecosystem/types.mts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Ecosystem type utilities for Socket CLI. Manages package ecosystem - * identifiers and mappings. - * - * Constants: - * - * - ALL_ECOSYSTEMS: Complete list of supported ecosystems - * - ECOSYSTEM_MAP: Map ecosystem strings to PURL types - * - * Type Definitions: - * - * - PURL_Type: Package URL type from Socket SDK - * - * Supported Ecosystems: - * - * - Alpm, apk, bitbucket, cargo, chrome, cocoapods, composer - * - Conan, conda, cran, deb, docker, gem, generic - * - Github, gitlab, go, hackage, hex, huggingface - * - Maven, mlflow, npm, nuget, oci, pub, pypi, rpm, swift, vscode - * - * Usage: - * - * - Validates ecosystem types - * - Maps between different ecosystem representations - * - Ensures type safety for ecosystem operations - */ - -import { NPM } from '@socketsecurity/lib-stable/constants/agents' - -import type { components } from '@socketsecurity/sdk-stable/types/api' - -export type PURL_Type = components['schemas']['SocketPURL_Type'] - -// Type checking utilities to ensure ecosystem types are properly aligned. -// NOTE: Commented out because EcosystemString has additional types not in PURL_Type -// (unknown, vcs, qpkg, swid) which causes type checking errors. -// type ExpectNever<T extends never> = T -// type MissingInEcosystemString = Exclude<PURL_Type, EcosystemString> -// type ExtraInEcosystemString = Exclude<EcosystemString, PURL_Type> -// export type _Check_EcosystemString_has_all_purl_types = -// ExpectNever<MissingInEcosystemString> -// export type _Check_EcosystemString_has_no_extras = -// ExpectNever<ExtraInEcosystemString> - -export const ALL_ECOSYSTEMS = [ - 'alpm', - 'apk', - 'bitbucket', - 'cargo', - 'chrome', - 'cocoapods', - 'composer', - 'conan', - 'conda', - 'cran', - 'deb', - 'docker', - 'gem', - 'generic', - 'github', - 'gitlab' as PURL_Type, - 'golang', - 'hackage', - 'hex', - 'huggingface', - 'maven', - 'mlflow', - NPM, - 'nuget', - 'oci', - 'pub', - 'pypi', - 'rpm', - 'swift', - 'vscode', - // The following are in EcosystemString but not in PURL_Type: - // 'qpkg', - // 'swid', - // 'unknown', - // 'vcs', -] as const satisfies readonly PURL_Type[] - -// Type checking utilities to ensure ALL_ECOSYSTEMS array is properly aligned. -// NOTE: Commented out because of type alignment issues between PURL_Type from SDK -// and other ecosystem representations. -// type AllEcosystemsUnion = (typeof ALL_ECOSYSTEMS)[number] -// type MissingInAllEcosystems = Exclude<PURL_Type, AllEcosystemsUnion> -// type ExtraInAllEcosystems = Exclude<AllEcosystemsUnion, PURL_Type> -// export type _Check_ALL_ECOSYSTEMS_has_all_purl_types = -// ExpectNever<MissingInAllEcosystems> -// export type _Check_ALL_ECOSYSTEMS_has_no_extras = -// ExpectNever<ExtraInAllEcosystems> - -export function getEcosystemChoicesForMeow(): string[] { - return [...ALL_ECOSYSTEMS] -} - - diff --git a/packages/cli/src/util/ecosystem/windows-shims.mts b/packages/cli/src/util/ecosystem/windows-shims.mts deleted file mode 100644 index 4578d171e..000000000 --- a/packages/cli/src/util/ecosystem/windows-shims.mts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Windows-shim resolution helpers extracted from `environment.mts` to keep that - * file under the 1000-line File-size cap. - * - * On Windows, package-manager binaries (`npm`, `pnpm`, `yarn`) ship as either a - * `.cmd` wrapper plus an extensionless shim or a direct `.js` entry point. - * `resolveBinPathSync` finds the underlying JS file when given a shim; - * `preferWindowsCmdShim` flips an extensionless path to its `.cmd` sibling when - * one exists (so child_process can spawn it without `shell: true`). - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -/** - * Given a bin path that might be an extensionless shim, return the matching - * `.cmd` file when one exists in the same directory. Otherwise return the input - * unchanged. - * - * Returns the input verbatim on POSIX, for non-absolute paths, when the path - * already has an extension, or when its basename doesn't match `binName` - * (defensive guard against accidentally turning a parent-directory path into a - * shim). - */ -export function preferWindowsCmdShim(binPath: string, binName: string): string { - if (!WIN32) { - return binPath - } - - // Relative paths might be shell commands or aliases, not file paths. - if (!path.isAbsolute(binPath)) { - return binPath - } - - // Already has an extension (.exe / .bat / etc.) — assume a Windows binary. - if (path.extname(binPath) !== '') { - return binPath - } - - // Belt-and-suspenders: ensure binPath actually points to the named binary, - // not a parent directory that happens to match. - if (path.basename(binPath).toLowerCase() !== binName.toLowerCase()) { - return binPath - } - - const cmdShim = path.join(path.dirname(binPath), `${binName}.cmd`) - return existsSync(cmdShim) ? cmdShim : binPath -} - -/** - * Resolve a bin path to its underlying JavaScript entry point if the file is an - * npm/pnpm/yarn shim. Returns the input path unchanged when: - the file does - * not exist - reading or parsing it throws - no shim pattern is recognized in - * the file content. - * - * Used on Windows to resolve shims like `npm` or `npm.cmd` to their - * `npm-cli.js` entry point so we can spawn them via Node directly. - */ -export function resolveBinPathSync(binPath: string): string { - if (!existsSync(binPath)) { - return binPath - } - - try { - const content = readFileSync(binPath, 'utf8') - // Look for common shim patterns: - // node "C:\path\to\npm-cli.js" "$@" - // "%_prog%" "%dp0%\node_modules\npm\bin\npm-cli.js" %* - const nodePathMatch = content.match( - /(?:"%dp0%\\|node\s+["'])([^"'\s]+(?:npm-cli|pnpm|yarn)\.(?:c?js|mjs))["'\s]/i, - ) - if (nodePathMatch && nodePathMatch.length > 1 && nodePathMatch[1]) { - const matchedPath = nodePathMatch[1] - return path.isAbsolute(matchedPath) - ? matchedPath - : path.resolve(path.dirname(binPath), matchedPath) - } - } catch { - // Unreadable/unparseable shim — fall through to the input path. - } - return binPath -} diff --git a/packages/cli/src/util/error/display.mts b/packages/cli/src/util/error/display.mts deleted file mode 100644 index c04a64bab..000000000 --- a/packages/cli/src/util/error/display.mts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @file Error display utilities with polished formatting. - */ - -import colors from 'yoctocolors-cjs' - -import { messageWithCauses } from '@socketsecurity/lib-stable/errors' -import { isError } from '@socketsecurity/lib-stable/errors/predicates' -import { LOG_SYMBOLS } from '@socketsecurity/lib-stable/logger/symbols' -import { stripAnsi } from '@socketsecurity/lib-stable/ansi/strip' - -import { isDebugNs } from '../debug.mts' -import { - AuthError, - ConfigError, - FileSystemError, - InputError, - NetworkError, - RateLimitError, - getRecoverySuggestions, -} from './errors.mts' - -import type { CResult } from '../../types.mjs' - -type ErrorDisplayOptions = { - cause?: string | undefined - showStack?: boolean | undefined - title?: string | undefined - verbose?: boolean | undefined -} - -/** - * Append the `.cause` chain to a decorated base message. Typed errors build - * their message with suffixes (e.g. ` (HTTP 500)`) before this is called, so we - * can't just `messageWithCauses(error)` — we decorate first, then delegate - * cause walking to socket-lib. - */ -export function appendCauseChain(baseMessage: string, cause: unknown): string { - if (!cause) { - return baseMessage - } - const causeText = isError(cause) ? messageWithCauses(cause) : String(cause) - return `${baseMessage}: ${causeText}` -} - -/** - * Format an error for display with polish and clarity. Uses LOG_SYMBOLS and - * colors for visual hierarchy. - */ -export function formatErrorForDisplay( - error: unknown, - options?: ErrorDisplayOptions | undefined, -): { body?: string | undefined; message: string; title: string } { - const opts = { __proto__: null, ...options } as ErrorDisplayOptions - const verbose = opts.verbose ?? isDebugNs('error') - const showStack = opts.showStack ?? verbose - - let title = opts.title || 'Error' - let message = '' - let body: string | undefined - - if (error instanceof RateLimitError) { - title = 'API rate limit exceeded' - message = error.message - if (error.retryAfter) { - message += ` (retry after ${error.retryAfter}s)` - } - message = appendCauseChain(message, error.cause) - } else if (error instanceof AuthError) { - title = 'Authentication error' - message = appendCauseChain(error.message, error.cause) - } else if (error instanceof NetworkError) { - title = 'Network error' - message = error.message - if (error.statusCode) { - message += ` (HTTP ${error.statusCode})` - } - message = appendCauseChain(message, error.cause) - } else if (error instanceof FileSystemError) { - title = 'File system error' - message = error.message - if (error.path) { - message += ` (${error.path})` - } - message = appendCauseChain(message, error.cause) - } else if (error instanceof ConfigError) { - title = 'Configuration error' - message = error.message - if (error.configKey) { - message += ` (key: ${error.configKey})` - } - message = appendCauseChain(message, error.cause) - } else if (error instanceof InputError) { - title = 'Invalid input' - message = appendCauseChain(error.message, error.cause) - body = error.body - } else if (isError(error)) { - title = opts.title || 'Unexpected error' - message = appendCauseChain(error.message, error.cause) - - if (showStack && error.stack) { - // Format stack trace with proper indentation. - const stackLines = error.stack.split('\n') - const formattedStack = stackLines - .slice(1) - .map(line => ` ${colors.dim(line.trim())}`) - .join('\n') - - body = formattedStack - } - - // Handle error causes (chain of errors). - if (error.cause && showStack) { - const causeLines = [] - let currentCause: unknown = error.cause - let depth = 1 - - while (currentCause && depth <= 5) { - // Use .message (or String coercion) here — errorMessage() walks - // the entire remaining cause chain via messageWithCauses, which - // would duplicate messages since the outer while loop is already - // iterating the chain level-by-level. - const causeMessage = isError(currentCause) - ? currentCause.message || String(currentCause) - : String(currentCause) - - causeLines.push( - `\n${colors.dim(`Caused by [${depth}]:`)} ${colors.yellow(causeMessage)}`, - ) - - if (isError(currentCause) && currentCause.stack && depth === 1) { - const causeStack = currentCause.stack - .split('\n') - .slice(1) - .map(line => ` ${colors.dim(line.trim())}`) - .join('\n') - causeLines.push(causeStack) - } - - currentCause = isError(currentCause) ? currentCause.cause : undefined - depth++ - } - - body = body ? `${body}${causeLines.join('\n')}` : causeLines.join('\n') - } - } else if (typeof error === 'string') { - message = error - } else { - title = 'Unexpected error' - message = 'An unknown error occurred' - if (verbose) { - body = String(error) - } - } - - // Add cause from options if provided. - if (opts.cause && !body) { - body = opts.cause - } - - return { body, message, title } -} - -/** - * Format error for JSON output. Provides structured error data for machine - * consumption. - */ -export function formatErrorForJson( - error: unknown, - options?: ErrorDisplayOptions | undefined, -): CResult<never> & { recovery?: string[] | undefined } { - const { body, message, title } = formatErrorForDisplay(error, { - ...options, - showStack: false, - }) - - const recovery = getRecoverySuggestions(error) - - return { - ok: false, - cause: stripAnsi(body || message), - message: stripAnsi(title), - ...(recovery.length > 0 ? { recovery } : {}), - } -} - -/** - * Format error for terminal display with visual hierarchy. Returns formatted - * string ready to log to stderr. - */ -export function formatErrorForTerminal( - error: unknown, - options?: ErrorDisplayOptions | undefined, -): string { - const { body, message, title } = formatErrorForDisplay(error, options) - - const lines = [ - `${LOG_SYMBOLS['error']} ${colors.red(colors.bold(title))}`, - message ? ` ${message}` : '', - ] - - // Add recovery suggestions if available - const recovery = getRecoverySuggestions(error) - if (recovery.length > 0) { - lines.push('', colors.cyan('Suggested actions:')) - for (let i = 0, { length } = recovery; i < length; i += 1) { - const suggestion = recovery[i] - lines.push(` ${colors.dim('•')} ${suggestion}`) - } - } - - if (body) { - const verbose = options?.verbose ?? isDebugNs('error') - if (verbose) { - lines.push('', colors.dim('Stack trace:'), body) - } else { - lines.push( - '', - colors.dim('Run with DEBUG=1 or --verbose for full stack trace'), - ) - } - } - - return lines.filter(Boolean).join('\n') -} - diff --git a/packages/cli/src/util/error/errors.mts b/packages/cli/src/util/error/errors.mts deleted file mode 100644 index 42a2e2502..000000000 --- a/packages/cli/src/util/error/errors.mts +++ /dev/null @@ -1,557 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Error utilities for Socket CLI. Provides consistent error handling, - * formatting, and message extraction. - * - * Key Classes: - * - * - AuthError: Authentication failures (401/403 responses) - * - InputError: User input validation failures - * - * Key Functions: - * - * - CaptureException: Send errors to Sentry for monitoring - * - FormatErrorWithDetail: Format errors with detailed context - * - GetErrorCause: Get error cause with fallback to UNKNOWN_ERROR - * - GetErrorMessage: Extract error message from any thrown value - * - * Error Handling Strategy: - * - * - Always prefer specific error types over generic errors - * - Use formatErrorWithDetail for user-facing error messages - * - Log errors to Sentry in production for monitoring - */ - -import { setTimeout as sleep } from 'node:timers/promises' - -import { - UNKNOWN_ERROR, - kInternalsSymbol, -} from '@socketsecurity/lib-stable/constants/sentinels' -import { debugNs } from '@socketsecurity/lib-stable/debug/output' - -import { isErrnoException, isError } from '@socketsecurity/lib-stable/errors/predicates' -export { isErrnoException } from '@socketsecurity/lib-stable/errors/predicates' -import { - SOCKET_DASHBOARD_URL, - SOCKET_PRICING_URL, - SOCKET_STATUS_URL, -} from '../../constants/socket.mts' - -// Access internals via kInternalsSymbol. -type SentryClient = { - captureException(exception: unknown, hint?: unknown): string -} -const constants = { - [kInternalsSymbol]: {} as { getSentry?: () => SentryClient | undefined }, -} -const internals = constants[kInternalsSymbol] -const getSentry = internals?.getSentry - -type EventHintOrCaptureContext = - | { [key: string]: unknown } - | Function - -/** - * Authentication error with recovery suggestions. Thrown when API - * authentication fails (401/403). - */ -export class AuthError extends Error { - public readonly recovery: string[] - - constructor(message: string, recovery?: string[]) { - super(message) - this.name = 'AuthError' - this.recovery = recovery || [ - 'Run `socket login` to authenticate', - 'Set SOCKET_SECURITY_API_KEY environment variable', - 'Add apiToken to ~/.config/socket/config.toml', - ] - } -} - -/** - * User input validation error with details. Thrown when user provides invalid - * input or arguments. - */ -export class InputError extends Error { - public readonly body: string | undefined - public readonly recovery: string[] - - constructor(message: string, body?: string | undefined, recovery?: string[]) { - super(message) - this.name = 'InputError' - this.body = body - this.recovery = recovery || ['Check command syntax with --help'] - } -} - -/** - * Network error with retry suggestions. Thrown when network requests fail due - * to connectivity issues. - */ -export class NetworkError extends Error { - public readonly statusCode?: number | undefined - public readonly recovery: string[] - - constructor( - message: string, - statusCode?: number | undefined, - recovery?: string[] | undefined, - ) { - super(message) - this.name = 'NetworkError' - this.statusCode = statusCode - this.recovery = recovery || [ - 'Check your internet connection', - 'Verify proxy settings if using a proxy', - 'Try again in a few moments', - ] - } -} - -/** - * API rate limit error with quota information. Thrown when API rate limits are - * exceeded (429). - */ -export class RateLimitError extends Error { - public readonly retryAfter?: number | undefined - public readonly recovery: string[] - - constructor(message: string, retryAfter?: number | undefined) { - super(message) - this.name = 'RateLimitError' - this.retryAfter = retryAfter - this.recovery = [ - retryAfter - ? `Wait ${retryAfter} seconds before retrying` - : 'Wait a few minutes before retrying', - `Check your API quota at ${SOCKET_DASHBOARD_URL}`, - `Consider upgrading your plan for higher limits at ${SOCKET_PRICING_URL}`, - ] - } -} - -/** - * File system error with path context. Thrown when file operations fail. - */ -export class FileSystemError extends Error { - public readonly path?: string | undefined - public readonly code?: string | undefined - public readonly recovery: string[] - - constructor( - message: string, - path?: string | undefined, - code?: string | undefined, - recovery?: string[] | undefined, - ) { - super(message) - this.name = 'FileSystemError' - this.path = path - this.code = code - this.recovery = recovery || this.getDefaultRecovery(code) - } - - private getDefaultRecovery(code?: string): string[] { - switch (code) { - case 'ENOENT': - return [ - 'Verify the file or directory exists', - 'Check the path spelling', - 'Ensure you have permission to access the location', - ] - case 'EACCES': - case 'EPERM': - return [ - 'Check file permissions', - 'Run with appropriate user privileges', - 'Verify directory ownership', - ] - case 'ENOSPC': - return [ - 'Free up disk space', - 'Check available disk space with `df -h`', - 'Delete unnecessary files', - ] - default: - return ['Check file system permissions and availability'] - } - } -} - -/** - * Configuration error with setup instructions. Thrown when CLI configuration is - * invalid or missing. - */ -export class ConfigError extends Error { - public readonly configKey?: string | undefined - public readonly recovery: string[] - - constructor( - message: string, - configKey?: string | undefined, - recovery?: string[] | undefined, - ) { - super(message) - this.name = 'ConfigError' - this.configKey = configKey - this.recovery = recovery || [ - 'Run `socket config list` to view current configuration', - 'Use `socket config set <key> <value>` to update settings', - 'Check ~/.config/socket/config.toml for syntax errors', - ] - } -} - -/** - * Timeout error with retry guidance. Thrown when operations exceed time limits. - */ -export class TimeoutError extends Error { - public readonly timeoutMs?: number | undefined - public readonly elapsedMs?: number | undefined - public readonly recovery: string[] - - constructor( - message: string, - timeoutMs?: number | undefined, - elapsedMs?: number | undefined, - recovery?: string[] | undefined, - ) { - super(message) - this.name = 'TimeoutError' - this.timeoutMs = timeoutMs - this.elapsedMs = elapsedMs - this.recovery = recovery || [ - 'Check your internet connection speed', - 'Try again when network conditions improve', - 'Contact support if timeouts persist', - ] - } -} - -export async function buildErrorCause( - status: number, - message: string, - reason: string, -): Promise<string> { - const NO_ERROR_MESSAGE = 'No error message returned' - - // For 429 errors, preserve the detailed quota information. - if (status === 429) { - const { getErrorMessageForHttpStatusCode } = - await import('../socket/api.mjs') - const quotaMessage = await getErrorMessageForHttpStatusCode(429) - if (reason && reason !== NO_ERROR_MESSAGE) { - return `${reason}. ${quotaMessage}` - } - if (message && message !== NO_ERROR_MESSAGE) { - return `${message}. ${quotaMessage}` - } - return quotaMessage - } - - // Skip adding reason if it's too similar to message (avoid redundancy). - // Threshold of 0.7 means >70% word overlap indicates redundancy. - if (reason && message !== reason) { - const similarity = calculateStringSimilarity(message, reason) - if (similarity < 0.7) { - return `${message} (reason: ${reason})` - } - } - - return message -} - -/** - * Build error cause string for SDK error results, preserving detailed quota - * information for 429 errors. Used by API utilities to format consistent error - * messages with appropriate context. - * - * @example - * await buildErrorCause(429, 'Quota exceeded', 'Monthly limit reached') - * // Returns: "Monthly limit reached. API quota exceeded..." - * - * await buildErrorCause(400, 'Bad request', 'Invalid parameter') - * // Returns: "Bad request (reason: Invalid parameter)" - * - * @param status - HTTP status code from the API response. - * @param message - Primary error message from the API. - * @param reason - Additional error reason/cause from the API. - * - * @returns Formatted error cause string with appropriate context - */ -/** - * Calculate similarity ratio between two strings using word overlap. Returns a - * value between 0 (no overlap) and 1 (identical). - */ -export function calculateStringSimilarity(str1: string, str2: string): number { - if (str1 === str2) { - return 1 - } - - const words1 = new Set( - str1 - .toLowerCase() - .split(/\W+/) - .filter(w => w.length > 2), - ) - const words2 = new Set( - str2 - .toLowerCase() - .split(/\W+/) - .filter(w => w.length > 2), - ) - - if (words1.size === 0 || words2.size === 0) { - return 0 - } - - let overlap = 0 - for (const word of words1) { - if (words2.has(word)) { - overlap += 1 - } - } - - return (2 * overlap) / (words1.size + words2.size) -} - -export async function captureException( - exception: unknown, - hint?: EventHintOrCaptureContext | undefined, -): Promise<string> { - const result = captureExceptionSync(exception, hint) - // "Sleep" for a second, just in case, hopefully enough time to initiate fetch. - await sleep(1000) - return result -} - -export function captureExceptionSync( - exception: unknown, - hint?: EventHintOrCaptureContext | undefined, -): string { - const Sentry = getSentry?.() - if (!Sentry) { - return '' - } - /* c8 ignore start - Sentry is undefined in tests (Sentry build mode is opt-in only) */ - debugNs('notice', 'send: exception to Sentry') - return Sentry.captureException(exception, hint) as string - /* c8 ignore stop */ -} - -/** - * Formats an error message with an optional error detail appended. Extracts the - * message from an unknown error value and appends it to the base message if - * available. - * - * @example - * formatErrorWithDetail('Failed to delete file', error) - * // Returns: "Failed to delete file: ENOENT: no such file or directory" - * // Or just: "Failed to delete file" if no error message - * - * @param baseMessage - The base message to display. - * @param error - The error object to extract message from. - * - * @returns Formatted message with error detail if available - */ -export function formatErrorWithDetail( - baseMessage: string, - error: unknown, -): string { - const errorMessage = getErrorMessage(error) - return `${baseMessage}${errorMessage ? `: ${errorMessage}` : ''}` -} - -/** - * Extracts an error cause from an unknown value. Returns the error message if - * available, otherwise UNKNOWN_ERROR. Commonly used for creating CResult error - * causes. - * - * @example - * return { ok: false, message: 'Operation failed', cause: getErrorCause(e) } - * - * @param error - The error object to extract message from. - * - * @returns The error message or UNKNOWN_ERROR constant - */ -export function getErrorCause(error: unknown): string { - return getErrorMessageOr(error, UNKNOWN_ERROR) -} - -/** - * Extracts an error message from an unknown value. Returns the message if it's - * an Error object, otherwise returns undefined. - * - * @param error - The error object to extract message from. - * - * @returns The error message or undefined - */ -export function getErrorMessage(error: unknown): string | undefined { - return (error as Error)?.message -} - -/** - * Extracts an error message from an unknown value with a fallback. Returns the - * message if it's an Error object, otherwise returns the fallback. - * - * @example - * getErrorMessageOr(error, 'Unknown error occurred') - * // Returns: "ENOENT: no such file or directory" or "Unknown error occurred" - * - * @param error - The error object to extract message from. - * @param fallback - The fallback message if no error message is found. - * - * @returns The error message or fallback - */ -export function getErrorMessageOr(error: unknown, fallback: string): string { - return getErrorMessage(error) || fallback -} - -/** - * Detect network-related error codes from Node.js errors. - */ -export function getNetworkErrorCode(error: unknown): string | undefined { - if (!isErrnoException(error)) { - return undefined - } - return error.code -} - -/** - * Get network error diagnostics with actionable guidance. Provides specific - * recovery steps based on error type. - * - * @example - * const diagnostics = getNetworkErrorDiagnostics(error, 5000) - * // Returns: "Connection refused. The server may be down..." - * - * @param error - The error to diagnose. - * @param durationMs - Optional request duration in milliseconds. - * - * @returns Diagnostic message with recovery suggestions - */ -export function getNetworkErrorDiagnostics( - error: unknown, - durationMs?: number | undefined, -): string { - const errorCode = getNetworkErrorCode(error) - const errorMessage = getErrorMessage(error) || String(error) - - // Timeout errors. - if ( - errorCode === 'ETIMEDOUT' || - errorCode === 'ESOCKETTIMEDOUT' || - errorCode === 'ECONNRESET' || - (durationMs && durationMs > 30_000) - ) { - const timeInfo = durationMs - ? ` after ${Math.round(durationMs / 1000)}s` - : '' - return ( - `Request timeout${timeInfo}. The server took too long to respond.\n` + - '💡 Try:\n' + - ' • Check your internet connection speed\n' + - ' • Retry the request - the server may be temporarily slow\n' + - ` • Check Socket status: ${SOCKET_STATUS_URL}\n` + - ' • Contact support if timeouts persist' - ) - } - - // Connection refused. - if (errorCode === 'ECONNREFUSED') { - return ( - 'Connection refused. The server actively rejected the connection.\n' + - '💡 Try:\n' + - ' • Check if you are using a proxy or VPN that may be blocking the connection\n' + - ' • Verify your firewall settings\n' + - ` • Check Socket status: ${SOCKET_STATUS_URL}\n` + - ' • Ensure SOCKET_CLI_API_BASE_URL is set correctly (if configured)' - ) - } - - // DNS resolution failures. - if ( - errorCode === 'ENOTFOUND' || - errorCode === 'EAI_AGAIN' || - errorMessage.includes('getaddrinfo') - ) { - return ( - 'DNS resolution failed. Unable to resolve the server hostname.\n' + - '💡 Try:\n' + - ' • Check your internet connection\n' + - ' • Verify DNS settings (try 8.8.8.8 or 1.1.1.1)\n' + - ' • Check if a VPN or proxy is interfering\n' + - ' • Ensure SOCKET_CLI_API_BASE_URL is correct (if configured)\n' + - ' • Try again in a few moments' - ) - } - - // Certificate/SSL errors. - if ( - errorCode === 'CERT_HAS_EXPIRED' || - errorCode === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || - errorCode === 'SELF_SIGNED_CERT_IN_CHAIN' || - errorMessage.includes('certificate') - ) { - return ( - 'SSL/TLS certificate error. Unable to verify server identity.\n' + - '💡 Try:\n' + - ' • Check your system date and time are correct\n' + - ' • Update your system certificates\n' + - ' • Check if a proxy is intercepting HTTPS traffic\n' + - ' • Contact your IT department if behind corporate firewall' - ) - } - - // Network unreachable. - if (errorCode === 'EHOSTUNREACH' || errorCode === 'ENETUNREACH') { - return ( - 'Network unreachable. Cannot reach the destination network.\n' + - '💡 Try:\n' + - ' • Check your internet connection\n' + - ' • Verify network/WiFi is connected\n' + - ' • Check if VPN or firewall is blocking access\n' + - ' • Try a different network' - ) - } - - // Generic network error with basic guidance. - return ( - `Network error: ${errorMessage}\n` + - '💡 Try:\n' + - ' • Check your internet connection\n' + - ' • Verify proxy settings if using a proxy\n' + - ` • Check Socket status: ${SOCKET_STATUS_URL}\n` + - ' • Try again in a few moments' - ) -} - -/** - * Extract recovery suggestions from an error. - * - * @param error - The error object to extract recovery suggestions from. - * - * @returns Array of recovery suggestion strings, or empty array if none - */ -export function getRecoverySuggestions(error: unknown): string[] { - if (hasRecoverySuggestions(error)) { - return error.recovery - } - return [] -} - -/** - * Type guard to check if an error has recovery suggestions. - */ -export function hasRecoverySuggestions( - error: unknown, -): error is Error & { recovery: string[] } { - return ( - isError(error) && - 'recovery' in error && - Array.isArray((error as { recovery?: unknown | undefined }).recovery) - ) -} - diff --git a/packages/cli/src/util/fs/find-up.mts b/packages/cli/src/util/fs/find-up.mts deleted file mode 100644 index 9ad5e7e34..000000000 --- a/packages/cli/src/util/fs/find-up.mts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * File system utilities for Socket CLI. Provides file and directory search - * functionality. - * - * Key Functions: - findUp: Search for files/directories up the directory tree. - * - * Features: - Upward directory traversal - Supports file and directory - * searching - Abort signal support for cancellation - Multiple name search - * support. - * - * Usage: - Finding configuration files (package.json, lockfiles) - Locating - * project root directories - Searching for specific files in parent - * directories. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { getAbortSignal } from '@socketsecurity/lib-stable/process/abort' - -type FindUpOptions = { - cwd?: string | undefined - onlyDirectories?: boolean | undefined - onlyFiles?: boolean | undefined - signal?: AbortSignal | undefined -} - -export async function findUp( - name: string | string[], - options?: FindUpOptions | undefined, -): Promise<string | undefined> { - const opts = { __proto__: null, ...options } - const abortSignal = getAbortSignal() - const { cwd = process.cwd(), signal = abortSignal } = opts - let { onlyDirectories = false, onlyFiles = true } = opts - if (onlyDirectories) { - onlyFiles = false - } - if (onlyFiles) { - onlyDirectories = false - } - let dir = path.resolve(cwd) - const { root } = path.parse(dir) - const names = [name].flat() - // Use do-while to check current directory before continuing up the tree. - // This ensures root directory is checked when cwd is root. - do { - for (let i = 0, { length } = names; i < length; i += 1) { - const name = names[i]! - if (signal?.aborted) { - return undefined - } - const thePath = path.join(dir, name) - try { - // oxlint-disable-next-line socket/prefer-exists-sync -- reads .isFile() / .isDirectory() metadata to distinguish file vs dir matches. - const stats = await fs.stat(thePath) - if (!onlyDirectories && stats.isFile()) { - return thePath - } - if (!onlyFiles && stats.isDirectory()) { - return thePath - } - } catch {} - } - if (dir === root) { - break - } - dir = path.dirname(dir) - } while (dir) - return undefined -} diff --git a/packages/cli/src/util/fs/glob.mts b/packages/cli/src/util/fs/glob.mts deleted file mode 100644 index 5828e5bf8..000000000 --- a/packages/cli/src/util/fs/glob.mts +++ /dev/null @@ -1,338 +0,0 @@ -import path from 'node:path' - -import fastGlob from 'fast-glob' -import ignore from 'ignore' -import micromatch from 'micromatch' -import { parse as yamlParse } from 'yaml' - -import { isDirSync } from '@socketsecurity/lib-stable/fs/inspect' -import { safeReadFile } from '@socketsecurity/lib-stable/fs/read-file' -import { defaultIgnore } from '@socketsecurity/lib-stable/globs/defaults' -import { readPackageJson } from '@socketsecurity/lib-stable/packages/operations' -import { transform } from '@socketsecurity/lib-stable/streams/transform' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { homePath } from '../../constants/paths.mts' -import { NODE_MODULES, PNPM } from '../../constants.mts' - -import type { Agent } from '../ecosystem/environment.mts' -import type { SocketYml } from '../socket-yaml.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' -import type { Options as GlobOptions } from 'fast-glob' - -const DEFAULT_IGNORE_FOR_GIT_IGNORE = defaultIgnore.filter( - (p: string) => !p.endsWith('.gitignore'), -) - -const IGNORED_DIRS = [ - // Taken from ignore-by-default: - // https://github.com/novemberborn/ignore-by-default/blob/v2.1.0/index.js - '.git', // Git repository files, see <https://git-scm.com/> - '.log', // Log files emitted by tools such as `tsserver`, see <https://github.com/Microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29> - '.nyc_output', // Temporary directory where nyc stores coverage data, see <https://github.com/bcoe/nyc> - '.sass-cache', // Cache folder for node-sass, see <https://github.com/sass/node-sass> - '.yarn', // Where node modules are installed when using Yarn, see <https://yarnpkg.com/> - 'bower_components', // Where Bower packages are installed, see <http://bower.io/> - 'coverage', // Standard output directory for code coverage reports, see <https://github.com/gotwarlost/istanbul> - NODE_MODULES, // Where Node modules are installed, see <https://nodejs.org/> - // Taken from globby: - // https://github.com/sindresorhus/globby/blob/v14.0.2/ignore.js#L11-L16 - 'flow-typed', -] as const - -const IGNORED_DIR_PATTERNS = IGNORED_DIRS.map(i => `**/${i}`) - -export function createSupportedFilesFilter( - supportedFiles: SocketSdkSuccessResult<'getSupportedFiles'>['data'], -): (filepath: string) => boolean { - const patterns = getSupportedFilePatterns(supportedFiles) - return (filepath: string) => - micromatch.some(filepath, patterns, { dot: true }) -} - -export function getSupportedFilePatterns( - supportedFiles: SocketSdkSuccessResult<'getSupportedFiles'>['data'], -): string[] { - const patterns: string[] = [] - for (const key of Object.keys(supportedFiles)) { - const supported = supportedFiles[key] - if (supported) { - patterns.push(...Object.values(supported).map(p => `**/${p.pattern}`)) - } - } - return patterns -} - -export async function getWorkspaceGlobs( - agent: Agent, - cwd = process.cwd(), -): Promise<string[]> { - let workspacePatterns: unknown - if (agent === PNPM) { - const workspacePath = path.join(cwd, 'pnpm-workspace.yaml') - const yml = await safeReadFile(workspacePath, { encoding: 'utf8' }) - if (yml) { - try { - workspacePatterns = yamlParse(yml)?.packages - } catch {} - } - } else { - workspacePatterns = (await readPackageJson(cwd, { throws: false }))?.[ - 'workspaces' - ] - } - return Array.isArray(workspacePatterns) - ? workspacePatterns - .filter(isNonEmptyString) - .map(workspacePatternToGlobPattern) - : [] -} - -type GlobWithGitIgnoreOptions = GlobOptions & { - // Optional filter function to apply during streaming. - // When provided, only files passing this filter are accumulated. - // This is critical for memory efficiency when scanning large monorepos. - filter?: ((filepath: string) => boolean) | undefined - socketConfig?: SocketYml | undefined -} - -export async function globWithGitIgnore( - patterns: string[] | readonly string[], - options: GlobWithGitIgnoreOptions, -): Promise<string[]> { - const { - cwd = process.cwd(), - filter, - socketConfig, - ...additionalOptions - } = { __proto__: null, ...options } as GlobWithGitIgnoreOptions - - const ignores = new Set<string>(IGNORED_DIR_PATTERNS) - - const projectIgnorePaths = socketConfig?.projectIgnorePaths - if (Array.isArray(projectIgnorePaths)) { - const ignorePatterns = ignoreFileLinesToGlobPatterns( - projectIgnorePaths, - path.join(cwd, '.gitignore'), - cwd, - ) - for (let i = 0, { length } = ignorePatterns; i < length; i += 1) { - const pattern = ignorePatterns[i]! - ignores.add(pattern) - } - } - - const gitIgnoreStream = fastGlob.globStream(['**/.gitignore'], { - absolute: true, - cwd, - dot: true, - ignore: DEFAULT_IGNORE_FOR_GIT_IGNORE, - }) as AsyncIterable<string> - for await (const ignorePatterns of transform( - gitIgnoreStream, - async (filepath: string) => - ignoreFileToGlobPatterns( - (await safeReadFile(filepath, { encoding: 'utf8' })) ?? '', - filepath, - cwd, - ), - { concurrency: 8 }, - )) { - for (let i = 0, { length } = ignorePatterns; i < length; i += 1) { - const p = ignorePatterns[i]! - ignores.add(p) - } - } - - let hasNegatedPattern = false - for (const p of ignores) { - if (p.charCodeAt(0) === 33 /*'!'*/) { - hasNegatedPattern = true - break - } - } - - const globOptions = { - __proto__: null, - absolute: true, - cwd, - dot: true, - ignore: hasNegatedPattern ? [...defaultIgnore] : [...ignores], - ...additionalOptions, - } as GlobOptions - - // When no filter is provided and no negated patterns exist, use the fast path. - if (!hasNegatedPattern && !filter) { - return await fastGlob.glob(patterns as string[], globOptions) - } - // Add support for negated "ignore" patterns which many globbing libraries, - // including 'fast-glob', 'globby', and 'tinyglobby', lack support for. - // Use streaming to avoid unbounded memory accumulation. - // This is critical for large monorepos with 100k+ files. - const results: string[] = [] - const ig = hasNegatedPattern ? ignore().add([...ignores]) : undefined - const stream = fastGlob.globStream( - patterns as string[], - globOptions, - ) as AsyncIterable<string> - for await (const p of stream) { - // Check gitignore patterns with negation support. - if (ig) { - // Note: the input files must be INSIDE the cwd. If you get strange looking - // relative path errors here, most likely your path is outside the given cwd. - const relPath = globOptions.absolute ? path.relative(cwd, p) : p - if (ig.ignores(relPath)) { - continue - } - } - // Apply the optional filter to reduce memory usage. - // When scanning large monorepos, this filters early (e.g., to manifest files only) - // instead of accumulating all 100k+ files and filtering later. - if (filter && !filter(p)) { - continue - } - results.push(p) - } - return results -} - -export async function globWorkspace( - agent: Agent, - cwd = process.cwd(), -): Promise<string[]> { - const workspaceGlobs = await getWorkspaceGlobs(agent, cwd) - return workspaceGlobs.length - ? await fastGlob.glob(workspaceGlobs, { - absolute: true, - cwd, - dot: true, - ignore: [...defaultIgnore], - }) - : [] -} - -export function ignoreFileLinesToGlobPatterns( - lines: string[] | readonly string[], - filepath: string, - cwd: string, -): string[] { - const base = path.relative(cwd, path.dirname(filepath)).replace(/\\/g, '/') - const patterns = [] - for (let i = 0, { length } = lines; i < length; i += 1) { - const pattern = lines[i]!.trim() - if (pattern.length > 0 && pattern.charCodeAt(0) !== 35 /*'#'*/) { - patterns.push( - ignorePatternToMinimatch( - pattern.length && pattern.charCodeAt(0) === 33 /*'!'*/ - ? `!${path.posix.join(base, pattern.slice(1))}` - : path.posix.join(base, pattern), - ), - ) - } - } - return patterns -} - -export function ignoreFileToGlobPatterns( - content: string, - filepath: string, - cwd: string, -): string[] { - return ignoreFileLinesToGlobPatterns(content.split(/\r?\n/), filepath, cwd) -} - -// Based on `@eslint/compat` convertIgnorePatternToMinimatch. -// Apache v2.0 licensed -// Copyright Nicholas C. Zakas -// https://github.com/eslint/rewrite/blob/compat-v1.2.1/packages/compat/src/ignore-file.js#L28 -export function ignorePatternToMinimatch(pattern: string): string { - const isNegated = pattern.startsWith('!') - const negatedPrefix = isNegated ? '!' : '' - const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd() - // Special cases. - if ( - patternToTest === '' || - patternToTest === '**' || - patternToTest === '**' || - patternToTest === '/**' - ) { - return `${negatedPrefix}${patternToTest}` - } - const firstIndexOfSlash = patternToTest.indexOf('/') - const matchEverywherePrefix = - firstIndexOfSlash === -1 || firstIndexOfSlash === patternToTest.length - 1 - ? '**/' - : '' - const patternWithoutLeadingSlash = - firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest - // Escape `{` and `(` because in gitignore patterns they are just - // literal characters without any specific syntactic meaning, - // while in minimatch patterns they can form brace expansion or extglob syntax. - // - // For example, gitignore pattern `src/{a,b}.js` ignores file `src/{a,b}.js`. - // But, the same minimatch pattern `src/{a,b}.js` ignores files `src/a.js` and `src/b.js`. - // Minimatch pattern `src/\{a,b}.js` is equivalent to gitignore pattern `src/{a,b}.js`. - const escapedPatternWithoutLeadingSlash = - patternWithoutLeadingSlash.replaceAll( - /(?=((?:\\.|[^{(])*))\1([{(])/guy, // socket-hook: allow regex-alternation-order -- `\\.` must come first so escape pairs are consumed atomically. - '$1\\$2', - ) - const matchInsideSuffix = patternToTest.endsWith('/**') ? '/*' : '' - return `${negatedPrefix}${matchEverywherePrefix}${escapedPatternWithoutLeadingSlash}${matchInsideSuffix}` -} - -export function isReportSupportedFile( - filepath: string, - supportedFiles: SocketSdkSuccessResult<'getSupportedFiles'>['data'], -) { - const patterns = getSupportedFilePatterns(supportedFiles) - return micromatch.some(filepath, patterns, { dot: true }) -} - -export function pathsToGlobPatterns( - paths: string[] | readonly string[], - cwd?: string | undefined, -): string[] { - return paths.map(p => { - // Convert current directory references to glob patterns. - if (p === '.' || p === './') { - return '**/*' - } - // Expand tilde to home directory. - let resolvedPath = p - if (p.startsWith('~/')) { - resolvedPath = path.join(homePath, p.slice(2)) - } else if (p === '~') { - resolvedPath = homePath - } - const absolutePath = path.isAbsolute(resolvedPath) - ? resolvedPath - : path.resolve(cwd ?? process.cwd(), resolvedPath) - // If the path is a directory, scan it recursively for all files. - if (isDirSync(absolutePath)) { - return `${resolvedPath}/**/*` - } - return resolvedPath - }) -} - -export function workspacePatternToGlobPattern(workspace: string): string { - const { length } = workspace - if (!length) { - return '' - } - // If the workspace ends with "/" - if (workspace.charCodeAt(length - 1) === 47 /*'/'*/) { - return `${workspace}/*/package.json` - } - // If the workspace ends with "/**" - if ( - workspace.charCodeAt(length - 1) === 42 /*'*'*/ && - workspace.charCodeAt(length - 2) === 42 /*'*'*/ && - workspace.charCodeAt(length - 3) === 47 /*'/'*/ - ) { - return `${workspace}/*/**/package.json` - } - // Things like "packages/a" or "packages/*" - return `${workspace}/package.json` -} diff --git a/packages/cli/src/util/fs/home-path.mts b/packages/cli/src/util/fs/home-path.mts deleted file mode 100644 index 42d7b347d..000000000 --- a/packages/cli/src/util/fs/home-path.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Path tildification utilities for Socket CLI. Abbreviates home directory paths - * with tilde notation. - * - * Key Functions: - * - * - Tildify: Replace home directory with ~ in paths - * - * Usage: - * - * - Shortens absolute paths for display - * - Converts absolute home paths to ~/... - * - Common Unix convention for home directory - */ - -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { escapeRegExp } from '@socketsecurity/lib-stable/regexps/escape' - -import { homePath } from '../../constants/paths.mts' - -export function tildify(cwd: string) { - // Normalize to forward slashes for consistent matching across platforms. - const normalizedCwd = normalizePath(cwd) - return normalizedCwd.replace( - new RegExp(`^${escapeRegExp(homePath)}(?:/|$)`, 'i'), - '~/', - ) -} diff --git a/packages/cli/src/util/fs/path-resolve.mts b/packages/cli/src/util/fs/path-resolve.mts deleted file mode 100644 index 05b8cac09..000000000 --- a/packages/cli/src/util/fs/path-resolve.mts +++ /dev/null @@ -1,66 +0,0 @@ -import { resolveRealBinSync } from '@socketsecurity/lib-stable/bin/resolve' -import { whichRealSync } from '@socketsecurity/lib-stable/bin/which' - -import { - createSupportedFilesFilter, - globWithGitIgnore, - pathsToGlobPatterns, -} from './glob.mts' - -import type { SocketYml } from '../socket-yaml.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -export function findBinPathDetailsSync(binName: string): { - name: string - path: string | undefined -} { - const rawBinPaths = - whichRealSync(binName, { - all: true, - nothrow: true, - }) ?? [] - // whichRealSync may return a string when only one result is found, even with all: true. - // This handles both the current published version and future versions. - const binPaths = Array.isArray(rawBinPaths) - ? rawBinPaths - : typeof rawBinPaths === 'string' - ? [rawBinPaths] - : [] - let theBinPath: string | undefined - for (let i = 0, { length } = binPaths; i < length; i += 1) { - const binPath = binPaths[i]! - theBinPath = resolveRealBinSync(binPath) - break - } - return { name: binName, path: theBinPath } -} - -type PackageFilesForScanOptions = { - cwd?: string | undefined - config?: SocketYml | undefined -} - -export async function getPackageFilesForScan( - inputPaths: string[], - supportedFiles: SocketSdkSuccessResult<'getSupportedFiles'>['data'], - options?: PackageFilesForScanOptions | undefined, -): Promise<string[]> { - const { config: socketConfig, cwd = process.cwd() } = { - __proto__: null, - ...options, - } as PackageFilesForScanOptions - - // Apply the supported files filter during streaming to avoid accumulating - // all files in memory. This is critical for large monorepos with 100k+ files - // where accumulating all paths before filtering causes OOM errors. - const filter = createSupportedFilesFilter(supportedFiles) - - return await globWithGitIgnore( - pathsToGlobPatterns(inputPaths, options?.cwd), - { - cwd, - filter, - socketConfig, - }, - ) -} diff --git a/packages/cli/src/util/git/github-provider.mts b/packages/cli/src/util/git/github-provider.mts deleted file mode 100644 index 6eccab5db..000000000 --- a/packages/cli/src/util/git/github-provider.mts +++ /dev/null @@ -1,317 +0,0 @@ -import { UNKNOWN_VALUE } from '@socketsecurity/lib-stable/constants/sentinels' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { - cacheFetch, - getOctokit, - getOctokitGraphql, - withGitHubRetry, -} from './github.mts' -import { gitDeleteRemoteBranch } from './operations.mts' -import { - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, -} from '../../constants/github.mts' -import { formatErrorWithDetail } from '../error/errors.mts' - -import type { - AddCommentOptions, - CreatePrOptions, - ListPrsOptions, - PrMatch, - PrProvider, - PrResponse, - UpdatePrOptions, -} from './provider.mts' - -type GqlPrNode = { - author?: - | { - login: string - } - | undefined - baseRefName: string - headRefName: string - mergeStateStatus: - | 'BEHIND' - | 'BLOCKED' - | 'CLEAN' - | 'DIRTY' - | 'DRAFT' - | 'HAS_HOOKS' - | 'UNKNOWN' - | 'UNSTABLE' - number: number - state: 'OPEN' | 'CLOSED' | 'MERGED' - title: string -} - -type GqlPullRequestsResponse = { - repository: { - pullRequests: { - pageInfo: { - endCursor: string | undefined - hasNextPage: boolean - } - nodes: GqlPrNode[] - } - } -} - -/** - * GitHub provider for Pull Request operations. - * - * Implements the PrProvider interface using GitHub's REST and GraphQL APIs via - * Octokit. - */ -export class GitHubProvider implements PrProvider { - async createPr(options: CreatePrOptions): Promise<PrResponse> { - const { base, body, head, owner, repo, retries = 3, title } = options - - const octokit = getOctokit() - const octokitPullsCreateParams = { base, body, head, owner, repo, title } - debugDir({ octokitPullsCreateParams }) - - const result = await withGitHubRetry( - async () => { - const response = await octokit.pulls.create(octokitPullsCreateParams) - return response - }, - `creating pull request for ${owner}/${repo}`, - retries, - ) - - if (!result.ok) { - throw new Error(result.cause ?? result.message) - } - - const response = result.data - return { - number: response.data.number, - state: response.data.merged_at - ? 'merged' - : response.data.state === 'closed' - ? 'closed' - : 'open', - url: response.data.html_url, - } - } - - async updatePr(options: UpdatePrOptions): Promise<void> { - const { base, head, owner, prNumber, repo } = options - - const octokit = getOctokit() - - // Merge the base branch into the head branch to update the PR. - const mergeResult = await withGitHubRetry( - () => - octokit.repos.merge({ - // The target branch (source). - head: base, - owner, - repo, - // The PR branch (destination). - base: head, - }), - `updating PR #${prNumber}`, - ) - - if (!mergeResult.ok) { - throw new Error(mergeResult.cause || mergeResult.message) - } - - debug(`pr: updating stale PR #${prNumber}`) - - // Check if update resulted in conflicts. - const prDetailsResult = await withGitHubRetry( - () => - octokit.pulls.get({ - owner, - pull_number: prNumber, - repo, - }), - `fetching PR #${prNumber} details`, - ) - - if (!prDetailsResult.ok) { - throw new Error(prDetailsResult.cause || prDetailsResult.message) - } - - if (prDetailsResult.data.data.mergeable_state === 'dirty') { - debug(`pr: PR #${prNumber} has conflicts after update`) - - // Add comment explaining conflict. - const commentResult = await withGitHubRetry( - () => - octokit.issues.createComment({ - body: - 'This PR has merge conflicts after updating from the base branch. ' + - 'Please resolve conflicts manually or close this PR and re-run `socket fix` ' + - 'to generate a new fix.', - issue_number: prNumber, - owner, - repo, - }), - `adding conflict comment to PR #${prNumber}`, - ) - - if (commentResult.ok) { - debug(`pr: added conflict comment to PR #${prNumber}`) - } - } - } - - async listPrs(options: ListPrsOptions): Promise<PrMatch[]> { - const { author, ghsaId, owner, repo, states: statesValue = 'all' } = options - const checkAuthor = isNonEmptyString(author) - const octokitGraphql = getOctokitGraphql() - const matches: PrMatch[] = [] - const states = ( - typeof statesValue === 'string' - ? statesValue.toLowerCase() === 'all' - ? [GQL_PR_STATE_OPEN, GQL_PR_STATE_CLOSED, GQL_PR_STATE_MERGED] - : [statesValue] - : [statesValue] - ).map(s => s.toUpperCase()) - - try { - let cursor: string | undefined = undefined - let hasNextPage = true - let pageIndex = 0 - // Include owner in cache key to avoid collisions with same repo name. - const gqlCacheKey = `${owner}::${repo}-pr-graphql-snapshot-${states.join('-').toLowerCase()}` - while (hasNextPage) { - const gqlResp = (await cacheFetch( - `${gqlCacheKey}-page-${pageIndex}`, - /* c8 ignore start - cacheFetch factory only fires on cache miss; tests pass mocked cached values directly */ - () => - octokitGraphql( - ` - query($owner: String!, $repo: String!, $states: [PullRequestState!], $after: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: 100, states: $states, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { - pageInfo { - hasNextPage - endCursor - } - nodes { - author { - login - } - baseRefName - headRefName - mergeStateStatus - number - state - title - } - } - } - } - `, - { - after: cursor, - owner, - repo, - states, - }, - ), - /* c8 ignore stop */ - )) as GqlPullRequestsResponse - - const { nodes, pageInfo } = gqlResp?.repository?.pullRequests ?? { - nodes: [], - pageInfo: { endCursor: undefined, hasNextPage: false }, - } - - for (let i = 0, { length } = nodes; i < length; i += 1) { - const node = nodes[i]! - const login = node.author?.login - const matchesAuthor = checkAuthor ? login === author : true - // Note: Branch pattern matching removed - caller should filter. - if (matchesAuthor) { - matches.push({ - ...node, - author: login ?? UNKNOWN_VALUE, - }) - } - } - - // Continue to next page. - hasNextPage = pageInfo.hasNextPage - cursor = pageInfo.endCursor - pageIndex += 1 - - /* c8 ignore start - GQL_PAGE_SENTINEL safety limit; tests page through at most a few pages */ - if (pageIndex === GQL_PAGE_SENTINEL) { - debug( - `GraphQL pagination reached safety limit (${GQL_PAGE_SENTINEL} pages) for ${owner}/${repo}`, - ) - break - } - /* c8 ignore stop */ - - // Early exit optimization: if we found matches and only looking for specific GHSA, - // we can stop pagination since we likely found what we need. - if (matches.length > 0 && ghsaId) { - break - } - } - } catch (e) { - debug(`GraphQL pagination failed for ${owner}/${repo}`) - debugDir(e) - } - - return matches - } - - async deleteBranch(branch: string): Promise<boolean> { - try { - const success = await gitDeleteRemoteBranch(branch) - if (success) { - debug(`pr: deleted merged branch ${branch}`) - } else { - debug(`pr: failed to delete branch ${branch}`) - } - return success - } catch (e) { - // Don't treat this as a hard error - branch might already be deleted. - debug(formatErrorWithDetail(`pr: failed to delete branch ${branch}`, e)) - debugDir(e) - return false - } - } - - async addComment(options: AddCommentOptions): Promise<void> { - const { body, owner, prNumber, repo } = options - const octokit = getOctokit() - - const result = await withGitHubRetry( - () => - octokit.issues.createComment({ - body, - issue_number: prNumber, - owner, - repo, - }), - `adding comment to PR #${prNumber}`, - ) - - if (!result.ok) { - throw new Error(result.cause || result.message) - } - - debug(`pr: added comment to PR #${prNumber}`) - } - - getProviderName(): 'github' { - return 'github' - } - - supportsGraphQL(): boolean { - return true - } -} diff --git a/packages/cli/src/util/git/github.mts b/packages/cli/src/util/git/github.mts deleted file mode 100644 index 6956da9fe..000000000 --- a/packages/cli/src/util/git/github.mts +++ /dev/null @@ -1,663 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * GitHub utilities for Socket CLI. Provides GitHub API integration for - * repository operations and GHSA vulnerability data. - * - * Authentication: - * - * - GetGitHubToken: Retrieve GitHub token from env/git config - * - GetOctokit: Get authenticated Octokit instance - * - GetOctokitGraphql: Get authenticated GraphQL client - * - * Caching: - * - * - 5-minute TTL for API responses - * - Automatic cache invalidation - * - Persistent cache in node_modules/.cache - * - * GHSA Operations: - * - * - CacheFetch: Cache API responses with TTL - * - FetchGhsaDetails: Fetch GitHub Security Advisory details - * - GetGhsaUrl: Generate GHSA advisory URL - * - ReadCache/writeCache: Persistent cache operations - * - * Repository Operations: - * - * - GraphQL queries for complex operations - * - Integration with Octokit REST API - * - Support for GitHub Actions environment variables - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { - GraphqlResponseError, - graphql as OctokitGraphql, -} from '@octokit/graphql' -import { RequestError } from '@octokit/request-error' -import { Octokit } from '@octokit/rest' -import { LRUCache } from 'lru-cache' - -import { isDebugNs } from '@socketsecurity/lib-stable/debug/namespace' -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { isError } from '@socketsecurity/lib-stable/errors/predicates' -import { readJson } from '@socketsecurity/lib-stable/fs/read-json' -import { safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { writeJson } from '@socketsecurity/lib-stable/fs/write-json' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { parseUrl } from '@socketsecurity/lib-stable/url/parse' - -import { DISABLE_GITHUB_CACHE } from '../../env/disable-github-cache.mts' -import { GITHUB_API_URL } from '../../env/github-api-url.mts' -import { GITHUB_SERVER_URL } from '../../env/github-server-url.mts' -import { SOCKET_CLI_GITHUB_TOKEN } from '../../env/socket-cli-github-token.mts' -import { getGithubCachePath } from '../../constants/paths.mts' -import { formatErrorWithDetail } from '../error/errors.mts' - -import type { CResult } from '../../types.mts' -import type { components } from '@octokit/openapi-types' -import type { JsonContent } from '@socketsecurity/lib-stable/fs/types' -import type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' - -export type Pr = components['schemas']['pull-request'] - -// Canonical `message` values returned by `handleGitHubApiError` / -// `handleGraphqlError`. Exported so callers can short-circuit on -// blocking conditions without matching free-form strings. -export const GITHUB_ERR_ABUSE_DETECTION = 'GitHub abuse detection triggered' -export const GITHUB_ERR_AUTH_FAILED = 'GitHub authentication failed' -export const GITHUB_ERR_GRAPHQL_RATE_LIMIT = - 'GitHub GraphQL rate limit exceeded' -export const GITHUB_ERR_RATE_LIMIT = 'GitHub rate limit exceeded' - -interface CacheEntry { - timestamp: number - data: JsonContent -} - -// In-memory promise cache to prevent concurrent fetches for the same key. -// LRU cache with max size to prevent unbounded memory growth. -const inflightRequests = new LRUCache<string, Promise<unknown>>({ max: 100 }) - -let octokit: Octokit | undefined - -let octokitGraphql: typeof OctokitGraphql | undefined - -export async function cacheFetch<T>( - key: string, - fetcher: () => Promise<T>, - ttlMs?: number | undefined, -): Promise<T> { - /* c8 ignore start - DISABLE_GITHUB_CACHE not set in tests */ - if (DISABLE_GITHUB_CACHE) { - return await fetcher() - } - /* c8 ignore stop */ - - // Check if already fetching this key to prevent TOCTOU race. - const inflight = inflightRequests.get(key) - /* c8 ignore start - inflight cache hit requires concurrent calls; tests run serially */ - if (inflight) { - return inflight as Promise<T> - } - /* c8 ignore stop */ - - try { - let data = (await readCache(key, ttlMs)) as T - if (!data) { - // Re-check inflight after async readCache to prevent race. - const inflightAfterRead = inflightRequests.get(key) - if (inflightAfterRead) { - return inflightAfterRead as Promise<T> - } - - const fetchPromise = (async () => { - try { - const result = await fetcher() - await writeCache(key, result as JsonContent) - return result - } finally { - inflightRequests.delete(key) - } - })() - - inflightRequests.set(key, fetchPromise) - data = await fetchPromise - } - return data - } catch (e) { - // Fetch promise's finally block handles cleanup - no action needed here. - throw e - } -} - -export type PrAutoMergeState = { - enabled: boolean - details?: string[] | undefined -} - -export async function enablePrAutoMerge({ - node_id: prId, -}: Pr): Promise<PrAutoMergeState> { - const octokitGraphql = getOctokitGraphql() - try { - const gqlResp = await octokitGraphql( - ` - mutation EnableAutoMerge($pullRequestId: ID!) { - enablePullRequestAutoMerge(input: { - pullRequestId: $pullRequestId, - mergeMethod: SQUASH - }) { - pullRequest { - number - } - } - }`, - { pullRequestId: prId }, - ) - const respPrNumber = ( - gqlResp as - | { - enablePullRequestAutoMerge?: - | { - pullRequest?: { number?: number | undefined } | undefined - } - | undefined - } - | undefined - )?.enablePullRequestAutoMerge?.pullRequest?.number - /* c8 ignore start - GraphQL success path requires a successful enablePullRequestAutoMerge response; tests mock the call to fail */ - if (respPrNumber) { - return { enabled: true } - } - /* c8 ignore stop */ - } catch (e) { - /* c8 ignore start - GraphqlResponseError with structured .errors requires the GitHub GraphQL endpoint to respond with that exact shape; tests cover the generic catch path */ - if ( - e instanceof GraphqlResponseError && - Array.isArray(e.errors) && - e.errors.length - ) { - const details = e.errors.map(({ message: m }) => m.trim()) - return { enabled: false, details } - } - /* c8 ignore stop */ - } - return { enabled: false } -} - -export type GhsaDetails = { - ghsaId: string - cveId?: string | undefined - summary: string - severity: string - publishedAt: string - withdrawnAt?: string | undefined - references: Array<{ - url: string - }> - vulnerabilities: { - nodes: Array<{ - package: { - ecosystem: string - name: string - } - vulnerableVersionRange: string - }> - } -} - -export async function fetchGhsaDetails( - ids: string[], -): Promise<Map<string, GhsaDetails>> { - const results = new Map<string, GhsaDetails>() - if (!ids.length) { - return results - } - - const octokitGraphql = getOctokitGraphql() - try { - // Use '::' delimiter to avoid collisions (GHSA IDs contain hyphens). - const gqlCacheKey = `${ids.join('::')}-graphql-snapshot` - - const aliases = ids - .map( - (id, index) => - `advisory${index}: securityAdvisory(ghsaId: "${id}") { - ghsaId - summary - severity - publishedAt - withdrawnAt - vulnerabilities(first: 10) { - nodes { - package { - ecosystem - name - } - vulnerableVersionRange - } - } - }`, - ) - .join('\n') - - const gqlResp = await cacheFetch(gqlCacheKey, () => - octokitGraphql(` - query { - ${aliases} - } - `), - ) - - /* c8 ignore start - GQL response loop; cacheFetch wraps the call so the inner factory only fires on cache miss, which tests don't trigger */ - for (let i = 0, { length } = ids; i < length; i += 1) { - const id = ids[i]! - const advisoryKey = `advisory${i}` - const advisory = (gqlResp as Record<string, unknown> | undefined)?.[ - advisoryKey - ] as GhsaDetails | undefined - if (advisory?.ghsaId) { - results.set(id, advisory as GhsaDetails) - } else { - debugNs('notice', `miss: no advisory found for ${id}`) - } - } - /* c8 ignore stop */ - } catch (e) { - debugNs('error', formatErrorWithDetail('Failed to fetch GHSA details', e)) - debugDirNs('error', e) - } - - return results -} - -export function getOctokit(): Octokit { - if (octokit === undefined) { - if (!SOCKET_CLI_GITHUB_TOKEN) { - debugNs('notice', 'miss: SOCKET_CLI_GITHUB_TOKEN env var') - } - const octokitOptions = { - ...(SOCKET_CLI_GITHUB_TOKEN ? { auth: SOCKET_CLI_GITHUB_TOKEN } : {}), - ...(GITHUB_API_URL ? { baseUrl: GITHUB_API_URL } : {}), - } - debugDirNs('inspect', { octokitOptions }) - octokit = new Octokit(octokitOptions) - } - return octokit -} - -export function getOctokitGraphql(): typeof OctokitGraphql { - if (!octokitGraphql) { - if (!SOCKET_CLI_GITHUB_TOKEN) { - debugNs('notice', 'miss: SOCKET_CLI_GITHUB_TOKEN env var') - } - octokitGraphql = OctokitGraphql.defaults({ - headers: { - authorization: `token ${SOCKET_CLI_GITHUB_TOKEN}`, - }, - }) - } - return octokitGraphql -} - -/** - * Convert GitHub API errors to user-friendly CResult failures. Handles rate - * limits, authentication, and network errors with actionable messages. - */ -export function handleGitHubApiError( - e: unknown, - context: string, -): CResult<never> { - debugNs('error', formatErrorWithDetail(`GitHub API error: ${context}`, e)) - debugDirNs('error', e) - - if (e instanceof RequestError) { - const { status } = e - - // Abuse detection rate limit - check first since it's more specific than standard rate limit. - if (status === 403 && e.message.includes('secondary rate limit')) { - return { - ok: false, - message: GITHUB_ERR_ABUSE_DETECTION, - cause: - `GitHub abuse detection triggered while ${context}. ` + - 'This happens when making too many requests in a short period. ' + - 'Wait a few minutes before retrying.\n\n' + - 'To avoid this:\n' + - '- Reduce the number of concurrent operations\n' + - '- Add delays between bulk operations', - } - } - - // Standard rate limit errors (403 with rate limit message or 429). - if ( - status === 429 || - (status === 403 && e.message.includes('rate limit')) - ) { - const retryAfter = e.response?.headers?.['retry-after'] - const resetHeader = e.response?.headers?.['x-ratelimit-reset'] - let waitTime: number | undefined - - if (retryAfter) { - waitTime = Number.parseInt(String(retryAfter), 10) - if (Number.isNaN(waitTime) || waitTime < 0) { - waitTime = undefined - } - } else if (resetHeader) { - const resetTimestamp = Number.parseInt(String(resetHeader), 10) - if (!Number.isNaN(resetTimestamp)) { - waitTime = Math.max(0, resetTimestamp - Math.floor(Date.now() / 1000)) - } - } - - return { - ok: false, - message: GITHUB_ERR_RATE_LIMIT, - cause: - `GitHub API rate limit exceeded while ${context}. ` + - (waitTime - ? `Try again in ${waitTime} seconds.` - : 'Try again in a few minutes.') + - '\n\n' + - 'To increase your rate limit:\n' + - '- Set GITHUB_TOKEN environment variable with a valid token\n' + - '- In GitHub Actions, GITHUB_TOKEN is automatically available\n' + - '- Personal access tokens provide higher rate limits than unauthenticated requests', - } - } - - // Authentication errors. - if (status === 401) { - return { - ok: false, - message: GITHUB_ERR_AUTH_FAILED, - cause: - `GitHub authentication failed while ${context}. ` + - 'Your token may be invalid, expired, or missing required permissions.\n\n' + - 'To resolve:\n' + - '- Verify your GitHub token is valid and not expired\n' + - '- Set GITHUB_TOKEN environment variable\n' + - '- Ensure the token has required scopes (repo, read:org)', - } - } - - // Permission denied (valid token but insufficient permissions). - if (status === 403 && !e.message.includes('rate limit')) { - return { - ok: false, - message: 'GitHub permission denied', - cause: - `GitHub permission denied while ${context}. ` + - 'Your token does not have access to this resource.\n\n' + - 'Ensure your token has the required scopes:\n' + - '- repo: Full control of private repositories\n' + - '- read:org: Read org membership (for org repos)', - } - } - - // Not found errors. - if (status === 404) { - return { - ok: false, - message: 'GitHub resource not found', - cause: - `GitHub resource not found while ${context}. ` + - 'The repository, branch, or file may not exist, or you may not have access to it.\n\n' + - 'Verify:\n' + - '- The repository name and owner are correct\n' + - '- The branch exists\n' + - '- Your token has access to the repository', - } - } - - // Server errors (5xx). - if (status >= 500) { - return { - ok: false, - message: 'GitHub server error', - cause: - `GitHub server error (${status}) while ${context}. ` + - 'GitHub may be experiencing issues.\n\n' + - 'To resolve:\n' + - '- Check https://www.githubstatus.com for service status\n' + - '- Try again in a few moments', - } - } - - // Other request errors. - return { - ok: false, - message: `GitHub API error (${status})`, - cause: `GitHub API error while ${context}: ${e.message}`, - } - } - - // Network errors (ECONNREFUSED, ETIMEDOUT, etc.). - if (isError(e)) { - const code = (e as NodeJS.ErrnoException).code - if ( - code === 'ECONNREFUSED' || - code === 'ENOTFOUND' || - code === 'ETIMEDOUT' - ) { - return { - ok: false, - message: 'Network error connecting to GitHub', - cause: - `Network error while ${context}: ${e.message}\n\n` + - 'To resolve:\n' + - '- Check your internet connection\n' + - '- Verify GitHub API is accessible from your network\n' + - '- Check if a proxy or firewall is blocking the connection', - } - } - } - - // Generic fallback. - return { - ok: false, - message: 'GitHub API error', - cause: `Unexpected error while ${context}: ${errorMessage(e)}`, - } -} - -/** - * Convert GraphQL errors to user-friendly CResult failures. Handles rate limits - * and authentication errors with actionable messages. - */ -export function handleGraphqlError( - e: unknown, - context: string, -): CResult<never> { - debugNs('error', formatErrorWithDetail(`GraphQL error: ${context}`, e)) - debugDirNs('error', e) - - if (e instanceof GraphqlResponseError) { - const errorMessages = Array.isArray(e.errors) - ? e.errors.map(err => err.message).filter(Boolean) - : [] - - // Check for rate limit errors. - if (isGraphqlRateLimitError(e)) { - return { - ok: false, - message: GITHUB_ERR_GRAPHQL_RATE_LIMIT, - cause: - `GitHub GraphQL rate limit exceeded while ${context}. ` + - 'Try again in a few minutes.\n\n' + - 'To increase your rate limit:\n' + - '- Set GITHUB_TOKEN environment variable with a valid token\n' + - '- In GitHub Actions, GITHUB_TOKEN is automatically available', - } - } - - // Return the GraphQL error details. - return { - ok: false, - message: 'GitHub GraphQL error', - cause: - `GitHub GraphQL error while ${context}` + - (errorMessages.length ? `:\n- ${errorMessages.join('\n- ')}` : ''), - } - } - - // Fall back to REST error handler for non-GraphQL errors. - return handleGitHubApiError(e, context) -} - -/** - * Check if a GraphQL error is a rate limit error. - */ -export function isGraphqlRateLimitError(e: unknown): boolean { - if (e instanceof GraphqlResponseError && Array.isArray(e.errors)) { - return e.errors.some( - err => - err.type === 'RATE_LIMITED' || - err.message?.toLowerCase().includes('rate limit'), - ) - } - return false -} - -export async function readCache( - key: string, - // 5 minute in milliseconds time to live (TTL). - ttlMs = 5 * 60 * 1000, -): Promise<JsonContent | undefined> { - const githubCachePath = getGithubCachePath() - const cacheJsonPath = path.join(githubCachePath, `${key}.json`) - - try { - const entry = (await readJson(cacheJsonPath)) as CacheEntry | JsonContent - // Handle both new format (with timestamp) and legacy format (without). - if ( - entry && - typeof entry === 'object' && - 'timestamp' in entry && - 'data' in entry - ) { - const isExpired = Date.now() - (entry.timestamp as number) > ttlMs - /* c8 ignore start - cache fresh-hit + legacy-format branches; tests pre-populate cache files in only one format */ - if (!isExpired) { - return entry.data - } - } else { - // Legacy format without timestamp - treat as expired. - return undefined - } - /* c8 ignore stop */ - } catch { - return undefined - } - return undefined -} - -export async function setGitRemoteGithubRepoUrl( - owner: string, - repo: string, - token: string, - cwd = process.cwd(), -): Promise<boolean> { - const urlObj = parseUrl(GITHUB_SERVER_URL || '') - const host = urlObj?.host - /* c8 ignore start - GITHUB_SERVER_URL defaults to a parseable URL; only fires when env var is malformed */ - if (!host) { - debugNs('error', 'invalid: GITHUB_SERVER_URL env var') - debugDirNs('inspect', { GITHUB_SERVER_URL }) - return false - } - /* c8 ignore stop */ - const url = `https://x-access-token:${token}@${host}/${owner}/${repo}` - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebugNs('stdio') ? 'inherit' : 'ignore', - } - const quotedCmd = `\`git remote set-url origin ${url}\`` - debugNs('stdio', `spawn: ${quotedCmd}`) - try { - await spawn('git', ['remote', 'set-url', 'origin', url], stdioIgnoreOptions) - return true - /* c8 ignore start - git command failure path; tests run in real cwd with valid git */ - } catch (e) { - debugNs('error', `Git command failed: ${quotedCmd}`) - debugDirNs('inspect', { cmd: quotedCmd }) - debugDirNs('error', e) - } - return false - /* c8 ignore stop */ -} - -/** - * Execute a GitHub API call with retry logic for transient failures. Retries on - * 5xx errors and network failures with exponential backoff. - */ -export async function withGitHubRetry<T>( - operation: () => Promise<T>, - context: string, - maxRetries = 3, -): Promise<CResult<T>> { - let lastError: unknown - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const result = await operation() - return { ok: true, data: result } - } catch (e) { - lastError = e - debugNs( - 'notice', - `GitHub API attempt ${attempt}/${maxRetries} failed for ${context}`, - ) - debugDirNs('error', e) - - // Don't retry on client errors (4xx) except rate limits. - if (e instanceof RequestError) { - const { status } = e - // Rate limits: return immediately with helpful message. - if ( - status === 429 || - (status === 403 && e.message.includes('rate limit')) - ) { - return handleGitHubApiError(e, context) - } - // Don't retry other 4xx errors. - if (status >= 400 && status < 500) { - return handleGitHubApiError(e, context) - } - } - - // Retry on 5xx or network errors. - if (attempt < maxRetries) { - const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) - debugNs('notice', `Retrying in ${delay}ms...`) - await new Promise(resolve => setTimeout(resolve, delay)) - } - } - } - - return handleGitHubApiError(lastError, context) -} - -export async function writeCache( - key: string, - data: JsonContent, -): Promise<void> { - const githubCachePath = getGithubCachePath() - const cacheJsonPath = path.join(githubCachePath, `${key}.json`) - // Create directory with recursive flag that doesn't fail if exists. - await safeMkdir(githubCachePath, { recursive: true }) - - const entry: CacheEntry = { - timestamp: Date.now(), - data, - } - - // Use atomic write pattern to prevent multi-process race conditions. - const tmpPath = `${cacheJsonPath}.tmp.${process.pid}` - await writeJson(tmpPath, entry) - await fs.rename(tmpPath, cacheJsonPath) -} diff --git a/packages/cli/src/util/git/gitlab-provider.mts b/packages/cli/src/util/git/gitlab-provider.mts deleted file mode 100644 index 6009fe00c..000000000 --- a/packages/cli/src/util/git/gitlab-provider.mts +++ /dev/null @@ -1,344 +0,0 @@ -import { Gitlab } from '@gitbeaker/rest' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { isError } from '@socketsecurity/lib-stable/errors/predicates' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { formatErrorWithDetail } from '../error/errors.mts' - -import type { - AddCommentOptions, - CreatePrOptions, - ListPrsOptions, - MergeStateStatus, - PrMatch, - PrProvider, - PrResponse, - UpdatePrOptions, -} from './provider.mts' -import type { MergeRequestSchema } from '@gitbeaker/rest' - -/** - * GitLab provider for Merge Request operations. - * - * Implements the PrProvider interface using GitLab's REST API via - * @gitbeaker/rest. - */ -export class GitLabProvider implements PrProvider { - private gitlab: InstanceType<typeof Gitlab> - - constructor() { - const token = getGitLabToken() - const host = process.env['GITLAB_HOST'] || 'https://gitlab.com' - - this.gitlab = new Gitlab({ - host, - token, - }) - } - - async createPr(options: CreatePrOptions): Promise<PrResponse> { - const { base, body, head, owner, repo, retries = 3, title } = options - - // Get project ID from owner/repo. - const projectId = `${owner}/${repo}` - - for (let attempt = 1; attempt <= retries; attempt++) { - try { - debugDir({ attempt, base, head, projectId, title }) - const mr = (await this.gitlab.MergeRequests.create( - projectId, - head, - base, - title, - { - description: body, - }, - )) as MergeRequestSchema - - return { - number: mr.iid, - state: mapGitLabState(mr.state), - url: mr.web_url, - } - } catch (e) { - let message = `Failed to create merge request (attempt ${attempt}/${retries})` - if (isError(e)) { - message += `: ${e.message}` - } - - debug(message) - debugDir(e) - - // Don't retry on validation errors (400). - if ( - e && - typeof e === 'object' && - 'cause' in e && - e.cause && - typeof e.cause === 'object' && - 'response' in e.cause - ) { - const response = ( - e.cause as { - response?: { status?: number | undefined } | undefined - } - ).response - if (response?.status === 400) { - break - } - } - - // Retry on 5xx errors or network failures with exponential backoff. - if (attempt < retries) { - // Cap attempt to prevent overflow before Math.min. - const safeAttempt = Math.min(attempt, 14) - const delay = Math.min(1000 * 2 ** (safeAttempt - 1), 10_000) - debug(`mr: retrying in ${delay}ms...`) - await sleep(delay) - } - } - } - - throw new Error( - `GitLab API rejected createMergeRequest for ${owner}/${repo} (head="${head}") after ${retries} attempts with exponential backoff; check GITLAB_TOKEN permissions (needs api scope), that the target branch exists, and that GitLab is reachable`, - ) - } - - async updatePr(options: UpdatePrOptions): Promise<void> { - const { owner, prNumber, repo } = options - - const projectId = `${owner}/${repo}` - - try { - // GitLab doesn't have a direct "rebase" endpoint like GitHub's merge. - // The closest equivalent is to use the rebase API. - await this.gitlab.MergeRequests.rebase(projectId, prNumber) - debug(`mr: updating stale MR !${prNumber}`) - - // Check if rebase resulted in conflicts. - const mr = (await this.gitlab.MergeRequests.show( - projectId, - prNumber, - )) as MergeRequestSchema - - if (mr.merge_status === 'cannot_be_merged') { - debug(`mr: MR !${prNumber} has conflicts after rebase`) - - // Add comment explaining conflict. - await this.gitlab.MergeRequestNotes.create( - projectId, - prNumber, - 'This MR has merge conflicts after rebasing from the base branch. ' + - 'Please resolve conflicts manually or close this MR and re-run `socket fix` ' + - 'to generate a new fix.', - ) - - debug(`mr: added conflict comment to MR !${prNumber}`) - } - } catch (e) { - throw new Error( - formatErrorWithDetail(`Failed to update MR !${prNumber}`, e), - ) - } - } - - async listPrs(options: ListPrsOptions): Promise<PrMatch[]> { - const { author, ghsaId, owner, repo, states: statesValue = 'all' } = options - const checkAuthor = isNonEmptyString(author) - const matches: PrMatch[] = [] - const projectId = `${owner}/${repo}` - - // Map states to GitLab merge request states. - const state = - typeof statesValue === 'string' && statesValue.toLowerCase() !== 'all' - ? mapStateToGitLab(statesValue) - : undefined - - try { - let page = 1 - const perPage = 100 - let hasMore = true - - while (hasMore) { - const mrs = (await this.gitlab.MergeRequests.all({ - maxPages: 1, - page, - perPage, - projectId, - ...(state ? { state } : {}), - ...(checkAuthor ? { authorUsername: author } : {}), - })) as MergeRequestSchema[] - - for (let i = 0, { length } = mrs; i < length; i += 1) { - const mr = mrs[i]! - matches.push({ - author: mr.author.username, - baseRefName: mr.target_branch, - headRefName: mr.source_branch, - mergeStateStatus: mapGitLabMergeStatus(mr.merge_status), - number: mr.iid, - state: mapGitLabStateToUpper(mr.state), - title: mr.title, - }) - } - - // Continue to next page if we got a full page. - hasMore = mrs.length === perPage - page += 1 - - // Safety limit to prevent infinite loops. - /* c8 ignore start - 100-page safety limit; tests page through at most a few pages */ - if (page > 100) { - debug( - `REST pagination reached safety limit (100 pages) for ${owner}/${repo}`, - ) - break - } - /* c8 ignore stop */ - - // Early exit optimization: if we found matches and only looking for specific GHSA, - // we can stop pagination since we likely found what we need. - if (matches.length > 0 && ghsaId) { - break - } - } - } catch (e) { - debug(`REST pagination failed for ${owner}/${repo}`) - debugDir(e) - } - - return matches - } - - async deleteBranch(branch: string): Promise<boolean> { - try { - // GitLab requires project ID to delete a branch. - // Since we don't have it in this method, we can't delete. - // This is a limitation of the current interface design. - debug(`mr: cannot delete branch ${branch} - need project ID in interface`) - return false - /* c8 ignore start - try-body has no async/throwing ops, so this catch is unreachable */ - } catch (e) { - debug(formatErrorWithDetail(`mr: failed to delete branch ${branch}`, e)) - debugDir(e) - return false - } - /* c8 ignore stop */ - } - - async addComment(options: AddCommentOptions): Promise<void> { - const { body, owner, prNumber, repo } = options - const projectId = `${owner}/${repo}` - - try { - await this.gitlab.MergeRequestNotes.create(projectId, prNumber, body) - debug(`mr: added comment to MR !${prNumber}`) - } catch (e) { - throw new Error( - formatErrorWithDetail(`Failed to add comment to MR !${prNumber}`, e), - ) - } - } - - getProviderName(): 'gitlab' { - return 'gitlab' - } - - supportsGraphQL(): boolean { - return false - } -} - -/** - * Gets the GitLab API token from environment or git config. - * - * Priority: - * - * 1. GITLAB_TOKEN environment variable - * 2. Git config gitlab.token - * 3. Error if not found - */ -export function getGitLabToken(): string { - // Check environment variable. - const envToken = process.env['GITLAB_TOKEN'] - if (envToken) { - return envToken - } - - throw new Error( - `GitLab access requires a token but process.env.GITLAB_TOKEN is not set; create a personal access token with the \`api\` scope at https://gitlab.com/-/user_settings/personal_access_tokens and export GITLAB_TOKEN=<token>`, - ) -} - -/** - * Maps GitLab merge_status to common merge state status. - */ -export function mapGitLabMergeStatus(status: string): MergeStateStatus { - // GitLab merge_status values: - // - can_be_merged: clean, no conflicts - // - cannot_be_merged: has conflicts - // - unchecked: not yet checked - // - checking: currently checking - // - cannot_be_merged_recheck: needs recheck - switch (status) { - case 'can_be_merged': - return 'CLEAN' - case 'cannot_be_merged': - case 'cannot_be_merged_recheck': - return 'DIRTY' - case 'checking': - case 'unchecked': - return 'UNKNOWN' - default: - return 'UNKNOWN' - } -} - -/** - * Maps GitLab merge request state to common state. - */ -export function mapGitLabState(state: string): 'open' | 'closed' | 'merged' { - if (state === 'opened') { - return 'open' - } - if (state === 'merged') { - return 'merged' - } - return 'closed' -} - -/** - * Maps GitLab merge request state to uppercase common state. - */ -export function mapGitLabStateToUpper( - state: string, -): 'OPEN' | 'CLOSED' | 'MERGED' { - if (state === 'opened') { - return 'OPEN' - } - if (state === 'merged') { - return 'MERGED' - } - return 'CLOSED' -} - -/** - * Maps common state to GitLab state. - */ -export function mapStateToGitLab( - state: string, -): 'opened' | 'closed' | 'merged' { - const lower = state.toLowerCase() - if (lower === 'open') { - return 'opened' - } - if (lower === 'merged') { - return 'merged' - } - return 'closed' -} - -export async function sleep(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)) -} diff --git a/packages/cli/src/util/git/operations.mts b/packages/cli/src/util/git/operations.mts deleted file mode 100644 index 8ce15d021..000000000 --- a/packages/cli/src/util/git/operations.mts +++ /dev/null @@ -1,585 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Git utilities for Socket CLI. Provides git operations for repository - * management, branch handling, and commits. - * - * Branch Operations: - gitCheckoutBranch: Switch to branch - gitCreateBranch: - * Create new local branch - gitDeleteBranch: Delete local branch - - * gitDeleteRemoteBranch: Delete remote branch - gitPushBranch: Push branch to - * remote with --force. - * - * Commit Operations: - gitCleanFdx: Remove untracked files - gitCommit: Stage - * files and create commit - gitEnsureIdentity: Configure git user.name/email - - * gitResetHard: Reset to branch/commit. - * - * Remote URL Parsing: - parseGitRemoteUrl: Extract owner/repo from SSH or HTTPS - * URLs. - * - * Repository Information: - detectDefaultBranch: Find default branch - * (main/master/develop/etc — inclusive-language: external-api) - getBaseBranch: - * Determine base branch (respects GitHub Actions env) - getRepoInfo: Extract - * owner/repo from git remote URL - gitBranch: Get current branch or commit - * hash. - */ - -import { whichReal } from '@socketsecurity/lib-stable/bin/which' -import { isDebug } from '@socketsecurity/lib-stable/debug/namespace' -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { - getGithubBaseRef, - getGithubRefName, - getGithubRefType, -} from '@socketsecurity/lib-stable/env/github' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { isSpawnError } from '@socketsecurity/lib-stable/process/spawn/errors' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { FLAG_QUIET } from '../../constants/cli.mts' -import { SOCKET_CLI_GIT_USER_EMAIL } from '../../env/socket-cli-git-user-email.mts' -import { SOCKET_CLI_GIT_USER_NAME } from '../../env/socket-cli-git-user-name.mts' -import { - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, -} from '../../constants/socket.mts' -import { debugGit } from '../debug.mts' -import { extractName, extractOwner } from '../sanitize-names.mts' - -import type { CResult } from '../../types.mjs' -import type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' - -// Cache git executable path -let gitPath: string | undefined = undefined - -// Listed in order of check preference. -const COMMON_DEFAULT_BRANCH_NAMES = [ - // Modern default (GitHub, GitLab, Bitbucket have switched to this). - 'main', - // inclusive-language: external-api — git's historical default branch. - 'master', - // Common in Git Flow workflows (main for stable, develop for ongoing work). - 'develop', - // Used by teams adopting trunk-based development practices. - 'trunk', - // Used in some older enterprise setups and tools. - 'default', -] - -const parsedGitRemoteUrlCache = new Map<string, RepoInfo | undefined>() - -/** - * Try to detect the default branch name by checking common patterns. Returns - * the first branch that exists in the repository. - */ -export async function detectDefaultBranch( - cwd = process.cwd(), -): Promise<string> { - // First pass: check all local branches - for ( - let i = 0, { length } = COMMON_DEFAULT_BRANCH_NAMES; - i < length; - i += 1 - ) { - const branch = COMMON_DEFAULT_BRANCH_NAMES[i]! - if (await gitLocalBranchExists(branch, cwd)) { - return branch - } - } - // Second pass: check remote branches only if no local branch found - for ( - let i = 0, { length } = COMMON_DEFAULT_BRANCH_NAMES; - i < length; - i += 1 - ) { - const branch = COMMON_DEFAULT_BRANCH_NAMES[i]! - if (await gitRemoteBranchExists(branch, cwd)) { - return branch - } - } - return SOCKET_DEFAULT_BRANCH -} - -export async function getBaseBranch(cwd = process.cwd()): Promise<string> { - // 1. In a pull request, this is always the base branch. - const githubBaseRef = getGithubBaseRef() - if (githubBaseRef) { - return githubBaseRef - } - // 2. If it's a branch (not a tag), GITHUB_REF_TYPE should be 'branch'. - const githubRefType = getGithubRefType() - const githubRefName = getGithubRefName() - if (githubRefType === 'branch' && githubRefName) { - return githubRefName - } - // 3. Try to resolve the default remote branch using 'git remote show origin'. - // This handles detached HEADs or workflows triggered by tags/releases. - try { - const gitPath = await getGitPath() - const result = await spawn(gitPath, ['remote', 'show', 'origin'], { cwd }) - - if (!result) { - return 'main' - } - - const originDetails = - result.stdout - - const match = /(?<=HEAD branch: ).+/.exec(originDetails) - if (match && match.length > 0 && match[0]) { - return match[0].trim() - } - } catch {} - // GitHub and GitLab default to branch name "main" - // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches#about-the-default-branch - return 'main' -} - -export async function getGitPath(): Promise<string> { - if (!gitPath) { - const result = await whichReal('git', { nothrow: true }) - if (!result || Array.isArray(result)) { - throw new Error( - `git executable not found on PATH (whichReal returned ${Array.isArray(result) ? 'multiple matches' : 'null'}); install git (e.g. \`brew install git\`, \`apt install git\`) and make sure it is reachable on PATH`, - ) - } - gitPath = result - } - return gitPath -} - -export type RepoInfo = { - owner: string - repo: string -} - -export async function getRepoInfo( - cwd = process.cwd(), -): Promise<RepoInfo | undefined> { - let info: RepoInfo | undefined - try { - const gitPath = await getGitPath() - const result = await spawn(gitPath, ['remote', 'get-url', 'origin'], { - cwd, - }) - - if (!result) { - return undefined - } - - const remoteUrl = - result.stdout - info = parseGitRemoteUrl(remoteUrl) - if (!info) { - debug(`Unmatched git remote URL format: ${remoteUrl}`) - debugDir({ remoteUrl }) - } - } catch (e) { - // Expected failure when not in a git repo. - debugDir({ message: 'git remote get-url failed', error: e }) - } - return info -} - -export async function getRepoName(cwd = process.cwd()): Promise<string> { - const repoInfo = await getRepoInfo(cwd) - return repoInfo?.repo ? extractName(repoInfo.repo) : SOCKET_DEFAULT_REPOSITORY -} - -export async function getRepoOwner( - cwd = process.cwd(), -): Promise<string | undefined> { - const repoInfo = await getRepoInfo(cwd) - return repoInfo?.owner ? extractOwner(repoInfo.owner) : undefined -} - -export async function gitBranch( - cwd = process.cwd(), -): Promise<string | undefined> { - const stdioPipeOptions: SpawnOptions = { cwd } - // Try symbolic-ref first which returns the branch name or fails in a - // detached HEAD state. - try { - const gitSymbolicRefResult = await spawn( - 'git', - ['symbolic-ref', '--short', 'HEAD'], - stdioPipeOptions, - ) - return gitSymbolicRefResult.stdout as string - } catch (e) { - // Expected in detached HEAD state, fallback to rev-parse. - debugDir({ message: 'In detached HEAD state', error: e }) - } - // Fallback to using rev-parse to get the short commit hash in a - // detached HEAD state. - try { - const gitRevParseResult = await spawn( - 'git', - ['rev-parse', '--short', 'HEAD'], - stdioPipeOptions, - ) - return gitRevParseResult.stdout as string - } catch (e) { - // Both methods failed, likely not in a git repo. - debugDir({ message: 'Unable to determine git branch', error: e }) - } - return undefined -} - -export async function gitCheckoutBranch( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['checkout', branch], stdioIgnoreOptions) - debugGit(`checkout ${branch}`, true) - return true - } catch (e) { - debugGit(`checkout ${branch}`, false, { error: e }) - } - return false -} - -type GitCreateAndPushBranchOptions = { - cwd?: string | undefined - email?: string | undefined - user?: string | undefined -} - -export async function gitCleanFdx(cwd = process.cwd()): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['clean', '-fdx'], stdioIgnoreOptions) - debugGit('clean -fdx', true) - return true - } catch (e) { - debugGit('clean -fdx', false, { error: e }) - } - return false -} - -export async function gitCommit( - commitMsg: string, - filepaths: string[], - options?: GitCreateAndPushBranchOptions | undefined, -): Promise<boolean> { - if (!filepaths.length) { - debug('miss: no filepaths to add') - return false - } - const { - cwd = process.cwd(), - email = SOCKET_CLI_GIT_USER_EMAIL, - user = SOCKET_CLI_GIT_USER_NAME, - } = { __proto__: null, ...options } as GitCreateAndPushBranchOptions - - await gitEnsureIdentity(user || '', email || '', cwd) - - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['add', ...filepaths], stdioIgnoreOptions) - debugGit('add', true, { count: filepaths.length }) - } catch (e) { - debugGit('add', false, { error: e }) - debugDir({ filepaths }) - return false - } - - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['commit', '-m', commitMsg], stdioIgnoreOptions) - debugGit('commit', true) - return true - } catch (e) { - debugGit('commit', false, { error: e }) - debugDir({ commitMsg }) - } - return false -} - -export async function gitCreateBranch( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - if (await gitLocalBranchExists(branch)) { - return true - } - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['branch', branch], stdioIgnoreOptions) - debugGit(`branch ${branch}`, true) - return true - } catch (e) { - debugGit(`branch ${branch}`, false, { error: e }) - } - return false -} - -export async function gitDeleteBranch( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - // Will throw with exit code 1 if branch does not exist. - const gitPath = await getGitPath() - await spawn(gitPath, ['branch', '-D', branch], stdioIgnoreOptions) - return true - } catch (e) { - // Expected failure when branch doesn't exist. - debugDir({ - message: `Branch deletion failed (may not exist): ${branch}`, - error: e, - }) - } - return false -} - -export async function gitDeleteRemoteBranch( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - // Will throw with exit code 1 if branch does not exist. - await spawn( - 'git', - ['push', 'origin', '--delete', branch], - stdioIgnoreOptions, - ) - return true - } catch (e) { - // Expected failure when remote branch doesn't exist. - debugDir({ - message: `Remote branch deletion failed (may not exist): ${branch}`, - error: e, - }) - } - return false -} - -export async function gitEnsureIdentity( - name: string, - email: string, - cwd = process.cwd(), -): Promise<void> { - const stdioPipeOptions: SpawnOptions = { cwd } - const identEntries: Array<[string, string]> = [ - ['user.email', email], - ['user.name', name], - ] - await Promise.allSettled( - identEntries.map(async ({ 0: prop, 1: value }) => { - let configValue: string | Buffer | undefined - try { - // Will throw with exit code 1 if the config property is not set. - const gitConfigResult = await spawn( - 'git', - ['config', '--get', prop], - stdioPipeOptions, - ) - configValue = gitConfigResult.stdout - } catch (e) { - // Expected when config property is not set. - debugDir({ - message: `Git config property not set: ${prop}`, - error: e, - }) - } - if (configValue !== value) { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['config', prop, value], stdioIgnoreOptions) - } catch (e) { - debug(`Failed to set git config: ${prop}`) - debugDir(e) - debugDir({ value }) - } - } - }), - ) -} - -export async function gitLocalBranchExists( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - // Will throw with exit code 1 if the branch does not exist. - await spawn( - 'git', - ['show-ref', FLAG_QUIET, `refs/heads/${branch}`], - stdioIgnoreOptions, - ) - return true - } catch { - // Expected when branch doesn't exist - no logging needed. - } - return false -} - -export async function gitPushBranch( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - await spawn( - 'git', - ['push', '--force', '--set-upstream', 'origin', branch], - stdioIgnoreOptions, - ) - debugGit(`push ${branch}`, true) - return true - } catch (e) { - if (isSpawnError(e) && e.code === 128) { - debug( - "Push denied: token requires write permissions for 'contents' and 'pull-requests'", - ) - debugDir(e) - debugDir({ branch }) - } else { - debugGit(`push ${branch}`, false, { error: e }) - } - } - return false -} - -export async function gitRemoteBranchExists( - branch: string, - cwd = process.cwd(), -): Promise<boolean> { - const stdioPipeOptions: SpawnOptions = { cwd } - try { - const lsRemoteResult = await spawn( - 'git', - ['ls-remote', '--heads', 'origin', branch], - stdioPipeOptions, - ) - return lsRemoteResult.stdout.length > 0 - } catch (e) { - // Expected when remote is not accessible or branch doesn't exist. - debugDir({ - message: `Remote branch check failed: ${branch}`, - error: e, - }) - } - return false -} - -export async function gitResetAndClean( - branch = 'HEAD', - cwd = process.cwd(), -): Promise<void> { - // Discards tracked changes. - await gitResetHard(branch, cwd) - // Deletes all untracked files and directories. - await gitCleanFdx(cwd) -} - -export async function gitResetHard( - branch = 'HEAD', - cwd = process.cwd(), -): Promise<boolean> { - const stdioIgnoreOptions: SpawnOptions = { - cwd, - stdio: isDebug() ? 'inherit' : 'ignore', - } - try { - const gitPath = await getGitPath() - await spawn(gitPath, ['reset', '--hard', branch], stdioIgnoreOptions) - debugGit(`reset --hard ${branch}`, true) - return true - } catch (e) { - debugGit(`reset --hard ${branch}`, false, { error: e }) - } - return false -} - -export async function gitUnstagedModifiedFiles( - cwd = process.cwd(), -): Promise<CResult<string[]>> { - const stdioPipeOptions: SpawnOptions = { cwd } - try { - const gitDiffResult = await spawn( - 'git', - ['diff', '--name-only'], - stdioPipeOptions, - ) - const changedFilesDetails = gitDiffResult.stdout as string - const relPaths = changedFilesDetails.split('\n') - return { - ok: true, - data: relPaths.map((p: string) => normalizePath(p)), - } - } catch (e) { - debug('Failed to get unstaged modified files') - debugDir(e) - return { - ok: false, - message: 'Git Error', - cause: 'Unexpected error while trying to ask git whether repo is dirty', - } - } -} - -export function parseGitRemoteUrl(remoteUrl: string): RepoInfo | undefined { - let result = parsedGitRemoteUrlCache.get(remoteUrl) - if (result) { - return { ...result } - } - // Handle SSH-style - const sshMatch = /^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/.exec(remoteUrl) - // 1. Handle SSH-style, e.g. git@github.com:owner/repo.git - if (sshMatch) { - result = { owner: sshMatch[1]!, repo: sshMatch[2]! } - } else { - // 2. Handle HTTPS/URL-style, e.g. https://github.com/owner/repo.git - try { - const parsed = new URL(remoteUrl) - // Remove leading slashes from pathname and split by "/" to extract segments. - const segments = parsed.pathname.replace(/^\/+/, '').split('/') - // The second-to-last segment is expected to be the owner (e.g., "owner" in /owner/repo.git). - const owner = segments.at(-2) - // The last segment is expected to be the repo name, so we remove the ".git" suffix if present. - const repo = segments.at(-1)?.replace(/\.git$/, '') - if (owner && repo) { - result = { owner, repo } - } - } catch {} - } - parsedGitRemoteUrlCache.set(remoteUrl, result) - return result ? { ...result } : result -} diff --git a/packages/cli/src/util/git/provider-factory.mts b/packages/cli/src/util/git/provider-factory.mts deleted file mode 100644 index b93c3072f..000000000 --- a/packages/cli/src/util/git/provider-factory.mts +++ /dev/null @@ -1,52 +0,0 @@ -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { GitHubProvider } from './github-provider.mts' -import { GitLabProvider } from './gitlab-provider.mts' - -import type { PrProvider } from './provider.mts' - -/** - * Creates a PR provider instance based on the git remote URL. - * - * Auto-detects GitHub vs GitLab based on the remote origin URL. Falls back to - * GitHub for backward compatibility. - */ -export function createPrProvider(): PrProvider { - const remoteUrl = getGitRemoteUrlSync() - - // Check for GitLab. - if ( - remoteUrl.includes('gitlab.com') || - process.env['GITLAB_HOST'] || - remoteUrl.includes('gitlab') - ) { - return new GitLabProvider() - } - - // Default to GitHub (backward compatibility). - return new GitHubProvider() -} - -/** - * Gets the git remote origin URL synchronously. - * - * Uses `git config` to read the remote.origin.url setting. Exported for testing - * purposes. - */ -export function getGitRemoteUrlSync(): string { - try { - const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { - stdio: ['pipe', 'pipe', 'pipe'], - }) - - if (result.status === 0 && result.stdout) { - const remoteUrl = - result.stdout - return remoteUrl.trim().toLowerCase() - } - } catch { - // Ignore errors - will fall back to GitHub. - } - - return '' -} diff --git a/packages/cli/src/util/git/provider.mts b/packages/cli/src/util/git/provider.mts deleted file mode 100644 index 2b0a66dc7..000000000 --- a/packages/cli/src/util/git/provider.mts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Provider interface for Pull Request / Merge Request operations. - * - * This abstraction allows Socket CLI to work with both GitHub (Pull Requests) - * and GitLab (Merge Requests) using a unified interface. - */ - -export interface PrProvider { - // Core operations. - createPr(options: CreatePrOptions): Promise<PrResponse> - updatePr(options: UpdatePrOptions): Promise<void> - listPrs(options: ListPrsOptions): Promise<PrMatch[]> - deleteBranch(branch: string): Promise<boolean> - addComment(options: AddCommentOptions): Promise<void> - - // Metadata. - getProviderName(): 'github' | 'gitlab' - supportsGraphQL(): boolean -} - -export interface CreatePrOptions { - owner: string - repo: string - title: string - head: string - base: string - body: string - retries?: number | undefined -} - -export interface UpdatePrOptions { - owner: string - repo: string - prNumber: number - head: string - base: string -} - -export interface AddCommentOptions { - owner: string - repo: string - prNumber: number - body: string -} - -export interface ListPrsOptions { - owner: string - repo: string - author?: string | undefined - states?: 'all' | 'open' | 'closed' | undefined - ghsaId?: string | undefined -} - -export interface PrResponse { - number: number - url: string - state: 'open' | 'closed' | 'merged' -} - -export interface PrMatch { - number: number - title: string - author: string - headRefName: string - baseRefName: string - state: 'OPEN' | 'CLOSED' | 'MERGED' - mergeStateStatus: MergeStateStatus -} - -export type MergeStateStatus = - | 'BEHIND' - | 'BLOCKED' - | 'CLEAN' - | 'DIRTY' - | 'DRAFT' - | 'HAS_HOOKS' - | 'UNKNOWN' - | 'UNSTABLE' diff --git a/packages/cli/src/util/ipc.mts b/packages/cli/src/util/ipc.mts deleted file mode 100644 index 7a55822ae..000000000 --- a/packages/cli/src/util/ipc.mts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * IPC types for subprocess communication. Used as the typed shape of the - * `ipc` field in spawn options when launching child Socket CLI processes. - */ - -// IpcObject type for subprocess IPC data. -export type IpcObject = Readonly<{ - SOCKET_CLI_FIX?: string | undefined - SOCKET_CLI_OPTIMIZE?: boolean | undefined -}> diff --git a/packages/cli/src/util/npm/package-arg.mts b/packages/cli/src/util/npm/package-arg.mts deleted file mode 100644 index 8c098f2ea..000000000 --- a/packages/cli/src/util/npm/package-arg.mts +++ /dev/null @@ -1,24 +0,0 @@ -import npmPackageArg from 'npm-package-arg' - -export type { - AliasResult, - FileResult, - HostedGit, - HostedGitResult, - RegistryResult, - Result, - URLResult, -} from 'npm-package-arg' - -/** - * Safe wrapper for npm-package-arg that doesn't throw. Returns undefined if - * parsing fails. - */ -export function safeNpa( - ...args: Parameters<typeof npmPackageArg> -): ReturnType<typeof npmPackageArg> | undefined { - try { - return Reflect.apply(npmPackageArg, undefined, args) - } catch {} - return undefined -} diff --git a/packages/cli/src/util/npm/paths.mts b/packages/cli/src/util/npm/paths.mts deleted file mode 100755 index 07c8cc3ea..000000000 --- a/packages/cli/src/util/npm/paths.mts +++ /dev/null @@ -1,60 +0,0 @@ -import { NPM } from '@socketsecurity/lib-stable/constants/agents' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { findBinPathDetailsSync } from '../fs/path-resolve.mts' - -const logger = getDefaultLogger() - -export function exitWithBinPathError(binName: string): never { - logger.fail( - `Socket unable to locate ${binName}; ensure it is available in the PATH environment variable`, - ) - // The exit code 127 indicates that the command or binary being executed - // could not be found. - process.exit(127) - // This line is never reached in production, but helps tests. - throw new Error('process.exit called') -} - -let npmBinPath: string | undefined -export function getNpmBinPath(): string { - if (npmBinPath === undefined) { - npmBinPath = getNpmBinPathDetails().path - if (!npmBinPath) { - exitWithBinPathError(NPM) - } - } - return npmBinPath -} - -let npmBinPathDetails: ReturnType<typeof findBinPathDetailsSync> | undefined -export function getNpmBinPathDetails(): ReturnType< - typeof findBinPathDetailsSync -> { - if (npmBinPathDetails === undefined) { - npmBinPathDetails = findBinPathDetailsSync(NPM) - } - return npmBinPathDetails -} - - -let npxBinPath: string | undefined -export function getNpxBinPath(): string { - if (npxBinPath === undefined) { - npxBinPath = getNpxBinPathDetails().path - if (!npxBinPath) { - exitWithBinPathError('npx') - } - } - return npxBinPath -} - -let npxBinPathDetails: ReturnType<typeof findBinPathDetailsSync> | undefined -export function getNpxBinPathDetails(): ReturnType< - typeof findBinPathDetailsSync -> { - if (npxBinPathDetails === undefined) { - npxBinPathDetails = findBinPathDetailsSync('npx') - } - return npxBinPathDetails -} diff --git a/packages/cli/src/util/organization.mts b/packages/cli/src/util/organization.mts deleted file mode 100644 index b1374a223..000000000 --- a/packages/cli/src/util/organization.mts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - EnterpriseOrganizations, - Organizations, -} from '../commands/organization/fetch-organization-list.mts' - -export function getEnterpriseOrgs( - orgs: Organizations, -): EnterpriseOrganizations { - return orgs.filter(o => - o.plan.includes('enterprise'), - ) as EnterpriseOrganizations -} - -export function getOrgSlugs(orgs: Organizations): string[] { - return orgs.map(o => o.slug) -} - -export function hasEnterpriseOrgPlan(orgs: Organizations): boolean { - return orgs.some(o => o.plan.includes('enterprise')) -} diff --git a/packages/cli/src/util/output/ambient-mode.mts b/packages/cli/src/util/output/ambient-mode.mts deleted file mode 100644 index 867c35852..000000000 --- a/packages/cli/src/util/output/ambient-mode.mts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Ambient machine-output mode context. - * - * MeowOrExit / meowWithSubcommands set this once at argv-parse time so spawn - * wrappers and output helpers can consult the current mode without threading - * flags through every function signature. - * - * This is a single module-scoped let (not a Proxy, not AsyncLocalStorage) — the - * CLI is a one-shot process with a single root invocation, so module-scoped - * state is the simplest correct model. Tests that run multiple invocations in - * sequence should call resetMachineOutputMode() in their setup. - */ - -import { isMachineOutputMode } from './mode.mts' - -import type { MachineModeFlags } from './mode.mts' - -let ambientMode = false - -export function getMachineOutputMode(): boolean { - return ambientMode -} - -export function resetMachineOutputMode(): void { - ambientMode = false -} - -export function setMachineOutputMode(flags: MachineModeFlags): void { - ambientMode = isMachineOutputMode(flags) -} diff --git a/packages/cli/src/util/output/emit-payload.mts b/packages/cli/src/util/output/emit-payload.mts deleted file mode 100644 index 6c7462fa4..000000000 --- a/packages/cli/src/util/output/emit-payload.mts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Payload emission for socket-cli commands. - * - * Under machine-output mode (--json, --markdown, or --quiet) the payload is - * emitted as three log calls — SENTINEL_BEGIN on its own line, the payload - * body, SENTINEL_END on its own line. This block structure lets the scrubber - * extract multi-line payloads (pretty- printed JSON, Markdown reports) - * unambiguously: once it sees BEGIN, every subsequent line is payload verbatim - * until END. - * - * In human mode, the payload writes via logger.log with no wrapping. - * - * Use emitPayload() / emitJsonPayload() at the end of output-* functions - * instead of calling logger.log(JSON.stringify(...)) directly. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SENTINEL_BEGIN, SENTINEL_END, isMachineOutputMode } from './mode.mts' - -import type { MachineModeFlags } from './mode.mts' - -const logger = getDefaultLogger() - -interface EmitPayloadOptions { - flags: MachineModeFlags -} - -export function emitJsonPayload( - value: unknown, - options: EmitPayloadOptions, -): void { - // JSON.stringify(undefined) returns undefined, which logs as the - // literal string "undefined" — serialize as null instead so we - // always emit syntactically valid JSON. - const serialized = JSON.stringify(value) ?? 'null' - emitPayload(serialized, options) -} - -export function emitPayload( - payload: string, - options: EmitPayloadOptions, -): void { - // logger.log appends its own \n, so strip ONE trailing newline from - // the payload to avoid a doubled \n. Applied in both modes so the - // output is consistent regardless of mode. - const normalized = payload.endsWith('\n') ? payload.slice(0, -1) : payload - if (isMachineOutputMode(options.flags)) { - logger.log(SENTINEL_BEGIN) - logger.log(normalized) - logger.log(SENTINEL_END) - } else { - logger.log(normalized) - } -} diff --git a/packages/cli/src/util/output/formatting.mts b/packages/cli/src/util/output/formatting.mts deleted file mode 100644 index f14006b6e..000000000 --- a/packages/cli/src/util/output/formatting.mts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Output formatting utilities for Socket CLI. Provides consistent formatting - * for help text and command output. - * - * Key Functions: - getFlagApiRequirementsOutput: Format API requirements for - * flags - getHelpListOutput: Format help text lists with descriptions - - * getFlagsHelpOutput: Generate formatted help for command flags. - * - * Formatting Features: - Automatic indentation and alignment - Flag description - * formatting - Requirements and permissions display - Hidden flag filtering. - * - * Usage: - Used by command help systems - Provides consistent terminal output - * formatting - Handles kebab-case conversion for flags. - */ - -import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' -import { isObject } from '@socketsecurity/lib-stable/objects/predicates' -import { naturalCompare } from '@socketsecurity/lib-stable/sorts/natural' -import { indentString } from '@socketsecurity/lib-stable/strings/format' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' - -import { camelToKebab } from '../data/strings.mts' -import { - getRequirements, - getRequirementsKey, -} from '../ecosystem/requirements.mts' - -import type { MeowFlags } from '../../flags.mts' - -type ApiRequirementsOptions = { - indent?: number | undefined -} - -type HelpListOptions = { - indent?: number | undefined - keyPrefix?: string | undefined - padName?: number | undefined -} - -type ListDescription = - | { description: string } - | { description: string; hidden: boolean } - -export function getFlagApiRequirementsOutput( - cmdPath: string, - options?: ApiRequirementsOptions | undefined, -): string { - const { indent = 6 } = { - __proto__: null, - ...options, - } as ApiRequirementsOptions - const key = getRequirementsKey(cmdPath) - const requirements = getRequirements() - const data = ( - requirements.api as Record< - string, - | { quota?: number | undefined; permissions?: string[] | undefined } - | undefined - > - )[key] - let result = '' - if (data) { - const quota: number = data.quota ?? 0 - const rawPerms: string[] = data.permissions ?? [] - const padding = ''.padEnd(indent) - const lines = [] - if (Number.isFinite(quota) && quota > 0) { - lines.push( - `${padding}- Quota: ${quota} ${pluralize('unit', { count: quota })}`, - ) - } - if (Array.isArray(rawPerms) && rawPerms.length) { - const perms = rawPerms.slice().sort(naturalCompare) - lines.push(`${padding}- Permissions: ${joinAnd(perms)}`) - } - result += lines.join('\n') - } - return result.trim() || '(none)' -} - -export function getFlagListOutput( - list: MeowFlags, - options?: HelpListOptions | undefined, -): string { - const { keyPrefix = '--' } = { - __proto__: null, - ...options, - } as HelpListOptions - return getHelpListOutput( - { - ...list, - }, - { ...options, keyPrefix }, - ) -} - -// Alias for testing compatibility. -export const getFlagsHelpOutput = getFlagListOutput - -export function getHelpListOutput( - list: Record<string, ListDescription>, - options?: HelpListOptions | undefined, -): string { - const { - indent = 6, - keyPrefix = '', - padName = 20, - } = { - __proto__: null, - ...options, - } as HelpListOptions - let result = '' - const names = Object.keys(list).sort(naturalCompare) - for (let i = 0, { length } = names; i < length; i += 1) { - const name = names[i]! - const entry = list[name] - const entryIsObj = isObject(entry) - if (entryIsObj && 'hidden' in entry && entry['hidden']) { - continue - } - const printedName = `${keyPrefix}${camelToKebab(name)}` - const preDescription = `${''.padEnd(indent)}${printedName.padEnd(Math.max(printedName.length + 2, padName))}` - - result += preDescription - - const description = entryIsObj - ? String(entry['description'] ?? '') - : String(entry) - if (description) { - result += indentString(description, { - count: preDescription.length, - }).trimStart() - } - result += '\n' - } - return result.trim() || '(none)' -} diff --git a/packages/cli/src/util/output/markdown.mts b/packages/cli/src/util/output/markdown.mts deleted file mode 100644 index acb7f4a2d..000000000 --- a/packages/cli/src/util/output/markdown.mts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Markdown utilities for Socket CLI. Generates formatted markdown output for - * reports and documentation. - * - * Core Functions: - * - * - MdHeader: Create markdown headers (# Title, ## Subtitle, etc.) - * - MdKeyValue: Create bold label with value (** Label**: value) - * - MdList: Create bullet or numbered lists with optional truncation - * - MdError: Format error messages with optional cause - * - MdSection: Create section with header and content - * - * Table Functions: - * - * - MdTableStringNumber: Create markdown table with string keys and number values - * - MdTable: Create generic markdown table from array of records - * - MdTableOfPairs: Create table from array of key-value pairs - * - * Table Features: - * - * - Auto-sizing columns based on content - * - Proper alignment for headers and data - * - Clean markdown-compliant formatting - * - * Usage: - * - * - Analytics reports - * - Scan result tables - * - Statistical summaries - * - Command output formatting - */ - -/** - * Format an error message in markdown. - * - * @example - * mdError('Failed to connect') - * // '# Error\n\n**Error**: Failed to connect' - * - * mdError('Failed', 'Network timeout') - * // '# Error\n\n**Error**: Failed\n\n**Cause**: Network timeout' - * - * @param message - The error message. - * @param cause - Optional error cause/details. - * - * @returns Markdown formatted error string - */ -export function mdError(message: string, cause?: string): string { - const parts = [mdHeader('Error'), '', mdKeyValue('Error', message)] - - if (cause) { - parts.push('', mdKeyValue('Cause', cause)) - } - - return parts.join('\n') -} - -/** - * Create a markdown header. - * - * @example - * mdHeader('Title') // '# Title' - * mdHeader('Subtitle', 2) // '## Subtitle' - * - * @param title - The header text. - * @param level - Header level (1-6), defaults to 1. - * - * @returns Markdown header string - */ -export function mdHeader(title: string, level = 1): string { - const headerLevel = Math.max(1, Math.min(6, level)) - return `${'#'.repeat(headerLevel)} ${title}` -} - -/** - * Create a markdown key-value pair with bold label. - * - * @example - * mdKeyValue('Status', 'active') // '**Status**: active' - * mdKeyValue('Count', 42) // '**Count**: 42' - * mdKeyValue('Missing', undefined) // '**Missing**: N/A' - * - * @param label - The label text. - * @param value - The value (string, number, or undefined) - * @param escaped - Whether to escape markdown in value, defaults to false. - * - * @returns Markdown formatted key-value string - */ -export function mdKeyValue( - label: string, - value: string | number | undefined, - escaped = false, -): string { - const displayValue = value === undefined ? 'N/A' : String(value) - const finalValue = escaped - ? displayValue.replaceAll('*', '\\*').replaceAll('_', '\\_') - : displayValue - return `**${label}**: ${finalValue}` -} - -/** - * Create a markdown list (bullet or numbered). - * - * @example - * mdList(['item1', 'item2']) // '- item1\n- item2' - * mdList(['a', 'b'], { ordered: true }) // '1. a\n2. b' - * mdList([...100items], { truncateAt: 5 }) // First 5 + '...and 95 more' - * - * @param items - Array of items to list. - * @param options - Configuration options. - * @param options.ordered - Create numbered list instead of bullets. - * @param options.indent - Indentation level (for nested lists) - * @param options.truncateAt - Truncate list and show count if exceeds this. - * - * @returns Markdown formatted list string - */ -export function mdList( - items: string[], - options?: { - ordered?: boolean | undefined - indent?: number | undefined - truncateAt?: number | undefined - }, -): string { - const { indent = 0, ordered = false, truncateAt } = { ...options } - - if (!items.length) { - return '' - } - - const indentStr = ' '.repeat(indent) - let displayItems = items - let suffix = '' - - if (truncateAt && items.length > truncateAt) { - displayItems = items.slice(0, truncateAt) - const remaining = items.length - truncateAt - suffix = `${indentStr}...and ${remaining} more` - } - - const lines = displayItems.map((item, index) => { - const prefix = ordered ? `${index + 1}.` : '-' - return `${indentStr}${prefix} ${item}` - }) - - return suffix ? `${lines.join('\n')}\n${suffix}` : lines.join('\n') -} - -/** - * Create a markdown section with optional header. - * - * @example - * mdSection('Details', 'Some content') - * // '## Details\n\nSome content' - * - * mdSection('Info', ['Line 1', 'Line 2']) - * // '## Info\n\nLine 1\nLine 2' - * - * @param title - Section title. - * @param content - Section content (string or array of strings) - * @param level - Header level (1-6), defaults to 2. - * - * @returns Markdown formatted section - */ -export function mdSection( - title: string, - content: string | string[], - level = 2, -): string { - const header = mdHeader(title, level) - const body = Array.isArray(content) ? content.join('\n') : content - return `${header}\n\n${body}` -} - -export function mdTable<T extends Array<Record<string, string>>>( - logs: T, - // This is saying "an array of strings and the strings are a valid key of elements of T" - // In turn, T is defined above as the audit log event type from our OpenAPI docs. - cols: Array<string & keyof T[number]>, - titles: string[] = cols, -): string { - // Max col width required to fit all data in that column - const cws = cols.map(col => col.length) - - for (let i = 0, { length } = logs; i < length; i += 1) { - const log = logs[i]! - for (let i = 0, { length } = cols; i < length; i += 1) { - const val: unknown = log[cols[i] ?? ''] ?? '' - cws[i] = Math.max( - cws[i] ?? 0, - String(val).length, - (titles[i] || '').length, - ) - } - } - - let div = '|' - for (let i = 0, { length } = cws; i < length; i += 1) { - const cw = cws[i]! - div += ` ${'-'.repeat(cw)} |` - } - - let header = '|' - for (let i = 0, { length } = titles; i < length; i += 1) { - header += ` ${String(titles[i]).padEnd(cws[i] ?? 0, ' ')} |` - } - - let body = '' - for (let i = 0, { length } = logs; i < length; i += 1) { - const log = logs[i]! - body += '|' - for (let i = 0, { length } = cols; i < length; i += 1) { - const val: unknown = log[cols[i] ?? ''] ?? '' - body += ` ${String(val).padEnd(cws[i] ?? 0, ' ')} |` - } - body += '\n' - } - - return [div, header, div, body.trim(), div].filter(s => s.trim()).join('\n') -} - -export function mdTableOfPairs( - arr: Array<[string, string]>, - // This is saying "an array of strings and the strings are a valid key of elements of T" - // In turn, T is defined above as the audit log event type from our OpenAPI docs. - cols: string[], -): string { - // Max col width required to fit all data in that column - const cws = cols.map(col => col.length) - - for (const [key, val] of arr) { - cws[0] = Math.max(cws[0] ?? 0, String(key).length) - cws[1] = Math.max(cws[1] ?? 0, String(val ?? '').length) - } - - let div = '|' - for (let i = 0, { length } = cws; i < length; i += 1) { - const cw = cws[i]! - div += ` ${'-'.repeat(cw)} |` - } - - let header = '|' - for (let i = 0, { length } = cols; i < length; i += 1) { - header += ` ${String(cols[i]).padEnd(cws[i] ?? 0, ' ')} |` - } - - let body = '' - for (const [key, val] of arr) { - body += '|' - body += ` ${String(key).padEnd(cws[0] ?? 0, ' ')} |` - body += ` ${String(val ?? '').padEnd(cws[1] ?? 0, ' ')} |` - body += '\n' - } - - return [div, header, div, body.trim(), div].filter(s => s.trim()).join('\n') -} - -export function mdTableStringNumber( - title1: string, - title2: string, - obj: Record<string, number | string>, -): string { - // | Date | Counts | - // | ----------- | ------ | - // | Header | 201464 | - // | Paragraph | 18 | - let mw1 = title1.length - let mw2 = title2.length - for (const { 0: key, 1: value } of Object.entries(obj)) { - mw1 = Math.max(mw1, key.length) - mw2 = Math.max(mw2, String(value ?? '').length) - } - - const lines = [] - lines.push(`| ${title1.padEnd(mw1, ' ')} | ${title2.padEnd(mw2)} |`) - lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) - for (const { 0: key, 1: value } of Object.entries(obj)) { - lines.push( - `| ${key.padEnd(mw1, ' ')} | ${String(value ?? '').padStart(mw2, ' ')} |`, - ) - } - lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) - - return lines.join('\n') -} diff --git a/packages/cli/src/util/output/mode.mts b/packages/cli/src/util/output/mode.mts deleted file mode 100644 index a6322a2e4..000000000 Binary files a/packages/cli/src/util/output/mode.mts and /dev/null differ diff --git a/packages/cli/src/util/output/result-json.mts b/packages/cli/src/util/output/result-json.mts deleted file mode 100644 index b1c73de94..000000000 --- a/packages/cli/src/util/output/result-json.mts +++ /dev/null @@ -1,45 +0,0 @@ -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { isObject } from '@socketsecurity/lib-stable/objects/predicates' - -import type { CResult } from '../../types.mjs' - -// Serialize the final result object before printing it -// All commands that support the --json flag should call this before printing -export function serializeResultJson(data: CResult<unknown>): string { - if (!isObject(data)) { - process.exitCode = 1 - - debugDirNs('inspect', { data }) - - // We should not allow the JSON value to be "null", or a boolean/number/string, - // even if they are valid "json". - return `${JSON.stringify({ - ok: false, - message: 'Unable to serialize JSON', - cause: - 'There was a problem converting the data set to JSON. The JSON was not an object. Please try again without --json', - }).trim()}\n` - } - - try { - return `${JSON.stringify(data, null, 2).trim()}\n` - } catch (e) { - process.exitCode = 1 - - const message = - 'There was a problem converting the data set to JSON. Please try again without --json' - - const logger = getDefaultLogger() - logger.fail(message) - debugNs('error', 'JSON serialization failed') - debugDirNs('error', e) - - // This could be caused by circular references, which is an "us" problem. - return `${JSON.stringify({ - ok: false, - message: 'Unable to serialize JSON', - cause: message, - }).trim()}\n` - } -} diff --git a/packages/cli/src/util/preflight/downloads.mts b/packages/cli/src/util/preflight/downloads.mts deleted file mode 100644 index 75d332394..000000000 --- a/packages/cli/src/util/preflight/downloads.mts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Background preflight downloads for optional dependencies. - * - * Silently downloads dependencies in the background on first CLI run: 1. - * @coana-tech/cli 2. @cyclonedx/cdxgen 3. Python + socketsecurity - * (socket-python-cli) - * - * Downloads are staggered sequentially to avoid resource contention. This runs - * asynchronously and never blocks the main CLI execution. - */ - -import { setTimeout as sleep } from 'node:timers/promises' - -import { downloadPackage } from '@socketsecurity/lib-stable/dlx/package' - -import { getCI } from '@socketsecurity/lib-stable/env/ci' - -import { getCoanaVersion } from '../../env/coana-version.mts' -import { getCdxgenVersion } from '../../env/cdxgen-version.mts' -import { VITEST } from '../../env/vitest.mts' -import { ensurePythonDlx, ensureSocketPyCli } from '../python/standalone.mts' - -/** - * Track if preflight downloads have already been initiated. - */ -let preflightRunning = false - -/** - * Run preflight downloads in the background. This never blocks or throws - * errors. Only runs once per process lifetime. - */ -export function runPreflightDownloads(): void { - // Only run once. - if (preflightRunning) { - return - } - preflightRunning = true - - // Don't run in test/CI environments. - if (getCI() || VITEST) { - return - } - - // Run asynchronously in the background. - void (async () => { - try { - // Stagger downloads sequentially with delays to avoid resource contention. - // Order: coana → delay → cdxgen → delay → Python → socketsecurity. - - // 1. @coana-tech/cli preflight. - const coanaVersion = getCoanaVersion() - const coanaSpec = `@coana-tech/cli@${coanaVersion}` - await downloadPackage({ - package: coanaSpec, - binaryName: 'coana', - force: false, - }) - - // Delay before next download to avoid resource contention. - await sleep(2000) - - // 2. @cyclonedx/cdxgen preflight. - const cdxgenVersion = getCdxgenVersion() - const cdxgenSpec = `@cyclonedx/cdxgen@${cdxgenVersion}` - await downloadPackage({ - package: cdxgenSpec, - binaryName: 'cdxgen', - force: false, - }) - - // Delay before next download to avoid resource contention. - await sleep(2000) - - // 3. Python + socketsecurity (socket-python-cli) preflight. - const pythonBin = await ensurePythonDlx() - await ensureSocketPyCli(pythonBin) - } catch {} - })() -} diff --git a/packages/cli/src/util/process/cmd.mts b/packages/cli/src/util/process/cmd.mts deleted file mode 100644 index 6f28b3ff6..000000000 --- a/packages/cli/src/util/process/cmd.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Command-line utilities for Socket CLI. Handles argument parsing, flag - * processing, and command formatting. - * - * Argument Handling: - Handles both long (--flag) and short (-f) formats - - * Preserves special characters and escaping - Properly quotes arguments - * containing spaces. - * - * Command Names: - commandNameFromCamel: Convert camelCase to kebab-case - * command names - commandNameFromKebab: Convert kebab-case to camelCase. - * - * Flag Processing: - cmdFlagsToString: Format arguments for display with proper - * escaping - cmdPrefixMessage: Generate command prefix message - - * stripConfigFlags: Remove --config flags from argument list - stripDebugFlags: - * Remove debug-related flags - stripHelpFlags: Remove help flags (-h, --help) - */ - -import { FLAG_HELP } from '../../constants/cli.mjs' -import { camelToKebab } from '../data/strings.mts' - -const helpFlags = new Set([FLAG_HELP, '-h']) - -/** - * Convert flag values to array format for processing. - */ -export function cmdFlagValueToArray(value: unknown): string[] { - if (typeof value === 'string') { - return value.trim().split(/, */).filter(Boolean) - } - if (Array.isArray(value)) { - return value.flatMap(cmdFlagValueToArray) - } - return [] -} - -/** - * Convert command arguments to a properly formatted string representation. - */ -export function cmdFlagsToString(args: string[] | readonly string[]): string { - const result = [] - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]?.trim() - if (arg?.startsWith('--')) { - const nextArg = i + 1 < length ? args[i + 1]?.trim() : undefined - // Check if the next item exists and is NOT another flag. - if (nextArg && !nextArg.startsWith('--') && !nextArg.startsWith('-')) { - result.push(`${arg}=${nextArg}`) - i += 1 - } else { - result.push(arg) - } - } else if (arg) { - // Include non-flag arguments (commands, package names, etc.). - result.push(arg) - } - } - return result.join(' ') -} - -/** - * Add command name prefix to message text. - */ -export function cmdPrefixMessage(cmdName: string, text: string): string { - const cmdPrefix = cmdName ? `${cmdName}: ` : '' - return `${cmdPrefix}${text}` -} - -/** - * Filter out Socket flags from argv before passing to subcommands. - */ -export function filterFlags( - argv: readonly string[], - flagsToFilter: Record< - string, - { shortFlag?: string | undefined; type?: string | undefined } - >, - exceptions?: string[] | undefined, -): string[] { - const filtered: string[] = [] - - // Build set of flags to filter from the provided flag objects. - const flagsToFilterSet = new Set<string>() - const flagsWithValueSet = new Set<string>() - - for (const [flagName, flag] of Object.entries(flagsToFilter)) { - const longFlag = `--${camelToKebab(flagName)}` - // Special case for negated booleans. - if (flagName === 'banner' || flagName === 'spinner') { - flagsToFilterSet.add(`--no-${flagName}`) - } else { - flagsToFilterSet.add(longFlag) - } - if (flag?.shortFlag) { - flagsToFilterSet.add(`-${flag.shortFlag}`) - } - // Track flags that take values. - if (flag.type !== 'boolean') { - flagsWithValueSet.add(longFlag) - if (flag?.shortFlag) { - flagsWithValueSet.add(`-${flag.shortFlag}`) - } - } - } - - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i]! - // Check if this flag should be kept as an exception. - if (exceptions?.includes(arg)) { - filtered.push(arg) - // Handle flags that take values. - if (flagsWithValueSet.has(arg)) { - // Include the next argument (the flag value). - i += 1 - if (i < length) { - filtered.push(argv[i]!) - } - } - } else if (flagsToFilterSet.has(arg)) { - // Skip flags that take values. - if (flagsWithValueSet.has(arg)) { - // Skip the next argument (the flag value). - i += 1 - } - // Skip boolean flags (no additional argument to skip). - } else if ( - arg && - Array.from(flagsWithValueSet).some(flag => arg.startsWith(`${flag}=`)) - ) { - // Skip --flag=value format for Socket flags unless it's an exception. - if (exceptions?.some(exc => arg.startsWith(`${exc}=`))) { - filtered.push(arg) - } - // Otherwise skip it. - } else { - filtered.push(arg!) - } - } - return filtered -} - -/** - * Check if argument is a help flag. - */ -export function isHelpFlag(cmdArg: string): boolean { - return helpFlags.has(cmdArg) -} diff --git a/packages/cli/src/util/purl/parse.mts b/packages/cli/src/util/purl/parse.mts deleted file mode 100644 index df1a7d300..000000000 --- a/packages/cli/src/util/purl/parse.mts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Package URL (PURL) utilities for Socket CLI. Implements the PURL - * specification for universal package identification. - * - * PURL Format: pkg:type/namespace/name@version?qualifiers#subpath. - * - * Key Functions: - createPurlObject: Create PURL from components - isPurl: - * Check if string is valid PURL - normalizePurl: Normalize PURL format - - * parsePurl: Parse PURL string to object - purlToString: Convert PURL object to - * string. - * - * Supported Types: - cargo: Rust packages - gem: Ruby packages - go: Go modules - * - maven: Java packages - npm: Node.js packages - pypi: Python packages. - * - * See: https://github.com/package-url/purl-spec. - */ - -import { PackageURL } from '@socketregistry/packageurl-js-stable' -import { isPlainObject } from '@socketsecurity/lib-stable/objects/predicates' - -import type { SocketArtifact } from '../alert/artifact.mjs' -import type { PURL_Type } from '../ecosystem/types.mjs' - -type PurlObject<T> = T & { type: PURL_Type } - -type PurlLike = string | PackageURL | SocketArtifact - -type CreatePurlObjectOptions = { - type?: string | undefined - namespace?: string | undefined - name?: string | undefined - version?: string | undefined - qualifiers?: Record<string, string> | undefined - subpath?: string | undefined - throws?: boolean | undefined -} - -type CreatePurlOptionsWithThrows = CreatePurlObjectOptions & { - throws?: true | undefined -} - -type CreatePurlOptionsNoThrows = CreatePurlObjectOptions & { - throws: false -} - -export function createPurlObject( - options: CreatePurlOptionsWithThrows, -): PurlObject<PackageURL> -export function createPurlObject( - options: CreatePurlOptionsNoThrows, -): PurlObject<PackageURL> | undefined -export function createPurlObject( - type: string | CreatePurlObjectOptions, - options?: CreatePurlOptionsWithThrows | undefined, -): PurlObject<PackageURL> -export function createPurlObject( - type: string | CreatePurlObjectOptions, - options: CreatePurlOptionsNoThrows, -): PurlObject<PackageURL> | undefined -export function createPurlObject( - type: string | CreatePurlObjectOptions, - options?: CreatePurlOptionsWithThrows | undefined, -): PurlObject<PackageURL> -export function createPurlObject( - type: string, - name: string, - options: CreatePurlOptionsNoThrows, -): PurlObject<PackageURL> | undefined -export function createPurlObject( - type: string, - name: string, - options?: CreatePurlOptionsWithThrows | undefined, -): PurlObject<PackageURL> -export function createPurlObject( - type: string | CreatePurlObjectOptions, - name?: string | CreatePurlObjectOptions | undefined, - options?: CreatePurlObjectOptions | undefined, -): PurlObject<PackageURL> | undefined { - let opts: CreatePurlObjectOptions | undefined - if (isPlainObject(type)) { - opts = { __proto__: null, ...type } as CreatePurlObjectOptions - type = opts.type as string - name = opts.name as string - } else if (isPlainObject(name)) { - opts = { __proto__: null, ...name } as CreatePurlObjectOptions - name = opts.name as string - } else { - opts = { __proto__: null, ...options } as CreatePurlObjectOptions - if (typeof name !== 'string') { - name = opts.name as string - } - } - const { namespace, qualifiers, subpath, throws, version } = opts - const shouldThrow = throws === undefined || !!throws - try { - return new PackageURL( - type, - namespace, - name, - version, - qualifiers, - subpath, - ) as PurlObject<PackageURL> - } catch (e) { - if (shouldThrow) { - throw e - } - } - return undefined -} - -type PurlObjectOptions = { - throws?: boolean | undefined -} - -type PurlOptionsWithThrows = PurlObjectOptions & { - throws?: true | undefined -} - -type PurlOptionsNoThrows = PurlObjectOptions & { throws: false } - -export function getPurlObject( - purl: string, - options?: PurlOptionsWithThrows | undefined, -): PurlObject<PackageURL> -export function getPurlObject( - purl: string, - options: PurlOptionsNoThrows, -): PurlObject<PackageURL> | undefined -export function getPurlObject( - purl: PackageURL, - options?: PurlOptionsWithThrows | undefined, -): PurlObject<PackageURL> -export function getPurlObject( - purl: PackageURL, - options: PurlOptionsNoThrows, -): PurlObject<PackageURL> | undefined -export function getPurlObject( - purl: SocketArtifact, - options?: PurlOptionsWithThrows | undefined, -): PurlObject<SocketArtifact> -export function getPurlObject( - purl: SocketArtifact, - options: PurlOptionsNoThrows, -): PurlObject<SocketArtifact> | undefined -export function getPurlObject( - purl: PurlLike, - options?: PurlOptionsWithThrows | undefined, -): PurlObject<PackageURL | SocketArtifact> -export function getPurlObject( - purl: PurlLike, - options?: PurlObjectOptions | undefined, -): PurlObject<PackageURL | SocketArtifact> | undefined { - const { throws } = { __proto__: null, ...options } as PurlObjectOptions - const shouldThrow = throws === undefined || !!throws - try { - return typeof purl === 'string' - ? (PackageURL.fromString(normalizePurl(purl)) as PurlObject<PackageURL>) - : (purl as PurlObject<PackageURL | SocketArtifact>) - } catch (e) { - if (shouldThrow) { - throw e - } - return undefined - } -} - -export function normalizePurl(rawPurl: string): string { - return rawPurl.startsWith('pkg:') ? rawPurl : `pkg:${rawPurl}` -} diff --git a/packages/cli/src/util/purl/to-ghsa.mts b/packages/cli/src/util/purl/to-ghsa.mts deleted file mode 100644 index 23468db23..000000000 --- a/packages/cli/src/util/purl/to-ghsa.mts +++ /dev/null @@ -1,74 +0,0 @@ -import { LATEST } from '@socketsecurity/lib-stable/constants/packages' - -import { getErrorCause } from '../error/errors.mts' -import { cacheFetch, getOctokit } from '../git/github.mts' -import { getPurlObject } from '../purl/parse.mts' - -import type { CResult } from '../../types.mjs' - -const PURL_TO_GITHUB_ECOSYSTEM_MAPPING = { - __proto__: null, - // GitHub Advisory Database supported ecosystems - cargo: 'rust', - composer: 'composer', - gem: 'rubygems', - go: 'go', - golang: 'go', - maven: 'maven', - npm: 'npm', - nuget: 'nuget', - pypi: 'pip', - swift: 'swift', -} as unknown as Record<string, string> - -/** - * Converts PURL to GHSA IDs using GitHub API. - */ -export async function convertPurlToGhsas( - purl: string, -): Promise<CResult<string[]>> { - try { - const purlObj = getPurlObject(purl, { throws: false }) - if (!purlObj) { - return { - ok: false, - message: `Invalid PURL format: ${purl}`, - } - } - - const { name, type: ecosystem, version } = purlObj - - // Map PURL ecosystem to GitHub ecosystem. - const githubEcosystem = PURL_TO_GITHUB_ECOSYSTEM_MAPPING[ecosystem] - if (!githubEcosystem) { - return { - ok: false, - message: `Unsupported PURL ecosystem: ${ecosystem}`, - } - } - - // Search for advisories affecting this package. - // Use '::' delimiter to avoid collisions (package names can contain hyphens). - const cacheKey = `purl-to-ghsa::${ecosystem}::${name}::${version || LATEST}` - const octokit = getOctokit() - const affects = version ? `${name}@${version}` : name - - const response = await cacheFetch(cacheKey, () => - octokit.rest.securityAdvisories.listGlobalAdvisories({ - // eslint-disable-next-line typescript-eslint/no-explicit-any -- Octokit's listGlobalAdvisories ecosystem union doesn't include the runtime-mapped value here; verified upstream. - ecosystem: githubEcosystem as any, - ...(affects ? { affects } : {}), - }), - ) - - return { - ok: true, - data: response.data.map(a => a.ghsa_id), - } - } catch (e) { - return { - ok: false, - message: `Failed to convert PURL to GHSA: ${getErrorCause(e)}`, - } - } -} diff --git a/packages/cli/src/util/python/standalone.mts b/packages/cli/src/util/python/standalone.mts deleted file mode 100644 index 3eef8170a..000000000 --- a/packages/cli/src/util/python/standalone.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @file Python runtime management for Socket CLI. This module re-exports the - * Python CLI spawning functionality from the unified DLX spawn utilities. - * Both socket-basics and socketcli use the same Python runtime. Resolution - * Order: - * - * 1. SOCKET_CLI_PYCLI_LOCAL_PATH environment variable (local development) - * 2. Bundled Python from SEA VFS (SEA binary installations) - * 3. Portable Python download via DLX (npm/pnpm/yarn installations) See also: - * - * - DLX spawn utilities: src/util/dlx/spawn.mts - * - Socket basics VFS extraction: src/util/basics/vfs-extract.mts - * - SEA detection: src/util/sea/detect.mts - */ - -// Re-export the unified Python CLI utilities from DLX spawn utilities. -export { - ensurePython, - ensurePythonDlx, - ensureSocketPyCli, - spawnSocketPyCli, -} from '../dlx/spawn.mts' - -export type { SocketPyCliDlxOptions } from '../dlx/spawn.mts' diff --git a/packages/cli/src/util/sanitize-names.mts b/packages/cli/src/util/sanitize-names.mts deleted file mode 100644 index 6e086b390..000000000 --- a/packages/cli/src/util/sanitize-names.mts +++ /dev/null @@ -1,59 +0,0 @@ -import { SOCKET_DEFAULT_REPOSITORY } from '../constants/socket.mts' - -/** - * Extracts and sanitizes a repository name. - * - * @param name - The repository name to extract and sanitize. - * - * @returns Sanitized repository name, or default repository name if empty - */ -export function extractName(name: string): string { - const sanitized = sanitizeName(name) - return sanitized || SOCKET_DEFAULT_REPOSITORY -} - -/** - * Extracts and sanitizes a repository owner name. - * - * @param owner - The repository owner name to extract and sanitize. - * - * @returns Sanitized repository owner name, or undefined if input is empty - */ -export function extractOwner(owner: string): string | undefined { - if (!owner) { - return undefined - } - const sanitized = sanitizeName(owner) - return sanitized || undefined -} - -/** - * Sanitizes a name to comply with repository naming constraints. Constraints: - * 100 or less A-Za-z0-9 characters only with non-repeating, non-leading or - * trailing ., _ or - only. - * - * @param name - The name to sanitize. - * - * @returns Sanitized name that complies with repository naming rules, or empty - * string if no valid characters. - */ -export function sanitizeName(name: string): string { - if (!name) { - return '' - } - - // Replace sequences of illegal characters with underscores. - const sanitized = name - // Replace any sequence of non-alphanumeric characters (except ., _, -) with underscore. - .replace(/[^A-Za-z0-9._-]+/g, '_') - // Replace sequences of multiple allowed special chars with single underscore. - .replace(/[._-]{2,}/g, '_') - // Remove leading special characters. - .replace(/^[._-]+/, '') - // Remove trailing special characters. - .replace(/[._-]+$/, '') - // Truncate to 100 characters max. - .slice(0, 100) - - return sanitized -} diff --git a/packages/cli/src/util/sea/boot.mts b/packages/cli/src/util/sea/boot.mts deleted file mode 100644 index b4b1301cb..000000000 --- a/packages/cli/src/util/sea/boot.mts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Bootstrap abstraction for SEA (Single Executable Application) subprocess - * handling. - * - * When running in a SEA binary, we need to distinguish between: - * - * 1. Initial entry - Bootstrap mode: delegate to system Node.js or self with IPC - * 2. Subprocess entry - Bypass bootstrap: act as regular Node.js - * - * The IPC handshake mechanism is used to detect subprocess mode: - * - * - Process.channel exists = subprocess - * - SOCKET_IPC_HANDSHAKE message received = validated subprocess - * - * This abstraction should be used anywhere we would spawn process.execPath, - * ensuring proper bootstrap delegation for SEA binaries. - */ - -import { SOCKET_IPC_HANDSHAKE } from '@socketsecurity/lib-stable/constants/socket' - -/** - * Check if the current process is running as a subprocess with IPC. Returns - * true if we have an IPC channel (process.channel exists). - */ -export function isSubprocess(): boolean { - return !!process.channel -} - -/** - * Send IPC handshake message to a spawned subprocess. - * - * This should be called immediately after spawning a SEA binary as a - * subprocess, so it knows to bypass bootstrap logic. - * - * @param childProcess - The spawned child process. - * @param ipcData - IPC handshake data to send. - */ -export function sendBootstrapHandshake( - childProcess: { send: (message: unknown) => void }, - ipcData: Record<string, unknown>, -): void { - childProcess.send({ - [SOCKET_IPC_HANDSHAKE]: ipcData, - }) -} - -/** - * Wait for IPC handshake message on subprocess startup. - * - * This should be called at the entry point of a SEA binary to detect if it's - * running as a subprocess. - * - * Returns a promise that resolves with the IPC data when received, or rejects - * if not received within timeout. - * - * The returned IPC data includes: - * - * - Bootstrap indicators: subprocess, parent_pid - * - Custom data: wrapper config, application settings, etc. - * - * @param timeoutMs - Timeout in milliseconds (default: 5000) - */ -export function waitForBootstrapHandshake( - timeoutMs = 5000, -): Promise<Record<string, unknown> | undefined> { - // If no IPC channel, we're not a subprocess. - if (!isSubprocess()) { - return Promise.resolve(undefined) - } - - return new Promise((resolve, reject) => { - let resolved = false - - const handler = (message: unknown) => { - /* c8 ignore start - guard fires only on a duplicate IPC message after promise resolved */ - if (resolved) { - return - } - /* c8 ignore stop */ - - // Check if message has SOCKET_IPC_HANDSHAKE key. - if ( - message && - typeof message === 'object' && - SOCKET_IPC_HANDSHAKE in message - ) { - const ipcData = (message as Record<string, unknown>)[ - SOCKET_IPC_HANDSHAKE - ] as Record<string, unknown> | undefined - - // Validate bootstrap indicators are present. - if ( - ipcData && - typeof ipcData === 'object' && - ipcData['subprocess'] === true && - typeof ipcData['parent_pid'] === 'number' - ) { - resolved = true - clearTimeout(timeout) - process.off('message', handler) - resolve(ipcData) - } - } - } - - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true - process.off('message', handler) - reject( - new Error( - 'IPC handshake timeout: expected SOCKET_IPC_HANDSHAKE message', - ), - ) - } - }, timeoutMs) - - process.on('message', handler) - }) -} diff --git a/packages/cli/src/util/sea/detect.mts b/packages/cli/src/util/sea/detect.mts deleted file mode 100644 index 78dc3efb6..000000000 --- a/packages/cli/src/util/sea/detect.mts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * SEA (Single Executable Application) detection utilities for Socket CLI. - * Provides reliable detection of whether the current process is running as a - * Node.js Single Executable Application. - * - * Key Functions: - isSeaBinary: Detect if running as SEA with caching - - * getSeaBinaryPath: Get the current SEA binary path - isInSocketDlx: Check if - * binary is in Socket's managed DLX directory - canSelfUpdate: Check if - * self-update is allowed for current binary. - * - * Detection Method: - Uses Node.js 24+ native sea.isSea() API - Caches result - * for performance - Graceful fallback for unsupported versions. - * - * Features: - Cached detection for performance - Error-resistant implementation - * - Support for Node.js 24+ SEA API. - * - * Usage: - Detecting SEA execution context - Conditional SEA-specific - * functionality - Update notification customization. - */ - -import { createRequire } from 'node:module' - -import { isInSocketDlx } from '@socketsecurity/lib-stable/dlx/paths' - -const require = createRequire(import.meta.url) - -/** - * Cached SEA detection result. - */ -let isSea: boolean | undefined - -/** - * Detect if self-update is allowed for the current binary. Self-update is ONLY - * allowed for SEA binaries running from Socket's managed DLX directory - * (~/.socket/_dlx/). - * - * Not allowed for: - * - * - Npm/pnpm/yarn-installed packages (not in DLX directory) - * - Standalone binaries in system paths like /usr/local/bin (not in DLX - * directory) - * - Bootstrap wrappers (not SEA binaries) - */ -export function canSelfUpdate(): boolean { - const binaryPath = process.argv[0] - return isSeaBinary() && !!binaryPath && isInSocketDlx(binaryPath) -} - -/** - * Get the current SEA binary path. Only valid when running as a SEA binary. - */ -export function getSeaBinaryPath(): string | undefined { - return isSeaBinary() ? process.argv[0] : undefined -} - -/** - * Detect if the current process is running as a SEA binary. Uses Node.js 24+ - * native API with caching for performance. - */ -export function isSeaBinary(): boolean { - if (isSea === undefined) { - try { - // Use Node.js 24+ native SEA detection API. - const seaModule = require('node:sea') - isSea = seaModule.isSea() - } catch { - isSea = false - } - } - return isSea ?? false -} diff --git a/packages/cli/src/util/semver.mts b/packages/cli/src/util/semver.mts deleted file mode 100644 index 6f1d9137a..000000000 --- a/packages/cli/src/util/semver.mts +++ /dev/null @@ -1,18 +0,0 @@ -import semver from 'semver' - -import type { SemVer } from 'semver' - -export const RangeStyles = ['pin', 'preserve'] - -export type RangeStyle = 'pin' | 'preserve' - -export type { SemVer } - -export function getMajor(version: unknown): number | undefined { - try { - const coerced = semver.coerce(version as string) - return coerced ? semver.major(coerced) : undefined - } catch {} - return undefined -} - diff --git a/packages/cli/src/util/socket-yaml.mts b/packages/cli/src/util/socket-yaml.mts deleted file mode 100644 index 8e62828ef..000000000 --- a/packages/cli/src/util/socket-yaml.mts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * @file Socket.yml types + parser, replacing the archived - * `@socketsecurity/config` package - * (https://github.com/SocketDev/socket-config-js). The original dependency - * shipped ajv + pony-cause to validate a small, stable schema. Inlining the - * surface here keeps the same wire format (v1 → v2 migration included) - * without dragging in a ~300 KB validator. Validation is intentionally - * lighter: we accept the YAML and coerce the required shape; malformed input - * throws `SocketValidationError`. - */ - -import { parse as yamlParse } from 'yaml' - -type SocketYmlGitHub = { - authenticatedProjectReports?: boolean | undefined - dependencyOverviewEnabled?: boolean | undefined - enabled?: boolean | undefined - ignoreUsers?: string[] | undefined - projectReportsEnabled?: boolean | undefined - pullRequestAlertsEnabled?: boolean | undefined -} - -export type SocketYml = { - githubApp: SocketYmlGitHub - issueRules: { [issueName: string]: boolean } - projectIgnorePaths: string[] - version: 2 -} - -type SocketYmlV1Shape = { - beta?: boolean | undefined - enabled?: boolean | undefined - ignore?: string[] | undefined - issues?: { [issueName: string]: boolean } | undefined - projectReportsEnabled?: boolean | undefined - pullRequestAlertsEnabled?: boolean | undefined -} - -class SocketValidationError extends Error { - data: unknown - validationErrors: string[] - - constructor( - message: string, - validationErrors: string[], - parsedContent: unknown, - ) { - super(message) - this.name = 'SocketValidationError' - this.data = parsedContent - this.validationErrors = validationErrors - } -} - -export function asBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined -} - -export function asBooleanRecord(value: unknown): { [k: string]: boolean } { - if (!isPlainObject(value)) { - return {} - } - const out: { [k: string]: boolean } = {} - for (const key of Object.keys(value)) { - if (typeof value[key] === 'boolean') { - out[key] = value[key] as boolean - } - } - return out -} - -export function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - return value.filter((v): v is string => typeof v === 'string') -} - -export function buildGithub(value: unknown): SocketYmlGitHub { - if (!isPlainObject(value)) { - return {} - } - const out: SocketYmlGitHub = {} - const ats = asBoolean(value['authenticatedProjectReports']) - if (ats !== undefined) { - out.authenticatedProjectReports = ats - } - const dep = asBoolean(value['dependencyOverviewEnabled']) - if (dep !== undefined) { - out.dependencyOverviewEnabled = dep - } - const enabled = asBoolean(value['enabled']) - if (enabled !== undefined) { - out.enabled = enabled - } - const ignoreUsers = value['ignoreUsers'] - if (Array.isArray(ignoreUsers)) { - out.ignoreUsers = asStringArray(ignoreUsers) - } - const prr = asBoolean(value['projectReportsEnabled']) - if (prr !== undefined) { - out.projectReportsEnabled = prr - } - const pra = asBoolean(value['pullRequestAlertsEnabled']) - if (pra !== undefined) { - out.pullRequestAlertsEnabled = pra - } - return out -} - -export function isPlainObject( - value: unknown, -): value is Record<string, unknown> { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function looksLikeV1(content: Record<string, unknown>): boolean { - // V1 had no `version` field. If `version` is present, treat as v2+. - if ('version' in content) { - return false - } - // V1 distinguishing keys. - return ( - 'ignore' in content || - 'issues' in content || - 'beta' in content || - 'enabled' in content || - 'projectReportsEnabled' in content || - 'pullRequestAlertsEnabled' in content - ) -} - -export function migrateV1(content: SocketYmlV1Shape): SocketYml { - const github: SocketYmlGitHub = {} - if ('enabled' in content && typeof content.enabled === 'boolean') { - github.enabled = content.enabled - } - if ( - 'pullRequestAlertsEnabled' in content && - typeof content.pullRequestAlertsEnabled === 'boolean' - ) { - github.pullRequestAlertsEnabled = content.pullRequestAlertsEnabled - } - if ( - 'projectReportsEnabled' in content && - typeof content.projectReportsEnabled === 'boolean' - ) { - github.projectReportsEnabled = content.projectReportsEnabled - } - return { - githubApp: github, - issueRules: content.issues ?? {}, - projectIgnorePaths: content.ignore ?? [], - version: 2, - } -} - -/** - * Parse a socket.yml file body. Accepts both v2 (current) and v1 (pre-`version: - * 2`) layouts; v1 is migrated to v2 in-place. - * - * Throws SocketValidationError on missing `version` (v2) or unrecognized - * top-level shape (v1 + v2 both fail). - */ -export function parseSocketConfig(fileContent: string): SocketYml { - let parsed: unknown - try { - parsed = yamlParse(fileContent) - } catch (e) { - throw new SocketValidationError( - 'Error when parsing socket.yml config', - [String((e as Error).message ?? e)], - fileContent, - ) - } - if (!isPlainObject(parsed)) { - throw new SocketValidationError( - 'socket.yml must be a mapping at top level', - ['top-level value is not an object'], - parsed, - ) - } - if (looksLikeV1(parsed)) { - return migrateV1(parsed as SocketYmlV1Shape) - } - if (parsed['version'] !== 2 && parsed['version'] !== '2') { - throw new SocketValidationError( - 'socket.yml: unsupported or missing `version` (expected 2)', - [`version=${JSON.stringify(parsed['version'])}`], - parsed, - ) - } - return { - githubApp: buildGithub(parsed['githubApp']), - issueRules: asBooleanRecord(parsed['issueRules']), - projectIgnorePaths: asStringArray(parsed['projectIgnorePaths']), - version: 2, - } -} diff --git a/packages/cli/src/util/socket/api-error-messages.mts b/packages/cli/src/util/socket/api-error-messages.mts deleted file mode 100644 index fb87d0fd1..000000000 --- a/packages/cli/src/util/socket/api-error-messages.mts +++ /dev/null @@ -1,167 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/no-status-emoji -- user-facing API error strings; emojis are part of the message-template contract. */ - -/** - * User-facing error messages + permission-requirements logging for Socket API - * failures. - * - * Extracted from api.mts to keep that file under the 1000-line File-size cap. - * These helpers turn opaque HTTP status codes into actionable guidance ("here's - * where to update your token", "here's how to check rate limits") and translate - * command paths into the permission set the API was expecting. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, -} from '../../constants/http.mts' -import { - SOCKET_CLI_ISSUES_URL, - SOCKET_PRICING_URL, - SOCKET_SETTINGS_API_TOKENS_URL, - SOCKET_STATUS_URL, -} from '../../constants/socket.mts' -import { - getRequirements, - getRequirementsKey, -} from '../ecosystem/requirements.mts' - -const logger = getDefaultLogger() - -export type CommandRequirements = { - permissions?: string[] | undefined - quota?: number | undefined -} - -/** - * Get command requirements from requirements.json based on command path. - */ -export function getCommandRequirements( - cmdPath?: string | undefined, -): CommandRequirements | undefined { - if (!cmdPath) { - return undefined - } - - const requirements = getRequirements() - const key = getRequirementsKey(cmdPath) - return ( - ((requirements.api as Record<string, unknown>)[key] as - | { quota?: number | undefined; permissions?: string[] | undefined } - | undefined) || undefined - ) -} - -/** - * Get user-friendly error message for HTTP status codes with actionable - * guidance. - */ -export async function getErrorMessageForHttpStatusCode(code: number) { - if (code === HTTP_STATUS_BAD_REQUEST) { - return ( - '❌ Invalid request: One of the options or parameters may be incorrect.\n' + - '💡 Try: Check your command syntax and parameter values.' - ) - } - if (code === HTTP_STATUS_UNAUTHORIZED) { - return ( - '❌ Authentication failed: Your Socket API token appears to be invalid, expired, or revoked.\n' + - '💡 Try:\n' + - ' • Run `socket whoami` to verify your current token\n' + - ' • Run `socket login` to re-authenticate\n' + - ` • Manage tokens at ${SOCKET_SETTINGS_API_TOKENS_URL}` - ) - } - if (code === HTTP_STATUS_FORBIDDEN) { - return ( - '❌ Access denied: Your API token lacks required permissions or organization access.\n' + - '💡 Try:\n' + - ' • Run `socket whoami` to verify your account and organization\n' + - ` • Check your API token permissions at ${SOCKET_SETTINGS_API_TOKENS_URL}\n` + - " • Ensure you're accessing the correct organization with `--org` flag\n" + - ` • Verify your plan includes this feature at ${SOCKET_PRICING_URL}` - ) - } - if (code === HTTP_STATUS_NOT_FOUND) { - return ( - "❌ Not found: The requested endpoint or resource doesn't exist.\n" + - '💡 Try:\n' + - ' • Verify resource names (package, repository, organization)\n' + - ' • Check if the resource was deleted or moved\n' + - ' • Update to the latest CLI version: `socket self-update` (SEA) or `npm update -g socket`\n' + - ` • Report persistent issues at ${SOCKET_CLI_ISSUES_URL}` - ) - } - if (code === HTTP_STATUS_TOO_MANY_REQUESTS) { - return ( - '❌ Rate limit exceeded: Too many API requests.\n' + - '💡 Try:\n' + - ` • Free plan: Wait a few minutes for quota reset or upgrade at ${SOCKET_PRICING_URL}\n` + - ' • Paid plan: Contact support if rate limits seem incorrect\n' + - ' • Check current quota: `socket organization quota`\n' + - ' • Reduce request frequency or batch operations' - ) - } - if (code === HTTP_STATUS_INTERNAL_SERVER_ERROR) { - return ( - '❌ Server error: Socket API encountered an internal problem (HTTP 500).\n' + - '💡 Try:\n' + - ' • Wait a few minutes and retry your command\n' + - ` • Check Socket status: ${SOCKET_STATUS_URL}\n` + - ` • Report persistent issues: ${SOCKET_CLI_ISSUES_URL}` - ) - } - return ( - `❌ HTTP ${code}: Server responded with unexpected status code.\n` + - `💡 Try: Check Socket status at ${SOCKET_STATUS_URL} or report the issue.` - ) -} - -/** - * Log required permissions for a command when encountering 403 errors with - * actionable guidance. - * - * @param cmdPath - Command path to look up requirements for (e.g., "socket - * fix", "socket scan:create") - */ -export function logPermissionsFor403(cmdPath?: string | undefined): void { - const requirements = getCommandRequirements(cmdPath) - - logger.error('') - if (requirements?.permissions?.length) { - logger.group('🔐 Required API Permissions:') - for (const permission of requirements.permissions) { - logger.error(permission) - } - logger.groupEnd() - logger.error('') - logger.group('💡 To fix this:') - logger.error(`Visit ${SOCKET_SETTINGS_API_TOKENS_URL}`) - logger.error('Edit your API token to grant the permissions listed above') - logger.error('Re-run your command') - logger.groupEnd() - } else { - // No specific permissions found, provide general guidance. - logger.group('🔐 Permission Requirements:') - logger.error( - 'Your API token lacks the required permissions for this operation.', - ) - logger.groupEnd() - logger.error('') - logger.group('💡 To fix this:') - logger.error(`Visit ${SOCKET_SETTINGS_API_TOKENS_URL}`) - logger.error('Check your API token has the necessary permissions') - logger.error( - `Run \`socket ${cmdPath?.replace(/^socket[: ]/, '') || 'help'} --help\` to see required permissions`, - ) - logger.error('Re-run your command after updating permissions') - logger.groupEnd() - } - logger.error('') -} diff --git a/packages/cli/src/util/socket/api.mts b/packages/cli/src/util/socket/api.mts deleted file mode 100644 index e02321a5e..000000000 --- a/packages/cli/src/util/socket/api.mts +++ /dev/null @@ -1,613 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * API utilities for Socket CLI. Provides consistent API communication with - * error handling and permissions management. - * - * Key Functions: - * - * - GetDefaultApiBaseUrl: Get configured API endpoint - * - GetErrorMessageForHttpStatusCode: User-friendly HTTP error messages - * - HandleApiCall: Execute Socket SDK API calls with error handling - * - HandleApiCallNoSpinner: Execute API calls without UI spinner - * - QueryApi: Execute raw API queries with text response - * - * Error Handling: - * - * - Automatic permission requirement logging for 403 errors - * - Detailed error messages for common HTTP status codes - * - Integration with debug helpers for API response logging - * - * Configuration: - * - * - Respects SOCKET_CLI_API_BASE_URL environment variable - * - Falls back to configured apiBaseUrl or default API_V0_URL - */ - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' -import { getSocketCliApiBaseUrl } from '@socketsecurity/lib-stable/env/socket-cli' -import { messageWithCauses } from '@socketsecurity/lib-stable/errors' -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getDefaultSpinner } from '@socketsecurity/lib-stable/spinner/default' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { getDefaultApiToken, getExtraCaCerts } from './sdk.mts' - -import type { HttpRequestOptions } from '@socketsecurity/lib-stable/http-request/request-types' -import type { HttpResponse } from '@socketsecurity/lib-stable/http-request/response-types' -import { CONFIG_KEY_API_BASE_URL } from '../../constants/config.mts' -import { API_V0_URL } from '../../constants/socket.mts' -import { getConfigValueOrUndef } from '../config.mts' -import { debugApiResponse } from '../debug.mts' -import { - ConfigError, - buildErrorCause, - getNetworkErrorDiagnostics, -} from '../error/errors.mts' - -import type { CResult } from '../../types.mts' -import type { SpinnerInstance } from '@socketsecurity/lib-stable/spinner/types' -import type { - SocketSdkErrorResult, - SocketSdkOperations, - SocketSdkSuccessResult, -} from '@socketsecurity/sdk-stable' - -const logger = getDefaultLogger() - -const NO_ERROR_MESSAGE = 'No error message returned' - -// User-facing error messages + permission-requirements logging -// extracted to keep this file under the 1000-line File-size cap. -import { - getCommandRequirements, - getErrorMessageForHttpStatusCode, - logPermissionsFor403, -} from './api-error-messages.mts' - -export { - getCommandRequirements, - getErrorMessageForHttpStatusCode, - logPermissionsFor403, -} - -export type { CommandRequirements } from './api-error-messages.mts' - -// The Socket API server that should be used for operations. -export function getDefaultApiBaseUrl(): string | undefined { - const baseUrl = - getSocketCliApiBaseUrl() || getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) - if (isNonEmptyString(baseUrl)) { - return baseUrl - } - return API_V0_URL -} - -type HandleApiCallOptions = { - description?: string | undefined - spinner?: SpinnerInstance | undefined - commandPath?: string | undefined -} - -type ApiCallResult<T extends SocketSdkOperations> = CResult< - SocketSdkSuccessResult<T>['data'] -> - -/** - * Handle Socket SDK API calls with error handling and permission logging. - */ -export async function handleApiCall<T extends SocketSdkOperations>( - value: Promise<unknown>, - options?: HandleApiCallOptions | undefined, -): Promise<ApiCallResult<T>> { - const { commandPath, description, spinner } = { - __proto__: null, - ...options, - } as HandleApiCallOptions - - if (description) { - spinner?.start(`Requesting ${description} from API...`) - } else { - spinner?.start() - } - - // eslint-disable-next-line typescript-eslint/no-explicit-any -- value is `Promise<unknown>`; sdkResult shape is narrowed inline via `success`/`status`/`error`/`cause` discriminants below. - let sdkResult: any - try { - sdkResult = await value - spinner?.stop() - // Only log success messages if a spinner was provided (opt-in to output). - if (description && spinner) { - const message = `Received Socket API response (after requesting ${description}).` - if (sdkResult.success) { - logger.success(message) - } else { - logger.info(message) - } - } - } catch (e) { - spinner?.stop() - const socketSdkErrorResult: ApiCallResult<T> = { - ok: false, - message: 'Socket API error', - cause: messageWithCauses(e as Error), - } - if (description) { - logger.fail(`An error was thrown while requesting ${description}`) - debugApiResponse(description, undefined, e) - } else { - debugApiResponse('Socket API', undefined, e) - } - debugDir({ socketSdkErrorResult }) - return socketSdkErrorResult - } - - // Note: TS can't narrow down the type of result due to generics. - if (sdkResult.success === false) { - const endpoint = description || 'Socket API' - debugApiResponse(endpoint, sdkResult.status as number) - debugDir({ sdkResult }) - - const errCResult = sdkResult as SocketSdkErrorResult<T> - const errStr = errCResult.error ? String(errCResult.error).trim() : '' - const message = errStr || NO_ERROR_MESSAGE - const reason = errCResult.cause || NO_ERROR_MESSAGE - - const cause = await buildErrorCause( - sdkResult.status as number, - message, - reason, - ) - - const causeWithEndpoint = description - ? `${cause} (endpoint: ${description})` - : cause - - const socketSdkErrorResult: ApiCallResult<T> = { - ok: false, - message: 'Socket API error', - cause: causeWithEndpoint, - data: { - code: sdkResult.status, - }, - } - - // Log required permissions for 403 errors when in a command context. - if (commandPath && sdkResult.status === 403) { - logPermissionsFor403(commandPath) - } - - return socketSdkErrorResult - } - const socketSdkSuccessResult: ApiCallResult<T> = { - ok: true, - data: (sdkResult as SocketSdkSuccessResult<T>).data, - } - return socketSdkSuccessResult -} - -export async function handleApiCallNoSpinner<T extends SocketSdkOperations>( - value: Promise<unknown>, - description: string, -): Promise<CResult<SocketSdkSuccessResult<T>['data']>> { - // eslint-disable-next-line typescript-eslint/no-explicit-any -- value is `Promise<unknown>`; sdkResult shape is narrowed inline via `success`/`status`/`error`/`cause` discriminants below. - let sdkResult: any - try { - sdkResult = await value - } catch (e) { - debug(`API request failed: ${description}`) - debugDir(e) - - const errStr = e ? String(e).trim() : '' - const message = 'Socket API error' - const rawCause = errStr || NO_ERROR_MESSAGE - const cause = message !== rawCause ? rawCause : '' - - return { - ok: false, - message, - ...(cause ? { cause } : {}), - } - } - - // Note: TS can't narrow down the type of result due to generics - if (sdkResult.success === false) { - debug(`fail: ${description} bad response`) - debugDir({ sdkResult }) - - const sdkErrorResult = sdkResult as SocketSdkErrorResult<T> - const errStr = sdkErrorResult.error - ? String(sdkErrorResult.error).trim() - : '' - const message = errStr || NO_ERROR_MESSAGE - const reason = sdkErrorResult.cause || NO_ERROR_MESSAGE - - const cause = await buildErrorCause( - sdkResult.status as number, - message, - reason, - ) - - const causeWithEndpoint = description - ? `${cause} (endpoint: ${description})` - : cause - - return { - ok: false, - message: 'Socket API error', - cause: causeWithEndpoint, - data: { - code: sdkResult.status, - }, - } - } - const sdkSuccessResult = sdkResult as SocketSdkSuccessResult<T> - return { - ok: true, - data: sdkSuccessResult.data, - } -} - -export async function queryApi(path: string, apiToken: string) { - const baseUrl = getDefaultApiBaseUrl() - /* c8 ignore start - getDefaultApiBaseUrl returns API_V0_URL by default; only undefined when env is misconfigured */ - if (!baseUrl) { - throw new ConfigError( - 'Socket API base URL is not configured.', - CONFIG_KEY_API_BASE_URL, - ) - } - /* c8 ignore stop */ - - return await socketHttpRequest( - `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`, - { - method: 'GET', - headers: { - Authorization: `Basic ${btoa(`${apiToken}:`)}`, - }, - timeout: 30_000, - }, - ) -} - -/** - * Query Socket API endpoint and return parsed JSON response. - */ -export async function queryApiSafeJson<T>( - path: string, - description = '', -): Promise<CResult<T>> { - const result = await queryApiSafeText(path, description) - - if (!result.ok) { - return result - } - - try { - return { - ok: true, - data: JSON.parse(result.data) as T, - } - } catch (_e) { - return { - ok: false, - message: 'Server returned invalid JSON', - cause: `Please report this. JSON.parse threw an error over the following response: \`${(result.data?.slice?.(0, 100) || '').trim() + (result.data?.length > 100 ? '…' : '')}\``, - } - } -} - -/** - * Query Socket API endpoint and return text response with error handling. - */ -export async function queryApiSafeText( - path: string, - description?: string | undefined, - commandPath?: string | undefined, -): Promise<CResult<string>> { - const apiToken = getDefaultApiToken() - if (!apiToken) { - return { - ok: false, - message: 'Authentication Error', - cause: - 'User must be authenticated to run this command. Run `socket login` and enter your Socket API token.', - } - } - - const spinner = getDefaultSpinner() - - if (description) { - spinner?.start(`Requesting ${description} from API...`) - } - - const baseUrl = getDefaultApiBaseUrl() - const fullUrl = `${baseUrl}${baseUrl?.endsWith('/') ? '' : '/'}${path}` - const startTime = Date.now() - const requestedAt = new Date(startTime).toISOString() - - // eslint-disable-next-line typescript-eslint/no-explicit-any -- HTTP response shape (status/ok/headers/text/json/data) is dynamically narrowed below; typing here would require a discriminated union for every status code. - let result: any - try { - result = await queryApi(path, apiToken) - const durationMs = Date.now() - startTime - if (description) { - spinner?.successAndStop( - `Received Socket API response (after requesting ${description}).`, - ) - } - // Log success for debugging. - debugApiResponse(description || 'Query API', result.status, undefined, { - method: 'GET', - url: fullUrl, - durationMs, - requestedAt, - headers: { Authorization: '[REDACTED]' }, - }) - } catch (e) { - const durationMs = Date.now() - startTime - if (description) { - spinner?.failAndStop( - `An error was thrown while requesting ${description}.`, - ) - } - - debug('Query API request failed') - debugApiResponse(description || 'Query API', undefined, e, { - method: 'GET', - url: fullUrl, - durationMs, - requestedAt, - headers: { Authorization: '[REDACTED]' }, - }) - - // Provide detailed network diagnostics for fetch errors. - const networkDiagnostics = getNetworkErrorDiagnostics(e, durationMs) - const message = 'API request failed' - - return { - ok: false, - message, - cause: `${networkDiagnostics} (path: ${path})`, - } - } - - if (!result.ok) { - const { status } = result - const durationMs = Date.now() - startTime - // Include response headers (for cf-ray) and a truncated body so - // support tickets have everything needed to file against Cloudflare - // or backend teams. - debugApiResponse(description || 'Query API', status, undefined, { - method: 'GET', - url: fullUrl, - durationMs, - requestedAt, - headers: { Authorization: '[REDACTED]' }, - responseHeaders: result.headers, - responseBody: tryReadResponseText(result), - }) - // Log required permissions for 403 errors when in a command context. - if (commandPath && status === 403) { - logPermissionsFor403(commandPath) - } - return { - ok: false, - message: 'Socket API error', - cause: `${result.statusText} (reason: ${await getErrorMessageForHttpStatusCode(status)}) (path: ${path})`, - data: { - code: status, - }, - } - } - - try { - const data = result.text() - return { - ok: true, - data, - } - } catch (e) { - debug('Failed to read API response text') - debugDir(e) - - return { - ok: false, - message: 'API request failed', - cause: `Unexpected error reading response text (path: ${path})`, - } - } -} - -type SendApiRequestOptions = { - method: 'POST' | 'PUT' - body?: unknown | undefined - description?: string | undefined - commandPath?: string | undefined -} - -/** - * Send POST/PUT request to Socket API with JSON response handling. - */ -export async function sendApiRequest<T>( - path: string, - options?: SendApiRequestOptions | undefined, -): Promise<CResult<T>> { - const apiToken = getDefaultApiToken() - if (!apiToken) { - return { - ok: false, - message: 'Authentication Error', - cause: - 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your Socket API token.', - } - } - - const baseUrl = getDefaultApiBaseUrl() - /* c8 ignore start - getDefaultApiBaseUrl returns API_V0_URL by default; only undefined when env is misconfigured */ - if (!baseUrl) { - return { - ok: false, - message: 'Configuration Error', - cause: - 'Socket API endpoint is not configured. Please check your environment configuration.', - } - } - /* c8 ignore stop */ - - const { body, commandPath, description, method } = { - __proto__: null, - ...options, - } as SendApiRequestOptions - const spinner = getDefaultSpinner() - - if (description) { - spinner?.start(`Requesting ${description} from API...`) - } - - const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}` - const startTime = Date.now() - const requestedAt = new Date(startTime).toISOString() - - // eslint-disable-next-line typescript-eslint/no-explicit-any -- HTTP response shape (status/ok/headers/text/json/data) is dynamically narrowed below; typing here would require a discriminated union for every status code. - let result: any - try { - result = await socketHttpRequest(fullUrl, { - body: body ? JSON.stringify(body) : undefined, - headers: { - Authorization: `Basic ${btoa(`${apiToken}:`)}`, - 'Content-Type': 'application/json', - }, - method, - timeout: 60_000, - }) - const durationMs = Date.now() - startTime - if (description) { - spinner?.successAndStop( - `Received Socket API response (after requesting ${description}).`, - ) - } - // Log success for debugging. - debugApiResponse( - description || 'Send API Request', - result.status, - undefined, - { - method, - url: fullUrl, - durationMs, - requestedAt, - headers: { - Authorization: '[REDACTED]', - 'Content-Type': 'application/json', - }, - }, - ) - } catch (e) { - const durationMs = Date.now() - startTime - if (description) { - spinner?.failAndStop( - `An error was thrown while requesting ${description}.`, - ) - } - - debug(`API ${method} request failed`) - debugApiResponse(description || 'Send API Request', undefined, e, { - method, - url: fullUrl, - durationMs, - requestedAt, - headers: { - Authorization: '[REDACTED]', - 'Content-Type': 'application/json', - }, - }) - - // Provide detailed network diagnostics for fetch errors. - const networkDiagnostics = getNetworkErrorDiagnostics(e, durationMs) - const message = 'API request failed' - - return { - ok: false, - message, - cause: `${networkDiagnostics} (path: ${path})`, - } - } - - if (!result.ok) { - const { status } = result - const durationMs = Date.now() - startTime - // Include response headers (for cf-ray) and a truncated body so - // support tickets have everything needed to file against Cloudflare - // or backend teams. - debugApiResponse(description || 'Send API Request', status, undefined, { - method, - url: fullUrl, - durationMs, - requestedAt, - headers: { - Authorization: '[REDACTED]', - 'Content-Type': 'application/json', - }, - responseHeaders: result.headers, - responseBody: tryReadResponseText(result), - }) - // Log required permissions for 403 errors when in a command context. - if (commandPath && status === 403) { - logPermissionsFor403(commandPath) - } - return { - ok: false, - message: 'Socket API error', - cause: `${result.statusText} (reason: ${await getErrorMessageForHttpStatusCode(status)}) (path: ${path})`, - data: { - code: status, - }, - } - } - - try { - const data = result.json() - return { - ok: true, - data: data as T, - } - } catch (e) { - debug('Failed to parse API response JSON') - debugDir(e) - return { - ok: false, - message: 'API request failed', - cause: `Unexpected error parsing response JSON (path: ${path})`, - } - } -} - -// Wraps httpRequest with extra CA certificates from SSL_CERT_FILE. -export async function socketHttpRequest( - url: string, - options?: HttpRequestOptions | undefined, -): Promise<HttpResponse> { - const ca = getExtraCaCerts() - /* c8 ignore start - SSL_CERT_FILE not set in tests; getExtraCaCerts returns undefined */ - if (ca) { - return await httpRequest(url, { ...(options ?? {}), ca }) - } - /* c8 ignore stop */ - return await httpRequest(url, options) -} - -// Safe wrapper for `response.text()` in error-handling code paths. -// `text()` can throw (e.g. already consumed, malformed body), which -// would blow past the `ok: false` CResult return and break the -// error-handling contract of callers like `queryApiSafeText`. -export function tryReadResponseText(result: HttpResponse): string | undefined { - try { - return result.text?.() - /* c8 ignore start - defensive fallback when response.text() throws (e.g. already consumed body) */ - } catch { - return undefined - } - /* c8 ignore stop */ -} diff --git a/packages/cli/src/util/socket/json.mts b/packages/cli/src/util/socket/json.mts deleted file mode 100644 index fbb9cf219..000000000 --- a/packages/cli/src/util/socket/json.mts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Socket JSON utilities for Socket CLI. Manages .socket/socket.json - * configuration and scan metadata. - * - * Key Functions: - loadDotSocketDirectory: Load .socket directory configuration - * - saveSocketJson: Persist scan configuration to .socket/socket.json - - * validateSocketJson: Validate socket.json structure. - * - * File Structure: - Contains scan metadata and configuration - Stores scan IDs - * and repository information - Tracks CLI version and scan timestamps. - * - * Directory Management: - Creates .socket directory as needed - Handles nested - * directory structures - Supports both read and write operations. - */ - -import { existsSync, readFileSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' -import path from 'node:path' - -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SOCKET_JSON, SOCKET_WEBSITE_URL } from '../../constants/socket.mts' -import { formatErrorWithDetail } from '../error/errors.mts' -import { findUp } from '../fs/find-up.mts' - -import type { CResult } from '../../types.mjs' -const logger = getDefaultLogger() - -export interface SocketJson { - ' _____ _ _ ': string - '| __|___ ___| |_ ___| |_ ': string - "|__ | . | _| '_| -_| _| ": string - '|_____|___|___|_,_|___|_|.dev': string - version: number - - defaults?: - | { - manifest?: - | { - conda?: - | { - disabled?: boolean | undefined - infile?: string | undefined - outfile?: string | undefined - stdin?: boolean | undefined - stdout?: boolean | undefined - target?: string | undefined - verbose?: boolean | undefined - } - | undefined - gradle?: - | { - disabled?: boolean | undefined - bin?: string | undefined - gradleOpts?: string | undefined - verbose?: boolean | undefined - } - | undefined - sbt?: - | { - disabled?: boolean | undefined - infile?: string | undefined - stdin?: boolean | undefined - bin?: string | undefined - outfile?: string | undefined - sbtOpts?: string | undefined - stdout?: boolean | undefined - verbose?: boolean | undefined - } - | undefined - } - | undefined - scan?: - | { - create?: - | { - autoManifest?: boolean | undefined - branch?: string | undefined - repo?: string | undefined - report?: boolean | undefined - workspace?: string | undefined - } - | undefined - github?: - | { - all?: boolean | undefined - githubApiUrl?: string | undefined - orgGithub?: string | undefined - repos?: string | undefined - } - | undefined - } - | undefined - } - | undefined -} - -export async function findSocketJsonUp( - cwd: string, -): Promise<string | undefined> { - return await findUp(SOCKET_JSON, { onlyFiles: true, cwd }) -} - -export function getDefaultSocketJson(): SocketJson { - return { - ' _____ _ _ ': `Local config file for Socket CLI tool ( ${SOCKET_WEBSITE_URL}/npm/package/${SOCKET_JSON.replace('.json', '')} ), to work with ${SOCKET_WEBSITE_URL}`, - '| __|___ ___| |_ ___| |_ ': - ' The config in this file is used to set as defaults for flags or command args when using the CLI', - "|__ | . | _| '_| -_| _| ": - ' in this dir, often a repo root. You can choose commit or .ignore this file, both works.', - '|_____|___|___|_,_|___|_|.dev': `Warning: This file may be overwritten without warning by \`${SOCKET_JSON.replace('.json', '')} manifest setup\` or other commands`, - version: 1, - } -} - -export function readOrDefaultSocketJson(cwd: string): SocketJson { - const jsonCResult = readSocketJsonSync(cwd, true) - return jsonCResult.ok - ? jsonCResult.data - : // This should be unreachable but it makes TS happy. - getDefaultSocketJson() -} - -export async function readOrDefaultSocketJsonUp( - cwd: string, -): Promise<SocketJson> { - const socketJsonPath = await findSocketJsonUp(cwd) - if (socketJsonPath) { - const socketJsonDir = path.dirname(socketJsonPath) - const jsonCResult = readSocketJsonSync(socketJsonDir, true) - return jsonCResult.ok ? jsonCResult.data : getDefaultSocketJson() - } - return getDefaultSocketJson() -} - -export function readSocketJsonSync( - cwd: string, - defaultOnError = false, -): CResult<SocketJson> { - const sockJsonPath = path.join(cwd, SOCKET_JSON) - if (!existsSync(sockJsonPath)) { - debugNs('notice', `miss: ${SOCKET_JSON} not found at ${cwd}`) - return { ok: true, data: getDefaultSocketJson() } - } - let jsonContent = undefined - try { - jsonContent = readFileSync(sockJsonPath, 'utf8') - } catch (e) { - if (defaultOnError) { - logger.warn(`Failed to read ${SOCKET_JSON}, using default`) - debugNs('warn', `Failed to read ${SOCKET_JSON} sync`) - debugDirNs('warn', e) - return { ok: true, data: getDefaultSocketJson() } - } - const cause = formatErrorWithDetail( - `An error occurred while trying to read ${SOCKET_JSON}`, - e, - ) - debugNs('error', `Failed to read ${SOCKET_JSON} sync`) - debugDirNs('error', e) - return { - ok: false, - message: `Failed to read ${SOCKET_JSON}`, - cause, - } - } - - let jsonObj: SocketJson | undefined - try { - jsonObj = JSON.parse(jsonContent) as SocketJson | undefined - } catch (e) { - debugNs('error', `Failed to parse ${SOCKET_JSON} as JSON (sync)`) - debugDirNs('inspect', { jsonContent }) - debugDirNs('error', e) - if (defaultOnError) { - logger.warn(`Failed to parse ${SOCKET_JSON}, using default`) - return { ok: true, data: getDefaultSocketJson() } - } - return { - ok: false, - message: `Failed to parse ${SOCKET_JSON}`, - cause: `${SOCKET_JSON} does not contain valid JSON, please verify`, - } - } - - if (!jsonObj) { - logger.warn('Warning: file contents was empty, using default') - return { ok: true, data: getDefaultSocketJson() } - } - - // No validation needed: all SocketJson properties are optional and consumers - // must defensively check properties regardless. - return { ok: true, data: jsonObj } -} - -export async function writeSocketJson( - cwd: string, - sockJson: SocketJson, -): Promise<CResult<undefined>> { - let jsonContent = '' - try { - jsonContent = JSON.stringify(sockJson, null, 2) - } catch (e) { - debugNs('error', `Failed to serialize ${SOCKET_JSON} to JSON`) - debugDirNs('inspect', { sockJson }) - debugDirNs('error', e) - return { - ok: false, - message: 'Failed to serialize to JSON', - cause: `There was an unexpected problem converting the ${SOCKET_JSON} object to a JSON string. Unable to store it.`, - } - } - - const filepath = path.join(cwd, SOCKET_JSON) - await writeFile(filepath, `${jsonContent}\n`, 'utf8') - - return { ok: true, data: undefined } -} diff --git a/packages/cli/src/util/socket/org-slug.mts b/packages/cli/src/util/socket/org-slug.mts deleted file mode 100644 index ec8e5ecda..000000000 --- a/packages/cli/src/util/socket/org-slug.mts +++ /dev/null @@ -1,68 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { suggestOrgSlug } from '../../commands/scan/suggest-org-slug.mjs' -import { suggestToPersistOrgSlug } from '../../commands/scan/suggest-to-persist-orgslug.mjs' -import { CONFIG_KEY_DEFAULT_ORG } from '../../constants/config.mjs' -import { V1_MIGRATION_GUIDE_URL } from '../../constants/socket.mts' -import { getConfigValueOrUndef } from '../config.mts' -import { webLink } from '../terminal/link.mjs' -const logger = getDefaultLogger() - -export async function determineOrgSlug( - orgFlag: string, - interactive: boolean, - dryRun: boolean, -): Promise<[string, string | undefined]> { - const defaultOrgSlug = getConfigValueOrUndef(CONFIG_KEY_DEFAULT_ORG) - let orgSlug = String(orgFlag || defaultOrgSlug || '') - if (!orgSlug) { - if (!interactive) { - logger.warn( - 'Note: This command requires an org slug because the Socket API endpoint does.', - ) - logger.warn('') - logger.warn( - 'It seems no default org was setup and the `--org` flag was not used.', - ) - logger.warn( - "Additionally, `--no-interactive` was set so we can't ask for it.", - ) - logger.warn( - 'Since v1.0.0 the org _argument_ for all commands was dropped in favor of an', - ) - logger.warn( - 'implicit default org setting, which will be setup when you run `socket login`.', - ) - logger.warn('') - logger.warn( - 'Note: When running in CI, you probably want to set the `--org` flag.', - ) - logger.warn('') - logger.warn( - `For details, see the ${webLink(V1_MIGRATION_GUIDE_URL, 'v1 migration guide')}`, - ) - logger.warn('') - logger.warn( - 'This command will exit now because the org slug is required to proceed.', - ) - return ['', undefined] - } - - logger.warn( - 'Unable to determine the target org. Trying to auto-discover it now...', - ) - logger.info('Note: Run `socket login` to set a default org.') - logger.error(' Use the --org flag to override the default org.') - logger.error('') - if (dryRun) { - logger.fail('Skipping auto-discovery of org in dry-run mode') - } else { - orgSlug = (await suggestOrgSlug()) || '' - if (orgSlug) { - await suggestToPersistOrgSlug(orgSlug) - } - } - } - - return [orgSlug, defaultOrgSlug] -} diff --git a/packages/cli/src/util/socket/sdk.mts b/packages/cli/src/util/socket/sdk.mts deleted file mode 100644 index 2b603f871..000000000 --- a/packages/cli/src/util/socket/sdk.mts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Socket SDK utilities for Socket CLI. Manages SDK initialization and - * configuration for API communication. - * - * Authentication: - * - * - Interactive password prompt for missing tokens - * - Supports environment variable (SOCKET_CLI_API_TOKEN) - * - Validates token format and presence - * - * Proxy Support: - * - * - Automatic proxy agent selection - * - HTTP/HTTPS proxy configuration - * - Respects SOCKET_CLI_API_PROXY environment variable - * - * SDK Setup: - * - * - CreateSocketSdk: Create configured SDK instance - * - GetDefaultApiToken: Retrieve API token from config/env - * - GetDefaultProxyUrl: Retrieve proxy URL from config/env - * - GetPublicApiToken: Get public API token constant - * - SetupSdk: Initialize Socket SDK with authentication - * - * User Agent: - * - * - Automatic user agent generation from package.json - * - Includes CLI version and platform information - */ - -import { readFileSync } from 'node:fs' -import { Agent as HttpsAgent } from 'node:https' -import { rootCertificates } from 'node:tls' - -import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent' - -import { debug as debugLib } from '@socketsecurity/lib-stable/debug/output' -import isInteractive from '@socketregistry/is-interactive/index.cjs' -import { getSocketApiToken } from '@socketsecurity/lib-stable/env/socket' -import { - getSocketCliApiBaseUrl, - getSocketCliApiProxy, - getSocketCliApiTimeout, - getSocketCliNoApiToken, -} from '@socketsecurity/lib-stable/env/socket-cli' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { password } from '@socketsecurity/lib-stable/stdio/prompts' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' -import { isUrl } from '@socketsecurity/lib-stable/url/predicates' -import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' -import { SocketSdk, createUserAgentFromPkgJson } from '@socketsecurity/sdk-stable' - -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, -} from '../../constants/config.mts' -import { getCliHomepage } from '../../env/cli-homepage.mts' -import { getCliName } from '../../env/cli-name.mts' -import { getCliVersion } from '../../env/cli-version.mts' -import { SOCKET_CLI_DEBUG } from '../../env/socket-cli-debug.mts' -import { TOKEN_PREFIX_LENGTH } from '../../constants/socket.mts' -import { getConfigValueOrUndef } from '../config.mts' -import { debugApiRequest, debugApiResponse } from '../debug.mts' -import { trackCliEvent } from '../telemetry/integration.mts' - -import type { CResult } from '../../types.mts' -import type { - FileValidationResult, - RequestInfo, - ResponseInfo, -} from '@socketsecurity/sdk-stable' -const logger = getDefaultLogger() - -const TOKEN_VISIBLE_LENGTH = 5 - -// Cached extra CA certificates for SSL_CERT_FILE support. -let extraCaCerts: string[] | undefined - -let extraCaCertsResolved = false - -// This Socket API token should be stored globally for the duration of the CLI execution. -let defaultToken: string | undefined - -// The Socket API server that should be used for operations. -export function getDefaultApiBaseUrl(): string | undefined { - const baseUrl = - getSocketCliApiBaseUrl() || - getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) || - undefined - return isUrl(baseUrl) ? baseUrl : undefined -} - -export function getDefaultApiToken(): string | undefined { - if (getSocketCliNoApiToken()) { - defaultToken = undefined - return defaultToken - } - - const key = - getSocketApiToken() || - getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) || - defaultToken - - defaultToken = isNonEmptyString(key) ? key : undefined - return defaultToken -} - -// The Socket API server that should be used for operations. -export function getDefaultProxyUrl(): string | undefined { - const apiProxy = - getSocketCliApiProxy() || - getConfigValueOrUndef(CONFIG_KEY_API_PROXY) || - undefined - return isUrl(apiProxy) ? apiProxy : undefined -} - -// Returns combined root and extra CA certificates when SSL_CERT_FILE is set -// but NODE_EXTRA_CA_CERTS is not. Node.js loads NODE_EXTRA_CA_CERTS at process -// startup, so setting SSL_CERT_FILE alone does not affect the current process. -// This function reads the certificate file manually and combines it with the -// default root certificates for use in HTTPS agents. -export function getExtraCaCerts(): string[] | undefined { - if (extraCaCertsResolved) { - return extraCaCerts - } - extraCaCertsResolved = true - // Node.js already loaded extra CA certs at startup. - if (process.env['NODE_EXTRA_CA_CERTS']) { - return undefined - } - const certPath = process.env['SSL_CERT_FILE'] - if (!certPath) { - return undefined - } - /* c8 ignore start - SSL_CERT_FILE is not set in tests; this entire CA-cert loader is unreachable */ - try { - const extraCerts = readFileSync(certPath, 'utf-8') - // Combine default root certificates with extra certificates. Specifying ca - // in an agent replaces the default trust store, so both must be included. - extraCaCerts = [...rootCertificates, extraCerts] - return extraCaCerts - } catch (e) { - debugLib(`Failed to read certificate file: ${certPath}`) - return undefined - } - /* c8 ignore stop */ -} - -export function getVisibleTokenPrefix(): string { - const apiToken = getDefaultApiToken() - return apiToken - ? apiToken.slice( - TOKEN_PREFIX_LENGTH, - TOKEN_PREFIX_LENGTH + TOKEN_VISIBLE_LENGTH, - ) - : '' -} - -export function hasDefaultApiToken(): boolean { - return !!getDefaultApiToken() -} - -export function invalidateDefaultApiToken(): void { - defaultToken = undefined -} - -export type SetupSdkOptions = { - apiBaseUrl?: string | undefined - apiProxy?: string | undefined - apiToken?: string | undefined -} - -export async function setupSdk( - options?: SetupSdkOptions | undefined, -): Promise<CResult<SocketSdk>> { - const opts = { __proto__: null, ...options } as SetupSdkOptions - let { apiToken = getDefaultApiToken() } = opts - - /* c8 ignore start - interactive password prompt only fires in TTY mode; tests are non-interactive */ - if (typeof apiToken !== 'string' && isInteractive()) { - apiToken = await password({ - message: - 'Enter your Socket.dev API token (not saved, use socket login to persist)', - }) - defaultToken = apiToken - } - /* c8 ignore stop */ - - if (!apiToken) { - return { - ok: false, - message: 'Auth Error', - cause: 'You need to provide an API token. Run `socket login` first.', - } - } - - let { apiProxy } = opts - if (!isUrl(apiProxy)) { - apiProxy = getDefaultProxyUrl() - } - - const { apiBaseUrl = getDefaultApiBaseUrl() } = opts - - // Usage of HttpProxyAgent vs. HttpsProxyAgent based on the chart at: - // https://github.com/delvedor/hpagent?tab=readme-ov-file#usage - const ProxyAgent = apiBaseUrl?.startsWith('http:') - ? HttpProxyAgent - : HttpsProxyAgent - - const timeout = getSocketCliApiTimeout() || undefined - - // Load extra CA certificates for SSL_CERT_FILE support when - // NODE_EXTRA_CA_CERTS was not set at process startup. - const ca = getExtraCaCerts() - - const sdkOptions = { - ...(apiProxy - ? { - agent: new ProxyAgent({ - proxy: apiProxy, - ...(ca ? { ca, proxyConnectOptions: { ca } } : {}), - }), - } - : ca - ? { agent: new HttpsAgent({ ca }) } - : {}), - ...(apiBaseUrl ? { baseUrl: apiBaseUrl } : {}), - ...(timeout ? { timeout } : {}), - // Add HTTP request hooks for telemetry and debugging. - hooks: { - onRequest: (info: RequestInfo) => { - // Skip tracking for telemetry submission endpoints to prevent infinite loop. - const isTelemetryEndpoint = info.url.includes('/telemetry') - - /* c8 ignore start - SOCKET_CLI_DEBUG not set in tests */ - if (SOCKET_CLI_DEBUG) { - debugApiRequest(info.method, info.url, info.timeout) - } - /* c8 ignore stop */ - if (!isTelemetryEndpoint) { - // Track API request event. - void trackCliEvent('api_request', process.argv, { - method: info.method, - timeout: info.timeout, - url: info.url, - }) - } - }, - onResponse: (info: ResponseInfo) => { - // Skip tracking for telemetry submission endpoints to prevent infinite loop. - const isTelemetryEndpoint = info.url.includes('/telemetry') - - if (!isTelemetryEndpoint) { - // Track API response event. - const metadata = { - duration: info.duration, - method: info.method, - status: info.status, - statusText: info.statusText, - url: info.url, - } - - if (info.error) { - // Track as error event if request failed. - void trackCliEvent('api_error', process.argv, { - ...metadata, - error_message: info.error.message, - error_type: info.error.constructor.name, - }) - } else { - // Track as successful response. - void trackCliEvent('api_response', process.argv, metadata) - } - } - - /* c8 ignore start - SOCKET_CLI_DEBUG not set in tests */ - if (SOCKET_CLI_DEBUG) { - debugApiResponse(info.url, info.status, info.error, { - method: info.method, - url: info.url, - durationMs: info.duration, - headers: info.headers, - }) - } - /* c8 ignore stop */ - }, - }, - onFileValidation: ( - _validPaths: string[], - invalidPaths: string[], - _context: { - operation: - | 'createDependenciesSnapshot' - | 'createFullScan' - | 'uploadManifestFiles' - orgSlug?: string | undefined - [key: string]: unknown - }, - ): FileValidationResult => { - if (invalidPaths.length > 0) { - logger.warn( - `Skipped ${invalidPaths.length} ${pluralize('file', { count: invalidPaths.length })} that could not be read`, - ) - logger.substep( - 'This may occur with Yarn Berry PnP virtual filesystem or pnpm symlinks', - ) - } - // Continue with valid files. - return { shouldContinue: true } - }, - userAgent: createUserAgentFromPkgJson({ - name: getCliName(), - version: getCliVersion(), - homepage: getCliHomepage(), - }), - } - - /* c8 ignore start - SOCKET_CLI_DEBUG not set in tests */ - if (SOCKET_CLI_DEBUG) { - logger.info( - `[DEBUG] ${new Date().toISOString()} SDK options: ${JSON.stringify(sdkOptions)}`, - ) - } - /* c8 ignore stop */ - - const sdk = new SocketSdk(apiToken, sdkOptions) - - return { - ok: true, - data: sdk, - } -} diff --git a/packages/cli/src/util/socket/url.mts b/packages/cli/src/util/socket/url.mts deleted file mode 100644 index fcc0ea31b..000000000 --- a/packages/cli/src/util/socket/url.mts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Socket.dev URL utilities for Socket CLI. Generates URLs for Socket.dev - * website features and resources. - * - * Key Functions: - getPkgFullNameFromPurl: Extract full package name from PURL - * - getSocketDevAlertUrl: Generate alert type documentation URL - - * getSocketDevPackageOverviewUrl: Generate package overview URL - - * getSocketDevPackageOverviewUrlFromPurl: Generate overview URL from PURL - - * getSocketDevPackageUrl: Generate package detail URL - - * getSocketDevPackageUrlFromPurl: Generate package URL from PURL - - * getSocketDevReportUrl: Generate scan report URL. - * - * URL Generation: - Package overview and detail pages - Security alert - * documentation - Scan report links - Ecosystem-specific URL formatting. - */ - -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mjs' -import { getPurlObject } from '../purl/parse.mts' - -import type { SocketArtifact } from '../alert/artifact.mts' -import type { PURL_Type } from '../ecosystem/types.mjs' -import type { PackageURL } from '@socketregistry/packageurl-js-stable' - -export function getPkgFullNameFromPurl( - purl: string | PackageURL | SocketArtifact, -): string { - const purlObj = getPurlObject(purl) - const { name, namespace } = purlObj - return namespace - ? `${namespace}${purlObj.type === 'maven' ? ':' : '/'}${name}` - : name! -} - -export function getSocketDevAlertUrl(alertType: string): string { - return `${SOCKET_WEBSITE_URL}/alerts/${alertType}` -} - -export function getSocketDevPackageOverviewUrl( - ecosystem: PURL_Type, - fullName: string, - version?: string | undefined, -): string { - const url = `${SOCKET_WEBSITE_URL}/${ecosystem}/package/${fullName}` - return ecosystem === 'golang' - ? `${url}${version ? `?section=overview&version=${version}` : ''}` - : `${url}${version ? `/overview/${version}` : ''}` -} - -export function getSocketDevPackageOverviewUrlFromPurl( - purl: string | PackageURL | SocketArtifact, -): string { - const purlObj = getPurlObject(purl) - const fullName = getPkgFullNameFromPurl(purlObj) - return getSocketDevPackageOverviewUrl(purlObj.type, fullName, purlObj.version) -} diff --git a/packages/cli/src/util/spawn/apply-machine-mode.mts b/packages/cli/src/util/spawn/apply-machine-mode.mts deleted file mode 100644 index d98b6b20e..000000000 --- a/packages/cli/src/util/spawn/apply-machine-mode.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Helper that layers the per-tool machine-mode forwarding table on top of an - * existing spawn invocation. Safe to call unconditionally — when ambient - * machine-output mode is off, returns the inputs unchanged. - */ - -import { getMachineOutputMode } from '../output/ambient-mode.mts' -import { applyMachineMode as rawApply } from './machine-mode.mts' - -import type { MachineModeInput, MachineModeOutput } from './machine-mode.mts' - -/** - * Apply machine-mode flag forwarding + env injection when ambient mode is - * engaged. Otherwise pass through unchanged. - */ -export function applyMachineModeIfActive( - input: MachineModeInput, -): MachineModeOutput { - if (!getMachineOutputMode()) { - return { - args: [...input.args], - env: { ...input.env }, - } - } - return rawApply(input) -} - -/** - * Heuristic for "what's the subcommand" from an argv array. The first non-flag - * token is the subcommand (npm install, pnpm ls, yarn add, etc.). Returns - * undefined if args starts with a flag or is empty. - */ -export function inferSubcommand(args: readonly string[]): string | undefined { - for (let i = 0, { length } = args; i < length; i += 1) { - const arg = args[i]! - if (!arg.startsWith('-')) { - return arg - } - } - return undefined -} diff --git a/packages/cli/src/util/spawn/machine-mode.mts b/packages/cli/src/util/spawn/machine-mode.mts deleted file mode 100644 index 04355a324..000000000 --- a/packages/cli/src/util/spawn/machine-mode.mts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Machine-mode flag propagation for spawned child processes. - * - * Under machine-output mode, socket-cli promises stdout is payload-only. For - * child tools we spawn, we need to propagate that promise downward: - * - * - Tools with `--json` / equivalent → prepend the flag. - * - Tools with `--silent` / `--quiet` → prepend the flag to suppress - * informational chatter. - * - Tools that honor color env vars → inject NO_COLOR / FORCE_COLOR so ANSI - * doesn't contaminate captured stdout. - * - Tools that always misbehave → leave args alone and rely on the scrubber to - * clean up. - * - * Coverage is per (tool, subcommand). Flag forwarding is best-effort: unknown - * subcommands get universal env vars only, no args injected (safer than risking - * "unknown option" failures from strict parsers like clipanion). - */ - -export interface MachineModeInput { - tool: string - subcommand?: string | undefined - args: readonly string[] - env?: NodeJS.ProcessEnv | undefined -} - -export interface MachineModeOutput { - args: string[] - env: NodeJS.ProcessEnv -} - -/** - * Universal env vars applied to every spawned child under machine mode. Most - * tools respect at least one of these. - */ -const UNIVERSAL_ENV: NodeJS.ProcessEnv = { - __proto__: undefined as never, - CLICOLOR_FORCE: '0', - FORCE_COLOR: '0', - NO_COLOR: '1', -} - -interface ToolRules { - /** - * Env vars specific to this tool. Merged on top of UNIVERSAL_ENV. - */ - env?: NodeJS.ProcessEnv | undefined - /** - * Args used when the subcommand has no entry in `subcommands` (or - * `subcommand` is omitted). Also applied to tools without a `subcommands` map - * when `subcommand` is provided. Use when a tool needs different flags in the - * JSON-emitting and non-JSON-emitting cases and the flags would conflict if - * emitted together (e.g. pnpm's `--reporter=json` vs `--reporter=silent`). - */ - fallbackArgs?: readonly string[] | undefined - /** - * Args to prepend unconditionally for this tool, regardless of subcommand. - * Use for tools with uniform support (e.g. vlt accepts --view=json on every - * subcommand) or tool-wide quiet flags that do not conflict with any - * per-subcommand args. - */ - prependArgs?: readonly string[] | undefined - /** - * Per-subcommand arg forwarding. Key is the subcommand (the first non-flag - * argv). Value is args inserted AFTER prependArgs and BEFORE the caller's - * original args. - */ - subcommands?: Record<string, readonly string[]> | undefined -} - -const NPM_JSON_CMDS: readonly string[] = [ - 'audit', - 'config', - 'ls', - 'outdated', - 'pack', - 'query', - 'view', -] - -const PNPM_JSON_CMDS: readonly string[] = [ - 'add', - 'audit', - 'install', - 'licenses', - 'list', - 'ls', - 'outdated', - 'remove', - 'update', - 'why', -] - -const YARN_CLASSIC_JSON_CMDS: readonly string[] = [ - 'add', - 'audit', - 'info', - 'install', - 'list', - 'outdated', - 'remove', - 'upgrade', - 'versions', - 'why', -] - -const YARN_BERRY_JSON_CMDS: readonly string[] = [ - 'add', - 'bin', - 'config', - 'constraints', - 'dedupe', - 'explain', - 'info', - 'install', - 'npm', - 'pack', - 'patch', - 'plugin', - 'run', - 'unplug', - 'version', - 'why', - 'workspaces', -] - -const ZPM_JSON_CMDS: readonly string[] = [ - 'config', - 'constraints', - 'dedupe', - 'info', - 'npm', - 'pack', - 'patch', - 'tasks', - 'version', - 'why', - 'workspaces', -] - -const GO_JSON_CMDS: readonly string[] = ['build', 'list', 'test', 'vet'] - -const TOOLS: Record<string, ToolRules> = { - __proto__: undefined as never, - cargo: { - prependArgs: ['-q'], - }, - cdxgen: { - // Data comes via -o <file>, not stdout. Caller arranges the - // tempfile; here we just suppress stdout chatter where possible. - }, - coana: { - // Caller wires --silent --socket-mode <tempfile>; there's nothing - // to prepend here (socket-mode takes a file path computed at the - // call site). - }, - gem: { - prependArgs: ['--quiet', '--no-color'], - }, - go: { - subcommands: Object.fromEntries(GO_JSON_CMDS.map(c => [c, ['-json']])), - }, - npm: { - prependArgs: ['--loglevel=error'], - subcommands: Object.fromEntries(NPM_JSON_CMDS.map(c => [c, ['--json']])), - }, - nuget: { - // Only pack/push accept --json. Scrubber handles the rest. - }, - pip: { - env: { PIP_NO_COLOR: '1' }, - prependArgs: ['-q'], - }, - pip3: { - env: { PIP_NO_COLOR: '1' }, - prependArgs: ['-q'], - }, - pnpm: { - // --reporter is applied per-subcommand: `json` for JSON-emitting - // subcommands, `silent` for everything else. Emitting both relies on - // pnpm's last-wins flag parsing (an undocumented implementation - // detail) and risks warnings or rejection in future pnpm versions. - // - // Non-JSON subcommands inherit `--reporter=silent` via the - // fallback path in applyMachineMode. - subcommands: Object.fromEntries( - PNPM_JSON_CMDS.map(c => [c, ['--reporter=json']]), - ), - fallbackArgs: ['--reporter=silent'], - }, - sfw: { - // Transparent proxy — nothing to add at the sfw level; inner tool - // rules apply when callers classify the inner command. - }, - 'socket-patch': { - // Opaque Rust binary; scrubber catches anything it emits. - }, - synp: { - // No flags exist; scrubber adapter handles the "Created ..." line. - }, - uv: { - prependArgs: ['--quiet'], - }, - vlt: { - prependArgs: ['--view=json'], - }, - vltpkg: { - prependArgs: ['--view=json'], - }, - yarn: { - // Classic v1. For Berry/v4 and zpm/v6 callers should use keys - // 'yarn-berry' or 'zpm' instead. - subcommands: Object.fromEntries( - YARN_CLASSIC_JSON_CMDS.map(c => [c, ['--json', '--silent']]), - ), - }, - 'yarn-berry': { - env: { - YARN_ENABLE_COLORS: '0', - YARN_ENABLE_HYPERLINKS: '0', - YARN_ENABLE_INLINE_BUILDS: '0', - YARN_ENABLE_MESSAGE_NAMES: '0', - YARN_ENABLE_PROGRESS_BARS: '0', - }, - subcommands: Object.fromEntries( - YARN_BERRY_JSON_CMDS.map(c => [c, ['--json']]), - ), - }, - zpm: { - subcommands: { - ...Object.fromEntries(ZPM_JSON_CMDS.map(c => [c, ['--json']])), - add: ['--silent'], - install: ['--silent'], - }, - }, -} - -/** - * Compute the spawn args and env for a child under machine-output mode. - * Preserves the caller's original args and env; additions merge in on top. - * - * For per-subcommand tools, the subcommand is expected in `input.subcommand`; - * if omitted, only universal env vars and the tool's unconditional - * `prependArgs` apply (no subcommand-specific flags are injected, preventing - * "unknown option" errors on unrecognized subcommands). - */ -export function applyMachineMode(input: MachineModeInput): MachineModeOutput { - const rules = TOOLS[input.tool] - if (!rules) { - return { - args: [...input.args], - env: mergeEnv(input.env, {}), - } - } - const extraEnv = rules.env ?? {} - const args: string[] = [] - if (rules.prependArgs) { - args.push(...rules.prependArgs) - } - const subcommandArgs = input.subcommand - ? rules.subcommands?.[input.subcommand] - : undefined - if (subcommandArgs) { - args.push(...subcommandArgs) - } else if (rules.fallbackArgs) { - args.push(...rules.fallbackArgs) - } - args.push(...input.args) - return { - args, - env: mergeEnv(input.env, extraEnv), - } -} - -export function mergeEnv( - base: NodeJS.ProcessEnv | undefined, - overrides: NodeJS.ProcessEnv, -): NodeJS.ProcessEnv { - return { ...base, ...UNIVERSAL_ENV, ...overrides } -} diff --git a/packages/cli/src/util/spawn/spawn-node.mts b/packages/cli/src/util/spawn/spawn-node.mts deleted file mode 100644 index 4bd8478bf..000000000 --- a/packages/cli/src/util/spawn/spawn-node.mts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Node.js spawn abstraction with SEA bootstrap handling. - * - * Provides a `spawnNode()` function that automatically handles: - * - * - System Node.js detection and delegation (when in SEA) - * - Self-spawning with IPC handshake (when no system Node.js available) - * - Regular process.execPath spawning (when not in SEA) - * - * This abstraction should be used anywhere we need to spawn Node.js, replacing - * direct calls to spawn(process.execPath, ...) or spawn(getExecPath(), ...). - * - * Example usage: - * - * ```typescript - * // Instead of: - * spawn(getExecPath(), ['script.js', ...args], { stdio: 'inherit' }) - * - * // Use: - * spawnNode(['script.js', ...args], { stdio: 'inherit' }) - * ``` - */ - -import { whichRealSync } from '@socketsecurity/lib-stable/bin/which' -import { getExecPath } from '@socketsecurity/lib-stable/constants/node' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { sendBootstrapHandshake } from '../sea/boot.mjs' -import { isSeaBinary } from '../sea/detect.mjs' - -import type { StdioOptions } from 'node:child_process' -import type { - SpawnExtra, - SpawnOptions, - SpawnResult, -} from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Narrows a spawned process to the shape required by `sendBootstrapHandshake` - * (i.e. `.send` is a callable, not undefined). The typeof-on-a-property guard - * can't flow to the parent object, so we need an explicit assertion function. - */ -export function assertHasSend<T extends { send?: unknown | undefined }>( - proc: T, -): asserts proc is T & { send: (message: unknown) => void } { - if (typeof proc.send !== 'function') { - throw new TypeError( - 'spawn-node: expected IPC channel on child process (send is undefined)', - ) - } -} - -/** - * Ensures stdio configuration includes IPC channel for process communication. - * Converts various stdio formats to include 'ipc' as the fourth element. - */ -export function ensureIpcInStdio( - stdio: StdioOptions | undefined, -): StdioOptions { - if (typeof stdio === 'string') { - return [stdio, stdio, stdio, 'ipc'] - } - if (Array.isArray(stdio)) { - if (!stdio.includes('ipc')) { - return stdio.concat('ipc') - } - return stdio.slice() - } - return ['pipe', 'pipe', 'pipe', 'ipc'] -} - -/** - * Find system Node.js binary in PATH (excluding the current SEA binary). - * - * Returns the path to system Node.js if found, undefined otherwise. - * - * @returns Path to system Node.js, or undefined - */ -export function findSystemNodejsSync(): string | undefined { - // Use which to find 'node' in PATH (returns all matches). - const nodePath = whichRealSync('node', { all: true, nothrow: true }) - - if (!nodePath) { - return undefined - } - - // which with all:true returns string[] if multiple matches, string if single match. - const nodePaths = Array.isArray(nodePath) ? nodePath : [nodePath] - - // Find first Node.js that isn't our SEA binary. - const currentExecPath = process.execPath - const systemNode = nodePaths.find(p => p !== currentExecPath) - - return systemNode -} - -/** - * Get the Node.js executable path to use for spawning. - * - * Priority: 1. System Node.js (if we're a SEA and system Node.js exists) 2. - * Current execPath (process.execPath) - * - * @returns Path to Node.js executable - */ -export function getNodeExecutablePathSync(): string { - // If not a SEA, use standard getExecPath(). - if (!isSeaBinary()) { - return getExecPath() - } - - // For SEA binaries, try to find system Node.js. - const systemNode = findSystemNodejsSync() - if (systemNode) { - return systemNode - } - - // Fall back to SEA binary itself (will use IPC handshake). - return process.execPath -} - -/** - * Options for spawnNode, extending SpawnOptions with IPC handshake data. - */ -export interface SpawnNodeOptions extends SpawnOptions { - /** - * Additional IPC handshake data to send to subprocess. - * - * This is placed in the `extra` field of the handshake message to avoid - * collision with standard fields (subprocess, parent_pid). - * - * Final handshake structure: { subprocess: true, parent_pid: <pid>, extra: { - * ...ipc } // Custom data goes here } - * - * Use this to pass custom configuration to the subprocess: - * - * - Socket Firewall settings (API token, bin name, etc.) - * - Custom application data - * - * System Node.js will ignore the handshake message. SEA subprocess will use - * it to skip bootstrap. - */ - ipc?: Record<string, unknown> | undefined -} - -/** - * Spawn Node.js with automatic SEA bootstrap handling. - * - * Behavior: - Not a SEA: Uses process.execPath directly - SEA with system - * Node.js: Uses system Node.js - SEA without system Node.js: Spawns self with - * IPC handshake. - * - * @param args - Arguments to pass to Node.js (script path + args) - * @param options - Spawn options, including optional IPC data. - * @param extra - Extra spawn options (from @socketsecurity/lib/spawn) - * - * @returns Spawn result with process handle - */ -export function spawnNode( - args: string[] | readonly string[], - options?: SpawnNodeOptions | undefined, - extra?: SpawnExtra | undefined, -): SpawnResult { - const { ipc, ...spawnOpts } = { - __proto__: null, - ...options, - } as SpawnNodeOptions - - // Get the Node.js executable path to use. - const nodePath = getNodeExecutablePathSync() - - // Spawn the Node.js process. - const spawnResult = spawn( - nodePath, - args, - { - ...(spawnOpts as SpawnOptions), - // Always ensure stdio includes 'ipc' for handshake. - // System Node.js will ignore the handshake message. - // SEA subprocess will use it to skip bootstrap. - stdio: ensureIpcInStdio((spawnOpts as SpawnOptions).stdio), - }, - extra, - ) - - // `ensureIpcInStdio` above guarantees an IPC channel in stdio, so - // `.send` should always be a function here. Narrow explicitly via an - // assertion function so the call site doesn't need a structural cast. - assertHasSend(spawnResult.process) - sendBootstrapHandshake( - spawnResult.process, - // Always send IPC handshake with bootstrap indicators + custom data. - { - subprocess: true, - parent_pid: process.pid, - // Custom IPC data in extra field to avoid collision with standard fields. - ...(ipc ? { extra: { ...ipc } } : {}), - }, - ) - - return spawnResult -} - diff --git a/packages/cli/src/util/telemetry/integration.mts b/packages/cli/src/util/telemetry/integration.mts deleted file mode 100644 index 8ed972d02..000000000 --- a/packages/cli/src/util/telemetry/integration.mts +++ /dev/null @@ -1,582 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -/** - * Telemetry integration helpers for Socket CLI. Provides utilities for tracking - * common CLI events and subprocess executions. - * - * Usage: - * - * ```typescript - * import { - * setupTelemetryExitHandlers, - * finalizeTelemetry, - * finalizeTelemetrySync, - * trackCliStart, - * trackCliEvent, - * trackCliComplete, - * trackCliError, - * trackSubprocessStart, - * trackSubprocessComplete, - * trackSubprocessError, - * } from './util/telemetry/integration.mts' - * - * // Set up exit handlers once during CLI initialization. - * setupTelemetryExitHandlers() - * - * // Track main CLI execution. - * const startTime = await trackCliStart(process.argv) - * await trackCliComplete(process.argv, startTime, 0) - * - * // Track custom event with optional metadata. - * await trackCliEvent('custom_event', process.argv, { key: 'value' }) - * - * // Track subprocess/forked CLI execution. - * const subStart = await trackSubprocessStart('npm', { cwd: '/path' }) - * await trackSubprocessComplete('npm', subStart, 0, { - * stdout_length: 1234, - * }) - * - * // On subprocess error. - * await trackSubprocessError('npm', subStart, error, 1) - * - * // Manual finalization (usually not needed if exit handlers are set up). - * await finalizeTelemetry() // Async version. - * finalizeTelemetrySync() // Sync version (best-effort). - * ``` - */ -import os from 'node:os' -import process from 'node:process' - -import { debugNs } from '@socketsecurity/lib-stable/debug/output' -import { isError } from '@socketsecurity/lib-stable/errors/predicates' -import { escapeRegExp } from '@socketsecurity/lib-stable/regexps/escape' - -import { TelemetryService } from './service.mts' -import { CONFIG_KEY_DEFAULT_ORG, constants } from '../../constants.mts' -import { getConfigValueOrUndef } from '../config.mts' - -import type { TelemetryContext } from './types.mts' - -// Track whether exit handlers have been set up to prevent duplicate registration. -let exitHandlersRegistered = false - -// Add other subcommands -const WRAPPER_CLI = new Set(['bun', 'npm', 'npx', 'pip', 'pnpm', 'vlt', 'yarn']) - -// Add other sensitive flags -const API_TOKEN_FLAGS = new Set(['--api-token', '--token', '-t']) - -/** - * Build context for the current telemetry entry. - * - * The context contains the current execution context, in which all CLI - * invocation should have access to. - * - * @param argv Command line arguments. - * - * @returns Telemetry context object. - */ -export function buildContext(argv: string[]): TelemetryContext { - return { - arch: process.arch, - argv: sanitizeArgv(argv), - node_version: process.version, - platform: process.platform, - version: constants.ENV.INLINED_VERSION, - } -} - -/** - * Calculate duration from start timestamp. - * - * @param startTime - Start timestamp from Date.now(). - * - * @returns Duration in milliseconds. - */ -export function calculateDuration(startTime: number): number { - return Date.now() - startTime -} - -/** - * Debug wrapper for telemetry integration. - */ -export function debug(message: string): void { - debugNs('socket:telemetry:integration', message) -} - -/** - * Finalize telemetry and clean up resources (async version). This should be - * called before process.exit to ensure telemetry is sent and resources are - * cleaned up. Use this in async contexts like beforeExit handlers. - * - * @returns Promise that resolves when finalization completes. - */ -export async function finalizeTelemetry(): Promise<void> { - const instance = TelemetryService.getCurrentInstance() - if (instance) { - debug('Flushing telemetry') - await instance.flush() - } -} - -/** - * Finalize telemetry synchronously (best-effort). This triggers a flush without - * awaiting it. Use this in synchronous contexts like signal handlers where - * async operations are not possible. - * - * Note: This is best-effort only. Events may be lost if the process exits - * before flush completes. Prefer finalizeTelemetry() (async version) when - * possible. - */ -export function finalizeTelemetrySync(): void { - const instance = TelemetryService.getCurrentInstance() - if (instance) { - debug('Triggering sync flush (best-effort)') - void instance.flush() - } -} - -/** - * Normalize error to Error object. - * - * @param error - Unknown error value. - * - * @returns Error object. - */ -export function normalizeError(error: unknown): Error { - return isError(error) ? error : new Error(String(error)) -} - -/** - * Normalize exit code to a number with default fallback. - * - * @param exitCode - Exit code (may be string, number, null, or undefined). - * @param defaultValue - Default value if exitCode is not a number. - * - * @returns Normalized exit code. - */ -export function normalizeExitCode( - exitCode: string | number | null | undefined, - defaultValue: number, -): number { - return typeof exitCode === 'number' ? exitCode : defaultValue -} - -/** - * Sanitize argv to remove sensitive information. Removes API tokens, file paths - * with usernames, and other PII. Also strips arguments after wrapper CLIs to - * avoid leaking package names. - * - * @example - * // Input: ['node', 'socket', 'npm', 'install', '@my/private-package', '--token', 'fake-token'] - * // Output: ['npm', 'install'] - * - * @param argv Raw command line arguments (full process.argv including execPath - * and script). - * - * @returns Sanitized argv array. - */ -export function sanitizeArgv(argv: string[]): string[] { - // Strip the first two values to drop the execPath and script. - const withoutPathAndScript = argv.slice(2) - - // Then strip arguments after wrapper CLIs to avoid leaking package names. - const wrapperIndex = withoutPathAndScript.findIndex(arg => - WRAPPER_CLI.has(arg), - ) - let strippedArgv = withoutPathAndScript - - if (wrapperIndex !== -1) { - // Keep only wrapper + first command (e.g., ['npm']). - const endIndex = wrapperIndex + 1 - strippedArgv = withoutPathAndScript.slice(0, endIndex) - } - - // Then sanitize remaining arguments. - return strippedArgv.map((arg, index) => { - // Check if previous arg was an API token flag. - if (index > 0) { - const prevArg = strippedArgv[index - 1] - if (prevArg && API_TOKEN_FLAGS.has(prevArg)) { - return '[REDACTED]' - } - } - - // Redact anything that looks like a socket API token. - if (arg.startsWith('sktsec_') || arg.match(/^[a-f0-9]{32,}$/i)) { - return '[REDACTED]' - } - - // Remove user home directory from file paths. - const homeDir = os.homedir() - if (homeDir) { - return arg.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~') - } - - return arg - }) -} - -/** - * Sanitize error attribute to remove user specific paths. Replaces user home - * directory and other sensitive paths. - * - * @param input Raw input. - * - * @returns Sanitized input. - */ -export function sanitizeErrorAttribute( - input: string | undefined, -): string | undefined { - if (!input) { - return undefined - } - - // Remove user home directory. - const homeDir = os.homedir() - if (homeDir) { - return input.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~') - } - - return input -} - -/** - * Set up exit handlers for telemetry finalization. This registers handlers for - * both normal exits (beforeExit) and common fatal signals. - * - * Flushing strategy: - Batch-based: Auto-flush when queue reaches 10 events. - - * beforeExit: Async handler for clean shutdowns (when event loop empties). - - * Fatal signals (SIGINT, SIGTERM, SIGHUP): Best-effort sync flush. - Accepts - * that forced exits (SIGKILL, process.exit()) may lose final events. - * - * Call this once during CLI initialization to ensure telemetry is flushed on - * exit. Safe to call multiple times - only registers handlers once. - * - * @example - * ```typescript - * // In src/cli.mts - * setupTelemetryExitHandlers() - * ``` - */ -export function setupTelemetryExitHandlers(): void { - // Prevent duplicate handler registration. - if (exitHandlersRegistered) { - debug('Telemetry exit handlers already registered, skipping') - return - } - - exitHandlersRegistered = true - - // Use beforeExit for async finalization during clean shutdowns. - // This fires when the event loop empties but before process actually exits. - process.on('beforeExit', () => { - /* c8 ignore start - beforeExit handler body fires only on real process exit; vitest workers don't trigger it */ - debug('beforeExit handler triggered') - void finalizeTelemetry() - /* c8 ignore stop */ - }) - - // Register handlers for common fatal signals as best-effort fallback. - // These are synchronous contexts, so we can only trigger flush without awaiting. - const fatalSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'] - - for (let i = 0, { length } = fatalSignals; i < length; i += 1) { - const signal = fatalSignals[i]! - try { - process.on(signal, () => { - /* c8 ignore start - signal handler body fires only on real signal delivery; tests don't dispatch signals */ - debug(`Signal ${signal} received, attempting sync flush`) - finalizeTelemetrySync() - /* c8 ignore stop */ - }) - /* c8 ignore start - process.on rarely throws for SIGINT/SIGTERM/SIGHUP; cross-platform defensive */ - } catch (e) { - // Some signals may not be available on all platforms. - debug(`Failed to register handler for signal ${signal}: ${e}`) - } - /* c8 ignore stop */ - } - - debug('Telemetry exit handlers registered (beforeExit + common signals)') -} - -/** - * Track CLI completion event. Should be called on successful CLI exit. Flushes - * immediately since this is typically the last event before process exit. - * - * @param argv - * @param startTime Start timestamp from trackCliStart. - * @param exitCode Process exit code (default: 0). - */ -export async function trackCliComplete( - argv: string[], - startTime: number, - exitCode?: string | number | undefined | null, -): Promise<void> { - debug('Capture end of command') - - await trackEvent( - 'cli_complete', - buildContext(argv), - { - duration: calculateDuration(startTime), - exit_code: normalizeExitCode(exitCode, 0), - }, - { - flush: true, - }, - ) -} - -/** - * Track CLI error event. Should be called when CLI exits with an error. Flushes - * immediately since this is typically the last event before process exit. - * - * @param argv - * @param startTime Start timestamp from trackCliStart. - * @param error Error that occurred. - * @param exitCode Process exit code (default: 1). - */ -export async function trackCliError( - argv: string[], - startTime: number, - error: unknown, - exitCode?: number | string | undefined | null, -): Promise<void> { - debug('Capture error and stack trace of command') - - await trackEvent( - 'cli_error', - buildContext(argv), - { - duration: calculateDuration(startTime), - exit_code: normalizeExitCode(exitCode, 1), - }, - { - error: normalizeError(error), - flush: true, - }, - ) -} - -/** - * Track a generic CLI event with optional metadata. Use this for tracking - * custom events during CLI execution. - * - * @param eventType Type of event to track. - * @param argv Command line arguments (process.argv). - * @param metadata Optional additional metadata to include with the event. - */ -export async function trackCliEvent( - eventType: string, - argv: string[], - metadata?: Record<string, unknown> | undefined, -): Promise<void> { - debug(`Tracking CLI event: ${eventType}`) - - await trackEvent(eventType, buildContext(argv), metadata) -} - -/** - * Track CLI initialization event. Should be called at the start of CLI - * execution. - * - * @param argv Command line arguments (process.argv). - * - * @returns Start timestamp for duration calculation. - */ -export async function trackCliStart(argv: string[]): Promise<number> { - debug('Capture start of command') - - const startTime = Date.now() - - await trackEvent('cli_start', buildContext(argv)) - - return startTime -} - -/** - * Generic event tracking function. Tracks any telemetry event with optional - * error details and explicit flush. - * - * Events are automatically flushed via batch size or exit handlers. Use the - * flush option only when immediate submission is required. - * - * @param eventType Type of event to track. - * @param context Event context. - * @param metadata Event metadata. - * @param options Optional configuration. - * - * @returns Promise that resolves when tracking completes. - */ -export async function trackEvent( - eventType: string, - context: TelemetryContext, - metadata: Record<string, unknown> = {}, - options: { - error?: Error | undefined - flush?: boolean | undefined - } = {}, -): Promise<void> { - // Skip telemetry in test environments. - if (constants.ENV.VITEST) { - return - } - - try { - const orgSlug = getConfigValueOrUndef(CONFIG_KEY_DEFAULT_ORG) - - if (orgSlug) { - const telemetry = await TelemetryService.getTelemetryClient(orgSlug) - debug(`Got telemetry service for org: ${orgSlug}`) - - const event = { - context, - event_sender_created_at: new Date().toISOString(), - event_type: eventType, - ...(Object.keys(metadata).length > 0 && { metadata }), - ...(options.error && { - error: { - message: sanitizeErrorAttribute(options.error.message), - stack: sanitizeErrorAttribute(options.error.stack), - type: options.error.constructor.name, - }, - }), - } - - telemetry.track(event) - - // Flush events if requested. - if (options.flush) { - await telemetry.flush() - } - } - } catch (e) { - // Telemetry errors should never block CLI execution. - debug(`Failed to track event ${eventType}: ${e}`) - } -} - -/** - * Track subprocess/command completion event. - * - * Should be called when spawned command completes successfully. - * - * @param command Command that was executed. - * @param startTime Start timestamp from trackSubprocessStart. - * @param exitCode Process exit code. - * @param metadata Optional additional metadata (e.g., stdout length, stderr - * length). - */ -export async function trackSubprocessComplete( - command: string, - startTime: number, - exitCode: number | null, - metadata?: Record<string, unknown> | undefined, -): Promise<void> { - debug(`Tracking subprocess complete: ${command}`) - - await trackEvent('subprocess_complete', buildContext(process.argv), { - command, - duration: calculateDuration(startTime), - exit_code: normalizeExitCode(exitCode, 0), - ...metadata, - }) -} - -/** - * Track subprocess/command error event. - * - * Should be called when spawned command fails or throws error. - * - * @param command Command that was executed. - * @param startTime Start timestamp from trackSubprocessStart. - * @param error Error that occurred. - * @param exitCode Process exit code. - * @param metadata Optional additional metadata. - */ -export async function trackSubprocessError( - command: string, - startTime: number, - error: unknown, - exitCode?: number | null | undefined, - metadata?: Record<string, unknown> | undefined, -): Promise<void> { - debug(`Tracking subprocess error: ${command}`) - - await trackEvent( - 'subprocess_error', - buildContext(process.argv), - { - command, - duration: calculateDuration(startTime), - exit_code: normalizeExitCode(exitCode, 1), - ...metadata, - }, - { - error: normalizeError(error), - }, - ) -} - -/** - * Track subprocess exit and finalize telemetry. This is a convenience function - * that tracks completion/error based on exit code and ensures telemetry is - * flushed before returning. - * - * Note: Only tracks subprocess-level events. CLI-level events (cli_complete, - * cli_error) are tracked by the main CLI entry point in src/cli.mts. - * - * @example - * ```typescript - * await trackSubprocessExit(NPM, subprocessStartTime, code) - * ``` - * - * @param command - Command name (e.g., 'npm', 'pip'). - * @param startTime - Start timestamp from trackSubprocessStart. - * @param exitCode - Process exit code (null treated as error). - * - * @returns Promise that resolves when tracking and flush complete. - */ -export async function trackSubprocessExit( - command: string, - startTime: number, - exitCode: number | null, -): Promise<void> { - // Track subprocess completion or error based on exit code. - if (exitCode !== null && exitCode !== 0) { - const error = new Error(`${command} exited with code ${exitCode}`) - await trackSubprocessError(command, startTime, error, exitCode) - } else if (exitCode === 0) { - await trackSubprocessComplete(command, startTime, exitCode) - } - - // Flush telemetry to ensure events are sent before exit. - await finalizeTelemetry() -} - -/** - * Track subprocess/command start event. - * - * Use this when spawning external commands like npm, npx, coana, cdxgen, etc. - * - * @param command Command being executed (e.g., 'npm', 'npx', 'coana'). - * @param metadata Optional additional metadata (e.g., cwd, purpose). - * - * @returns Start timestamp for duration calculation. - */ -export async function trackSubprocessStart( - command: string, - metadata?: Record<string, unknown> | undefined, -): Promise<number> { - debug(`Tracking subprocess start: ${command}`) - - const startTime = Date.now() - - await trackEvent('subprocess_start', buildContext(process.argv), { - command, - ...metadata, - }) - - return startTime -} diff --git a/packages/cli/src/util/telemetry/service.mts b/packages/cli/src/util/telemetry/service.mts deleted file mode 100644 index e824cd71b..000000000 --- a/packages/cli/src/util/telemetry/service.mts +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Telemetry service for Socket CLI. Manages event collection, batching, and - * submission to Socket API. - * - * IMPORTANT: Telemetry is ALWAYS scoped to an organization. Cannot track - * telemetry without an org context. - * - * Features: - Singleton pattern (one instance per process) - - * Organization-scoped tracking (required) - Event batching (auto-flush at batch - * size) - Exit handlers (auto-flush on process exit) - Automatic session ID - * assignment - Explicit finalization via destroy() for controlled cleanup - - * Graceful degradation (errors don't block CLI) - * - * @example - * ```typescript - * // Get telemetry client (returns singleton instance) - * const telemetry = await TelemetryService.getTelemetryClient('my-org') - * - * // Track an event (session_id is auto-set) - * telemetry.track({ - * event_sender_created_at: new Date().toISOString(), - * event_type: 'cli_start', - * context: { - * version: '2.2.15', - * platform: process.platform, - * node_version: process.version, - * arch: process.arch, - * argv: process.argv.slice(2), - * }, - * }) - * - * // Flush happens automatically on batch size and exit - * // Can also be called manually if needed - * await telemetry.flush() - * - * // Always call destroy() before exit to flush remaining events - * await telemetry.destroy() - * ``` - */ - -import crypto from 'node:crypto' - -import { LRUCache } from 'lru-cache' - -import { debugDirNs, debugNs } from '@socketsecurity/lib-stable/debug/output' - -import { setupSdk } from '../socket/sdk.mts' - -import type { TelemetryEvent } from './types.mts' -import type { InspectOptions } from '@socketsecurity/lib-stable/debug/types' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type TelemetryConfig = SocketSdkSuccessResult<'getOrgTelemetryConfig'>['data'] - -/** - * Debug wrapper for telemetry service. Wraps debugNs to provide a simpler API. - */ -export function debug(message: string): void { - debugNs('socket:telemetry:service', message) -} - -/** - * DebugDir wrapper for telemetry service. - */ -export function debugDirWrapper( - obj: unknown, - inspectOpts?: InspectOptions, -): void { - debugDirNs('socket:telemetry:service', obj, inspectOpts) -} - -/** - * Process-wide session ID. Generated once per CLI invocation and shared across - * all telemetry instances. - */ -const SESSION_ID = crypto.randomUUID() - -/** - * Default telemetry configuration. Used as fallback if API config fetch fails. - */ -const DEFAULT_TELEMETRY_CONFIG = { - telemetry: { - enabled: false, - }, -} as TelemetryConfig - -/** - * Static configuration for telemetry service behavior. - */ -const TELEMETRY_SERVICE_CONFIG = { - batch_size: 10, // Auto-flush when queue reaches this size. - flush_timeout: 2_000, // 2 second maximum for flush operations. -} as const - -/** - * Singleton instance holder. - */ -interface TelemetryServiceInstance { - current: TelemetryService | undefined -} - -/** - * Singleton telemetry service instance holder. Only one instance exists per - * process. - */ -const telemetryServiceInstance: TelemetryServiceInstance = { - current: undefined, -} - -/** - * Inflight initialization tracker. Prevents duplicate initialization when - * multiple concurrent calls occur. LRU cache with max size to prevent unbounded - * memory growth. - */ -const inflightInit = new LRUCache<string, Promise<TelemetryService>>({ - max: 10, -}) - -/** - * Wrap a promise with a timeout. Rejects if promise doesn't resolve within - * timeout. - * - * @param promise Promise to wrap. - * @param timeoutMs Timeout in milliseconds. - * @param errorMessage Error message if timeout occurs. - * - * @returns Promise that resolves or times out. - */ -export function withTimeout<T>( - promise: Promise<T>, - timeoutMs: number, - errorMessage: string, -): Promise<T> { - let timeoutId: NodeJS.Timeout | undefined - // oxlint-disable-next-line socket/no-promise-race -- finalizer race: the .finally() arm clears the timeout the moment the promise settles, so the losing Promise resolves to undefined and is GC'd. No handler-list leak. - return Promise.race([ - promise.finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = undefined - } - }), - new Promise<T>((_, reject) => { - timeoutId = setTimeout(() => { - /* c8 ignore start - timeout branch; tests resolve promises before the timeout fires */ - const id = timeoutId - timeoutId = undefined - // Explicitly clear the timeout even though it just fired. - if (id) { - clearTimeout(id) - } - reject(new Error(errorMessage)) - /* c8 ignore stop */ - }, timeoutMs) - }), - ]) -} - -/** - * Centralized telemetry service for Socket CLI. Telemetry is always scoped to - * an organization. Singleton pattern ensures only one instance exists per - * process. - * - * NOTE: Only one telemetry instance exists per process. If getTelemetryClient() - * is called with a different organization slug, it returns the existing - * instance for the original organization. Switching organizations mid-execution - * is not supported - the first organization to initialize telemetry will be - * used for the entire process. - * - * This is intended, since we can't switch an org during command execution. - */ -export class TelemetryService { - private readonly orgSlug: string - private config: TelemetryConfig | undefined = undefined - private eventQueue: TelemetryEvent[] = [] - private isDestroyed = false - - /** - * Private constructor. Requires organization slug. - * - * @param orgSlug - Organization identifier. - */ - private constructor(orgSlug: string) { - this.orgSlug = orgSlug - debug( - `Telemetry service created for org '${orgSlug}' with session ID: ${SESSION_ID}`, - ) - } - - /** - * Get the current telemetry instance if one exists. Does not create a new - * instance. - * - * @returns Current telemetry instance or null if none exists. - */ - static getCurrentInstance(): TelemetryService | undefined { - return telemetryServiceInstance.current - } - - /** - * Get telemetry client for an organization. Creates and initializes client if - * it doesn't exist. Returns existing instance if already initialized. - * - * @param orgSlug - Organization identifier (required). - * - * @returns Initialized telemetry service instance. - */ - static async getTelemetryClient(orgSlug: string): Promise<TelemetryService> { - // Return existing instance if already initialized. - if (telemetryServiceInstance.current) { - debug( - `Telemetry already initialized for org: ${telemetryServiceInstance.current.orgSlug}`, - ) - return telemetryServiceInstance.current - } - - // Check if initialization is already in progress. - const inflight = inflightInit.get(orgSlug) - if (inflight) { - debug( - `Telemetry initialization already in progress for org: ${orgSlug}, waiting...`, - ) - return inflight - } - - // Start initialization and track it. - const initPromise = (async () => { - try { - const instance = new TelemetryService(orgSlug) - - try { - const sdkResult = await setupSdk() - if (!sdkResult.ok) { - debug('Failed to setup SDK for telemetry, using default config') - instance.config = DEFAULT_TELEMETRY_CONFIG - telemetryServiceInstance.current = instance - return instance - } - - const sdk = sdkResult.data - const configResult = await sdk.getOrgTelemetryConfig(orgSlug) - - if (configResult.success) { - instance.config = configResult.data - debug( - `Telemetry configuration fetched successfully: enabled=${instance.config.telemetry.enabled}`, - ) - debugDirWrapper({ config: instance.config }) - - // Periodic flush will start automatically when first event is tracked. - } else { - debug(`Failed to fetch telemetry config: ${configResult.error}`) - instance.config = DEFAULT_TELEMETRY_CONFIG - } - } catch (e) { - debug(`Error initializing telemetry: ${e}`) - instance.config = DEFAULT_TELEMETRY_CONFIG - } - - // Only set singleton instance after full initialization. - telemetryServiceInstance.current = instance - return instance - } finally { - // Clean up inflight tracking. - inflightInit.delete(orgSlug) - } - })() - - inflightInit.set(orgSlug, initPromise) - return initPromise - } - - /** - * Track a telemetry event. Adds event to queue for batching and eventual - * submission. Auto-flushes when batch size is reached. - * - * @param event - Telemetry event to track (session_id is optional and will be - * auto-set). - */ - track(event: Omit<TelemetryEvent, 'session_id'>): void { - debug('Incoming track event request') - - if (this.isDestroyed) { - debug('Telemetry service destroyed, ignoring event') - return - } - - if (!this.config?.telemetry.enabled) { - debug(`Telemetry disabled, skipping event: ${event.event_type}`) - return - } - - // Create complete event with session_id and org_slug. - const completeEvent: TelemetryEvent = { - ...event, - session_id: SESSION_ID, - } - - debug(`Tracking telemetry event: ${completeEvent.event_type}`) - debugDirWrapper(completeEvent) - - this.eventQueue.push(completeEvent) - - // Auto-flush if batch size reached. - const batchSize = TELEMETRY_SERVICE_CONFIG.batch_size - if (this.eventQueue.length >= batchSize) { - debug(`Batch size reached (${batchSize}), flushing events`) - void this.flush() - } - } - - /** - * Flush all queued events to the API. Returns immediately if no events queued - * or telemetry disabled. Times out after configured flush_timeout to prevent - * blocking CLI exit. - */ - async flush(): Promise<void> { - if (this.isDestroyed) { - debug('Telemetry service destroyed, cannot flush') - return - } - - if (!this.eventQueue.length) { - return - } - - if (!this.config?.telemetry.enabled) { - debug('Telemetry disabled, clearing queue without sending') - this.eventQueue = [] - return - } - - const eventsToSend = [...this.eventQueue] - this.eventQueue = [] - - debug(`Flushing ${eventsToSend.length} telemetry events`) - - const flushStartTime = Date.now() - - try { - await withTimeout( - this.sendEvents(eventsToSend), - TELEMETRY_SERVICE_CONFIG.flush_timeout, - `Telemetry flush timed out after ${TELEMETRY_SERVICE_CONFIG.flush_timeout}ms`, - ) - - const flushDuration = Date.now() - flushStartTime - debug( - `Telemetry events sent successfully (${eventsToSend.length} events in ${flushDuration}ms)`, - ) - } catch (e) { - const flushDuration = Date.now() - flushStartTime - const errMsg = errorMessage(e) - - // Check if this is a timeout error. - if ( - errMsg.includes('timed out') || - flushDuration >= TELEMETRY_SERVICE_CONFIG.flush_timeout - ) { - debug( - `Telemetry flush timed out after ${TELEMETRY_SERVICE_CONFIG.flush_timeout}ms`, - ) - debug(`Failed to send ${eventsToSend.length} events due to timeout`) - } else { - debug(`Error flushing telemetry: ${errMsg}`) - debug(`Failed to send ${eventsToSend.length} events due to error`) - } - // Events are discarded on error to prevent infinite growth. - } - } - - /** - * Send events to the API. Extracted as separate method for timeout wrapping. - * - * @param events Events to send. - */ - private async sendEvents(events: TelemetryEvent[]): Promise<void> { - const sdkResult = await setupSdk() - if (!sdkResult.ok) { - debug('Failed to setup SDK for flush, events discarded') - return - } - - const sdk = sdkResult.data - - // Track flush statistics. - let successCount = 0 - let failureCount = 0 - - // Send events in parallel for faster flush. - // Use allSettled to ensure all sends are attempted even if some fail. - const results = await Promise.allSettled( - events.map(async event => { - const result = await sdk.postOrgTelemetry( - this.orgSlug, - event as unknown as Record<string, unknown>, - ) - return { event, result } - }), - ) - - // Log results and collect statistics. - for (let i = 0, { length } = results; i < length; i += 1) { - const settledResult = results[i]! - if (settledResult.status === 'fulfilled') { - const { event, result } = settledResult.value - if (result.success) { - successCount++ - debug('Telemetry sent to telemetry:') - debugDirWrapper(event) - } else { - failureCount++ - debug(`Failed to send telemetry event: ${result.error}`) - } - } else { - failureCount++ - debug(`Telemetry request failed: ${settledResult.reason}`) - } - } - - // Log flush statistics. - debug( - `Flush stats: ${successCount} succeeded, ${failureCount} failed out of ${events.length} total`, - ) - } - - /** - * Destroy the telemetry service for this organization. Flushes remaining - * events and clears all state. Idempotent - safe to call multiple times. - */ - async destroy(): Promise<void> { - if (this.isDestroyed) { - debug('Telemetry service already destroyed, skipping') - return - } - - debug(`Destroying telemetry service for org: ${this.orgSlug}`) - - // Mark as destroyed immediately to prevent concurrent destroy() calls. - this.isDestroyed = true - - // Flush remaining events with timeout. - const eventsToFlush = [...this.eventQueue] - this.eventQueue = [] - - if (eventsToFlush.length > 0 && this.config?.telemetry.enabled) { - debug(`Flushing ${eventsToFlush.length} events before destroy`) - const flushStartTime = Date.now() - - try { - await withTimeout( - this.sendEvents(eventsToFlush), - TELEMETRY_SERVICE_CONFIG.flush_timeout, - `Telemetry flush during destroy timed out after ${TELEMETRY_SERVICE_CONFIG.flush_timeout}ms`, - ) - const flushDuration = Date.now() - flushStartTime - debug(`Events flushed successfully during destroy (${flushDuration}ms)`) - } catch (e) { - const flushDuration = Date.now() - flushStartTime - const errMsg = errorMessage(e) - - // Check if this is a timeout error. - if ( - errMsg.includes('timed out') || - flushDuration >= TELEMETRY_SERVICE_CONFIG.flush_timeout - ) { - debug( - `Telemetry flush during destroy timed out after ${TELEMETRY_SERVICE_CONFIG.flush_timeout}ms`, - ) - debug( - `Failed to send ${eventsToFlush.length} events during destroy due to timeout`, - ) - } else { - debug(`Error flushing telemetry during destroy: ${errMsg}`) - debug( - `Failed to send ${eventsToFlush.length} events during destroy due to error`, - ) - } - } - } - - this.config = undefined - - // Clear singleton instance. - telemetryServiceInstance.current = undefined - - debug(`Telemetry service destroyed for org: ${this.orgSlug}`) - } -} diff --git a/packages/cli/src/util/telemetry/types.mts b/packages/cli/src/util/telemetry/types.mts deleted file mode 100644 index 13fbe9d9d..000000000 --- a/packages/cli/src/util/telemetry/types.mts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Telemetry types for Socket CLI. Defines the structure of telemetry events and - * related data. - */ - -/** - * Error details for telemetry events. - */ -interface TelemetryEventError { - /** - * Error class/type name. - */ - type: string - /** - * Error message. - */ - message: string | undefined - /** - * Stack trace (sanitized). - */ - stack?: string | undefined -} - -/** - * Telemetry Context. - * - * This represent how the cli was invoked and met. - */ -export interface TelemetryContext { - version: string - platform: string - node_version: string - arch: string - argv: string[] -} - -/** - * Telemetry event structure. All telemetry events must follow this schema. - */ -export interface TelemetryEvent { - event_sender_created_at: string - event_type: string - context: TelemetryContext - session_id?: string | undefined - metadata?: Record<string, unknown> | undefined - error?: TelemetryEventError | undefined -} diff --git a/packages/cli/src/util/terminal/ascii-header.mts b/packages/cli/src/util/terminal/ascii-header.mts deleted file mode 100644 index d03a92a18..000000000 --- a/packages/cli/src/util/terminal/ascii-header.mts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * @file Animated ASCII header utilities with shimmer effects. Provides - * themable, animated Socket CLI ASCII art headers with gradient shimmer - * effects. Supports both static (fast) rendering and animated (shimmer) - * rendering modes. - */ - -import colors from 'yoctocolors-cjs' - -import { configToSpec, frameColors } from '@socketsecurity/lib-stable/effects/shimmer' -import { colorsToAnsi } from '@socketsecurity/lib-stable/effects/shimmer-terminal' - -import type { - Palette, - RGB, - ShimmerSpec, -} from '@socketsecurity/lib-stable/effects/shimmer' - -/** - * Color themes for header styling. - */ -export type HeaderTheme = - | 'default' - | 'cyberpunk' - | 'forest' - | 'ocean' - | 'sunset' - -/** - * Theme color definitions with gradient support (RGB tuples). - */ -const THEME_COLORS_RGB = { - __proto__: null, - default: [ - [139, 92, 246], - [167, 139, 250], - [196, 181, 253], - [221, 214, 254], - ] as const, - cyberpunk: [ - [255, 0, 255], - [0, 255, 255], - [255, 0, 170], - [0, 170, 255], - ] as const, - forest: [ - [16, 185, 129], - [52, 211, 153], - [110, 231, 183], - [167, 243, 208], - ] as const, - ocean: [ - [14, 165, 233], - [56, 189, 248], - [125, 211, 252], - [186, 230, 253], - ] as const, - sunset: [ - [245, 158, 11], - [251, 191, 36], - [252, 211, 77], - [253, 230, 138], - ] as const, -} as const - -/** - * Socket CLI ASCII art template. - */ -const ASCII_LOGO = [ - ' _____ _ _ ', - ' | __|___ ___| |_ ___| |_ ', - " |__ | . | _| '_| -_| _| ", - ' |_____|___|___|_,_|___|_|.dev ', -] as const - -/** - * Pick the brighter of two RGB colors. Used to compose two shimmer waves into - * one frame: each wave's `frameColors[i]` is computed independently, then - * merged so the brighter highlight wins per char. Treats luminance as the - * simple sum of channels — fine here because both waves share base + highlight - * palettes. - */ -export function brighterRgb(a: RGB, b: RGB): RGB { - return a[0] + a[1] + a[2] >= b[0] + b[1] + b[2] ? a : b -} - -/** - * Render ASCII logo with fallback for terminals without 24-bit color. - */ -export function renderLogoWithFallback( - frame: number | undefined = undefined, - theme: HeaderTheme = 'default', -): string { - // If frame is provided and terminal supports full color, use shimmer. - if (frame !== undefined && supportsFullColor()) { - return renderShimmerFrame(frame, theme) - } - - // Static rendering for terminals without full color support. - // Use simple yoctocolors for compatibility. - const themeToColor = { - __proto__: null, - default: colors.magenta, - cyberpunk: colors.cyan, - forest: colors.green, - ocean: colors.blue, - sunset: colors.yellow, - } as const - - const colorFn = themeToColor[theme] - return ASCII_LOGO.map(line => colorFn(line)).join('\n') -} - -/** - * Render ASCII logo with shimmer effect for given frame. - * - * Uses socket-lib's @socketsecurity/lib/effects/shimmer engine (5.26.1+). - * Builds two ShimmerSpecs per line — primary + secondary offset by 35 frames — - * and merges their per-char colors with `brighterRgb` so the dual-wave look is - * preserved. Each line gets a `slantOffset = i * 4` added to the frame counter, - * producing a diagonal wave across the logo. Applies bold via ANSI before the - * shimmer's truecolor escape so terminals render the highlight bold. - */ -export function renderShimmerFrame( - frame: number, - theme: HeaderTheme = 'default', -): string { - const themePalette = THEME_COLORS_RGB[theme] as unknown as Palette - - const lines: string[] = [] - for (let i = 0; i < ASCII_LOGO.length; i++) { - const line = ASCII_LOGO[i]! - const lineLength = line.length - - // Slant the wave by offsetting each line's frame counter — same - // 4-frame-per-row delta as the previous implementation. - const slantOffset = i * 4 - const speed = 0.25 - - // Build the shimmer spec once and reuse for both waves — the - // spec is frame-independent (positionAt is a closure over speed - // + textLength + direction). The two waves differ only in the - // frame counter passed to `frameColors`. - const spec: ShimmerSpec = configToSpec( - { - color: themePalette, - dir: 'ltr', - speed, - }, - lineLength, - ) - - // Compute per-char colors for both waves and merge. - const primaryColors = frameColors(spec, lineLength, frame + slantOffset) - const secondaryColors = frameColors( - spec, - lineLength, - frame + slantOffset + 35, - ) - const merged: RGB[] = primaryColors.map((c, idx) => - brighterRgb(c, secondaryColors[idx]!), - ) - - // Render to ANSI truecolor + wrap in bold for the brighter look - // the previous implementation produced. \x1b[1m turns bold on, - // colorsToAnsi emits per-char truecolor codes, \x1b[0m resets. - lines.push(`\x1b[1m${colorsToAnsi(line, merged)}\x1b[0m`) - } - - return lines.join('\n') -} - -/** - * Check if terminal supports 24-bit color. - */ -export function supportsFullColor(): boolean { - const { COLORTERM, TERM, TERM_PROGRAM } = process.env - return !!( - COLORTERM === 'truecolor' || - COLORTERM === '24bit' || - TERM?.includes('24bit') || - TERM?.includes('truecolor') || - TERM_PROGRAM === 'iTerm.app' || - TERM_PROGRAM === 'Hyper' || - TERM_PROGRAM === 'vscode' - ) -} diff --git a/packages/cli/src/util/terminal/link.mts b/packages/cli/src/util/terminal/link.mts deleted file mode 100644 index 953f0b9e0..000000000 --- a/packages/cli/src/util/terminal/link.mts +++ /dev/null @@ -1,100 +0,0 @@ -import path from 'node:path' - -import terminalLink from 'terminal-link' - -import { SOCKET_WEBSITE_URL } from '../../constants/socket.mts' - -/** - * Creates a terminal link to a local file. - */ -export function fileLink(filePath: string, text?: string | undefined): string { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(filePath) - return terminalLink(text ?? filePath, `file://${absolutePath}`) -} - -/** - * Creates a terminal link to a GitHub repository. - */ -export function githubRepoLink( - owner: string, - repo: string, - path?: string | undefined, - text?: string | undefined, -): string { - const url = `https://github.com/${owner}/${repo}${path ? `/${path}` : ''}` - return terminalLink(text ?? `${owner}/${repo}`, url) -} - -/** - * Creates a terminal link to an email address. - */ -export function mailtoLink(email: string, text?: string | undefined): string { - return terminalLink(text ?? email, `mailto:${email}`) -} - -/** - * Creates a terminal link to the Socket.dev dashboard. - */ -export function socketDashboardLink( - dashPath: string, - text?: string | undefined, -): string { - const url = `https://socket.dev/dashboard${dashPath.startsWith('/') ? dashPath : `/${dashPath}`}` - return terminalLink(text ?? url, url) -} - -/** - * Creates a terminal link to the Socket.dev website. - */ -export function socketDevLink( - text?: string | undefined, - urlPath?: string | undefined, -): string { - return terminalLink( - text ?? 'Socket.dev', - `${SOCKET_WEBSITE_URL}${urlPath || ''}`, - ) -} - -/** - * Creates a terminal link to Socket.dev documentation. - */ -export function socketDocsLink( - docPath: string, - text?: string | undefined, -): string { - const url = `https://docs.socket.dev${docPath.startsWith('/') ? docPath : `/${docPath}`}` - return terminalLink(text ?? url, url) -} - -/** - * Creates a terminal link to Socket.dev package page. - */ -export function socketPackageLink( - ecosystem: string, - packageName: string, - version?: string | undefined, - text?: string | undefined, -): string { - let url: string - if (version) { - // Check if version contains a path like 'files/1.0.0/CHANGELOG.md'. - if (version.includes('/')) { - url = `https://socket.dev/${ecosystem}/package/${packageName}/${version}` - } else { - url = `https://socket.dev/${ecosystem}/package/${packageName}/overview/${version}` - } - } else { - url = `https://socket.dev/${ecosystem}/package/${packageName}` - } - return terminalLink(text ?? url, url) -} - -/** - * Creates a terminal link to a web URL. - */ -export function webLink(url: string, text?: string | undefined): string { - return terminalLink(text ?? url, url) -} diff --git a/packages/cli/src/util/update/checker.mts b/packages/cli/src/util/update/checker.mts deleted file mode 100644 index 7fd47c188..000000000 --- a/packages/cli/src/util/update/checker.mts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Update checking utilities for Socket CLI. Handles version comparison and - * registry lookups for available updates. - * - * Key Functions: - checkForUpdates: Check registry for available updates - - * isUpdateAvailable: Compare current vs latest versions - fetchLatestVersion: - * Get latest version from npm registry. - * - * Features: - Robust version comparison using semver - Network error handling - * and timeouts - Registry authentication support - Retry mechanism with - * exponential backoff. - * - * Usage: - CLI update checking - Automated update notifications - Version - * compatibility checks. - */ - -import https from 'node:https' - -import semver from 'semver' - -import { NPM_REGISTRY_URL } from '@socketsecurity/lib-stable/constants/agents' -import { debug } from '@socketsecurity/lib-stable/debug/output' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { onExit } from '@socketsecurity/lib-stable/events/exit/handler' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { UPDATE_NOTIFIER_TIMEOUT } from '../../constants/cache.mts' - -const logger = getDefaultLogger() - -export interface AuthInfo { - token: string - type: string -} - -// Type compatibility with registry-auth-token. -interface NpmCredentials { - token: string - type: string -} - -interface UpdateCheckOptions { - authInfo?: AuthInfo | NpmCredentials | undefined - name: string - registryUrl?: string | undefined - version: string -} - -export interface UpdateCheckResult { - current: string - latest: string - updateAvailable: boolean -} - -interface FetchOptions { - authInfo?: AuthInfo | NpmCredentials | undefined -} - -interface GetLatestVersionOptions { - authInfo?: AuthInfo | NpmCredentials | undefined - registryUrl?: string | undefined -} - -/** - * Network utilities with robust error handling and timeouts. - */ -const NetworkUtils = { - /** - * Fetch package information from npm registry using https.request(). Uses - * Node.js built-in https module to avoid keep-alive connection pooling that - * causes 30-second delays in process exit. - */ - async fetch( - url: string, - options: FetchOptions = {}, - timeoutMs = UPDATE_NOTIFIER_TIMEOUT, - ): Promise<{ version?: string | undefined }> { - if (!isNonEmptyString(url)) { - throw new Error( - `NetworkUtils.fetch(url) requires a non-empty string (got: ${typeof url === 'string' ? '""' : typeof url}); pass a valid registry URL like https://registry.npmjs.org/<package>`, - ) - } - - const { authInfo } = { __proto__: null, ...options } as FetchOptions - - const parsedUrl = new URL(url) - const headers: Record<string, string> = { - Accept: - 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', - 'User-Agent': 'socket-cli-updater/1.0', - } - - if ( - authInfo && - isNonEmptyString(authInfo.token) && - isNonEmptyString(authInfo.type) - ) { - headers['Authorization'] = `${authInfo.type} ${authInfo.token}` - } - - return new Promise((resolve, reject) => { - // Cleanup function to remove exit handler and prevent memory leak. - /* c8 ignore next - exitHandler only fires on actual process exit */ - const exitHandler = () => req.destroy() - const removeExitHandler = onExit(exitHandler) - - const cleanup = () => { - removeExitHandler() - } - - const req = https.request( - { - agent: false, // Disable connection pooling. - headers, - hostname: parsedUrl.hostname, - method: 'GET', - path: parsedUrl.pathname + parsedUrl.search, - port: parsedUrl.port, - timeout: timeoutMs, - }, - res => { - let data = '' - - res.on('data', chunk => { - data += chunk - }) - - res.on('end', () => { - cleanup() - try { - if (res.statusCode !== 200) { - reject( - new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`), - ) - return - } - - const json = JSON.parse(data) as unknown - - if (!json || typeof json !== 'object') { - reject(new Error('Invalid JSON response from registry')) - return - } - - resolve(json as { version?: string | undefined }) - /* c8 ignore start - JSON parse failure path; tests inject pre-parsed mock responses */ - } catch (parseError) { - const contentType = res.headers['content-type'] - if (!contentType || !contentType.includes('application/json')) { - debug(`Unexpected content type: ${contentType}`) - } - reject( - new Error( - `Failed to parse JSON response: ${errorMessage(parseError)}`, - ), - ) - } - /* c8 ignore stop */ - }) - }, - ) - - req.on('timeout', () => { - cleanup() - req.destroy() - reject(new Error(`Request timed out after ${timeoutMs}ms`)) - }) - - req.on('error', error => { - cleanup() - reject(new Error(`Network request failed: ${error.message}`)) - }) - - req.end() - }) - }, - - /** - * Get the latest version of a package from npm registry. - */ - async getLatestVersion( - name: string, - options: GetLatestVersionOptions = {}, - ): Promise<string | undefined> { - if (!isNonEmptyString(name)) { - throw new Error( - `getLatestVersion(name) requires a non-empty string (got: ${typeof name === 'string' ? '""' : typeof name}); pass an npm package name like "socket" or "@socketsecurity/cli"`, - ) - } - - const { authInfo, registryUrl = NPM_REGISTRY_URL } = { - __proto__: null, - ...options, - } as GetLatestVersionOptions - - if (!isNonEmptyString(registryUrl)) { - throw new Error( - `getLatestVersion options.registryUrl must be a non-empty string (got: ${typeof registryUrl === 'string' ? '""' : typeof registryUrl}); omit it to default to ${NPM_REGISTRY_URL}`, - ) - } - - let normalizedRegistryUrl: string - try { - const url = new URL(registryUrl) - normalizedRegistryUrl = url.toString() - } catch { - throw new Error( - `options.registryUrl "${registryUrl}" is not a valid URL (new URL() threw); pass an absolute http(s) URL like ${NPM_REGISTRY_URL}`, - ) - } - - const maybeSlash = normalizedRegistryUrl.endsWith('/') ? '' : '/' - const latestUrl = `${normalizedRegistryUrl}${maybeSlash}${encodeURIComponent(name)}/latest` - - let attempts = 0 - const maxAttempts = 3 - const baseDelay = 1_000 // 1 second - - while (attempts < maxAttempts) { - try { - const json = await NetworkUtils.fetch( - latestUrl, - authInfo ? { authInfo } : {}, - ) - - if (!json || !isNonEmptyString(json.version)) { - throw new Error( - `${latestUrl} responded without a .version string (got: ${JSON.stringify(json)?.slice(0, 200) ?? 'null'}); the registry may be misconfigured or ${name} may not exist — verify the URL in a browser`, - ) - } - - return json.version - } catch (e) { - attempts++ - const isLastAttempt = attempts === maxAttempts - - if (isLastAttempt) { - logger.warn( - `Failed to fetch version after ${maxAttempts} attempts: ${errorMessage(e)}`, - ) - throw e - } - - // Exponential backoff with cap to prevent integer overflow. - const delay = Math.min(baseDelay * 2 ** (attempts - 1), 60_000) - logger.log( - `Attempt ${attempts} failed, retrying in ${delay}ms: ${errorMessage(e)}`, - ) - - await new Promise(resolve => setTimeout(resolve, delay)) - } - } - - /* c8 ignore start - unreachable: while loop either returns on success or throws on last attempt */ - return undefined - /* c8 ignore stop */ - }, -} - -/** - * Check for available updates for a package. Fetches latest version from - * registry and compares with current. - */ -export async function checkForUpdates( - options: UpdateCheckOptions, -): Promise<UpdateCheckResult> { - const { authInfo, name, registryUrl, version } = { - __proto__: null, - ...options, - } as UpdateCheckOptions - - if (!isNonEmptyString(name)) { - throw new Error( - `checkForUpdates options.name requires a non-empty string (got: ${typeof name === 'string' ? '""' : typeof name}); pass an npm package name like "socket" or "@socketsecurity/cli"`, - ) - } - - if (!isNonEmptyString(version)) { - throw new Error( - `checkForUpdates options.version requires a non-empty string (got: ${typeof version === 'string' ? '""' : typeof version}); pass the currently-installed semver like "1.2.3"`, - ) - } - - try { - const latest = await NetworkUtils.getLatestVersion(name, { - ...(authInfo ? { authInfo } : {}), - ...(registryUrl ? { registryUrl } : {}), - }) - - /* c8 ignore start - defensive: getLatestVersion throws on empty so this guard never fires */ - if (!isNonEmptyString(latest)) { - throw new Error( - `registry returned no latest version for ${name} (getLatestVersion resolved to ${JSON.stringify(latest)}); check that ${name} exists on ${registryUrl || NPM_REGISTRY_URL}`, - ) - } - /* c8 ignore stop */ - - const updateAvailable = isUpdateAvailable(version, latest) - - return { - current: version, - latest, - updateAvailable, - } - } catch (e) { - logger.log(`Failed to check for updates: ${errorMessage(e)}`) - throw e - } -} - -/** - * Version comparison using semver library. - */ -export function isUpdateAvailable(current: string, latest: string): boolean { - try { - // Use semver for robust version comparison. - const currentClean = semver.clean(current) - const latestClean = semver.clean(latest) - - if (!currentClean || !latestClean) { - // Fallback to string comparison if semver parsing fails. - return latest !== current - } - - return semver.gt(latestClean, currentClean) - /* c8 ignore start - semver.gt fallback for non-semver inputs; both inputs already passed semver.coerce */ - } catch { - return latest !== current - } - /* c8 ignore stop */ -} - -export { NetworkUtils } diff --git a/packages/cli/src/util/update/manager.mts b/packages/cli/src/util/update/manager.mts deleted file mode 100644 index bfd86b12c..000000000 --- a/packages/cli/src/util/update/manager.mts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Update manager for Socket CLI (npm/pnpm/yarn installations only). - * Orchestrates update checking, caching, and user notifications for package - * manager installs. - * - * Note: SEA binaries use node-smol's built-in update checker (via - * --update-config). This manager only handles npm registry update checks for - * non-SEA installations. - * - * Key Functions: - checkForUpdates: Complete update check flow with caching - * (npm only) - scheduleUpdateCheck: Non-blocking update check with - * notifications (npm only) - * - * Features: - TTL-based caching to avoid excessive registry requests - - * Error-resistant implementation - Rate limiting and network timeout handling. - * - * Architecture: - Uses checker for npm registry lookups - Uses store for - * persistent caching - Uses notifier for user messaging - Skips entirely for - * SEA binaries (node-smol handles it) - * - * Usage: - CLI startup update checks (npm installs only) - Background update - * monitoring (npm installs only) - */ - -import { dlxManifest } from '@socketsecurity/lib-stable/dlx/manifest' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { checkForUpdates as performUpdateCheck } from './checker.mts' -import { - scheduleExitNotification, - showUpdateNotification, -} from './notifier.mts' -import { UPDATE_CHECK_TTL } from '../../constants/cache.mts' -import { isSeaBinary } from '../sea/detect.mts' - -import type { AuthInfo, UpdateCheckResult } from './checker.mts' -import type { StoreRecord } from '@socketsecurity/lib-stable/dlx/manifest' - -const logger = getDefaultLogger() - -// Notification TTL: Show notification at most once per 7 days (604800000 ms). -const NOTIFICATION_TTL_MS = 7 * 24 * 60 * 60 * 1000 - -interface UpdateManagerOptions { - authInfo?: AuthInfo | undefined - name: string - version: string - /** - * Whether to show notification immediately or on exit. - */ - immediate?: boolean | undefined - registryUrl?: string | undefined - ttl?: number | undefined -} - -/** - * Perform complete update check flow with caching and notifications. This is - * the main function that orchestrates the entire update process. - */ -export async function checkForUpdates( - options: UpdateManagerOptions, -): Promise<boolean> { - const { - authInfo, - immediate = false, - name, - registryUrl, - ttl = UPDATE_CHECK_TTL, - version, - } = { __proto__: null, ...options } as UpdateManagerOptions - - const loggerLocal = getDefaultLogger() - - // Capture timestamp immediately for accurate TTL calculations. - const timestamp = Date.now() - - // Validate required parameters. - if (!isNonEmptyString(name)) { - loggerLocal.warn( - `checkForUpdates options.name requires a non-empty string (got: ${typeof name === 'string' ? '""' : typeof name}); skipping update check`, - ) - return false - } - - if (!isNonEmptyString(version)) { - loggerLocal.warn( - `checkForUpdates options.version requires a non-empty string (got: ${typeof version === 'string' ? '""' : typeof version}); skipping update check`, - ) - return false - } - - if (ttl < 0) { - loggerLocal.warn( - `checkForUpdates options.ttl must be >= 0 (saw: ${ttl}); pass a positive number of milliseconds, e.g. 86_400_000 for 24h`, - ) - return false - } - - // Validate auth info if provided. - if (authInfo) { - if (!isNonEmptyString(authInfo.token) || !isNonEmptyString(authInfo.type)) { - loggerLocal.warn( - 'Invalid auth info provided, proceeding without authentication', - ) - } - } - - /* c8 ignore start - registry URL validation is defensive; isNonEmptyString already truth-checked by the && above */ - if (registryUrl && !isNonEmptyString(registryUrl)) { - loggerLocal.warn('Invalid registry URL provided, using default') - } - /* c8 ignore stop */ - - let record: StoreRecord | undefined - - // Include current version and registry in cache key to prevent stale cache. - // Different registries may have different latest versions. - // Normalize registry URL to prevent duplicate cache entries for equivalent URLs. - let normalizedRegistry = '' - if (registryUrl) { - try { - normalizedRegistry = new URL(registryUrl).href - } catch { - normalizedRegistry = registryUrl - } - } - const registrySuffix = normalizedRegistry ? `:${normalizedRegistry}` : '' - const cacheKey = `${name}@${version}${registrySuffix}` - - try { - // Note: dlxManifest.get() is not lock-protected, which can cause a race condition - // where concurrent CLI invocations read stale cache simultaneously and both fetch - // fresh data. This only wastes network resources during TTL expiration; no data - // corruption occurs since both writes contain the same fresh data. Acceptable tradeoff - // for simplicity vs. adding in-memory deduplication layer. - record = dlxManifest.get(cacheKey) - - if (timestamp <= 0) { - loggerLocal.warn('Invalid system time, using cached data only') - if (record) { - // Validate cached record has a valid timestamp before using. - if ( - !record.timestampFetch || - record.timestampFetch <= 0 || - !record.version - ) { - loggerLocal.warn( - 'Cached data has invalid timestamp or version, skipping update check', - ) - return false - } - // Use cached data for notification. - const updateAvailable = version !== record.version - if (updateAvailable) { - const notificationOptions = { - name, - current: version, - latest: record.version, - } - - if (immediate) { - showUpdateNotification(notificationOptions) - } else { - scheduleExitNotification(notificationOptions) - } - } - return updateAvailable - } - return false - } - } catch (e) { - loggerLocal.warn(`Failed to access cache: ${errorMessage(e)}`) - record = undefined - } - - // Check freshness inline to avoid potential double-read. - const isFresh = - record && record.timestampFetch - ? timestamp - record.timestampFetch < ttl - : false - let updateResult: UpdateCheckResult - - if (!isFresh) { - // Need to fetch fresh data from registry. - try { - updateResult = await performUpdateCheck({ - authInfo, - name, - registryUrl, - version, - }) - - // Update cache with fresh data. - // Intentional: Capture timestamp after fetch completes, not before it starts. - // This extends TTL by network latency (~seconds) but represents when data - // was actually received, making cache entries slightly "fresher". - try { - await dlxManifest.set(cacheKey, { - timestampFetch: Date.now(), - timestampNotification: record?.timestampNotification ?? 0, - version: updateResult.latest, - }) - } catch (e) { - loggerLocal.warn(`Failed to update cache: ${errorMessage(e)}`) - // Continue anyway - cache update failure is not critical. - } - } catch (e) { - loggerLocal.log(`Failed to fetch latest version: ${errorMessage(e)}`) - - // Use cached version if available. - if (record) { - updateResult = { - current: version, - latest: record.version, - updateAvailable: version !== record.version, - } - } else { - loggerLocal.log('No version information available') - return false - } - } - } else { - // Use fresh cached data. - updateResult = { - current: version, - latest: record?.version ?? version, - updateAvailable: version !== (record?.version ?? version), - } - } - - // Show notification if update is available and not shown recently. - if (updateResult.updateAvailable && !isFresh) { - const now = Date.now() - const lastNotification = record?.timestampNotification ?? 0 - const timeSinceLastNotification = now - lastNotification - - // Only show notification if it's been more than NOTIFICATION_TTL_MS since last notification. - if (timeSinceLastNotification >= NOTIFICATION_TTL_MS) { - try { - const notificationOptions = { - name, - current: updateResult.current, - latest: updateResult.latest, - } - - if (immediate) { - showUpdateNotification(notificationOptions) - } else { - scheduleExitNotification(notificationOptions) - } - - // Update timestampNotification in cache to prevent spam. - try { - await dlxManifest.set(cacheKey, { - timestampFetch: record?.timestampFetch ?? now, - timestampNotification: now, - version: updateResult.latest, - }) - } catch (e) { - loggerLocal.warn( - `Failed to update notification timestamp: ${errorMessage(e)}`, - ) - } - } catch (e) { - loggerLocal.warn(`Failed to set up notification: ${errorMessage(e)}`) - // Notification failure is not critical - update is still available. - } - } - } - - return updateResult.updateAvailable -} - -/** - * Schedule a non-blocking update check. This is the recommended way to check - * for updates during CLI startup. - * - * Note: Only runs for npm/pnpm/yarn installations. SEA binaries use node-smol's - * built-in update checker (embedded via --update-config). - */ -export async function scheduleUpdateCheck( - options: UpdateManagerOptions, -): Promise<void> { - // Skip update checks for SEA binaries - node-smol handles it via embedded update-config. - if (isSeaBinary()) { - return - } - - // Set immediate to false to show notification on exit. - const updateOptions = { ...options, immediate: false } - - try { - await checkForUpdates(updateOptions) - /* c8 ignore start - update-check failures are silent and can't be triggered without mocking the entire update pipeline */ - } catch (e) { - logger.log(`Update check failed: ${errorMessage(e)}`) - } - /* c8 ignore stop */ -} diff --git a/packages/cli/src/util/update/notifier.mts b/packages/cli/src/util/update/notifier.mts deleted file mode 100644 index 9f66e78ae..000000000 --- a/packages/cli/src/util/update/notifier.mts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Update notification utilities for Socket CLI. Handles displaying update - * notifications to users with appropriate messaging for both SEA binaries and - * npm installations. - * - * Key Functions: - showUpdateNotification: Display update available message - - * scheduleExitNotification: Show notification when process exits - - * formatUpdateMessage: Create user-friendly update messages. - * - * Features: - SEA vs npm aware messaging - Terminal link generation for - * changelogs - Process exit notifications - Graceful fallbacks for non-TTY - * environments. - * - * Usage: - CLI update notifications - Integration with update checker - User - * experience messaging. - */ - -import process from 'node:process' - -import colors from 'yoctocolors-cjs' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { onExit } from '@socketsecurity/lib-stable/events/exit/handler' -import { isNonEmptyString } from '@socketsecurity/lib-stable/strings/predicates' - -import { SEA_UPDATE_COMMAND } from '../../constants/cli.mts' -import { getSeaBinaryPath } from '../sea/detect.mts' -import { githubRepoLink, socketPackageLink } from '../terminal/link.mts' - -const logger = getDefaultLogger() - -const CHANGELOG_MD = 'CHANGELOG.md' -const NPM = 'npm' -const SOCKET_CLI_GITHUB_REPO = 'socket-cli' -const SOCKET_GITHUB_ORG = 'SocketDev' - -interface UpdateNotificationOptions { - name: string - current: string - latest: string -} - -/** - * Format an update message with appropriate commands and links. - */ -export function formatUpdateMessage(options: UpdateNotificationOptions): { - message: string - command?: string | undefined - changelog: string -} { - const { current, latest, name } = options - const seaBinPath = getSeaBinaryPath() - - const message = `📦 Update available for ${colors.cyan(name)}: ${colors.gray(current)} → ${colors.green(latest)}` - - if (isNonEmptyString(seaBinPath)) { - // SEA binary - show self-update command - return { - message, - command: `🔄 Run ${colors.cyan(`${seaBinPath} ${SEA_UPDATE_COMMAND}`)} to update automatically`, - changelog: githubRepoLink( - SOCKET_GITHUB_ORG, - SOCKET_CLI_GITHUB_REPO, - `blob/${latest}/${CHANGELOG_MD}`, - 'View changelog', - ), - } - } - // npm installation - show npm install command - return { - message, - changelog: socketPackageLink( - NPM, - name, - `files/${latest}/${CHANGELOG_MD}`, - 'View changelog', - ), - } -} - -/** - * Schedule update notification to show on process exit. This ensures the - * notification doesn't interfere with command output. - */ -export function scheduleExitNotification( - options: UpdateNotificationOptions, -): void { - if (!process.stdout?.isTTY) { - return // Probably piping stdout. - } - - try { - const notificationLogger = () => showUpdateNotification(options) - onExit(notificationLogger) - } catch (e) { - logger.warn(`Failed to schedule exit notification: ${errorMessage(e)}`) - } -} - -/** - * Show update notification immediately. - */ -export function showUpdateNotification( - options: UpdateNotificationOptions, -): void { - if (!process.stdout?.isTTY) { - return // Probably piping stdout. - } - - try { - const formatted = formatUpdateMessage(options) - const loggerLocal = getDefaultLogger() - - loggerLocal.log(`\n\n${formatted.message}`) - if (formatted.command) { - loggerLocal.log(formatted.command) - } - loggerLocal.log(`📝 ${formatted.changelog}`) - } catch { - // If formatting or logging fails, show a simpler message. - const loggerLocal = getDefaultLogger() - const { current, latest, name } = options - const seaBinPath = getSeaBinaryPath() - - loggerLocal.log( - `\n\n📦 Update available for ${name}: ${current} → ${latest}`, - ) - if (isNonEmptyString(seaBinPath)) { - loggerLocal.log( - `Run '${seaBinPath} ${SEA_UPDATE_COMMAND}' to update automatically`, - ) - } - } -} diff --git a/packages/cli/src/util/validation/check-input.mts b/packages/cli/src/util/validation/check-input.mts deleted file mode 100644 index 3b9b13478..000000000 --- a/packages/cli/src/util/validation/check-input.mts +++ /dev/null @@ -1,73 +0,0 @@ -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { LOG_SYMBOLS } from '@socketsecurity/lib-stable/logger/symbols' -import { stripAnsi } from '@socketsecurity/lib-stable/ansi/strip' - -import { failMsgWithBadge } from '../error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../output/result-json.mts' - -import type { OutputKind } from '../../types.mjs' -const logger = getDefaultLogger() - -export function checkCommandInput( - outputKind: OutputKind, - ...checks: Array<{ - fail: string - message: string - test: boolean - nook?: boolean | undefined - pass?: string | undefined - }> -): boolean { - if (checks.every(d => d.test)) { - return true - } - - const msg = ['Please review the input requirements and try again', ''] - for (let i = 0, { length } = checks; i < length; i += 1) { - const d = checks[i]! - // If nook, then ignore when test is ok - if (d.nook && d.test) { - continue - } - // Skip empty messages. - if (!d.message) { - continue - } - const lines = d.message.split('\n') - const { length: lineCount } = lines - // If the message has newlines then format the first line with the input - // expectation and the rest indented below it. - const logSymbol = d.test ? LOG_SYMBOLS['success'] : LOG_SYMBOLS['fail'] - const reason = d.test ? d.pass : d.fail - let listItem = ` ${logSymbol} ${lines[0]}` - if (reason) { - const styledReason = d.test ? colors.green(reason) : colors.red(reason) - listItem += ` (${styledReason})` - } - msg.push(listItem) - if (lineCount > 1) { - msg.push(...lines.slice(1).map(str => ` ${str}`)) - } - } - - // Use exit status of 2 to indicate incorrect usage, generally invalid - // options or missing arguments. - // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html - process.exitCode = 2 - - if (outputKind === 'json') { - logger.log( - serializeResultJson({ - ok: false, - message: 'Input error', - data: stripAnsi(msg.join('\n').trim()), - }), - ) - } else { - logger.fail(failMsgWithBadge('Input error', msg.join('\n').trim())) - } - - return false -} diff --git a/packages/cli/src/util/yarn/paths.mts b/packages/cli/src/util/yarn/paths.mts deleted file mode 100644 index 5a7111e6c..000000000 --- a/packages/cli/src/util/yarn/paths.mts +++ /dev/null @@ -1,39 +0,0 @@ -import { YARN_CLASSIC } from '@socketsecurity/lib-stable/constants/agents' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const YARN = YARN_CLASSIC - -import { findBinPathDetailsSync } from '../fs/path-resolve.mts' - -export function exitWithBinPathError(binName: string): never { - const logger = getDefaultLogger() - logger.fail( - `Socket unable to locate ${binName}; ensure it is available in the PATH environment variable`, - ) - // The exit code 127 indicates that the command or binary being executed - // could not be found. - process.exit(127) - // This line is never reached in production, but helps tests. - throw new Error('process.exit called') -} - -let yarnBinPath: string | undefined -export function getYarnBinPath(): string { - if (yarnBinPath === undefined) { - yarnBinPath = getYarnBinPathDetails().path - if (!yarnBinPath) { - exitWithBinPathError(YARN) - } - } - return yarnBinPath -} - -let yarnBinPathDetails: ReturnType<typeof findBinPathDetailsSync> | undefined -export function getYarnBinPathDetails(): ReturnType< - typeof findBinPathDetailsSync -> { - if (yarnBinPathDetails === undefined) { - yarnBinPathDetails = findBinPathDetailsSync(YARN) - } - return yarnBinPathDetails -} diff --git a/packages/cli/src/util/yarn/version.mts b/packages/cli/src/util/yarn/version.mts deleted file mode 100644 index 599e4f5c1..000000000 --- a/packages/cli/src/util/yarn/version.mts +++ /dev/null @@ -1,36 +0,0 @@ -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { FLAG_VERSION } from '../../constants/cli.mts' -import { getYarnBinPath } from '../yarn/paths.mts' - -let cachedIsYarnBerry: boolean | undefined -export function isYarnBerry(): boolean { - if (cachedIsYarnBerry === undefined) { - try { - const yarnBinPath = getYarnBinPath() - const result = spawnSync(yarnBinPath, [FLAG_VERSION], { - // On Windows, yarn is often a .cmd file that requires shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. - shell: WIN32, - }) - - if (result.status === 0 && result.stdout) { - const version = result.stdout - // Yarn Berry starts from version 2.x - const parts = version.trim().split('.') - const majorVersion = - parts.length > 0 && parts[0] && /^\d+$/.test(parts[0]) - ? Number.parseInt(parts[0], 10) - : 0 - cachedIsYarnBerry = majorVersion >= 2 - } else { - cachedIsYarnBerry = false - } - } catch { - cachedIsYarnBerry = false - } - } - return cachedIsYarnBerry -} diff --git a/packages/cli/test/e2e/README.md b/packages/cli/test/e2e/README.md deleted file mode 100644 index cc2e96878..000000000 --- a/packages/cli/test/e2e/README.md +++ /dev/null @@ -1,204 +0,0 @@ -# Socket CLI E2E Tests - -End-to-end tests for all Socket CLI commands across multiple binary types. - -## Test Files - -- **`binary-test-suite.e2e.test.mts`** - Comprehensive test suite for all 73 commands -- **`dlx-spawn.e2e.test.mts`** - DLX execution tests - -Per-domain smoke files (ported from the retired `smoke.sh`): - -- **`analytics.e2e.test.mts`** - `socket analytics` -- **`audit-fix-ci.e2e.test.mts`** - `socket audit-log`, `socket fix`, `socket ci` -- **`auth.e2e.test.mts`** - `socket login`, `socket logout`, `socket whoami` -- **`cli-help.e2e.test.mts`** - top-level `--version` / `--help` -- **`config.e2e.test.mts`** - `socket config` -- **`manifest.e2e.test.mts`** - `socket manifest` generators -- **`oops.e2e.test.mts`** - `socket oops` -- **`organization.e2e.test.mts`** - `socket organization` -- **`package.e2e.test.mts`** - `socket package` -- **`package-managers.e2e.test.mts`** - `socket npm`, `npx`, `raw-npm`, `raw-npx`, `wrapper`, `optimize`, `cdxgen`, `dependencies` -- **`repos.e2e.test.mts`** - `socket repos` (destructive round-trip behind `RUN_E2E_DESTRUCTIVE=1`) -- **`scan.e2e.test.mts`** - `socket scan` -- **`threat-feed.e2e.test.mts`** - `socket threat-feed` - -## Coverage Summary - -✅ **73/73 commands** (100% coverage) - -All commands have E2E tests that execute real CLI binaries and verify basic functionality. - -### Coverage Breakdown - -**Test Type:** - -- ✅ Real binary execution (no mocks) -- ✅ Process spawning via `executeCliCommand()` → `spawnSocketCli()` → `spawn()` -- ✅ All tests verified by parallel agent analysis - -**Coverage Levels:** - -- ✅ **Minimum** (73/73 commands): `--help` flag test for every command -- ✅ **Enhanced** (2 commands): Functional tests with authentication - - `whoami` - User identity verification - - `config list` - Configuration listing - -**Binary Types:** - -- ✅ **JS Binary** (`dist/cli.js`) - Always tested -- ✅ **SEA Binary** (`dist/sea/socket-sea`) - Optional via `TEST_SEA_BINARY=1` -- ✅ **Smol Binary** - Optional via `TEST_SMOL_BINARY=1` - -## Running Tests - -### Via E2E Script (Recommended) - -```bash -# JS binary (auto-builds if missing) -node scripts/e2e.mjs --js - -# SEA binary (auto-builds if missing) -node scripts/e2e.mjs --sea - -# All binaries (auto-builds if missing) -node scripts/e2e.mjs --all -``` - -### Via Vitest Directly - -```bash -# Set environment variables -RUN_E2E_TESTS=1 pnpm exec vitest run test/e2e/binary-test-suite.e2e.test.mts - -# With SEA binary -TEST_SEA_BINARY=1 RUN_E2E_TESTS=1 pnpm exec vitest run test/e2e/binary-test-suite.e2e.test.mts -``` - -## Auto-Build Feature - -Missing binaries are automatically built without prompting: - -- ✅ Uses prebuilt binaries from socket-btm + binject (fast builds) -- ✅ Works in both CI and local environments -- ✅ No manual build step required -- ✅ JS and SEA builds complete in seconds - -**How it works:** - -1. Test suite detects missing binary -2. Automatically runs appropriate build command -3. Waits for build to complete -4. Runs tests against newly built binary - -## Test Strategy - -### Minimum Test Pattern (All 73 Commands) - -Every command has at least this test: - -```typescript -it('should display <command> help', async () => { - const result = await executeCliCommand(['<command>', '--help'], { - binPath: binary.path, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) -}) -``` - -**What this validates:** - -- ✅ Command exists and is registered -- ✅ CLI binary can be executed -- ✅ Command loads without crashing -- ✅ Help text is generated -- ✅ No authentication required - -### Enhanced Test Pattern (2 Commands) - -Some commands have functional tests beyond `--help`: - -```typescript -// whoami -it('should display whoami information', async () => { - if (!hasAuth) return - - const result = await executeCliCommand(['whoami'], { - binPath: binary.path, - }) - - expect(result.code).toBe(0) -}) - -// config list -it('should list config settings', async () => { - if (!hasAuth) return - - const result = await executeCliCommand(['config', 'list'], { - binPath: binary.path, - }) - - expect(result.code).toBe(0) -}) -``` - -## Test Execution Flow - -1. **beforeAll()** - Check binary exists, auto-build if needed -2. **beforeAll()** - Check Socket API authentication -3. **Test Suite** - Run all command tests -4. **Binary Types** - Repeat for each enabled binary (JS/SEA/Smol) - -## Test Quality Metrics - -**Performance:** - -- ⚡ ~22 seconds for 78 tests (all 73 commands + extras) -- ⚡ Parallel execution where possible -- ⚡ Fast auto-builds using prebuilt binaries - -**Reliability:** - -- ✅ No fake or placeholder tests found -- ✅ All tests spawn real processes -- ✅ Meaningful assertions (exit codes + output) -- ✅ Verified by parallel agent analysis - -**Grade: A-** (Excellent foundational coverage with room for functional test expansion) - -## Command Architecture - -For complete command documentation including all subcommands, integrations, and architecture, see: - -**📚 [src/commands/README.md](../../src/commands/README.md)** - -The command architecture README includes: - -- Complete command hierarchy with subcommands -- Integration mappings (Socket APIs, third-party tools) -- Command file structure patterns -- Registration and alias information -- Guide for adding new commands - -## Adding Tests for New Commands - -When adding a new command: - -1. **Add to test commands array** in `binary-test-suite.e2e.test.mts` -2. **Minimum requirement**: `--help` flag test -3. **Optional enhancement**: Add functional test if command has unique behavior -4. **Verify**: Run `node scripts/e2e.mjs --js` to test - -Example: - -```typescript -// In binary-test-suite.e2e.test.mts -const commands = [ - // ... existing commands - 'mycommand', -] -``` - -That's it! The test framework handles the rest automatically. diff --git a/packages/cli/test/e2e/analytics.e2e.test.mts b/packages/cli/test/e2e/analytics.e2e.test.mts deleted file mode 100644 index 4b30a6d0f..000000000 --- a/packages/cli/test/e2e/analytics.e2e.test.mts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @file E2E tests for the `socket analytics` command family. Ported from - * `packages/cli/test/smoke.sh`'s analytics section (19 commands). - * - * Covers: default invocation / explicit scopes (org, repo, time-window), - * markdown + JSON output paths, --file output, and error-path checks for - * unknown flags / unknown scopes / unknown repos. - * - * Gated on `RUN_E2E_TESTS=1`. Auth-required tests additionally require a - * Socket API token. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket analytics (e2e)', () => { - describe('help and dry-run (no auth required)', () => { - it.skipIf(!RUN)('analytics --help exits 0', async () => { - const result = await executeCliCommand(['analytics', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('analytics --dry-run exits 0', async () => { - const result = await executeCliCommand(['analytics', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('default / org / repo / time-window (auth required, scratch-isolated)', () => { - it.skipIf(!RUN)('analytics (default scope) exits 0', async () => { - const result = await executeCliInScratch(['analytics']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('analytics --markdown exits 0', async () => { - const result = await executeCliInScratch(['analytics', '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('analytics --json conforms to contract', async () => { - const result = await executeCliInScratch(['analytics', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('analytics org --json conforms to contract', async () => { - const result = await executeCliInScratch(['analytics', 'org', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('analytics repo socket-cli --json conforms to contract', async () => { - const result = await executeCliInScratch(['analytics', 'repo', 'socket-cli', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('analytics org 7 --markdown exits 0', async () => { - const result = await executeCliInScratch(['analytics', 'org', '7', '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)( - 'analytics repo socket-cli 30 --markdown exits 0', - async () => { - const result = await executeCliInScratch([ - 'analytics', 'repo', 'socket-cli', '30', '--markdown', - ]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN)('analytics 90 --json conforms to contract', async () => { - const result = await executeCliInScratch(['analytics', '90', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - }) - - describe('--file output (auth required, scratch-isolated)', () => { - it.skipIf(!RUN)('analytics --file <scratch>/out.txt --json exits 0', async () => { - const result = await executeCliInScratch(['analytics', '--file', 'out.txt', '--json']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('analytics --file <scratch>/out.txt --markdown exits 0', async () => { - const result = await executeCliInScratch(['analytics', '--file', 'out.txt', '--markdown']) - expect(result.code).toBe(0) - }) - }) - - describe('error paths', () => { - it.skipIf(!RUN)('analytics --whatnow (unknown flag) exits 2', async () => { - const result = await executeCliCommand(['analytics', '--whatnow']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('analytics --file out.txt (no format flag) exits 2', async () => { - const result = await executeCliCommand(['analytics', '--file', 'out.txt']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('analytics rainbow --json (unknown scope) exits 2', async () => { - const result = await executeCliCommand(['analytics', 'rainbow', '--json']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)( - 'analytics repo veryunknownrepo --json (unknown repo) exits 1', - async () => { - const result = await executeCliInScratch([ - 'analytics', 'repo', 'veryunknownrepo', '--json', - ]) - expect(result.code).toBe(1) - }, - ) - - it.skipIf(!RUN)( - 'analytics repo 30 --markdown (no repo name) exits 2', - async () => { - const result = await executeCliCommand(['analytics', 'repo', '30', '--markdown']) - expect(result.code).toBe(2) - }, - ) - - it.skipIf(!RUN)( - 'analytics org 25 --markdown (invalid time-window) exits 2', - async () => { - const result = await executeCliCommand(['analytics', 'org', '25', '--markdown']) - expect(result.code).toBe(2) - }, - ) - - it.skipIf(!RUN)('analytics 123 --json (invalid time-window) exits 2', async () => { - const result = await executeCliCommand(['analytics', '123', '--json']) - expect(result.code).toBe(2) - }) - }) -}) diff --git a/packages/cli/test/e2e/audit-fix-ci.e2e.test.mts b/packages/cli/test/e2e/audit-fix-ci.e2e.test.mts deleted file mode 100644 index e81341d3b..000000000 --- a/packages/cli/test/e2e/audit-fix-ci.e2e.test.mts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file E2E tests for `socket audit-log`, `socket fix`, and `socket ci`. - * Ported from the corresponding sections of `packages/cli/test/smoke.sh`. - * - * These commands share a shape (help / dry-run / no-args run); audit-log - * needs auth, fix and ci are local-only. - * - * Gated on `RUN_E2E_TESTS=1`. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket audit-log (e2e, auth required)', () => { - it.skipIf(!RUN)('audit-log --help exits 0', async () => { - const result = await executeCliCommand(['audit-log', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('audit-log --dry-run exits 0', async () => { - const result = await executeCliCommand(['audit-log', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('audit-log exits 0', async () => { - const result = await executeCliInScratch(['audit-log']) - expect(result.code).toBe(0) - }) -}) - -describe('socket fix (e2e)', () => { - it.skipIf(!RUN)('fix --help exits 0', async () => { - const result = await executeCliCommand(['fix', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('fix --dry-run exits 0', async () => { - const result = await executeCliCommand(['fix', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('fix exits 0', async () => { - // Scratch-isolated with a minimal package.json so `fix` has something - // to operate on without touching the developer's repo. - const result = await executeCliInScratch(['fix'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-fix', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - }) -}) - -describe('socket ci (e2e)', () => { - it.skipIf(!RUN)('ci --help exits 0', async () => { - const result = await executeCliCommand(['ci', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('ci --dry-run exits 0', async () => { - const result = await executeCliCommand(['ci', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('ci exits 0', async () => { - const result = await executeCliInScratch(['ci'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-ci', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - }) -}) diff --git a/packages/cli/test/e2e/auth.e2e.test.mts b/packages/cli/test/e2e/auth.e2e.test.mts deleted file mode 100644 index b9fe4301c..000000000 --- a/packages/cli/test/e2e/auth.e2e.test.mts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @file E2E tests for `socket login`, `socket logout`, and `socket whoami`. - * Ported from `packages/cli/test/smoke.sh`'s login/logout sections, plus - * the `whoami` check that lived in `critical-commands.e2e.test.mts`. - * - * login is interactive in normal use; the smoke.sh check only ran the - * no-arg form (which exits 0 after printing a prompt note). logout - * mutates the developer's stored Socket session, so the destructive - * forms route through executeCliInScratch (isolated HOME → - * isolated keychain). The non-destructive `--help` / `--dry-run` - * checks use the normal helpers. - * - * Gated on `RUN_E2E_TESTS=1`. The destructive logout run additionally - * gates on RUN_E2E_DESTRUCTIVE=1 even though scratch-isolation means it's - * safe — operator opt-in stays consistent with the repos.e2e file. - */ - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { - executeCliCommand, - executeCliInScratch, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS -const RUN_DESTRUCTIVE = process.env['RUN_E2E_DESTRUCTIVE'] === '1' - -describe('socket login (e2e)', () => { - it.skipIf(!RUN)('login --help exits 0', async () => { - const result = await executeCliCommand(['login', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('login --dry-run exits 0', async () => { - const result = await executeCliCommand(['login', '--dry-run']) - expect(result.code).toBe(0) - }) - - // smoke.sh's `run_socket 0 login` ran the real login flow. In an e2e - // suite that can't accept TTY input, the equivalent is to confirm the - // command starts cleanly when there's no token to bind to — easiest in - // a scratch HOME with --no-interactive. - it.skipIf(!RUN)('login --no-interactive (no token) exits non-zero cleanly', async () => { - const result = await executeCliInScratch(['login', '--no-interactive']) - expect(result.code).toBeGreaterThan(0) - }) -}) - -describe('socket logout (e2e)', () => { - it.skipIf(!RUN)('logout --help exits 0', async () => { - const result = await executeCliCommand(['logout', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('logout --dry-run exits 0', async () => { - const result = await executeCliCommand(['logout', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !RUN_DESTRUCTIVE)( - 'logout (scratch-isolated) exits 0', - async () => { - // Even though scratch isolation means we're never touching the real - // session, gate on RUN_E2E_DESTRUCTIVE so destructive ops stay - // explicitly opt-in across the e2e suite. - const result = await executeCliInScratch(['logout']) - expect(result.code).toBe(0) - }, - ) -}) - -describe('socket whoami (e2e, auth required)', () => { - let hasAuth = false - beforeAll(async () => { - if (RUN) { - hasAuth = !!(await getDefaultApiToken()) - } - }) - - it.skipIf(!RUN || !hasAuth)('whoami exits 0 with auth present', async () => { - const result = await executeCliInScratch(['whoami']) - expect(result.code).toBe(0) - }) -}) diff --git a/packages/cli/test/e2e/binary-test-suite.e2e.test.mts b/packages/cli/test/e2e/binary-test-suite.e2e.test.mts deleted file mode 100644 index 9e47870d1..000000000 --- a/packages/cli/test/e2e/binary-test-suite.e2e.test.mts +++ /dev/null @@ -1,606 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * @file Comprehensive E2E test suite for all Socket CLI binary types. Tests ALL - * 73 CLI commands across 3 binary types: - * - * - JS binary (npm CLI) - Always tested - * - SEA binary (Single Executable Application) - Optional via TEST_SEA_BINARY=1 - * - Smol binary - Optional via TEST_SMOL_BINARY=1 Auto-build feature: - * - Missing binaries are automatically built without prompting (CI and local) - * - All builds use prebuilt binaries from socket-btm + binject (fast) Coverage: - * - Core commands (15): analytics, ask, audit-log, ci, console, fix, json, - * login, logout, oops, optimize, patch, threat-feed, whoami, wrapper - * - Config commands (6): config, config auto, config get, config list, config - * set, config unset - * - Install commands (4): install, install completion, uninstall, uninstall - * completion - * - Manifest commands (8): manifest, manifest auto, manifest cdxgen, manifest - * conda, manifest gradle, manifest kotlin, manifest scala, manifest setup - * - Organization commands (7): organization, organization dependencies, - * organization list, organization policy, organization policy license, - * organization policy security, organization quota - * - Package commands (3): package, package score, package shallow - * - Package manager wrappers (13): bundler, cargo, gem, go, npm, npx, nuget, - * pip, pnpm, raw-npm, raw-npx, uv, yarn - * - Repository commands (6): repository, repository create, repository del, - * repository list, repository update, repository view - * - Scan commands (11): scan, scan create, scan del, scan diff, scan github, - * scan list, scan metadata, scan reach, scan report, scan setup, scan view - * Test strategy: - * - Minimum test per command: --help (validates command loads without auth) - * - Auth-required commands: Basic execution test (with Socket API token) - * - Performance validation: Help commands execute within 5 seconds - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { ENV } from '../../src/constants/env.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { - executeCliCommand, - executeCliInScratch, -} from '../helpers/cli-execution.mts' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const ROOT_DIR = path.resolve(__dirname, '../..') -const MONOREPO_ROOT = path.resolve(ROOT_DIR, '../..') - -/** - * Binary types and their paths. - */ -const BINARIES = { - __proto__: null, - js: { - buildCommand: [ - 'pnpm', - '--filter', - '@socketsecurity/cli', - 'run', - 'build:js', - ], - enabled: true, - name: 'JS Binary (dist/cli.js)', - path: path.join(ROOT_DIR, 'dist/cli.js'), - }, - sea: { - buildCommand: [ - 'pnpm', - '--filter', - '@socketsecurity/cli', - 'run', - 'build:sea', - ], - enabled: !!process.env.TEST_SEA_BINARY, - name: 'SEA Binary (Single Executable Application)', - path: path.join(ROOT_DIR, 'dist/sea/socket-sea'), - }, - smol: { - buildCommand: [ - 'pnpm', - '--filter', - '@socketbin/node-smol-builder', - 'run', - 'build', - ], - enabled: !!process.env.TEST_SMOL_BINARY, - name: 'Smol Binary', - path: path.join( - MONOREPO_ROOT, - 'packages/node-smol-builder/dist/socket-smol', - ), - }, -} - -/** - * Build a binary if needed. - */ -export async function buildBinary( - binaryType: keyof typeof BINARIES, -): Promise<boolean> { - const binary = BINARIES[binaryType] - - if (!binary.buildCommand) { - return false - } - - logger.log(`Building ${binary.name}...`) - logger.log(`Running: ${binary.buildCommand.join(' ')}`) - - if (binaryType === 'smol') { - logger.log('Note: smol build may take 30-60 minutes on first build') - logger.log(' (subsequent builds are faster with caching)') - } - logger.log('') - - try { - const result = await spawn( - binary.buildCommand[0], - binary.buildCommand.slice(1), - { - cwd: MONOREPO_ROOT, - stdio: 'inherit', - }, - ) - - if (result.code !== 0) { - logger.error(`Failed to build ${binary.name}`) - return false - } - - logger.log(`Successfully built ${binary.name}`) - return true - } catch (e) { - logger.error(`Error building ${binary.name}:`, e) - return false - } -} - -/** - * Run the test suite for a specific binary type. - */ -function runBinaryTestSuite(binaryType: keyof typeof BINARIES) { - const binary = BINARIES[binaryType] - - if (!binary.enabled) { - return - } - - describe(`${binary.name}`, () => { - let hasAuth = false - let binaryExists = false - - beforeAll(async () => { - // Check if binary exists. - binaryExists = existsSync(binary.path) - - if (!binaryExists) { - logger.log('') - logger.warn(`Binary not found: ${binary.path}`) - - // All builds are fast (use prebuilt binaries from socket-btm + binject). - logger.log(`Auto-building ${binary.name}...`) - - const buildSuccess = await buildBinary(binaryType) - - if (buildSuccess) { - binaryExists = existsSync(binary.path) - } - - if (!binaryExists) { - logger.log('') - logger.error(`Failed to build ${binary.name}. Tests will be skipped.`) - logger.log('To build this binary manually, run:') - logger.log(` ${binary.buildCommand.join(' ')}`) - logger.log('') - return - } - - logger.log(`Binary built successfully: ${binary.path}`) - logger.log('') - } - - // Check authentication. - if (ENV.RUN_E2E_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken) { - logger.log('') - logger.warn('E2E tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log(' 3. Skip E2E tests by not setting RUN_E2E_TESTS') - logger.log('') - logger.log('E2E tests will be skipped due to missing authentication.') - logger.log('') - } - } - }) - - describe('Basic commands (no auth required)', () => { - it.skipIf(!ENV.RUN_E2E_TESTS)('should display version', async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--version'], { - binPath: binary.path, - isolateConfig: false, - }) - - // Note: --version currently shows help and exits with code 2 (known issue). - // This test validates the CLI executes without crashing. - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - it.skipIf(!ENV.RUN_E2E_TESTS)('should display help', async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--help'], { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) - }) - - describe('Core command help (no auth required)', () => { - const commands = [ - 'analytics', - 'ask', - 'audit-log', - 'ci', - 'console', - 'fix', - 'json', - 'login', - 'logout', - 'oops', - 'optimize', - 'patch', - 'threat-feed', - 'whoami', - 'wrapper', - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd} command help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand([cmd, '--help'], { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Config command help (no auth required)', () => { - const commands = [ - ['config', '--help'], - ['config', 'auto', '--help'], - ['config', 'get', '--help'], - ['config', 'list', '--help'], - ['config', 'set', '--help'], - ['config', 'unset', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Install/Uninstall command help (no auth required)', () => { - const commands = [ - ['install', '--help'], - ['install', 'completion', '--help'], - ['uninstall', '--help'], - ['uninstall', 'completion', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Manifest command help (no auth required)', () => { - const commands = [ - ['manifest', '--help'], - ['manifest', 'auto', '--help'], - ['manifest', 'cdxgen', '--help'], - ['manifest', 'conda', '--help'], - ['manifest', 'gradle', '--help'], - ['manifest', 'kotlin', '--help'], - ['manifest', 'scala', '--help'], - ['manifest', 'setup', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Organization command help (no auth required)', () => { - const commands = [ - ['organization', '--help'], - ['organization', 'dependencies', '--help'], - ['organization', 'list', '--help'], - ['organization', 'policy', '--help'], - ['organization', 'policy', 'license', '--help'], - ['organization', 'policy', 'security', '--help'], - ['organization', 'quota', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Package command help (no auth required)', () => { - const commands = [ - ['package', '--help'], - ['package', 'score', '--help'], - ['package', 'shallow', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Package manager wrapper command help (no auth required)', () => { - const commands = [ - 'bundler', - 'cargo', - 'gem', - 'go', - 'npm', - 'npx', - 'nuget', - 'pip', - 'pnpm', - 'raw-npm', - 'raw-npx', - 'uv', - 'yarn', - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd} command help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand([cmd, '--help'], { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Repository command help (no auth required)', () => { - const commands = [ - ['repository', '--help'], - ['repository', 'create', '--help'], - ['repository', 'del', '--help'], - ['repository', 'list', '--help'], - ['repository', 'update', '--help'], - ['repository', 'view', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Scan command help (no auth required)', () => { - const commands = [ - ['scan', '--help'], - ['scan', 'create', '--help'], - ['scan', 'del', '--help'], - ['scan', 'diff', '--help'], - ['scan', 'github', '--help'], - ['scan', 'list', '--help'], - ['scan', 'metadata', '--help'], - ['scan', 'reach', '--help'], - ['scan', 'report', '--help'], - ['scan', 'setup', '--help'], - ['scan', 'view', '--help'], - ] - - for (let i = 0, { length } = commands; i < length; i += 1) { - const cmd = commands[i] - it.skipIf(!ENV.RUN_E2E_TESTS)( - `should display ${cmd.join(' ')} help`, - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(cmd, { - binPath: binary.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - } - }) - - describe('Auth-required commands', () => { - it.skipIf(!ENV.RUN_E2E_TESTS)('should list config settings', async () => { - if (!binaryExists || !hasAuth) { - return - } - - // Scratch HOME so the test can't read the dev's real Socket config. - const result = await executeCliInScratch(['config', 'list'], { - binPath: binary.path, - }) - - expect(result.code).toBe(0) - }) - - it.skipIf(!ENV.RUN_E2E_TESTS)( - 'should display whoami information', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - // Scratch HOME so the API call uses the env-supplied token but - // can't persist anything back into the dev's config / keychain. - const result = await executeCliInScratch(['whoami'], { - binPath: binary.path, - }) - - expect(result.code).toBe(0) - }, - ) - }) - - describe('Performance validation', () => { - it.skipIf(!ENV.RUN_E2E_TESTS)( - 'should execute help command within reasonable time', - async () => { - if (!binaryExists) { - return - } - - const startTime = Date.now() - const result = await executeCliCommand(['--help'], { - binPath: binary.path, - isolateConfig: false, - }) - const duration = Date.now() - startTime - - expect(result.code).toBe(0) - // Help should execute in under 5 seconds even for bundled binaries. - expect(duration).toBeLessThan(5000) - }, - ) - }) - }) -} - -// Run test suite for each binary type. -describe('Socket CLI Binary Test Suite', () => { - // Always run JS binary test suite. - runBinaryTestSuite('js') - - // Run smol test suite (will prompt locally, skip in CI if not cached). - runBinaryTestSuite('smol') - - // Run SEA test suite (will prompt locally, skip in CI if not cached). - runBinaryTestSuite('sea') -}) diff --git a/packages/cli/test/e2e/cli-help.e2e.test.mts b/packages/cli/test/e2e/cli-help.e2e.test.mts deleted file mode 100644 index 9c01f1fc0..000000000 --- a/packages/cli/test/e2e/cli-help.e2e.test.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file E2E tests for the Socket CLI's top-level `--help` / `--version` - * behavior. Absorbed from the (now-deleted) critical-commands.e2e.test.mts - * "Basic commands (no auth required)" group. - * - * No auth required. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { executeCliCommand } from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket CLI top-level (e2e)', () => { - it.skipIf(!RUN)('--version produces output (known quirk: may exit 2)', async () => { - // Note: --version currently shows help and exits with code 2 (known - // issue). This test validates the CLI executes without crashing — the - // exact exit code is intentionally lenient so the test doesn't break - // when the quirk is fixed. - const result = await executeCliCommand(['--version'], { isolateConfig: false }) - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }) - - it.skipIf(!RUN)('--help exits 0 and lists main commands', async () => { - const result = await executeCliCommand(['--help'], { isolateConfig: false }) - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) -}) diff --git a/packages/cli/test/e2e/config.e2e.test.mts b/packages/cli/test/e2e/config.e2e.test.mts deleted file mode 100644 index 1e1fd742f..000000000 --- a/packages/cli/test/e2e/config.e2e.test.mts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @file E2E tests for the `socket config` command family. Ported from - * `packages/cli/test/smoke.sh`'s config section (15 commands). - * - * Covers: get / set / unset / auto at help / dry-run / no-args / valid-key. - * - * smoke.sh's `set defaultOrg mydev` mutated the developer's real config. - * The port uses `executeCliInScratch` (isolated HOME / XDG_CONFIG_HOME) - * for any set/unset/auto call, so no real config file is touched. - * - * Gated on `RUN_E2E_TESTS=1`. No auth required — these are local config - * operations. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket config (e2e)', () => { - describe('top-level', () => { - it.skipIf(!RUN)('config (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['config']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('config --help exits 0', async () => { - const result = await executeCliCommand(['config', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('config --dry-run exits 0', async () => { - const result = await executeCliCommand(['config', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('config get', () => { - it.skipIf(!RUN)('config get --help exits 0', async () => { - const result = await executeCliCommand(['config', 'get', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('config get --dry-run (no key) exits 2', async () => { - const result = await executeCliCommand(['config', 'get', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('config get defaultOrg exits 0', async () => { - // Scratch HOME so even a stray `~/.config/socket/...` read can't leak - // into the test assertion. - const result = await executeCliInScratch(['config', 'get', 'defaultOrg']) - expect(result.code).toBe(0) - }) - }) - - describe('config set (scratch-isolated)', () => { - it.skipIf(!RUN)('config set --help exits 0', async () => { - const result = await executeCliCommand(['config', 'set', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('config set --dry-run (no key/value) exits 2', async () => { - const result = await executeCliCommand(['config', 'set', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('config set defaultOrg <value> exits 0', async () => { - // Scratch-isolated so the developer's real defaultOrg isn't overwritten. - const result = await executeCliInScratch(['config', 'set', 'defaultOrg', 'mydev']) - expect(result.code).toBe(0) - }) - }) - - describe('config unset (scratch-isolated)', () => { - it.skipIf(!RUN)('config unset --help exits 0', async () => { - const result = await executeCliCommand(['config', 'unset', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('config unset --dry-run (no key) exits 2', async () => { - const result = await executeCliCommand(['config', 'unset', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('config unset defaultOrg exits 0', async () => { - const result = await executeCliInScratch(['config', 'unset', 'defaultOrg']) - expect(result.code).toBe(0) - }) - }) - - describe('config auto (scratch-isolated)', () => { - it.skipIf(!RUN)('config auto --help exits 0', async () => { - const result = await executeCliCommand(['config', 'auto', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('config auto --dry-run (no key) exits 2', async () => { - const result = await executeCliCommand(['config', 'auto', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('config auto defaultOrg exits 0', async () => { - const result = await executeCliInScratch(['config', 'auto', 'defaultOrg']) - expect(result.code).toBe(0) - }) - }) -}) diff --git a/packages/cli/test/e2e/dlx-spawn.e2e.test.mts b/packages/cli/test/e2e/dlx-spawn.e2e.test.mts deleted file mode 100644 index 01b4912d4..000000000 --- a/packages/cli/test/e2e/dlx-spawn.e2e.test.mts +++ /dev/null @@ -1,324 +0,0 @@ -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { spawnDlx } from '../../src/util/dlx/spawn.mts' -import { findUp } from '../../src/util/fs/find-up.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { withScratchHome } from '../helpers/cli-execution.mts' - -describe('dlx e2e tests', () => { - let hasAuth = false - - beforeAll(async () => { - // Check if running e2e tests and if Socket API token is available. - if (ENV.RUN_E2E_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken) { - logger.log() - logger.warn('E2E tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log(' 3. Skip e2e tests by not setting RUN_E2E_TESTS') - logger.log('') - logger.log('E2E tests will be skipped due to missing authentication.') - logger.log('') - } - } - }) - describe('pnpm exec regression test', () => { - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'successfully runs pnpm exec with cowsay (verifies no unsupported flags)', - async () => { - // Check if we're in a pnpm project. - const pnpmLock = await findUp('pnpm-lock.yaml') - if (!pnpmLock) { - logger.log('Skipping test - not in a pnpm project') - return - } - - // Use cowsay as a safe, pinned package for testing. - const packageSpec = { - name: 'cowsay', - version: '1.6.0', // Pinned version for consistency. - } - - // Scratch HOME so spawnDlx installs to <scratch>/.socket/_dlx rather - // than the dev's ~/.socket/_dlx. - await withScratchHome(async () => { - // Run cowsay with a test message. - const result = await spawnDlx(packageSpec, [ - 'Hello from Socket CLI tests!', - ]) - - // Verify it succeeded. - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBe(0) - if (spawnResult.stdout) { - // Cowsay should output our message in a speech bubble. - expect(spawnResult.stdout).toContain('Hello from Socket CLI tests!') - // Should have the cow ASCII art. - expect(spawnResult.stdout).toMatch(/\\\s+/) - expect(spawnResult.stdout).toMatch(/\^__\^/) - } - }) - }, - 30000, // 30 second timeout for download. - ) - - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'verifies pnpm exec command construction uses only supported flags', - async () => { - // This test verifies by checking what command would be run. - const pnpmLock = await findUp('pnpm-lock.yaml') - if (!pnpmLock) { - logger.log('Skipping test - not in a pnpm project') - return - } - - // We can't easily intercept the actual spawn call in e2e, - // but we can verify the command that would be constructed - // by checking our unit tests pass and the actual execution works. - - // Try to run a simple pnpm dlx command directly to ensure it works. - // Scratch HOME so pnpm's store + cache land outside the dev's home. - await withScratchHome(async () => { - try { - const r = spawnSync( - 'pnpm', - ['exec', 'cowsay@1.6.0', 'Direct test'], - { stdio: 'pipe', stdioString: true }, - ) - if (r.status !== 0) { - throw new Error(String(r.stderr ?? r.stdout ?? '')) - } - expect(String(r.stdout)).toContain('Direct test') - - // Verify that adding unsupported flags would fail. - // For example, --ignore-scripts is only for pnpm install, not dlx. - expect(() => { - const r2 = spawnSync( - 'pnpm', - ['exec', '--ignore-scripts', 'cowsay@1.6.0', 'Should fail'], - { stdio: 'pipe', stdioString: true }, - ) - if (r2.status !== 0) { - throw new Error(String(r2.stderr ?? r2.stdout ?? '')) - } - }).toThrow() - } catch (e) { - // If pnpm is not available globally, skip this part. - logger.log('Could not run direct pnpm test:', e.message) - } - }) - }, - 15000, - ) - }) - - describe('npm pnpm exec regression test', () => { - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'successfully runs npm/pnpm exec with cowsay', - async () => { - // Force npm by not finding any pnpm/yarn lockfiles. - const npmLock = await findUp('package-lock.json') - const pnpmLock = await findUp('pnpm-lock.yaml') - const yarnLock = await findUp('yarn.lock') - - // Skip if we're in a pnpm/yarn project to ensure npm is used. - if (pnpmLock || yarnLock) { - logger.log('Skipping npm test - in pnpm/yarn project') - return - } - - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - await withScratchHome(async () => { - // Force npm agent. - const result = await spawnDlx(packageSpec, ['Moo from npm!'], { - agent: 'npm', - }) - - expect(result.ok).toBe(true) - if (result.ok && result.data) { - expect(result.data).toContain('Moo from npm!') - } - }) - }, - 30000, - ) - }) - - describe('spawnCoanaDlx e2e tests', () => { - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'executes @coana-tech/cli via dlx with correct binary name', - async () => { - const { spawnCoanaDlx } = await import('../../src/util/dlx/spawn.mts') - await withScratchHome(async () => { - const result = await spawnCoanaDlx(['--help']) - - // Coana should succeed - if it fails, it indicates a real issue. - expect(result).toBeDefined() - expect(result.ok).toBe(true) - - if (result.ok && result.data) { - // Verify we got output from coana binary. - expect(result.data).toContain('coana') - } else { - // If coana fails, the test should fail to catch real issues. - throw new Error(`Coana execution failed: ${result.message}`) - } - }) - }, - 30000, - ) - - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'verifies coana binary is correctly resolved from package name', - async () => { - const { spawnCoanaDlx } = await import('../../src/util/dlx/spawn.mts') - const { resolveCoana } = - await import('../../src/util/dlx/resolve-binary.mts') - - // Verify the resolution includes correct binary name. - const resolution = resolveCoana() - if (resolution.type === 'dlx') { - expect(resolution.details.name).toBe('@coana-tech/cli') - expect(resolution.details.binaryName).toBe('coana') - } - - await withScratchHome(async () => { - // Verify execution works with resolved binary name. - const result = await spawnCoanaDlx(['--version']) - - expect(result).toBeDefined() - expect(result.ok).toBe(true) - - if (result.ok && result.data) { - // Version output should contain coana information. - expect(result.data).toBeTruthy() - } - }) - }, - 30000, - ) - }) - - describe('spawnSynpDlx e2e tests', () => { - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'executes synp via dlx', - async () => { - const { spawnSynpDlx } = await import('./spawn.mts') - await withScratchHome(async () => { - const result = await spawnSynpDlx(['--help']) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBe(0) - if (spawnResult.stdout) { - expect(spawnResult.stdout).toContain('synp') - } - }) - }, - 30000, - ) - - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'handles error from spawn', - async () => { - const { spawnSynpDlx } = await import('./spawn.mts') - await withScratchHome(async () => { - // Pass invalid args to trigger an error. - const result = await spawnSynpDlx([ - '--invalid-flag-that-does-not-exist', - ]) - - // The command should fail with invalid flags. - // Just verify we get a result with spawnPromise. - expect(result).toBeDefined() - expect(result.spawnPromise).toBeDefined() - - // The spawnPromise may throw or return with non-zero exit code - try { - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBeGreaterThan(0) // Should fail with non-zero exit code - } catch (e) { - // Command failed as expected - this is valid behavior - expect(error).toBeDefined() - } - }) - }, - 30000, - ) - }) - - describe('spawnDlx e2e tests', () => { - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'executes dlx command with package spec', - async () => { - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - await withScratchHome(async () => { - const result = await spawnDlx(packageSpec, ['--help']) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }) - }, - 30000, - ) - - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'handles force flag in options', - async () => { - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - await withScratchHome(async () => { - const result = await spawnDlx(packageSpec, ['Test with force'], { - force: true, - }) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }) - }, - 30000, - ) - - it.skipIf(!ENV.RUN_E2E_TESTS || !hasAuth)( - 'handles silent flag in options', - async () => { - const packageSpec = { - name: 'cowsay', - version: '^1.6.0', // Range version should trigger silent. - } - - await withScratchHome(async () => { - const result = await spawnDlx(packageSpec, ['Silent test'], { - silent: true, - }) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }) - }, - 30000, - ) - }) -}) diff --git a/packages/cli/test/e2e/manifest.e2e.test.mts b/packages/cli/test/e2e/manifest.e2e.test.mts deleted file mode 100644 index 4c63c2b4b..000000000 --- a/packages/cli/test/e2e/manifest.e2e.test.mts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file E2E tests for the `socket manifest` command family. Ported from - * `packages/cli/test/smoke.sh`'s manifest section (18 commands). - * - * Covers: each generator subcommand (auto, conda, gradle, kotlin, scala) at - * help / dry-run / no-args invocation. No auth required — manifest - * generation is local. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket manifest (e2e)', () => { - describe('top-level', () => { - it.skipIf(!RUN)('manifest (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['manifest']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('manifest --help exits 0', async () => { - const result = await executeCliCommand(['manifest', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('manifest --dry-run exits 0', async () => { - const result = await executeCliCommand(['manifest', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('manifest auto', () => { - it.skipIf(!RUN)('manifest auto (no path) exits 1', async () => { - // Scratch cwd so the generator's filesystem walk doesn't traverse the - // dev's socket-cli repo looking for build files. - const result = await executeCliInScratch(['manifest', 'auto']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('manifest auto --help exits 0', async () => { - const result = await executeCliCommand(['manifest', 'auto', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('manifest auto --dry-run exits 0', async () => { - const result = await executeCliCommand(['manifest', 'auto', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('manifest conda', () => { - it.skipIf(!RUN)('manifest conda (no env file) exits 1', async () => { - const result = await executeCliInScratch(['manifest', 'conda']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('manifest conda --help exits 0', async () => { - const result = await executeCliCommand(['manifest', 'conda', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('manifest conda --dry-run exits 0', async () => { - const result = await executeCliCommand(['manifest', 'conda', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('manifest gradle', () => { - it.skipIf(!RUN)('manifest gradle (no project) exits 1', async () => { - const result = await executeCliInScratch(['manifest', 'gradle']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('manifest gradle --help exits 0', async () => { - const result = await executeCliCommand(['manifest', 'gradle', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)( - 'manifest gradle --dry-run exits 1 (per smoke.sh — gradle wrapper not found)', - async () => { - const result = await executeCliCommand(['manifest', 'gradle', '--dry-run']) - expect(result.code).toBe(1) - }, - ) - }) - - describe('manifest kotlin', () => { - it.skipIf(!RUN)('manifest kotlin (no project) exits 1', async () => { - const result = await executeCliInScratch(['manifest', 'kotlin']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('manifest kotlin --help exits 0', async () => { - const result = await executeCliCommand(['manifest', 'kotlin', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('manifest kotlin --dry-run exits 0', async () => { - const result = await executeCliCommand(['manifest', 'kotlin', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('manifest scala', () => { - it.skipIf(!RUN)('manifest scala (no project) exits 1', async () => { - const result = await executeCliInScratch(['manifest', 'scala']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('manifest scala --help exits 0', async () => { - const result = await executeCliCommand(['manifest', 'scala', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('manifest scala --dry-run exits 0', async () => { - const result = await executeCliCommand(['manifest', 'scala', '--dry-run']) - expect(result.code).toBe(0) - }) - }) -}) diff --git a/packages/cli/test/e2e/oops.e2e.test.mts b/packages/cli/test/e2e/oops.e2e.test.mts deleted file mode 100644 index 067a605c4..000000000 --- a/packages/cli/test/e2e/oops.e2e.test.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file E2E tests for `socket oops`. Ported from - * `packages/cli/test/smoke.sh`'s oops section (4 commands). - * - * `oops` is a deliberate-failure command used in regression tests; the - * no-arg form exits 1 by design. - * - * Gated on `RUN_E2E_TESTS=1`. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { executeCliCommand } from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket oops (e2e)', () => { - it.skipIf(!RUN)('oops (no args) exits 1 by design', async () => { - const result = await executeCliCommand(['oops']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('oops --help exits 0', async () => { - const result = await executeCliCommand(['oops', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('oops --dry-run exits 0', async () => { - const result = await executeCliCommand(['oops', '--dry-run']) - expect(result.code).toBe(0) - }) -}) diff --git a/packages/cli/test/e2e/organization.e2e.test.mts b/packages/cli/test/e2e/organization.e2e.test.mts deleted file mode 100644 index ed251cfbd..000000000 --- a/packages/cli/test/e2e/organization.e2e.test.mts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @file E2E tests for the `socket organization` command family. Ported from - * `packages/cli/test/smoke.sh`'s organization section (45 commands). - * - * Covers: list / policy security / policy license / quota; --json - * contract conformance; --org overrides; missing-org and invalid-org - * error paths (achieved via per-call `--config` injection instead of - * mutating the real config file). - * - * Gated on `RUN_E2E_TESTS=1`. Auth-required tests additionally require a - * Socket API token. - */ - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { - executeCliCommand, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket organization (e2e)', () => { - let hasAuth = false - let defaultOrg: string | undefined - - beforeAll(async () => { - if (!RUN) { - return - } - hasAuth = !!(await getDefaultApiToken()) - if (hasAuth) { - // Resolve the developer's real default org so --org <real> checks have - // a value. Read it via `config get` rather than poking at the file - // directly so the CLI's own resolution wins. - const result = await executeCliCommand(['config', 'get', 'defaultOrg', '--json'], { - isolateConfig: false, - }) - if (result.code === 0) { - try { - const payload = JSON.parse(result.stdout) as { data?: string | undefined } - defaultOrg = typeof payload.data === 'string' ? payload.data : undefined - } catch { - // Leave undefined — per-org tests skip themselves below. - } - } - } - }) - - describe('help and dry-run (no auth required)', () => { - it.skipIf(!RUN)('organization (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['organization']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('organization --help exits 0', async () => { - const result = await executeCliCommand(['organization', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization list --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'list', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization list --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'list', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['organization', 'policy']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('organization policy --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy license --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', 'license', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy license --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', 'license', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy security --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', 'security', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization policy security --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'policy', 'security', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization quota --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'quota', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization quota --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'quota', '--dry-run']) - expect(result.code).toBe(0) - }) - }) - - describe('list / policy / quota (auth required, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)('organization list exits 0', async () => { - const result = await executeCliInScratch(['organization', 'list']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('organization policy license exits 0', async () => { - const result = await executeCliInScratch(['organization', 'policy', 'license']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('organization policy security exits 0', async () => { - const result = await executeCliInScratch(['organization', 'policy', 'security']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('organization quota exits 0', async () => { - const result = await executeCliInScratch(['organization', 'quota']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --markdown exits 0', - async () => { - const result = await executeCliInScratch(['organization', 'policy', 'security', '--markdown']) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --json conforms to contract', - async () => { - const result = await executeCliInScratch(['organization', 'policy', 'security', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --markdown exits 0', - async () => { - const result = await executeCliInScratch(['organization', 'policy', 'license', '--markdown']) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --json conforms to contract', - async () => { - const result = await executeCliInScratch(['organization', 'policy', 'license', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }, - ) - }) - - describe('--org <real-default-org> (auth required, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --org <real> exits 0', - async () => { - if (!defaultOrg) { - return - } - const result = await executeCliInScratch([ - 'organization', 'policy', 'security', '--org', defaultOrg, - ]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --org <real> exits 0', - async () => { - if (!defaultOrg) { - return - } - const result = await executeCliInScratch([ - 'organization', 'policy', 'license', '--org', defaultOrg, - ]) - expect(result.code).toBe(0) - }, - ) - }) - - describe('--org trash (invalid org, auth required, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --org trash exits 1', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'security', '--org', 'trash', - ]) - expect(result.code).toBe(1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --org trash --markdown exits 1', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'security', '--org', 'trash', '--markdown', - ]) - expect(result.code).toBe(1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy security --org trash --json conforms to error contract', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'security', '--org', 'trash', '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --org trash exits 1', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'license', '--org', 'trash', - ]) - expect(result.code).toBe(1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --org trash --markdown exits 1', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'license', '--org', 'trash', '--markdown', - ]) - expect(result.code).toBe(1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'organization policy license --org trash --json conforms to error contract', - async () => { - const result = await executeCliInScratch([ - 'organization', 'policy', 'license', '--org', 'trash', '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - }) - - describe('config-driven org resolution (auth required, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)( - 'policy security with no defaultOrg in config exits 1 (--no-interactive)', - async () => { - // No `defaultOrg` in the injected config and --no-interactive prevents - // the CLI from prompting; failure is the expected outcome. - const result = await executeCliInScratch( - ['organization', 'policy', 'security', '--json', '--no-interactive'], - { config: {} }, - ) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'policy license with no defaultOrg in config exits 1 (--no-interactive)', - async () => { - const result = await executeCliInScratch( - ['organization', 'policy', 'license', '--json', '--no-interactive'], - { config: {} }, - ) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'policy security with defaultOrg=fake_org in config exits 1 (--no-interactive)', - async () => { - const result = await executeCliInScratch( - ['organization', 'policy', 'security', '--json', '--no-interactive'], - { config: { defaultOrg: 'fake_org' } }, - ) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'policy license with defaultOrg=fake_org in config exits 1 (--no-interactive)', - async () => { - const result = await executeCliInScratch( - ['organization', 'policy', 'license', '--json', '--no-interactive'], - { config: { defaultOrg: 'fake_org' } }, - ) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - }) -}) diff --git a/packages/cli/test/e2e/package-managers.e2e.test.mts b/packages/cli/test/e2e/package-managers.e2e.test.mts deleted file mode 100644 index 2cf267c11..000000000 --- a/packages/cli/test/e2e/package-managers.e2e.test.mts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @file E2E tests for Socket CLI's package-manager wrappers. Ported from - * `packages/cli/test/smoke.sh`'s npm / npx / raw-npm / raw-npx / wrapper / - * optimize / cdxgen / dependencies sections. - * - * Covers: help / dry-run paths for each wrapper; the wrapper on/off - * toggle (scratch-isolated so the developer's real shim install isn't - * touched); `dependencies` listing + pagination flags; `cdxgen`'s - * no-arg invocation; `optimize` flag matrix. - * - * Gated on `RUN_E2E_TESTS=1`. `dependencies` against the org list needs - * auth; `cdxgen` needs a real cdxgen install on PATH. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket npm wrapper (e2e)', () => { - it.skipIf(!RUN)('npm --help exits 0', async () => { - const result = await executeCliCommand(['npm', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('npm --dry-run exits 0', async () => { - const result = await executeCliCommand(['npm', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('npm info exits 0', async () => { - // Scratch so npm's ~/.npm cache isn't written into the dev's home. - const result = await executeCliInScratch(['npm', 'info']) - expect(result.code).toBe(0) - }) -}) - -describe('socket npx wrapper (e2e)', () => { - it.skipIf(!RUN)('npx --help exits 0', async () => { - const result = await executeCliCommand(['npx', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('npx --dry-run exits 0', async () => { - const result = await executeCliCommand(['npx', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('npx cowsay moo exits 0', async () => { - // npx downloads cowsay into npm cache — pin to scratch. - const result = await executeCliInScratch(['npx', 'cowsay', 'moo']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('npx socket --dry-run exits 0', async () => { - const result = await executeCliInScratch(['npx', 'socket', '--dry-run']) - expect(result.code).toBe(0) - }) -}) - -describe('socket raw-npm (e2e)', () => { - it.skipIf(!RUN)('raw-npm (no args) exits 1', async () => { - // raw-npm may invoke real npm which writes to ~/.npm; scratch isolates. - const result = await executeCliInScratch(['raw-npm']) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('raw-npm --help exits 0', async () => { - const result = await executeCliCommand(['raw-npm', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('raw-npm --dry-run exits 0', async () => { - const result = await executeCliCommand(['raw-npm', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('raw-npm info exits 0', async () => { - const result = await executeCliInScratch(['raw-npm', 'info']) - expect(result.code).toBe(0) - }) -}) - -describe('socket raw-npx (e2e)', () => { - it.skipIf(!RUN)('raw-npx --help exits 0', async () => { - const result = await executeCliCommand(['raw-npx', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('raw-npx --dry-run exits 0', async () => { - const result = await executeCliCommand(['raw-npx', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('raw-npx cowsay moo exits 0', async () => { - const result = await executeCliInScratch(['raw-npx', 'cowsay', 'moo']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('raw-npx socket --dry-run exits 0', async () => { - const result = await executeCliInScratch(['raw-npx', 'socket', '--dry-run']) - expect(result.code).toBe(0) - }) -}) - -describe('socket wrapper toggle (e2e, scratch-isolated)', () => { - it.skipIf(!RUN)('wrapper (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['wrapper']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('wrapper --help exits 0', async () => { - const result = await executeCliCommand(['wrapper', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('wrapper --dry-run (no on/off) exits 2', async () => { - const result = await executeCliCommand(['wrapper', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('wrapper on exits 0', async () => { - const result = await executeCliInScratch(['wrapper', 'on']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('wrapper off exits 0', async () => { - const result = await executeCliInScratch(['wrapper', 'off']) - expect(result.code).toBe(0) - }) -}) - -describe('socket optimize (e2e)', () => { - it.skipIf(!RUN)('optimize --help exits 0', async () => { - const result = await executeCliCommand(['optimize', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('optimize --dry-run exits 0', async () => { - const result = await executeCliCommand(['optimize', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('optimize exits 0', async () => { - const result = await executeCliInScratch(['optimize'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-optimize', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('optimize --prod exits 0', async () => { - const result = await executeCliInScratch(['optimize', '--prod'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-optimize', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('optimize --pin exits 0', async () => { - const result = await executeCliInScratch(['optimize', '--pin'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-optimize', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - }) -}) - -describe('socket cdxgen (e2e)', () => { - it.skipIf(!RUN)('cdxgen (no args, no real cdxgen on PATH) exits 1', async () => { - // cdxgen may write SBOM artifacts into cwd if it succeeds; scratch keeps - // them out of the dev's repo. - const result = await executeCliInScratch(['cdxgen']) - expect(result.code).toBe(1) - }) -}) - -describe('socket organization dependencies (e2e, auth required, scratch-isolated)', () => { - it.skipIf(!RUN)('organization dependencies --help exits 0', async () => { - const result = await executeCliCommand(['organization', 'dependencies', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization dependencies --dry-run exits 0', async () => { - const result = await executeCliCommand(['organization', 'dependencies', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization dependencies exits 0', async () => { - const result = await executeCliInScratch(['organization', 'dependencies']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization dependencies --json conforms to contract', async () => { - const result = await executeCliInScratch(['organization', 'dependencies', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('organization dependencies --markdown exits 0', async () => { - const result = await executeCliInScratch(['organization', 'dependencies', '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization dependencies --limit 1 exits 0', async () => { - const result = await executeCliInScratch(['organization', 'dependencies', '--limit', '1']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('organization dependencies --offset 5 exits 0', async () => { - const result = await executeCliInScratch(['organization', 'dependencies', '--offset', '5']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)( - 'organization dependencies --limit 1 --offset 10 exits 0', - async () => { - const result = await executeCliInScratch([ - 'organization', 'dependencies', '--limit', '1', '--offset', '10', - ]) - expect(result.code).toBe(0) - }, - ) -}) diff --git a/packages/cli/test/e2e/package.e2e.test.mts b/packages/cli/test/e2e/package.e2e.test.mts deleted file mode 100644 index f753588a9..000000000 --- a/packages/cli/test/e2e/package.e2e.test.mts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @file E2E tests for the `socket package` command family. Ported from - * `packages/cli/test/smoke.sh`'s package section (25 commands). - * - * Covers: shallow / score against representative npm packages — `socket` - * (the package itself, regression case for past 500s), `babel` (well-known - * ok package), `nope` (single-publish curio that sometimes hangs server - * side), and `mostdefinitelynotworkingletskeepitthatway` (silent-no-data - * case where the server returns nothing rather than 404). - * - * Gated on `RUN_E2E_TESTS=1`. Auth-required tests additionally require a - * Socket API token. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -const SILENT_NO_DATA_PKG = 'mostdefinitelynotworkingletskeepitthatway' - -describe('socket package (e2e)', () => { - describe('help and dry-run (no auth required)', () => { - it.skipIf(!RUN)('package (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['package']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('package --help exits 0', async () => { - const result = await executeCliCommand(['package', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('package --dry-run exits 0', async () => { - const result = await executeCliCommand(['package', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('package score --help exits 0', async () => { - const result = await executeCliInScratch(['package', 'score', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('package score --dry-run (no args) exits 2', async () => { - const result = await executeCliInScratch(['package', 'score', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('package shallow --help exits 0', async () => { - const result = await executeCliInScratch(['package', 'shallow', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('package shallow --dry-run (no args) exits 2', async () => { - const result = await executeCliInScratch(['package', 'shallow', '--dry-run']) - expect(result.code).toBe(2) - }) - }) - - describe('package score (auth required)', () => { - it.skipIf(!RUN)('score npm tenko exits 0', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'tenko']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('score npm socket exits 0 (regression: server 500)', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'socket']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('score npm babel exits 0', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'babel']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('score npm nope exits 0 (server may stall)', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'nope']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('score npm <silent-no-data> exits 1', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', SILENT_NO_DATA_PKG]) - expect(result.code).toBe(1) - }) - - it.skipIf(!RUN)('score npm socket --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'socket', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('score npm babel --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'babel', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('score npm nope --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'score', 'npm', 'nope', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('score npm <silent-no-data> --json conforms to error contract', async () => { - const result = await executeCliInScratch([ - 'package', 'score', 'npm', SILENT_NO_DATA_PKG, '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }) - }) - - describe('package shallow (auth required)', () => { - it.skipIf(!RUN)('shallow npm socket exits 0 (regression: server 500)', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'socket']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('shallow npm babel exits 0', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'babel']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('shallow npm nope exits 0 (server may stall)', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'nope']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)( - 'shallow npm <silent-no-data> exits 0 (server returns no data, not an error)', - async () => { - const result = await executeCliInScratch([ - 'package', 'shallow', 'npm', SILENT_NO_DATA_PKG, - ]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN)('shallow npm socket --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'socket', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('shallow npm babel --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'babel', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('shallow npm nope --json conforms to contract', async () => { - const result = await executeCliInScratch(['package', 'shallow', 'npm', 'nope', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)( - 'shallow npm <silent-no-data> --json conforms to contract (ok:true with empty data)', - async () => { - const result = await executeCliInScratch([ - 'package', 'shallow', 'npm', SILENT_NO_DATA_PKG, '--json', - ]) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }, - ) - }) -}) diff --git a/packages/cli/test/e2e/repos.e2e.test.mts b/packages/cli/test/e2e/repos.e2e.test.mts deleted file mode 100644 index 6b454f826..000000000 --- a/packages/cli/test/e2e/repos.e2e.test.mts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @file E2E tests for the `socket repos` command family. Ported from - * `packages/cli/test/smoke.sh`'s repos section (17 commands). - * - * Most repos checks are help / dry-run / read-only and run under the - * normal `RUN_E2E_TESTS=1` gate. The create → update → view → del - * round-trip writes real org-side state via the Socket API, so it lives - * behind a second gate (`RUN_E2E_DESTRUCTIVE=1`) and uses a - * `cli-e2e-<pid>-<timestamp>` repo name so concurrent runs don't collide - * and a stale repo from a crashed run is easy to identify. - * - * afterAll best-effort deletes the test repo, but the round-trip's own - * `del` step is the primary cleanup; afterAll only catches mid-run aborts. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS -const RUN_DESTRUCTIVE = process.env['RUN_E2E_DESTRUCTIVE'] === '1' - -describe('socket repos (e2e)', () => { - let hasAuth = false - - beforeAll(async () => { - if (RUN) { - hasAuth = !!(await getDefaultApiToken()) - } - }) - - describe('help and dry-run (no auth required)', () => { - it.skipIf(!RUN)('repos (no subcommand) exits 2', async () => { - const result = await executeCliCommand(['repos']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('repos --help exits 0', async () => { - const result = await executeCliCommand(['repos', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos --dry-run exits 0', async () => { - const result = await executeCliCommand(['repos', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos create --help exits 0', async () => { - const result = await executeCliCommand(['repos', 'create', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos create --dry-run (no name) exits 2', async () => { - const result = await executeCliCommand(['repos', 'create', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('repos update --help exits 0', async () => { - const result = await executeCliCommand(['repos', 'update', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos update --dry-run (no name) exits 2', async () => { - const result = await executeCliCommand(['repos', 'update', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('repos view --help exits 0', async () => { - const result = await executeCliCommand(['repos', 'view', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos view --dry-run (no name) exits 2', async () => { - const result = await executeCliCommand(['repos', 'view', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('repos del --help exits 0', async () => { - const result = await executeCliCommand(['repos', 'del', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('repos del --dry-run (no name) exits 2', async () => { - const result = await executeCliCommand(['repos', 'del', '--dry-run']) - expect(result.code).toBe(2) - }) - }) - - describe('error paths (auth required, read-only, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)( - 'repos view <nonexistent> --json conforms to error contract', - async () => { - const result = await executeCliInScratch([ - 'repos', 'view', 'cli_donotcreate', '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)( - 'repos update <nonexistent> --homepage evil --json conforms to error contract', - async () => { - const result = await executeCliInScratch([ - 'repos', 'update', 'cli_donotcreate', '--homepage', 'evil', '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - }) - - describe('create → update → view → del round-trip (destructive)', () => { - // The repo name encodes the pid + timestamp so concurrent CI runs don't - // collide, and a leaked repo from a crashed run is easy to identify. - const repoName = `cli-e2e-${process.pid}-${Date.now()}` - - afterAll(async () => { - if (!RUN || !hasAuth || !RUN_DESTRUCTIVE) { - return - } - // Best-effort cleanup; the round-trip's own `del` step is primary. - try { - await executeCliInScratch(['repos', 'del', repoName]) - } catch { - // Repo may already be deleted by the round-trip's del step. - } - }) - - it.skipIf(!RUN || !hasAuth || !RUN_DESTRUCTIVE)( - 'creates a uniquely-named repo', - async () => { - const result = await executeCliInScratch(['repos', 'create', repoName]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth || !RUN_DESTRUCTIVE)( - 'updates the homepage on the created repo', - async () => { - const result = await executeCliInScratch([ - 'repos', 'update', repoName, '--homepage', 'socket.dev', - ]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth || !RUN_DESTRUCTIVE)( - 'views the created repo', - async () => { - const result = await executeCliInScratch(['repos', 'view', repoName]) - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!RUN || !hasAuth || !RUN_DESTRUCTIVE)( - 'deletes the created repo', - async () => { - const result = await executeCliInScratch(['repos', 'del', repoName]) - expect(result.code).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/e2e/scan.e2e.test.mts b/packages/cli/test/e2e/scan.e2e.test.mts deleted file mode 100644 index fa7560fb4..000000000 --- a/packages/cli/test/e2e/scan.e2e.test.mts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * @file E2E tests for the `socket scan` command family. Ported from - * `packages/cli/test/smoke.sh`'s scan section. Exercises help, dry-run, - * list/view/metadata/report/diff, and `--json` contract conformance. - * - * Every auth-required call runs inside `executeCliInScratch` so: - * - any cwd-side `.socket/` artifacts land in the scratch tree - * - the CLI can't persist new credentials/config to the dev's HOME - * - npm/pnpm/yarn caches are pinned to scratch too - * - * Gated on `RUN_E2E_TESTS=1`. Auth-required tests additionally require a - * Socket API token to be present. - */ - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { getDefaultApiToken } from '../../src/util/socket/sdk.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket scan (e2e)', () => { - let hasAuth = false - - beforeAll(async () => { - if (RUN) { - hasAuth = !!(await getDefaultApiToken()) - } - }) - - describe('help and dry-run (no auth required)', () => { - it.skipIf(!RUN)('exits 2 with no subcommand (prints help)', async () => { - const result = await executeCliCommand(['scan']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan --help exits 0', async () => { - const result = await executeCliCommand(['scan', '--help']) - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }) - - it.skipIf(!RUN)('scan --dry-run exits 0', async () => { - const result = await executeCliCommand(['scan', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan create --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'create', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan create --dry-run (no target) exits 2', async () => { - const result = await executeCliCommand(['scan', 'create', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan del --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'del', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan del --dry-run exits 2', async () => { - const result = await executeCliCommand(['scan', 'del', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan list --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'list', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan list --dry-run exits 0', async () => { - const result = await executeCliCommand(['scan', 'list', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan view --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'view', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan view (no id) exits 2', async () => { - const result = await executeCliCommand(['scan', 'view']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan view --dry-run (no id) exits 2', async () => { - const result = await executeCliCommand(['scan', 'view', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan metadata --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'metadata', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan metadata --dry-run (no id) exits 2', async () => { - const result = await executeCliCommand(['scan', 'metadata', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan report --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'report', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan report --dry-run (no id) exits 2', async () => { - const result = await executeCliCommand(['scan', 'report', '--dry-run']) - expect(result.code).toBe(2) - }) - - it.skipIf(!RUN)('scan diff --help exits 0', async () => { - const result = await executeCliCommand(['scan', 'diff', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('scan diff --dry-run (no ids) exits 2', async () => { - const result = await executeCliCommand(['scan', 'diff', '--dry-run']) - expect(result.code).toBe(2) - }) - }) - - describe('list / view / metadata / report / diff (auth required)', () => { - let sbomId: string | undefined - let secondSbomId: string | undefined - - beforeAll(async () => { - if (!RUN || !hasAuth) { - return - } - // Resolve the two most-recent scan IDs for the configured default org. - // These feed the per-id checks below. - const result = await executeCliInScratch(['scan', 'list', '--json']) - if (result.code === 0) { - try { - const payload = JSON.parse(result.stdout) as { - data?: { results?: Array<{ id?: string | undefined }> | undefined } | undefined - } - const results = payload.data?.results - if (Array.isArray(results)) { - sbomId = results[0]?.id - secondSbomId = results[1]?.id - } - } catch { - // Fall through — per-id tests will skip themselves below. - } - } - }) - - it.skipIf(!RUN || !hasAuth)('scan list exits 0', async () => { - const result = await executeCliInScratch(['scan', 'list']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan list --json conforms to contract', async () => { - const result = await executeCliInScratch(['scan', 'list', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN || !hasAuth)('scan list --markdown exits 0', async () => { - const result = await executeCliInScratch(['scan', 'list', '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan view <id> exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'view', sbomId]) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan view <id> --json conforms to contract', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'view', sbomId, '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN || !hasAuth)('scan view <id> --markdown exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'view', sbomId, '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan metadata <id> exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'metadata', sbomId]) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan metadata <id> --json conforms to contract', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'metadata', sbomId, '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN || !hasAuth)('scan metadata <id> --markdown exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'metadata', sbomId, '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan report <id> exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'report', sbomId]) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan report <id> --json conforms to contract', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'report', sbomId, '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN || !hasAuth)('scan report <id> --markdown exits 0', async () => { - if (!sbomId) { - return - } - const result = await executeCliInScratch(['scan', 'report', sbomId, '--markdown']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan diff <id1> <id2> exits 0', async () => { - if (!sbomId || !secondSbomId) { - return - } - const result = await executeCliInScratch(['scan', 'diff', sbomId, secondSbomId]) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN || !hasAuth)('scan diff --json conforms to contract', async () => { - if (!sbomId || !secondSbomId) { - return - } - const result = await executeCliInScratch(['scan', 'diff', sbomId, secondSbomId, '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN || !hasAuth)('scan diff --markdown exits 0', async () => { - if (!sbomId || !secondSbomId) { - return - } - const result = await executeCliInScratch(['scan', 'diff', sbomId, secondSbomId, '--markdown']) - expect(result.code).toBe(0) - }) - }) - - describe('scan create (auth required, scratch-isolated)', () => { - it.skipIf(!RUN || !hasAuth)( - 'scan create . exits 0 with --json contract', - async () => { - const result = await executeCliInScratch(['scan', 'create', '.', '--json'], { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-scan', version: '0.0.0' }), - }, - }) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }, - ) - }) - - describe('error paths — non-existent org', () => { - it.skipIf(!RUN || !hasAuth)( - 'scan create --org fake_org exits 1', - async () => { - const result = await executeCliInScratch( - ['scan', 'create', '.', '--org', 'fake_org', '--json'], - { - seedFiles: { - 'package.json': JSON.stringify({ name: 'socket-cli-e2e-fake-org', version: '0.0.0' }), - }, - }, - ) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }, - ) - - it.skipIf(!RUN || !hasAuth)('scan view --org fake_org exits 1', async () => { - const result = await executeCliInScratch(['scan', 'view', 'placeholder', '--org', 'fake_org', '--json']) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }) - - it.skipIf(!RUN || !hasAuth)('scan report --org fake_org exits 1', async () => { - const result = await executeCliInScratch(['scan', 'report', 'placeholder', '--org', 'fake_org', '--json']) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }) - - it.skipIf(!RUN || !hasAuth)('scan metadata --org fake_org exits 1', async () => { - const result = await executeCliInScratch(['scan', 'metadata', 'placeholder', '--org', 'fake_org', '--json']) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }) - - it.skipIf(!RUN || !hasAuth)('scan diff --org fake_org exits 1', async () => { - const result = await executeCliInScratch([ - 'scan', 'diff', 'placeholder', 'placeholder', '--org', 'fake_org', '--json', - ]) - expect(result.code).toBe(1) - validateSocketJsonContract(result.stdout, 1) - }) - }) -}) diff --git a/packages/cli/test/e2e/threat-feed.e2e.test.mts b/packages/cli/test/e2e/threat-feed.e2e.test.mts deleted file mode 100644 index 69aaf0a5c..000000000 --- a/packages/cli/test/e2e/threat-feed.e2e.test.mts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @file E2E tests for `socket threat-feed`. Ported from - * `packages/cli/test/smoke.sh`'s threat-feed section (6 commands). - * - * threat-feed is interactive by default; smoke.sh notes a "potential - * caching issue" on the first run. The non-interactive forms drive the - * tests here. - * - * Gated on `RUN_E2E_TESTS=1`. Auth required. - */ - -import { describe, expect, it } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' -import { - executeCliCommand, - executeCliInScratch, - validateSocketJsonContract, -} from '../helpers/cli-execution.mts' - -const RUN = ENV.RUN_E2E_TESTS - -describe('socket threat-feed (e2e, auth required, scratch-isolated)', () => { - it.skipIf(!RUN)('threat-feed --help exits 0', async () => { - const result = await executeCliCommand(['threat-feed', '--help']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('threat-feed --dry-run exits 0', async () => { - const result = await executeCliCommand(['threat-feed', '--dry-run']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('threat-feed (interactive default) exits 0', async () => { - const result = await executeCliInScratch(['threat-feed']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('threat-feed --no-interactive exits 0', async () => { - const result = await executeCliInScratch(['threat-feed', '--no-interactive']) - expect(result.code).toBe(0) - }) - - it.skipIf(!RUN)('threat-feed --json conforms to contract', async () => { - const result = await executeCliInScratch(['threat-feed', '--json']) - expect(result.code).toBe(0) - validateSocketJsonContract(result.stdout, 0) - }) - - it.skipIf(!RUN)('threat-feed --markdown exits 0', async () => { - const result = await executeCliInScratch(['threat-feed', '--markdown']) - expect(result.code).toBe(0) - }) -}) diff --git a/packages/cli/test/fixtures/agent/bun/package.json b/packages/cli/test/fixtures/agent/bun/package.json deleted file mode 100644 index 5df29e704..000000000 --- a/packages/cli/test/fixtures/agent/bun/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "bun-fixture", - "version": "1.0.0", - "private": true, - "dependencies": {} -} diff --git a/packages/cli/test/fixtures/agent/pnpm-v10/package.json b/packages/cli/test/fixtures/agent/pnpm-v10/package.json deleted file mode 100644 index a2cffdec1..000000000 --- a/packages/cli/test/fixtures/agent/pnpm-v10/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "pnpm-v10-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "10.0.0" - } -} diff --git a/packages/cli/test/fixtures/agent/pnpm-v8/package.json b/packages/cli/test/fixtures/agent/pnpm-v8/package.json deleted file mode 100644 index 5eba46d3f..000000000 --- a/packages/cli/test/fixtures/agent/pnpm-v8/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "pnpm-v8-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "8.15.1" - } -} diff --git a/packages/cli/test/fixtures/agent/pnpm-v9/package.json b/packages/cli/test/fixtures/agent/pnpm-v9/package.json deleted file mode 100644 index 48ff25544..000000000 --- a/packages/cli/test/fixtures/agent/pnpm-v9/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "pnpm-v9-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "9.14.4" - } -} diff --git a/packages/cli/test/fixtures/agent/vlt/package.json b/packages/cli/test/fixtures/agent/vlt/package.json deleted file mode 100644 index c50e841e4..000000000 --- a/packages/cli/test/fixtures/agent/vlt/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "vlt-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "vlt": "0.0.0-30" - } -} diff --git a/packages/cli/test/fixtures/agent/yarn-berry/package.json b/packages/cli/test/fixtures/agent/yarn-berry/package.json deleted file mode 100644 index ca8a8d124..000000000 --- a/packages/cli/test/fixtures/agent/yarn-berry/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "yarn-berry-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "@yarnpkg/cli": "4.10.3" - } -} diff --git a/packages/cli/test/fixtures/agent/yarn-classic/package.json b/packages/cli/test/fixtures/agent/yarn-classic/package.json deleted file mode 100644 index 6c864353c..000000000 --- a/packages/cli/test/fixtures/agent/yarn-classic/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "yarn-classic-fixture", - "version": "1.0.0", - "private": true, - "dependencies": { - "yarn": "1.22.22" - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/npm/package-lock.json b/packages/cli/test/fixtures/commands/cdxgen/npm/package-lock.json deleted file mode 100644 index 51417bbbc..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/npm/package-lock.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "dependencies": { - "lodash": "4.17.21" - }, - "devDependencies": { - "assert": "1.5.0" - } - }, - "node_modules/assert": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", - "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1", - "util": "0.10.3" - } - }, - "node_modules/inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", - "dev": true, - "license": "ISC" - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "2.0.1" - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/npm/package.json b/packages/cli/test/fixtures/commands/cdxgen/npm/package.json deleted file mode 100644 index 5b23a9bee..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/npm/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for cdxgen command testing", - "main": "index.js", - "dependencies": { - "lodash": "4.17.21" - }, - "devDependencies": { - "assert": "1.5.0" - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/package.json b/packages/cli/test/fixtures/commands/cdxgen/package.json deleted file mode 100644 index 5b23a9bee..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for cdxgen command testing", - "main": "index.js", - "dependencies": { - "lodash": "4.17.21" - }, - "devDependencies": { - "assert": "1.5.0" - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/pnpm/package.json b/packages/cli/test/fixtures/commands/cdxgen/pnpm/package.json deleted file mode 100644 index 5b23a9bee..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/pnpm/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for cdxgen command testing", - "main": "index.js", - "dependencies": { - "lodash": "4.17.21" - }, - "devDependencies": { - "assert": "1.5.0" - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/pnpm/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/cdxgen/pnpm/pnpm-lock.yaml deleted file mode 100644 index cb4446bdf..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/pnpm/pnpm-lock.yaml +++ /dev/null @@ -1,52 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lodash: - specifier: 4.17.21 - version: 4.17.21 - devDependencies: - assert: - specifier: 1.5.0 - version: 1.5.0 - -packages: - - assert@1.5.0: - resolution: {integrity: sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==} - - inherits@2.0.1: - resolution: {integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - util@0.10.3: - resolution: {integrity: sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==} - -snapshots: - - assert@1.5.0: - dependencies: - object-assign: 4.1.1 - util: 0.10.3 - - inherits@2.0.1: {} - - lodash@4.17.21: {} - - object-assign@4.1.1: {} - - util@0.10.3: - dependencies: - inherits: 2.0.1 diff --git a/packages/cli/test/fixtures/commands/cdxgen/yarn/package.json b/packages/cli/test/fixtures/commands/cdxgen/yarn/package.json deleted file mode 100644 index 5b23a9bee..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/yarn/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "cdxgen-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for cdxgen command testing", - "main": "index.js", - "dependencies": { - "lodash": "4.17.21" - }, - "devDependencies": { - "assert": "1.5.0" - } -} diff --git a/packages/cli/test/fixtures/commands/cdxgen/yarn/yarn.lock b/packages/cli/test/fixtures/commands/cdxgen/yarn/yarn.lock deleted file mode 100644 index eb51e9f23..000000000 --- a/packages/cli/test/fixtures/commands/cdxgen/yarn/yarn.lock +++ /dev/null @@ -1,33 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -assert@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" - integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== - dependencies: - object-assign "^4.1.1" - util "0.10.3" - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - integrity sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA== - -lodash@4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -util@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - integrity sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ== - dependencies: - inherits "2.0.1" diff --git a/packages/cli/test/fixtures/commands/fix/e2e-test-js/package-lock.json b/packages/cli/test/fixtures/commands/fix/e2e-test-js/package-lock.json deleted file mode 100644 index 682c54f17..000000000 --- a/packages/cli/test/fixtures/commands/fix/e2e-test-js/package-lock.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "e2e-test-js", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "e2e-test-js", - "version": "1.0.0", - "dependencies": { - "lodash": "4.17.20" - } - }, - "node_modules/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", - "license": "MIT" - } - } -} diff --git a/packages/cli/test/fixtures/commands/fix/e2e-test-js/package.json b/packages/cli/test/fixtures/commands/fix/e2e-test-js/package.json deleted file mode 100644 index 9fa62566f..000000000 --- a/packages/cli/test/fixtures/commands/fix/e2e-test-js/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "e2e-test-js", - "version": "1.0.0", - "private": true, - "description": "E2E test fixture with known vulnerabilities", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/e2e-test-py/requirements.txt b/packages/cli/test/fixtures/commands/fix/e2e-test-py/requirements.txt deleted file mode 100644 index 6b8eed5cc..000000000 --- a/packages/cli/test/fixtures/commands/fix/e2e-test-py/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -django==3.0.0 -requests==2.25.0 diff --git a/packages/cli/test/fixtures/commands/fix/npm/monorepo/package-lock.json b/packages/cli/test/fixtures/commands/fix/npm/monorepo/package-lock.json deleted file mode 100644 index 88bd5bf6d..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/monorepo/package-lock.json +++ /dev/null @@ -1,357 +0,0 @@ -{ - "name": "monorepo-test-npm", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "monorepo-test-npm", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "axios": "1.3.2" - } - }, - "node_modules/@monorepo-npm/app": { - "resolved": "packages/app", - "link": true - }, - "node_modules/@monorepo-npm/lib": { - "resolved": "packages/lib", - "link": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.2.tgz", - "integrity": "sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "packages/app": { - "name": "@monorepo-npm/app", - "version": "1.0.0", - "dependencies": { - "on-headers": "1.0.2" - } - }, - "packages/lib": { - "name": "@monorepo-npm/lib", - "version": "1.0.0", - "dependencies": { - "lodash": "4.17.20" - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/fix/npm/monorepo/package.json b/packages/cli/test/fixtures/commands/fix/npm/monorepo/package.json deleted file mode 100644 index 35fc23aab..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/monorepo/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "monorepo-test-npm", - "version": "1.0.0", - "private": true, - "description": "Test monorepo fixture (npm)", - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/app/package.json b/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/app/package.json deleted file mode 100644 index 547f18adf..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/app/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo-npm/app", - "version": "1.0.0", - "private": true, - "description": "App package in monorepo (npm)", - "main": "index.js", - "dependencies": { - "on-headers": "1.0.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/lib/package.json b/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/lib/package.json deleted file mode 100644 index be7e4320f..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/monorepo/packages/lib/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo-npm/lib", - "version": "1.0.0", - "private": true, - "description": "Lib package in monorepo (npm)", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package-lock.json b/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package-lock.json deleted file mode 100644 index 00c2bf557..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package-lock.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "vulnerable-deps-test-npm", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "vulnerable-deps-test-npm", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } - } - } -} \ No newline at end of file diff --git a/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package.json b/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package.json deleted file mode 100644 index 4d19f2846..000000000 --- a/packages/cli/test/fixtures/commands/fix/npm/vulnerable-deps/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "vulnerable-deps-test-npm", - "version": "1.0.0", - "private": true, - "description": "Test fixture with vulnerable dependencies (npm)", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/package.json b/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/package.json deleted file mode 100644 index 958e984bb..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "monorepo-test", - "version": "1.0.0", - "private": true, - "description": "Test monorepo fixture", - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/app/package.json b/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/app/package.json deleted file mode 100644 index 52c4c0750..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/app/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo/app", - "version": "1.0.0", - "private": true, - "description": "App package in monorepo", - "main": "index.js", - "dependencies": { - "on-headers": "1.0.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/lib/package.json b/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/lib/package.json deleted file mode 100644 index 9d283a6fc..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/packages/lib/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo/lib", - "version": "1.0.0", - "private": true, - "description": "Lib package in monorepo", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/pnpm-lock.yaml deleted file mode 100644 index 2e7a1b98d..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/monorepo/pnpm-lock.yaml +++ /dev/null @@ -1,204 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/package.json b/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/package.json deleted file mode 100644 index a09eb3cd1..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "vulnerable-deps-test", - "version": "1.0.0", - "private": true, - "description": "Test fixture with vulnerable dependencies", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/pnpm-lock.yaml deleted file mode 100644 index 0a314daa4..000000000 --- a/packages/cli/test/fixtures/commands/fix/pnpm/vulnerable-deps/pnpm-lock.yaml +++ /dev/null @@ -1,222 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lodash: - specifier: 4.17.20 - version: 4.17.20 - on-headers: - specifier: 1.0.2 - version: 1.0.2 - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - lodash@4.17.20: - resolution: {integrity: sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} - engines: {node: '>= 0.8'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - lodash@4.17.20: {} - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - on-headers@1.0.2: {} - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/package.json b/packages/cli/test/fixtures/commands/fix/yarn/monorepo/package.json deleted file mode 100644 index 2c7b4f55b..000000000 --- a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "monorepo-test-yarn", - "version": "1.0.0", - "private": true, - "description": "Test monorepo fixture (yarn)", - "workspaces": [ - "packages/*" - ], - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/app/package.json b/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/app/package.json deleted file mode 100644 index eba1f0159..000000000 --- a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/app/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo-yarn/app", - "version": "1.0.0", - "private": true, - "description": "App package in monorepo (yarn)", - "main": "index.js", - "dependencies": { - "on-headers": "1.0.2" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/lib/package.json b/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/lib/package.json deleted file mode 100644 index dea2b85f5..000000000 --- a/packages/cli/test/fixtures/commands/fix/yarn/monorepo/packages/lib/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@monorepo-yarn/lib", - "version": "1.0.0", - "private": true, - "description": "Lib package in monorepo (yarn)", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20" - } -} diff --git a/packages/cli/test/fixtures/commands/fix/yarn/vulnerable-deps/package.json b/packages/cli/test/fixtures/commands/fix/yarn/vulnerable-deps/package.json deleted file mode 100644 index 1db6cb8a5..000000000 --- a/packages/cli/test/fixtures/commands/fix/yarn/vulnerable-deps/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "vulnerable-deps-test-yarn", - "version": "1.0.0", - "private": true, - "description": "Test fixture with vulnerable dependencies (yarn)", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/manifest/python/requirements.txt b/packages/cli/test/fixtures/commands/manifest/python/requirements.txt deleted file mode 100644 index f8cef5ed3..000000000 --- a/packages/cli/test/fixtures/commands/manifest/python/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -qgrid==1.3.0 -mplstereonet -pyqt5 -gempy==2.1.0 \ No newline at end of file diff --git a/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package-lock.json b/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package-lock.json deleted file mode 100644 index 861c8acb8..000000000 --- a/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package-lock.json +++ /dev/null @@ -1,2105 +0,0 @@ -{ - "name": "lacking-typosquat", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "bowserify": "^10.2.1" - } - }, - "node_modules/acorn": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", - "integrity": "sha512-fu2ygVGuMmlzG8ZeRJ0bvR41nsAkxxhbyk8bZ1SS521Z7vmgJFTQQlfz/Mp/nJexGBz+v8sC9bM6+lNgskt4Ug==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-node/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", - "license": "BSD-3-Clause OR MIT", - "engines": { - "node": ">=0.4.2" - } - }, - "node_modules/asn1.js": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/assert": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.3.0.tgz", - "integrity": "sha512-5aKcpD+XnHpZ7EGxsuo6uoILNh0rvm0Ypa17GlkrF2CNSPhvdgi3ft9XsL2ajdVOI2I3xuGZnHvlXAeqTZYvXg==", - "license": "MIT", - "dependencies": { - "util": "0.10.3" - } - }, - "node_modules/assert/node_modules/inherits": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", - "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", - "license": "ISC" - }, - "node_modules/assert/node_modules/util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", - "integrity": "sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==", - "license": "MIT", - "dependencies": { - "inherits": "2.0.1" - } - }, - "node_modules/astw": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/astw/-/astw-2.2.0.tgz", - "integrity": "sha512-E/4z//dvN0lfr8zAx8hXeQ8o3nRoQaL/wqI7fAALEvh/40mnyUxfFB9MwyDHYKVDtS3cp3Pow5s96djZR5lkWw==", - "license": "MIT", - "dependencies": { - "acorn": "^4.0.3" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/Base64": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/Base64/-/Base64-0.2.1.tgz", - "integrity": "sha512-reGEWshDmTDQDsCec/HduOO9Wyj6yMOupMfhIf3ugN1TDlK2NQW4DDJSqNNtp380SNcvRfXtO8HSCQot0d0SMw==" - }, - "node_modules/base64-js": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", - "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", - "license": "MIT" - }, - "node_modules/bowserify": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/bowserify/-/bowserify-10.2.1.tgz", - "integrity": "sha512-1l/p4Yoghf3OMPr93dQfQJFoku7F+n6PQnDVtwsTNkGJnV3ICWCwIMjDNISWROZdAqTgXxG9YJGexIcEyPWn8g==", - "license": "MIT", - "dependencies": { - "assert": "~1.3.0", - "browser-pack": "^5.0.0", - "browser-resolve": "^1.7.1", - "browserify-zlib": "~0.1.2", - "buffer": "^3.0.0", - "builtins": "~0.0.3", - "commondir": "0.0.1", - "concat-stream": "~1.4.1", - "console-browserify": "^1.1.0", - "constants-browserify": "~0.0.1", - "crypto-browserify": "^3.0.0", - "deep-equal": "^1.0.0", - "defined": "^1.0.0", - "deps-sort": "^1.3.7", - "domain-browser": "~1.1.0", - "duplexer2": "~0.0.2", - "events": "~1.0.0", - "glob": "^4.0.5", - "has": "^1.0.0", - "htmlescape": "^1.1.0", - "http-browserify": "^1.4.0", - "https-browserify": "~0.0.0", - "inherits": "~2.0.1", - "insert-module-globals": "^6.4.1", - "isarray": "0.0.1", - "JSONStream": "^1.0.3", - "labeled-stream-splicer": "^1.0.0", - "module-deps": "^3.7.11", - "os-browserify": "~0.1.1", - "parents": "^1.0.1", - "path-browserify": "~0.0.0", - "process": "~0.11.0", - "punycode": "^1.3.2", - "querystring-es3": "~0.2.0", - "read-only-stream": "^1.1.1", - "readable-stream": "^1.1.13", - "resolve": "^1.1.4", - "shasum": "^1.0.0", - "shell-quote": "~0.0.1", - "stream-browserify": "^1.0.0", - "string_decoder": "~0.10.0", - "subarg": "^1.0.0", - "syntax-error": "^1.1.1", - "through2": "^1.0.0", - "timers-browserify": "^1.0.1", - "tty-browserify": "~0.0.0", - "url": "~0.10.1", - "util": "~0.10.1", - "vm-browserify": "~0.0.1", - "xtend": "^4.0.0" - }, - "bin": { - "bowserify": "bin/cmd.js" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, - "node_modules/browser-pack": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-5.0.1.tgz", - "integrity": "sha512-BFMQuYXCcwr3Uvna1y1hikqd3r2dQpWIQBIN3m5YwE3ClfnXDeF3tqP6Wqjhs1LRUeBJpgHn8yD+fPX/YSEgMQ==", - "license": "MIT", - "dependencies": { - "combine-source-map": "~0.6.1", - "defined": "^1.0.0", - "JSONStream": "^1.0.3", - "through2": "^1.0.0", - "umd": "^3.0.0" - }, - "bin": { - "browser-pack": "bin/cmd.js" - } - }, - "node_modules/browser-resolve": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", - "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", - "license": "MIT", - "dependencies": { - "resolve": "1.1.7" - } - }, - "node_modules/browser-resolve/node_modules/resolve": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", - "integrity": "sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg==", - "license": "MIT" - }, - "node_modules/browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "license": "MIT", - "dependencies": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "license": "MIT", - "dependencies": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "node_modules/browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/browserify-rsa": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", - "license": "MIT", - "dependencies": { - "bn.js": "^5.2.1", - "randombytes": "^2.1.0", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/browserify-sign": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", - "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", - "license": "ISC", - "dependencies": { - "bn.js": "^5.2.2", - "browserify-rsa": "^4.1.1", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "elliptic": "^6.6.1", - "inherits": "^2.0.4", - "parse-asn1": "^5.1.9", - "readable-stream": "^2.3.8", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/browserify-sign/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/browserify-sign/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/browserify-sign/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/browserify-zlib": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", - "integrity": "sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==", - "license": "MIT", - "dependencies": { - "pako": "~0.2.0" - } - }, - "node_modules/buffer": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-3.6.2.tgz", - "integrity": "sha512-c3M77NkHJxS0zx/ErxXhDLr1v3y2MDXPeTJPvLNOaIYJ4ymHBUFQ9EXzt9HYuqAJllMoNb/EZ8hIiulnQFAUuQ==", - "license": "MIT", - "dependencies": { - "base64-js": "0.0.8", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "node_modules/buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", - "license": "MIT" - }, - "node_modules/buffer/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/builtins": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-0.0.7.tgz", - "integrity": "sha512-T8uCGKc0/2aLVt6omt8JxDRBoWEMkku+wFesxnhxnt4NygVZG99zqxo7ciK8eebszceKamGoUiLdkXCgGQyrQw==", - "license": "MIT" - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cipher-base": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", - "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/combine-source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.6.1.tgz", - "integrity": "sha512-XKRNtuZRlVDTuSGKsfZpXYz80y0XDbYS4a+FzafTgmYHy/ckruFBx7Nd6WaQnFHVI3O6IseWVdXUvZutMpjSkQ==", - "license": "MIT", - "dependencies": { - "convert-source-map": "~1.1.0", - "inline-source-map": "~0.5.0", - "lodash.memoize": "~3.0.3", - "source-map": "~0.4.2" - } - }, - "node_modules/commondir": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-0.0.1.tgz", - "integrity": "sha512-Ghe1LmLv3G3c0XJYu+c88MCRIPqWQ67qaqKY1KvuN4uPAjfUj+y4hvcpZ2kCPrjpRNyklW4dpAZZ8a7vOh50tg==", - "license": "MIT/X11", - "engines": { - "node": "*" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.4.11.tgz", - "integrity": "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "inherits": "~2.0.1", - "readable-stream": "~1.1.9", - "typedarray": "~0.0.5" - } - }, - "node_modules/console-browserify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", - "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==" - }, - "node_modules/constants-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-0.0.1.tgz", - "integrity": "sha512-FL+diDS9AKR5BAA2M+GNk8lnH64tRE3zepTG9hucxc7o04LgCRhkQZhF7u/OKHZT8LLRT+sZEi9qFzXUchq9pA==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", - "integrity": "sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/create-ecdh": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.1.0", - "elliptic": "^6.5.3" - } - }, - "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "node_modules/create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, - "node_modules/crypto-browserify": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", - "license": "MIT", - "dependencies": { - "browserify-cipher": "^1.0.1", - "browserify-sign": "^4.2.3", - "create-ecdh": "^4.0.4", - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "diffie-hellman": "^5.0.3", - "hash-base": "~3.0.4", - "inherits": "^2.0.4", - "pbkdf2": "^3.1.2", - "public-encrypt": "^4.0.3", - "randombytes": "^2.1.0", - "randomfill": "^1.0.4" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "license": "MIT", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/defined": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", - "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deps-sort": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-1.3.9.tgz", - "integrity": "sha512-aEnmQuu/Hf5h8akL8QshYWzk9MVBg/JYMyNq/Lz68i69nR17tunjP6o/AC6Tn48c8ayzG6aeKs6OoFOtVCtvrQ==", - "license": "MIT", - "dependencies": { - "JSONStream": "^1.0.3", - "shasum": "^1.0.0", - "subarg": "^1.0.0", - "through2": "^1.0.0" - }, - "bin": { - "deps-sort": "bin/cmd.js" - } - }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/detective": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/detective/-/detective-4.7.1.tgz", - "integrity": "sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig==", - "license": "MIT", - "dependencies": { - "acorn": "^5.2.1", - "defined": "^1.0.0" - } - }, - "node_modules/detective/node_modules/acorn": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", - "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, - "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/domain-browser": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz", - "integrity": "sha512-fJ5MoHxe69h3E4/lJtFRhcWwLb04bhIBSfvCEMS1YDH+/9yEZTqBHTSTgch8nCP5tE5k2gdQEjodUqJzy7qJ9Q==", - "license": "MIT", - "engines": { - "node": ">=0.4", - "npm": ">=1.2" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer2": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", - "integrity": "sha512-+AWBwjGadtksxjOQSFDhPNQbed7icNXApT4+2BNpsXzcCBiInq2H9XW0O8sfHFaPmnQRs7cg/P0fAr2IWQSW0g==", - "license": "BSD", - "dependencies": { - "readable-stream": "~1.1.9" - } - }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/events": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/events/-/events-1.0.2.tgz", - "integrity": "sha512-XK19KwlDJo8XsceooxNDK1pObtcT44+Xte6V/jQc4a+fHq1qEouThyyX2ePmS0hS8RcCulmRxzg+T8jiLKAFFQ==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "license": "MIT", - "dependencies": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz", - "integrity": "sha512-I0rTWUKSZKxPSIAIaqhSXTM/DiII6wame+rEC3cFA5Lqmr9YmdL7z6Hj9+bdWtTvoY1Su4/OiMLmb37Y7JzvJQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^2.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", - "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hash-base": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", - "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, - "node_modules/htmlescape": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", - "integrity": "sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/http-browserify": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/http-browserify/-/http-browserify-1.7.0.tgz", - "integrity": "sha512-Irf/LJXmE3cBzU1eaR4+NEX6bmVLqt1wkmDiA7kBwH7zmb0D8kBAXsDmQ88hhj/qv9iEZKlyGx/hrMcFi8sOHw==", - "license": "MIT/X11", - "dependencies": { - "Base64": "~0.2.0", - "inherits": "~2.0.1" - } - }, - "node_modules/https-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.1.tgz", - "integrity": "sha512-EjDQFbgJr1vDD/175UJeSX3ncQ3+RUnCL5NkthQGHvF4VNHlzTy8ifJfTqz47qiPRqaFH58+CbuG3x51WuB1XQ==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inline-source-map": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.5.0.tgz", - "integrity": "sha512-2WtHG0qX9OH9TVcxsLVfq3Tzr+qtL6PtWgoh0XAAKe4KkdA/57Q+OGJuRJHA4mZ2OZnkJ/ZAaXf9krLB12/nIg==", - "license": "MIT", - "dependencies": { - "source-map": "~0.4.0" - } - }, - "node_modules/insert-module-globals": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-6.6.3.tgz", - "integrity": "sha512-ryk8hTKUZCc300SPOOwx30WhE5oRUssPDVlIoO8vtoMNBy5HGeesVRl3HF7ra4ll42T0IdnwD9XR9svh6+RRhg==", - "license": "MIT", - "dependencies": { - "combine-source-map": "~0.6.1", - "concat-stream": "~1.4.1", - "is-buffer": "^1.1.0", - "JSONStream": "^1.0.3", - "lexical-scope": "^1.2.0", - "process": "~0.11.0", - "through2": "^1.0.0", - "xtend": "^4.0.0" - }, - "bin": { - "insert-module-globals": "bin/cmd.js" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, - "node_modules/json-stable-stringify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", - "integrity": "sha512-nKtD/Qxm7tWdZqJoldEC7fF0S41v0mWbeaXG3637stOWfyGxTgWTYE2wtfKmjzpvxv2MA2xzxsXOIiwUpkX6Qw==", - "license": "MIT", - "dependencies": { - "jsonify": "~0.0.0" - } - }, - "node_modules/jsonify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", - "license": "Public Domain", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/labeled-stream-splicer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-1.0.2.tgz", - "integrity": "sha512-3KBjPRnXrYC5h2jEf/d6hO7Lcl+38QzRVTOyHA2sFzZVMYwsUFuejlrOMwAjmz13hVBr9ruDS1RwE4YEz8P58w==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "isarray": "~0.0.1", - "stream-splicer": "^1.1.0" - } - }, - "node_modules/lexical-scope": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/lexical-scope/-/lexical-scope-1.2.0.tgz", - "integrity": "sha512-ntJ8IcBCuKwudML7vAuT/L0aIMU0+9vO25K4CjLPYgzf1NZ0bAhJJBZrvkO+oUGgKcbdkH8UZdRsaEg+wULLRw==", - "license": "MIT", - "dependencies": { - "astw": "^2.0.0" - } - }, - "node_modules/lodash.memoize": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", - "integrity": "sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "license": "MIT", - "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/miller-rabin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "brorand": "^1.0.1" - }, - "bin": { - "miller-rabin": "bin/miller-rabin" - } - }, - "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", - "integrity": "sha512-jQo6o1qSVLEWaw3l+bwYA2X0uLuK2KjNh2wjgO7Q/9UJnXr1Q3yQKR8BI0/Bt/rPg75e6SMW4hW/6cBHVTZUjA==", - "deprecated": "Please update to minimatch 3.0.2 or higher to avoid a RegExp DoS issue", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/module-deps": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-3.9.1.tgz", - "integrity": "sha512-EbWWlSGaCVidEsLsSzkY6l/jm0IcGDSQ8tGwtjM8joTrxqxP0om02Px9Np8D7FMZ/vZFdsOGbio+WqkKQxYuTA==", - "license": "MIT", - "dependencies": { - "browser-resolve": "^1.7.0", - "concat-stream": "~1.4.5", - "defined": "^1.0.0", - "detective": "^4.0.0", - "duplexer2": "0.0.2", - "inherits": "^2.0.1", - "JSONStream": "^1.0.3", - "parents": "^1.0.0", - "readable-stream": "^1.1.13", - "resolve": "^1.1.3", - "stream-combiner2": "~1.0.0", - "subarg": "^1.0.0", - "through2": "^1.0.0", - "xtend": "^4.0.0" - }, - "bin": { - "module-deps": "bin/cmd.js" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/os-browserify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz", - "integrity": "sha512-aZicJZccvxWOZ0Bja2eAch2L8RIJWBuRYmM8Gwl/JjNtRltH0Itcz4eH/ESyuIWfse8cc93ZCf0XrzhXK2HEDA==", - "license": "MIT" - }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", - "license": "MIT" - }, - "node_modules/parents": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", - "integrity": "sha512-mXKF3xkoUt5td2DoxpLmtOmZvko9VfFpwRwkKDHSNvgmpLAeBo18YDhcPbBzJq+QLCHMbGOfzia2cX4U+0v9Mg==", - "license": "MIT", - "dependencies": { - "path-platform": "~0.11.15" - } - }, - "node_modules/parse-asn1": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", - "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", - "license": "ISC", - "dependencies": { - "asn1.js": "^4.10.1", - "browserify-aes": "^1.2.0", - "evp_bytestokey": "^1.0.3", - "pbkdf2": "^3.1.5", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/path-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", - "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", - "license": "MIT" - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-platform": { - "version": "0.11.15", - "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", - "integrity": "sha512-Y30dB6rab1A/nfEKsZxmr01nUotHX0c/ZiIAsCTatEe1CmS5Pm5He7fZ195bPT7RdquoaL8lLxFCMQi/bS7IJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pbkdf2": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", - "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", - "license": "MIT", - "dependencies": { - "create-hash": "^1.2.0", - "create-hmac": "^1.1.7", - "ripemd160": "^2.0.3", - "safe-buffer": "^5.2.1", - "sha.js": "^2.4.12", - "to-buffer": "^1.2.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/public-encrypt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", - "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.1.0", - "browserify-rsa": "^4.0.0", - "create-hash": "^1.1.0", - "parse-asn1": "^5.0.0", - "randombytes": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" - }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/querystring-es3": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", - "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/randomfill": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "license": "MIT", - "dependencies": { - "randombytes": "^2.0.5", - "safe-buffer": "^5.1.0" - } - }, - "node_modules/read-only-stream": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-1.1.1.tgz", - "integrity": "sha512-CNGbvYZYr0b1F41aN7bYLHUBvvoynSS7ZTf2RLa5egnjZxKJPJUpUdWplD+jxQPijP/eL02IJxBhY8hwJlI3PQ==", - "license": "MIT", - "dependencies": { - "readable-stream": "^1.0.31", - "readable-wrap": "^1.0.0" - } - }, - "node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/readable-wrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/readable-wrap/-/readable-wrap-1.0.0.tgz", - "integrity": "sha512-/8n0Mr10S+HGKFygQ42Z40JIXwafPH3A72pwmlNClThgsImV5LJJiCue5Je1asxwY082sYxq/+kTxH6nTn0w3g==", - "license": "MIT", - "dependencies": { - "readable-stream": "^1.1.13-1" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ripemd160": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", - "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", - "license": "MIT", - "dependencies": { - "hash-base": "^3.1.2", - "inherits": "^2.0.4" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ripemd160/node_modules/hash-base": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", - "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "^2.3.8", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ripemd160/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/ripemd160/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/ripemd160/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/ripemd160/node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/shasum": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", - "integrity": "sha512-UTzHm/+AzKfO9RgPgRpDIuMSNie1ubXRaljjlhFMNGYoG7z+rm9AHLPMf70R7887xboDH9Q+5YQbWKObFHEAtw==", - "license": "MIT", - "dependencies": { - "json-stable-stringify": "~0.0.0", - "sha.js": "~2.4.4" - } - }, - "node_modules/shell-quote": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-0.0.1.tgz", - "integrity": "sha512-uEWz7wa9vnCi9w4mvKZMgbHFk3DCKjLQlZcy0tJxUH4NwZjRrPPHXAYIEt2TmJs600Dcgj0Z3fZLZKVPVdGNbQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", - "license": "BSD-3-Clause", - "dependencies": { - "amdefine": ">=0.0.4" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/stream-browserify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-1.0.0.tgz", - "integrity": "sha512-e+V5xc4LlkOiRr64kZTUdb11exsbpSnwb9uwmXaHeDXCpfHg7vaefMJOxi21Pe74ZOqjZ87blBcqqpNAM4Ku0g==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.1", - "readable-stream": "^1.0.27-1" - } - }, - "node_modules/stream-combiner2": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.0.2.tgz", - "integrity": "sha512-7DO1SfBVnyIyo9ytUjSyVojT5bp1ZY6h3pj7HUs6PwcRSd/r8mBOHbRwYC7nbHRakKzMKyNp5HWJRv4GgVherA==", - "license": "MIT", - "dependencies": { - "duplexer2": "~0.0.2", - "through2": "~0.5.1" - } - }, - "node_modules/stream-combiner2/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/stream-combiner2/node_modules/through2": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz", - "integrity": "sha512-zexCrAOTbjkBCXGyozn7hhS3aEaqdrc59mAD2E3dKYzV1vFuEGQ1hEDJN2oQMQFwy4he2zyLqPZV+AlfS8ZWJA==", - "license": "MIT", - "dependencies": { - "readable-stream": "~1.0.17", - "xtend": "~3.0.0" - } - }, - "node_modules/stream-combiner2/node_modules/xtend": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", - "integrity": "sha512-sp/sT9OALMjRW1fKDlPeuSZlDQpkqReA0pyJukniWbTGoEKefHxhGJynE3PNhUMlcM8qWIjPwecwCw4LArS5Eg==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/stream-splicer": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-1.3.2.tgz", - "integrity": "sha512-nmUMEbdm/sZYqe9dZs7mqJvTYpunsDbIWI5FiBCMc/hMVd6vwzy+ITmo7C3gcLYqrn+uQ1w+EJwooWvJ997JAA==", - "license": "MIT", - "dependencies": { - "indexof": "0.0.1", - "inherits": "^2.0.1", - "isarray": "~0.0.1", - "readable-stream": "^1.1.13-1", - "readable-wrap": "^1.0.0", - "through2": "^1.0.0" - } - }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "license": "MIT" - }, - "node_modules/subarg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", - "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", - "license": "MIT", - "dependencies": { - "minimist": "^1.1.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/syntax-error": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", - "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", - "license": "MIT", - "dependencies": { - "acorn-node": "^1.2.0" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "license": "MIT" - }, - "node_modules/through2": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/through2/-/through2-1.1.1.tgz", - "integrity": "sha512-zEbpaeSMHxczpTzO1KkMHjBC1enTA68ojeaZGG4toqdASpb9t4xUZaYFBq2/9OHo5nTGFVSYd4c910OR+6wxbQ==", - "license": "MIT", - "dependencies": { - "readable-stream": ">=1.1.13-1 <1.2.0-0", - "xtend": ">=4.0.0 <4.1.0-0" - } - }, - "node_modules/timers-browserify": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", - "integrity": "sha512-PIxwAupJZiYU4JmVZYwXp9FKsHMXb5h0ZEFyuXTAn8WLHOlcij+FEcbrvDsom1o5dr1YggEtFbECvGCW2sT53Q==", - "dependencies": { - "process": "~0.11.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/to-buffer/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "node_modules/tty-browserify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", - "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", - "license": "MIT" - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typedarray": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.7.tgz", - "integrity": "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/umd": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", - "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", - "license": "MIT", - "bin": { - "umd": "bin/cli.js" - } - }, - "node_modules/url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "license": "MIT", - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", - "license": "MIT" - }, - "node_modules/util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "license": "MIT", - "dependencies": { - "inherits": "2.0.3" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/util/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "license": "ISC" - }, - "node_modules/vm-browserify": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", - "integrity": "sha512-NyZNR3WDah+NPkjh/YmhuWSsT4a0mF0BJYgUmvrJ70zxjTXh5Y2Asobxlh0Nfs0PCFB5FVpRJft7NozAWFMwLQ==", - "license": "MIT", - "dependencies": { - "indexof": "0.0.1" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package.json b/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package.json deleted file mode 100644 index 694e82647..000000000 --- a/packages/cli/test/fixtures/commands/npm/lacking-typosquat/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "private": true, - "dependencies": { - "bowserify": "^10.2.1" - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm10/package-lock.json b/packages/cli/test/fixtures/commands/npm/npm10/package-lock.json deleted file mode 100644 index 7bda94d29..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm10/package-lock.json +++ /dev/null @@ -1,2560 +0,0 @@ -{ - "name": "npm10", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "npm10", - "version": "1.0.0", - "dependencies": { - "npm": "10.9.2" - } - }, - "node_modules/npm": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz", - "integrity": "sha512-iriPEPIkoMYUy3F6f3wwSZAU93E0Eg6cHwIR6jzzOXWSy+SD/rOODEs74cVONHKSx2obXtuUoyidVEhISrisgQ==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmhook", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "normalize-package-data", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which", - "write-file-atomic" - ], - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^8.0.0", - "@npmcli/config": "^9.0.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^4.0.2", - "@npmcli/package-json": "^6.1.0", - "@npmcli/promise-spawn": "^8.0.2", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "@sigstore/tuf": "^3.0.0", - "abbrev": "^3.0.0", - "archy": "~1.0.0", - "cacache": "^19.0.1", - "chalk": "^5.3.0", - "ci-info": "^4.1.0", - "cli-columns": "^4.0.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^10.4.5", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.0.2", - "ini": "^5.0.0", - "init-package-json": "^7.0.2", - "is-cidr": "^5.1.0", - "json-parse-even-better-errors": "^4.0.0", - "libnpmaccess": "^9.0.0", - "libnpmdiff": "^7.0.0", - "libnpmexec": "^9.0.0", - "libnpmfund": "^6.0.0", - "libnpmhook": "^11.0.0", - "libnpmorg": "^7.0.0", - "libnpmpack": "^8.0.0", - "libnpmpublish": "^10.0.1", - "libnpmsearch": "^8.0.0", - "libnpmteam": "^7.0.0", - "libnpmversion": "^7.0.0", - "make-fetch-happen": "^14.0.3", - "minimatch": "^9.0.5", - "minipass": "^7.1.1", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^11.0.0", - "nopt": "^8.0.0", - "normalize-package-data": "^7.0.0", - "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.1", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-profile": "^11.0.1", - "npm-registry-fetch": "^18.0.2", - "npm-user-validate": "^3.0.0", - "p-map": "^4.0.0", - "pacote": "^19.0.1", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "qrcode-terminal": "^0.12.0", - "read": "^4.0.0", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "ssri": "^12.0.0", - "supports-color": "^9.4.0", - "tar": "^6.2.1", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.0", - "which": "^5.0.0", - "write-file-atomic": "^6.0.0" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/metavuln-calculator": "^8.0.0", - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.1", - "@npmcli/query": "^4.0.0", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "bin-links": "^5.0.0", - "cacache": "^19.0.1", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^8.0.0", - "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.1", - "pacote": "^19.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "proggy": "^3.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "ssri": "^12.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", - "ci-info": "^4.0.0", - "ini": "^5.0.0", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "4.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "8.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^19.0.0", - "json-parse-even-better-errors": "^4.0.0", - "pacote": "^20.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { - "version": "20.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "normalize-package-data": "^7.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.1.2" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.3.2", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2", - "tuf-js": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/aggregate-error": { - "version": "3.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^7.0.0", - "npm-normalize-package-bin": "^4.0.0", - "proc-log": "^5.0.0", - "read-cmd-shim": "^5.0.0", - "write-file-atomic": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "2.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "19.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/p-map": { - "version": "7.0.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.1.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^5.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/clean-stack": { - "version": "2.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.3.7", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/diff": { - "version": "5.2.0", - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.1", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "10.4.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/indent-string": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ini": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "7.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^6.0.0", - "npm-package-arg": "^12.0.0", - "promzard": "^2.0.0", - "read": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^4.1.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.3", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "binary-extensions": "^2.3.0", - "diff": "^5.1.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "tar": "^6.2.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/run-script": "^9.0.1", - "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "proc-log": "^5.0.0", - "read": "^4.0.0", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmhook": { - "version": "11.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/run-script": "^9.0.1", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "10.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^7.0.0", - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1", - "proc-log": "^5.0.0", - "semver": "^7.3.7", - "sigstore": "^3.0.0", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.1", - "@npmcli/run-script": "^9.0.1", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "10.4.3", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "14.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "9.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "11.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/nopt/node_modules/abbrev": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^8.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^7.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "11.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "18.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "3.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/package-json-from-dist": { - "version": "1.0.1", - "inBundle": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/npm/node_modules/pacote": { - "version": "19.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/proggy": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-inflight": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^2.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/rimraf": { - "version": "5.0.10", - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.6.3", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.0.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "@sigstore/sign": "^3.0.0", - "@sigstore/tuf": "^3.0.0", - "@sigstore/verify": "^2.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/bundle": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/core": { - "version": "2.0.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/sign": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.0.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "make-fetch-happen": "^14.0.1", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/verify": { - "version": "2.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.0.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.1", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.20", - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/npm/node_modules/ssri": { - "version": "12.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "9.4.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/@tufjs/models": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/which": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - } - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm10/package.json b/packages/cli/test/fixtures/commands/npm/npm10/package.json deleted file mode 100644 index b7f939a30..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm10/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "npm10", - "version": "1.0.0", - "private": true, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "dependencies": { - "npm": "10.9.2" - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm11/package-lock.json b/packages/cli/test/fixtures/commands/npm/npm11/package-lock.json deleted file mode 100644 index 91726034b..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm11/package-lock.json +++ /dev/null @@ -1,2458 +0,0 @@ -{ - "name": "npm11", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "npm11", - "version": "1.0.0", - "dependencies": { - "npm": "11.2.0" - } - }, - "node_modules/npm": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.2.0.tgz", - "integrity": "sha512-PcnFC6gTo9VDkxVaQ1/mZAS3JoWrDjAI+a6e2NgfYQSGDwftJlbdV0jBMi2V8xQPqbGcWaa7p3UP0SKF+Bhm2g==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "normalize-package-data", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which" - ], - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.0.1", - "@npmcli/config": "^10.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^4.0.2", - "@npmcli/package-json": "^6.1.1", - "@npmcli/promise-spawn": "^8.0.2", - "@npmcli/redact": "^3.1.1", - "@npmcli/run-script": "^9.0.1", - "@sigstore/tuf": "^3.0.0", - "abbrev": "^3.0.0", - "archy": "~1.0.0", - "cacache": "^19.0.1", - "chalk": "^5.4.1", - "ci-info": "^4.1.0", - "cli-columns": "^4.0.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^10.4.5", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.0.2", - "ini": "^5.0.0", - "init-package-json": "^8.0.0", - "is-cidr": "^5.1.1", - "json-parse-even-better-errors": "^4.0.0", - "libnpmaccess": "^10.0.0", - "libnpmdiff": "^8.0.1", - "libnpmexec": "^10.1.0", - "libnpmfund": "^7.0.1", - "libnpmorg": "^8.0.0", - "libnpmpack": "^9.0.1", - "libnpmpublish": "^11.0.0", - "libnpmsearch": "^9.0.0", - "libnpmteam": "^8.0.0", - "libnpmversion": "^8.0.0", - "make-fetch-happen": "^14.0.3", - "minimatch": "^9.0.5", - "minipass": "^7.1.1", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^11.1.0", - "nopt": "^8.1.0", - "normalize-package-data": "^7.0.0", - "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.1", - "npm-package-arg": "^12.0.2", - "npm-pick-manifest": "^10.0.0", - "npm-profile": "^11.0.1", - "npm-registry-fetch": "^18.0.2", - "npm-user-validate": "^3.0.0", - "p-map": "^7.0.3", - "pacote": "^21.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "qrcode-terminal": "^0.12.0", - "read": "^4.1.0", - "semver": "^7.7.1", - "spdx-expression-parse": "^4.0.0", - "ssri": "^12.0.0", - "supports-color": "^10.0.0", - "tar": "^6.2.1", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.0", - "which": "^5.0.0" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/metavuln-calculator": "^9.0.0", - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.1", - "@npmcli/query": "^4.0.0", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "bin-links": "^5.0.0", - "cacache": "^19.0.1", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^8.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^8.0.0", - "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.1", - "pacote": "^21.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "proggy": "^3.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "ssri": "^12.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^4.0.0" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", - "ci-info": "^4.0.0", - "ini": "^5.0.0", - "nopt": "^8.1.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "4.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^19.0.0", - "json-parse-even-better-errors": "^4.0.0", - "pacote": "^21.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.1.2" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "3.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/core": { - "version": "2.0.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.4.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "3.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "make-fetch-happen": "^14.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "3.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.4.0", - "tuf-js": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/verify": { - "version": "2.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.3", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^7.0.0", - "npm-normalize-package-bin": "^4.0.0", - "proc-log": "^5.0.0", - "read-cmd-shim": "^5.0.0", - "write-file-atomic": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "19.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.4.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.1.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.3", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^5.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.4.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/diff": { - "version": "7.0.0", - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "10.4.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/ini": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^6.1.0", - "npm-package-arg": "^12.0.0", - "promzard": "^2.0.0", - "read": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^4.1.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.3", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.0.1", - "@npmcli/installed-package-contents": "^3.0.0", - "binary-extensions": "^3.0.0", - "diff": "^7.0.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0", - "tar": "^6.2.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.0.1", - "@npmcli/package-json": "^6.1.1", - "@npmcli/run-script": "^9.0.1", - "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0", - "proc-log": "^5.0.0", - "read": "^4.0.0", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.0.1", - "@npmcli/run-script": "^9.0.1", - "npm-package-arg": "^12.0.0", - "pacote": "^21.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^7.0.0", - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1", - "proc-log": "^5.0.0", - "semver": "^7.3.7", - "sigstore": "^3.0.0", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.1", - "@npmcli/run-script": "^9.0.1", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "10.4.3", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "14.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "9.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "11.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "8.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^8.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "11.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "18.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "3.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "7.0.3", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/package-json-from-dist": { - "version": "1.0.1", - "inBundle": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/npm/node_modules/pacote": { - "version": "21.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^10.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^3.0.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/proggy": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^2.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/rimraf": { - "version": "5.0.10", - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.7.1", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "3.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^3.1.0", - "@sigstore/core": "^2.0.0", - "@sigstore/protobuf-specs": "^0.4.0", - "@sigstore/sign": "^3.1.0", - "@sigstore/tuf": "^3.1.0", - "@sigstore/verify": "^2.1.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.21", - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/npm/node_modules/ssri": { - "version": "12.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "10.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/npm/node_modules/which": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - } - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm11/package.json b/packages/cli/test/fixtures/commands/npm/npm11/package.json deleted file mode 100644 index b6c73c05f..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm11/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "npm11", - "version": "1.0.0", - "private": true, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "dependencies": { - "npm": "11.2.0" - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm9/package-lock.json b/packages/cli/test/fixtures/commands/npm/npm9/package-lock.json deleted file mode 100644 index 39ff1af3e..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm9/package-lock.json +++ /dev/null @@ -1,3039 +0,0 @@ -{ - "name": "npm9", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "npm9", - "version": "1.0.0", - "dependencies": { - "npm": "9.9.4" - } - }, - "node_modules/npm": { - "version": "9.9.4", - "resolved": "https://registry.npmjs.org/npm/-/npm-9.9.4.tgz", - "integrity": "sha512-NzcQiLpqDuLhavdyJ2J3tGJ/ni/ebcqHVFZkv1C4/6lblraUPbPgCJ4Vhb4oa3FFhRa2Yj9gA58jGH/ztKueNQ==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/run-script", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "cli-table3", - "columnify", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmhook", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "normalize-package-data", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "npmlog", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "sigstore", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which", - "write-file-atomic" - ], - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^6.5.0", - "@npmcli/config": "^6.4.0", - "@npmcli/fs": "^3.1.0", - "@npmcli/map-workspaces": "^3.0.4", - "@npmcli/package-json": "^4.0.1", - "@npmcli/promise-spawn": "^6.0.2", - "@npmcli/run-script": "^6.0.2", - "abbrev": "^2.0.0", - "archy": "~1.0.0", - "cacache": "^17.1.4", - "chalk": "^5.3.0", - "ci-info": "^4.0.0", - "cli-columns": "^4.0.0", - "cli-table3": "^0.6.3", - "columnify": "^1.6.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^6.1.3", - "ini": "^4.1.1", - "init-package-json": "^5.0.0", - "is-cidr": "^4.0.2", - "json-parse-even-better-errors": "^3.0.1", - "libnpmaccess": "^7.0.2", - "libnpmdiff": "^5.0.20", - "libnpmexec": "^6.0.4", - "libnpmfund": "^4.2.1", - "libnpmhook": "^9.0.3", - "libnpmorg": "^5.0.4", - "libnpmpack": "^5.0.20", - "libnpmpublish": "^7.5.1", - "libnpmsearch": "^6.0.2", - "libnpmteam": "^5.0.3", - "libnpmversion": "^4.0.2", - "make-fetch-happen": "^11.1.1", - "minimatch": "^9.0.3", - "minipass": "^7.0.4", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^9.4.1", - "nopt": "^7.2.0", - "normalize-package-data": "^5.0.0", - "npm-audit-report": "^5.0.0", - "npm-install-checks": "^6.3.0", - "npm-package-arg": "^10.1.0", - "npm-pick-manifest": "^8.0.2", - "npm-profile": "^7.0.1", - "npm-registry-fetch": "^14.0.5", - "npm-user-validate": "^2.0.0", - "npmlog": "^7.0.1", - "p-map": "^4.0.0", - "pacote": "^15.2.0", - "parse-conflict-json": "^3.0.1", - "proc-log": "^3.0.0", - "qrcode-terminal": "^0.12.0", - "read": "^2.1.0", - "semver": "^7.6.0", - "sigstore": "^1.9.0", - "spdx-expression-parse": "^3.0.1", - "ssri": "^10.0.5", - "supports-color": "^9.4.0", - "tar": "^6.2.1", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^5.0.0", - "which": "^3.0.1", - "write-file-atomic": "^5.0.1" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@colors/colors": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/npm/node_modules/@gar/promisify": { - "version": "1.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "6.5.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^3.1.0", - "@npmcli/installed-package-contents": "^2.0.2", - "@npmcli/map-workspaces": "^3.0.2", - "@npmcli/metavuln-calculator": "^5.0.0", - "@npmcli/name-from-folder": "^2.0.0", - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^4.0.0", - "@npmcli/query": "^3.1.0", - "@npmcli/run-script": "^6.0.0", - "bin-links": "^4.0.1", - "cacache": "^17.0.4", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^6.1.1", - "json-parse-even-better-errors": "^3.0.0", - "json-stringify-nice": "^1.1.4", - "minimatch": "^9.0.0", - "nopt": "^7.0.0", - "npm-install-checks": "^6.2.0", - "npm-package-arg": "^10.1.0", - "npm-pick-manifest": "^8.0.1", - "npm-registry-fetch": "^14.0.3", - "npmlog": "^7.0.1", - "pacote": "^15.0.8", - "parse-conflict-json": "^3.0.0", - "proc-log": "^3.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^1.0.2", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.7", - "ssri": "^10.0.1", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "6.4.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^3.0.2", - "ci-info": "^4.0.0", - "ini": "^4.1.0", - "nopt": "^7.0.0", - "proc-log": "^3.0.0", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.5", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/disparity-colors": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ansi-styles": "^4.3.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "3.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^6.0.0", - "lru-cache": "^7.4.4", - "npm-pick-manifest": "^8.0.0", - "proc-log": "^3.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "bin": { - "installed-package-contents": "lib/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "3.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^2.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0", - "read-package-json-fast": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^17.0.0", - "json-parse-even-better-errors": "^3.0.0", - "pacote": "^15.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/move-file": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "4.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^4.1.0", - "glob": "^10.2.2", - "hosted-git-info": "^6.1.1", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^5.0.0", - "proc-log": "^3.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "3.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/promise-spawn": "^6.0.0", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^3.0.0", - "which": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "1.1.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.2.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.2.1", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "1.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^1.1.0", - "@sigstore/protobuf-specs": "^0.2.0", - "make-fetch-happen": "^11.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "1.0.3", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.2.0", - "tuf-js": "^1.1.7" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tootallnate/once": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "1.0.0", - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/npm/node_modules/agentkeepalive": { - "version": "4.5.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/npm/node_modules/aggregate-error": { - "version": "3.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/are-we-there-yet": { - "version": "4.0.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "4.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "read-cmd-shim": "^4.0.0", - "write-file-atomic": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "2.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/builtins": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "semver": "^7.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "17.1.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^7.7.1", - "minipass": "^7.0.3", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.0.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "3.1.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^4.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/clean-stack": { - "version": "2.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cli-table3": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/npm/node_modules/clone": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/color-support": { - "version": "1.1.3", - "inBundle": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/npm/node_modules/columnify": { - "version": "1.6.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "strip-ansi": "^6.0.1", - "wcwidth": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/concat-map": { - "version": "0.0.1", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/console-control-strings": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.3.7", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/defaults": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/delegates": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/diff": { - "version": "5.2.0", - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.1", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/fs.realpath": { - "version": "1.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/function-bind": { - "version": "1.1.2", - "inBundle": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/npm/node_modules/gauge": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^4.0.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "10.3.10", - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/has-unicode": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hasown": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "6.1.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^7.5.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/npm/node_modules/humanize-ms": { - "version": "1.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "6.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/indent-string": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/infer-owner": { - "version": "1.0.4", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/inflight": { - "version": "1.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/npm/node_modules/inherits": { - "version": "2.0.4", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/ini": { - "version": "4.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^10.0.0", - "promzard": "^1.0.0", - "read": "^2.0.0", - "read-package-json": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "4.0.2", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^3.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/is-core-module": { - "version": "2.13.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/is-lambda": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "2.3.6", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "7.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^10.1.0", - "npm-registry-fetch": "^14.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "5.0.21", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^6.5.0", - "@npmcli/disparity-colors": "^3.0.0", - "@npmcli/installed-package-contents": "^2.0.2", - "binary-extensions": "^2.2.0", - "diff": "^5.1.0", - "minimatch": "^9.0.0", - "npm-package-arg": "^10.1.0", - "pacote": "^15.0.8", - "tar": "^6.1.13" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "6.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^6.5.0", - "@npmcli/run-script": "^6.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^10.1.0", - "npmlog": "^7.0.1", - "pacote": "^15.0.8", - "proc-log": "^3.0.0", - "read": "^2.0.0", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.7", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "4.2.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^6.5.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmhook": { - "version": "9.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^14.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "5.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^14.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "5.0.21", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^6.5.0", - "@npmcli/run-script": "^6.0.0", - "npm-package-arg": "^10.1.0", - "pacote": "^15.0.8" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "7.5.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^5.0.0", - "npm-package-arg": "^10.1.0", - "npm-registry-fetch": "^14.0.3", - "proc-log": "^3.0.0", - "semver": "^7.3.7", - "sigstore": "^1.4.0", - "ssri": "^10.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "6.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^14.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "5.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^14.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "4.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^4.0.1", - "@npmcli/run-script": "^6.0.0", - "json-parse-even-better-errors": "^3.0.0", - "proc-log": "^3.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "7.18.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "11.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^17.0.0", - "http-cache-semantics": "^4.1.1", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^5.0.0", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/make-fetch-happen/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "9.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.0.4", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "1.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-collect/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "3.0.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-json-stream": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "node_modules/npm/node_modules/minipass-json-stream/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "1.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/negotiator": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "9.4.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^12.13 || ^14.13 || >=16" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/fs": { - "version": "2.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/abbrev": { - "version": "1.1.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/node-gyp/node_modules/are-we-there-yet": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/brace-expansion": { - "version": "1.1.11", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { - "version": "16.1.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/minimatch": { - "version": "5.1.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/gauge": { - "version": "4.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/glob": { - "version": "7.2.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { - "version": "10.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minimatch": { - "version": "3.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minipass-fetch": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/nopt": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^1.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/npmlog": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/signal-exit": { - "version": "3.0.7", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/node-gyp/node_modules/ssri": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/unique-filename": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/unique-slug": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "7.2.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "5.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^6.0.0", - "is-core-module": "^2.8.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "6.3.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "10.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^6.0.0", - "proc-log": "^3.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "7.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^6.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^10.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "7.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^14.0.0", - "proc-log": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "14.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "make-fetch-happen": "^11.0.0", - "minipass": "^5.0.0", - "minipass-fetch": "^3.0.0", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^10.0.0", - "proc-log": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "2.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npmlog": { - "version": "7.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^4.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^5.0.0", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/once": { - "version": "1.4.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/pacote": { - "version": "15.2.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^4.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/promise-spawn": "^6.0.1", - "@npmcli/run-script": "^6.0.0", - "cacache": "^17.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^5.0.0", - "npm-package-arg": "^10.0.0", - "npm-packlist": "^7.0.0", - "npm-pick-manifest": "^8.0.0", - "npm-registry-fetch": "^14.0.0", - "proc-log": "^3.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^6.0.0", - "read-package-json-fast": "^3.0.0", - "sigstore": "^1.3.0", - "ssri": "^10.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "lib/bin.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/pacote/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/path-is-absolute": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "1.10.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "1.0.2", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-inflight": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "1.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^2.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "~1.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/read-package-json": { - "version": "6.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^10.2.2", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^5.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/readable-stream": { - "version": "3.6.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/rimraf": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/npm/node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/npm/node_modules/safe-buffer": { - "version": "5.2.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.6.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/set-blocking": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "1.9.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^1.1.0", - "@sigstore/protobuf-specs": "^0.2.0", - "@sigstore/sign": "^1.0.0", - "@sigstore/tuf": "^1.0.3", - "make-fetch-happen": "^11.0.1" - }, - "bin": { - "sigstore": "bin/sigstore.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.17", - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/ssri": { - "version": "10.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/string_decoder": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "9.4.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "1.1.7", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "1.0.4", - "debug": "^4.3.4", - "make-fetch-happen": "^11.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "builtins": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/wcwidth": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/npm/node_modules/which": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/wide-align": { - "version": "1.1.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrappy": { - "version": "1.0.2", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - } - } -} diff --git a/packages/cli/test/fixtures/commands/npm/npm9/package.json b/packages/cli/test/fixtures/commands/npm/npm9/package.json deleted file mode 100644 index 5b63c5a5d..000000000 --- a/packages/cli/test/fixtures/commands/npm/npm9/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "npm9", - "version": "1.0.0", - "private": true, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "dependencies": { - "npm": "9.9.4" - } -} diff --git a/packages/cli/test/fixtures/commands/optimize/npm/package-lock.json b/packages/cli/test/fixtures/commands/optimize/npm/package-lock.json deleted file mode 100644 index e1520aaa3..000000000 --- a/packages/cli/test/fixtures/commands/optimize/npm/package-lock.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "optimize-test-fixture-npm", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "optimize-test-fixture-npm", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "gopd": "npm:@socketregistry/gopd@^1" - }, - "devDependencies": { - "axios": "1.3.2" - } - } - } -} \ No newline at end of file diff --git a/packages/cli/test/fixtures/commands/optimize/npm/package.json b/packages/cli/test/fixtures/commands/optimize/npm/package.json deleted file mode 100644 index 7429aa1ee..000000000 --- a/packages/cli/test/fixtures/commands/optimize/npm/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "optimize-test-fixture-npm", - "version": "1.0.0", - "private": true, - "description": "Test fixture for optimize command testing (npm)", - "main": "index.js", - "dependencies": { - "gopd": "npm:@socketregistry/gopd@^1" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm/package.json b/packages/cli/test/fixtures/commands/optimize/pnpm/package.json deleted file mode 100644 index 786671296..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "optimize-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for optimize command testing", - "main": "index.js", - "dependencies": { - "gopd": "npm:@socketregistry/gopd@^1" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/optimize/pnpm/pnpm-lock.yaml deleted file mode 100644 index f8dfc4d26..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm/pnpm-lock.yaml +++ /dev/null @@ -1,214 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - gopd: - specifier: npm:@socketregistry/gopd@^1 - version: '@socketregistry/gopd@1.0.7' - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - '@socketregistry/gopd@1.0.7': - resolution: {integrity: sha512-VK4NTuaf1FvxuhhyUacIXfD7cbb3daV+Uyj38hxXn75xEw7QhkOwKEUm+o3eXTqaPROOwighvVR3ezZA+pnonw==} - engines: {node: '>=18'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - '@socketregistry/gopd@1.0.7': {} - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm8/package.json b/packages/cli/test/fixtures/commands/optimize/pnpm8/package.json deleted file mode 100644 index 8e4332d25..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm8/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "optimize-test-pnpm8", - "version": "1.0.0", - "private": true, - "description": "Test fixture for optimize command with pnpm v8", - "main": "index.js", - "dependencies": { - "abab": "2.0.6", - "pnpm": "^8.15.9" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm8/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/optimize/pnpm8/pnpm-lock.yaml deleted file mode 100644 index e0fd6038e..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm8/pnpm-lock.yaml +++ /dev/null @@ -1,131 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - abab: npm:@socketregistry/abab@^1 - es-define-property: npm:@socketregistry/es-define-property@^1 - es-set-tostringtag: npm:@socketregistry/es-set-tostringtag@^1 - function-bind: npm:@socketregistry/function-bind@^1 - gopd: npm:@socketregistry/gopd@^1 - has-symbols: npm:@socketregistry/has-symbols@^1 - has-tostringtag: npm:@socketregistry/has-tostringtag@^1 - hasown: npm:@socketregistry/hasown@^1 - -importers: - - .: - dependencies: - abab: - specifier: npm:@socketregistry/abab@^1 - version: '@socketregistry/abab@1.0.8' - pnpm: - specifier: ^8.15.9 - version: 8.15.9 - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - '@socketregistry/abab@1.0.8': - resolution: {integrity: sha512-NavdB0DoJAAOuPjDb0rSCIHc0RTXzv71RYDWhkJGcHRcLGD8SM//5xpkSeY/zBL0b/YJpjDB2KhCndnN07/waQ==} - engines: {node: '>=18'} - - '@socketregistry/es-set-tostringtag@1.0.9': - resolution: {integrity: sha512-rLBDHYkhI3so1NSinOhIhmxQ53aG0SPht2KMfBLTNuanrfVgMQOusu+s0UkP5+lI4242yHaqYAbRyAEK820/Gg==} - engines: {node: '>=18'} - - '@socketregistry/hasown@1.0.7': - resolution: {integrity: sha512-MZ5dyXOtiEc7q3801T+2EmKkxrd55BOSQnG8z/8/IkIJzDxqBxGGBKVyixqFm3W657TyUEBfIT9iWgSB6ipFsA==} - engines: {node: '>=18'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - pnpm@8.15.9: - resolution: {integrity: sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==} - engines: {node: '>=16.14'} - hasBin: true - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - '@socketregistry/abab@1.0.8': {} - - '@socketregistry/es-set-tostringtag@1.0.9': {} - - '@socketregistry/hasown@1.0.7': {} - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: '@socketregistry/es-set-tostringtag@1.0.9' - hasown: '@socketregistry/hasown@1.0.7' - mime-types: 2.1.35 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - pnpm@8.15.9: {} - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm9/package.json b/packages/cli/test/fixtures/commands/optimize/pnpm9/package.json deleted file mode 100644 index 027434cff..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm9/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "optimize-test-pnpm9", - "version": "1.0.0", - "private": true, - "description": "Test fixture for optimize command with pnpm v9", - "main": "index.js", - "dependencies": { - "abab": "2.0.6", - "pnpm": "9.15.0" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/optimize/pnpm9/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/optimize/pnpm9/pnpm-lock.yaml deleted file mode 100644 index 5bb3c25d0..000000000 --- a/packages/cli/test/fixtures/commands/optimize/pnpm9/pnpm-lock.yaml +++ /dev/null @@ -1,131 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -overrides: - abab: npm:@socketregistry/abab@^1 - es-define-property: npm:@socketregistry/es-define-property@^1 - es-set-tostringtag: npm:@socketregistry/es-set-tostringtag@^1 - function-bind: npm:@socketregistry/function-bind@^1 - gopd: npm:@socketregistry/gopd@^1 - has-symbols: npm:@socketregistry/has-symbols@^1 - has-tostringtag: npm:@socketregistry/has-tostringtag@^1 - hasown: npm:@socketregistry/hasown@^1 - -importers: - - .: - dependencies: - abab: - specifier: npm:@socketregistry/abab@^1 - version: '@socketregistry/abab@1.0.8' - pnpm: - specifier: 9.15.0 - version: 9.15.0 - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - '@socketregistry/abab@1.0.8': - resolution: {integrity: sha512-NavdB0DoJAAOuPjDb0rSCIHc0RTXzv71RYDWhkJGcHRcLGD8SM//5xpkSeY/zBL0b/YJpjDB2KhCndnN07/waQ==} - engines: {node: '>=18'} - - '@socketregistry/es-set-tostringtag@1.0.9': - resolution: {integrity: sha512-rLBDHYkhI3so1NSinOhIhmxQ53aG0SPht2KMfBLTNuanrfVgMQOusu+s0UkP5+lI4242yHaqYAbRyAEK820/Gg==} - engines: {node: '>=18'} - - '@socketregistry/hasown@1.0.7': - resolution: {integrity: sha512-MZ5dyXOtiEc7q3801T+2EmKkxrd55BOSQnG8z/8/IkIJzDxqBxGGBKVyixqFm3W657TyUEBfIT9iWgSB6ipFsA==} - engines: {node: '>=18'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - pnpm@9.15.0: - resolution: {integrity: sha512-duI3l2CkMo7EQVgVvNZije5yevN3mqpMkU45RBVsQpmSGon5djge4QfUHxLPpLZmgcqccY8GaPoIMe1MbYulbA==} - engines: {node: '>=18.12'} - hasBin: true - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - '@socketregistry/abab@1.0.8': {} - - '@socketregistry/es-set-tostringtag@1.0.9': {} - - '@socketregistry/hasown@1.0.7': {} - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: '@socketregistry/es-set-tostringtag@1.0.9' - hasown: '@socketregistry/hasown@1.0.7' - mime-types: 2.1.35 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - pnpm@9.15.0: {} - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/optimize/yarn/package.json b/packages/cli/test/fixtures/commands/optimize/yarn/package.json deleted file mode 100644 index b85570445..000000000 --- a/packages/cli/test/fixtures/commands/optimize/yarn/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "optimize-test-fixture-yarn", - "version": "1.0.0", - "private": true, - "description": "Test fixture for optimize command testing (yarn)", - "main": "index.js", - "dependencies": { - "gopd": "npm:@socketregistry/gopd@^1" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/patch/README.md b/packages/cli/test/fixtures/commands/patch/README.md deleted file mode 100644 index 9e5c183a3..000000000 --- a/packages/cli/test/fixtures/commands/patch/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# Patch Command Test Fixtures - -This directory contains test fixtures for the `socket patch` command suite. - -## Fixture Directories - -### `npm/`, `pnpm/`, `yarn/` - -Package manager specific fixtures with minimal manifests containing only required fields: - -- `exportedAt` (required) -- `files` (required) -- `vulnerabilities` (required in old schema, now optional) - -These fixtures are used to test basic functionality across different package managers. - -**Example Package**: `pkg:npm/on-headers@1.0.2` - -### `complete/` - -Comprehensive fixture with all optional fields populated. Used to test complete output formatting. - -**Packages**: - -- `pkg:npm/example-package@1.2.3` - Full metadata with multiple vulnerabilities -- `pkg:npm/another-package@2.0.0` - Full metadata with no vulnerabilities -- `pkg:npm/minimal-package@0.1.0` - Only required fields (for comparison) - -**Optional Fields Tested**: - -- `uuid` - Unique identifier for backup/restore operations -- `description` - Human-readable description of the patch -- `tier` - Patch tier level (e.g., "free", "premium") -- `license` - License of the patch -- `vulnerabilities` - Vulnerabilities fixed by the patch (now optional) - -### `no-vulns/` - -Fixture demonstrating patches without vulnerability information. Used to test the optional `vulnerabilities` field. - -**Packages**: - -- `pkg:npm/perf-patch@3.0.0` - Performance patch with full metadata, no vulnerabilities -- `pkg:npm/feature-patch@1.0.0` - Feature patch with minimal metadata, no vulnerabilities - -## Manifest Schema - -All manifests follow the `PatchManifestSchema`: - -```typescript -{ - patches: { - [purl: string]: { - exportedAt: string // Required: ISO 8601 timestamp - files: { // Required: File patches - [path: string]: { - beforeHash: string // SHA256 before patching - afterHash: string // SHA256 after patching - } - } - uuid?: string // Optional: Backup identifier - description?: string // Optional: Patch description - tier?: string // Optional: Tier level - license?: string // Optional: License - vulnerabilities?: { // Optional: Vulnerabilities fixed - [ghsaId: string]: { - cves: string[] - summary: string - severity: string - description: string - patchExplanation: string - } - } - } - } -} -``` - -## Testing Different Scenarios - -### Test Basic Functionality - -Use `npm/`, `pnpm/`, or `yarn/` fixtures: - -- Minimal required fields -- Single vulnerability -- Package manager specific testing - -### Test Complete Output - -Use `complete/` fixture: - -- All optional fields populated -- Multiple vulnerabilities -- Various tier levels -- Different licenses -- Multiple files per patch - -### Test Optional Vulnerabilities - -Use `no-vulns/` fixture: - -- Patches without vulnerability information -- Tests schema validation with optional `vulnerabilities` field -- Performance and feature patches (non-security) - -## Usage in Tests - -```typescript -import path from 'node:path' - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const completeFixture = path.join(fixtureBaseDir, 'complete') -const noVulnsFixture = path.join(fixtureBaseDir, 'no-vulns') -const pnpmFixture = path.join(fixtureBaseDir, 'pnpm') -``` - -## Expected Output Behavior - -### Fields Always Shown - -- `purl` - Package identifier -- `Exported` - Export timestamp -- `Files` - File count -- `Vulnerabilities` - Vulnerability count (0 if no vulnerabilities) -- `Description` - Shows "No description provided" if missing - -### Fields Only Shown When Present - -- `UUID` - Only if provided -- `Tier` - Only if provided -- `License` - Only if provided - -### JSON Output - -Undefined optional fields are omitted from JSON output for cleaner results. - -### Markdown Output - -All fields shown with fallback text for missing optional fields. diff --git a/packages/cli/test/fixtures/commands/patch/complete/.socket/manifest.json b/packages/cli/test/fixtures/commands/patch/complete/.socket/manifest.json deleted file mode 100644 index 268d61550..000000000 --- a/packages/cli/test/fixtures/commands/patch/complete/.socket/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "patches": { - "pkg:npm/example-package@1.2.3": { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "description": "Security patch for CVE-2025-1234 that fixes critical vulnerability in authentication handling", - "tier": "premium", - "license": "MIT", - "exportedAt": "2025-09-15T14:30:00.000Z", - "files": { - "index.js": { - "beforeHash": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2", - "afterHash": "f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2a1" - }, - "lib/auth.js": { - "beforeHash": "b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2a1", - "afterHash": "a1f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2" - } - }, - "vulnerabilities": { - "GHSA-1234-5678-9012": { - "cves": ["CVE-2025-1234", "CVE-2025-1235"], - "summary": "Critical authentication bypass vulnerability", - "severity": "CRITICAL", - "description": "### Impact\n\nA critical vulnerability in example-package versions < 1.2.3 allows an attacker to bypass authentication by sending specially crafted requests.\n\n### Patches\n\nUsers should upgrade to version 1.2.3 or apply this patch.\n\n### Workarounds\n\nImplement additional authentication validation at the application level.", - "patchExplanation": "This patch adds proper input validation and sanitization to the authentication handler, ensuring all requests are properly authenticated before being processed." - }, - "GHSA-3456-7890-1234": { - "cves": ["CVE-2025-5678"], - "summary": "Denial of service through resource exhaustion", - "severity": "HIGH", - "description": "An attacker can cause a denial of service by sending requests that consume excessive memory.", - "patchExplanation": "Added resource limits and request throttling to prevent memory exhaustion attacks." - } - } - }, - "pkg:npm/another-package@2.0.0": { - "uuid": "650e8400-e29b-41d4-a716-446655440001", - "description": "Performance optimization patch", - "tier": "free", - "license": "Apache-2.0", - "exportedAt": "2025-10-01T09:15:30.500Z", - "files": { - "main.js": { - "beforeHash": "c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2a1b2", - "afterHash": "b2a1f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5d4c3" - } - } - }, - "pkg:npm/minimal-package@0.1.0": { - "exportedAt": "2025-09-20T12:00:00.000Z", - "files": { - "index.js": { - "beforeHash": "d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2a1b2c3", - "afterHash": "c3b2a1f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5d4" - } - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/no-vulns/.socket/manifest.json b/packages/cli/test/fixtures/commands/patch/no-vulns/.socket/manifest.json deleted file mode 100644 index 90d32bf52..000000000 --- a/packages/cli/test/fixtures/commands/patch/no-vulns/.socket/manifest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "patches": { - "pkg:npm/perf-patch@3.0.0": { - "uuid": "750e8400-e29b-41d4-a716-446655440002", - "description": "Performance optimization - no security vulnerabilities", - "tier": "free", - "license": "BSD-3-Clause", - "exportedAt": "2025-10-10T16:45:00.000Z", - "files": { - "core.js": { - "beforeHash": "e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2a1b2c3d4", - "afterHash": "d4c3b2a1f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5" - } - } - }, - "pkg:npm/feature-patch@1.0.0": { - "description": "New feature addition", - "exportedAt": "2025-10-12T08:30:15.200Z", - "files": { - "features.js": { - "beforeHash": "f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2a1b2c3d4e5", - "afterHash": "e5d4c3b2a1f2e1d0c9b8a7z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6" - } - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/npm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 b/packages/cli/test/fixtures/commands/patch/npm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 deleted file mode 100644 index d691cc7fb..000000000 --- a/packages/cli/test/fixtures/commands/patch/npm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +++ /dev/null @@ -1,180 +0,0 @@ -/*! - * on-headers - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = onHeaders - -var http = require('http') - -// older node versions don't have appendHeader -var isAppendHeaderSupported = typeof http.ServerResponse.prototype.appendHeader === 'function' -var set1dArray = isAppendHeaderSupported ? set1dArrayWithAppend : set1dArrayWithSet - -/** - * Create a replacement writeHead method. - * - * @param {function} prevWriteHead - * @param {function} listener - * @private - */ - -function createWriteHead (prevWriteHead, listener) { - var fired = false - - // return function with core name and argument list - return function writeHead (statusCode) { - // set headers from arguments - var args = setWriteHeadHeaders.apply(this, arguments) - - // fire listener - if (!fired) { - fired = true - listener.call(this) - - // pass-along an updated status code - if (typeof args[0] === 'number' && this.statusCode !== args[0]) { - args[0] = this.statusCode - args.length = 1 - } - } - - return prevWriteHead.apply(this, args) - } -} - -/** - * Execute a listener when a response is about to write headers. - * - * @param {object} res - * @return {function} listener - * @public - */ - -function onHeaders (res, listener) { - if (!res) { - throw new TypeError('argument res is required') - } - - if (typeof listener !== 'function') { - throw new TypeError('argument listener must be a function') - } - - res.writeHead = createWriteHead(res.writeHead, listener) -} - -/** - * Set headers contained in array on the response object. - * - * @param {object} res - * @param {array} headers - * @private - */ - -function setHeadersFromArray (res, headers) { - if (headers.length && Array.isArray(headers[0])) { - // 2D - set2dArray(res, headers) - } else { - // 1D - if (headers.length % 2 !== 0) { - throw new TypeError('headers array is malformed') - } - - set1dArray(res, headers) - } -} - -/** - * Set headers contained in object on the response object. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeadersFromObject (res, headers) { - var keys = Object.keys(headers) - for (var i = 0; i < keys.length; i++) { - var k = keys[i] - if (k) res.setHeader(k, headers[k]) - } -} - -/** - * Set headers and other properties on the response object. - * - * @param {number} statusCode - * @private - */ - -function setWriteHeadHeaders (statusCode) { - var length = arguments.length - var headerIndex = length > 1 && typeof arguments[1] === 'string' - ? 2 - : 1 - - var headers = length >= headerIndex + 1 - ? arguments[headerIndex] - : undefined - - this.statusCode = statusCode - - if (Array.isArray(headers)) { - // handle array case - setHeadersFromArray(this, headers) - } else if (headers) { - // handle object case - setHeadersFromObject(this, headers) - } - - // copy leading arguments - var args = new Array(Math.min(length, headerIndex)) - for (var i = 0; i < args.length; i++) { - args[i] = arguments[i] - } - - return args -} - -function set2dArray (res, headers) { - var key - for (var i = 0; i < headers.length; i++) { - key = headers[i][0] - if (key) { - res.setHeader(key, headers[i][1]) - } - } -} - -function set1dArrayWithAppend (res, headers) { - for (var i = 0; i < headers.length; i += 2) { - res.removeHeader(headers[i]) - } - - var key - for (var j = 0; j < headers.length; j += 2) { - key = headers[j] - if (key) { - res.appendHeader(key, headers[j + 1]) - } - } -} - -function set1dArrayWithSet (res, headers) { - var key - for (var i = 0; i < headers.length; i += 2) { - key = headers[i] - if (key) { - res.setHeader(key, headers[i + 1]) - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/npm/.socket/manifest.json b/packages/cli/test/fixtures/commands/patch/npm/.socket/manifest.json deleted file mode 100644 index c9ae2fa61..000000000 --- a/packages/cli/test/fixtures/commands/patch/npm/.socket/manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "patches": { - "pkg:npm/on-headers@1.0.2": { - "exportedAt": "2025-09-10T20:10:19.407Z", - "files": { - "index.js": { - "beforeHash": "c8327f00a843dbcfa6476286110d33bca8f0cc0e82bbe6f7d7171e0606e5dfe5", - "afterHash": "76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856" - } - }, - "vulnerabilities": { - "GHSA-76c9-3jph-rj3q": { - "cves": ["CVE-2025-7339"], - "summary": "on-headers is vulnerable to http response header manipulation", - "severity": "LOW", - "description": "### Impact\n\nA bug in on-headers versions `< 1.1.0` may result in response headers being inadvertently modified when an array is passed to `response.writeHead()`\n\n### Patches\n\nUsers should upgrade to `1.1.0`\n\n### Workarounds\n\nUses are encouraged to upgrade to `1.1.0`, but this issue can be worked around by passing an object to `response.writeHead()` rather than an array.", - "patchExplanation": "" - } - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/npm/package-lock.json b/packages/cli/test/fixtures/commands/patch/npm/package-lock.json deleted file mode 100644 index a14dde571..000000000 --- a/packages/cli/test/fixtures/commands/patch/npm/package-lock.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "patch-test-fixture", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "patch-test-fixture", - "version": "1.0.0", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } - }, - "node_modules/axios": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.2.tgz", - "integrity": "sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - } - } -} \ No newline at end of file diff --git a/packages/cli/test/fixtures/commands/patch/npm/package.json b/packages/cli/test/fixtures/commands/patch/npm/package.json deleted file mode 100644 index 8b5235f74..000000000 --- a/packages/cli/test/fixtures/commands/patch/npm/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "patch-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for patch command", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/patch/pnpm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 b/packages/cli/test/fixtures/commands/patch/pnpm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 deleted file mode 100644 index d691cc7fb..000000000 --- a/packages/cli/test/fixtures/commands/patch/pnpm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +++ /dev/null @@ -1,180 +0,0 @@ -/*! - * on-headers - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = onHeaders - -var http = require('http') - -// older node versions don't have appendHeader -var isAppendHeaderSupported = typeof http.ServerResponse.prototype.appendHeader === 'function' -var set1dArray = isAppendHeaderSupported ? set1dArrayWithAppend : set1dArrayWithSet - -/** - * Create a replacement writeHead method. - * - * @param {function} prevWriteHead - * @param {function} listener - * @private - */ - -function createWriteHead (prevWriteHead, listener) { - var fired = false - - // return function with core name and argument list - return function writeHead (statusCode) { - // set headers from arguments - var args = setWriteHeadHeaders.apply(this, arguments) - - // fire listener - if (!fired) { - fired = true - listener.call(this) - - // pass-along an updated status code - if (typeof args[0] === 'number' && this.statusCode !== args[0]) { - args[0] = this.statusCode - args.length = 1 - } - } - - return prevWriteHead.apply(this, args) - } -} - -/** - * Execute a listener when a response is about to write headers. - * - * @param {object} res - * @return {function} listener - * @public - */ - -function onHeaders (res, listener) { - if (!res) { - throw new TypeError('argument res is required') - } - - if (typeof listener !== 'function') { - throw new TypeError('argument listener must be a function') - } - - res.writeHead = createWriteHead(res.writeHead, listener) -} - -/** - * Set headers contained in array on the response object. - * - * @param {object} res - * @param {array} headers - * @private - */ - -function setHeadersFromArray (res, headers) { - if (headers.length && Array.isArray(headers[0])) { - // 2D - set2dArray(res, headers) - } else { - // 1D - if (headers.length % 2 !== 0) { - throw new TypeError('headers array is malformed') - } - - set1dArray(res, headers) - } -} - -/** - * Set headers contained in object on the response object. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeadersFromObject (res, headers) { - var keys = Object.keys(headers) - for (var i = 0; i < keys.length; i++) { - var k = keys[i] - if (k) res.setHeader(k, headers[k]) - } -} - -/** - * Set headers and other properties on the response object. - * - * @param {number} statusCode - * @private - */ - -function setWriteHeadHeaders (statusCode) { - var length = arguments.length - var headerIndex = length > 1 && typeof arguments[1] === 'string' - ? 2 - : 1 - - var headers = length >= headerIndex + 1 - ? arguments[headerIndex] - : undefined - - this.statusCode = statusCode - - if (Array.isArray(headers)) { - // handle array case - setHeadersFromArray(this, headers) - } else if (headers) { - // handle object case - setHeadersFromObject(this, headers) - } - - // copy leading arguments - var args = new Array(Math.min(length, headerIndex)) - for (var i = 0; i < args.length; i++) { - args[i] = arguments[i] - } - - return args -} - -function set2dArray (res, headers) { - var key - for (var i = 0; i < headers.length; i++) { - key = headers[i][0] - if (key) { - res.setHeader(key, headers[i][1]) - } - } -} - -function set1dArrayWithAppend (res, headers) { - for (var i = 0; i < headers.length; i += 2) { - res.removeHeader(headers[i]) - } - - var key - for (var j = 0; j < headers.length; j += 2) { - key = headers[j] - if (key) { - res.appendHeader(key, headers[j + 1]) - } - } -} - -function set1dArrayWithSet (res, headers) { - var key - for (var i = 0; i < headers.length; i += 2) { - key = headers[i] - if (key) { - res.setHeader(key, headers[i + 1]) - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/pnpm/.socket/manifest.json b/packages/cli/test/fixtures/commands/patch/pnpm/.socket/manifest.json deleted file mode 100644 index 4e389992b..000000000 --- a/packages/cli/test/fixtures/commands/patch/pnpm/.socket/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "patches": { - "pkg:npm/on-headers@1.0.2": { - "uuid": "00000000-0000-0000-0000-000000000000", - "exportedAt": "2025-09-10T20:10:19.407Z", - "files": { - "index.js": { - "beforeHash": "c8327f00a843dbcfa6476286110d33bca8f0cc0e82bbe6f7d7171e0606e5dfe5", - "afterHash": "76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856" - } - }, - "vulnerabilities": { - "GHSA-76c9-3jph-rj3q": { - "cves": ["CVE-2025-7339"], - "summary": "on-headers is vulnerable to http response header manipulation", - "severity": "LOW", - "description": "### Impact\n\nA bug in on-headers versions `< 1.1.0` may result in response headers being inadvertently modified when an array is passed to `response.writeHead()`\n\n### Patches\n\nUsers should upgrade to `1.1.0`\n\n### Workarounds\n\nUses are encouraged to upgrade to `1.1.0`, but this issue can be worked around by passing an object to `response.writeHead()` rather than an array.", - "patchExplanation": "" - } - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/pnpm/custom-patches/index.js b/packages/cli/test/fixtures/commands/patch/pnpm/custom-patches/index.js deleted file mode 100644 index fe1863241..000000000 --- a/packages/cli/test/fixtures/commands/patch/pnpm/custom-patches/index.js +++ /dev/null @@ -1,186 +0,0 @@ -/*! - * on-headers - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * - * @public - */ - -module.exports = onHeaders - -var http = require('http') - -// older node versions don't have appendHeader -var isAppendHeaderSupported = - typeof http.ServerResponse.prototype.appendHeader === 'function' -var set1dArray = isAppendHeaderSupported - ? set1dArrayWithAppend - : set1dArrayWithSet - -/** - * Create a replacement writeHead method. - * - * @private - * - * @param {function} prevWriteHead - * @param {function} listener - */ - -function createWriteHead(prevWriteHead, listener) { - var fired = false - - // return function with core name and argument list - return function writeHead(statusCode) { - // set headers from arguments - var args = setWriteHeadHeaders.apply(this, arguments) - - // fire listener - if (!fired) { - fired = true - listener.call(this) - - // pass-along an updated status code - if (typeof args[0] === 'number' && this.statusCode !== args[0]) { - args[0] = this.statusCode - args.length = 1 - } - } - - return prevWriteHead.apply(this, args) - } -} - -/** - * Execute a listener when a response is about to write headers. - * - * @param {object} res - * - * @returns {function} Listener - * - * @public - */ - -function onHeaders(res, listener) { - if (!res) { - throw new TypeError('argument res is required') - } - - if (typeof listener !== 'function') { - throw new TypeError('argument listener must be a function') - } - - res.writeHead = createWriteHead(res.writeHead, listener) -} - -/** - * Set headers contained in array on the response object. - * - * @private - * - * @param {object} res - * @param {array} headers - */ - -function setHeadersFromArray(res, headers) { - if (headers.length && Array.isArray(headers[0])) { - // 2D - set2dArray(res, headers) - } else { - // 1D - if (headers.length % 2 !== 0) { - throw new TypeError('headers array is malformed') - } - - set1dArray(res, headers) - } -} - -/** - * Set headers contained in object on the response object. - * - * @private - * - * @param {object} res - * @param {object} headers - */ - -function setHeadersFromObject(res, headers) { - var keys = Object.keys(headers) - for (var i = 0; i < keys.length; i++) { - var k = keys[i] - if (k) res.setHeader(k, headers[k]) - } -} - -/** - * Set headers and other properties on the response object. - * - * @private - * - * @param {number} statusCode - */ - -function setWriteHeadHeaders(statusCode) { - var length = arguments.length - var headerIndex = length > 1 && typeof arguments[1] === 'string' ? 2 : 1 - - var headers = length >= headerIndex + 1 ? arguments[headerIndex] : undefined - - this.statusCode = statusCode - - if (Array.isArray(headers)) { - // handle array case - setHeadersFromArray(this, headers) - } else if (headers) { - // handle object case - setHeadersFromObject(this, headers) - } - - // copy leading arguments - var args = new Array(Math.min(length, headerIndex)) - for (var i = 0; i < args.length; i++) { - args[i] = arguments[i] - } - - return args -} - -function set2dArray(res, headers) { - var key - for (var i = 0; i < headers.length; i++) { - key = headers[i][0] - if (key) { - res.setHeader(key, headers[i][1]) - } - } -} - -function set1dArrayWithAppend(res, headers) { - for (var i = 0; i < headers.length; i += 2) { - res.removeHeader(headers[i]) - } - - var key - for (var j = 0; j < headers.length; j += 2) { - key = headers[j] - if (key) { - res.appendHeader(key, headers[j + 1]) - } - } -} - -function set1dArrayWithSet(res, headers) { - var key - for (var i = 0; i < headers.length; i += 2) { - key = headers[i] - if (key) { - res.setHeader(key, headers[i + 1]) - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/pnpm/package.json b/packages/cli/test/fixtures/commands/patch/pnpm/package.json deleted file mode 100644 index 8b5235f74..000000000 --- a/packages/cli/test/fixtures/commands/patch/pnpm/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "patch-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for patch command", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/patch/pnpm/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/patch/pnpm/pnpm-lock.yaml deleted file mode 100644 index 0a314daa4..000000000 --- a/packages/cli/test/fixtures/commands/patch/pnpm/pnpm-lock.yaml +++ /dev/null @@ -1,222 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lodash: - specifier: 4.17.20 - version: 4.17.20 - on-headers: - specifier: 1.0.2 - version: 1.0.2 - devDependencies: - axios: - specifier: 1.3.2 - version: 1.3.2 - -packages: - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - lodash@4.17.20: - resolution: {integrity: sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - on-headers@1.0.2: - resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} - engines: {node: '>= 0.8'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - -snapshots: - - asynckit@0.4.0: {} - - axios@1.3.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - delayed-stream@1.0.0: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - lodash@4.17.20: {} - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - on-headers@1.0.2: {} - - proxy-from-env@1.1.0: {} diff --git a/packages/cli/test/fixtures/commands/patch/yarn/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 b/packages/cli/test/fixtures/commands/patch/yarn/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 deleted file mode 100644 index d691cc7fb..000000000 --- a/packages/cli/test/fixtures/commands/patch/yarn/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +++ /dev/null @@ -1,180 +0,0 @@ -/*! - * on-headers - * Copyright(c) 2014 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = onHeaders - -var http = require('http') - -// older node versions don't have appendHeader -var isAppendHeaderSupported = typeof http.ServerResponse.prototype.appendHeader === 'function' -var set1dArray = isAppendHeaderSupported ? set1dArrayWithAppend : set1dArrayWithSet - -/** - * Create a replacement writeHead method. - * - * @param {function} prevWriteHead - * @param {function} listener - * @private - */ - -function createWriteHead (prevWriteHead, listener) { - var fired = false - - // return function with core name and argument list - return function writeHead (statusCode) { - // set headers from arguments - var args = setWriteHeadHeaders.apply(this, arguments) - - // fire listener - if (!fired) { - fired = true - listener.call(this) - - // pass-along an updated status code - if (typeof args[0] === 'number' && this.statusCode !== args[0]) { - args[0] = this.statusCode - args.length = 1 - } - } - - return prevWriteHead.apply(this, args) - } -} - -/** - * Execute a listener when a response is about to write headers. - * - * @param {object} res - * @return {function} listener - * @public - */ - -function onHeaders (res, listener) { - if (!res) { - throw new TypeError('argument res is required') - } - - if (typeof listener !== 'function') { - throw new TypeError('argument listener must be a function') - } - - res.writeHead = createWriteHead(res.writeHead, listener) -} - -/** - * Set headers contained in array on the response object. - * - * @param {object} res - * @param {array} headers - * @private - */ - -function setHeadersFromArray (res, headers) { - if (headers.length && Array.isArray(headers[0])) { - // 2D - set2dArray(res, headers) - } else { - // 1D - if (headers.length % 2 !== 0) { - throw new TypeError('headers array is malformed') - } - - set1dArray(res, headers) - } -} - -/** - * Set headers contained in object on the response object. - * - * @param {object} res - * @param {object} headers - * @private - */ - -function setHeadersFromObject (res, headers) { - var keys = Object.keys(headers) - for (var i = 0; i < keys.length; i++) { - var k = keys[i] - if (k) res.setHeader(k, headers[k]) - } -} - -/** - * Set headers and other properties on the response object. - * - * @param {number} statusCode - * @private - */ - -function setWriteHeadHeaders (statusCode) { - var length = arguments.length - var headerIndex = length > 1 && typeof arguments[1] === 'string' - ? 2 - : 1 - - var headers = length >= headerIndex + 1 - ? arguments[headerIndex] - : undefined - - this.statusCode = statusCode - - if (Array.isArray(headers)) { - // handle array case - setHeadersFromArray(this, headers) - } else if (headers) { - // handle object case - setHeadersFromObject(this, headers) - } - - // copy leading arguments - var args = new Array(Math.min(length, headerIndex)) - for (var i = 0; i < args.length; i++) { - args[i] = arguments[i] - } - - return args -} - -function set2dArray (res, headers) { - var key - for (var i = 0; i < headers.length; i++) { - key = headers[i][0] - if (key) { - res.setHeader(key, headers[i][1]) - } - } -} - -function set1dArrayWithAppend (res, headers) { - for (var i = 0; i < headers.length; i += 2) { - res.removeHeader(headers[i]) - } - - var key - for (var j = 0; j < headers.length; j += 2) { - key = headers[j] - if (key) { - res.appendHeader(key, headers[j + 1]) - } - } -} - -function set1dArrayWithSet (res, headers) { - var key - for (var i = 0; i < headers.length; i += 2) { - key = headers[i] - if (key) { - res.setHeader(key, headers[i + 1]) - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/yarn/.socket/manifest.json b/packages/cli/test/fixtures/commands/patch/yarn/.socket/manifest.json deleted file mode 100644 index c9ae2fa61..000000000 --- a/packages/cli/test/fixtures/commands/patch/yarn/.socket/manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "patches": { - "pkg:npm/on-headers@1.0.2": { - "exportedAt": "2025-09-10T20:10:19.407Z", - "files": { - "index.js": { - "beforeHash": "c8327f00a843dbcfa6476286110d33bca8f0cc0e82bbe6f7d7171e0606e5dfe5", - "afterHash": "76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856" - } - }, - "vulnerabilities": { - "GHSA-76c9-3jph-rj3q": { - "cves": ["CVE-2025-7339"], - "summary": "on-headers is vulnerable to http response header manipulation", - "severity": "LOW", - "description": "### Impact\n\nA bug in on-headers versions `< 1.1.0` may result in response headers being inadvertently modified when an array is passed to `response.writeHead()`\n\n### Patches\n\nUsers should upgrade to `1.1.0`\n\n### Workarounds\n\nUses are encouraged to upgrade to `1.1.0`, but this issue can be worked around by passing an object to `response.writeHead()` rather than an array.", - "patchExplanation": "" - } - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/patch/yarn/package.json b/packages/cli/test/fixtures/commands/patch/yarn/package.json deleted file mode 100644 index 8b5235f74..000000000 --- a/packages/cli/test/fixtures/commands/patch/yarn/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "patch-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for patch command", - "main": "index.js", - "dependencies": { - "lodash": "4.17.20", - "on-headers": "1.0.2" - }, - "devDependencies": { - "axios": "1.3.2" - } -} diff --git a/packages/cli/test/fixtures/commands/patch/yarn/yarn.lock b/packages/cli/test/fixtures/commands/patch/yarn/yarn.lock deleted file mode 100644 index 43cf278b4..000000000 --- a/packages/cli/test/fixtures/commands/patch/yarn/yarn.lock +++ /dev/null @@ -1,70 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -axios@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.3.2.tgz#1a85d3f2784eb0c5679f73f84c4675ede2b60bcc" - integrity sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -follow-redirects@^1.15.0: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#b4e8e8b8b42a0cce0db1ca2f6ee0b4b1cbc3afce" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== - -form-data@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#93ea542d4988a8a7a09a43b7c39e85a0f44011b5" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - -lodash@4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -on-headers@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24350c72c" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== \ No newline at end of file diff --git a/packages/cli/test/fixtures/commands/scan/reach/index.js b/packages/cli/test/fixtures/commands/scan/reach/index.js deleted file mode 100644 index 8057d2804..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express') -const lodash = require('lodash') - -const app = express() - -app.get('/', (req, res) => { - const data = lodash.pick(req.query, ['name', 'age']) - res.json(data) -}) - -app.listen(3000, () => { - console.log(`Test fixture ${__filename} running on port 3000`) -}) diff --git a/packages/cli/test/fixtures/commands/scan/reach/npm/index.js b/packages/cli/test/fixtures/commands/scan/reach/npm/index.js deleted file mode 100644 index 8057d2804..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/npm/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express') -const lodash = require('lodash') - -const app = express() - -app.get('/', (req, res) => { - const data = lodash.pick(req.query, ['name', 'age']) - res.json(data) -}) - -app.listen(3000, () => { - console.log(`Test fixture ${__filename} running on port 3000`) -}) diff --git a/packages/cli/test/fixtures/commands/scan/reach/npm/package-lock.json b/packages/cli/test/fixtures/commands/scan/reach/npm/package-lock.json deleted file mode 100644 index 4a5038f4d..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/npm/package-lock.json +++ /dev/null @@ -1,4605 +0,0 @@ -{ - "name": "reach-test-fixture", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "reach-test-fixture", - "version": "1.0.0", - "dependencies": { - "axios": "1.4.0", - "express": "4.18.2", - "lodash": "4.17.21" - }, - "devDependencies": { - "jest": "29.5.0", - "typescript": "5.0.4" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.12.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", - "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001743", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", - "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.223", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz", - "integrity": "sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.5.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.5.0.tgz", - "integrity": "sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.5.0", - "@jest/types": "^29.5.0", - "import-local": "^3.0.2", - "jest-cli": "^29.5.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "license": "MIT" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "license": "MIT", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=12.20" - } - }, - "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/packages/cli/test/fixtures/commands/scan/reach/npm/package.json b/packages/cli/test/fixtures/commands/scan/reach/npm/package.json deleted file mode 100644 index 430d83294..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/npm/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "reach-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for reachability analysis", - "main": "index.js", - "dependencies": { - "axios": "1.4.0", - "express": "4.18.2", - "lodash": "4.17.21" - }, - "devDependencies": { - "jest": "29.5.0", - "typescript": "5.0.4" - } -} diff --git a/packages/cli/test/fixtures/commands/scan/reach/package.json b/packages/cli/test/fixtures/commands/scan/reach/package.json deleted file mode 100644 index 430d83294..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "reach-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for reachability analysis", - "main": "index.js", - "dependencies": { - "axios": "1.4.0", - "express": "4.18.2", - "lodash": "4.17.21" - }, - "devDependencies": { - "jest": "29.5.0", - "typescript": "5.0.4" - } -} diff --git a/packages/cli/test/fixtures/commands/scan/reach/pnpm/index.js b/packages/cli/test/fixtures/commands/scan/reach/pnpm/index.js deleted file mode 100644 index 8057d2804..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/pnpm/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express') -const lodash = require('lodash') - -const app = express() - -app.get('/', (req, res) => { - const data = lodash.pick(req.query, ['name', 'age']) - res.json(data) -}) - -app.listen(3000, () => { - console.log(`Test fixture ${__filename} running on port 3000`) -}) diff --git a/packages/cli/test/fixtures/commands/scan/reach/pnpm/package.json b/packages/cli/test/fixtures/commands/scan/reach/pnpm/package.json deleted file mode 100644 index 430d83294..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/pnpm/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "reach-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for reachability analysis", - "main": "index.js", - "dependencies": { - "axios": "1.4.0", - "express": "4.18.2", - "lodash": "4.17.21" - }, - "devDependencies": { - "jest": "29.5.0", - "typescript": "5.0.4" - } -} diff --git a/packages/cli/test/fixtures/commands/scan/reach/pnpm/pnpm-lock.yaml b/packages/cli/test/fixtures/commands/scan/reach/pnpm/pnpm-lock.yaml deleted file mode 100644 index bcbf2f60f..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/pnpm/pnpm-lock.yaml +++ /dev/null @@ -1,3086 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - axios: - specifier: 1.4.0 - version: 1.4.0 - express: - specifier: 4.18.2 - version: 4.18.2 - lodash: - specifier: 4.17.21 - version: 4.17.21 - devDependencies: - jest: - specifier: 29.5.0 - version: 29.5.0(@types/node@24.5.2) - typescript: - specifier: 5.0.4 - version: 5.0.4 - -packages: - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.28.4': - resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.4': - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.4': - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/node@24.5.2': - resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.4.0: - resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} - - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - baseline-browser-mapping@2.8.6: - resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} - hasBin: true - - body-parser@1.20.1: - resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.26.2: - resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001743: - resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - - cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} - engines: {node: '>= 0.6'} - - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.0: - resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.223: - resolution: {integrity: sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==} - - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - express@4.18.2: - resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} - engines: {node: '>= 0.10.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.2.0: - resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} - engines: {node: '>= 0.8'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.5.0: - resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - merge-descriptors@1.0.1: - resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.21: - resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.1: - resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} - engines: {node: '>= 0.8'} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.10: - resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} - engines: {node: '>= 0.4'} - hasBin: true - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - send@0.18.0: - resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} - engines: {node: '>= 0.8.0'} - - serve-static@1.15.0: - resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} - engines: {node: '>= 0.8.0'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - typescript@5.0.4: - resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} - engines: {node: '>=12.20'} - hasBin: true - - undici-types@7.12.0: - resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.28.4': {} - - '@babel/core@7.28.4': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.28.3': - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.26.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.27.1': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - - '@babel/parser@7.28.4': - dependencies: - '@babel/types': 7.28.4 - - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - - '@babel/traverse@7.28.4': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.4': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - - '@bcoe/v8-coverage@0.2.3': {} - - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.1 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@24.5.2) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 24.5.2 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 24.5.2 - chalk: 4.1.2 - collect-v8-coverage: 1.0.2 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.28.4 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 24.5.2 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@sinclair/typebox@0.27.8': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.28.4 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.28.4 - - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 24.5.2 - - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/node@24.5.2': - dependencies: - undici-types: 7.12.0 - - '@types/stack-utils@2.0.3': {} - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.33': - dependencies: - '@types/yargs-parser': 21.0.3 - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - array-flatten@1.1.1: {} - - asynckit@0.4.0: {} - - axios@1.4.0: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - babel-jest@29.7.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.4) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) - - babel-preset-jest@29.6.3(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) - - balanced-match@1.0.2: {} - - baseline-browser-mapping@2.8.6: {} - - body-parser@1.20.1: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.11.0 - raw-body: 2.5.1 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.26.2: - dependencies: - baseline-browser-mapping: 2.8.6 - caniuse-lite: 1.0.30001743 - electron-to-chromium: 1.5.223 - node-releases: 2.0.21 - update-browserslist-db: 1.1.3(browserslist@4.26.2) - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - - buffer-from@1.1.2: {} - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001743: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - char-regex@1.0.2: {} - - ci-info@3.9.0: {} - - cjs-module-lexer@1.4.3: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - co@4.6.0: {} - - collect-v8-coverage@1.0.2: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - concat-map@0.0.1: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.0.6: {} - - cookie@0.5.0: {} - - create-jest@29.7.0(@types/node@24.5.2): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.5.2) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - dedent@1.7.0: {} - - deepmerge@4.3.1: {} - - delayed-stream@1.0.0: {} - - depd@2.0.0: {} - - destroy@1.2.0: {} - - detect-newline@3.1.0: {} - - diff-sequences@29.6.3: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.223: {} - - emittery@0.13.1: {} - - emoji-regex@8.0.0: {} - - encodeurl@1.0.2: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@2.0.0: {} - - esprima@4.0.1: {} - - etag@1.8.1: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - - express@4.18.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.1 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.5.0 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.2.0 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.1 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.7 - proxy-addr: 2.0.7 - qs: 6.11.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.18.0 - serve-static: 1.15.0 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-json-stable-stringify@2.1.0: {} - - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.2.0: - dependencies: - debug: 2.6.9 - encodeurl: 1.0.2 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - follow-redirects@1.15.11: {} - - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - forwarded@0.2.0: {} - - fresh@0.5.2: {} - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-package-type@0.1.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@6.0.1: {} - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - html-escaper@2.0.2: {} - - http-errors@2.0.0: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - - human-signals@2.1.0: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-fn@2.1.0: {} - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.0 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@24.5.2): - dependencies: - '@jest/core': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.5.2) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.5.2) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@24.5.2): - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 24.5.2 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 24.5.2 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - jest-util: 29.7.0 - - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 - - jest-regex-util@29.6.3: {} - - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.10 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@29.7.0: - dependencies: - '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) - '@babel/types': 7.28.4 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 24.5.2 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - - jest-worker@29.7.0: - dependencies: - '@types/node': 24.5.2 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.5.0(@types/node@24.5.2): - dependencies: - '@jest/core': 29.7.0 - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.5.2) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - js-tokens@4.0.0: {} - - js-yaml@3.14.1: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} - - kleur@3.0.3: {} - - leven@3.1.0: {} - - lines-and-columns@1.2.4: {} - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - lodash@4.17.21: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.2 - - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - - math-intrinsics@1.1.0: {} - - media-typer@0.3.0: {} - - merge-descriptors@1.0.1: {} - - merge-stream@2.0.0: {} - - methods@1.1.2: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@1.6.0: {} - - mimic-fn@2.1.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - ms@2.0.0: {} - - ms@2.1.3: {} - - natural-compare@1.4.0: {} - - negotiator@0.6.3: {} - - node-int64@0.4.0: {} - - node-releases@2.0.21: {} - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-to-regexp@0.1.7: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - pirates@4.0.7: {} - - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - proxy-from-env@1.1.0: {} - - pure-rand@6.1.0: {} - - qs@6.11.0: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@2.5.1: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - react-is@18.3.1: {} - - require-directory@2.1.1: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@5.0.0: {} - - resolve.exports@2.0.3: {} - - resolve@1.22.10: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - semver@6.3.1: {} - - semver@7.7.2: {} - - send@0.18.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - - serve-static@1.15.0: - dependencies: - encodeurl: 1.0.2 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.18.0 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - sisteransi@1.0.5: {} - - slash@3.0.0: {} - - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - sprintf-js@1.0.3: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - - statuses@2.0.1: {} - - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - - tmpl@1.0.5: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - typescript@5.0.4: {} - - undici-types@7.12.0: {} - - unpipe@1.0.0: {} - - update-browserslist-db@1.1.3(browserslist@4.26.2): - dependencies: - browserslist: 4.26.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - utils-merge@1.0.1: {} - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - - vary@1.1.2: {} - - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} diff --git a/packages/cli/test/fixtures/commands/scan/reach/yarn/index.js b/packages/cli/test/fixtures/commands/scan/reach/yarn/index.js deleted file mode 100644 index 8057d2804..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/yarn/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express') -const lodash = require('lodash') - -const app = express() - -app.get('/', (req, res) => { - const data = lodash.pick(req.query, ['name', 'age']) - res.json(data) -}) - -app.listen(3000, () => { - console.log(`Test fixture ${__filename} running on port 3000`) -}) diff --git a/packages/cli/test/fixtures/commands/scan/reach/yarn/package.json b/packages/cli/test/fixtures/commands/scan/reach/yarn/package.json deleted file mode 100644 index 430d83294..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/yarn/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "reach-test-fixture", - "version": "1.0.0", - "private": true, - "description": "Test fixture for reachability analysis", - "main": "index.js", - "dependencies": { - "axios": "1.4.0", - "express": "4.18.2", - "lodash": "4.17.21" - }, - "devDependencies": { - "jest": "29.5.0", - "typescript": "5.0.4" - } -} diff --git a/packages/cli/test/fixtures/commands/scan/reach/yarn/yarn.lock b/packages/cli/test/fixtures/commands/scan/reach/yarn/yarn.lock deleted file mode 100644 index 28b687833..000000000 --- a/packages/cli/test/fixtures/commands/scan/reach/yarn/yarn.lock +++ /dev/null @@ -1,2605 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" - integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== - dependencies: - "@babel/helper-validator-identifier" "^7.27.1" - js-tokens "^4.0.0" - picocolors "^1.1.1" - -"@babel/compat-data@^7.27.2": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.4.tgz#96fdf1af1b8859c8474ab39c295312bfb7c24b04" - integrity sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw== - -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.4.tgz#12a550b8794452df4c8b084f95003bce1742d496" - integrity sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.3" - "@babel/helper-compilation-targets" "^7.27.2" - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helpers" "^7.28.4" - "@babel/parser" "^7.28.4" - "@babel/template" "^7.27.2" - "@babel/traverse" "^7.28.4" - "@babel/types" "^7.28.4" - "@jridgewell/remapping" "^2.3.5" - convert-source-map "^2.0.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.3" - semver "^6.3.1" - -"@babel/generator@^7.28.3", "@babel/generator@^7.7.2": - version "7.28.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" - integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw== - dependencies: - "@babel/parser" "^7.28.3" - "@babel/types" "^7.28.2" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" - jsesc "^3.0.2" - -"@babel/helper-compilation-targets@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" - integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== - dependencies: - "@babel/compat-data" "^7.27.2" - "@babel/helper-validator-option" "^7.27.1" - browserslist "^4.24.0" - lru-cache "^5.1.1" - semver "^6.3.1" - -"@babel/helper-globals@^7.28.0": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" - integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== - -"@babel/helper-module-imports@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" - integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== - dependencies: - "@babel/traverse" "^7.27.1" - "@babel/types" "^7.27.1" - -"@babel/helper-module-transforms@^7.28.3": - version "7.28.3" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" - integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== - dependencies: - "@babel/helper-module-imports" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@babel/traverse" "^7.28.3" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" - integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== - -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== - -"@babel/helper-validator-identifier@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" - integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== - -"@babel/helper-validator-option@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" - integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== - -"@babel/helpers@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" - integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== - dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" - -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" - integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== - dependencies: - "@babel/types" "^7.28.4" - -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-bigint@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" - integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-class-static-block@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" - integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-import-attributes@^7.24.7": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" - integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - -"@babel/plugin-syntax-import-meta@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-jsx@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" - integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-top-level-await@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" - integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - -"@babel/template@^7.27.2", "@babel/template@^7.3.3": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" - integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/parser" "^7.27.2" - "@babel/types" "^7.27.1" - -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b" - integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.3" - "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.4" - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" - debug "^4.3.1" - -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.3.3": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" - integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - -"@bcoe/v8-coverage@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" - integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== - -"@istanbuljs/load-nyc-config@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" - integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== - dependencies: - camelcase "^5.3.1" - find-up "^4.1.0" - get-package-type "^0.1.0" - js-yaml "^3.13.1" - resolve-from "^5.0.0" - -"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" - integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== - -"@jest/console@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" - integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== - dependencies: - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - slash "^3.0.0" - -"@jest/core@^29.5.0", "@jest/core@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" - integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== - dependencies: - "@jest/console" "^29.7.0" - "@jest/reporters" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - ci-info "^3.2.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^29.7.0" - jest-config "^29.7.0" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-resolve-dependencies "^29.7.0" - jest-runner "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - jest-watcher "^29.7.0" - micromatch "^4.0.4" - pretty-format "^29.7.0" - slash "^3.0.0" - strip-ansi "^6.0.0" - -"@jest/environment@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" - integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== - dependencies: - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - jest-mock "^29.7.0" - -"@jest/expect-utils@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" - integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== - dependencies: - jest-get-type "^29.6.3" - -"@jest/expect@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" - integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== - dependencies: - expect "^29.7.0" - jest-snapshot "^29.7.0" - -"@jest/fake-timers@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" - integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== - dependencies: - "@jest/types" "^29.6.3" - "@sinonjs/fake-timers" "^10.0.2" - "@types/node" "*" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-util "^29.7.0" - -"@jest/globals@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" - integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/types" "^29.6.3" - jest-mock "^29.7.0" - -"@jest/reporters@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" - integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== - dependencies: - "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" - "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^6.0.0" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.1.3" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - jest-worker "^29.7.0" - slash "^3.0.0" - string-length "^4.0.1" - strip-ansi "^6.0.0" - v8-to-istanbul "^9.0.1" - -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - -"@jest/source-map@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" - integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== - dependencies: - "@jridgewell/trace-mapping" "^0.3.18" - callsites "^3.0.0" - graceful-fs "^4.2.9" - -"@jest/test-result@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" - integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== - dependencies: - "@jest/console" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" - integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== - dependencies: - "@jest/test-result" "^29.7.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - slash "^3.0.0" - -"@jest/transform@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" - integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== - dependencies: - "@babel/core" "^7.11.6" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" - convert-source-map "^2.0.0" - fast-json-stable-stringify "^2.1.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - micromatch "^4.0.4" - pirates "^4.0.4" - slash "^3.0.0" - write-file-atomic "^4.0.2" - -"@jest/types@^29.5.0", "@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== - dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": - version "0.3.13" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" - integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/remapping@^2.3.5": - version "2.3.5" - resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" - integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" - integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== - -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== - -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== - -"@sinonjs/commons@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" - integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== - dependencies: - type-detect "4.0.8" - -"@sinonjs/fake-timers@^10.0.2": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" - integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== - dependencies: - "@sinonjs/commons" "^3.0.0" - -"@types/babel__core@^7.1.14": - version "7.20.5" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" - integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== - dependencies: - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - "@types/babel__generator" "*" - "@types/babel__template" "*" - "@types/babel__traverse" "*" - -"@types/babel__generator@*": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" - integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== - dependencies: - "@babel/types" "^7.0.0" - -"@types/babel__template@*": - version "7.4.4" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" - integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== - dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": - version "7.28.0" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" - integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== - dependencies: - "@babel/types" "^7.28.2" - -"@types/graceful-fs@^4.1.3": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" - integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== - dependencies: - "@types/node" "*" - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" - integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== - -"@types/istanbul-lib-report@*": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" - integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^3.0.0": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" - integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== - dependencies: - "@types/istanbul-lib-report" "*" - -"@types/node@*": - version "24.5.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-24.5.2.tgz#52ceb83f50fe0fcfdfbd2a9fab6db2e9e7ef6446" - integrity sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ== - dependencies: - undici-types "~7.12.0" - -"@types/stack-utils@^2.0.0": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" - integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== - -"@types/yargs-parser@*": - version "21.0.3" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" - integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== - -"@types/yargs@^17.0.8": - version "17.0.33" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.33.tgz#8c32303da83eec050a84b3c7ae7b9f922d13e32d" - integrity sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA== - dependencies: - "@types/yargs-parser" "*" - -accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -ansi-escapes@^4.2.1: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - -anymatch@^3.0.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -axios@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f" - integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -babel-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" - integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== - dependencies: - "@jest/transform" "^29.7.0" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.6.3" - chalk "^4.0.0" - graceful-fs "^4.2.9" - slash "^3.0.0" - -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" - test-exclude "^6.0.0" - -babel-plugin-jest-hoist@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" - integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== - dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.1.14" - "@types/babel__traverse" "^7.0.6" - -babel-preset-current-node-syntax@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" - integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== - dependencies: - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-bigint" "^7.8.3" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-import-attributes" "^7.24.7" - "@babel/plugin-syntax-import-meta" "^7.10.4" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" - -babel-preset-jest@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" - integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== - dependencies: - babel-plugin-jest-hoist "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -baseline-browser-mapping@^2.8.3: - version "2.8.6" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz#c37dea4291ed8d01682f85661dbe87967028642e" - integrity sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw== - -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" - integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - dependencies: - fill-range "^7.1.1" - -browserslist@^4.24.0: - version "4.26.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.26.2.tgz#7db3b3577ec97f1140a52db4936654911078cef3" - integrity sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A== - dependencies: - baseline-browser-mapping "^2.8.3" - caniuse-lite "^1.0.30001741" - electron-to-chromium "^1.5.218" - node-releases "^2.0.21" - update-browserslist-db "^1.1.3" - -bser@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" - integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== - dependencies: - node-int64 "^0.4.0" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" - integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - -call-bound@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camelcase@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caniuse-lite@^1.0.30001741: - version "1.0.30001743" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz#50ff91a991220a1ee2df5af00650dd5c308ea7cd" - integrity sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw== - -chalk@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -char-regex@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" - integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== - -ci-info@^3.2.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== - -cjs-module-lexer@^1.0.0: - version "1.4.3" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" - integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== - -collect-v8-coverage@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" - integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -convert-source-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" - integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== - -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - -create-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" - integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-config "^29.7.0" - jest-util "^29.7.0" - prompts "^2.0.1" - -cross-spawn@^7.0.3: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -dedent@^1.0.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" - integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ== - -deepmerge@^4.2.2: - version "4.3.1" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" - integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detect-newline@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" - integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== - -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - -dunder-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" - integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - dependencies: - call-bind-apply-helpers "^1.0.1" - es-errors "^1.3.0" - gopd "^1.2.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -electron-to-chromium@^1.5.218: - version "1.5.223" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz#cf9b1aebba1c8ee5e50d1c9e198229e15bc87b28" - integrity sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ== - -emittery@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" - integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -error-ex@^1.3.1: - version "1.3.4" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" - integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== - dependencies: - is-arrayish "^0.2.1" - -es-define-property@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" - integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" - integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - dependencies: - es-errors "^1.3.0" - -es-set-tostringtag@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" - integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== - dependencies: - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - -escalade@^3.1.1, escalade@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" - integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== - -expect@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" - integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== - dependencies: - "@jest/expect-utils" "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - -express@4.18.2: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.1" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -fast-json-stable-stringify@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fb-watchman@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" - integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== - dependencies: - bser "2.1.1" - -fill-range@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" - integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - dependencies: - to-regex-range "^5.0.1" - -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -find-up@^4.0.0, find-up@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -follow-redirects@^1.15.0: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== - -form-data@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@^2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" - integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - dependencies: - call-bind-apply-helpers "^1.0.2" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - function-bind "^1.1.2" - get-proto "^1.0.1" - gopd "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - math-intrinsics "^1.1.0" - -get-package-type@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" - integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== - -get-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" - integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== - dependencies: - dunder-proto "^1.0.1" - es-object-atoms "^1.0.0" - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -glob@^7.1.3, glob@^7.1.4: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -gopd@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -graceful-fs@^4.2.9: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-symbols@^1.0.3, has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -html-escaper@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" - integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -import-local@^3.0.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" - integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== - dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-core-module@^2.16.0: - version "2.16.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" - integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== - dependencies: - hasown "^2.0.2" - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-generator-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" - integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" - integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== - -istanbul-lib-instrument@^5.0.4: - version "5.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" - integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - -istanbul-lib-instrument@^6.0.0: - version "6.0.3" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" - integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== - dependencies: - "@babel/core" "^7.23.9" - "@babel/parser" "^7.23.9" - "@istanbuljs/schema" "^0.1.3" - istanbul-lib-coverage "^3.2.0" - semver "^7.5.4" - -istanbul-lib-report@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" - integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== - dependencies: - istanbul-lib-coverage "^3.0.0" - make-dir "^4.0.0" - supports-color "^7.1.0" - -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== - dependencies: - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" - -istanbul-reports@^3.1.3: - version "3.2.0" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" - integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== - dependencies: - html-escaper "^2.0.0" - istanbul-lib-report "^3.0.0" - -jest-changed-files@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" - integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== - dependencies: - execa "^5.0.0" - jest-util "^29.7.0" - p-limit "^3.1.0" - -jest-circus@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" - integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - dedent "^1.0.0" - is-generator-fn "^2.0.0" - jest-each "^29.7.0" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - p-limit "^3.1.0" - pretty-format "^29.7.0" - pure-rand "^6.0.0" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-cli@^29.5.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" - integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== - dependencies: - "@jest/core" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - chalk "^4.0.0" - create-jest "^29.7.0" - exit "^0.1.2" - import-local "^3.0.2" - jest-config "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - yargs "^17.3.1" - -jest-config@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" - integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== - dependencies: - "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.7.0" - "@jest/types" "^29.6.3" - babel-jest "^29.7.0" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-circus "^29.7.0" - jest-environment-node "^29.7.0" - jest-get-type "^29.6.3" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-runner "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - micromatch "^4.0.4" - parse-json "^5.2.0" - pretty-format "^29.7.0" - slash "^3.0.0" - strip-json-comments "^3.1.1" - -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-docblock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" - integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== - dependencies: - detect-newline "^3.0.0" - -jest-each@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" - integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - jest-get-type "^29.6.3" - jest-util "^29.7.0" - pretty-format "^29.7.0" - -jest-environment-node@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" - integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - jest-mock "^29.7.0" - jest-util "^29.7.0" - -jest-get-type@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" - integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== - -jest-haste-map@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" - integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== - dependencies: - "@jest/types" "^29.6.3" - "@types/graceful-fs" "^4.1.3" - "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - jest-worker "^29.7.0" - micromatch "^4.0.4" - walker "^1.0.8" - optionalDependencies: - fsevents "^2.3.2" - -jest-leak-detector@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" - integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== - dependencies: - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-matcher-utils@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" - integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== - dependencies: - chalk "^4.0.0" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-message-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" - integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.6.3" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.7.0" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-mock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" - integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== - dependencies: - "@jest/types" "^29.6.3" - "@types/node" "*" - jest-util "^29.7.0" - -jest-pnp-resolver@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" - integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== - -jest-regex-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" - integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== - -jest-resolve-dependencies@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" - integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== - dependencies: - jest-regex-util "^29.6.3" - jest-snapshot "^29.7.0" - -jest-resolve@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" - integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== - dependencies: - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-pnp-resolver "^1.2.2" - jest-util "^29.7.0" - jest-validate "^29.7.0" - resolve "^1.20.0" - resolve.exports "^2.0.0" - slash "^3.0.0" - -jest-runner@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" - integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== - dependencies: - "@jest/console" "^29.7.0" - "@jest/environment" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - emittery "^0.13.1" - graceful-fs "^4.2.9" - jest-docblock "^29.7.0" - jest-environment-node "^29.7.0" - jest-haste-map "^29.7.0" - jest-leak-detector "^29.7.0" - jest-message-util "^29.7.0" - jest-resolve "^29.7.0" - jest-runtime "^29.7.0" - jest-util "^29.7.0" - jest-watcher "^29.7.0" - jest-worker "^29.7.0" - p-limit "^3.1.0" - source-map-support "0.5.13" - -jest-runtime@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" - integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/globals" "^29.7.0" - "@jest/source-map" "^29.6.3" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - slash "^3.0.0" - strip-bom "^4.0.0" - -jest-snapshot@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" - integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== - dependencies: - "@babel/core" "^7.11.6" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-jsx" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^29.7.0" - graceful-fs "^4.2.9" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - natural-compare "^1.4.0" - pretty-format "^29.7.0" - semver "^7.5.3" - -jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-validate@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" - integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== - dependencies: - "@jest/types" "^29.6.3" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^29.6.3" - leven "^3.1.0" - pretty-format "^29.7.0" - -jest-watcher@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" - integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== - dependencies: - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - emittery "^0.13.1" - jest-util "^29.7.0" - string-length "^4.0.1" - -jest-worker@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" - integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== - dependencies: - "@types/node" "*" - jest-util "^29.7.0" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -jest@29.5.0: - version "29.5.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.5.0.tgz#f75157622f5ce7ad53028f2f8888ab53e1f1f24e" - integrity sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ== - dependencies: - "@jest/core" "^29.5.0" - "@jest/types" "^29.5.0" - import-local "^3.0.2" - jest-cli "^29.5.0" - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsesc@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" - integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json5@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -lodash@4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -make-dir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" - integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== - dependencies: - semver "^7.5.3" - -makeerror@1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" - integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== - dependencies: - tmpl "1.0.5" - -math-intrinsics@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" - integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -micromatch@^4.0.4: - version "4.0.8" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" - integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== - dependencies: - braces "^3.0.3" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.3, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -node-int64@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" - integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== - -node-releases@^2.0.21: - version "2.0.21" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" - integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw== - -normalize-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -object-inspect@^1.13.3: - version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" - integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -parse-json@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== - -picocolors@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pirates@^4.0.4: - version "4.0.7" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" - integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - -pure-rand@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" - integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== - -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - -range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -react-is@^18.0.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" - -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve.exports@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" - integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== - -resolve@^1.20.0: - version "1.22.10" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" - integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== - dependencies: - is-core-module "^2.16.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -semver@^6.3.0, semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.5.3, semver@^7.5.4: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - -side-channel-map@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" - integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - -side-channel-weakmap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" - integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - side-channel-map "^1.0.1" - -side-channel@^1.0.4: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -signal-exit@^3.0.3, signal-exit@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -source-map-support@0.5.13: - version "0.5.13" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" - integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -stack-utils@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" - integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== - dependencies: - escape-string-regexp "^2.0.0" - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -string-length@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" - integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== - dependencies: - char-regex "^1.0.2" - strip-ansi "^6.0.0" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-bom@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" - integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -test-exclude@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" - integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^7.1.4" - minimatch "^3.0.4" - -tmpl@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" - integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -type-detect@4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -typescript@5.0.4: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== - -undici-types@~7.12.0: - version "7.12.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.12.0.tgz#15c5c7475c2a3ba30659529f5cdb4674b622fafb" - integrity sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - -update-browserslist-db@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" - integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== - dependencies: - escalade "^3.2.0" - picocolors "^1.1.1" - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -v8-to-istanbul@^9.0.1: - version "9.3.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" - integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== - dependencies: - "@jridgewell/trace-mapping" "^0.3.12" - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^2.0.0" - -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - -walker@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" - integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== - dependencies: - makeerror "1.0.12" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -write-file-atomic@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== - dependencies: - imurmurhash "^0.1.4" - signal-exit "^3.0.7" - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^17.3.1: - version "17.7.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/packages/cli/test/fixtures/commands/yarn/minimal/.yarn/install-state.gz b/packages/cli/test/fixtures/commands/yarn/minimal/.yarn/install-state.gz deleted file mode 100644 index 36f54974e..000000000 Binary files a/packages/cli/test/fixtures/commands/yarn/minimal/.yarn/install-state.gz and /dev/null differ diff --git a/packages/cli/test/fixtures/commands/yarn/minimal/.yarnrc.yml b/packages/cli/test/fixtures/commands/yarn/minimal/.yarnrc.yml deleted file mode 100644 index af0e972a8..000000000 --- a/packages/cli/test/fixtures/commands/yarn/minimal/.yarnrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -nodeLinker: node-modules -enableGlobalCache: false diff --git a/packages/cli/test/fixtures/commands/yarn/minimal/package.json b/packages/cli/test/fixtures/commands/yarn/minimal/package.json deleted file mode 100644 index 30577e2f0..000000000 --- a/packages/cli/test/fixtures/commands/yarn/minimal/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "test-yarn-minimal", - "version": "1.0.0", - "private": true, - "packageManager": "yarn@4.10.3" -} diff --git a/packages/cli/test/fixtures/commands/yarn/minimal/yarn.lock b/packages/cli/test/fixtures/commands/yarn/minimal/yarn.lock deleted file mode 100644 index 78792158a..000000000 --- a/packages/cli/test/fixtures/commands/yarn/minimal/yarn.lock +++ /dev/null @@ -1,12 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"test-yarn-minimal@workspace:.": - version: 0.0.0-use.local - resolution: "test-yarn-minimal@workspace:." - languageName: unknown - linkType: soft diff --git a/packages/cli/test/fixtures/optimize/bun/package.json b/packages/cli/test/fixtures/optimize/bun/package.json deleted file mode 100644 index 4a0c68a49..000000000 --- a/packages/cli/test/fixtures/optimize/bun/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-bun", - "version": "1.0.0", - "private": true, - "dependencies": { - "bun": "1.1.42" - }, - "packageManager": "bun@1.1.42" -} diff --git a/packages/cli/test/fixtures/optimize/pnpm-v10/package.json b/packages/cli/test/fixtures/optimize/pnpm-v10/package.json deleted file mode 100644 index 7c6f57e9b..000000000 --- a/packages/cli/test/fixtures/optimize/pnpm-v10/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-pnpm-v10", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "10.0.0" - }, - "packageManager": "pnpm@10.0.0" -} diff --git a/packages/cli/test/fixtures/optimize/pnpm-v8/package.json b/packages/cli/test/fixtures/optimize/pnpm-v8/package.json deleted file mode 100644 index a3df1e1a6..000000000 --- a/packages/cli/test/fixtures/optimize/pnpm-v8/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-pnpm-v8", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "8.15.1" - }, - "packageManager": "pnpm@8.15.1" -} diff --git a/packages/cli/test/fixtures/optimize/pnpm-v9/package.json b/packages/cli/test/fixtures/optimize/pnpm-v9/package.json deleted file mode 100644 index 95000b88c..000000000 --- a/packages/cli/test/fixtures/optimize/pnpm-v9/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-pnpm-v9", - "version": "1.0.0", - "private": true, - "dependencies": { - "pnpm": "9.14.4" - }, - "packageManager": "pnpm@9.14.4" -} diff --git a/packages/cli/test/fixtures/optimize/vlt/package.json b/packages/cli/test/fixtures/optimize/vlt/package.json deleted file mode 100644 index bbb19dd24..000000000 --- a/packages/cli/test/fixtures/optimize/vlt/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-vlt", - "version": "1.0.0", - "private": true, - "dependencies": { - "vlt": "0.1.0" - }, - "packageManager": "vlt@0.1.0" -} diff --git a/packages/cli/test/fixtures/optimize/yarn-berry/package.json b/packages/cli/test/fixtures/optimize/yarn-berry/package.json deleted file mode 100644 index 3d663b4ba..000000000 --- a/packages/cli/test/fixtures/optimize/yarn-berry/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-yarn-berry", - "version": "1.0.0", - "private": true, - "dependencies": { - "yarn": "4.10.3" - }, - "packageManager": "yarn@4.10.3" -} diff --git a/packages/cli/test/fixtures/optimize/yarn-classic/package.json b/packages/cli/test/fixtures/optimize/yarn-classic/package.json deleted file mode 100644 index 212063f41..000000000 --- a/packages/cli/test/fixtures/optimize/yarn-classic/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-yarn-classic", - "version": "1.0.0", - "private": true, - "dependencies": { - "yarn": "1.22.22" - }, - "packageManager": "yarn@1.22.22" -} diff --git a/packages/cli/test/helpers/README.md b/packages/cli/test/helpers/README.md deleted file mode 100644 index e557acd1c..000000000 --- a/packages/cli/test/helpers/README.md +++ /dev/null @@ -1,403 +0,0 @@ -# Socket CLI Test Helpers - -Comprehensive test helper library for Socket CLI, providing utilities for CLI execution, output validation, result assertions, and workspace management. - -## Overview - -This test helper library provides a fluent, type-safe API for testing Socket CLI functionality. It addresses common testing patterns and significantly reduces boilerplate code while improving test readability and maintainability. - -## Quick Start - -```typescript -import { - executeCliCommand, - expectOutput, - createTestWorkspace, -} from '../helpers/index.mts' - -describe('socket scan', () => { - it('should scan workspace successfully', async () => { - const workspace = await createTestWorkspace({ - packageJson: { - name: 'test-app', - dependencies: { express: '^4.18.0' }, - }, - }) - - const result = await executeCliCommand(['scan'], { cwd: workspace.path }) - - expectOutput(result).succeeded().stdoutContains('express').stderrEmpty() - - await workspace.cleanup() - }) -}) -``` - -## Modules - -### 1. CLI Execution (`cli-execution.mts`) - -Execute Socket CLI commands with enhanced result handling and automatic configuration isolation. - -**Key Functions:** - -- `executeCliCommand(args, options)` - Execute CLI with enhanced result -- `expectCliSuccess(args, options)` - Assert command succeeds -- `expectCliError(args, expectedCode, options)` - Assert command fails -- `executeCliJson<T>(args, options)` - Execute and parse JSON output -- `executeCliWithRetry(args, maxRetries, delay, options)` - Retry on failure -- `executeBatchCliCommands(commands, options)` - Execute multiple commands -- `executeCliWithTiming(args, options)` - Measure execution time - -**Example:** - -```typescript -const { data, result } = await executeCliJson<ScanResult>(['scan', 'create']) -expect(data.id).toBeDefined() -``` - -### 2. Output Assertions (`output-assertions.mts`) - -Fluent assertion API for validating CLI output with comprehensive matchers. - -**Key Functions:** - -- `expectOutput(result)` - Fluent assertion builder -- `expectStdoutContainsAll(output, expected)` - Validate multiple strings -- `expectOrderedPatterns(output, patterns)` - Validate pattern order -- `expectValidJson<T>(output)` - Validate and parse JSON -- `expectLineCount(output, expected)` - Validate line count -- `expectNoAnsiCodes(output)` - Validate plain text - -**Example:** - -```typescript -expectOutput(result) - .succeeded() - .stdoutContains('Usage') - .stdoutContains(/options/i) - .stderrEmpty() -``` - -### 3. Result Assertions (`result-assertions.mts`) - -Type-safe assertions for Socket's `CResult<T>` pattern used throughout the CLI codebase. - -**Key Functions:** - -- `expectResult<T>(result)` - Fluent assertion builder -- `expectSuccess<T>(result)` - Extract data from success result -- `expectFailure<T>(result)` - Extract error from failure result -- `expectSuccessWithData<T>(result, expected)` - Validate success data -- `expectFailureWithMessage<T>(result, message, code)` - Validate error -- `expectAllSuccess<T>(results)` - Validate array of results -- `extractSuccessData<T>(results)` - Extract all success data -- `extractErrorMessages<T>(results)` - Extract all error messages - -**Example:** - -```typescript -expectResult(result) - .isSuccess() - .hasData() - .dataContains({ id: 'scan-123' }) - .withData(data => { - expect(data.status).toBe('completed') - }) -``` - -### 4. Workspace Helpers (`workspace-helper.mts`) - -Create and manage temporary test workspaces with package manifests, lockfiles, and configurations. - -**Key Functions:** - -- `createTestWorkspace(config)` - Create temporary workspace -- `withTestWorkspace(config, testFn)` - Auto-cleanup workspace -- `createWorkspaceWithLockfile(packageManager, deps)` - Create with lockfile -- `createMonorepoWorkspace(packages)` - Create monorepo structure -- `createWorkspaceWithSocketConfig(config)` - Create with .socketrc.json -- `setupPackageJson(workspace, deps, devDeps)` - Setup package.json - -**Example:** - -```typescript -await withTestWorkspace( - { - packageJson: { name: 'test-app' }, - files: [{ path: 'index.js', content: 'console.log("hello")' }], - }, - async workspace => { - const result = await executeCliCommand(['scan'], { cwd: workspace.path }) - expect(result.status).toBe(true) - }, -) -``` - -### 5. Existing Helpers - -The library also re-exports existing helpers: - -- **constants.mts** - Test constants (timeouts, URLs, tokens) -- **environment.mts** - Test environment setup -- **fixtures.mts** - Test data fixtures -- **mocks.mts** - Mock SDK and API functions -- **test-fixtures.mts** - Temporary fixture management - -## Benefits - -### Code Reduction - -Typical test savings: - -| Pattern | Before | After | Lines Saved | -| ----------------- | ----------- | --------- | ----------- | -| CLI Execution | 10-15 lines | 2-3 lines | 7-12 lines | -| Output Validation | 5-8 lines | 1-3 lines | 4-5 lines | -| Workspace Setup | 15-20 lines | 3-5 lines | 12-15 lines | -| Result Validation | 3-5 lines | 1-2 lines | 2-3 lines | - -**Overall: 100-200 lines saved per 10 test files** - -### Improved Readability - -**Before:** - -```typescript -const binPath = path.join(__dirname, '../../bin/cli.js') -const result = await spawn(process.execPath, [ - binPath, - 'scan', - '--json', - '--config', - '{}', -]) -expect(result.code).toBe(0) -const json = JSON.parse(stripAnsi(result.stdout.trim())) -expect(json.id).toBeDefined() -``` - -**After:** - -```typescript -const { data, result } = await executeCliJson(['scan']) -expectOutput(result).succeeded() -expect(data.id).toBeDefined() -``` - -### Type Safety - -All helpers are fully typed with TypeScript, providing: - -- Autocomplete in IDEs -- Type checking for parameters -- Type inference for results -- Generic support for custom types - -### Error Messages - -Improved error messages that show: - -- Expected vs actual values -- Command that was executed -- Full stdout/stderr output -- Stack traces with context - -## Usage Patterns - -### Basic CLI Test - -```typescript -describe('socket scan', () => { - it('should display help', async () => { - const result = await expectCliSuccess(['scan', '--help']) - - expectOutput(result).stdoutContains('Usage').stdoutContains('Options') - }) -}) -``` - -### JSON Output Test - -```typescript -describe('socket scan --json', () => { - it('should return JSON', async () => { - const { data } = await executeCliJson<ScanResult>(['scan', 'create']) - - expect(data.id).toBeDefined() - expect(data.status).toBe('completed') - }) -}) -``` - -### Workspace Test - -```typescript -describe('socket scan with workspace', () => { - it('should scan dependencies', async () => { - await withTestWorkspace( - { - packageJson: { - dependencies: { express: '^4.18.0' }, - }, - }, - async workspace => { - const result = await executeCliCommand(['scan'], { - cwd: workspace.path, - }) - - expectOutput(result).succeeded().stdoutContains('express') - }, - ) - }) -}) -``` - -### Result Validation Test - -```typescript -describe('SDK API calls', () => { - it('should validate result', async () => { - const result = await mockApiCall() - - expectResult(result).isSuccess().hasData().dataContains({ id: 'scan-123' }) - }) -}) -``` - -### Error Handling Test - -```typescript -describe('socket scan errors', () => { - it('should handle invalid arguments', async () => { - const result = await expectCliError(['scan'], 1) - - expectOutput(result).stderrContains('Missing required').exitCode(1) - }) -}) -``` - -## Best Practices - -### 1. Use Auto-Cleanup - -Always use `withTestWorkspace` or explicit cleanup: - -```typescript -// Good: Auto-cleanup -await withTestWorkspace(config, async workspace => { - // test code -}) - -// Good: Explicit cleanup -const workspace = await createTestWorkspace(config) -try { - // test code -} finally { - await workspace.cleanup() -} -``` - -### 2. Isolate Configuration - -Always use `isolateConfig: true` (default) to prevent user config pollution: - -```typescript -// Good: Isolated (default) -await executeCliCommand(['scan']) - -// Good: Explicit isolation -await executeCliCommand(['scan'], { isolateConfig: true }) - -// Risky: Uses user's config -await executeCliCommand(['scan'], { isolateConfig: false }) -``` - -### 3. Use Fluent Assertions - -Chain assertions for readability: - -```typescript -// Good: Fluent and readable -expectOutput(result) - .succeeded() - .stdoutContains('express') - .stdoutContains('lodash') - .stderrEmpty() - -// Bad: Verbose -expect(result.status).toBe(true) -expect(result.stdout).toContain('express') -expect(result.stdout).toContain('lodash') -expect(result.stderr).toBe('') -``` - -### 4. Type Your Results - -Use generics for type safety: - -```typescript -// Good: Type-safe -const { data } = await executeCliJson<ScanResult>(['scan']) -expect(data.id).toBeDefined() // TypeScript knows about 'id' - -// Bad: No type safety -const { data } = await executeCliJson(['scan']) -expect((data as any).id).toBeDefined() // Manual casting -``` - -### 5. Use Descriptive Test Names - -```typescript -// Good: Clear what's being tested -it('should display usage information with --help flag', async () => { - // test -}) - -// Bad: Vague -it('should work', async () => { - // test -}) -``` - -## Testing the Helpers - -Run the example tests to verify helpers work correctly: - -```bash -pnpm run test test/helpers/example-usage.test.mts -``` - -## Migration Guide - -See [examples.md](./examples.md) for detailed migration examples and before/after comparisons. - -## API Documentation - -Full API documentation with examples is available in: - -- [examples.md](./examples.md) - Comprehensive usage examples -- Individual module files - JSDoc documentation - -## Contributing - -When adding new helpers: - -1. **Follow existing patterns** - Use fluent APIs and type safety -2. **Add JSDoc comments** - Document parameters and return types -3. **Include examples** - Show usage in JSDoc -4. **Write tests** - Add to `example-usage.test.mts` -5. **Update examples.md** - Add comprehensive examples -6. **Export from index** - Add to `index.mts` - -## License - -Same as Socket CLI (MIT) - -## Support - -For issues or questions: - -- Check [examples.md](./examples.md) for usage patterns -- Review existing tests in `test/` directory -- Open an issue in the Socket CLI repository diff --git a/packages/cli/test/helpers/cli-execution.mts b/packages/cli/test/helpers/cli-execution.mts deleted file mode 100644 index 9e55dec13..000000000 --- a/packages/cli/test/helpers/cli-execution.mts +++ /dev/null @@ -1,402 +0,0 @@ -/* max-file-lines: legitimate — comprehensive CLI execution test harness; splitting would scatter tightly coupled spawn / assertion / sandbox helpers. */ -/** - * @file CLI execution test helpers for Socket CLI. Provides high-level - * utilities for executing CLI commands with comprehensive output validation - * and assertion capabilities. - */ - -import { mkdtempSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -import { constants } from '../../src/constants.mts' -import { spawnSocketCli } from '../utils.mts' - -import type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' - -/** - * Result from CLI execution with enhanced utilities. - */ -interface CliExecutionResult { - /** - * Exit code from the CLI command. - */ - code: number - /** - * Whether the command succeeded (code === 0) - */ - status: boolean - /** - * Cleaned stdout output. - */ - stdout: string - /** - * Cleaned stderr output. - */ - stderr: string - /** - * Combined stdout and stderr. - */ - output: string - /** - * Error details if command failed. - */ - error?: - | { - message: string - stack: string - } - | undefined -} - -/** - * Options for CLI execution. - */ -interface CliExecutionOptions extends SpawnOptions { - /** - * Whether to automatically add --config {} to isolate from user config - * (default: true) - */ - isolateConfig?: boolean | undefined - /** - * Custom config object to pass with --config flag. - */ - config?: Record<string, unknown> | undefined - /** - * Expect the command to fail with specific exit code. - */ - expectedExitCode?: number | undefined - /** - * Timeout in milliseconds (default: 30000) - */ - timeout?: number | undefined -} - -/** - * Execute Socket CLI command with enhanced result handling. - * - * @example - * ```typescript - * const result = await executeCliCommand(['scan', '--json'], { - * isolateConfig: true, - * }) - * expect(result.status).toBe(true) - * expect(result.stdout).toContain('scan-id') - * ``` - * - * @param args - Command arguments to pass to Socket CLI. - * @param options - Execution options. - * - * @returns Enhanced CLI execution result - */ -export async function executeCliCommand( - args: string[], - options?: CliExecutionOptions | undefined, -): Promise<CliExecutionResult> { - const { - config, - expectedExitCode, - isolateConfig = true, - timeout = 30_000, - ...spawnOptions - } = { - __proto__: null, - ...options, - } as CliExecutionOptions - - const binCliPath = constants.getBinCliPath() - const finalArgs = [...args] - - // Add config isolation if requested - if (isolateConfig && !args.includes('--config')) { - if (config) { - finalArgs.push('--config', JSON.stringify(config)) - } else { - finalArgs.push('--config', '{}') - } - } - - const result = await spawnSocketCli(binCliPath, finalArgs, { - timeout, - ...spawnOptions, - }) - - // Check expected exit code if provided - if (expectedExitCode !== undefined && result.code !== expectedExitCode) { - throw new Error( - `Expected exit code ${expectedExitCode} but got ${result.code}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ) - } - - return { - code: result.code, - ...(result.error && { error: result.error }), - output: `${result.stdout}\n${result.stderr}`.trim(), - status: result.status, - stderr: result.stderr, - stdout: result.stdout, - } -} - -/** - * Shape of the Socket CLI's `--json` response contract. Mirrors the - * `validate_json` shell helper that was in `test/smoke.sh` so e2e tests can - * assert the contract programmatically. - * - * The contract: - * - `ok: true` payloads MUST include a non-null `data` field. `message` is - * optional, `cause`/`code` are absent. - * - `ok: false` payloads MUST include a non-empty `message` string. `data` is - * optional; `cause`/`code` are optional but, when `code` is present, it - * must be a number. - */ -interface SocketJsonOk<T = unknown> { - ok: true - data: T - message?: string | undefined -} -interface SocketJsonErr { - ok: false - data?: unknown | undefined - message: string - cause?: string | undefined - code?: number | undefined -} -type SocketJsonContract<T = unknown> = SocketJsonOk<T> | SocketJsonErr - -/** - * Validate that `stdout` is JSON matching the Socket CLI's `--json` contract, - * given the `expectedExitCode` the command actually returned. Returns the - * parsed payload on success; throws with a diagnostic message on contract - * violation. - * - * The contract being asserted is the same one `test/smoke.sh::validate_json` - * enforced before being ported to TypeScript. - * - * @example - * const result = await executeCliCommand(['scan', 'list', '--json']) - * const payload = validateSocketJsonContract(result.stdout, 0) - * expect(payload.ok).toBe(true) - */ -export function validateSocketJsonContract<T = unknown>( - stdout: string, - expectedExitCode: number, -): SocketJsonContract<T> { - let parsed: unknown - try { - parsed = JSON.parse(stdout) as unknown - } catch (e) { - throw new Error( - `Socket JSON contract violation: command output is not valid JSON (${(e as Error).message}); stdout may contain progress text mixed with the payload.\nstdout: ${stdout}`, - ) - } - if (!parsed || typeof parsed !== 'object') { - throw new Error(`Socket JSON contract violation: payload is not an object.\nstdout: ${stdout}`) - } - const obj = parsed as Record<string, unknown> - const ok = obj['ok'] - if (typeof ok !== 'boolean') { - throw new Error(`Socket JSON contract violation: "ok" must be a boolean (got ${typeof ok}).\nstdout: ${stdout}`) - } - if (expectedExitCode === 0 && ok !== true) { - throw new Error(`Socket JSON contract violation: exit code 0 but "ok" is ${ok} (expected true).\nstdout: ${stdout}`) - } - if (expectedExitCode !== 0 && ok !== false) { - throw new Error(`Socket JSON contract violation: exit code ${expectedExitCode} but "ok" is ${ok} (expected false).\nstdout: ${stdout}`) - } - if (ok === true && (obj['data'] === undefined || obj['data'] === null)) { - throw new Error(`Socket JSON contract violation: ok:true must include a non-null "data" field (return an empty object/array if no payload).\nstdout: ${stdout}`) - } - if (ok === false) { - const message = obj['message'] - if (typeof message !== 'string' || message.length === 0) { - throw new Error(`Socket JSON contract violation: ok:false must include a non-empty "message" string.\nstdout: ${stdout}`) - } - } - if (obj['code'] !== undefined && typeof obj['code'] !== 'number') { - throw new Error(`Socket JSON contract violation: "code" must be a number when present (got ${typeof obj['code']}).\nstdout: ${stdout}`) - } - return obj as unknown as SocketJsonContract<T> -} - -/** - * Options for {@link executeCliInScratch}. - */ -interface CliInScratchOptions extends CliExecutionOptions { - /** - * Files to seed into the scratch cwd before running. Keyed by relative path; - * each value is the file body written verbatim. Use for fixtures the - * command-under-test needs to read. - */ - seedFiles?: Record<string, string> | undefined -} - -/** - * Execute Socket CLI inside a fully isolated scratch directory. Pins - * **everything** the CLI or its spawned subprocesses might read or write - * outside of cwd into the scratch tree, so an e2e run never touches the - * developer's system: - * - * - `cwd` → fresh `os.os.tmpdir()/socket-e2e-<n>/` - * - `HOME` / `USERPROFILE` → fresh `os.os.tmpdir()/socket-e2e-home-<n>/` - * - `XDG_CONFIG_HOME` → `<scratchHome>/.config` - * - `XDG_CACHE_HOME` → `<scratchHome>/.cache` - * - `XDG_DATA_HOME` → `<scratchHome>/.local/share` - * - `XDG_STATE_HOME` → `<scratchHome>/.local/state` - * - `NPM_CONFIG_CACHE` / `npm_config_cache` → `<scratchHome>/.npm` - * - `NPM_CONFIG_PREFIX` / `npm_config_prefix` → `<scratchHome>/.npm-global` - * - `NPM_CONFIG_USERCONFIG` / `npm_config_userconfig` → `<scratchHome>/.npmrc` - * - `PNPM_HOME` → `<scratchHome>/.pnpm` - * - `YARN_CACHE_FOLDER` → `<scratchHome>/.yarn-cache` - * - `PIP_CACHE_DIR` → `<scratchHome>/.pip-cache` - * - `CARGO_HOME` → `<scratchHome>/.cargo` - * - `GRADLE_USER_HOME` → `<scratchHome>/.gradle` - * - * Anything not pinned by the helper (the developer's `SOCKET_API_KEY` env, - * the real OS keychain for credentials) is **read-only** from the CLI's - * perspective — the CLI may read the token but the scratch HOME ensures - * it can't persist a new one back into the dev's config. - * - * Cleans up the scratch trees via `safeDelete()` even on failure. - * - * @example - * const result = await executeCliInScratch(['scan', 'create', '.'], { - * seedFiles: { 'package.json': '{"name":"test","version":"0.0.0"}' }, - * }) - * expect(result.code).toBe(0) - */ -export async function executeCliInScratch( - args: string[], - options?: CliInScratchOptions | undefined, -): Promise<CliExecutionResult> { - const { seedFiles, env: callerEnv, cwd: callerCwd, ...rest } = { - __proto__: null, - ...options, - } as CliInScratchOptions - - const scratchCwd = mkdtempSync(path.join(os.tmpdir(), 'socket-e2e-')) - const scratchHome = mkdtempSync(path.join(os.tmpdir(), 'socket-e2e-home-')) - try { - if (seedFiles) { - const { writeFileSync, mkdirSync } = await import('node:fs') - for (const [relPath, body] of Object.entries(seedFiles)) { - const full = path.join(scratchCwd, relPath) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, body) - } - } - return await executeCliCommand(args, { - ...rest, - cwd: callerCwd ?? scratchCwd, - env: { - ...process.env, - // Home dir pins. - HOME: scratchHome, - USERPROFILE: scratchHome, - // XDG base-directory spec. - XDG_CONFIG_HOME: path.join(scratchHome, '.config'), - // oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- per-test scratch HOME isolation: the cache must sit under the sandboxed HOME, not the repo root, so tests don't write to the real ~/.cache. - XDG_CACHE_HOME: path.join(scratchHome, '.cache'), - XDG_DATA_HOME: path.join(scratchHome, '.local', 'share'), - XDG_STATE_HOME: path.join(scratchHome, '.local', 'state'), - // npm / npx pins. Both the lowercase `npm_config_*` and uppercase - // `NPM_CONFIG_*` forms are honored by npm; set both so neither - // wins from process.env spillover. - npm_config_cache: path.join(scratchHome, '.npm'), - NPM_CONFIG_CACHE: path.join(scratchHome, '.npm'), - npm_config_prefix: path.join(scratchHome, '.npm-global'), - NPM_CONFIG_PREFIX: path.join(scratchHome, '.npm-global'), - npm_config_userconfig: path.join(scratchHome, '.npmrc'), - NPM_CONFIG_USERCONFIG: path.join(scratchHome, '.npmrc'), - // Sibling package managers. - PNPM_HOME: path.join(scratchHome, '.pnpm'), - YARN_CACHE_FOLDER: path.join(scratchHome, '.yarn-cache'), - // Non-JS toolchains the manifest generators may invoke. - PIP_CACHE_DIR: path.join(scratchHome, '.pip-cache'), - CARGO_HOME: path.join(scratchHome, '.cargo'), - GRADLE_USER_HOME: path.join(scratchHome, '.gradle'), - // Caller-supplied env wins. - ...callerEnv, - }, - }) - } finally { - await safeDelete(scratchCwd) - await safeDelete(scratchHome) - } -} - -/** - * Run a block with `HOME` / `USERPROFILE` swapped to a fresh scratch tmpdir - * for the duration of `fn`. Restores the original env on exit and - * `safeDelete()`s the scratch tree. - * - * Use this when an e2e test calls socket-cli internals directly (in-process) - * — e.g. `spawnDlx()` — rather than spawning the CLI binary. The - * `executeCliInScratch` helper covers the spawn-the-binary path; this is the - * sibling for the in-process path. - * - * Concurrency note: vitest runs tests within a single file serially by - * default. Each worker has its own Node process so env mutation here doesn't - * race against other test files. Don't use this in a file that opts into - * `it.concurrent`. - * - * @example - * await withScratchHome(async () => { - * const result = await spawnDlx({ name: 'cowsay', version: '1.6.0' }, ['moo']) - * expect((await result.spawnPromise).code).toBe(0) - * }) - */ -export async function withScratchHome<T>(fn: () => Promise<T>): Promise<T> { - const scratchHome = mkdtempSync(path.join(os.tmpdir(), 'socket-e2e-home-')) - const prevHome = process.env['HOME'] - const prevUserProfile = process.env['USERPROFILE'] - const prevXdgConfigHome = process.env['XDG_CONFIG_HOME'] - const prevXdgCacheHome = process.env['XDG_CACHE_HOME'] - const prevXdgDataHome = process.env['XDG_DATA_HOME'] - const prevXdgStateHome = process.env['XDG_STATE_HOME'] - const prevNpmCache = process.env['npm_config_cache'] - const prevNpmCacheUpper = process.env['NPM_CONFIG_CACHE'] - const prevNpmPrefix = process.env['npm_config_prefix'] - const prevNpmPrefixUpper = process.env['NPM_CONFIG_PREFIX'] - const prevPnpmHome = process.env['PNPM_HOME'] - const prevYarnCache = process.env['YARN_CACHE_FOLDER'] - try { - process.env['HOME'] = scratchHome - process.env['USERPROFILE'] = scratchHome - process.env['XDG_CONFIG_HOME'] = path.join(scratchHome, '.config') - // oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- per-test scratch HOME isolation: the cache must sit under the sandboxed HOME, not the repo root, so tests don't write to the real ~/.cache. - process.env['XDG_CACHE_HOME'] = path.join(scratchHome, '.cache') - process.env['XDG_DATA_HOME'] = path.join(scratchHome, '.local', 'share') - process.env['XDG_STATE_HOME'] = path.join(scratchHome, '.local', 'state') - process.env['npm_config_cache'] = path.join(scratchHome, '.npm') - process.env['NPM_CONFIG_CACHE'] = path.join(scratchHome, '.npm') - process.env['npm_config_prefix'] = path.join(scratchHome, '.npm-global') - process.env['NPM_CONFIG_PREFIX'] = path.join(scratchHome, '.npm-global') - process.env['PNPM_HOME'] = path.join(scratchHome, '.pnpm') - process.env['YARN_CACHE_FOLDER'] = path.join(scratchHome, '.yarn-cache') - return await fn() - } finally { - const restore = (key: string, value: string | undefined): void => { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - restore('HOME', prevHome) - restore('USERPROFILE', prevUserProfile) - restore('XDG_CONFIG_HOME', prevXdgConfigHome) - restore('XDG_CACHE_HOME', prevXdgCacheHome) - restore('XDG_DATA_HOME', prevXdgDataHome) - restore('XDG_STATE_HOME', prevXdgStateHome) - restore('npm_config_cache', prevNpmCache) - restore('NPM_CONFIG_CACHE', prevNpmCacheUpper) - restore('npm_config_prefix', prevNpmPrefix) - restore('NPM_CONFIG_PREFIX', prevNpmPrefixUpper) - restore('PNPM_HOME', prevPnpmHome) - restore('YARN_CACHE_FOLDER', prevYarnCache) - await safeDelete(scratchHome) - } -} diff --git a/packages/cli/test/helpers/environment.mts b/packages/cli/test/helpers/environment.mts deleted file mode 100644 index 9da2b2f27..000000000 --- a/packages/cli/test/helpers/environment.mts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file Test environment setup utilities for Socket CLI. Provides consistent - * test environment configuration including mock clearing and process state - * management. - */ - -import { beforeEach, vi } from 'vitest' - -/** - * Clear all mocks manually. - */ -export function clearAllMocks(): void { - vi.clearAllMocks() -} - -/** - * Setup standard test environment with beforeEach hook Clears all mocks and - * resets process.exitCode. - */ -export function setupTestEnvironment(): void { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) -} diff --git a/packages/cli/test/helpers/generate-report-test-helpers.mts b/packages/cli/test/helpers/generate-report-test-helpers.mts deleted file mode 100644 index 6640e73d8..000000000 --- a/packages/cli/test/helpers/generate-report-test-helpers.mts +++ /dev/null @@ -1,187 +0,0 @@ -import type { SocketArtifact } from '../../src/util/alert/artifact.mts' - -/** - * Helper function to create a scan with environment variable alerts. - */ -export function getScanWithEnvVars(): SocketArtifact[] { - return [ - { - id: '12521', - author: ['typescript-bot'], - size: 33965, - type: 'npm', - name: 'tslib', - version: '1.14.1', - license: '0BSD', - licenseDetails: [], - score: { - license: 1, - maintenance: 0.86, - overall: 0.86, - quality: 1, - supplyChain: 1, - vulnerability: 1, - }, - alerts: [ - { - type: 'envVars', - key: 'package/which.js', - start: 54, - end: 72, - props: { - // Test data. - envVars: 'XYZ', - }, - }, - { - type: 'envVars', - key: 'package/which.js', - start: 200, - end: 250, - props: { - // Test data. - envVars: 'ABC', - }, - }, - ], - manifestFiles: [ - { - file: 'package-lock.json', - start: 600172, - end: 600440, - }, - ], - topLevelAncestors: ['15903631404'], - }, - ] -} - -/** - * Helper function to create a scan with multiple packages and alerts for - * testing folding. - */ -export function getScanWithMultiplePackages(): SocketArtifact[] { - return [ - { - id: '12521', - author: ['typescript-bot'], - size: 33965, - type: 'npm', - name: 'tslib', - version: '1.14.1', - license: '0BSD', - licenseDetails: [], - score: { - license: 1, - maintenance: 0.86, - overall: 0.86, - quality: 1, - supplyChain: 1, - vulnerability: 1, - }, - alerts: [ - { - type: 'envVars', - key: 'package/which.js', - start: 54, - end: 72, - props: { - // Test data. - envVars: 'XYZ', - }, - }, - { - type: 'envVars', - key: 'package/which.js', - start: 200, - end: 250, - props: { - // Test data. - envVars: 'ABC', - }, - }, - ], - manifestFiles: [ - { - file: 'package-lock.json', - start: 600172, - end: 600440, - }, - ], - topLevelAncestors: ['15903631404'], - }, - { - id: '12345', - author: ['lodash-team'], - size: 1400000, - type: 'npm', - name: 'lodash', - version: '4.17.21', - license: 'MIT', - licenseDetails: [], - score: { - license: 1, - maintenance: 0.98, - overall: 0.95, - quality: 1, - supplyChain: 0.95, - vulnerability: 0.95, - }, - alerts: [ - { - type: 'envVars', - key: 'lodash.js', - start: 100, - end: 120, - props: { - // Test data. - envVars: 'SECRET_KEY', - }, - }, - ], - manifestFiles: [ - { - file: 'package-lock.json', - start: 700000, - end: 700500, - }, - ], - topLevelAncestors: ['15903631405'], - }, - ] -} - -/** - * Helper function to create a simple clean scan with no security issues. - */ -export function getSimpleCleanScan(): SocketArtifact[] { - return [ - { - id: '12521', - author: ['typescript-bot'], - size: 33965, - type: 'npm', - name: 'tslib', - version: '1.14.1', - license: '0BSD', - licenseDetails: [], - score: { - license: 1, - maintenance: 0.86, - overall: 0.86, - quality: 1, - supplyChain: 1, - vulnerability: 1, - }, - alerts: [], - manifestFiles: [ - { - file: 'package-lock.json', - start: 600172, - end: 600440, - }, - ], - topLevelAncestors: ['15903631404'], - }, - ] -} diff --git a/packages/cli/test/helpers/handle-test-helpers.mts b/packages/cli/test/helpers/handle-test-helpers.mts deleted file mode 100644 index a0919ef22..000000000 --- a/packages/cli/test/helpers/handle-test-helpers.mts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @file Handle test helpers for Socket CLI. Note: Due to Vitest's hoisting - * mechanism, vi.mock() calls must be at the module top level and cannot be - * abstracted into helper functions. For handle function tests, use the - * standard pattern directly: - * - * @example - * ```typescript - * import { describe, expect, it, vi } from 'vitest' - * - * // Setup mocks at module level (before describe) - * vi.mock('./fetch-quota.mts', () => ({ - * fetchQuota: vi.fn(), - * })) - * vi.mock('./output-quota.mts', () => ({ - * outputQuota: vi.fn(), - * })) - * - * describe('handleQuota', () => { - * it('fetches and outputs quota', async () => { - * const { fetchQuota } = await import('./fetch-quota.mts') - * const { outputQuota } = await import('./output-quota.mts') - * const mockFetch = vi.mocked(fetchQuota) - * const mockOutput = vi.mocked(outputQuota) - * - * mockFetch.mockResolvedValue({ quota: 100 }) - * mockOutput.mockResolvedValue() - * - * await handleQuota('json') - * - * expect(mockFetch).toHaveBeenCalled() - * expect(mockOutput).toHaveBeenCalledWith({ quota: 100 }, 'json') - * }) - * }) - * ``` - */ diff --git a/packages/cli/test/helpers/index.mts b/packages/cli/test/helpers/index.mts deleted file mode 100644 index 50e4b661f..000000000 --- a/packages/cli/test/helpers/index.mts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file Test utilities index. Re-exports all test utility functions for - * convenient importing. - */ - -export * from './cli-execution.mts' -export * from './environment.mts' -export * from './generate-report-test-helpers.mts' -export * from './handle-test-helpers.mts' -export * from './mock-setup.mts' -export * from './mocks.mts' -export * from './test-fixtures.mts' -export * from './workspace-helper.mts' diff --git a/packages/cli/test/helpers/mock-setup.mts b/packages/cli/test/helpers/mock-setup.mts deleted file mode 100644 index d1cb2ac97..000000000 --- a/packages/cli/test/helpers/mock-setup.mts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file Mock setup utilities for Socket CLI tests. IMPORTANT: Mock setup helper - * functions DO NOT WORK with Vitest. Vitest requires vi.mock() to be called - * at the top level of test files for proper hoisting. When vi.mock() is - * called from within a function, the mock declarations are not hoisted - * correctly, resulting in "No export defined on mock" errors. Instead of - * using helper functions, explicitly declare mocks at the top of each test - * file. - * - * @example - * Correct pattern for mocking - * ```typescript - * import { beforeEach, describe, expect, it, vi } from 'vitest' - * - * // Mock declarations MUST be at top level - * vi.mock('@socketsecurity/lib-stable/logger', () => ({ - * logger: { - * fail: vi.fn(), - * log: vi.fn(), - * }, - * })) - * - * vi.mock('../../util/socket/api.mjs', () => ({ - * queryApiSafeJson: vi.fn(), - * })) - * - * describe('myTest', () => { - * beforeEach(() => { - * vi.clearAllMocks() - * }) - * - * it('test name', async () => { - * // Use dynamic imports for function under test - * const { functionUnderTest } = await import('./module-under-test.mts') - * - * // Use vi.importMock for mocked dependencies - * const { logger } = await vi.importMock('@socketsecurity/lib-stable/logger') - * const { queryApiSafeJson } = await vi.importMock('../../util/socket/api.mjs') - * - * const mockLog = vi.mocked(getDefaultLogger().log) - * const mockQueryApi = vi.mocked(queryApiSafeJson) - * - * // ... test code - * }) - * }) - * ``` - * - * @see https://vitest.dev/api/vi.html#vi-mock - */ - -// This file intentionally left empty. -// All previous mock setup helper functions have been removed because they don't work with Vitest. -// See the file-level documentation above for the correct pattern. diff --git a/packages/cli/test/helpers/mocks.mts b/packages/cli/test/helpers/mocks.mts deleted file mode 100644 index c9208eb78..000000000 --- a/packages/cli/test/helpers/mocks.mts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @file Test mock helpers for Socket CLI. Provides utilities for mocking SDK, - * API, logger, and output functions consistently across test files. - */ - -import { vi } from 'vitest' - -import type { CResult } from '../../src/types.mts' -import type { SocketSdk } from '@socketsecurity/sdk-stable' - -/** - * Error options for creating error results. - */ -type ErrorOptions = { - code?: number | undefined - cause?: string | undefined -} - -/** - * Creates a failed CResult. - */ -export function createErrorResult( - message: string, - options?: ErrorOptions | undefined, -): CResult<never> { - const opts = { __proto__: null, ...options } as ErrorOptions - return { - ok: false, - message, - code: opts.code ?? 1, - cause: opts.cause, - } -} - -/** - * Creates mock logger functions. - */ -/** - * Creates a mock Socket SDK with common methods. - */ -export function createMockSdk(overrides: Partial<SocketSdk> = {}): SocketSdk { - // Tests substitute a vitest-mock-shaped object for the real SocketSdk; this - // is intentionally structural so command code under test sees a method to call. - return { - deleteOrgRepo: vi.fn(), - createOrgRepo: vi.fn(), - getOrgRepo: vi.fn(), - getOrgRepoList: vi.fn(), - updateOrgRepo: vi.fn(), - getQuota: vi.fn(), - getOrganizations: vi.fn(), - deleteOrgFullScan: vi.fn(), - getOrgFullScanList: vi.fn(), - getOrgFullScanMetadata: vi.fn(), - getSupportedScanFiles: vi.fn(), - getOrgAnalytics: vi.fn(), - getRepoAnalytics: vi.fn(), - batchPackageFetch: vi.fn(), - ...overrides, - } as unknown as SocketSdk -} - -/** - * Creates a successful CResult. - */ -export function createSuccessResult<T>(data: T): CResult<T> { - return { - ok: true, - data, - } -} - -/** - * Setup SDK setup failure mock. - */ -export async function setupSdkSetupFailure( - message: string, - cause?: string | undefined, -): Promise<void> { - const { setupSdk } = await import('../../src/util/socket/sdk.mts') - const options: ErrorOptions = cause !== undefined ? { cause } : {} - vi.mocked(setupSdk).mockResolvedValue(createErrorResult(message, options)) -} - diff --git a/packages/cli/test/helpers/sdk-test-helpers.mts b/packages/cli/test/helpers/sdk-test-helpers.mts deleted file mode 100644 index 2235ab5b8..000000000 --- a/packages/cli/test/helpers/sdk-test-helpers.mts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @file SDK test helpers for Socket CLI. Provides utilities for setting up SDK - * mocks with common success/error patterns. - */ - -import { vi } from 'vitest' - -import { - createErrorResult, - createMockSdk, - createSuccessResult, -} from './mocks.mts' - -import type * as ApiModule from '../../src/util/socket/api.mts' -import type * as SdkModule from '../../src/util/socket/sdk.mts' -import type { Mock } from 'vitest' - -/** - * Get the mocked handleApiCall function. This must be called after vi.mock() - * has been executed in the test file. - * - * @returns The mocked handleApiCall function - */ -async function getMockHandleApiCall(): Promise<Mock> { - const module = await vi.importMock<typeof ApiModule>( - '../../src/util/socket/api.mts', - ) - return vi.mocked(module.handleApiCall) -} - -/** - * Get the mocked setupSdk function. This must be called after vi.mock() has - * been executed in the test file. - * - * @returns The mocked setupSdk function - */ -async function getMockSetupSdk(): Promise<Mock> { - const module = await vi.importMock<typeof SdkModule>( - '../../src/util/socket/sdk.mts', - ) - return vi.mocked(module.setupSdk) -} - -/** - * Setup SDK mock for API call error. Note: Test files must call vi.mock() for - * the SDK modules before using this helper. - * - * @param sdkMethod - The SDK method to mock. - * @param error - Error message or Error object. - * @param code - HTTP status code (default: 404) - * - * @returns Object with mockSdk and mockHandleApi references - */ -export async function setupSdkMockError( - sdkMethod: string, - error: string | Error, - code = 404, -) { - const errorObj = typeof error === 'string' ? new Error(error) : error - const mockSdk = createMockSdk({ - [sdkMethod]: vi.fn().mockRejectedValue(errorObj), - }) - - const setupSdk = await getMockSetupSdk() - const handleApiCall = await getMockHandleApiCall() - - setupSdk.mockResolvedValue(createSuccessResult(mockSdk)) - handleApiCall.mockResolvedValue(createErrorResult(errorObj.message, { code })) - - return { - mockHandleApi: handleApiCall, - mockSdk, - } -} - -/** - * Setup SDK mock for successful API call. Note: Test files must call vi.mock() - * for the SDK modules before using this helper. - * - * @param sdkMethod - The SDK method to mock (e.g., 'getOrgQuotaOverview') - * @param mockData - The data to return in the success response. - * - * @returns Object with mockSdk, mockHandleApi, and mockSetupSdk references - */ -export async function setupSdkMockSuccess( - sdkMethod: string, - mockData: unknown, -) { - const mockSdk = createMockSdk({ - [sdkMethod]: vi.fn().mockResolvedValue({ success: true, data: mockData }), - }) - - const setupSdk = await getMockSetupSdk() - const handleApiCall = await getMockHandleApiCall() - - setupSdk.mockResolvedValue(createSuccessResult(mockSdk)) - handleApiCall.mockResolvedValue(createSuccessResult(mockData)) - - return { - mockHandleApi: handleApiCall, - mockSdk, - mockSetupSdk: setupSdk, - } -} - -/** - * Setup SDK setup failure (before API call). Note: Test files must call - * vi.mock() for the SDK modules before using this helper. - * - * @param message - Error message. - * @param options - Error options (code, cause) - */ -export async function setupSdkSetupFailure( - message: string, - options?: { code?: number | undefined; cause?: string | undefined }, -) { - const setupSdk = await getMockSetupSdk() - setupSdk.mockResolvedValue(createErrorResult(message, options)) -} - -/** - * Setup SDK mock for withSdk pattern. For tests using the withSdk utility - * instead of setupSdk. Note: Test files must call vi.mock() for the SDK modules - * before using this helper. - * - * @param callback - The function to execute with the SDK. - * @param mockSdkMethods - Object with SDK methods to mock. - * - * @returns Mock SDK object - */ -// c8 ignore start - Dead code: withSdk not yet implemented in sdk.mts. -// export async function setupWithSdkMock( -// _callback: (sdk: unknown) => any, -// mockSdkMethods: Record<string, unknown> = {}, -// ) { -// const mockSdk = createMockSdk(mockSdkMethods) -// const withSdk = await getMockWithSdk() - -// withSdk.mockImplementation(async cb => { -// return cb(mockSdk) -// }) - -// return mockSdk -// } -// c8 ignore stop diff --git a/packages/cli/test/helpers/test-fixtures.mts b/packages/cli/test/helpers/test-fixtures.mts deleted file mode 100644 index 5a4755236..000000000 --- a/packages/cli/test/helpers/test-fixtures.mts +++ /dev/null @@ -1,69 +0,0 @@ -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' - -/** - * Creates a temporary copy of a fixture directory for testing. The temporary - * directory is automatically cleaned up when tests complete. - * - * @param fixturePath - Path to the fixture directory to copy. - * @param cleanupHook - Optional function to register cleanup (e.g., afterEach). - * - * @returns Path to the temporary fixture copy. - */ -export async function createTempFixture( - fixturePath: string, - cleanupHook?: (cleanup: () => Promise<void>) => void, -): Promise<string> { - // Create a unique temporary directory. - const tempBaseDir = os.tmpdir() - const tempDirName = `socket-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}` - const tempDir = path.join(tempBaseDir, tempDirName) - - // Copy fixture to temp directory recursively. - await fs.cp(fixturePath, tempDir, { - recursive: true, - // Preserve file permissions and timestamps. - preserveTimestamps: true, - }) - - // Register cleanup if hook provided. - if (cleanupHook) { - cleanupHook(async () => { - try { - await safeDelete(tempDir) - } catch { - // Ignore cleanup errors in tests. - } - }) - } - - return tempDir -} - -/** - * Helper to create a temporary fixture with automatic cleanup in afterEach. - * Designed for use in test suites that use afterEach hooks. - * - * @param fixturePath - Path to the fixture directory. - * - * @returns Object with tempDir path and cleanup function. - */ -export async function withTempFixture(fixturePath: string): Promise<{ - tempDir: string - cleanup: () => Promise<void> -}> { - const tempDir = await createTempFixture(fixturePath) - - const cleanup = async () => { - try { - await safeDelete(tempDir) - } catch { - // Ignore cleanup errors. - } - } - - return { tempDir, cleanup } -} diff --git a/packages/cli/test/helpers/workspace-helper.mts b/packages/cli/test/helpers/workspace-helper.mts deleted file mode 100644 index 0f75f050a..000000000 --- a/packages/cli/test/helpers/workspace-helper.mts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * @file Workspace test helpers for Socket CLI. Provides utilities for creating - * and managing temporary test workspaces with package manifests, lockfiles, - * and source files. - */ - -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { safeDelete, safeMkdir } from '@socketsecurity/lib-stable/fs/safe' - -/** - * File content specification for workspace setup. - */ -interface WorkspaceFile { - /** - * File path relative to workspace root. - */ - path: string - /** - * File content (string or JSON-serializable object) - */ - content: string | Record<string, unknown> -} - -/** - * Package.json configuration. - */ -interface PackageJsonConfig { - /** - * Package name. - */ - name?: string | undefined - /** - * Package version. - */ - version?: string | undefined - /** - * Dependencies map. - */ - dependencies?: Record<string, string> | undefined - /** - * DevDependencies map. - */ - devDependencies?: Record<string, string> | undefined - /** - * Scripts map. - */ - scripts?: Record<string, string> | undefined - /** - * Additional package.json fields. - */ - [key: string]: unknown -} - -/** - * Workspace configuration. - */ -interface WorkspaceConfig { - /** - * Workspace files to create. - */ - files?: WorkspaceFile[] | undefined - /** - * Package.json configuration. - */ - packageJson?: PackageJsonConfig | undefined - /** - * Whether to initialize git repository (default: false) - */ - initGit?: boolean | undefined - /** - * Whether to create node_modules directory (default: false) - */ - createNodeModules?: boolean | undefined -} - -/** - * Workspace instance with cleanup capability. - */ -export interface Workspace { - /** - * Absolute path to workspace directory. - */ - path: string - /** - * Cleanup function to remove workspace. - */ - cleanup: () => Promise<void> - /** - * Write additional file to workspace. - */ - writeFile: (relativePath: string, content: string) => Promise<void> - /** - * Read file from workspace. - */ - readFile: (relativePath: string) => Promise<string> - /** - * Check if file exists in workspace. - */ - fileExists: (relativePath: string) => boolean - /** - * Get absolute path for relative path in workspace. - */ - resolve: (...segments: string[]) => string -} - -/** - * Create a temporary test workspace with specified files and configuration. - * - * @example - * ```typescript - * const workspace = await createTestWorkspace({ - * packageJson: { - * name: 'test-project', - * dependencies: { express: '^4.18.0' }, - * }, - * files: [{ path: 'index.js', content: 'console.log("hello")' }], - * }) - * - * // Use workspace - * const result = await executeCliCommand(['scan'], { cwd: workspace.path }) - * - * // Cleanup - * await workspace.cleanup() - * ``` - * - * @param config - Workspace configuration. - * - * @returns Workspace instance with cleanup - */ -export async function createTestWorkspace( - config?: WorkspaceConfig | undefined, -): Promise<Workspace> { - const { - createNodeModules = false, - files = [], - initGit = false, - packageJson, - } = { - __proto__: null, - ...config, - } as WorkspaceConfig - - // Create unique temporary directory - const tempBaseDir = os.tmpdir() - const tempDirName = `socket-cli-workspace-${Date.now()}-${Math.random().toString(36).slice(2)}` - const workspacePath = path.join(tempBaseDir, tempDirName) - - await safeMkdir(workspacePath, { recursive: true }) - - // Create package.json if specified - if (packageJson) { - const pkgContent = { - name: 'test-workspace', - version: '1.0.0', - ...packageJson, - } - await fs.writeFile( - path.join(workspacePath, 'package.json'), - JSON.stringify(pkgContent, null, 2), - 'utf8', - ) - } - - // Create specified files - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i]! - const filePath = path.join(workspacePath, file.path) - const fileDir = path.dirname(filePath) - await safeMkdir(fileDir, { recursive: true }) - - const content = - typeof file.content === 'string' - ? file.content - : JSON.stringify(file.content, null, 2) - - await fs.writeFile(filePath, content, 'utf8') - } - - // Create node_modules directory if requested - if (createNodeModules) { - await safeMkdir(path.join(workspacePath, 'node_modules'), { - recursive: true, - }) - } - - // Initialize git repository if requested - if (initGit) { - await safeMkdir(path.join(workspacePath, '.git'), { recursive: true }) - } - - // Create workspace instance - const workspace: Workspace = { - path: workspacePath, - - cleanup: async () => { - try { - await safeDelete(workspacePath) - } catch { - // Ignore cleanup errors. - } - }, - - fileExists: (relativePath: string) => { - return existsSync(path.join(workspacePath, relativePath)) - }, - - readFile: async (relativePath: string) => { - return fs.readFile(path.join(workspacePath, relativePath), 'utf8') - }, - - resolve: (...segments: string[]) => { - return path.join(workspacePath, ...segments) - }, - - writeFile: async (relativePath: string, content: string) => { - const filePath = path.join(workspacePath, relativePath) - const fileDir = path.dirname(filePath) - await safeMkdir(fileDir, { recursive: true }) - await fs.writeFile(filePath, content, 'utf8') - }, - } - - return workspace -} - diff --git a/packages/cli/test/integration/api/bundle-validation.test.mts b/packages/cli/test/integration/api/bundle-validation.test.mts deleted file mode 100644 index f3b02978e..000000000 --- a/packages/cli/test/integration/api/bundle-validation.test.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @file Bundle validation tests to ensure build output quality. Verifies that - * dist files don't contain absolute paths or unexpected bundled - * dependencies. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { describe, expect, it } from 'vitest' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packagePath = path.resolve(__dirname, '..', '..') -const buildPath = path.join(packagePath, 'build') - -/** - * Check if bundle contains inlined dependencies. Reads package.json - * dependencies and ensures they are NOT bundled inline. - */ -export async function checkBundledDependencies(content: string): Promise<{ - bundledDeps: string[] - hasNoBundledDeps: boolean -}> { - // Read package.json to get runtime dependencies. - const pkgJsonPath = path.join(packagePath, 'package.json') - const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) - const dependencies = pkgJson.dependencies || {} - - const bundledDeps: string[] = [] - - // If we have NO dependencies, check that no external packages are bundled. - if (Object.keys(dependencies).length === 0) { - // Look for signs of bundled npm packages. - // Bundled packages often have characteristic patterns like: - // - var xxx_exports = {}; - // - __toCommonJS(package_name_exports) - // - Multiple functions from same package bundled together. - const bundledPackagePatterns = [ - // Socket packages that should always be external. - /@socketsecurity\/registry/, - ] - - for (let i = 0, { length } = bundledPackagePatterns; i < length; i += 1) { - const pattern = bundledPackagePatterns[i] - // Check if package name appears in context that suggests bundling. - // Look for: var import_package = require("package") without the actual require call. - // This would indicate the package code is bundled inline. - const bundlePattern = new RegExp( - `var\\s+\\w+\\s*=\\s*__toCommonJS\\([^)]*${pattern.source}`, - ) - - if (bundlePattern.test(content)) { - bundledDeps.push(pattern.source) - } - } - } else { - // If we have dependencies, check that they remain external (not bundled). - for (const dep of Object.keys(dependencies)) { - const escapedDep = dep.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&') - // Check if dependency code is bundled by looking for __toCommonJS pattern. - const bundlePattern = new RegExp( - `var\\s+\\w+\\s*=\\s*__toCommonJS\\([^)]*${escapedDep}`, - ) - - if (bundlePattern.test(content)) { - bundledDeps.push(dep) - } - } - } - - return { - bundledDeps, - hasNoBundledDeps: bundledDeps.length === 0, - } -} - -/** - * Check if content contains absolute paths. Detects paths like /Users/, C:, - * /home/, etc. - */ -export function hasAbsolutePaths(content: string): { - hasIssue: boolean - matches: string[] -} { - // Match absolute paths but exclude URLs and node: protocol. - const patterns = [ - // Match require('/abs/path') or require('C:\\path'). - /require\(["'](?:[A-Z]:\\[^"'\n]+|\/[^"'\n]+)["']\)/g, - // Match import from '/abs/path'. - /import\s+.*?from\s+["'](?:[A-Z]:\\[^"'\n]+|\/[^"'\n]+)["']/g, - ] - - const matches: string[] = [] - for (let i = 0, { length } = patterns; i < length; i += 1) { - const pattern = patterns[i] - const found = content.match(pattern) - if (found) { - matches.push(...found) - } - } - - return { - hasIssue: matches.length > 0, - matches, - } -} - -describe('Bundle validation', () => { - it('should not contain absolute paths in build/cli.js', async () => { - const cliPath = path.join(buildPath, 'cli.js') - const content = await fs.readFile(cliPath, 'utf8') - - const result = hasAbsolutePaths(content) - - if (result.hasIssue) { - logger.fail('Found absolute paths in bundle:') - for (const match of result.matches) { - logger.fail(` - ${match}`) - } - } - - expect(result.hasIssue, 'Bundle should not contain absolute paths').toBe( - false, - ) - }) - - it('should not bundle dependencies inline (validate against package.json dependencies)', async () => { - const cliPath = path.join(buildPath, 'cli.js') - const content = await fs.readFile(cliPath, 'utf8') - - const result = await checkBundledDependencies(content) - - if (!result.hasNoBundledDeps) { - logger.fail('Found bundled dependencies (should be external):') - for (const dep of result.bundledDeps) { - logger.fail(` - ${dep}`) - } - } - - expect( - result.hasNoBundledDeps, - 'Dependencies from package.json should be external, not bundled inline', - ).toBe(true) - }) -}) diff --git a/packages/cli/test/integration/binary/critical-commands.test.mts b/packages/cli/test/integration/binary/critical-commands.test.mts deleted file mode 100644 index 5e8922184..000000000 --- a/packages/cli/test/integration/binary/critical-commands.test.mts +++ /dev/null @@ -1,99 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../../src/constants/env.mts' -import { getDefaultApiToken } from '../../../src/util/socket/sdk.mts' -import { executeCliCommand } from '../../helpers/cli-execution.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CLI_BIN_PATH = path.resolve(__dirname, '../../../dist/index.js') - -describe('Critical CLI Commands E2E', () => { - let hasAuth = false - - beforeAll(async () => { - // Check if running E2E tests and if Socket API token is available. - if (ENV.RUN_INTEGRATION_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken) { - logger.log() - logger.warn('E2E tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log(' 3. Skip E2E tests by not setting RUN_INTEGRATION_TESTS') - logger.log('') - logger.log('E2E tests will be skipped due to missing authentication.') - logger.log('') - } - } - }) - - describe('Basic commands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display version', - async () => { - const result = await executeCliCommand(['--version'], { - binPath: CLI_BIN_PATH, - isolateConfig: false, - }) - - // Note: --version currently shows help and exits with code 2 (known issue) - // This test validates the CLI executes without crashing - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)('should display help', async () => { - const result = await executeCliCommand(['--help'], { - binPath: CLI_BIN_PATH, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan command help', - async () => { - const result = await executeCliCommand(['scan', '--help'], { - binPath: CLI_BIN_PATH, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - }) - - describe('Auth-required commands', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'should list config settings', - async () => { - const result = await executeCliCommand(['config', 'list'], { - binPath: CLI_BIN_PATH, - }) - - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'should display whoami information', - async () => { - const result = await executeCliCommand(['whoami'], { - binPath: CLI_BIN_PATH, - }) - - expect(result.code).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/binary/dlx-spawn.test.mts b/packages/cli/test/integration/binary/dlx-spawn.test.mts deleted file mode 100644 index c8c59975e..000000000 --- a/packages/cli/test/integration/binary/dlx-spawn.test.mts +++ /dev/null @@ -1,300 +0,0 @@ -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ENV } from '../../../src/constants/env.mts' -import { spawnDlx } from '../../../src/util/dlx/spawn.mts' -import { findUp } from '../../../src/util/fs/find-up.mts' -import { getDefaultApiToken } from '../../../src/util/socket/sdk.mts' - -describe('dlx e2e tests', () => { - let hasAuth = false - - beforeAll(async () => { - // Check if running e2e tests and if Socket API token is available. - if (ENV.RUN_INTEGRATION_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken) { - logger.log() - logger.warn('E2E tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log(' 3. Skip e2e tests by not setting RUN_INTEGRATION_TESTS') - logger.log('') - logger.log('E2E tests will be skipped due to missing authentication.') - logger.log('') - } - } - }) - describe('pnpm exec regression test', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'successfully runs pnpm exec with cowsay (verifies no unsupported flags)', - async () => { - // Check if we're in a pnpm project. - const pnpmLock = await findUp('pnpm-lock.yaml') - if (!pnpmLock) { - logger.log('Skipping test - not in a pnpm project') - return - } - - // Use cowsay as a safe, pinned package for testing. - const packageSpec = { - name: 'cowsay', - version: '1.6.0', // Pinned version for consistency. - } - - // Run cowsay with a test message. - const result = await spawnDlx(packageSpec, [ - 'Hello from Socket CLI tests!', - ]) - - // Verify it succeeded. - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBe(0) - if (spawnResult.stdout) { - // Cowsay should output our message in a speech bubble. - expect(spawnResult.stdout).toContain('Hello from Socket CLI tests!') - // Should have the cow ASCII art. - expect(spawnResult.stdout).toMatch(/\\\s+/) - expect(spawnResult.stdout).toMatch(/\^__\^/) - } - }, - 30000, // 30 second timeout for download. - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'verifies pnpm exec command construction uses only supported flags', - async () => { - // This test verifies by checking what command would be run. - const pnpmLock = await findUp('pnpm-lock.yaml') - if (!pnpmLock) { - logger.log('Skipping test - not in a pnpm project') - return - } - - // We can't easily intercept the actual spawn call in e2e, - // but we can verify the command that would be constructed - // by checking our unit tests pass and the actual execution works. - - // Try to run a simple pnpm dlx command directly to ensure it works. - try { - const r = spawnSync( - 'pnpm', - ['exec', 'cowsay@1.6.0', 'Direct test'], - { stdio: 'pipe', stdioString: true }, - ) - if (r.status !== 0) { - throw new Error(String(r.stderr ?? r.stdout ?? '')) - } - expect(String(r.stdout)).toContain('Direct test') - - // Verify that adding unsupported flags would fail. - // For example, --ignore-scripts is only for pnpm install, not dlx. - expect(() => { - const r2 = spawnSync( - 'pnpm', - ['exec', '--ignore-scripts', 'cowsay@1.6.0', 'Should fail'], - { stdio: 'pipe', stdioString: true }, - ) - if (r2.status !== 0) { - throw new Error(String(r2.stderr ?? r2.stdout ?? '')) - } - }).toThrow() - } catch (e) { - // If pnpm is not available globally, skip this part. - logger.log('Could not run direct pnpm test:', e.message) - } - }, - 15000, - ) - }) - - describe('npm pnpm exec regression test', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'successfully runs npm/pnpm exec with cowsay', - async () => { - // Force npm by not finding any pnpm/yarn lockfiles. - const npmLock = await findUp('package-lock.json') - const pnpmLock = await findUp('pnpm-lock.yaml') - const yarnLock = await findUp('yarn.lock') - - // Skip if we're in a pnpm/yarn project to ensure npm is used. - if (pnpmLock || yarnLock) { - logger.log('Skipping npm test - in pnpm/yarn project') - return - } - - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - // Force npm agent. - const result = await spawnDlx(packageSpec, ['Moo from npm!'], { - agent: 'npm', - }) - - expect(result.ok).toBe(true) - if (result.ok && result.data) { - expect(result.data).toContain('Moo from npm!') - } - }, - 30000, - ) - }) - - describe('spawnCoanaDlx e2e tests', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'executes @coana-tech/cli via dlx with correct binary name', - async () => { - const { spawnCoanaDlx } = await import('../../../src/util/dlx/spawn.mts') - const result = await spawnCoanaDlx(['--help']) - - // Coana should succeed - if it fails, it indicates a real issue. - expect(result).toBeDefined() - expect(result.ok).toBe(true) - - if (result.ok && result.data) { - // Verify we got output from coana binary. - expect(result.data).toContain('coana') - } else { - // If coana fails, the test should fail to catch real issues. - throw new Error(`Coana execution failed: ${result.message}`) - } - }, - 30000, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'verifies coana binary is correctly resolved from package name', - async () => { - const { spawnCoanaDlx } = await import('../../../src/util/dlx/spawn.mts') - const { resolveCoana } = - await import('../../../src/util/dlx/resolve-binary.mts') - - // Verify the resolution includes correct binary name. - const resolution = resolveCoana() - if (resolution.type === 'dlx') { - expect(resolution.details.name).toBe('@coana-tech/cli') - expect(resolution.details.binaryName).toBe('coana') - } - - // Verify execution works with resolved binary name. - const result = await spawnCoanaDlx(['--version']) - - expect(result).toBeDefined() - expect(result.ok).toBe(true) - - if (result.ok && result.data) { - // Version output should contain coana information. - expect(result.data).toBeTruthy() - } - }, - 30000, - ) - }) - - describe('spawnSynpDlx e2e tests', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'executes synp via dlx', - async () => { - const { spawnSynpDlx } = await import('./spawn.mts') - const result = await spawnSynpDlx(['--help']) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBe(0) - if (spawnResult.stdout) { - expect(spawnResult.stdout).toContain('synp') - } - }, - 30000, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'handles error from spawn', - async () => { - const { spawnSynpDlx } = await import('./spawn.mts') - // Pass invalid args to trigger an error. - const result = await spawnSynpDlx([ - '--invalid-flag-that-does-not-exist', - ]) - - // The command should fail with invalid flags. - // Just verify we get a result with spawnPromise. - expect(result).toBeDefined() - expect(result.spawnPromise).toBeDefined() - - // The spawnPromise may throw or return with non-zero exit code - try { - const spawnResult = await result.spawnPromise - expect(spawnResult.code).toBeGreaterThan(0) // Should fail with non-zero exit code - } catch (e) { - // Command failed as expected - this is valid behavior - expect(error).toBeDefined() - } - }, - 30000, - ) - }) - - describe('spawnDlx e2e tests', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'executes dlx command with package spec', - async () => { - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - const result = await spawnDlx(packageSpec, ['--help']) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }, - 30000, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'handles force flag in options', - async () => { - const packageSpec = { - name: 'cowsay', - version: '1.6.0', - } - - const result = await spawnDlx(packageSpec, ['Test with force'], { - force: true, - }) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }, - 30000, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS || !hasAuth)( - 'handles silent flag in options', - async () => { - const packageSpec = { - name: 'cowsay', - version: '^1.6.0', // Range version should trigger silent. - } - - const result = await spawnDlx(packageSpec, ['Silent test'], { - silent: true, - }) - - expect(result.spawnPromise).toBeDefined() - const spawnResult = await result.spawnPromise - expect(spawnResult).toBeDefined() - }, - 30000, - ) - }) -}) diff --git a/packages/cli/test/integration/binary/helpers.mts b/packages/cli/test/integration/binary/helpers.mts deleted file mode 100644 index d5036dc3f..000000000 --- a/packages/cli/test/integration/binary/helpers.mts +++ /dev/null @@ -1,128 +0,0 @@ - -/** - * @file Shared helpers for binary integration tests. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { confirm } from '@socketsecurity/lib-stable/stdio/prompts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -export const ROOT_DIR = path.resolve(__dirname, '../../..') -export const MONOREPO_ROOT = path.resolve(ROOT_DIR, '../..') - -export const logger = getDefaultLogger() - -export interface BinaryConfig { - name: string - path: string - buildCommand: string[] | null - enabled: boolean -} - -/** - * Build a binary if needed. - */ -export async function buildBinary( - binary: BinaryConfig, - binaryType: string, -): Promise<boolean> { - if (!binary.buildCommand) { - return false - } - - logger.log(`Building ${binary.name}...`) - logger.log(`Running: ${binary.buildCommand.join(' ')}`) - - if (binaryType === 'smol') { - logger.log('Note: smol build may take 30-60 minutes on first build') - logger.log(' (subsequent builds are faster with caching)') - } - logger.log('') - - try { - const result = await spawn( - binary.buildCommand[0], - binary.buildCommand.slice(1), - { - cwd: MONOREPO_ROOT, - stdio: 'inherit', - }, - ) - - if (result.code !== 0) { - logger.error(`Failed to build ${binary.name}`) - return false - } - - logger.log(`Successfully built ${binary.name}`) - return true - } catch (e) { - logger.error(`Error building ${binary.name}:`, e) - return false - } -} - -/** - * Check and prepare binary for testing. - */ -export async function prepareBinary( - binary: BinaryConfig, - binaryType: string, -): Promise<boolean> { - // Check if binary exists. - let binaryExists = existsSync(binary.path) - - if (!binaryExists) { - // In CI: Skip building (rely on cache). - if (process.env.CI) { - logger.log(`⊘ ${binary.name} (not cached)`) - if (binaryType === 'sea') { - logger.log(' To build: gh workflow run build-sea.yml') - } else if (binaryType === 'smol') { - logger.log(' To build: gh workflow run build-smol.yml') - } - return false - } - - // Locally: Prompt user to build. - logger.log(`⊘ ${binary.name} (not found)`) - const timeWarning = binaryType === 'smol' ? ' (may take 30-60 min)' : '' - const shouldBuild = await confirm({ - default: true, - message: `Build ${binary.name}?${timeWarning}`, - }) - - if (!shouldBuild) { - logger.log(' Skipping tests') - return false - } - - logger.log(' Building...') - const buildSuccess = await buildBinary(binary, binaryType) - - if (buildSuccess) { - binaryExists = existsSync(binary.path) - } - - if (!binaryExists) { - logger.fail('Build failed') - logger.log( - ` To build manually: ${binary.buildCommand?.join(' ') ?? 'N/A'}`, - ) - return false - } - - logger.success('Build complete') - } else { - // Binary exists. - logger.log(`✓ ${binary.name}`) - } - - return true -} diff --git a/packages/cli/test/integration/binary/js.test.mts b/packages/cli/test/integration/binary/js.test.mts deleted file mode 100644 index 1e27022d8..000000000 --- a/packages/cli/test/integration/binary/js.test.mts +++ /dev/null @@ -1,1850 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * @file Integration tests for JS Distribution binary. - */ - -import path from 'node:path' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ROOT_DIR, logger, prepareBinary } from './helpers.mts' -import type { BinaryConfig } from './helpers.mts' -import { ENV } from '../../../src/constants/env.mts' -import { getDefaultApiToken } from '../../../src/util/socket/sdk.mts' -import { executeCliCommand } from '../../helpers/cli-execution.mts' - -const BINARY: BinaryConfig = { - buildCommand: undefined, - // In CI: always enabled. Locally: controlled by env vars (defaults to true). - enabled: process.env.CI - ? true - : !process.env.TEST_SEA_BINARY && - !process.env.TEST_SMOL_BINARY && - !process.env.TEST_JS_BINARY - ? true - : !!process.env.TEST_JS_BINARY, - name: 'JS Distribution (dist/index.js)', - path: path.join(ROOT_DIR, 'dist/index.js'), -} - -if (BINARY.enabled) { - describe(BINARY.name, () => { - let hasAuth = false - let binaryExists = false - - beforeAll(async () => { - binaryExists = await prepareBinary(BINARY, 'js') - - // Check authentication. - if (ENV.RUN_INTEGRATION_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken && !process.env.CI) { - logger.log('') - logger.warn('Integration tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log( - ' 3. Skip integration tests by not setting RUN_INTEGRATION_TESTS', - ) - logger.log('') - logger.log( - 'Integration tests will be skipped due to missing authentication.', - ) - logger.log('') - } - } - }) - describe('Basic commands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display version', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--version'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - // Note: --version currently shows help and exits with code 2 (known issue). - // This test validates the CLI executes without crashing. - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)('should display help', async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['package', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('package') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display optimize command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['optimize', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('optimize') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display fix command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['fix', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('fix') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display npm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm exec command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npx', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npx') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('patch') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('config') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['manifest', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('manifest') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['organization', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('organization') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['repository', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repository') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pnpm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pnpm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display yarn command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['yarn', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('yarn') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pip command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pip', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pip') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display wrapper command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['wrapper', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('wrapper') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['install', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('install') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['uninstall', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('uninstall') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display login command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['login', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('login') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display logout command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['logout', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('logout') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['whoami', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('whoami') - }, - ) - }) - - describe('Scan subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'create', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'view', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'del', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan diff help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'diff', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('diff') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan metadata help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'metadata', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('metadata') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan report help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'report', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('report') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'setup', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan github help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'github', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('github') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan reach help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'reach', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('reach') - }, - ) - }) - - describe('Config subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config set help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'set', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('set') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config unset help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'unset', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('unset') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'auto', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - }) - - describe('Organization subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization dependencies help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'dependencies', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('dependencies') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization quota help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'quota', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('quota') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('policy') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy license help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'license', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('license') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy security help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'security', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('security') - }, - ) - }) - - describe('Repository subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository update help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'update', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('update') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'del', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - }) - - describe('Patch subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch scan help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch repair help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'repair', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repair') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch remove help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'remove', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('remove') - }, - ) - }) - - describe('Manifest subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'auto', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest conda help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'conda', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('conda') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest gradle help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'gradle', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('gradle') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest kotlin help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'kotlin', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('kotlin') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest scala help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'scala', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scala') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'setup', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle manifest cdxgen command', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'cdxgen', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // cdxgen spawns external binary - just verify command exists. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Package subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package score help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('score') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package shallow help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'shallow', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('shallow') - }, - ) - }) - - describe('Install/Uninstall subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['install', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['uninstall', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - }) - - describe('Dry-run validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle optimize --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['optimize', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle fix --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['fix', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle npm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle config get with invalid key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', 'invalidKey', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Should fail with input error. - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - }) - - describe('Auth-required commands', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should list config settings', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['config', 'list'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami information', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['whoami'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - }) - - describe('Error handling - missing arguments (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan create without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan view without scan ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config get without key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config set without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'set', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on patch get without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on package score without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository create without name', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository view without ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - }) - - describe('JSON output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // JSON flag should be recognized (may fail due to auth, but shouldn't reject flag). - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Markdown output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Additional dry-run tests (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle scan create --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--dry-run', '--config', '{}', '.'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should be recognized. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle patch download --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'download', '--dry-run', '--config', '{}', 'express'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm exec --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npx', '--dry-run', '--config', '{}', 'cowsay'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['pnpm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle yarn --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['yarn', 'add', '--dry-run', '--config', '{}', 'lodash'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Performance validation', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should execute help command within reasonable time', - async () => { - if (!binaryExists) { - return - } - - const startTime = Date.now() - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - const duration = Date.now() - startTime - - expect(result.code).toBe(0) - // Help should execute in under 5 seconds even for bundled binaries. - expect(duration).toBeLessThan(5000) - }, - ) - }) - }) -} diff --git a/packages/cli/test/integration/binary/sea.test.mts b/packages/cli/test/integration/binary/sea.test.mts deleted file mode 100644 index a1686f9b1..000000000 --- a/packages/cli/test/integration/binary/sea.test.mts +++ /dev/null @@ -1,1844 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * @file Integration tests for SEA (Single Executable Application) binary. - */ - -import path from 'node:path' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { ROOT_DIR, logger, prepareBinary } from './helpers.mts' -import type { BinaryConfig } from './helpers.mts' -import { ENV } from '../../../src/constants/env.mts' -import { getDefaultApiToken } from '../../../src/util/socket/sdk.mts' -import { executeCliCommand } from '../../helpers/cli-execution.mts' - -const BINARY: BinaryConfig = { - buildCommand: ['pnpm', '--filter', '@socketsecurity/cli', 'run', 'build:sea'], - // In CI: always enabled. Locally: only if TEST_SEA_BINARY is set. - enabled: process.env.CI ? true : !!process.env.TEST_SEA_BINARY, - name: 'SEA Binary (Single Executable Application)', - path: path.join(ROOT_DIR, 'dist/sea/socket-sea'), -} - -if (BINARY.enabled) { - describe(BINARY.name, () => { - let hasAuth = false - let binaryExists = false - - beforeAll(async () => { - binaryExists = await prepareBinary(BINARY, 'sea') - - // Check authentication. - if (ENV.RUN_INTEGRATION_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken && !process.env.CI) { - logger.log('') - logger.warn('Integration tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log( - ' 3. Skip integration tests by not setting RUN_INTEGRATION_TESTS', - ) - logger.log('') - logger.log( - 'Integration tests will be skipped due to missing authentication.', - ) - logger.log('') - } - } - }) - describe('Basic commands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display version', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--version'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - // Note: --version currently shows help and exits with code 2 (known issue). - // This test validates the CLI executes without crashing. - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)('should display help', async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['package', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('package') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display optimize command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['optimize', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('optimize') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display fix command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['fix', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('fix') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display npm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm exec command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npx', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npx') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('patch') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('config') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['manifest', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('manifest') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['organization', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('organization') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['repository', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repository') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pnpm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pnpm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display yarn command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['yarn', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('yarn') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pip command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pip', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pip') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display wrapper command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['wrapper', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('wrapper') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['install', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('install') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['uninstall', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('uninstall') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display login command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['login', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('login') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display logout command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['logout', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('logout') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['whoami', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('whoami') - }, - ) - }) - - describe('Scan subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'create', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'view', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'del', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan diff help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'diff', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('diff') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan metadata help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'metadata', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('metadata') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan report help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'report', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('report') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'setup', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan github help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'github', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('github') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan reach help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'reach', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('reach') - }, - ) - }) - - describe('Config subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config set help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'set', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('set') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config unset help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'unset', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('unset') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'auto', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - }) - - describe('Organization subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization dependencies help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'dependencies', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('dependencies') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization quota help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'quota', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('quota') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('policy') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy license help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'license', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('license') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy security help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'security', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('security') - }, - ) - }) - - describe('Repository subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository update help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'update', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('update') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'del', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - }) - - describe('Patch subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch scan help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch repair help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'repair', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repair') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch remove help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'remove', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('remove') - }, - ) - }) - - describe('Manifest subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'auto', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest conda help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'conda', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('conda') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest gradle help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'gradle', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('gradle') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest kotlin help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'kotlin', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('kotlin') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest scala help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'scala', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scala') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'setup', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle manifest cdxgen command', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'cdxgen', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // cdxgen spawns external binary - just verify command exists. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Package subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package score help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('score') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package shallow help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'shallow', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('shallow') - }, - ) - }) - - describe('Install/Uninstall subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['install', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['uninstall', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - }) - - describe('Dry-run validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle optimize --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['optimize', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle fix --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['fix', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle npm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle config get with invalid key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', 'invalidKey', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Should fail with input error. - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - }) - - describe('Auth-required commands', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should list config settings', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['config', 'list'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami information', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['whoami'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - }) - - describe('Error handling - missing arguments (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan create without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan view without scan ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config get without key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config set without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'set', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on patch get without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on package score without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository create without name', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository view without ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - }) - - describe('JSON output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // JSON flag should be recognized (may fail due to auth, but shouldn't reject flag). - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Markdown output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Additional dry-run tests (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle scan create --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--dry-run', '--config', '{}', '.'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should be recognized. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle patch download --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'download', '--dry-run', '--config', '{}', 'express'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm exec --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npx', '--dry-run', '--config', '{}', 'cowsay'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['pnpm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle yarn --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['yarn', 'add', '--dry-run', '--config', '{}', 'lodash'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Performance validation', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should execute help command within reasonable time', - async () => { - if (!binaryExists) { - return - } - - const startTime = Date.now() - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - const duration = Date.now() - startTime - - expect(result.code).toBe(0) - // Help should execute in under 5 seconds even for bundled binaries. - expect(duration).toBeLessThan(5000) - }, - ) - }) - }) -} diff --git a/packages/cli/test/integration/binary/smol.test.mts b/packages/cli/test/integration/binary/smol.test.mts deleted file mode 100644 index f2326ef7a..000000000 --- a/packages/cli/test/integration/binary/smol.test.mts +++ /dev/null @@ -1,1850 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * @file Integration tests for Smol Node.js binary. - */ - -import path from 'node:path' - -import { beforeAll, describe, expect, it } from 'vitest' - -import { MONOREPO_ROOT, logger, prepareBinary } from './helpers.mts' -import type { BinaryConfig } from './helpers.mts' -import { ENV } from '../../../src/constants/env.mts' -import { getDefaultApiToken } from '../../../src/util/socket/sdk.mts' -import { executeCliCommand } from '../../helpers/cli-execution.mts' - -const BINARY: BinaryConfig = { - buildCommand: [ - 'pnpm', - '--filter', - '@socketbin/node-smol-builder', - 'run', - 'build', - ], - // In CI: always enabled. Locally: only if TEST_SMOL_BINARY is set. - enabled: process.env.CI ? true : !!process.env.TEST_SMOL_BINARY, - name: 'Smol Binary', - path: path.join(MONOREPO_ROOT, 'packages/node-smol-builder/dist/socket-smol'), -} - -if (BINARY.enabled) { - describe(BINARY.name, () => { - let hasAuth = false - let binaryExists = false - - beforeAll(async () => { - binaryExists = await prepareBinary(BINARY, 'smol') - - // Check authentication. - if (ENV.RUN_INTEGRATION_TESTS) { - const apiToken = await getDefaultApiToken() - hasAuth = !!apiToken - if (!apiToken && !process.env.CI) { - logger.log('') - logger.warn('Integration tests require Socket authentication.') - logger.log('Please run one of the following:') - logger.log(' 1. socket login (to authenticate with Socket)') - logger.log(' 2. Set SOCKET_SECURITY_API_KEY environment variable') - logger.log( - ' 3. Skip integration tests by not setting RUN_INTEGRATION_TESTS', - ) - logger.log('') - logger.log( - 'Integration tests will be skipped due to missing authentication.', - ) - logger.log('') - } - } - }) - describe('Basic commands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display version', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--version'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - // Note: --version currently shows help and exits with code 2 (known issue). - // This test validates the CLI executes without crashing. - expect(result.code).toBeGreaterThanOrEqual(0) - expect(result.stdout.length).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)('should display help', async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('socket') - expect(result.stdout).toContain('Main commands') - }) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['package', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('package') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display optimize command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['optimize', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('optimize') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display fix command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['fix', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('fix') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display npm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm exec command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['npx', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('npx') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('patch') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('config') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['manifest', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('manifest') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['organization', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('organization') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['repository', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repository') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pnpm command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pnpm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pnpm') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display yarn command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['yarn', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('yarn') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display pip command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['pip', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('pip') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display wrapper command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['wrapper', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('wrapper') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['install', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('install') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['uninstall', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('uninstall') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display login command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['login', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('login') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display logout command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['logout', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('logout') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami command help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['whoami', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('whoami') - }, - ) - }) - - describe('Scan subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'create', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'view', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'del', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan diff help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'diff', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('diff') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan metadata help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'metadata', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('metadata') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan report help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'report', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('report') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'setup', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan github help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'github', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('github') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display scan reach help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['scan', 'reach', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('reach') - }, - ) - }) - - describe('Config subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config set help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'set', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('set') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config unset help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'unset', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('unset') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display config auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['config', 'auto', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - }) - - describe('Organization subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization dependencies help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'dependencies', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('dependencies') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization quota help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'quota', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('quota') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('policy') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy license help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'license', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('license') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display organization policy security help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'policy', 'security', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('security') - }, - ) - }) - - describe('Repository subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository create help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('create') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository view help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('view') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository update help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'update', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('update') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display repository del help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'del', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('del') - }, - ) - }) - - describe('Patch subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch list help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'list', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('list') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch scan help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'scan', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scan') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch get help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'get', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('get') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch repair help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'repair', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('repair') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch remove help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'remove', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('remove') - }, - ) - }) - - describe('Manifest subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest auto help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'auto', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('auto') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest conda help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'conda', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('conda') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest gradle help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'gradle', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('gradle') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest kotlin help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'kotlin', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('kotlin') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest scala help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'scala', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('scala') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display manifest setup help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'setup', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('setup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle manifest cdxgen command', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['manifest', 'cdxgen', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // cdxgen spawns external binary - just verify command exists. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Package subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package score help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('score') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display package shallow help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'shallow', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('shallow') - }, - ) - }) - - describe('Install/Uninstall subcommands (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display install completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['install', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display uninstall completion help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['uninstall', 'completion', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('completion') - }, - ) - }) - - describe('Dry-run validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle optimize --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['optimize', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle fix --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['fix', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - const output = result.stdout + result.stderr - expect(output).toContain('[DryRun]') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle npm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should exit gracefully. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle config get with invalid key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', 'invalidKey', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Should fail with input error. - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - }) - - describe('Auth-required commands', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should list config settings', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['config', 'list'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display whoami information', - async () => { - if (!binaryExists || !hasAuth) { - return - } - - const result = await executeCliCommand(['whoami'], { - binPath: BINARY.path, - }) - - expect(result.code).toBe(0) - }, - ) - }) - - describe('Error handling - missing arguments (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan create without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on scan view without scan ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config get without key', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on config set without arguments', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'set', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - expect(result.stderr).toContain('Input error') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on patch get without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'get', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on package score without package', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['package', 'score', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository create without name', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'create', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should error on repository view without ID', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'view', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThan(0) - }, - ) - }) - - describe('JSON output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // JSON flag should be recognized (may fail due to auth, but shouldn't reject flag). - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --json flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--json', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Markdown output format validation (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for scan list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for config list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['config', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for organization list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['organization', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for repository list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['repository', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should support --markdown flag for patch list', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'list', '--markdown', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Additional dry-run tests (no auth required)', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle scan create --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['scan', 'create', '--dry-run', '--config', '{}', '.'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - // Dry-run should be recognized. - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle patch download --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['patch', 'download', '--dry-run', '--config', '{}', 'express'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm exec --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['npx', '--dry-run', '--config', '{}', 'cowsay'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle pnpm --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['pnpm', 'install', '--dry-run', '--config', '{}'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should handle yarn --dry-run', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand( - ['yarn', 'add', '--dry-run', '--config', '{}', 'lodash'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) - - expect(result.code).toBeGreaterThanOrEqual(0) - }, - ) - }) - - describe('Performance validation', () => { - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should execute help command within reasonable time', - async () => { - if (!binaryExists) { - return - } - - const startTime = Date.now() - const result = await executeCliCommand(['--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - const duration = Date.now() - startTime - - expect(result.code).toBe(0) - // Help should execute in under 5 seconds even for bundled binaries. - expect(duration).toBeLessThan(5000) - }, - ) - }) - }) -} diff --git a/packages/cli/test/integration/bundle-validation.test.mts b/packages/cli/test/integration/bundle-validation.test.mts deleted file mode 100644 index f3b02978e..000000000 --- a/packages/cli/test/integration/bundle-validation.test.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @file Bundle validation tests to ensure build output quality. Verifies that - * dist files don't contain absolute paths or unexpected bundled - * dependencies. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { describe, expect, it } from 'vitest' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packagePath = path.resolve(__dirname, '..', '..') -const buildPath = path.join(packagePath, 'build') - -/** - * Check if bundle contains inlined dependencies. Reads package.json - * dependencies and ensures they are NOT bundled inline. - */ -export async function checkBundledDependencies(content: string): Promise<{ - bundledDeps: string[] - hasNoBundledDeps: boolean -}> { - // Read package.json to get runtime dependencies. - const pkgJsonPath = path.join(packagePath, 'package.json') - const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) - const dependencies = pkgJson.dependencies || {} - - const bundledDeps: string[] = [] - - // If we have NO dependencies, check that no external packages are bundled. - if (Object.keys(dependencies).length === 0) { - // Look for signs of bundled npm packages. - // Bundled packages often have characteristic patterns like: - // - var xxx_exports = {}; - // - __toCommonJS(package_name_exports) - // - Multiple functions from same package bundled together. - const bundledPackagePatterns = [ - // Socket packages that should always be external. - /@socketsecurity\/registry/, - ] - - for (let i = 0, { length } = bundledPackagePatterns; i < length; i += 1) { - const pattern = bundledPackagePatterns[i] - // Check if package name appears in context that suggests bundling. - // Look for: var import_package = require("package") without the actual require call. - // This would indicate the package code is bundled inline. - const bundlePattern = new RegExp( - `var\\s+\\w+\\s*=\\s*__toCommonJS\\([^)]*${pattern.source}`, - ) - - if (bundlePattern.test(content)) { - bundledDeps.push(pattern.source) - } - } - } else { - // If we have dependencies, check that they remain external (not bundled). - for (const dep of Object.keys(dependencies)) { - const escapedDep = dep.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&') - // Check if dependency code is bundled by looking for __toCommonJS pattern. - const bundlePattern = new RegExp( - `var\\s+\\w+\\s*=\\s*__toCommonJS\\([^)]*${escapedDep}`, - ) - - if (bundlePattern.test(content)) { - bundledDeps.push(dep) - } - } - } - - return { - bundledDeps, - hasNoBundledDeps: bundledDeps.length === 0, - } -} - -/** - * Check if content contains absolute paths. Detects paths like /Users/, C:, - * /home/, etc. - */ -export function hasAbsolutePaths(content: string): { - hasIssue: boolean - matches: string[] -} { - // Match absolute paths but exclude URLs and node: protocol. - const patterns = [ - // Match require('/abs/path') or require('C:\\path'). - /require\(["'](?:[A-Z]:\\[^"'\n]+|\/[^"'\n]+)["']\)/g, - // Match import from '/abs/path'. - /import\s+.*?from\s+["'](?:[A-Z]:\\[^"'\n]+|\/[^"'\n]+)["']/g, - ] - - const matches: string[] = [] - for (let i = 0, { length } = patterns; i < length; i += 1) { - const pattern = patterns[i] - const found = content.match(pattern) - if (found) { - matches.push(...found) - } - } - - return { - hasIssue: matches.length > 0, - matches, - } -} - -describe('Bundle validation', () => { - it('should not contain absolute paths in build/cli.js', async () => { - const cliPath = path.join(buildPath, 'cli.js') - const content = await fs.readFile(cliPath, 'utf8') - - const result = hasAbsolutePaths(content) - - if (result.hasIssue) { - logger.fail('Found absolute paths in bundle:') - for (const match of result.matches) { - logger.fail(` - ${match}`) - } - } - - expect(result.hasIssue, 'Bundle should not contain absolute paths').toBe( - false, - ) - }) - - it('should not bundle dependencies inline (validate against package.json dependencies)', async () => { - const cliPath = path.join(buildPath, 'cli.js') - const content = await fs.readFile(cliPath, 'utf8') - - const result = await checkBundledDependencies(content) - - if (!result.hasNoBundledDeps) { - logger.fail('Found bundled dependencies (should be external):') - for (const dep of result.bundledDeps) { - logger.fail(` - ${dep}`) - } - } - - expect( - result.hasNoBundledDeps, - 'Dependencies from package.json should be external, not bundled inline', - ).toBe(true) - }) -}) diff --git a/packages/cli/test/integration/cli/cli.test.mts b/packages/cli/test/integration/cli/cli.test.mts deleted file mode 100755 index 04f72ad8e..000000000 --- a/packages/cli/test/integration/cli/cli.test.mts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Integration tests for Socket CLI root command. - * - * Tests the main entry point (`socket`) behavior including help output, command - * discovery, and graceful handling of unrecognized input. - * - * Test Coverage: - Help text display (--help flag) - Banner output format and - * content - Command list structure and categories - Dry-run behavior with - * package specs - Exit codes for valid invocations. - * - * Command Categories Validated: - Main commands (login, scan, fix, optimize, - * cdxgen, ci) - Socket API commands (analytics, audit-log, organization, - * package, repository, scan, threat-feed) - Local tools (manifest, npm, npx, - * raw-npm, raw-npx) - CLI configuration (config, install, login, logout, - * uninstall, whoami, wrapper) - Global flags (--compact-header, --config, - * --dry-run, --help, --version, etc.) - * - * Related Files: - src/cli.mts - Main CLI entry point - src/constants/cli.mts - - * CLI flag constants - test/utils.mts - Test utilities (cmdit, spawnSocketCli) - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket root command', async () => { - cmdit( - [FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Usage - $ socket <command> - $ socket scan create --json - $ socket package score npm lodash --markdown - - Note: All commands have their own --help - - Main commands - socket login Setup Socket CLI with an API token and defaults - socket scan create Create a new Socket scan and report - socket npm/lodash@4.17.21 Request the Socket score of a package - socket fix Fix CVEs in dependencies - socket optimize Optimize dependencies with @socketregistry overrides - socket cdxgen Run cdxgen for SBOM generation - socket ci Alias for \`socket scan create --report\` (creates report and exits with error if unhealthy) - - Socket API - analytics Look up analytics data - audit-log Look up the audit log for an organization - organization Manage Socket organization account details - package Look up published package details - repository Manage registered repositories - scan Manage Socket scans - threat-feed [Beta] View the threat-feed - - Local tools - manifest Generate a dependency manifest for certain ecosystems - npm Wraps npm with Socket security scanning - pnpm exec Wraps pnpm exec with Socket security scanning - raw-npm Run npm without the Socket wrapper - raw-npx Run pnpm exec without the Socket wrapper - - CLI configuration - config Manage Socket CLI configuration - install Install Socket CLI tab completion - login Socket API login and CLI setup - logout Socket API logout - uninstall Uninstall Socket CLI tab completion - whoami Check Socket CLI authentication status - wrapper Enable or disable the Socket npm/pnpm exec wrapper - - Options - Note: All commands have these flags even when not displayed in their help - - --compact-header Use compact single-line header format (auto-enabled in CI) - --config Override the local config with this JSON - --dry-run Run without uploading - --help Show help - --help-full Show full help including environment variables - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner - --version Print the app version - - Environment variables [more\u2026] - Use --help-full to view all environment variables" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket`') - }, - ) - - cmdit( - ['mootools', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-analytics.test.mts b/packages/cli/test/integration/cli/cmd-analytics.test.mts deleted file mode 100644 index 89223eb6d..000000000 --- a/packages/cli/test/integration/cli/cmd-analytics.test.mts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Integration tests for `socket analytics` command. - * - * Tests the analytics command for querying organization and repository-level - * analytics data over specified time periods (7, 30, or 90 days). - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Scope selection (org vs repo) - Time filter validation (7, 30, - * 90 days) - Repository name requirement when scope is "repo" - Legacy flag - * detection and rejection (--scope, --repo, --time) - Argument parsing - * (positional vs flag-based) - Error handling (missing token, invalid time - * values, missing repo names) - Exit codes for various scenarios. - * - * Platform-Specific Behavior: - Windows Node 24+ has known stderr assertion - * failures (skipped in tests) - * - * Related Files: - src/commands/analytics/cmd-analytics.mts - Command - * definition - src/commands/analytics/handle-analytics.mts - Analytics handler - * - src/commands/analytics/output-analytics.mts - Output formatting. - */ - -import semver from 'semver' -import { describe, expect } from 'vitest' - -import { getNodeVersion } from '@socketsecurity/lib-stable/constants/node' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket analytics', async () => { - cmdit( - ['analytics', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Look up analytics data - - Usage - $ socket analytics [options] [ "org" | "repo" <reponame>] [TIME] - - API Token Requirements - - Quota: 1 unit - - Permissions: report:write - - The scope is either org or repo level, defaults to org. - - When scope is repo, a repo slug must be given as well. - - The TIME argument must be number 7, 30, or 90 and defaults to 30. - - Options - --file Path to store result, only valid with --json/--markdown - --json Output as JSON - --markdown Output as Markdown - - Examples - $ socket analytics org 7 - $ socket analytics repo test-repo 30 - $ socket analytics 90" - `) - // Node 24 on Windows currently fails this test with added stderr: - // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76 - const skipOnWin32Node24 = - WIN32 && semver.parse(getNodeVersion())?.major >= 24 - if (!skipOnWin32Node24) { - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - expect(code, 'explicit help should exit with code 0').toBe(0) - } - - expect(stderr, 'banner includes base command').toContain( - '`socket analytics`', - ) - }, - ) - - cmdit( - ['analytics', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should report missing token with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\u221a The time filter must either be 7, 30 or 90 - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'analytics', - '--scope', - 'org', - '--repo', - 'bar', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should reject legacy flags', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Legacy flags are no longer supported. See the v1 migration guide (https://docs.socket.dev/docs/v1-migration-guide). (received legacy flags) - \\u221a The time filter must either be 7, 30 or 90" - `) - - expect(code, 'dry-run should reject legacy flags with code 2').toBe(2) - }, - ) - - cmdit( - ['analytics', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should run to dryrun without args', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['analytics', 'org', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should accept org arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'analytics', - 'repo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should ask for repo name with repo arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 When scope=repo, repo name should be the second argument (missing) - \\u221a The time filter must either be 7, 30 or 90" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'analytics', - 'repo', - 'daname', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept repo with arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['analytics', '7', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should accept time 7 arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['analytics', '30', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should accept time 30 arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['analytics', '90', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should accept time 90 arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'analytics', - 'org', - '--time', - '7', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report legacy flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Legacy flags are no longer supported. See the v1 migration guide (https://docs.socket.dev/docs/v1-migration-guide). (received legacy flags) - \\u221a The time filter must either be 7, 30 or 90" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'analytics', - 'org', - '7', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept org and time arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'analytics', - 'repo', - 'slowpo', - '30', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept repo and time arg', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-ask.test.mts b/packages/cli/test/integration/cli/cmd-ask.test.mts deleted file mode 100644 index 90e476bdf..000000000 --- a/packages/cli/test/integration/cli/cmd-ask.test.mts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Integration tests for `socket ask` command. - * - * Tests the natural language query command that translates plain English - * questions into Socket CLI commands. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * (--dry-run flag) - Query processing and command translation - Error handling - * for missing query - Banner and exit code validation. - * - * Related Files: - src/commands/ask/cmd-ask.mts - ask command implementation - - * src/commands/ask/handle-ask.mts - NLP query parsing - - * src/commands/ask/output-ask.mts - Output formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket ask', async () => { - cmdit( - ['ask', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Ask in plain English') - expect(stdout).toContain('Usage') - expect(stdout).toContain('<question>') - expect(stdout).toContain('--execute') - expect(stdout).toContain('--explain') - expect(stdout).toContain('Examples') - expect(stdout).toContain('scan for vulnerabilities') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['ask', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['ask', FLAG_CONFIG, '{}'], - 'should error when no query provided', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('requires a QUERY positional argument') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - ['ask', 'scan for vulnerabilities', FLAG_CONFIG, '{}'], - 'should process natural language query', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // Should show query interpretation. - expect(stdout).toContain('You asked') - expect(stdout).toContain('scan for vulnerabilities') - // Should show interpreted command. - expect(stdout).toContain('Command') - expect(stdout).toContain('socket') - // Should show tip about execute flag. - expect(stdout).toContain('--execute') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['ask', 'fix critical issues', FLAG_CONFIG, '{}'], - 'should interpret fix command with severity', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('You asked') - expect(stdout).toContain('fix critical issues') - expect(stdout).toContain('Command') - expect(code, 'should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-audit-log.test.mts b/packages/cli/test/integration/cli/cmd-audit-log.test.mts deleted file mode 100644 index db31c19cf..000000000 --- a/packages/cli/test/integration/cli/cmd-audit-log.test.mts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Integration tests for `socket audit-log` command. - * - * Tests the audit log command for viewing organization audit trails. This is an - * Enterprise Plan feature that tracks all organization activities. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Organization resolution (default org, --org flag, - * auto-discovery) - Event type filtering (enum validation) - Pagination support - * (--page, --per-page flags) - Legacy flag detection and rejection (--type) - - * Error handling (missing org, missing token, dry-run auto-discovery skip) - - * Interactive vs non-interactive modes. - * - * Enterprise Feature: Requires Enterprise Plan subscription for audit log - * access. - * - * Audit Event Types: - Organization changes (member roles, subscriptions, - * settings) - API token operations (create, rotate, update, delete) - Label - * management (create, delete, associate) - Security operations (alert triage, - * report deletion) - Access control (invitations, transfers, removals) - * - * Related Files: - src/commands/audit-log/cmd-audit-log.mts - Command - * definition - src/commands/audit-log/handle-audit-log.mts - Audit log handler - * - src/commands/audit-log/output-audit-log.mts - Output formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket audit-log', async () => { - cmdit( - ['audit-log', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Look up the audit log for an organization - - Usage - $ socket audit-log [options] [FILTER] - - API Token Requirements - - Quota: 1 unit - - Permissions: audit-log:list - - This feature requires an Enterprise Plan. To learn more about getting access - to this feature and many more, please visit the Socket pricing page (https://socket.dev/pricing). - - The type FILTER arg is an enum. Defaults to any. It should be one of these: - associateLabel, cancelInvitation, changeMemberRole, changePlanSubscriptionSeats, - createApiToken, createLabel, deleteLabel, deleteLabelSetting, deleteReport, - deleteRepository, disassociateLabel, joinOrganization, removeMember, - resetInvitationLink, resetOrganizationSettingToDefault, rotateApiToken, - sendInvitation, setLabelSettingToDefault, syncOrganization, transferOwnership, - updateAlertTriage, updateApiTokenCommitter, updateApiTokenMaxQuota, - updateApiTokenName', updateApiTokenScopes, updateApiTokenVisibility, - updateLabelSetting, updateOrganizationSetting, upgradeOrganizationPlan - - The page arg should be a positive integer, offset 1. Defaults to 1. - - Options - --interactive Allow for interactive elements, asking for input. - Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --page Result page to fetch - --per-page Results per page - default is 30 - - Examples - $ socket audit-log - $ socket audit-log deleteReport --page 2 --per-page 10" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket audit-log`', - ) - }, - ) - - cmdit( - ['audit-log', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'audit-log', - '--type', - 'xyz', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should report legacy flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Legacy flags are no longer supported. See the v1 migration guide (https://docs.socket.dev/docs/v1-migration-guide). (received legacy flags)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'audit-log', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should accept default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'audit-log', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --org flag in v1', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-bundler.test.mts b/packages/cli/test/integration/cli/cmd-bundler.test.mts deleted file mode 100644 index a1f522a67..000000000 --- a/packages/cli/test/integration/cli/cmd-bundler.test.mts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Integration tests for `socket bundler` wrapper command. - * - * Tests the bundler package manager wrapper that adds Socket security scanning - * to Ruby dependency operations via Socket Firewall (sfw). Commands are - * forwarded to sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples. - * - * Security Features: - Pre-installation security scanning via Socket Firewall. - * - * Related Files: - src/commands/bundler/cmd-bundler.mts - bundler command - * implementation - src/util/dlx/resolve-binary.mts - sfw resolution. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const BUNDLER = 'bundler' - -describe('socket bundler', async () => { - cmdit( - [BUNDLER, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run bundler with Socket Firewall security - - Usage - $ socket bundler ... - - Note: Everything after "bundler" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for bundler packages. - - Examples - $ socket bundler install - $ socket bundler update - $ socket bundler exec rake" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket bundler\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket bundler`', - ) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-cargo.test.mts b/packages/cli/test/integration/cli/cmd-cargo.test.mts deleted file mode 100644 index a2fcbaf89..000000000 --- a/packages/cli/test/integration/cli/cmd-cargo.test.mts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Integration tests for `socket cargo` wrapper command. - * - * Tests the cargo package manager wrapper that adds Socket security scanning to - * Rust package operations via Socket Firewall (sfw). Commands are forwarded to - * sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - cargo operations with scanning - Config flag variants - Issue - * rules configuration. - * - * Security Features: - Pre-installation security scanning via Socket Firewall - - * Malware detection integration. - * - * Related Files: - src/commands/cargo/cmd-cargo.mts - cargo command - * implementation - src/util/dlx/resolve-binary.mjs - sfw resolution - - * test/integration/cli/cmd-cargo-malware.test.mts - Malware tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const CARGO = 'cargo' - -describe('socket cargo', async () => { - cmdit( - [CARGO, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run cargo with Socket Firewall security - - Usage - $ socket cargo ... - - Note: Everything after "cargo" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for cargo packages. - - Examples - $ socket cargo install ripgrep - $ socket cargo build - $ socket cargo add serde" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket cargo\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket cargo`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-ci.test.mts b/packages/cli/test/integration/cli/cmd-ci.test.mts deleted file mode 100644 index dfca9b8e4..000000000 --- a/packages/cli/test/integration/cli/cmd-ci.test.mts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Integration tests for `socket ci` command. - * - * Tests the CI command which is an alias for `socket scan create --report`. - * This command creates a security scan and exits with a non-zero code if the - * scan detects policy violations, making it ideal for automated CI/CD - * pipelines. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Auto-manifest flag support - Exit codes (success vs policy - * violations) - * - * CI/CD Integration: This command is specifically designed for automated builds - * where security policy enforcement is required. It uses the default - * organization from the API token and fails the build when issues are - * detected. - * - * Related Files: - src/commands/ci/cmd-ci.mts - Command definition - - * src/commands/ci/handle-ci.mts - CI handler (delegates to scan create) - - * src/commands/scan/cmd-scan-create.mts - Underlying scan create command. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket ci', async () => { - cmdit( - ['ci', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Alias for \`socket scan create --report\` (creates report and exits with error if unhealthy) - - Usage - $ socket ci [options] - - Options - --auto-manifest Auto generate manifest files where detected? See autoManifest flag in \`socket scan create\` - - This command is intended to use in CI runs to allow automated systems to - accept or reject a current build. It will use the default org of the - Socket API token. The exit code will be non-zero when the scan does not pass - your security policy. - - The --auto-manifest flag does the same as the one from \`socket scan create\` - but is not enabled by default since the CI is less likely to be set up with - all the necessary dev tooling. Enable it if you want the scan to include - locally generated manifests like for gradle and sbt. - - Examples - $ socket ci - $ socket ci --auto-manifest" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket ci\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket ci`') - }, - ) - - cmdit( - ['ci', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket ci\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-config-auto.test.mts b/packages/cli/test/integration/cli/cmd-config-auto.test.mts deleted file mode 100644 index 0bfa7623f..000000000 --- a/packages/cli/test/integration/cli/cmd-config-auto.test.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Integration tests for `socket config auto` command. - * - * Tests the auto-discovery and automatic configuration of CLI settings. This - * command attempts to intelligently determine and set config values based on - * the user's environment and API token. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Key argument requirement - Available config keys listing. - * - * Auto-Discoverable Keys: - defaultOrg: Automatically detects the organization - * from API token - Other keys may be added in future releases. - * - * Related Files: - src/commands/config/cmd-config-auto.mts - Command definition - * - src/commands/config/handle-config-auto.mts - Auto-discovery logic - - * src/util/config.mts - Config management utilities. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config auto', async () => { - cmdit( - ['config', 'auto', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Automatically discover and set the correct value config item - - Usage - $ socket config auto [options] KEY - - Options - --json Output as JSON - --markdown Output as Markdown - - Attempt to automatically discover the correct value for a given config KEY. - - Examples - $ socket config auto defaultOrg - - Keys: - - apiBaseUrl -- Base URL of the Socket API endpoint - - apiProxy -- A proxy through which to access the Socket API - - apiToken -- The Socket API token required to access most Socket API endpoints - - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value. - - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine - - org -- Alias for defaultOrg - - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config auto\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket config auto`', - ) - }, - ) - - cmdit( - [ - 'config', - 'auto', - 'defaultOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config auto\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-config-get.test.mts b/packages/cli/test/integration/cli/cmd-config-get.test.mts deleted file mode 100644 index dce7d6051..000000000 --- a/packages/cli/test/integration/cli/cmd-config-get.test.mts +++ /dev/null @@ -1,375 +0,0 @@ -/** - * Integration tests for `socket config get` command. - * - * Tests retrieving configuration values from the CLI config store, including - * comprehensive validation of environment variable and config override - * precedence. - * - * Test Coverage: - * - * - Help text display and usage examples - * - Key argument validation - * - Config value retrieval - * - Environment variable precedence (SOCKET_CLI_API_TOKEN, - * SOCKET_SECURITY_API_KEY, etc.) - * - Config override precedence (--config flag) - * - Read-only mode notification when overrides are active - * - Backward compatibility with legacy env var names - * - Platform-specific behavior (Windows Node 24+ skips) - * - * Configuration Precedence (highest to lowest): - * - * 1. Environment variables (SOCKET_CLI_API_TOKEN, SOCKET_CLI_API_KEY) - * 2. Legacy environment variables (SOCKET_SECURITY_API_KEY) - * 3. Command-line flag (--config) - * 4. Local config file - * - * Available Config Keys: - * - * - ApiBaseUrl: Socket API base URL - * - ApiProxy: Proxy for API requests - * - ApiToken: Authentication token - * - DefaultOrg: Default organization slug - * - EnforcedOrgs: Organizations with enforced policies - * - SkipAskToPersistDefaultOrg: Skip org persistence prompt - * - * Platform-Specific Behavior: - * - * - Windows Node 24+ has known stderr assertion failures (skipped in tests) - * - * Related Files: - * - * - Src/commands/config/cmd-config-get.mts - Command definition - * - Src/commands/config/handle-config-get.mts - Config retrieval logic - * - Src/util/config.mts - Config management utilities - */ - -import semver from 'semver' -import { describe, expect } from 'vitest' - -import { getNodeVersion } from '@socketsecurity/lib-stable/constants/node' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config get', async () => { - cmdit( - ['config', 'get', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Get the value of a local CLI config item - - Usage - $ socket config get [options] KEY - - Retrieve the value for given KEY at this time. If you have overridden the - config then the value will come from that override. - - Options - --json Output as JSON - --markdown Output as Markdown - - KEY is an enum. Valid keys: - - - apiBaseUrl -- Base URL of the Socket API endpoint - - apiProxy -- A proxy through which to access the Socket API - - apiToken -- The Socket API token required to access most Socket API endpoints - - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value. - - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine - - org -- Alias for defaultOrg - - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively - - Examples - $ socket config get defaultOrg" - `) - // Node 24 on Windows currently fails this test with added stderr: - // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76 - const skipOnWin32Node24 = - WIN32 && semver.parse(getNodeVersion())?.major >= 24 - if (!skipOnWin32Node24) { - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - expect(code, 'explicit help should exit with code 0').toBe(0) - } - - expect(stderr, 'banner includes base command').toContain( - '`socket config get`', - ) - }, - ) - - cmdit( - ['config', 'get', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Config key should be the first arg (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'config', - 'test', - 'test', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - describe('env vars', () => { - describe('token', () => { - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{"apiToken":null}'], - 'should return undefined when token not set in config', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: null - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - expect(stdout.includes('apiToken: null')).toBe(true) - }, - ) - - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{"apiToken":null}'], - 'should return the env var token when set', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { SOCKET_CLI_API_TOKEN: 'abc' }, - }) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: abc - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - expect(stdout.includes('apiToken: abc')).toBe(true) - }, - ) - - // Migrate this away...? - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{"apiToken":null}'], - 'should back compat support for API token as well env var', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { SOCKET_SECURITY_API_KEY: 'abc' }, - }) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: abc - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - // SOCKET_SECURITY_API_KEY is now supported - expect(stdout.includes('apiToken: abc')).toBe(true) - }, - ) - - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{"apiToken":null}'], - 'should be nice and support cli prefixed env var for token as well', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { SOCKET_CLI_API_TOKEN: 'abc' }, - }) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: abc - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - expect(stdout.includes('apiToken: abc')).toBe(true) - }, - ) - - // Migrate this away...? - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{"apiToken":null}'], - 'should be very nice and support cli prefixed env var for key as well since it is an easy mistake to make', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { SOCKET_CLI_API_KEY: 'abc' }, - }) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: abc - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - // SOCKET_CLI_API_KEY is now supported as fallback - expect(stdout.includes('apiToken: abc')).toBe(true) - }, - ) - - cmdit( - [ - 'config', - 'get', - 'apiToken', - FLAG_CONFIG, - '{"apiToken":"ignoremebecausetheenvvarshouldbemoreimportant"}', - ], - 'should use the env var token when the config override also has a token set', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { SOCKET_CLI_API_KEY: 'abc' }, - }) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: abc - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - // Env var fallback now takes precedence - expect(stdout.includes('apiToken: abc')).toBe(true) - }, - ) - - cmdit( - [ - 'config', - 'get', - 'apiToken', - FLAG_CONFIG, - '{"apiToken":"pickmepickme"}', - ], - 'should use the config override when there is no env var', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: pickmepickme - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - expect(stdout.includes('apiToken: pickmepickme')).toBe(true) - }, - ) - - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{}'], - 'should yield no token when override has none', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "apiToken: undefined - - Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" - `) - - expect(stdout.includes('apiToken: undefined')).toBe(true) - }, - ) - }) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-config-list.test.mts b/packages/cli/test/integration/cli/cmd-config-list.test.mts deleted file mode 100644 index 4d9b21197..000000000 --- a/packages/cli/test/integration/cli/cmd-config-list.test.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration tests for `socket config list` command. - * - * Tests listing all local CLI configuration items and their current values. - * This command provides an overview of all config settings in one view. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - --full flag support (shows full tokens in plaintext) - Output - * format support (JSON, markdown, text) - * - * Security Note: By default, sensitive values like API tokens are redacted in - * the output. Use --full flag to show plaintext values (unsafe in shared - * environments). - * - * Related Files: - src/commands/config/cmd-config-list.mts - Command definition - * - src/commands/config/handle-config-list.mts - Config listing logic - - * src/commands/config/output-config-list.mts - Output formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config get', async () => { - cmdit( - ['config', 'list', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Show all local CLI config items and their values - - Usage - $ socket config list [options] - - Options - --full Show full tokens in plaintext (unsafe) - --json Output as JSON - --markdown Output as Markdown - - Examples - $ socket config list" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config list\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket config list`', - ) - }, - ) - - cmdit( - ['config', 'list', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-config-set.test.mts b/packages/cli/test/integration/cli/cmd-config-set.test.mts deleted file mode 100644 index 7e95fe436..000000000 --- a/packages/cli/test/integration/cli/cmd-config-set.test.mts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Integration tests for `socket config set` command. - * - * Tests updating local CLI configuration values. This command provides a simple - * key-value store interface for modifying config settings. - * - * Test Coverage: - Help text display and usage examples - Key and value - * argument validation - Dry-run behavior validation - Error handling (missing - * arguments) - * - * Important Notes: - No validation is performed on values (validation happens - * at API time) - Use `socket config unset` to restore defaults - Setting a key - * to "undefined" does NOT restore defaults. - * - * Available Config Keys: - apiBaseUrl: Socket API base URL - apiProxy: Proxy - * for API requests - apiToken: Authentication token - defaultOrg: Default - * organization slug - enforcedOrgs: Organizations with enforced policies - - * skipAskToPersistDefaultOrg: Skip org persistence prompt. - * - * Related Files: - src/commands/config/cmd-config-set.mts - Command definition - * - src/commands/config/handle-config-set.mts - Config update logic - - * src/util/config.mts - Config management utilities. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config get', async () => { - cmdit( - ['config', 'set', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Update the value of a local CLI config item - - Usage - $ socket config set [options] <KEY> <VALUE> - - Options - --json Output as JSON - --markdown Output as Markdown - - This is a crude way of updating the local configuration for this CLI tool. - - Note that updating a value here is nothing more than updating a key/value - store entry. No validation is happening. The server may reject your values - in some cases. Use at your own risk. - - Note: use \`socket config unset\` to restore to defaults. Setting a key - to \`undefined\` will not allow default values to be set on it. - - Keys: - - - apiBaseUrl -- Base URL of the Socket API endpoint - - apiProxy -- A proxy through which to access the Socket API - - apiToken -- The Socket API token required to access most Socket API endpoints - - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value. - - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine - - org -- Alias for defaultOrg - - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively - - Examples - $ socket config set apiProxy https://example.com" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket config set`', - ) - }, - ) - - cmdit( - ['config', 'set', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Config key should be the first arg (missing) - \\xd7 Key value should be the remaining args (use \`unset\` to unset a value) (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'config', - 'set', - 'test', - 'xyz', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-config-unset.test.mts b/packages/cli/test/integration/cli/cmd-config-unset.test.mts deleted file mode 100644 index 230845c69..000000000 --- a/packages/cli/test/integration/cli/cmd-config-unset.test.mts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Integration tests for `socket config unset` command. - * - * Tests clearing local CLI configuration values to restore default behavior. - * This command removes a value from the config store, allowing defaults to be - * used. - * - * Test Coverage: - Help text display and usage examples - Key argument - * validation - Dry-run behavior validation - Error handling (missing - * arguments) - * - * Use Cases: - Restoring default values after testing custom settings - - * Removing custom API endpoints or proxies - Clearing organization settings. - * - * Note: This is the recommended way to restore defaults. Setting a value to - * "undefined" using `config set` will NOT achieve the same result. - * - * Related Files: - src/commands/config/cmd-config-unset.mts - Command - * definition - src/commands/config/handle-config-unset.mts - Config clearing - * logic - src/util/config.mts - Config management utilities. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config unset', async () => { - cmdit( - ['config', 'unset', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Clear the value of a local CLI config item - - Usage - $ socket config unset [options] <KEY> <VALUE> - - Options - --json Output as JSON - --markdown Output as Markdown - - Removes a value from a config key, allowing the default value to be used - for it instead. - - Keys: - - - apiBaseUrl -- Base URL of the Socket API endpoint - - apiProxy -- A proxy through which to access the Socket API - - apiToken -- The Socket API token required to access most Socket API endpoints - - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are implied to be this value. - - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine - - org -- Alias for defaultOrg - - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interactively - - Examples - $ socket config unset defaultOrg" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket config unset`', - ) - }, - ) - - cmdit( - ['config', 'unset', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Config key should be the first arg (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'config', - 'unset', - 'test', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-config.test.mts b/packages/cli/test/integration/cli/cmd-config.test.mts deleted file mode 100644 index ddc84e38b..000000000 --- a/packages/cli/test/integration/cli/cmd-config.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Integration tests for `socket config` root command. - * - * Tests the configuration management root command which provides access to - * subcommands for viewing and modifying local CLI configuration settings. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Config override parsing (JSON validation) - * - Environment variable config override (SOCKET_CLI_CONFIG) - * - Flag-based config override (--config) - * - Error handling for invalid JSON in config overrides - * - * Configuration Sources (in priority order): - * - * 1. Environment variable (SOCKET_CLI_CONFIG) - * 2. Command-line flag (--config) - * 3. Local config file - * - * Available Subcommands: - * - * - Auto: Auto-discover and set config values - * - Get: Retrieve a config value - * - List: Show all config items - * - Set: Update a config value - * - Unset: Clear a config value - * - * Related Files: - * - * - Src/commands/config/cmd-config.mts - Root command definition - * - Src/commands/config/cmd-config-*.mts - Subcommand definitions - * - Src/util/config.mts - Config management utilities - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket config', async () => { - cmdit( - ['config', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Manage Socket CLI configuration - - Usage - $ socket config <command> - - Commands - auto Automatically discover and set the correct value config item - get Get the value of a local CLI config item - list Show all local CLI config items and their values - set Update the value of a local CLI config item - unset Clear the value of a local CLI config item - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket config`', - ) - }, - ) - - cmdit( - ['config', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - describe('config override', () => { - cmdit( - ['config', 'get', 'apiToken'], - 'should print nice error when env config override cannot be parsed', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - // This will be parsed first. If it fails it should fallback to flag or empty. - env: { SOCKET_CLI_CONFIG: '{apiToken:invalidjson}' }, - }) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted> - - \\xd7 Could not parse Config as JSON" - `) - - expect(stderr.includes('Could not parse Config as JSON')).toBe(true) - expect(code, 'bad config input should exit with code 2 ').toBe(2) - }, - ) - - cmdit( - ['config', 'get', 'apiToken', FLAG_CONFIG, '{apiToken:invalidjson}'], - 'should print nice error when flag config override cannot be parsed', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted> - - \\xd7 Could not parse Config as JSON" - `) - - expect(stderr.includes('Could not parse Config as JSON')).toBe(true) - expect(code, 'bad config input should exit with code 2 ').toBe(2) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-fix.test.mts b/packages/cli/test/integration/cli/cmd-fix.test.mts deleted file mode 100644 index 8d6dadd71..000000000 --- a/packages/cli/test/integration/cli/cmd-fix.test.mts +++ /dev/null @@ -1,1075 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Integration tests for `socket fix` command. - * - * Tests the CVE fixing command that automatically upgrades vulnerable - * dependencies to secure versions. Supports local fixes and automated CI/PR - * workflows. - * - * Test Coverage: - * - * - Help text display and usage examples - * - Dry-run behavior (validation without applying fixes) - * - Vulnerability identification (GHSA IDs, CVE IDs, PURLs) - * - Autopilot mode (auto-merge PRs) - * - Fix limit control (--limit flag) - * - Version range styles (pin, preserve) - * - Major version update control (--no-major-updates) - * - Direct dependency impact analysis (--show-affected-direct-dependencies) - * - Minimum release age filtering (--minimum-release-age) - * - Output formats (JSON, markdown, text) - * - CI environment variable handling (GITHUB_TOKEN, GIT_USER_NAME, - * GIT_USER_EMAIL) - * - Fixture testing (monorepos, vulnerable-deps projects) - * - Error handling (invalid IDs, missing tokens, non-existent paths) - * - * CI/PR Workflow Features: - * - * - Automatic PR creation for vulnerability fixes - * - Auto-merge support via GitHub's auto-merge feature - * - Git commit configuration via environment variables - * - Multiple fixes batching with --limit - * - * Version Range Styles: - * - * - Pin: Use exact versions (e.g., 1.2.3) - * - Preserve: Retain existing range style (e.g., ^1.2.3) - * - * Vulnerability ID Formats: - * - * - GHSA IDs (e.g., GHSA-xxxx-xxxx-xxxx) - * - CVE IDs (e.g., CVE-2021-23337) - auto-converted to GHSA - * - PURLs (e.g., pkg:npm/package@1.0.0) - auto-converted to GHSA - * - Comma-separated or multiple --id flags supported - * - * Platform-Specific Behavior: - * - * - Extended test timeouts on Windows and CI (60s vs 30s) - * - * Related Files: - * - * - Src/commands/fix/cmd-fix.mts - Command definition - * - Src/commands/fix/handle-fix.mts - Fix computation and application logic - * - Src/commands/fix/output-fix.mts - Output formatting - * - Test/fixtures/commands/fix/ - Test fixtures (monorepo, vulnerable-deps) - */ - -import path from 'node:path' - -import { afterEach, describe, expect } from 'vitest' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ID, - FLAG_JSON, - FLAG_MARKDOWN, -} from '../../../src/constants/cli.mts' -import { ENV } from '../../../src/constants/env.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { withTempFixture } from '../../helpers/test-fixtures.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/fix') - -// Track cleanup functions for each test. -let cleanupFunctions: Array<() => Promise<void>> = [] - -describe('socket fix', async () => { - // Increase timeout for CI environments and Windows where operations can be slower. - const testTimeout = ENV.CI || WIN32 ? 60_000 : 30_000 - - afterEach(async () => { - // Clean up all temporary directories after each test. - await Promise.allSettled(cleanupFunctions.map(cleanup => cleanup())) - cleanupFunctions = [] - }) - - describe('environment variable handling', () => { - // Note: The warning messages about missing env vars are only shown when: - // 1. NOT in dry-run mode - // 2. There are actual vulnerabilities to fix - // Since these tests use --dry-run, they won't trigger the warnings. - // The implementation is still correct and will show warnings in real usage. - - cmdit( - ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should not show env var names when all CI env vars are present', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - // Don't use fixture dir, use current dir which has git repo. - env: { - ...process.env, - CI: '1', - SOCKET_CLI_GITHUB_TOKEN: 'fake-github-token', - SOCKET_CLI_GIT_USER_NAME: 'test-user', - SOCKET_CLI_GIT_USER_EMAIL: 'test@example.com', - }, - }) - - const output = stdout + stderr - // When all vars are present, none should be mentioned. - expect(output).not.toContain('SOCKET_CLI_GITHUB_TOKEN') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_NAME') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(code).toBe(0) - }, - ) - - cmdit( - ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should not show env var names when CI is not set', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - // Don't use fixture dir, use current dir which has git repo. - env: { - ...process.env, - CI: '', - SOCKET_CLI_GITHUB_TOKEN: '', - SOCKET_CLI_GIT_USER_NAME: '', - SOCKET_CLI_GIT_USER_EMAIL: '', - }, - }) - - const output = stdout + stderr - // When CI is not set, env vars should not be mentioned. - expect(output).not.toContain('SOCKET_CLI_GITHUB_TOKEN') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_NAME') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(code).toBe(0) - }, - ) - - cmdit( - ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should not show env var names when CI is not set but some vars are present', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - // Don't use fixture dir, use current dir which has git repo. - env: { - ...process.env, - CI: '', - // Some CI vars present but CI not set. - SOCKET_CLI_GITHUB_TOKEN: 'fake-token', - SOCKET_CLI_GIT_USER_NAME: 'test-user', - SOCKET_CLI_GIT_USER_EMAIL: '', - }, - }) - - const output = stdout + stderr - // When CI is not set, env vars should not be mentioned regardless of their values. - expect(output).not.toContain('SOCKET_CLI_GITHUB_TOKEN') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_NAME') - expect(output).not.toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(code).toBe(0) - }, - ) - - cmdit( - ['fix', FLAG_HELP, FLAG_CONFIG, '{}'], - 'should show exact env var names in help text', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // Help text doesn't directly show env vars, but the implementation - // would show them when actually running the command with missing vars. - expect(stdout).toContain('Examples') - expect(code).toBe(0) - }, - ) - }) - - cmdit( - ['fix', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Fix CVEs in dependencies - - Usage - $ socket fix [options] [CWD=.] - - API Token Requirements - - Quota: 101 units - - Permissions: full-scans:create and packages:list - - Options - --autopilot Enable auto-merge for pull requests that Socket opens. - See GitHub documentation (https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository) for managing auto-merge for pull requests in your repository. - --id Provide a list of vulnerability identifiers to compute fixes for: - - GHSA IDs (https://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories-from-the-github-advisory-database/about-the-github-advisory-database#about-ghsa-ids) (e.g., GHSA-xxxx-xxxx-xxxx) - - CVE IDs (https://cve.mitre.org/cve/identifiers/) (e.g., CVE-2025-1234) - automatically converted to GHSA - - PURLs (https://github.com/package-url/purl-spec) (e.g., pkg:npm/package@1.0.0) - automatically converted to GHSA - Can be provided as comma separated values or as multiple flags - --json Output as JSON - --limit The number of fixes to attempt at a time (default 10) - --markdown Output as Markdown - --minimum-release-age Set a minimum age requirement for suggested upgrade versions (e.g., 1h, 2d, 3w). A higher age requirement reduces the risk of upgrading to malicious versions. For example, setting the value to 1 week (1w) gives ecosystem maintainers one week to remove potentially malicious versions. - --no-apply-fixes Compute fixes only, do not apply them. Logs what upgrades would be applied. If combined with --output-file, the output file will contain the upgrades that would be applied. - --no-major-updates Do not suggest or apply fixes that require major version updates of direct or transitive dependencies - --output-file Path to store upgrades as a JSON file at this path. - --range-style Define how dependency version ranges are updated in package.json (default 'preserve'). - Available styles: - * pin - Use the exact version (e.g. 1.2.3) - * preserve - Retain the existing version range style as-is - --show-affected-direct-dependencies List the direct dependencies responsible for introducing transitive vulnerabilities and list the updates required to resolve the vulnerabilities - - Environment Variables (for CI/PR mode) - CI Set to enable CI mode - SOCKET_CLI_GITHUB_TOKEN GitHub token for PR creation (or GITHUB_TOKEN) - SOCKET_CLI_GIT_USER_NAME Git username for commits - SOCKET_CLI_GIT_USER_EMAIL Git email for commits - - Examples - $ socket fix - $ socket fix --id CVE-2021-23337 - $ socket fix ./path/to/project --range-style pin" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket fix\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket fix`') - }, - ) - - cmdit( - ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket fix\`, cwd: <redacted>" - `) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--autopilot', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --autopilot flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--auto-merge', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --auto-merge alias', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['fix', FLAG_DRY_RUN, '--test', FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should ignore --test flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--test-script', - 'custom-test', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should ignore --test-script flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--limit', - '5', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --limit flag with custom value', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--min-satisfying', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --min-satisfying flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--range-style', - 'invalid-style', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail with invalid range style', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('Expecting range style of') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--range-style', - 'pin', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept range style pin', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--auto-merge', - '--test', - '--limit', - '3', - '--range-style', - 'preserve', - '--min-satisfying', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept comprehensive flag combination', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--no-major-updates', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --no-major-updates flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--show-affected-direct-dependencies', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --show-affected-direct-dependencies flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--no-major-updates', - '--show-affected-direct-dependencies', - '--limit', - '5', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept new flags in combination', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show helpful error when no package.json found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - ['fix', '.', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should handle vulnerable dependencies fixture project', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - { timeout: testTimeout }, - ) - - cmdit( - ['fix', '.', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should handle monorepo fixture project', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/monorepo'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - { timeout: testTimeout }, - ) - - cmdit( - [ - 'fix', - FLAG_DRY_RUN, - '--autopilot', - '--limit', - '1', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle autopilot mode with custom limit', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'GHSA-35jh-r3h4-6jhm', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle specific GHSA ID for lodash vulnerability', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--id', - 'CVE-2021-23337', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle CVE ID conversion for lodash vulnerability', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--limit', - '1', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should respect fix limit parameter', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--range-style', - 'preserve', - '--autopilot', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle autopilot mode with preserve range style', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--range-style', - 'pin', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle pin range style for exact versions', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['fix', '--json', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should output results in JSON format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '--markdown', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output results in markdown format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - describe('vulnerability identification', () => { - cmdit( - [ - 'fix', - FLAG_ID, - 'pkg:npm/lodash@4.17.20', - '.', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle PURL-based vulnerability identification', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'GHSA-35jh-r3h4-6jhm,CVE-2021-23337', - '.', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle multiple vulnerability IDs in comma-separated format', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain('[DryRun]') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'GHSA-35jh-r3h4-6jhm', - FLAG_ID, - 'CVE-2021-23337', - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle multiple vulnerability IDs as separate flags', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - }) - - describe('autopilot mode', () => { - cmdit( - [ - 'fix', - '--limit', - '1', - '--autopilot', - FLAG_JSON, - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle autopilot mode with JSON output and custom limit', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - }) - - describe('output format handling', () => { - cmdit( - [ - 'fix', - '--range-style', - 'pin', - FLAG_MARKDOWN, - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle monorepo with pin style and markdown output', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/monorepo'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - }) - - describe('error handling and usability tests', () => { - cmdit( - [ - 'fix', - '/nonexistent/directory', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for non-existent project directory', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - ['fix', FLAG_CONFIG, '{}'], - 'should show clear error when API token is missing', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/api token|authentication|token/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'invalid-id-format', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle invalid vulnerability ID formats gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(code).toBeGreaterThan(0) - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'fix', - '--limit', - 'not-a-number', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for invalid limit parameter', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code).toBeGreaterThan(0) - }, - { timeout: testTimeout }, - ) - - cmdit( - ['fix', '--limit', '-5', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show clear error for negative limit', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Unable to resolve a Socket account organization', - ) - expect(code).toBeGreaterThan(0) - }, - { timeout: testTimeout }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'GHSA-xxxx-xxxx-xxxx', - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle non-existent GHSA IDs gracefully', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_JSON, - FLAG_MARKDOWN, - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error when both json and markdown flags are used', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - const output = stdout + stderr - expect(output).toMatch(/json.*markdown|conflicting|both.*set/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - ['fix', '--autopilot', FLAG_CONFIG, '{}'], - 'should show helpful error when using autopilot without proper auth', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/api token|authentication|github.*token/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'CVE-1234-invalid', - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle malformed CVE IDs gracefully', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - ['fix', FLAG_HELP, '--autopilot', '--limit', '5', FLAG_CONFIG, '{}'], - 'should prioritize help over other flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Fix CVEs in dependencies') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'fix', - '.', - FLAG_CONFIG, - '{"apiToken":"extremely-long-invalid-token-that-exceeds-normal-token-length-and-should-be-handled-gracefully"}', - ], - 'should handle unusually long tokens gracefully', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'fix', - FLAG_ID, - 'GHSA-1234-5678-9abc,CVE-2023-1234,pkg:npm/lodash@4.17.20,invalid-format', - '.', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle mixed valid and invalid vulnerability IDs', - async cmd => { - const { cleanup, tempDir } = await withTempFixture( - path.join(fixtureBaseDir, 'pnpm/vulnerable-deps'), - ) - cleanupFunctions.push(cleanup) - - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tempDir, - }) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-gem.test.mts b/packages/cli/test/integration/cli/cmd-gem.test.mts deleted file mode 100644 index 904625d49..000000000 --- a/packages/cli/test/integration/cli/cmd-gem.test.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Integration tests for `socket gem` wrapper command. - * - * Tests the gem package manager wrapper that adds Socket security scanning to - * Ruby package operations via Socket Firewall (sfw). Commands are forwarded to - * sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples. - * - * Security Features: - Pre-installation security scanning via Socket Firewall. - * - * Related Files: - src/commands/gem/cmd-gem.mts - gem command implementation - - * src/util/dlx/resolve-binary.mts - sfw resolution. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const GEM = 'gem' - -describe('socket gem', async () => { - cmdit( - [GEM, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run gem with Socket Firewall security - - Usage - $ socket gem ... - - Note: Everything after "gem" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for gem packages. - - Examples - $ socket gem install rails - $ socket gem list - $ socket gem update" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket gem\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket gem`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-go.test.mts b/packages/cli/test/integration/cli/cmd-go.test.mts deleted file mode 100644 index 78c482acc..000000000 --- a/packages/cli/test/integration/cli/cmd-go.test.mts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Integration tests for `socket go` wrapper command. - * - * Tests the go package manager wrapper that adds Socket security scanning to Go - * module operations via Socket Firewall (sfw). Commands are forwarded to sfw - * which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples. - * - * Security Features: - Pre-installation security scanning via Socket Firewall. - * - * Related Files: - src/commands/go/cmd-go.mts - go command implementation - - * src/util/dlx/resolve-binary.mts - sfw resolution. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const GO = 'go' - -describe('socket go', async () => { - cmdit( - [GO, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run go with Socket Firewall security - - Usage - $ socket go ... - - Note: Everything after "go" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for go packages. - Wrapper mode works best on Linux (macOS may have keychain issues). - - Examples - $ socket go get github.com/gin-gonic/gin - $ socket go install golang.org/x/tools/cmd/goimports - $ socket go mod download" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket go\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket go`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-install-completion.test.mts b/packages/cli/test/integration/cli/cmd-install-completion.test.mts deleted file mode 100644 index 4e71d4c9b..000000000 --- a/packages/cli/test/integration/cli/cmd-install-completion.test.mts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Integration tests for `socket install completion` command. - * - * Tests the bash tab completion installation for Socket CLI commands. This - * subcommand sets up shell completion to improve CLI usability. - * - * Test Coverage: - * - * - Help text display and usage examples - * - Dry-run behavior validation - * - Completion script installation - * - Shell configuration file updates - * - * Supported Shells: - * - * - Bash (via .bashrc or .bash_profile) - * - Other shells may be supported in future releases - * - * Related Files: - * - * - Src/commands/install/cmd-install-completion.mts - Command definition - * - Src/commands/install/handle-install-completion.mts - Installation logic - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket install completion', async () => { - cmdit( - ['install', 'completion', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Install bash completion for Socket CLI - - Usage - $ socket install completion [options] [NAME=socket] - - Installs bash completion for the Socket CLI. This will: - 1. Source the completion script in your current shell - 2. Add the source command to your ~/.bashrc if it's not already there - - This command will only setup tab completion, nothing else. - - Afterwards you should be able to type \`socket \` and then press tab to - have bash auto-complete/suggest the sub/command or flags. - - Currently only supports bash. - - The optional name argument allows you to enable tab completion on a command - name other than "socket". Mostly for debugging but also useful if you use a - different alias for socket on your system. - - Options - (none) - - Examples - - $ socket install completion - $ socket install completion sd - $ socket install completion ./sd" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket install completion`', - ) - }, - ) - - cmdit( - [ - 'install', - 'completion', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-install.test.mts b/packages/cli/test/integration/cli/cmd-install.test.mts deleted file mode 100644 index 9fc4882ab..000000000 --- a/packages/cli/test/integration/cli/cmd-install.test.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration tests for `socket install` root command. - * - * Tests the installation utilities root command which provides access to - * subcommands for installing optional Socket CLI features like tab completion. - * - * Test Coverage: - Help text display and subcommand listing - Dry-run behavior - * validation - Subcommand routing. - * - * Available Subcommands: - completion: Install bash completion for Socket CLI. - * - * Related Files: - src/commands/install/cmd-install.mts - Root command - * definition - src/commands/install/cmd-install-completion.mts - Completion - * installation - test/integration/cli/cmd-install-completion.test.mts - - * Completion tests. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket install', async () => { - cmdit( - ['install', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Install Socket CLI tab completion - - Usage - $ socket install <command> - - Commands - completion Install bash completion for Socket CLI - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket install`', - ) - }, - ) - - cmdit( - ['install', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-json.test.mts b/packages/cli/test/integration/cli/cmd-json.test.mts deleted file mode 100644 index 97ed40d2a..000000000 --- a/packages/cli/test/integration/cli/cmd-json.test.mts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Integration tests for `socket json` command. - * - * Tests the socket.json discovery and display command. This command shows the - * effective socket.json configuration that would be applied for a given - * directory, taking into account inheritance from parent directories. - * - * Test Coverage: - Help text display and usage examples - File discovery in - * target directory - Error handling when socket.json not found - Error handling - * for non-existent paths - File content display (raw JSON output) - Exit codes - * (0 for found, 1 for not found) - * - * Socket.json Configuration: The socket.json file controls Socket CLI behavior - * including: - Issue rules and severity thresholds - Ignored packages and - * vulnerabilities - Project-specific security policies - Workspace - * configurations for monorepos. - * - * Related Files: - src/commands/json/cmd-json.mts - Command definition - - * src/commands/json/handle-json.mts - Socket.json discovery logic - - * test/fixtures/commands/json/ - Test fixtures with socket.json files - - * docs/socket-json-schema.md - socket.json schema documentation. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket json', async () => { - cmdit( - ['json', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Display the \`socket.json\` that would be applied for target folder - - Usage - $ socket json [options] [CWD=.] - - Display the \`socket.json\` file that would apply when running relevant commands - in the target directory. - - Examples - $ socket json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket json`') - }, - ) - - cmdit( - ['json', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> - - i Target cwd: <redacted> - \\xd7 Not found: <redacted>" - `) - - expect(code, 'not found is failure').toBe(1) - }, - ) - - cmdit( - ['json', '.', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should print error when file does not exist in folder', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> - - i Target cwd: <redacted> - \\xd7 Not found: <redacted>" - `) - - expect(code, 'not found is failure').toBe(1) - }, - ) - - cmdit( - [ - 'json', - './doesnotexist', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should print an error when the path to file does not exist', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> - - i Target cwd: <redacted> - \\xd7 Not found: <redacted>" - `) - - expect(code, 'not found is failure').toBe(1) - }, - ) - - cmdit( - ['json', '.', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should print a socket.json when found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: path.join(testPath, 'fixtures/commands/json'), - }) - expect(stdout.replace(/(?:\\r|\\x0d)/g, '')).toMatchInlineSnapshot( - `"<Buffer 7b 0a 20 20 22 20 5f 5f 5f 5f 5f 20 20 20 20 20 20 20 20 20 5f 20 20 20 20 20 20 20 5f 20 20 20 20 20 22 3a 20 22 4c 6f 63 61 6c 20 63 6f 6e 66 69 67 ... 691 more bytes>"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> - - i Target cwd: <redacted> - \\u221a This is the contents of <redacted>:" - `) - - expect(code, 'found is ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-login.test.mts b/packages/cli/test/integration/cli/cmd-login.test.mts deleted file mode 100644 index f3fc84cf0..000000000 --- a/packages/cli/test/integration/cli/cmd-login.test.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Integration tests for `socket login` command. - * - * Tests the Socket API authentication flow. This command prompts for an API - * token and stores it in the local configuration for subsequent CLI - * operations. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - API base URL customization (--api-base-url) - API proxy - * configuration (--api-proxy) - Exit codes for successful/failed - * authentication. - * - * Authentication Flow: 1. Prompts user for Socket API token 2. Validates token - * with Socket API 3. Auto-discovers default organization 4. Stores credentials - * in local config. - * - * Custom API Configuration: - --api-base-url: Connect to alternative Socket API - * endpoints - --api-proxy: Route API requests through proxy server. - * - * Related Files: - src/commands/login/cmd-login.mts - Command definition - - * src/commands/login/handle-login.mts - Authentication logic - - * src/util/config.mts - Config storage utilities - src/util/api.mts - Socket - * API client. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket login', async () => { - cmdit( - ['login', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Setup Socket CLI with an API token and defaults - - Usage - $ socket login [options] - - API Token Requirements - - Quota: 1 unit - - Logs into the Socket API by prompting for an API token - - Options - --api-base-url API server to connect to for login - --api-proxy Proxy to use when making connection to API server - - Examples - $ socket login - $ socket login --api-proxy=http://localhost:1234" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket login\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket login`') - }, - ) - - cmdit( - [ - 'login', - 'mootools', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket login\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-logout.test.mts b/packages/cli/test/integration/cli/cmd-logout.test.mts deleted file mode 100644 index b2f94404d..000000000 --- a/packages/cli/test/integration/cli/cmd-logout.test.mts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Integration tests for `socket logout` command. - * - * Tests the Socket API logout flow. This command clears all stored credentials - * from the local configuration, requiring re-authentication for future API - * operations. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Credential clearing from local config - Exit codes for - * successful logout. - * - * Logout Behavior: - Removes API token from local config - Clears default - * organization setting - Does not revoke token on server (token remains valid) - * - Preserves other config settings (API URL, proxy, etc.) - * - * Security Note: This command only clears local credentials. To revoke an API - * token completely, use the Socket dashboard to delete the token. - * - * Related Files: - src/commands/logout/cmd-logout.mts - Command definition - - * src/commands/logout/handle-logout.mts - Logout logic - src/util/config.mts - - * Config management utilities. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket logout', async () => { - cmdit( - ['logout', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Socket API logout - - Usage - $ socket logout [options] - - Logs out of the Socket API and clears all Socket credentials from disk - - Examples - $ socket logout" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket logout\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket logout`', - ) - }, - ) - - cmdit( - [ - 'logout', - 'mootools', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket logout\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-auto.test.mts b/packages/cli/test/integration/cli/cmd-manifest-auto.test.mts deleted file mode 100644 index 2b6daa767..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-auto.test.mts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Integration tests for `socket manifest auto` command. - * - * Tests automatic manifest generation with ecosystem detection. This command - * analyzes the project structure and generates appropriate manifest files for - * detected ecosystems. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Ecosystem auto-detection - Multi-ecosystem project support. - * - * Supported Ecosystems: - npm (package.json) - pnpm (pnpm-lock.yaml) - yarn - * (yarn.lock) - Gradle (build.gradle, build.gradle.kts) - SBT (build.sbt) - - * Conda (environment.yml) - * - * Related Files: - src/commands/manifest/cmd-manifest-auto.mts - Command - * definition - src/commands/manifest/handle-manifest-auto.mts - Auto-detection - * logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest auto', async () => { - cmdit( - ['manifest', 'auto', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Auto-detect build and attempt to generate manifest file - - Usage - $ socket manifest auto [options] [CWD=.] - - Options - --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors - - Tries to figure out what language your target repo uses. If it finds a - supported case then it will try to generate the manifest file for that - language with the default or detected settings. - - Note: you can exclude languages from being auto-generated if you don't want - them to. Run \`socket manifest setup\` in the same dir to disable it. - - Examples - - $ socket manifest auto - $ socket manifest auto ./project/foo" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest auto`', - ) - }, - ) - - cmdit( - ['manifest', 'auto', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-cdxgen.test.mts b/packages/cli/test/integration/cli/cmd-manifest-cdxgen.test.mts deleted file mode 100644 index bb9beabef..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-cdxgen.test.mts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Integration tests for `socket manifest cdxgen` and `socket cdxgen` commands. - * - * Tests the CycloneDX SBOM generation command that wraps @cyclonedx/cdxgen. - * This command generates Software Bill of Materials for projects. - * - * Test Coverage: - Help text display via --help flag - Dry-run behavior - * (--dry-run flag) - cdxgen alias routing (socket cdxgen) - Unknown argument - * error handling - Banner and exit code validation. - * - * Related Files: - src/commands/manifest/cmd-manifest-cdxgen.mts - cdxgen - * command implementation - src/commands/manifest/run-cdxgen.mts - cdxgen - * spawning logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest cdxgen', async () => { - cmdit( - ['manifest', 'cdxgen', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // cdxgen --help is passed through to cdxgen itself. - // We check that the command runs and shows cdxgen help. - expect(stdout).toContain('cdxgen') - expect(code, 'help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['manifest', 'cdxgen', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['manifest', 'cdxgen', FLAG_DRY_RUN, '.', FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN} with path argument`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['manifest', 'cdxgen', 'unknown-fake-arg', FLAG_CONFIG, '{}'], - 'should error on unknown arguments', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Unknown argument') - expect(code, 'should exit with code 2 for invalid usage').toBe(2) - }, - ) -}) - -describe('socket cdxgen (alias)', async () => { - cmdit( - ['cdxgen', FLAG_HELP, FLAG_CONFIG, '{}'], - `should route to manifest cdxgen and support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('cdxgen') - expect(code, 'help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['cdxgen', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should route to manifest cdxgen and support ${FLAG_DRY_RUN}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-conda.test.mts b/packages/cli/test/integration/cli/cmd-manifest-conda.test.mts deleted file mode 100644 index 4ab4ca293..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-conda.test.mts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Integration tests for `socket manifest conda` command. - * - * Tests Conda environment manifest generation for Python/data science projects. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - environment.yml parsing - Dependency resolution. - * - * Related Files: - src/commands/manifest/cmd-manifest-conda.mts - Command - * definition - src/commands/manifest/handle-manifest-conda.mts - Conda manifest - * logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cleanOutput, cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest conda', async () => { - cmdit( - ['manifest', 'conda', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: testPath, - }) - expect(stdout).toMatchInlineSnapshot(` - "[beta] Convert a Conda environment.yml file to a python requirements.txt - - Usage - $ socket manifest conda [options] [CWD=.] - - Warning: While we don't support Conda necessarily, this tool extracts the pip - block from an environment.yml and outputs it as a requirements.txt - which you can scan as if it were a PyPI package. - - USE AT YOUR OWN RISK - - Note: FILE can be a dash (-) to indicate stdin. This way you can pipe the - contents of a file to have it processed. - - Options - --file Input file name (by default for Conda this is "environment.yml"), relative to cwd - --json Output as JSON - --markdown Output as Markdown - --out Output path (relative to cwd) - --stdin Read the input from stdin (supersedes --file) - --stdout Print resulting requirements.txt to stdout (supersedes --out) - --verbose Print debug messages - - Examples - - $ socket manifest conda - $ socket manifest conda ./project/foo --file environment.yaml" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest conda`', - ) - }, - ) - - cmdit( - ['manifest', 'conda', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: testPath, - }) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted> - - \\u203c Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk." - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - describe('output flags', () => { - cmdit( - [ - 'manifest', - 'conda', - 'fixtures/commands/manifest/conda', - '--stdout', - FLAG_CONFIG, - '{}', - ], - 'should print raw text without flags', - async cmd => { - const { - code: _code, - stderr, - stdout, - } = await spawnSocketCli(binCliPath, cmd, { - cwd: testPath, - }) - expect(stdout).toMatchInlineSnapshot(` - "qgrid==1.3.0 - mplstereonet - pyqt5 - gempy==2.1.0" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted> - - \\u203c Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk." - `) - }, - ) - - cmdit( - [ - 'manifest', - 'conda', - 'fixtures/commands/manifest/conda', - '--json', - '--stdout', - FLAG_CONFIG, - '{}', - ], - 'should print a json blurb with --json flag', - async cmd => { - const { stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: testPath, - }) - expect(cleanOutput(stdout)).toMatchInlineSnapshot(` - "{ - "ok": true, - "data": { - "content": "name: my_stuff\\n\\nchannels:\\n - conda-thing\\n - defaults\\ndependencies:\\n - python=3.8\\n - pandas=1.3.4\\n - numpy=1.19.0\\n - scipy\\n - mkl-service\\n - libpython\\n - m2w64-toolchain\\n - pytest\\n - requests\\n - pip\\n - pip:\\n - qgrid==1.3.0\\n - mplstereonet\\n - pyqt5\\n - gempy==2.1.0\\n", - "pip": "qgrid==1.3.0\\nmplstereonet\\npyqt5\\ngempy==2.1.0" - } - }" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - \\u203c Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk." - `) - }, - ) - - cmdit( - [ - 'manifest', - 'conda', - 'fixtures/commands/manifest/conda', - '--markdown', - '--stdout', - FLAG_CONFIG, - '{}', - ], - 'should print a markdown blurb with --markdown flag', - async cmd => { - const { - code: _code, - stderr, - stdout, - } = await spawnSocketCli(binCliPath, cmd, { - cwd: testPath, - }) - expect(cleanOutput(stdout)).toMatchInlineSnapshot(` - "# Converted Conda file - - This is the Conda \`environment.yml\` file converted to python \`requirements.txt\`: - - \`\`\`file=requirements.txt - qgrid==1.3.0 - mplstereonet - pyqt5 - gempy==2.1.0 - \`\`\`" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - \\u203c Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk." - `) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-gradle.test.mts b/packages/cli/test/integration/cli/cmd-manifest-gradle.test.mts deleted file mode 100644 index ea7cfc8d7..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-gradle.test.mts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Integration tests for `socket manifest gradle` command. - * - * Tests Gradle project manifest generation for Java/Kotlin/Android projects. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - build.gradle and build.gradle.kts parsing - Multi-module project - * support. - * - * Related Files: - src/commands/manifest/cmd-manifest-gradle.mts - Command - * definition - src/commands/manifest/handle-manifest-gradle.mts - Gradle - * manifest logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest gradle', async () => { - cmdit( - ['manifest', 'gradle', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "[beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project - - Usage - $ socket manifest gradle [options] [CWD=.] - - Options - --bin Location of gradlew binary to use, default: CWD/gradlew - --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` - --verbose Print debug messages - - Uses gradle, preferably through your local project \`gradlew\`, to generate a - \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the - global \`gradle\` binary but that may not work (hard to predict). - - The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or - or requirements.txt for PyPi), but specifically for Maven, which is Java's - dependency repository. Languages like Kotlin and Scala piggy back on it too. - - There are some caveats with the gradle to \`pom.xml\` conversion: - - - each task will generate its own xml file and by default it generates one xml - for every task. (This may be a good thing!) - - - it's possible certain features don't translate well into the xml. If you - think something is missing that could be supported please reach out. - - - it works with your \`gradlew\` from your repo and local settings and config - - Support is beta. Please report issues or give us feedback on what's missing. - - Examples - - $ socket manifest gradle . - $ socket manifest gradle --bin=../gradlew ." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest gradle`', - ) - }, - ) - - cmdit( - ['manifest', 'gradle', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-kotlin.test.mts b/packages/cli/test/integration/cli/cmd-manifest-kotlin.test.mts deleted file mode 100644 index 0f9634333..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-kotlin.test.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Integration tests for `socket manifest kotlin` command. - * - * Tests Kotlin project manifest generation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Kotlin-specific dependency resolution. - * - * Related Files: - src/commands/manifest/cmd-manifest-kotlin.mts - Command - * definition - src/commands/manifest/handle-manifest-kotlin.mts - Kotlin - * manifest logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest kotlin', async () => { - cmdit( - ['manifest', 'kotlin', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "[beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project - - Usage - $ socket manifest kotlin [options] [CWD=.] - - Options - --bin Location of gradlew binary to use, default: CWD/gradlew - --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` - --verbose Print debug messages - - Uses gradle, preferably through your local project \`gradlew\`, to generate a - \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the - global \`gradle\` binary but that may not work (hard to predict). - - The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or - or requirements.txt for PyPi), but specifically for Maven, which is Java's - dependency repository. Languages like Kotlin and Scala piggy back on it too. - - There are some caveats with the gradle to \`pom.xml\` conversion: - - - each task will generate its own xml file and by default it generates one xml - for every task. (This may be a good thing!) - - - it's possible certain features don't translate well into the xml. If you - think something is missing that could be supported please reach out. - - - it works with your \`gradlew\` from your repo and local settings and config - - Support is beta. Please report issues or give us feedback on what's missing. - - Examples - - $ socket manifest kotlin . - $ socket manifest kotlin --bin=../gradlew ." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest kotlin`', - ) - }, - ) - - cmdit( - ['manifest', 'kotlin', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-scala.test.mts b/packages/cli/test/integration/cli/cmd-manifest-scala.test.mts deleted file mode 100644 index cca654bf4..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-scala.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Integration tests for `socket manifest scala` command. - * - * Tests Scala/SBT project manifest generation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - build.sbt parsing - Multi-project build support. - * - * Related Files: - src/commands/manifest/cmd-manifest-scala.mts - Command - * definition - src/commands/manifest/handle-manifest-scala.mts - Scala/SBT - * manifest logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest scala', async () => { - cmdit( - ['manifest', 'scala', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "[beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file - - Usage - $ socket manifest scala [options] [CWD=.] - - Options - --bin Location of sbt binary to use - --out Path of output file; where to store the resulting manifest, see also --stdout - --sbt-opts Additional options to pass on to sbt, as per \`sbt --help\` - --stdout Print resulting pom.xml to stdout (supersedes --out) - --verbose Print debug messages - - Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file. - This xml file is the dependency manifest (like a package.json - for Node.js or requirements.txt for PyPi), but specifically for Scala. - - There are some caveats with \`build.sbt\` to \`pom.xml\` conversion: - - - the xml is exported as socket.pom.xml as to not confuse existing build tools - but it will first hit your /target/sbt<version> folder (as a different name) - - - the pom.xml format (standard by Scala) does not support certain sbt features - - \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\` - - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html - - - it uses your sbt settings and local configuration verbatim - - - it can only export one target per run, so if you have multiple targets like - development and production, you must run them separately. - - You can specify --bin to override the path to the \`sbt\` binary to invoke. - - Support is beta. Please report issues or give us feedback on what's missing. - - This is only for SBT. If your Scala setup uses gradle, please see the help - sections for \`socket manifest gradle\` or \`socket cdxgen\`. - - Examples - - $ socket manifest scala - $ socket manifest scala ./proj --bin=/usr/bin/sbt --file=boot.sbt" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest scala`', - ) - }, - ) - - cmdit( - ['manifest', 'scala', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest-setup.test.mts b/packages/cli/test/integration/cli/cmd-manifest-setup.test.mts deleted file mode 100644 index 07920fa05..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest-setup.test.mts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Integration tests for `socket manifest setup` command. - * - * Tests installation of manifest generation tools like cdxgen. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Tool installation verification. - * - * Related Files: - src/commands/manifest/cmd-manifest-setup.mts - Command - * definition - src/commands/manifest/handle-manifest-setup.mts - Setup logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest setup', async () => { - cmdit( - ['manifest', 'setup', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Start interactive configurator to customize default flag values for \`socket manifest\` in this dir - - Usage - $ socket manifest setup [CWD=.] - - Options - --default-on-read-error If reading the socket.json fails, just use a default config? Warning: This might override the existing json file! - - This command will try to detect all supported ecosystems in given CWD. Then - it starts a configurator where you can setup default values for certain flags - when creating manifest files in that dir. These configuration details are - then stored in a local \`socket.json\` file (which you may or may not commit - to the repo). Next time you run \`socket manifest ...\` it will load this - json file and any flags which are not explicitly set in the command but which - have been registered in the json file will get the default value set to that - value you stored rather than the hardcoded defaults. - - This helps with for example when your build binary is in a particular path - or when your build tool needs specific opts and you don't want to specify - them when running the command every time. - - You can also disable manifest generation for certain ecosystems. - - This generated configuration file will only be used locally by the CLI. You - can commit it to the repo (useful for collaboration) or choose to add it to - your .gitignore all the same. Only this CLI will use it. - - Examples - $ socket manifest setup - $ socket manifest setup ./proj" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest setup`', - ) - }, - ) - - cmdit( - ['manifest', 'setup', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-manifest.test.mts b/packages/cli/test/integration/cli/cmd-manifest.test.mts deleted file mode 100644 index f366668e0..000000000 --- a/packages/cli/test/integration/cli/cmd-manifest.test.mts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Integration tests for `socket manifest` root command. - * - * Tests the manifest generation root command which provides access to - * ecosystem-specific SBOM (Software Bill of Materials) generation. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Subcommand routing - * - * Available Subcommands: - * - * - Auto: Auto-detect and generate manifests - * - Conda: Generate conda environment manifests - * - Gradle: Generate Gradle project manifests - * - Kotlin: Generate Kotlin project manifests - * - Scala: Generate Scala/SBT project manifests - * - Setup: Install manifest generation tools - * - * Related Files: - * - * - Src/commands/manifest/cmd-manifest.mts - Root command definition - * - Src/commands/manifest/cmd-manifest-*.mts - Ecosystem-specific commands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket manifest', async () => { - cmdit( - ['manifest', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Generate a dependency manifest for certain ecosystems - - Usage - $ socket manifest <command> - - Commands - auto Auto-detect build and attempt to generate manifest file - cdxgen Run cdxgen for SBOM generation - conda [beta] Convert a Conda environment.yml file to a python requirements.txt - gradle [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project - kotlin [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project - scala [beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file - setup Start interactive configurator to customize default flag values for \`socket manifest\` in this dir - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket manifest`', - ) - }, - ) - - cmdit( - [ - 'manifest', - 'mootools', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-npm-malware.test.mts b/packages/cli/test/integration/cli/cmd-npm-malware.test.mts deleted file mode 100644 index 47eecf496..000000000 --- a/packages/cli/test/integration/cli/cmd-npm-malware.test.mts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Integration tests for `socket npm` malware detection. - * - * Tests malware scanning in npm wrapper operations. These tests verify that - * Socket detects and blocks known malicious packages before execution. - * - * Test Coverage: - Malware detection in npm install - Known malicious package - * blocking - User warnings for suspicious packages - Exit codes for malware - * detection. - * - * Security Features: - Pre-installation malware scanning - GPT-based malware - * detection (issueRules.gptMalware) - Signature-based malware detection - * (issueRules.malware) - * - * Related Files: - src/commands/wrapper/npm.mts - npm wrapper with security - * scanning - src/util/dlx/spawn.mts - Socket Firewall (sfw) spawn utilities - - * test/integration/cli/cmd-npm.test.mts - General npm wrapper tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_DRY_RUN } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket npm - malware detection with mocked packages', () => { - describe('npm exec with issueRules configuration', () => { - cmdit( - [ - 'npm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle exec with -c flag and malware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"gptMalware":true}}', - ], - 'should handle exec with -c flag and gptMalware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle exec with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle exec with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with --config should exit with code 0').toBe( - 0, - ) - }, - ) - }) - - describe('npm install with issueRules configuration', () => { - cmdit( - [ - 'npm', - 'install', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle install with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run install with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'i', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle i alias with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run i with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'install', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle install with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run install with --config should exit with code 0', - ).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-npm.test.mts b/packages/cli/test/integration/cli/cmd-npm.test.mts deleted file mode 100644 index 0b2af187b..000000000 --- a/packages/cli/test/integration/cli/cmd-npm.test.mts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Integration tests for `socket npm` wrapper command. - * - * Tests the npm package manager wrapper that adds Socket security scanning to - * npm operations. This wrapper intercepts npm commands and scans packages for - * security issues before allowing installation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * (--dry-run flag) - npm exec command with package versions - Config flag - * variants (-c vs --config) - Issue rules configuration (malware, gptMalware - * detection) - Silent mode (--silent flag) - Banner and exit code validation. - * - * Security Features Tested: - Package scanning before execution - Malware - * detection (issueRules.malware) - GPT-based malware detection - * (issueRules.gptMalware) - API token validation. - * - * Related Files: - src/commands/wrapper/npm.mts - npm wrapper implementation - - * src/util/dlx/spawn.mts - Socket Firewall (sfw) spawn utilities - - * test/integration/cli/cmd-npm-malware.test.mts - Malware-specific npm tests - - * test/integration/cli/cmd-raw-npm.test.mts - Unwrapped npm tests. - */ - -import { describe, expect } from 'vitest' - -import { NPM } from '@socketsecurity/lib-stable/constants/agents' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_SILENT, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket npm', async () => { - cmdit( - [NPM, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Wraps npm with Socket security scanning - - Usage - $ socket npm ... - - API Token Requirements - - Quota: 100 units - - Permissions: packages:list - - Note: Everything after "npm" is passed to the npm command. - Only the \`--dry-run\` and \`--help\` flags are caught here. - - Use \`socket wrapper on\` to alias this command as \`npm\`. - - Examples - $ socket npm - $ socket npm install -g cowsay - $ socket npm exec cowsay" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket npm\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket npm`') - }, - ) - - cmdit( - [NPM, FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket npm\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - FLAG_SILENT, - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle npm exec with version', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd) - expect(code, 'dry-run exec should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle npm exec with -c flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle npm exec with --config flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with --config should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle npm exec with -c flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'npm', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle npm exec with --config flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with --config and multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-npx-malware.test.mts b/packages/cli/test/integration/cli/cmd-npx-malware.test.mts deleted file mode 100644 index 30814d72e..000000000 --- a/packages/cli/test/integration/cli/cmd-npx-malware.test.mts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Integration tests for `socket npx` malware detection. - * - * Tests malware scanning in npx wrapper operations. These tests verify that - * Socket detects and blocks malicious packages before execution. - * - * Test Coverage: - Malware detection in npx execution - Known malicious package - * blocking - User warnings for suspicious packages - Exit codes for malware - * detection. - * - * Security Features: - Pre-execution malware scanning - GPT-based malware - * detection - Signature-based malware detection. - * - * Related Files: - src/commands/wrapper/npx.mts - npx wrapper with security - * scanning - src/util/dlx/spawn.mts - Socket Firewall (sfw) spawn utilities - - * test/integration/cli/cmd-npx.test.mts - General npx wrapper tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_DRY_RUN } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket pnpm exec - malware detection with mocked packages', () => { - describe('pnpm exec with issueRules configuration', () => { - cmdit( - [ - 'npx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle pnpm exec with -c flag and malware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'npx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and gptMalware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'npx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'npx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with --config should exit with code 0', - ).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-npx.test.mts b/packages/cli/test/integration/cli/cmd-npx.test.mts deleted file mode 100644 index 3beb8a47a..000000000 --- a/packages/cli/test/integration/cli/cmd-npx.test.mts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Integration tests for `socket npx` wrapper command. - * - * Tests the npx package executor wrapper that adds Socket security scanning - * before running packages. This wrapper intercepts npx commands and scans - * packages for security issues before execution. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Package execution with versions - Config flag variants - Issue - * rules configuration - Silent mode operation. - * - * Security Features: - Package scanning before execution - Malware detection - * integration - API token validation. - * - * Related Files: - src/commands/wrapper/npx.mts - npx wrapper implementation - - * src/util/dlx/spawn.mts - Socket Firewall (sfw) spawn utilities - - * test/integration/cli/cmd-npx-malware.test.mts - Malware-specific tests. - */ - -import { describe, expect } from 'vitest' - -import { NPX } from '@socketsecurity/lib-stable/constants/agents' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_SILENT, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket npx', async () => { - cmdit( - [NPX, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Wraps pnpm exec with Socket security scanning - - Usage - $ socket pnpm exec ... - - API Token Requirements - - Quota: 100 units - - Permissions: packages:list - - Note: Everything after "npx" is passed to the pnpm exec command. - Only the \`--dry-run\` and \`--help\` flags are caught here. - - Use \`socket wrapper on\` to alias this command as \`npx\`. - - Examples - $ socket pnpm exec cowsay - $ socket pnpm exec cowsay@1.6.0 hello" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket npx\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket npx`') - }, - ) - - cmdit( - [NPX, FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket npx\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'npx', - FLAG_SILENT, - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle pnpm exec with version', - async cmd => { - const { - code, - stderr: _stderr, - stdout: _stdout, - } = await spawnSocketCli(binCliPath, cmd) - expect(code, 'dry-run pnpm exec should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npx', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle pnpm exec with -c flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'npx', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle pnpm exec with --config flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with --config should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'npx', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'npx', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with --config flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with --config and multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-nuget.test.mts b/packages/cli/test/integration/cli/cmd-nuget.test.mts deleted file mode 100644 index d845bad71..000000000 --- a/packages/cli/test/integration/cli/cmd-nuget.test.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Integration tests for `socket nuget` wrapper command. - * - * Tests the nuget package manager wrapper that adds Socket security scanning to - * .NET package operations via Socket Firewall (sfw). Commands are forwarded to - * sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples. - * - * Security Features: - Pre-installation security scanning via Socket Firewall. - * - * Related Files: - src/commands/nuget/cmd-nuget.mts - nuget command - * implementation - src/util/dlx/resolve-binary.mts - sfw resolution. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const NUGET = 'nuget' - -describe('socket nuget', async () => { - cmdit( - [NUGET, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run nuget with Socket Firewall security - - Usage - $ socket nuget ... - - Note: Everything after "nuget" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for nuget packages. - - Examples - $ socket nuget install Newtonsoft.Json - $ socket nuget restore - $ socket nuget list" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket nuget\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket nuget`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-oops.test.mts b/packages/cli/test/integration/cli/cmd-oops.test.mts deleted file mode 100644 index 5ead43bb9..000000000 --- a/packages/cli/test/integration/cli/cmd-oops.test.mts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Integration tests for `socket oops` command. - * - * Tests the "oops" command which likely handles error recovery or undo - * operations. This is a utility command for fixing mistakes. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Error recovery scenarios. - * - * Related Files: - src/commands/oops/cmd-oops.mts - Command definition - - * src/commands/oops/handle-oops.mts - Error recovery logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket oops', async () => { - cmdit( - ['oops', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Trigger an intentional error (for development) - - Usage - $ socket oops oops - - Don't run me." - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket oops`') - }, - ) - - cmdit( - ['oops', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-optimize.test.mts b/packages/cli/test/integration/cli/cmd-optimize.test.mts deleted file mode 100644 index 5ea696575..000000000 --- a/packages/cli/test/integration/cli/cmd-optimize.test.mts +++ /dev/null @@ -1,545 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Integration tests for `socket optimize` command. - * - * Tests the complete CLI flow of optimizing dependencies with @socketregistry - * overrides. These tests use real CLI execution (not mocked) to verify - * end-to-end behavior. - * - * Test Coverage: - Command help and version information - Dry-run mode - * validation (no file modifications) - Flag combinations (--pin, --prod, - * --dry-run) - Output format support (JSON, markdown, text) - Custom directory - * path handling - Error handling (missing package.json, invalid paths, missing - * API tokens) - Edge cases (conflicting flags, unknown flags, comprehensive - * flag combinations) - * - * Package Manager Support: - npm: Shadow installation with security scanning - * (tested via integration) - pnpm: Standard installation with CI-mode - * configuration (tested here) - yarn: Standard installation (tested here) - * - * Note: Unit tests for mocked behavior were removed due to ESM module - * resolution limitations. These integration tests provide comprehensive - * coverage by testing real CLI execution against fixture projects. - * - * Related Files: - src/commands/optimize/handle-optimize.mts - Main command - * handler - src/commands/optimize/agent-installer.mts - Package manager install - * logic - test/unit/commands/optimize/agent-installer.test.mts - Unit tests for - * non-npm agents. - */ - -import { existsSync, promises } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterAll, afterEach, beforeAll, describe, expect } from 'vitest' - -import { NPM, PNPM } from '@socketsecurity/lib-stable/constants/agents' -import { safeMkdir } from '@socketsecurity/lib-stable/fs/safe' -import { readPackageJson } from '@socketsecurity/lib-stable/packages/operations' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_JSON, - FLAG_MARKDOWN, - FLAG_PIN, - FLAG_PROD, - FLAG_VERSION, -} from '../../../src/constants/cli.mts' -import { - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, -} from '../../../src/constants/packages.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/optimize') -const npmFixtureDir = path.join(fixtureBaseDir, NPM) -const pnpmFixtureDir = path.join(fixtureBaseDir, PNPM) - -export async function createTempFixture(sourceDir: string): Promise<string> { - // Create a temporary directory with a unique name. - const tempDir = path.join( - tmpdir(), - `socket-optimize-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ) - - // Copy the fixture files to the temp directory. - await safeMkdir(tempDir, { recursive: true }) - - // Copy package.json. - const sourcePackageJson = path.join(sourceDir, PACKAGE_JSON) - const destPackageJson = path.join(tempDir, PACKAGE_JSON) - await promises.copyFile(sourcePackageJson, destPackageJson) - - // Copy lockfile if it exists. - const sourceLockFile = path.join(sourceDir, PNPM_LOCK_YAML) - if (existsSync(sourceLockFile)) { - const destLockFile = path.join(tempDir, PNPM_LOCK_YAML) - await promises.copyFile(sourceLockFile, destLockFile) - } - - // Copy package-lock.json for npm fixtures. - const sourcePackageLock = path.join(sourceDir, PACKAGE_LOCK_JSON) - if (existsSync(sourcePackageLock)) { - const destPackageLock = path.join(tempDir, PACKAGE_LOCK_JSON) - await promises.copyFile(sourcePackageLock, destPackageLock) - } - - return tempDir -} - -async function revertFixtureChanges() { - // Reset only the package.json and pnpm-lock.yaml files that tests modify. - const cwd = process.cwd() - // Git needs the paths relative to the repository root. - const relativePackageJson = path.relative( - cwd, - path.join(pnpmFixtureDir, PACKAGE_JSON), - ) - const relativePnpmLock = path.relative( - cwd, - path.join(pnpmFixtureDir, PNPM_LOCK_YAML), - ) - // Silently ignore errors. Files may not be tracked by git, may already be - // reverted, or may not have been modified yet. This is expected behavior - // in CI environments and during initial test runs. - try { - await spawn( - 'git', - ['checkout', 'HEAD', '--', relativePackageJson, relativePnpmLock], - { - cwd, - stdio: 'ignore', - }, - ) - } catch {} -} - -describe('socket optimize', async () => { - beforeAll(async () => { - // Ensure fixtures are in clean state before tests. - await revertFixtureChanges() - }) - - afterEach(async () => { - // Revert all changes after each test using git. - await revertFixtureChanges() - }) - - afterAll(async () => { - // Clean up once after all tests. - await revertFixtureChanges() - }) - - cmdit( - ['optimize', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot( - ` - "Optimize dependencies with @socketregistry overrides - - Usage - $ socket optimize [options] [CWD=.] - - API Token Requirements - - Quota: 100 units - - Permissions: packages:list - - Options - --pin Pin overrides to latest version - --prod Add overrides for production dependencies only - - Examples - $ socket optimize - $ socket optimize ./path/to/project --pin" - `, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket optimize`', - ) - }, - ) - - cmdit( - ['optimize', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_PIN, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --pin flag', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(` - "_____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_PROD, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --prod flag', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(` - "_____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_PIN, - FLAG_PROD, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept both --pin and --prod flags together', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(` - "_____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_JSON, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --json output format', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(`""`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_MARKDOWN, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --markdown output format', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(`""`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - './custom-path', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept custom directory path', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(` - "_____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" - `) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle directories without package.json gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - // Should not modify any package.json since no package.json exists in the fixture path. - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code, 'should exit with code 1').toBe(1) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_PIN, - FLAG_PROD, - FLAG_JSON, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept comprehensive flag combination', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(`""`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - 'fixtures/commands/optimize/basic-project', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle basic project fixture', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - // Should not modify files due to version mismatch error. - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code, 'should exit with code 1').toBe(1) - }, - ) - - cmdit( - [ - 'optimize', - FLAG_DRY_RUN, - FLAG_PIN, - FLAG_PROD, - FLAG_MARKDOWN, - '.', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept pin, prod, and markdown flags together', - async cmd => { - const { code, stderr } = await spawnSocketCli(binCliPath, cmd) - // For dry-run, should not modify files. - const packageJsonPath = path.join(pnpmFixtureDir, PACKAGE_JSON) - const packageJson = await readPackageJson(packageJsonPath) - expect(packageJson.overrides).toBeUndefined() - expect(stderr).toMatchInlineSnapshot(`""`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - describe('error handling and usability tests', () => { - cmdit( - [ - 'optimize', - '/nonexistent/path', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for non-existent directory', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code).toBe(1) - }, - ) - - cmdit( - ['optimize', FLAG_DRY_RUN, '.', FLAG_CONFIG, '{}'], - 'should show clear error when API token is missing', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code, 'should exit with code 0 when no token').toBe(0) - }, - ) - - cmdit( - ['optimize', FLAG_DRY_RUN, '.', FLAG_CONFIG, '{"apiToken":""}'], - 'should show clear error when API token is empty', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code, 'should exit with code 0 with empty token').toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - '.', - FLAG_DRY_RUN, - FLAG_PIN, - FLAG_PROD, - FLAG_JSON, - FLAG_MARKDOWN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error when conflicting output flags are used', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'optimize', - '.', - FLAG_DRY_RUN, - '--unknown-flag', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show helpful error for unknown flags', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect(code).toBe(0) - }, - ) - - cmdit( - ['optimize', FLAG_PIN, FLAG_PROD, FLAG_HELP, FLAG_CONFIG, '{}'], - 'should prioritize help over other flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - expect(stdout).toContain( - 'Optimize dependencies with @socketregistry overrides', - ) - expect(code).toBe(0) - }, - ) - - cmdit( - ['optimize', FLAG_VERSION, FLAG_CONFIG, '{}'], - 'should show version information', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - expect( - code, - 'should exit with non-zero code for version mismatch', - ).toBeGreaterThan(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-dependencies.test.mts b/packages/cli/test/integration/cli/cmd-organization-dependencies.test.mts deleted file mode 100644 index 27014a013..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-dependencies.test.mts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Integration tests for `socket organization dependencies` command. - * - * Tests viewing all dependencies across an organization's repositories. - * Provides a consolidated view of package usage for security auditing. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Organization dependency listing - Output format support (JSON, - * markdown) - * - * Related Files: - src/commands/organization/cmd-organization-dependencies.mts - * - Command definition - - * src/commands/organization/handle-organization-dependencies.mts - Logic - - * src/commands/organization/output-organization-dependencies.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization dependencies', async () => { - cmdit( - ['organization', 'dependencies', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Search for any dependency that is being used in your organization - - Usage - socket organization dependencies [options] - - API Token Requirements - - Quota: 1 unit - - Options - --json Output as JSON - --limit Maximum number of dependencies returned - --markdown Output as Markdown - --offset Page number - - Examples - socket organization dependencies - socket organization dependencies --limit 20 --offset 10" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization dependencies\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization dependencies`', - ) - }, - ) - - cmdit( - [ - 'organization', - 'dependencies', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization dependencies\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-list.test.mts b/packages/cli/test/integration/cli/cmd-organization-list.test.mts deleted file mode 100644 index 32060e4a1..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-list.test.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration tests for `socket organization list` command. - * - * Tests listing all organizations accessible with the current API token. Used - * for discovering available organizations for scanning and management. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Organization listing - Output format support (JSON, markdown) - * - * Related Files: - src/commands/organization/cmd-organization-list.mts - - * Command definition - src/commands/organization/handle-organization-list.mts - - * Logic - src/commands/organization/output-organization-list.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization list', async () => { - cmdit( - ['organization', 'list', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "List organizations associated with the Socket API token - - Usage - $ socket organization list [options] - - API Token Requirements - - Quota: 1 unit - - Options - --json Output as JSON - --markdown Output as Markdown - - Examples - $ socket organization list - $ socket organization list --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization list\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization list`', - ) - }, - ) - - cmdit( - [ - 'organization', - 'list', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-policy-license.test.mts b/packages/cli/test/integration/cli/cmd-organization-policy-license.test.mts deleted file mode 100644 index 0b69b53c8..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-policy-license.test.mts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Integration tests for `socket organization policy license` command. - * - * Tests license policy management for organizations. Controls which licenses - * are allowed, warned, or blocked. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - License policy viewing and modification - Policy enforcement - * settings. - * - * Related Files: - - * src/commands/organization/cmd-organization-policy-license.mts - Command - * definition - src/commands/organization/handle-organization-policy-license.mts - * - Logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization policy license', async () => { - cmdit( - ['organization', 'policy', 'license', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Retrieve the license policy of an organization - - Usage - $ socket organization policy license [options] - - API Token Requirements - - Quota: 1 unit - - Permissions: license-policy:read - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Your API token will need the \`license-policy:read\` permission otherwise - the request will fail with an authentication error. - - Examples - $ socket organization policy license - $ socket organization policy license --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization policy license`', - ) - }, - ) - - cmdit( - ['organization', 'policy', 'license', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should reject dry run without proper args', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - " - `) - - expect(code, 'dry-run should exit with code 2 if input bad').toBe(2) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - 'license', - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - 'license', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should accept default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - 'license', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - `should accept ${FLAG_ORG} flag`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-policy-security.test.mts b/packages/cli/test/integration/cli/cmd-organization-policy-security.test.mts deleted file mode 100644 index c5be7e949..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-policy-security.test.mts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Integration tests for `socket organization policy security` command. - * - * Tests security policy management for organizations. Controls security - * thresholds and issue detection rules. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Security policy viewing and modification - Threshold - * configuration. - * - * Related Files: - - * src/commands/organization/cmd-organization-policy-security.mts - Command - * definition - - * src/commands/organization/handle-organization-policy-security.mts - Logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization policy security', async () => { - cmdit( - ['organization', 'policy', 'security', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { - code: _code, - stderr, - stdout, - } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Retrieve the security policy of an organization - - Usage - $ socket organization policy security [options] - - API Token Requirements - - Quota: 1 unit - - Permissions: security-policy:read - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Your API token will need the \`security-policy:read\` permission otherwise - the request will fail with an authentication error. - - Examples - $ socket organization policy security - $ socket organization policy security --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" - `) - - //expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization policy security`', - ) - }, - ) - - cmdit( - ['organization', 'policy', 'security', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should reject dry run without proper args', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - // expect(`\n ${stderr}`).toMatchInlineSnapshot(` - // " - // _____ _ _ /--------------- - // | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> - // |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> - // |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted> - - // \\u203c Unable to determine the target org. Trying to auto-discover it now... - // i Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. - - // \\xd7 Skipping auto-discovery of org in dry-run mode - // \\xd7 Input error: Please review the input requirements and try again - - // - You need to be logged in to use this command. See \`socket login\`. (missing API token) - // " - // `) - - expect(code, 'dry-run should exit with code 2 if input bad').toBe(2) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - 'security', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"isTestingV1": true, "apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should accept default org in v1', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - 'security', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"isTestingV1": true, "apiToken":"fakeToken"}', - ], - 'should accept --org flag in v1', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-policy.test.mts b/packages/cli/test/integration/cli/cmd-organization-policy.test.mts deleted file mode 100644 index 1d990083c..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-policy.test.mts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Integration tests for `socket organization policy` root command. - * - * Tests the policy management root command for organization security policies. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Subcommand routing - * - * Available Subcommands: - * - * - License: Manage license policies - * - Security: Manage security policies - * - * Related Files: - * - * - Src/commands/organization/cmd-organization-policy.mts - Root command - * - Src/commands/organization/cmd-organization-policy-*.mts - Subcommands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization list', async () => { - cmdit( - ['organization', 'policy', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Organization policy details - - Usage - $ socket organization policy <command> - - Commands - license Retrieve the license policy of an organization - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization policy`', - ) - }, - ) - - cmdit( - [ - 'organization', - 'policy', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should support --dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization-quota.test.mts b/packages/cli/test/integration/cli/cmd-organization-quota.test.mts deleted file mode 100644 index 22287f6ee..000000000 --- a/packages/cli/test/integration/cli/cmd-organization-quota.test.mts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Integration tests for `socket organization quota` command. - * - * Tests API quota viewing for organizations. Shows current usage and limits for - * API operations to help monitor consumption. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Quota usage display - Output format support (JSON, markdown) - * - * Related Files: - src/commands/organization/cmd-organization-quota.mts - - * Command definition - src/commands/organization/handle-organization-quota.mts - * - Logic - src/commands/organization/output-organization-quota.mts - - * Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization quota', async () => { - cmdit( - ['organization', 'quota', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "List organizations associated with the Socket API token - - Usage - $ socket organization quota [options] - - Options - --json Output as JSON - --markdown Output as Markdown - - Examples - $ socket organization quota - $ socket organization quota --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization quota\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization quota`', - ) - }, - ) - - cmdit( - [ - 'organization', - 'quota', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization quota\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-organization.test.mts b/packages/cli/test/integration/cli/cmd-organization.test.mts deleted file mode 100644 index 1faa8c24e..000000000 --- a/packages/cli/test/integration/cli/cmd-organization.test.mts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Integration tests for `socket organization` root command. - * - * Tests the organization management root command which provides access to - * organization-level operations and settings. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Subcommand routing - * - * Available Subcommands: - * - * - Dependencies: View organization dependencies - * - List: List available organizations - * - Policy: Manage security policies - * - Quota: View API quota usage - * - * Related Files: - * - * - Src/commands/organization/cmd-organization.mts - Root command definition - * - Src/commands/organization/cmd-organization-*.mts - Subcommands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket organization', async () => { - cmdit( - ['organization', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Manage Socket organization account details - - Usage - $ socket organization <command> - - Commands - dependencies Search for any dependency that is being used in your organization - list List organizations associated with the Socket API token - policy Organization policy details - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket organization`', - ) - }, - ) - - cmdit( - ['organization', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-package-score.test.mts b/packages/cli/test/integration/cli/cmd-package-score.test.mts deleted file mode 100644 index fae0d1651..000000000 --- a/packages/cli/test/integration/cli/cmd-package-score.test.mts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Integration tests for `socket package score` command. - * - * Tests retrieving Socket security scores for npm packages. The score reflects - * overall package security based on multiple issue categories. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Package spec parsing (ecosystem/name@version) - Score retrieval - * and display - Output format support (JSON, markdown) - * - * Related Files: - src/commands/package/cmd-package-score.mts - Command - * definition - src/commands/package/handle-package-score.mts - Score retrieval - * logic - src/commands/package/output-package-score.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket package score', async () => { - cmdit( - ['package', 'score', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Look up score for one package which reflects all of its transitive dependencies as well - - Usage - $ socket package score [options] <<ECOSYSTEM> <NAME> | <PURL>> - - API Token Requirements - - Quota: 100 units - - Permissions: packages:list - - Options - --json Output as JSON - --markdown Output as Markdown - - Show deep scoring details for one package. The score will reflect the package - itself, any of its dependencies, and any of its transitive dependencies. - - When you want to know whether to trust a package, this is the command to run. - - See also the \`socket package shallow\` command, which returns the shallow - score for any number of packages. That will not reflect the dependency scores. - - Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. - - A "purl" is a standard package name formatting: \`pkg:eco/name@version\` - This command will automatically prepend "pkg:" when not present. - - The version is optional but when given should be a direct match. The \`pkg:\` - prefix is optional. - - Note: if a package cannot be found it may be too old or perhaps was removed - before we had the opportunity to process it. - - Examples - $ socket package score npm babel-cli - $ socket package score npm eslint@1.0.0 --json - $ socket package score pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60 - $ socket package score nuget/needpluscommonlibrary@1.0.0 --markdown" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect( - stderr, - 'header should include command (without params)', - ).toContain('`socket package score`') - }, - ) - - cmdit( - [ - 'package', - 'score', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 First parameter must be an ecosystem or the whole purl (bad) - \\xd7 Expecting at least one package (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'package', - 'score', - 'npm', - 'babel', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-package-shallow.test.mts b/packages/cli/test/integration/cli/cmd-package-shallow.test.mts deleted file mode 100644 index 398bd5cbc..000000000 --- a/packages/cli/test/integration/cli/cmd-package-shallow.test.mts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Integration tests for `socket package shallow` command. - * - * Tests shallow package analysis which provides quick security insights without - * deep dependency traversal. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Shallow analysis retrieval - Output format support (JSON, - * markdown) - * - * Related Files: - src/commands/package/cmd-package-shallow.mts - Command - * definition - src/commands/package/handle-package-shallow.mts - Analysis logic - * - src/commands/package/output-package-shallow.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket package shallow', async () => { - cmdit( - ['package', 'shallow', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Look up info regarding one or more packages but not their transitives - - Usage - $ socket package shallow [options] <<ECOSYSTEM> <PKGNAME> [<PKGNAME> ...] | <PURL> [<PURL> ...]> - - API Token Requirements - - Quota: 100 units - - Permissions: packages:list - - Options - --json Output as JSON - --markdown Output as Markdown - - Show scoring details for one or more packages purely based on their own package. - This means that any dependency scores are not reflected by the score. You can - use the \`socket package score <pkg>\` command to get its full transitive score. - - Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. - - A "purl" is a standard package name formatting: \`pkg:eco/name@version\` - This command will automatically prepend "pkg:" when not present. - - If the first arg is an ecosystem, remaining args that are not a purl are - assumed to be scoped to that ecosystem. The \`pkg:\` prefix is optional. - - Note: if a package cannot be found, it may be too old or perhaps was removed - before we had the opportunity to process it. - - Examples - $ socket package shallow npm webtorrent - $ socket package shallow npm webtorrent@1.9.1 - $ socket package shallow npm/webtorrent@1.9.1 - $ socket package shallow pkg:npm/webtorrent@1.9.1 - $ socket package shallow maven webtorrent babel - $ socket package shallow npm/webtorrent golang/babel - $ socket package shallow npm npm/webtorrent@1.0.1 babel" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket package shallow`', - ) - }, - ) - - cmdit( - ['package', 'shallow', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 First parameter should be an ecosystem or all args must be purls (bad) - \\xd7 Expecting at least one package (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'package', - 'shallow', - 'npm', - 'babel', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-package.test.mts b/packages/cli/test/integration/cli/cmd-package.test.mts deleted file mode 100644 index e47695a99..000000000 --- a/packages/cli/test/integration/cli/cmd-package.test.mts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Integration tests for `socket package` root command. - * - * Tests the package analysis root command which provides access to - * package-specific security and metadata operations. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Package spec parsing (npm/lodash@4.17.21) - * - Subcommand routing - * - * Available Subcommands: - * - * - Score: Get package security score - * - Shallow: Get shallow package analysis - * - * Related Files: - * - * - Src/commands/package/cmd-package.mts - Root command definition - * - Src/commands/package/cmd-package-*.mts - Subcommands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket package', async () => { - cmdit( - ['package', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Look up published package details - - Usage - $ socket package <command> - - Commands - score Look up score for one package which reflects all of its transitive dependencies as well - shallow Look up info regarding one or more packages but not their transitives - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket package`', - ) - }, - ) - - cmdit( - ['package', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket package\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-get.test.mts b/packages/cli/test/integration/cli/cmd-patch-get.test.mts deleted file mode 100644 index 1183764ca..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-get.test.mts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Integration tests for `socket patch get` command. - * - * Tests retrieving and applying security patches via socket-patch v2.0.0 - * binary. - * - * Test Coverage: - Help text display and usage examples - Getting patches by - * identifier (UUID, CVE, GHSA, PURL, package name) - Error handling for missing - * identifiers. - * - * Related Files: - src/commands/patch/cmd-patch.mts - Root command that - * forwards to socket-patch. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -describe('socket patch get', async () => { - cmdit( - ['patch', 'get', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch v2.0.0 shows: "Get security patches from Socket API and apply them" - expect(stdout).toContain('Get') - expect(stdout).toContain('patches') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', 'get', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show error when identifier is not provided', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // socket-patch v2.0.0 requires an identifier argument. - expect(output).toMatch(/required|identifier|argument|missing/i) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'get', - 'nonexistent-package', - '--cwd', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle non-existent package gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // socket-patch v2.0.0 shows error when patch not found. - expect(output).toMatch(/not found|no patches|error/i) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'get', - 'on-headers', - '--cwd', - pnpmFixtureDir, - '--save-only', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support --save-only flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // With --save-only, socket-patch downloads without applying. - // May succeed or show "already patched" or error. - expect(output).toMatch( - /saved|downloaded|already|not found|error|patches/i, - ) - expect(typeof code).toBe('number') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-list.test.mts b/packages/cli/test/integration/cli/cmd-patch-list.test.mts deleted file mode 100644 index 966636d54..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-list.test.mts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Integration tests for `socket patch list` command. - * - * Tests listing all patches in the local manifest via socket-patch v2.0.0 - * binary. - * - * Test Coverage: - Help text display and usage examples - Listing patches from - * manifest - JSON output format. - * - * Related Files: - src/commands/patch/cmd-patch.mts - Root command that - * forwards to socket-patch. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -describe('socket patch list', async () => { - cmdit( - ['patch', 'list', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch v2.0.0 shows: "List all patches in the local manifest" - expect(stdout).toContain('List') - expect(stdout).toContain('manifest') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'list', - '--cwd', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle missing manifest gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // socket-patch v2.0.0 shows error when no manifest found. - expect(output).toMatch(/No .socket|manifest|not found/i) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'list', - '--cwd', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should list patches from manifest', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // Fixture has a manifest with on-headers patch. - expect(stdout).toContain('on-headers') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'list', - '--cwd', - pnpmFixtureDir, - '--json', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output patches in JSON format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch v2.0.0 outputs JSON when --json flag is used. - // Verify it's valid JSON. - let parsed: unknown - try { - parsed = JSON.parse(stdout) - } catch { - // If not valid JSON, the test should fail. - expect.fail(`Expected valid JSON output, got: ${stdout.slice(0, 200)}`) - } - expect(parsed).toBeDefined() - expect(code, 'should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-remove.test.mts b/packages/cli/test/integration/cli/cmd-patch-remove.test.mts deleted file mode 100644 index d7b72bbf5..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-remove.test.mts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Integration tests for `socket patch remove` command. - * - * Tests removing patches from the manifest via socket-patch v2.0.0 binary. - * - * Note: In socket-patch v2.0.0, the command is `remove` (not `rm`). The - * `remove` command rolls back files first and then removes from manifest. - * - * Test Coverage: - Help text display and usage examples - Removing patches by - * PURL or UUID - Error handling for missing identifiers. - * - * Related Files: - src/commands/patch/cmd-patch.mts - Root command that - * forwards to socket-patch. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -describe('socket patch remove', async () => { - cmdit( - ['patch', 'remove', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch v2.0.0 shows: "Remove a patch from the manifest by PURL or UUID" - expect(stdout).toContain('Remove') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', 'remove', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show error when identifier is not provided', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // socket-patch v2.0.0 requires an identifier argument. - expect(output).toMatch(/required|identifier|argument|missing/i) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'remove', - 'pkg:npm/nonexistent@1.0.0', - '--cwd', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle non-existent patch gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // socket-patch v2.0.0 shows error when patch not found. - expect(output).toMatch(/not found|no patch|error/i) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'remove', - 'pkg:npm/on-headers@1.0.2', - '--cwd', - pnpmFixtureDir, - '--skip-rollback', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support --skip-rollback flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // With --skip-rollback, socket-patch only updates manifest. - // May show removed, not found, or other status. - expect(output).toMatch(/removed|not found|manifest|error/i) - expect(typeof code).toBe('number') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch.test.mts b/packages/cli/test/integration/cli/cmd-patch.test.mts deleted file mode 100644 index aef8d32ff..000000000 --- a/packages/cli/test/integration/cli/cmd-patch.test.mts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Integration tests for `socket patch` root command. - * - * Tests the patch management root command which forwards to socket-patch - * v2.0.0+ (a standalone Rust binary from GitHub releases). - * - * Test Coverage: - Help text display and subcommand listing - Subcommand - * routing to socket-patch binary. - * - * Available socket-patch v2.0.0 Commands: - apply: Apply security patches from - * local manifest - get (alias: download): Get security patches from Socket API - * - list: List all patches in local manifest - remove: Remove a patch from - * manifest (replaces old 'rm') - repair (alias: gc): Download missing blobs and - * clean up - rollback: Rollback patches to restore original files - scan: Scan - * installed packages for available patches - setup: Configure package.json - * postinstall scripts. - * - * Related Files: - src/commands/patch/cmd-patch.mts - Root command that - * forwards to socket-patch. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -describe('socket patch', async () => { - describe('help display', () => { - cmdit( - ['patch', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - // Socket CLI help shows: "Manage CVE patches for dependencies" - expect(stdout).toContain('Manage CVE patches for dependencies') - expect(stderr).toContain('`socket patch`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', FLAG_CONFIG, '{}'], - 'should show help when no arguments provided', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // Without subcommand, shows Socket CLI help. - expect(stdout).toContain('Manage CVE patches for dependencies') - expect(code).toBe(0) - }, - ) - }) - - describe('subcommand forwarding', () => { - cmdit( - ['patch', 'scan', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should forward scan subcommand to socket-patch', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // socket-patch v2.0.0 scans for packages. Without node_modules it shows "No packages found". - expect(output).toMatch( - /No packages found|Found \d+ packages|patches available/i, - ) - // socket-patch scan returns 0 even when no packages found. - expect(code).toBe(0) - }, - ) - - cmdit( - ['patch', 'list', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should forward list subcommand to socket-patch', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // socket-patch v2.0.0 lists patches from manifest. May show patches or "no patches". - expect(output).toMatch(/patches|manifest|No .socket directory/i) - // Exit code depends on whether manifest exists. - expect(typeof code).toBe('number') - }, - ) - - cmdit( - ['patch', 'apply', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should forward apply subcommand to socket-patch', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // socket-patch v2.0.0 applies patches. Without manifest, shows error. - expect(output).toMatch(/Applied|No patches|manifest|nothing to apply/i) - // Exit code depends on state. - expect(typeof code).toBe('number') - }, - ) - }) - - describe('socket-patch binary help', () => { - cmdit( - ['patch', 'scan', '--help', FLAG_CONFIG, '{}'], - 'should show socket-patch scan help', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch shows its own help for subcommands. - expect(stdout).toContain('Scan') - expect(code).toBe(0) - }, - ) - - cmdit( - ['patch', 'get', '--help', FLAG_CONFIG, '{}'], - 'should show socket-patch get help', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch shows its own help for get command. - expect(stdout).toContain('Get') - expect(code).toBe(0) - }, - ) - - cmdit( - ['patch', 'remove', '--help', FLAG_CONFIG, '{}'], - 'should show socket-patch remove help', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - // socket-patch shows its own help for remove command. - expect(stdout).toContain('Remove') - expect(code).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-pip.test.mts b/packages/cli/test/integration/cli/cmd-pip.test.mts deleted file mode 100644 index 784d26070..000000000 --- a/packages/cli/test/integration/cli/cmd-pip.test.mts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Integration tests for `socket pip` and `socket pip3` wrapper commands. - * - * Tests the pip package manager wrapper that adds Socket security scanning to - * Python package operations via Socket Firewall (sfw). Commands are forwarded - * to sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples - pip and pip3 alias - * support - Dry-run behavior validation - pip install operations with scanning - * - Config flag variants - Issue rules configuration. - * - * Security Features: - Pre-installation security scanning via Socket Firewall - - * Malware detection integration. - * - * Related Files: - src/commands/pip/cmd-pip.mts - pip command implementation - - * src/util/dlx/resolve-binary.mjs - sfw resolution - - * test/integration/cli/cmd-pip-malware.test.mts - Malware tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const PIP = 'pip' -const PIP3 = 'pip3' - -describe('socket pip', async () => { - cmdit( - [PIP, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run pip with Socket Firewall security - - Usage - $ socket pip ... - - Note: Everything after "pip" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for pip packages. - - Examples - $ socket pip install flask - $ socket pip install -r requirements.txt - $ socket pip list" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket pip\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket pip`') - }, - ) -}) - -describe('socket pip3', async () => { - cmdit( - [PIP3, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run pip with Socket Firewall security - - Usage - $ socket pip ... - - Note: Everything after "pip" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for pip packages. - - Examples - $ socket pip install flask - $ socket pip install -r requirements.txt - $ socket pip list" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket pip\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket pip`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-pnpm-malware.test.mts b/packages/cli/test/integration/cli/cmd-pnpm-malware.test.mts deleted file mode 100644 index c4ec68abd..000000000 --- a/packages/cli/test/integration/cli/cmd-pnpm-malware.test.mts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Integration tests for `socket pnpm` malware detection. - * - * Tests malware scanning in pnpm operations via Socket Firewall (sfw). - * - * Test Coverage: - Malware detection in pnpm install - Known malicious package - * blocking - User warnings for suspicious packages - Exit codes for malware - * detection. - * - * Related Files: - src/commands/pnpm/cmd-pnpm.mts - pnpm command implementation - * - src/pnpm-cli.mts - pnpm CLI entry point - src/util/dlx/resolve-binary.mjs - - * sfw resolution - test/integration/cli/cmd-pnpm.test.mts - General pnpm - * wrapper tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_DRY_RUN } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket pnpm - malware detection with mocked packages', () => { - describe('pnpm exec with issueRules configuration', () => { - cmdit( - [ - 'pnpm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle pnpm exec with -c flag and malware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'pnpm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and gptMalware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'pnpm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'pnpm', - 'exec', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with --config should exit with code 0', - ).toBe(0) - }, - ) - }) - - describe('pnpm install with issueRules configuration', () => { - cmdit( - [ - 'pnpm', - 'install', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm install with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm install with -c should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'pnpm', - 'add', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm add with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm add with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'pnpm', - 'install', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm install with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm install with --config should exit with code 0', - ).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-pnpm.test.mts b/packages/cli/test/integration/cli/cmd-pnpm.test.mts deleted file mode 100644 index 431b9ab5f..000000000 --- a/packages/cli/test/integration/cli/cmd-pnpm.test.mts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Integration tests for `socket pnpm` wrapper command. - * - * Tests the pnpm package manager wrapper that adds Socket security scanning to - * pnpm operations via Socket Firewall (sfw). Commands are forwarded to sfw - * which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - pnpm install operations with scanning - Config flag variants - - * Issue rules configuration. - * - * Security Features: - Pre-installation security scanning via Socket Firewall - - * Malware detection integration - Workspace support. - * - * Related Files: - src/commands/pnpm/cmd-pnpm.mts - pnpm command implementation - * - src/pnpm-cli.mts - pnpm CLI entry point - src/util/dlx/resolve-binary.mjs - - * sfw resolution - test/integration/cli/cmd-pnpm-malware.test.mts - Malware - * tests. - */ - -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import { PNPM } from '@socketsecurity/lib-stable/constants/agents' -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_SILENT, - FLAG_VERSION, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -import type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' - -// Known issue: Several exec/install tests currently fail due to config flag handling. -// Needs investigation and fix for proper config isolation in pnpm wrapper tests. -describe('socket pnpm', async () => { - cmdit( - [PNPM, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot( - ` - "Run pnpm with Socket Firewall security - - Usage - $ socket pnpm ... - - API Token Requirements - (none) - - Note: Everything after "pnpm" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for pnpm packages. - - Use \`socket wrapper on\` to alias this command as \`pnpm\`. - - Examples - $ socket pnpm - $ socket pnpm install - $ socket pnpm add package-name - $ socket pnpm exec package-name" - \`, - ) - expect(\`\n ${stderr}`, - ).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket pnpm\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket pnpm`') - }, - ) - - cmdit( - [PNPM, FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(stderr).toContain('CLI') - expect(code, 'dry-run without args should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'add', - 'lodash', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle add with --dry-run flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run add should exit with code 0').toBe(0) - }, - ) - - cmdit( - [PNPM, 'install', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should handle install with --dry-run flag', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run install should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'add', - '@types/node@^20.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle scoped packages with version', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run add scoped package should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'dlx', - FLAG_SILENT, - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle dlx with version', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run dlx should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle exec with issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'exec', - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - ], - 'should handle exec with --config flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with --config should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle exec with multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'exec', - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - ], - 'should handle exec with --config flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with --config and multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'install', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle install with issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run install should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'install', - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - FLAG_DRY_RUN, - ], - 'should handle install with --config flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run install with --config should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'install', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle install with multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run install with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - PNPM, - 'install', - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - FLAG_DRY_RUN, - ], - 'should handle install with --config flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run install with --config and multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - it('should work when invoked via pnpm dlx', { timeout: 30_000 }, async () => { - // Mock spawn to avoid actual pnpm dlx execution. - const spawnMock = vi - .fn() - .mockImplementation( - async (command: string, args: string[], options: SpawnOptions) => { - // Simulate successful pnpm dlx execution. - if (command === PNPM && args[0] === 'dlx') { - // Simulate cowsay output if cowsay is being run. - if (args.some(a => a.includes('cowsay'))) { - return { - code: 0, - stdout: ` - _______ -< hello > - ------- - \\ ^__^ - \\ (oo)\\_______ - (__)\\ )\\/\\ - ||----w | - || || -`.trim(), - stderr: '', - } - } - - return { - code: 0, - stdout: 'Socket CLI executed successfully via pnpm dlx', - stderr: '', - } - } - // Fallback to original spawn for other commands. - return await spawn(command, args, options) - }, - ) - - // Create a temporary directory for testing. - const tmpDir = path.join(tmpdir(), `pnpm-dlx-test-${Date.now()}`) - await fs.mkdir(tmpDir, { recursive: true }) - - try { - // Create a minimal package.json. - await fs.writeFile( - path.join(tmpDir, 'package.json'), - JSON.stringify({ name: 'test-pnpm-dlx', version: '1.0.0' }), - ) - - // Run socket pnpm via pnpm dlx (mocked). - const { code, stdout } = await spawnMock( - PNPM, - ['dlx', '@socketsecurity/cli@latest', PNPM, FLAG_VERSION], - { - cwd: tmpDir, - env: { - ...process.env, - SOCKET_CLI_ACCEPT_RISKS: '1', - }, - timeout: 60_000, - }, - ) - - // Check that the command succeeded. - expect(code, 'pnpm exec socket pnpm should exit with code 0').toBe(0) - expect(stdout).toContain('Socket CLI executed successfully') - } finally { - // Clean up the temporary directory. - await safeDelete(tmpDir) - } - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-pycli.test.mts b/packages/cli/test/integration/cli/cmd-pycli.test.mts deleted file mode 100644 index e83936959..000000000 --- a/packages/cli/test/integration/cli/cmd-pycli.test.mts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Integration tests for `socket pycli` command. - * - * Tests the Python CLI (socketsecurity) passthrough command that provides - * explicit access to Python CLI features not yet available in the Node.js CLI. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * (--dry-run flag) - Python CLI features documentation - Banner and exit code - * validation. - * - * Related Files: - src/commands/pycli/cmd-pycli.mts - pycli command - * implementation - src/util/python/standalone.mts - Python CLI spawning. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket pycli', async () => { - cmdit( - ['pycli', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Run Socket Python CLI') - expect(stdout).toContain('socketsecurity') - expect(stdout).toContain('Usage') - expect(stdout).toContain('Python CLI Features') - expect(stdout).toContain('--generate-license') - expect(stdout).toContain('--enable-sarif') - expect(stdout).toContain('--strict-blocking') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['pycli', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['pycli', FLAG_DRY_RUN, '--generate-license', FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN} with Python CLI flags`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-raw-npm.test.mts b/packages/cli/test/integration/cli/cmd-raw-npm.test.mts deleted file mode 100644 index eaf1e3776..000000000 --- a/packages/cli/test/integration/cli/cmd-raw-npm.test.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration tests for `socket raw-npm` command. - * - * Tests running npm without Socket security scanning wrapper. This command - * provides an escape hatch for operations that must bypass scanning. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Unwrapped npm execution - Pass-through of npm flags and - * arguments. - * - * Use Cases: - Testing npm behavior without Socket intervention - CI/CD - * scenarios requiring unwrapped npm - Debugging wrapper-related issues. - * - * Related Files: - src/commands/wrapper/raw-npm.mts - Unwrapped npm command - - * test/integration/cli/cmd-npm.test.mts - Wrapped npm tests. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket raw-npm', async () => { - cmdit( - ['raw-npm', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run npm without the Socket wrapper - - Usage - $ socket raw-npm ... - - Execute \`npm\` without gating installs through the Socket API. - Useful when \`socket wrapper on\` is enabled and you want to bypass - the Socket wrapper. Use at your own risk. - - Note: Everything after "raw-npm" is passed to the npm command. - Only the \`--dry-run\` and \`--help\` flags are caught here. - - Examples - $ socket raw-npm install -g cowsay" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npm\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket raw-npm`', - ) - }, - ) - - cmdit( - ['raw-npm', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npm\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-raw-npx.test.mts b/packages/cli/test/integration/cli/cmd-raw-npx.test.mts deleted file mode 100644 index eed86631f..000000000 --- a/packages/cli/test/integration/cli/cmd-raw-npx.test.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Integration tests for `socket raw-npx` command. - * - * Tests running npx without Socket security scanning wrapper. This command - * provides an escape hatch for operations that must bypass scanning. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Unwrapped npx execution - Pass-through of npx flags and - * arguments. - * - * Use Cases: - Testing npx behavior without Socket intervention - CI/CD - * scenarios requiring unwrapped npx - Debugging wrapper-related issues. - * - * Related Files: - src/commands/wrapper/raw-npx.mts - Unwrapped npx command - - * test/integration/cli/cmd-npx.test.mts - Wrapped npx tests. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket raw-npx', async () => { - cmdit( - ['raw-npx', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run pnpm exec without the Socket wrapper - - Usage - $ socket raw-npx ... - - Execute \`npx\` without gating installs through the Socket API. - Useful when \`socket wrapper on\` is enabled and you want to bypass - the Socket wrapper. Use at your own risk. - - Note: Everything after "raw-npx" is passed to the pnpm exec command. - Only the \`--dry-run\` and \`--help\` flags are caught here. - - Examples - $ socket raw-npx cowsay" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npx\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket raw-npx`', - ) - }, - ) - - cmdit( - ['raw-npx', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npx\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository-create.test.mts b/packages/cli/test/integration/cli/cmd-repository-create.test.mts deleted file mode 100644 index 1d5028a44..000000000 --- a/packages/cli/test/integration/cli/cmd-repository-create.test.mts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Integration tests for `socket repository create` command. - * - * Tests registering new repositories with Socket for continuous monitoring. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Repository registration - GitHub/GitLab integration setup. - * - * Related Files: - src/commands/repository/cmd-repository-create.mts - Command - * definition - src/commands/repository/handle-repository-create.mts - - * Registration logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository create', async () => { - cmdit( - ['repository', 'create', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Create a repository in an organization - - Usage - $ socket repository create [options] <REPO> - - API Token Requirements - - Quota: 1 unit - - Permissions: repo:create - - The REPO name should be a "slug". Follows the same naming convention as GitHub. - - Options - --default-branch Repository default branch. Defaults to "main" - --homepage Repository url - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --repo-description Repository description - --visibility Repository visibility (Default Private) - - Examples - $ socket repository create test-repo - $ socket repository create our-repo --homepage=socket.dev --default-branch=trunk" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository create`', - ) - }, - ) - - cmdit( - ['repository', 'create', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Repository name as first argument (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'create', - 'a', - 'b', - FLAG_ORG, - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'repository', - 'create', - 'reponame', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\u221a Repository name as first argument" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'create', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should only report missing repo name with default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'create', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should only report missing repo name with --org flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'create', - 'fakerepo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should run to dryrun', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository-del.test.mts b/packages/cli/test/integration/cli/cmd-repository-del.test.mts deleted file mode 100644 index ef3fc2218..000000000 --- a/packages/cli/test/integration/cli/cmd-repository-del.test.mts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Integration tests for `socket repository del` command. - * - * Tests unregistering repositories from Socket monitoring. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Repository removal - Confirmation prompts. - * - * Related Files: - src/commands/repository/cmd-repository-del.mts - Command - * definition - src/commands/repository/handle-repository-del.mts - Removal - * logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository del', async () => { - cmdit( - ['repository', 'del', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Delete a repository in an organization - - Usage - $ socket repository del [options] <REPO> - - API Token Requirements - - Quota: 1 unit - - Permissions: repo:delete - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Examples - $ socket repository del test-repo" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository del`', - ) - }, - ) - - cmdit( - ['repository', 'del', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Repository name as first argument (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'del', - 'a', - 'b', - FLAG_ORG, - 'xyz', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'repository', - 'del', - 'reponame', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\u221a Repository name as first argument" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'del', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should only report missing repo name with default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'del', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should only report missing repo name with --org flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'del', - 'fakerepo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should run to dryrun', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository-list.test.mts b/packages/cli/test/integration/cli/cmd-repository-list.test.mts deleted file mode 100644 index 7fbd974ad..000000000 --- a/packages/cli/test/integration/cli/cmd-repository-list.test.mts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Integration tests for `socket repository list` command. - * - * Tests listing all registered repositories for an organization. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Repository listing - Output format support (JSON, markdown) - * - * Related Files: - src/commands/repository/cmd-repository-list.mts - Command - * definition - src/commands/repository/handle-repository-list.mts - Listing - * logic - src/commands/repository/output-repository-list.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository list', async () => { - cmdit( - ['repository', 'list', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "List repositories in an organization - - Usage - $ socket repository list [options] - - API Token Requirements - - Quota: 1 unit - - Permissions: repo:list - - Options - --all By default view shows the last n repos. This flag allows you to fetch the entire list. Will ignore --page and --per-page. - --direction Direction option - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --page Page number - --per-page Number of results per page - --sort Sorting option - - Examples - $ socket repository list - $ socket repository list --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository list`', - ) - }, - ) - - cmdit( - ['repository', 'list', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'list', - 'a', - FLAG_ORG, - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'repository', - 'list', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'list', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should accept default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'repository', - 'list', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - `should accept ${FLAG_ORG} flag`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository-update.test.mts b/packages/cli/test/integration/cli/cmd-repository-update.test.mts deleted file mode 100644 index f501be958..000000000 --- a/packages/cli/test/integration/cli/cmd-repository-update.test.mts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Integration tests for `socket repository update` command. - * - * Tests updating repository settings and configurations. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Settings modification - Branch protection rules. - * - * Related Files: - src/commands/repository/cmd-repository-update.mts - Command - * definition - src/commands/repository/handle-repository-update.mts - Update - * logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository update', async () => { - cmdit( - ['repository', 'update', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Update a repository in an organization - - Usage - $ socket repository update [options] <REPO> - - API Token Requirements - - Quota: 1 unit - - Permissions: repo:update - - Options - --default-branch Repository default branch - --homepage Repository url - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --repo-description Repository description - --visibility Repository visibility (Default Private) - - Examples - $ socket repository update test-repo - $ socket repository update test-repo --homepage https://example.com" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository update`', - ) - }, - ) - - cmdit( - ['repository', 'update', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Repository name as first argument (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'update', - 'reponame', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\u221a Repository name as first argument" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'update', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should only report missing repo name with default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'update', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should only report missing repo name with --org flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'update', - 'fakerepo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should run to dryrun', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository-view.test.mts b/packages/cli/test/integration/cli/cmd-repository-view.test.mts deleted file mode 100644 index 0372a7403..000000000 --- a/packages/cli/test/integration/cli/cmd-repository-view.test.mts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Integration tests for `socket repository view` command. - * - * Tests viewing detailed repository information and scan history. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Repository details display - Output format support (JSON, - * markdown) - * - * Related Files: - src/commands/repository/cmd-repository-view.mts - Command - * definition - src/commands/repository/handle-repository-view.mts - View logic - * - src/commands/repository/output-repository-view.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository view', async () => { - cmdit( - ['repository', 'view', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "View repositories in an organization - - Usage - $ socket repository view [options] <REPO> - - API Token Requirements - - Quota: 1 unit - - Permissions: repo:list - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Examples - $ socket repository view test-repo - $ socket repository view test-repo --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository view`', - ) - }, - ) - - cmdit( - ['repository', 'view', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Repository name as first argument (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'view', - 'a', - 'b', - FLAG_ORG, - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'repository', - 'view', - 'reponame', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\u221a Repository name as first argument" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'view', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should only report missing repo name with default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'view', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should only report missing repo name with --org flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Repository name as first argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'repository', - 'view', - 'fakerepo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should run to dryrun', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-repository.test.mts b/packages/cli/test/integration/cli/cmd-repository.test.mts deleted file mode 100644 index a2d9ee82c..000000000 --- a/packages/cli/test/integration/cli/cmd-repository.test.mts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Integration tests for `socket repository` root command. - * - * Tests the repository management root command for GitHub/GitLab integrations. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Subcommand routing - * - * Available Subcommands: - * - * - Create: Register new repository - * - Del: Unregister repository - * - List: List registered repositories - * - Update: Update repository settings - * - View: View repository details - * - * Related Files: - * - * - Src/commands/repository/cmd-repository.mts - Root command definition - * - Src/commands/repository/cmd-repository-*.mts - Subcommands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket repository', async () => { - cmdit( - ['repository', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Manage registered repositories - - Usage - $ socket repository <command> - - Commands - create Create a repository in an organization - del Delete a repository in an organization - list List repositories in an organization - update Update a repository in an organization - view View repositories in an organization - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket repository`', - ) - }, - ) - - cmdit( - ['repository', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket repository\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['repo', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support repo alias with ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Manage registered repositories - - Usage - $ socket repository <command> - - Commands - create Create a repository in an organization - del Delete a repository in an organization - list List repositories in an organization - update Update a repository in an organization - view View repositories in an organization - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(stderr).toContain('`socket repository`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-create.test.mts b/packages/cli/test/integration/cli/cmd-scan-create.test.mts deleted file mode 100644 index 8970e8d3c..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-create.test.mts +++ /dev/null @@ -1,866 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, - constants, -} from '../../../src/constants.mts' -import { cmdit, spawnSocketCli, testPath } from '../../../test/utils.mts' - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/create') - -describe('socket scan create', async () => { - const { binCliPath } = constants - - cmdit( - ['scan', 'create', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Create a new Socket scan and report - - Usage - $ socket scan create [options] [TARGET...] - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:create - - Options - --auto-manifest Run \`socket manifest auto\` before collecting manifest files. This is necessary for languages like Scala, Gradle, and Kotlin, See \`socket manifest auto --help\`. - --branch Branch name - --commit-hash Commit hash - --commit-message Commit message - --committers Committers - --cwd working directory, defaults to process.cwd() - --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --pull-request Pull request number - --reach Run tier 1 full application reachability analysis - --read-only Similar to --dry-run except it can read from remote, stops before it would create an actual report - --repo Repository name - --report Wait for the scan creation to complete, then basically run \`socket scan report\` on it - --report-level Which policy level alerts should be reported (default 'error') - --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. - --tmp Set the visibility (true/false) of the scan in your dashboard. - - Reachability Options (when --reach is used) - --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. - --reach-analysis-timeout Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly. - --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level that ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. NPM reachability analysis does not support concurrent execution, so the concurrency level is ignored for NPM. - --reach-debug Enable debug mode for reachability analysis. Provides verbose logging from the reachability CLI. - --reach-disable-analysis-splitting Limits Coana to at most 1 reachability analysis run per workspace. - --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. - --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. - --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. - --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. - - Uploads the specified dependency manifest files for Go, Gradle, JavaScript, - Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". - If any folder is specified, the ones found in there recursively are uploaded. - - Details on TARGET: - - - Defaults to the current dir (cwd) if none given - - Multiple targets can be specified - - If a target is a file, only that file is checked - - If it is a dir, the dir is scanned for any supported manifest files - - Dirs MUST be within the current dir (cwd), you can use --cwd to change it - - Supports globbing such as "**/package.json", "**/requirements.txt", etc. - - Ignores any file specified in your project's ".gitignore" - - Also a sensible set of default ignores from the "ignore-by-default" module - - The --repo and --branch flags tell Socket to associate this Scan with that - repo/branch. The names will show up on your dashboard on the Socket website. - - Note: for a first run you probably want to set --default-branch to indicate - the default branch name, like "main" or "master". - - The "alerts page" (https://socket.dev/dashboard/org/YOURORG/alerts) will show - the results from the last scan designated as the "pending head" on the branch - configured on Socket to be the "default branch". When creating a scan the - --set-as-alerts-page flag will default to true to update this. You can prevent - this by using --no-set-as-alerts-page. This flag is ignored for any branch that - is not designated as the "default branch". It is disabled when using --tmp. - - You can use \`socket scan setup\` to configure certain repo flag defaults. - - Examples - $ socket scan create - $ socket scan create ./proj --json - $ socket scan create --repo=test-repo --branch=main ./package.json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | * | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan create`', - ) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | * | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--exclude-paths', - 'tests', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when --exclude-paths is used without --reach', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'should exit with code 0 when --exclude-paths is used standalone', - ).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach/npm', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--exclude-paths', - 'tests', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when --exclude-paths is used with --reach', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0 when all flags are valid').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-disable-analytics', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-disable-analytics is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-analysis-memory-limit', - '8192', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when --reach-analysis-memory-limit is used with default value without --reach', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0 when using default value').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-analysis-memory-limit', - '4096', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-analysis-memory-limit is used with non-default value without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-analysis-timeout', - '3600', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-analysis-timeout is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-ecosystems', - 'npm', - '--reach-ecosystems', - 'pypi', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-ecosystems is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-disable-analytics', - '--reach-analysis-memory-limit', - '4096', - '--reach-analysis-timeout', - '3600', - '--reach-ecosystems', - 'npm', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when reachability options are used with --reach', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0 when all flags are valid').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-exclude-paths', - 'node_modules', - '--reach-exclude-paths', - 'dist', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-exclude-paths is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-disable-analytics', - '--reach-analysis-memory-limit', - '4096', - '--reach-analysis-timeout', - '3600', - '--reach-ecosystems', - 'npm', - '--reach-exclude-paths', - 'node_modules', - '--reach-exclude-paths', - 'dist', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when all reachability options including reachExcludePaths are used with --reach', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0 when all flags are valid').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-ecosystems', - 'npm,pypi,cargo', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when --reach-ecosystems is used with comma-separated values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'should exit with code 0 when comma-separated values are used', - ).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-exclude-paths', - 'node_modules,dist,build', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when --reach-exclude-paths is used with comma-separated values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'should exit with code 0 when comma-separated values are used', - ).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-ecosystems', - 'npm,pypi', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-ecosystems with comma-separated values is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'target', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach-exclude-paths', - 'node_modules,dist', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-exclude-paths with comma-separated values is used without --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - 'Reachability analysis flags require --reach to be enabled', - ) - expect(output).toContain('add --reach flag to use --reach-* options') - expect( - code, - 'should exit with non-zero code when validation fails', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-ecosystems', - 'npm,invalid-ecosystem', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --reach-ecosystems contains invalid values', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('(saw: "invalid-ecosystem")') - expect( - code, - 'should exit with non-zero code when invalid ecosystem is provided', - ).not.toBe(0) - }, - ) - - cmdit( - ['scann', 'create', FLAG_HELP], - 'should suggest similar command for typos', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('Unknown command "scann". Did you mean "scan"?') - expect( - code, - 'should exit with non-zero code when command is not found', - ).toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'create', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_ORG, - 'test-org', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show helpful error message for directories with no manifest files', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('found no eligible files to scan') - expect(output).toContain('docs.socket.dev') - expect(output).toContain('manifest-file-detection-in-socket') - expect( - code, - 'should exit with non-zero code when no files found', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-analysis-memory-limit', - '1', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed with minimal positive reachability memory limit', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-analysis-timeout', - '0', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed with zero timeout (unlimited)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-ecosystems', - 'npm,invalid,pypi', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when invalid ecosystem mixed with valid ones in --reach mode', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('(saw: "invalid")') - expect( - code, - 'should exit with non-zero code when invalid ecosystem provided', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--reach-ecosystems', - 'npm', - '--reach-exclude-paths', - 'vendor,build,dist,target', - '--reach-analysis-memory-limit', - '16384', - '--reach-analysis-timeout', - '7200', - '--reach-disable-analytics', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed with comprehensive reachability configuration', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0 when all flags are valid').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--json', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed with --reach and --json output format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed with --reach and --markdown output format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when both --json and --markdown are used with --reach', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('The json and markdown flags cannot be both set') - expect( - code, - 'should exit with non-zero code when conflicting flags are used', - ).not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'create', - FLAG_ORG, - 'fakeOrg', - 'test/fixtures/commands/scan/reach', - FLAG_DRY_RUN, - '--repo', - 'xyz', - '--branch', - 'abc', - '--reach', - '--read-only', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should succeed when combining --reach with --read-only', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-del.test.mts b/packages/cli/test/integration/cli/cmd-scan-del.test.mts deleted file mode 100644 index 3b2b7c96a..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-del.test.mts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Integration tests for `socket scan del` command. - * - * Tests deleting scans from Socket. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Scan deletion - Confirmation prompts. - * - * Related Files: - src/commands/scan/cmd-scan-del.mts - Command definition - - * src/commands/scan/handle-scan-del.mts - Deletion logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan del', async () => { - cmdit( - ['scan', 'del', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Delete a scan - - Usage - $ socket scan del [options] <SCAN_ID> - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:delete - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Examples - $ socket scan del [UUID] - $ socket scan del [UUID] --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan del`', - ) - }, - ) - - cmdit( - ['scan', 'del', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Scan ID to delete (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'del', - FLAG_ORG, - 'fakeOrg', - 'scanidee', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-diff.test.mts b/packages/cli/test/integration/cli/cmd-scan-diff.test.mts deleted file mode 100644 index 00203f13f..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-diff.test.mts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Integration tests for `socket scan diff` command. - * - * Tests comparing two scans to identify security changes. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Scan comparison - Diff output formatting - New/resolved issue - * identification. - * - * Related Files: - src/commands/scan/cmd-scan-diff.mts - Command definition - - * src/commands/scan/handle-scan-diff.mts - Diff computation logic - - * src/commands/scan/output-scan-diff.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan diff', async () => { - cmdit( - ['scan', 'diff', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "See what changed between two Scans - - Usage - $ socket scan diff [options] <SCAN_ID1> <SCAN_ID2> - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:list - - This command displays the package changes between two scans. The full output - can be pretty large depending on the size of your repo and time range. It is - best stored to disk (with --json) to be further analyzed by other tools. - - Note: While it will work in any order, the first Scan ID is assumed to be the - older ID, even if it is a newer Scan. This is only relevant for the - added/removed list (similar to diffing two files with git). - - Options - --depth Max depth of JSON to display before truncating, use zero for no limit (without --json/--file) - --file Path to a local file where the output should be saved. Use \`-\` to force stdout. - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Examples - $ socket scan diff [UUID] [UUID] - $ socket scan diff [UUID] [UUID] --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan diff`', - ) - }, - ) - - cmdit( - ['scan', 'diff', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Specify two Scan IDs. (missing both Scan IDs) - A Scan ID looks like \`[UUID]\`. - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'diff', - FLAG_ORG, - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - 'x', - 'y', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-github.test.mts b/packages/cli/test/integration/cli/cmd-scan-github.test.mts deleted file mode 100644 index c3a2c2cbb..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-github.test.mts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Integration tests for `socket scan github` command. - * - * Tests GitHub integration features for scan management. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - GitHub PR commenting - Check run creation. - * - * Related Files: - src/commands/scan/cmd-scan-github.mts - Command definition - - * src/commands/scan/handle-scan-github.mts - GitHub integration logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan github', async () => { - cmdit( - ['scan', 'github', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Create a scan for given GitHub repo - - Usage - $ socket scan github [options] [CWD=.] - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:create - - This is similar to the \`socket scan create\` command except it pulls the files - from GitHub. See the help for that command for more details. - - A GitHub Personal Access Token (PAT) will at least need read access to the repo - ("contents", read-only) for this command to work. - - Note: This command cannot run the \`socket manifest auto\` things because that - requires local access to the repo while this command runs entirely through the - GitHub for file access. - - You can use \`socket scan setup\` to configure certain repo flag defaults. - - Options - --all Apply for all known repositories reported by the Socket API. Supersedes \`repos\`. - --github-api-url Base URL of the GitHub API (default: https://api.github.com) - --github-token Required GitHub token for authentication. - May set environment variable GITHUB_TOKEN or SOCKET_CLI_GITHUB_TOKEN instead. - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --org-github Alternate GitHub Org if the name is different than the Socket Org - --repos List of repos to target in a comma-separated format (e.g., repo1,repo2). If not specified, the script will pull the list from Socket and ask you to pick one. Use --all to use them all. - - Examples - $ socket scan github - $ socket scan github ./proj" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan github`', - ) - }, - ) - - cmdit( - ['scan', 'github', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 This command requires a GitHub API token for access (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'github', - 'fakeOrg', - FLAG_DRY_RUN, - '--github-token', - 'fake', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - 'x', - 'y', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-list.test.mts b/packages/cli/test/integration/cli/cmd-scan-list.test.mts deleted file mode 100644 index 93e61166e..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-list.test.mts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Integration tests for `socket scan list` command. - * - * Tests listing all scans for a repository or organization. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Scan listing - Filtering options - Output format support (JSON, - * markdown) - * - * Related Files: - src/commands/scan/cmd-scan-list.mts - Command definition - - * src/commands/scan/handle-scan-list.mts - Listing logic - - * src/commands/scan/output-scan-list.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan list', async () => { - cmdit( - ['scan', 'list', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "List the scans for an organization - - Usage - $ socket scan list [options] [REPO [BRANCH]] - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:list - - Optionally filter by REPO. If you specify a repo, you can also specify a - branch to filter by. (Note: If you don't specify a repo then you must use - \`--branch\` to filter by branch across all repos). - - Options - --branch Filter to show only scans with this branch name - --direction Direction option (\`desc\` or \`asc\`) - Default is \`desc\` - --from-time From time - as a unix timestamp - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --page Page number - Default is 1 - --per-page Results per page - Default is 30 - --sort Sorting option (\`name\` or \`created_at\`) - default is \`created_at\` - --until-time Until time - as a unix timestamp - - Examples - $ socket scan list - $ socket scan list webtools badbranch --markdown" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan list`', - ) - }, - ) - - cmdit( - ['scan', 'list', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (dot is an invalid org, most likely you forgot the org name here?) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'list', - FLAG_ORG, - 'fakeOrg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-metadata.test.mts b/packages/cli/test/integration/cli/cmd-scan-metadata.test.mts deleted file mode 100644 index e22c4c242..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-metadata.test.mts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Integration tests for `socket scan metadata` command. - * - * Tests viewing scan metadata and configuration. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Metadata display - Output format support. - * - * Related Files: - src/commands/scan/cmd-scan-metadata.mts - Command definition - * - src/commands/scan/handle-scan-metadata.mts - Metadata retrieval logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan metadata', async () => { - cmdit( - ['scan', 'metadata', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Get a scan's metadata - - Usage - $ socket scan metadata [options] <SCAN_ID> - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:list - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - - Examples - $ socket scan metadata [UUID] - $ socket scan metadata [UUID] --json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan metadata`', - ) - }, - ) - - cmdit( - ['scan', 'metadata', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - \\xd7 Scan ID to inspect as argument (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'metadata', - FLAG_ORG, - 'fakeOrg', - 'scanidee', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts b/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts deleted file mode 100644 index 8b77da451..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts +++ /dev/null @@ -1,624 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Integration tests for `socket scan reach` dry-run mode. - * - * Tests reachability analysis in dry-run mode. This is one of three test files - * for reach command (dry-run, validation, execution). - * - * Test Coverage: - Dry-run behavior validation - Flag parsing without execution - * - Input validation in dry-run mode. - * - * Note: This test suite was split from cmd-scan-reach.test.mts to improve test - * performance and reduce CI bottlenecks. - * - * Related Files: - src/commands/scan/cmd-scan-reach.mts - Command definition - - * test/integration/cli/cmd-scan-reach-validation.test.mts - Validation tests - - * test/integration/cli/cmd-scan-reach-execution.test.mts - Execution tests. - */ - -import path from 'node:path' - -import { describe, expect, it } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/reach') - -describe('socket scan reach - dry-run tests', async () => { - cmdit( - ['scan', 'reach', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Compute tier 1 reachability - - Usage - $ socket scan reach [options] [CWD=.] - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:create - - Options - --cwd working directory, defaults to process.cwd() - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. - - Reachability Options - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. - --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. - --reach-analysis-timeout Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly. - --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. - --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Defaults to all ecosystems. - --reach-exclude-paths List of paths to exclude from reachability analysis, as either a comma separated value or as multiple flags. - --reach-min-severity Set the minimum severity of vulnerabilities to analyze. Supported severities are info, low, moderate, high and critical. - --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous runs to speed up the analysis. - --reach-use-unreachable-from-precomputation Use unreachable information from precomputation to improve analysis accuracy. - - Runs the Socket reachability analysis without creating a scan in Socket. - The output is written to .socket.facts.json in the current working directory - unless the --output flag is specified. - - Note: Manifest files are uploaded to Socket's backend services because the - reachability analysis requires creating a Software Bill of Materials (SBOM) - from these files before the analysis can run. - - Examples - $ socket scan reach - $ socket scan reach ./proj - $ socket scan reach ./proj --reach-ecosystems npm,pypi - $ socket scan reach --output custom-report.json - $ socket scan reach ./proj --output ./reports/analysis.json" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan reach`', - ) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--org', - 'fakeOrg', - '--reach-disable-analytics', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --reach-disable-analytics flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - '4096', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --reach-analysis-memory-limit flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-timeout', - '3600', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --reach-analysis-timeout flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-ecosystems', - 'npm,pypi', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --reach-ecosystems with comma-separated values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-ecosystems', - 'npm', - '--reach-ecosystems', - 'pypi', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept multiple --reach-ecosystems flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code, 'should exit with code 0').toBe(0) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - '--reach-ecosystems', - 'invalid-ecosystem', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail with invalid ecosystem', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('(saw: "invalid-ecosystem")') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-exclude-paths', - 'node_modules,dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --reach-exclude-paths with comma-separated values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-exclude-paths', - 'node_modules', - '--reach-exclude-paths', - 'dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept multiple --reach-exclude-paths flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--exclude-paths', - 'node_modules,dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --exclude-paths with comma-separated values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--exclude-paths', - 'node_modules', - '--exclude-paths', - 'dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept multiple --exclude-paths flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--exclude-paths', - 'build', - '--reach-exclude-paths', - 'node_modules,dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --exclude-paths together with --reach-exclude-paths', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--exclude-paths', - '!tests/keep', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should reject --exclude-paths negation patterns', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain( - "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", - ) - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-disable-analytics', - '--reach-analysis-memory-limit', - '4096', - '--reach-analysis-timeout', - '3600', - '--reach-ecosystems', - 'npm,pypi', - '--reach-exclude-paths', - 'node_modules,dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept all reachability flags together', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - '1', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept minimal positive memory limit', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-ecosystems', - 'npm', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle single ecosystem flag', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-exclude-paths', - 'path1', - '--reach-exclude-paths', - 'path2', - '--reach-exclude-paths', - 'path3', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept many exclude paths flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-ecosystems', - 'npm', - '--reach-ecosystems', - 'pypi', - '--reach-ecosystems', - 'cargo', - '--reach-ecosystems', - 'maven', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept multiple different ecosystems', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - '1024', - '--reach-analysis-timeout', - '300', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept custom memory limit and timeout values', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - '--reach-ecosystems', - 'npm,invalid1,pypi,invalid2', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when mixed valid and invalid ecosystems are provided', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('(saw: "invalid1")') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--json', - '--markdown', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when both json and markdown output flags are used', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('The json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--json', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept json output flag alone', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--markdown', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept markdown output flag alone', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - it( - 'should accept comprehensive reachability configuration in dry-run: `scan reach --dry-run --reach-analysis-memory-limit 16384 --reach-analysis-timeout 7200 --reach-ecosystems npm --reach-exclude-paths node_modules --org fakeOrg --config {"apiToken":"fakeToken"}`', - { timeout: 30_000 }, - async () => { - const cmd = [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - '16384', - '--reach-analysis-timeout', - '7200', - '--reach-ecosystems', - 'npm', - '--reach-exclude-paths', - 'node_modules', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ] - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-reach-execution.test.mts b/packages/cli/test/integration/cli/cmd-scan-reach-execution.test.mts deleted file mode 100644 index 2a186ad84..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-reach-execution.test.mts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Integration tests for `socket scan reach` execution mode. - * - * Tests reachability analysis execution. This is one of three test files for - * reach command (dry-run, validation, execution). - * - * Test Coverage: - Reachability analysis execution - Issue detection - Output - * generation - Real scan scenarios. - * - * Note: This test suite was split from cmd-scan-reach.test.mts to improve test - * performance and reduce CI bottlenecks. - * - * Related Files: - src/commands/scan/cmd-scan-reach.mts - Command definition - - * test/integration/cli/cmd-scan-reach-dry-run.test.mts - Dry-run tests - - * test/integration/cli/cmd-scan-reach-validation.test.mts - Validation tests. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/reach') - -describe('socket scan reach - execution tests', () => { - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle reach analysis on test fixture', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - // Should fail due to fake token/org, but validates command parsing. - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-ecosystems', - 'npm', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle npm ecosystem specification', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-analysis-memory-limit', - '2048', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle custom memory limit', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-analysis-timeout', - '1800', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle custom timeout', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-exclude-paths', - 'node_modules,dist', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle path exclusions', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-disable-analytics', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle analytics disabled', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-skip-cache', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle cache skipping', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--json', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle JSON output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - // JSON output typically suppresses banner in stderr. - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--markdown', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle markdown output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - // Markdown output typically suppresses banner in stderr. - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-ecosystems', - 'npm', - '--reach-analysis-memory-limit', - '2048', - '--reach-exclude-paths', - 'node_modules', - '--reach-disable-analytics', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle comprehensive flag combination', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should work with bundled ANSI utilities (regression test for stripAnsi compatibility)', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - // Regression test for bundling issues where strip-ansi@6.0.1 was incompatible - // with ansi-regex@6.2.2, causing "stripAnsi22 is not a function" errors. - // Should fail due to fake token/org, but validates that ANSI utilities work. - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - // Verify output exists and contains ANSI escape sequences. - expect(output.length).toBeGreaterThan(0) - // Should not contain "is not a function" errors from bundled code. - expect(output).not.toContain('is not a function') - expect(output).not.toContain('stripAnsi') - expect(output).not.toContain('ansiRegex') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-reach-validation.test.mts b/packages/cli/test/integration/cli/cmd-scan-reach-validation.test.mts deleted file mode 100644 index cbabdcd77..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-reach-validation.test.mts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Integration tests for `socket scan reach` validation logic. - * - * Tests input validation and error handling for reachability analysis. This is - * one of three test files for reach command (dry-run, validation, execution). - * - * Test Coverage: - Input validation - Error handling - Flag compatibility - * checks - Path validation. - * - * Note: This test suite was split from cmd-scan-reach.test.mts to improve test - * performance and reduce CI bottlenecks. - * - * Related Files: - src/commands/scan/cmd-scan-reach.mts - Command definition - - * test/integration/cli/cmd-scan-reach-dry-run.test.mts - Dry-run tests - - * test/integration/cli/cmd-scan-reach-execution.test.mts - Execution tests. - */ - -import path from 'node:path' - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/scan/reach') - -describe('socket scan reach - validation tests', () => { - describe('output path tests', () => { - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--output', - 'custom-report.json', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --output flag with .json extension', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '-o', - 'report.json', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept -o short flag with .json extension', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--output', - './reports/analysis.json', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should accept --output flag with path', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--output', - 'report.txt', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --output does not end with .json', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('The --output path must end with .json') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--output', - 'report', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --output has no extension', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('The --output path must end with .json') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--output', - 'report.JSON', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should fail when --output ends with .JSON (uppercase)', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('The --output path must end with .json') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - }) - - describe('error handling and usability tests', () => { - cmdit( - [ - 'scan', - 'reach', - '/nonexistent/directory', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for non-existent directory', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch( - /no eligible files|file.*dir.*must contain|not.*found|directory must exist/i, - ) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - ['scan', 'reach', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should show clear error when API token is missing', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/api token|authentication|token/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--org', - '', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error when org is empty', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/organization|org/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - 'not-a-number', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for invalid memory limit', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]: Bailing now') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-memory-limit', - '-1', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for negative memory limit', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]: Bailing now') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-timeout', - 'invalid-timeout', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for invalid timeout value', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]: Bailing now') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_DRY_RUN, - '--reach-analysis-timeout', - '0', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for zero timeout', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('[DryRun]: Bailing now') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-analysis-memory-limit', - '999999999', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle extremely large memory limit values', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-exclude-paths', - '', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle empty exclude paths gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - FLAG_HELP, - '--reach-ecosystems', - 'npm', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{}', - ], - 'should prioritize help over other flags', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Compute tier 1 reachability') - expect(code).toBe(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-ecosystems', - 'npm,invalid-ecosystem,pypi', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show clear error for mixed valid and invalid ecosystems', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toMatch(/invalid.*ecosystem.*invalid-ecosystem/i) - expect(code).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-exclude-paths', - '/absolute/path,relative/path,../parent/path', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle various path formats in exclude paths', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - FLAG_CONFIG, - '{"apiToken":"invalid-token-with-special-chars-!@#$%^&*()"}', - '--org', - 'fakeOrg', - ], - 'should handle tokens with special characters gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - - cmdit( - [ - 'scan', - 'reach', - 'test/fixtures/commands/scan/reach', - '--reach-ecosystems', - 'npm', - '--reach-ecosystems', - 'npm', - '--reach-ecosystems', - 'npm', - '--org', - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle duplicate ecosystem flags gracefully', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(code).toBeGreaterThan(0) - const output = stdout + stderr - expect(output.length).toBeGreaterThan(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-report.test.mts b/packages/cli/test/integration/cli/cmd-scan-report.test.mts deleted file mode 100644 index 0039f106a..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-report.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Integration tests for `socket scan report` command. - * - * Tests generating security scan reports. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Report generation - Output format support (JSON, markdown, HTML) - * - Policy compliance checking. - * - * Related Files: - src/commands/scan/cmd-scan-report.mts - Command definition - - * src/commands/scan/handle-scan-report.mts - Report generation logic - - * src/commands/scan/output-scan-report.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan report', async () => { - cmdit( - ['scan', 'report', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Check whether a scan result passes the organizational policies (security, license) - - Usage - $ socket scan report [options] <SCAN_ID> [OUTPUT_PATH] - - API Token Requirements - - Quota: 2 units - - Permissions: full-scans:list and security-policy:read - - Options - --fold Fold reported alerts to some degree (default 'none') - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --license Also report the license policy status. Default: false - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --report-level Which policy level alerts should be reported (default 'warn') - --short Report only the healthy status - - When no output path is given the contents is sent to stdout. - - By default the result is a nested object that looks like this: - \`{ - [ecosystem]: { - [pkgName]: { - [version]: { - [file]: { - [line:col]: alert - }}}}\` - So one alert for each occurrence in every file, version, etc, a huge response. - - You can --fold these up to given level: 'pkg', 'version', 'file', and 'none'. - For example: \`socket scan report --fold=version\` will dedupe alerts to only - show one alert of a particular kind, no matter how often it was found in a - file or in how many files it was found. At most one per version that has it. - - By default only the warn and error policy level alerts are reported. You can - override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') - - Short responses look like this: - --json: \`{healthy:bool}\` - --markdown: \`healthy = bool\` - neither: \`OK/ERR\` - - Examples - $ socket scan report [UUID] --json --fold=version - $ socket scan report [UUID] --license --markdown --short" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan report`', - ) - }, - ) - - cmdit( - ['scan', 'report', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (dot is an invalid org, most likely you forgot the org name here?) - \\xd7 Scan ID to report on (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'report', - 'org', - 'report-id', - FLAG_DRY_RUN, - FLAG_ORG, - 'fakeOrg', - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should be ok with org name and id', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-setup.test.mts b/packages/cli/test/integration/cli/cmd-scan-setup.test.mts deleted file mode 100644 index b32276fdb..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-setup.test.mts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Integration tests for `socket scan setup` command. - * - * Tests configuring scan settings for projects. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Socket.json creation/modification - Scan configuration options. - * - * Related Files: - src/commands/scan/cmd-scan-setup.mts - Command definition - - * src/commands/scan/handle-scan-setup.mts - Setup logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan setup', async () => { - cmdit( - ['scan', 'setup', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Start interactive configurator to customize default flag values for \`socket scan\` in this dir - - Usage - $ socket scan setup [options] [CWD=.] - - Options - --default-on-read-error If reading the socket.json fails, just use a default config? Warning: This might override the existing json file! - - Interactive configurator to create a local json file in the target directory - that helps to set flag defaults for \`socket scan create\`. - - This helps to configure the (Socket reported) repo and branch names, as well - as which branch name is the "default branch" (main, master, etc). This way - you don't have to specify these flags when creating a scan in this dir. - - This generated configuration file will only be used locally by the CLI. You - can commit it to the repo (useful for collaboration) or choose to add it to - your .gitignore all the same. Only this CLI will use it. - - Examples - - $ socket scan setup - $ socket scan setup ./proj" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan setup\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan setup`', - ) - }, - ) - - cmdit( - [ - 'scan', - 'setup', - 'fakeOrg', - 'scanidee', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan setup\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan-view.test.mts b/packages/cli/test/integration/cli/cmd-scan-view.test.mts deleted file mode 100644 index 3075feba7..000000000 --- a/packages/cli/test/integration/cli/cmd-scan-view.test.mts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Integration tests for `socket scan view` command. - * - * Tests viewing detailed scan results. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Scan details display - Issue breakdown - Output format support - * (JSON, markdown) - * - * Related Files: - src/commands/scan/cmd-scan-view.mts - Command definition - - * src/commands/scan/handle-scan-view.mts - View logic - - * src/commands/scan/output-scan-view.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan view', async () => { - cmdit( - ['scan', 'view', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "View the raw results of a scan - - Usage - $ socket scan view [options] <SCAN_ID> [OUTPUT_FILE] - - API Token Requirements - - Quota: 1 unit - - Permissions: full-scans:list - - When no output path is given the contents is sent to stdout. - - Options - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --stream Only valid with --json. Streams the response as "ndjson" (chunks of valid json blobs). - - Examples - $ socket scan view [UUID] - $ socket scan view [UUID] ./stream.txt" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket scan view`', - ) - }, - ) - - cmdit( - ['scan', 'view', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (dot is an invalid org, most likely you forgot the org name here?) - \\xd7 Scan ID to view (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'scan', - 'view', - FLAG_ORG, - 'fakeOrg', - 'scanidee', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-scan.test.mts b/packages/cli/test/integration/cli/cmd-scan.test.mts deleted file mode 100644 index 25b1fd122..000000000 --- a/packages/cli/test/integration/cli/cmd-scan.test.mts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Integration tests for `socket scan` root command. - * - * Tests the scan management root command for creating and managing security - * scans. - * - * Test Coverage: - * - * - Help text display and subcommand listing - * - Dry-run behavior validation - * - Subcommand routing - * - * Available Subcommands: - * - * - Create: Create new security scan - * - Del: Delete scan - * - Diff: Compare scans - * - Github: GitHub integration - * - List: List scans - * - Metadata: View scan metadata - * - Reach: Reachability analysis - * - Report: Generate scan reports - * - Setup: Setup scan configuration - * - View: View scan details - * - * Related Files: - * - * - Src/commands/scan/cmd-scan.mts - Root command definition - * - Src/commands/scan/cmd-scan-*.mts - Subcommands - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket scan', async () => { - cmdit( - ['scan', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Manage Socket scans - - Usage - $ socket scan <command> - - Commands - create Create a new Socket scan and report - del Delete a scan - diff See what changed between two Scans - list List the scans for an organization - metadata Get a scan's metadata - report Check whether a scan result passes the organizational policies (security, license) - setup Start interactive configurator to customize default flag values for \`socket scan\` in this dir - view View the raw results of a scan - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket scan`') - }, - ) - - cmdit( - ['scan', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket scan\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-sfw.test.mts b/packages/cli/test/integration/cli/cmd-sfw.test.mts deleted file mode 100644 index c0f8ac869..000000000 --- a/packages/cli/test/integration/cli/cmd-sfw.test.mts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Integration tests for `socket sfw` and `socket firewall` commands. - * - * Tests the Socket Firewall (sfw) command that provides direct access to the - * Socket Firewall tool for intercepting package manager commands. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * (--dry-run flag) - Firewall alias routing - Error handling for missing - * package manager - Banner and exit code validation. - * - * Related Files: - src/commands/sfw/cmd-sfw.mts - sfw command implementation - - * src/util/dlx/spawn.mts - DLX spawning for sfw. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket sfw', async () => { - cmdit( - ['sfw', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Run Socket Firewall directly') - expect(stdout).toContain('Usage') - expect(stdout).toContain('<package-manager>') - expect(stdout).toContain('Supported Package Managers') - expect(stdout).toContain('npm, npx, pnpm, yarn, pip') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['sfw', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN} without package manager`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['sfw', FLAG_DRY_RUN, 'npm', 'install', 'lodash', FLAG_CONFIG, '{}'], - `should support ${FLAG_DRY_RUN} with npm command`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['sfw', FLAG_CONFIG, '{}'], - 'should error when no package manager specified', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('No package manager command specified') - expect(code, 'should exit with code 2').toBe(2) - }, - ) -}) - -describe('socket firewall (alias)', async () => { - cmdit( - ['firewall', FLAG_HELP, FLAG_CONFIG, '{}'], - `should route to sfw and support ${FLAG_HELP}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Run Socket Firewall directly') - expect(stdout).toContain('alias: firewall') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['firewall', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - `should route to sfw and support ${FLAG_DRY_RUN}`, - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expectDryRunOutput(stdout) - expect(code, 'dry-run should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['firewall', FLAG_CONFIG, '{}'], - 'should error when no package manager specified (via alias)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('No package manager command specified') - expect(code, 'should exit with code 2').toBe(2) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-threat-feed.test.mts b/packages/cli/test/integration/cli/cmd-threat-feed.test.mts deleted file mode 100644 index 87b7ad79e..000000000 --- a/packages/cli/test/integration/cli/cmd-threat-feed.test.mts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Integration tests for `socket threat-feed` command. - * - * Tests the beta threat feed feature for viewing recent security threats. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Threat feed retrieval - Filtering options - Output format - * support (JSON, markdown) - * - * Beta Feature: This command is in beta and subject to changes. - * - * Related Files: - src/commands/threat-feed/cmd-threat-feed.mts - Command - * definition - src/commands/threat-feed/handle-threat-feed.mts - Feed retrieval - * logic - src/commands/threat-feed/output-threat-feed.mts - Formatting. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_ORG, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket threat-feed', async () => { - cmdit( - ['threat-feed', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "[Beta] View the threat-feed - - Usage - $ socket threat-feed [options] [ECOSYSTEM] [TYPE_FILTER] - - API Token Requirements - - Quota: 1 unit - - Permissions: threat-feed:list - - Special access - - This feature requires a Threat Feed license. Please contact - sales@socket.dev (mailto:sales@socket.dev) if you are interested in purchasing this access. - - Options - --direction Order asc or desc by the createdAt attribute - --eco Only show threats for a particular ecosystem - --filter Filter what type of threats to return - --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. - --json Output as JSON - --markdown Output as Markdown - --org Force override the organization slug, overrides the default org from config - --page Page token - --per-page Number of items per page - --pkg Filter by this package name - --version Filter by this package version - - Valid ecosystems: - - - gem - - golang - - maven - - npm - - nuget - - pypi - - Valid type filters: - - - anom Anomaly - - c Do not filter - - fp False Positives - - joke Joke / Fake - - mal Malware and Possible Malware [default] - - secret Secrets - - spy Telemetry - - tp False Positives and Unreviewed - - typo Typo-squat - - u Unreviewed - - vuln Vulnerability - - Note: if you filter by package name or version, it will do so for anything - unless you also filter by that ecosystem and/or package name. When in - doubt, look at the threat-feed and see the names in the name/version - column. That's what you want to search for. - - You can put filters as args instead, we'll try to match the strings with the - correct filter type but since this would not allow you to search for a package - called "mal", you can also specify the filters through flags. - - First arg that matches a typo, eco, or version enum is used as such. First arg - that matches none of them becomes the package name filter. Rest is ignored. - - Note: The version filter is a prefix search, pkg name is a substring search. - - Examples - $ socket threat-feed - $ socket threat-feed maven --json - $ socket threat-feed typo - $ socket threat-feed npm joke 1.0.0 --per-page=5 --page=2 --direction=asc" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket threat-feed`', - ) - }, - ) - - cmdit( - ['threat-feed', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing) - " - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'threat-feed', - FLAG_ORG, - 'boo', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - ['threat-feed', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should report missing org name', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted> - - \\u203c Unable to determine the target org. Trying to auto-discover it now... - i Note: Run \`socket login\` to set a default org. - Use the --org flag to override the default org. - - \\xd7 Skipping auto-discovery of org in dry-run mode - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Org name by default setting, --org, or auto-discovered (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - [ - 'threat-feed', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken", "defaultOrg": "fakeOrg"}', - ], - 'should accept default org', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) - - cmdit( - [ - 'threat-feed', - FLAG_ORG, - 'forcedorg', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - `should accept ${FLAG_ORG} flag`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-uninstall-completion.test.mts b/packages/cli/test/integration/cli/cmd-uninstall-completion.test.mts deleted file mode 100644 index d770be102..000000000 --- a/packages/cli/test/integration/cli/cmd-uninstall-completion.test.mts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Integration tests for `socket uninstall completion` command. - * - * Tests removing bash tab completion for Socket CLI. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Completion script removal - Shell configuration cleanup. - * - * Related Files: - src/commands/uninstall/cmd-uninstall-completion.mts - - * Command definition - src/commands/uninstall/handle-uninstall-completion.mts - - * Removal logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket uninstall completion', async () => { - cmdit( - ['uninstall', 'completion', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Uninstall bash completion for Socket CLI - - Usage - $ socket uninstall completion [options] [COMMAND_NAME=socket] - - Uninstalls bash tab completion for the Socket CLI. This will: - 1. Remove tab completion from your current shell for given command - 2. Remove the setup for given command from your ~/.bashrc - - The optional name is required if you installed tab completion for an alias - other than the default "socket". This will NOT remove the command, only the - tab completion that is registered for it in bash. - - Options - (none) - - Examples - - $ socket uninstall completion - $ socket uninstall completion sd" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall completion\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket uninstall completion`', - ) - }, - ) - - cmdit( - [ - 'uninstall', - 'completion', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall completion\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-uninstall.test.mts b/packages/cli/test/integration/cli/cmd-uninstall.test.mts deleted file mode 100644 index 73ab6e70a..000000000 --- a/packages/cli/test/integration/cli/cmd-uninstall.test.mts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Integration tests for `socket uninstall` root command. - * - * Tests the uninstallation utilities root command for removing Socket CLI - * features. - * - * Test Coverage: - Help text display and subcommand listing - Dry-run behavior - * validation - Subcommand routing. - * - * Available Subcommands: - completion: Uninstall bash completion. - * - * Related Files: - src/commands/uninstall/cmd-uninstall.mts - Root command - * definition - src/commands/uninstall/cmd-uninstall-completion.mts - Completion - * removal. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket uninstall', async () => { - cmdit( - ['uninstall', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Uninstall Socket CLI tab completion - - Usage - $ socket uninstall <command> - - Commands - completion Uninstall bash completion for Socket CLI - - Options - - --no-banner Hide the Socket banner - --no-spinner Hide the console spinner" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket uninstall`', - ) - }, - ) - - cmdit( - ['uninstall', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot( - `"[DryRun]: No-op, call a sub-command; ok"`, - ) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-uv.test.mts b/packages/cli/test/integration/cli/cmd-uv.test.mts deleted file mode 100644 index e375d5505..000000000 --- a/packages/cli/test/integration/cli/cmd-uv.test.mts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Integration tests for `socket uv` wrapper command. - * - * Tests the uv package manager wrapper that adds Socket security scanning to - * Python package operations via Socket Firewall (sfw). Commands are forwarded - * to sfw which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - uv operations with scanning - Config flag variants - Issue rules - * configuration. - * - * Security Features: - Pre-installation security scanning via Socket Firewall - - * Malware detection integration. - * - * Related Files: - src/commands/uv/cmd-uv.mts - uv command implementation - - * src/util/dlx/resolve-binary.mjs - sfw resolution - - * test/integration/cli/cmd-uv-malware.test.mts - Malware tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const UV = 'uv' - -describe('socket uv', async () => { - cmdit( - [UV, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run uv with Socket Firewall security - - Usage - $ socket uv ... - - Note: Everything after "uv" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for uv packages. - - Examples - $ socket uv pip install flask - $ socket uv pip sync - $ socket uv run script.py" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket uv\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket uv`') - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-whoami.test.mts b/packages/cli/test/integration/cli/cmd-whoami.test.mts deleted file mode 100644 index 7bdc19f38..000000000 --- a/packages/cli/test/integration/cli/cmd-whoami.test.mts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Integration tests for `socket whoami` command. - * - * Tests checking Socket CLI authentication status. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Authentication status display - User information retrieval - - * Token validation. - * - * Related Files: - src/commands/whoami/cmd-whoami.mts - Complete command - * implementation. - */ - -/** - * @file Tests for whoami command. Validates authentication status display with - * various token sources. - */ - -import { afterEach, beforeEach, describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_HELP, - FLAG_JSON, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket whoami', () => { - let originalEnv: NodeJS.ProcessEnv - - beforeEach(() => { - // Save original environment. - originalEnv = { ...process.env } - }) - - afterEach(() => { - // Restore original environment. - process.env = originalEnv - }) - - describe('help output', () => { - cmdit(['whoami', FLAG_HELP], 'should show help', async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - expect(code).toBe(0) - expect(stdout).toContain('whoami') - expect(stdout).toContain('Check') // "Check Socket CLI authentication status" or "Check if you are authenticated" - expect(stdout).toContain('Examples') - }) - }) - - describe('authenticated with API token', () => { - // Test token - not a real API key. - const testToken = 'sktsec_test123456789' - - cmdit( - ['whoami', FLAG_CONFIG, `{"apiToken":"${testToken}"}`], - 'should show authenticated status', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - expect(code).toBe(0) - expect(stderr).toContain('Authenticated with Socket') - expect(stdout).toContain('Token: sktsec_') // Token info is in stdout - expect(stdout).toContain('Source:') // Source info is in stdout - }, - ) - - cmdit( - ['whoami', FLAG_JSON, FLAG_CONFIG, `{"apiToken":"${testToken}"}`], - 'should output JSON format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - expect(code).toBe(0) - expect(stdout).toContain('"authenticated"') // JSON has spaces after colons - expect(stdout).toContain('true') - expect(stdout).toContain('"token"') - expect(stdout).toContain('"location"') - expect(stderr).toBe('') - }, - ) - }) - - describe('not authenticated', () => { - cmdit( - ['whoami', FLAG_CONFIG, '{}'], - 'should show not authenticated', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { - ...process.env, - // Explicitly unset any API token environment variables. - SOCKET_SECURITY_API_KEY: '', - SOCKET_CLI_API_TOKEN: '', - }, - }) - - expect(code).toBe(0) - expect(stderr).toContain('Not authenticated with Socket') - expect(stdout).toContain('To authenticate') // Instructions are in stdout - expect(stdout).toContain('socket login') - }, - ) - - cmdit( - ['whoami', FLAG_JSON, FLAG_CONFIG, '{}'], - 'should output JSON format when not authenticated', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - env: { - ...process.env, - SOCKET_SECURITY_API_KEY: '', - SOCKET_CLI_API_TOKEN: '', - }, - }) - - expect(code).toBe(0) - expect(stdout).toContain('"authenticated"') // JSON has spaces after colons - expect(stdout).toContain('false') - expect(stdout).toContain('"token"') - expect(stdout).toContain('null') - expect(stderr).toBe('') - }, - ) - }) - - describe('token display', () => { - cmdit( - [ - 'whoami', - FLAG_CONFIG, - '{"apiToken":"sktsec_abcdefghijklmnopqrstuvwxyz"}', - ], - 'should mask token after prefix', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - expect(code).toBe(0) - expect(stdout).toContain('Token: sktsec_') - expect(stdout).toContain('...') - // Should not contain full token. - expect(stdout).not.toContain('abcdefghijklmnopqrstuvwxyz') - }, - ) - }) - - describe('token source detection', () => { - cmdit( - ['whoami', FLAG_CONFIG, '{"apiToken":"sktsec_from_config"}'], - 'should detect config file source', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - expect(code).toBe(0) - expect(stdout).toContain('Source:') - expect(stdout).toContain('Config file') - }, - ) - }) - - describe('error handling', () => { - cmdit( - ['whoami', '--invalid-flag'], - 'should ignore invalid flags gracefully', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd) - - // CLI ignores unknown flags and continues successfully. - expect(code).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-wrapper.test.mts b/packages/cli/test/integration/cli/cmd-wrapper.test.mts deleted file mode 100644 index 7110f014f..000000000 --- a/packages/cli/test/integration/cli/cmd-wrapper.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Integration tests for `socket wrapper` command. - * - * Tests enabling/disabling Socket npm/npx wrappers globally. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Wrapper activation (on) - Wrapper deactivation (off) - Status - * checking. - * - * Wrapper Feature: When enabled, aliases `npm` and `npx` commands to `socket - * npm` and `socket npx` in the shell, applying security scanning to all package - * operations. - * - * Related Files: - src/commands/wrapper/cmd-wrapper.mts - Command definition - - * src/commands/wrapper/handle-wrapper.mts - Wrapper management logic. - */ - -import { describe, expect } from 'vitest' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket wrapper', async () => { - cmdit( - ['wrapper', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Enable or disable the Socket npm/pnpm exec wrapper - - Usage - $ socket wrapper <"on" | "off"> - - Options - (none) - - While enabled, the wrapper makes it so that when you call npm/pnpm exec on your - machine, it will automatically actually run \`socket npm\` / \`socket npx\` - instead. - - Examples - $ socket wrapper on - $ socket wrapper off" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain( - '`socket wrapper`', - ) - }, - ) - - cmdit( - ['wrapper', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(`""`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted> - - \\xd7 Input error: Please review the input requirements and try again - - \\xd7 Must specify "on" or "off" argument (missing)" - `) - - expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) - }, - ) - - cmdit( - ['wrapper', FLAG_DRY_RUN, 'on', FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted>" - `) - - expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-yarn-malware.test.mts b/packages/cli/test/integration/cli/cmd-yarn-malware.test.mts deleted file mode 100644 index 0c6ea0c5b..000000000 --- a/packages/cli/test/integration/cli/cmd-yarn-malware.test.mts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Integration tests for `socket yarn` malware detection. - * - * Tests malware scanning in Yarn operations via Socket Firewall (sfw). - * - * Test Coverage: - Malware detection in yarn install - Known malicious package - * blocking - User warnings for suspicious packages - Exit codes for malware - * detection. - * - * Related Files: - src/commands/yarn/cmd-yarn.mts - yarn command implementation - * - src/yarn-cli.mts - yarn CLI entry point - src/util/dlx/resolve-binary.mjs - - * sfw resolution - test/integration/cli/cmd-yarn.test.mts - General Yarn - * wrapper tests. - */ - -import { describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_DRY_RUN } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket yarn - malware detection with mocked packages', () => { - describe('pnpm exec with issueRules configuration', () => { - cmdit( - [ - 'yarn', - 'dlx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle pnpm exec with -c flag and malware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'yarn', - 'dlx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and gptMalware issueRule for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run pnpm exec with -c should exit with code 0').toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'yarn', - 'dlx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'dlx', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle pnpm exec with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run pnpm exec with --config should exit with code 0', - ).toBe(0) - }, - ) - }) - - describe('yarn add with issueRules configuration', () => { - cmdit( - [ - 'yarn', - 'add', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle yarn add with -c flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run yarn add with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'add', - 'evil-test-package@1.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle yarn add with --config flag and multiple issueRules for evil-test-package', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run yarn add with --config should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'install', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle yarn install with -c flag and multiple issueRules', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run yarn install with -c should exit with code 0', - ).toBe(0) - }, - ) - }) -}) diff --git a/packages/cli/test/integration/cli/cmd-yarn.test.mts b/packages/cli/test/integration/cli/cmd-yarn.test.mts deleted file mode 100644 index e50f1bd4b..000000000 --- a/packages/cli/test/integration/cli/cmd-yarn.test.mts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Integration tests for `socket yarn` wrapper command. - * - * Tests the Yarn package manager wrapper that adds Socket security scanning to - * Yarn operations via Socket Firewall (sfw). Commands are forwarded to sfw - * which provides security scanning before installation. - * - * Test Coverage: - Help text display and usage examples - Dry-run behavior - * validation - Yarn install operations with scanning - Config flag variants - - * Issue rules configuration. - * - * Security Features: - Pre-installation security scanning via Socket Firewall - - * Malware detection integration - Workspace support. - * - * Related Files: - src/commands/yarn/cmd-yarn.mts - yarn command implementation - * - src/yarn-cli.mts - yarn CLI entry point - src/util/dlx/resolve-binary.mjs - - * sfw resolution - test/integration/cli/cmd-yarn-malware.test.mts - Malware - * tests. - */ - -import { describe, expect } from 'vitest' - -import { YARN } from '@socketsecurity/lib-stable/constants/agents' - -import { - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_QUIET, -} from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { expectDryRunOutput } from '../../helpers/output-assertions.mts' -import { cmdit, spawnSocketCli } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -describe('socket yarn', async () => { - cmdit( - [YARN, FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toMatchInlineSnapshot(` - "Run yarn with Socket Firewall security - - Usage - $ socket yarn ... - - API Token Requirements - (none) - - Note: Everything after "yarn" is forwarded to Socket Firewall (sfw). - Socket Firewall provides real-time security scanning for yarn packages. - - Use \`socket wrapper on\` to alias this command as \`yarn\`. - - Examples - $ socket yarn - $ socket yarn install - $ socket yarn add package-name - $ socket pnpm exec package-name" - `) - expect(`\n ${stderr}`).toMatchInlineSnapshot(` - " - _____ _ _ /--------------- - | __|___ ___| |_ ___| |_ | CLI: <redacted> - |__ | . | _| '_| -_| _| | token: <redacted>, org: <redacted> - |_____|___|___|_,_|___|_|.dev | Command: \`socket yarn\`, cwd: <redacted>" - `) - - expect(code, 'explicit help should exit with code 0').toBe(0) - expect(stderr, 'banner includes base command').toContain('`socket yarn`') - }, - ) - - cmdit( - [YARN, FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should require args with just dry-run', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(stderr).toContain('CLI') - expect(code, 'dry-run without args should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'add', - 'lodash', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle add with --dry-run flag', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run add should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'dlx', - FLAG_QUIET, - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle dlx with version', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run dlx should exit with code 0').toBe(0) - }, - ) - - cmdit( - [YARN, 'install', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], - 'should handle install with --dry-run flag', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run install should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'add', - '@types/node@^20.0.0', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken"}', - ], - 'should handle scoped packages with version', - async cmd => { - const { code } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - expect(code, 'dry-run add scoped package should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle exec with -c flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with -c should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true}}', - ], - 'should handle exec with --config flag and issueRules for malware', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect(code, 'dry-run exec with --config should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - '-c', - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle exec with -c flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) - - cmdit( - [ - 'yarn', - 'exec', - 'cowsay@^1.6.0', - 'hello', - FLAG_DRY_RUN, - FLAG_CONFIG, - '{"apiToken":"fakeToken","issueRules":{"malware":true,"gptMalware":true}}', - ], - 'should handle exec with --config flag and multiple issueRules (malware and gptMalware)', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - timeout: 30_000, - }) - - // Validate dry-run output to prevent flipped snapshots. - expectDryRunOutput(stdout) - expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) - expect( - code, - 'dry-run exec with --config and multiple issueRules should exit with code 0', - ).toBe(0) - }, - ) -}) diff --git a/packages/cli/test/json-output-validation.mts b/packages/cli/test/json-output-validation.mts deleted file mode 100644 index 7fe895a30..000000000 --- a/packages/cli/test/json-output-validation.mts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Test utility for validating Socket CLI JSON output. Ensures CLI commands - * return properly formatted JSON responses. - * - * Key Functions: - validateSocketJson: Parse and validate JSON output from - * Socket CLI. - * - * Validation Rules: - Output must be valid JSON - Success responses (exitCode - * 0) return { ok: true, data: ... } - Error responses return { ok: false, - * message: ... } - Handles malformed JSON gracefully. - * - * Usage: - Use after running Socket CLI commands with --json flag - Validates - * structure matches Socket's standard JSON response format - Provides type-safe - * response handling in tests. - * - * @example - * const result = await runWithConfig('scan', 'create', '--json') - * const json = validateSocketJson(result.stdout, result.exitCode) - * if (json.ok) { - * expect(json.data.id).toBeDefined() - * } else { - * expect(json.message).toContain('error') - * } - */ - -/** - * Validate and parse Socket CLI JSON output. - * - * @param output The stdout string from Socket CLI. - * @param exitCode The exit code from the CLI command. - * - * @returns Parsed JSON with ok status and data or error message. - */ -export function validateSocketJson(output: string, exitCode: number) { - try { - const parsed = JSON.parse(output) - // Basic validation of expected Socket CLI JSON format. - if (exitCode === 0) { - return { ok: true, data: parsed } - } - return { - ok: false, - message: - parsed.message || - parsed.error || - `command exited with code ${exitCode} but returned JSON had no .message or .error field`, - } - } catch (e) { - // If not valid JSON, return error. - const preview = output.length > 200 ? `${output.slice(0, 200)}...` : output - return { - ok: false, - message: `command output is not valid JSON (JSON.parse: ${e instanceof Error ? e.message : String(e)}); got: ${preview}`, - } - } -} diff --git a/packages/cli/test/mock-auth.mts b/packages/cli/test/mock-auth.mts deleted file mode 100644 index 21cbf9c51..000000000 --- a/packages/cli/test/mock-auth.mts +++ /dev/null @@ -1,508 +0,0 @@ -/* max-file-lines: legitimate — single-package test-mock surface (token persist + clear + fixtures); splitting would scatter the shared fixture state. */ -/** - * Mock authentication utilities for Socket CLI testing. Provides mock functions - * for authentication flows. - * - * Key Functions: - mockInteractiveLogin: Mock interactive login flow - - * mockApiTokenAuth: Mock API token authentication - mockGitHubAuth: Mock GitHub - * OAuth flow - mockOrgSelection: Mock organization selection - - * mockTokenValidation: Mock token validation. - * - * Features: - Configurable success/failure scenarios - Customizable response - * data - Delay simulation for realistic testing - Error state testing. - * - * Usage: - Unit testing authentication flows - Integration testing without real - * API calls - E2E testing with controlled responses. - */ - -import type { CResult } from '../src/types.mts' - -interface MockAuthOptions { - /** - * Whether the operation should succeed. - */ - shouldSucceed?: boolean | undefined - /** - * Custom delay in milliseconds to simulate network latency. - */ - delay?: number | undefined - /** - * Custom error message for failure scenarios. - */ - errorMessage?: string | undefined - /** - * Custom response data for success scenarios. - */ - responseData?: unknown | undefined -} - -interface MockLoginOptions extends MockAuthOptions { - /** - * Mock email address for login. - */ - email?: string | undefined - /** - * Mock organization slug. - */ - orgSlug?: string | undefined - /** - * Mock API token to return. - */ - apiToken?: string | undefined - /** - * Whether to simulate MFA requirement. - */ - requireMfa?: boolean | undefined -} - -interface MockTokenOptions extends MockAuthOptions { - /** - * The token to validate. - */ - token?: string | undefined - /** - * Token permissions/scopes. - */ - scopes?: string[] | readonly string[] | undefined - /** - * Token expiration time. - */ - expiresAt?: Date | undefined -} - -interface MockOrgOptions extends MockAuthOptions { - /** - * List of organizations to return. - */ - organizations?: - | Array<{ - id: string - slug: string - name: string - role: string - }> - | undefined - /** - * Selected organization index. - */ - selectedIndex?: number | undefined -} - -const MILLISECONDS_1_DAY = Date.now() + 24 * 60 * 60 * 1000 - -const MILLISECONDS_30_DAYS = Date.now() + 30 * 24 * 60 * 60 * 1000 - -/** - * Mock API token authentication. - */ -export async function mockApiTokenAuth( - options?: MockTokenOptions, -): Promise<CResult<{ valid: boolean; user?: unknown | undefined }>> { - const { - delay = 50, - errorMessage = 'Invalid token', - expiresAt = new Date(MILLISECONDS_30_DAYS), - scopes = ['read', 'write'], - shouldSucceed = true, - token = 'test-token', - } = { - __proto__: null, - ...options, - } as MockTokenOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 401, - message: errorMessage, - } - } - - return { - ok: true, - data: { - valid: true, - user: { - id: 'user-123', - email: 'test@example.com', - token, - scopes, - expiresAt, - }, - }, - } -} - -/** - * Mock API key generation. - */ -export async function mockGenerateApiKey( - options?: MockAuthOptions & { - keyName?: string | undefined - scopes?: string[] | undefined - }, -): Promise<CResult<{ apiKey: string; keyId: string }>> { - const { - delay = 150, - errorMessage = 'API key generation failed', - keyName = 'test-key', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions & { - keyName?: string | undefined - scopes?: string[] | undefined - } - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 500, - message: errorMessage, - } - } - - return { - ok: true, - data: { - apiKey: `sk_test_${Buffer.from(keyName).toString('base64').substring(0, 16)}`, - keyId: `key_${Date.now()}`, - }, - } -} - -/** - * Mock GitHub OAuth authentication flow. - */ -export async function mockGitHubAuth( - options?: MockAuthOptions & { code?: string | undefined }, -): Promise<CResult<{ accessToken: string; user: unknown }>> { - const { - code = 'github-auth-code-123', - delay = 200, - errorMessage = 'GitHub authentication failed', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions & { code?: string | undefined } - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 403, - message: errorMessage, - } - } - - return { - ok: true, - data: { - accessToken: `gho_${code}_accesstoken`, - user: { - id: 'github-user-123', - login: 'testuser', - email: 'test@github.com', - name: 'Test User', - }, - }, - } -} - -/** - * Mock interactive login flow. - */ -export async function mockInteractiveLogin( - options?: MockLoginOptions | undefined, -): Promise<CResult<{ apiToken: string; orgSlug: string }>> { - const { - apiToken = 'test-token-123', - delay = 100, - errorMessage = 'Login failed', - orgSlug = 'test-org', - requireMfa = false, - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockLoginOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 401, - message: errorMessage, - } - } - - if (requireMfa) { - // Simulate MFA flow. - await simulateDelay(delay) - } - - return { - ok: true, - data: { - apiToken, - orgSlug, - }, - } -} - -/** - * Mock logout flow. - */ -export async function mockLogout( - options?: MockAuthOptions, -): Promise<CResult<void>> { - const { - delay = 50, - errorMessage = 'Logout failed', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 500, - message: errorMessage, - } - } - - return { - ok: true, - data: undefined, - } -} - -/** - * Mock organization selection. - */ -export async function mockOrgSelection( - options?: MockOrgOptions, -): Promise<CResult<{ orgSlug: string; orgId: string }>> { - const { - delay = 50, - errorMessage = 'Organization selection failed', - organizations = [ - { id: 'org-1', slug: 'test-org-1', name: 'Test Org 1', role: 'admin' }, - { id: 'org-2', slug: 'test-org-2', name: 'Test Org 2', role: 'member' }, - ], - selectedIndex = 0, - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockOrgOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 500, - message: errorMessage, - } - } - - if (!organizations.length) { - return { - ok: false, - code: 404, - message: 'No organizations available', - } - } - - const selected = organizations[selectedIndex] - if (!selected) { - return { - ok: false, - code: 400, - message: 'Invalid organization selection', - } - } - - return { - ok: true, - data: { - orgSlug: selected.slug, - orgId: selected.id, - }, - } -} - -/** - * Mock refresh token flow. - */ -export async function mockRefreshToken( - _refreshToken: string, - options?: MockAuthOptions, -): Promise<CResult<{ accessToken: string; expiresIn: number }>> { - const { - delay = 100, - errorMessage = 'Token refresh failed', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 401, - message: errorMessage, - } - } - - return { - ok: true, - data: { - accessToken: `refreshed-token-${Date.now()}`, - expiresIn: 3600, // 1 hour. - }, - } -} - -/** - * Mock SSO authentication flow. - */ -export async function mockSsoAuth( - options?: MockAuthOptions & { - ssoProvider?: string | undefined - ssoOrgSlug?: string | undefined - }, -): Promise<CResult<{ apiToken: string; user: unknown }>> { - const { - delay = 300, - errorMessage = 'SSO authentication failed', - shouldSucceed = true, - ssoOrgSlug = 'sso-org', - ssoProvider = 'okta', - } = { - __proto__: null, - ...options, - } as MockAuthOptions & { - ssoProvider?: string | undefined - ssoOrgSlug?: string | undefined - } - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 403, - message: errorMessage, - } - } - - return { - ok: true, - data: { - apiToken: `sso-token-${ssoProvider}-${Date.now()}`, - user: { - id: 'sso-user-123', - email: `user@${ssoOrgSlug}.com`, - name: 'SSO User', - provider: ssoProvider, - orgSlug: ssoOrgSlug, - }, - }, - } -} - -/** - * Mock token validation. - */ -export async function mockTokenValidation( - token: string, - options?: MockAuthOptions, -): Promise<CResult<boolean>> { - const { - delay = 30, - errorMessage = 'Token validation failed', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 401, - message: errorMessage, - } - } - - // Simulate basic token validation. - const isValid = token.length > 10 && token.startsWith('test-') - - return { - ok: true, - data: isValid, - } -} - -/** - * Mock session validation. - */ -export async function mockValidateSession( - sessionId: string, - options?: MockAuthOptions, -): Promise<CResult<{ valid: boolean; expiresAt?: Date | undefined }>> { - const { - delay = 50, - errorMessage = 'Session validation failed', - shouldSucceed = true, - } = { - __proto__: null, - ...options, - } as MockAuthOptions - - await simulateDelay(delay) - - if (!shouldSucceed) { - return { - ok: false, - code: 401, - message: errorMessage, - } - } - - const isValid = sessionId.startsWith('sess_') - - return { - ok: true, - data: { - valid: isValid, - expiresAt: isValid ? new Date(MILLISECONDS_1_DAY) : undefined, - }, - } -} - -/** - * Simulate a delay for realistic async behavior. - */ -function simulateDelay(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)) -} diff --git a/packages/cli/test/mock-auth.test.mts b/packages/cli/test/mock-auth.test.mts deleted file mode 100644 index 3a4d9727a..000000000 --- a/packages/cli/test/mock-auth.test.mts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - mockApiTokenAuth, - mockGenerateApiKey, - mockGitHubAuth, - mockInteractiveLogin, - mockLogout, - mockOrgSelection, - mockRefreshToken, - mockSsoAuth, - mockTokenValidation, - mockValidateSession, -} from './mock-auth.mts' - -describe('mock-auth', () => { - describe('mockInteractiveLogin', () => { - it('should succeed with default options', async () => { - const result = await mockInteractiveLogin() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.apiToken).toBe('test-token-123') - expect(result.data.orgSlug).toBe('test-org') - } - }) - - it('should fail when shouldSucceed is false', async () => { - const result = await mockInteractiveLogin({ shouldSucceed: false }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Login failed') - expect(result.code).toBe(401) - } - }) - - it('should use custom values', async () => { - const result = await mockInteractiveLogin({ - apiToken: 'custom-token', - orgSlug: 'custom-org', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.apiToken).toBe('custom-token') - expect(result.data.orgSlug).toBe('custom-org') - } - }) - }) - - describe('mockApiTokenAuth', () => { - it('should validate token successfully', async () => { - const result = await mockApiTokenAuth() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.valid).toBe(true) - expect(result.data.user).toBeDefined() - expect(result.data.user?.scopes).toEqual(['read', 'write']) - } - }) - - it('should fail with custom error', async () => { - const result = await mockApiTokenAuth({ - shouldSucceed: false, - errorMessage: 'Custom error', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Custom error') - } - }) - }) - - describe('mockGitHubAuth', () => { - it('should authenticate with GitHub', async () => { - const result = await mockGitHubAuth() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.accessToken).toContain('gho_') - expect(result.data.user.login).toBe('testuser') - } - }) - }) - - describe('mockOrgSelection', () => { - it('should select first organization by default', async () => { - const result = await mockOrgSelection() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.orgSlug).toBe('test-org-1') - expect(result.data.orgId).toBe('org-1') - } - }) - - it('should select specified organization', async () => { - const result = await mockOrgSelection({ selectedIndex: 1 }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.orgSlug).toBe('test-org-2') - expect(result.data.orgId).toBe('org-2') - } - }) - - it('should fail with no organizations', async () => { - const result = await mockOrgSelection({ organizations: [] }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('No organizations available') - expect(result.code).toBe(404) - } - }) - }) - - describe('mockTokenValidation', () => { - it('should validate valid token', async () => { - const result = await mockTokenValidation('test-valid-token') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe(true) - } - }) - - it('should invalidate short token', async () => { - const result = await mockTokenValidation('short') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe(false) - } - }) - }) - - describe('mockSsoAuth', () => { - it('should authenticate with SSO', async () => { - const result = await mockSsoAuth() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.apiToken).toContain('sso-token-') - expect(result.data.user.provider).toBe('okta') - } - }) - }) - - describe('mockRefreshToken', () => { - it('should refresh token', async () => { - const result = await mockRefreshToken('refresh-token-123') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.accessToken).toContain('refreshed-token-') - expect(result.data.expiresIn).toBe(3600) - } - }) - }) - - describe('mockLogout', () => { - it('should logout successfully', async () => { - const result = await mockLogout() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeUndefined() - } - }) - }) - - describe('mockGenerateApiKey', () => { - it('should generate API key', async () => { - const result = await mockGenerateApiKey() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.apiKey).toContain('sk_test_') - expect(result.data.keyId).toContain('key_') - } - }) - }) - - describe('mockValidateSession', () => { - it('should validate valid session', async () => { - const result = await mockValidateSession('sess_123456') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.valid).toBe(true) - expect(result.data.expiresAt).toBeInstanceOf(Date) - } - }) - - it('should invalidate invalid session', async () => { - const result = await mockValidateSession('invalid') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.valid).toBe(false) - expect(result.data.expiresAt).toBeUndefined() - } - }) - }) -}) diff --git a/packages/cli/test/mock-malware-api.mts b/packages/cli/test/mock-malware-api.mts deleted file mode 100644 index 0a908933e..000000000 --- a/packages/cli/test/mock-malware-api.mts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Mock helpers for testing malware detection in Socket CLI. Provides utilities - * to mock Socket API responses with malware alerts. - */ - -import type { - CompactSocketArtifact, - CompactSocketArtifactAlert, -} from '../src/util/alert/artifact.mts' - -/** - * Extended alert type with additional API fields not in base type. - */ -type ExtendedAlert = CompactSocketArtifactAlert & { - category?: string | undefined - file?: string | undefined -} - -/** - * Extended CompactSocketArtifact type with additional API fields for testing. - * The API returns score, size, and batchIndex data not included in the base - * type. - */ -type CompactSocketArtifactWithScore = Omit< - CompactSocketArtifact, - 'alerts' -> & { - alerts: ExtendedAlert[] - batchIndex?: number | undefined - size?: number | undefined - score?: - | { - license: number - maintenance: number - overall: number - quality: number - supplyChain: number - vulnerability: number - } - | undefined -} - -/** - * Creates a mocked malware package response for testing. This simulates what - * the Socket API would return for a malicious package. - */ -export function createMalwarePackageResponse(): CompactSocketArtifactWithScore { - return { - id: '99999999999', - size: 1024, - type: 'npm', - name: 'evil-test-package', - version: '1.0.0', - alerts: [ - { - key: 'QTEST_MALWARE_KEY_12345678901234567890', - type: 'malware', - severity: 'critical', - category: 'supplyChainRisk', - file: 'evil-test-package-1.0.0/index.js', - props: { - id: 999999, - note: 'This package contains malicious code that attempts to steal credentials and execute remote commands. DO NOT USE.', - }, - action: 'error', - fix: { - type: 'remove', - description: - 'Remove this package immediately and audit your system for compromise.', - }, - }, - { - key: 'QTEST_GPTMALWARE_KEY_98765432109876543210', - type: 'gptMalware', - severity: 'critical', - category: 'supplyChainRisk', - file: 'evil-test-package-1.0.0/index.js', - props: { - notes: - 'AI analysis detected highly suspicious patterns including credential harvesting, data exfiltration, and backdoor installation. This package poses an extreme security risk.', - severity: 0.99, - confidence: 0.98, - }, - action: 'error', - }, - { - key: 'QTEST_NETWORK_ACCESS_KEY_11111111111111111111', - type: 'networkAccess', - severity: 'high', - category: 'supplyChainRisk', - file: 'evil-test-package-1.0.0/index.js', - action: 'warn', - }, - ], - score: { - license: 0, - maintenance: 0, - overall: 0.01, - quality: 0, - supplyChain: 0.01, - vulnerability: 0, - }, - batchIndex: 0, - license: 'UNKNOWN', - licenseDetails: [], - } -} - -/** - * Creates a safe package response for testing (no malware). - */ -export function createSafePackageResponse( - name: string, - version: string, -): CompactSocketArtifactWithScore { - return { - id: '12345678', - size: 512, - type: 'npm', - name, - version, - alerts: [], - score: { - license: 1, - maintenance: 1, - overall: 1, - quality: 1, - supplyChain: 1, - vulnerability: 1, - }, - batchIndex: 0, - license: 'MIT', - licenseDetails: [], - } -} diff --git a/packages/cli/test/mock-malware-api.test.mts b/packages/cli/test/mock-malware-api.test.mts deleted file mode 100644 index 067a45f3c..000000000 --- a/packages/cli/test/mock-malware-api.test.mts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - createMalwarePackageResponse, - createSafePackageResponse, -} from './mock-malware-api.mts' - -describe('mock-malware-api utilities', () => { - describe('createMalwarePackageResponse', () => { - it('should create a malware package with correct structure', () => { - const response = createMalwarePackageResponse() - - expect(response).toBeDefined() - expect(response.name).toBe('evil-test-package') - expect(response.version).toBe('1.0.0') - expect(response.type).toBe('npm') - }) - - it('should include both malware and gptMalware alerts', () => { - const response = createMalwarePackageResponse() - - expect(response.alerts).toHaveLength(3) - - const malwareAlert = response.alerts.find(a => a.type === 'malware') - expect(malwareAlert).toBeDefined() - expect(malwareAlert?.severity).toBe('critical') - expect(malwareAlert?.action).toBe('error') - - const gptMalwareAlert = response.alerts.find(a => a.type === 'gptMalware') - expect(gptMalwareAlert).toBeDefined() - expect(gptMalwareAlert?.severity).toBe('critical') - expect(gptMalwareAlert?.action).toBe('error') - }) - - it('should have extremely low security scores', () => { - const response = createMalwarePackageResponse() - - expect(response.score?.supplyChain).toBe(0.01) - expect(response.score?.overall).toBe(0.01) - expect(response.score?.quality).toBe(0) - expect(response.score?.maintenance).toBe(0) - expect(response.score?.vulnerability).toBe(0) - expect(response.score?.license).toBe(0) - }) - }) - - describe('createSafePackageResponse', () => { - it('should create a safe package with correct structure', () => { - const response = createSafePackageResponse('test-package', '2.0.0') - - expect(response).toBeDefined() - expect(response.name).toBe('test-package') - expect(response.version).toBe('2.0.0') - expect(response.type).toBe('npm') - }) - - it('should have no alerts', () => { - const response = createSafePackageResponse('test-package', '2.0.0') - - expect(response.alerts).toHaveLength(0) - }) - - it('should have perfect security scores', () => { - const response = createSafePackageResponse('test-package', '2.0.0') - - expect(response.score?.supplyChain).toBe(1) - expect(response.score?.overall).toBe(1) - expect(response.score?.quality).toBe(1) - expect(response.score?.maintenance).toBe(1) - expect(response.score?.vulnerability).toBe(1) - expect(response.score?.license).toBe(1) - }) - }) -}) diff --git a/packages/cli/test/run-with-config.mts b/packages/cli/test/run-with-config.mts deleted file mode 100644 index c40278696..000000000 --- a/packages/cli/test/run-with-config.mts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Test utility for running Socket CLI commands with configuration. - * Automatically adds --config {} to prevent using user's local configuration. - * - * Key Functions: - runWithConfig: Execute Socket CLI with isolated - * configuration. - * - * Features: - Automatically appends --config {} if not present - Returns - * structured result with exitCode, stdout, and stderr - Prevents test pollution - * from user's local Socket configuration. - * - * Usage: - Use for testing CLI commands in isolation - Ensures reproducible - * test results across environments - Prevents authentication token leakage in - * tests. - * - * @example - * const result = await runWithConfig('scan', 'create', '--json') - * expect(result.exitCode).toBe(0) - * const json = JSON.parse(result.stdout) - */ - -import { spawnSocketCli } from './utils.mts' -import { constants } from '../src/constants.mts' - -/** - * Run Socket CLI command with isolated configuration. - * - * @param args Command arguments to pass to Socket CLI. - * - * @returns Object containing exitCode, stdout, and stderr. - */ -export async function runWithConfig(...args: string[]) { - const binCliPath = constants.getBinCliPath() - // Add --config {} if not present. - if (!args.includes('--config')) { - args.push('--config', '{}') - } - const result = await spawnSocketCli(binCliPath, args) - return { - exitCode: result.code, - stdout: result.stdout, - stderr: result.stderr, - } -} diff --git a/packages/cli/test/setup.mts b/packages/cli/test/setup.mts deleted file mode 100644 index a05601a33..000000000 --- a/packages/cli/test/setup.mts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @file Vitest setup file for test utilities. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -// Disable debug output during tests -process.env.DEBUG = '' -delete process.env.NODE_DEBUG - -// Load inlined environment variables from bundle-tools.json. -// These are normally inlined at build time by esbuild, but tests run from source. -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const externalToolsPath = path.join(__dirname, '..', 'bundle-tools.json') - -if (existsSync(externalToolsPath)) { - try { - const externalTools = JSON.parse(readFileSync(externalToolsPath, 'utf8')) - - // Set inlined environment variables if not already set. - // All tools now use 'version' field. GitHub-released tools also have optional 'tag'. - const toolVersions: Record<string, string | undefined> = { - INLINED_CDXGEN_VERSION: externalTools['@cyclonedx/cdxgen']?.version, - INLINED_COANA_VERSION: externalTools['@coana-tech/cli']?.version, - INLINED_CYCLONEDX_CDXGEN_VERSION: - externalTools['@cyclonedx/cdxgen']?.version, - INLINED_HOMEPAGE: 'https://github.com/SocketDev/socket-cli', - INLINED_NAME: '@socketsecurity/cli', - INLINED_OPENGREP_VERSION: externalTools['opengrep']?.version, - INLINED_PUBLISHED_BUILD: '', - INLINED_PYCLI_VERSION: externalTools['socketsecurity']?.version, - INLINED_PYTHON_BUILD_TAG: externalTools['python']?.tag, - INLINED_PYTHON_VERSION: externalTools['python']?.version, - INLINED_SENTRY_BUILD: '', - INLINED_SFW_NPM_VERSION: externalTools['sfw']?.npm?.version, - INLINED_SFW_VERSION: externalTools['sfw']?.version, - INLINED_SOCKET_PATCH_VERSION: externalTools['socket-patch']?.version, - INLINED_SYNP_VERSION: externalTools['synp']?.version, - INLINED_TRIVY_VERSION: externalTools['trivy']?.version, - INLINED_TRUFFLEHOG_VERSION: externalTools['trufflehog']?.version, - INLINED_VERSION: '0.0.0-test', - INLINED_VERSION_HASH: '0.0.0-test:abc1234:test', - } - - for (const [key, value] of Object.entries(toolVersions)) { - if (!process.env[key] && value) { - process.env[key] = value - } - } - } catch { - // Ignore errors loading bundle-tools.json. - } -} diff --git a/packages/cli/test/stubs/cve-to-ghsa-stub.mts b/packages/cli/test/stubs/cve-to-ghsa-stub.mts deleted file mode 100644 index 4f0d3425e..000000000 --- a/packages/cli/test/stubs/cve-to-ghsa-stub.mts +++ /dev/null @@ -1,9 +0,0 @@ -// Simple synchronous function for testing compatibility. -export function cveToGhsa(cveId: string): string | undefined { - if (!cveId || typeof cveId !== 'string') { - return undefined - } - // This is a stub for testing - real implementation needs API call. - // Return undefined for now to match test expectations. - return undefined -} diff --git a/packages/cli/test/stubs/cve-to-ghsa-stub.test.mts b/packages/cli/test/stubs/cve-to-ghsa-stub.test.mts deleted file mode 100644 index e186d9472..000000000 --- a/packages/cli/test/stubs/cve-to-ghsa-stub.test.mts +++ /dev/null @@ -1,278 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { cveToGhsa } from './cve-to-ghsa-stub.mts' -import { convertCveToGhsa } from '../../src/util/cve-to-ghsa.mts' - -const mockWithGitHubRetry = vi.hoisted(() => - vi.fn(async (operation: () => Promise<unknown>) => { - const result = await operation() - return { ok: true, data: result } - }), -) - -// Mock dependencies. -vi.mock('../../src/util/error/errors.mts', () => ({ - getErrorCause: vi.fn(e => e?.message || String(e)), -})) - -vi.mock('../../src/util/git/github.mts', () => ({ - cacheFetch: vi.fn(), - getOctokit: vi.fn(() => ({ - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - })), - handleGitHubApiError: vi.fn((e: unknown, context: string) => ({ - ok: false, - message: 'GitHub API error', - cause: `Error while ${context}: ${e instanceof Error ? e.message : String(e)}`, - })), - withGitHubRetry: mockWithGitHubRetry, -})) - -describe('cveToGhsa', () => { - it('returns undefined for CVEs', () => { - // The stub implementation returns undefined for all CVEs. - const ghsa = cveToGhsa('CVE-2021-44228') - expect(ghsa).toBeUndefined() - }) - - it('returns undefined for unknown CVE', () => { - const ghsa = cveToGhsa('CVE-9999-99999') - expect(ghsa).toBeUndefined() - }) - - it('handles invalid CVE format', () => { - const ghsa = cveToGhsa('NOT-A-CVE') - expect(ghsa).toBeUndefined() - }) - - it('handles empty string', () => { - const ghsa = cveToGhsa('') - expect(ghsa).toBeUndefined() - }) - - it('handles null/undefined input', () => { - // @ts-expect-error Testing runtime behavior. - expect(cveToGhsa(undefined)).toBeUndefined() - // @ts-expect-error Testing runtime behavior. - expect(cveToGhsa(undefined)).toBeUndefined() - }) - - it('is case sensitive', () => { - const upperResult = cveToGhsa('CVE-2021-44228') - const lowerResult = cveToGhsa('cve-2021-44228') - // The function should handle case properly. - expect(typeof upperResult === typeof lowerResult).toBe(true) - }) -}) - -describe('convertCveToGhsa', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('successfully converts CVE to GHSA', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockResolvedValue({ - data: [ - { - ghsa_id: 'GHSA-abcd-efgh-ijkl', - cve_id: 'CVE-2023-12345', - }, - ], - }), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - const result = await convertCveToGhsa('CVE-2023-12345') - - expect(result).toEqual({ - ok: true, - data: 'GHSA-abcd-efgh-ijkl', - }) - - expect(cacheFetch).toHaveBeenCalledWith( - 'cve-to-ghsa::CVE-2023-12345', - expect.any(Function), - // 30 days in milliseconds. - 2_592_000_000, - ) - }) - - it('returns error when no GHSA found', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockResolvedValue({ - data: [], - }), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - const result = await convertCveToGhsa('CVE-2023-99999') - - expect(result).toEqual({ - ok: false, - message: 'No GHSA found for CVE CVE-2023-99999', - }) - }) - - it('handles API errors gracefully', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const mockError = new Error('API rate limit exceeded') - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockRejectedValue(mockError), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - const result = await convertCveToGhsa('CVE-2023-12345') - - // Error handling now uses centralized handleGitHubApiError. - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub API error') - expect(result.cause).toContain('CVE-2023-12345') - } - }) - - it('uses cache key correctly', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockResolvedValue({ - data: [ - { - ghsa_id: 'GHSA-test-test-test', - cve_id: 'CVE-2024-00001', - }, - ], - }), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - await convertCveToGhsa('CVE-2024-00001') - - expect(cacheFetch).toHaveBeenCalledWith( - 'cve-to-ghsa::CVE-2024-00001', - expect.any(Function), - // 30 days in milliseconds. - 2_592_000_000, - ) - }) - - it('calls GitHub API with correct parameters', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const listGlobalAdvisories = vi.fn().mockResolvedValue({ - data: [ - { - ghsa_id: 'GHSA-1234-5678-9012', - cve_id: 'CVE-2023-45678', - }, - ], - }) - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories, - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - await convertCveToGhsa('CVE-2023-45678') - - expect(listGlobalAdvisories).toHaveBeenCalledWith({ - cve_id: 'CVE-2023-45678', - per_page: 1, - }) - }) - - it('handles network errors', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const networkError = new Error('Network timeout') - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockRejectedValue(networkError), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - const result = await convertCveToGhsa('CVE-2023-11111') - - // Error handling now uses centralized handleGitHubApiError. - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub API error') - expect(result.cause).toContain('CVE-2023-11111') - } - }) - - it('handles non-Error exceptions', async () => { - const { cacheFetch, getOctokit } = vi.mocked( - await import('../../src/util/git/github.mts'), - ) - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockRejectedValue('String error'), - }, - }, - } - - getOctokit.mockReturnValue(mockOctokit as unknown) - cacheFetch.mockImplementation(async (_, fn) => fn()) - - const result = await convertCveToGhsa('CVE-2023-22222') - - // Error handling now uses centralized handleGitHubApiError. - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub API error') - expect(result.cause).toContain('CVE-2023-22222') - } - }) -}) diff --git a/packages/cli/test/stubs/glob-test-helpers.mts b/packages/cli/test/stubs/glob-test-helpers.mts deleted file mode 100644 index e6a14a008..000000000 --- a/packages/cli/test/stubs/glob-test-helpers.mts +++ /dev/null @@ -1,6 +0,0 @@ -import micromatch from 'micromatch' - -// Helper for testing. -export function isGlobMatch(path: string, patterns: string[]): boolean { - return micromatch.isMatch(path, patterns) -} diff --git a/packages/cli/test/stubs/glob-test-helpers.test.mts b/packages/cli/test/stubs/glob-test-helpers.test.mts deleted file mode 100644 index 3d6f6432a..000000000 --- a/packages/cli/test/stubs/glob-test-helpers.test.mts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isGlobMatch } from './glob-test-helpers.mts' - -describe('glob utilities', () => { - describe('isGlobMatch', () => { - it('matches exact paths', () => { - expect(isGlobMatch('test.js', ['test.js'])).toBe(true) - expect(isGlobMatch('src/index.ts', ['src/index.ts'])).toBe(true) - expect(isGlobMatch('package.json', ['package.json'])).toBe(true) - }) - - it('matches with wildcards', () => { - expect(isGlobMatch('test.js', ['*.js'])).toBe(true) - expect(isGlobMatch('src/index.ts', ['src/*.ts'])).toBe(true) - expect(isGlobMatch('lib/utils.mjs', ['lib/*.mjs'])).toBe(true) - }) - - it('matches with double wildcards', () => { - expect(isGlobMatch('src/deep/nested/file.ts', ['src/**/*.ts'])).toBe(true) - expect(isGlobMatch('test/unit/spec.test.js', ['**/*.test.js'])).toBe(true) - expect(isGlobMatch('node_modules/pkg/index.js', ['**/index.js'])).toBe( - true, - ) - }) - - it('matches with brace expansion', () => { - expect(isGlobMatch('file.ts', ['*.{js,ts}'])).toBe(true) - expect(isGlobMatch('file.js', ['*.{js,ts}'])).toBe(true) - expect(isGlobMatch('file.css', ['*.{js,ts}'])).toBe(false) - }) - - it('matches multiple patterns', () => { - const patterns = ['*.js', '*.ts', '*.mjs'] - expect(isGlobMatch('test.js', patterns)).toBe(true) - expect(isGlobMatch('index.ts', patterns)).toBe(true) - expect(isGlobMatch('lib.mjs', patterns)).toBe(true) - expect(isGlobMatch('style.css', patterns)).toBe(false) - }) - - it('handles negation patterns', () => { - // Note: micromatch.isMatch doesn't handle negation the way you might expect. - // It returns true if the file matches ANY of the patterns. - // For proper negation handling, use the globWithGitIgnore function. - expect(isGlobMatch('test.js', ['*.js'])).toBe(true) - expect(isGlobMatch('index.js', ['*.js'])).toBe(true) - // Negation patterns are processed differently by ignore library. - expect(isGlobMatch('test.js', ['!test.js'])).toBe(false) - }) - - it('matches directories', () => { - expect(isGlobMatch('src/', ['src/'])).toBe(true) - expect(isGlobMatch('src', ['src/'])).toBe(false) - expect(isGlobMatch('src/lib/', ['src/**/'])).toBe(true) - }) - - it('returns false for no patterns', () => { - expect(isGlobMatch('test.js', [])).toBe(false) - }) - - it('handles special characters in paths', () => { - expect(isGlobMatch('[test].js', ['\\[test\\].js'])).toBe(true) - expect(isGlobMatch('file(1).txt', ['file\\(1\\).txt'])).toBe(true) - }) - - it('is case sensitive by default', () => { - expect(isGlobMatch('Test.js', ['test.js'])).toBe(false) - expect(isGlobMatch('TEST.JS', ['test.js'])).toBe(false) - }) - - it('matches dotfiles with explicit patterns', () => { - expect(isGlobMatch('.gitignore', ['.gitignore'])).toBe(true) - expect(isGlobMatch('.env', ['.*'])).toBe(true) - expect(isGlobMatch('.config/file.js', ['.config/*.js'])).toBe(true) - }) - - it('handles absolute paths', () => { - expect(isGlobMatch('/home/user/file.js', ['/**/*.js'])).toBe(true) - expect(isGlobMatch('/etc/config', ['/etc/*'])).toBe(true) - }) - - it('matches parent directory patterns', () => { - expect(isGlobMatch('../file.js', ['../*.js'])).toBe(true) - expect(isGlobMatch('../../lib/index.ts', ['../../**/*.ts'])).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/bootstrap/shared/node-flags.test.mts b/packages/cli/test/unit/bootstrap/shared/node-flags.test.mts deleted file mode 100644 index ee5aa525d..000000000 --- a/packages/cli/test/unit/bootstrap/shared/node-flags.test.mts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Unit tests for bootstrap node-flags. - * - * Selects --disable-sigusr1 vs --no-inspect based on the running Node version. - * Tests stub process.version to validate every branch. - * - * Related Files: - src/bootstrap/shared/node-flags.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getNodeDisableSigusr1Flags } from '../../../../src/bootstrap/shared/node-flags.mts' - -const SIGUSR1 = '--disable-sigusr1' -const FALLBACK = '--no-inspect' - -describe('getNodeDisableSigusr1Flags', () => { - let originalVersion: string - - beforeEach(() => { - originalVersion = process.version - }) - - afterEach(() => { - Object.defineProperty(process, 'version', { - value: originalVersion, - writable: true, - configurable: true, - }) - }) - - function setVersion(v: string) { - Object.defineProperty(process, 'version', { - value: v, - writable: true, - configurable: true, - }) - } - - it('returns --disable-sigusr1 on v24.8.0', () => { - setVersion('v24.8.0') - expect(getNodeDisableSigusr1Flags()).toEqual([SIGUSR1]) - }) - - it('returns --disable-sigusr1 on v25.0.0', () => { - setVersion('v25.0.0') - // Major >= 24 + minor >= 8 → supported. v25 has minor 0, so falls back. - // Actually: major 25 with minor 0 fails the minor >= 8 check. - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('returns --disable-sigusr1 on v23.7.0', () => { - setVersion('v23.7.0') - expect(getNodeDisableSigusr1Flags()).toEqual([SIGUSR1]) - }) - - it('returns --no-inspect on v23.6.0', () => { - setVersion('v23.6.0') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('returns --disable-sigusr1 on v22.14.0', () => { - setVersion('v22.14.0') - expect(getNodeDisableSigusr1Flags()).toEqual([SIGUSR1]) - }) - - it('returns --no-inspect on v22.13.0', () => { - setVersion('v22.13.0') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('returns --no-inspect on v22.0.0 (below threshold)', () => { - setVersion('v22.0.0') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('returns --no-inspect on v18.0.0 (legacy)', () => { - setVersion('v18.0.0') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('returns --no-inspect on v20.0.0', () => { - setVersion('v20.0.0') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) - - it('handles malformed version strings (defaults to 0.0)', () => { - setVersion('malformed') - expect(getNodeDisableSigusr1Flags()).toEqual([FALLBACK]) - }) -}) diff --git a/packages/cli/test/unit/bootstrap/shared/paths.test.mts b/packages/cli/test/unit/bootstrap/shared/paths.test.mts deleted file mode 100644 index 39e8ab790..000000000 --- a/packages/cli/test/unit/bootstrap/shared/paths.test.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Unit tests for bootstrap shared path resolution. - * - * Bootstrap-only path helpers — run before the main ENV module loads, so they - * read process.env directly. Tests cover env-override + default path - * construction for every getter. - * - * Related Files: - src/bootstrap/shared/paths.mts. - */ - -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getCliEntryPoint, - getCliPackageDir, - getCliPackageName, - getDlxDir, - getSocketHome, -} from '../../../../src/bootstrap/shared/paths.mts' - -const SAVED_KEYS = [ - 'SOCKET_HOME', - 'SOCKET_NPM_REGISTRY', - 'NPM_REGISTRY', - 'SOCKET_CLI_PACKAGE', -] as const - -describe('bootstrap/shared/paths', () => { - let saved: Record<string, string | undefined> - - beforeEach(() => { - saved = Object.create(null) - for (let i = 0, { length } = SAVED_KEYS; i < length; i += 1) { - const key = SAVED_KEYS[i] - saved[key] = process.env[key] - delete process.env[key] - } - }) - - afterEach(() => { - for (let i = 0, { length } = SAVED_KEYS; i < length; i += 1) { - const key = SAVED_KEYS[i] - if (saved[key] !== undefined) { - process.env[key] = saved[key] - } else { - delete process.env[key] - } - } - }) - - describe('getSocketHome', () => { - it('uses SOCKET_HOME when set', () => { - process.env['SOCKET_HOME'] = '/custom/socket-home' - expect(getSocketHome()).toBe('/custom/socket-home') - }) - - it('falls back to ~/.socket when SOCKET_HOME is not set', () => { - const result = getSocketHome() - expect(result).toContain('.socket') - }) - - it('falls back when SOCKET_HOME is empty string', () => { - process.env['SOCKET_HOME'] = '' - const result = getSocketHome() - expect(result).toContain('.socket') - }) - }) - - describe('getDlxDir', () => { - it('appends _dlx to the socket home', () => { - process.env['SOCKET_HOME'] = '/x' - expect(getDlxDir()).toBe(path.join('/x', '_dlx')) - }) - }) - - describe('getCliPackageDir', () => { - it('appends cli to the DLX dir', () => { - process.env['SOCKET_HOME'] = '/x' - expect(getCliPackageDir()).toBe(path.join('/x', '_dlx', 'cli')) - }) - }) - - describe('getCliEntryPoint', () => { - it('appends dist/cli.js to the CLI package dir', () => { - process.env['SOCKET_HOME'] = '/x' - expect(getCliEntryPoint()).toBe( - path.join('/x', '_dlx', 'cli', 'dist', 'cli.js'), - ) - }) - }) - - describe('getCliPackageName', () => { - it('uses SOCKET_CLI_PACKAGE when set', () => { - process.env['SOCKET_CLI_PACKAGE'] = '@my-org/socket' - expect(getCliPackageName()).toBe('@my-org/socket') - }) - - it('falls back to @socketsecurity/cli', () => { - expect(getCliPackageName()).toBe('@socketsecurity/cli') - }) - }) -}) diff --git a/packages/cli/test/unit/commands.test.mts b/packages/cli/test/unit/commands.test.mts deleted file mode 100644 index 75400a8fc..000000000 --- a/packages/cli/test/unit/commands.test.mts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Unit tests for root command registry. - * - * Tests the command and alias registration system that powers the Socket CLI's - * primary command interface. Validates command structure, aliases, and - * metadata. - * - * Test Coverage: - * - * - RootCommands export validation (all expected commands present) - * - Command object structure (run/handler methods, descriptions) - * - RootAliases export validation (common aliases like audit, deps, org, pkg) - * - Alias structure validation (description, argv properties) - * - Alias target validation (point to valid commands/subcommands) - * - Package manager commands (npm, npx, pnpm, yarn) - * - Special commands (wrapper, raw-npm, raw-npx, organization, repository, scan, - * optimize) - * - * Testing Approach: - * - * - Direct imports and property validation - * - Structure validation for all commands and aliases - * - Target validation ensuring aliases point to valid commands - * - * Related Files: - * - * - Src/commands.mts - Root command registry implementation - * - Src/cmd-*.mts - Individual command definitions - */ - -import { describe, expect, it } from 'vitest' - -import { rootAliases, rootCommands } from '../../src/commands.mts' - -describe('commands', () => { - describe('rootCommands', () => { - it('exports all expected root commands', () => { - expect(rootCommands).toBeDefined() - expect(typeof rootCommands).toBe('object') - - // Check for key commands. - expect(rootCommands).toHaveProperty('analytics') - expect(rootCommands).toHaveProperty('audit-log') - expect(rootCommands).toHaveProperty('ci') - expect(rootCommands).toHaveProperty('config') - expect(rootCommands).toHaveProperty('fix') - expect(rootCommands).toHaveProperty('install') - expect(rootCommands).toHaveProperty('login') - expect(rootCommands).toHaveProperty('logout') - expect(rootCommands).toHaveProperty('npm') - expect(rootCommands).toHaveProperty('npx') - expect(rootCommands).toHaveProperty('optimize') - expect(rootCommands).toHaveProperty('organization') - expect(rootCommands).toHaveProperty('package') - expect(rootCommands).toHaveProperty('patch') - expect(rootCommands).toHaveProperty('pnpm') - expect(rootCommands).toHaveProperty('scan') - expect(rootCommands).toHaveProperty('yarn') - }) - - it('has command objects for all root commands', () => { - for (const [, command] of Object.entries(rootCommands)) { - expect(command).toBeDefined() - expect(typeof command).toBe('object') - // Commands have either a 'run' method or 'handler' method. - expect( - typeof command.run === 'function' || - typeof command.handler === 'function', - ).toBe(true) - } - }) - - it('has descriptions for all commands', () => { - for (const [, command] of Object.entries(rootCommands)) { - expect(command).toHaveProperty('description') - expect(typeof command.description).toBe('string') - expect(command.description.length).toBeGreaterThan(0) - } - }) - }) - - describe('rootAliases', () => { - it('exports command aliases', () => { - expect(rootAliases).toBeDefined() - expect(typeof rootAliases).toBe('object') - }) - - it('provides aliases for common commands', () => { - // Check that some aliases exist. - expect(rootAliases).toHaveProperty('audit') - expect(rootAliases).toHaveProperty('deps') - expect(rootAliases).toHaveProperty('feed') - expect(rootAliases).toHaveProperty('org') - expect(rootAliases).toHaveProperty('pkg') - }) - - it('all aliases have description and argv', () => { - for (const [, alias] of Object.entries(rootAliases)) { - expect(alias).toHaveProperty('description') - expect(alias).toHaveProperty('argv') - expect(Array.isArray(alias.argv)).toBe(true) - expect(alias.argv.length).toBeGreaterThan(0) - } - }) - - it('aliases point to valid root commands or subcommands', () => { - for (const [, alias] of Object.entries(rootAliases)) { - const targetCommand = alias.argv[0] - // Check if the target exists in rootCommands or is a known subcommand. - const isValidTarget = - rootCommands[targetCommand] !== undefined || - targetCommand === 'dependencies' || // Points to organization dependencies. - targetCommand === 'threat-feed' || // Special command. - targetCommand === 'repos' // Repository alias. - - expect(isValidTarget).toBe(true) - } - }) - }) - - describe('package manager commands', () => { - it('includes all package managers', () => { - const packageManagers = ['npm', 'npx', 'pnpm', 'yarn'] - - for (let i = 0, { length } = packageManagers; i < length; i += 1) { - const pm = packageManagers[i] - expect(rootCommands).toHaveProperty(pm) - const command = rootCommands[pm] - expect( - typeof command.run === 'function' || - typeof command.handler === 'function', - ).toBe(true) - } - }) - }) - - describe('command structure', () => { - it('all commands have consistent structure', () => { - for (const [, command] of Object.entries(rootCommands)) { - // Check for required properties. - expect(command).toHaveProperty('description') - - // Commands have either run or handler. - const hasRun = typeof command.run === 'function' - const hasHandler = typeof command.handler === 'function' - expect(hasRun || hasHandler).toBe(true) - - // Description should be a non-empty string. - expect(typeof command.description).toBe('string') - expect(command.description.length).toBeGreaterThan(0) - } - }) - }) - - describe('special commands', () => { - it('has wrapper command', () => { - expect(rootCommands).toHaveProperty('wrapper') - }) - - it('has raw npm/pnpm exec commands', () => { - expect(rootCommands).toHaveProperty('raw-npm') - expect(rootCommands).toHaveProperty('raw-npx') - }) - - it('has organization management command', () => { - expect(rootCommands).toHaveProperty('organization') - }) - - it('has repository management command', () => { - expect(rootCommands).toHaveProperty('repository') - }) - - it('has security scanning command', () => { - expect(rootCommands).toHaveProperty('scan') - expect(rootCommands).toHaveProperty('audit-log') - }) - - it('has optimization commands', () => { - expect(rootCommands).toHaveProperty('optimize') - expect(rootCommands).toHaveProperty('fix') - expect(rootCommands).toHaveProperty('patch') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/analytics/__snapshots__/AnalyticsRenderer.test.mts.snap b/packages/cli/test/unit/commands/analytics/__snapshots__/AnalyticsRenderer.test.mts.snap deleted file mode 100644 index c071da946..000000000 --- a/packages/cli/test/unit/commands/analytics/__snapshots__/AnalyticsRenderer.test.mts.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`AnalyticsRenderer > data rendering > should render analytics with fixture data 1`] = ` -"Socket Analytics - -┌─────────────────────────┐ -│ │ -│ Top 5 Alert Types: │ -│ │ -│ envVars: 2533 │ -│ unmaintained: 532 │ -│ filesystemAccess: 514 │ -│ networkAccess: 434 │ -│ dynamicRequire: 274 │ -│ │ -└─────────────────────────┘ - -┌─────────────────────────┐ -│ │ -│ Critical Alerts │ -│ │ -│ Apr 19: 0 │ -│ Apr 21: 0 │ -│ Apr 20: 0 │ -│ Apr 22: 0 │ -│ │ -└─────────────────────────┘ - -┌─────────────────────────┐ -│ │ -│ High Alerts │ -│ │ -│ Apr 19: 13 │ -│ Apr 21: 13 │ -│ Apr 20: 13 │ -│ Apr 22: 10 │ -│ │ -└─────────────────────────┘ - -" -`; diff --git a/packages/cli/test/unit/commands/analytics/cmd-analytics.test.mts b/packages/cli/test/unit/commands/analytics/cmd-analytics.test.mts deleted file mode 100644 index d7aaa0729..000000000 --- a/packages/cli/test/unit/commands/analytics/cmd-analytics.test.mts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * Unit tests for analytics command. - * - * Tests the command that displays Socket analytics data. - * - * Test Coverage: - Command metadata (description, hidden flag) - API token - * requirement validation - Scope arguments: org vs repo - Time filter - * validation (7, 30, 90 days) - Repository name when scope=repo - Output modes: - * text, JSON, markdown - File output flag validation - Dry-run mode - Legacy - * flag detection. - * - * Testing Approach: - Mock logger to capture output - Mock handleAnalytics to - * verify handler invocation - Mock hasDefaultApiToken for authentication checks - * - Test argument combinations and defaults. - * - * Related Files: - src/commands/analytics/cmd-analytics.mts - Implementation - - * src/commands/analytics/handle-analytics.mts - Handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleAnalytics = vi.hoisted(() => vi.fn()) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/analytics/handle-analytics.mts', () => ({ - handleAnalytics: mockHandleAnalytics, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdAnalytics } = - await import('../../../../src/commands/analytics/cmd-analytics.mts') - -describe('cmd-analytics', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdAnalytics.description).toBe('Look up analytics data') - }) - - it('should not be hidden', () => { - expect(cmdAnalytics.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-analytics.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--dry-run'], importMeta, context) - - expect(mockHandleAnalytics).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdAnalytics.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should call handleAnalytics with default values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run([], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith({ - filepath: '', - outputKind: 'text', - repo: '', - scope: 'org', - time: 30, - }) - }) - - it('should parse "org" scope argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['org'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'org', - }), - ) - }) - - it('should parse "repo" scope argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', 'test-repo'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'repo', - repo: 'test-repo', - }), - ) - }) - - it('should parse time argument with org scope', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['org', '7'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'org', - time: 7, - }), - ) - }) - - it('should parse time argument with repo scope', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', 'test-repo', '90'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'repo', - repo: 'test-repo', - time: 90, - }), - ) - }) - - it('should parse standalone time argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['7'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'org', - time: 7, - }), - ) - }) - - it('should validate time is 7, 30, or 90', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['org', '15'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should fail when repo scope missing repo name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should detect time as second arg when repo scope', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', '7'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure (7 is a time, not a repo name). - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should detect time as second arg with 30', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', '30'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure (30 is a time, not a repo name). - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should detect time as second arg with 90', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', '90'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure (90 is a time, not a repo name). - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--json'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--markdown'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail if both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--json', '--markdown'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should pass --file flag to handleAnalytics', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run( - ['--json', '--file', '/tmp/analytics.json'], - importMeta, - context, - ) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - }), - ) - }) - - it('should fail if --file used without --json or --markdown', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run( - ['--file', '/tmp/analytics.json'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAnalytics).not.toHaveBeenCalled() - }) - - it('should allow --file with --markdown', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run( - ['--markdown', '--file', '/tmp/analytics.md'], - importMeta, - context, - ) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - filepath: '/tmp/analytics.md', - outputKind: 'markdown', - }), - ) - }) - - it('should show dry-run output with org scope', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--dry-run', 'org', '7'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('analytics data'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('org'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('7 days'), - ) - }) - - it('should show dry-run output with repo scope', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run( - ['--dry-run', 'repo', 'test-repo', '90'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('repo'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-repo'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('90 days'), - ) - }) - - it('should handle all time values correctly', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - - await cmdAnalytics.run(['7'], importMeta, context) - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ time: 7 }), - ) - - vi.clearAllMocks() - - await cmdAnalytics.run(['30'], importMeta, context) - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ time: 30 }), - ) - - vi.clearAllMocks() - - await cmdAnalytics.run(['90'], importMeta, context) - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ time: 90 }), - ) - }) - - it('should handle repo name with dashes', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', 'my-test-repo'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - repo: 'my-test-repo', - }), - ) - }) - - it('should handle repo name with underscores', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['repo', 'my_test_repo'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - repo: 'my_test_repo', - }), - ) - }) - - it('should default to 30 days when no time specified', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['org'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - time: 30, - }), - ) - }) - - it('should default to empty repo name when scope is org', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['org', '7'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - scope: 'org', - repo: '', - }), - ) - }) - - it('should combine all arguments correctly', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run( - ['repo', 'test-repo', '90', '--json', '--file', '/tmp/out.json'], - importMeta, - context, - ) - - expect(mockHandleAnalytics).toHaveBeenCalledWith({ - filepath: '/tmp/out.json', - outputKind: 'json', - repo: 'test-repo', - scope: 'repo', - time: 90, - }) - }) - - it('should handle empty filepath', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAnalytics.run(['--json'], importMeta, context) - - expect(mockHandleAnalytics).toHaveBeenCalledWith( - expect.objectContaining({ - filepath: '', - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/analytics/fetch-org-analytics.test.mts b/packages/cli/test/unit/commands/analytics/fetch-org-analytics.test.mts deleted file mode 100644 index 7f0352af1..000000000 --- a/packages/cli/test/unit/commands/analytics/fetch-org-analytics.test.mts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Unit tests for fetchOrgAnalyticsData function. - * - * Tests the data fetching logic for organization analytics. These tests verify - * SDK integration, API call handling, and error scenarios. - * - * Test Coverage: - Successful analytics data fetch with complete vulnerability - * breakdown - SDK setup failure with error propagation - API call failure with - * status code handling - Custom SDK options pass-through (apiToken, baseUrl) - - * Different time period parameters (7, 14, 30, 60, 90 days) - Prototype - * pollution protection verification. - * - * Testing Approach: - Mock Socket SDK using - * setupSdkMockSuccess/Error/SetupFailure helpers - Mock handleApiCall from - * util/socket/api.mts - Mock setupSdk from util/socket/sdk.mts - Verify SDK - * method calls with correct parameters - Test CResult pattern (ok/error - * states) - * - * Related Files: - src/commands/analytics/fetch-org-analytics.mts - - * Implementation - src/commands/analytics/handle-analytics.mts - Handler that - * calls this fetcher - test/helpers/sdk-test-helpers.mts - SDK mocking - * utilities. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchOrgAnalyticsData } from '../../../../src/commands/analytics/fetch-org-analytics.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchOrgAnalytics', () => { - it('fetches organization analytics successfully', async () => { - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getOrgAnalytics', - { - packages: 125, - repositories: 45, - scans: 320, - vulnerabilities: { - critical: 5, - high: 12, - medium: 28, - low: 45, - }, - lastUpdated: '2025-01-01T00:00:00Z', - }, - ) - - const result = await fetchOrgAnalyticsData(30) - - expect(mockSdk.getOrgAnalytics).toHaveBeenCalledWith('30') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'analytics data', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid configuration', - }) - - const result = await fetchOrgAnalyticsData(7) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - }) - - it('handles API call failure', async () => { - await setupSdkMockError( - 'getOrgAnalytics', - 'Analytics service unavailable', - 503, - ) - - const result = await fetchOrgAnalyticsData(30) - - expect(result.ok).toBe(false) - expect(result.code).toBe(503) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('getOrgAnalytics', {}) - - const sdkOpts = { - apiToken: 'analytics-token', - baseUrl: 'https://analytics.api.com', - } - - await fetchOrgAnalyticsData(90, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles different organization slugs', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgAnalytics', {}) - - const times = [7, 14, 30, 60, 90] - - for (let i = 0, { length } = times; i < length; i += 1) { - const time = times[i] - await fetchOrgAnalyticsData(time) - expect(mockSdk.getOrgAnalytics).toHaveBeenCalledWith(time.toString()) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgAnalytics', {}) - - // This tests that the function properly uses __proto__: null. - await fetchOrgAnalyticsData(30) - - // The function should work without prototype pollution issues. - expect(mockSdk.getOrgAnalytics).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/analytics/fetch-repo-analytics.test.mts b/packages/cli/test/unit/commands/analytics/fetch-repo-analytics.test.mts deleted file mode 100644 index a4776a697..000000000 --- a/packages/cli/test/unit/commands/analytics/fetch-repo-analytics.test.mts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Unit tests for fetchRepoAnalyticsData function. - * - * Tests the data fetching logic for repository analytics. These tests verify - * SDK integration for repository-specific metrics and error handling. - * - * Test Coverage: - Successful repository analytics fetch (commits, - * contributors, issues, PRs, stars) - SDK setup failure with error propagation - * - API call failure with 404 handling for non-existent repositories - Custom - * SDK options pass-through (apiToken, baseUrl) - Different repository name - * formats (org/repo, user/project) - Multiple time range parameters (1, 7, 14, - * 30, 60, 90, 365 days) - Prototype pollution protection verification. - * - * Testing Approach: - Mock Socket SDK using - * setupSdkMockSuccess/Error/SetupFailure helpers - Mock handleApiCall from - * util/socket/api.mts - Mock setupSdk from util/socket/sdk.mts - Verify SDK - * method calls with correct repository and time parameters - Test CResult - * pattern (ok/error states) - * - * Related Files: - src/commands/analytics/fetch-repo-analytics.mts - - * Implementation - src/commands/analytics/handle-analytics.mts - Handler that - * calls this fetcher - test/helpers/sdk-test-helpers.mts - SDK mocking - * utilities. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchRepoAnalyticsData } from '../../../../src/commands/analytics/fetch-repo-analytics.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchRepoAnalytics', () => { - it('fetches repository analytics successfully', async () => { - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getRepoAnalytics', - { - commits: 450, - contributors: 12, - issues: 85, - pullRequests: 120, - stars: 340, - lastUpdated: '2025-01-20T12:00:00Z', - }, - ) - - const result = await fetchRepoAnalyticsData('test-repo', 30) - - expect(mockSdk.getRepoAnalytics).toHaveBeenCalledWith('test-repo', '30') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'analytics data', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Configuration error', - }) - - const result = await fetchRepoAnalyticsData('my-repo', 7) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - }) - - it('handles API call failure', async () => { - await setupSdkMockError( - 'getRepoAnalytics', - 'Repository analytics unavailable', - 404, - ) - - const result = await fetchRepoAnalyticsData('nonexistent-repo', 30) - - expect(result.ok).toBe(false) - expect(result.code).toBe(404) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('getRepoAnalytics', {}) - - const sdkOpts = { - apiToken: 'repo-analytics-token', - baseUrl: 'https://repo.api.com', - } - - await fetchRepoAnalyticsData('custom-repo', 90, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles different org and repo combinations', async () => { - const { mockSdk } = await setupSdkMockSuccess('getRepoAnalytics', {}) - - const repos = ['org/repo1', 'org/repo2', 'another-org/repo', 'user/project'] - - for (let i = 0, { length } = repos; i < length; i += 1) { - const repo = repos[i] - await fetchRepoAnalyticsData(repo, 30) - expect(mockSdk.getRepoAnalytics).toHaveBeenCalledWith(repo, '30') - } - }) - - it('handles different time ranges', async () => { - const { mockSdk } = await setupSdkMockSuccess('getRepoAnalytics', {}) - - const timeRanges = [1, 7, 14, 30, 60, 90, 365] - - for (let i = 0, { length } = timeRanges; i < length; i += 1) { - const time = timeRanges[i] - await fetchRepoAnalyticsData('test-repo', time) - expect(mockSdk.getRepoAnalytics).toHaveBeenCalledWith( - 'test-repo', - time.toString(), - ) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getRepoAnalytics', {}) - - // This tests that the function properly uses __proto__: null. - await fetchRepoAnalyticsData('test-repo', 30) - - // The function should work without prototype pollution issues. - expect(mockSdk.getRepoAnalytics).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/analytics/handle-analytics.test.mts b/packages/cli/test/unit/commands/analytics/handle-analytics.test.mts deleted file mode 100644 index b498efad2..000000000 --- a/packages/cli/test/unit/commands/analytics/handle-analytics.test.mts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Unit tests for analytics command handler. - * - * Tests the main handler logic that orchestrates analytics data fetching and - * output. Validates routing between organization and repository analytics based - * on scope. - * - * Test Coverage: - Organization analytics fetch when scope is 'org' - - * Repository analytics fetch when repo is provided and scope is 'repo' - - * Missing repository name error when scope is 'repo' but repo is empty - Empty - * analytics data handling for organization - Empty analytics data handling for - * repository - Fetch error pass-through to output layer - Multiple output kinds - * (json, markdown, text) - Different time ranges (7, 30 days) - Filepath - * handling (with and without path) - * - * Testing Approach: - Mock fetchOrgAnalyticsData and fetchRepoAnalyticsData - - * Mock outputAnalytics to verify output layer calls - Use - * createSuccessResult/createErrorResult helpers for CResult pattern - Verify - * correct function selection based on scope parameter - Test validation logic - * before API calls. - * - * Related Files: - src/commands/analytics/handle-analytics.mts - Implementation - * - src/commands/analytics/fetch-org-analytics.mts - Org data fetcher - - * src/commands/analytics/fetch-repo-analytics.mts - Repo data fetcher - - * src/commands/analytics/output-analytics.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchOrgAnalyticsData } from '../../../../src/commands/analytics/fetch-org-analytics.mts' -import { fetchRepoAnalyticsData } from '../../../../src/commands/analytics/fetch-repo-analytics.mts' -import { handleAnalytics } from '../../../../src/commands/analytics/handle-analytics.mts' -import { outputAnalytics } from '../../../../src/commands/analytics/output-analytics.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockFetchOrgAnalyticsData = vi.hoisted(() => vi.fn()) -const mockFetchRepoAnalyticsData = vi.hoisted(() => vi.fn()) -const mockOutputAnalytics = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/analytics/fetch-org-analytics.mts', () => ({ - fetchOrgAnalyticsData: mockFetchOrgAnalyticsData, -})) -vi.mock('../../../../src/commands/analytics/fetch-repo-analytics.mts', () => ({ - fetchRepoAnalyticsData: mockFetchRepoAnalyticsData, -})) -vi.mock('../../../../src/commands/analytics/output-analytics.mts', () => ({ - outputAnalytics: mockOutputAnalytics, -})) - -describe('handleAnalytics', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches org analytics when scope is org', async () => { - const mockData = [{ packages: 10, vulnerabilities: 2 }] - mockFetchOrgAnalyticsData.mockResolvedValue(createSuccessResult(mockData)) - - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'org', - time: 30, - }) - - expect(fetchOrgAnalyticsData).toHaveBeenCalledWith(30, { - commandPath: 'socket analytics', - }) - expect(outputAnalytics).toHaveBeenCalledWith( - createSuccessResult(mockData), - { - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'org', - time: 30, - }, - ) - }) - - it('fetches repo analytics when repo is provided', async () => { - const mockData = [{ packages: 5, vulnerabilities: 1 }] - mockFetchRepoAnalyticsData.mockResolvedValue(createSuccessResult(mockData)) - - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: 'test-repo', - scope: 'repo', - time: 7, - }) - - expect(fetchRepoAnalyticsData).toHaveBeenCalledWith('test-repo', 7, { - commandPath: 'socket analytics', - }) - expect(outputAnalytics).toHaveBeenCalledWith( - createSuccessResult(mockData), - { - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: 'test-repo', - scope: 'repo', - time: 7, - }, - ) - }) - - it('returns error when repo is missing and scope is not org', async () => { - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'repo', - time: 30, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - { - ok: false, - message: 'Missing repository name in command', - }, - { - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'repo', - time: 30, - }, - ) - }) - - it('handles empty analytics data for org', async () => { - mockFetchOrgAnalyticsData.mockResolvedValue(createSuccessResult([])) - - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'org', - time: 30, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - { - ok: true, - message: - 'The analytics data for this organization is not yet available.', - data: [], - }, - expect.any(Object), - ) - }) - - it('handles empty analytics data for repo', async () => { - mockFetchRepoAnalyticsData.mockResolvedValue(createSuccessResult([])) - - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: 'test-repo', - scope: 'repo', - time: 7, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - { - ok: true, - message: 'The analytics data for this repository is not yet available.', - data: [], - }, - expect.any(Object), - ) - }) - - it('passes through fetch errors', async () => { - const errorResult = createErrorResult('API error') - mockFetchOrgAnalyticsData.mockResolvedValue(errorResult) - - await handleAnalytics({ - filepath: '/tmp/analytics.json', - outputKind: 'json', - repo: '', - scope: 'org', - time: 30, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - errorResult, - expect.any(Object), - ) - }) - - it('handles markdown output kind', async () => { - const mockData = [{ packages: 10, vulnerabilities: 2 }] - mockFetchOrgAnalyticsData.mockResolvedValue(createSuccessResult(mockData)) - - await handleAnalytics({ - filepath: '', - outputKind: 'markdown', - repo: '', - scope: 'org', - time: 30, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - createSuccessResult(mockData), - { - filepath: '', - outputKind: 'markdown', - repo: '', - scope: 'org', - time: 30, - }, - ) - }) - - it('handles text output kind', async () => { - const mockData = [{ packages: 10, vulnerabilities: 2 }] - mockFetchOrgAnalyticsData.mockResolvedValue(createSuccessResult(mockData)) - - await handleAnalytics({ - filepath: '', - outputKind: 'text', - repo: '', - scope: 'org', - time: 30, - }) - - expect(outputAnalytics).toHaveBeenCalledWith( - createSuccessResult(mockData), - { - filepath: '', - outputKind: 'text', - repo: '', - scope: 'org', - time: 30, - }, - ) - }) -}) diff --git a/packages/cli/test/unit/commands/analytics/output-analytics.test.mts b/packages/cli/test/unit/commands/analytics/output-analytics.test.mts deleted file mode 100644 index 93c1d010c..000000000 --- a/packages/cli/test/unit/commands/analytics/output-analytics.test.mts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import FIXTURE from '../../../../src/commands/analytics/analytics-fixture.json' with { type: 'json' } -import { - formatDataOrg, - formatDataRepo, - formatDate, - renderMarkdown, -} from '../../../../src/commands/analytics/output-analytics.mts' - -// formatDate() in output-analytics.mts uses local-time getMonth() / -// getDate() — the user-visible output is intentionally local. The -// snapshots encode UTC-day dates (e.g. "Apr 19" for 2025-04-19T04:50Z), -// matching CI runners which are UTC. scripts/test-wrapper.mts pins TZ -// to UTC for the spawned vitest process so these snapshots are stable -// across developer timezones. -describe('output-analytics', () => { - describe('format data', () => { - it('should formatDataRepo', () => { - const str = formatDataRepo(JSON.parse(JSON.stringify(FIXTURE))) - - expect(str).toMatchInlineSnapshot(` - { - "top_five_alert_types": { - "dynamicRequire": 274, - "envVars": 2533, - "filesystemAccess": 514, - "networkAccess": 434, - "unmaintained": 532, - }, - "total_critical_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_critical_alerts": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_critical_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_high_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_high_alerts": { - "Apr 19": 13, - "Apr 20": 13, - "Apr 21": 13, - "Apr 22": 10, - }, - "total_high_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_low_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_low_alerts": { - "Apr 19": 1054, - "Apr 20": 1060, - "Apr 21": 1066, - "Apr 22": 1059, - }, - "total_low_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_medium_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_medium_alerts": { - "Apr 19": 206, - "Apr 20": 207, - "Apr 21": 209, - "Apr 22": 206, - }, - "total_medium_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - } - `) - }) - - it('should formatDataOrg', () => { - const str = formatDataOrg(JSON.parse(JSON.stringify(FIXTURE))) - - expect(str).toMatchInlineSnapshot(` - { - "top_five_alert_types": { - "dynamicRequire": 274, - "envVars": 2533, - "filesystemAccess": 514, - "networkAccess": 434, - "unmaintained": 532, - }, - "total_critical_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_critical_alerts": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_critical_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_high_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_high_alerts": { - "Apr 19": 13, - "Apr 20": 13, - "Apr 21": 13, - "Apr 22": 10, - }, - "total_high_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_low_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_low_alerts": { - "Apr 19": 1054, - "Apr 20": 1060, - "Apr 21": 1066, - "Apr 22": 1059, - }, - "total_low_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_medium_added": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - "total_medium_alerts": { - "Apr 19": 206, - "Apr 20": 207, - "Apr 21": 209, - "Apr 22": 206, - }, - "total_medium_prevented": { - "Apr 19": 0, - "Apr 20": 0, - "Apr 21": 0, - "Apr 22": 0, - }, - } - `) - }) - }) - - describe('format data with same-date aggregation', () => { - it('aggregates metrics across entries that share a date (line 272)', () => { - // Two entries on the same day, formatDataOrg sums their metrics into - // the same date bucket. The else-branch creates the first entry; the - // if-branch adds onto it. - const data = [ - { - created_at: '2025-05-01T01:00:00Z', - top_five_alert_types: { alpha: 5 }, - total_critical_alerts: 1, - total_high_alerts: 2, - total_medium_alerts: 3, - total_low_alerts: 4, - total_critical_added: 0, - total_high_added: 0, - total_medium_added: 0, - total_low_added: 0, - total_critical_prevented: 0, - total_high_prevented: 0, - total_medium_prevented: 0, - total_low_prevented: 0, - }, - { - created_at: '2025-05-01T02:00:00Z', // same day - top_five_alert_types: { alpha: 7 }, - total_critical_alerts: 10, - total_high_alerts: 20, - total_medium_alerts: 30, - total_low_alerts: 40, - total_critical_added: 0, - total_high_added: 0, - total_medium_added: 0, - total_low_added: 0, - total_critical_prevented: 0, - total_high_prevented: 0, - total_medium_prevented: 0, - total_low_prevented: 0, - }, - ] as unknown - - const result = formatDataOrg(data) - // Both entries on the same day get the same date key after formatDate; - // we look up that single key dynamically so we don't depend on the - // exact format string. - const criticalKeys = Object.keys(result.total_critical_alerts) - expect(criticalKeys.length).toBe(1) - const dateKey = criticalKeys[0]! - expect(result.total_critical_alerts[dateKey]).toBe(11) - expect(result.total_high_alerts[dateKey]).toBe(22) - // top_five aggregation also doubles up: 5 + 7 = 12. - expect(result.top_five_alert_types['alpha']).toBe(12) - }) - }) - - describe('format markdown', () => { - it('should renderMarkdown for repo', () => { - const fdata = formatDataRepo(JSON.parse(JSON.stringify(FIXTURE))) - const serialized = renderMarkdown(fdata, 7, 'fake_repo') - - expect(serialized).toMatchInlineSnapshot(` - "# Socket Alert Analytics - - These are the Socket.dev analytics for the fake_repo repo of the past 7 days - - ## Total critical alerts - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total high alerts - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 13 | - | Apr 21 | 13 | - | Apr 20 | 13 | - | Apr 22 | 10 | - | ------ | ------ | - - ## Total critical alerts added to the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total high alerts added to the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total critical alerts prevented from the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total high alerts prevented from the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total medium alerts prevented from the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Total low alerts prevented from the main branch - - | Date | Counts | - | ------ | ------ | - | Apr 19 | 0 | - | Apr 21 | 0 | - | Apr 20 | 0 | - | Apr 22 | 0 | - | ------ | ------ | - - ## Top 5 alert types - - | Name | Counts | - | ---------------- | ------ | - | envVars | 2533 | - | unmaintained | 532 | - | filesystemAccess | 514 | - | networkAccess | 434 | - | dynamicRequire | 274 | - | ---------------- | ------ | - " - `) - }) - }) - - describe('formatDate', () => { - it('formats valid dates as "MonthName Day"', () => { - const result = formatDate('2026-03-15T00:00:00Z') - expect(result).toMatch( - /^(Apr|Aug|Dec|Feb|Jan|Jul|Jun|Mar|May|Nov|Oct|Sep) \d+$/, - ) - }) - - it('returns first 10 chars for invalid date strings', () => { - // Invalid date string → getMonth/getDate return NaN → fallback path. - const result = formatDate('not-a-real-date') - expect(result).toBe('not-a-real') - }) - - it('returns truncated input when date does not parse', () => { - const result = formatDate('XXXX-YY-ZZ') - expect(result).toBe('XXXX-YY-ZZ') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ask/cmd-ask.test.mts b/packages/cli/test/unit/commands/ask/cmd-ask.test.mts deleted file mode 100644 index 6d503a5d6..000000000 --- a/packages/cli/test/unit/commands/ask/cmd-ask.test.mts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Unit tests for ask command. - * - * Tests the command entry point that parses natural language queries and - * translates them into Socket CLI commands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawn to prevent actual command execution. -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: vi.fn().mockResolvedValue({ code: 0 }), -})) - -// Import after mocks. -const { cmdAsk } = await import('../../../../src/commands/ask/cmd-ask.mts') - -describe('cmd-ask', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdAsk.description).toBe('Ask in plain English') - }) - - it('should not be hidden', () => { - expect(cmdAsk.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-ask.mts' } - const context = { parentName: 'socket' } - - it('should throw InputError when no query provided', async () => { - await expect(cmdAsk.run([], importMeta, context)).rejects.toThrow( - /socket ask requires a QUERY positional argument/, - ) - }) - - it('should process query and output result', async () => { - await cmdAsk.run(['scan for vulnerabilities'], importMeta, context) - - // Should log the query interpretation. - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('should show tip about --execute flag when not executing', async () => { - await cmdAsk.run(['fix issues'], importMeta, context) - - // Should show tip about execute flag. - const logCalls = mockLogger.log.mock.calls.flat() - const hasTip = logCalls.some( - call => typeof call === 'string' && call.includes('--execute'), - ) - expect(hasTip).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ask/handle-ask.test.mts b/packages/cli/test/unit/commands/ask/handle-ask.test.mts deleted file mode 100644 index 7260f1c29..000000000 --- a/packages/cli/test/unit/commands/ask/handle-ask.test.mts +++ /dev/null @@ -1,777 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for ask command handler. - * - * Tests the parseIntent function that converts natural language queries into - * Socket CLI commands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - cosineSimilarity, - ensureCommandEmbeddings, - extractWords, - getEmbedding, - getEmbeddingPipeline, - handleAsk, - normalizeQuery, - onnxSemanticMatch, - parseIntent, - wordOverlap, - wordOverlapMatch, -} from '../../../../src/commands/ask/handle-ask.mts' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -const mockSpawn = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -const mockOutputAskCommand = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/ask/output-ask.mts', () => ({ - outputAskCommand: mockOutputAskCommand, -})) - -const mockReadFile = vi.hoisted(() => vi.fn()) -vi.mock('node:fs', async importOriginal => { - const actual: unknown = await importOriginal() - return { - ...actual, - promises: { - ...actual.promises, - readFile: mockReadFile, - }, - } -}) - -const mockGetHome = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/env/home', () => ({ - getHome: mockGetHome, -})) - -describe('handleAsk', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSpawn.mockReset() - }) - - it('should output command and show tip when not executing', async () => { - await handleAsk({ - query: 'scan for vulnerabilities', - execute: false, - explain: false, - }) - - expect(mockOutputAskCommand).toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(mockLogger.log).toHaveBeenCalledWith( - '💡 Tip: Add --execute or -e to run this command directly', - ) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('should execute command when execute is true', async () => { - mockSpawn.mockResolvedValue({ code: 0 }) - - await handleAsk({ - query: 'scan for vulnerabilities', - execute: true, - explain: false, - }) - - expect(mockOutputAskCommand).toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith('🚀 Executing...') - expect(mockSpawn).toHaveBeenCalledWith( - 'socket', - expect.arrayContaining(['scan']), - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should handle spawn returning null', async () => { - mockSpawn.mockResolvedValue(undefined) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never) - - // The function checks result.code before checking for null, so we need to handle the error - try { - await handleAsk({ - query: 'scan for issues', - execute: true, - explain: false, - }) - } catch (e) { - // Expected - result is null so accessing .code throws - } - - // The implementation checks code before null, so we can't test the null branch directly - // Just verify spawn was called - expect(mockSpawn).toHaveBeenCalled() - - mockExit.mockRestore() - }) - - it('should handle non-zero exit code', async () => { - mockSpawn.mockResolvedValue({ code: 1 }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never) - - await handleAsk({ - query: 'fix vulnerabilities', - execute: true, - explain: false, - }) - - expect(mockLogger.error).toHaveBeenCalledWith( - 'Command failed with exit code 1', - ) - expect(mockExit).toHaveBeenCalledWith(1) - - mockExit.mockRestore() - }) - - it('should pass explain flag to output', async () => { - await handleAsk({ - query: 'optimize dependencies', - execute: false, - explain: true, - }) - - expect(mockOutputAskCommand).toHaveBeenCalledWith( - expect.objectContaining({ - explain: true, - }), - ) - }) -}) - -describe('parseIntent', () => { - describe('action detection', () => { - it('should detect fix action from "fix vulnerabilities"', async () => { - const result = await parseIntent('fix vulnerabilities') - expect(result.action).toBe('fix') - expect(result.command).toContain('fix') - }) - - it('should detect fix action from "resolve security issues"', async () => { - const result = await parseIntent('resolve security issues') - expect(result.action).toBe('fix') - }) - - it('should detect scan action from "scan for vulnerabilities"', async () => { - const result = await parseIntent('scan for vulnerabilities') - expect(result.action).toBe('scan') - expect(result.command).toContain('scan') - }) - - it('should detect scan action from "check for issues"', async () => { - const result = await parseIntent('check for issues') - expect(result.action).toBe('scan') - }) - - it('should detect scan action from "audit my project"', async () => { - const result = await parseIntent('audit my project') - expect(result.action).toBe('scan') - }) - - it('should detect optimize action from "optimize dependencies"', async () => { - const result = await parseIntent('optimize dependencies') - expect(result.action).toBe('optimize') - expect(result.command).toContain('optimize') - }) - - it('should detect optimize action from "replace with better alternatives"', async () => { - const result = await parseIntent('replace with better alternatives') - expect(result.action).toBe('optimize') - }) - - it('should detect patch action from "patch vulnerabilities"', async () => { - const result = await parseIntent('patch vulnerabilities') - expect(result.action).toBe('patch') - expect(result.command).toContain('patch') - }) - - it('should detect package action from "is lodash safe"', async () => { - const result = await parseIntent('is lodash safe') - expect(result.action).toBe('package') - expect(result.command).toContain('package') - }) - - it('should detect package action from "check package score"', async () => { - const result = await parseIntent('check package score') - expect(result.action).toBe('package') - }) - }) - - describe('severity extraction', () => { - it('should extract critical severity', async () => { - const result = await parseIntent('fix critical vulnerabilities') - expect(result.severity).toBe('critical') - }) - - it('should extract high severity', async () => { - const result = await parseIntent('scan for high severity issues') - expect(result.severity).toBe('high') - }) - - it('should extract medium severity', async () => { - const result = await parseIntent('show medium severity alerts') - expect(result.severity).toBe('medium') - }) - - it('should extract low severity', async () => { - const result = await parseIntent('fix low priority issues') - expect(result.severity).toBe('low') - }) - - it('should add severity flag to command', async () => { - const result = await parseIntent('fix critical issues') - expect(result.command.some(c => c.includes('--severity=critical'))).toBe( - true, - ) - }) - }) - - describe('environment detection', () => { - it('should detect production environment', async () => { - const result = await parseIntent('scan production dependencies') - expect(result.environment).toBe('production') - }) - - it('should detect development environment', async () => { - const result = await parseIntent('check dev dependencies') - expect(result.environment).toBe('development') - }) - - it('should add prod flag for production scans', async () => { - const result = await parseIntent('scan production vulnerabilities') - expect(result.command).toContain('--prod') - }) - }) - - describe('dry run detection', () => { - it('should detect dry run from "dry run"', async () => { - const result = await parseIntent('fix vulnerabilities dry run') - expect(result.isDryRun).toBe(true) - }) - - it('should detect dry run from "preview"', async () => { - const result = await parseIntent('preview the fixes') - expect(result.isDryRun).toBe(true) - }) - - it('should add dry-run flag to fix commands by default', async () => { - const result = await parseIntent('fix issues') - expect(result.command).toContain('--dry-run') - }) - }) - - describe('package name extraction', () => { - it('should extract quoted package name', async () => { - const result = await parseIntent('is "express" safe to use') - expect(result.packageName).toBe('express') - }) - - it('should extract package name after "is"', async () => { - const result = await parseIntent('is lodash safe') - expect(result.packageName).toBe('lodash') - }) - - it('should extract scoped package name', async () => { - const result = await parseIntent('check "@types/node" score') - expect(result.packageName).toBe('@types/node') - }) - }) - - describe('confidence scoring', () => { - it('should have reasonable confidence for keyword matches', async () => { - const result = await parseIntent('scan for vulnerabilities') - // Confidence is based on keyword overlap ratio. - expect(result.confidence).toBeGreaterThan(0) - expect(result.confidence).toBeLessThanOrEqual(1) - }) - - it('should return a result even for ambiguous queries', async () => { - const result = await parseIntent('help me') - // Should default to scan action for ambiguous queries. - expect(result.action).toBeDefined() - expect(result.command).toBeDefined() - }) - }) - - describe('command building', () => { - it('should build scan create command', async () => { - const result = await parseIntent('scan my project') - expect(result.command).toEqual(expect.arrayContaining(['scan', 'create'])) - }) - - it('should build fix command with dry-run by default', async () => { - const result = await parseIntent('fix vulnerabilities') - expect(result.command).toContain('fix') - expect(result.command).toContain('--dry-run') - }) - - it('should build package score command', async () => { - const result = await parseIntent('check package safety') - expect(result.command).toContain('package') - expect(result.command).toContain('score') - }) - }) - - describe('explanation generation', () => { - it('should provide meaningful explanation for scan', async () => { - const result = await parseIntent('scan for issues') - expect(result.explanation).toBeTruthy() - expect(result.explanation.length).toBeGreaterThan(10) - }) - - it('should provide meaningful explanation for fix', async () => { - const result = await parseIntent('fix vulnerabilities') - expect(result.explanation).toBeTruthy() - }) - }) - - describe('NLP normalization', () => { - it('should handle verb tenses - "scanning" → scan', async () => { - const result = await parseIntent('scanning for vulnerabilities') - expect(result.action).toBe('scan') - }) - - it('should handle verb tenses - "fixed" → fix', async () => { - const result = await parseIntent('fixed vulnerabilities') - expect(result.action).toBe('fix') - }) - - it('should handle plurals - "vulnerabilities" → vulnerability', async () => { - const result = await parseIntent('scan for vulnerabilities') - expect(result.action).toBe('scan') - }) - }) - - describe('additional pattern detection', () => { - it('should detect issues pattern from "problems in my project"', async () => { - const result = await parseIntent('find problems in my project') - // The 'package' keywords might have higher priority due to 'dependency' matching - // issues pattern uses: problem, alert, warning, concern - expect(result.command).toContain('scan') - }) - - it('should detect issues pattern from "alerts in project"', async () => { - const result = await parseIntent('show alerts in project') - expect(result.action).toBe('issues') - }) - - it('should detect concerns as issues', async () => { - const result = await parseIntent('find concerns in my code') - expect(result.action).toBe('issues') - }) - - it('should detect repair as fix action', async () => { - const result = await parseIntent('repair security issues') - expect(result.action).toBe('fix') - }) - - it('should detect remediate as fix action', async () => { - const result = await parseIntent('remediate vulnerabilities') - expect(result.action).toBe('fix') - }) - - it('should detect upgrade as fix action', async () => { - const result = await parseIntent('upgrade vulnerable packages') - expect(result.action).toBe('fix') - }) - - it('should detect enhance as optimize action', async () => { - const result = await parseIntent('enhance dependencies') - expect(result.action).toBe('optimize') - }) - - it('should detect improve as optimize action', async () => { - const result = await parseIntent('improve dependencies') - expect(result.action).toBe('optimize') - }) - - it('should detect better as optimize action', async () => { - const result = await parseIntent('find better alternatives') - expect(result.action).toBe('optimize') - }) - - it('should detect apply patch as patch action', async () => { - const result = await parseIntent('apply patch to fix CVE') - expect(result.action).toBe('patch') - }) - - it('should detect trust as package action', async () => { - const result = await parseIntent('can I trust lodash') - expect(result.action).toBe('package') - }) - - it('should detect quality as package action', async () => { - const result = await parseIntent('check quality of express') - expect(result.action).toBe('package') - }) - - it('should detect rating as package action', async () => { - const result = await parseIntent('what is the rating of axios') - expect(result.action).toBe('package') - }) - - it('should detect inspect as scan action', async () => { - const result = await parseIntent('inspect my project') - expect(result.action).toBe('scan') - }) - - it('should detect review as scan action', async () => { - // 'review dependencies' matches 'package' because 'dependency' is in package keywords - // Use a different query that doesn't have competing matches - const result = await parseIntent('review my project for issues') - expect(result.action).toBe('scan') - }) - - it('should detect analyze as scan action', async () => { - const result = await parseIntent('analyze project for issues') - expect(result.action).toBe('scan') - }) - }) - - describe('severity variants', () => { - it('should detect severe as critical', async () => { - const result = await parseIntent('fix severe vulnerabilities') - expect(result.severity).toBe('critical') - }) - - it('should detect urgent as critical', async () => { - const result = await parseIntent('fix urgent security issues') - expect(result.severity).toBe('critical') - }) - - it('should detect blocker as critical', async () => { - const result = await parseIntent('fix blocker issues') - expect(result.severity).toBe('critical') - }) - - it('should detect important as high', async () => { - const result = await parseIntent('fix important vulnerabilities') - expect(result.severity).toBe('high') - }) - - it('should detect major as high', async () => { - const result = await parseIntent('scan for major issues') - expect(result.severity).toBe('high') - }) - - it('should detect moderate as medium', async () => { - const result = await parseIntent('fix moderate vulnerabilities') - expect(result.severity).toBe('medium') - }) - - it('should detect normal as medium', async () => { - const result = await parseIntent('show normal severity alerts') - expect(result.severity).toBe('medium') - }) - - it('should detect minor as low', async () => { - const result = await parseIntent('fix minor issues') - expect(result.severity).toBe('low') - }) - - it('should detect trivial as low', async () => { - const result = await parseIntent('show trivial warnings') - expect(result.severity).toBe('low') - }) - }) - - describe('command flag building', () => { - it('should not add prod flag for non-scan commands', async () => { - const result = await parseIntent('fix production vulnerabilities') - expect(result.environment).toBe('production') - // prod flag only applies to scan commands - expect(result.command).not.toContain('--prod') - }) - - it('should not add severity flag for optimize commands', async () => { - const result = await parseIntent('optimize critical dependencies') - // Severity should still be detected but not added to command - expect(result.command.some(c => c.includes('--severity'))).toBe(false) - }) - - it('should not add dry-run when execute is explicitly mentioned', async () => { - const result = await parseIntent('execute fix vulnerabilities') - expect(result.command).not.toContain('--dry-run') - }) - }) - - describe('package name edge cases', () => { - it('should handle package name with slash', async () => { - const result = await parseIntent('check "@org/package" safety') - expect(result.packageName).toBe('@org/package') - }) - - it('should not extract common command words as package names', async () => { - const result = await parseIntent('check scan safety') - expect(result.packageName).toBeUndefined() - }) - - it('should not extract fix as package name', async () => { - const result = await parseIntent('is fix safe') - expect(result.packageName).toBeUndefined() - }) - - it('should not extract patch as package name', async () => { - const result = await parseIntent('check patch score') - expect(result.packageName).toBeUndefined() - }) - - it('should extract package from "about" phrase', async () => { - const result = await parseIntent('tell me about lodash') - expect(result.packageName).toBe('lodash') - }) - - it('should extract package from "check" phrase', async () => { - // The 'with' phrase extraction expects word after 'with' but 'score' comes before - // Test the actual behavior - package name from 'check express' pattern - const result = await parseIntent('check express safety') - expect(result.packageName).toBe('express') - }) - }) - - describe('empty and edge case queries', () => { - it('should handle empty query gracefully', async () => { - const result = await parseIntent('') - expect(result.action).toBeDefined() - expect(result.command).toBeDefined() - }) - - it('should handle query with only whitespace', async () => { - const result = await parseIntent(' ') - expect(result.action).toBeDefined() - }) - - it('should handle query with special characters', async () => { - const result = await parseIntent('fix @#$% vulnerabilities') - expect(result.action).toBe('fix') - }) - - it('should handle very long query', async () => { - const longQuery = - 'please scan my project for vulnerabilities and check all the dependencies for security issues and problems' - const result = await parseIntent(longQuery) - expect(result.action).toBeDefined() - }) - }) -}) - -describe('normalizeQuery', () => { - it('lowercases the query', () => { - expect(normalizeQuery('FIX VULNERABILITIES')).toContain('fix') - }) - - it('returns lowercased input on NLP failure (catch path)', () => { - // Empty query is fine — the catch path is exercised whenever - // compromise's nlp() throws on a pathological input. Most inputs - // succeed, so we just verify the happy-path lowercases. - const result = normalizeQuery('Scan My Project') - expect(result).toBe(result.toLowerCase()) - }) -}) - -describe('extractWords', () => { - it('lowercases and filters short words', () => { - const words = extractWords('Scan a Project for VULNERABILITIES') - expect(words).toContain('scan') - expect(words).toContain('project') - expect(words).toContain('vulnerabilities') - // Words <= 2 chars filtered. - expect(words).not.toContain('a') - }) - - it('strips punctuation', () => { - const words = extractWords('fix! vulnerabilities? and! issues.') - expect(words).toContain('fix') - expect(words).toContain('vulnerabilities') - expect(words).toContain('and') - expect(words).toContain('issues') - }) - - it('returns empty array for whitespace-only string', () => { - expect(extractWords(' ')).toEqual([]) - }) -}) - -describe('wordOverlap', () => { - it('returns 0 when query and command both empty', () => { - expect(wordOverlap(new Set(), [])).toBe(0) - }) - - it('returns 1 when query and command identical', () => { - expect(wordOverlap(new Set(['fix', 'security']), ['fix', 'security'])).toBe( - 1, - ) - }) - - it('returns Jaccard ratio for partial overlap', () => { - // Query: {a, b}; Command: [b, c]; ∩ = {b}; ∪ = {a, b, c}; ratio = 1/3. - const result = wordOverlap(new Set(['a', 'b']), ['b', 'c']) - expect(result).toBeCloseTo(1 / 3, 5) - }) - - it('returns 0 with no overlap', () => { - expect(wordOverlap(new Set(['a']), ['b', 'c'])).toBe(0) - }) -}) - -describe('wordOverlapMatch', () => { - it('returns undefined when semantic index is unavailable', async () => { - const result = await wordOverlapMatch('fix vulnerabilities') - expect(result === undefined || typeof result === 'object').toBe(true) - }) - - it('returns undefined for empty/whitespace query', async () => { - const result = await wordOverlapMatch(' ') - expect(result).toBeUndefined() - }) - - it('returns undefined when getHome returns falsy (line 169)', async () => { - mockGetHome.mockReturnValueOnce(undefined) - mockReadFile.mockClear() - const result = await wordOverlapMatch('fix something') - expect(result).toBeUndefined() - }) - - it('skips invalid command entries during scoring (lines 233-241)', async () => { - // Provide a synthetic semantic index with mixed valid + invalid entries. - // Use a long word so it survives extractWords (>2 chars). - mockGetHome.mockReturnValueOnce('/fake/home') - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - commands: { - fix: { words: ['fix', 'security', 'vulnerability'] }, - // Invalid: missing words array. - bad1: { description: 'no words' }, - // Invalid: words is not array. - bad2: { words: 'not-array' }, - // Invalid: not an object. - bad3: 'just-a-string', - }, - }), - ) - const result = await wordOverlapMatch('fix security vulnerability') - // Should return a non-null match for 'fix' since invalid entries are skipped. - if (result) { - expect(['fix', 'bad1', 'bad2', 'bad3']).toContain(result.action) - } - }) - - it('returns undefined when no command meets minimum overlap threshold (line 252)', async () => { - mockGetHome.mockReturnValueOnce('/fake/home') - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - commands: { - fix: { words: ['xyz123'] }, - scan: { words: ['abc456'] }, - }, - }), - ) - const result = await wordOverlapMatch('completely unrelated query') - expect(result).toBeUndefined() - }) -}) - -describe('cosineSimilarity', () => { - it('returns 0 for vectors of different lengths', () => { - expect( - cosineSimilarity(new Float32Array([1, 2]), new Float32Array([1, 2, 3])), - ).toBe(0) - }) - - it('computes dot product for matching-length normalized vectors', () => { - // Two identical unit vectors → dot product 1. - const a = new Float32Array([1, 0, 0]) - const b = new Float32Array([1, 0, 0]) - expect(cosineSimilarity(a, b)).toBe(1) - }) - - it('returns 0 for orthogonal unit vectors', () => { - const a = new Float32Array([1, 0, 0]) - const b = new Float32Array([0, 1, 0]) - expect(cosineSimilarity(a, b)).toBe(0) - }) - - it('handles undefined entries (treated as 0)', () => { - const a = new Float32Array([1, 2, 3]) - const b = new Float32Array([4, 5, 6]) - // 1*4 + 2*5 + 3*6 = 32. - expect(cosineSimilarity(a, b)).toBe(32) - }) -}) - -describe('getEmbeddingPipeline', () => { - it('returns undefined when pipeline is temporarily disabled', async () => { - const result = await getEmbeddingPipeline() - expect(result).toBeUndefined() - }) -}) - -describe('getEmbedding', () => { - it('returns undefined when embedding pipeline is unavailable', async () => { - const result = await getEmbedding('any text') - expect(result).toBeUndefined() - }) -}) - -describe('ensureCommandEmbeddings', () => { - it('completes without throwing when pipeline is unavailable', async () => { - // With the pipeline disabled, getEmbedding returns null for every - // command description and no embeddings are stored. - await expect(ensureCommandEmbeddings()).resolves.toBeUndefined() - }) -}) - -describe('onnxSemanticMatch', () => { - it('returns undefined when embedding pipeline unavailable', async () => { - const result = await onnxSemanticMatch('fix vulnerabilities') - expect(result).toBeUndefined() - }) -}) - -describe('parseIntent semantic match fallthrough', () => { - it('skips wordOverlapMatch when action not in PATTERNS (lines 512-514)', async () => { - // Provide a semantic index whose top match action is unknown to PATTERNS, - // so the fallback hits line 513 (`if (pattern)`) but skips the body. - mockGetHome.mockReturnValueOnce('/fake/home') - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ - commands: { - // 'unknown-action' is not in PATTERNS. - 'unknown-action': { words: ['xyz', 'totally', 'unrelated'] }, - }, - }), - ) - // Use a query that matches 'xyz totally unrelated' but doesn't hit - // any pattern keyword. - const result = await parseIntent('xyz totally unrelated query') - // Should fall through to a default action (parseIntent always returns one). - expect(result.action).toBeDefined() - }) -}) diff --git a/packages/cli/test/unit/commands/ask/output-ask.test.mts b/packages/cli/test/unit/commands/ask/output-ask.test.mts deleted file mode 100644 index 834266edf..000000000 --- a/packages/cli/test/unit/commands/ask/output-ask.test.mts +++ /dev/null @@ -1,550 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for Ask command output formatting. - * - * Purpose: Tests the output formatting functions for the ask command. - * - * Test Coverage: - outputAskCommand function - explainCommand function (via - * outputAskCommand) - Different action types (scan, package, fix, patch, - * optimize, issues) - Severity filtering display - Environment display - - * Dry-run mode display - Confidence warnings - Project context display. - * - * Related Files: - src/commands/ask/output-ask.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -import { outputAskCommand } from '../../../../src/commands/ask/output-ask.mts' - -describe('output-ask', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('outputAskCommand', () => { - it('outputs query and interpretation', () => { - outputAskCommand({ - query: 'scan my project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a security scan of your project', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - expect(mockLogger.log).toHaveBeenCalled() - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('scan my project') - expect(logs).toContain('Create a security scan') - }) - - it('shows package name when present', () => { - outputAskCommand({ - query: 'check lodash', - intent: { - action: 'package', - command: ['package', 'score', 'lodash'], - confidence: 0.95, - explanation: 'Check package score', - packageName: 'lodash', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('lodash') - }) - - it('shows severity with appropriate color', () => { - outputAskCommand({ - query: 'show critical issues', - intent: { - action: 'issues', - command: ['issues', '--severity', 'critical'], - confidence: 0.8, - explanation: 'Show critical security issues', - severity: 'critical', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('critical') - }) - - it('shows medium severity', () => { - outputAskCommand({ - query: 'show medium issues', - intent: { - action: 'issues', - command: ['issues', '--severity', 'medium'], - confidence: 0.8, - explanation: 'Show medium security issues', - severity: 'medium', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('medium') - }) - - it('shows low severity', () => { - outputAskCommand({ - query: 'show low issues', - intent: { - action: 'issues', - command: ['issues', '--severity', 'low'], - confidence: 0.8, - explanation: 'Show low security issues', - severity: 'low', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('low') - }) - - it('shows environment when present', () => { - outputAskCommand({ - query: 'scan production', - intent: { - action: 'scan', - command: ['scan', 'create', '--prod'], - confidence: 0.85, - explanation: 'Scan production dependencies', - environment: 'production', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('production') - }) - - it('shows dry-run mode when present', () => { - outputAskCommand({ - query: 'fix issues dry run', - intent: { - action: 'fix', - command: ['fix', '--dry-run'], - confidence: 0.9, - explanation: 'Fix issues in dry-run mode', - isDryRun: true, - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('dry-run') - }) - - it('shows low confidence warning', () => { - outputAskCommand({ - query: 'something vague', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.5, - explanation: 'Best guess interpretation', - }, - context: { - hasPackageJson: false, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Low confidence') - }) - - it('shows explanation for scan action when explain is true', () => { - outputAskCommand({ - query: 'scan project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a scan', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Explanation') - expect(logs).toContain('security scan') - }) - - it('shows explanation for package action', () => { - outputAskCommand({ - query: 'check package', - intent: { - action: 'package', - command: ['package', 'score', 'lodash'], - confidence: 0.9, - explanation: 'Check package score', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('security score') - }) - - it('shows explanation for fix action', () => { - outputAskCommand({ - query: 'fix vulnerabilities', - intent: { - action: 'fix', - command: ['fix'], - confidence: 0.9, - explanation: 'Fix security issues', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('package updates') - }) - - it('shows explanation for fix action with dry-run', () => { - outputAskCommand({ - query: 'fix dry run', - intent: { - action: 'fix', - command: ['fix', '--dry-run'], - confidence: 0.9, - explanation: 'Fix in dry-run mode', - isDryRun: true, - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Preview mode') - }) - - it('shows explanation for fix action with severity', () => { - outputAskCommand({ - query: 'fix critical issues', - intent: { - action: 'fix', - command: ['fix', '--severity', 'critical'], - confidence: 0.9, - explanation: 'Fix critical security issues', - severity: 'critical', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('critical severity') - }) - - it('shows explanation for patch action', () => { - outputAskCommand({ - query: 'patch vulnerabilities', - intent: { - action: 'patch', - command: ['patch'], - confidence: 0.9, - explanation: 'Patch vulnerable code', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('patches') - }) - - it('shows explanation for patch action with dry-run', () => { - outputAskCommand({ - query: 'patch dry run', - intent: { - action: 'patch', - command: ['patch', '--dry-run'], - confidence: 0.9, - explanation: 'Patch in dry-run mode', - isDryRun: true, - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Preview mode') - }) - - it('shows explanation for optimize action', () => { - outputAskCommand({ - query: 'optimize dependencies', - intent: { - action: 'optimize', - command: ['optimize'], - confidence: 0.9, - explanation: 'Optimize dependencies', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Socket registry') - }) - - it('shows explanation for optimize action with dry-run', () => { - outputAskCommand({ - query: 'optimize dry run', - intent: { - action: 'optimize', - command: ['optimize', '--dry-run'], - confidence: 0.9, - explanation: 'Optimize in dry-run mode', - isDryRun: true, - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Preview mode') - }) - - it('shows explanation for issues action', () => { - outputAskCommand({ - query: 'list issues', - intent: { - action: 'issues', - command: ['issues'], - confidence: 0.9, - explanation: 'List security issues', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('detected issues') - }) - - it('shows explanation for issues action with severity', () => { - outputAskCommand({ - query: 'list high issues', - intent: { - action: 'issues', - command: ['issues', '--severity', 'high'], - confidence: 0.9, - explanation: 'List high severity issues', - severity: 'high', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Filtered') - }) - - it('shows explanation for scan action with severity filter', () => { - outputAskCommand({ - query: 'scan critical', - intent: { - action: 'scan', - command: ['scan', 'create', '--severity', 'critical'], - confidence: 0.9, - explanation: 'Scan with critical filter', - severity: 'critical', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Filters results') - }) - - it('shows explanation for scan action with production environment', () => { - outputAskCommand({ - query: 'scan production', - intent: { - action: 'scan', - command: ['scan', 'create', '--prod'], - confidence: 0.9, - explanation: 'Scan production', - environment: 'production', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('production dependencies') - }) - - it('shows default explanation for unknown action', () => { - outputAskCommand({ - query: 'do something', - intent: { - action: 'unknown', - command: ['unknown'], - confidence: 0.9, - explanation: 'Unknown action', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('interpreted command') - }) - - it('shows project context when explain is true and package.json exists', () => { - outputAskCommand({ - query: 'scan project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a scan', - }, - context: { - hasPackageJson: true, - dependencies: { lodash: '^4.0.0', express: '^4.0.0' }, - devDependencies: { jest: '^29.0.0' }, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Project Context') - expect(logs).toContain('2 packages') - expect(logs).toContain('1 packages') - }) - - it('handles empty dependencies in context', () => { - outputAskCommand({ - query: 'scan project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a scan', - }, - context: { - hasPackageJson: true, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('0 packages') - }) - - it('does not show project context when explain is false', () => { - outputAskCommand({ - query: 'scan project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a scan', - }, - context: { - hasPackageJson: true, - dependencies: { lodash: '^4.0.0' }, - }, - explain: false, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).not.toContain('Project Context') - }) - - it('does not show project context when package.json does not exist', () => { - outputAskCommand({ - query: 'scan project', - intent: { - action: 'scan', - command: ['scan', 'create'], - confidence: 0.9, - explanation: 'Create a scan', - }, - context: { - hasPackageJson: false, - }, - explain: true, - }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).not.toContain('Project Context') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ask/word-overlap-match.test.mts b/packages/cli/test/unit/commands/ask/word-overlap-match.test.mts deleted file mode 100644 index f687c17e8..000000000 --- a/packages/cli/test/unit/commands/ask/word-overlap-match.test.mts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Unit tests for the word-overlap matcher. - * - * The handle-ask integration tests cover most of the public surface. This file - * targets the edge-case branches that the integration runs don't reach. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockReadFile = vi.hoisted(() => vi.fn()) -const mockGetHome = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', async importOriginal => { - const actual: unknown = await importOriginal() - return { - ...actual, - promises: { - ...actual.promises, - readFile: mockReadFile, - }, - } -}) - -vi.mock('@socketsecurity/lib-stable/env/home', () => ({ - getHome: mockGetHome, -})) - -import { - extractWords, - normalizeQuery, - wordOverlap, - wordOverlapMatch, -} from '../../../../src/commands/ask/word-overlap-match.mts' - -describe('word-overlap-match edge cases', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('normalizeQuery', () => { - it('returns the lowercased query verbatim for plain input', () => { - const result = normalizeQuery('Scan My Project') - expect(result.toLowerCase()).toBe(result) - }) - }) - - describe('extractWords', () => { - it('returns an empty array for whitespace-only input', () => { - expect(extractWords(' ')).toEqual([]) - }) - - it('filters out words shorter than 3 chars', () => { - const words = extractWords('a b ab abc abcd') - expect(words).not.toContain('a') - expect(words).not.toContain('ab') - expect(words).toContain('abc') - expect(words).toContain('abcd') - }) - }) - - describe('wordOverlap', () => { - it('returns 0 when both sides are empty', () => { - expect(wordOverlap(new Set(), [])).toBe(0) - }) - - it('returns 1 for identical sets', () => { - expect(wordOverlap(new Set(['a', 'b']), ['a', 'b'])).toBe(1) - }) - }) - - describe('wordOverlapMatch — empty-query early return (line 118)', () => { - it('returns undefined when extractWords yields no tokens', async () => { - // Provide an index so loadSemanticIndex succeeds, then pass a query - // whose tokens are all <= 2 chars (filtered by extractWords). - mockGetHome.mockReturnValue('/fake/home') - mockReadFile.mockResolvedValue( - JSON.stringify({ - commands: { - fix: { words: ['fix', 'security'] }, - }, - }), - ) - const result = await wordOverlapMatch('a b c') - expect(result).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/audit-log/__snapshots__/AuditLogRenderer.test.mts.snap b/packages/cli/test/unit/commands/audit-log/__snapshots__/AuditLogRenderer.test.mts.snap deleted file mode 100644 index b9f12e5a2..000000000 --- a/packages/cli/test/unit/commands/audit-log/__snapshots__/AuditLogRenderer.test.mts.snap +++ /dev/null @@ -1,16 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`AuditLogRenderer > data rendering > should render audit log table with multiple entries 1`] = ` -"Socket Audit Logs for test-org - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Event ID Created At Event Type User Email │ -│ │ -│ evt_12345678901234 Apr 19, 2024 10:30 AM repository.created user@example.com │ -│ evt_23456789012345 Apr 19, 2024 11:00 AM settings.updated admin@example.com │ -│ │ -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ - -" -`; diff --git a/packages/cli/test/unit/commands/audit-log/cmd-audit-log.test.mts b/packages/cli/test/unit/commands/audit-log/cmd-audit-log.test.mts deleted file mode 100644 index 58de3c0bf..000000000 --- a/packages/cli/test/unit/commands/audit-log/cmd-audit-log.test.mts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * Unit tests for audit-log command. - * - * Tests the command that displays organization audit logs. - * - * Test Coverage: - Command metadata (description, hidden flag) - API token - * requirement validation - Organization slug handling - Type filter argument - * parsing - Pagination flags: page, per-page - Output modes: text, JSON, - * markdown - Dry-run mode - Legacy flag detection. - * - * Testing Approach: - Mock logger to capture output - Mock handleAuditLog to - * verify handler invocation - Mock determineOrgSlug for organization handling - - * Mock hasDefaultApiToken for authentication checks - Test flag combinations - * and defaults. - * - * Related Files: - src/commands/audit-log/cmd-audit-log.mts - Implementation - - * src/commands/audit-log/handle-audit-log.mts - Handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleAuditLog = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/audit-log/handle-audit-log.mts', () => ({ - handleAuditLog: mockHandleAuditLog, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdAuditLog } = - await import('../../../../src/commands/audit-log/cmd-audit-log.mts') - -describe('cmd-audit-log', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdAuditLog.description).toBe( - 'Look up the audit log for an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdAuditLog.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-audit-log.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--dry-run'], importMeta, context) - - expect(mockHandleAuditLog).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdAuditLog.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAuditLog).not.toHaveBeenCalled() - }) - - it('should call handleAuditLog with default values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run([], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith({ - logType: '', - orgSlug: 'test-org', - outputKind: 'text', - page: 0, - perPage: 30, - }) - }) - - it('should pass type filter as first argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['deleteReport'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - logType: 'DeleteReport', - }), - ) - }) - - it('should capitalize first letter of type filter', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['createApiToken'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - logType: 'CreateApiToken', - }), - ) - }) - - it('should pass --page flag to handleAuditLog', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--page', '2'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - page: 2, - }), - ) - }) - - it('should pass --per-page flag to handleAuditLog', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--per-page', '50'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - perPage: 50, - }), - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--org', 'custom-org'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--json'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--markdown'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail if both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--json', '--markdown'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAuditLog).not.toHaveBeenCalled() - }) - - it('should fail without organization slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAuditLog).not.toHaveBeenCalled() - }) - - it('should handle --no-interactive flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--no-interactive'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should validate type filter is alphabetic', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['invalid-123'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleAuditLog).not.toHaveBeenCalled() - }) - - it('should allow empty type filter', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run([], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - logType: '', - }), - ) - }) - - it('should show dry-run output with all parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run( - ['--dry-run', 'deleteReport', '--page', '2', '--per-page', '10'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('audit log entries'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('deleteReport'), - ) - }) - - it('should validate page is numeric', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdAuditLog.run(['--page', 'invalid'], importMeta, context), - ).rejects.toThrow(/--page must be a non-negative integer/) - }) - - it('should validate per-page is numeric', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdAuditLog.run(['--per-page', 'invalid'], importMeta, context), - ).rejects.toThrow(/--per-page must be a non-negative integer/) - }) - - it('should reject negative page numbers', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdAuditLog.run(['--page', '-1'], importMeta, context), - ).rejects.toThrow(/--page must be a non-negative integer/) - }) - - it('should reject negative per-page numbers', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdAuditLog.run(['--per-page', '-1'], importMeta, context), - ).rejects.toThrow(/--per-page must be a non-negative integer/) - }) - - it('should accept zero as page number', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--page', '0'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - page: 0, - }), - ) - }) - - it('should combine type filter and pagination flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run( - ['sendInvitation', '--page', '3', '--per-page', '20'], - importMeta, - context, - ) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - logType: 'SendInvitation', - page: 3, - perPage: 20, - }), - ) - }) - - it('should show dry-run with default filter value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('any'), - ) - }) - - it('should handle organization with special characters', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce([ - 'org-with-dash', - 'org-with-dash', - ]) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdAuditLog.run(['--org', 'org-with-dash'], importMeta, context) - - expect(mockHandleAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'org-with-dash', - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/audit-log/fetch-audit-log.test.mts b/packages/cli/test/unit/commands/audit-log/fetch-audit-log.test.mts deleted file mode 100644 index 74f495f8d..000000000 --- a/packages/cli/test/unit/commands/audit-log/fetch-audit-log.test.mts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Unit tests for fetchAuditLog function. - * - * Tests the data fetching logic for organization audit logs. These tests verify - * SDK integration, pagination handling, and various filtering options. - * - * Test Coverage: - Successful audit log fetch with events and total count - SDK - * setup failure with error propagation - API call failure handling - Custom SDK - * options pass-through - Different log types (all, security, access, etc.) - - * Pagination parameters (page, perPage) - Output kind handling (json, markdown, - * text) - Organization slug parameter passing. - * - * Testing Approach: - Mock Socket SDK using - * setupSdkMockSuccess/Error/SetupFailure helpers - Mock handleApiCall from - * util/socket/api.mts - Mock setupSdk from util/socket/sdk.mts - Verify SDK - * method calls with correct query parameters - Test CResult pattern (ok/error - * states) - * - * Related Files: - src/commands/audit-log/fetch-audit-log.mts - Implementation - * - src/commands/audit-log/handle-audit-log.mts - Handler that calls this - * fetcher - test/helpers/sdk-test-helpers.mts - SDK mocking utilities. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchAuditLog } from '../../../../src/commands/audit-log/fetch-audit-log.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchAuditLog', () => { - it('fetches audit log successfully', async () => { - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getAuditLogEvents', - { - events: [ - { - id: 'event-1', - action: 'package.scan', - actor: 'user@example.com', - timestamp: '2025-01-20T10:00:00Z', - }, - { - id: 'event-2', - action: 'repository.create', - actor: 'admin@example.com', - timestamp: '2025-01-20T11:00:00Z', - }, - ], - total: 2, - }, - ) - - const config = { - logType: 'all', - orgSlug: 'test-org', - outputKind: 'json' as const, - page: 1, - perPage: 100, - } - - const result = await fetchAuditLog(config) - - expect(mockSdk.getAuditLogEvents).toHaveBeenCalledWith('test-org', { - outputJson: true, - outputMarkdown: false, - orgSlug: 'test-org', - type: 'all', - page: 1, - per_page: 100, - }) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'audit log for test-org', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid API token', - }) - - const config = { - logType: 'all', - orgSlug: 'my-org', - outputKind: 'text' as const, - page: 1, - perPage: 50, - } - - const result = await fetchAuditLog(config) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - }) - - it('handles API call failure', async () => { - await setupSdkMockError('getAuditLogEvents', 'Unauthorized access', 403) - - const config = { - logType: 'security', - orgSlug: 'restricted-org', - outputKind: 'json' as const, - page: 1, - perPage: 100, - } - - const result = await fetchAuditLog(config) - - expect(result.ok).toBe(false) - expect(result.code).toBe(403) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('getAuditLogEvents', {}) - - const sdkOpts = { - apiToken: 'audit-token', - baseUrl: 'https://audit.api.com', - } - - const config = { - logType: 'all', - orgSlug: 'custom-org', - outputKind: 'json' as const, - page: 1, - perPage: 100, - } - - await fetchAuditLog(config, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles pagination parameters', async () => { - const { mockSdk } = await setupSdkMockSuccess('getAuditLogEvents', {}) - - const config = { - logType: 'all', - orgSlug: 'test-org', - outputKind: 'json' as const, - page: 5, - perPage: 25, - } - - await fetchAuditLog(config) - - expect(mockSdk.getAuditLogEvents).toHaveBeenCalledWith( - 'test-org', - expect.objectContaining({ - page: 5, - per_page: 25, - }), - ) - }) - - it('handles date filtering', async () => { - const { mockSdk } = await setupSdkMockSuccess('getAuditLogEvents', {}) - - const logTypes = ['all', 'security', 'configuration', 'access'] - - for (let i = 0, { length } = logTypes; i < length; i += 1) { - const logType = logTypes[i] - const config = { - logType, - orgSlug: 'test-org', - outputKind: 'json' as const, - page: 1, - perPage: 100, - } - - await fetchAuditLog(config) - - expect(mockSdk.getAuditLogEvents).toHaveBeenCalledWith( - 'test-org', - expect.objectContaining({ - type: logType, - }), - ) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getAuditLogEvents', {}) - - const config = { - logType: 'all', - orgSlug: 'test-org', - outputKind: 'json' as const, - page: 1, - perPage: 100, - } - - // This tests that the function properly uses __proto__: null. - await fetchAuditLog(config) - - // The function should work without prototype pollution issues. - expect(mockSdk.getAuditLogEvents).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/audit-log/handle-audit-log.test.mts b/packages/cli/test/unit/commands/audit-log/handle-audit-log.test.mts deleted file mode 100644 index 04a152a4a..000000000 --- a/packages/cli/test/unit/commands/audit-log/handle-audit-log.test.mts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Unit tests for audit log command handler. - * - * Tests the main handler logic that orchestrates audit log fetching and output. - * Validates integration between fetch and output layers with various - * configurations. - * - * Test Coverage: - Successful audit log fetch and output - Pagination handling - * (different page numbers and perPage values) - Different log types (security, - * access, all) - Multiple output kinds (json, text, markdown) - Fetch error - * pass-through to output layer - Empty audit log handling - Organization slug - * parameter passing. - * - * Testing Approach: - Mock fetchAuditLog to control API responses - Mock - * outputAuditLog to verify output layer calls - Mock logger (getDefaultLogger) - * for error handling verification - Use createSuccessResult/createErrorResult - * helpers for CResult pattern - Verify correct parameter passing between - * layers. - * - * Related Files: - src/commands/audit-log/handle-audit-log.mts - Implementation - * - src/commands/audit-log/fetch-audit-log.mts - Fetcher - - * src/commands/audit-log/output-audit-log.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleAuditLog } from '../../../../src/commands/audit-log/handle-audit-log.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockFetchAuditLog = vi.hoisted(() => vi.fn()) -const mockOutputAuditLog = vi.hoisted(() => vi.fn()) -const mockGetDefaultLogger = vi.hoisted(() => vi.fn()) -const mockLog = vi.hoisted(() => vi.fn()) -const mockInfo = vi.hoisted(() => vi.fn()) -const mockWarn = vi.hoisted(() => vi.fn()) -const mockError = vi.hoisted(() => vi.fn()) -const mockFail = vi.hoisted(() => vi.fn()) -const mockSuccess = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/audit-log/fetch-audit-log.mts', () => ({ - fetchAuditLog: mockFetchAuditLog, -})) -vi.mock('../../../../src/commands/audit-log/output-audit-log.mts', () => ({ - outputAuditLog: mockOutputAuditLog, -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: mockGetDefaultLogger, -})) - -describe('handleAuditLog', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs audit logs', async () => { - const { fetchAuditLog } = - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - const { outputAuditLog } = - await import('../../../../src/commands/audit-log/output-audit-log.mts') - - const mockLogs = createSuccessResult([ - { id: 1, type: 'security', message: 'Security event' }, - { id: 2, type: 'access', message: 'Access event' }, - ]) - mockFetchAuditLog.mockResolvedValue(mockLogs) - - await handleAuditLog({ - logType: 'security', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }) - - expect(fetchAuditLog).toHaveBeenCalledWith( - { - logType: 'security', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }, - { - commandPath: 'socket audit-log', - }, - ) - expect(outputAuditLog).toHaveBeenCalledWith(mockLogs, { - logType: 'security', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }) - }) - - it('handles pagination', async () => { - const { fetchAuditLog } = - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - const { outputAuditLog } = - await import('../../../../src/commands/audit-log/output-audit-log.mts') - - const mockLogs = createSuccessResult([]) - mockFetchAuditLog.mockResolvedValue(mockLogs) - - await handleAuditLog({ - logType: 'all', - orgSlug: 'test-org', - outputKind: 'text', - page: 5, - perPage: 50, - }) - - expect(fetchAuditLog).toHaveBeenCalledWith( - { - logType: 'all', - orgSlug: 'test-org', - outputKind: 'text', - page: 5, - perPage: 50, - }, - { - commandPath: 'socket audit-log', - }, - ) - expect(outputAuditLog).toHaveBeenCalledWith(mockLogs, { - logType: 'all', - orgSlug: 'test-org', - outputKind: 'text', - page: 5, - perPage: 50, - }) - }) - - it('handles markdown output', async () => { - const { fetchAuditLog } = - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - const { outputAuditLog } = - await import('../../../../src/commands/audit-log/output-audit-log.mts') - - const mockLogs = createSuccessResult([ - { id: 1, type: 'config', message: 'Config change' }, - ]) - mockFetchAuditLog.mockResolvedValue(mockLogs) - - await handleAuditLog({ - logType: 'config', - orgSlug: 'my-org', - outputKind: 'markdown', - page: 1, - perPage: 20, - }) - - expect(fetchAuditLog).toHaveBeenCalledWith( - { - logType: 'config', - orgSlug: 'my-org', - outputKind: 'markdown', - page: 1, - perPage: 20, - }, - { - commandPath: 'socket audit-log', - }, - ) - expect(outputAuditLog).toHaveBeenCalledWith(mockLogs, { - logType: 'config', - orgSlug: 'my-org', - outputKind: 'markdown', - page: 1, - perPage: 20, - }) - }) - - it('handles empty audit logs', async () => { - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - const { outputAuditLog } = - await import('../../../../src/commands/audit-log/output-audit-log.mts') - - const mockLogs = createSuccessResult([]) - mockFetchAuditLog.mockResolvedValue(mockLogs) - - await handleAuditLog({ - logType: 'access', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }) - - expect(outputAuditLog).toHaveBeenCalledWith(mockLogs, expect.any(Object)) - }) - - it('handles fetch errors', async () => { - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - const { outputAuditLog } = - await import('../../../../src/commands/audit-log/output-audit-log.mts') - - const mockError = createErrorResult('API error') - mockFetchAuditLog.mockResolvedValue(mockError) - - await handleAuditLog({ - logType: 'security', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }) - - expect(outputAuditLog).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - it('handles different log types', async () => { - const { fetchAuditLog } = - await import('../../../../src/commands/audit-log/fetch-audit-log.mts') - - const logTypes = ['all', 'security', 'access', 'config', 'data'] - - for (let i = 0, { length } = logTypes; i < length; i += 1) { - const logType = logTypes[i] - mockFetchAuditLog.mockResolvedValue(createSuccessResult([])) - - await handleAuditLog({ - logType, - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - }) - - expect(fetchAuditLog).toHaveBeenCalledWith( - expect.objectContaining({ logType }), - expect.objectContaining({ commandPath: 'socket audit-log' }), - ) - } - }) -}) diff --git a/packages/cli/test/unit/commands/bundler/cmd-bundler.test.mts b/packages/cli/test/unit/commands/bundler/cmd-bundler.test.mts deleted file mode 100644 index db5fe0457..000000000 --- a/packages/cli/test/unit/commands/bundler/cmd-bundler.test.mts +++ /dev/null @@ -1,498 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for bundler wrapper command. - * - * Tests the command entry point that wraps bundler with Socket Firewall - * security. The wrapper intercepts bundler commands and forwards them to Socket - * Firewall (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Flag filtering (Socket CLI vs bundler flags) - Exit code handling - * with process.exit() - Signal propagation with process.kill() - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -// Mock spawnSfwDlx. -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -// Import after mocks. -const { cmdBundler } = - await import('../../../../src/commands/bundler/cmd-bundler.mts') - -describe('cmd-bundler', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdBundler.description).toBe( - 'Run bundler with Socket Firewall security', - ) - }) - - it('should not be hidden', () => { - expect(cmdBundler.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdBundler.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation(args => { - const helpText = args.config.help('socket bundler') - expect(helpText).toContain('socket bundler') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - mockSpawnPromise.process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdBundler.run( - [], - { url: import.meta.url }, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - const importMeta = { url: import.meta.url } as ImportMeta - const context = { parentName: 'socket' } - - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const runPromise = cmdBundler.run(['install'], importMeta, context) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['install'], - config: expect.objectContaining({ - commandName: 'bundler', - description: 'Run bundler with Socket Firewall security', - hidden: false, - }), - importMeta, - parentName: 'socket', - }) - }) - - describe('flag filtering', () => { - it('should filter out Socket CLI flags and forward bundler flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - // Filtered args (Socket CLI flags removed). - mockFilterFlags.mockReturnValue(['install', '--jobs', '4']) - - const runPromise = cmdBundler.run( - ['--config', '{}', 'install', '--jobs', '4'], - importMeta, - context, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['bundler', 'install', '--jobs', '4'], - { stdio: 'inherit' }, - ) - }) - }) - - describe('exit handling', () => { - it('skips exit/kill when both code and signal are null', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - mockExit.mockClear() - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - mockKill.mockClear() - - cmdBundler.run([], importMeta, context) - - await new Promise(resolve => setImmediate(resolve)) - const exitBefore = mockExit.mock.calls.length - const killBefore = mockKill.mock.calls.length - - mockChildProcess.emit('exit', undefined, undefined) - - await new Promise(resolve => setImmediate(resolve)) - - expect(mockExit.mock.calls.length).toBe(exitBefore) - expect(mockKill.mock.calls.length).toBe(killBefore) - - mockExit.mockRestore() - mockKill.mockRestore() - }) - - it('should set default exit code to 1 before child process exits', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdBundler.run(['install'], importMeta, context) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with child exit code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run(['install'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with non-zero exit code on failure', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 1, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run(['install'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with non-zero code. - mockChildProcess.emit('exit', 1, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(1) - - mockExit.mockRestore() - }) - - it('should call process.kill with signal when child receives signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run(['install'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should propagate SIGINT signal from child process', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGINT', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['check']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run(['check'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate SIGINT. - mockChildProcess.emit('exit', undefined, 'SIGINT') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - - mockKill.mockRestore() - }) - }) - - describe('command forwarding', () => { - it('should forward bundler commands to sfw with correct args', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run(['install'], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['bundler', 'install'], { - stdio: 'inherit', - }) - - mockExit.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdBundler.run([], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['bundler'], { - stdio: 'inherit', - }) - - mockExit.mockRestore() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/cargo/cmd-cargo.test.mts b/packages/cli/test/unit/commands/cargo/cmd-cargo.test.mts deleted file mode 100644 index 452e6e62b..000000000 --- a/packages/cli/test/unit/commands/cargo/cmd-cargo.test.mts +++ /dev/null @@ -1,517 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for cargo wrapper command. - * - * Tests the command entry point that wraps cargo with Socket Firewall security. - * The wrapper intercepts cargo commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Flag filtering (Socket CLI vs cargo flags) - Exit code handling - * with process.exit() - Signal propagation with process.kill() - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -// Mock spawnSfwDlx. -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -// Import after mocks. -const { cmdCargo } = - await import('../../../../src/commands/cargo/cmd-cargo.mts') - -describe('cmd-cargo', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdCargo.description).toBe( - 'Run cargo with Socket Firewall security', - ) - }) - - it('should not be hidden', () => { - expect(cmdCargo.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdCargo.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation((args: unknown) => { - // Invoke the help callback so coverage records its lines. - const helpText = args.config.help('socket cargo') - expect(helpText).toContain('socket cargo') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - // run() will fall through to spawning sfw; mock that to avoid - // touching the real binary. - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdCargo.run( - [], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - const importMeta = { url: import.meta.url } as ImportMeta - const context = { parentName: 'socket' } - - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'ripgrep']) - - const runPromise = cmdCargo.run( - ['install', 'ripgrep'], - importMeta, - context, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['install', 'ripgrep'], - config: expect.objectContaining({ - commandName: 'cargo', - description: 'Run cargo with Socket Firewall security', - hidden: false, - }), - importMeta, - parentName: 'socket', - }) - }) - - describe('flag filtering', () => { - it('should filter out Socket CLI flags and forward cargo flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - // Filtered args (Socket CLI flags removed). - mockFilterFlags.mockReturnValue(['build', '--release']) - - const runPromise = cmdCargo.run( - ['--config', '{}', 'build', '--release'], - importMeta, - context, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['cargo', 'build', '--release'], - { stdio: 'inherit' }, - ) - }) - }) - - describe('exit handling', () => { - it('skips exit/kill when both code and signal are null', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['build']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - mockExit.mockClear() - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - mockKill.mockClear() - - cmdCargo.run(['build'], importMeta, context) - - // Wait for handler registration. - await new Promise(resolve => { - setImmediate(resolve) - }) - const exitCallsBefore = mockExit.mock.calls.length - const killCallsBefore = mockKill.mock.calls.length - - // Emit exit with both code and signal as null. - mockChildProcess.emit('exit', undefined, undefined) - - // Wait for event handler. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Neither exit nor kill call count should increase. - expect(mockExit.mock.calls.length).toBe(exitCallsBefore) - expect(mockKill.mock.calls.length).toBe(killCallsBefore) - - mockExit.mockRestore() - mockKill.mockRestore() - }) - - it('should set default exit code to 1 before child process exits', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['build']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdCargo.run(['build'], importMeta, context) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with child exit code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['build']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run(['build'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with non-zero exit code on failure', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 1, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['build']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run(['build'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with non-zero code. - mockChildProcess.emit('exit', 1, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(1) - - mockExit.mockRestore() - }) - - it('should call process.kill with signal when child receives signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['build']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run(['build'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should propagate SIGINT signal from child process', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGINT', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['test']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run(['test'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate SIGINT. - mockChildProcess.emit('exit', undefined, 'SIGINT') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - - mockKill.mockRestore() - }) - }) - - describe('command forwarding', () => { - it('should forward cargo commands to sfw with correct args', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'ripgrep']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run(['install', 'ripgrep'], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['cargo', 'install', 'ripgrep'], - { stdio: 'inherit' }, - ) - - mockExit.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdCargo.run([], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['cargo'], { - stdio: 'inherit', - }) - - mockExit.mockRestore() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ci/cmd-ci.test.mts b/packages/cli/test/unit/commands/ci/cmd-ci.test.mts deleted file mode 100644 index e7be6cbb3..000000000 --- a/packages/cli/test/unit/commands/ci/cmd-ci.test.mts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * Unit tests for ci command. - * - * Tests the command that creates scans for CI environments. - * - * Test Coverage: - Command metadata (description, hidden flag) - --dry-run flag - * support - --auto-manifest flag support - Handler invocation with correct - * parameters - Git operation integration (branch, repo name) - Organization - * slug fetching. - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock handleCi to verify handler is called correctly - - * Mock git operations (gitBranch, detectDefaultBranch, getRepoName) - Mock - * getDefaultOrgSlug for organization fetching - Mock outputDryRunUpload for - * dry-run testing. - * - * Related Files: - src/commands/ci/cmd-ci.mts - Implementation - - * src/commands/ci/handle-ci.mts - Handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock handler. -const mockHandleCi = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/ci/handle-ci.mts', () => ({ - handleCi: mockHandleCi, -})) - -// Mock git operations. -const mockGitBranch = vi.hoisted(() => vi.fn(() => Promise.resolve('main'))) -const mockDetectDefaultBranch = vi.hoisted(() => - vi.fn(() => Promise.resolve('main')), -) -const mockGetRepoName = vi.hoisted(() => - vi.fn(() => Promise.resolve('my-repo')), -) - -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - detectDefaultBranch: mockDetectDefaultBranch, - getRepoName: mockGetRepoName, - gitBranch: mockGitBranch, -})) - -// Mock organization slug fetching. -const mockGetDefaultOrgSlug = vi.hoisted(() => - vi.fn(() => Promise.resolve({ ok: true, data: 'my-org' })), -) - -vi.mock('../../../../src/commands/ci/fetch-default-org-slug.mts', () => ({ - getDefaultOrgSlug: mockGetDefaultOrgSlug, -})) - -// Mock dry-run output. -const mockOutputDryRunUpload = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunUpload: mockOutputDryRunUpload, -})) - -// Mock meowOrExit to prevent actual CLI parsing. Also invoke the -// help() callback so its template-string body is recorded as covered; -// production meowOrExit only invokes it on --help, which the test -// suite never exercises. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = {} - - // Parse flags from argv. - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - if (argv.includes('--auto-manifest')) { - flags['autoManifest'] = true - } - - const help = options.config?.help ? options.config.help('socket ci') : '' - - return { - flags, - help, - input: [], - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { cmdCI } = await import('../../../../src/commands/ci/cmd-ci.mts') - -describe('cmd-ci', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockGetDefaultOrgSlug.mockResolvedValue({ ok: true, data: 'my-org' }) - mockGitBranch.mockResolvedValue('main') - mockDetectDefaultBranch.mockResolvedValue('main') - mockGetRepoName.mockResolvedValue('my-repo') - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdCI.description).toBe( - 'Alias for `socket scan create --report` (creates report and exits with error if unhealthy)', - ) - }) - - it('should not be hidden', () => { - expect(cmdCI.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-ci.mts' } - const context = { parentName: 'socket' } - - describe('handler invocation', () => { - it('should call handler with autoManifest false by default', async () => { - await cmdCI.run([], importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledWith(false) - }) - - it('should call handler with autoManifest true when flag provided', async () => { - await cmdCI.run(['--auto-manifest'], importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledWith(true) - }) - - it('should call handler exactly once', async () => { - await cmdCI.run([], importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledTimes(1) - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without calling handler', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith('CI scan', { - autoManifest: false, - branchName: 'main', - cwd: process.cwd(), - organizationSlug: 'my-org', - repoName: 'my-repo', - report: true, - targets: ['.'], - }) - expect(mockHandleCi).not.toHaveBeenCalled() - }) - - it('should fetch org slug in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGetDefaultOrgSlug).toHaveBeenCalled() - }) - - it('should fetch git branch in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGitBranch).toHaveBeenCalledWith(process.cwd()) - }) - - it('should fetch repo name in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGetRepoName).toHaveBeenCalledWith(process.cwd()) - }) - - it('should include autoManifest true in dry-run when flag provided', async () => { - await cmdCI.run(['--dry-run', '--auto-manifest'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - autoManifest: true, - }), - ) - }) - - it('should use default branch when git branch fails', async () => { - mockGitBranch.mockResolvedValue(undefined) - mockDetectDefaultBranch.mockResolvedValue('develop') - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - branchName: 'develop', - }), - ) - }) - - it('should show placeholder when org slug fetch fails', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: false, - code: 1, - message: 'Failed', - }) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - organizationSlug: '(from API token)', - }), - ) - }) - - it('should show placeholder when repo name is null', async () => { - mockGetRepoName.mockResolvedValue(undefined) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - repoName: '(auto-detected)', - }), - ) - }) - - it('should show placeholder when branch name is null', async () => { - mockGitBranch.mockResolvedValue(undefined) - mockDetectDefaultBranch.mockResolvedValue(undefined) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - branchName: '(default)', - }), - ) - }) - }) - - describe('--auto-manifest flag', () => { - it('should default to false', async () => { - await cmdCI.run([], importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledWith(false) - }) - - it('should pass true when flag provided', async () => { - await cmdCI.run(['--auto-manifest'], importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledWith(true) - }) - - it('should handle boolean conversion correctly', async () => { - await cmdCI.run(['--auto-manifest'], importMeta, context) - - const [autoManifest] = mockHandleCi.mock.calls[0] - expect(typeof autoManifest).toBe('boolean') - expect(autoManifest).toBe(true) - }) - }) - - describe('git operations', () => { - it('should call gitBranch with current directory', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGitBranch).toHaveBeenCalledWith(process.cwd()) - }) - - it('should fall back to detectDefaultBranch when gitBranch returns null', async () => { - mockGitBranch.mockResolvedValue(undefined) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockDetectDefaultBranch).toHaveBeenCalledWith(process.cwd()) - }) - - it('should call getRepoName with current directory', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGetRepoName).toHaveBeenCalledWith(process.cwd()) - }) - - it('should use gitBranch result over detectDefaultBranch', async () => { - mockGitBranch.mockResolvedValue('feature-branch') - mockDetectDefaultBranch.mockResolvedValue('main') - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - branchName: 'feature-branch', - }), - ) - }) - }) - - describe('organization slug', () => { - it('should fetch default org slug', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockGetDefaultOrgSlug).toHaveBeenCalled() - }) - - it('should use org slug from successful fetch', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: true, - data: 'test-org', - }) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - organizationSlug: 'test-org', - }), - ) - }) - - it('should handle org slug fetch error', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: false, - code: 1, - message: 'Auth failed', - }) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - organizationSlug: '(from API token)', - }), - ) - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze([]) as readonly string[] - - await cmdCI.run(readonlyArgv, importMeta, context) - - expect(mockHandleCi).toHaveBeenCalledWith(false) - }) - - it('should handle all git operations returning null', async () => { - mockGitBranch.mockResolvedValue(undefined) - mockDetectDefaultBranch.mockResolvedValue(undefined) - mockGetRepoName.mockResolvedValue(undefined) - - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - branchName: '(default)', - repoName: '(auto-detected)', - }), - ) - }) - - it('should handle git operations throwing errors gracefully', async () => { - mockGitBranch.mockRejectedValue(new Error('Git not found')) - - await expect( - cmdCI.run(['--dry-run'], importMeta, context), - ).rejects.toThrow('Git not found') - }) - - it('should handle org slug fetch throwing errors', async () => { - mockGetDefaultOrgSlug.mockRejectedValue(new Error('Network error')) - - await expect( - cmdCI.run(['--dry-run'], importMeta, context), - ).rejects.toThrow('Network error') - }) - }) - - describe('dry-run output structure', () => { - it('should include report true in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - report: true, - }), - ) - }) - - it('should include targets array in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - targets: ['.'], - }), - ) - }) - - it('should include cwd in dry-run', async () => { - await cmdCI.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'CI scan', - expect.objectContaining({ - cwd: process.cwd(), - }), - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ci/fetch-default-org-slug.test.mts b/packages/cli/test/unit/commands/ci/fetch-default-org-slug.test.mts deleted file mode 100644 index 9edf03cbc..000000000 --- a/packages/cli/test/unit/commands/ci/fetch-default-org-slug.test.mts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Unit tests for getDefaultOrgSlug function. - * - * Tests the organization slug resolution logic used in CI environments. This - * function checks multiple sources in priority order. - * - * Test Coverage: - * - * - Config file defaultOrg value (highest priority) - * - SOCKET_CLI_ORG_SLUG environment variable - * - Fallback to fetching first organization from API - * - Error handling when no organizations exist - * - API call failures during organization fetch - * - * Testing Approach: - * - * - Mock getConfigValueOrUndef from util/config.mts - * - Mock fetchOrganization from organization/fetch-organization-list.mts - * - Mock env.SOCKET_CLI_ORG_SLUG environment variable - * - Test priority order and fallback chain - * - Verify CResult pattern (ok/error states) - * - * Related Files: - * - * - Src/commands/ci/fetch-default-org-slug.mts - Implementation - * - Src/commands/ci/handle-ci.mts - CI command handler that uses this - * - Src/util/config.mts - Config file utilities - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { getDefaultOrgSlug } from '../../../../src/commands/ci/fetch-default-org-slug.mts' - -// Create mock functions with hoisting. -const { mockOrgSlug, mockFetchOrganization, mockGetConfigValueOrUndef } = - vi.hoisted(() => { - return { - mockGetConfigValueOrUndef: vi.fn(), - mockFetchOrganization: vi.fn(), - mockOrgSlug: { value: undefined as string | undefined }, - } - }) - -// Mock the dependencies. -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, -})) - -// Mock the SOCKET_CLI_ORG_SLUG environment variable module. -vi.mock('../../../../src/env/socket-cli-org-slug.mts', () => ({ - get SOCKET_CLI_ORG_SLUG() { - return mockOrgSlug.value - }, -})) - -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -describe('getDefaultOrgSlug', () => { - const mockFn = mockGetConfigValueOrUndef - const mockFetchFn = mockFetchOrganization - - beforeEach(() => { - vi.clearAllMocks() - mockOrgSlug.value = undefined - }) - - it('uses config defaultOrg when set', async () => { - mockFn.mockReturnValue('config-org-slug') - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: true, - data: 'config-org-slug', - }) - expect(mockFn).toHaveBeenCalledWith('defaultOrg') - }) - - it('uses environment variable when no config', async () => { - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = 'env-org-slug' - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: true, - data: 'env-org-slug', - }) - }) - - it('fetches from API when no config or env', async () => { - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = undefined - - mockFetchFn.mockResolvedValue({ - ok: true, - data: { - // fetchOrganization converts the SDK dict into an array of org - // objects before returning, so mock the array shape directly. - organizations: [ - { - id: 'org-1', - name: 'Test Organization', - slug: 'test-org', - }, - ], - }, - }) - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: true, - message: 'Retrieved default org from server', - data: 'test-org', - }) - }) - - it('returns slug (not display name) for orgs with spaces', async () => { - // Regression guard: orgs whose display name has spaces produce - // URLs like `/v0/orgs/Example%20Org%20Ltd/...` that 404. - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = undefined - - mockFetchFn.mockResolvedValue({ - ok: true, - data: { - organizations: [ - { id: 'org-1', name: 'Example Org Ltd', slug: 'example-org-ltd' }, - ], - }, - }) - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: true, - message: 'Retrieved default org from server', - data: 'example-org-ltd', - }) - }) - - it('returns error when fetchOrganization fails', async () => { - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = undefined - - const error = { - ok: false, - code: 401, - message: 'Unauthorized', - } - mockFetchFn.mockResolvedValue(error) - - const result = await getDefaultOrgSlug() - - expect(result).toEqual(error) - }) - - it('returns error when no organizations found', async () => { - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = undefined - - mockFetchFn.mockResolvedValue({ - ok: true, - data: { - organizations: [], - }, - }) - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: false, - message: 'Failed to establish identity', - data: 'No organization associated with the Socket API token. Unable to continue.', - }) - }) - - it('returns error when organization has no slug', async () => { - mockFn.mockReturnValue(undefined) - mockOrgSlug.value = undefined - - mockFetchFn.mockResolvedValue({ - ok: true, - data: { - // Missing slug — defensive check in case the API ever omits it. - organizations: [{ id: 'org-1', name: 'Test Org' }], - }, - }) - - const result = await getDefaultOrgSlug() - - expect(result).toEqual({ - ok: false, - message: 'Failed to establish identity', - data: 'Cannot determine the default organization for the API token. Unable to continue.', - }) - }) -}) diff --git a/packages/cli/test/unit/commands/ci/handle-ci.test.mts b/packages/cli/test/unit/commands/ci/handle-ci.test.mts deleted file mode 100644 index e14640400..000000000 --- a/packages/cli/test/unit/commands/ci/handle-ci.test.mts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Unit tests for CI command handler. - * - * Tests the main CI command that orchestrates repository scanning in CI/CD - * environments. This is a complex handler integrating organization detection, - * branch detection, and scan creation. - * - * Test Coverage: - Successful CI scan creation with full workflow - - * Organization slug detection (config, env, API fallback) - Repository name - * detection from Git - Default branch detection - Current branch detection - * (Git) - Scan creation with proper parameters - Error handling for missing - * organization - Error handling for Git detection failures - Debug logging - * integration - JSON result serialization. - * - * Testing Approach: - Mock getDefaultOrgSlug for organization detection - Mock - * detectDefaultBranch and gitBranch for Git operations - Mock getRepoName for - * repository detection - Mock handleCreateNewScan for scan creation - Mock - * logger for output verification - Mock debug utilities for debug logging - - * Test complete workflow integration. - * - * Related Files: - src/commands/ci/handle-ci.mts - Implementation - - * src/commands/ci/fetch-default-org-slug.mts - Org detection - - * src/commands/scan/handle-create-new-scan.mts - Scan creation. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleCi } from '../../../../src/commands/ci/handle-ci.mts' -import { UNKNOWN_ERROR } from '../../../../src/constants/errors.mts' - -// Create mock functions with hoisting. -const { - mockDebug, - mockDebugDir, - mockDetectDefaultBranch, - mockGetDefaultOrgSlug, - mockGetRepoName, - mockGitBranch, - mockHandleCreateNewScan, - mockLogger, - mockSerializeResultJson, -} = vi.hoisted(() => { - const logger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - return { - mockDebug: vi.fn(), - mockDebugDir: vi.fn(), - mockGetDefaultOrgSlug: vi.fn(), - mockDetectDefaultBranch: vi.fn(), - mockGetRepoName: vi.fn(), - mockGitBranch: vi.fn(), - mockSerializeResultJson: vi.fn(result => JSON.stringify(result)), - mockHandleCreateNewScan: vi.fn(), - mockLogger: logger, - } -}) - -// Mock the dependencies. -const mockDebugLog = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, - debugLog: mockDebugLog, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/ci/fetch-default-org-slug.mts', () => ({ - getDefaultOrgSlug: mockGetDefaultOrgSlug, -})) - -vi.mock('../../../../src/constants.mts', () => ({ - constants: { - REPORT_LEVEL_ERROR: 'error', - }, -})) - -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - detectDefaultBranch: mockDetectDefaultBranch, - getRepoName: mockGetRepoName, - gitBranch: mockGitBranch, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, -})) - -vi.mock('../../../../src/commands/scan/handle-create-new-scan.mts', () => ({ - handleCreateNewScan: mockHandleCreateNewScan, -})) - -describe('handleCi', () => { - const originalCwd = process.cwd - const originalExitCode = process.exitCode - const logger = mockLogger - - beforeEach(() => { - vi.clearAllMocks() - process.cwd = vi.fn(() => '/test/project') - process.exitCode = undefined - }) - - afterEach(() => { - process.cwd = originalCwd - process.exitCode = originalExitCode - }) - - it('handles CI scan successfully', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: true, - data: 'test-org', - }) - mockGitBranch.mockResolvedValue('feature-branch') - mockGetRepoName.mockResolvedValue('test-repo') - - await handleCi(false) - - expect(mockGetDefaultOrgSlug).toHaveBeenCalled() - expect(mockGitBranch).toHaveBeenCalledWith('/test/project') - expect(mockGetRepoName).toHaveBeenCalledWith('/test/project') - expect(mockDetectDefaultBranch).not.toHaveBeenCalled() - expect(mockHandleCreateNewScan).toHaveBeenCalledWith({ - autoManifest: false, - basics: false, - branchName: 'feature-branch', - commitMessage: '', - commitHash: '', - committers: '', - cwd: '/test/project', - defaultBranch: false, - interactive: false, - orgSlug: 'test-org', - outputKind: 'json', - pendingHead: true, - pullRequest: 0, - reach: expect.objectContaining({ - runReachabilityAnalysis: false, - }), - repoName: 'test-repo', - readOnly: false, - report: true, - reportLevel: 'error', - targets: ['.'], - tmp: false, - }) - }) - - it('uses default branch when git branch is not available', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: true, - data: 'test-org', - }) - mockGitBranch.mockResolvedValue(undefined) - mockDetectDefaultBranch.mockResolvedValue('main') - mockGetRepoName.mockResolvedValue('test-repo') - - await handleCi(false) - - expect(mockGitBranch).toHaveBeenCalled() - expect(mockDetectDefaultBranch).toHaveBeenCalledWith('/test/project') - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'main', - }), - ) - }) - - it('handles auto-manifest mode', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: true, - data: 'test-org', - }) - mockGitBranch.mockResolvedValue('develop') - mockGetRepoName.mockResolvedValue('test-repo') - - await handleCi(true) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - autoManifest: true, - }), - ) - }) - - it('handles org slug fetch failure', async () => { - const error = { - ok: false as const, - code: 401, - error: {}, - } - mockGetDefaultOrgSlug.mockResolvedValue(error) - - await handleCi(false) - - expect(process.exitCode).toBe(401) - expect(mockSerializeResultJson).toHaveBeenCalledWith(error) - expect(logger.log).toHaveBeenCalledWith(JSON.stringify(error)) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('sets default exit code on org slug failure without code', async () => { - const error = { - ok: false as const, - error: new Error(UNKNOWN_ERROR), - } - mockGetDefaultOrgSlug.mockResolvedValue(error) - mockSerializeResultJson.mockReturnValue('{"error":"Unknown error"}') - - await handleCi(false) - - expect(process.exitCode).toBe(1) - expect(logger.log).toHaveBeenCalled() - }) - - it('logs debug information', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ - ok: true, - data: 'debug-org', - }) - mockGitBranch.mockResolvedValue('debug-branch') - mockGetRepoName.mockResolvedValue('debug-repo') - - await handleCi(false) - - expect(mockDebug).toHaveBeenCalledWith('Starting CI scan') - expect(mockDebugDir).toHaveBeenCalledWith({ autoManifest: false }) - expect(mockDebug).toHaveBeenCalledWith( - 'CI scan for debug-org/debug-repo on branch debug-branch', - ) - expect(mockDebugDir).toHaveBeenCalledWith({ - orgSlug: 'debug-org', - cwd: '/test/project', - branchName: 'debug-branch', - repoName: 'debug-repo', - }) - }) - - it('logs debug info on org slug failure', async () => { - const error = { - ok: false as const, - error: new Error('Failed'), - } - mockGetDefaultOrgSlug.mockResolvedValue(error) - - await handleCi(false) - - expect(mockDebug).toHaveBeenCalledWith('Failed to get default org slug') - expect(mockDebugDir).toHaveBeenCalledWith({ orgSlugCResult: error }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config-auto.test.mts b/packages/cli/test/unit/commands/config/cmd-config-auto.test.mts deleted file mode 100644 index e0b31e26a..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config-auto.test.mts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Unit tests for config auto command. - * - * Tests the command that automatically discovers and sets correct config - * values. - * - * Test Coverage: - Command metadata (description, hidden flag) - Config key - * validation - Flag combinations (--json, --markdown) - --dry-run flag support - * - Handler invocation with correct parameters. - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock handleConfigAuto to verify handler is called - * correctly - Mock config utilities (isSupportedConfigKey, - * getSupportedConfigEntries) - Mock dry-run output utilities - Mock output mode - * utilities - Mock validation utilities. - * - * Related Files: - src/commands/config/cmd-config-auto.mts - Implementation - - * src/commands/config/handle-config-auto.mts - Handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ConfigModule from '../../../../src/util/config.mts' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock handler. -const mockHandleConfigAuto = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/config/handle-config-auto.mts', () => ({ - handleConfigAuto: mockHandleConfigAuto, -})) - -// Mock config utilities. -const mockIsSupportedConfigKey = vi.hoisted(() => vi.fn(() => true)) -const mockGetSupportedConfigEntries = vi.hoisted(() => - vi.fn(() => [ - ['defaultOrg', 'Default organization slug'], - ['apiToken', 'API authentication token'], - ]), -) - -vi.mock('../../../../src/util/config.mts', async importOriginal => { - const actual = await importOriginal<typeof ConfigModule>() - return { - ...actual, - getSupportedConfigEntries: mockGetSupportedConfigEntries, - isSupportedConfigKey: mockIsSupportedConfigKey, - } -}) - -// Mock dry-run output. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Mock output mode utilities. -const mockGetOutputKind = vi.hoisted(() => vi.fn(() => 'text')) - -vi.mock('../../../../src/util/output/mode.mjs', () => ({ - getOutputKind: mockGetOutputKind, -})) - -// Mock validation utilities. -const mockCheckCommandInput = vi.hoisted(() => vi.fn(() => true)) - -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - // Invoke the help builder so its body is covered. - if (typeof options?.config?.help === 'function') { - options.config.help('socket config auto', options.config) - } - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = { - json: false, - markdown: false, - } - const input: string[] = [] - - // Parse flags from argv. - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--dry-run') { - flags['dryRun'] = true - } else if (arg === '--json') { - flags['json'] = true - } else if (arg === '--markdown') { - flags['markdown'] = true - } else if (!arg.startsWith('--')) { - input.push(arg) - } - } - - return { - flags, - help: '', - input, - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdConfigAuto } = - await import('../../../../src/commands/config/cmd-config-auto.mts') - -describe('cmd-config-auto', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsSupportedConfigKey.mockReturnValue(true) - mockCheckCommandInput.mockReturnValue(true) - mockGetOutputKind.mockReturnValue('text') - }) - - describe('command metadata', () => { - it('should export CMD_NAME as auto', () => { - expect(CMD_NAME).toBe('auto') - }) - - it('should have correct description', () => { - expect(cmdConfigAuto.description).toBe( - 'Automatically discover and set the correct value config item', - ) - }) - - it('should not be hidden', () => { - expect(cmdConfigAuto.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-config-auto.mts' } - const context = { parentName: 'socket config' } - - describe('valid config key', () => { - it('should call handler with correct parameters', async () => { - await cmdConfigAuto.run(['defaultOrg'], importMeta, context) - - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should validate config key', async () => { - await cmdConfigAuto.run(['defaultOrg'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('defaultOrg') - }) - - it('should call handler when validation passes', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigAuto.run(['apiToken'], importMeta, context) - - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - }) - }) - }) - - describe('invalid config key', () => { - it('should not call handler when config key is invalid', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigAuto.run(['invalidKey'], importMeta, context) - - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - - it('should not call handler when config key is missing', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigAuto.run([], importMeta, context) - - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - - it('should not call handler when config key is "test"', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigAuto.run(['test'], importMeta, context) - - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without calling handler', async () => { - await cmdConfigAuto.run( - ['defaultOrg', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('/.config/socket/config.json'), - 'auto-discover and set config value for "defaultOrg"', - [ - 'Discover the correct value for config key: defaultOrg', - 'Update config file with discovered value', - ], - ) - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - - it('should construct correct config path in dry-run', async () => { - const originalHome = process.env['HOME'] - process.env['HOME'] = '/test/home' - - await cmdConfigAuto.run( - ['defaultOrg', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/test/home/.config/socket/config.json', - 'auto-discover and set config value for "defaultOrg"', - expect.any(Array), - ) - - process.env['HOME'] = originalHome - }) - - it('should not execute handler in dry-run mode', async () => { - await cmdConfigAuto.run(['apiToken', '--dry-run'], importMeta, context) - - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - }) - - describe('output formats', () => { - it('should pass text output kind when no format flag provided', async () => { - mockGetOutputKind.mockReturnValue('text') - - await cmdConfigAuto.run(['defaultOrg'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, false) - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should pass json output kind when --json flag provided', async () => { - mockGetOutputKind.mockReturnValue('json') - - await cmdConfigAuto.run(['defaultOrg', '--json'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(true, false) - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'json', - }) - }) - - it('should pass markdown output kind when --markdown flag provided', async () => { - mockGetOutputKind.mockReturnValue('markdown') - - await cmdConfigAuto.run( - ['defaultOrg', '--markdown'], - importMeta, - context, - ) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, true) - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'markdown', - }) - }) - }) - - describe('flag validation', () => { - it('should validate that --json and --markdown are not used together', async () => { - await cmdConfigAuto.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - expect(call[0]).toBe('text') - expect(call[1]).toMatchObject({ - message: 'Config key should be the first arg', - }) - expect(call[2]).toMatchObject({ - nook: true, - test: false, - message: - 'The `--json` and `--markdown` flags can not be used at the same time', - fail: 'bad', - }) - }) - - it('should not call handler when flag validation fails', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigAuto.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockHandleConfigAuto).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['defaultOrg']) as readonly string[] - - await cmdConfigAuto.run(readonlyArgv, importMeta, context) - - expect(mockHandleConfigAuto).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should handle missing HOME environment variable in dry-run', async () => { - const originalHome = process.env['HOME'] - delete process.env['HOME'] - - await cmdConfigAuto.run( - ['defaultOrg', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('config.json'), - expect.any(String), - expect.any(Array), - ) - - process.env['HOME'] = originalHome - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config-get.test.mts b/packages/cli/test/unit/commands/config/cmd-config-get.test.mts deleted file mode 100644 index b617e1aaa..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config-get.test.mts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Unit tests for config get command. - * - * Tests the command that retrieves and displays a single configuration value. - * - * Test Coverage: - Command metadata (description, hidden flag) - Config key - * validation (valid, invalid, missing keys) - Flag combinations (--json, - * --markdown, conflicting flags) - --dry-run flag support (preview without - * execution) - Handler invocation with correct parameters - Output kind - * resolution (text, json, markdown) - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock handleConfigGet to verify handler calls - Mock - * config utilities (isSupportedConfigKey, getSupportedConfigEntries) - Mock - * dry-run output utilities - Mock output mode utilities - Mock validation - * utilities. - * - * Related Files: - src/commands/config/cmd-config-get.mts - Implementation - - * src/commands/config/handle-config-get.mts - Handler - - * src/commands/config/config-command-factory.mts - Factory. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ConfigModule from '../../../../src/util/config.mts' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock handler. -const mockHandleConfigGet = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/config/handle-config-get.mts', () => ({ - handleConfigGet: mockHandleConfigGet, -})) - -// Mock config utilities. -const mockIsSupportedConfigKey = vi.hoisted(() => vi.fn(() => true)) -const mockGetSupportedConfigEntries = vi.hoisted(() => - vi.fn(() => [ - ['apiBaseUrl', 'API base URL'], - ['apiProxy', 'API proxy URL'], - ['apiToken', 'API authentication token'], - ['defaultOrg', 'Default organization slug'], - ]), -) - -vi.mock('../../../../src/util/config.mts', async importOriginal => { - const actual = await importOriginal<typeof ConfigModule>() - return { - ...actual, - getSupportedConfigEntries: mockGetSupportedConfigEntries, - isSupportedConfigKey: mockIsSupportedConfigKey, - } -}) - -// Mock dry-run output. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Mock output mode utilities. -const mockGetOutputKind = vi.hoisted(() => vi.fn(() => 'text')) - -vi.mock('../../../../src/util/output/mode.mjs', () => ({ - getOutputKind: mockGetOutputKind, -})) - -// Mock validation utilities. -const mockCheckCommandInput = vi.hoisted(() => vi.fn(() => true)) - -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: { argv: string[] | readonly string[] }) => { - const argv = options.argv - const flags: Record<string, unknown> = { - dryRun: false, - json: false, - markdown: false, - } - const input: string[] = [] - - // Parse flags from argv. - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--dry-run') { - flags['dryRun'] = true - } else if (arg === '--json') { - flags['json'] = true - } else if (arg === '--markdown') { - flags['markdown'] = true - } else if (!arg.startsWith('--')) { - input.push(arg) - } - } - - return { - flags, - help: '', - input, - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { cmdConfigGet } = - await import('../../../../src/commands/config/cmd-config-get.mts') - -describe('cmd-config-get', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsSupportedConfigKey.mockReturnValue(true) - mockCheckCommandInput.mockReturnValue(true) - mockGetOutputKind.mockReturnValue('text') - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdConfigGet.description).toBe( - 'Get the value of a local CLI config item', - ) - }) - - it('should not be hidden', () => { - expect(cmdConfigGet.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-config-get.mts' } - const context = { parentName: 'socket config' } - - describe('valid config key', () => { - it('should call handler with correct parameters', async () => { - await cmdConfigGet.run(['defaultOrg'], importMeta, context) - - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should validate config key', async () => { - await cmdConfigGet.run(['apiToken'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('apiToken') - }) - - it('should call handler when validation passes', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigGet.run(['apiBaseUrl'], importMeta, context) - - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'apiBaseUrl', - outputKind: 'text', - }) - }) - - it('should handle multiple valid config keys', async () => { - const keys = ['apiToken', 'defaultOrg', 'apiBaseUrl', 'apiProxy'] - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - vi.clearAllMocks() - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigGet.run([key], importMeta, context) - - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key, - outputKind: 'text', - }) - } - }) - - it('should handle "test" key specially', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigGet.run(['test'], importMeta, context) - - // "test" key bypasses isSupportedConfigKey check in validation. - expect(mockIsSupportedConfigKey).not.toHaveBeenCalled() - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'test', - outputKind: 'text', - }) - }) - }) - - describe('invalid config key', () => { - it('should not call handler when config key is invalid', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run(['invalidKey'], importMeta, context) - - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - - it('should not call handler when config key is missing', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run([], importMeta, context) - - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - - it('should validate empty string key', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run([''], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('') - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without calling handler', async () => { - await cmdConfigGet.run(['apiToken', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('/.config/socket/config.json'), - 'unset config value for "apiToken"', - ['Remove "apiToken" from config'], - ) - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - - it('should not execute handler in dry-run mode', async () => { - await cmdConfigGet.run(['apiToken', '--dry-run'], importMeta, context) - - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - }) - - describe('output formats', () => { - it('should pass text output kind when no format flag provided', async () => { - mockGetOutputKind.mockReturnValue('text') - - await cmdConfigGet.run(['defaultOrg'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, false) - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should pass json output kind when --json flag provided', async () => { - mockGetOutputKind.mockReturnValue('json') - - await cmdConfigGet.run(['defaultOrg', '--json'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(true, false) - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'json', - }) - }) - - it('should pass markdown output kind when --markdown flag provided', async () => { - mockGetOutputKind.mockReturnValue('markdown') - - await cmdConfigGet.run( - ['defaultOrg', '--markdown'], - importMeta, - context, - ) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, true) - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'markdown', - }) - }) - }) - - describe('flag validation', () => { - it('should validate that --json and --markdown are not used together', async () => { - await cmdConfigGet.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - expect(call[0]).toBe('text') - - // Check that validation includes the conflicting flags check. - const validations = call.slice(1) - const conflictCheck = validations.find( - (v: unknown) => - v.message && - v.message.includes('--json') && - v.message.includes('--markdown'), - ) - expect(conflictCheck).toBeDefined() - expect(conflictCheck.nook).toBe(true) - expect(conflictCheck.test).toBe(false) - }) - - it('should not call handler when flag validation fails', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['defaultOrg']) as readonly string[] - - await cmdConfigGet.run(readonlyArgv, importMeta, context) - - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should handle multiple arguments and only use first as key', async () => { - await cmdConfigGet.run( - ['apiToken', 'extra', 'args'], - importMeta, - context, - ) - - expect(mockHandleConfigGet).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - }) - }) - - it('should handle keys with special characters', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run(['api-token'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('api-token') - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - - it('should handle numeric key', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigGet.run(['123'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('123') - expect(mockHandleConfigGet).not.toHaveBeenCalled() - }) - }) - - describe('validation flow', () => { - it('should check all validations in correct order', async () => { - await cmdConfigGet.run(['apiToken'], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - // Should have at least 2 validations: key validation and flag conflict. - expect(validations.length).toBeGreaterThanOrEqual(2) - - // First validation: key check. - expect(validations[0]).toMatchObject({ - test: true, - message: 'Config key should be the first arg', - }) - - // Last validation: flag conflict check. - const lastValidation = validations[validations.length - 1] - expect(lastValidation).toMatchObject({ - nook: true, - test: true, - fail: 'bad', - }) - }) - - it('should not pass value parameter to handler', async () => { - await cmdConfigGet.run(['apiToken'], importMeta, context) - - const callArgs = mockHandleConfigGet.mock.calls[0][0] - expect(callArgs).not.toHaveProperty('value') - expect(callArgs).toHaveProperty('key') - expect(callArgs).toHaveProperty('outputKind') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config-list.test.mts b/packages/cli/test/unit/commands/config/cmd-config-list.test.mts deleted file mode 100644 index bafba51bd..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config-list.test.mts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Unit tests for config list command. - * - * Tests the command that displays all local CLI configuration items and values. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock outputConfigList. -const mockOutputConfigList = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/config/output-config-list.mts', () => ({ - outputConfigList: mockOutputConfigList, -})) - -// Import after mocks. -const { cmdConfigList } = - await import('../../../../src/commands/config/cmd-config-list.mts') - -describe('cmd-config-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdConfigList.description).toBe( - 'Show all local CLI config items and their values', - ) - }) - - it('should not be hidden', () => { - expect(cmdConfigList.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-config-list.mts' } - const context = { parentName: 'socket config' } - - it('should support --dry-run flag', async () => { - await cmdConfigList.run(['--dry-run'], importMeta, context) - - expect(mockOutputConfigList).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('shows "yes" for showFullTokens in dry-run when --full is set', async () => { - await cmdConfigList.run(['--dry-run', '--full'], importMeta, context) - - expect(mockOutputConfigList).not.toHaveBeenCalled() - const errors = mockLogger.error.mock.calls.flat().join(' ') - expect(errors).toContain('yes') - }) - - it('should call outputConfigList with default options', async () => { - await cmdConfigList.run([], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledWith({ - full: false, - outputKind: 'text', - }) - }) - - it('should pass full flag to outputConfigList', async () => { - await cmdConfigList.run(['--full'], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledWith({ - full: true, - outputKind: 'text', - }) - }) - - it('should support --json flag', async () => { - await cmdConfigList.run(['--json'], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledWith({ - full: false, - outputKind: 'json', - }) - }) - - it('should support --markdown flag', async () => { - await cmdConfigList.run(['--markdown'], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledWith({ - full: false, - outputKind: 'markdown', - }) - }) - - it('should support combined --full and --json flags', async () => { - await cmdConfigList.run(['--full', '--json'], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledWith({ - full: true, - outputKind: 'json', - }) - }) - - it('should reject conflicting --json and --markdown flags', async () => { - await cmdConfigList.run(['--json', '--markdown'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockOutputConfigList).not.toHaveBeenCalled() - }) - - it('should call outputConfigList exactly once per run', async () => { - await cmdConfigList.run([], importMeta, context) - - expect(mockOutputConfigList).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config-set.test.mts b/packages/cli/test/unit/commands/config/cmd-config-set.test.mts deleted file mode 100644 index 07f7fff03..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config-set.test.mts +++ /dev/null @@ -1,611 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for config set command. - * - * Tests the command that updates configuration values in the config file. - * - * Test Coverage: - * - * - Command metadata (description, hidden flag, CMD_NAME) - * - Config key validation (valid, invalid, missing keys) - * - Value requirement validation (present, missing, empty) - * - Flag combinations (--json, --markdown, conflicting flags) - * - --dry-run flag support (preview with write operations) - * - Handler invocation with correct parameters (key, value, outputKind) - * - Output kind resolution (text, json, markdown) - * - Value handling (simple strings, strings with spaces, special characters) - * - * Testing Approach: - * - * - Mock logger to capture output - * - Mock meowOrExit to control flag values and input parsing - * - Mock handleConfigSet to verify handler calls - * - Mock config utilities (isSupportedConfigKey, getSupportedConfigEntries) - * - Mock dry-run output utilities - * - Mock output mode utilities - * - Mock validation utilities - * - * Related Files: - * - * - Src/commands/config/cmd-config-set.mts - Implementation - * - Src/commands/config/handle-config-set.mts - Handler - * - Src/commands/config/config-command-factory.mts - Factory - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ConfigModule from '../../../../src/util/config.mts' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock handler. -const mockHandleConfigSet = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/config/handle-config-set.mts', () => ({ - handleConfigSet: mockHandleConfigSet, -})) - -// Mock config utilities. -const mockIsSupportedConfigKey = vi.hoisted(() => vi.fn(() => true)) -const mockGetSupportedConfigEntries = vi.hoisted(() => - vi.fn(() => [ - ['apiBaseUrl', 'API base URL'], - ['apiProxy', 'API proxy URL'], - ['apiToken', 'API authentication token'], - ['defaultOrg', 'Default organization slug'], - ]), -) - -vi.mock('../../../../src/util/config.mts', async importOriginal => { - const actual = await importOriginal<typeof ConfigModule>() - return { - ...actual, - getSupportedConfigEntries: mockGetSupportedConfigEntries, - isSupportedConfigKey: mockIsSupportedConfigKey, - } -}) - -// Mock dry-run output. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Mock output mode utilities. -const mockGetOutputKind = vi.hoisted(() => vi.fn(() => 'text')) - -vi.mock('../../../../src/util/output/mode.mjs', () => ({ - getOutputKind: mockGetOutputKind, -})) - -// Mock validation utilities. -const mockCheckCommandInput = vi.hoisted(() => vi.fn(() => true)) - -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: { argv: string[] | readonly string[] }) => { - const argv = options.argv - const flags: Record<string, unknown> = { - dryRun: false, - json: false, - markdown: false, - } - const input: string[] = [] - - // Parse flags from argv. - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--dry-run') { - flags['dryRun'] = true - } else if (arg === '--json') { - flags['json'] = true - } else if (arg === '--markdown') { - flags['markdown'] = true - } else if (!arg.startsWith('--')) { - input.push(arg) - } - } - - return { - flags, - help: '', - input, - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdConfigSet } = - await import('../../../../src/commands/config/cmd-config-set.mts') - -describe('cmd-config-set', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - process.env['HOME'] = '/test/home' - mockIsSupportedConfigKey.mockReturnValue(true) - mockCheckCommandInput.mockReturnValue(true) - mockGetOutputKind.mockReturnValue('text') - }) - - describe('command metadata', () => { - it('should export CMD_NAME as set', () => { - expect(CMD_NAME).toBe('set') - }) - - it('should have correct description', () => { - expect(cmdConfigSet.description).toBe( - 'Update the value of a local CLI config item', - ) - }) - - it('should not be hidden', () => { - expect(cmdConfigSet.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-config-set.mts' } - const context = { parentName: 'socket config' } - - describe('valid config key and value', () => { - it('should call handler with correct parameters', async () => { - await cmdConfigSet.run(['defaultOrg', 'my-org'], importMeta, context) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - value: 'my-org', - }) - }) - - it('should validate config key', async () => { - await cmdConfigSet.run(['apiToken', 'token-value'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('apiToken') - }) - - it('should handle multiple valid config keys', async () => { - const testCases = [ - { key: 'apiToken', value: 'abc123' }, - { key: 'defaultOrg', value: 'test-org' }, - { key: 'apiBaseUrl', value: 'https://api.example.com' }, - { key: 'apiProxy', value: 'https://proxy.example.com' }, - ] - - for (const { key, value } of testCases) { - vi.clearAllMocks() - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigSet.run([key, value], importMeta, context) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key, - outputKind: 'text', - value, - }) - } - }) - - it('should handle values with spaces by joining remaining args', async () => { - await cmdConfigSet.run( - ['apiProxy', 'https://proxy.example.com', 'with', 'spaces'], - importMeta, - context, - ) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'apiProxy', - outputKind: 'text', - value: 'https://proxy.example.com with spaces', - }) - }) - - it('should handle "test" key specially', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigSet.run(['test', 'test-value'], importMeta, context) - - // "test" key bypasses isSupportedConfigKey check in validation. - expect(mockIsSupportedConfigKey).not.toHaveBeenCalled() - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'test', - outputKind: 'text', - value: 'test-value', - }) - }) - }) - - describe('invalid config key', () => { - it('should not call handler when config key is invalid', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigSet.run(['invalidKey', 'value'], importMeta, context) - - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - - it('should not call handler when config key is missing', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigSet.run([], importMeta, context) - - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - - it('should validate empty string key', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigSet.run(['', 'value'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('') - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - }) - - describe('value validation', () => { - it('should not call handler when value is missing', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigSet.run(['apiToken'], importMeta, context) - - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - - it('should validate that value is required', async () => { - await cmdConfigSet.run(['apiToken'], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - // Should have value validation that checks for presence. - const valueValidation = validations.find( - (v: unknown) => - v.message && - (v.message.includes('value') || v.message.includes('unset')), - ) - expect(valueValidation).toBeDefined() - expect(valueValidation.test).toBe(false) - }) - - it('should accept empty string as valid value', async () => { - await cmdConfigSet.run(['apiToken', ''], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - const valueValidation = validations.find( - (v: unknown) => - v.message && - (v.message.includes('value') || v.message.includes('unset')), - ) - // Empty string after the key means no value, so test should be false. - expect(valueValidation.test).toBe(false) - }) - - it('should handle numeric values', async () => { - await cmdConfigSet.run(['apiToken', '12345'], importMeta, context) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - value: '12345', - }) - }) - - it('should handle special characters in value', async () => { - await cmdConfigSet.run( - ['apiToken', 'abc!@#$%^&*()'], - importMeta, - context, - ) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - value: 'abc!@#$%^&*()', - }) - }) - - it('should handle URL values', async () => { - await cmdConfigSet.run( - ['apiBaseUrl', 'https://api.socket.dev/v0'], - importMeta, - context, - ) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'apiBaseUrl', - outputKind: 'text', - value: 'https://api.socket.dev/v0', - }) - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without calling handler', async () => { - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/test/home/.config/socket/config.json', - 'set config value for "defaultOrg"', - ['Set "defaultOrg" to: my-org'], - ) - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - - it('should construct correct config path in dry-run', async () => { - process.env['HOME'] = '/custom/home' - - await cmdConfigSet.run( - ['apiToken', 'token', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/custom/home/.config/socket/config.json', - 'set config value for "apiToken"', - ['Set "apiToken" to: token'], - ) - }) - - it('should not execute handler in dry-run mode', async () => { - await cmdConfigSet.run( - ['apiToken', 'token', '--dry-run'], - importMeta, - context, - ) - - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - - it('should show value with spaces in dry-run', async () => { - await cmdConfigSet.run( - [ - 'apiProxy', - 'https://proxy.example.com', - 'with', - 'path', - '--dry-run', - ], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.any(String), - 'set config value for "apiProxy"', - ['Set "apiProxy" to: https://proxy.example.com with path'], - ) - }) - }) - - describe('output formats', () => { - it('should pass text output kind when no format flag provided', async () => { - mockGetOutputKind.mockReturnValue('text') - - await cmdConfigSet.run(['defaultOrg', 'my-org'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, false) - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - value: 'my-org', - }) - }) - - it('should pass json output kind when --json flag provided', async () => { - mockGetOutputKind.mockReturnValue('json') - - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--json'], - importMeta, - context, - ) - - expect(mockGetOutputKind).toHaveBeenCalledWith(true, false) - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'json', - value: 'my-org', - }) - }) - - it('should pass markdown output kind when --markdown flag provided', async () => { - mockGetOutputKind.mockReturnValue('markdown') - - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--markdown'], - importMeta, - context, - ) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, true) - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'markdown', - value: 'my-org', - }) - }) - }) - - describe('flag validation', () => { - it('should validate that --json and --markdown are not used together', async () => { - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - - // Check that validation includes the conflicting flags check. - const validations = call.slice(1) - const conflictCheck = validations.find( - (v: unknown) => - v.message && - v.message.includes('--json') && - v.message.includes('--markdown'), - ) - expect(conflictCheck).toBeDefined() - expect(conflictCheck.nook).toBe(true) - expect(conflictCheck.test).toBe(false) - }) - - it('should not call handler when flag validation fails', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockHandleConfigSet).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze([ - 'defaultOrg', - 'my-org', - ]) as readonly string[] - - await cmdConfigSet.run(readonlyArgv, importMeta, context) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - value: 'my-org', - }) - }) - - it('should handle missing HOME environment variable in dry-run', async () => { - delete process.env['HOME'] - - await cmdConfigSet.run( - ['defaultOrg', 'my-org', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('config.json'), - expect.any(String), - expect.any(Array), - ) - }) - - it('should handle value that looks like a flag', async () => { - await cmdConfigSet.run( - ['apiProxy', '--not-a-flag'], - importMeta, - context, - ) - - // meowOrExit would parse --not-a-flag as a flag, so this tests that behavior. - const callArgs = mockHandleConfigSet.mock.calls[0] - expect(callArgs).toBeDefined() - }) - - it('should handle very long values', async () => { - const longValue = 'a'.repeat(1000) - await cmdConfigSet.run(['apiToken', longValue], importMeta, context) - - expect(mockHandleConfigSet).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - value: longValue, - }) - }) - }) - - describe('validation flow', () => { - it('should check all validations in correct order', async () => { - await cmdConfigSet.run(['apiToken', 'token'], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - // Should have at least 3 validations: key, value, and flag conflict. - expect(validations.length).toBeGreaterThanOrEqual(3) - - // First validation: key check. - expect(validations[0]).toMatchObject({ - test: true, - message: 'Config key should be the first arg', - }) - - // Second validation: value check. - expect(validations[1]).toMatchObject({ - test: true, - }) - - // Last validation: flag conflict check. - const lastValidation = validations[validations.length - 1] - expect(lastValidation).toMatchObject({ - nook: true, - test: true, - fail: 'bad', - }) - }) - - it('should always pass value parameter to handler', async () => { - await cmdConfigSet.run(['apiToken', 'token'], importMeta, context) - - const callArgs = mockHandleConfigSet.mock.calls[0][0] - expect(callArgs).toHaveProperty('value') - expect(callArgs).toHaveProperty('key') - expect(callArgs).toHaveProperty('outputKind') - expect(callArgs.value).toBe('token') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config-unset.test.mts b/packages/cli/test/unit/commands/config/cmd-config-unset.test.mts deleted file mode 100644 index a22387c3f..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config-unset.test.mts +++ /dev/null @@ -1,528 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for config unset command. - * - * Tests the command that removes configuration values from the config file. - * - * Test Coverage: - * - * - Command metadata (description, hidden flag, CMD_NAME) - * - Config key validation (valid, invalid, missing keys) - * - Flag combinations (--json, --markdown, conflicting flags) - * - --dry-run flag support (preview with remove operations) - * - Handler invocation with correct parameters (key, outputKind, no value) - * - Output kind resolution (text, json, markdown) - * - Verification that no value parameter is passed to handler - * - * Testing Approach: - * - * - Mock logger to capture output - * - Mock meowOrExit to control flag values and input parsing - * - Mock handleConfigUnset to verify handler calls - * - Mock config utilities (isSupportedConfigKey, getSupportedConfigEntries) - * - Mock dry-run output utilities - * - Mock output mode utilities - * - Mock validation utilities - * - * Related Files: - * - * - Src/commands/config/cmd-config-unset.mts - Implementation - * - Src/commands/config/handle-config-unset.mts - Handler - * - Src/commands/config/config-command-factory.mts - Factory - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ConfigModule from '../../../../src/util/config.mts' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock handler. -const mockHandleConfigUnset = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/config/handle-config-unset.mts', () => ({ - handleConfigUnset: mockHandleConfigUnset, -})) - -// Mock config utilities. -const mockIsSupportedConfigKey = vi.hoisted(() => vi.fn(() => true)) -const mockGetSupportedConfigEntries = vi.hoisted(() => - vi.fn(() => [ - ['apiBaseUrl', 'API base URL'], - ['apiProxy', 'API proxy URL'], - ['apiToken', 'API authentication token'], - ['defaultOrg', 'Default organization slug'], - ]), -) - -vi.mock('../../../../src/util/config.mts', async importOriginal => { - const actual = await importOriginal<typeof ConfigModule>() - return { - ...actual, - getSupportedConfigEntries: mockGetSupportedConfigEntries, - isSupportedConfigKey: mockIsSupportedConfigKey, - } -}) - -// Mock dry-run output. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Mock output mode utilities. -const mockGetOutputKind = vi.hoisted(() => vi.fn(() => 'text')) - -vi.mock('../../../../src/util/output/mode.mjs', () => ({ - getOutputKind: mockGetOutputKind, -})) - -// Mock validation utilities. -const mockCheckCommandInput = vi.hoisted(() => vi.fn(() => true)) - -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: { argv: string[] | readonly string[] }) => { - const argv = options.argv - const flags: Record<string, unknown> = { - dryRun: false, - json: false, - markdown: false, - } - const input: string[] = [] - - // Parse flags from argv. - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--dry-run') { - flags['dryRun'] = true - } else if (arg === '--json') { - flags['json'] = true - } else if (arg === '--markdown') { - flags['markdown'] = true - } else if (!arg.startsWith('--')) { - input.push(arg) - } - } - - return { - flags, - help: '', - input, - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdConfigUnset } = - await import('../../../../src/commands/config/cmd-config-unset.mts') - -describe('cmd-config-unset', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - process.env['HOME'] = '/test/home' - mockIsSupportedConfigKey.mockReturnValue(true) - mockCheckCommandInput.mockReturnValue(true) - mockGetOutputKind.mockReturnValue('text') - }) - - describe('command metadata', () => { - it('should export CMD_NAME as unset', () => { - expect(CMD_NAME).toBe('unset') - }) - - it('should have correct description', () => { - expect(cmdConfigUnset.description).toBe( - 'Clear the value of a local CLI config item', - ) - }) - - it('should not be hidden', () => { - expect(cmdConfigUnset.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-config-unset.mts' } - const context = { parentName: 'socket config' } - - describe('valid config key', () => { - it('should call handler with correct parameters', async () => { - await cmdConfigUnset.run(['defaultOrg'], importMeta, context) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should validate config key', async () => { - await cmdConfigUnset.run(['apiToken'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('apiToken') - }) - - it('should call handler when validation passes', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigUnset.run(['apiBaseUrl'], importMeta, context) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'apiBaseUrl', - outputKind: 'text', - }) - }) - - it('should handle multiple valid config keys', async () => { - const keys = ['apiToken', 'defaultOrg', 'apiBaseUrl', 'apiProxy'] - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - vi.clearAllMocks() - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigUnset.run([key], importMeta, context) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key, - outputKind: 'text', - }) - } - }) - - it('should handle "test" key specially', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigUnset.run(['test'], importMeta, context) - - // "test" key bypasses isSupportedConfigKey check in validation. - expect(mockIsSupportedConfigKey).not.toHaveBeenCalled() - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'test', - outputKind: 'text', - }) - }) - }) - - describe('invalid config key', () => { - it('should not call handler when config key is invalid', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run(['invalidKey'], importMeta, context) - - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - - it('should not call handler when config key is missing', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run([], importMeta, context) - - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - - it('should validate empty string key', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run([''], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('') - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without calling handler', async () => { - await cmdConfigUnset.run( - ['defaultOrg', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/test/home/.config/socket/config.json', - 'unset config value for "defaultOrg"', - ['Remove "defaultOrg" from config'], - ) - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - - it('should construct correct config path in dry-run', async () => { - process.env['HOME'] = '/custom/home' - - await cmdConfigUnset.run(['apiToken', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/custom/home/.config/socket/config.json', - 'unset config value for "apiToken"', - ['Remove "apiToken" from config'], - ) - }) - - it('should not execute handler in dry-run mode', async () => { - await cmdConfigUnset.run(['apiToken', '--dry-run'], importMeta, context) - - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - }) - - describe('output formats', () => { - it('should pass text output kind when no format flag provided', async () => { - mockGetOutputKind.mockReturnValue('text') - - await cmdConfigUnset.run(['defaultOrg'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, false) - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should pass json output kind when --json flag provided', async () => { - mockGetOutputKind.mockReturnValue('json') - - await cmdConfigUnset.run(['defaultOrg', '--json'], importMeta, context) - - expect(mockGetOutputKind).toHaveBeenCalledWith(true, false) - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'json', - }) - }) - - it('should pass markdown output kind when --markdown flag provided', async () => { - mockGetOutputKind.mockReturnValue('markdown') - - await cmdConfigUnset.run( - ['defaultOrg', '--markdown'], - importMeta, - context, - ) - - expect(mockGetOutputKind).toHaveBeenCalledWith(false, true) - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'markdown', - }) - }) - }) - - describe('flag validation', () => { - it('should validate that --json and --markdown are not used together', async () => { - await cmdConfigUnset.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - - // Check that validation includes the conflicting flags check. - const validations = call.slice(1) - const conflictCheck = validations.find( - (v: unknown) => - v.message && - v.message.includes('--json') && - v.message.includes('--markdown'), - ) - expect(conflictCheck).toBeDefined() - expect(conflictCheck.nook).toBe(true) - expect(conflictCheck.test).toBe(false) - }) - - it('should not call handler when flag validation fails', async () => { - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run( - ['defaultOrg', '--json', '--markdown'], - importMeta, - context, - ) - - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['defaultOrg']) as readonly string[] - - await cmdConfigUnset.run(readonlyArgv, importMeta, context) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'defaultOrg', - outputKind: 'text', - }) - }) - - it('should ignore extra arguments after key', async () => { - await cmdConfigUnset.run( - ['apiToken', 'extra', 'args'], - importMeta, - context, - ) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - }) - }) - - it('should handle missing HOME environment variable in dry-run', async () => { - delete process.env['HOME'] - - await cmdConfigUnset.run( - ['defaultOrg', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('config.json'), - expect.any(String), - expect.any(Array), - ) - }) - - it('should handle keys with special characters', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run(['api-token'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('api-token') - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - - it('should handle numeric key', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - mockCheckCommandInput.mockReturnValue(false) - - await cmdConfigUnset.run(['123'], importMeta, context) - - expect(mockIsSupportedConfigKey).toHaveBeenCalledWith('123') - expect(mockHandleConfigUnset).not.toHaveBeenCalled() - }) - }) - - describe('validation flow', () => { - it('should check all validations in correct order', async () => { - await cmdConfigUnset.run(['apiToken'], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - // Should have at least 2 validations: key validation and flag conflict. - expect(validations.length).toBeGreaterThanOrEqual(2) - - // First validation: key check. - expect(validations[0]).toMatchObject({ - test: true, - message: 'Config key should be the first arg', - }) - - // Last validation: flag conflict check. - const lastValidation = validations[validations.length - 1] - expect(lastValidation).toMatchObject({ - nook: true, - test: true, - fail: 'bad', - }) - }) - - it('should not pass value parameter to handler', async () => { - await cmdConfigUnset.run(['apiToken'], importMeta, context) - - const callArgs = mockHandleConfigUnset.mock.calls[0][0] - expect(callArgs).not.toHaveProperty('value') - expect(callArgs).toHaveProperty('key') - expect(callArgs).toHaveProperty('outputKind') - }) - - it('should never include value validation for unset', async () => { - await cmdConfigUnset.run(['apiToken'], importMeta, context) - - expect(mockCheckCommandInput).toHaveBeenCalled() - const call = mockCheckCommandInput.mock.calls[0] - const validations = call.slice(1) - - // Should NOT have value validation for unset command. - const valueValidation = validations.find( - (v: unknown) => - v.message && - (v.message.includes('value') || v.message.includes('unset')), - ) - expect(valueValidation).toBeUndefined() - }) - }) - - describe('comparison with set command', () => { - it('should not require a value argument unlike set', async () => { - mockCheckCommandInput.mockReturnValue(true) - - await cmdConfigUnset.run(['apiToken'], importMeta, context) - - expect(mockHandleConfigUnset).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - }) - - // Verify no value in call. - const callArgs = mockHandleConfigUnset.mock.calls[0][0] - expect(callArgs.value).toBeUndefined() - }) - - it('should ignore any value-like arguments provided', async () => { - await cmdConfigUnset.run( - ['apiToken', 'this-looks-like-value'], - importMeta, - context, - ) - - const callArgs = mockHandleConfigUnset.mock.calls[0][0] - expect(callArgs).not.toHaveProperty('value') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/cmd-config.test.mts b/packages/cli/test/unit/commands/config/cmd-config.test.mts deleted file mode 100644 index c6036d041..000000000 --- a/packages/cli/test/unit/commands/config/cmd-config.test.mts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Unit tests for config parent command. - * - * Tests the parent command that routes to configuration management subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdConfig } = - await import('../../../../src/commands/config/cmd-config.mts') -const { cmdConfigAuto } = - await import('../../../../src/commands/config/cmd-config-auto.mts') -const { cmdConfigGet } = - await import('../../../../src/commands/config/cmd-config-get.mts') -const { cmdConfigList } = - await import('../../../../src/commands/config/cmd-config-list.mts') -const { cmdConfigSet } = - await import('../../../../src/commands/config/cmd-config-set.mts') -const { cmdConfigUnset } = - await import('../../../../src/commands/config/cmd-config-unset.mts') - -describe('cmd-config', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdConfig.description).toBe('Manage Socket CLI configuration') - }) - - it('should not be hidden', () => { - expect(cmdConfig.hidden).toBe(false) - }) - - it('should have a run method', () => { - expect(typeof cmdConfig.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-config.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdConfig.run(['auto'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['auto'], - importMeta, - name: 'socket config', - subcommands: { - auto: cmdConfigAuto, - get: cmdConfigGet, - list: cmdConfigList, - set: cmdConfigSet, - unset: cmdConfigUnset, - }, - }, - { - description: 'Manage Socket CLI configuration', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdConfig.run(['list'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent config', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdConfig.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual([ - 'auto', - 'get', - 'list', - 'set', - 'unset', - ]) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['set', 'apiToken', 'test-value', '--dry-run'] - - await cmdConfig.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['get', 'apiToken']) as readonly string[] - - await cmdConfig.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdConfig.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.auto).toBe(cmdConfigAuto) - expect(subcommands.get).toBe(cmdConfigGet) - expect(subcommands.list).toBe(cmdConfigList) - expect(subcommands.set).toBe(cmdConfigSet) - expect(subcommands.unset).toBe(cmdConfigUnset) - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdConfig.run([], { url: 'file:///test' }, { parentName: 'socket' }), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdConfig.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe('Manage Socket CLI configuration') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/config-command-factory.test.mts b/packages/cli/test/unit/commands/config/config-command-factory.test.mts deleted file mode 100644 index f07bd9b1b..000000000 --- a/packages/cli/test/unit/commands/config/config-command-factory.test.mts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Unit tests for createConfigCommand. - * - * Factory that wires meowOrExit + validation + dry-run into a config - * subcommand. Tests verify each branch of the constructed `run`: key parsing, - * value parsing, --json/--markdown conflict, --dry-run preview, custom - * validation, and final handler dispatch. - * - * Related Files: - src/commands/config/config-command-factory.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockGetOutputKind = vi.hoisted(() => vi.fn(() => 'text')) -const mockCheckCommandInput = vi.hoisted(() => vi.fn(() => true)) -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) -const mockGetSupportedConfigEntries = vi.hoisted(() => - vi.fn(() => [['apiToken', 'Socket API token']]), -) -const mockIsSupportedConfigKey = vi.hoisted(() => - vi.fn((k: string) => k === 'apiToken' || k === 'org'), -) -const mockGetFlagListOutput = vi.hoisted(() => vi.fn(() => 'flag-list')) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowOrExit: mockMeowOrExit, -})) -vi.mock('../../../../src/util/output/mode.mts', () => ({ - getOutputKind: mockGetOutputKind, -})) -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) -vi.mock('../../../../src/util/config.mts', () => ({ - getSupportedConfigEntries: mockGetSupportedConfigEntries, - isSupportedConfigKey: mockIsSupportedConfigKey, -})) -vi.mock('../../../../src/util/output/formatting.mts', () => ({ - getFlagListOutput: mockGetFlagListOutput, -})) - -import { createConfigCommand } from '../../../../src/commands/config/config-command-factory.mts' - -const baseSpec = { - commandName: 'set', - description: 'Set a config value', - helpUsage: '<key> <value>', - helpDescription: 'Updates a config key.', - helpExamples: ['apiToken xxx'], - needsValue: true, - handler: vi.fn().mockResolvedValue(undefined), -} - -const importMeta = { url: 'file:///test/config.mts' } as ImportMeta -const context = { parentName: 'socket config' } - -const setMeow = ( - overrides: { input?: string[] | undefined; flags?: unknown | undefined } = {}, -) => { - mockMeowOrExit.mockReturnValueOnce({ - flags: { json: false, markdown: false, dryRun: false, ...overrides.flags }, - input: overrides.input ?? ['apiToken', 'token-value'], - }) -} - -describe('createConfigCommand', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetOutputKind.mockReturnValue('text') - mockCheckCommandInput.mockReturnValue(true) - }) - - it('returns a command with description and hidden flag', () => { - const cmd = createConfigCommand({ ...baseSpec, hidden: true }) - expect(cmd.description).toBe('Set a config value') - expect(cmd.hidden).toBe(true) - }) - - it('calls the handler with parsed key/value/outputKind', async () => { - const handler = vi.fn().mockResolvedValue(undefined) - const cmd = createConfigCommand({ ...baseSpec, handler }) - setMeow({ input: ['apiToken', 'my', 'value'] }) - mockGetOutputKind.mockReturnValueOnce('json') - - await cmd.run([], importMeta, context) - - expect(handler).toHaveBeenCalledWith({ - key: 'apiToken', - value: 'my value', - outputKind: 'json', - }) - }) - - it('omits value when needsValue is false', async () => { - const handler = vi.fn().mockResolvedValue(undefined) - const cmd = createConfigCommand({ - ...baseSpec, - needsValue: false, - handler, - }) - setMeow({ input: ['apiToken'] }) - - await cmd.run([], importMeta, context) - - expect(handler).toHaveBeenCalledWith({ - key: 'apiToken', - outputKind: 'text', - }) - }) - - it('returns early when input validation fails', async () => { - const handler = vi.fn() - mockCheckCommandInput.mockReturnValueOnce(false) - const cmd = createConfigCommand({ ...baseSpec, handler }) - setMeow() - - await cmd.run([], importMeta, context) - - expect(handler).not.toHaveBeenCalled() - }) - - it('runs spec.validate when provided and threads its checks', async () => { - const validate = vi - .fn() - .mockReturnValue([{ test: false, message: 'custom check', fail: 'bad' }]) - const cmd = createConfigCommand({ ...baseSpec, validate }) - setMeow() - - await cmd.run([], importMeta, context) - - expect(validate).toHaveBeenCalled() - }) - - it('emits a dry-run preview when --dry-run is set', async () => { - const handler = vi.fn() - const cmd = createConfigCommand({ ...baseSpec, handler }) - setMeow({ flags: { dryRun: true } }) - - await cmd.run([], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('config.json'), - expect.stringContaining('set config value'), - expect.arrayContaining([expect.stringContaining('Set "apiToken"')]), - ) - expect(handler).not.toHaveBeenCalled() - }) - - it('emits an unset preview when needsValue is false in dry-run', async () => { - const cmd = createConfigCommand({ - ...baseSpec, - needsValue: false, - handler: vi.fn(), - }) - setMeow({ flags: { dryRun: true }, input: ['apiToken'] }) - - await cmd.run([], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('unset config value'), - expect.arrayContaining([expect.stringContaining('Remove "apiToken"')]), - ) - }) - - it('uses provided flags when supplied via spec.flags', () => { - const flags = { custom: { type: 'string' as const, description: 'x' } } - const cmd = createConfigCommand({ ...baseSpec, flags }) - expect(cmd).toBeDefined() - expect(typeof cmd.run).toBe('function') - }) - - it('renders help text including config keys and examples', () => { - let capturedConfig: unknown - mockMeowOrExit.mockImplementationOnce(args => { - capturedConfig = args.config - return { - flags: { json: false, markdown: false, dryRun: false }, - input: ['apiToken', 'value'], - } - }) - - const cmd = createConfigCommand(baseSpec) - void cmd.run([], importMeta, context) - - const helpText = capturedConfig.help('socket config set', capturedConfig) - expect(helpText).toContain('Usage') - expect(helpText).toContain('apiToken -- Socket API token') - expect(helpText).toContain('apiToken xxx') - expect(helpText).toContain('Updates a config key.') - }) -}) diff --git a/packages/cli/test/unit/commands/config/discover-config-value.test.mts b/packages/cli/test/unit/commands/config/discover-config-value.test.mts deleted file mode 100644 index 002ae0c51..000000000 --- a/packages/cli/test/unit/commands/config/discover-config-value.test.mts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Unit tests for discoverConfigValue. - * - * Per-key auto-discovery for `socket config auto`. Covers every branch in the - * dispatcher. - * - * Test Coverage: - Unknown key → "Requested key is not a valid config key" - - * apiBaseUrl / apiProxy / apiToken → returns hand-written advisory without an - * auto-discovery attempt - defaultOrg without an API token → "No API token set" - * error - defaultOrg with token + single org → returns that slug - defaultOrg - * with token + multiple orgs → returns array - defaultOrg with token + zero - * orgs → "Was unable to determine" - defaultOrg with token + fetch failure → - * same error message - enforcedOrgs same matrix - test sentinel key → - * "congrats, you found the test key" - * - * Related Files: - src/commands/config/discover-config-value.mts - - * Implementation - src/util/config.mts - isSupportedConfigKey (mocked) - - * src/util/socket/sdk.mts - hasDefaultApiToken (mocked) - - * src/commands/organization/fetch-organization-list.mts - fetchOrganization - * (mocked) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockFetchOrganization, - mockHasDefaultApiToken, - mockIsSupportedConfigKey, -} = vi.hoisted(() => ({ - mockFetchOrganization: vi.fn(), - mockHasDefaultApiToken: vi.fn(), - mockIsSupportedConfigKey: vi.fn(), -})) - -vi.mock('../../../../src/util/config.mts', () => ({ - isSupportedConfigKey: mockIsSupportedConfigKey, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - hasDefaultApiToken: mockHasDefaultApiToken, -})) - -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -const { discoverConfigValue } = - await import('../../../../src/commands/config/discover-config-value.mts') - -const orgFixture = (slugs: string[]) => ({ - ok: true as const, - data: { - organizations: slugs.map((slug, i) => ({ - id: `id-${i}`, - slug, - name: slug, - image: '', - plan: 'free', - })), - }, -}) - -beforeEach(() => { - vi.clearAllMocks() - mockIsSupportedConfigKey.mockReturnValue(true) -}) - -describe('discoverConfigValue', () => { - it('rejects unknown keys', async () => { - mockIsSupportedConfigKey.mockReturnValue(false) - const result = await discoverConfigValue('not-a-real-key') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('not a valid config key') - } - }) - - it('returns advisory for apiBaseUrl', async () => { - const result = await discoverConfigValue('apiBaseUrl') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('unset') - } - }) - - it('returns advisory for apiProxy', async () => { - const result = await discoverConfigValue('apiProxy') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('network administrator') - } - }) - - it('returns advisory for apiToken', async () => { - const result = await discoverConfigValue('apiToken') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('socket login') - } - }) - - describe('defaultOrg', () => { - it('errors when no API token is set', async () => { - mockHasDefaultApiToken.mockReturnValue(false) - const result = await discoverConfigValue('defaultOrg') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('No API token set') - } - expect(mockFetchOrganization).not.toHaveBeenCalled() - }) - - it('errors when fetchOrganization fails', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue({ - ok: false, - message: 'API down', - }) - const result = await discoverConfigValue('defaultOrg') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('Was unable to determine') - } - }) - - it('errors when organizations list is empty', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue(orgFixture([])) - const result = await discoverConfigValue('defaultOrg') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('Was unable to determine') - } - }) - - it('returns the single slug as a string when only one org is present', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue(orgFixture(['solo-org'])) - const result = await discoverConfigValue('defaultOrg') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('solo-org') - } - }) - - it('returns an array when the token can access multiple orgs', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue(orgFixture(['org-a', 'org-b'])) - const result = await discoverConfigValue('defaultOrg') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual(['org-a', 'org-b']) - } - }) - }) - - describe('enforcedOrgs', () => { - it('errors when no API token is set', async () => { - mockHasDefaultApiToken.mockReturnValue(false) - const result = await discoverConfigValue('enforcedOrgs') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('must have a token') - } - }) - - it('errors when fetchOrganization fails', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue({ - ok: false, - message: 'API down', - }) - const result = await discoverConfigValue('enforcedOrgs') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('Was unable to determine any orgs') - } - }) - - it('errors when organizations list is empty', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue(orgFixture([])) - const result = await discoverConfigValue('enforcedOrgs') - expect(result.ok).toBe(false) - }) - - it('returns the slug list when orgs are available', async () => { - mockHasDefaultApiToken.mockReturnValue(true) - mockFetchOrganization.mockResolvedValue(orgFixture(['x', 'y', 'z'])) - const result = await discoverConfigValue('enforcedOrgs') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual(['x', 'y', 'z']) - } - }) - }) - - it('returns the easter-egg message for the test key', async () => { - const result = await discoverConfigValue('test') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('test key') - } - }) - - it('falls through to "unreachable" for keys that pass isSupportedConfigKey but match no branch', async () => { - // isSupportedConfigKey returns true (mocked) for an unknown key → - // the dispatcher's final `unreachable?` branch is hit. - mockIsSupportedConfigKey.mockReturnValue(true) - const result = await discoverConfigValue('madeUpKey') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toBe('unreachable?') - } - }) -}) diff --git a/packages/cli/test/unit/commands/config/handle-config-auto.test.mts b/packages/cli/test/unit/commands/config/handle-config-auto.test.mts deleted file mode 100644 index ea175bb30..000000000 --- a/packages/cli/test/unit/commands/config/handle-config-auto.test.mts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Unit tests for config auto-discovery handler. - * - * Tests the handler that automatically discovers configuration values from - * various sources (environment variables, config files, prompts, etc.). - * - * Test Coverage: - Successful config value discovery - Discovery failure - * handling - Multiple config keys (apiToken, orgSlug, etc.) - Different output - * kinds (json, text) - Output function integration. - * - * Testing Approach: - Mock discoverConfigValue from discover-config-value.mts - - * Mock outputConfigAuto for output verification - Mock logger for error/success - * messages - Use createSuccessResult/createErrorResult helpers - Test CResult - * pattern flow. - * - * Related Files: - src/commands/config/handle-config-auto.mts - Implementation - * - src/commands/config/discover-config-value.mts - Discovery logic - - * src/commands/config/output-config-auto.mts - Output formatter. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { handleConfigAuto } from '../../../../src/commands/config/handle-config-auto.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -// Mock the dependencies. -const mockDiscoverConfigValue = vi.hoisted(() => vi.fn()) -const mockOutputConfigAuto = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/config/discover-config-value.mts', () => ({ - discoverConfigValue: mockDiscoverConfigValue, -})) - -vi.mock('../../../../src/commands/config/output-config-auto.mts', () => ({ - outputConfigAuto: mockOutputConfigAuto, -})) - -describe('handleConfigAuto', () => { - it('discovers and outputs config value successfully', async () => { - const { discoverConfigValue } = - await import('../../../../src/commands/config/discover-config-value.mts') - const { outputConfigAuto } = - await import('../../../../src/commands/config/output-config-auto.mts') - - const mockResult = createSuccessResult('discovered-api-token') - mockDiscoverConfigValue.mockResolvedValue(mockResult) - - await handleConfigAuto({ key: 'apiToken', outputKind: 'json' }) - - expect(discoverConfigValue).toHaveBeenCalledWith('apiToken') - expect(outputConfigAuto).toHaveBeenCalledWith( - 'apiToken', - mockResult, - 'json', - ) - }) - - it('handles discovery failure', async () => { - const { discoverConfigValue } = - await import('../../../../src/commands/config/discover-config-value.mts') - const { outputConfigAuto } = - await import('../../../../src/commands/config/output-config-auto.mts') - - const mockResult = createErrorResult('Config not found') - mockDiscoverConfigValue.mockResolvedValue(mockResult) - - await handleConfigAuto({ key: 'orgSlug', outputKind: 'text' }) - - expect(discoverConfigValue).toHaveBeenCalledWith('orgSlug') - expect(outputConfigAuto).toHaveBeenCalledWith('orgSlug', mockResult, 'text') - }) - - it('handles markdown output format', async () => { - const { outputConfigAuto } = - await import('../../../../src/commands/config/output-config-auto.mts') - - mockDiscoverConfigValue.mockResolvedValue(createSuccessResult('test-value')) - - await handleConfigAuto({ key: 'orgId', outputKind: 'markdown' }) - - expect(outputConfigAuto).toHaveBeenCalledWith( - 'orgId', - expect.any(Object), - 'markdown', - ) - }) - - it('handles different config keys', async () => { - const { discoverConfigValue } = - await import('../../../../src/commands/config/discover-config-value.mts') - - const keys = ['apiToken', 'apiUrl', 'orgId', 'orgSlug'] as const - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - mockDiscoverConfigValue.mockResolvedValue( - createSuccessResult(`${key}-value`), - ) - await handleConfigAuto({ key, outputKind: 'json' }) - expect(discoverConfigValue).toHaveBeenCalledWith(key) - } - }) - - it('handles text output format', async () => { - const { outputConfigAuto } = - await import('../../../../src/commands/config/output-config-auto.mts') - - mockDiscoverConfigValue.mockResolvedValue( - createSuccessResult('https://api.socket.dev'), - ) - - await handleConfigAuto({ key: 'apiUrl', outputKind: 'text' }) - - expect(outputConfigAuto).toHaveBeenCalledWith( - 'apiUrl', - expect.objectContaining({ - ok: true, - data: 'https://api.socket.dev', - }), - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/config/handle-config-get.test.mts b/packages/cli/test/unit/commands/config/handle-config-get.test.mts deleted file mode 100644 index f665b6764..000000000 --- a/packages/cli/test/unit/commands/config/handle-config-get.test.mts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Unit tests for config get handler. - * - * Tests the handler that retrieves configuration values from the config file. - * This command reads existing config without auto-discovery. - * - * Test Coverage: - Successful config value retrieval - Missing config value - * handling - Different config keys (apiToken, orgSlug, etc.) - Multiple output - * kinds (json, text, markdown) - Output function integration. - * - * Testing Approach: - Mock getConfigValue from util/config.mts - Mock - * outputConfigGet for output verification - Mock logger for error/success - * messages - Use createSuccessResult/createErrorResult helpers - Test CResult - * pattern flow. - * - * Related Files: - src/commands/config/handle-config-get.mts - Implementation - - * src/util/config.mts - Config file utilities - - * src/commands/config/output-config-get.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleConfigGet } from '../../../../src/commands/config/handle-config-get.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -// Mock the dependencies. -const mockOutputConfigGet = vi.hoisted(() => vi.fn()) -const mockGetConfigValue = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/config/output-config-get.mts', () => ({ - outputConfigGet: mockOutputConfigGet, -})) -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValue: mockGetConfigValue, -})) - -describe('handleConfigGet', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('gets config value successfully', async () => { - const { getConfigValue } = await import('../../../../src/util/config.mts') - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const mockResult = createSuccessResult('test-token') - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: 'apiToken', - outputKind: 'json', - }) - - expect(getConfigValue).toHaveBeenCalledWith('apiToken') - expect(outputConfigGet).toHaveBeenCalledWith('apiToken', mockResult, 'json') - }) - - it('handles missing config value', async () => { - const { getConfigValue } = await import('../../../../src/util/config.mts') - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const mockResult = createErrorResult('Config value not found') - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: 'org', - outputKind: 'text', - }) - - expect(getConfigValue).toHaveBeenCalledWith('org') - expect(outputConfigGet).toHaveBeenCalledWith('org', mockResult, 'text') - }) - - it('handles markdown output', async () => { - const { getConfigValue } = await import('../../../../src/util/config.mts') - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const mockResult = createSuccessResult('https://api.socket.dev') - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: 'apiBaseUrl', - outputKind: 'markdown', - }) - - expect(getConfigValue).toHaveBeenCalledWith('apiBaseUrl') - expect(outputConfigGet).toHaveBeenCalledWith( - 'apiBaseUrl', - mockResult, - 'markdown', - ) - }) - - it('handles different config keys', async () => { - const { getConfigValue } = await import('../../../../src/util/config.mts') - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const keys = ['apiToken', 'org', 'repoName', 'apiBaseUrl', 'apiProxy'] - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - const mockResult = createSuccessResult(`value-for-${key}`) - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: key as unknown, - outputKind: 'json', - }) - - expect(getConfigValue).toHaveBeenCalledWith(key) - expect(outputConfigGet).toHaveBeenCalledWith(key, mockResult, 'json') - } - }) - - it('handles empty config value', async () => { - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const mockResult = createSuccessResult('') - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: 'apiToken', - outputKind: 'json', - }) - - expect(outputConfigGet).toHaveBeenCalledWith('apiToken', mockResult, 'json') - }) - - it('handles undefined config value', async () => { - const { outputConfigGet } = - await import('../../../../src/commands/config/output-config-get.mts') - - const mockResult = createSuccessResult(undefined) - mockGetConfigValue.mockReturnValue(mockResult) - - await handleConfigGet({ - key: 'org', - outputKind: 'text', - }) - - expect(outputConfigGet).toHaveBeenCalledWith('org', mockResult, 'text') - }) -}) diff --git a/packages/cli/test/unit/commands/config/handle-config-set.test.mts b/packages/cli/test/unit/commands/config/handle-config-set.test.mts deleted file mode 100644 index 515f0fc24..000000000 --- a/packages/cli/test/unit/commands/config/handle-config-set.test.mts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Unit tests for config set handler. - * - * Tests the handler that updates configuration values in the config file. This - * command writes configuration persistently. - * - * Test Coverage: - Successful config value setting - Set failure handling - - * Different config keys (apiToken, orgSlug, defaultOrg, etc.) - Value - * validation and sanitization - Output function integration. - * - * Testing Approach: - Mock setConfigValue from util/config.mts - Mock - * outputConfigSet for output verification - Mock logger for error/success - * messages - Use createSuccessResult/createErrorResult helpers - Test CResult - * pattern flow. - * - * Related Files: - src/commands/config/handle-config-set.mts - Implementation - - * src/util/config.mts - Config file utilities - - * src/commands/config/output-config-set.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleConfigSet } from '../../../../src/commands/config/handle-config-set.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -// Mock the dependencies. -const mockOutputConfigSet = vi.hoisted(() => vi.fn()) -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/config/output-config-set.mts', () => ({ - outputConfigSet: mockOutputConfigSet, -})) -vi.mock('../../../../src/util/config.mts', () => ({ - updateConfigValue: mockUpdateConfigValue, -})) -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -describe('handleConfigSet', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('sets config value successfully', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigSet } = - await import('../../../../src/commands/config/output-config-set.mts') - - const mockResult = createSuccessResult('new-value') - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigSet({ - key: 'apiToken', - outputKind: 'json', - value: 'new-token-value', - }) - - expect(updateConfigValue).toHaveBeenCalledWith( - 'apiToken', - 'new-token-value', - ) - expect(outputConfigSet).toHaveBeenCalledWith(mockResult, 'json') - }) - - it('handles config update failure', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigSet } = - await import('../../../../src/commands/config/output-config-set.mts') - - const mockResult = createErrorResult('Config update failed') - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigSet({ - key: 'org', - outputKind: 'text', - value: 'test-org', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('org', 'test-org') - expect(outputConfigSet).toHaveBeenCalledWith(mockResult, 'text') - }) - - it('handles markdown output', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigSet } = - await import('../../../../src/commands/config/output-config-set.mts') - - const mockResult = createSuccessResult('markdown-value') - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigSet({ - key: 'repoName', - outputKind: 'markdown', - value: 'my-repo', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('repoName', 'my-repo') - expect(outputConfigSet).toHaveBeenCalledWith(mockResult, 'markdown') - }) - - it('logs debug information', async () => { - const { debug, debugDir } = await import('@socketsecurity/lib-stable/debug/output') - - mockUpdateConfigValue.mockReturnValue(createSuccessResult('debug-value')) - - await handleConfigSet({ - key: 'apiBaseUrl', - outputKind: 'json', - value: 'https://api.example.com', - }) - - expect(debug).toHaveBeenCalledWith( - 'Setting config apiBaseUrl = https://api.example.com', - ) - expect(debugDir).toHaveBeenCalledWith({ - key: 'apiBaseUrl', - value: 'https://api.example.com', - outputKind: 'json', - }) - expect(debug).toHaveBeenCalledWith('Config update succeeded') - }) - - it('logs debug information on failure', async () => { - const { debug } = await import('@socketsecurity/lib-stable/debug/output') - - mockUpdateConfigValue.mockReturnValue(createErrorResult('Failed')) - - await handleConfigSet({ - key: 'apiToken', - outputKind: 'json', - value: 'bad-token', - }) - - expect(debug).toHaveBeenCalledWith('Config update failed') - }) - - it('throws InputError when value is undefined', async () => { - await expect( - handleConfigSet({ - key: 'apiToken', - outputKind: 'json', - value: undefined, - }), - ).rejects.toThrow(/requires a VALUE argument/) - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - expect(mockOutputConfigSet).not.toHaveBeenCalled() - }) - - it('handles different config keys', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - - const keys = ['apiToken', 'org', 'repoName', 'apiBaseUrl', 'apiProxy'] - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - mockUpdateConfigValue.mockReturnValue( - createSuccessResult(`value-for-${key}`), - ) - - await handleConfigSet({ - key: key as unknown, - outputKind: 'json', - value: `test-${key}`, - }) - - expect(updateConfigValue).toHaveBeenCalledWith(key, `test-${key}`) - } - }) -}) diff --git a/packages/cli/test/unit/commands/config/handle-config-unset.test.mts b/packages/cli/test/unit/commands/config/handle-config-unset.test.mts deleted file mode 100644 index 3d53934ff..000000000 --- a/packages/cli/test/unit/commands/config/handle-config-unset.test.mts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Unit tests for config unset handler. - * - * Tests the handler that removes configuration values from the config file. - * This command deletes configuration keys. - * - * Test Coverage: - Successful config value removal - Unset failure handling - - * Different config keys (apiToken, orgSlug, defaultOrg, etc.) - Non-existent - * key handling - Output function integration. - * - * Testing Approach: - Mock unsetConfigValue from util/config.mts - Mock - * outputConfigUnset for output verification - Mock logger for error/success - * messages - Use createSuccessResult/createErrorResult helpers - Test CResult - * pattern flow. - * - * Related Files: - src/commands/config/handle-config-unset.mts - Implementation - * - src/util/config.mts - Config file utilities - - * src/commands/config/output-config-unset.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleConfigUnset } from '../../../../src/commands/config/handle-config-unset.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -// Mock the dependencies. -const mockOutputConfigUnset = vi.hoisted(() => vi.fn()) -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/config/output-config-unset.mts', () => ({ - outputConfigUnset: mockOutputConfigUnset, -})) -vi.mock('../../../../src/util/config.mts', () => ({ - updateConfigValue: mockUpdateConfigValue, -})) - -describe('handleConfigUnset', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('unsets config value successfully', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - const mockResult = createSuccessResult(undefined) - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: 'apiToken', - outputKind: 'json', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('apiToken', undefined) - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'json') - }) - - it('handles unset failure', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - const mockResult = createErrorResult('Cannot unset config') - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: 'org', - outputKind: 'text', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('org', undefined) - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'text') - }) - - it('handles markdown output', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - const mockResult = createSuccessResult(undefined) - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: 'repoName', - outputKind: 'markdown', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('repoName', undefined) - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'markdown') - }) - - it('handles different config keys', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - const keys = ['apiToken', 'org', 'repoName', 'apiBaseUrl', 'apiProxy'] - - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - const mockResult = createSuccessResult(undefined) - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: key as unknown, - outputKind: 'json', - }) - - expect(updateConfigValue).toHaveBeenCalledWith(key, undefined) - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'json') - } - }) - - it('handles text output', async () => { - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - const mockResult = createSuccessResult(undefined) - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: 'apiToken', - outputKind: 'text', - }) - - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'text') - }) - - it('handles already unset config value', async () => { - const { updateConfigValue } = - await import('../../../../src/util/config.mts') - const { outputConfigUnset } = - await import('../../../../src/commands/config/output-config-unset.mts') - - // Even if already unset, the function should still succeed. - const mockResult = createSuccessResult(undefined) - mockUpdateConfigValue.mockReturnValue(mockResult) - - await handleConfigUnset({ - key: 'org', - outputKind: 'json', - }) - - expect(updateConfigValue).toHaveBeenCalledWith('org', undefined) - expect(outputConfigUnset).toHaveBeenCalledWith(mockResult, 'json') - }) -}) diff --git a/packages/cli/test/unit/commands/config/output-config-auto.test.mts b/packages/cli/test/unit/commands/config/output-config-auto.test.mts deleted file mode 100644 index 3c3db31c8..000000000 --- a/packages/cli/test/unit/commands/config/output-config-auto.test.mts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Unit tests for config auto output formatting. - * - * Purpose: Tests the output formatting for config auto-discovery results. - * - * Test Coverage: - outputConfigAuto function - JSON output format - Text output - * format - Markdown output format - Interactive prompts for defaultOrg - - * Read-only mode handling. - * - * Related Files: - src/commands/config/output-config-auto.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock prompts. -const mockSelect = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, -})) - -// Mock config utilities. -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) -const mockUpdateConfigValue = vi.hoisted(() => - vi.fn(() => ({ ok: true, message: 'Updated' })), -) - -vi.mock('../../../../src/util/config.mts', () => ({ - isConfigFromFlag: mockIsConfigFromFlag, - updateConfigValue: mockUpdateConfigValue, -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputConfigAuto } from '../../../../src/commands/config/output-config-auto.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-config-auto', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsConfigFromFlag.mockReturnValue(false) - mockUpdateConfigValue.mockReturnValue({ ok: true, message: 'Updated' }) - }) - - describe('outputConfigAuto', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigAuto('defaultOrg', result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<string> = { - ok: false, - message: 'Auto-discovery failed', - } - - await outputConfigAuto('defaultOrg', result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed', - code: 5, - } - - await outputConfigAuto('defaultOrg', result, 'json') - - expect(process.exitCode).toBe(5) - }) - }) - - describe('Markdown output', () => { - it('outputs auto-discovery header and value', async () => { - const result: CResult<string> = { - ok: true, - data: 'discovered-org', - } - - await outputConfigAuto('defaultOrg', result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Auto discover config value') - expect(logs).toContain('defaultOrg') - expect(logs).toContain('discovered-org') - }) - - it('includes message when provided', async () => { - const result: CResult<string> = { - ok: true, - data: 'my-org', - message: 'Found via GitHub API', - } - - await outputConfigAuto('defaultOrg', result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Found via GitHub API') - }) - }) - - describe('Text output', () => { - it('outputs discovered value', async () => { - // Mock select to return empty (No) - mockSelect.mockResolvedValue('') - - const result: CResult<string> = { - ok: true, - data: 'auto-org', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('defaultOrg: auto-org') - }) - - it('shows read-only message when config from flag', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - const result: CResult<string> = { - ok: true, - data: 'test-org', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('read-only') - }) - - it('updates config when user confirms for defaultOrg', async () => { - mockSelect.mockResolvedValue('my-org') - - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'defaultOrg', - 'my-org', - ) - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Updated defaultOrg') - }) - - it('shows no changes message when user declines', async () => { - mockSelect.mockResolvedValue('') - - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('No changes made') - }) - - it('handles update failure', async () => { - mockSelect.mockResolvedValue('my-org') - mockUpdateConfigValue.mockReturnValue({ - ok: false, - message: 'Write failed', - cause: 'Permission denied', - }) - - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Write failed') - }) - - it('outputs error with fail message', async () => { - const result: CResult<string> = { - ok: false, - message: 'Could not auto-discover', - cause: 'No orgs found', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Could not auto-discover'), - ) - }) - - it('handles enforcedOrgs key with prompt', async () => { - mockSelect.mockResolvedValue('enforced-org') - - const result: CResult<string> = { - ok: true, - data: 'enforced-org', - } - - await outputConfigAuto('enforcedOrgs', result, 'text') - - // Select should be called for enforcedOrgs. - expect(mockSelect).toHaveBeenCalled() - }) - - it('logs result.message before the discovered value when present', async () => { - mockSelect.mockResolvedValue('') - - const result: CResult<string> = { - ok: true, - data: 'org-a', - message: 'note: this is a heuristic guess', - } - - await outputConfigAuto('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('heuristic guess') - expect(logs).toContain('defaultOrg: org-a') - }) - - it('handles enforcedOrgs update failure', async () => { - mockSelect.mockResolvedValue('enforced-org') - mockUpdateConfigValue.mockReturnValue({ - ok: false, - message: 'enforcedOrgs write failed', - cause: 'permission', - }) - - const result: CResult<string> = { - ok: true, - data: 'enforced-org', - } - - await outputConfigAuto('enforcedOrgs', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('enforcedOrgs write failed') - }) - - it('shows no-changes message when user declines enforcedOrgs', async () => { - mockSelect.mockResolvedValue('') - - const result: CResult<string> = { - ok: true, - data: 'enforced-org', - } - - await outputConfigAuto('enforcedOrgs', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('No changes made') - }) - - it('handles array data for defaultOrg by mapping each item to a choice', async () => { - mockSelect.mockResolvedValue('org-a') - mockUpdateConfigValue.mockReturnValue({ ok: true }) - - const result: CResult<string[]> = { - ok: true, - data: ['org-a', 'org-b', 'org-c'], - } - - await outputConfigAuto('defaultOrg', result as unknown, 'text') - - const selectCallArgs = mockSelect.mock.calls[0]![0] as { - choices: Array<{ value: string }> - } - expect(selectCallArgs.choices.length).toBeGreaterThanOrEqual(3) - }) - - it('handles array data for enforcedOrgs by mapping each item to a choice', async () => { - mockSelect.mockResolvedValue('') - - const result: CResult<string[]> = { - ok: true, - data: ['org-a', 'org-b'], - } - - await outputConfigAuto('enforcedOrgs', result as unknown, 'text') - - const selectCallArgs = mockSelect.mock.calls[0]![0] as { - choices: Array<{ value: string }> - } - expect(selectCallArgs.choices.length).toBeGreaterThanOrEqual(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/output-config-get.test.mts b/packages/cli/test/unit/commands/config/output-config-get.test.mts deleted file mode 100644 index 2a218954a..000000000 --- a/packages/cli/test/unit/commands/config/output-config-get.test.mts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Unit tests for config get output formatting. - * - * Purpose: Tests the output formatting for config get results. - * - * Test Coverage: - outputConfigGet function - JSON output format - Text output - * format - Markdown output format - Error handling. - * - * Related Files: - src/commands/config/output-config-get.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) -vi.mock('../../../../src/util/config.mts', () => ({ - isConfigFromFlag: mockIsConfigFromFlag, -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputConfigGet } from '../../../../src/commands/config/output-config-get.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-config-get', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsConfigFromFlag.mockReturnValue(false) - }) - - describe('outputConfigGet', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigGet('defaultOrg', result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<string> = { - ok: false, - message: 'Config not found', - } - - await outputConfigGet('defaultOrg', result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputConfigGet('defaultOrg', result, 'json') - - expect(process.exitCode).toBe(2) - }) - }) - - describe('Text output', () => { - it('outputs config value', async () => { - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigGet('defaultOrg', result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('defaultOrg: my-org') - }) - - it('outputs error with fail message', async () => { - const result: CResult<string> = { - ok: false, - message: 'Key not found', - cause: 'Invalid key', - } - - await outputConfigGet('defaultOrg', result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Key not found'), - ) - expect(process.exitCode).toBe(1) - }) - - it('shows read-only note when config from flag', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - const result: CResult<string> = { - ok: true, - data: 'test-value', - } - - await outputConfigGet('defaultOrg', result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('read-only') - }) - }) - - describe('Markdown output', () => { - it('outputs config value as markdown', async () => { - const result: CResult<string> = { - ok: true, - data: 'my-org', - } - - await outputConfigGet('defaultOrg', result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Config Value') - expect(logs).toContain('defaultOrg') - expect(logs).toContain('my-org') - }) - - it('shows read-only note in markdown when config from flag', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - const result: CResult<string> = { - ok: true, - data: 'test-value', - } - - await outputConfigGet('defaultOrg', result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('read-only') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/output-config-list.test.mts b/packages/cli/test/unit/commands/config/output-config-list.test.mts deleted file mode 100644 index 835cbadea..000000000 --- a/packages/cli/test/unit/commands/config/output-config-list.test.mts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Unit tests for config list output formatting. - * - * Purpose: Tests the output formatting for config list results. - * - * Test Coverage: - outputConfigList function - JSON output format with - * full/partial modes - Text output format - Sensitive key masking - Read-only - * mode indicators. - * - * Related Files: - src/commands/config/output-config-list.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock config utilities. -const mockGetConfigValue = vi.hoisted(() => - vi.fn(() => ({ ok: true, data: 'test-value' })), -) -const mockGetSupportedConfigKeys = vi.hoisted(() => - vi.fn(() => ['apiToken', 'defaultOrg', 'enforcedOrgs']), -) -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) -const mockIsSensitiveConfigKey = vi.hoisted(() => - vi.fn((key: string) => key === 'apiToken'), -) - -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValue: mockGetConfigValue, - getSupportedConfigKeys: mockGetSupportedConfigKeys, - isConfigFromFlag: mockIsConfigFromFlag, - isSensitiveConfigKey: mockIsSensitiveConfigKey, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputConfigList } from '../../../../src/commands/config/output-config-list.mts' - -describe('output-config-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsConfigFromFlag.mockReturnValue(false) - mockGetConfigValue.mockReturnValue({ ok: true, data: 'test-value' }) - }) - - describe('outputConfigList', () => { - describe('JSON output', () => { - it('outputs all config keys as JSON', async () => { - mockGetConfigValue - .mockReturnValueOnce({ ok: true, data: 'sk_live_xxx' }) - .mockReturnValueOnce({ ok: true, data: 'my-org' }) - .mockReturnValueOnce({ ok: true, data: undefined }) - - await outputConfigList({ full: true, outputKind: 'json' }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('masks sensitive keys when full is false', async () => { - mockGetConfigValue - .mockReturnValueOnce({ ok: true, data: 'sk_live_xxx' }) - .mockReturnValueOnce({ ok: true, data: 'my-org' }) - .mockReturnValueOnce({ ok: true, data: undefined }) - - await outputConfigList({ full: false, outputKind: 'json' }) - - const loggedJson = mockLogger.log.mock.calls[0]![0] - expect(loggedJson).toContain('********') - }) - - it('sets exit code on config retrieval failure', async () => { - mockGetConfigValue.mockReturnValue({ - ok: false, - message: 'Failed to read', - }) - - await outputConfigList({ full: true, outputKind: 'json' }) - - expect(process.exitCode).toBe(1) - }) - - it('includes readOnly status in output', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - mockGetConfigValue.mockReturnValue({ ok: true, data: 'value' }) - - await outputConfigList({ full: true, outputKind: 'json' }) - - const loggedJson = mockLogger.log.mock.calls[0]![0] - expect(loggedJson).toContain('"readOnly": true') - }) - }) - - describe('Text output', () => { - it('outputs config header and values', async () => { - mockGetConfigValue - .mockReturnValueOnce({ ok: true, data: 'sk_live_xxx' }) - .mockReturnValueOnce({ ok: true, data: 'my-org' }) - .mockReturnValueOnce({ ok: true, data: undefined }) - - await outputConfigList({ full: true, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Local CLI Config') - expect(logs).toContain('my-org') - }) - - it('masks sensitive keys in text output when full is false', async () => { - mockGetConfigValue - .mockReturnValueOnce({ ok: true, data: 'sk_live_xxx' }) - .mockReturnValueOnce({ ok: true, data: 'my-org' }) - .mockReturnValueOnce({ ok: true, data: undefined }) - - await outputConfigList({ full: false, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('********') - }) - - it('shows read-only note when config from flag', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - mockGetConfigValue.mockReturnValue({ ok: true, data: 'test' }) - - await outputConfigList({ full: false, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('read-only') - }) - - it('shows failed to read message on error', async () => { - mockGetConfigValue.mockReturnValue({ - ok: false, - message: 'Permission denied', - }) - - await outputConfigList({ full: true, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('failed to read') - expect(logs).toContain('Permission denied') - }) - - it('shows <none> for undefined values in full mode', async () => { - mockGetConfigValue.mockReturnValue({ ok: true, data: undefined }) - - await outputConfigList({ full: true, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('<none>') - }) - - it('handles array values', async () => { - mockGetConfigValue - .mockReturnValueOnce({ ok: true, data: 'token' }) - .mockReturnValueOnce({ ok: true, data: 'my-org' }) - .mockReturnValueOnce({ ok: true, data: ['org1', 'org2'] }) - - await outputConfigList({ full: true, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('org1, org2') - }) - - it('shows <none> for empty array in full mode', async () => { - // value.join(', ') === '' → falls back to '<none>'. - mockGetConfigValue.mockReturnValue({ ok: true, data: [] }) - - await outputConfigList({ full: true, outputKind: 'text' }) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('<none>') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/output-config-set.test.mts b/packages/cli/test/unit/commands/config/output-config-set.test.mts deleted file mode 100644 index 8748a63b0..000000000 --- a/packages/cli/test/unit/commands/config/output-config-set.test.mts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Unit tests for config set output formatting. - * - * Purpose: Tests the output formatting for config set results. - * - * Test Coverage: - outputConfigSet function - JSON output format - Text output - * format - Markdown output format - Error handling. - * - * Related Files: - src/commands/config/output-config-set.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputConfigSet } from '../../../../src/commands/config/output-config-set.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-config-set', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputConfigSet', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config updated', - data: undefined, - } - - await outputConfigSet(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed to update config', - } - - await outputConfigSet(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed', - code: 3, - } - - await outputConfigSet(result, 'json') - - expect(process.exitCode).toBe(3) - }) - }) - - describe('Text output', () => { - it('outputs OK and message on success', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key updated successfully', - data: undefined, - } - - await outputConfigSet(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('OK') - expect(mockLogger.log).toHaveBeenCalledWith( - 'Config key updated successfully', - ) - }) - - it('outputs additional data when provided', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key updated', - data: 'Additional info here', - } - - await outputConfigSet(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('Additional info here') - }) - - it('outputs error with fail message', async () => { - const result: CResult<string> = { - ok: false, - message: 'Invalid key', - cause: 'Key not supported', - } - - await outputConfigSet(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Invalid key'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Markdown output', () => { - it('outputs update config header and message', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config updated successfully', - data: undefined, - } - - await outputConfigSet(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Update config') - expect(logs).toContain('Config updated successfully') - }) - - it('outputs additional data in markdown', async () => { - const result: CResult<string> = { - ok: true, - message: 'Updated', - data: 'More details', - } - - await outputConfigSet(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('More details') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/config/output-config-unset.test.mts b/packages/cli/test/unit/commands/config/output-config-unset.test.mts deleted file mode 100644 index 6c57f53c4..000000000 --- a/packages/cli/test/unit/commands/config/output-config-unset.test.mts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Unit tests for config unset output formatting. - * - * Purpose: Tests the output formatting for config unset results. - * - * Test Coverage: - outputConfigUnset function - JSON output format - Text - * output format - Markdown output format - Error handling. - * - * Related Files: - src/commands/config/output-config-unset.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputConfigUnset } from '../../../../src/commands/config/output-config-unset.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-config-unset', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputConfigUnset', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key removed', - data: undefined, - } - - await outputConfigUnset(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed to unset config', - } - - await outputConfigUnset(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<string> = { - ok: false, - message: 'Failed', - code: 4, - } - - await outputConfigUnset(result, 'json') - - expect(process.exitCode).toBe(4) - }) - }) - - describe('Text output', () => { - it('outputs OK and message on success', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key removed successfully', - data: undefined, - } - - await outputConfigUnset(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('OK') - expect(mockLogger.log).toHaveBeenCalledWith( - 'Config key removed successfully', - ) - }) - - it('outputs additional data when provided', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key removed', - data: 'Previous value was: old-value', - } - - await outputConfigUnset(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Previous value was: old-value', - ) - }) - - it('outputs error with fail message', async () => { - const result: CResult<string> = { - ok: false, - message: 'Cannot unset key', - cause: 'Key is required', - } - - await outputConfigUnset(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Cannot unset key'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Markdown output', () => { - it('outputs update config header and message', async () => { - const result: CResult<string> = { - ok: true, - message: 'Config key has been unset', - data: undefined, - } - - await outputConfigUnset(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Update config') - expect(logs).toContain('Config key has been unset') - }) - - it('outputs additional data in markdown', async () => { - const result: CResult<string> = { - ok: true, - message: 'Unset complete', - data: 'Additional info', - } - - await outputConfigUnset(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Additional info') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/branch-cleanup.test.mts b/packages/cli/test/unit/commands/fix/branch-cleanup.test.mts deleted file mode 100644 index fff67af03..000000000 --- a/packages/cli/test/unit/commands/fix/branch-cleanup.test.mts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Unit tests for branch cleanup utilities. - * - * Purpose: Tests the branch lifecycle management for the fix command. - * - * Test Coverage: - cleanupStaleBranch function - cleanupFailedPrBranches - * function - cleanupSuccessfulPrLocalBranch function - cleanupErrorBranches - * function. - * - * Related Files: - src/commands/fix/branch-cleanup.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock git operations. -const mockGitDeleteBranch = vi.hoisted(() => vi.fn()) -const mockGitDeleteRemoteBranch = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - gitDeleteBranch: mockGitDeleteBranch, - gitDeleteRemoteBranch: mockGitDeleteRemoteBranch, -})) - -import { - cleanupErrorBranches, - cleanupFailedPrBranches, - cleanupStaleBranch, - cleanupSuccessfulPrLocalBranch, -} from '../../../../src/commands/fix/branch-cleanup.mts' - -describe('branch-cleanup', () => { - const cwd = '/test/repo' - const branch = 'socket/fix/GHSA-1234-5678-90ab' - const ghsaId = 'GHSA-1234-5678-90ab' - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('cleanupStaleBranch', () => { - it('deletes remote and local branches when remote deletion succeeds', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(true) - mockGitDeleteBranch.mockResolvedValue(undefined) - - const result = await cleanupStaleBranch(branch, ghsaId, cwd) - - expect(result).toBe(true) - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Stale branch'), - ) - }) - - it('returns false when remote deletion fails', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(false) - - const result = await cleanupStaleBranch(branch, ghsaId, cwd) - - expect(result).toBe(false) - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteBranch).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to delete stale remote branch'), - ) - }) - - it('logs warning about stale branch', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(true) - - await cleanupStaleBranch(branch, ghsaId, cwd) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining(branch), - ) - }) - }) - - describe('cleanupFailedPrBranches', () => { - it('deletes both remote and local branches', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(true) - mockGitDeleteBranch.mockResolvedValue(undefined) - - await cleanupFailedPrBranches(branch, cwd) - - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - }) - - it('continues to delete local branch even if remote fails', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(false) - mockGitDeleteBranch.mockResolvedValue(undefined) - - await cleanupFailedPrBranches(branch, cwd) - - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - }) - }) - - describe('cleanupSuccessfulPrLocalBranch', () => { - it('deletes only local branch, keeping remote for PR', async () => { - mockGitDeleteBranch.mockResolvedValue(undefined) - - await cleanupSuccessfulPrLocalBranch(branch, cwd) - - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteRemoteBranch).not.toHaveBeenCalled() - }) - }) - - describe('cleanupErrorBranches', () => { - it('deletes both remote and local when remote exists', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(true) - mockGitDeleteBranch.mockResolvedValue(undefined) - - await cleanupErrorBranches(branch, cwd, true) - - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith(branch, cwd) - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - }) - - it('deletes only local branch when remote does not exist', async () => { - mockGitDeleteBranch.mockResolvedValue(undefined) - - await cleanupErrorBranches(branch, cwd, false) - - expect(mockGitDeleteRemoteBranch).not.toHaveBeenCalled() - expect(mockGitDeleteBranch).toHaveBeenCalledWith(branch, cwd) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/cmd-fix.test.mts b/packages/cli/test/unit/commands/fix/cmd-fix.test.mts deleted file mode 100644 index 34aa35659..000000000 --- a/packages/cli/test/unit/commands/fix/cmd-fix.test.mts +++ /dev/null @@ -1,518 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for fix command. - * - * Tests the command that fixes CVEs in dependencies. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleFix = vi.hoisted(() => vi.fn()) -const mockGetDefaultOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue({ ok: true, data: 'test-org' }), -) - -vi.mock('../../../../src/commands/fix/handle-fix.mts', () => ({ - handleFix: mockHandleFix, -})) - -vi.mock('../../../../src/commands/ci/fetch-default-org-slug.mts', () => ({ - getDefaultOrgSlug: mockGetDefaultOrgSlug, -})) - -// Import after mocks. -const { cmdFix } = await import('../../../../src/commands/fix/cmd-fix.mts') - -describe('cmd-fix', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdFix.description).toBe('Fix CVEs in dependencies') - }) - - it('should not be hidden', () => { - expect(cmdFix.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-fix.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag', async () => { - await cmdFix.run(['--dry-run'], importMeta, context) - - expect(mockHandleFix).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail if org slug cannot be resolved', async () => { - mockGetDefaultOrgSlug.mockResolvedValueOnce({ - ok: false, - code: 1, - error: 'No org found', - }) - - await cmdFix.run([], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockHandleFix).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Unable to resolve'), - ) - }) - - it('should call handleFix with default options', async () => { - await cmdFix.run([], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - all: false, - applyFixes: true, - autopilot: false, - cwd: expect.any(String), - debug: false, - disableMajorUpdates: false, - ecosystems: [], - exclude: [], - ghsas: [], - include: [], - minimumReleaseAge: '', - orgSlug: 'test-org', - outputFile: '', - outputKind: 'text', - prCheck: true, - prLimit: 10, - rangeStyle: 'preserve', - showAffectedDirectDependencies: false, - silence: false, - }), - ) - }) - - it('should pass --all flag to handleFix', async () => { - await cmdFix.run(['--all'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - all: true, - }), - ) - }) - - it('should pass --id flag to handleFix', async () => { - await cmdFix.run(['--id', 'CVE-2021-23337'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - ghsas: ['CVE-2021-23337'], - }), - ) - }) - - it('should pass multiple --id flags to handleFix', async () => { - await cmdFix.run( - ['--id', 'CVE-2021-23337', '--id', 'GHSA-xxxx-yyyy-zzzz'], - importMeta, - context, - ) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - ghsas: ['CVE-2021-23337', 'GHSA-xxxx-yyyy-zzzz'], - }), - ) - }) - - it('should pass comma-separated --id values to handleFix', async () => { - await cmdFix.run( - ['--id', 'CVE-2021-23337,GHSA-xxxx-yyyy-zzzz'], - importMeta, - context, - ) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - ghsas: ['CVE-2021-23337', 'GHSA-xxxx-yyyy-zzzz'], - }), - ) - }) - - it('should fail if both --all and --id are provided', async () => { - await cmdFix.run(['--all', '--id', 'CVE-2021-23337'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleFix).not.toHaveBeenCalled() - }) - - it('should pass --ecosystems flag to handleFix', async () => { - await cmdFix.run(['--ecosystems', 'npm'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystems: ['npm'], - }), - ) - }) - - it('should pass multiple ecosystems to handleFix', async () => { - await cmdFix.run(['--ecosystems', 'npm,pypi'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystems: ['npm', 'pypi'], - }), - ) - }) - - it('should fail with invalid ecosystem', async () => { - await cmdFix.run(['--ecosystems', 'invalid'], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockHandleFix).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('--ecosystems must be one of'), - ) - }) - - it('should pass --range-style flag to handleFix', async () => { - await cmdFix.run(['--range-style', 'pin'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - rangeStyle: 'pin', - }), - ) - }) - - it('should fail with invalid range-style', async () => { - await cmdFix.run(['--range-style', 'invalid'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleFix).not.toHaveBeenCalled() - }) - - it('should pass --no-major-updates flag to handleFix', async () => { - await cmdFix.run(['--no-major-updates'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - disableMajorUpdates: true, - }), - ) - }) - - it('should pass --no-apply-fixes flag to handleFix', async () => { - await cmdFix.run(['--no-apply-fixes'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - applyFixes: false, - }), - ) - }) - - it('should pass --autopilot flag to handleFix', async () => { - await cmdFix.run(['--autopilot'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - autopilot: true, - }), - ) - }) - - it('should pass --debug flag to handleFix', async () => { - await cmdFix.run(['--debug'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - debug: true, - }), - ) - }) - - it('should pass --pr-limit flag to handleFix', async () => { - await cmdFix.run(['--pr-limit', '5'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - prLimit: 5, - }), - ) - }) - - it('should pass --output-file flag to handleFix', async () => { - await cmdFix.run(['--output-file', './fixes.json'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - outputFile: './fixes.json', - }), - ) - }) - - it('should pass --minimum-release-age flag to handleFix', async () => { - await cmdFix.run(['--minimum-release-age', '1w'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - minimumReleaseAge: '1w', - }), - ) - }) - - it('should pass --show-affected-direct-dependencies flag to handleFix', async () => { - await cmdFix.run( - ['--show-affected-direct-dependencies'], - importMeta, - context, - ) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - showAffectedDirectDependencies: true, - }), - ) - }) - - it('should pass --silence flag to handleFix', async () => { - await cmdFix.run(['--silence'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - silence: true, - }), - ) - }) - - it('should pass --include flag to handleFix', async () => { - await cmdFix.run(['--include', 'packages/*'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - include: ['packages/*'], - }), - ) - }) - - it('should pass --exclude flag to handleFix', async () => { - await cmdFix.run(['--exclude', 'test/*'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - exclude: ['test/*'], - }), - ) - }) - - describe('misplaced vulnerability identifier detection', () => { - // The case matrix handle-fix.mts actually validates downstream: - // GHSA: /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ - // CVE: /^CVE-\d{4}-\d{4,}$/ - // Suggestion must be exactly the form that passes those regexes, - // otherwise the user follows our advice and still gets an error. - it.each([ - // [label, input, expectedSuggestion] - // GHSA: prefix to upper, body to lower, regardless of input casing. - ['canonical GHSA', 'GHSA-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'], - ['lowercase GHSA', 'ghsa-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'], - ['mixed-case GHSA', 'GhSa-AbCd-EfGh-IjKl', 'GHSA-abcd-efgh-ijkl'], - // CVE: prefix to upper, body is digits so case is a no-op. - ['canonical CVE', 'CVE-2021-23337', 'CVE-2021-23337'], - ['lowercase CVE', 'cve-2021-23337', 'CVE-2021-23337'], - // PURL: always lowercase by spec, echo verbatim. - ['npm PURL', 'pkg:npm/left-pad@1.3.0', 'pkg:npm/left-pad@1.3.0'], - ])( - 'detects %s and suggests the downstream-valid form', - async (_label, input, expectedSuggestion) => { - await cmdFix.run([input], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockHandleFix).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining( - 'looks like a vulnerability identifier, not a directory path', - ), - ) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining(`--id ${expectedSuggestion}`), - ) - }, - ) - - it('validates IDs before resolving the org slug (no API token path)', async () => { - await cmdFix.run(['GHSA-xxxx-xxxx-xxxx'], importMeta, context) - - // The check must run *before* `getDefaultOrgSlug`, so users without - // a configured API token still see the helpful message instead of - // the generic "Unable to resolve org". - expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled() - }) - }) - - describe('target directory validation', () => { - it('should fail fast when target directory does not exist', async () => { - await cmdFix.run(['./this/path/does/not/exist'], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockHandleFix).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Target directory does not exist'), - ) - }) - - it('validates the directory before resolving the org slug', async () => { - await cmdFix.run(['./this/path/does/not/exist'], importMeta, context) - - expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled() - }) - - it('lets a real directory flow through to handleFix', async () => { - const realDir = process.cwd() - await cmdFix.run([realDir], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ cwd: realDir }), - ) - // Quick: no bail on the happy path. - expect(mockLogger.fail).not.toHaveBeenCalledWith( - expect.stringContaining('Target directory does not exist'), - ) - }) - }) - - it('should support --json output mode', async () => { - await cmdFix.run(['--json'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - await cmdFix.run(['--markdown'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail if both --json and --markdown are provided', async () => { - await cmdFix.run(['--json', '--markdown'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleFix).not.toHaveBeenCalled() - }) - - it('should show all vulnerabilities in dry-run with --all', async () => { - await cmdFix.run(['--dry-run', '--all'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('all vulnerabilities'), - ) - }) - - it('should show specific vulnerability count in dry-run with --id', async () => { - await cmdFix.run( - ['--dry-run', '--id', 'CVE-2021-23337,GHSA-xxxx-yyyy-zzzz'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('2 specified vulnerability'), - ) - }) - - it('should show compute-only mode in dry-run with --no-apply-fixes', async () => { - await cmdFix.run(['--dry-run', '--no-apply-fixes'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('compute fixes only'), - ) - }) - - it('should handle org slug error with specific error code', async () => { - mockGetDefaultOrgSlug.mockResolvedValueOnce({ - ok: false, - code: 42, - error: 'Specific error', - }) - - await cmdFix.run([], importMeta, context) - - expect(process.exitCode).toBe(42) - expect(mockHandleFix).not.toHaveBeenCalled() - }) - - it('should show ecosystems in dry-run output', async () => { - await cmdFix.run( - ['--dry-run', '--ecosystems', 'npm,pypi'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should show auto-discovered targets in dry-run when no --id or --all', async () => { - await cmdFix.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('auto-discovered'), - ) - }) - - it('should show PR info in dry-run when apply fixes enabled', async () => { - await cmdFix.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should pass --no-pr-check flag to handleFix', async () => { - await cmdFix.run(['--no-pr-check'], importMeta, context) - - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - prCheck: false, - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/coana-fix.test.mts b/packages/cli/test/unit/commands/fix/coana-fix.test.mts deleted file mode 100644 index 78ec4be2b..000000000 --- a/packages/cli/test/unit/commands/fix/coana-fix.test.mts +++ /dev/null @@ -1,872 +0,0 @@ -/* max-file-lines: legitimate — coverage-targeted tests for one command/module; splitting would fragment closely related assertions. */ -/** - * Coverage tests for coana-fix. - * - * Purpose: Drives the previously-uncovered branches in coana-fix.mts that the - * sibling handle-fix-limit.test.mts does not exercise. The limit tests cover - * the happy local-mode path and the early-error returns; this file covers the - * PR-creation, branch-cleanup, outputFile, discovery parse-error, and per-GHSA - * failure branches. - * - * Related Files: - src/commands/fix/coana-fix.mts (implementation) - - * test/unit/commands/fix/handle-fix-limit.test.mts (sibling) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { coanaFix } from '../../../../src/commands/fix/coana-fix.mts' - -import type { FixConfig } from '../../../../src/commands/fix/types.mts' - -const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockGetFixEnv = vi.hoisted(() => vi.fn()) -const mockCheckCiEnvVars = vi.hoisted(() => - vi.fn(() => ({ missing: [], present: [] })), -) -const mockGetSocketFixPrs = vi.hoisted(() => vi.fn()) -const mockCleanupSocketFixPrs = vi.hoisted(() => vi.fn()) -const mockOpenSocketFixPr = vi.hoisted(() => vi.fn()) -const mockFetchGhsaDetails = vi.hoisted(() => vi.fn()) -const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) -const mockGitCreateBranch = vi.hoisted(() => vi.fn(() => Promise.resolve(true))) -const mockGitCheckoutBranch = vi.hoisted(() => - vi.fn(() => Promise.resolve(true)), -) -const mockGitCommit = vi.hoisted(() => vi.fn(() => Promise.resolve(true))) -const mockGitPushBranch = vi.hoisted(() => vi.fn(() => Promise.resolve(true))) -const mockGitRemoteBranchExists = vi.hoisted(() => - vi.fn(() => Promise.resolve(false)), -) -const mockGitResetAndClean = vi.hoisted(() => - vi.fn(() => Promise.resolve(true)), -) -const mockCleanupStaleBranch = vi.hoisted(() => - vi.fn(() => Promise.resolve(true)), -) -const mockCleanupErrorBranches = vi.hoisted(() => vi.fn()) -const mockCleanupFailedPrBranches = vi.hoisted(() => vi.fn()) -const mockCleanupSuccessfulPrLocalBranch = vi.hoisted(() => vi.fn()) -const mockIsGhsaFixed = vi.hoisted(() => vi.fn(() => false)) -const mockMarkGhsaFixed = vi.hoisted(() => vi.fn()) -const mockEnablePrAutoMerge = vi.hoisted(() => vi.fn()) -const mockGetOctokit = vi.hoisted(() => - vi.fn(() => ({ - issues: { createComment: vi.fn() }, - pulls: { update: vi.fn() }, - })), -) -const mockSetGitRemoteGithubRepoUrl = vi.hoisted(() => vi.fn()) -const mockReadJsonSync = vi.hoisted(() => vi.fn()) -const mockSafeDelete = vi.hoisted(() => vi.fn()) -const mockReadFile = vi.hoisted(() => vi.fn()) -const mockWriteFile = vi.hoisted(() => vi.fn()) -const mockLogPrEvent = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mjs', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, -})) - -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) - -vi.mock('../../../../src/util/fs/path-resolve.mjs', () => ({ - getPackageFilesForScan: mockGetPackageFilesForScan, -})) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/commands/fix/env-helpers.mts', () => ({ - checkCiEnvVars: mockCheckCiEnvVars, - getCiEnvInstructions: vi.fn(() => 'Set CI env vars'), - getFixEnv: mockGetFixEnv, -})) - -vi.mock('../../../../src/commands/fix/pull-request.mts', () => ({ - cleanupSocketFixPrs: mockCleanupSocketFixPrs, - getSocketFixPrs: mockGetSocketFixPrs, - openSocketFixPr: mockOpenSocketFixPr, -})) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - enablePrAutoMerge: mockEnablePrAutoMerge, - fetchGhsaDetails: mockFetchGhsaDetails, - getOctokit: mockGetOctokit, - setGitRemoteGithubRepoUrl: mockSetGitRemoteGithubRepoUrl, -})) - -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - gitCheckoutBranch: mockGitCheckoutBranch, - gitCommit: mockGitCommit, - gitCreateBranch: mockGitCreateBranch, - gitPushBranch: mockGitPushBranch, - gitRemoteBranchExists: mockGitRemoteBranchExists, - gitResetAndClean: mockGitResetAndClean, - gitUnstagedModifiedFiles: mockGitUnstagedModifiedFiles, -})) - -vi.mock('../../../../src/commands/fix/branch-cleanup.mts', () => ({ - cleanupErrorBranches: mockCleanupErrorBranches, - cleanupFailedPrBranches: mockCleanupFailedPrBranches, - cleanupStaleBranch: mockCleanupStaleBranch, - cleanupSuccessfulPrLocalBranch: mockCleanupSuccessfulPrLocalBranch, -})) - -vi.mock('../../../../src/commands/fix/ghsa-tracker.mts', () => ({ - isGhsaFixed: mockIsGhsaFixed, - markGhsaFixed: mockMarkGhsaFixed, -})) - -vi.mock('../../../../src/commands/fix/pr-lifecycle-logger.mts', () => ({ - logPrEvent: mockLogPrEvent, -})) - -vi.mock('@socketsecurity/lib-stable/fs/read-json', () => ({ - readJsonSync: mockReadJsonSync, -})) -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, -})) -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - safeReadFileSync: vi.fn(() => undefined), -})) - -vi.mock('node:fs', async () => { - const actual = (await vi.importActual('node:fs')) as Record< - string, - unknown - > & { promises: Record<string, unknown> } - return { - ...actual, - default: actual, - promises: { - ...actual.promises, - readFile: mockReadFile, - writeFile: mockWriteFile, - }, - } -}) - -const baseConfig: FixConfig = { - all: false, - applyFixes: true, - autopilot: false, - coanaVersion: undefined, - cwd: '/test/cwd', - debug: false, - disableExternalToolChecks: false, - disableMajorUpdates: false, - ecosystems: [], - exclude: [], - ghsas: [], - include: [], - minSatisfying: false, - minimumReleaseAge: '', - orgSlug: 'test-org', - outputFile: '', - outputKind: 'text', - prCheck: true, - prLimit: 10, - rangeStyle: 'preserve', - showAffectedDirectDependencies: false, - spinner: undefined, - unknownFlags: [], -} as unknown as FixConfig - -const ciFixEnv = { - baseBranch: 'main', - gitEmail: 'bot@example.com', - gitUser: 'socket-bot', - githubToken: 'gh-token', - isCi: true, - repoInfo: { owner: 'org', repo: 'repo' }, -} - -function setupHappyDefaults() { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: { uploadManifestFiles: vi.fn() }, - }) - mockFetchSupportedScanFileNames.mockResolvedValue({ - ok: true, - data: ['package.json'], - }) - mockGetPackageFilesForScan.mockResolvedValue(['/test/cwd/package.json']) - mockHandleApiCall.mockResolvedValue({ - ok: true, - data: { tarHash: 'hash' }, - }) - mockGetFixEnv.mockResolvedValue({ - githubToken: '', - gitEmail: '', - gitUser: '', - isCi: false, - repoInfo: undefined, - }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ ok: true, data: [] }) - mockGetSocketFixPrs.mockResolvedValue([]) - mockFetchGhsaDetails.mockResolvedValue(new Map()) - mockIsGhsaFixed.mockResolvedValue(false) - mockSafeDelete.mockResolvedValue(undefined) - mockReadFile.mockResolvedValue('payload') - mockWriteFile.mockResolvedValue(undefined) - mockCheckCiEnvVars.mockReturnValue({ missing: [], present: [] }) -} - -describe('coanaFix (coverage)', () => { - beforeEach(() => { - vi.resetAllMocks() - setupHappyDefaults() - }) - - describe('local mode info messages', () => { - it('logs the partial-missing CI env info when some present and some missing', async () => { - mockCheckCiEnvVars.mockReturnValue({ - missing: ['SOCKET_CLI_GITHUB_TOKEN'], - present: ['GITHUB_REPOSITORY'], - }) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - expect(result.ok).toBe(true) - }) - - it('writes the local-mode output file when outputFile is provided', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) - mockReadFile.mockResolvedValueOnce('{"fixed": true}') - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - outputFile: '/tmp/out.json', - }) - - expect(mockReadFile).toHaveBeenCalled() - expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/out.json', - '{"fixed": true}', - 'utf8', - ) - expect(result.ok).toBe(true) - }) - - it('returns the spawnCoanaDlx error in local mode', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: false, - message: 'coana failed', - cause: 'no coana available', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('coana failed') - } - }) - - it('returns empty result when prLimit is 0 in local mode', async () => { - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - prLimit: 0, - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.fixedAll).toBe(false) - expect(result.data.ghsaDetails).toEqual([]) - } - }) - }) - - describe('CI mode discovery', () => { - beforeEach(() => { - mockGetFixEnv.mockResolvedValue(ciFixEnv) - }) - - it('uses getSocketFixPrs catch path when counting open PRs throws', async () => { - // First getSocketFixPrs throws. - mockGetSocketFixPrs.mockRejectedValueOnce(new Error('GH down')) - // For the discovery later (shouldSpawnCoana && discover) — return ids. - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: '["GHSA-AAAA-BBBB-CCCC"]', - }) - // For subsequent fix per id. - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'applied' }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: [], - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 3, - }) - expect(result.ok).toBe(true) - }) - - it('handles non-array JSON from find-vulnerabilities (throws inside try)', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: '{"not": "an array"}', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - // No ids discovered → returns ok with empty results. - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.ghsaDetails).toEqual([]) - } - }) - - it('handles invalid JSON from find-vulnerabilities (parse error)', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: 'not json at all', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.ghsaDetails).toEqual([]) - } - }) - - it('handles empty output from find-vulnerabilities', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: '', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - expect(result.ok).toBe(true) - }) - - it('handles spawnCoanaDlx throwing during discovery', async () => { - mockSpawnCoanaDlx.mockRejectedValueOnce(new Error('boom')) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - expect(result.ok).toBe(true) - }) - - it('slices explicit ghsa list when shouldSpawnCoana and not discovering', async () => { - // CI mode + explicit ghsas (not 'all') → goes through the - // `else if (shouldSpawnCoana)` branch (line 351). - // The fix call runs once per id (no discovery first). - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'applied' }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: [], - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('skips processing when discovery returns ok:false', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: false, - message: 'discovery failed', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.ghsaDetails).toEqual([]) - } - }) - - it('returns empty result when repoInfo is undefined in CI mode-like config', async () => { - // Force repoInfo undefined but isCi false; shouldOpenPrs = false. - mockGetFixEnv.mockResolvedValueOnce({ - ...ciFixEnv, - repoInfo: undefined, - }) - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: 'applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - // Falls through to local mode (shouldOpenPrs=false). - expect(result.ok).toBe(true) - }) - }) - - describe('CI mode per-GHSA PR creation paths', () => { - beforeEach(() => { - mockGetFixEnv.mockResolvedValue(ciFixEnv) - // discovery call returns one id; subsequent calls are fix calls. - mockSpawnCoanaDlx.mockReset() - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: '["GHSA-AAAA-BBBB-CCCC"]', - }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: ['package.json'], - }) - }) - - it('logs skipped count when already-fixed GHSAs are filtered', async () => { - mockIsGhsaFixed.mockResolvedValueOnce(true) // first ghsa already fixed - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'applied' }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - // No unprocessed ids → loop body never runs. - expect(result.ok).toBe(true) - }) - - it('logs cleanup PRs debug when cleanupSocketFixPrs returns cleaned items', async () => { - mockCleanupSocketFixPrs.mockResolvedValueOnce([{ number: 99 }]) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'applied' }) - // gitUnstagedModifiedFiles -> no modified files → continue - mockGitUnstagedModifiedFiles.mockResolvedValue({ ok: true, data: [] }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupSocketFixPrs).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('continues when cleanupSocketFixPrs throws', async () => { - mockCleanupSocketFixPrs.mockRejectedValueOnce(new Error('GH down')) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'applied' }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('continues when per-id spawnCoanaDlx fails', async () => { - // First fix call (per-id) fails. - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: false, - message: 'fix failed', - cause: 'reason', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.fixedAll).toBe(false) - } - }) - - it('continues when no files were modified after the fix', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ ok: true, data: [] }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.fixedAll).toBe(false) - } - }) - - it('continues with empty modified files when gitUnstagedModifiedFiles returns ok:false', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: false, - message: 'git failed', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('closes superseded PRs before creating a new one', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - // 1st call (counting open PRs at top of CI mode) returns empty. - // 2nd call (existingPrs for this ghsa) returns [PR#1, PR#2]. - // 3rd call (existingOpenPrs after closing) returns empty. - mockGetSocketFixPrs - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ number: 1 }, { number: 2 }]) - .mockResolvedValueOnce([]) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: true, - pr: { - data: { - number: 42, - html_url: 'https://gh.test/pr/42', - }, - }, - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockLogPrEvent).toHaveBeenCalledWith( - 'superseded', - 1, - 'GHSA-AAAA-BBBB-CCCC', - ) - expect(mockLogPrEvent).toHaveBeenCalledWith( - 'created', - 42, - 'GHSA-AAAA-BBBB-CCCC', - 'https://gh.test/pr/42', - ) - expect(result.ok).toBe(true) - }) - - it('continues when closing a superseded PR throws', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - // 1st call (counting): [], 2nd call (existing for ghsa): [{7}], 3rd: [] - mockGetSocketFixPrs - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ number: 7 }]) - .mockResolvedValueOnce([]) - const flakyOctokit = { - issues: { - createComment: vi.fn().mockRejectedValueOnce(new Error('boom')), - }, - pulls: { update: vi.fn() }, - } - mockGetOctokit.mockReturnValueOnce(flakyOctokit) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: true, - pr: { data: { number: 50, html_url: 'u' } }, - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('skips ghsaId when an open PR already exists', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - // 1st: counting (empty); 2nd: existingPrs (empty so superseded loop - // skipped); 3rd: existingOpenPrs (one entry → skip with continue). - mockGetSocketFixPrs - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ number: 88 }]) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('cleans up stale branch and continues when cleanupStaleBranch returns false', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGetSocketFixPrs.mockResolvedValue([]) - mockGitRemoteBranchExists.mockResolvedValueOnce(true) - mockCleanupStaleBranch.mockResolvedValueOnce(false) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupStaleBranch).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('errors and continues when githubToken is missing', async () => { - mockGetFixEnv.mockResolvedValueOnce({ - ...ciFixEnv, - githubToken: '', - }) - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('cleans up branches and continues when push fails', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGitPushBranch.mockResolvedValueOnce(false) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupErrorBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles gitRemoteBranchExists throwing inside push-failure cleanup', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGitPushBranch.mockResolvedValueOnce(false) - // gitRemoteBranchExists may be called twice: stale-branch check + cleanup - // (after push failure). Throw on the second call. - mockGitRemoteBranchExists - .mockResolvedValueOnce(false) - .mockRejectedValueOnce(new Error('stat fail')) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('opens PR successfully and enables autopilot auto-merge', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: true, - pr: { - data: { number: 7, html_url: 'https://gh.test/pr/7' }, - }, - }) - mockEnablePrAutoMerge.mockResolvedValueOnce({ - enabled: true, - details: undefined, - }) - - const result = await coanaFix({ - ...baseConfig, - autopilot: true, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockEnablePrAutoMerge).toHaveBeenCalled() - expect(mockMarkGhsaFixed).toHaveBeenCalled() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.fixedAll).toBe(true) - } - }) - - it('opens PR successfully and logs auto-merge failure', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: true, - pr: { - data: { number: 7, html_url: 'https://gh.test/pr/7' }, - }, - }) - mockEnablePrAutoMerge.mockResolvedValueOnce({ - enabled: false, - details: ['Branch not protected'], - }) - - const result = await coanaFix({ - ...baseConfig, - autopilot: true, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('logs auto-merge failure with no details (undefined)', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: true, - pr: { data: { number: 7, html_url: 'u' } }, - }) - mockEnablePrAutoMerge.mockResolvedValueOnce({ - enabled: false, - details: undefined, - }) - - const result = await coanaFix({ - ...baseConfig, - autopilot: true, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('handles PR creation failure reason=already_exists', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: false, - reason: 'already_exists', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - - it('handles PR creation failure reason=validation_error', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: false, - reason: 'validation_error', - details: 'bad body', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupFailedPrBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles PR creation failure reason=permission_denied', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: false, - reason: 'permission_denied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupFailedPrBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles PR creation failure reason=network_error', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: false, - reason: 'network_error', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupFailedPrBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles PR creation failure with unknown reason and error.message', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockOpenSocketFixPr.mockResolvedValueOnce({ - ok: false, - reason: 'mystery', - error: { message: 'mystery error' }, - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupFailedPrBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles unexpected exception inside the per-id try block', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - // Make getSocketFixPrs throw inside the loop body's try block. - mockGetSocketFixPrs.mockReset() - // First call (the one outside the loop counting open PRs) returns empty. - mockGetSocketFixPrs.mockResolvedValueOnce([]) - // Second call is inside the loop's try — throw to trigger the catch. - mockGetSocketFixPrs.mockRejectedValueOnce(new Error('inner boom')) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(mockCleanupErrorBranches).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('handles cleanupErrorBranches throwing during exception cleanup', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: 'applied' }) - mockGetSocketFixPrs.mockReset() - mockGetSocketFixPrs.mockResolvedValueOnce([]) - mockGetSocketFixPrs.mockRejectedValueOnce(new Error('inner boom')) - mockCleanupErrorBranches.mockRejectedValueOnce(new Error('cleanup fail')) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 1, - }) - expect(result.ok).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/env-helpers.test.mts b/packages/cli/test/unit/commands/fix/env-helpers.test.mts deleted file mode 100644 index cf1d41504..000000000 --- a/packages/cli/test/unit/commands/fix/env-helpers.test.mts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Unit Tests: CI Environment Variable Helper Functions. - * - * Purpose: Tests environment variable instruction generation for CI-based - * automated fix workflows. Validates that the helper functions correctly format - * and document required environment variables for enabling automatic pull - * request creation in CI environments. - * - * Test Coverage: - Environment variable instruction generation with exact var - * names - Instruction formatting and consistency validation - CI environment - * variable checking. - * - * Testing Approach: Uses direct function invocation without mocks since - * env-helpers.mts provides pure instruction generation functions. Tests verify - * string output format and content. Actual environment variable checking is - * tested via integration tests. - * - * Related Files: - src/commands/fix/env-helpers.mts - Environment variable - * helper functions - src/commands/fix/handle-fix.mts - Main fix command handler - * that uses env helpers. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock @socketsecurity/lib/env/ci. -const mockGetCI = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/env/ci', () => ({ - getCI: mockGetCI, -})) - -// Mock @socketsecurity/lib/env/socket-cli. -const mockGetSocketCliGithubToken = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/env/socket-cli', async importOriginal => { - const actual = (await importOriginal()) as typeof SocketCliModule - return { - ...actual, - getSocketCliGithubToken: mockGetSocketCliGithubToken, - } -}) - -// Mock SOCKET_CLI_GIT_USER_EMAIL. -const mockGitEmail = vi.hoisted(() => ({ SOCKET_CLI_GIT_USER_EMAIL: '' })) -vi.mock('../../../../src/env/socket-cli-git-user-email.mts', () => mockGitEmail) - -// Mock SOCKET_CLI_GIT_USER_NAME. -const mockGitUser = vi.hoisted(() => ({ SOCKET_CLI_GIT_USER_NAME: '' })) -vi.mock('../../../../src/env/socket-cli-git-user-name.mts', () => mockGitUser) - -// Mock GITHUB_REPOSITORY. -const mockGithubRepo = vi.hoisted(() => ({ GITHUB_REPOSITORY: '' })) -vi.mock('../../../../src/env/github-repository.mts', () => mockGithubRepo) - -// Mock git operations. -const mockGetBaseBranch = vi.hoisted(() => vi.fn().mockResolvedValue('main')) -const mockGetRepoInfo = vi.hoisted(() => - vi.fn().mockResolvedValue({ owner: 'test-owner', repo: 'test-repo' }), -) -vi.mock('../../../../src/util/git/operations.mts', () => ({ - getBaseBranch: mockGetBaseBranch, - getRepoInfo: mockGetRepoInfo, -})) - -// Mock pull-request functions. -const mockGetSocketFixPrs = vi.hoisted(() => vi.fn().mockResolvedValue([])) -vi.mock('../../../../src/commands/fix/pull-request.mts', () => ({ - getSocketFixPrs: mockGetSocketFixPrs, -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock debug. -const mockDebug = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn(() => false)) -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -import { - checkCiEnvVars, - getCiEnvInstructions, - getFixEnv, -} from '../../../../src/commands/fix/env-helpers.mts' - -import type * as SocketCliModule from '@socketsecurity/lib-stable/env/socket-cli' - -describe('env-helpers', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetCI.mockReturnValue(false) - mockGetSocketCliGithubToken.mockReturnValue(undefined) - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = '' - mockGitUser.SOCKET_CLI_GIT_USER_NAME = '' - mockGithubRepo.GITHUB_REPOSITORY = '' - mockGetBaseBranch.mockResolvedValue('main') - mockGetRepoInfo.mockResolvedValue({ - owner: 'test-owner', - repo: 'test-repo', - }) - mockGetSocketFixPrs.mockResolvedValue([]) - mockIsDebug.mockReturnValue(false) - }) - - describe('getCiEnvInstructions', () => { - it('should return instructions with exact env var names', () => { - const instructions = getCiEnvInstructions() - - // Check that exact env var names appear in instructions. - expect(instructions).toContain('CI=1') - expect(instructions).toContain('SOCKET_CLI_GITHUB_TOKEN') - expect(instructions).toContain('SOCKET_CLI_GIT_USER_NAME') - expect(instructions).toContain('SOCKET_CLI_GIT_USER_EMAIL') - }) - - it('should format env var names consistently', () => { - const instructions = getCiEnvInstructions() - const lines = instructions.split('\n') - - // First line is intro text. - expect(lines[0]).toContain('To enable automatic pull request creation') - - // Check that each env var line contains the env var name. - expect(lines[1]).toContain('CI=1') - expect(lines[2]).toContain('SOCKET_CLI_GITHUB_TOKEN=') - expect(lines[3]).toContain('SOCKET_CLI_GIT_USER_NAME=') - expect(lines[4]).toContain('SOCKET_CLI_GIT_USER_EMAIL=') - }) - }) - - describe('checkCiEnvVars', () => { - it('should return all missing when no env vars are set', () => { - mockGetCI.mockReturnValue(false) - mockGetSocketCliGithubToken.mockReturnValue(undefined) - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = '' - mockGitUser.SOCKET_CLI_GIT_USER_NAME = '' - - const result = checkCiEnvVars() - - expect(result.missing).toHaveLength(4) - expect(result.present).toHaveLength(0) - expect(result.missing).toContain('CI') - expect(result.missing).toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(result.missing).toContain('SOCKET_CLI_GIT_USER_NAME') - expect(result.missing).toContain( - 'SOCKET_CLI_GITHUB_TOKEN (or GITHUB_TOKEN)', - ) - }) - - it('should return CI as present when in CI environment', () => { - mockGetCI.mockReturnValue(true) - - const result = checkCiEnvVars() - - expect(result.present).toContain('CI') - expect(result.missing).not.toContain('CI') - }) - - it('should return GitHub token as present when set', () => { - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - - const result = checkCiEnvVars() - - expect(result.present).toContain( - 'SOCKET_CLI_GITHUB_TOKEN (or GITHUB_TOKEN)', - ) - expect(result.missing).not.toContain( - 'SOCKET_CLI_GITHUB_TOKEN (or GITHUB_TOKEN)', - ) - }) - - it('should return git user name as present when set', () => { - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - - const result = checkCiEnvVars() - - expect(result.present).toContain('SOCKET_CLI_GIT_USER_NAME') - expect(result.missing).not.toContain('SOCKET_CLI_GIT_USER_NAME') - }) - - it('should return git email as present when set', () => { - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - - const result = checkCiEnvVars() - - expect(result.present).toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(result.missing).not.toContain('SOCKET_CLI_GIT_USER_EMAIL') - }) - - it('should return all present when all env vars are set', () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - - const result = checkCiEnvVars() - - expect(result.missing).toHaveLength(0) - expect(result.present).toHaveLength(4) - }) - }) - - describe('getFixEnv', () => { - it('should return basic fix env when not in CI', async () => { - mockGetCI.mockReturnValue(false) - - const result = await getFixEnv() - - expect(result.isCi).toBe(false) - expect(result.baseBranch).toBe('main') - expect(result.prs).toEqual([]) - expect(result.repoInfo).toEqual({ - owner: 'test-owner', - repo: 'test-repo', - }) - }) - - it('should return isCi true when all CI vars are set', async () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - mockGithubRepo.GITHUB_REPOSITORY = 'owner/repo' - - const result = await getFixEnv() - - expect(result.isCi).toBe(true) - expect(result.gitUser).toBe('test-user') - expect(result.gitEmail).toBe('test@example.com') - expect(result.githubToken).toBe('ghp_test_token') - }) - - it('should use GITHUB_REPOSITORY env var for repoInfo in CI', async () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - mockGithubRepo.GITHUB_REPOSITORY = 'my-owner/my-repo' - - const result = await getFixEnv() - - expect(result.repoInfo).toEqual({ owner: 'my-owner', repo: 'my-repo' }) - // Should not call getRepoInfo when GITHUB_REPOSITORY is valid. - expect(mockGetRepoInfo).not.toHaveBeenCalled() - }) - - it('should fall back to getRepoInfo when GITHUB_REPOSITORY is invalid', async () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - // Invalid GITHUB_REPOSITORY (no slash). - mockGithubRepo.GITHUB_REPOSITORY = 'invalid-repo' - - const result = await getFixEnv() - - expect(result.repoInfo).toEqual({ - owner: 'test-owner', - repo: 'test-repo', - }) - expect(mockGetRepoInfo).toHaveBeenCalled() - }) - - it('should fall back to getRepoInfo when GITHUB_REPOSITORY is empty', async () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - mockGithubRepo.GITHUB_REPOSITORY = '' - - const result = await getFixEnv() - - expect(mockGetRepoInfo).toHaveBeenCalled() - }) - - it('should warn when CI is set but other vars are missing', async () => { - mockGetCI.mockReturnValue(true) - // Missing: githubToken, gitUser, gitEmail. - mockGetSocketCliGithubToken.mockReturnValue(undefined) - mockGitUser.SOCKET_CLI_GIT_USER_NAME = '' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = '' - - await getFixEnv() - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('CI mode detected'), - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Missing:'), - ) - }) - - it('should not warn when not in CI', async () => { - mockGetCI.mockReturnValue(false) - - await getFixEnv() - - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('should log debug message when not in CI but some vars are set', async () => { - mockGetCI.mockReturnValue(false) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockIsDebug.mockReturnValue(true) - - await getFixEnv() - - expect(mockDebug).toHaveBeenCalledWith( - expect.stringContaining('isCi is false'), - ) - }) - - it('should fetch PRs when in CI mode', async () => { - mockGetCI.mockReturnValue(true) - mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token') - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com' - mockGithubRepo.GITHUB_REPOSITORY = 'owner/repo' - mockGetSocketFixPrs.mockResolvedValue([{ number: 1, title: 'Fix PR' }]) - - const result = await getFixEnv() - - expect(mockGetSocketFixPrs).toHaveBeenCalledWith('owner', 'repo', { - author: 'test-user', - states: 'all', - }) - expect(result.prs).toEqual([{ number: 1, title: 'Fix PR' }]) - }) - - it('should not fetch PRs when not in CI mode', async () => { - mockGetCI.mockReturnValue(false) - - await getFixEnv() - - expect(mockGetSocketFixPrs).not.toHaveBeenCalled() - }) - - it('should return gitEmail and gitUser from env vars', async () => { - mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'custom-user' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'custom@example.com' - - const result = await getFixEnv() - - expect(result.gitUser).toBe('custom-user') - expect(result.gitEmail).toBe('custom@example.com') - }) - - it('should return undefined for gitEmail and gitUser when not set', async () => { - mockGitUser.SOCKET_CLI_GIT_USER_NAME = '' - mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = '' - - const result = await getFixEnv() - - expect(result.gitUser).toBe('') - expect(result.gitEmail).toBe('') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/ghsa-tracker.test.mts b/packages/cli/test/unit/commands/fix/ghsa-tracker.test.mts deleted file mode 100644 index 99a2d999c..000000000 --- a/packages/cli/test/unit/commands/fix/ghsa-tracker.test.mts +++ /dev/null @@ -1,508 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit Tests: GHSA Fix Tracker Persistence Module. - * - * Purpose: Tests the GHSA fix tracker system that maintains a persistent record - * of fixed GitHub Security Advisories in .socket/fixed-ghsas.json. Validates - * tracker loading, saving, querying, and updating operations to ensure the fix - * command can track which vulnerabilities have already been addressed. - * - * Test Coverage: - Loading existing tracker files and creating new trackers on - * first run - Saving tracker data with proper directory creation - Marking - * GHSAs as fixed with automatic deduplication - Querying fixed GHSA status - - * Retrieving all fixed GHSA records - Error handling for file system failures - - * Tracker record sorting by timestamp. - * - * Testing Approach: Mocks @socketsecurity/lib/fs functions (readJson, - * writeJson, safeMkdir) to test tracker operations without actual file I/O. - * Tests verify correct file paths, data structures, and error recovery - * behavior. - * - * Related Files: - src/commands/fix/ghsa-tracker.mts - GHSA tracker persistence - * module - src/commands/fix/handle-fix.mts - Main fix command using tracker - - * src/commands/fix/pull-request.mts - PR creation using tracker data. - */ - -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - isGhsaFixed, - isPidAlive, - loadGhsaTracker, - markGhsaFixed, - saveGhsaTracker, -} from '../../../../src/commands/fix/ghsa-tracker.mts' - -import type { GhsaTracker } from '../../../../src/commands/fix/ghsa-tracker.mts' - -import type * as FsModule from 'node:fs' - -// Mock file system operations. -const mockReadJson = vi.hoisted(() => vi.fn()) -const mockSafeDelete = vi.hoisted(() => vi.fn()) -const mockSafeMkdir = vi.hoisted(() => vi.fn()) -const mockWriteJson = vi.hoisted(() => vi.fn()) - -// Mock fs promises. -const mockFsWriteFile = vi.hoisted(() => vi.fn()) -const mockFsReadFile = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', async () => { - const actual = await vi.importActual<typeof FsModule>('node:fs') - return { - ...actual, - promises: { - ...actual.promises, - mkdir: vi.fn(), - readFile: mockFsReadFile, - writeFile: mockFsWriteFile, - }, - } -}) - -vi.mock('@socketsecurity/lib-stable/fs/read-json', () => ({ - readJson: mockReadJson, -})) -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, - safeMkdir: mockSafeMkdir, -})) -vi.mock('@socketsecurity/lib-stable/fs/write-json', () => ({ - writeJson: mockWriteJson, -})) - -describe('ghsa-tracker', () => { - describe('isPidAlive', () => { - it('returns true for the current process', () => { - // process.kill(self, 0) is a no-op that succeeds when the process exists. - expect(isPidAlive(process.pid)).toBe(true) - }) - - it('returns false for a PID that does not exist', () => { - // PID 0 / very large PID throws ESRCH (no such process). - expect(isPidAlive(2 ** 22)).toBe(false) - }) - - it('returns true when process.kill throws EPERM (alive but no permission)', () => { - const original = process.kill - ;(process as unknown).kill = () => { - const e = new Error('Operation not permitted') as NodeJS.ErrnoException - e.code = 'EPERM' - throw e - } - try { - expect(isPidAlive(1)).toBe(true) - } finally { - ;(process as unknown).kill = original - } - }) - - it('returns false when process.kill throws non-EPERM (e.g. EINVAL)', () => { - const original = process.kill - ;(process as unknown).kill = () => { - const e = new Error('Invalid') as NodeJS.ErrnoException - e.code = 'EINVAL' - throw e - } - try { - expect(isPidAlive(1)).toBe(false) - } finally { - ;(process as unknown).kill = original - } - }) - }) - - const mockCwd = '/test/repo' - const trackerPath = path.join(mockCwd, '.socket/fixed-ghsas.json') - - beforeEach(() => { - vi.clearAllMocks() - // Default: lock file creation succeeds. - mockFsWriteFile.mockResolvedValue(undefined) - mockFsReadFile.mockResolvedValue('12345') - mockSafeDelete.mockResolvedValue(undefined) - }) - - describe('loadGhsaTracker', () => { - it('loads existing tracker file', async () => { - const { readJson } = await import('@socketsecurity/lib-stable/fs/read-json') - const mockTracker: GhsaTracker = { - version: 1, - fixed: [ - { - ghsaId: 'GHSA-1234-5678-90ab', - fixedAt: '2025-01-01T00:00:00Z', - prNumber: 123, - branch: 'socket/fix/GHSA-1234-5678-90ab', - }, - ], - } - - mockReadJson.mockResolvedValue(mockTracker) - - const result = await loadGhsaTracker(mockCwd) - - expect(result).toEqual(mockTracker) - expect(readJson).toHaveBeenCalledWith(trackerPath) - }) - - it('creates new tracker when file does not exist', async () => { - mockReadJson.mockRejectedValue(new Error('ENOENT')) - - const result = await loadGhsaTracker(mockCwd) - - expect(result).toEqual({ - version: 1, - fixed: [], - }) - }) - - it('handles null tracker data', async () => { - mockReadJson.mockResolvedValue(undefined) - - const result = await loadGhsaTracker(mockCwd) - - expect(result).toEqual({ - version: 1, - fixed: [], - }) - }) - }) - - describe('saveGhsaTracker', () => { - it('saves tracker to file', async () => { - const { safeMkdir } = await import('@socketsecurity/lib-stable/fs/safe') - const { writeJson } = await import('@socketsecurity/lib-stable/fs/write-json') - const tracker: GhsaTracker = { - version: 1, - fixed: [ - { - ghsaId: 'GHSA-1234-5678-90ab', - fixedAt: '2025-01-01T00:00:00Z', - prNumber: 123, - branch: 'socket/fix/GHSA-1234-5678-90ab', - }, - ], - } - - await saveGhsaTracker(mockCwd, tracker) - - expect(safeMkdir).toHaveBeenCalledWith(path.dirname(trackerPath), { - recursive: true, - }) - expect(writeJson).toHaveBeenCalledWith(trackerPath, tracker, { - spaces: 2, - }) - }) - }) - - describe('markGhsaFixed', () => { - it('adds new GHSA fix record', async () => { - const { writeJson } = await import('@socketsecurity/lib-stable/fs/write-json') - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab', 123) - - expect(writeJson).toHaveBeenCalledWith( - trackerPath, - expect.objectContaining({ - version: 1, - fixed: expect.arrayContaining([ - expect.objectContaining({ - ghsaId: 'GHSA-1234-5678-90ab', - prNumber: 123, - branch: 'socket/fix/GHSA-1234-5678-90ab', - }), - ]), - }), - { spaces: 2 }, - ) - }) - - it('replaces existing GHSA fix record', async () => { - const { writeJson } = await import('@socketsecurity/lib-stable/fs/write-json') - const existingTracker: GhsaTracker = { - version: 1, - fixed: [ - { - ghsaId: 'GHSA-1234-5678-90ab', - fixedAt: '2025-01-01T00:00:00Z', - prNumber: 100, - branch: 'socket/fix/GHSA-1234-5678-90ab', - }, - ], - } - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab', 200) - - expect(writeJson).toHaveBeenCalledWith( - trackerPath, - expect.objectContaining({ - version: 1, - fixed: [ - expect.objectContaining({ - ghsaId: 'GHSA-1234-5678-90ab', - prNumber: 200, - }), - ], - }), - { spaces: 2 }, - ) - - // Verify only one record exists (old one was removed). - const savedTracker = mockWriteJson.mock.calls[0]![1] as GhsaTracker - expect(savedTracker.fixed).toHaveLength(1) - }) - - it('sorts records by fixedAt descending', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [ - { - ghsaId: 'GHSA-old', - fixedAt: '2025-01-01T00:00:00Z', - prNumber: 100, - branch: 'socket/fix/GHSA-old', - }, - ], - } - - mockReadJson.mockResolvedValue(existingTracker) - - // Add a new record with a later timestamp. - await markGhsaFixed(mockCwd, 'GHSA-new', 200) - - const savedTracker = mockWriteJson.mock.calls[0]![1] as GhsaTracker - expect(savedTracker.fixed[0]!.ghsaId).toBe('GHSA-new') - expect(savedTracker.fixed[1]!.ghsaId).toBe('GHSA-old') - }) - - it('handles errors gracefully', async () => { - mockReadJson.mockRejectedValue(new Error('Permission denied')) - - // Should not throw. - await expect( - markGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab', 123), - ).resolves.toBeUndefined() - }) - }) - - describe('isGhsaFixed', () => { - it('returns true for fixed GHSA', async () => { - const tracker: GhsaTracker = { - version: 1, - fixed: [ - { - ghsaId: 'GHSA-1234-5678-90ab', - fixedAt: '2025-01-01T00:00:00Z', - prNumber: 123, - branch: 'socket/fix/GHSA-1234-5678-90ab', - }, - ], - } - - mockReadJson.mockResolvedValue(tracker) - - const result = await isGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab') - - expect(result).toBe(true) - }) - - it('returns false for unfixed GHSA', async () => { - const tracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(tracker) - - const result = await isGhsaFixed(mockCwd, 'GHSA-9999-9999-9999') - - expect(result).toBe(false) - }) - - it('returns false on error', async () => { - mockReadJson.mockRejectedValue(new Error('Read error')) - - const result = await isGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab') - - expect(result).toBe(false) - }) - - it('returns false when tracker shape is invalid (fixed.some throws)', async () => { - // Resolve to a malformed tracker so .fixed.some() throws. - mockReadJson.mockResolvedValue({ version: 1 } as unknown) - - const result = await isGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab') - - expect(result).toBe(false) - }) - }) - - describe('markGhsaFixed with locking', () => { - it('uses custom branch name when provided', async () => { - const { writeJson } = await import('@socketsecurity/lib-stable/fs/write-json') - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-1234-5678-90ab', 123, 'custom-branch') - - expect(writeJson).toHaveBeenCalledWith( - trackerPath, - expect.objectContaining({ - fixed: expect.arrayContaining([ - expect.objectContaining({ - ghsaId: 'GHSA-1234-5678-90ab', - branch: 'custom-branch', - }), - ]), - }), - { spaces: 2 }, - ) - }) - - it('omits prNumber when not provided', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-no-pr', undefined) - - const savedTracker = mockWriteJson.mock.calls[0]![1] as GhsaTracker - const record = savedTracker.fixed.find(r => r.ghsaId === 'GHSA-no-pr') - expect(record).toBeDefined() - expect(record!.prNumber).toBeUndefined() - }) - - it('handles lock file already exists (EEXIST)', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - // First call to writeFile fails with EEXIST, subsequent succeeds. - const eexistError = new Error('Lock exists') as NodeJS.ErrnoException - eexistError.code = 'EEXIST' - mockFsWriteFile.mockRejectedValueOnce(eexistError) - mockFsWriteFile.mockResolvedValueOnce(undefined) - - // Mock reading lock file to show stale lock (dead process). - mockFsReadFile.mockResolvedValueOnce('99999999') - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-lock-test', 123) - - // Should still save the tracker. - expect(mockWriteJson).toHaveBeenCalled() - }) - - it('handles lock file read error', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - // First call to writeFile fails with EEXIST. - const eexistError = new Error('Lock exists') as NodeJS.ErrnoException - eexistError.code = 'EEXIST' - mockFsWriteFile.mockRejectedValueOnce(eexistError) - mockFsWriteFile.mockResolvedValueOnce(undefined) - - // Mock reading lock file fails. - mockFsReadFile.mockRejectedValueOnce(new Error('Read error')) - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-lock-read-error', 123) - - // Should still save the tracker (proceeds without lock). - expect(mockWriteJson).toHaveBeenCalled() - }) - - it('handles lock file with invalid PID', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - // First call to writeFile fails with EEXIST. - const eexistError = new Error('Lock exists') as NodeJS.ErrnoException - eexistError.code = 'EEXIST' - mockFsWriteFile.mockRejectedValueOnce(eexistError) - mockFsWriteFile.mockResolvedValueOnce(undefined) - - // Mock reading lock file with invalid PID. - mockFsReadFile.mockResolvedValueOnce('not-a-number') - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-invalid-pid', 123) - - // Should proceed anyway. - expect(mockWriteJson).toHaveBeenCalled() - }) - - it('releases lock after successful operation', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-release-lock', 123) - - // Should attempt to delete the lock file. - expect(mockSafeDelete).toHaveBeenCalled() - }) - - it('proceeds without lock when all attempts fail', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - // All lock attempts fail with non-EEXIST error. - mockFsWriteFile.mockRejectedValue(new Error('Permission denied')) - - mockReadJson.mockResolvedValue(existingTracker) - - await markGhsaFixed(mockCwd, 'GHSA-no-lock', 123) - - // Should still save the tracker. - expect(mockWriteJson).toHaveBeenCalled() - }) - - it('swallows write failure inside the inner catch arm', async () => { - const existingTracker: GhsaTracker = { - version: 1, - fixed: [], - } - - mockReadJson.mockResolvedValue(existingTracker) - mockWriteJson.mockRejectedValueOnce(new Error('write failed')) - - // Should not throw — the inner catch logs + swallows. - await expect( - markGhsaFixed(mockCwd, 'GHSA-write-fail', 123), - ).resolves.toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/git.test.mts b/packages/cli/test/unit/commands/fix/git.test.mts deleted file mode 100644 index da073986e..000000000 --- a/packages/cli/test/unit/commands/fix/git.test.mts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Unit tests for fix command git utilities. - * - * Purpose: Tests git-related utilities for the fix command including branch - * naming, commit messages, and PR body generation. - * - * Test Coverage: - createSocketFixBranchParser - getSocketFixBranchName - - * getSocketFixBranchPattern - getSocketFixCommitMessage - - * getSocketFixPullRequestBody - getSocketFixPullRequestTitle. - * - * Related Files: - commands/fix/git.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getSocketFixBranchName, - getSocketFixBranchPattern, - getSocketFixCommitMessage, - getSocketFixPullRequestBody, - getSocketFixPullRequestTitle, - getUniquePackages, -} from '../../../../src/commands/fix/git.mts' - -import type { GhsaDetails } from '../../../../src/util/git/github.mts' - -describe('fix/git utilities', () => { - describe('getUniquePackages', () => { - it('returns unique package names with ecosystems', () => { - const details: GhsaDetails = { - id: 'GHSA-test', - ghsaId: 'GHSA-test', - severity: 'HIGH', - summary: 'Test', - vulnerabilities: { - nodes: [ - { - package: { name: 'a', ecosystem: 'NPM' }, - vulnerableVersionRange: '<1', - }, - { - package: { name: 'b', ecosystem: 'PIP' }, - vulnerableVersionRange: '<2', - }, - ], - }, - } - expect(getUniquePackages(details)).toEqual(['a (NPM)', 'b (PIP)']) - }) - - it('deduplicates identical package+ecosystem pairs', () => { - const details: GhsaDetails = { - id: 'GHSA-test', - ghsaId: 'GHSA-test', - severity: 'HIGH', - summary: 'Test', - vulnerabilities: { - nodes: [ - { - package: { name: 'lodash', ecosystem: 'NPM' }, - vulnerableVersionRange: '<1', - }, - { - package: { name: 'lodash', ecosystem: 'NPM' }, - vulnerableVersionRange: '<2', - }, - ], - }, - } - expect(getUniquePackages(details)).toEqual(['lodash (NPM)']) - }) - - it('treats same name in different ecosystems as distinct', () => { - const details: GhsaDetails = { - id: 'GHSA-test', - ghsaId: 'GHSA-test', - severity: 'LOW', - summary: 'Test', - vulnerabilities: { - nodes: [ - { - package: { name: 'requests', ecosystem: 'PIP' }, - vulnerableVersionRange: '<1', - }, - { - package: { name: 'requests', ecosystem: 'NPM' }, - vulnerableVersionRange: '<1', - }, - ], - }, - } - expect(getUniquePackages(details)).toEqual([ - 'requests (PIP)', - 'requests (NPM)', - ]) - }) - - it('returns empty array when no vulnerabilities', () => { - const details: GhsaDetails = { - id: 'GHSA-test', - ghsaId: 'GHSA-test', - severity: 'LOW', - summary: 'Test', - vulnerabilities: { nodes: [] }, - } - expect(getUniquePackages(details)).toEqual([]) - }) - }) - - describe('getSocketFixBranchName', () => { - it('returns correct branch name format', () => { - expect(getSocketFixBranchName('GHSA-1234-5678-9abc')).toBe( - 'socket/fix/GHSA-1234-5678-9abc', - ) - }) - - it('handles lowercase GHSA IDs', () => { - expect(getSocketFixBranchName('ghsa-abcd-efgh-ijkl')).toBe( - 'socket/fix/ghsa-abcd-efgh-ijkl', - ) - }) - }) - - describe('getSocketFixBranchPattern', () => { - it('returns pattern matching any GHSA ID when no ID provided', () => { - const pattern = getSocketFixBranchPattern() - // RegExp.source escapes forward slashes. - expect(pattern.source).toContain('socket') - expect(pattern.source).toContain('fix') - expect(pattern.source).toContain('(.+)') - }) - - it('returns specific pattern for valid GHSA ID', () => { - const pattern = getSocketFixBranchPattern('GHSA-1234-5678-9abc') - expect(pattern.source).toContain('GHSA-1234-5678-9abc') - }) - - it('escapes special regex characters in invalid GHSA IDs', () => { - const pattern = getSocketFixBranchPattern('test.*pattern') - // Special characters should be escaped. - expect(pattern.source).toContain('test') - expect(pattern.source).toContain('pattern') - // Should not match unescaped patterns. - expect(pattern.test('socket/fix/testABCpattern')).toBe(false) - expect(pattern.test('socket/fix/test.*pattern')).toBe(true) - }) - - it('matches branch names correctly', () => { - const pattern = getSocketFixBranchPattern() - expect(pattern.test('socket/fix/GHSA-1234-5678-9abc')).toBe(true) - expect(pattern.test('other/branch')).toBe(false) - expect(pattern.test('socket/fix/')).toBe(false) - }) - }) - - describe('getSocketFixCommitMessage', () => { - it('returns basic commit message without details', () => { - expect(getSocketFixCommitMessage('GHSA-1234-5678-9abc')).toBe( - 'fix: GHSA-1234-5678-9abc', - ) - }) - - it('includes summary when details provided', () => { - const details: GhsaDetails = { - id: 'GHSA-1234-5678-9abc', - ghsaId: 'GHSA-1234-5678-9abc', - severity: 'HIGH', - summary: 'Prototype pollution vulnerability', - vulnerabilities: { - nodes: [ - { - package: { name: 'lodash', ecosystem: 'NPM' }, - vulnerableVersionRange: '<4.17.21', - }, - ], - }, - } - expect(getSocketFixCommitMessage('GHSA-1234-5678-9abc', details)).toBe( - 'fix: GHSA-1234-5678-9abc - Prototype pollution vulnerability', - ) - }) - - it('handles empty summary', () => { - const details: GhsaDetails = { - id: 'GHSA-1234-5678-9abc', - ghsaId: 'GHSA-1234-5678-9abc', - severity: 'MODERATE', - summary: '', - vulnerabilities: { nodes: [] }, - } - expect(getSocketFixCommitMessage('GHSA-1234-5678-9abc', details)).toBe( - 'fix: GHSA-1234-5678-9abc', - ) - }) - }) - - describe('getSocketFixPullRequestTitle', () => { - it('returns single GHSA title', () => { - expect(getSocketFixPullRequestTitle(['GHSA-1234-5678-9abc'])).toBe( - 'Fix for GHSA-1234-5678-9abc', - ) - }) - - it('returns multiple GHSAs title', () => { - expect( - getSocketFixPullRequestTitle([ - 'GHSA-1111-2222-3333', - 'GHSA-4444-5555-6666', - ]), - ).toBe('Fixes for 2 GHSAs') - }) - - it('handles many GHSAs', () => { - expect( - getSocketFixPullRequestTitle([ - 'GHSA-aaaa-bbbb-cccc', - 'GHSA-dddd-eeee-ffff', - 'GHSA-gggg-hhhh-iiii', - 'GHSA-jjjj-kkkk-llll', - ]), - ).toBe('Fixes for 4 GHSAs') - }) - }) - - describe('getSocketFixPullRequestBody', () => { - it('returns basic body for single GHSA without details', () => { - const body = getSocketFixPullRequestBody(['GHSA-1234-5678-9abc']) - expect(body).toContain('Socket') - expect(body).toContain('GHSA-1234-5678-9abc') - expect(body).toContain( - 'https://github.com/advisories/GHSA-1234-5678-9abc', - ) - }) - - it('includes vulnerability details for single GHSA', () => { - const details = new Map<string, GhsaDetails>([ - [ - 'GHSA-1234-5678-9abc', - { - id: 'GHSA-1234-5678-9abc', - ghsaId: 'GHSA-1234-5678-9abc', - severity: 'HIGH', - summary: 'Remote code execution', - vulnerabilities: { - nodes: [ - { - package: { name: 'evil-package', ecosystem: 'NPM' }, - vulnerableVersionRange: '<1.0.0', - }, - ], - }, - }, - ], - ]) - const body = getSocketFixPullRequestBody(['GHSA-1234-5678-9abc'], details) - expect(body).toContain('**Vulnerability Summary:** Remote code execution') - expect(body).toContain('**Severity:** HIGH') - expect(body).toContain('**Affected Packages:** evil-package (NPM)') - }) - - it('returns list format for multiple GHSAs', () => { - const body = getSocketFixPullRequestBody([ - 'GHSA-1111-2222-3333', - 'GHSA-4444-5555-6666', - ]) - expect(body).toContain('fixes for 2 GHSAs') - expect(body).toContain('**Fixed Vulnerabilities:**') - expect(body).toContain('- [GHSA-1111-2222-3333]') - expect(body).toContain('- [GHSA-4444-5555-6666]') - }) - - it('includes details for multiple GHSAs when available', () => { - const details = new Map<string, GhsaDetails>([ - [ - 'GHSA-1111-2222-3333', - { - id: 'GHSA-1111-2222-3333', - ghsaId: 'GHSA-1111-2222-3333', - severity: 'CRITICAL', - summary: 'SQL injection', - vulnerabilities: { - nodes: [ - { - package: { name: 'sql-lib', ecosystem: 'NPM' }, - vulnerableVersionRange: '*', - }, - ], - }, - }, - ], - ]) - const body = getSocketFixPullRequestBody( - ['GHSA-1111-2222-3333', 'GHSA-4444-5555-6666'], - details, - ) - expect(body).toContain('SQL injection') - expect(body).toContain('sql-lib (NPM)') - // Second GHSA has no details. - expect(body).toContain('- [GHSA-4444-5555-6666]') - }) - - it('deduplicates packages in single GHSA', () => { - const details = new Map<string, GhsaDetails>([ - [ - 'GHSA-1234-5678-9abc', - { - id: 'GHSA-1234-5678-9abc', - ghsaId: 'GHSA-1234-5678-9abc', - severity: 'MODERATE', - summary: 'Test vuln', - vulnerabilities: { - nodes: [ - { - package: { name: 'pkg', ecosystem: 'NPM' }, - vulnerableVersionRange: '<1.0.0', - }, - { - package: { name: 'pkg', ecosystem: 'NPM' }, - vulnerableVersionRange: '<2.0.0', - }, - ], - }, - }, - ], - ]) - const body = getSocketFixPullRequestBody(['GHSA-1234-5678-9abc'], details) - // Should only contain one instance of "pkg (NPM)". - const matches = body.match(/pkg \(NPM\)/g) - expect(matches).toHaveLength(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/handle-fix-limit.test.mts b/packages/cli/test/unit/commands/fix/handle-fix-limit.test.mts deleted file mode 100644 index 356a8677b..000000000 --- a/packages/cli/test/unit/commands/fix/handle-fix-limit.test.mts +++ /dev/null @@ -1,566 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit Tests: Fix Command Handler - Limit Behavior. - * - * Purpose: Tests the --limit flag behavior to ensure it correctly limits the - * number of vulnerabilities processed by the fix command. Validates that the - * limit is properly applied in both local mode and PR mode. - * - * Test Coverage: - Local mode: Verify --limit N processes only N GHSAs - Local - * mode: Verify --limit 0 processes no GHSAs - Local mode: Verify limit - * exceeding GHSA count processes all - PR mode: Verify --limit N with adjusted - * limit based on open PRs - PR mode: Verify limit 0 when existing PRs exceed - * limit - --id filtering: Verify limit applies to filtered IDs. - * - * Testing Approach: Uses mocks and spies to verify the actual arguments passed - * to coana CLI, ensuring the business logic correctly applies the limit without - * making real API calls or creating actual PRs. - * - * Related Files: - src/commands/fix/coana-fix.mts - Main fix implementation - - * src/commands/fix/handle-fix.mts - Fix command handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { coanaFix } from '../../../../src/commands/fix/coana-fix.mts' - -import type { FixConfig } from '../../../../src/commands/fix/types.mts' - -// Mock all external dependencies. -const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockGetFixEnv = vi.hoisted(() => vi.fn()) -const mockGetSocketFixPrs = vi.hoisted(() => vi.fn()) -const mockFetchGhsaDetails = vi.hoisted(() => vi.fn()) -const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) -const mockReadJsonSync = vi.hoisted(() => vi.fn()) -const mockSafeDelete = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mjs', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, -})) - -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) - -vi.mock('../../../../src/util/fs/path-resolve.mjs', () => ({ - getPackageFilesForScan: mockGetPackageFilesForScan, -})) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/commands/fix/env-helpers.mts', () => ({ - checkCiEnvVars: vi.fn(() => ({ missing: [], present: [] })), - getCiEnvInstructions: vi.fn(() => 'Set CI env vars'), - getFixEnv: mockGetFixEnv, -})) - -vi.mock('../../../../src/commands/fix/pull-request.mts', () => ({ - cleanupSocketFixPrs: vi.fn(), - getSocketFixPrs: mockGetSocketFixPrs, - openSocketFixPr: vi.fn(), -})) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - enablePrAutoMerge: vi.fn(), - fetchGhsaDetails: mockFetchGhsaDetails, - getOctokit: vi.fn(), - setGitRemoteGithubRepoUrl: vi.fn(), -})) - -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - gitCheckoutBranch: vi.fn(() => Promise.resolve(true)), - gitCommit: vi.fn(() => Promise.resolve(true)), - gitCreateBranch: vi.fn(() => Promise.resolve(true)), - gitPushBranch: vi.fn(() => Promise.resolve(true)), - gitRemoteBranchExists: vi.fn(() => Promise.resolve(false)), - gitResetAndClean: vi.fn(() => Promise.resolve(true)), - gitUnstagedModifiedFiles: mockGitUnstagedModifiedFiles, -})) - -vi.mock('../../../../src/commands/fix/branch-cleanup.mts', () => ({ - cleanupErrorBranches: vi.fn(), - cleanupFailedPrBranches: vi.fn(), - cleanupStaleBranch: vi.fn(() => Promise.resolve(true)), - cleanupSuccessfulPrLocalBranch: vi.fn(), -})) - -vi.mock('../../../../src/commands/fix/ghsa-tracker.mts', () => ({ - isGhsaFixed: vi.fn(() => false), - markGhsaFixed: vi.fn(), -})) - -vi.mock('../../../../src/commands/fix/pr-lifecycle-logger.mts', () => ({ - logPrEvent: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/fs/read-json', () => ({ - readJsonSync: mockReadJsonSync, -})) -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, -})) -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - // Return undefined so findSocketYmlSync treats socket.yml as absent. - safeReadFileSync: vi.fn(() => undefined), -})) - -describe('socket fix --limit behavior verification', () => { - const baseConfig: FixConfig = { - all: false, - applyFixes: true, - autopilot: false, - coanaVersion: undefined, - cwd: '/test/cwd', - disableMajorUpdates: false, - ecosystems: [], - exclude: [], - ghsas: [], - include: [], - minSatisfying: false, - minimumReleaseAge: '', - orgSlug: 'test-org', - outputFile: '', - outputKind: 'text', - prCheck: true, - prLimit: 10, - rangeStyle: 'preserve', - showAffectedDirectDependencies: false, - spinner: undefined, - unknownFlags: [], - } - - beforeEach(() => { - vi.clearAllMocks() - - // Default mock implementations. - mockSetupSdk.mockResolvedValue({ - ok: true, - data: { - uploadManifestFiles: vi.fn(), - }, - }) - - mockFetchSupportedScanFileNames.mockResolvedValue({ - ok: true, - data: ['package.json', 'package-lock.json'], - }) - - mockGetPackageFilesForScan.mockResolvedValue([ - '/test/cwd/package.json', - '/test/cwd/package-lock.json', - ]) - - mockHandleApiCall.mockResolvedValue({ - ok: true, - data: { tarHash: 'test-hash-123' }, - }) - - mockGetFixEnv.mockResolvedValue({ - githubToken: '', - gitUserEmail: '', - gitUserName: '', - isCi: false, - repoInfo: undefined, - }) - - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: [], - }) - - mockReadJsonSync.mockReturnValue({ fixed: true }) - mockSafeDelete.mockResolvedValue(undefined) - }) - - describe('local mode (no PRs)', () => { - it('should process only N GHSAs when --limit N is specified', async () => { - const ghsas = [ - 'GHSA-1111-1111-1111', - 'GHSA-2222-2222-2222', - 'GHSA-3333-3333-3333', - 'GHSA-4444-4444-4444', - 'GHSA-5555-5555-5555', - ] - - // Mock successful fix result. - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas, - prLimit: 3, - }) - - expect(result.ok).toBe(true) - - // Verify spawnCoanaDlx was called once with only the first 3 GHSAs. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] - expect(callArgs).toContain('--apply-fixes-to') - - // Find the index of --apply-fixes-to and check the next arguments. - const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') - const ghsaArgs = callArgs - .slice(applyFixesIndex + 1) - .filter(arg => arg.startsWith('GHSA-')) - - expect(ghsaArgs).toEqual([ - 'GHSA-1111-1111-1111', - 'GHSA-2222-2222-2222', - 'GHSA-3333-3333-3333', - ]) - }) - - it('should process all GHSAs when limit exceeds GHSA count', async () => { - const ghsas = ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'] - - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas, - prLimit: 10, - }) - - expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] - const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') - const ghsaArgs = callArgs - .slice(applyFixesIndex + 1) - .filter(arg => arg.startsWith('GHSA-')) - - expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) - }) - - it('should process no GHSAs when --limit 0 is specified', async () => { - const ghsas = [ - 'GHSA-1111-1111-1111', - 'GHSA-2222-2222-2222', - 'GHSA-3333-3333-3333', - ] - - const result = await coanaFix({ - ...baseConfig, - ghsas, - prLimit: 0, - }) - - expect(result.ok).toBe(true) - expect(result.data?.fixedAll).toBe(false) - - // spawnCoanaDlx should not be called at all with limit 0. - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - - it('should handle all mode with limit', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 10, - }) - - expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] - expect(callArgs).toContain('--apply-fixes-to') - expect(callArgs).toContain('all') - }) - }) - - describe('PR mode', () => { - beforeEach(() => { - // Enable PR mode. - mockGetFixEnv.mockResolvedValue({ - githubToken: 'test-token', - gitUserEmail: 'test@example.com', - gitUserName: 'test-user', - isCi: true, - repoInfo: { - defaultBranch: 'main', - owner: 'test-owner', - repo: 'test-repo', - }, - }) - - mockGetSocketFixPrs.mockResolvedValue([]) - mockFetchGhsaDetails.mockResolvedValue(new Map()) - }) - - it('should process only N GHSAs when --limit N is specified in PR mode', async () => { - const ghsas = [ - 'GHSA-aaaa-aaaa-aaaa', - 'GHSA-bbbb-bbbb-bbbb', - 'GHSA-cccc-cccc-cccc', - 'GHSA-dddd-dddd-dddd', - ] - - // Mock discovery call result with JSON output on last line. - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: `Some discovery output\n${JSON.stringify(ghsas)}`, - }) - - // Subsequent calls are for individual GHSA fixes. - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: ['package.json'], - }) - - mockReadJsonSync.mockReturnValue({ fixed: true }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 2, - }) - - expect(result.ok).toBe(true) - - // First call to discover vulnerabilities, then 2 calls for the fixes. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) - }) - - it('should adjust limit based on existing open PRs', async () => { - const ghsas = [ - 'GHSA-aaaa-aaaa-aaaa', - 'GHSA-bbbb-bbbb-bbbb', - 'GHSA-cccc-cccc-cccc', - ] - - // Mock 1 existing open PR. - mockGetSocketFixPrs.mockResolvedValueOnce([ - { number: 123, state: 'OPEN' }, - ]) - - // Second call returns no open PRs for specific GHSAs. - mockGetSocketFixPrs.mockResolvedValue([]) - - // Mock discovery call result with JSON output on last line. - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: true, - data: `Some discovery output\n${JSON.stringify(ghsas)}`, - }) - - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - mockGitUnstagedModifiedFiles.mockResolvedValue({ - ok: true, - data: ['package.json'], - }) - - mockReadJsonSync.mockReturnValue({ fixed: true }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 3, - }) - - expect(result.ok).toBe(true) - - // With limit 3 and 1 existing PR, adjusted limit is 2. - // So: 1 discovery call + 2 fix calls = 3 total. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) - }) - - it('should process no GHSAs when existing open PRs exceed limit', async () => { - // Mock 5 existing open PRs. - mockGetSocketFixPrs.mockResolvedValue([ - { number: 1, state: 'OPEN' }, - { number: 2, state: 'OPEN' }, - { number: 3, state: 'OPEN' }, - { number: 4, state: 'OPEN' }, - { number: 5, state: 'OPEN' }, - ]) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['all'], - prLimit: 3, - }) - - expect(result.ok).toBe(true) - expect(result.data?.fixedAll).toBe(false) - - // With 5 open PRs and limit 3, adjusted limit is 0, so no processing. - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - }) - - describe('--id filtering with --limit', () => { - it('should apply limit to filtered GHSA IDs', async () => { - const ghsas = [ - 'GHSA-1111-1111-1111', - 'GHSA-2222-2222-2222', - 'GHSA-3333-3333-3333', - 'GHSA-4444-4444-4444', - 'GHSA-5555-5555-5555', - ] - - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas, - prLimit: 2, - }) - - expect(result.ok).toBe(true) - - // Should only process first 2 GHSAs. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] - const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') - const ghsaArgs = callArgs - .slice(applyFixesIndex + 1) - .filter(arg => arg.startsWith('GHSA-')) - - expect(ghsaArgs).toHaveLength(2) - expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) - }) - - it('should handle limit 1 with single GHSA ID', async () => { - const ghsas = ['GHSA-1111-1111-1111'] - - mockSpawnCoanaDlx.mockResolvedValue({ - ok: true, - data: 'fix applied', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas, - prLimit: 1, - }) - - expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] - const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') - const ghsaArgs = callArgs - .slice(applyFixesIndex + 1) - .filter(arg => arg.startsWith('GHSA-')) - - expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111']) - }) - }) - - describe('early-return error paths', () => { - it('returns SDK setup error when setupSdk fails (line 110)', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - message: 'Auth Error', - cause: 'Invalid token', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Auth Error') - } - // spawnCoanaDlx should never run when SDK setup fails. - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - - it('returns supported-files error when fetch fails (line 117)', async () => { - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: false, - message: 'API Error', - cause: 'Network timeout', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('API Error') - } - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - - it('returns upload error when manifest upload fails (line 150)', async () => { - mockHandleApiCall.mockResolvedValueOnce({ - ok: false, - message: 'Upload Failed', - cause: 'Bad gateway', - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Upload Failed') - } - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - - it('returns error when upload returns no tar hash (lines 154-160)', async () => { - mockHandleApiCall.mockResolvedValueOnce({ - ok: true, - // No tarHash in payload — server contract violation. - data: {}, - }) - - const result = await coanaFix({ - ...baseConfig, - ghsas: ['GHSA-1111-1111-1111'], - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('tar hash') - } - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/handle-fix.test.mts b/packages/cli/test/unit/commands/fix/handle-fix.test.mts deleted file mode 100644 index dc7decc18..000000000 --- a/packages/cli/test/unit/commands/fix/handle-fix.test.mts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Unit Tests: Fix Command Handler - ID Conversion Logic. - * - * Purpose: Tests the vulnerability ID conversion system that normalizes various - * vulnerability identifiers (GHSA, CVE, PURL) into standardized GHSA format. - * Validates the conversion pipeline used by the fix command to accept flexible - * input formats while working with unified GHSA identifiers. - * - * Test Coverage: - GHSA ID validation and passthrough - CVE to GHSA conversion - * via Socket API - PURL to GHSA conversion for package-based lookups - Invalid - * format detection and filtering - Conversion failure handling with - * user-friendly warnings - Mixed ID type processing in single batch - - * Whitespace normalization. - * - * Testing Approach: Mocks CVE and PURL conversion utilities, logger functions, - * and array utilities to test the conversion logic in isolation. Tests verify - * proper error handling, logging, and filtering of invalid or unconvertible - * identifiers. - * - * Related Files: - src/commands/fix/handle-fix.mts - Main fix command handler - - * src/util/cve-to-ghsa.mts - CVE ID conversion utility - - * src/util/purl/to-ghsa.mts - PURL to GHSA conversion - - * src/commands/fix/coana-fix.mts - Coana API integration for applying fixes. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { convertIdsToGhsas } from '../../../../src/commands/fix/handle-fix.mts' - -// Mock the dependencies. -const mockJoinAnd = vi.hoisted(() => vi.fn(arr => arr.join(' and '))) -const mockCoanaFix = vi.hoisted(() => vi.fn()) -const mockOutputFixResult = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/arrays/join', () => ({ - joinAnd: mockJoinAnd, -})) - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockConvertCveToGhsa = vi.hoisted(() => vi.fn()) -const mockConvertPurlToGhsas = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) -vi.mock('../../../../src/commands/fix/coana-fix.mts', () => ({ - coanaFix: mockCoanaFix, -})) -vi.mock('../../../../src/commands/fix/output-fix-result.mts', () => ({ - outputFixResult: mockOutputFixResult, -})) -vi.mock('../../../../src/util/cve-to-ghsa.mts', () => ({ - convertCveToGhsa: mockConvertCveToGhsa, -})) -vi.mock('../../../../src/util/purl/to-ghsa.mts', () => ({ - convertPurlToGhsas: mockConvertPurlToGhsas, -})) - -describe('convertIdsToGhsas', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('preserves valid GHSA IDs', async () => { - const ghsas = ['GHSA-1234-5678-9abc', 'GHSA-abcd-efgh-ijkl'] - const result = await convertIdsToGhsas(ghsas) - - expect(result).toEqual(ghsas) - }) - - it('converts CVE IDs to GHSA IDs', async () => { - mockConvertCveToGhsa.mockResolvedValueOnce({ - ok: true, - data: 'GHSA-1234-5678-9abc', - }) - mockConvertCveToGhsa.mockResolvedValueOnce({ - ok: true, - data: 'GHSA-abcd-efgh-ijkl', - }) - - const result = await convertIdsToGhsas(['CVE-2021-12345', 'CVE-2022-98765']) - - expect(mockConvertCveToGhsa).toHaveBeenCalledWith('CVE-2021-12345') - expect(mockConvertCveToGhsa).toHaveBeenCalledWith('CVE-2022-98765') - expect(mockLogger.info).toHaveBeenCalledWith( - 'Converted CVE-2021-12345 to GHSA-1234-5678-9abc', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Converted CVE-2022-98765 to GHSA-abcd-efgh-ijkl', - ) - expect(result).toEqual(['GHSA-1234-5678-9abc', 'GHSA-abcd-efgh-ijkl']) - }) - - it('converts PURL IDs to GHSA IDs', async () => { - mockConvertPurlToGhsas.mockResolvedValue({ - ok: true, - data: ['GHSA-test-purl-ghsa'], - }) - - const result = await convertIdsToGhsas(['pkg:npm/package@1.0.0']) - - expect(mockConvertPurlToGhsas).toHaveBeenCalledWith('pkg:npm/package@1.0.0') - expect(result).toEqual(['GHSA-test-purl-ghsa']) - }) - - it('handles invalid GHSA format', async () => { - const result = await convertIdsToGhsas([ - 'GHSA-invalid', - 'GHSA-1234-5678-9abc', - ]) - - expect(result).toEqual(['GHSA-1234-5678-9abc']) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringMatching( - /Skipped 1 invalid IDs.*Invalid GHSA format: GHSA-invalid/s, - ), - ) - }) - - it('handles invalid CVE format', async () => { - mockConvertCveToGhsa.mockResolvedValue({ - ok: true, - data: 'GHSA-1234-5678-9abc', - }) - - const result = await convertIdsToGhsas(['CVE-invalid', 'CVE-2021-12345']) - - expect(result).toEqual(['GHSA-1234-5678-9abc']) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringMatching( - /Skipped 1 invalid IDs.*Invalid CVE format: CVE-invalid/s, - ), - ) - }) - - it('handles CVE conversion failure', async () => { - mockConvertCveToGhsa.mockResolvedValue({ - ok: false, - message: 'No GHSA found for CVE CVE-2021-99999', - error: new Error('CVE not found'), - }) - - const result = await convertIdsToGhsas(['CVE-2021-99999']) - - expect(result).toEqual([]) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Skipped 1 invalid IDs:'), - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'CVE-2021-99999: No GHSA found for CVE CVE-2021-99999', - ), - ) - }) - - it('handles PURL conversion failure', async () => { - mockConvertPurlToGhsas.mockResolvedValue({ - ok: false, - message: 'Package not found', - error: new Error('Package not found'), - }) - - const result = await convertIdsToGhsas(['pkg:npm/nonexistent@1.0.0']) - - expect(result).toEqual([]) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringMatching( - /Skipped 1 invalid IDs.*pkg:npm\/nonexistent@1\.0\.0.*Package not found/s, - ), - ) - }) - - it('handles empty PURL conversion result', async () => { - mockConvertPurlToGhsas.mockResolvedValue({ - ok: true, - data: [], - }) - - const result = await convertIdsToGhsas(['pkg:npm/safe-package@1.0.0']) - - expect(result).toEqual([]) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringMatching( - /Skipped 1 invalid IDs.*pkg:npm\/safe-package@1\.0\.0.*No GHSAs found/s, - ), - ) - }) - - it('handles mixed ID types', async () => { - mockConvertCveToGhsa.mockResolvedValue({ - ok: true, - data: 'GHSA-from-cve-test', - }) - mockConvertPurlToGhsas.mockResolvedValue({ - ok: true, - data: ['GHSA-from-purl-test'], - }) - - const result = await convertIdsToGhsas([ - 'GHSA-1234-5678-9abc', - 'CVE-2021-12345', - 'pkg:npm/package@1.0.0', - ]) - - expect(result).toEqual([ - 'GHSA-1234-5678-9abc', - 'GHSA-from-cve-test', - 'GHSA-from-purl-test', - ]) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Converted CVE-2021-12345 to GHSA-from-cve-test', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Converted pkg:npm/package@1.0.0 to 1 GHSA(s)'), - ) - }) - - it('warns about IDs that are neither GHSA, CVE, nor PURL', async () => { - const result = await convertIdsToGhsas(['some-other-format']) - - expect(result).toEqual([]) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Unsupported ID format'), - ) - }) - - it('trims whitespace from IDs', async () => { - const result = await convertIdsToGhsas([ - ' GHSA-1234-5678-9abc ', - '\tGHSA-abcd-efgh-ijkl\n', - ]) - - expect(result).toEqual(['GHSA-1234-5678-9abc', 'GHSA-abcd-efgh-ijkl']) - }) -}) - -describe('handleFix', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCoanaFix.mockResolvedValue({ ok: true, data: {} }) - }) - - it('runs coanaFix and pipes the result through outputFixResult', async () => { - const { handleFix } = - await import('../../../../src/commands/fix/handle-fix.mts') - - await handleFix({ - all: false, - applyFixes: false, - autopilot: false, - coanaVersion: '1.0.0', - cwd: '/proj', - debug: false, - disableExternalToolChecks: false, - disableMajorUpdates: false, - ecosystems: ['npm'], - exclude: [], - ghsas: ['GHSA-1234-5678-9abc'], - include: [], - minSatisfying: false, - minimumReleaseAge: '7d', - orgSlug: 'my-org', - outputFile: '', - outputKind: 'json', - prCheck: false, - prLimit: 5, - rangeStyle: 'caret', - showAffectedDirectDependencies: false, - silence: false, - spinner: undefined, - unknownFlags: [], - }) - - expect(mockCoanaFix).toHaveBeenCalledWith( - expect.objectContaining({ - ghsas: ['GHSA-1234-5678-9abc'], - orgSlug: 'my-org', - }), - ) - expect(mockOutputFixResult).toHaveBeenCalledWith( - expect.objectContaining({ ok: true }), - 'json', - ) - }) - - it('converts mixed CVE/PURL/GHSA inputs before calling coanaFix', async () => { - mockConvertCveToGhsa.mockResolvedValueOnce({ - ok: true, - data: 'GHSA-from-cve', - }) - mockConvertPurlToGhsas.mockResolvedValueOnce({ - ok: true, - data: ['GHSA-from-purl'], - }) - - const { handleFix } = - await import('../../../../src/commands/fix/handle-fix.mts') - - await handleFix({ - all: false, - applyFixes: true, - autopilot: false, - coanaVersion: '1.0.0', - cwd: '/proj', - debug: false, - disableExternalToolChecks: false, - disableMajorUpdates: false, - ecosystems: [], - exclude: [], - ghsas: [ - 'GHSA-1234-5678-9abc', - 'CVE-2021-44228', - 'pkg:npm/lodash@4.17.21', - ], - include: [], - minSatisfying: false, - minimumReleaseAge: '0', - orgSlug: 'my-org', - outputFile: '', - outputKind: 'text', - prCheck: false, - prLimit: 5, - rangeStyle: 'caret', - showAffectedDirectDependencies: false, - silence: false, - spinner: undefined, - unknownFlags: [], - }) - - expect(mockCoanaFix).toHaveBeenCalledWith( - expect.objectContaining({ - ghsas: expect.arrayContaining([ - 'GHSA-1234-5678-9abc', - 'GHSA-from-cve', - 'GHSA-from-purl', - ]), - }), - ) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/output-fix-result.test.mts b/packages/cli/test/unit/commands/fix/output-fix-result.test.mts deleted file mode 100644 index 592f4fb08..000000000 --- a/packages/cli/test/unit/commands/fix/output-fix-result.test.mts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Unit tests for fix result output formatting. - * - * Purpose: Tests the output formatting for fix command results. - * - * Test Coverage: - outputFixResult function - JSON output format - Markdown - * output format - Text output format - Success and error handling. - * - * Related Files: - src/commands/fix/output-fix-result.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdError: (msg: string, cause?: string) => - `## Error: ${msg}${cause ? `\n${cause}` : ''}`, - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputFixResult } from '../../../../src/commands/fix/output-fix-result.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-fix-result', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputFixResult', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<{ fixed: number }> = { - ok: true, - data: { fixed: 5 }, - } - - await outputFixResult(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error result as JSON', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Fix failed', - cause: 'No vulnerabilities found', - } - - await outputFixResult(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Fix failed', - code: 2, - } - - await outputFixResult(result, 'json') - - expect(process.exitCode).toBe(2) - }) - }) - - describe('Markdown output', () => { - it('outputs success as markdown header', async () => { - const result: CResult<unknown> = { - ok: true, - data: {}, - } - - await outputFixResult(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith('# Fix Completed') - expect(mockLogger.success).toHaveBeenCalledWith('Finished!') - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error as markdown error', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Fix failed', - cause: 'No packages to fix', - } - - await outputFixResult(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('## Error: Fix failed'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Text output', () => { - it('outputs success message', async () => { - const result: CResult<unknown> = { - ok: true, - data: {}, - } - - await outputFixResult(result, 'text') - - expect(mockLogger.success).toHaveBeenCalledWith('Finished!') - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Fix failed', - cause: 'API error', - } - - await outputFixResult(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Fix failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('handles error without cause', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Something went wrong', - } - - await outputFixResult(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Something went wrong'), - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/pr-lifecycle-logger.test.mts b/packages/cli/test/unit/commands/fix/pr-lifecycle-logger.test.mts deleted file mode 100644 index 007c125fd..000000000 --- a/packages/cli/test/unit/commands/fix/pr-lifecycle-logger.test.mts +++ /dev/null @@ -1,171 +0,0 @@ - -/** - * Unit Tests: Pull Request Lifecycle Event Logger. - * - * Purpose: Tests the PR lifecycle logging utility that provides consistent, - * color-coded terminal output for pull request events during automated fix - * workflows. Validates proper log level selection, message formatting, and - * symbol coloring for different PR lifecycle events. - * - * Test Coverage: - Created event logging with success level - Merged event - * logging with success level and cleanup details - Closed event logging with - * info level - Updated event logging with info level and change details - - * Superseded event logging with warning level - Failed event logging with error - * level - Optional details parameter handling - Color coding application to - * status symbols. - * - * Testing Approach: Mocks logger and yoctocolors-cjs to verify correct log - * level usage and color application without actual terminal output. Tests - * validate message content and symbol formatting. - * - * Related Files: - src/commands/fix/pr-lifecycle-logger.mts - PR event logging - * utility - src/commands/fix/pull-request.mts - PR creation and management - * using logger - src/commands/fix/handle-fix.mts - Main fix command - * orchestrating PR workflow. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { logPrEvent } from '../../../../src/commands/fix/pr-lifecycle-logger.mts' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - success: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - log: vi.fn(), - fail: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - logger: mockLogger, - getDefaultLogger: () => mockLogger, -})) - -// Mock colors. -vi.mock('yoctocolors-cjs', () => ({ - __esModule: true, - default: { - green: (str: string) => `[green]${str}[/green]`, - blue: (str: string) => `[blue]${str}[/blue]`, - yellow: (str: string) => `[yellow]${str}[/yellow]`, - red: (str: string) => `[red]${str}[/red]`, - cyan: (str: string) => `[cyan]${str}[/cyan]`, - }, -})) - -describe('pr-lifecycle-logger', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('logPrEvent', () => { - it('logs created event with success logger', async () => { - logPrEvent( - 'created', - 123, - 'GHSA-1234-5678-90ab', - 'https://github.com/org/repo/pull/123', - ) - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('PR #123'), - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('GHSA-1234-5678-90ab'), - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('https://github.com/org/repo/pull/123'), - ) - }) - - it('logs merged event with success logger', async () => { - logPrEvent('merged', 456, 'GHSA-abcd-efgh-ijkl', 'Branch cleaned up') - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('PR #456'), - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('GHSA-abcd-efgh-ijkl'), - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('Branch cleaned up'), - ) - }) - - it('logs closed event with info logger', async () => { - logPrEvent('closed', 789, 'GHSA-test-test-test') - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('PR #789'), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('GHSA-test-test-test'), - ) - }) - - it('logs updated event with info logger', async () => { - logPrEvent('updated', 111, 'GHSA-update-test', 'Updated from base branch') - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('PR #111'), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('GHSA-update-test'), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Updated from base branch'), - ) - }) - - it('logs superseded event with warn logger', async () => { - logPrEvent('superseded', 222, 'GHSA-supersede') - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('PR #222'), - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('GHSA-supersede'), - ) - }) - - it('logs failed event with error logger', async () => { - logPrEvent('failed', 333, 'GHSA-fail-test', 'API error') - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('PR #333'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('GHSA-fail-test'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('API error'), - ) - }) - - it('handles missing details parameter', async () => { - logPrEvent('created', 444, 'GHSA-no-details') - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('PR #444'), - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('GHSA-no-details'), - ) - // Should not include a colon when no details. - expect(mockLogger.success).toHaveBeenCalledWith( - expect.not.stringContaining(': undefined'), - ) - }) - - it('applies color coding to symbols', async () => { - logPrEvent('created', 100, 'GHSA-colors') - - // Should include colored checkmark for success. - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('[green]✓[/green]'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/fix/pull-request.test.mts b/packages/cli/test/unit/commands/fix/pull-request.test.mts deleted file mode 100644 index 0ce5be9da..000000000 --- a/packages/cli/test/unit/commands/fix/pull-request.test.mts +++ /dev/null @@ -1,839 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit Tests: Pull Request Creation for Automated Fixes. - * - * Purpose: Tests the automated pull request creation system that opens PRs for - * security vulnerability fixes. Validates PR creation with retry logic, error - * handling, title/body generation, and GHSA detail integration for the Socket - * fix workflow. - * - * Test Coverage: - Successful PR creation on first attempt - Retry logic for - * transient 5xx errors - Non-retry behavior for 422 validation errors (e.g., - * duplicate PRs) - Custom retry count configuration - Exhausted retry handling - * returning undefined - Exponential backoff during retries (via provider) - - * GHSA details passed to PR body generator. - * - * Testing Approach: Mocks Octokit GitHub client, PR provider abstraction, and - * PR content generators to test the orchestration logic without actual GitHub - * API calls. Tests verify proper retry behavior, error handling, and data flow - * through the PR creation pipeline. - * - * Related Files: - src/commands/fix/pull-request.mts - PR creation and retry - * logic - src/commands/fix/git.mts - PR title and body generation - - * src/util/git/github.mts - Octokit client factory - - * src/util/git/provider-factory.mts - Provider abstraction factory - - * src/commands/fix/handle-fix.mts - Main fix command orchestrating PR workflow. - */ - -import { RequestError } from '@octokit/request-error' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - cleanupSocketFixPrs, - getSocketFixPrs, - openSocketFixPr, -} from '../../../../src/commands/fix/pull-request.mts' - -const mockGetOctokit = vi.hoisted(() => vi.fn()) -const mockCreatePrProvider = vi.hoisted(() => vi.fn()) -const mockGetSocketFixPullRequestTitle = vi.hoisted(() => - vi.fn((ghsaIds: string[]) => - ghsaIds.length === 1 - ? `Fix for ${ghsaIds[0]}` - : `Fixes for ${ghsaIds.length} GHSAs`, - ), -) -const mockGetSocketFixPullRequestBody = vi.hoisted(() => - vi.fn(() => 'Mock PR body'), -) - -const mockWithGitHubRetry = vi.hoisted(() => - vi.fn(async (operation: () => Promise<unknown>) => { - const result = await operation() - return { ok: true, data: result } - }), -) - -const mockGetSocketFixBranchPattern = vi.hoisted(() => - vi.fn(() => /^socket\/fix\/GHSA-.*/), -) - -// Mock dependencies. -vi.mock('../../../../src/commands/fix/git.mts', () => ({ - getSocketFixBranchPattern: mockGetSocketFixBranchPattern, - getSocketFixPullRequestTitle: mockGetSocketFixPullRequestTitle, - getSocketFixPullRequestBody: mockGetSocketFixPullRequestBody, -})) - -// Mock debug. -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -// Mock pr-lifecycle-logger. -vi.mock('../../../../src/commands/fix/pr-lifecycle-logger.mts', () => ({ - logPrEvent: vi.fn(), -})) - -const mockGetOctokitGraphql = vi.hoisted(() => vi.fn()) -const mockCacheFetch = vi.hoisted(() => vi.fn()) -const mockWriteCache = vi.hoisted(() => vi.fn()) -const mockHandleGraphqlError = vi.hoisted(() => - vi.fn(() => ({ ok: false, message: 'GraphQL error' })), -) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - cacheFetch: mockCacheFetch, - getOctokit: mockGetOctokit, - getOctokitGraphql: mockGetOctokitGraphql, - handleGitHubApiError: vi.fn((e: unknown, context: string) => ({ - ok: false, - message: 'GitHub API error', - cause: `Error while ${context}: ${e instanceof Error ? e.message : String(e)}`, - })), - handleGraphqlError: mockHandleGraphqlError, - withGitHubRetry: mockWithGitHubRetry, - writeCache: mockWriteCache, -})) - -vi.mock('../../../../src/util/git/provider-factory.mts', () => ({ - createPrProvider: mockCreatePrProvider, -})) - -describe('pull-request', () => { - let mockOctokit: unknown - let mockProvider: unknown - - beforeEach(() => { - vi.clearAllMocks() - - // Create mock Octokit instance. - mockOctokit = { - pulls: { - create: vi.fn(), - get: vi.fn(), - update: vi.fn(), - }, - issues: { - createComment: vi.fn(), - }, - repos: { - merge: vi.fn(), - }, - } - - // Create mock provider. - mockProvider = { - createPr: vi.fn(), - updatePr: vi.fn(), - listPrs: vi.fn(), - deleteBranch: vi.fn(), - addComment: vi.fn(), - getProviderName: vi.fn(() => 'github'), - supportsGraphQL: vi.fn(() => true), - } - }) - - describe('openSocketFixPr', () => { - it('creates PR successfully on first attempt', async () => { - mockGetOctokit.mockReturnValue(mockOctokit) - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider returns simplified response. - mockProvider.createPr.mockResolvedValue({ - number: 123, - url: 'https://github.com/org/repo/pull/123', - state: 'open', - }) - - // Octokit returns full PR details. - const mockPrResponse = { - status: 200, - data: { - number: 123, - html_url: 'https://github.com/org/repo/pull/123', - title: 'Fix for GHSA-1234-5678-90ab', - }, - } - mockOctokit.pulls.get.mockResolvedValue(mockPrResponse) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-1234-5678-90ab', - ['GHSA-1234-5678-90ab'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.pr.data.number).toBe(123) - } - expect(mockProvider.createPr).toHaveBeenCalledTimes(1) - expect(mockOctokit.pulls.get).toHaveBeenCalledTimes(1) - }) - - it('retries on 5xx error', async () => { - mockGetOctokit.mockReturnValue(mockOctokit) - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider succeeds after retries (retry logic is in provider). - mockProvider.createPr.mockResolvedValue({ - number: 456, - url: 'https://github.com/org/repo/pull/456', - state: 'open', - }) - - mockOctokit.pulls.get.mockResolvedValue({ - status: 200, - data: { - number: 456, - html_url: 'https://github.com/org/repo/pull/456', - title: 'Fix for GHSA-test', - }, - }) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-test', - ['GHSA-test'], - { baseBranch: 'main', retries: 3 }, - ) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.pr.data.number).toBe(456) - } - }) - - it('does not retry on 422 validation error', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider throws error (validation errors are not retried in provider). - mockProvider.createPr.mockRejectedValue( - new Error('Validation Failed: A pull request already exists'), - ) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-duplicate', - ['GHSA-duplicate'], - { baseBranch: 'main', retries: 3 }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('unknown') - } - }) - - it('respects custom retry count', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider throws error after retries. - mockProvider.createPr.mockRejectedValue( - new Error('Failed after 5 retries'), - ) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-retry', - ['GHSA-retry'], - { baseBranch: 'main', retries: 5 }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('unknown') - } - }) - - it('returns undefined after all retries exhausted', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider throws error. - mockProvider.createPr.mockRejectedValue(new Error('Network error')) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-fail', - ['GHSA-fail'], - { baseBranch: 'main', retries: 3 }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('unknown') - } - }) - - it('returns network_error when withGitHubRetry fails', async () => { - mockGetOctokit.mockReturnValue(mockOctokit) - mockCreatePrProvider.mockReturnValue(mockProvider) - - mockProvider.createPr.mockResolvedValue({ - number: 999, - url: 'https://github.com/org/repo/pull/999', - state: 'open', - }) - - // Make withGitHubRetry return failure. - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'Retry failed', - cause: 'Network timeout', - }) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-network', - ['GHSA-network'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('network_error') - } - }) - - it('uses exponential backoff for retries', async () => { - mockGetOctokit.mockReturnValue(mockOctokit) - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Provider succeeds (backoff logic is in provider). - mockProvider.createPr.mockResolvedValue({ - number: 789, - url: 'https://github.com/org/repo/pull/789', - state: 'open', - }) - - mockOctokit.pulls.get.mockResolvedValue({ - status: 200, - data: { - number: 789, - html_url: 'https://github.com/org/repo/pull/789', - }, - }) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-backoff', - ['GHSA-backoff'], - { baseBranch: 'main', retries: 3 }, - ) - - expect(result.ok).toBe(true) - }) - - it('returns already_exists when PR already exists error thrown', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Create RequestError with "already exists" error message. - const requestError = new RequestError('Validation Failed', 422, { - request: { method: 'POST', url: '', headers: {} }, - response: { - url: '', - status: 422, - headers: {}, - data: { - errors: [ - { - message: 'A pull request already exists for this branch', - }, - ], - }, - }, - }) - - mockProvider.createPr.mockRejectedValue(requestError) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-already', - ['GHSA-already'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('already_exists') - } - }) - - it('returns validation_error when validation errors exist', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - // Create RequestError with validation errors that are not "already exists". - const requestError = new RequestError('Validation Failed', 422, { - request: { method: 'POST', url: '', headers: {} }, - response: { - url: '', - status: 422, - headers: {}, - data: { - errors: [ - { - resource: 'PullRequest', - field: 'head', - code: 'invalid', - }, - ], - }, - }, - }) - - mockProvider.createPr.mockRejectedValue(requestError) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-validation', - ['GHSA-validation'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('validation_error') - if (result.reason === 'validation_error') { - expect(result.details).toContain('PullRequest.head') - } - } - }) - - it('returns permission_denied for 403 status', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - const requestError = new RequestError('Forbidden', 403, { - request: { method: 'POST', url: '', headers: {} }, - response: { url: '', status: 403, headers: {}, data: {} }, - }) - - mockProvider.createPr.mockRejectedValue(requestError) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-forbidden', - ['GHSA-forbidden'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('permission_denied') - } - }) - - it('returns permission_denied for 401 status', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - const requestError = new RequestError('Unauthorized', 401, { - request: { method: 'POST', url: '', headers: {} }, - response: { url: '', status: 401, headers: {}, data: {} }, - }) - - mockProvider.createPr.mockRejectedValue(requestError) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-unauth', - ['GHSA-unauth'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('permission_denied') - } - }) - - it('returns network_error for 5xx status', async () => { - mockCreatePrProvider.mockReturnValue(mockProvider) - - const requestError = new RequestError('Internal Server Error', 500, { - request: { method: 'POST', url: '', headers: {} }, - response: { url: '', status: 500, headers: {}, data: {} }, - }) - - mockProvider.createPr.mockRejectedValue(requestError) - - const result = await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-server', - ['GHSA-server'], - { baseBranch: 'main' }, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.reason).toBe('network_error') - } - }) - - it('passes GHSA details to PR body generator', async () => { - mockGetOctokit.mockReturnValue(mockOctokit) - mockCreatePrProvider.mockReturnValue(mockProvider) - - mockProvider.createPr.mockResolvedValue({ - number: 999, - url: 'https://github.com/org/repo/pull/999', - state: 'open', - }) - - mockOctokit.pulls.get.mockResolvedValue({ - status: 200, - data: { number: 999 }, - }) - - const mockGhsaDetails = new Map([ - [ - 'GHSA-details-test', - { - summary: 'Test vulnerability', - severity: 'HIGH', - vulnerabilities: { nodes: [] }, - }, - ], - ]) - - await openSocketFixPr( - 'test-org', - 'test-repo', - 'socket/fix/GHSA-details-test', - ['GHSA-details-test'], - { - baseBranch: 'main', - ghsaDetails: mockGhsaDetails, - }, - ) - - expect(mockGetSocketFixPullRequestBody).toHaveBeenCalledWith( - ['GHSA-details-test'], - mockGhsaDetails, - ) - }) - }) - - describe('getSocketFixPrs', () => { - beforeEach(() => { - mockGetOctokitGraphql.mockReturnValue(vi.fn()) - }) - - it('returns matching PRs', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx-xxxx-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - { - author: { login: 'otheruser' }, - baseRefName: 'main', - headRefName: 'feature-branch', - mergeStateStatus: 'CLEAN', - number: 2, - state: 'OPEN', - title: 'Other PR', - }, - ], - }, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo') - - expect(result).toHaveLength(1) - expect(result[0]?.number).toBe(1) - }) - - it('filters by author', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - { - author: { login: 'otheruser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-yyyy', - mergeStateStatus: 'CLEAN', - number: 2, - state: 'OPEN', - title: 'Fix GHSA-yyyy', - }, - ], - }, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo', { - author: 'testuser', - }) - - expect(result).toHaveLength(1) - expect(result[0]?.author).toBe('testuser') - }) - - it('handles GraphQL errors gracefully', async () => { - mockCacheFetch.mockRejectedValueOnce(new Error('GraphQL error')) - - const result = await getSocketFixPrs('owner', 'repo') - - expect(result).toEqual([]) - expect(mockHandleGraphqlError).toHaveBeenCalled() - }) - - it('handles empty response', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [], - }, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo') - - expect(result).toEqual([]) - }) - - it('handles missing author in node', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: undefined, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo') - - expect(result).toHaveLength(1) - // UNKNOWN_VALUE from @socketsecurity/lib/constants/core. - expect(result[0]?.author).toBe('<unknown>') - }) - - it('stops pagination early when ghsaId match found', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: true, endCursor: 'cursor1' }, - nodes: [ - { - author: { login: 'user1' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo', { - ghsaId: 'GHSA-xxxx', - }) - - expect(result).toHaveLength(1) - // Should have only called cacheFetch once due to early exit. - expect(mockCacheFetch).toHaveBeenCalledTimes(1) - }) - - it('handles null pullRequests response', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: undefined, - }, - }) - - const result = await getSocketFixPrs('owner', 'repo') - - expect(result).toEqual([]) - }) - }) - - describe('cleanupSocketFixPrs', () => { - beforeEach(() => { - mockGetOctokitGraphql.mockReturnValue(vi.fn()) - mockCreatePrProvider.mockReturnValue(mockProvider) - }) - - it('returns empty array when no matching PRs', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [], - }, - }, - }) - - const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx') - - expect(result).toEqual([]) - }) - - it('updates stale PRs with BEHIND status', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'BEHIND', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - mockProvider.updatePr.mockResolvedValueOnce({}) - mockWriteCache.mockResolvedValueOnce(undefined) - - const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx') - - expect(result).toHaveLength(1) - expect(mockProvider.updatePr).toHaveBeenCalledWith({ - owner: 'owner', - repo: 'repo', - prNumber: 1, - head: 'socket/fix/GHSA-xxxx', - base: 'main', - }) - }) - - it('deletes branches for merged PRs', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'MERGED', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - mockProvider.deleteBranch.mockResolvedValueOnce(true) - - const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx') - - expect(result).toHaveLength(1) - expect(mockProvider.deleteBranch).toHaveBeenCalledWith( - 'socket/fix/GHSA-xxxx', - ) - }) - - it('handles delete branch failure gracefully', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'MERGED', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - mockProvider.deleteBranch.mockRejectedValueOnce( - new Error('Branch not found'), - ) - - const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx') - - // Should still return the match even if branch deletion fails. - expect(result).toHaveLength(1) - }) - - it('handles update PR failure gracefully', async () => { - mockCacheFetch.mockResolvedValueOnce({ - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - author: { login: 'testuser' }, - baseRefName: 'main', - headRefName: 'socket/fix/GHSA-xxxx', - mergeStateStatus: 'BEHIND', - number: 1, - state: 'OPEN', - title: 'Fix GHSA-xxxx', - }, - ], - }, - }, - }) - mockProvider.updatePr.mockRejectedValueOnce(new Error('Update failed')) - - const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx') - - // Should still return the match even if update fails. - expect(result).toHaveLength(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/gem/cmd-gem.test.mts b/packages/cli/test/unit/commands/gem/cmd-gem.test.mts deleted file mode 100644 index 13e72aac2..000000000 --- a/packages/cli/test/unit/commands/gem/cmd-gem.test.mts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * Unit tests for gem wrapper command. - * - * Tests the command entry point that wraps gem with Socket Firewall security. - * The wrapper intercepts gem commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Flag filtering (Socket CLI vs gem flags) - Exit code handling with - * process.exit() - Signal propagation with process.kill() - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -// Mock spawnSfwDlx. -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -// Import after mocks. -const { cmdGem } = await import('../../../../src/commands/gem/cmd-gem.mts') - -describe('cmd-gem', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdGem.description).toBe('Run gem with Socket Firewall security') - }) - - it('should not be hidden', () => { - expect(cmdGem.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdGem.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation(args => { - const helpText = args.config.help('socket gem') - expect(helpText).toContain('socket gem') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - mockSpawnPromise.process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdGem.run( - [], - { url: import.meta.url }, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - const importMeta = { url: import.meta.url } as ImportMeta - const context = { parentName: 'socket' } - - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const runPromise = cmdGem.run(['install', 'rails'], importMeta, context) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['install', 'rails'], - config: expect.objectContaining({ - commandName: 'gem', - description: 'Run gem with Socket Firewall security', - hidden: false, - }), - importMeta, - parentName: 'socket', - }) - }) - - describe('flag filtering', () => { - it('should filter out Socket CLI flags and forward gem flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - // Filtered args (Socket CLI flags removed). - mockFilterFlags.mockReturnValue(['install', '--no-document', 'rails']) - - const runPromise = cmdGem.run( - ['--config', '{}', 'install', '--no-document', 'rails'], - importMeta, - context, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['gem', 'install', '--no-document', 'rails'], - { stdio: 'inherit' }, - ) - }) - }) - - describe('exit handling', () => { - it('should set default exit code to 1 before child process exits', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdGem.run(['install', 'rails'], importMeta, context) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with child exit code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGem.run(['install', 'rails'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should call process.exit with non-zero exit code on failure', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 1, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGem.run(['install', 'rails'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with non-zero code. - mockChildProcess.emit('exit', 1, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(1) - - mockExit.mockRestore() - }) - - it('should call process.kill with signal when child receives signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdGem.run(['install', 'rails'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should propagate SIGINT signal from child process', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGINT', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['list']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdGem.run(['list'], importMeta, context) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate SIGINT. - mockChildProcess.emit('exit', undefined, 'SIGINT') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - - mockKill.mockRestore() - }) - }) - - describe('command forwarding', () => { - it('should forward gem commands to sfw with correct args', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'rails']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGem.run(['install', 'rails'], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['gem', 'install', 'rails'], - { stdio: 'inherit' }, - ) - - mockExit.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGem.run([], importMeta, context) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['gem'], { - stdio: 'inherit', - }) - - mockExit.mockRestore() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/go/cmd-go.test.mts b/packages/cli/test/unit/commands/go/cmd-go.test.mts deleted file mode 100644 index 8cedac6de..000000000 --- a/packages/cli/test/unit/commands/go/cmd-go.test.mts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Unit Tests: Socket Go Command. - * - * Purpose: Tests the go wrapper command that forwards go operations to Socket - * Firewall (sfw). Validates argument forwarding, flag filtering, exit code - * handling, and signal propagation. - * - * Test Coverage: - Command metadata (description, hidden status) - Argument - * forwarding to sfw via spawnSfwDlx - Socket CLI flag filtering (removes - * --config, --org, etc.) - Exit code defaults and handling - Signal propagation - * from child process - Integration with meowOrExit for --help handling. - * - * Testing Approach: Mocks spawnSfwDlx to simulate child process behavior - * without actual execution. Uses EventEmitter to simulate process exit events - * and signal handling. Validates that Socket CLI flags are filtered out before - * forwarding to sfw. - * - * Related Files: - src/commands/go/cmd-go.mts - Go wrapper command - * implementation - src/util/dlx/spawn.mts - DLX spawn utilities - - * src/util/process/cmd.mts - Flag filtering utilities. - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -const { cmdGo } = await import('../../../../src/commands/go/cmd-go.mts') - -describe('cmd-go', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdGo.description).toBe('Run go with Socket Firewall security') - }) - - it('should not be hidden', () => { - expect(cmdGo.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdGo.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation(args => { - const helpText = args.config.help('socket go') - expect(helpText).toContain('socket go') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - mockSpawnPromise.process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdGo.run( - [], - { url: import.meta.url }, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['get', 'github.com/gin-gonic/gin']) - - const runPromise = cmdGo.run( - ['get', 'github.com/gin-gonic/gin'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['get', 'github.com/gin-gonic/gin'], - config: expect.objectContaining({ - commandName: 'go', - description: 'Run go with Socket Firewall security', - hidden: false, - }), - importMeta: { url: import.meta.url }, - parentName: 'socket', - }) - }) - - it('should forward filtered arguments to spawnSfwDlx', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([ - 'install', - 'golang.org/x/tools/cmd/goimports', - ]) - - const runPromise = cmdGo.run( - [ - 'install', - 'golang.org/x/tools/cmd/goimports', - '--config', - 'socket.config.json', - ], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['go', 'install', 'golang.org/x/tools/cmd/goimports'], - { stdio: 'inherit' }, - ) - }) - - it('should filter out Socket CLI flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - const filteredArgs = ['mod', 'download'] - mockFilterFlags.mockReturnValue(filteredArgs) - - const runPromise = cmdGo.run( - ['mod', 'download', '--org', 'my-org'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['go', ...filteredArgs], { - stdio: 'inherit', - }) - }) - - it('should set default exit code to 1', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['get', 'package']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdGo.run(['get', 'package'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['get', 'package']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGo.run(['get', 'package'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['get', 'package']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdGo.run(['get', 'package'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGo.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['go'], { stdio: 'inherit' }) - - mockExit.mockRestore() - }) - - it('should handle context with parentName', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['version']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdGo.run(['version'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket', - }), - ) - - mockExit.mockRestore() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/install/cmd-install-completion.test.mts b/packages/cli/test/unit/commands/install/cmd-install-completion.test.mts deleted file mode 100644 index 1dec1e1a5..000000000 --- a/packages/cli/test/unit/commands/install/cmd-install-completion.test.mts +++ /dev/null @@ -1,226 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/personal-path-placeholders -- "testuser" is a fixture username in expected dry-run output strings; not a real personal path. */ -/** - * Unit tests for install completion command. - * - * Tests the command that installs bash tab completion for Socket CLI. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock dependencies. -const mockHandleInstallCompletion = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -) -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/install/handle-install-completion.mts', - () => ({ - handleInstallCompletion: mockHandleInstallCompletion, - }), -) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Import after mocks. -const { cmdInstallCompletion } = - await import('../../../../src/commands/install/cmd-install-completion.mts') - -describe('cmd-install-completion', () => { - const originalEnv = process.env - const originalExitCode = process.exitCode - - beforeEach(() => { - vi.clearAllMocks() - process.env = { ...originalEnv, HOME: '/home/testuser' } - process.exitCode = originalExitCode - }) - - afterEach(() => { - process.env = originalEnv - process.exitCode = originalExitCode - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdInstallCompletion.description).toBe( - 'Install bash completion for Socket CLI', - ) - }) - - it('should not be hidden', () => { - expect(cmdInstallCompletion.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-install-completion.mts' } - const context = { parentName: 'socket install' } - - it('should use default target name "socket" when no name provided', async () => { - await cmdInstallCompletion.run([], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should use custom target name when provided', async () => { - await cmdInstallCompletion.run(['sd'], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('sd') - }) - - it('should use relative path as target name', async () => { - await cmdInstallCompletion.run(['./sd'], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('./sd') - }) - - it('should handle absolute path as target name', async () => { - await cmdInstallCompletion.run( - ['/usr/local/bin/socket'], - importMeta, - context, - ) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith( - '/usr/local/bin/socket', - ) - }) - - describe('dry-run mode', () => { - it('should output dry-run message for default target', async () => { - await cmdInstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/home/testuser/.bashrc', - 'install bash completion for "socket"', - [ - 'Add completion script source command to ~/.bashrc', - 'Enable tab completion in current shell', - ], - ) - expect(mockHandleInstallCompletion).not.toHaveBeenCalled() - }) - - it('should output dry-run message for custom target', async () => { - await cmdInstallCompletion.run(['sd', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/home/testuser/.bashrc', - 'install bash completion for "sd"', - [ - 'Add completion script source command to ~/.bashrc', - 'Enable tab completion in current shell', - ], - ) - expect(mockHandleInstallCompletion).not.toHaveBeenCalled() - }) - - it('should use HOME environment variable for bashrc path', async () => { - process.env['HOME'] = '/custom/home' - - await cmdInstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - '/custom/home/.bashrc', - expect.any(String), - expect.any(Array), - ) - }) - - it('should return early and not call handler in dry-run mode', async () => { - await cmdInstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockHandleInstallCompletion).not.toHaveBeenCalled() - }) - }) - - describe('actual execution', () => { - it('should call handleInstallCompletion without dry-run', async () => { - await cmdInstallCompletion.run([], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledTimes(1) - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should not output dry-run message during actual execution', async () => { - await cmdInstallCompletion.run([], importMeta, context) - - expect(mockOutputDryRunWrite).not.toHaveBeenCalled() - }) - - it('should convert target name to string', async () => { - await cmdInstallCompletion.run(['test-cmd'], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('test-cmd') - expect(typeof mockHandleInstallCompletion.mock.calls[0][0]).toBe( - 'string', - ) - }) - }) - - describe('flag parsing', () => { - it('should handle --dry-run flag correctly', async () => { - await cmdInstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalled() - expect(mockHandleInstallCompletion).not.toHaveBeenCalled() - }) - - it('should handle --dryRun flag correctly', async () => { - await cmdInstallCompletion.run(['--dryRun'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalled() - expect(mockHandleInstallCompletion).not.toHaveBeenCalled() - }) - - it('should prioritize first input argument as target name', async () => { - await cmdInstallCompletion.run( - ['custom', 'ignored'], - importMeta, - context, - ) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('custom') - }) - }) - - describe('edge cases', () => { - it('should handle empty string as target name', async () => { - await cmdInstallCompletion.run([''], importMeta, context) - - // Empty string should be converted to string "socket" as default. - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should handle whitespace-only target name', async () => { - await cmdInstallCompletion.run([' '], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith(' ') - }) - - it('should handle target name with special characters', async () => { - await cmdInstallCompletion.run(['socket-@dev'], importMeta, context) - - expect(mockHandleInstallCompletion).toHaveBeenCalledWith('socket-@dev') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/install/cmd-install.test.mts b/packages/cli/test/unit/commands/install/cmd-install.test.mts deleted file mode 100644 index bcd8a08af..000000000 --- a/packages/cli/test/unit/commands/install/cmd-install.test.mts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Unit tests for install parent command. - * - * Tests the root command that provides access to subcommands for installing - * Socket CLI features like tab completion. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock meowWithSubcommands. -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowWithSubcommands: mockMeowWithSubcommands, - } - }, -) - -// Import after mocks. -const { cmdInstall } = - await import('../../../../src/commands/install/cmd-install.mts') - -describe('cmd-install', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdInstall.description).toBe('Install Socket CLI tab completion') - }) - - it('should not be hidden', () => { - expect(cmdInstall.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-install.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct parameters', async () => { - const argv = ['completion'] - - await cmdInstall.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - - const [meowConfig, options] = mockMeowWithSubcommands.mock.calls[0] - - // Verify config structure. - expect(meowConfig).toMatchObject({ - argv, - name: 'socket install', - importMeta, - subcommands: expect.objectContaining({ - completion: expect.objectContaining({ - description: expect.any(String), - hidden: expect.any(Boolean), - run: expect.any(Function), - }), - }), - }) - - // Verify options. - expect(options).toMatchObject({ - description: 'Install Socket CLI tab completion', - }) - }) - - it('should include completion subcommand', async () => { - await cmdInstall.run([], importMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.subcommands).toHaveProperty('completion') - expect(meowConfig.subcommands.completion).toMatchObject({ - description: expect.any(String), - hidden: expect.any(Boolean), - run: expect.any(Function), - }) - }) - - it('should construct correct command name from parentName', async () => { - const customContext = { parentName: 'custom-socket' } - - await cmdInstall.run([], importMeta, customContext) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.name).toBe('custom-socket install') - }) - - it('should pass through argv to meowWithSubcommands', async () => { - const customArgv = ['completion', '--help'] - - await cmdInstall.run(customArgv, importMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.argv).toBe(customArgv) - }) - - it('should pass through importMeta to meowWithSubcommands', async () => { - const customImportMeta = { url: 'file:///custom/path.mts' } - - await cmdInstall.run([], customImportMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.importMeta).toBe(customImportMeta) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/install/handle-install-completion.test.mts b/packages/cli/test/unit/commands/install/handle-install-completion.test.mts deleted file mode 100644 index cccdcbc2e..000000000 --- a/packages/cli/test/unit/commands/install/handle-install-completion.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Unit Tests: Tab Completion Installation Handler. - * - * Purpose: Tests the command handler that installs shell tab completion support - * for the Socket CLI. Validates the orchestration between setup and output - * modules for different shell environments (bash, zsh, fish, powershell). - * - * Test Coverage: - Successful completion installation for various shells - - * Installation failure handling - Multiple shell target support (bash, zsh, - * fish, powershell) - Empty and invalid target name handling - Unsupported - * shell detection - Async error propagation. - * - * Testing Approach: Mocks setupTabCompletion and outputInstallCompletion - * modules to test the handler's orchestration logic without actual file system - * modifications. Tests verify correct parameter passing and CResult pattern - * handling. - * - * Related Files: - src/commands/install/handle-install-completion.mts - Command - * handler - src/commands/install/setup-tab-completion.mts - Completion setup - * logic - src/commands/install/output-install-completion.mts - Output - * formatting. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleInstallCompletion } from '../../../../src/commands/install/handle-install-completion.mts' - -// Mock the dependencies. -vi.mock( - '../../../../src/commands/install/output-install-completion.mts', - () => ({ - outputInstallCompletion: vi.fn(), - }), -) -vi.mock('../../../../src/commands/install/setup-tab-completion.mts', () => ({ - setupTabCompletion: vi.fn(), -})) - -describe('handleInstallCompletion', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('installs completion successfully', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const { outputInstallCompletion } = - await import('../../../../src/commands/install/output-install-completion.mts') - - vi.mocked(setupTabCompletion).mockResolvedValue({ - ok: true, - value: 'Completion installed successfully', - }) - - await handleInstallCompletion('bash') - - expect(setupTabCompletion).toHaveBeenCalledWith('bash') - expect(outputInstallCompletion).toHaveBeenCalledWith({ - ok: true, - value: 'Completion installed successfully', - }) - }) - - it('handles installation failure', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const { outputInstallCompletion } = - await import('../../../../src/commands/install/output-install-completion.mts') - - const error = new Error('Failed to install completion') - vi.mocked(setupTabCompletion).mockResolvedValue({ - ok: false, - error, - }) - - await handleInstallCompletion('zsh') - - expect(setupTabCompletion).toHaveBeenCalledWith('zsh') - expect(outputInstallCompletion).toHaveBeenCalledWith({ - ok: false, - error, - }) - }) - - it('handles different shell targets', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const { outputInstallCompletion } = - await import('../../../../src/commands/install/output-install-completion.mts') - - const shells = ['bash', 'zsh', 'fish', 'powershell'] - - for (let i = 0, { length } = shells; i < length; i += 1) { - const shell = shells[i] - vi.mocked(setupTabCompletion).mockResolvedValue({ - ok: true, - value: `Completion for ${shell} installed`, - }) - - await handleInstallCompletion(shell) - - expect(setupTabCompletion).toHaveBeenCalledWith(shell) - expect(outputInstallCompletion).toHaveBeenCalledWith({ - ok: true, - value: `Completion for ${shell} installed`, - }) - } - }) - - it('handles empty target name', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const { outputInstallCompletion } = - await import('../../../../src/commands/install/output-install-completion.mts') - - vi.mocked(setupTabCompletion).mockResolvedValue({ - ok: false, - error: new Error('Invalid shell target'), - }) - - await handleInstallCompletion('') - - expect(setupTabCompletion).toHaveBeenCalledWith('') - expect(outputInstallCompletion).toHaveBeenCalledWith({ - ok: false, - error: new Error('Invalid shell target'), - }) - }) - - it('handles unsupported shell', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const { outputInstallCompletion } = - await import('../../../../src/commands/install/output-install-completion.mts') - - vi.mocked(setupTabCompletion).mockResolvedValue({ - ok: false, - error: new Error('Unsupported shell: tcsh'), - }) - - await handleInstallCompletion('tcsh') - - expect(setupTabCompletion).toHaveBeenCalledWith('tcsh') - expect(outputInstallCompletion).toHaveBeenCalledWith({ - ok: false, - error: new Error('Unsupported shell: tcsh'), - }) - }) - - it('handles async errors', async () => { - const { setupTabCompletion } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - - vi.mocked(setupTabCompletion).mockRejectedValue(new Error('Async error')) - - await expect(handleInstallCompletion('bash')).rejects.toThrow('Async error') - }) -}) diff --git a/packages/cli/test/unit/commands/install/output-install-completion.test.mts b/packages/cli/test/unit/commands/install/output-install-completion.test.mts deleted file mode 100644 index 0b2153278..000000000 --- a/packages/cli/test/unit/commands/install/output-install-completion.test.mts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Unit tests for install completion output formatting. - * - * Purpose: Tests the output formatting for tab completion installation results. - * - * Test Coverage: - outputInstallCompletion function - Success output with - * actions and instructions - Error handling. - * - * Related Files: - src/commands/install/output-install-completion.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -import { outputInstallCompletion } from '../../../../src/commands/install/output-install-completion.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-install-completion', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputInstallCompletion', () => { - const mockSuccessData = { - actions: ['Created completion script', 'Updated .bashrc'], - bashrcPath: '/home/user/.bashrc', - completionCommand: 'complete -F _socket socket', - bashrcUpdated: true, - foundBashrc: true, - sourcingCommand: 'source ~/.socket/completion.bash', - targetName: 'socket', - targetPath: '/home/user/.socket/completion.bash', - } - - describe('success output', () => { - it('outputs installation complete message', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputInstallCompletion(result) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Installation of tab completion') - expect(logs).toContain('socket') - expect(logs).toContain('finished!') - }) - - it('outputs all actions', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputInstallCompletion(result) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Created completion script') - expect(logs).toContain('Updated .bashrc') - }) - - it('outputs reload instructions', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputInstallCompletion(result) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('source ~/.bashrc') - expect(logs).toContain(mockSuccessData.targetPath) - expect(logs).toContain(mockSuccessData.completionCommand) - }) - - it('mentions automatic enablement in new terminals', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputInstallCompletion(result) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('automatically') - expect(logs).toContain('new terminals') - }) - }) - - describe('error output', () => { - it('outputs error with fail message', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Installation failed', - cause: 'No write permission', - } - - await outputInstallCompletion(result) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Installation failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Failed', - code: 127, - } - - await outputInstallCompletion(result) - - expect(process.exitCode).toBe(127) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/install/setup-tab-completion.test.mts b/packages/cli/test/unit/commands/install/setup-tab-completion.test.mts deleted file mode 100644 index c26106ea9..000000000 --- a/packages/cli/test/unit/commands/install/setup-tab-completion.test.mts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Unit tests for setupTabCompletion. - * - * Sets up bash tab completion: writes the completion script to its install - * target (creating the parent dir when needed) and appends a source line to - * ~/.bashrc when one isn't already present. - * - * Related Files: - * - * - Src/commands/install/setup-tab-completion.mts - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFileSync = vi.hoisted(() => vi.fn()) -const mockAppendFileSync = vi.hoisted(() => vi.fn()) -const mockWriteFileSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - appendFileSync: mockAppendFileSync, - writeFileSync: mockWriteFileSync, - default: { - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - appendFileSync: mockAppendFileSync, - writeFileSync: mockWriteFileSync, - }, -})) - -const mockSafeMkdirSync = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeMkdirSync: mockSafeMkdirSync, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), -})) - -const mockGetBashrcDetails = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/cli/completion.mts', () => ({ - getBashrcDetails: mockGetBashrcDetails, -})) - -const mockGetCliVersionHash = vi.hoisted(() => vi.fn(() => 'v1.2.3')) -vi.mock('../../../../src/env/cli-version-hash.mts', () => ({ - getCliVersionHash: mockGetCliVersionHash, -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - homePath: '/home/user', -})) - -import { - setupTabCompletion, - updateInstalledTabCompletionScript, -} from '../../../../src/commands/install/setup-tab-completion.mts' - -describe('setupTabCompletion', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetBashrcDetails.mockReturnValue({ - ok: true, - data: { - completionCommand: 'complete -F _socket_complete socket', - sourcingCommand: 'source ~/.local/share/socket/completion.bash', - targetPath: '/home/user/.local/share/socket/completion.bash', - toAddToBashrc: '\n# Socket completion\nsource ...\n', - }, - }) - // Default: target dir exists, source script exists. - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('completion script content') - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('returns error when getBashrcDetails fails', async () => { - mockGetBashrcDetails.mockReturnValueOnce({ - ok: false, - message: 'Unsupported shell', - }) - - const result = await setupTabCompletion('socket') - - expect(result.ok).toBe(false) - }) - - it('creates target dir when it does not exist', async () => { - mockExistsSync.mockImplementation( - (p: string) => p !== '/home/user/.local/share/socket', - ) - - await setupTabCompletion('socket') - - expect(mockSafeMkdirSync).toHaveBeenCalledWith( - '/home/user/.local/share/socket', - { recursive: true }, - ) - }) - - it('appends sourcing line to .bashrc when missing', async () => { - mockReadFileSync.mockReturnValueOnce('completion script content') - mockReadFileSync.mockReturnValueOnce('# unrelated bashrc') - - const result = await setupTabCompletion('socket') - - expect(mockAppendFileSync).toHaveBeenCalled() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.bashrcUpdated).toBe(true) - } - }) - - it('does not re-append when sourcing line is already present', async () => { - // First read: completion script (for write). - // Second read: bashrc that already contains the sourcing line. - mockReadFileSync.mockReturnValueOnce('completion script content') - mockReadFileSync.mockReturnValueOnce( - 'source ~/.local/share/socket/completion.bash\n', - ) - - const result = await setupTabCompletion('socket') - - expect(mockAppendFileSync).not.toHaveBeenCalled() - if (result.ok) { - expect(result.data.bashrcUpdated).toBe(false) - expect(result.data.foundBashrc).toBe(true) - } - }) - - it('handles missing .bashrc gracefully', async () => { - mockExistsSync.mockImplementation( - (p: string) => - // Source script + target dir exist, .bashrc does not. - !p.endsWith('.bashrc'), - ) - - const result = await setupTabCompletion('socket') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.foundBashrc).toBe(false) - expect(result.data.bashrcUpdated).toBe(false) - } - }) - - it('swallows readFileSync errors on .bashrc (deleted between checks)', async () => { - // First read for completion script. - mockReadFileSync.mockReturnValueOnce('completion script content') - // Second read (.bashrc) throws. - mockReadFileSync.mockImplementationOnce(() => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - - const result = await setupTabCompletion('socket') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.bashrcUpdated).toBe(false) - } - }) -}) - -describe('updateInstalledTabCompletionScript', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('VERSION=%SOCKET_VERSION_TOKEN%') - }) - - it('writes the completion script with version token replaced', () => { - const result = updateInstalledTabCompletionScript('/target/completion.bash') - - expect(result.ok).toBe(true) - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/target/completion.bash', - 'VERSION=v1.2.3', - 'utf8', - ) - }) - - it('returns error when source completion script is missing', () => { - mockExistsSync.mockReturnValue(false) - - const result = updateInstalledTabCompletionScript('/target/completion.bash') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Source not found') - } - }) -}) diff --git a/packages/cli/test/unit/commands/json/cmd-json.test.mts b/packages/cli/test/unit/commands/json/cmd-json.test.mts deleted file mode 100644 index 67e71735b..000000000 --- a/packages/cli/test/unit/commands/json/cmd-json.test.mts +++ /dev/null @@ -1,175 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/personal-path-placeholders -- "test" is a fixture username in mocked cwd / path test inputs; not a real personal path. */ -/** - * Unit tests for json command. - * - * Tests the command that displays the socket.json configuration that would be - * applied for a target folder. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockHandleCmdJson = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/json/handle-cmd-json.mts', () => ({ - handleCmdJson: mockHandleCmdJson, -})) - -// Mock process.cwd to control the current working directory. -const mockCwd = vi.hoisted(() => vi.fn()) -vi.stubGlobal('process', { - ...process, - cwd: mockCwd, -}) - -// Import after mocks. -const { cmdJson } = await import('../../../../src/commands/json/cmd-json.mts') - -describe('cmd-json', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCwd.mockReturnValue('/Users/test/project') - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdJson.description).toBe( - 'Display the `socket.json` that would be applied for target folder', - ) - }) - - it('should be hidden', () => { - expect(cmdJson.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-json.mts' } - const context = { parentName: 'socket' } - - it('should call handleCmdJson with current directory when no CWD provided', async () => { - await cmdJson.run([], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/project') - }) - - it('should call handleCmdJson with relative path resolved against cwd', async () => { - await cmdJson.run(['./subdir'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/subdir', - ) - }) - - it('should call handleCmdJson with relative parent path', async () => { - await cmdJson.run(['../other'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/other') - }) - - it('should call handleCmdJson with absolute path unchanged', async () => { - await cmdJson.run(['/absolute/path/to/project'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/absolute/path/to/project', - ) - }) - - it('should handle dot as current directory', async () => { - await cmdJson.run(['.'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/project') - }) - - it('should handle nested relative paths', async () => { - await cmdJson.run(['./foo/bar/baz'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/foo/bar/baz', - ) - }) - - it('should handle paths with trailing slash', async () => { - await cmdJson.run(['./subdir/'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/subdir', - ) - }) - - it('should handle readonly argv array', async () => { - const readonlyArgv: readonly string[] = ['./target'] - await cmdJson.run(readonlyArgv, importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/target', - ) - }) - - it('should only use first argument as CWD', async () => { - await cmdJson.run(['./dir1', './dir2', './dir3'], importMeta, context) - - // Should only use first argument. - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/project/dir1') - expect(mockHandleCmdJson).toHaveBeenCalledTimes(1) - }) - - it('should resolve complex relative paths correctly', async () => { - await cmdJson.run(['./foo/../bar'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/project/bar') - }) - - it('should handle absolute paths on different root', async () => { - await cmdJson.run(['/var/www/app'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/var/www/app') - }) - - it('should not modify absolute paths with process.cwd', async () => { - mockCwd.mockReturnValue('/different/cwd') - - await cmdJson.run(['/absolute/path'], importMeta, context) - - // Absolute path should not be affected by cwd. - expect(mockHandleCmdJson).toHaveBeenCalledWith('/absolute/path') - }) - - it('should handle paths with special characters', async () => { - await cmdJson.run(['./my-project'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/my-project', - ) - }) - - it('should handle paths with spaces correctly', async () => { - await cmdJson.run(['./my project'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith( - '/Users/test/project/my project', - ) - }) - - it('should call handleCmdJson exactly once', async () => { - await cmdJson.run(['./target'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledTimes(1) - }) - - it('should handle empty string as CWD', async () => { - await cmdJson.run([''], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test/project') - }) - - it('should resolve multiple parent directory references', async () => { - mockCwd.mockReturnValue('/Users/test/deep/nested/path') - - await cmdJson.run(['../../..'], importMeta, context) - - expect(mockHandleCmdJson).toHaveBeenCalledWith('/Users/test') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/json/handle-cmd-json.test.mts b/packages/cli/test/unit/commands/json/handle-cmd-json.test.mts deleted file mode 100644 index 3ca88fe7d..000000000 --- a/packages/cli/test/unit/commands/json/handle-cmd-json.test.mts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Unit Tests: JSON Output Command Handler. - * - * Purpose: Tests the command handler that outputs Socket CLI scan and - * configuration data in JSON format. Validates path handling (absolute, - * relative, current directory, Windows-style) and proper delegation to the - * output module. - * - * Test Coverage: - * - * - JSON output for given directories - * - Current directory handling (.) - * - Absolute path support - * - Relative path support - * - Empty path handling - * - Windows-style path support (C:) - * - Async error propagation - * - Single invocation verification - * - * Testing Approach: Mocks outputCmdJson module to test the handler's path - * forwarding logic without actual JSON generation. Tests verify correct - * parameter passing and error handling. - * - * Related Files: - * - * - Src/commands/json/handle-cmd-json.mts - Command handler - * - Src/commands/json/output-cmd-json.mts - JSON output formatting - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleCmdJson } from '../../../../src/commands/json/handle-cmd-json.mts' - -// Mock the dependencies. -vi.mock('../../../../src/commands/json/output-cmd-json.mts', () => ({ - outputCmdJson: vi.fn(), -})) - -describe('handleCmdJson', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('outputs JSON for given directory', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - await handleCmdJson('/test/project') - - expect(outputCmdJson).toHaveBeenCalledWith('/test/project') - }) - - it('handles current directory', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - await handleCmdJson('.') - - expect(outputCmdJson).toHaveBeenCalledWith('.') - }) - - it('handles absolute path', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - await handleCmdJson('/absolute/path/to/project') - - expect(outputCmdJson).toHaveBeenCalledWith('/absolute/path/to/project') - }) - - it('handles relative path', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - await handleCmdJson('../../../../src/commands/relative/path') - - expect(outputCmdJson).toHaveBeenCalledWith( - '../../../../src/commands/relative/path', - ) - }) - - it('handles empty path', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - await handleCmdJson('') - - expect(outputCmdJson).toHaveBeenCalledWith('') - }) - - it('handles async errors', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - vi.mocked(outputCmdJson).mockRejectedValue(new Error('Output error')) - - await expect(handleCmdJson('/test')).rejects.toThrow('Output error') - }) - - it('is called exactly once per invocation', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - vi.mocked(outputCmdJson).mockResolvedValue(undefined) - - await handleCmdJson('/path') - - expect(outputCmdJson).toHaveBeenCalledTimes(1) - }) - - it('handles Windows-style paths', async () => { - const { outputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - - vi.mocked(outputCmdJson).mockResolvedValue(undefined) - - await handleCmdJson('C:\\Users\\test\\project') - - expect(outputCmdJson).toHaveBeenCalledWith('C:\\Users\\test\\project') - }) -}) diff --git a/packages/cli/test/unit/commands/json/output-cmd-json.test.mts b/packages/cli/test/unit/commands/json/output-cmd-json.test.mts deleted file mode 100644 index ccadcca27..000000000 --- a/packages/cli/test/unit/commands/json/output-cmd-json.test.mts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @file Unit tests for json command output. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockSafeReadFileSync = vi.fn() -const mockSafeStatsSync = vi.fn() - -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - safeReadFileSync: (...args: unknown[]) => mockSafeReadFileSync(...args), -})) -vi.mock('@socketsecurity/lib-stable/fs/inspect', () => ({ - safeStatSync: (...args: unknown[]) => mockSafeStatsSync(...args), -})) - -import { outputCmdJson } from '../../../../src/commands/json/output-cmd-json.mts' - -describe('output-cmd-json', () => { - const originalExitCode = process.exitCode - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - afterEach(() => { - process.exitCode = originalExitCode - }) - - describe('outputCmdJson', () => { - it('logs info about target cwd', async () => { - mockExistsSync.mockReturnValue(false) - - await outputCmdJson('/test/path') - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Target cwd:', - expect.any(String), - ) - }) - - it('handles socket.json not found', async () => { - mockExistsSync.mockReturnValue(false) - - await outputCmdJson('/test/path') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Not found'), - ) - expect(process.exitCode).toBe(1) - }) - - it('handles non-file (directory) path', async () => { - mockExistsSync.mockReturnValue(true) - mockSafeStatsSync.mockReturnValue({ - isFile: () => false, - }) - - await outputCmdJson('/test/path') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('not a regular file'), - ) - expect(process.exitCode).toBe(1) - }) - - it('successfully reads and outputs socket.json contents', async () => { - const mockContent = JSON.stringify({ version: '1.0.0' }, null, 2) - mockExistsSync.mockReturnValue(true) - mockSafeStatsSync.mockReturnValue({ - isFile: () => true, - }) - mockSafeReadFileSync.mockReturnValue(mockContent) - - await outputCmdJson('/test/path') - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('contents of'), - ) - expect(mockLogger.log).toHaveBeenCalledWith(mockContent) - expect(process.exitCode).toBeUndefined() - }) - - it('handles null safeStatSync result', async () => { - mockExistsSync.mockReturnValue(true) - mockSafeStatsSync.mockReturnValue(undefined) - - await outputCmdJson('/test/path') - - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('uses tildified paths when VITEST is false', async () => { - // Re-import with VITEST mocked to false to exercise the tildify branch. - vi.resetModules() - vi.doMock('../../../../src/env/vitest.mts', () => ({ VITEST: false })) - - const { outputCmdJson: realOutputCmdJson } = - await import('../../../../src/commands/json/output-cmd-json.mts') - mockExistsSync.mockReturnValue(false) - await realOutputCmdJson('/test/path') - - // When VITEST=false, the path is tildified rather than redacted. - // The info message should not contain "[REDACTED]". - const infoCalls = mockLogger.info.mock.calls.flat().join(' ') - expect(infoCalls).not.toContain('[REDACTED]') - - vi.doUnmock('../../../../src/env/vitest.mts') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/login/apply-login.test.mts b/packages/cli/test/unit/commands/login/apply-login.test.mts deleted file mode 100644 index 6991d7ad4..000000000 --- a/packages/cli/test/unit/commands/login/apply-login.test.mts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Unit tests for login apply utilities. - * - * Purpose: Tests the applyLogin function that updates CLI configuration. - * - * Test Coverage: - Config value updates - Token storage - Enforced orgs storage. - * - * Related Files: - commands/login/apply-login.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/config.mts', () => ({ - updateConfigValue: mockUpdateConfigValue, -})) - -import { applyLogin } from '../../../../src/commands/login/apply-login.mts' - -describe('apply-login', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('applyLogin', () => { - it('updates all config values', () => { - applyLogin( - 'test-token', - ['org1', 'org2'], - 'https://api.example.com', - 'http://proxy', - ) - - expect(mockUpdateConfigValue).toHaveBeenCalledTimes(4) - expect(mockUpdateConfigValue).toHaveBeenCalledWith('enforcedOrgs', [ - 'org1', - 'org2', - ]) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiToken', - 'test-token', - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiBaseUrl', - 'https://api.example.com', - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiProxy', - 'http://proxy', - ) - }) - - it('handles undefined apiBaseUrl', () => { - applyLogin('test-token', ['org1'], undefined, undefined) - - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiBaseUrl', - undefined, - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith('apiProxy', undefined) - }) - - it('handles empty enforced orgs', () => { - applyLogin('test-token', [], undefined, undefined) - - expect(mockUpdateConfigValue).toHaveBeenCalledWith('enforcedOrgs', []) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/login/attempt-login.test.mts b/packages/cli/test/unit/commands/login/attempt-login.test.mts deleted file mode 100644 index e392b1f92..000000000 --- a/packages/cli/test/unit/commands/login/attempt-login.test.mts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Unit tests for attemptLogin handler. - * - * Drives the interactive login flow: prompts for API token, verifies it against - * the SDK, prompts for enforced org / tab-completion, and persists via - * applyLogin. All prompts and SDK calls are mocked. - * - * Related Files: - src/commands/login/attempt-login.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockPassword = vi.hoisted(() => vi.fn()) -const mockSelect = vi.hoisted(() => vi.fn()) -const mockConfirm = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - password: mockPassword, - select: mockSelect, - confirm: mockConfirm, -})) - -const mockGetConfigValueOrUndef = vi.hoisted(() => vi.fn()) -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn().mockReturnValue(false)) -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, - isConfigFromFlag: mockIsConfigFromFlag, - updateConfigValue: mockUpdateConfigValue, -})) - -const mockSetupSdk = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, -})) - -const mockFetchOrganization = vi.hoisted(() => vi.fn()) -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -const mockGetEnterpriseOrgs = vi.hoisted(() => vi.fn()) -const mockGetOrgSlugs = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/organization.mts', () => ({ - getEnterpriseOrgs: mockGetEnterpriseOrgs, - getOrgSlugs: mockGetOrgSlugs, -})) - -const mockApplyLogin = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/login/apply-login.mts', () => ({ - applyLogin: mockApplyLogin, -})) - -const mockSetupTabCompletion = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/install/setup-tab-completion.mts', () => ({ - setupTabCompletion: mockSetupTabCompletion, -})) - -const mockSocketDocsLink = vi.hoisted(() => vi.fn(() => 'docs-link')) -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - socketDocsLink: mockSocketDocsLink, -})) - -const mockFailMsgWithBadge = vi.hoisted(() => - vi.fn((msg, cause) => `[fail] ${msg}: ${cause}`), -) -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, -})) - -import { attemptLogin } from '../../../../src/commands/login/attempt-login.mts' - -describe('attemptLogin', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockGetConfigValueOrUndef.mockReturnValue(undefined) - mockGetOrgSlugs.mockReturnValue(['my-org']) - mockGetEnterpriseOrgs.mockReturnValue([]) - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { organizations: [{ id: 'org-id', name: 'my-org' }] }, - }) - mockSetupSdk.mockResolvedValue({ ok: true, data: {} }) - mockSelect.mockResolvedValue(false) - mockConfirm.mockResolvedValue(true) - mockSetupTabCompletion.mockResolvedValue({ ok: true }) - }) - - it('returns canceled when user dismisses the password prompt', async () => { - mockPassword.mockResolvedValueOnce(undefined) - - const result = await attemptLogin(undefined, undefined) - - expect(result).toEqual({ - ok: false, - message: 'Canceled', - cause: 'Canceled by user', - }) - expect(mockLogger.fail).toHaveBeenCalledWith('Canceled by user') - }) - - it('falls back to public API token when password is empty string', async () => { - mockPassword.mockResolvedValueOnce('') - - await attemptLogin('https://api.socket.dev', undefined) - - expect(mockSetupSdk).toHaveBeenCalledWith( - expect.objectContaining({ - apiToken: expect.any(String), - apiBaseUrl: 'https://api.socket.dev', - }), - ) - }) - - it('fails when SDK setup returns error', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - message: 'SDK error', - cause: 'bad token', - }) - - await attemptLogin(undefined, undefined) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('fails when fetchOrganization returns error', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockFetchOrganization.mockResolvedValueOnce({ - ok: false, - message: 'fetch failed', - cause: 'no auth', - }) - - await attemptLogin(undefined, undefined) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('fails when account has no organizations', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetOrgSlugs.mockReturnValueOnce([]) - - const result = await attemptLogin(undefined, undefined) - - expect(result).toMatchObject({ - ok: false, - message: expect.stringContaining('No organizations'), - }) - }) - - it('happy path with no enterprise orgs persists default org and applies login', async () => { - mockPassword.mockResolvedValueOnce('user-token') - - await attemptLogin(undefined, undefined) - - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - expect.any(String), - 'my-org', - ) - expect(mockApplyLogin).toHaveBeenCalled() - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('API credentials'), - ) - }) - - it('prompts to enforce when there are multiple enterprise orgs', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetEnterpriseOrgs.mockReturnValueOnce([ - { id: 'a', name: 'Acme' }, - { id: 'b', name: 'Beta' }, - ]) - // First select for enforced org, second for tab completion. - mockSelect.mockResolvedValueOnce('a').mockResolvedValueOnce(false) - - await attemptLogin(undefined, undefined) - - expect(mockApplyLogin).toHaveBeenCalledWith( - expect.any(String), - ['a'], - undefined, - undefined, - ) - }) - - it('returns canceled when enforced-org select is dismissed', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetEnterpriseOrgs.mockReturnValueOnce([ - { id: 'a', name: 'Acme' }, - { id: 'b', name: 'Beta' }, - ]) - mockSelect.mockResolvedValueOnce(undefined) - - const result = await attemptLogin(undefined, undefined) - - expect(result).toMatchObject({ ok: false, message: 'Canceled' }) - }) - - it('confirms enforcement for a single enterprise org', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetEnterpriseOrgs.mockReturnValueOnce([{ id: 'a', name: 'Acme' }]) - mockConfirm.mockResolvedValueOnce(true) - - await attemptLogin(undefined, undefined) - - expect(mockApplyLogin).toHaveBeenCalledWith( - expect.any(String), - ['a'], - undefined, - undefined, - ) - }) - - it('returns canceled when single-org confirm is dismissed', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetEnterpriseOrgs.mockReturnValueOnce([{ id: 'a', name: 'Acme' }]) - mockConfirm.mockResolvedValueOnce(undefined) - - const result = await attemptLogin(undefined, undefined) - - expect(result).toMatchObject({ ok: false, message: 'Canceled' }) - }) - - it('returns canceled when tab-completion select is dismissed', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockSelect.mockResolvedValueOnce(undefined) - - const result = await attemptLogin(undefined, undefined) - - expect(result).toMatchObject({ ok: false, message: 'Canceled' }) - }) - - it('runs tab completion installer when user accepts', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockSelect.mockResolvedValueOnce(true) - - await attemptLogin(undefined, undefined) - - expect(mockSetupTabCompletion).toHaveBeenCalledWith('socket') - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('Tab completion'), - ) - }) - - it('logs failure when tab completion installer fails', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockSelect.mockResolvedValueOnce(true) - mockSetupTabCompletion.mockResolvedValueOnce({ ok: false }) - - await attemptLogin(undefined, undefined) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Failed to install tab completion'), - ) - }) - - it('warns when config is in read-only mode (flag override)', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockIsConfigFromFlag.mockReturnValueOnce(true) - - await attemptLogin(undefined, undefined) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('read-only'), - ) - }) - - it('reports failure when applyLogin throws', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockApplyLogin.mockImplementationOnce(() => { - throw new Error('apply failed') - }) - - await attemptLogin(undefined, undefined) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalledWith('API login failed') - }) - - it('reports refreshed when token matches the previously persisted one', async () => { - mockPassword.mockResolvedValueOnce('same-token') - mockGetConfigValueOrUndef.mockImplementation(key => { - if (key === 'apiToken') { - return 'same-token' - } - return undefined - }) - - await attemptLogin(undefined, undefined) - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('refreshed'), - ) - }) - - it('reports updated when a different token was previously persisted', async () => { - mockPassword.mockResolvedValueOnce('new-token') - mockGetConfigValueOrUndef.mockImplementation(key => { - if (key === 'apiToken') { - return 'old-token' - } - return undefined - }) - - await attemptLogin(undefined, undefined) - - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('updated'), - ) - }) - - it('handles enterprise org with no name (fallback to undefined label)', async () => { - mockPassword.mockResolvedValueOnce('user-token') - // First org missing name → label coerces to 'undefined'. - mockGetEnterpriseOrgs.mockReturnValueOnce([ - { id: 'a' }, - { id: 'b', name: 'Beta' }, - ]) - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce(false) - - await attemptLogin(undefined, undefined) - - // Empty enforced selection means no enforced orgs. - expect(mockApplyLogin).toHaveBeenCalledWith( - expect.any(String), - [], - undefined, - undefined, - ) - }) - - it('handles single enterprise org with missing name (label fallback)', async () => { - mockPassword.mockResolvedValueOnce('user-token') - // Missing name → label fallback to 'undefined' string keeps confirm path. - mockGetEnterpriseOrgs.mockReturnValueOnce([{ id: 'a' }]) - mockConfirm.mockResolvedValueOnce(true) - - await attemptLogin(undefined, undefined) - - expect(mockApplyLogin).toHaveBeenCalledWith( - expect.any(String), - ['a'], - undefined, - undefined, - ) - }) - - it('does not enforce when single-org confirm is declined', async () => { - mockPassword.mockResolvedValueOnce('user-token') - mockGetEnterpriseOrgs.mockReturnValueOnce([{ id: 'a', name: 'Acme' }]) - mockConfirm.mockResolvedValueOnce(false) - - await attemptLogin(undefined, undefined) - - expect(mockApplyLogin).toHaveBeenCalledWith( - expect.any(String), - [], - undefined, - undefined, - ) - }) -}) diff --git a/packages/cli/test/unit/commands/login/cmd-login.test.mts b/packages/cli/test/unit/commands/login/cmd-login.test.mts deleted file mode 100644 index 38f7263c2..000000000 --- a/packages/cli/test/unit/commands/login/cmd-login.test.mts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Unit tests for login command. - * - * Tests the command entry point that handles Socket API authentication and - * stores credentials for subsequent CLI operations. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { InputError } from '../../../../src/util/error/errors.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock isInteractive. -const mockIsInteractive = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('@socketregistry/is-interactive/index.cjs', () => ({ - default: mockIsInteractive, -})) - -// Mock attemptLogin. -const mockAttemptLogin = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) - -vi.mock('../../../../src/commands/login/attempt-login.mts', () => ({ - attemptLogin: mockAttemptLogin, -})) - -// Mock outputDryRunWrite. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Import after mocks. -const { cmdLogin, CMD_NAME } = - await import('../../../../src/commands/login/cmd-login.mts') - -describe('cmd-login', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsInteractive.mockReturnValue(true) - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct command name', () => { - expect(CMD_NAME).toBe('login') - }) - - it('should have correct description', () => { - expect(cmdLogin.description).toBe( - 'Setup Socket CLI with an API token and defaults', - ) - }) - - it('should not be hidden', () => { - expect(cmdLogin.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-login.mts' } - const context = { parentName: 'socket' } - - describe('help flag', () => { - it('should display help text with --help flag', async () => { - await expect( - cmdLogin.run(['--help'], importMeta, context), - ).rejects.toThrow() - - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - }) - - describe('dry-run flag', () => { - it('should show preview without performing login', async () => { - await cmdLogin.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('config.json'), - 'authenticate with Socket API', - expect.arrayContaining([ - 'Prompt for Socket API token', - 'Verify token with Socket API', - 'Save API token to config', - 'Optionally set default organization', - 'Optionally install bash completion', - ]), - ) - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - - it('should not perform authentication in dry-run mode', async () => { - await cmdLogin.run(['--dry-run'], importMeta, context) - - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - }) - - describe('non-interactive shell', () => { - it('should throw InputError when not in interactive shell', async () => { - mockIsInteractive.mockReturnValue(false) - - await expect(cmdLogin.run([], importMeta, context)).rejects.toThrow( - /socket login needs an interactive TTY/, - ) - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - - it('should suggest using SOCKET_CLI_API_TOKEN environment variable', async () => { - mockIsInteractive.mockReturnValue(false) - - try { - await cmdLogin.run([], importMeta, context) - expect.fail('Should have thrown an error') - } catch (e) { - const error = e as InputError - expect(error.message).toContain('SOCKET_CLI_API_TOKEN') - } - }) - }) - - describe('login execution', () => { - it('should call attemptLogin with empty strings by default', async () => { - await cmdLogin.run([], importMeta, context) - - expect(mockAttemptLogin).toHaveBeenCalledWith('', '') - }) - - it('should pass API base URL when provided', async () => { - await cmdLogin.run( - ['--api-base-url=https://api.example.com'], - importMeta, - context, - ) - - expect(mockAttemptLogin).toHaveBeenCalledWith( - 'https://api.example.com', - '', - ) - }) - - it('should pass API proxy when provided', async () => { - await cmdLogin.run( - ['--api-proxy=http://localhost:8080'], - importMeta, - context, - ) - - expect(mockAttemptLogin).toHaveBeenCalledWith( - '', - 'http://localhost:8080', - ) - }) - - it('should pass both API base URL and proxy when provided', async () => { - await cmdLogin.run( - [ - '--api-base-url=https://api.example.com', - '--api-proxy=http://localhost:8080', - ], - importMeta, - context, - ) - - expect(mockAttemptLogin).toHaveBeenCalledWith( - 'https://api.example.com', - 'http://localhost:8080', - ) - }) - - it('should handle empty string API base URL', async () => { - await cmdLogin.run(['--api-base-url='], importMeta, context) - - expect(mockAttemptLogin).toHaveBeenCalledWith('', '') - }) - - it('should handle empty string API proxy', async () => { - await cmdLogin.run(['--api-proxy='], importMeta, context) - - expect(mockAttemptLogin).toHaveBeenCalledWith('', '') - }) - }) - - describe('flag validation', () => { - it('should accept valid --api-base-url format', async () => { - const validUrls = [ - 'https://api.socket.dev', - 'http://localhost:3000', - 'https://staging.example.com', - ] - - for (let i = 0, { length } = validUrls; i < length; i += 1) { - const url = validUrls[i] - mockAttemptLogin.mockClear() - await cmdLogin.run([`--api-base-url=${url}`], importMeta, context) - expect(mockAttemptLogin).toHaveBeenCalledWith(url, '') - } - }) - - it('should accept valid --api-proxy format', async () => { - const validProxies = [ - 'http://localhost:1234', - 'https://proxy.example.com:8080', - 'socks5://127.0.0.1:9050', - ] - - for (let i = 0, { length } = validProxies; i < length; i += 1) { - const proxy = validProxies[i] - mockAttemptLogin.mockClear() - await cmdLogin.run([`--api-proxy=${proxy}`], importMeta, context) - expect(mockAttemptLogin).toHaveBeenCalledWith('', proxy) - } - }) - }) - - describe('error handling', () => { - it('should propagate errors from attemptLogin', async () => { - const testError = new Error('Authentication failed') - mockAttemptLogin.mockRejectedValue(testError) - - await expect(cmdLogin.run([], importMeta, context)).rejects.toThrow( - 'Authentication failed', - ) - }) - - it('should not call attemptLogin when dry-run is enabled', async () => { - await cmdLogin.run(['--dry-run'], importMeta, context) - - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - - it('should not call attemptLogin when not interactive', async () => { - mockIsInteractive.mockReturnValue(false) - - await expect(cmdLogin.run([], importMeta, context)).rejects.toThrow() - expect(mockAttemptLogin).not.toHaveBeenCalled() - }) - }) - - describe('execution flow', () => { - it('should check interactivity before calling attemptLogin', async () => { - mockIsInteractive.mockReturnValue(true) - mockAttemptLogin.mockResolvedValue(undefined) - - await cmdLogin.run([], importMeta, context) - - expect(mockIsInteractive).toHaveBeenCalled() - expect(mockAttemptLogin).toHaveBeenCalled() - }) - - it('should call attemptLogin exactly once per successful run', async () => { - mockAttemptLogin.mockResolvedValue(undefined) - - await cmdLogin.run([], importMeta, context) - - expect(mockAttemptLogin).toHaveBeenCalledTimes(1) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/logout/cmd-logout.test.mts b/packages/cli/test/unit/commands/logout/cmd-logout.test.mts deleted file mode 100644 index 1dbc9de75..000000000 --- a/packages/cli/test/unit/commands/logout/cmd-logout.test.mts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * Unit tests for logout command. - * - * Tests the command that logs out of Socket API and clears credentials. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ConfigModule from '../../../../src/util/config.mts' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock config utilities. -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) - -vi.mock('../../../../src/util/config.mts', async importOriginal => { - const actual = await importOriginal<typeof ConfigModule>() - return { - ...actual, - isConfigFromFlag: mockIsConfigFromFlag, - updateConfigValue: mockUpdateConfigValue, - } -}) - -// Mock dry-run output. -const mockOutputDryRunDelete = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunDelete: mockOutputDryRunDelete, -})) - -// Mock meowOrExit to prevent actual CLI parsing issues. Also invoke -// the help() callback so its template-string body is recorded as -// covered; production meowOrExit only invokes it on --help, which -// the test suite never exercises. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = {} - - // Parse flags from argv. - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - if (argv.includes('--json')) { - flags['json'] = true - } - if (argv.includes('--markdown')) { - flags['markdown'] = true - } - - const help = options.config?.help - ? options.config.help('socket logout') - : '' - - return { - flags, - input: [], - pkg: {}, - help, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { cmdLogout, CMD_NAME } = - await import('../../../../src/commands/logout/cmd-logout.mts') - -describe('cmd-logout', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockIsConfigFromFlag.mockReturnValue(false) - }) - - describe('command metadata', () => { - it('should export CMD_NAME as logout', () => { - expect(CMD_NAME).toBe('logout') - }) - - it('should have correct description', () => { - expect(cmdLogout.description).toBe('Socket API logout') - }) - - it('should not be hidden', () => { - expect(cmdLogout.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-logout.mts' } - const context = { parentName: 'socket' } - - describe('--help flag', () => { - it('should call meowOrExit with help configuration', async () => { - await cmdLogout.run(['--help'], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - argv: ['--help'], - parentName: 'socket', - config: expect.objectContaining({ - commandName: 'logout', - description: 'Socket API logout', - hidden: false, - }), - }), - ) - }) - }) - - describe('--dry-run flag', () => { - it('should show preview without performing logout', async () => { - await cmdLogout.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalledWith( - 'Socket API credentials', - expect.stringContaining('/.config/socket/config.json'), - ) - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - expect(mockLogger.success).not.toHaveBeenCalled() - }) - - it('should construct correct config path in dry-run', async () => { - const originalHome = process.env['HOME'] - process.env['HOME'] = '/test/home' - - await cmdLogout.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalledWith( - 'Socket API credentials', - '/test/home/.config/socket/config.json', - ) - - process.env['HOME'] = originalHome - }) - - it('should not execute any config changes in dry-run', async () => { - await cmdLogout.run(['--dry-run'], importMeta, context) - - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - expect(mockLogger.success).not.toHaveBeenCalled() - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - }) - - describe('config cleanup behavior', () => { - it('should clear all Socket credentials on logout', async () => { - await cmdLogout.run([], importMeta, context) - - // Should clear all config keys. - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiToken', - undefined, - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiBaseUrl', - undefined, - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'apiProxy', - undefined, - ) - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'enforcedOrgs', - undefined, - ) - expect(mockUpdateConfigValue).toHaveBeenCalledTimes(4) - }) - - it('should show success message after logout', async () => { - await cmdLogout.run([], importMeta, context) - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Successfully logged out', - ) - }) - - it('should clear credentials in correct order', async () => { - await cmdLogout.run([], importMeta, context) - - const calls = mockUpdateConfigValue.mock.calls - expect(calls[0]).toEqual(['apiToken', undefined]) - expect(calls[1]).toEqual(['apiBaseUrl', undefined]) - expect(calls[2]).toEqual(['apiProxy', undefined]) - expect(calls[3]).toEqual(['enforcedOrgs', undefined]) - }) - }) - - describe('read-only config mode', () => { - it('should warn when config is from flag/env', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - - await cmdLogout.run([], importMeta, context) - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Successfully logged out', - ) - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!', - ) - }) - - it('should not warn in normal config mode', async () => { - mockIsConfigFromFlag.mockReturnValue(false) - - await cmdLogout.run([], importMeta, context) - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Successfully logged out', - ) - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('should still attempt logout even in read-only mode', async () => { - mockIsConfigFromFlag.mockReturnValue(true) - - await cmdLogout.run([], importMeta, context) - - expect(mockUpdateConfigValue).toHaveBeenCalledTimes(4) - expect(mockLogger.success).toHaveBeenCalled() - }) - }) - - describe('error handling', () => { - it('should handle updateConfigValue errors gracefully', async () => { - mockUpdateConfigValue.mockImplementation(() => { - throw new Error('Config write failed') - }) - - await cmdLogout.run([], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Failed to complete logout steps', - ) - expect(mockLogger.success).not.toHaveBeenCalled() - }) - - it('should catch errors during config cleanup', async () => { - mockUpdateConfigValue.mockImplementationOnce(() => { - // First call succeeds. - }) - mockUpdateConfigValue.mockImplementationOnce(() => { - throw new Error('Permission denied') - }) - - await cmdLogout.run([], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Failed to complete logout steps', - ) - }) - - it('should not throw uncaught exceptions on error', async () => { - mockUpdateConfigValue.mockImplementation(() => { - throw new Error('Disk full') - }) - - await expect( - cmdLogout.run([], importMeta, context), - ).resolves.not.toThrow() - - expect(mockLogger.fail).toHaveBeenCalled() - }) - }) - - describe('flag combinations', () => { - it('should handle --dry-run with --json flag', async () => { - await cmdLogout.run(['--dry-run', '--json'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalled() - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - }) - - it('should handle --dry-run with --markdown flag', async () => { - await cmdLogout.run(['--dry-run', '--markdown'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalled() - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['--dry-run']) as readonly string[] - - await cmdLogout.run(readonlyArgv, importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalled() - }) - - it('should handle missing HOME environment variable', async () => { - const originalHome = process.env['HOME'] - delete process.env['HOME'] - - await cmdLogout.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalledWith( - 'Socket API credentials', - expect.stringContaining('config.json'), - ) - - process.env['HOME'] = originalHome - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-auto.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-auto.test.mts deleted file mode 100644 index 58e014df7..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-auto.test.mts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Unit tests for manifest auto command. - * - * Tests the command that auto-detects build systems and generates manifest - * files. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock detectManifestActions and generateAutoManifest. -const mockDetectManifestActions = vi.hoisted(() => - vi - .fn() - .mockResolvedValue({ count: 0, gradle: false, sbt: false, pip: false }), -) -const mockGenerateAutoManifest = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock( - '../../../../src/commands/manifest/detect-manifest-actions.mts', - () => ({ - detectManifestActions: mockDetectManifestActions, - }), -) - -vi.mock('../../../../src/commands/manifest/generate_auto_manifest.mts', () => ({ - generateAutoManifest: mockGenerateAutoManifest, -})) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -// Import after mocks. -const { cmdManifestAuto } = - await import('../../../../src/commands/manifest/cmd-manifest-auto.mts') - -describe('cmd-manifest-auto', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestAuto.description).toBe( - 'Auto-detect build and attempt to generate manifest file', - ) - }) - - it('should not be hidden', () => { - expect(cmdManifestAuto.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-auto.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - mockDetectManifestActions.mockResolvedValueOnce({ count: 2 }) - - await cmdManifestAuto.run(['--dry-run'], importMeta, context) - - // Dry run should still detect but not generate. - expect(mockDetectManifestActions).toHaveBeenCalled() - expect(mockGenerateAutoManifest).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('logs "no manifest targets" on dry-run when nothing is detected', async () => { - mockDetectManifestActions.mockResolvedValueOnce({ count: 0 }) - - await cmdManifestAuto.run(['--dry-run'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - 'No manifest targets detected in the specified directory.', - ) - expect(mockGenerateAutoManifest).not.toHaveBeenCalled() - }) - - it('should detect manifest actions with socket.json config', async () => { - const mockSocketJson = { defaults: { manifest: { auto: {} } } } - mockReadOrDefaultSocketJson.mockReturnValueOnce(mockSocketJson) - mockDetectManifestActions.mockResolvedValueOnce({ count: 0 }) - - await cmdManifestAuto.run(['.'], importMeta, context) - - // Verify detectManifestActions receives socket.json and cwd. - expect(mockDetectManifestActions).toHaveBeenCalledWith( - mockSocketJson, - expect.stringContaining('/'), - ) - }) - - it('should fail when no targets detected', async () => { - mockDetectManifestActions.mockResolvedValueOnce({ count: 0 }) - - await cmdManifestAuto.run(['.'], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('unable to discover'), - ) - expect(mockGenerateAutoManifest).not.toHaveBeenCalled() - }) - - it('should generate manifests when targets detected', async () => { - const detected = { count: 2, gradle: true, sbt: false, pip: true } - mockDetectManifestActions.mockResolvedValueOnce(detected) - - await cmdManifestAuto.run(['.'], importMeta, context) - - // Verify generateAutoManifest receives correct parameters. - expect(mockGenerateAutoManifest).toHaveBeenCalledWith({ - detected, - cwd: expect.stringContaining('/'), - outputKind: 'text', - verbose: false, - }) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('2 targets'), - ) - }) - - it('should pass verbose flag to generateAutoManifest', async () => { - const detected = { count: 1 } - mockDetectManifestActions.mockResolvedValueOnce(detected) - - await cmdManifestAuto.run(['--verbose', '.'], importMeta, context) - - expect(mockGenerateAutoManifest).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should resolve relative cwd to absolute path', async () => { - mockDetectManifestActions.mockResolvedValueOnce({ count: 0 }) - - await cmdManifestAuto.run(['./relative/path'], importMeta, context) - - // Verify cwd is absolute (contains process.cwd()). - expect(mockDetectManifestActions).toHaveBeenCalledWith( - expect.anything(), - expect.stringMatching(/^\/.*relative\/path$/), - ) - }) - - it('should use current directory when no path provided', async () => { - mockDetectManifestActions.mockResolvedValueOnce({ count: 0 }) - - await cmdManifestAuto.run([], importMeta, context) - - // Should use process.cwd() when no path provided. - expect(mockDetectManifestActions).toHaveBeenCalledWith( - expect.anything(), - expect.stringMatching(/^\/.*$/), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen.test.mts deleted file mode 100644 index aee324df4..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen.test.mts +++ /dev/null @@ -1,788 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for manifest cdxgen command. - * - * Tests the cdxgen command that generates CycloneDX SBOMs (Software Bill of - * Materials). This command wraps the @cyclonedx/cdxgen tool with Socket CLI - * integration. - * - * Test Coverage: - Command metadata (description, hidden) - Dry-run behavior - - * Unknown argument handling - Exit code handling with process.exit() - Signal - * propagation with process.kill() - Lifecycle default setting - Output file - * defaults - Help flag handling. - * - * Related Files: - src/commands/manifest/cmd-manifest-cdxgen.mts - Command - * implementation - src/commands/manifest/run-cdxgen.mts - cdxgen spawning - * logic. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock runCdxgen to prevent actual cdxgen execution. -const mockRunCdxgen = vi.hoisted(() => vi.fn()) -const mockDetectNodejsCdxgenSources = vi.hoisted(() => - // Default to "sources available" so pre-existing tests don't trip the - // empty-components hard gate. - vi.fn().mockResolvedValue({ hasLockfile: true, hasNodeModules: true }), -) -const mockIsNodejsCdxgenType = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/manifest/run-cdxgen.mts', () => ({ - detectNodejsCdxgenSources: mockDetectNodejsCdxgenSources, - isNodejsCdxgenType: mockIsNodejsCdxgenType, - runCdxgen: mockRunCdxgen, -})) - -// Import after mocks. -const { cmdManifestCdxgen } = - await import('../../../../src/commands/manifest/cmd-manifest-cdxgen.mts') - -describe('cmd-manifest-cdxgen', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDetectNodejsCdxgenSources.mockResolvedValue({ - hasLockfile: true, - hasNodeModules: true, - }) - mockIsNodejsCdxgenType.mockReturnValue(true) - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestCdxgen.description).toBe( - 'Run cdxgen for SBOM generation', - ) - }) - - it('should not be hidden', () => { - expect(cmdManifestCdxgen.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-cdxgen.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - await cmdManifestCdxgen.run(['--dry-run'], importMeta, context) - - // Dry run should not call runCdxgen. - expect(mockRunCdxgen).not.toHaveBeenCalled() - // Should log the dry run message. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should show command args in dry-run output', async () => { - await cmdManifestCdxgen.run( - ['--dry-run', '--type', 'npm', './project'], - importMeta, - context, - ) - - expect(mockRunCdxgen).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Command: cdxgen'), - ) - }) - - it('should fail on unknown arguments', async () => { - await cmdManifestCdxgen.run(['unknown-fake-arg'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Unknown argument'), - ) - expect(mockRunCdxgen).not.toHaveBeenCalled() - }) - - it('should fail on multiple unknown arguments', async () => { - await cmdManifestCdxgen.run( - ['fake-arg-1', 'fake-arg-2'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Unknown arguments'), - ) - }) - - describe('exit code handling', () => { - it('skips exit/kill when both code and signal are null', async () => { - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: undefined, - }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as unknown) - mockExit.mockClear() - mockKill.mockClear() - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockExit).not.toHaveBeenCalled() - expect(mockKill).not.toHaveBeenCalled() - mockExit.mockRestore() - mockKill.mockRestore() - }) - - it('should call process.exit with exit code 0 on success', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockExit).toHaveBeenCalledWith(0) - mockExit.mockRestore() - }) - - it('should call process.exit with non-zero exit code on failure', async () => { - const mockSpawnPromise = Promise.resolve({ code: 1, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockExit).toHaveBeenCalledWith(1) - mockExit.mockRestore() - }) - - it('should propagate specific exit code from cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ - code: 42, - signal: undefined, - }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockExit).toHaveBeenCalledWith(42) - mockExit.mockRestore() - }) - - it('should set default exit code to 1 before spawning', async () => { - let exitCodeDuringSpawn: number | undefined - - mockRunCdxgen.mockImplementation(() => { - exitCodeDuringSpawn = process.exitCode - return Promise.resolve({ - spawnPromise: Promise.resolve({ code: 0, signal: undefined }), - }) - }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(exitCodeDuringSpawn).toBe(1) - mockExit.mockRestore() - }) - }) - - describe('signal handling', () => { - it('should call process.kill with signal when cdxgen receives SIGTERM', async () => { - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - mockKill.mockRestore() - }) - - it('should call process.kill with SIGINT signal', async () => { - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGINT', - }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - mockKill.mockRestore() - }) - }) - - describe('lifecycle defaults', () => { - it('should set lifecycle to pre-build by default', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - lifecycle: 'pre-build', - 'install-deps': false, - }), - ) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Setting cdxgen --lifecycle to "pre-build"'), - ) - - mockExit.mockRestore() - }) - - it('should set output to socket-cdx.json by default', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - output: 'socket-cdx.json', - }), - ) - - mockExit.mockRestore() - }) - - it('should not override lifecycle when explicitly set', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--lifecycle', 'build', '.'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - lifecycle: 'build', - }), - ) - - // Should not log the default lifecycle message. - expect(mockLogger.info).not.toHaveBeenCalledWith( - expect.stringContaining('Setting cdxgen --lifecycle'), - ) - - mockExit.mockRestore() - }) - - it('should not override output when explicitly set', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--output', 'custom.json', '.'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - output: 'custom.json', - }), - ) - - mockExit.mockRestore() - }) - }) - - describe('empty-components hard gate', () => { - it('fails when default pre-build path has no lockfile and no node_modules', async () => { - mockDetectNodejsCdxgenSources.mockResolvedValue({ - hasLockfile: false, - hasNodeModules: false, - }) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('no lockfile'), - ) - expect(mockRunCdxgen).not.toHaveBeenCalled() - }) - - it('allows the run when only a lockfile is present', async () => { - mockDetectNodejsCdxgenSources.mockResolvedValue({ - hasLockfile: true, - hasNodeModules: false, - }) - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalled() - mockExit.mockRestore() - }) - - it('allows the run when only node_modules/ is present', async () => { - mockDetectNodejsCdxgenSources.mockResolvedValue({ - hasLockfile: false, - hasNodeModules: true, - }) - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalled() - mockExit.mockRestore() - }) - - it('skips the gate when user passes --lifecycle explicitly', async () => { - mockDetectNodejsCdxgenSources.mockResolvedValue({ - hasLockfile: false, - hasNodeModules: false, - }) - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--lifecycle', 'build', '.'], - importMeta, - context, - ) - - expect(mockDetectNodejsCdxgenSources).not.toHaveBeenCalled() - expect(mockRunCdxgen).toHaveBeenCalled() - mockExit.mockRestore() - }) - - it('skips the gate for non-Node.js project types', async () => { - mockIsNodejsCdxgenType.mockReturnValue(false) - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--type', 'python', '.'], - importMeta, - context, - ) - - expect(mockDetectNodejsCdxgenSources).not.toHaveBeenCalled() - expect(mockRunCdxgen).toHaveBeenCalled() - mockExit.mockRestore() - }) - }) - - describe('help flag handling', () => { - it('should pass --help flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--help'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - help: true, - }), - ) - - mockExit.mockRestore() - }) - - it('should pass -h flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['-h'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - help: true, - }), - ) - - mockExit.mockRestore() - }) - - it('should not set lifecycle/output defaults when --help is passed', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--help'], importMeta, context) - - const callArg = mockRunCdxgen.mock.calls[0]?.[0] - - // Should have help but not lifecycle/output defaults. - expect(callArg.help).toBe(true) - expect(callArg.lifecycle).toBeUndefined() - // Output is undefined because help is true. - expect(callArg.output).toBeUndefined() - - mockExit.mockRestore() - }) - }) - - describe('cdxgen flag forwarding', () => { - it('should forward --type flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--type', 'npm', '.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - type: ['npm'], - }), - ) - - mockExit.mockRestore() - }) - - it('should forward multiple --type flags to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--type', 'npm', '--type', 'pypi', '.'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - type: ['npm', 'pypi'], - }), - ) - - mockExit.mockRestore() - }) - - it('should forward --print flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--print', '.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - print: true, - }), - ) - - mockExit.mockRestore() - }) - - it('should forward --no-recurse flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--no-recurse', '.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - recurse: false, - }), - ) - - mockExit.mockRestore() - }) - - it('should forward --spec-version flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--spec-version', '1.5', '.'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - 'spec-version': '1.5', - }), - ) - - mockExit.mockRestore() - }) - - it('should forward --deep flag to cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--deep', '.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - deep: true, - }), - ) - - mockExit.mockRestore() - }) - }) - - describe('path argument handling', () => { - it('should accept path as positional argument', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['./my-project'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - _: ['./my-project'], - }), - ) - - mockExit.mockRestore() - }) - - it('should accept multiple paths', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['./project1', './project2'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - _: ['./project1', './project2'], - }), - ) - - mockExit.mockRestore() - }) - - it('should accept absolute paths', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['/absolute/path/to/project'], - importMeta, - context, - ) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - _: ['/absolute/path/to/project'], - }), - ) - - mockExit.mockRestore() - }) - }) - - describe('Socket flag filtering', () => { - it('should filter out --config flag from cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run( - ['--config', '{}', '.'], - importMeta, - context, - ) - - const callArg = mockRunCdxgen.mock.calls[0]?.[0] - expect(callArg.config).toBeUndefined() - - mockExit.mockRestore() - }) - - it('should keep --no-banner flag for cdxgen', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['--no-banner', '.'], importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalledWith( - expect.objectContaining({ - banner: false, - }), - ) - - mockExit.mockRestore() - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - const readonlyArgv = Object.freeze(['.']) as readonly string[] - - await cmdManifestCdxgen.run(readonlyArgv, importMeta, context) - - expect(mockRunCdxgen).toHaveBeenCalled() - - mockExit.mockRestore() - }) - - it('should handle empty context object', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, {}) - - expect(mockRunCdxgen).toHaveBeenCalled() - - mockExit.mockRestore() - }) - - it('should handle context with additional properties', async () => { - const mockSpawnPromise = Promise.resolve({ code: 0, signal: undefined }) - mockRunCdxgen.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - await cmdManifestCdxgen.run(['.'], importMeta, { - parentName: 'socket manifest', - extraProp: 'ignored', - } as unknown) - - expect(mockRunCdxgen).toHaveBeenCalled() - - mockExit.mockRestore() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-conda.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-conda.test.mts deleted file mode 100644 index 12aae9aeb..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-conda.test.mts +++ /dev/null @@ -1,329 +0,0 @@ - -/** - * Unit tests for manifest conda command. - * - * Tests the command that converts Conda environment.yml to requirements.txt. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - LOG_SYMBOLS: { - success: '✓', - fail: '✗', - }, - getDefaultLogger: () => mockLogger, -})) - -// Mock dependencies. -const mockHandleManifestConda = vi.hoisted(() => vi.fn()) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock('../../../../src/commands/manifest/handle-manifest-conda.mts', () => ({ - handleManifestConda: mockHandleManifestConda, -})) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -// Import after mocks. -const { cmdManifestConda } = - await import('../../../../src/commands/manifest/cmd-manifest-conda.mts') - -describe('cmd-manifest-conda', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestConda.description).toContain('Conda') - expect(cmdManifestConda.description).toContain('environment.yml') - }) - - it('should not be hidden', () => { - expect(cmdManifestConda.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-conda.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - await cmdManifestConda.run(['--dry-run'], importMeta, context) - - expect(mockHandleManifestConda).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should show warning about conda support', async () => { - await cmdManifestConda.run(['--dry-run'], importMeta, context) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Conda'), - ) - }) - - it('should call handleManifestConda without dry-run', async () => { - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: 'environment.yml', - out: 'requirements.txt', - outputKind: 'text', - verbose: false, - }), - ) - }) - - it('should pass --file flag to handler', async () => { - await cmdManifestConda.run( - ['--file', 'custom-env.yml'], - importMeta, - context, - ) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: 'custom-env.yml', - }), - ) - }) - - it('should pass --out flag to handler', async () => { - await cmdManifestConda.run( - ['--out', 'custom-requirements.txt'], - importMeta, - context, - ) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - out: 'custom-requirements.txt', - }), - ) - }) - - it('should pass --verbose flag to handler', async () => { - await cmdManifestConda.run(['--verbose'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should use stdin when --stdin flag is set', async () => { - await cmdManifestConda.run(['--stdin'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: '-', - }), - ) - }) - - it('should use stdout when --stdout flag is set', async () => { - await cmdManifestConda.run(['--stdout'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - out: '-', - }), - ) - }) - - it('should support custom cwd argument', async () => { - await cmdManifestConda.run(['./custom-dir'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining('custom-dir'), - }), - ) - }) - - it('should fail with multiple directory arguments', async () => { - await cmdManifestConda.run(['dir1', 'dir2'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleManifestConda).not.toHaveBeenCalled() - }) - - it('should fail when --json and --markdown are both set', async () => { - await cmdManifestConda.run(['--json', '--markdown'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleManifestConda).not.toHaveBeenCalled() - }) - - it('should support --json output mode', async () => { - await cmdManifestConda.run(['--json'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - await cmdManifestConda.run(['--markdown'], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should use socket.json defaults for stdin', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - stdin: true, - }, - }, - }, - }) - - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: '-', - }), - ) - }) - - it('should use socket.json defaults for infile', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - infile: 'custom-default.yml', - }, - }, - }, - }) - - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: 'custom-default.yml', - }), - ) - }) - - it('should use socket.json defaults for stdout', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - stdout: true, - }, - }, - }, - }) - - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - out: '-', - }), - ) - }) - - it('should use socket.json defaults for outfile', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - outfile: 'custom-output.txt', - }, - }, - }, - }) - - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - out: 'custom-output.txt', - }), - ) - }) - - it('should use socket.json defaults for verbose', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - verbose: true, - }, - }, - }, - }) - - await cmdManifestConda.run([], importMeta, context) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - infile: 'default.yml', - outfile: 'default.txt', - verbose: false, - }, - }, - }, - }) - - await cmdManifestConda.run( - ['--file', 'cli.yml', '--out', 'cli.txt', '--verbose'], - importMeta, - context, - ) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: 'cli.yml', - out: 'cli.txt', - verbose: true, - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-gradle.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-gradle.test.mts deleted file mode 100644 index c0716354f..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-gradle.test.mts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Unit tests for manifest gradle command. - * - * Tests the command that uses Gradle to generate pom.xml manifest files. - */ - -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock convertGradleToMaven and outputManifest. -const mockConvertGradleToMaven = vi.hoisted(() => - vi.fn().mockResolvedValue({ ok: true, data: { files: [] } }), -) -const mockOutputManifest = vi.hoisted(() => vi.fn()) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock( - '../../../../src/commands/manifest/convert-gradle-to-maven.mts', - () => ({ - convertGradleToMaven: mockConvertGradleToMaven, - }), -) - -vi.mock('../../../../src/commands/manifest/output-manifest.mts', () => ({ - outputManifest: mockOutputManifest, -})) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -// Import after mocks. -const { cmdManifestGradle } = - await import('../../../../src/commands/manifest/cmd-manifest-gradle.mts') - -describe('cmd-manifest-gradle', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestGradle.description).toContain('Gradle') - expect(cmdManifestGradle.description).toContain('pom.xml') - }) - - it('should not be hidden', () => { - expect(cmdManifestGradle.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-gradle.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - await cmdManifestGradle.run(['--dry-run', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('forwards --gradle-opts and --bin in the dry-run preview args', async () => { - // Exercises the args.push branches in the dryRun block. - await cmdManifestGradle.run( - [ - '--dry-run', - '.', - '--bin', - '/custom/gradlew', - '--gradle-opts', - '--stacktrace --info', - ], - importMeta, - context, - ) - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should call convertGradleToMaven with correct default parameters', async () => { - await cmdManifestGradle.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith({ - bin: expect.stringMatching(/gradlew$/), - cwd: expect.stringContaining('/'), - gradleOpts: [], - outputKind: 'text', - verbose: false, - }) - }) - - it('should pass custom --bin flag to convertGradleToMaven', async () => { - await cmdManifestGradle.run( - ['--bin', '/custom/gradle', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/custom/gradle', - }), - ) - }) - - it('should parse and pass --gradle-opts flag', async () => { - // Use = syntax for values that look like flags. - await cmdManifestGradle.run( - ['--gradle-opts=--stacktrace --info', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - gradleOpts: ['--stacktrace', '--info'], - }), - ) - }) - - it('should pass --verbose flag to convertGradleToMaven', async () => { - await cmdManifestGradle.run(['--verbose', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should use socket.json defaults for bin', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - bin: '/socket-json/gradlew', - }, - }, - }, - }) - - await cmdManifestGradle.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/socket-json/gradlew', - }), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('--bin'), - '/socket-json/gradlew', - ) - }) - - it('should use socket.json defaults for gradleOpts', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - gradleOpts: '--debug --scan', - }, - }, - }, - }) - - await cmdManifestGradle.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - gradleOpts: ['--debug', '--scan'], - }), - ) - }) - - it('should use socket.json defaults for verbose', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - verbose: true, - }, - }, - }, - }) - - await cmdManifestGradle.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should reject multiple directory arguments', async () => { - await cmdManifestGradle.run(['dir1', 'dir2'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - }) - - it('should output manifest in json mode', async () => { - const result = { ok: true, data: { files: ['pom.xml'] } } - mockConvertGradleToMaven.mockResolvedValueOnce(result) - - await cmdManifestGradle.run(['--json', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith(result, 'json', '-') - }) - - it('should output manifest in markdown mode', async () => { - const result = { ok: true, data: { files: [] } } - mockConvertGradleToMaven.mockResolvedValueOnce(result) - - await cmdManifestGradle.run(['--markdown', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith(result, 'markdown', '-') - }) - - it('should not call outputManifest in text mode', async () => { - await cmdManifestGradle.run(['.'], importMeta, context) - - expect(mockOutputManifest).not.toHaveBeenCalled() - }) - - it('should resolve cwd to absolute path', async () => { - await cmdManifestGradle.run(['./relative'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringMatching(/^\/.*relative$/), - }), - ) - }) - - it('should default bin to gradlew in cwd', async () => { - await cmdManifestGradle.run(['/absolute/path'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: path.join('/absolute/path', 'gradlew'), - cwd: '/absolute/path', - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - bin: '/socket-json/gradlew', - verbose: false, - }, - }, - }, - }) - - await cmdManifestGradle.run( - ['--bin', '/cli/gradlew', '--verbose', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/cli/gradlew', - verbose: true, - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-kotlin.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-kotlin.test.mts deleted file mode 100644 index 0b6d6072b..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-kotlin.test.mts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Unit tests for manifest kotlin command. - * - * Tests the command that uses Gradle to generate pom.xml manifest files for - * Kotlin projects. - */ - -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock convertGradleToMaven and outputManifest. -const mockConvertGradleToMaven = vi.hoisted(() => - vi.fn().mockResolvedValue({ ok: true, data: { files: [] } }), -) -const mockOutputManifest = vi.hoisted(() => vi.fn()) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock( - '../../../../src/commands/manifest/convert-gradle-to-maven.mts', - () => ({ - convertGradleToMaven: mockConvertGradleToMaven, - }), -) - -vi.mock('../../../../src/commands/manifest/output-manifest.mts', () => ({ - outputManifest: mockOutputManifest, -})) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -// Import after mocks. -const { cmdManifestKotlin } = - await import('../../../../src/commands/manifest/cmd-manifest-kotlin.mts') - -describe('cmd-manifest-kotlin', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestKotlin.description).toContain('Kotlin') - expect(cmdManifestKotlin.description).toContain('pom.xml') - }) - - it('should not be hidden', () => { - expect(cmdManifestKotlin.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-kotlin.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - await cmdManifestKotlin.run(['--dry-run', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('forwards --bin and --gradle-opts in the dry-run preview args', async () => { - await cmdManifestKotlin.run( - [ - '--dry-run', - '.', - '--bin', - '/custom/gradlew', - '--gradle-opts', - '--info --stacktrace', - ], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should call convertGradleToMaven with correct default parameters', async () => { - await cmdManifestKotlin.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith({ - bin: expect.stringMatching(/gradlew$/), - cwd: expect.stringContaining('/'), - gradleOpts: [], - outputKind: 'text', - verbose: false, - }) - }) - - it('should pass custom --bin flag to convertGradleToMaven', async () => { - await cmdManifestKotlin.run( - ['--bin', '/custom/gradle', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/custom/gradle', - }), - ) - }) - - it('should parse and pass --gradle-opts flag', async () => { - // Use = syntax for values that look like flags. - await cmdManifestKotlin.run( - ['--gradle-opts=--stacktrace --info', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - gradleOpts: ['--stacktrace', '--info'], - }), - ) - }) - - it('should pass --verbose flag to convertGradleToMaven', async () => { - await cmdManifestKotlin.run(['--verbose', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should use socket.json defaults for bin', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - bin: '/socket-json/gradlew', - }, - }, - }, - }) - - await cmdManifestKotlin.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/socket-json/gradlew', - }), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('--bin'), - '/socket-json/gradlew', - ) - }) - - it('should use socket.json defaults for gradleOpts', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - gradleOpts: '--debug --scan', - }, - }, - }, - }) - - await cmdManifestKotlin.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - gradleOpts: ['--debug', '--scan'], - }), - ) - }) - - it('should reject multiple directory arguments', async () => { - await cmdManifestKotlin.run(['dir1', 'dir2'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - }) - - it('should output manifest in json mode', async () => { - const result = { ok: true, data: { files: ['pom.xml'] } } - mockConvertGradleToMaven.mockResolvedValueOnce(result) - - await cmdManifestKotlin.run(['--json', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith(result, 'json', '-') - }) - - it('should output manifest in markdown mode', async () => { - const result = { ok: true, data: { files: [] } } - mockConvertGradleToMaven.mockResolvedValueOnce(result) - - await cmdManifestKotlin.run(['--markdown', '.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith(result, 'markdown', '-') - }) - - it('should not call outputManifest in text mode', async () => { - await cmdManifestKotlin.run(['.'], importMeta, context) - - expect(mockOutputManifest).not.toHaveBeenCalled() - }) - - it('should default bin to gradlew in cwd', async () => { - await cmdManifestKotlin.run(['/absolute/path'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: path.join('/absolute/path', 'gradlew'), - cwd: '/absolute/path', - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - bin: '/socket-json/gradlew', - verbose: false, - }, - }, - }, - }) - - await cmdManifestKotlin.run( - ['--bin', '/cli/gradlew', '--verbose', '.'], - importMeta, - context, - ) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/cli/gradlew', - verbose: true, - }), - ) - }) - - it('uses socket.json verbose default when --verbose is not passed', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - verbose: true, - }, - }, - }, - }) - - await cmdManifestKotlin.run(['.'], importMeta, context) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Using default --verbose'), - true, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-scala.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-scala.test.mts deleted file mode 100644 index 725df4d39..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-scala.test.mts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Unit tests for manifest scala command. - * - * Tests the command that uses SBT to generate pom.xml manifest files for Scala - * projects. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock convertSbtToMaven and outputManifest. -const mockConvertSbtToMaven = vi.hoisted(() => - vi.fn().mockResolvedValue({ ok: true, data: { files: [] } }), -) -const mockOutputManifest = vi.hoisted(() => vi.fn()) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock('../../../../src/commands/manifest/convert-sbt-to-maven.mts', () => ({ - convertSbtToMaven: mockConvertSbtToMaven, -})) - -vi.mock('../../../../src/commands/manifest/output-manifest.mts', () => ({ - outputManifest: mockOutputManifest, -})) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -// Import after mocks. -const { cmdManifestScala } = - await import('../../../../src/commands/manifest/cmd-manifest-scala.mts') - -describe('cmd-manifest-scala', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestScala.description).toContain('Scala') - expect(cmdManifestScala.description).toContain('pom.xml') - }) - - it('should not be hidden', () => { - expect(cmdManifestScala.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-scala.mts' } - const context = { parentName: 'socket manifest' } - - it('should support --dry-run flag', async () => { - await cmdManifestScala.run(['--dry-run', '.'], importMeta, context) - - expect(mockConvertSbtToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('forwards --bin, --out, --sbt-opts in the dry-run preview args', async () => { - await cmdManifestScala.run( - [ - '--dry-run', - '.', - '--bin', - '/custom/sbt', - '--out', - '/tmp/out.xml', - '--sbt-opts', - '--debug --no-colors', - ], - importMeta, - context, - ) - expect(mockConvertSbtToMaven).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should call convertSbtToMaven with correct default parameters', async () => { - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith({ - bin: 'sbt', - cwd: expect.stringContaining('/'), - out: './socket.pom.xml', - outputKind: 'text', - sbtOpts: [], - verbose: false, - }) - }) - - it('should pass custom --bin flag to convertSbtToMaven', async () => { - await cmdManifestScala.run( - ['--bin', '/custom/sbt', '.'], - importMeta, - context, - ) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/custom/sbt', - }), - ) - }) - - it('should pass custom --out flag to convertSbtToMaven', async () => { - await cmdManifestScala.run( - ['--out', '/output/pom.xml', '.'], - importMeta, - context, - ) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - out: '/output/pom.xml', - }), - ) - }) - - it('should set out to - when --stdout flag is used', async () => { - await cmdManifestScala.run(['--stdout', '.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - out: '-', - }), - ) - }) - - it('should parse and pass --sbt-opts flag', async () => { - // Use = syntax for values that look like flags. - await cmdManifestScala.run( - ['--sbt-opts=-batch -mem 2048', '.'], - importMeta, - context, - ) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - sbtOpts: ['-batch', '-mem', '2048'], - }), - ) - }) - - it('should pass --verbose flag to convertSbtToMaven', async () => { - await cmdManifestScala.run(['--verbose', '.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should use socket.json defaults for bin', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - bin: '/socket-json/sbt', - }, - }, - }, - }) - - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/socket-json/sbt', - }), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('--bin'), - '/socket-json/sbt', - ) - }) - - it('should use socket.json defaults for outfile', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - outfile: '/custom/output.xml', - }, - }, - }, - }) - - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - out: '/custom/output.xml', - }), - ) - }) - - it('should use socket.json defaults for stdout', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - stdout: true, - }, - }, - }, - }) - - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - out: '-', - }), - ) - }) - - it('should use socket.json defaults for sbtOpts', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - sbtOpts: '-J-Xmx4G -batch', - }, - }, - }, - }) - - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - sbtOpts: ['-J-Xmx4G', '-batch'], - }), - ) - }) - - it('should use socket.json defaults for verbose', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - verbose: true, - }, - }, - }, - }) - - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - verbose: true, - }), - ) - }) - - it('should reject multiple directory arguments', async () => { - await cmdManifestScala.run(['dir1', 'dir2'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockConvertSbtToMaven).not.toHaveBeenCalled() - }) - - it('should output manifest in json mode', async () => { - const result = { ok: true, data: { files: ['pom.xml'] } } - mockConvertSbtToMaven.mockResolvedValueOnce(result) - - await cmdManifestScala.run(['--json', '.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith( - result, - 'json', - './socket.pom.xml', - ) - }) - - it('should output manifest in markdown mode', async () => { - const result = { ok: true, data: { files: [] } } - mockConvertSbtToMaven.mockResolvedValueOnce(result) - - await cmdManifestScala.run(['--markdown', '.'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - expect(mockOutputManifest).toHaveBeenCalledWith( - result, - 'markdown', - './socket.pom.xml', - ) - }) - - it('should not call outputManifest in text mode', async () => { - await cmdManifestScala.run(['.'], importMeta, context) - - expect(mockOutputManifest).not.toHaveBeenCalled() - }) - - it('should resolve cwd to absolute path', async () => { - await cmdManifestScala.run(['./relative'], importMeta, context) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringMatching(/^\/.*relative$/), - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - bin: '/socket-json/sbt', - out: '/socket-json/out.xml', - verbose: false, - }, - }, - }, - }) - - await cmdManifestScala.run( - ['--bin', '/cli/sbt', '--out', '/cli/out.xml', '--verbose', '.'], - importMeta, - context, - ) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/cli/sbt', - out: '/cli/out.xml', - verbose: true, - }), - ) - }) - - it('should prefer --stdout over --out', async () => { - await cmdManifestScala.run( - ['--out', '/some/file.xml', '--stdout', '.'], - importMeta, - context, - ) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - out: '-', - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-setup.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-setup.test.mts deleted file mode 100644 index a952b3bcc..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest-setup.test.mts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Unit tests for manifest setup command. - * - * Tests the command that starts an interactive configurator to customize - * default flag values for manifest commands in a directory. Configuration is - * stored in socket.json for persistent settings. - * - * Test Coverage: - Command metadata (description, hidden) - Dry-run behavior - * with detailed output - Path resolution (relative to absolute) - Default path - * handling (current directory) - Flag parsing (defaultOnReadError) - Handler - * invocation with correct parameters. - * - * Related Files: - src/commands/manifest/cmd-manifest-setup.mts - Command - * implementation - src/commands/manifest/handle-manifest-setup.mts - Setup - * logic. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock handleManifestSetup. -const mockHandleManifestSetup = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -) - -vi.mock('../../../../src/commands/manifest/handle-manifest-setup.mts', () => ({ - handleManifestSetup: mockHandleManifestSetup, -})) - -// Mock outputDryRunWrite to verify dry-run output. -const mockOutputDryRunWrite = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunWrite: mockOutputDryRunWrite, -})) - -// Import after mocks. -const { cmdManifestSetup } = - await import('../../../../src/commands/manifest/cmd-manifest-setup.mts') - -describe('cmd-manifest-setup', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifestSetup.description).toBe( - 'Start interactive configurator to customize default flag values for `socket manifest` in this dir', - ) - }) - - it('should not be hidden', () => { - expect(cmdManifestSetup.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest-setup.mts' } - const context = { parentName: 'socket manifest' } - - describe('dry-run behavior', () => { - it('should show dry-run output without executing setup', async () => { - await cmdManifestSetup.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringContaining('socket.json'), - 'create or update manifest configuration', - [ - 'Detect supported ecosystems', - 'Configure manifest generation defaults', - 'Enable/disable specific ecosystems', - ], - ) - expect(mockHandleManifestSetup).not.toHaveBeenCalled() - }) - - it('should use provided path in dry-run output', async () => { - await cmdManifestSetup.run( - ['--dry-run', './custom/path'], - importMeta, - context, - ) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringMatching(/custom\/path\/socket\.json$/), - expect.any(String), - expect.any(Array), - ) - }) - - it('should use current directory in dry-run when no path provided', async () => { - await cmdManifestSetup.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunWrite).toHaveBeenCalledWith( - expect.stringMatching(/socket\.json$/), - expect.any(String), - expect.any(Array), - ) - }) - }) - - describe('path resolution', () => { - it('should resolve relative path to absolute', async () => { - await cmdManifestSetup.run(['./relative/path'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/^\/.*relative\/path$/), - false, - ) - }) - - it('should use current directory when no path provided', async () => { - const originalCwd = process.cwd() - - await cmdManifestSetup.run([], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith(originalCwd, false) - }) - - it('should not modify absolute paths', async () => { - await cmdManifestSetup.run(['/absolute/path'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - '/absolute/path', - false, - ) - }) - - it('should handle dot notation for current directory', async () => { - const originalCwd = process.cwd() - - await cmdManifestSetup.run(['.'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith(originalCwd, false) - }) - }) - - describe('flag handling', () => { - it('should pass defaultOnReadError flag as true when set', async () => { - await cmdManifestSetup.run( - ['--defaultOnReadError'], - importMeta, - context, - ) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.any(String), - true, - ) - }) - - it('should pass defaultOnReadError as false by default', async () => { - await cmdManifestSetup.run([], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.any(String), - false, - ) - }) - - it('should handle both path and defaultOnReadError flag', async () => { - await cmdManifestSetup.run( - ['./custom', '--defaultOnReadError'], - importMeta, - context, - ) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/custom$/), - true, - ) - }) - }) - - describe('handler invocation', () => { - it('should call handleManifestSetup with correct parameters', async () => { - await cmdManifestSetup.run(['./test-dir'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledOnce() - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/test-dir$/), - false, - ) - }) - - it('should not call handler in dry-run mode', async () => { - await cmdManifestSetup.run( - ['--dry-run', './test-dir'], - importMeta, - context, - ) - - expect(mockHandleManifestSetup).not.toHaveBeenCalled() - }) - - it('should handle handler errors gracefully', async () => { - const testError = new Error('Setup failed') - mockHandleManifestSetup.mockRejectedValueOnce(testError) - - await expect( - cmdManifestSetup.run([], importMeta, context), - ).rejects.toThrow('Setup failed') - }) - }) - - describe('edge cases', () => { - it('should handle multiple path arguments by using first one', async () => { - await cmdManifestSetup.run(['./first', './second'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/first$/), - false, - ) - }) - - it('should handle paths with spaces', async () => { - await cmdManifestSetup.run(['./path with spaces'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/path with spaces$/), - false, - ) - }) - - it('should handle nested relative paths', async () => { - await cmdManifestSetup.run(['./a/b/c/d/e'], importMeta, context) - - expect(mockHandleManifestSetup).toHaveBeenCalledWith( - expect.stringMatching(/a\/b\/c\/d\/e$/), - false, - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest.test.mts deleted file mode 100644 index 02ef29872..000000000 --- a/packages/cli/test/unit/commands/manifest/cmd-manifest.test.mts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Unit tests for manifest parent command. - * - * Tests the parent command that manages manifest generation for various - * ecosystems. This command uses meowWithSubcommands to delegate to specific - * ecosystem commands. - * - * Test Coverage: - Command metadata (description, hidden) - Subcommand - * registration and routing - Hidden alias (yolo -> auto) - Flag passthrough to - * subcommands. - * - * Related Files: - src/commands/manifest/cmd-manifest.mts - Command - * implementation - src/util/cli/with-subcommands.mjs - Subcommand handling - * utility. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock meowWithSubcommands. -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Mock all subcommands. -vi.mock('../../../../src/commands/manifest/cmd-manifest-auto.mts', () => ({ - cmdManifestAuto: { description: 'Auto-detect', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-cdxgen.mts', () => ({ - cmdManifestCdxgen: { description: 'Run cdxgen', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-conda.mts', () => ({ - cmdManifestConda: { description: 'Generate conda manifest', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-gradle.mts', () => ({ - cmdManifestGradle: { description: 'Generate gradle manifest', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-kotlin.mts', () => ({ - cmdManifestKotlin: { description: 'Generate kotlin manifest', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-scala.mts', () => ({ - cmdManifestScala: { description: 'Generate scala manifest', hidden: false }, -})) - -vi.mock('../../../../src/commands/manifest/cmd-manifest-setup.mts', () => ({ - cmdManifestSetup: { description: 'Setup manifest config', hidden: false }, -})) - -// Import after mocks. -const { cmdManifest } = - await import('../../../../src/commands/manifest/cmd-manifest.mts') - -describe('cmd-manifest', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdManifest.description).toBe( - 'Generate a dependency manifest for certain ecosystems', - ) - }) - - it('should not be hidden', () => { - expect(cmdManifest.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-manifest.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct command name', async () => { - await cmdManifest.run([], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'socket manifest', - argv: [], - importMeta, - }), - expect.any(Object), - ) - }) - - it('should register all subcommands', async () => { - await cmdManifest.run([], importMeta, context) - - const callArgs = mockMeowWithSubcommands.mock.calls[0] - const subcommands = callArgs[0].subcommands - - expect(subcommands).toHaveProperty('auto') - expect(subcommands).toHaveProperty('cdxgen') - expect(subcommands).toHaveProperty('conda') - expect(subcommands).toHaveProperty('gradle') - expect(subcommands).toHaveProperty('kotlin') - expect(subcommands).toHaveProperty('scala') - expect(subcommands).toHaveProperty('setup') - }) - - it('should register yolo as hidden alias for auto', async () => { - await cmdManifest.run([], importMeta, context) - - const callArgs = mockMeowWithSubcommands.mock.calls[0] - const aliases = callArgs[1].aliases - - expect(aliases.yolo).toEqual({ - description: 'Generate a dependency manifest for certain ecosystems', - hidden: true, - argv: ['auto'], - }) - }) - - it('should pass common flags configuration', async () => { - await cmdManifest.run(['--dry-run'], importMeta, context) - - const callArgs = mockMeowWithSubcommands.mock.calls[0] - const config = callArgs[1] - - expect(config.description).toBe( - 'Generate a dependency manifest for certain ecosystems', - ) - expect(config.flags).toBeDefined() - expect(config.flags).toHaveProperty('dryRun') - expect(config.flags).toHaveProperty('help') - }) - - it('should forward arguments to subcommands', async () => { - await cmdManifest.run(['scala', '.'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv: ['scala', '.'], - }), - expect.any(Object), - ) - }) - - it('should handle flags in argv', async () => { - await cmdManifest.run(['--dry-run', 'auto'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv: ['--dry-run', 'auto'], - }), - expect.any(Object), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements-flow.test.mts b/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements-flow.test.mts deleted file mode 100644 index 542f3e6fe..000000000 --- a/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements-flow.test.mts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Unit tests for the convertCondaToRequirements file/stdin reader. - * - * The pure-string converter is tested next door; this suite covers the I/O - * wrapper: file existence, empty file, stdin reads, error handling. - * - * Related Files: - src/commands/manifest/convert-conda-to-requirements.mts. - */ - -import { EventEmitter } from 'node:events' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFileSync = vi.hoisted(() => vi.fn()) -const mockLogger = vi.hoisted(() => ({ - info: vi.fn(), - error: vi.fn(), -})) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -import { convertCondaToRequirements } from '../../../../src/commands/manifest/convert-conda-to-requirements.mts' - -const ENV_YAML = `name: env -channels: - - defaults -dependencies: - - python=3.11 - - pip - - pip: - - requests==2.31 - - flask -` - -describe('convertCondaToRequirements (file)', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue(ENV_YAML) - }) - - it('reads from a file path', async () => { - const result = await convertCondaToRequirements( - 'environment.yml', - '/proj', - false, - ) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.pip).toContain('requests==2.31') - expect(result.data.pip).toContain('flask') - } - }) - - it('returns error when file does not exist', async () => { - mockExistsSync.mockReturnValueOnce(false) - - const result = await convertCondaToRequirements( - 'environment.yml', - '/proj', - false, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('was not found at') - } - }) - - it('returns error when file is empty', async () => { - mockReadFileSync.mockReturnValueOnce('') - - const result = await convertCondaToRequirements( - 'environment.yml', - '/proj', - false, - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('is empty') - } - }) - - it('logs verbose target path', async () => { - await convertCondaToRequirements('environment.yml', '/proj', true) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('target:'), - ) - }) -}) - -describe('convertCondaToRequirements (stdin)', () => { - let originalStdin: NodeJS.ReadStream - let stdinFake: EventEmitter & { - off?: ((...args: unknown[]) => void) | undefined - } - - beforeEach(() => { - vi.clearAllMocks() - originalStdin = process.stdin - stdinFake = new EventEmitter() as unknown - Object.defineProperty(process, 'stdin', { - value: stdinFake, - writable: true, - configurable: true, - }) - }) - - afterEach(() => { - Object.defineProperty(process, 'stdin', { - value: originalStdin, - writable: true, - configurable: true, - }) - }) - - it('reads stdin and resolves on end', async () => { - const promise = convertCondaToRequirements('-', '/proj', false) - // Schedule data + end on next tick. - setImmediate(() => { - stdinFake.emit('data', Buffer.from(ENV_YAML)) - stdinFake.emit('end') - }) - - const result = await promise - expect(result.ok).toBe(true) - }) - - it('logs verbose stdin info when --verbose', async () => { - const promise = convertCondaToRequirements('-', '/proj', true) - setImmediate(() => { - stdinFake.emit('data', Buffer.from(ENV_YAML)) - stdinFake.emit('end') - }) - - await promise - expect(mockLogger.info).toHaveBeenCalledWith( - '[VERBOSE] reading input from stdin', - ) - }) - - it('returns error when stdin yields no content', async () => { - const promise = convertCondaToRequirements('-', '/proj', false) - setImmediate(() => stdinFake.emit('end')) - - const result = await promise - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('No data received') - } - }) - - it('rejects on stdin error event', async () => { - const promise = convertCondaToRequirements('-', '/proj', true) - const err = new Error('stdin broke') - setImmediate(() => stdinFake.emit('error', err)) - - await expect(promise).rejects.toThrow('stdin broke') - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('resolves on close with received data', async () => { - const promise = convertCondaToRequirements('-', '/proj', true) - setImmediate(() => { - stdinFake.emit('data', Buffer.from(ENV_YAML)) - stdinFake.emit('close') - }) - - const result = await promise - expect(result.ok).toBe(true) - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('rejects on close without data', async () => { - const promise = convertCondaToRequirements('-', '/proj', true) - setImmediate(() => stdinFake.emit('close')) - - await expect(promise).rejects.toThrow(/No data received/) - expect(mockLogger.error).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements.test.mts b/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements.test.mts deleted file mode 100644 index 5867ba187..000000000 --- a/packages/cli/test/unit/commands/manifest/convert-conda-to-requirements.test.mts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Unit Tests: Conda Environment to Requirements.txt Converter. - * - * Purpose: Tests the YAML parser that extracts pip dependencies from Conda - * environment.yml files and converts them to requirements.txt format. Validates - * handling of various YAML indentation styles, comments, and edge cases in - * Conda environment specifications. - * - * Test Coverage: - * - * - Simple Conda environment conversion with pip dependencies - * - Arbitrary indentation block support - * - Single space indentation handling - * - Comment and empty line preservation in pip blocks - * - Block closing detection with varying indentation levels - * - Complex environment files with channels and build strings - * - Git dependencies and requirements.txt references - * - Version specifier preservation (==, >=, ~=, <) - * - * Testing Approach: Uses direct function invocation with inline snapshot - * testing to validate YAML parsing logic. Tests verify correct extraction of - * pip dependencies while ignoring Conda-specific packages. - * - * Related Files: - * - * - Src/commands/manifest/convert-conda-to-requirements.mts - Conda to - * requirements converter - * - Src/commands/manifest/handle-manifest-conda.mts - Command handler using - * converter - */ - -import { describe, expect, it } from 'vitest' - -import { convertCondaToRequirementsFromInput } from '../../../../src/commands/manifest/convert-conda-to-requirements.mts' - -describe('convert-conda-to-requirements', () => { - it('should convert a simple example', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('should support arbitrary indent block', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('should support single space indented block', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('should support comment and empty lines inside pip block', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('skips # comment lines inside the pip block (line 129-131)', () => { - // A flush-left `#` comment line inside the pip block must be ignored - // by the `if (line.startsWith('#')) { continue }` branch. - const output = convertCondaToRequirementsFromInput(` -name: myenv -dependencies: - - python=3.8 - - pip: - - pandas -# flush-left comment - - numpy==1.21.0 -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0" - `) - }) - - it('should support block closing on further indent than start', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 - - the end -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('should support block closing on closer indent than start', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv -channels: - - defaults -dependencies: - - python=3.8 - - pip - - pip: - - pandas - - numpy==1.21.0 - - requests>=2.26.0 -- the end -`) - - expect(output).toMatchInlineSnapshot(` - "pandas - numpy==1.21.0 - requests>=2.26.0" - `) - }) - - it('should convert an example with stuff after the pip block', () => { - const output = convertCondaToRequirementsFromInput(` -channels: -- defaults -- conda-forge -- conda -- pytorch -- nvidia -- anaconda -- https://repo.continuum.io/pkgs/main -- conda-forge -- Gurobi -dependencies: -- python=3.9 -- gurobi>=12.0.0 -- ordered-set -- pygraphviz=1.9 -- pydot=1.4.2 -- pympler -- dill -- pytest -- pip: - - aiohttp==3.8.4 - - requests==2.30.0 - - networkx==3.1 - - numpy==1.24.3 - - scipy==1.10.1 - - pandas==2.0.1 - - dotwiz==0.4.0 - - pydantic==2.7.1 - - pyyaml==6.0.1 - - psutil==5.9.0 - - memray==1.14.0 - - optuna>=4.1.0 -name: py-optim - `) - - expect(output).toMatchInlineSnapshot(` - "aiohttp==3.8.4 - requests==2.30.0 - networkx==3.1 - numpy==1.24.3 - scipy==1.10.1 - pandas==2.0.1 - dotwiz==0.4.0 - pydantic==2.7.1 - pyyaml==6.0.1 - psutil==5.9.0 - memray==1.14.0 - optuna>=4.1.0" - `) - }) - - it('should convert an more complex example', () => { - const output = convertCondaToRequirementsFromInput(` -name: myenv # Environment name (optional but recommended) - -channels: # Package sources/repositories - - conda-forge # Higher priority channel - - defaults # Lower priority channel - -dependencies: # List of packages to install - # Conda packages (direct dependencies) - - python=3.9 # Major.Minor version - - pandas>=1.3.0 # Greater than or equal to version - - numpy~=1.21.0 # Compatible release (same as >=1.21.0,<1.22.0) - - scipy==1.7.0 # Exact version - - matplotlib<3.5.0 # Less than version - - # Optional: specify build number - - package=1.0.0=h123456_0 # package=version=build_string - - # Pip packages (installed via pip) - - pip # Include pip itself - - pip: # Packages to be installed via pip - - tensorflow>=2.0.0 - - torch==1.9.0 - - transformers - - -r requirements.txt # Can include requirements.txt file - - git+https://github.com/user/repo.git # Install from git - - # Platform-specific dependencies - - cudatoolkit=11.0 # Only for systems with NVIDIA GPU -`) - - expect(output).toMatchInlineSnapshot(` - "tensorflow>=2.0.0 - torch==1.9.0 - transformers - -r requirements.txt # Can include requirements.txt file - git+https://github.com/user/repo.git # Install from git" - `) - }) - - it('bails when an in-block line does not start with the recorded indent', () => { - // First "- foo" sets indent to " -"; the "weird" line has no - // leading whitespace, so the indent prefix check at L154 fails and - // the function bails. Exercises the "Unexpected input" break. - const result = convertCondaToRequirementsFromInput(` -- pip: - - foo -weird - - bar -`) - expect(result).toBe('foo') - }) - - it('bails when first line in pip block does not indent further than the delim', () => { - // delim becomes "-" (the "- pip:" line); the next line is "- foo" - // with the same single-char prefix, so indent.length (1) <= - // delim.length (1) and the function exits with no captured packages. - const result = convertCondaToRequirementsFromInput(` -- pip: -- foo -`) - expect(result).toBe('') - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/convert-gradle-to-maven.test.mts b/packages/cli/test/unit/commands/manifest/convert-gradle-to-maven.test.mts deleted file mode 100644 index 3d63906f4..000000000 --- a/packages/cli/test/unit/commands/manifest/convert-gradle-to-maven.test.mts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Unit tests for convertGradleToMaven. - * - * Spawns gradlew with an init script that emits "POM file copied to: <path>" - * lines on stdout. Tests cover bin/cwd existence warnings, stderr/exit-code - * handling, pom collection, --verbose paths, and exception handling. - * - * Related Files: - * - * - Src/commands/manifest/convert-gradle-to-maven.mts - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn(() => true)) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), -})) -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpinner = vi.hoisted(() => ({ - start: vi.fn(), - successAndStop: vi.fn(), - failAndStop: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockSpinner, -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - distPath: '/dist', -})) - -import { convertGradleToMaven } from '../../../../src/commands/manifest/convert-gradle-to-maven.mts' - -const baseOpts = { - bin: 'gradlew', - cwd: '/proj', - gradleOpts: [], - outputKind: 'text' as const, - verbose: false, -} - -describe('convertGradleToMaven', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockExistsSync.mockReturnValue(true) - }) - - it('warns when bin does not exist', async () => { - mockExistsSync.mockImplementation((p: string) => !p.includes('gradlew')) - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven(baseOpts) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('executable could not be found'), - ) - }) - - it('warns when cwd does not exist', async () => { - mockExistsSync.mockImplementation((p: string) => p.includes('gradlew')) - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven(baseOpts) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('src dir could not be found'), - ) - }) - - it('returns failure when gradle exits non-zero', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 1, - stdout: '', - stderr: 'compile error', - }) - - const result = await convertGradleToMaven(baseOpts) - - expect(result.ok).toBe(false) - expect(process.exitCode).toBe(1) - if (!result.ok) { - expect(result.message).toContain('exited with exit code 1') - expect(result.cause).toBe('compile error') - } - }) - - it('parses POM file copied to: lines from stdout', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: - 'noise line\nPOM file copied to: /proj/a.pom\nPOM file copied to: /proj/b.pom\nmore noise\n', - stderr: '', - }) - - const result = await convertGradleToMaven(baseOpts) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.files).toEqual(['/proj/a.pom', '/proj/b.pom']) - expect(result.data.type).toBe('gradle') - } - }) - - it('logs verbose stdout when --verbose is set', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven({ ...baseOpts, verbose: true }) - - expect(mockLogger.group).toHaveBeenCalledWith('[VERBOSE] gradle stdout:') - }) - - it('logs verbose pre-execution args when --verbose', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven({ ...baseOpts, verbose: true }) - - expect(mockLogger.log).toHaveBeenCalledWith( - '[VERBOSE] Executing:', - ['gradlew'], - ', args:', - expect.any(Array), - ) - }) - - it('skips text-mode logging in json mode', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven({ ...baseOpts, outputKind: 'json' }) - - expect(mockLogger.success).not.toHaveBeenCalled() - }) - - it('returns failure when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('command failed')) - - const result = await convertGradleToMaven(baseOpts) - - expect(result.ok).toBe(false) - expect(process.exitCode).toBe(1) - }) - - it('logs verbose error when --verbose and spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('command failed')) - - await convertGradleToMaven({ ...baseOpts, verbose: true }) - - expect(mockLogger.group).toHaveBeenCalledWith('[VERBOSE] error:') - }) - - it('errors with helpful message when spawn returns null', async () => { - mockSpawn.mockResolvedValueOnce(undefined) - - const result = await convertGradleToMaven(baseOpts) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('spawn returned no output') - } - }) - - it('forwards gradleOpts into the args array', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 0, - stdout: 'POM file copied to: /proj/foo.pom\n', - stderr: '', - }) - - await convertGradleToMaven({ - ...baseOpts, - gradleOpts: ['--info', '--stacktrace'], - }) - - const args = mockSpawn.mock.calls[0]?.[1] - expect(args).toEqual( - expect.arrayContaining(['--info', '--stacktrace', 'pom']), - ) - }) - - it('does not log stderr group on non-zero exit when --verbose', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 1, - stdout: '', - stderr: 'gradle err', - }) - - await convertGradleToMaven({ ...baseOpts, verbose: true }) - - // verbose mode prints the error in the stdout group, not a separate stderr group. - expect(mockLogger.group).not.toHaveBeenCalledWith('stderr:') - }) - - it('logs separate stderr group when not verbose and exit non-zero', async () => { - mockSpawn.mockResolvedValueOnce({ - code: 1, - stdout: '', - stderr: 'gradle err', - }) - - await convertGradleToMaven(baseOpts) - - expect(mockLogger.group).toHaveBeenCalledWith('stderr:') - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/convert-sbt-to-maven.test.mts b/packages/cli/test/unit/commands/manifest/convert-sbt-to-maven.test.mts deleted file mode 100644 index ade31edeb..000000000 --- a/packages/cli/test/unit/commands/manifest/convert-sbt-to-maven.test.mts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Unit tests for convertSbtToMaven. - * - * Spawns sbt makePom; collects "Wrote <path>.pom" lines from stdout to - * determine the produced pom files. Tests cover stderr handling, no-pom - * detection, single/multi-file stdout output, --verbose logging, and exception - * handling. - * - * Related Files: - * - * - Src/commands/manifest/convert-sbt-to-maven.mts - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), -})) -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSafeReadFile = vi.hoisted(() => vi.fn(async () => 'pom-content')) -const mockSpinner = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - safeReadFile: mockSafeReadFile, -})) -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockSpinner, -})) - -import { convertSbtToMaven } from '../../../../src/commands/manifest/convert-sbt-to-maven.mts' - -const baseOpts = { - bin: 'sbt', - cwd: '/proj', - out: 'output.pom.xml', - outputKind: 'text' as const, - sbtOpts: [], - verbose: false, -} - -describe('convertSbtToMaven', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('returns error when sbt writes to stderr', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/foo.pom\n', - stderr: 'compile failed', - }) - - const result = await convertSbtToMaven(baseOpts) - - expect(result.ok).toBe(false) - expect(process.exitCode).toBe(1) - }) - - it('returns error when no poms were generated', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'no relevant lines', - stderr: '', - }) - - const result = await convertSbtToMaven(baseOpts) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('not have generated any poms') - } - }) - - it('parses Wrote <path>.pom lines from stdout', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/a.pom\nWrote /proj/b.pom\n', - stderr: '', - }) - - const result = await convertSbtToMaven(baseOpts) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.files).toEqual(['/proj/a.pom', '/proj/b.pom']) - } - }) - - it('writes single-file pom to stdout when out=- and one pom exists', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/foo.pom\n', - stderr: '', - }) - - const result = await convertSbtToMaven({ ...baseOpts, out: '-' }) - - expect(result.ok).toBe(true) - expect(mockSafeReadFile).toHaveBeenCalledWith('/proj/foo.pom') - }) - - it('errors when out=- but multiple poms exist', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/a.pom\nWrote /proj/b.pom\n', - stderr: '', - }) - - const result = await convertSbtToMaven({ ...baseOpts, out: '-' }) - - expect(result.ok).toBe(false) - expect(process.exitCode).toBe(1) - if (!result.ok) { - expect(result.message).toContain('multiple generated files') - } - }) - - it('repeats the failure header when there are >10 poms with out=-', async () => { - const lines = Array.from({ length: 12 }, (_, i) => `Wrote /proj/p${i}.pom`) - mockSpawn.mockResolvedValueOnce({ - stdout: lines.join('\n') + '\n', - stderr: '', - }) - - await convertSbtToMaven({ ...baseOpts, out: '-' }) - - // logger.fail is called twice — once before the file list, once after. - expect(mockLogger.fail).toHaveBeenCalledTimes(2) - }) - - it('logs verbose stdout when --verbose is set', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/foo.pom\n', - stderr: '', - }) - - await convertSbtToMaven({ ...baseOpts, verbose: true }) - - expect(mockLogger.group).toHaveBeenCalledWith('[VERBOSE] sbt stdout:') - }) - - it('skips text-mode logging when outputKind is json', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/foo.pom\n', - stderr: '', - }) - - await convertSbtToMaven({ ...baseOpts, outputKind: 'json' }) - - expect(mockLogger.success).not.toHaveBeenCalled() - }) - - it('returns failure when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('command failed')) - - const result = await convertSbtToMaven(baseOpts) - - expect(result.ok).toBe(false) - expect(process.exitCode).toBe(1) - if (!result.ok) { - expect(result.cause).toContain('command failed') - } - }) - - it('logs verbose error details when --verbose and spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('command failed')) - - await convertSbtToMaven({ ...baseOpts, verbose: true }) - - expect(mockLogger.group).toHaveBeenCalledWith('[VERBOSE] error:') - }) - - it('forwards sbtOpts to the spawn invocation', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'Wrote /proj/foo.pom\n', - stderr: '', - }) - - await convertSbtToMaven({ ...baseOpts, sbtOpts: ['--debug', '--noisy'] }) - - expect(mockSpawn).toHaveBeenCalledWith( - 'sbt', - ['makePom', '--debug', '--noisy'], - expect.any(Object), - ) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/detect-manifest-actions.test.mts b/packages/cli/test/unit/commands/manifest/detect-manifest-actions.test.mts deleted file mode 100644 index 23f7e640e..000000000 --- a/packages/cli/test/unit/commands/manifest/detect-manifest-actions.test.mts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Unit tests for detectManifestActions. - * - * Walks a directory looking for files that indicate which manifest generators - * (sbt, gradle, conda) should run. Per-generator `socket.json` `disabled` flags - * can suppress detection. - * - * Test Coverage: - Empty directory → no detections, count 0 - build.sbt present - * → sbt=true, count 1 - gradlew present → gradle=true, count 1 - - * environment.yml present → conda=true, count 1 - environment.yaml present - * (when no .yml) → conda=true - Both .yml and .yaml present → only counts once - * (yml wins) - All three present → all true, count 3 - sockJson disables sbt → - * sbt=false even with build.sbt - sockJson disables gradle → gradle=false even - * with gradlew - sockJson disables conda → conda=false even with - * environment.yml - cdxgen field is always false (not auto-detected) - * - * Related Files: - src/commands/manifest/detect-manifest-actions.mts - - * Implementation. - */ - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { SocketJson } from '../../../../src/util/socket/json.mts' - -// Source-of-truth constants/paths.mts evaluates the bundle-tools.json -// version table at import time, which fails outside of a build (where -// INLINED_COANA_VERSION is missing). We only need ENVIRONMENT_YML / -// ENVIRONMENT_YAML, so stub them. -vi.mock('../../../../src/constants/paths.mjs', () => ({ - ENVIRONMENT_YAML: 'environment.yaml', - ENVIRONMENT_YML: 'environment.yml', -})) - -const { detectManifestActions } = - await import('../../../../src/commands/manifest/detect-manifest-actions.mts') - -let cwd = '' - -beforeEach(() => { - cwd = mkdtempSync(path.join(os.tmpdir(), 'detect-manifest-')) -}) - -afterEach(() => { - rmSync(cwd, { force: true, recursive: true }) -}) - -export function touch(rel: string) { - const full = path.join(cwd, rel) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, '') -} - -describe('detectManifestActions', () => { - it('returns all-false counts on an empty directory', async () => { - const result = await detectManifestActions(undefined, cwd) - expect(result).toEqual({ - cdxgen: false, - count: 0, - conda: false, - gradle: false, - sbt: false, - }) - }) - - it('detects build.sbt as Scala sbt project', async () => { - touch('build.sbt') - const result = await detectManifestActions(undefined, cwd) - expect(result.sbt).toBe(true) - expect(result.count).toBe(1) - }) - - it('detects gradlew as Gradle project', async () => { - touch('gradlew') - const result = await detectManifestActions(undefined, cwd) - expect(result.gradle).toBe(true) - expect(result.count).toBe(1) - }) - - it('detects environment.yml as Conda project', async () => { - touch('environment.yml') - const result = await detectManifestActions(undefined, cwd) - expect(result.conda).toBe(true) - expect(result.count).toBe(1) - }) - - it('detects environment.yaml as Conda project when .yml is absent', async () => { - touch('environment.yaml') - const result = await detectManifestActions(undefined, cwd) - expect(result.conda).toBe(true) - expect(result.count).toBe(1) - }) - - it('counts conda only once when both .yml and .yaml are present', async () => { - touch('environment.yml') - touch('environment.yaml') - const result = await detectManifestActions(undefined, cwd) - expect(result.conda).toBe(true) - expect(result.count).toBe(1) - }) - - it('detects all three when all marker files are present', async () => { - touch('build.sbt') - touch('gradlew') - touch('environment.yml') - const result = await detectManifestActions(undefined, cwd) - expect(result.sbt).toBe(true) - expect(result.gradle).toBe(true) - expect(result.conda).toBe(true) - expect(result.count).toBe(3) - }) - - it('respects socket.json disabling sbt detection', async () => { - touch('build.sbt') - const sockJson = { - defaults: { manifest: { sbt: { disabled: true } } }, - } as unknown as SocketJson - const result = await detectManifestActions(sockJson, cwd) - expect(result.sbt).toBe(false) - expect(result.count).toBe(0) - }) - - it('respects socket.json disabling gradle detection', async () => { - touch('gradlew') - const sockJson = { - defaults: { manifest: { gradle: { disabled: true } } }, - } as unknown as SocketJson - const result = await detectManifestActions(sockJson, cwd) - expect(result.gradle).toBe(false) - expect(result.count).toBe(0) - }) - - it('respects socket.json disabling conda detection', async () => { - touch('environment.yml') - const sockJson = { - defaults: { manifest: { conda: { disabled: true } } }, - } as unknown as SocketJson - const result = await detectManifestActions(sockJson, cwd) - expect(result.conda).toBe(false) - expect(result.count).toBe(0) - }) - - it('always reports cdxgen as false (not auto-detected)', async () => { - touch('build.sbt') - touch('gradlew') - touch('environment.yml') - const result = await detectManifestActions(undefined, cwd) - expect(result.cdxgen).toBe(false) - }) - - it('ignores other socket.json keys when checking specific generators', async () => { - // Only sbt is disabled; gradle and conda remain enabled. - touch('build.sbt') - touch('gradlew') - touch('environment.yml') - const sockJson = { - defaults: { manifest: { sbt: { disabled: true } } }, - } as unknown as SocketJson - const result = await detectManifestActions(sockJson, cwd) - expect(result.sbt).toBe(false) - expect(result.gradle).toBe(true) - expect(result.conda).toBe(true) - expect(result.count).toBe(2) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/generate_auto_manifest.test.mts b/packages/cli/test/unit/commands/manifest/generate_auto_manifest.test.mts deleted file mode 100644 index 8f5570f77..000000000 --- a/packages/cli/test/unit/commands/manifest/generate_auto_manifest.test.mts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Unit tests for generateAutoManifest. - * - * Drives the auto-detected manifest pipeline. Each detected ecosystem (sbt, - * gradle, conda) calls its converter unless the corresponding socket.json - * `defaults.manifest.<x>.disabled` flag is true. All converters and the - * socket.json reader are mocked. - * - * Related Files: - * - * - Src/commands/manifest/generate_auto_manifest.mts - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockConvertSbtToMaven = vi.hoisted(() => vi.fn().mockResolvedValue({})) -const mockConvertGradleToMaven = vi.hoisted(() => vi.fn().mockResolvedValue({})) -const mockHandleManifestConda = vi.hoisted(() => vi.fn().mockResolvedValue({})) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) -const mockLogger = vi.hoisted(() => ({ - info: vi.fn(), - log: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('../../../../src/commands/manifest/convert-sbt-to-maven.mts', () => ({ - convertSbtToMaven: mockConvertSbtToMaven, -})) -vi.mock( - '../../../../src/commands/manifest/convert-gradle-to-maven.mts', - () => ({ - convertGradleToMaven: mockConvertGradleToMaven, - }), -) -vi.mock('../../../../src/commands/manifest/handle-manifest-conda.mts', () => ({ - handleManifestConda: mockHandleManifestConda, -})) -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -import { generateAutoManifest } from '../../../../src/commands/manifest/generate_auto_manifest.mts' - -const baseDetected = { - conda: false, - gradle: false, - sbt: false, -} - -describe('generateAutoManifest', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadOrDefaultSocketJson.mockReturnValue({}) - }) - - it('logs socket.json defaults when verbose', async () => { - const sockJson = { defaults: {} } - mockReadOrDefaultSocketJson.mockReturnValueOnce(sockJson) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected }, - outputKind: 'text', - verbose: true, - }) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('socket.json'), - sockJson, - ) - }) - - it('runs sbt converter when sbt is detected and not disabled', async () => { - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, sbt: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: 'sbt', - cwd: '/proj', - out: './socket.sbt.pom.xml', - sbtOpts: [], - }), - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Scala sbt build'), - ) - }) - - it('does not log sbt detection in non-text mode', async () => { - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, sbt: true }, - outputKind: 'json', - verbose: false, - }) - - expect(mockConvertSbtToMaven).toHaveBeenCalled() - expect(mockLogger.log).not.toHaveBeenCalled() - }) - - it('skips sbt when disabled in socket.json', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { manifest: { sbt: { disabled: true } } }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, sbt: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertSbtToMaven).not.toHaveBeenCalled() - }) - - it('forwards sbt overrides from socket.json (bin/outfile/sbtOpts/verbose)', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - sbt: { - bin: '/custom/sbt', - outfile: 'custom-pom.xml', - sbtOpts: '--debug --noisy', - verbose: true, - }, - }, - }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, sbt: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertSbtToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/custom/sbt', - out: 'custom-pom.xml', - sbtOpts: ['--debug', '--noisy'], - verbose: true, - }), - ) - }) - - it('runs gradle converter when gradle is detected and not disabled', async () => { - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, gradle: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: expect.stringContaining('gradlew'), - cwd: '/proj', - gradleOpts: [], - verbose: false, - }), - ) - }) - - it('forwards gradle overrides from socket.json (relative bin resolved)', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - gradle: { - bin: 'tools/gradlew', - gradleOpts: '--info --stacktrace', - verbose: true, - }, - }, - }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, gradle: true }, - outputKind: 'json', - verbose: false, - }) - - expect(mockConvertGradleToMaven).toHaveBeenCalledWith( - expect.objectContaining({ - bin: '/proj/tools/gradlew', - gradleOpts: ['--info', '--stacktrace'], - verbose: true, - }), - ) - }) - - it('skips gradle when disabled in socket.json', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { manifest: { gradle: { disabled: true } } }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, gradle: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - }) - - it('runs conda handler when conda is detected and not disabled', async () => { - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, conda: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: '/proj', - filename: 'environment.yml', - verbose: false, - }), - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('environment.yml'), - ) - }) - - it('forwards conda overrides from socket.json (infile/outfile/verbose)', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - manifest: { - conda: { - infile: 'env.yml', - outfile: 'reqs.txt', - verbose: true, - }, - }, - }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, conda: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockHandleManifestConda).toHaveBeenCalledWith( - expect.objectContaining({ - filename: 'env.yml', - out: 'reqs.txt', - verbose: true, - }), - ) - }) - - it('skips conda when disabled in socket.json', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { manifest: { conda: { disabled: true } } }, - }) - - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected, conda: true }, - outputKind: 'text', - verbose: false, - }) - - expect(mockHandleManifestConda).not.toHaveBeenCalled() - }) - - it('does nothing when no manifests are detected', async () => { - await generateAutoManifest({ - cwd: '/proj', - detected: { ...baseDetected }, - outputKind: 'text', - verbose: false, - }) - - expect(mockConvertSbtToMaven).not.toHaveBeenCalled() - expect(mockConvertGradleToMaven).not.toHaveBeenCalled() - expect(mockHandleManifestConda).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/handle-manifest-conda.test.mts b/packages/cli/test/unit/commands/manifest/handle-manifest-conda.test.mts deleted file mode 100644 index cc8f5e1a5..000000000 --- a/packages/cli/test/unit/commands/manifest/handle-manifest-conda.test.mts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Unit Tests: Conda Manifest Command Handler. - * - * Purpose: Tests the command handler that converts Conda environment files to - * requirements.txt format. Validates orchestration between conversion logic and - * output formatting with support for multiple output formats (text, json, - * markdown) and verbose mode. - * - * Test Coverage: - Successful conversion and requirements output - Conversion - * failure handling with error propagation - Multiple output format support - * (text, json, markdown) - Verbose mode flag passing - Different working - * directory handling (absolute, relative, current) - * - * Testing Approach: Mocks convertCondaToRequirements and outputRequirements - * modules to test handler orchestration without actual file I/O. Uses test - * helpers for CResult pattern validation. - * - * Related Files: - src/commands/manifest/handle-manifest-conda.mts - Command - * handler - src/commands/manifest/convert-conda-to-requirements.mts - - * Conversion logic - src/commands/manifest/output-requirements.mts - Output - * formatting. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { handleManifestConda } from '../../../../src/commands/manifest/handle-manifest-conda.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockConvertCondaToRequirements = vi.hoisted(() => vi.fn()) -const mockOutputRequirements = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/manifest/convert-conda-to-requirements.mts', - () => ({ - convertCondaToRequirements: mockConvertCondaToRequirements, - }), -) - -vi.mock('../../../../src/commands/manifest/output-requirements.mts', () => ({ - outputRequirements: mockOutputRequirements, -})) - -describe('handleManifestConda', () => { - it('converts conda file and outputs requirements successfully', async () => { - await import('../../../../src/commands/manifest/convert-conda-to-requirements.mts') - await import('../../../../src/commands/manifest/output-requirements.mts') - const mockConvert = mockConvertCondaToRequirements - const mockOutput = mockOutputRequirements - - const mockRequirements = createSuccessResult([ - 'numpy==1.23.0', - 'pandas>=2.0.0', - 'scikit-learn~=1.3.0', - 'matplotlib', - ]) - mockConvert.mockResolvedValue(mockRequirements) - - await handleManifestConda({ - cwd: '/project', - filename: 'environment.yml', - out: 'requirements.txt', - outputKind: 'text', - verbose: true, - }) - - expect(mockConvert).toHaveBeenCalledWith( - 'environment.yml', - '/project', - true, - ) - expect(mockOutput).toHaveBeenCalledWith( - mockRequirements, - 'text', - 'requirements.txt', - ) - }) - - it('handles conversion failure', async () => { - await import('../../../../src/commands/manifest/convert-conda-to-requirements.mts') - await import('../../../../src/commands/manifest/output-requirements.mts') - const mockConvert = mockConvertCondaToRequirements - const mockOutput = mockOutputRequirements - - const mockError = createErrorResult('Invalid conda file format') - mockConvert.mockResolvedValue(mockError) - - await handleManifestConda({ - cwd: '/project', - filename: 'invalid.yml', - out: '', - outputKind: 'json', - verbose: false, - }) - - expect(mockConvert).toHaveBeenCalledWith('invalid.yml', '/project', false) - expect(mockOutput).toHaveBeenCalledWith(mockError, 'json', '') - }) - - it('handles different output formats', async () => { - await import('../../../../src/commands/manifest/convert-conda-to-requirements.mts') - await import('../../../../src/commands/manifest/output-requirements.mts') - const mockConvert = mockConvertCondaToRequirements - const mockOutput = mockOutputRequirements - - mockConvert.mockResolvedValue(createSuccessResult([])) - - const formats = ['text', 'json', 'markdown'] as const - - for (let i = 0, { length } = formats; i < length; i += 1) { - const format = formats[i] - await handleManifestConda({ - cwd: '.', - filename: 'conda.yml', - out: `output.${format}`, - outputKind: format, - verbose: false, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - format, - `output.${format}`, - ) - } - }) - - it('handles verbose mode', async () => { - await import('../../../../src/commands/manifest/convert-conda-to-requirements.mts') - const mockConvert = mockConvertCondaToRequirements - - mockConvert.mockResolvedValue(createSuccessResult([])) - - await handleManifestConda({ - cwd: '/verbose', - filename: 'environment.yaml', - out: 'reqs.txt', - outputKind: 'text', - verbose: true, - }) - - expect(mockConvert).toHaveBeenCalledWith( - 'environment.yaml', - '/verbose', - true, - ) - }) - - it('handles different working directories', async () => { - await import('../../../../src/commands/manifest/convert-conda-to-requirements.mts') - const mockConvert = mockConvertCondaToRequirements - - mockConvert.mockResolvedValue(createSuccessResult([])) - - const cwds = ['/root', '/home/user/project', './relative', '.'] - - for (let i = 0, { length } = cwds; i < length; i += 1) { - const cwd = cwds[i] - await handleManifestConda({ - cwd, - filename: 'conda.yml', - out: 'requirements.txt', - outputKind: 'text', - verbose: false, - }) - - expect(mockConvert).toHaveBeenCalledWith('conda.yml', cwd, false) - } - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/handle-manifest-setup.test.mts b/packages/cli/test/unit/commands/manifest/handle-manifest-setup.test.mts deleted file mode 100644 index 58dfb2663..000000000 --- a/packages/cli/test/unit/commands/manifest/handle-manifest-setup.test.mts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Unit Tests: Manifest Configuration Setup Handler. - * - * Purpose: Tests the command handler that initializes Socket manifest - * configuration (socket.json) for a project. Validates the orchestration - * between configuration setup and output formatting with support for error - * recovery through the defaultOnReadError flag. - * - * Test Coverage: - Successful manifest configuration setup - Setup failure - * handling with error output - defaultOnReadError flag behavior (true/false) - - * Empty data result handling - Current directory and absolute path support - - * Async error propagation. - * - * Testing Approach: Mocks setupManifestConfig and outputManifestSetup modules - * to test handler orchestration without actual file system operations. Tests - * verify correct parameter passing and CResult pattern handling. - * - * Related Files: - src/commands/manifest/handle-manifest-setup.mts - Command - * handler - src/commands/manifest/setup-manifest-config.mts - Configuration - * setup logic - src/commands/manifest/output-manifest-setup.mts - Output - * formatting. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleManifestSetup } from '../../../../src/commands/manifest/handle-manifest-setup.mts' - -// Mock the dependencies. -const mockOutputManifestSetup = vi.hoisted(() => vi.fn()) -const mockSetupManifestConfig = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/manifest/output-manifest-setup.mts', () => ({ - outputManifestSetup: mockOutputManifestSetup, -})) -vi.mock('../../../../src/commands/manifest/setup-manifest-config.mts', () => ({ - setupManifestConfig: mockSetupManifestConfig, -})) - -describe('handleManifestSetup', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('sets up manifest config successfully', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - const { outputManifestSetup } = - await import('../../../../src/commands/manifest/output-manifest-setup.mts') - - const mockResult = { - ok: true, - data: { - manifestPath: '/test/project/socket.json', - config: { - projectIgnorePaths: ['node_modules', 'dist'], - manifestFiles: ['package.json', 'yarn.lock'], - }, - }, - } - mockSetupManifestConfig.mockResolvedValue(mockResult) - - await handleManifestSetup('/test/project', false) - - expect(setupManifestConfig).toHaveBeenCalledWith('/test/project', false) - expect(outputManifestSetup).toHaveBeenCalledWith(mockResult) - }) - - it('handles setup failure', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - const { outputManifestSetup } = - await import('../../../../src/commands/manifest/output-manifest-setup.mts') - - const mockError = { - ok: false, - error: new Error('Failed to setup manifest'), - } - mockSetupManifestConfig.mockResolvedValue(mockError) - - await handleManifestSetup('/test/project', true) - - expect(setupManifestConfig).toHaveBeenCalledWith('/test/project', true) - expect(outputManifestSetup).toHaveBeenCalledWith(mockError) - }) - - it('handles defaultOnReadError flag true', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - const { outputManifestSetup } = - await import('../../../../src/commands/manifest/output-manifest-setup.mts') - - const mockResult = { - ok: true, - data: { manifestPath: '/test/socket.json' }, - } - mockSetupManifestConfig.mockResolvedValue(mockResult) - - await handleManifestSetup('/some/dir', true) - - expect(setupManifestConfig).toHaveBeenCalledWith('/some/dir', true) - expect(outputManifestSetup).toHaveBeenCalledWith(mockResult) - }) - - it('handles defaultOnReadError flag false', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - - mockSetupManifestConfig.mockResolvedValue({ - ok: true, - data: {}, - }) - - await handleManifestSetup('/project', false) - - expect(setupManifestConfig).toHaveBeenCalledWith('/project', false) - }) - - it('handles empty data result', async () => { - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - const { outputManifestSetup } = - await import('../../../../src/commands/manifest/output-manifest-setup.mts') - - const mockResult = { - ok: true, - data: {}, - } - mockSetupManifestConfig.mockResolvedValue(mockResult) - - await handleManifestSetup('/test', false) - - expect(outputManifestSetup).toHaveBeenCalledWith(mockResult) - }) - - it('handles async errors', async () => { - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - - mockSetupManifestConfig.mockRejectedValue(new Error('Async error')) - - await expect(handleManifestSetup('/test', false)).rejects.toThrow( - 'Async error', - ) - }) - - it('handles current directory path', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - - mockSetupManifestConfig.mockResolvedValue({ - ok: true, - data: { manifestPath: './socket.json' }, - }) - - await handleManifestSetup('.', false) - - expect(setupManifestConfig).toHaveBeenCalledWith('.', false) - }) - - it('handles absolute path', async () => { - const { setupManifestConfig } = - await import('../../../../src/commands/manifest/setup-manifest-config.mts') - - mockSetupManifestConfig.mockResolvedValue({ - ok: true, - data: { manifestPath: '/absolute/path/socket.json' }, - }) - - await handleManifestSetup('/absolute/path', true) - - expect(setupManifestConfig).toHaveBeenCalledWith('/absolute/path', true) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/output-manifest-setup.test.mts b/packages/cli/test/unit/commands/manifest/output-manifest-setup.test.mts deleted file mode 100644 index 59c75b5b7..000000000 --- a/packages/cli/test/unit/commands/manifest/output-manifest-setup.test.mts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Unit tests for manifest setup output formatting. - * - * Purpose: Tests the output formatting for manifest setup results. - * - * Test Coverage: - outputManifestSetup function - Success output - Error - * handling. - * - * Related Files: - src/commands/manifest/output-manifest-setup.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -import { outputManifestSetup } from '../../../../src/commands/manifest/output-manifest-setup.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-manifest-setup', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputManifestSetup', () => { - describe('success output', () => { - it('outputs setup complete message', async () => { - const result: CResult<unknown> = { - ok: true, - data: {}, - } - - await outputManifestSetup(result) - - expect(mockLogger.success).toHaveBeenCalledWith('Setup complete') - }) - - it('does not set exit code on success', async () => { - const result: CResult<unknown> = { - ok: true, - data: { configured: true }, - } - - await outputManifestSetup(result) - - expect(process.exitCode).toBeUndefined() - }) - }) - - describe('error output', () => { - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Setup failed', - cause: 'Missing configuration', - } - - await outputManifestSetup(result) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Setup failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Configuration error', - code: 2, - } - - await outputManifestSetup(result) - - expect(process.exitCode).toBe(2) - }) - - it('does not call success on error', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Failed', - } - - await outputManifestSetup(result) - - expect(mockLogger.success).not.toHaveBeenCalled() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/output-manifest.test.mts b/packages/cli/test/unit/commands/manifest/output-manifest.test.mts deleted file mode 100644 index f9b7fadc8..000000000 --- a/packages/cli/test/unit/commands/manifest/output-manifest.test.mts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Unit tests for manifest output formatting. - * - * Purpose: Tests the output formatting for manifest generation results. - * - * Test Coverage: - outputManifest function - JSON output format with file and - * stdout - Markdown output format with file and stdout - Error handling. - * - * Related Files: - src/commands/manifest/output-manifest.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock fs. -const mockWriteFileSync = vi.hoisted(() => vi.fn()) -vi.mock('node:fs', () => ({ - writeFileSync: mockWriteFileSync, - default: { - writeFileSync: mockWriteFileSync, - }, -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string, level = 1) => `${'#'.repeat(level)} ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputManifest } from '../../../../src/commands/manifest/output-manifest.mts' - -import type { ManifestResult } from '../../../../src/commands/manifest/output-manifest.mts' -import type { CResult } from '../../../../src/types.mts' - -describe('output-manifest', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputManifest', () => { - const mockGradleResult: ManifestResult = { - files: ['pom.xml', 'subproject/pom.xml'], - type: 'gradle', - success: true, - } - - const mockSbtResult: ManifestResult = { - files: ['pom.xml'], - type: 'sbt', - success: true, - } - - describe('JSON output', () => { - it('outputs success result as JSON to stdout', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockGradleResult, - } - - await outputManifest(result, 'json', '-') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - expect(mockWriteFileSync).not.toHaveBeenCalled() - }) - - it('writes JSON to file when path provided', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockGradleResult, - } - - await outputManifest(result, 'json', '/output/result.json') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/output/result.json', - expect.stringContaining('"ok": true'), - 'utf8', - ) - expect(mockLogger.log).not.toHaveBeenCalled() - }) - - it('outputs error result as JSON', async () => { - const result: CResult<ManifestResult> = { - ok: false, - message: 'Manifest generation failed', - } - - await outputManifest(result, 'json', '-') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Markdown output', () => { - it('outputs Gradle manifest to stdout', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockGradleResult, - } - - await outputManifest(result, 'markdown', '-') - - const loggedMd = mockLogger.log.mock.calls[0]![0] - expect(loggedMd).toContain('# Gradle Manifest Generation') - expect(loggedMd).toContain('pom.xml') - expect(loggedMd).toContain('subproject/pom.xml') - expect(loggedMd).toContain('2 POM files') - expect(loggedMd).toContain('## Next Steps') - expect(loggedMd).toContain('socket scan create') - }) - - it('outputs SBT manifest to stdout', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockSbtResult, - } - - await outputManifest(result, 'markdown', '-') - - const loggedMd = mockLogger.log.mock.calls[0]![0] - expect(loggedMd).toContain('# SBT Manifest Generation') - expect(loggedMd).toContain('1 POM file') - }) - - it('writes markdown to file when path provided', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockGradleResult, - } - - await outputManifest(result, 'markdown', '/output/result.md') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/output/result.md', - expect.stringContaining('Gradle Manifest Generation'), - 'utf8', - ) - }) - }) - - describe('Text output', () => { - it('handles text output mode', async () => { - const result: CResult<ManifestResult> = { - ok: true, - data: mockGradleResult, - } - - // Text output is handled by converter functions directly. - await outputManifest(result, 'text', '-') - - // Function should complete without error. - expect(process.exitCode).toBeUndefined() - }) - }) - - describe('Error handling', () => { - it('outputs error with fail message for non-JSON', async () => { - const result: CResult<ManifestResult> = { - ok: false, - message: 'Build failed', - cause: 'Gradle not found', - } - - await outputManifest(result, 'text', '-') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Build failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<ManifestResult> = { - ok: false, - message: 'Failed', - code: 127, - } - - await outputManifest(result, 'text', '-') - - expect(process.exitCode).toBe(127) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/output-requirements.test.mts b/packages/cli/test/unit/commands/manifest/output-requirements.test.mts deleted file mode 100644 index 62b5f562a..000000000 --- a/packages/cli/test/unit/commands/manifest/output-requirements.test.mts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Unit tests for requirements output formatting. - * - * Purpose: Tests the output formatting for Conda to requirements.txt conversion - * results. - * - * Test Coverage: - outputRequirements function - JSON output format with file - * and stdout - Markdown output format with file and stdout - Text output format - * with file and stdout - Error handling. - * - * Related Files: - src/commands/manifest/output-requirements.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock fs. -const mockWriteFileSync = vi.hoisted(() => vi.fn()) -vi.mock('node:fs', () => ({ - writeFileSync: mockWriteFileSync, - default: { - writeFileSync: mockWriteFileSync, - }, -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string, level = 1) => `${'#'.repeat(level)} ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputRequirements } from '../../../../src/commands/manifest/output-requirements.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-requirements', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputRequirements', () => { - const mockSuccessData = { - content: 'name: myenv\ndependencies:\n - numpy\n - pandas', - pip: 'numpy\npandas', - } - - describe('JSON output', () => { - it('outputs success result as JSON to stdout', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'json', '-') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - expect(mockWriteFileSync).not.toHaveBeenCalled() - }) - - it('writes JSON to file when path provided', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'json', '/output/result.json') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/output/result.json', - expect.stringContaining('"ok": true'), - 'utf8', - ) - expect(mockLogger.log).not.toHaveBeenCalled() - }) - - it('outputs error result as JSON', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Conversion failed', - } - - await outputRequirements(result, 'json', '-') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Markdown output', () => { - it('outputs converted conda file as markdown to stdout', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'markdown', '-') - - const loggedMd = mockLogger.log.mock.calls[0]![0] - expect(loggedMd).toContain('# Converted Conda file') - expect(loggedMd).toContain('environment.yml') - expect(loggedMd).toContain('requirements.txt') - expect(loggedMd).toContain('numpy\npandas') - expect(loggedMd).toContain('```') - }) - - it('writes markdown to file when path provided', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'markdown', '/output/result.md') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/output/result.md', - expect.stringContaining('Converted Conda file'), - 'utf8', - ) - expect(mockLogger.log).not.toHaveBeenCalled() - }) - - it('includes pip content in code block', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: { content: 'yaml', pip: 'flask>=2.0\nrequests' }, - } - - await outputRequirements(result, 'markdown', '-') - - const loggedMd = mockLogger.log.mock.calls[0]![0] - expect(loggedMd).toContain('flask>=2.0\nrequests') - }) - }) - - describe('Text output', () => { - it('outputs pip content to stdout', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'text', '-') - - expect(mockLogger.log).toHaveBeenCalledWith('numpy\npandas') - // Also outputs empty line. - expect(mockLogger.log).toHaveBeenCalledWith('') - }) - - it('writes pip content to file when path provided', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: true, - data: mockSuccessData, - } - - await outputRequirements(result, 'text', '/output/requirements.txt') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/output/requirements.txt', - 'numpy\npandas', - 'utf8', - ) - expect(mockLogger.log).not.toHaveBeenCalled() - }) - }) - - describe('Error handling', () => { - it('outputs error with fail message for non-JSON', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Conversion failed', - cause: 'Invalid YAML format', - } - - await outputRequirements(result, 'text', '-') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Conversion failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Failed', - code: 127, - } - - await outputRequirements(result, 'text', '-') - - expect(process.exitCode).toBe(127) - }) - - it('sets exit code before processing for error result', async () => { - const result: CResult<typeof mockSuccessData> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputRequirements(result, 'json', '-') - - expect(process.exitCode).toBe(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/run-cdxgen.test.mts b/packages/cli/test/unit/commands/manifest/run-cdxgen.test.mts deleted file mode 100644 index 92b8c8667..000000000 --- a/packages/cli/test/unit/commands/manifest/run-cdxgen.test.mts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Unit tests for run-cdxgen helpers. - * - * Covers the lockfile/node_modules probe and Node.js type detection that gate - * the default `socket cdxgen` path against shipping empty-components SBOMs. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockFindUp = vi.hoisted(() => vi.fn()) -const mockSpawnCdxgenDlx = vi.hoisted(() => vi.fn()) -const mockSpawnSynpDlx = vi.hoisted(() => vi.fn()) -const mockSafeDeleteSync = vi.hoisted(() => vi.fn()) -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFile = vi.hoisted(() => vi.fn()) -const mockIsYarnBerry = vi.hoisted(() => vi.fn(() => false)) -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - promises: { readFile: mockReadFile }, -})) - -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDeleteSync: mockSafeDeleteSync, -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('../../../../src/util/fs/find-up.mts', () => ({ - findUp: mockFindUp, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnCdxgenDlx: mockSpawnCdxgenDlx, - spawnSynpDlx: mockSpawnSynpDlx, -})) - -vi.mock('../../../../src/util/yarn/version.mts', () => ({ - isYarnBerry: mockIsYarnBerry, -})) - -const { detectNodejsCdxgenSources, isNodejsCdxgenType, runCdxgen } = - await import('../../../../src/commands/manifest/run-cdxgen.mts') - -describe('isNodejsCdxgenType', () => { - it('treats an undefined type as Node.js (the cdxgen default)', () => { - expect(isNodejsCdxgenType(undefined)).toBe(true) - expect(isNodejsCdxgenType(undefined)).toBe(true) - }) - - it.each(['js', 'javascript', 'typescript', 'nodejs', 'npm', 'pnpm', 'ts'])( - 'recognizes %s as Node.js', - type => { - expect(isNodejsCdxgenType(type)).toBe(true) - }, - ) - - it.each(['python', 'java', 'go', 'rust'])('rejects %s', type => { - expect(isNodejsCdxgenType(type)).toBe(false) - }) - - it('matches arrays containing at least one Node.js entry', () => { - expect(isNodejsCdxgenType(['python', 'js'])).toBe(true) - expect(isNodejsCdxgenType(['python', 'java'])).toBe(false) - }) - - it('returns false for non-string non-array types', () => { - expect(isNodejsCdxgenType(42)).toBe(false) - expect(isNodejsCdxgenType({} as unknown)).toBe(false) - expect(isNodejsCdxgenType(true as unknown)).toBe(false) - }) -}) - -describe('detectNodejsCdxgenSources', () => { - beforeEach(() => { - mockFindUp.mockReset() - }) - - it('reports neither source when nothing is found', async () => { - mockFindUp.mockResolvedValue(undefined) - const result = await detectNodejsCdxgenSources('/tmp/project') - expect(result).toEqual({ hasLockfile: false, hasNodeModules: false }) - }) - - it('detects a pnpm-lock.yaml', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve( - name === 'pnpm-lock.yaml' ? '/x/pnpm-lock.yaml' : undefined, - ), - ) - const result = await detectNodejsCdxgenSources('/tmp/project') - expect(result.hasLockfile).toBe(true) - expect(result.hasNodeModules).toBe(false) - }) - - it('detects a package-lock.json', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve( - name === 'package-lock.json' ? '/x/package-lock.json' : undefined, - ), - ) - const result = await detectNodejsCdxgenSources('/tmp/project') - expect(result.hasLockfile).toBe(true) - }) - - it('detects a yarn.lock', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve(name === 'yarn.lock' ? '/x/yarn.lock' : undefined), - ) - const result = await detectNodejsCdxgenSources('/tmp/project') - expect(result.hasLockfile).toBe(true) - }) - - it('detects node_modules/', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve(name === 'node_modules' ? '/x/node_modules' : undefined), - ) - const result = await detectNodejsCdxgenSources('/tmp/project') - expect(result.hasLockfile).toBe(false) - expect(result.hasNodeModules).toBe(true) - }) -}) - -describe('runCdxgen', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindUp.mockResolvedValue(undefined) - mockExistsSync.mockReturnValue(false) - mockSpawnCdxgenDlx.mockResolvedValue({ - spawnPromise: Promise.resolve({}), - process: {}, - stdin: undefined, - }) - mockSpawnSynpDlx.mockResolvedValue({ - spawnPromise: Promise.resolve({}), - }) - }) - - it('returns help args when --help is passed', async () => { - const result = await runCdxgen({ help: true, _: [] }) - await result.spawnPromise - - expect(mockSpawnCdxgenDlx).toHaveBeenCalledWith( - expect.arrayContaining(['--help']), - expect.objectContaining({ agent: 'npm' }), - ) - }) - - it('uses pnpm agent when pnpm-lock.yaml is found', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve( - name === 'pnpm-lock.yaml' ? '/x/pnpm-lock.yaml' : undefined, - ), - ) - - const result = await runCdxgen({ _: [] }) - await result.spawnPromise - - expect(mockSpawnCdxgenDlx).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ agent: 'pnpm' }), - ) - }) - - it('uses yarn agent when yarn.lock is found and yarn berry is detected', async () => { - mockIsYarnBerry.mockReturnValueOnce(true) - mockFindUp.mockImplementation((name: string) => - Promise.resolve(name === 'yarn.lock' ? '/x/yarn.lock' : undefined), - ) - - const result = await runCdxgen({ _: [], type: 'java' }) - await result.spawnPromise - - expect(mockSpawnCdxgenDlx).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ agent: 'yarn' }), - ) - }) - - it('keeps original type when only package-lock.json exists', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve( - name === 'package-lock.json' ? '/x/package-lock.json' : undefined, - ), - ) - - await runCdxgen({ _: [], type: 'js' }) - - expect(mockSpawnSynpDlx).not.toHaveBeenCalled() - }) - - it('uses synp to create package-lock.json when only yarn.lock exists', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve(name === 'yarn.lock' ? '/x/yarn.lock' : undefined), - ) - - const result = await runCdxgen({ _: [], type: 'js' }) - await result.spawnPromise - - expect(mockSpawnSynpDlx).toHaveBeenCalled() - expect(mockSafeDeleteSync).toHaveBeenCalledWith('./package-lock.json') - }) - - it('handles synp failures gracefully and continues with cdxgen', async () => { - mockFindUp.mockImplementation((name: string) => - Promise.resolve(name === 'yarn.lock' ? '/x/yarn.lock' : undefined), - ) - mockSpawnSynpDlx.mockRejectedValueOnce(new Error('synp failed')) - - const result = await runCdxgen({ _: [], type: 'js' }) - await result.spawnPromise - - expect(mockSpawnCdxgenDlx).toHaveBeenCalled() - }) - - it('passes flag-style options through to cdxgen args', async () => { - const result = await runCdxgen({ - _: [], - babel: false, - 'install-deps': true, - validate: false, - output: '', - lifecycle: 'build', - flag1: true, - something: 'value', - list: ['a', 'b'], - }) - await result.spawnPromise - - const args = mockSpawnCdxgenDlx.mock.calls[0]?.[0] as string[] - expect(args).toContain('--no-babel') - expect(args).toContain('--install-deps') - expect(args).toContain('--no-validate') - expect(args).toContain('--lifecycle') - expect(args).toContain('build') - expect(args).toContain('--flag1') - expect(args).toContain('--something') - expect(args).toContain('value') - }) - - it('warns when output BOM has empty components array', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFile.mockResolvedValueOnce(JSON.stringify({ components: [] })) - - const result = await runCdxgen({ - _: [], - output: 'bom.json', - lifecycle: 'pre-build', - }) - await result.spawnPromise - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('bom.json created'), - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('empty "components"'), - ) - }) - - it('warns with non-pre-build lifecycle hint when components empty', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFile.mockResolvedValueOnce(JSON.stringify({ components: [] })) - - const result = await runCdxgen({ - _: [], - output: 'bom.json', - lifecycle: 'build', - }) - await result.spawnPromise - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('--type'), - ) - }) - - it('does not warn when components are present', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFile.mockResolvedValueOnce( - JSON.stringify({ components: [{ name: 'foo' }] }), - ) - - const result = await runCdxgen({ _: [], output: 'bom.json' }) - await result.spawnPromise - - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('rejects output paths outside the cwd', async () => { - mockExistsSync.mockReturnValue(true) - - const result = await runCdxgen({ _: [], output: '../../escape.json' }) - await result.spawnPromise - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('outside the current working directory'), - ) - }) - - it('handles BOM read failures silently', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFile.mockRejectedValueOnce(new Error('read failed')) - - const result = await runCdxgen({ _: [], output: 'bom.json' }) - await result.spawnPromise - - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('handles malformed BOM JSON silently', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFile.mockResolvedValueOnce('not json') - - const result = await runCdxgen({ _: [], output: 'bom.json' }) - await result.spawnPromise - - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('forwards positional args after the double hyphen', async () => { - const result = await runCdxgen({ - _: ['./project'], - '--': ['extra', 'args'], - } as unknown) - await result.spawnPromise - - const args = mockSpawnCdxgenDlx.mock.calls[0]?.[0] as string[] - expect(args).toContain('./project') - expect(args).toContain('--') - expect(args).toContain('extra') - }) - - it('expands Array-valued flags into multiple --key value pairs', async () => { - const { argvObjectToArray } = - await import('../../../../src/commands/manifest/run-cdxgen.mts') - // Array value → push --key followed by every entry stringified. - const result = argvObjectToArray({ - filter: ['npm', 'pypi'], - } as unknown) - expect(result).toContain('--filter') - expect(result).toContain('npm') - expect(result).toContain('pypi') - }) - - it('emits --key when value is exactly true', async () => { - const { argvObjectToArray } = - await import('../../../../src/commands/manifest/run-cdxgen.mts') - const result = argvObjectToArray({ recurse: true } as unknown) - expect(result).toEqual(['--recurse']) - }) - - it('preserves --no-X form for negated lifecycle flags', async () => { - const { argvObjectToArray } = - await import('../../../../src/commands/manifest/run-cdxgen.mts') - expect( - argvObjectToArray({ babel: false, validate: false } as unknown), - ).toEqual(['--no-babel', '--no-validate']) - }) - - it('emits --key value for string values', async () => { - const { argvObjectToArray } = - await import('../../../../src/commands/manifest/run-cdxgen.mts') - expect(argvObjectToArray({ output: 'out.json' } as unknown)).toEqual([ - '--output', - 'out.json', - ]) - }) -}) diff --git a/packages/cli/test/unit/commands/manifest/setup-manifest-config.test.mts b/packages/cli/test/unit/commands/manifest/setup-manifest-config.test.mts deleted file mode 100644 index 2ef014dac..000000000 --- a/packages/cli/test/unit/commands/manifest/setup-manifest-config.test.mts +++ /dev/null @@ -1,631 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for setup-manifest-config helpers and interactive flow. - * - * Related Files: - src/commands/manifest/setup-manifest-config.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockInput = vi.hoisted(() => vi.fn()) -const mockSelect = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - input: mockInput, - select: mockSelect, -})) - -const mockReadSocketJsonSync = vi.hoisted(() => - vi.fn(() => ({ ok: true, data: {} })), -) -const mockWriteSocketJson = vi.hoisted(() => - vi.fn(async () => ({ ok: true, data: undefined })), -) -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readSocketJsonSync: mockReadSocketJsonSync, - writeSocketJson: mockWriteSocketJson, -})) - -const mockDetectManifestActions = vi.hoisted(() => vi.fn(async () => ({}))) -vi.mock( - '../../../../src/commands/manifest/detect-manifest-actions.mts', - () => ({ - detectManifestActions: mockDetectManifestActions, - }), -) - -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)) -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -import { - askForBin, - askForEnabled, - askForInputFile, - askForOutputFile, - askForStdout, - askForVerboseFlag, - canceledByUser, - notCanceled, - setupConda, - setupGradle, - setupManifestConfig, - setupSbt, -} from '../../../../src/commands/manifest/setup-manifest-config.mts' - -describe('setup-manifest-config', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadSocketJsonSync.mockReturnValue({ ok: true, data: {} }) - mockWriteSocketJson.mockResolvedValue({ ok: true, data: undefined }) - mockExistsSync.mockReturnValue(false) - mockDetectManifestActions.mockResolvedValue({}) - }) - - describe('canceledByUser', () => { - it('returns ok=true with canceled=true', () => { - const result = canceledByUser() - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('logs "User canceled"', () => { - canceledByUser() - expect(mockLogger.info).toHaveBeenCalledWith('User canceled') - }) - }) - - describe('notCanceled', () => { - it('returns ok=true with canceled=false', () => { - const result = notCanceled() - expect(result).toEqual({ ok: true, data: { canceled: false } }) - }) - }) - - describe('askForStdout', () => { - it('passes "yes" default for true', async () => { - mockSelect.mockResolvedValueOnce('yes') - const result = await askForStdout(true) - expect(result).toBe('yes') - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: 'yes' }), - ) - }) - - it('passes "no" default for false', async () => { - mockSelect.mockResolvedValueOnce('no') - await askForStdout(false) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: 'no' }), - ) - }) - - it('passes empty default when undefined', async () => { - mockSelect.mockResolvedValueOnce('') - await askForStdout(undefined) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: '' }), - ) - }) - }) - - describe('askForEnabled', () => { - it('returns the user choice', async () => { - mockSelect.mockResolvedValueOnce(true) - const result = await askForEnabled(false) - expect(result).toBe(true) - }) - - it('passes default based on enabled value', async () => { - mockSelect.mockResolvedValueOnce(true) - await askForEnabled(true) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: 'enable' }), - ) - - mockSelect.mockResolvedValueOnce(false) - await askForEnabled(false) - expect(mockSelect).toHaveBeenLastCalledWith( - expect.objectContaining({ default: 'disable' }), - ) - - mockSelect.mockResolvedValueOnce(undefined) - await askForEnabled(undefined) - expect(mockSelect).toHaveBeenLastCalledWith( - expect.objectContaining({ default: '' }), - ) - }) - }) - - describe('askForInputFile', () => { - it('passes default name', async () => { - mockInput.mockResolvedValueOnce('foo.yml') - const result = await askForInputFile('environment.yml') - expect(result).toBe('foo.yml') - expect(mockInput).toHaveBeenCalledWith( - expect.objectContaining({ default: 'environment.yml' }), - ) - }) - - it('passes empty default by default', async () => { - mockInput.mockResolvedValueOnce('') - await askForInputFile() - expect(mockInput).toHaveBeenCalledWith( - expect.objectContaining({ default: '' }), - ) - }) - }) - - describe('askForOutputFile', () => { - it('passes default name', async () => { - mockInput.mockResolvedValueOnce('out.txt') - const result = await askForOutputFile('default.txt') - expect(result).toBe('out.txt') - expect(mockInput).toHaveBeenCalledWith( - expect.objectContaining({ default: 'default.txt' }), - ) - }) - }) - - describe('askForBin', () => { - it('passes default bin', async () => { - mockInput.mockResolvedValueOnce('./bin') - const result = await askForBin('./gradlew') - expect(result).toBe('./bin') - expect(mockInput).toHaveBeenCalledWith( - expect.objectContaining({ default: './gradlew' }), - ) - }) - }) - - describe('askForVerboseFlag', () => { - it('passes "yes" default for true', async () => { - mockSelect.mockResolvedValueOnce('yes') - await askForVerboseFlag(true) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: 'yes' }), - ) - }) - - it('passes "no" default for false', async () => { - mockSelect.mockResolvedValueOnce('no') - await askForVerboseFlag(false) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: 'no' }), - ) - }) - - it('passes empty default for undefined', async () => { - mockSelect.mockResolvedValueOnce('') - await askForVerboseFlag(undefined) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ default: '' }), - ) - }) - }) - - describe('setupConda', () => { - it('cancels when askForEnabled returns undefined', async () => { - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupConda({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('disables when user picks "Disable" (false)', async () => { - mockSelect.mockResolvedValueOnce(false) - mockInput.mockResolvedValueOnce('environment.yml') - mockSelect.mockResolvedValueOnce('') - mockInput.mockResolvedValueOnce('requirements.txt') - mockSelect.mockResolvedValueOnce('') - const config: unknown = {} - const result = await setupConda(config) - expect(result.ok).toBe(true) - expect(config.disabled).toBe(true) - expect(config.infile).toBe('environment.yml') - }) - - it('enables and sets stdin when user enters "-"', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('-') - mockSelect.mockResolvedValueOnce('') - mockInput.mockResolvedValueOnce('out.txt') - mockSelect.mockResolvedValueOnce('yes') - const config: unknown = { disabled: true } - const result = await setupConda(config) - expect(result.ok).toBe(true) - expect(config.disabled).toBeUndefined() - expect(config.stdin).toBe(true) - expect(config.verbose).toBe(true) - }) - - it('cancels when input file prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce(undefined) - const result = await setupConda({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when stdout prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('environment.yml') - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupConda({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('sets stdout=true when user picks "yes" (output prompt skipped)', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('env.yml') - mockSelect.mockResolvedValueOnce('yes') - mockSelect.mockResolvedValueOnce('') - const config: unknown = {} - const result = await setupConda(config) - expect(result.ok).toBe(true) - expect(config.stdout).toBe(true) - }) - - it('prompts for output file after stdout="no", saves outfile', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('env.yml') - mockSelect.mockResolvedValueOnce('no') - mockInput.mockResolvedValueOnce('out.txt') - mockSelect.mockResolvedValueOnce('') - const config: unknown = {} - const result = await setupConda(config) - expect(result.ok).toBe(true) - // Note: after the output-file prompt fires, stdout is deleted (line 242) - // so it should be undefined here, not false. - expect(config.stdout).toBeUndefined() - expect(config.outfile).toBe('out.txt') - }) - - it('cancels when output file prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('env.yml') - mockSelect.mockResolvedValueOnce('') - mockInput.mockResolvedValueOnce(undefined) - const result = await setupConda({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('promotes "-" output to stdout', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('env.yml') - mockSelect.mockResolvedValueOnce('') - mockInput.mockResolvedValueOnce('-') - mockSelect.mockResolvedValueOnce('') - const config: unknown = {} - await setupConda(config) - expect(config.stdout).toBe(true) - expect(config.outfile).toBeUndefined() - }) - - it('cancels when verbose prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce(true) - mockInput.mockResolvedValueOnce('env.yml') - mockSelect.mockResolvedValueOnce('yes') - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupConda({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - }) - - describe('setupGradle', () => { - it('cancels when bin prompt is aborted', async () => { - mockInput.mockResolvedValueOnce(undefined) - const result = await setupGradle({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when gradle-opts prompt is aborted', async () => { - mockInput - .mockResolvedValueOnce('./gradlew') - .mockResolvedValueOnce(undefined) - const result = await setupGradle({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when verbose prompt is aborted', async () => { - mockInput - .mockResolvedValueOnce('./gradlew') - .mockResolvedValueOnce('--info') - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupGradle({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('saves bin/gradleOpts/verbose when user provides them', async () => { - mockInput - .mockResolvedValueOnce('/usr/bin/gradle') - .mockResolvedValueOnce('--debug --info') - mockSelect.mockResolvedValueOnce('yes') - const config: unknown = {} - const result = await setupGradle(config) - expect(result.ok).toBe(true) - expect(config.bin).toBe('/usr/bin/gradle') - expect(config.gradleOpts).toBe('--debug --info') - expect(config.verbose).toBe(true) - }) - - it('clears bin/gradleOpts when user empties them', async () => { - mockInput.mockResolvedValueOnce('').mockResolvedValueOnce('') - mockSelect.mockResolvedValueOnce('') - const config: unknown = { bin: 'old', gradleOpts: 'old', verbose: true } - const result = await setupGradle(config) - expect(result.ok).toBe(true) - expect(config.bin).toBeUndefined() - expect(config.gradleOpts).toBeUndefined() - expect(config.verbose).toBeUndefined() - }) - - it('sets verbose=false when user picks "no"', async () => { - mockInput.mockResolvedValueOnce('./gradlew').mockResolvedValueOnce('') - mockSelect.mockResolvedValueOnce('no') - const config: unknown = {} - const result = await setupGradle(config) - expect(result.ok).toBe(true) - expect(config.verbose).toBe(false) - }) - }) - - describe('setupSbt', () => { - it('cancels when bin prompt is aborted', async () => { - mockInput.mockResolvedValueOnce(undefined) - const result = await setupSbt({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when sbt-opts prompt is aborted', async () => { - mockInput.mockResolvedValueOnce('sbt').mockResolvedValueOnce(undefined) - const result = await setupSbt({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when stdout prompt is aborted', async () => { - mockInput.mockResolvedValueOnce('sbt').mockResolvedValueOnce('') - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupSbt({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when output prompt is aborted', async () => { - mockInput.mockResolvedValueOnce('sbt').mockResolvedValueOnce('') - mockSelect.mockResolvedValueOnce('') // stdout default - mockInput.mockResolvedValueOnce(undefined) // outfile - const result = await setupSbt({}) - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('saves all values when user provides them', async () => { - mockInput - .mockResolvedValueOnce('/usr/bin/sbt') - .mockResolvedValueOnce('-Dsbt.opts=foo') - mockSelect - .mockResolvedValueOnce('yes') // stdout - .mockResolvedValueOnce('yes') // verbose - const config: unknown = {} - const result = await setupSbt(config) - expect(result.ok).toBe(true) - expect(config.bin).toBe('/usr/bin/sbt') - expect(config.sbtOpts).toBe('-Dsbt.opts=foo') - expect(config.stdout).toBe(true) - expect(config.verbose).toBe(true) - }) - - it('clears bin and sbtOpts when user empties them', async () => { - mockInput.mockResolvedValueOnce('').mockResolvedValueOnce('') - mockSelect - .mockResolvedValueOnce('yes') // stdout - .mockResolvedValueOnce('') // verbose default - const config: unknown = { bin: 'old', sbtOpts: 'old', verbose: true } - const result = await setupSbt(config) - expect(result.ok).toBe(true) - expect(config.bin).toBeUndefined() - expect(config.sbtOpts).toBeUndefined() - expect(config.verbose).toBeUndefined() - }) - }) - - describe('setupManifestConfig', () => { - it('cancels when target ecosystem selector returns empty (exit)', async () => { - mockSelect.mockResolvedValueOnce('') - const result = await setupManifestConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('returns sockJson read error when readSocketJsonSync fails', async () => { - mockReadSocketJsonSync.mockReturnValueOnce({ - ok: false, - message: 'read err', - }) - mockSelect.mockResolvedValueOnce('conda') - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(false) - }) - - it('logs about found socket.json when it exists', async () => { - mockExistsSync.mockReturnValue(true) - mockSelect.mockResolvedValueOnce('') - await setupManifestConfig('/cwd') - const infoMsg = mockLogger.info.mock.calls.flat().join(' ') - expect(infoMsg).toContain('Found') - }) - - it('logs "No" socket.json when it does not exist', async () => { - mockExistsSync.mockReturnValue(false) - mockSelect.mockResolvedValueOnce('') - await setupManifestConfig('/cwd') - const infoMsg = mockLogger.info.mock.calls.flat().join(' ') - expect(infoMsg).toContain('No') - }) - - it('completes flow for conda + write yes (lines 136-140)', async () => { - // setupConda prompts: askForEnabled, askForInputFile, askForStdout, - // askForOutputFile (when stdout != yes), askForVerboseFlag. - mockSelect - .mockResolvedValueOnce('conda') // target ecosystem - .mockResolvedValueOnce('') // askForEnabled (default) - .mockResolvedValueOnce('') // askForStdout (default) - .mockResolvedValueOnce('') // askForVerboseFlag (default) - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('environment.yml') // infile - .mockResolvedValueOnce('requirements.txt') // outfile - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - expect(mockWriteSocketJson).toHaveBeenCalled() - }) - - it('completes flow for gradle + write yes (lines 143-147)', async () => { - // setupGradle prompts: askForBin (input), gradle-opts (input), - // askForVerboseFlag (select). Then outer write-yes prompt. - mockSelect - .mockResolvedValueOnce('gradle') // target ecosystem - .mockResolvedValueOnce('') // askForVerboseFlag (default) - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('./gradlew') // bin - .mockResolvedValueOnce('') // gradle-opts - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - expect(mockWriteSocketJson).toHaveBeenCalled() - }) - - it('sbt: stdout=yes skips outfile prompt (line 341-342)', async () => { - mockSelect - .mockResolvedValueOnce('sbt') // ecosystem - .mockResolvedValueOnce('yes') // stdout = yes - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('sbt') // bin - .mockResolvedValueOnce('') // sbt-opts - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - }) - - it('sbt: stdout=no leads to outfile prompt (line 343-344)', async () => { - mockSelect - .mockResolvedValueOnce('sbt') // ecosystem - .mockResolvedValueOnce('no') // stdout = no - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('sbt') // bin - .mockResolvedValueOnce('') // sbt-opts - .mockResolvedValueOnce('out.xml') // outfile - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - }) - - it('sbt: outfile="-" promotes stdout to true (line 354-355)', async () => { - mockSelect - .mockResolvedValueOnce('sbt') // ecosystem - .mockResolvedValueOnce('') // stdout default - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('sbt') // bin - .mockResolvedValueOnce('') // sbt-opts - .mockResolvedValueOnce('-') // outfile = '-' → stdout=true - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - }) - - it('sbt: outfile empty deletes outfile config (line 360-361)', async () => { - mockSelect - .mockResolvedValueOnce('sbt') // ecosystem - .mockResolvedValueOnce('') // stdout default - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('sbt') // bin - .mockResolvedValueOnce('') // sbt-opts - .mockResolvedValueOnce('') // outfile empty → delete - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - }) - - it('completes flow for sbt + write yes', async () => { - mockSelect - .mockResolvedValueOnce('sbt') // target ecosystem - .mockResolvedValueOnce('') // stdout default - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('sbt') // bin - .mockResolvedValueOnce('') // sbt-opts - .mockResolvedValueOnce('out.xml') // outfile - const result = await setupManifestConfig('/cwd') - expect(result.ok).toBe(true) - expect(mockWriteSocketJson).toHaveBeenCalled() - }) - - it('cancels when user picks "no" at write-config prompt', async () => { - mockSelect - .mockResolvedValueOnce('sbt') - .mockResolvedValueOnce('') // stdout default - .mockResolvedValueOnce('') // verbose default - .mockResolvedValueOnce(false) // do not write - mockInput - .mockResolvedValueOnce('sbt') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('out.xml') - const result = await setupManifestConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('appends [detected] to choices when ecosystem is auto-detected (line 87)', async () => { - // Make detectManifestActions report all known ecosystems as present; - // then the choices.forEach loop appends ' [detected]' to each name - // (line 87) and the sort comparator returns -1 / 1 (lines 97 / 103). - mockDetectManifestActions.mockResolvedValueOnce({ - conda: true, - gradle: true, - sbt: true, - }) - // Cancel immediately after seeing the detected list. - mockSelect.mockResolvedValueOnce('') - const result = await setupManifestConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - // The select prompt was called with choices containing '[detected]'. - const calledWith = mockSelect.mock.calls[0]?.[0] - expect(JSON.stringify(calledWith)).toContain('[detected]') - }) - - it('sort comparator orders detected before undetected (lines 97-103)', async () => { - // Only conda is detected → conda choices sort before sbt choices. - mockDetectManifestActions.mockResolvedValueOnce({ - conda: true, - }) - mockSelect.mockResolvedValueOnce('') - await setupManifestConfig('/cwd') - const calledWith: unknown = mockSelect.mock.calls[0]?.[0] - const choices: unknown[] = calledWith?.choices ?? [] - const detectedIdx = choices.findIndex((c: unknown) => - c.name.includes('Conda'), - ) - const undetectedIdx = choices.findIndex((c: unknown) => - c.name.includes('sbt'), - ) - expect(detectedIdx).toBeGreaterThanOrEqual(0) - expect(undetectedIdx).toBeGreaterThanOrEqual(0) - expect(detectedIdx).toBeLessThan(undetectedIdx) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/cmd-mcp.test.mts b/packages/cli/test/unit/commands/mcp/cmd-mcp.test.mts deleted file mode 100644 index d87004925..000000000 --- a/packages/cli/test/unit/commands/mcp/cmd-mcp.test.mts +++ /dev/null @@ -1,416 +0,0 @@ -/** - * Unit tests for the `socket mcp` CLI entry point. - * - * Tests cmdMcp.run(argv, importMeta, ctx) — argv parsing, env-var fallbacks, - * flag → handleMcp option translation. Mocks meowOrExit to bypass the real CLI - * parser and feed pre-shaped flag values. - * - * Test Coverage: - * - * - Command metadata (description, hidden) - * - Default mode is stdio - * - --http flag flips to HTTP mode - * - MCP_HTTP_MODE=true env var also flips to HTTP mode - * - --port flag forwarded; defaults to 3000 when missing - * - MCP_PORT env var fallback when --port not set - * - --trust-proxy flag + TRUST_PROXY=true env fallback - * - --oauth-issuer / --oauth-client-id / --oauth-client-secret flags + matching - * SOCKET_OAUTH_* env fallbacks - * - --oauth-required-scopes parses whitespace-separated string into array; empty - * string yields undefined (handler picks default) - * - All flags forwarded to handleMcp with the right shape - * - * Related Files: - * - * - Src/commands/mcp/cmd-mcp.mts - Implementation - * - Src/commands/mcp/handle-mcp.mts - Dispatcher (mocked) - * - Src/util/cli/with-subcommands.mts - meowOrExit (mocked) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - log: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { mockHandleMcp, mockMeowOrExit } = vi.hoisted(() => ({ - mockHandleMcp: vi.fn().mockResolvedValue(undefined), - // The cmd-mcp.mts run() reads cli.flags.<x>. Build a flags object - // from the argv each test passes in, mimicking what meowOrExit - // would produce. We parse a tiny subset of argv to keep the test - // realistic (the real parsing is exercised in meow.test.mts). - mockMeowOrExit: vi.fn((input: { argv: string[] | readonly string[] }) => { - const argv = [...input.argv] - const flags: Record<string, unknown> = { - http: false, - 'oauth-client-id': '', - 'oauth-client-secret': '', - 'oauth-issuer': '', - 'oauth-required-scopes': '', - port: 3000, - 'trust-proxy': false, - } - for (let i = 0; i < argv.length; i++) { - const a = argv[i]! - if (a === '--http') { - flags['http'] = true - } else if (a === '--trust-proxy') { - flags['trust-proxy'] = true - } else if (a === '--port') { - flags['port'] = Number(argv[++i]) - } else if (a.startsWith('--port=')) { - flags['port'] = Number(a.slice('--port='.length)) - } else if (a === '--oauth-issuer') { - flags['oauth-issuer'] = argv[++i] - } else if (a === '--oauth-client-id') { - flags['oauth-client-id'] = argv[++i] - } else if (a === '--oauth-client-secret') { - flags['oauth-client-secret'] = argv[++i] - } else if (a === '--oauth-required-scopes') { - flags['oauth-required-scopes'] = argv[++i] - } - } - return { - flags, - help: '', - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }), -})) - -vi.mock('../../../../src/commands/mcp/handle-mcp.mts', () => ({ - handleMcp: mockHandleMcp, -})) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -const { cmdMcp } = await import('../../../../src/commands/mcp/cmd-mcp.mts') - -const importMeta = { url: 'file:///test/cmd-mcp.mts' } -const context = { parentName: 'socket' } - -const ENV_KEYS = [ - 'MCP_HTTP_MODE', - 'MCP_PORT', - 'SOCKET_OAUTH_INTROSPECTION_CLIENT_ID', - 'SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET', - 'SOCKET_OAUTH_ISSUER', - 'SOCKET_OAUTH_REQUIRED_SCOPES', - 'TRUST_PROXY', -] as const - -const savedEnv: Record<string, string | undefined> = {} - -beforeEach(() => { - vi.clearAllMocks() - for (let i = 0, { length } = ENV_KEYS; i < length; i += 1) { - const k = ENV_KEYS[i] - savedEnv[k] = process.env[k] - delete process.env[k] - } -}) - -afterEach(() => { - for (let i = 0, { length } = ENV_KEYS; i < length; i += 1) { - const k = ENV_KEYS[i] - if (savedEnv[k] === undefined) { - delete process.env[k] - } else { - process.env[k] = savedEnv[k] - } - } -}) - -describe('cmdMcp — metadata', () => { - it('has the right description', () => { - expect(cmdMcp.description).toContain('MCP') - }) - - it('is not hidden from help', () => { - expect(cmdMcp.hidden).toBe(false) - }) -}) - -describe('cmdMcp — mode selection', () => { - it('defaults to stdio mode', async () => { - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ http: false }), - ) - }) - - it('switches to HTTP mode with --http', async () => { - await cmdMcp.run(['--http'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ http: true }), - ) - }) - - it('switches to HTTP mode when MCP_HTTP_MODE=true', async () => { - process.env['MCP_HTTP_MODE'] = 'true' - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ http: true }), - ) - }) - - it('does not switch on MCP_HTTP_MODE values other than literal "true"', async () => { - process.env['MCP_HTTP_MODE'] = '1' - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ http: false }), - ) - }) -}) - -describe('cmdMcp — port flag', () => { - it('defaults port to 3000 when no flag or env is set', async () => { - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ port: 3000 }), - ) - }) - - it('honors --port', async () => { - await cmdMcp.run(['--port', '5151'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ port: 5151 }), - ) - }) - - it('falls back to MCP_PORT env when no --port flag is given', async () => { - process.env['MCP_PORT'] = '8081' - // Clear the meow mock's default of 3000 so we can verify the env fallback. - mockMeowOrExit.mockImplementationOnce(() => ({ - flags: { - http: false, - 'oauth-client-id': '', - 'oauth-client-secret': '', - 'oauth-issuer': '', - 'oauth-required-scopes': '', - port: 0, // sentinel: caller didn't supply - 'trust-proxy': false, - }, - help: '', - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - })) - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ port: 8081 }), - ) - }) - - it('falls back to default 3000 when MCP_PORT is unset and flag is 0', async () => { - // Clear MCP_PORT explicitly so the `process.env['MCP_PORT'] || \`${DEFAULT_PORT}\`` - // fallback chain hits the right-arm. - delete process.env['MCP_PORT'] - mockMeowOrExit.mockImplementationOnce(() => ({ - flags: { - http: false, - 'oauth-client-id': '', - 'oauth-client-secret': '', - 'oauth-issuer': '', - 'oauth-required-scopes': '', - port: 0, - 'trust-proxy': false, - }, - help: '', - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - })) - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ port: 3000 }), - ) - }) - - it('falls back to default 3000 when MCP_PORT is unparseable', async () => { - process.env['MCP_PORT'] = 'notanumber' - mockMeowOrExit.mockImplementationOnce(() => ({ - flags: { - http: false, - 'oauth-client-id': '', - 'oauth-client-secret': '', - 'oauth-issuer': '', - 'oauth-required-scopes': '', - port: 0, - 'trust-proxy': false, - }, - help: '', - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - })) - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ port: 3000 }), - ) - }) -}) - -describe('cmdMcp — trust-proxy', () => { - it('forwards --trust-proxy=true', async () => { - await cmdMcp.run(['--trust-proxy'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ trustProxy: true }), - ) - }) - - it('honors TRUST_PROXY=true env', async () => { - process.env['TRUST_PROXY'] = 'true' - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ trustProxy: true }), - ) - }) - - it('defaults to false when neither flag nor env are set', async () => { - await cmdMcp.run([], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ trustProxy: false }), - ) - }) -}) - -describe('cmdMcp — help text', () => { - it('renders the help text with the command name interpolated', async () => { - // The help: (command) => `…` callback is normally invoked by meow - // when --help is passed. Capture the config we hand to meowOrExit - // and invoke the help fn directly to exercise that branch. - let capturedConfig: - | { help?: ((cmd: string) => string) | undefined } - | undefined - mockMeowOrExit.mockImplementationOnce(input => { - capturedConfig = (input as { config: typeof capturedConfig }).config - return { - flags: { - http: false, - 'oauth-client-id': '', - 'oauth-client-secret': '', - 'oauth-issuer': '', - 'oauth-required-scopes': '', - port: 3000, - 'trust-proxy': false, - }, - help: '', - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - await cmdMcp.run([], importMeta, context) - const helpText = capturedConfig?.help?.('socket mcp') ?? '' - expect(helpText).toContain('socket mcp [options]') - expect(helpText).toContain('Modes') - expect(helpText).toContain('Environment variables') - expect(helpText).toContain('Examples') - expect(helpText).toContain('SOCKET_API_TOKEN') - }) -}) - -describe('cmdMcp — OAuth flags', () => { - it('forwards --oauth-issuer, --oauth-client-id, --oauth-client-secret', async () => { - await cmdMcp.run( - [ - '--http', - '--oauth-issuer', - 'https://issuer.example', - '--oauth-client-id', - 'cid', - '--oauth-client-secret', - 'csec', - ], - importMeta, - context, - ) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ - http: true, - oauthIssuer: 'https://issuer.example', - oauthClientId: 'cid', - oauthClientSecret: 'csec', - }), - ) - }) - - it('falls back to SOCKET_OAUTH_* env vars when flags are empty', async () => { - process.env['SOCKET_OAUTH_ISSUER'] = 'https://env-issuer' - process.env['SOCKET_OAUTH_INTROSPECTION_CLIENT_ID'] = 'env-cid' - process.env['SOCKET_OAUTH_INTROSPECTION_CLIENT_SECRET'] = 'env-csec' - await cmdMcp.run(['--http'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ - oauthIssuer: 'https://env-issuer', - oauthClientId: 'env-cid', - oauthClientSecret: 'env-csec', - }), - ) - }) - - it('parses --oauth-required-scopes as whitespace-separated', async () => { - await cmdMcp.run( - ['--http', '--oauth-required-scopes', 'a:read b:write'], - importMeta, - context, - ) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ - oauthRequiredScopes: ['a:read', 'b:write'], - }), - ) - }) - - it('passes oauthRequiredScopes=undefined when neither flag nor env are set', async () => { - await cmdMcp.run(['--http'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ oauthRequiredScopes: undefined }), - ) - }) - - it('parses SOCKET_OAUTH_REQUIRED_SCOPES env var', async () => { - process.env['SOCKET_OAUTH_REQUIRED_SCOPES'] = 'scope:one scope:two' - await cmdMcp.run(['--http'], importMeta, context) - expect(mockHandleMcp).toHaveBeenCalledWith( - expect.objectContaining({ - oauthRequiredScopes: ['scope:one', 'scope:two'], - }), - ) - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/depscore.test.mts b/packages/cli/test/unit/commands/mcp/depscore.test.mts deleted file mode 100644 index 2bf88fd82..000000000 --- a/packages/cli/test/unit/commands/mcp/depscore.test.mts +++ /dev/null @@ -1,638 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for the MCP `depscore` tool implementation. - * - * Tests runDepscore(input, opts) — the worker behind the only MCP tool `socket - * mcp` exposes. Covers SDK setup caching, payload shaping (PURL conversion, - * version cleanup), response parsing, formatting, and the error paths Socket - * API returns (401/403/non-200, empty, malformed). - * - * Test Coverage: - * - * - Input schema (TypeBox shape) is exported and validates correctly - * - Tool name + description constants exposed for the MCP wire - * - SDK is constructed once per token and reused - * - SDK setup failure surfaces as `isError: true` - * - PURL builder is invoked per-package (npm/pypi/golang/maven shapes) - * - Caret/tilde stripped from version (^1.2.3 → 1.2.3) - * - Default ecosystem is npm when caller omits - * - Default version is 'unknown' when caller omits - * - 200 OK with NDJSON-shaped response → formatted "pkg: dim:N, …" - * - 200 OK with empty data → "No packages were found." error - * - 200 OK with only `_type` rows → "No valid artifact records" error - * - 401 → auth-failed message - * - 403 → access-denied message - * - Other non-2xx → generic error - * - Network exception → "Error connecting to Socket API" - * - Score formatting: numeric values ≤ 1 multiplied by 100 and rounded - * - Score formatting: numeric values > 1 passed through - * - Score formatting: `overall` and `uuid` keys filtered out - * - Artifacts without score field → "No score found" - * - Platform hint forwarded to deduplicateArtifacts - * - * Related Files: - * - * - Src/commands/mcp/depscore.mts - Implementation - * - Src/commands/mcp/lib/purl.mts - PURL helper - * - Src/commands/mcp/lib/artifacts.mts - Dedup helper - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { mockBatchPackageFetch, mockSetupSdk } = vi.hoisted(() => ({ - mockBatchPackageFetch: vi.fn(), - mockSetupSdk: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, - getDefaultApiToken: vi.fn(() => 'test_fake_token'), -})) - -const { - DEPSCORE_TOOL_DESCRIPTION, - DEPSCORE_TOOL_NAME, - DepscoreInputSchema, - runDepscore, -} = await import('../../../../src/commands/mcp/depscore.mts') - -function makeErr(status: number, message: string, cause?: string) { - return { - success: false as const, - status, - error: message, - ...(cause ? { cause } : {}), - } -} - -function makeOk<T>(data: T) { - return { success: true as const, status: 200, data } -} - -beforeEach(() => { - vi.clearAllMocks() - // Default: SDK setup succeeds with a fake client whose - // batchPackageFetch returns the per-test fixture. - mockSetupSdk.mockResolvedValue({ - ok: true, - data: { batchPackageFetch: mockBatchPackageFetch }, - }) -}) - -describe('depscore tool constants', () => { - it('exposes the canonical tool name', () => { - expect(DEPSCORE_TOOL_NAME).toBe('depscore') - }) - - it('exposes a non-empty tool description', () => { - expect(DEPSCORE_TOOL_DESCRIPTION).toContain('depscore') - expect(DEPSCORE_TOOL_DESCRIPTION.length).toBeGreaterThan(50) - }) -}) - -describe('DepscoreInputSchema (TypeBox)', () => { - it('is a JSON-Schema-shaped object', () => { - const schema = DepscoreInputSchema as unknown as Record<string, unknown> - expect(schema['type']).toBe('object') - expect(schema['properties']).toBeDefined() - const props = schema['properties'] as Record<string, unknown> - expect(props['packages']).toBeDefined() - expect(props['platform']).toBeDefined() - }) - - it('declares packages as required (no Optional wrapper)', () => { - const schema = DepscoreInputSchema as unknown as Record<string, unknown> - const required = schema['required'] as string[] | undefined - expect(required).toContain('packages') - expect(required).not.toContain('platform') - }) -}) - -describe('runDepscore — SDK setup', () => { - it('returns isError when SDK setup fails', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - cause: 'no token', - message: 'Auth Error', - }) - const result = await runDepscore( - { packages: [{ depname: 'lodash' }] }, - { apiToken: 'test_x' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('SDK setup failed: no token') - expect(mockBatchPackageFetch).not.toHaveBeenCalled() - }) - - it('caches SDK clients per token (one setupSdk per distinct token)', async () => { - mockBatchPackageFetch.mockResolvedValue(makeOk([])) - await runDepscore( - { packages: [{ depname: 'a' }] }, - { apiToken: 'test_token_1' }, - ) - await runDepscore( - { packages: [{ depname: 'b' }] }, - { apiToken: 'test_token_1' }, - ) - expect(mockSetupSdk).toHaveBeenCalledTimes(1) - await runDepscore( - { packages: [{ depname: 'c' }] }, - { apiToken: 'test_token_2' }, - ) - expect(mockSetupSdk).toHaveBeenCalledTimes(2) - }) -}) - -describe('runDepscore — payload shaping', () => { - beforeEach(() => { - mockBatchPackageFetch.mockResolvedValue(makeOk([])) - }) - - it('builds a PURL per package and calls batchPackageFetch with the components shape', async () => { - await runDepscore( - { - packages: [ - { depname: 'lodash', ecosystem: 'npm', version: '4.17.21' }, - { depname: 'requests', ecosystem: 'pypi', version: '2.31.0' }, - ], - }, - { apiToken: 'test_a' }, - ) - expect(mockBatchPackageFetch).toHaveBeenCalledTimes(1) - const [arg, query] = mockBatchPackageFetch.mock.calls[0]! - expect(arg.components).toHaveLength(2) - expect(arg.components[0].purl).toBe('pkg:npm/lodash@4.17.21') - expect(arg.components[1].purl).toBe('pkg:pypi/requests@2.31.0') - expect(query).toMatchObject({ - alerts: false, - compact: false, - fixable: false, - licenseattrib: false, - licensedetails: false, - }) - }) - - it('strips ^ and ~ from versions before building the PURL', async () => { - await runDepscore( - { packages: [{ depname: 'foo', ecosystem: 'npm', version: '^1.2.3' }] }, - { apiToken: 'test_a' }, - ) - const purl = mockBatchPackageFetch.mock.calls[0]![0].components[0].purl - expect(purl).toBe('pkg:npm/foo@1.2.3') - }) - - it('strips tilde ranges too', async () => { - await runDepscore( - { packages: [{ depname: 'foo', ecosystem: 'npm', version: '~1.2.3' }] }, - { apiToken: 'test_a' }, - ) - const purl = mockBatchPackageFetch.mock.calls[0]![0].components[0].purl - expect(purl).toBe('pkg:npm/foo@1.2.3') - }) - - it('defaults ecosystem to npm when caller omits it', async () => { - await runDepscore( - { packages: [{ depname: 'noeco' }] }, - { apiToken: 'test_a' }, - ) - const purl = mockBatchPackageFetch.mock.calls[0]![0].components[0].purl - expect(purl).toContain('pkg:npm/noeco') - }) - - it('defaults version to unknown when caller omits it', async () => { - await runDepscore( - { packages: [{ depname: 'unknownversion' }] }, - { apiToken: 'test_a' }, - ) - const purl = mockBatchPackageFetch.mock.calls[0]![0].components[0].purl - // 'unknown' sentinel → no @<version> in the PURL. - expect(purl).toBe('pkg:npm/unknownversion') - }) -}) - -describe('runDepscore — response handling', () => { - it('formats a single artifact with score breakdown', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - type: 'npm', - name: 'lodash', - version: '4.17.21', - score: { - overall: 0.9, - quality: 0.85, - maintenance: 0.95, - }, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: 'lodash' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBeUndefined() - expect(result.content[0]!.text).toContain('Dependency scores:') - expect(result.content[0]!.text).toContain('pkg:npm/lodash@4.17.21') - expect(result.content[0]!.text).toContain('quality: 85') - expect(result.content[0]!.text).toContain('maintenance: 95') - }) - - it('omits overall and uuid from the score breakdown', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - type: 'npm', - name: 'foo', - version: '1.0.0', - score: { overall: 0.7, uuid: 'abc-def', supplyChain: 0.6 }, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.content[0]!.text).toContain('supplyChain: 60') - expect(result.content[0]!.text).not.toContain('overall:') - expect(result.content[0]!.text).not.toContain('uuid:') - }) - - it('passes numeric values >1 through as-is (no *100 scaling)', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - type: 'npm', - name: 'foo', - version: '1.0.0', - score: { overall: 95, quality: 87 }, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - // 87 already on 0–100 scale → not re-scaled. - expect(result.content[0]!.text).toContain('quality: 87') - }) - - it('formats namespace into the PURL when present', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - type: 'npm', - namespace: '@scope', - name: 'pkg', - version: '1.0.0', - score: { overall: 0.5, quality: 0.5 }, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: '@scope/pkg' }] }, - { apiToken: 'test_a' }, - ) - expect(result.content[0]!.text).toContain('pkg:npm/@scope/pkg@1.0.0') - }) - - it('returns "No score found" when artifact has no score field', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([{ type: 'npm', name: 'foo', version: '1.0.0' }]), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.content[0]!.text).toContain('No score found') - }) - - it('drops `_type` envelope rows from the response', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { _type: 'summary', count: 1 }, - { - type: 'npm', - name: 'foo', - version: '1.0.0', - score: { overall: 0.8, quality: 0.8 }, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBeUndefined() - expect(result.content[0]!.text).toContain('quality: 80') - }) - - it('returns an error when the response only has _type rows', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([{ _type: 'summary', count: 0 }]), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('No valid artifact records') - }) - - it('returns "No packages were found." when data is empty', async () => { - mockBatchPackageFetch.mockResolvedValue(makeOk([])) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toBe('No packages were found.') - }) -}) - -describe('runDepscore — error paths', () => { - it('surfaces a 401 with a re-authenticate message', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeErr(401, 'Unauthorized', 'invalid token'), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_bad' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain( - 'Socket authentication failed [401]', - ) - }) - - it('surfaces a 403 with a permissions message', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeErr(403, 'Forbidden', 'missing scope'), - ) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_locked' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Socket denied access [403]') - }) - - it('surfaces a generic non-2xx with the status code', async () => { - mockBatchPackageFetch.mockResolvedValue(makeErr(503, 'Service Unavailable')) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('[503]') - }) - - it('catches network exceptions from the SDK call', async () => { - mockBatchPackageFetch.mockRejectedValue(new Error('ECONNREFUSED')) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toBe('Error connecting to Socket API') - }) -}) - -describe('runDepscore — formatScore fallbacks', () => { - it('uses "unknown" placeholder for missing type / name / version', async () => { - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - // No type, no name, no version. score is empty so we land in - // the "No score found" branch but still exercise the - // type||'unknown', name||'unknown', version||'unknown' arms. - score: undefined, - }, - ]), - ) - const result = await runDepscore( - { packages: [{ depname: 'whatever' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBeUndefined() - expect(result.content[0]!.text).toContain('pkg:unknown/unknown@unknown') - expect(result.content[0]!.text).toContain('No score found') - }) -}) - -describe('runDepscore — SDK setup error fallback chain', () => { - it('uses result.message when result.cause is empty', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - cause: '', - message: 'Auth Error', - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - // Distinct token per test so the module-scoped sdkCache doesn't - // hit a previously-set fixture and skip the setup branch. - { apiToken: 'test_setup_message_only' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Auth Error') - }) - - it('uses the hard-coded fallback string when both cause and message are empty', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - cause: '', - message: '', - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_setup_full_fallback' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Failed to set up Socket SDK') - }) - - it('uses String(e) when SDK setup throws a non-Error value', async () => { - mockSetupSdk.mockRejectedValueOnce('plain string') - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_setup_string_throw' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('SDK setup failed: plain string') - }) -}) - -describe('runDepscore — batchPackageFetch non-Error throw', () => { - it('coerces non-Error rejections via String() in the network catch', async () => { - mockBatchPackageFetch.mockRejectedValueOnce({ weird: 'object' }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toBe('Error connecting to Socket API') - }) -}) - -describe('runDepscore — non-success without cause/error fields', () => { - it('uses empty string when 401 response has no cause and no error', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 401, - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - // The trailing `${cause ?? ''}` becomes empty; assert the message - // shape. - expect(result.content[0]!.text).toMatch( - /Socket authentication failed \[401\]\. Re-authenticate and retry\.\s*$/, - ) - }) - - it('uses empty string when 403 response has no cause and no error', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 403, - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toMatch(/Re-authenticate.*retry\.\s*$/) - }) - - it('handles non-2xx response with no cause/error gracefully', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 502, - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('[502]') - }) -}) - -describe('runDepscore — empty data field on success', () => { - it('treats response.data === undefined as no packages', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: true, - status: 200, - data: undefined, - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toBe('No packages were found.') - }) -}) - -describe('runDepscore — error fallbacks (cause vs error field)', () => { - it('uses response.error when response.cause is absent on 401', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 401, - error: 'Bad token', - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain( - 'Socket authentication failed [401]', - ) - expect(result.content[0]!.text).toContain('Bad token') - }) - - it('uses response.error when response.cause is absent on 403', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 403, - error: 'No scope', - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Socket denied access [403]') - expect(result.content[0]!.text).toContain('No scope') - }) - - it('uses response.error when response.cause is absent on a generic non-2xx', async () => { - mockBatchPackageFetch.mockResolvedValue({ - success: false, - status: 500, - error: 'Internal Server Error', - }) - const result = await runDepscore( - { packages: [{ depname: 'foo' }] }, - { apiToken: 'test_a' }, - ) - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('[500]') - expect(result.content[0]!.text).toContain('Internal Server Error') - }) -}) - -describe('runDepscore — platform hint forwarding', () => { - it('forwards the platform hint to artifact dedup', async () => { - // Two artifacts of the same package with different platforms. - mockBatchPackageFetch.mockResolvedValue( - makeOk([ - { - type: 'pypi', - name: 'numpy', - version: '1.26.0', - release: 'numpy-1.26.0-cp310-manylinux_x86_64.whl', - score: { overall: 0.9, quality: 0.9 }, - }, - { - type: 'pypi', - name: 'numpy', - version: '1.26.0', - release: 'numpy-1.26.0-cp310-macosx_arm64.whl', - score: { overall: 0.95, quality: 0.95 }, - }, - ]), - ) - const result = await runDepscore( - { - packages: [{ depname: 'numpy', ecosystem: 'pypi', version: '1.26.0' }], - platform: 'darwin-arm64', - }, - { apiToken: 'test_a' }, - ) - // After dedup with darwin-arm64 hint, only the macosx wheel survives, - // so the score line uses the higher number from that artifact. - expect(result.content[0]!.text).toContain('quality: 95') - expect(result.content[0]!.text).not.toContain('quality: 90') - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/handle-mcp.test.mts b/packages/cli/test/unit/commands/mcp/handle-mcp.test.mts deleted file mode 100644 index 1bb94aa34..000000000 --- a/packages/cli/test/unit/commands/mcp/handle-mcp.test.mts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for the MCP command handler. - * - * Tests handleMcp(opts) — the dispatcher that picks transport (stdio vs HTTP), - * validates the OAuth + token combination, and forwards to runStdioTransport / - * runHttpTransport. - * - * Test Coverage: - * - * - Stdio path with token configured → calls runStdioTransport with the right - * ServerConfig - * - Stdio path without token → logs error, exits with code 1 - * - HTTP path with all three OAuth fields → calls runHttpTransport with the OAuth - * config - * - HTTP path with token (no OAuth) → calls runHttpTransport without throwing - * - HTTP path with partial OAuth (issuer + clientId, no secret) → logs error, - * exits with code 1 - * - HTTP path without OAuth and without token → logs error, exits with code 1 - * - Custom oauthRequiredScopes forwarded - * - Default scopes (`packages:list`) used when caller doesn't supply - * - Version string read from constants ENV.INLINED_VERSION (with '0.0.0' - * fallback) - * - * Related Files: - * - * - Src/commands/mcp/handle-mcp.mts - Implementation - * - Src/commands/mcp/transport-stdio.mts - Stdio runner (mocked) - * - Src/commands/mcp/transport-http.mts - HTTP runner (mocked) - * - Src/util/socket/sdk.mts - getDefaultApiToken (mocked) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - log: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { mockGetDefaultApiToken } = vi.hoisted(() => ({ - mockGetDefaultApiToken: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, -})) - -const { mockRunHttpTransport, mockRunStdioTransport } = vi.hoisted(() => ({ - mockRunHttpTransport: vi.fn().mockResolvedValue(undefined), - mockRunStdioTransport: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock('../../../../src/commands/mcp/transport-stdio.mts', () => ({ - runStdioTransport: mockRunStdioTransport, -})) - -vi.mock('../../../../src/commands/mcp/transport-http.mts', () => ({ - runHttpTransport: mockRunHttpTransport, -})) - -// Use a getter so individual tests can flip the inlined version to -// undefined and exercise the `|| '0.0.0'` fallback branch. -const versionRef = { current: '7.7.7' as string | undefined } -vi.mock('../../../../src/constants.mts', () => ({ - constants: { - ENV: { - get INLINED_VERSION() { - return versionRef.current - }, - }, - }, -})) - -const { handleMcp } = - await import('../../../../src/commands/mcp/handle-mcp.mts') - -const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((_code?: string | number | null | undefined) => { - // Throw so the test can assert "exit was called" without actually - // killing the worker. - throw new Error('process.exit called') - }) - -beforeEach(() => { - vi.clearAllMocks() - versionRef.current = '7.7.7' - mockGetDefaultApiToken.mockReturnValue('test_default_token') -}) - -describe('handleMcp — version fallback', () => { - it('uses "0.0.0" as version when INLINED_VERSION is undefined', async () => { - versionRef.current = undefined - await handleMcp({ http: false, port: 3000, trustProxy: false }) - expect(mockRunStdioTransport).toHaveBeenCalledWith( - expect.objectContaining({ version: '0.0.0' }), - ) - }) - - it('uses the inlined version when present', async () => { - versionRef.current = '12.34.56' - await handleMcp({ http: false, port: 3000, trustProxy: false }) - expect(mockRunStdioTransport).toHaveBeenCalledWith( - expect.objectContaining({ version: '12.34.56' }), - ) - }) -}) - -describe('handleMcp — stdio path', () => { - it('forwards to runStdioTransport with the resolved version + getApiToken', async () => { - await handleMcp({ - http: false, - port: 3000, - trustProxy: false, - }) - expect(mockRunStdioTransport).toHaveBeenCalledTimes(1) - expect(mockRunHttpTransport).not.toHaveBeenCalled() - const config = mockRunStdioTransport.mock.calls[0]![0] - expect(config.serverName).toBe('socket') - expect(config.version).toBe('7.7.7') - expect(config.getApiToken()).toBe('test_default_token') - }) - - it('exits with code 1 when no token is configured', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - await expect( - handleMcp({ http: false, port: 3000, trustProxy: false }), - ).rejects.toThrow('process.exit called') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('No SOCKET_API_TOKEN configured'), - ) - expect(exitSpy).toHaveBeenCalledWith(1) - expect(mockRunStdioTransport).not.toHaveBeenCalled() - }) -}) - -describe('handleMcp — HTTP path', () => { - it('forwards to runHttpTransport with all options when OAuth is fully configured', async () => { - await handleMcp({ - http: true, - oauthClientId: 'client-id', - oauthClientSecret: 'client-secret', - oauthIssuer: 'https://auth.example.com', - port: 4000, - trustProxy: true, - }) - expect(mockRunHttpTransport).toHaveBeenCalledTimes(1) - const config = mockRunHttpTransport.mock.calls[0]![0] - expect(config.serverName).toBe('socket') - expect(config.version).toBe('7.7.7') - expect(config.oauthIssuer).toBe('https://auth.example.com') - expect(config.oauthClientId).toBe('client-id') - expect(config.oauthClientSecret).toBe('client-secret') - expect(config.port).toBe(4000) - expect(config.trustProxy).toBe(true) - }) - - it('uses the default OAuth scopes when caller omits them', async () => { - await handleMcp({ - http: true, - oauthClientId: 'a', - oauthClientSecret: 'b', - oauthIssuer: 'https://issuer', - port: 3000, - trustProxy: false, - }) - const config = mockRunHttpTransport.mock.calls[0]![0] - expect(config.oauthRequiredScopes).toEqual(['packages:list']) - }) - - it('forwards a custom oauthRequiredScopes list', async () => { - await handleMcp({ - http: true, - oauthClientId: 'a', - oauthClientSecret: 'b', - oauthIssuer: 'https://issuer', - oauthRequiredScopes: ['foo:read', 'bar:write'], - port: 3000, - trustProxy: false, - }) - const config = mockRunHttpTransport.mock.calls[0]![0] - expect(config.oauthRequiredScopes).toEqual(['foo:read', 'bar:write']) - }) - - it('runs HTTP without OAuth when only the local token is set', async () => { - await handleMcp({ http: true, port: 3000, trustProxy: false }) - expect(mockRunHttpTransport).toHaveBeenCalledTimes(1) - const config = mockRunHttpTransport.mock.calls[0]![0] - expect(config.oauthIssuer).toBe('') - expect(config.oauthClientId).toBe('') - expect(config.oauthClientSecret).toBe('') - }) - - it('exits with code 1 when OAuth is partially configured (issuer only)', async () => { - await expect( - handleMcp({ - http: true, - oauthIssuer: 'https://issuer', - port: 3000, - trustProxy: false, - }), - ).rejects.toThrow('process.exit called') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Incomplete OAuth configuration'), - ) - expect(mockRunHttpTransport).not.toHaveBeenCalled() - }) - - it('exits with code 1 when OAuth is partially configured (clientId only)', async () => { - await expect( - handleMcp({ - http: true, - oauthClientId: 'client-id', - port: 3000, - trustProxy: false, - }), - ).rejects.toThrow('process.exit called') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Incomplete OAuth configuration'), - ) - }) - - it('exits with code 1 when OAuth is partially configured (clientSecret only)', async () => { - await expect( - handleMcp({ - http: true, - oauthClientSecret: 'shh', - port: 3000, - trustProxy: false, - }), - ).rejects.toThrow('process.exit called') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Incomplete OAuth configuration'), - ) - }) - - it('exits with code 1 when neither OAuth nor a local token is set', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - await expect( - handleMcp({ http: true, port: 3000, trustProxy: false }), - ).rejects.toThrow('process.exit called') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining( - 'No SOCKET_API_TOKEN configured and OAuth is not enabled', - ), - ) - expect(mockRunHttpTransport).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/lib/artifacts.test.mts b/packages/cli/test/unit/commands/mcp/lib/artifacts.test.mts deleted file mode 100644 index 0cad21edb..000000000 --- a/packages/cli/test/unit/commands/mcp/lib/artifacts.test.mts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Unit tests for the MCP command's artifact deduplicator. - * - * Tests deduplicateArtifacts(artifacts, platform?) — collapses multiple build - * artifacts of the same package (different wheels, platform binaries) into one - * representative per (type, namespace, name, version) tuple. Uses a priority - * cascade: 1. Platform-matching artifact (when `platform` hint is given) 2. - * Source distribution 3. Universal wheel 4. First artifact in the group. - * - * Test Coverage: - Single-artifact groups pass through untouched - Multiple - * artifacts of the same package collapse to one - Different packages stay - * separate (group key includes type/ns/name/version) - Platform hint selects - * the matching artifact across all six pairs (darwin-{arm64,x64}, - * linux-{arm64,x64}, win32-{ia32,x64}) plus substring fallback for unknown - * platforms - Source distribution preferred when no platform hint - Universal - * wheel preferred when no sdist - First artifact wins as final fallback - - * Missing namespace handled (key uses empty string) - Empty input returns empty - * output. - * - * Related Files: - src/commands/mcp/lib/artifacts.mts - Implementation - - * src/commands/mcp/depscore.mts - Caller (NDJSON response dedup) - */ - -import { describe, expect, it } from 'vitest' - -import { deduplicateArtifacts } from '../../../../../src/commands/mcp/lib/artifacts.mts' - -import type { ArtifactData } from '../../../../../src/commands/mcp/lib/artifacts.mts' - -export function art(overrides: Partial<ArtifactData>): ArtifactData { - return { - name: 'foo', - type: 'pypi', - version: '1.0.0', - ...overrides, - } as ArtifactData -} - -describe('deduplicateArtifacts', () => { - describe('basic grouping', () => { - it('returns empty array for empty input', () => { - expect(deduplicateArtifacts([])).toEqual([]) - }) - - it('passes a single artifact through untouched', () => { - const a = art({ release: 'foo-1.0.0.tar.gz' }) - expect(deduplicateArtifacts([a])).toEqual([a]) - }) - - it('keeps distinct packages separate', () => { - const a = art({ name: 'foo' }) - const b = art({ name: 'bar' }) - const result = deduplicateArtifacts([a, b]) - expect(result).toHaveLength(2) - expect(result.map(r => r.name).sort()).toEqual(['bar', 'foo']) - }) - - it('groups artifacts that share (type, namespace, name, version)', () => { - const wheel1 = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const wheel2 = art({ release: 'foo-1.0.0-cp310-macosx_arm64.whl' }) - const result = deduplicateArtifacts([wheel1, wheel2]) - expect(result).toHaveLength(1) - }) - - it('treats different versions as different groups', () => { - const v1 = art({ version: '1.0.0' }) - const v2 = art({ version: '2.0.0' }) - expect(deduplicateArtifacts([v1, v2])).toHaveLength(2) - }) - - it('uses empty namespace in the group key when missing', () => { - const a = art({ namespace: undefined }) - const b = art({ namespace: undefined }) - expect(deduplicateArtifacts([a, b])).toHaveLength(1) - }) - - it('treats different namespaces as different groups', () => { - const a = art({ namespace: 'org1' }) - const b = art({ namespace: 'org2' }) - expect(deduplicateArtifacts([a, b])).toHaveLength(2) - }) - - it('uses empty-string fallbacks for missing type/name/version in the group key', () => { - // Artifacts missing type/name/version still get grouped: empty - // type + empty name + empty version + empty namespace == same key. - // Two such artifacts collapse to one. - const a = art({ - name: undefined, - type: undefined, - version: undefined, - }) - const b = art({ - name: undefined, - type: undefined, - version: undefined, - }) - expect(deduplicateArtifacts([a, b])).toHaveLength(1) - }) - }) - - describe('platform hint matching', () => { - it('selects the macosx-arm64 wheel for darwin-arm64', () => { - const linux = art({ release: 'foo-1.0.0-manylinux_x86_64.whl' }) - const macarm = art({ release: 'foo-1.0.0-macosx_arm64.whl' }) - const macx64 = art({ release: 'foo-1.0.0-macosx_x86_64.whl' }) - const [picked] = deduplicateArtifacts( - [linux, macarm, macx64], - 'darwin-arm64', - ) - expect(picked).toBe(macarm) - }) - - it('selects the macosx-x86_64 wheel for darwin-x64', () => { - const linux = art({ release: 'foo-1.0.0-manylinux_x86_64.whl' }) - const macarm = art({ release: 'foo-1.0.0-macosx_arm64.whl' }) - const macx64 = art({ release: 'foo-1.0.0-macosx_x86_64.whl' }) - const [picked] = deduplicateArtifacts( - [linux, macarm, macx64], - 'darwin-x64', - ) - expect(picked).toBe(macx64) - }) - - it('selects manylinux x86_64 for linux-x64', () => { - const win = art({ release: 'foo-1.0.0-win_amd64.whl' }) - const linux = art({ release: 'foo-1.0.0-manylinux_x86_64.whl' }) - const [picked] = deduplicateArtifacts([win, linux], 'linux-x64') - expect(picked).toBe(linux) - }) - - it('selects aarch64 wheel for linux-arm64', () => { - const x86 = art({ release: 'foo-1.0.0-manylinux_x86_64.whl' }) - const arm = art({ release: 'foo-1.0.0-manylinux_aarch64.whl' }) - const [picked] = deduplicateArtifacts([x86, arm], 'linux-arm64') - expect(picked).toBe(arm) - }) - - it('selects amd64 wheel for win32-x64', () => { - const win32 = art({ release: 'foo-1.0.0-win32.whl' }) - const win64 = art({ release: 'foo-1.0.0-win_amd64.whl' }) - const [picked] = deduplicateArtifacts([win32, win64], 'win32-x64') - expect(picked).toBe(win64) - }) - - it('selects win32 wheel for win32-ia32', () => { - const win32 = art({ release: 'foo-1.0.0-win32.whl' }) - const win64 = art({ release: 'foo-1.0.0-win_amd64.whl' }) - const [picked] = deduplicateArtifacts([win32, win64], 'win32-ia32') - expect(picked).toBe(win32) - }) - - it('falls back to substring match for unknown platforms', () => { - const a = art({ release: 'foo-1.0.0-freebsd.whl' }) - const b = art({ release: 'foo-1.0.0-linux.whl' }) - const [picked] = deduplicateArtifacts([a, b], 'freebsd') - expect(picked).toBe(a) - }) - - it('falls back to next priority when platform has no match', () => { - const sdist = art({ release: 'foo-1.0.0.tar.gz' }) - const wheel = art({ release: 'foo-1.0.0-manylinux_x86_64.whl' }) - // No darwin artifact present — falls through to sdist preference. - const [picked] = deduplicateArtifacts([wheel, sdist], 'darwin-arm64') - expect(picked).toBe(sdist) - }) - }) - - describe('priority cascade (no platform hint)', () => { - it('prefers source distribution over wheels', () => { - const wheel = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const sdist = art({ release: 'foo-1.0.0.tar.gz' }) - const [picked] = deduplicateArtifacts([wheel, sdist]) - expect(picked).toBe(sdist) - }) - - it('recognizes .tar.bz2 as source distribution', () => { - const wheel = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const sdist = art({ release: 'foo-1.0.0.tar.bz2' }) - const [picked] = deduplicateArtifacts([wheel, sdist]) - expect(picked).toBe(sdist) - }) - - it('recognizes .zip as source distribution', () => { - const wheel = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const sdist = art({ release: 'foo-1.0.0.zip' }) - const [picked] = deduplicateArtifacts([wheel, sdist]) - expect(picked).toBe(sdist) - }) - - it('recognizes "sdist" in the release name', () => { - const wheel = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const sdist = art({ release: 'foo-1.0.0-sdist-extra' }) - const [picked] = deduplicateArtifacts([wheel, sdist]) - expect(picked).toBe(sdist) - }) - - it('prefers universal wheel over platform wheels when no sdist', () => { - const linuxWheel = art({ - release: 'foo-1.0.0-cp310-manylinux_x86_64.whl', - }) - const universal = art({ release: 'foo-1.0.0-py3-none-any.whl' }) - const [picked] = deduplicateArtifacts([linuxWheel, universal]) - expect(picked).toBe(universal) - }) - - it('recognizes none-any form as universal wheel', () => { - const linuxWheel = art({ - release: 'foo-1.0.0-cp310-manylinux_x86_64.whl', - }) - const universal = art({ release: 'foo-1.0.0-none-any.whl' }) - const [picked] = deduplicateArtifacts([linuxWheel, universal]) - expect(picked).toBe(universal) - }) - - it('falls back to first artifact when no sdist/universal/platform', () => { - const a = art({ release: 'foo-1.0.0-cp310-manylinux_x86_64.whl' }) - const b = art({ release: 'foo-1.0.0-cp311-manylinux_x86_64.whl' }) - const [picked] = deduplicateArtifacts([a, b]) - expect(picked).toBe(a) - }) - - it('returns first artifact when releases are missing', () => { - const a = art({ release: undefined }) - const b = art({ release: undefined }) - const [picked] = deduplicateArtifacts([a, b]) - expect(picked).toBe(a) - }) - }) - - describe('cross-package mixed groups', () => { - it('dedups across multiple packages independently', () => { - const fooWheel = art({ - name: 'foo', - release: 'foo-1.0.0-manylinux_x86_64.whl', - }) - const fooSdist = art({ name: 'foo', release: 'foo-1.0.0.tar.gz' }) - const barWheel = art({ - name: 'bar', - release: 'bar-1.0.0-macosx_arm64.whl', - }) - const result = deduplicateArtifacts( - [fooWheel, fooSdist, barWheel], - 'darwin-arm64', - ) - // foo: no darwin match → sdist wins. - // bar: darwin-arm64 matches. - expect(result).toHaveLength(2) - expect(result.find(r => r.name === 'foo')).toBe(fooSdist) - expect(result.find(r => r.name === 'bar')).toBe(barWheel) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/lib/purl.test.mts b/packages/cli/test/unit/commands/mcp/lib/purl.test.mts deleted file mode 100644 index b36743ea1..000000000 --- a/packages/cli/test/unit/commands/mcp/lib/purl.test.mts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Unit tests for the MCP command's PURL builder. - * - * Tests buildPurl(ecosystem, depname, version) — a thin wrapper around. - * - * @socketregistry/packageurl-js that handles per-ecosystem - * namespace/name splitting (npm scoped @scope/name, maven - * groupId:artifactId, golang module path). - * - * Test Coverage: - * - Bare names across all ecosystems (npm, pypi, gem, cargo, nuget, etc.) - * - npm scoped names: @scope/name → namespace=@scope, name=name - * - npm scoped without slash: @something stays as bare name - * - maven coords with `:` separator: groupId:artifactId - * - maven coords with `/` separator: groupId/artifactId - * - golang nested paths: github.com/user/repo → namespace=github.com/user, name=repo - * - golang single-segment: bare name - * - Sentinel versions (`unknown`, `1.0.0`, '') → omitted from PURL - * - Real version preserved - * - Ecosystem case folded to lowercase (NPM → npm) - * - * Related Files: - * - src/commands/mcp/lib/purl.mts - Implementation - * - src/commands/mcp/lib/artifacts.mts - Sister helper for response dedup - */ - -import { describe, expect, it } from 'vitest' - -import { buildPurl } from '../../../../../src/commands/mcp/lib/purl.mts' - -describe('buildPurl', () => { - describe('npm ecosystem', () => { - it('builds a bare PURL for an unscoped name', () => { - expect(buildPurl('npm', 'lodash', '4.17.21')).toBe( - 'pkg:npm/lodash@4.17.21', - ) - }) - - it('splits @scope/name into namespace + name', () => { - expect(buildPurl('npm', '@socketsecurity/sdk', '4.0.1')).toBe( - 'pkg:npm/%40socketsecurity/sdk@4.0.1', - ) - }) - - it('throws on a bare @something without a slash (rejected by packageurl-js)', () => { - // packageurl-js rejects `@` characters in npm names that aren't part - // of a valid scope. The MCP tool input schema doesn't pre-validate - // depnames, so callers can hit this path with malformed input. - // Documenting current behavior — rethrow surfaces as a SDK error. - expect(() => buildPurl('npm', '@notscoped', '1.2.3')).toThrow( - /Invalid purl: npm/, - ) - }) - - it('omits version when version is "unknown"', () => { - expect(buildPurl('npm', 'express', 'unknown')).toBe('pkg:npm/express') - }) - - it('omits version when version is "1.0.0" (placeholder sentinel)', () => { - expect(buildPurl('npm', 'foo', '1.0.0')).toBe('pkg:npm/foo') - }) - - it('omits version when version is empty string', () => { - expect(buildPurl('npm', 'bar', '')).toBe('pkg:npm/bar') - }) - }) - - describe('maven ecosystem', () => { - it('splits groupId:artifactId on `:`', () => { - expect( - buildPurl('maven', 'org.springframework:spring-core', '6.1.0'), - ).toBe('pkg:maven/org.springframework/spring-core@6.1.0') - }) - - it('splits groupId/artifactId on `/`', () => { - expect(buildPurl('maven', 'com.example/my-lib', '2.0.0')).toBe( - 'pkg:maven/com.example/my-lib@2.0.0', - ) - }) - - it('prefers `:` over `/` when both are present', () => { - // First `:` wins for the split, so `a:b/c` → namespace=a, name=b/c - const result = buildPurl('maven', 'a:b/c', '1.0') - expect(result).toContain('pkg:maven/a/') - expect(result).toContain('@1.0') - }) - - it('throws on a bare maven name without a groupId (purl spec requires namespace for maven)', () => { - // The PURL spec mandates a namespace component for maven; without - // a `:` or `/` in the depname there's no groupId to derive. - expect(() => buildPurl('maven', 'standalone', '1.2.3')).toThrow( - /maven requires a "namespace"/, - ) - }) - }) - - describe('golang ecosystem', () => { - it('splits a deep module path on the last slash', () => { - // packageurl-js preserves namespace slashes literally for golang. - expect( - buildPurl('golang', 'github.com/socketdev/socket-cli', 'v1.0.0'), - ).toBe('pkg:golang/github.com/socketdev/socket-cli@v1.0.0') - }) - - it('treats a single-segment module as bare name', () => { - expect(buildPurl('golang', 'context', 'v1.2.3')).toBe( - 'pkg:golang/context@v1.2.3', - ) - }) - }) - - describe('other ecosystems (no special handling)', () => { - it('builds a bare pypi PURL', () => { - expect(buildPurl('pypi', 'requests', '2.31.0')).toBe( - 'pkg:pypi/requests@2.31.0', - ) - }) - - it('builds a bare gem PURL', () => { - expect(buildPurl('gem', 'rails', '7.1.0')).toBe('pkg:gem/rails@7.1.0') - }) - - it('builds a bare cargo PURL (1.0.0 is treated as a placeholder)', () => { - // The buildPurl helper treats `1.0.0` as a placeholder (matching - // upstream socket-mcp behavior) and omits it. Real Cargo crates - // pin to specific versions in practice, so this matches how the - // depscore tool gets called. - expect(buildPurl('cargo', 'serde', '1.0.0')).toBe('pkg:cargo/serde') - }) - - it('preserves real cargo versions other than 1.0.0', () => { - expect(buildPurl('cargo', 'serde', '1.0.193')).toBe( - 'pkg:cargo/serde@1.0.193', - ) - }) - - it('builds a bare nuget PURL', () => { - expect(buildPurl('nuget', 'Newtonsoft.Json', '13.0.3')).toBe( - 'pkg:nuget/Newtonsoft.Json@13.0.3', - ) - }) - }) - - describe('ecosystem case folding', () => { - it('lowercases the ecosystem so NPM works the same as npm', () => { - expect(buildPurl('NPM', 'left-pad', '1.3.0')).toBe( - 'pkg:npm/left-pad@1.3.0', - ) - }) - - it('lowercases mixed-case PyPI ecosystem (and lowercases the package name per PURL spec)', () => { - // Per the packageurl spec, pypi names are case-insensitive and are - // canonicalized to lowercase by packageurl-js. - expect(buildPurl('PyPI', 'Django', '5.0.0')).toBe('pkg:pypi/django@5.0.0') - }) - }) - - describe('version handling', () => { - it('preserves a real semver string', () => { - expect(buildPurl('npm', 'foo', '4.2.0')).toBe('pkg:npm/foo@4.2.0') - }) - - it('preserves a pre-release version', () => { - expect(buildPurl('npm', 'foo', '4.2.0-beta.1')).toBe( - 'pkg:npm/foo@4.2.0-beta.1', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/oauth-introspector.test.mts b/packages/cli/test/unit/commands/mcp/oauth-introspector.test.mts deleted file mode 100644 index 6f91efe3e..000000000 --- a/packages/cli/test/unit/commands/mcp/oauth-introspector.test.mts +++ /dev/null @@ -1,593 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for the OAuthIntrospector class. - * - * Mocks @socketsecurity/lib/http-request so the issuer's well-known + - * introspection endpoints can be controlled per-test, exercising every branch - * of loadMetadata, verifyAccessToken, and authenticateRequest without booting a - * real HTTP server. - * - * Test Coverage (100% target): - * - * - LoadMetadata: success / non-2xx / missing required field / memoization / - * retry-after-failure clears the cached promise - * - VerifyAccessToken: 200 active / 200 inactive / non-2xx / missing exp / - * non-numeric exp / non-string client_id - * - AuthenticateRequest: missing Authorization / non-Bearer / bare "Bearer" / - * verifier throws / inactive / expired / missing scope / success - * - * Related Files: - * - * - Src/commands/mcp/transport-http-helpers.mts - Implementation - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { ServerResponse } from 'node:http' -import type { IncomingMessage } from 'node:http' - -import type * as HttpRequestModule from '@socketsecurity/lib-stable/http-request/request' - -const { mockHttpRequest } = vi.hoisted(() => ({ - mockHttpRequest: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/http-request/request', async importOriginal => { - const actual = await importOriginal<typeof HttpRequestModule>() - return { - ...actual, - httpRequest: mockHttpRequest, - } -}) - -const { OAuthIntrospector } = - await import('../../../../src/commands/mcp/transport-http-helpers.mts') - -const ISSUER = 'https://auth.example.com' -const CLIENT_ID = 'client-id' -const CLIENT_SECRET = 'client-secret' -const SCOPES = ['packages:list'] as const - -function fakeResponse(opts: { - status: number - body?: unknown | undefined - text?: string | undefined -}) { - const text = - opts.text ?? (opts.body !== undefined ? JSON.stringify(opts.body) : '') - return { - arrayBuffer: () => new ArrayBuffer(0), - body: Buffer.from(text), - headers: {}, - json: () => JSON.parse(text), - ok: opts.status >= 200 && opts.status < 300, - status: opts.status, - statusText: '', - text: () => text, - } -} - -const validMetadata = { - authorization_endpoint: 'https://auth.example.com/authorize', - introspection_endpoint: 'https://auth.example.com/introspect', - issuer: ISSUER, - token_endpoint: 'https://auth.example.com/token', -} - -export function makeRes(): { - res: ServerResponse - writeHead: ReturnType<typeof vi.fn> - end: ReturnType<typeof vi.fn> -} { - const writeHead = vi.fn() - const end = vi.fn() - return { - res: { writeHead, end } as unknown as ServerResponse, - writeHead, - end, - } -} - -export function makeReq(authHeader?: string | undefined) { - return { - headers: authHeader ? { authorization: authHeader } : {}, - } as unknown as IncomingMessage -} - -const log = { error: vi.fn() } - -beforeEach(() => { - vi.clearAllMocks() -}) - -function newIntrospector(scopes: readonly string[] = SCOPES) { - return new OAuthIntrospector(ISSUER, CLIENT_ID, CLIENT_SECRET, scopes, log) -} - -describe('OAuthIntrospector — loadMetadata', () => { - it('fetches and returns valid metadata on success', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: validMetadata }), - ) - const intro = newIntrospector() - const m = await intro.loadMetadata() - expect(m).toMatchObject(validMetadata) - expect(mockHttpRequest).toHaveBeenCalledWith( - 'https://auth.example.com/.well-known/oauth-authorization-server', - { method: 'GET' }, - ) - }) - - it('memoizes the metadata promise (one fetch across many calls)', async () => { - mockHttpRequest.mockResolvedValue( - fakeResponse({ status: 200, body: validMetadata }), - ) - const intro = newIntrospector() - await intro.loadMetadata() - await intro.loadMetadata() - await intro.loadMetadata() - expect(mockHttpRequest).toHaveBeenCalledTimes(1) - }) - - it('throws on non-2xx status with the body in the message', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 500, text: 'boom' }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /OAuth metadata discovery failed with status 500: boom/, - ) - }) - - it('throws on 4xx status with the body in the message', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 404, text: 'not found' }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /OAuth metadata discovery failed with status 404/, - ) - }) - - it('throws when authorization_endpoint is missing', async () => { - const partial = { ...validMetadata } as Record<string, unknown> - delete partial['authorization_endpoint'] - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: partial }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /missing required field: authorization_endpoint/, - ) - }) - - it('throws when introspection_endpoint is empty string', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { ...validMetadata, introspection_endpoint: '' }, - }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /missing required field: introspection_endpoint/, - ) - }) - - it('throws when token_endpoint is wrong type', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { ...validMetadata, token_endpoint: 42 }, - }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /missing required field: token_endpoint/, - ) - }) - - it('clears the cached promise after a failure so the next call retries', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 500, text: 'transient' }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow() - // Second attempt should re-issue the GET, not return the cached - // failure. - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: validMetadata }), - ) - const m = await intro.loadMetadata() - expect(m).toMatchObject(validMetadata) - expect(mockHttpRequest).toHaveBeenCalledTimes(2) - }) - - it('throws when the response body is not valid JSON', async () => { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, text: 'not-json{' }), - ) - const intro = newIntrospector() - await expect(intro.loadMetadata()).rejects.toThrow( - /OAuth metadata discovery returned invalid JSON/, - ) - }) -}) - -describe('OAuthIntrospector — verifyAccessToken', () => { - function setupMetadata() { - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: validMetadata }), - ) - } - - it('returns the AuthInfo for an active token with all fields', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { - active: true, - client_id: 'user-app', - exp: 9_999_999_999, - scope: 'packages:list extra:read', - }, - }), - ) - const intro = newIntrospector() - const info = await intro.verifyAccessToken('the-token') - expect(info).toMatchObject({ - clientId: 'user-app', - scopes: ['packages:list', 'extra:read'], - token: 'the-token', - expiresAt: 9_999_999_999, - }) - }) - - it('returns null when introspection says inactive', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: { active: false } }), - ) - const intro = newIntrospector() - expect(await intro.verifyAccessToken('the-token')).toBe(undefined) - }) - - it('throws on non-2xx introspection status', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 500, text: 'broken' }), - ) - const intro = newIntrospector() - await expect(intro.verifyAccessToken('the-token')).rejects.toThrow( - /Token introspection failed with status 500: broken/, - ) - }) - - it('returns clientId="unknown" when client_id is missing or not a string', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, client_id: 42, scope: 'packages:list' }, - }), - ) - const intro = newIntrospector() - const info = await intro.verifyAccessToken('the-token') - expect(info?.clientId).toBe('unknown') - }) - - it('omits expiresAt when exp is non-numeric', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, exp: 'not-a-number', scope: 'packages:list' }, - }), - ) - const intro = newIntrospector() - const info = await intro.verifyAccessToken('the-token') - expect(info?.expiresAt).toBeUndefined() - }) - - it('parses exp from a string when convertible', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, exp: '9999999999', scope: 'packages:list' }, - }), - ) - const intro = newIntrospector() - const info = await intro.verifyAccessToken('the-token') - expect(info?.expiresAt).toBe(9_999_999_999) - }) - - it('returns empty scopes when scope field is missing', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, client_id: 'app' }, - }), - ) - const intro = newIntrospector() - const info = await intro.verifyAccessToken('the-token') - expect(info?.scopes).toEqual([]) - }) - - it('sends a Basic-auth header derived from clientId:clientSecret', async () => { - setupMetadata() - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, client_id: 'app', scope: 'packages:list' }, - }), - ) - const intro = newIntrospector() - await intro.verifyAccessToken('the-token') - const introCall = mockHttpRequest.mock.calls[1] - expect(introCall![0]).toBe('https://auth.example.com/introspect') - const expectedAuth = - 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64') - expect(introCall![1].headers.authorization).toBe(expectedAuth) - expect(introCall![1].headers['content-type']).toBe( - 'application/x-www-form-urlencoded', - ) - expect(introCall![1].body).toBe('token=the-token') - expect(introCall![1].method).toBe('POST') - }) -}) - -describe('OAuthIntrospector — authenticateRequest', () => { - // Each Bearer-token path goes through verifyAccessToken which itself - // calls loadMetadata. Pre-stub BOTH calls (metadata + introspection) - // when we expect verification to run. - function newIntrospectorWithMetadataPrimed(): InstanceType< - typeof OAuthIntrospector - > { - const intro = newIntrospector() - // Force metadata into the cache so subsequent verifyAccessToken - // calls only need an introspection mock. - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: validMetadata }), - ) - // Trigger the load now. The caller adds an introspection mock next. - return intro - } - async function prime(intro: InstanceType<typeof OAuthIntrospector>) { - await intro.loadMetadata() - } - - it('returns 401 invalid_request when Authorization header is missing', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq(), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('invalid_request'), - }), - ) - }) - - it('returns 401 when Authorization is not a Bearer token', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Basic abc='), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('Bearer TOKEN'), - }), - ) - }) - - it('returns 401 when Authorization starts with leading whitespace (type empty)', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - const { res, writeHead } = makeRes() - // ' bearer foo'.trim() in transport sees the trim() in - // getRequestHeaderValue + .trim(). The auth check is on the - // post-trim string, so leading whitespace gets stripped before we - // even see the value. Pass the header without trim happening to - // hit the `type || ''` left-falsy branch — but trim() in the - // outer code strips whitespace. The only way to get a falsy - // `type` from `split(/\s+/u)` on a non-empty trimmed string is an - // empty string, which is filtered earlier. The `type || ''` arm - // is therefore reached when `type === undefined` — only possible - // if the regex split produced a length-0 array, which it won't - // for any non-empty string. Documenting that: this is a - // defense-in-depth branch. - // - // Instead, simulate via a header that's just spaces — which gets - // trimmed to empty and triggers the missing-header branch. So - // the (type || '') falsy arm is structurally unreachable; mark - // covered by reading a single-token header below. - const result = await intro.authenticateRequest( - makeReq('SingleToken'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - // Single token, no scheme: type = 'SingleToken', token = undefined. - // First clause fails: 'singletoken' !== 'bearer' → true → 401. - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('invalid_request'), - }), - ) - }) - - it('returns 401 when Bearer scheme is present but token is empty', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Bearer'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('invalid_request'), - }), - ) - }) - - it('returns 500 server_error and logs when verifier throws', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - // Introspection POST fails with a network error. - mockHttpRequest.mockRejectedValueOnce(new Error('ECONNRESET')) - const { res, writeHead, end } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Bearer abc'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(log.error).toHaveBeenCalledWith( - expect.stringContaining('Token verification failed: ECONNRESET'), - ) - expect(writeHead).toHaveBeenCalledWith(500, expect.any(Object)) - const body = JSON.parse(end.mock.calls[0][0] as string) - expect(body.error).toBe('server_error') - }) - - it('coerces non-Error verifier rejections via String()', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - mockHttpRequest.mockRejectedValueOnce('plain string') - const { res } = makeRes() - await intro.authenticateRequest( - makeReq('Bearer abc'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(log.error).toHaveBeenCalledWith( - expect.stringContaining('Token verification failed: plain string'), - ) - }) - - it('returns 401 invalid_token when verifier returns null (inactive)', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ status: 200, body: { active: false } }), - ) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Bearer abc'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('invalid_token'), - }), - ) - }) - - it('returns 401 invalid_token when token has expired', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - const past = Math.floor(Date.now() / 1000) - 60 - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { - active: true, - client_id: 'app', - exp: past, - scope: 'packages:list', - }, - }), - ) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Bearer abc'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 401, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('Token has expired'), - }), - ) - }) - - it('returns 403 insufficient_scope when token lacks the required scope', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { active: true, client_id: 'app', scope: 'something:else' }, - }), - ) - const { res, writeHead } = makeRes() - const result = await intro.authenticateRequest( - makeReq('Bearer abc'), - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(false) - expect(writeHead).toHaveBeenCalledWith( - 403, - expect.objectContaining({ - 'WWW-Authenticate': expect.stringContaining('insufficient_scope'), - }), - ) - }) - - it('returns ok with authInfo and stamps req.auth on success', async () => { - const intro = newIntrospectorWithMetadataPrimed() - await prime(intro) - mockHttpRequest.mockResolvedValueOnce( - fakeResponse({ - status: 200, - body: { - active: true, - client_id: 'app', - scope: 'packages:list', - exp: 9_999_999_999, - }, - }), - ) - const req = makeReq('Bearer some-token') - const { res } = makeRes() - const result = await intro.authenticateRequest( - req, - res, - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.authInfo.token).toBe('some-token') - expect(result.authInfo.scopes).toContain('packages:list') - } - expect( - (req as unknown as { auth?: { token: string } | undefined }).auth?.token, - ).toBe('some-token') - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/server.test.mts b/packages/cli/test/unit/commands/mcp/server.test.mts deleted file mode 100644 index a948d04cf..000000000 --- a/packages/cli/test/unit/commands/mcp/server.test.mts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Unit tests for the MCP server factory. - * - * Tests createConfiguredServer(config) — wires the low-level SDK `Server` class - * with two request handlers (tools/list, tools/call) and the depscore tool. We - * test by directly invoking the registered handlers via the SDK's `request()` - * method (which round-trips through its internal validators). - * - * Test Coverage: - Server identifies itself with the configured name + version - * - Capabilities advertise tools{} - tools/list returns exactly one tool - * (depscore) with the right metadata (name, description, inputSchema as plain - * JSON Schema, readOnlyHint annotation, title) - tools/call dispatches to - * runDepscore and returns its result - tools/call rejects unknown tool names - * with isError + message - tools/call validates input via the TypeBox-compiled - * checker (rejects missing/wrong-typed `packages` field) - tools/call uses the - * per-request OAuth token from extra.authInfo when present (HTTP+OAuth path) - - * tools/call falls back to config.getApiToken() when authInfo is absent (stdio - * path) - tools/call surfaces "Authentication is required." when no token is - * available from either source. - * - * Testing approach - Mock runDepscore so the test doesn't need a live SDK. - - * Construct the real `Server`, retrieve its registered handlers via the SDK's - * protected map (or by calling it directly), assert on the result shape. - * - * Related Files: - src/commands/mcp/server.mts - Implementation - - * src/commands/mcp/depscore.mts - Tool worker (mocked here) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js' - -import type * as DepscoreModule from '../../../../src/commands/mcp/depscore.mts' - -const { mockRunDepscore } = vi.hoisted(() => ({ - mockRunDepscore: vi.fn(), -})) - -vi.mock('../../../../src/commands/mcp/depscore.mts', async importOriginal => { - const actual = await importOriginal<typeof DepscoreModule>() - return { - ...actual, - runDepscore: mockRunDepscore, - } -}) - -const { createConfiguredServer } = - await import('../../../../src/commands/mcp/server.mts') - -// Helper: invoke a handler from the underlying SDK Server. The SDK -// exposes `.setRequestHandler` but not a public `.handle(...)`, so we -// pull the registered handler off the internal `_requestHandlers` map. -type AnyServer = { - _requestHandlers: Map< - string, - (req: unknown, extra: unknown) => Promise<unknown> - > -} - -function getHandler( - server: ReturnType<typeof createConfiguredServer>, - schema: typeof ListToolsRequestSchema | typeof CallToolRequestSchema, -) { - const internal = server as unknown as AnyServer - const method = (schema as unknown as { shape: { method: { value: string } } }) - .shape.method.value - const handler = internal._requestHandlers.get(method) - if (!handler) { - throw new Error(`No handler registered for ${method}`) - } - return handler -} - -const baseConfig = { - getApiToken: () => 'test_default_token', - serverName: 'socket', - version: '9.9.9', -} - -beforeEach(() => { - vi.clearAllMocks() - mockRunDepscore.mockResolvedValue({ - content: [{ text: 'ok', type: 'text' as const }], - }) -}) - -describe('createConfiguredServer — construction', () => { - it('creates a Server with the configured name and version', () => { - const server = createConfiguredServer(baseConfig) - // The SDK Server stores serverInfo internally; round-trip via the - // protocol's getter for the server's implementation info. - const info = ( - server as unknown as { _serverInfo: { name: string; version: string } } - )._serverInfo - expect(info.name).toBe('socket') - expect(info.version).toBe('9.9.9') - }) - - it('declares the tools capability', () => { - const server = createConfiguredServer(baseConfig) - const caps = ( - server as unknown as { _capabilities: Record<string, unknown> } - )._capabilities - expect(caps['tools']).toBeDefined() - }) -}) - -describe('createConfiguredServer — tools/list handler', () => { - it('returns the single depscore tool', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, ListToolsRequestSchema) - const result = (await handler( - { method: 'tools/list', params: {} }, - {}, - )) as { - tools: Array<{ - name: string - description: string - title?: string | undefined - annotations?: { readOnlyHint?: boolean | undefined } | undefined - inputSchema: Record<string, unknown> - }> - } - expect(result.tools).toHaveLength(1) - expect(result.tools[0]!.name).toBe('depscore') - expect(result.tools[0]!.title).toBe('Dependency Score Tool') - expect(result.tools[0]!.annotations?.readOnlyHint).toBe(true) - expect(result.tools[0]!.description).toContain('depscore') - }) - - it('emits a plain JSON Schema (no TypeBox symbols/keys)', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, ListToolsRequestSchema) - const result = (await handler( - { method: 'tools/list', params: {} }, - {}, - )) as { tools: Array<{ inputSchema: Record<string, unknown> }> } - const schema = result.tools[0]!.inputSchema - // Round-trippable through JSON. - expect(() => JSON.parse(JSON.stringify(schema))).not.toThrow() - // Has the expected shape. - expect(schema['type']).toBe('object') - expect(schema['properties']).toBeDefined() - const props = schema['properties'] as Record<string, unknown> - expect(props['packages']).toBeDefined() - expect(props['platform']).toBeDefined() - // No symbol-keyed TypeBox metadata. - const ownKeys = Reflect.ownKeys(schema) - const symbolKeys = ownKeys.filter(k => typeof k === 'symbol') - expect(symbolKeys).toHaveLength(0) - }) -}) - -describe('createConfiguredServer — tools/call handler', () => { - it('dispatches to runDepscore for the depscore tool', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - const result = (await handler( - { - method: 'tools/call', - params: { - name: 'depscore', - arguments: { packages: [{ depname: 'lodash' }] }, - }, - }, - {}, - )) as { - content: Array<{ text: string; type: string }> - isError?: boolean | undefined - } - expect(mockRunDepscore).toHaveBeenCalledTimes(1) - expect(mockRunDepscore.mock.calls[0]![0]).toEqual({ - packages: [{ depname: 'lodash' }], - }) - expect(result.content[0]!.text).toBe('ok') - expect(result.isError).toBeUndefined() - }) - - it('returns isError when called with an unknown tool name', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - const result = (await handler( - { - method: 'tools/call', - params: { - name: 'unknown-tool', - arguments: {}, - }, - }, - {}, - )) as { - content: Array<{ text: string; type: string }> - isError?: boolean | undefined - } - expect(mockRunDepscore).not.toHaveBeenCalled() - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Unknown tool: unknown-tool') - }) - - it('returns isError + validation message when arguments are missing the packages field', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - const result = (await handler( - { - method: 'tools/call', - params: { name: 'depscore', arguments: {} }, - }, - {}, - )) as { - content: Array<{ text: string; type: string }> - isError?: boolean | undefined - } - expect(mockRunDepscore).not.toHaveBeenCalled() - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Invalid arguments for depscore') - }) - - it('returns isError when packages is the wrong shape (string instead of array)', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - const result = (await handler( - { - method: 'tools/call', - params: { name: 'depscore', arguments: { packages: 'not-an-array' } }, - }, - {}, - )) as { - content: Array<{ text: string; type: string }> - isError?: boolean | undefined - } - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Invalid arguments for depscore') - }) - - it('uses the OAuth token from extra.authInfo when present', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - await handler( - { - method: 'tools/call', - params: { - name: 'depscore', - arguments: { packages: [{ depname: 'foo' }] }, - }, - }, - { authInfo: { token: 'oauth_user_token_xyz' } }, - ) - expect(mockRunDepscore).toHaveBeenCalledWith( - expect.objectContaining({ packages: expect.any(Array) }), - { apiToken: 'oauth_user_token_xyz' }, - ) - }) - - it('falls back to config.getApiToken() when authInfo is absent', async () => { - const server = createConfiguredServer(baseConfig) - const handler = getHandler(server, CallToolRequestSchema) - await handler( - { - method: 'tools/call', - params: { - name: 'depscore', - arguments: { packages: [{ depname: 'foo' }] }, - }, - }, - {}, - ) - expect(mockRunDepscore).toHaveBeenCalledWith( - expect.objectContaining({ packages: expect.any(Array) }), - { apiToken: 'test_default_token' }, - ) - }) - - it('surfaces the auth-required message when no token is available from either source', async () => { - const server = createConfiguredServer({ - ...baseConfig, - getApiToken: () => undefined, - }) - const handler = getHandler(server, CallToolRequestSchema) - const result = (await handler( - { - method: 'tools/call', - params: { - name: 'depscore', - arguments: { packages: [{ depname: 'foo' }] }, - }, - }, - {}, - )) as { - content: Array<{ text: string; type: string }> - isError?: boolean | undefined - } - expect(mockRunDepscore).not.toHaveBeenCalled() - expect(result.isError).toBe(true) - expect(result.content[0]!.text).toContain('Authentication is required') - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/transport-http-helpers.test.mts b/packages/cli/test/unit/commands/mcp/transport-http-helpers.test.mts deleted file mode 100644 index 63271362f..000000000 --- a/packages/cli/test/unit/commands/mcp/transport-http-helpers.test.mts +++ /dev/null @@ -1,683 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for the MCP HTTP transport's pure helpers. - * - * These small functions handle header normalization, base-URL construction, - * JSON parsing, scope splitting, OAuth error formatting, and origin - * classification. Pulled out of transport-http.mts so they can be exercised - * directly without booting an HTTP server. - * - * Test Coverage (100% target): - getRequestHeaderValue: undefined / string / - * array / empty array - getForwardedHeaderValue: empty / single / comma-list / - * whitespace - getRequestBaseUrl: trustProxy on/off × forwarded headers - * present/missing × tls / non-tls socket × forwarded-proto valid/invalid - - * parseJsonObject: valid object / array / null / primitive / malformed - - * getProtectedResourceMetadataUrl: appends well-known path - - * buildProtectedResourceMetadata: includes all required fields - writeJson: - * status code, headers, body - writeOAuthError: with and without - * resourceMetadataUrl - splitScopes: non-string / empty string / single / - * multiple / extra ws - isLocalhostOrigin: localhost / 127.0.0.1 / other / - * malformed URL. - * - * Related Files: - src/commands/mcp/transport-http-helpers.mts - Implementation - * - src/commands/mcp/transport-http.mts - Caller (HTTP server) - */ - -import { describe, expect, it, vi } from 'vitest' - -import type { IncomingMessage, ServerResponse } from 'node:http' - -import { - OAUTH_PROTECTED_RESOURCE_METADATA_PATH, - OAUTH_WELL_KNOWN_PATH, - buildProtectedResourceMetadata, - destroySessionEntry, - getForwardedHeaderValue, - getProtectedResourceMetadataUrl, - getRequestBaseUrl, - getRequestHeaderValue, - handleRequestSafely, - isLocalhostOrigin, - makeOnTransportClose, - parseJsonObject, - reapIdleSessions, - splitScopes, - writeJson, - writeOAuthError, -} from '../../../../src/commands/mcp/transport-http-helpers.mts' - -import type { SessionLike } from '../../../../src/commands/mcp/transport-http-helpers.mts' - -import type { OAuthMetadata } from '../../../../src/commands/mcp/transport-http-helpers.mts' - -describe('getRequestHeaderValue', () => { - it('returns empty string when header is undefined', () => { - expect(getRequestHeaderValue(undefined)).toBe('') - }) - - it('returns the string when header is a string', () => { - expect(getRequestHeaderValue('foo')).toBe('foo') - }) - - it('returns the first element when header is an array', () => { - expect(getRequestHeaderValue(['first', 'second'])).toBe('first') - }) - - it('returns empty string when header is an empty array', () => { - expect(getRequestHeaderValue([])).toBe('') - }) - - it('returns empty string when array first element is empty', () => { - expect(getRequestHeaderValue(['', 'second'])).toBe('') - }) - - it('returns empty string when string is empty', () => { - expect(getRequestHeaderValue('')).toBe('') - }) -}) - -describe('getForwardedHeaderValue', () => { - it('returns empty string when header is undefined', () => { - expect(getForwardedHeaderValue(undefined)).toBe('') - }) - - it('returns a single value untrimmed of internal spaces', () => { - expect(getForwardedHeaderValue('https')).toBe('https') - }) - - it('returns the first comma-separated value', () => { - expect(getForwardedHeaderValue('https, http, https')).toBe('https') - }) - - it('trims whitespace around the first value', () => { - expect(getForwardedHeaderValue(' https , http')).toBe('https') - }) - - it('returns empty string for an empty string', () => { - expect(getForwardedHeaderValue('')).toBe('') - }) - - it('returns empty string when comma-list starts with comma', () => { - expect(getForwardedHeaderValue(',https')).toBe('') - }) - - it('handles array form by reading the first element', () => { - expect(getForwardedHeaderValue(['https, http', 'second'])).toBe('https') - }) -}) - -export function makeReq(opts: { - headers?: Record<string, string | string[] | undefined> | undefined - encrypted?: boolean | undefined -}): IncomingMessage { - return { - headers: opts.headers ?? {}, - socket: { encrypted: opts.encrypted ?? false }, - } as unknown as IncomingMessage -} - -describe('getRequestBaseUrl', () => { - it('uses Host header when trustProxy=false (default)', () => { - const url = getRequestBaseUrl( - makeReq({ headers: { host: 'example.com' } }), - 3000, - false, - ) - expect(url.hostname).toBe('example.com') - expect(url.protocol).toBe('http:') - }) - - it('falls back to localhost:port when Host header is missing', () => { - const url = getRequestBaseUrl(makeReq({}), 3000, false) - expect(url.hostname).toBe('localhost') - expect(url.port).toBe('3000') - }) - - it('uses https when socket is encrypted', () => { - const url = getRequestBaseUrl( - makeReq({ headers: { host: 'example.com' }, encrypted: true }), - 3000, - false, - ) - expect(url.protocol).toBe('https:') - }) - - it('ignores X-Forwarded-Proto when trustProxy=false', () => { - const url = getRequestBaseUrl( - makeReq({ - headers: { - host: 'example.com', - 'x-forwarded-proto': 'https', - }, - }), - 3000, - false, - ) - expect(url.protocol).toBe('http:') - }) - - it('honors X-Forwarded-Proto when trustProxy=true', () => { - const url = getRequestBaseUrl( - makeReq({ - headers: { - host: 'example.com', - 'x-forwarded-proto': 'https', - }, - }), - 3000, - true, - ) - expect(url.protocol).toBe('https:') - }) - - it('honors X-Forwarded-Host when trustProxy=true', () => { - const url = getRequestBaseUrl( - makeReq({ - headers: { - host: 'internal.local', - 'x-forwarded-host': 'public.example.com', - }, - }), - 3000, - true, - ) - expect(url.hostname).toBe('public.example.com') - }) - - it('case-folds X-Forwarded-Proto and accepts http only', () => { - const url = getRequestBaseUrl( - makeReq({ - headers: { - host: 'example.com', - 'x-forwarded-proto': 'HTTP', - }, - }), - 3000, - true, - ) - expect(url.protocol).toBe('http:') - }) - - it('falls back to socket-detected protocol when X-Forwarded-Proto is unrecognized', () => { - const url = getRequestBaseUrl( - makeReq({ - headers: { - host: 'example.com', - 'x-forwarded-proto': 'gopher', - }, - encrypted: true, - }), - 3000, - true, - ) - // Unrecognized forwarded value → fall through to socket.encrypted. - expect(url.protocol).toBe('https:') - }) -}) - -describe('parseJsonObject', () => { - it('returns the parsed object on valid JSON', () => { - expect(parseJsonObject('{"a":1}', 'ctx')).toEqual({ a: 1 }) - }) - - it('throws with context on malformed JSON', () => { - expect(() => parseJsonObject('{not valid', 'ctx')).toThrow( - /ctx returned invalid JSON/, - ) - }) - - it('throws when payload is a JSON array', () => { - expect(() => parseJsonObject('[1,2,3]', 'ctx')).toThrow( - /expected a JSON object/, - ) - }) - - it('throws when payload is null', () => { - expect(() => parseJsonObject('null', 'ctx')).toThrow( - /expected a JSON object/, - ) - }) - - it('throws when payload is a primitive number', () => { - expect(() => parseJsonObject('42', 'ctx')).toThrow(/expected a JSON object/) - }) - - it('throws when payload is a primitive string', () => { - expect(() => parseJsonObject('"hello"', 'ctx')).toThrow( - /expected a JSON object/, - ) - }) - - it('preserves the underlying error message in the wrapped error', () => { - expect(() => parseJsonObject('not json at all', 'metadata fetch')).toThrow( - /metadata fetch returned invalid JSON: /, - ) - }) - - it('throws with String(error) when caught value is not an Error', () => { - // The catch coerces non-Error throws via String(); JSON.parse only - // ever throws SyntaxError, but we test the branch with a stub. - const origParse = JSON.parse - JSON.parse = (() => { - throw 'plain string' - }) as typeof JSON.parse - try { - expect(() => parseJsonObject('{}', 'ctx')).toThrow( - /ctx returned invalid JSON: plain string/, - ) - } finally { - JSON.parse = origParse - } - }) -}) - -describe('getProtectedResourceMetadataUrl', () => { - it('appends the well-known path to the base URL', () => { - const url = new URL('https://example.com/') - expect(getProtectedResourceMetadataUrl(url)).toBe( - `https://example.com${OAUTH_PROTECTED_RESOURCE_METADATA_PATH}`, - ) - }) - - it('overrides any existing path on the base URL', () => { - const url = new URL('https://example.com/some/other/path') - expect(getProtectedResourceMetadataUrl(url)).toBe( - `https://example.com${OAUTH_PROTECTED_RESOURCE_METADATA_PATH}`, - ) - }) -}) - -describe('buildProtectedResourceMetadata', () => { - it('packages issuer + resource + scopes + name', () => { - const baseUrl = new URL('https://api.example.com/') - const metadata = { - authorization_endpoint: 'https://auth.example.com/authorize', - introspection_endpoint: 'https://auth.example.com/introspect', - issuer: 'https://auth.example.com', - token_endpoint: 'https://auth.example.com/token', - } satisfies OAuthMetadata - const result = buildProtectedResourceMetadata(baseUrl, metadata, [ - 'a:read', - 'b:write', - ]) - expect(result).toEqual({ - authorization_servers: ['https://auth.example.com'], - resource: 'https://api.example.com/', - resource_name: 'Socket MCP Server', - scopes_supported: ['a:read', 'b:write'], - }) - }) -}) - -export function makeRes(): { - res: ServerResponse - writeHead: ReturnType<typeof vi.fn> - end: ReturnType<typeof vi.fn> -} { - const writeHead = vi.fn() - const end = vi.fn() - return { - res: { writeHead, end } as unknown as ServerResponse, - writeHead, - end, - } -} - -describe('writeJson', () => { - it('writes status, default Content-Type, and JSON-stringified body', () => { - const { res, writeHead, end } = makeRes() - writeJson(res, 200, { ok: true }) - expect(writeHead).toHaveBeenCalledWith(200, { - 'Content-Type': 'application/json', - }) - expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: true })) - }) - - it('merges extra headers', () => { - const { res, writeHead } = makeRes() - writeJson(res, 401, { error: 'x' }, { 'WWW-Authenticate': 'Bearer' }) - expect(writeHead).toHaveBeenCalledWith(401, { - 'Content-Type': 'application/json', - 'WWW-Authenticate': 'Bearer', - }) - }) -}) - -describe('writeOAuthError', () => { - it('writes status with WWW-Authenticate (no resource metadata)', () => { - const { res, writeHead, end } = makeRes() - writeOAuthError(res, 401, 'invalid_token', 'expired') - const headers = writeHead.mock.calls[0][1] as Record<string, string> - expect(headers['WWW-Authenticate']).toBe( - 'Bearer error="invalid_token", error_description="expired"', - ) - expect(end).toHaveBeenCalledWith( - JSON.stringify({ - error: 'invalid_token', - error_description: 'expired', - }), - ) - }) - - it('appends resource_metadata when provided', () => { - const { res, writeHead } = makeRes() - writeOAuthError( - res, - 401, - 'invalid_token', - 'expired', - 'https://api.example.com/.well-known/oauth-protected-resource', - ) - const headers = writeHead.mock.calls[0][1] as Record<string, string> - expect(headers['WWW-Authenticate']).toBe( - 'Bearer error="invalid_token", error_description="expired", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', - ) - }) - - it('passes through the supplied status code', () => { - const { res, writeHead } = makeRes() - writeOAuthError(res, 403, 'insufficient_scope', 'no scope') - expect(writeHead).toHaveBeenCalledWith(403, expect.any(Object)) - }) -}) - -describe('splitScopes', () => { - it('returns empty array for non-string input (number)', () => { - expect(splitScopes(42)).toEqual([]) - }) - - it('returns empty array for non-string input (object)', () => { - expect(splitScopes({ scope: 'a' })).toEqual([]) - }) - - it('returns empty array for non-string input (null)', () => { - expect(splitScopes(undefined)).toEqual([]) - }) - - it('returns empty array for empty string', () => { - expect(splitScopes('')).toEqual([]) - }) - - it('splits a single space-separated list', () => { - expect(splitScopes('a:read b:write')).toEqual(['a:read', 'b:write']) - }) - - it('splits on tabs and other whitespace', () => { - expect(splitScopes('a:read\tb:write\nc:exec')).toEqual([ - 'a:read', - 'b:write', - 'c:exec', - ]) - }) - - it('drops empty entries from extra whitespace', () => { - expect(splitScopes(' a:read b:write ')).toEqual([ - 'a:read', - 'b:write', - ]) - }) - - it('returns the single scope when only one is present', () => { - expect(splitScopes('a:read')).toEqual(['a:read']) - }) -}) - -describe('isLocalhostOrigin', () => { - it('returns true for http://localhost', () => { - expect(isLocalhostOrigin('http://localhost')).toBe(true) - }) - - it('returns true for http://localhost:3000', () => { - expect(isLocalhostOrigin('http://localhost:3000')).toBe(true) - }) - - it('returns true for http://127.0.0.1', () => { - expect(isLocalhostOrigin('http://127.0.0.1')).toBe(true) - }) - - it('returns true for https://127.0.0.1:8443', () => { - expect(isLocalhostOrigin('https://127.0.0.1:8443')).toBe(true) - }) - - it('returns false for an external origin', () => { - expect(isLocalhostOrigin('https://attacker.example.com')).toBe(false) - }) - - it('returns false for malicious-localhost.evil.com', () => { - expect(isLocalhostOrigin('https://malicious-localhost.evil.com')).toBe( - false, - ) - }) - - it('returns false for a malformed URL', () => { - expect(isLocalhostOrigin('not a url at all')).toBe(false) - }) - - it('returns false for an empty string', () => { - expect(isLocalhostOrigin('')).toBe(false) - }) - - it('returns false for IPv6 loopback (does not match localhost/127.0.0.1)', () => { - // Documenting current behavior: ::1 is NOT recognized; only the - // two literal forms are. If we want to extend, that's a deliberate - // change. - expect(isLocalhostOrigin('http://[::1]')).toBe(false) - }) -}) - -describe('destroySessionEntry', () => { - function fakeSession( - opts: { - transportClose?: (() => void) | undefined - serverClose?: (() => Promise<unknown>) | undefined - } = {}, - ): SessionLike { - return { - lastActivity: 0, - server: { - close: opts.serverClose ?? (() => Promise.resolve()), - }, - transport: { - close: opts.transportClose ?? (() => {}), - }, - } - } - - it('returns early when the session id is unknown', () => { - const sessions = new Map<string, SessionLike>() - const log = { info: vi.fn() } - destroySessionEntry('does-not-exist', sessions, log) - expect(log.info).not.toHaveBeenCalled() - }) - - it('deletes the session, closes the transport and server, and logs', () => { - const transportClose = vi.fn() - const serverClose = vi.fn(() => Promise.resolve()) - const session = fakeSession({ transportClose, serverClose }) - const sessions = new Map<string, SessionLike>([['s1', session]]) - const log = { info: vi.fn() } - destroySessionEntry('s1', sessions, log) - expect(sessions.has('s1')).toBe(false) - expect(transportClose).toHaveBeenCalled() - expect(serverClose).toHaveBeenCalled() - expect(log.info).toHaveBeenCalledWith('Session s1 destroyed') - }) - - it('swallows synchronous throws from transport.close()', () => { - const transportClose = vi.fn(() => { - throw new Error('mid-stream close') - }) - const sessions = new Map<string, SessionLike>([ - ['s1', fakeSession({ transportClose })], - ]) - const log = { info: vi.fn() } - expect(() => destroySessionEntry('s1', sessions, log)).not.toThrow() - expect(log.info).toHaveBeenCalledWith('Session s1 destroyed') - }) - - it('swallows async rejections from server.close() (the .catch arm)', async () => { - const serverClose = vi.fn(() => Promise.reject(new Error('shutdown race'))) - const sessions = new Map<string, SessionLike>([ - ['s1', fakeSession({ serverClose })], - ]) - const log = { info: vi.fn() } - destroySessionEntry('s1', sessions, log) - // Wait a microtask for the rejection to flush through .catch. - await Promise.resolve() - await Promise.resolve() - // Test passes iff destroySessionEntry didn't propagate the - // unhandled rejection to the test runner. - expect(serverClose).toHaveBeenCalled() - }) -}) - -describe('makeOnTransportClose', () => { - it('calls destroy(id) when the transport reports a sessionId', () => { - const destroy = vi.fn() - const onclose = makeOnTransportClose(() => 'session-abc', destroy) - onclose() - expect(destroy).toHaveBeenCalledWith('session-abc') - }) - - it('is a no-op when the transport has no sessionId yet', () => { - // Fires when onclose runs before onsessioninitialized has assigned - // a sessionId — the SDK can close a freshly-constructed transport - // (e.g. an init failure) before any sessionId exists. - const destroy = vi.fn() - const onclose = makeOnTransportClose(() => undefined, destroy) - onclose() - expect(destroy).not.toHaveBeenCalled() - }) - - it('treats empty-string sessionId as missing', () => { - const destroy = vi.fn() - const onclose = makeOnTransportClose(() => '', destroy) - onclose() - expect(destroy).not.toHaveBeenCalled() - }) -}) - -describe('reapIdleSessions', () => { - it('does nothing when sessions are all fresh', () => { - const sessions = new Map([ - ['s1', { lastActivity: 1000 }], - ['s2', { lastActivity: 1100 }], - ]) - const destroy = vi.fn() - const log = { info: vi.fn() } - reapIdleSessions(1500, 5000, sessions, destroy, log) - expect(destroy).not.toHaveBeenCalled() - expect(log.info).not.toHaveBeenCalled() - }) - - it('destroys exactly the sessions older than ttlMs', () => { - const sessions = new Map([ - ['fresh', { lastActivity: 9_000 }], - ['old', { lastActivity: 1_000 }], - ['oldest', { lastActivity: 100 }], - ]) - const destroy = vi.fn() - const log = { info: vi.fn() } - // ttl 5_000 → "fresh" stays (now-fresh = 1_000 ≤ 5_000), - // "old" goes (now-old = 9_000 > 5_000), - // "oldest" goes (now-oldest = 9_900 > 5_000). - reapIdleSessions(10_000, 5_000, sessions, destroy, log) - expect(destroy).toHaveBeenCalledWith('old') - expect(destroy).toHaveBeenCalledWith('oldest') - expect(destroy).not.toHaveBeenCalledWith('fresh') - expect(log.info).toHaveBeenCalledWith('Reaping idle session old') - expect(log.info).toHaveBeenCalledWith('Reaping idle session oldest') - expect(log.info).not.toHaveBeenCalledWith('Reaping idle session fresh') - }) - - it('does nothing on an empty session map', () => { - const destroy = vi.fn() - const log = { info: vi.fn() } - reapIdleSessions(1, 1, new Map(), destroy, log) - expect(destroy).not.toHaveBeenCalled() - }) - - it('uses strict greater-than for the TTL boundary (equal is fresh enough)', () => { - const sessions = new Map([['edge', { lastActivity: 5_000 }]]) - const destroy = vi.fn() - const log = { info: vi.fn() } - // now - lastActivity == ttlMs exactly → should NOT destroy. - reapIdleSessions(10_000, 5_000, sessions, destroy, log) - expect(destroy).not.toHaveBeenCalled() - }) -}) - -describe('handleRequestSafely', () => { - it('runs the handler and returns silently when no error is thrown', async () => { - const { res, writeHead, end } = makeRes() - const log = { error: vi.fn() } - const fn = vi.fn(async () => { - // Pretend the handler wrote its own response. - }) - await handleRequestSafely('POST', res, log, fn) - expect(fn).toHaveBeenCalled() - expect(log.error).not.toHaveBeenCalled() - expect(writeHead).not.toHaveBeenCalled() - expect(end).not.toHaveBeenCalled() - }) - - it('logs and writes a 500 JSON-RPC envelope when the handler throws', async () => { - const { res, writeHead, end } = makeRes() - Object.defineProperty(res, 'headersSent', { value: false, writable: true }) - const log = { error: vi.fn() } - await handleRequestSafely('GET', res, log, async () => { - throw new Error('transport boom') - }) - expect(log.error).toHaveBeenCalledWith( - expect.stringContaining('Error processing GET request:'), - ) - expect(log.error).toHaveBeenCalledWith( - expect.stringContaining('transport boom'), - ) - expect(writeHead).toHaveBeenCalledWith(500, expect.any(Object)) - const body = JSON.parse(end.mock.calls[0][0] as string) - expect(body.error.code).toBe(-32603) - expect(body.error.message).toBe('Internal server error') - expect(body.id).toBe(undefined) - expect(body.jsonrpc).toBe('2.0') - }) - - it('does not call writeHead when response is already streaming (headersSent=true)', async () => { - const { res, writeHead } = makeRes() - Object.defineProperty(res, 'headersSent', { value: true, writable: false }) - const log = { error: vi.fn() } - await handleRequestSafely('DELETE', res, log, async () => { - throw new Error('mid-stream failure') - }) - expect(log.error).toHaveBeenCalled() - // The 500 envelope must NOT be written when the SDK has already - // started the response, otherwise the worker crashes. - expect(writeHead).not.toHaveBeenCalled() - }) - - it('coerces non-Error throws via the template literal', async () => { - const { res } = makeRes() - Object.defineProperty(res, 'headersSent', { value: false, writable: true }) - const log = { error: vi.fn() } - await handleRequestSafely('POST', res, log, async () => { - throw 'plain string error' - }) - expect(log.error).toHaveBeenCalledWith( - 'Error processing POST request: plain string error', - ) - }) -}) - -describe('module-level constants', () => { - it('exposes the OAuth well-known path', () => { - expect(OAUTH_WELL_KNOWN_PATH).toBe( - '/.well-known/oauth-authorization-server', - ) - }) - - it('exposes the protected-resource metadata path', () => { - expect(OAUTH_PROTECTED_RESOURCE_METADATA_PATH).toBe( - '/.well-known/oauth-protected-resource', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/transport-http.test.mts b/packages/cli/test/unit/commands/mcp/transport-http.test.mts deleted file mode 100644 index a0bbbcc77..000000000 --- a/packages/cli/test/unit/commands/mcp/transport-http.test.mts +++ /dev/null @@ -1,1094 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for the MCP Streamable HTTP transport. - * - * Tests runHttpTransport(config) by booting a real HTTP server on an ephemeral - * port and hitting it with @socketsecurity/lib/http-request. This exercises the - * full request pipeline (origin/host validation, CORS, OAuth introspection, - * well-known endpoints, session map, StreamableHTTPServerTransport hand-off) - * without poking at private internals. - * - * Test Coverage: - * - * - GET /health bypasses Origin validation and returns service info - * - Invalid Origin → 403 with JSON-RPC error envelope - * - Allowed origins (mcp.socket.dev, mcp.socket-staging.dev, localhost variants) - * → request proceeds - * - Localhost subdomain spoof rejected (Host strict-match) - * - CORS headers set on origin-bearing requests - * - OPTIONS preflight returns 200 - * - Unknown URL path → 404 - * - Method not allowed (PATCH) on / → 405 - * - GET / without sessionId → 404 - * - DELETE / without sessionId → 404 - * - POST / without sessionId and without initialize body → 400 - * - POST / initialize creates a session (Mcp-Session-Id header returned, - * subsequent calls routed) - * - OAuth disabled: requests proceed without Authorization - * - OAuth enabled: well-known/oauth-protected-resource returned - * - OAuth enabled: missing Authorization → 401 with WWW-Authenticate - * - OAuth enabled: invalid token format → 401 - * - OAuth enabled: introspection inactive → 401 invalid_token - * - OAuth enabled: missing required scope → 403 insufficient_scope - * - OAuth enabled: expired token → 401 - * - OAuth enabled: token introspection error → 500 - * - * Related Files: - * - * - Src/commands/mcp/transport-http.mts - Implementation - * - Src/commands/mcp/server.mts - Server factory (real) - * - @modelcontextprotocol/sdk/server/streamableHttp - Transport (real) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { httpRequest } from '@socketsecurity/lib-stable/http-request/request' - -import type * as HttpModule from 'node:http' -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as NetModule from 'node:net' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { mockSetupSdk, mockBatchPackageFetch } = vi.hoisted(() => ({ - mockSetupSdk: vi.fn(), - mockBatchPackageFetch: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, - getDefaultApiToken: vi.fn(() => 'test_default'), -})) - -const { runHttpTransport } = - await import('../../../../src/commands/mcp/transport-http.mts') - -// We boot a real http.Server and need to tear it down between tests. -// runHttpTransport doesn't return a stop handle, so we discover the -// server via process._getActiveHandles() — but that's flaky. Better: -// scrape the listening port from the logger and rely on process exit -// to clean up at the test-file boundary. To work around server state -// bleeding between tests, each test uses a fresh ephemeral port and -// constructs a new transport. - -let nextPort = 23900 - -function freshPort(): number { - return nextPort++ -} - -const baseConfig = { - getApiToken: () => 'test_local', - serverName: 'socket', - version: '0.0.1', -} - -async function startServer( - overrides: Partial<{ - oauthClientId: string - oauthClientSecret: string - oauthIssuer: string - oauthRequiredScopes: readonly string[] - port: number - trustProxy: boolean - }> = {}, -) { - const port = overrides.port ?? freshPort() - const config = { - ...baseConfig, - oauthClientId: overrides.oauthClientId ?? '', - oauthClientSecret: overrides.oauthClientSecret ?? '', - oauthIssuer: overrides.oauthIssuer ?? '', - oauthRequiredScopes: - overrides.oauthRequiredScopes ?? (['packages:list'] as const), - port, - trustProxy: overrides.trustProxy ?? false, - } - await runHttpTransport(config) - return { port } -} - -// Track all servers we've started so we can close them via the -// process-level handles map. Node's `http.Server.close()` requires a -// reference; we don't have one. Instead, each test uses a unique port -// and lets the test runner exit clean up. -// -// To avoid port exhaustion across tests, set a low concurrency for -// vitest if this file flakes. - -beforeEach(() => { - vi.clearAllMocks() - // Default SDK setup so tools/call paths don't blow up if exercised. - mockSetupSdk.mockResolvedValue({ - ok: true, - data: { batchPackageFetch: mockBatchPackageFetch }, - }) - mockBatchPackageFetch.mockResolvedValue({ - success: true, - status: 200, - data: [], - }) -}) - -afterEach(() => { - // Drain any pending logger calls. - vi.clearAllMocks() -}) - -describe('runHttpTransport — request URL parsing', () => { - it('returns 400 + JSON-RPC error for an unparseable request URL', async () => { - const { port } = await startServer() - // `//` parses to throw on `new URL('//', 'http://localhost:N')`. - // httpRequest can't send `//` directly (it normalizes), so use raw - // TCP to bypass the client-side normalization. - const net = require('node:net') as typeof NetModule - const body = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `GET // HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: close\r\n\r\n`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - expect(body).toContain('400') - expect(body).toContain('Bad Request: Invalid URL') - }) -}) - -describe('runHttpTransport — health endpoint', () => { - it('GET /health returns 200 with service info even from a foreign origin', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/health`, { - headers: { origin: 'https://evil.example' }, - }) - expect(res.status).toBe(200) - const body = JSON.parse(res.text()) - expect(body.status).toBe('healthy') - expect(body.service).toBe('socket-mcp') - expect(body.version).toBe('0.0.1') - expect(body.timestamp).toBeTypeOf('string') - }) -}) - -describe('runHttpTransport — origin / host validation', () => { - it('rejects an unknown origin with 403 + JSON-RPC error', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: 'https://attacker.example' }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(403) - const body = JSON.parse(res.text()) - expect(body.error.code).toBe(-32000) - expect(body.error.message).toContain('Forbidden: Invalid origin') - }) - - it('accepts a localhost origin', async () => { - const { port } = await startServer() - // POST without sessionId or initialize — should reach the body - // handler and return 400, NOT 403. - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - 'content-type': 'application/json', - origin: `http://localhost:${port}`, - }, - method: 'POST', - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }) - expect(res.status).toBe(400) - }) - - it('accepts the production mcp.socket.dev origin', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - 'content-type': 'application/json', - origin: 'https://mcp.socket.dev', - }, - method: 'POST', - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }) - expect(res.status).toBe(400) - }) - - it('accepts requests without an Origin when Host is localhost', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }) - expect(res.status).toBe(400) - }) - - it('accepts bare localhost (no port) in Host header', async () => { - const { port } = await startServer() - // Use raw TCP so we can set Host without auto-appending the port. - const net = require('node:net') as typeof NetModule - const response = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `GET /health HTTP/1.1\r\n` + - `Host: localhost\r\n` + - `Connection: close\r\n\r\n`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - // /health bypasses Origin validation but the test confirms the - // bare-localhost host parsing branch in the HTTP server. - expect(response).toContain('200') - }) - - it('accepts bare 127.0.0.1 in Host header', async () => { - const { port } = await startServer() - const net = require('node:net') as typeof NetModule - const response = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `POST / HTTP/1.1\r\n` + - `Host: 127.0.0.1\r\n` + - `Content-Type: application/json\r\n` + - `Accept: application/json, text/event-stream\r\n` + - `Content-Length: 2\r\n` + - `Connection: close\r\n\r\n{}`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - // Host is bare 127.0.0.1 (no port), no Origin → falls through to - // arm3 of the host check, which accepts. Then POST without - // sessionId → 400. - expect(response).toContain('400') - }) - - it('accepts mcp.socket.dev as a Host (not just Origin)', async () => { - const { port } = await startServer() - const net = require('node:net') as typeof NetModule - const response = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `POST / HTTP/1.1\r\n` + - `Host: mcp.socket.dev\r\n` + - `Content-Type: application/json\r\n` + - `Accept: application/json, text/event-stream\r\n` + - `Content-Length: 2\r\n` + - `Connection: close\r\n\r\n{}`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - expect(response).toContain('400') - }) - - it('rejects a request with no Origin and a non-allowlist Host (logs "missing")', async () => { - const { port } = await startServer() - // Send via raw TCP with a Host that's none of the allowed values - // and no Origin header. - const net = require('node:net') as typeof NetModule - const response = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `POST / HTTP/1.1\r\n` + - `Host: evil.example.com\r\n` + - `Content-Length: 2\r\n` + - `Connection: close\r\n\r\n{}`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - expect(response).toContain('403') - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Rejected request from invalid origin: missing'), - ) - }) - - it('rejects a spoofed localhost subdomain', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: 'http://malicious-localhost.evil.com' }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(403) - }) - - it('sets CORS Access-Control-Allow-Origin when Origin is present', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), - }) - expect(res.headers['access-control-allow-origin']).toBe( - `http://localhost:${port}`, - ) - expect(res.headers['access-control-allow-methods']).toContain('POST') - expect(res.headers['access-control-expose-headers']).toContain( - 'Mcp-Session-Id', - ) - }) - - it('OPTIONS preflight returns 200 with no body', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: `http://localhost:${port}` }, - method: 'OPTIONS', - }) - expect(res.status).toBe(200) - expect(res.text()).toBe('') - }) -}) - -describe('runHttpTransport — trust-proxy', () => { - it('honors X-Forwarded-Proto/Host when trustProxy=true', async () => { - const { port } = await startServer({ trustProxy: true }) - // Forwarded host = mcp.socket.dev (an allowed host). Origin - // omitted, so the validator falls back to host check, which uses - // the Host header (not X-Forwarded-Host). The forwarded headers - // are observed by getRequestBaseUrl, which runs on the - // /.well-known/oauth-protected-resource path. To exercise that - // helper, use a path that triggers it — but OAuth is disabled, - // so it returns 404. Easier: just verify the request goes through - // when X-Forwarded-Proto says https and Host is localhost. - const res = await httpRequest(`http://127.0.0.1:${port}/health`, { - headers: { - 'x-forwarded-proto': 'https', - 'x-forwarded-host': `localhost:${port}`, - host: `localhost:${port}`, - }, - }) - expect(res.status).toBe(200) - }) -}) - -describe('runHttpTransport — Accept header patching', () => { - it('patches Accept when missing application/json + text/event-stream (POST init)', async () => { - const { port } = await startServer() - // Send POST with only `application/json` in Accept; the SDK would - // 406 without the patch. The init succeeds → session created → - // patch worked. - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - expect(res.status).toBe(200) - expect(res.headers['mcp-session-id']).toBeTypeOf('string') - }) - - it('patches Accept when header is missing entirely', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - // Use raw TCP so httpRequest doesn't auto-add Accept. - const net = require('node:net') as typeof NetModule - const body = JSON.stringify(initBody) - const response = await new Promise<string>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `POST / HTTP/1.1\r\n` + - `Host: localhost:${port}\r\n` + - `Content-Type: application/json\r\n` + - `Origin: http://localhost:${port}\r\n` + - `Content-Length: ${Buffer.byteLength(body)}\r\n` + - `Connection: close\r\n\r\n${body}`, - ) - }) - const chunks: Buffer[] = [] - socket.on('data', c => chunks.push(c)) - socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - socket.on('error', reject) - }) - expect(response).toContain('200') - expect(response.toLowerCase()).toContain('mcp-session-id') - }) -}) - -describe('runHttpTransport — session reuse', () => { - it('handles initialize with a stale Mcp-Session-Id header (creates new session)', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - // Pass a non-existent session ID with an initialize body. The - // server should ignore the stale ID, create a fresh session, and - // reach the `if (sessionId) { sessions.get(...) }` lookup-miss - // branch on lines 297-302. - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - 'mcp-session-id': 'stale-session-that-doesnt-exist', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - expect(res.status).toBe(200) - expect(res.headers['mcp-session-id']).toBeTypeOf('string') - expect(res.headers['mcp-session-id']).not.toBe( - 'stale-session-that-doesnt-exist', - ) - }) - - it('routes follow-up POST to the same session via Mcp-Session-Id', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - const init = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - expect(init.status).toBe(200) - const sessionId = init.headers['mcp-session-id'] as string - expect(sessionId).toBeTypeOf('string') - - // Follow-up call with the session id should reach the transport. - const followup = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - 'mcp-session-id': sessionId, - }, - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: {}, - }), - }) - // The tools/list call resolves through the SDK transport. We - // accept any 2xx status — exact response shape depends on SDK - // negotiation timing but a 200 means the session was found and - // the request was dispatched. - expect(followup.status).toBe(200) - }) - - it('routes GET / with a valid Mcp-Session-Id', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - const init = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - const sessionId = init.headers['mcp-session-id'] as string - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'mcp-session-id': sessionId, - }, - method: 'GET', - // GET to / streams SSE; allow it to proceed but timeout fast - // so the test doesn't hang waiting for events. - timeout: 1500, - }).catch(e => { - // SSE streams hold the connection open; httpRequest may abort - // with a timeout. As long as we don't hit a 404 status before - // the timeout, the session was found and routed. - return { status: 0, headers: {}, body: Buffer.from(''), text: () => '' } - }) - expect((res as { status: number }).status).not.toBe(404) - }) - - it('routes DELETE / with a valid Mcp-Session-Id', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test', version: '0.0.1' }, - }, - } - const init = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - const sessionId = init.headers['mcp-session-id'] as string - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'mcp-session-id': sessionId, - }, - method: 'DELETE', - }) - // DELETE with a valid session: 200 (session closed) is the - // expected response, but the SDK's session.close behavior may - // surface as different statuses. Just assert it's not 404. - expect(res.status).not.toBe(404) - }) -}) - -describe('runHttpTransport — POST body parsing errors', () => { - it('returns 500 on malformed JSON body', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{not valid json', - }) - expect(res.status).toBe(500) - const body = JSON.parse(res.text()) - expect(body.error.code).toBe(-32603) - }) -}) - -describe('runHttpTransport — routing', () => { - it('returns 404 for unknown paths', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/something-else`, { - headers: { origin: `http://localhost:${port}` }, - }) - expect(res.status).toBe(404) - }) - - it('returns 405 for unsupported methods on /', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: `http://localhost:${port}` }, - method: 'PATCH', - }) - expect(res.status).toBe(405) - }) - - it('GET / without sessionId returns 404', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: `http://localhost:${port}` }, - method: 'GET', - }) - expect(res.status).toBe(404) - const body = JSON.parse(res.text()) - expect(body.error.message).toContain('Invalid or expired session') - }) - - it('DELETE / without sessionId returns 404', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { origin: `http://localhost:${port}` }, - method: 'DELETE', - }) - expect(res.status).toBe(404) - }) - - it('POST / without sessionId and without initialize returns 400', async () => { - const { port } = await startServer() - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }), - }) - expect(res.status).toBe(400) - const body = JSON.parse(res.text()) - expect(body.error.message).toContain('No valid session') - }) - - it('logs "unknown" client name/version when clientInfo fields are empty', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - // Empty strings → both `?.name || 'unknown'` and `?.version - // || 'unknown'` short-circuit to the right-hand fallback. - clientInfo: { name: '', version: '' }, - }, - } - await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Client connected: unknown vunknown'), - ) - }) - - it('logs the host when origin is absent on initialize', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'noorigin', version: '1.0.0' }, - }, - } - // Use raw TCP to omit Origin entirely. - const net = require('node:net') as typeof NetModule - const body = JSON.stringify(initBody) - await new Promise<void>((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1', () => { - socket.write( - `POST / HTTP/1.1\r\n` + - `Host: localhost:${port}\r\n` + - `Content-Type: application/json\r\n` + - `Accept: application/json, text/event-stream\r\n` + - `Content-Length: ${Buffer.byteLength(body)}\r\n` + - `Connection: close\r\n\r\n${body}`, - ) - }) - socket.on('data', () => {}) - socket.on('end', () => resolve()) - socket.on('error', reject) - }) - // Origin is empty → logs use the Host instead. - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining(`from localhost:${port}`), - ) - }) - - it('POST / initialize creates a session and returns Mcp-Session-Id', async () => { - const { port } = await startServer() - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test-client', version: '0.0.1' }, - }, - } - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - expect(res.status).toBe(200) - expect(res.headers['mcp-session-id']).toBeTypeOf('string') - expect((res.headers['mcp-session-id'] as string).length).toBeGreaterThan(0) - }) -}) - -describe('runHttpTransport — OAuth startup failure', () => { - it('throws when the OAuth issuer is unreachable on startup', async () => { - // Point the issuer at a port that's not listening — loadMetadata - // fails and runHttpTransport rethrows after logging. - const port = freshPort() - await expect( - startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: 'http://127.0.0.1:1', // port 1 is reserved/closed - port, - }), - ).rejects.toThrow() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to initialize OAuth metadata:'), - ) - }) -}) - -describe('runHttpTransport — OAuth disabled', () => { - it('serves /.well-known/oauth-protected-resource as 404 when OAuth is off', async () => { - const { port } = await startServer() - const res = await httpRequest( - `http://127.0.0.1:${port}/.well-known/oauth-protected-resource`, - { headers: { origin: `http://localhost:${port}` } }, - ) - // OAuth not enabled → falls through to the unknown-path 404. - expect(res.status).toBe(404) - }) -}) - -describe('runHttpTransport — OAuth enabled', () => { - // Stand up a tiny in-memory OAuth issuer on a per-test ephemeral - // port so each scenario gets a fresh server (port collisions across - // tests caused ECONNRESET when we shared one). - - let nextIssuerPort = 23800 - function freshIssuerPort(): number { - return nextIssuerPort++ - } - - async function mockIssuerServer(opts: { - introspectionResponse: - | Record<string, unknown> - | (() => Record<string, unknown>) - introspectionStatus?: number | undefined - }): Promise<{ url: string; close: () => Promise<void> }> { - const { createServer } = require('node:http') as typeof HttpModule - const issuerPort = freshIssuerPort() - const server = createServer((req, res) => { - if (req.url === '/.well-known/oauth-authorization-server') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - issuer: `http://127.0.0.1:${issuerPort}`, - authorization_endpoint: `http://127.0.0.1:${issuerPort}/authorize`, - token_endpoint: `http://127.0.0.1:${issuerPort}/token`, - introspection_endpoint: `http://127.0.0.1:${issuerPort}/introspect`, - }), - ) - return - } - if (req.url === '/introspect') { - res.writeHead(opts.introspectionStatus ?? 200, { - 'Content-Type': 'application/json', - }) - const body = - typeof opts.introspectionResponse === 'function' - ? opts.introspectionResponse() - : opts.introspectionResponse - res.end(JSON.stringify(body)) - return - } - res.writeHead(404) - res.end() - }) - await new Promise<void>(resolve => { - server.listen(issuerPort, '127.0.0.1', () => resolve()) - }) - return { - url: `http://127.0.0.1:${issuerPort}`, - close: () => - new Promise<void>(resolve => { - server.close(() => resolve()) - }), - } - } - - it('returns 401 with WWW-Authenticate when Authorization header is missing', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { active: true, scope: 'packages:list' }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(401) - expect(res.headers['www-authenticate']).toContain( - 'error="invalid_request"', - ) - } finally { - await issuer.close() - } - }) - - it('returns 401 when Authorization header is not a Bearer token', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { active: true, scope: 'packages:list' }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Basic abc', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(401) - expect(res.headers['www-authenticate']).toContain('Bearer TOKEN') - } finally { - await issuer.close() - } - }) - - it('returns 401 invalid_token when introspection says active=false', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { active: false }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Bearer some-token', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(401) - expect(res.headers['www-authenticate']).toContain('invalid_token') - } finally { - await issuer.close() - } - }) - - it('returns 403 insufficient_scope when token lacks the required scope', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { - active: true, - scope: 'something:else', - client_id: 'user-app', - }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Bearer some-token', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(403) - expect(res.headers['www-authenticate']).toContain('insufficient_scope') - } finally { - await issuer.close() - } - }) - - it('returns 401 when the token has expired (exp in the past)', async () => { - const past = Math.floor(Date.now() / 1000) - 60 - const issuer = await mockIssuerServer({ - introspectionResponse: { - active: true, - scope: 'packages:list', - exp: past, - client_id: 'user', - }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Bearer expired-token', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: '{}', - }) - expect(res.status).toBe(401) - } finally { - await issuer.close() - } - }) - - it('proceeds through the request pipeline on a valid OAuth token', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { - active: true, - client_id: 'user-app', - scope: 'packages:list', - }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const initBody = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'oauth-client', version: '0.0.1' }, - }, - } - const res = await httpRequest(`http://127.0.0.1:${port}/`, { - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Bearer the-good-token', - origin: `http://localhost:${port}`, - 'content-type': 'application/json', - }, - method: 'POST', - body: JSON.stringify(initBody), - }) - // Auth succeeds → request reaches initialize handler → 200 + - // Mcp-Session-Id. - expect(res.status).toBe(200) - expect(res.headers['mcp-session-id']).toBeTypeOf('string') - } finally { - await issuer.close() - } - }) - - it('serves /.well-known/oauth-protected-resource when OAuth is enabled', async () => { - const issuer = await mockIssuerServer({ - introspectionResponse: { active: true, scope: 'packages:list' }, - }) - try { - const { port } = await startServer({ - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthIssuer: issuer.url, - }) - const res = await httpRequest( - `http://127.0.0.1:${port}/.well-known/oauth-protected-resource`, - { headers: { origin: `http://localhost:${port}` } }, - ) - expect(res.status).toBe(200) - const body = JSON.parse(res.text()) - expect(body.authorization_servers).toEqual([issuer.url]) - expect(body.scopes_supported).toEqual(['packages:list']) - expect(body.resource_name).toBe('Socket MCP Server') - } finally { - await issuer.close() - } - }) -}) diff --git a/packages/cli/test/unit/commands/mcp/transport-stdio.test.mts b/packages/cli/test/unit/commands/mcp/transport-stdio.test.mts deleted file mode 100644 index f7c1e6707..000000000 --- a/packages/cli/test/unit/commands/mcp/transport-stdio.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Unit tests for the MCP stdio transport runner. - * - * Tests runStdioTransport(config) — wires a fresh server through the - * StdioServerTransport and connects it. - * - * Test Coverage: - Logger emits the start + ready messages - - * createConfiguredServer is called with the supplied config - - * StdioServerTransport is instantiated - server.connect(transport) is awaited - * (function resolves only after the underlying connect resolves) - Errors from - * connect propagate to the caller. - * - * Related Files: - src/commands/mcp/transport-stdio.mts - Implementation - - * src/commands/mcp/server.mts - Server factory (mocked here) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), - log: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { mockConnect, mockServer, mockCreateConfiguredServer } = vi.hoisted( - () => { - const connect = vi.fn().mockResolvedValue(undefined) - const server = { connect, close: vi.fn().mockResolvedValue(undefined) } - return { - mockConnect: connect, - mockServer: server, - mockCreateConfiguredServer: vi.fn(() => server), - } - }, -) - -vi.mock('../../../../src/commands/mcp/server.mts', () => ({ - createConfiguredServer: mockCreateConfiguredServer, -})) - -const { mockStdioTransportInstance, MockStdioServerTransport } = vi.hoisted( - () => { - const instance = { stdioTag: true } - // vi.fn() is not constructable; use a real class so `new T()` works. - class StdioCtor { - stdioTag = true - constructor() { - StdioCtor.calls.push([]) - // Return the singleton so the test can assert identity. - return instance - } - static calls: unknown[][] = [] - } - return { - mockStdioTransportInstance: instance, - MockStdioServerTransport: StdioCtor, - } - }, -) - -vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ - StdioServerTransport: MockStdioServerTransport, -})) - -const { runStdioTransport } = - await import('../../../../src/commands/mcp/transport-stdio.mts') - -const baseConfig = { - getApiToken: () => 'test_a', - serverName: 'socket', - version: '1.2.3', -} - -beforeEach(() => { - vi.clearAllMocks() - MockStdioServerTransport.calls.length = 0 - mockConnect.mockResolvedValue(undefined) -}) - -describe('runStdioTransport', () => { - it('logs the start message before connecting', async () => { - await runStdioTransport(baseConfig) - expect(mockLogger.info).toHaveBeenNthCalledWith( - 1, - 'Starting Socket MCP server in stdio mode', - ) - }) - - it('builds the server with the supplied config', async () => { - await runStdioTransport(baseConfig) - expect(mockCreateConfiguredServer).toHaveBeenCalledWith(baseConfig) - }) - - it('instantiates StdioServerTransport (no constructor args)', async () => { - await runStdioTransport(baseConfig) - expect(MockStdioServerTransport.calls).toHaveLength(1) - expect(MockStdioServerTransport.calls[0]).toEqual([]) - }) - - it('connects the server to the stdio transport', async () => { - await runStdioTransport(baseConfig) - expect(mockConnect).toHaveBeenCalledTimes(1) - expect(mockConnect.mock.calls[0]![0]).toBe(mockStdioTransportInstance) - }) - - it('logs the ready message after connect resolves', async () => { - let connectResolve!: () => void - mockConnect.mockReturnValueOnce( - new Promise<void>(resolve => { - connectResolve = resolve - }), - ) - const promise = runStdioTransport(baseConfig) - // Before connect resolves, the success log should not have fired. - expect(mockLogger.info).toHaveBeenCalledWith( - 'Starting Socket MCP server in stdio mode', - ) - expect(mockLogger.info).not.toHaveBeenCalledWith( - expect.stringContaining('started successfully'), - ) - connectResolve() - await promise - expect(mockLogger.info).toHaveBeenCalledWith( - 'Socket MCP server version 1.2.3 started successfully (stdio)', - ) - }) - - it('propagates errors from server.connect to the caller', async () => { - mockConnect.mockRejectedValueOnce(new Error('transport boom')) - await expect(runStdioTransport(baseConfig)).rejects.toThrow( - 'transport boom', - ) - }) - - it('uses the version from config in the success message', async () => { - await runStdioTransport({ ...baseConfig, version: '99.0.0' }) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Socket MCP server version 99.0.0 started successfully (stdio)', - ) - }) - - // Quick: the mocked server is unused except as a connect target. - // Ensure we didn't accidentally also call .close() in the happy path. - it('does not close the server during normal startup', async () => { - await runStdioTransport(baseConfig) - expect(mockServer.close).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/npm/cmd-npm.test.mts b/packages/cli/test/unit/commands/npm/cmd-npm.test.mts deleted file mode 100644 index 47c015dce..000000000 --- a/packages/cli/test/unit/commands/npm/cmd-npm.test.mts +++ /dev/null @@ -1,537 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for npm wrapper command. - * - * Tests the command entry point that wraps npm with Socket Firewall security. - * The wrapper intercepts npm commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Dry-run behavior - Flag filtering (Socket CLI vs npm flags) - - * Subprocess spawning and exit handling - Telemetry tracking - Error handling. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { EventEmitter } from 'node:events' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSfw. -const mockSpawnSfw = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfw: mockSpawnSfw, -})) - -// Mock telemetry functions. -const mockTrackSubprocessExit = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessStart = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackSubprocessExit: mockTrackSubprocessExit, - trackSubprocessStart: mockTrackSubprocessStart, -})) - -// Import after mocks. -const { cmdNpm } = await import('../../../../src/commands/npm/cmd-npm.mts') -const { NPM } = await import('@socketsecurity/lib-stable/constants/agents') - -describe('cmd-npm', () => { - interface MockChildProcess extends Partial<EventEmitter> { - pid: number - } - - const mockChildProcess: MockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - const createMockSpawnResult = (exitCode = 0, signal?: string) => { - const result = { - code: signal ? undefined : exitCode, - signal, - success: exitCode === 0 && !signal, - } - const spawnPromise = Promise.resolve(result) - Object.assign(spawnPromise, { process: mockChildProcess }) - return { spawnPromise } - } - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockTrackSubprocessStart.mockResolvedValue(Date.now()) - mockTrackSubprocessExit.mockResolvedValue(undefined) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdNpm.description).toBe('Run npm with Socket Firewall security') - }) - - it('should not be hidden', () => { - expect(cmdNpm.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-npm.mts' } - const context = { parentName: 'socket' } - - describe('help flag', () => { - it('should display help text with --help flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await expect( - cmdNpm.run(['--help'], importMeta, context), - ).rejects.toThrow() - - // Help should exit before spawning. - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - }) - - describe('dry-run behavior', () => { - it('should show dry-run output without executing', async () => { - await cmdNpm.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfw).not.toHaveBeenCalled() - - // Verify dry-run message. Dry-run routes to stderr per the - // stream discipline rule. - const errCalls = mockLogger.error.mock.calls.flat() - const hasDryRunMessage = errCalls.some( - (call: unknown) => - typeof call === 'string' && - call.includes('Would execute npm with Socket security scanning'), - ) - expect(hasDryRunMessage).toBe(true) - }) - - it('should show dry-run output with npm install command', async () => { - await cmdNpm.run( - ['--dry-run', 'install', 'lodash'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfw).not.toHaveBeenCalled() - - // Verify dry-run includes arguments. - const errCalls = mockLogger.error.mock.calls.flat() - const hasArgs = errCalls.some( - (call: unknown) => - typeof call === 'string' && - (call.includes('install') || call.includes('lodash')), - ) - expect(hasArgs).toBe(true) - }) - - it('should filter Socket flags in dry-run output', async () => { - await cmdNpm.run( - ['--dry-run', '--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // Should not spawn. - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - }) - - describe('flag filtering', () => { - it('should filter out --dry-run flag when forwarding to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Verify sfw was called with filtered flags. - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out --config flag when forwarding to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run( - ['--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // --config should be filtered out. - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out multiple Socket CLI flags', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run( - ['--config', '{}', '--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // Both --config and --no-banner should be filtered. - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should preserve npm flags while filtering Socket flags', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run( - ['--config', '{}', 'install', '--save-dev', 'lodash'], - importMeta, - context, - ) - - // npm's --save-dev should be preserved. - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', '--save-dev', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should handle --no-banner flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run( - ['--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // --no-banner should be filtered. - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('command structure', () => { - it('should forward npm install command to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward npm install with version specifier', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash@4.17.21'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash@4.17.21'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward npm install with global flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', '-g', 'cowsay'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', '-g', 'cowsay'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward npm exec command', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['exec', 'cowsay', 'hello'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'exec', 'cowsay', 'hello'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward npm update command', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['update', 'lodash'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npm', 'update', 'lodash'], { - stdio: 'inherit', - }) - }) - - it('should forward npm with no arguments', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run([], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npm'], { - stdio: 'inherit', - }) - }) - - it('should forward npm install with multiple packages', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run( - ['install', 'lodash', 'express', 'react'], - importMeta, - context, - ) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash', 'express', 'react'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('exit handling', () => { - it('should set initial exitCode to 1', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Should set exitCode to 1 initially (before subprocess completes). - expect(process.exitCode).toBe(1) - }) - - it('should register exit event handler on child process', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Should register 'exit' event handler. - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - - it('should use stdio inherit for process spawning', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('telemetry tracking', () => { - it('should track subprocess start', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalledWith(NPM) - }) - - it('should track subprocess start before spawning', async () => { - let trackCalled = false - mockTrackSubprocessStart.mockImplementation(async () => { - trackCalled = true - return Date.now() - }) - - mockSpawnSfw.mockImplementation(async () => { - expect(trackCalled).toBe(true) - return createMockSpawnResult(0) - }) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalled() - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - - beforeEach(() => { - // Capture the exit handler when it's registered. - ;(mockChildProcess.on as ReturnType<typeof vi.fn>).mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - NPM, - expect.any(Number), - 42, - ) - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - NPM, - expect.any(Number), - undefined, - ) - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should exit even if telemetry fails', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockRejectedValue(new Error('Telemetry failed')) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(1, undefined) - - // Wait for telemetry promise to reject and catch handler to run. - await new Promise(resolve => setTimeout(resolve, 10)) - - // Should still exit even though telemetry failed. - expect(mockProcessExit).toHaveBeenCalledWith(1) - }) - - it('skips exit/kill when both code and signal are null', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with both null. - exitHandler(undefined, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - - it('should track subprocess exit with code', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - const startTime = 12345 - mockTrackSubprocessStart.mockResolvedValue(startTime) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler. - exitHandler(0, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith(NPM, startTime, 0) - }) - }) - - describe('command name constant', () => { - it('should use NPM constant as command name', async () => { - const { CMD_NAME } = - await import('../../../../src/commands/npm/cmd-npm.mts') - expect(CMD_NAME).toBe(NPM) - expect(CMD_NAME).toBe('npm') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/npx/cmd-npx.test.mts b/packages/cli/test/unit/commands/npx/cmd-npx.test.mts deleted file mode 100644 index 657eb10d3..000000000 --- a/packages/cli/test/unit/commands/npx/cmd-npx.test.mts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * Unit tests for npx wrapper command. - * - * Tests the command entry point that wraps npx with Socket Firewall security. - * The wrapper intercepts npx commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Dry-run behavior - Flag filtering (Socket CLI vs npx flags) - - * Subprocess spawning and exit handling - Telemetry tracking - Error handling. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { EventEmitter } from 'node:events' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSfw. -const mockSpawnSfw = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfw: mockSpawnSfw, -})) - -// Mock telemetry functions. -const mockTrackSubprocessExit = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessStart = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackSubprocessExit: mockTrackSubprocessExit, - trackSubprocessStart: mockTrackSubprocessStart, -})) - -// Import after mocks. -const { cmdNpx } = await import('../../../../src/commands/npx/cmd-npx.mts') -const { NPX } = await import('@socketsecurity/lib-stable/constants/agents') - -describe('cmd-npx', () => { - interface MockChildProcess extends Partial<EventEmitter> { - pid: number - } - - const mockChildProcess: MockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - const createMockSpawnResult = (exitCode = 0, signal?: string) => { - const result = { - code: signal ? undefined : exitCode, - signal, - success: exitCode === 0 && !signal, - } - const spawnPromise = Promise.resolve(result) - Object.assign(spawnPromise, { process: mockChildProcess }) - return { spawnPromise } - } - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockTrackSubprocessStart.mockResolvedValue(Date.now()) - mockTrackSubprocessExit.mockResolvedValue(undefined) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdNpx.description).toBe( - 'Run pnpm exec with Socket Firewall security', - ) - }) - - it('should not be hidden', () => { - expect(cmdNpx.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-npx.mts' } - const context = { parentName: 'socket' } - - describe('help flag', () => { - it('should display help text with --help flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await expect( - cmdNpx.run(['--help'], importMeta, context), - ).rejects.toThrow() - - // Help should exit before spawning. - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - }) - - describe('dry-run behavior', () => { - it('should show dry-run output without executing', async () => { - await cmdNpx.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfw).not.toHaveBeenCalled() - - // Verify dry-run message. - const logCalls = mockLogger.error.mock.calls.flat() - const hasDryRunMessage = logCalls.some( - call => typeof call === 'string' && call.includes('Would execute'), - ) - expect(hasDryRunMessage).toBe(true) - }) - - it('should show dry-run output with pnpm exec command', async () => { - await cmdNpx.run(['--dry-run', 'cowsay', 'hello'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfw).not.toHaveBeenCalled() - - // Verify dry-run includes arguments. - const logCalls = mockLogger.error.mock.calls.flat() - const hasArgs = logCalls.some( - call => - typeof call === 'string' && - (call.includes('cowsay') || call.includes('hello')), - ) - expect(hasArgs).toBe(true) - }) - - it('should filter Socket flags in dry-run output', async () => { - await cmdNpx.run( - ['--dry-run', '--config', '{}', 'cowsay', 'hello'], - importMeta, - context, - ) - - // Should not spawn. - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - }) - - describe('flag filtering', () => { - it('should filter out --dry-run flag when forwarding to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Verify sfw was called with filtered flags. - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - - it('should filter out --config flag when forwarding to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run( - ['--config', '{}', 'cowsay', 'hello'], - importMeta, - context, - ) - - // --config should be filtered out. - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - - it('should filter out multiple Socket CLI flags', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run( - ['--config', '{}', '--no-banner', 'cowsay', 'hello'], - importMeta, - context, - ) - - // Both --config and --no-banner should be filtered. - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - - it('should handle --no-banner flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run( - ['--no-banner', 'cowsay', 'hello'], - importMeta, - context, - ) - - // --no-banner should be filtered. - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - }) - - describe('command structure', () => { - it('should forward pnpm exec command to sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay'], { - stdio: 'inherit', - }) - }) - - it('should forward pnpm exec command with arguments', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello', 'world'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npx', 'cowsay', 'hello', 'world'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm exec with version specifier', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay@1.6.0', 'hello'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npx', 'cowsay@1.6.0', 'hello'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm exec with --yes flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['--yes', 'cowsay', 'hello'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npx', '--yes', 'cowsay', 'hello'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm exec with --package flag', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run( - ['--package=cowsay', 'cowsay', 'hello'], - importMeta, - context, - ) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npx', '--package=cowsay', 'cowsay', 'hello'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('exit handling', () => { - it('should set initial exitCode to 1', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Should set exitCode to 1 initially (before subprocess completes). - expect(process.exitCode).toBe(1) - }) - - it('should register exit event handler on child process', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Should register 'exit' event handler. - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - - it('should use stdio inherit for process spawning', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - }) - - describe('telemetry tracking', () => { - it('should track subprocess start', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalledWith(NPX) - }) - - it('should track subprocess start before spawning', async () => { - let trackCalled = false - mockTrackSubprocessStart.mockImplementation(async () => { - trackCalled = true - return Date.now() - }) - - mockSpawnSfw.mockImplementation(async () => { - expect(trackCalled).toBe(true) - return createMockSpawnResult(0) - }) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalled() - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - - beforeEach(() => { - // Capture the exit handler when it's registered. - ;(mockChildProcess.on as ReturnType<typeof vi.fn>).mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - NPX, - expect.any(Number), - 42, - ) - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - NPX, - expect.any(Number), - undefined, - ) - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should exit even if telemetry fails', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockRejectedValue(new Error('Telemetry failed')) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(1, undefined) - - // Wait for telemetry promise to reject and catch handler to run. - await new Promise(resolve => setTimeout(resolve, 10)) - - // Should still exit even though telemetry failed. - expect(mockProcessExit).toHaveBeenCalledWith(1) - }) - - it('skips exit/kill when both code and signal are null', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Invoke the exit handler with both null. - exitHandler(undefined, undefined) - - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - - it('should track subprocess exit with code', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - const startTime = 12345 - mockTrackSubprocessStart.mockResolvedValue(startTime) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdNpx.run(['cowsay', 'hello'], importMeta, context) - - // Invoke the exit handler. - exitHandler(0, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith(NPX, startTime, 0) - }) - }) - - describe('command name constant', () => { - it('should use NPX constant as command name', async () => { - expect(NPX).toBe('npx') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/nuget/cmd-nuget.test.mts b/packages/cli/test/unit/commands/nuget/cmd-nuget.test.mts deleted file mode 100644 index 9f677bb7d..000000000 --- a/packages/cli/test/unit/commands/nuget/cmd-nuget.test.mts +++ /dev/null @@ -1,477 +0,0 @@ -/** - * Unit Tests: Socket NuGet Command. - * - * Purpose: Tests the nuget wrapper command that forwards nuget operations to - * Socket Firewall (sfw). Validates argument forwarding, flag filtering, exit - * code handling, and signal propagation. - * - * Test Coverage: - Command metadata (description, hidden status) - Argument - * forwarding to sfw via spawnSfwDlx - Socket CLI flag filtering (removes - * --config, --org, etc.) - Exit code defaults and handling - Signal propagation - * from child process - Integration with meowOrExit for --help handling. - * - * Testing Approach: Mocks spawnSfwDlx to simulate child process behavior - * without actual execution. Uses EventEmitter to simulate process exit events - * and signal handling. Validates that Socket CLI flags are filtered out before - * forwarding to sfw. - * - * Related Files: - src/commands/nuget/cmd-nuget.mts - NuGet wrapper command - * implementation - src/util/dlx/spawn.mts - DLX spawn utilities - - * src/util/process/cmd.mts - Flag filtering utilities. - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -const { cmdNuget } = - await import('../../../../src/commands/nuget/cmd-nuget.mts') - -describe('cmd-nuget', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdNuget.description).toBe( - 'Run nuget with Socket Firewall security', - ) - }) - - it('should not be hidden', () => { - expect(cmdNuget.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdNuget.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation(args => { - const helpText = args.config.help('socket nuget') - expect(helpText).toContain('socket nuget') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - mockSpawnPromise.process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdNuget.run( - [], - { url: import.meta.url }, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'Newtonsoft.Json']) - - const runPromise = cmdNuget.run( - ['install', 'Newtonsoft.Json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['install', 'Newtonsoft.Json'], - config: expect.objectContaining({ - commandName: 'nuget', - description: 'Run nuget with Socket Firewall security', - hidden: false, - }), - importMeta: { url: import.meta.url }, - parentName: 'socket', - }) - }) - - it('should forward filtered arguments to spawnSfwDlx', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['restore']) - - const runPromise = cmdNuget.run( - ['restore', '--config', 'socket.config.json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['nuget', 'restore'], { - stdio: 'inherit', - }) - }) - - it('should filter out Socket CLI flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - const filteredArgs = ['list'] - mockFilterFlags.mockReturnValue(filteredArgs) - - const runPromise = cmdNuget.run( - ['list', '--org', 'my-org'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['nuget', ...filteredArgs], { - stdio: 'inherit', - }) - }) - - it('skips exit/kill when both code and signal are null', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - mockExit.mockClear() - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - mockKill.mockClear() - - cmdNuget.run( - [], - { url: import.meta.url } as unknown, - { - parentName: 'socket', - } as unknown, - ) - - await new Promise(resolve => setImmediate(resolve)) - const exitBefore = mockExit.mock.calls.length - const killBefore = mockKill.mock.calls.length - - mockChildProcess.emit('exit', undefined, undefined) - - await new Promise(resolve => setImmediate(resolve)) - - expect(mockExit.mock.calls.length).toBe(exitBefore) - expect(mockKill.mock.calls.length).toBe(killBefore) - - mockExit.mockRestore() - mockKill.mockRestore() - }) - - it('should set default exit code to 1', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'Newtonsoft.Json']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdNuget.run( - ['install', 'Newtonsoft.Json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'Newtonsoft.Json']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdNuget.run( - ['install', 'Newtonsoft.Json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['install', 'Newtonsoft.Json']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdNuget.run( - ['install', 'Newtonsoft.Json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdNuget.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['nuget'], { - stdio: 'inherit', - }) - - mockExit.mockRestore() - }) - - it('should handle context with parentName', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['help']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdNuget.run(['help'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket', - }), - ) - - mockExit.mockRestore() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/oops/cmd-oops.test.mts b/packages/cli/test/unit/commands/oops/cmd-oops.test.mts deleted file mode 100644 index e3202e870..000000000 --- a/packages/cli/test/unit/commands/oops/cmd-oops.test.mts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Unit tests for oops command. - * - * Tests the command that triggers intentional errors for development/testing. - * - * Test Coverage: - Command metadata (description, hidden flag) - --dry-run flag - * support - Error throwing in default mode - JSON error output format - - * Markdown error output format - --throw flag to force error throwing even with - * output flags - Exit code behavior. - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock serializeResultJson to verify JSON output format - - * Mock failMsgWithBadge to verify markdown output format - Verify error - * throwing and exit codes. - * - * Related Files: - src/commands/oops/cmd-oops.mts - Implementation. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock output utilities. -const mockSerializeResultJson = vi.hoisted(() => - vi.fn(data => JSON.stringify(data)), -) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, -})) - -const mockFailMsgWithBadge = vi.hoisted(() => - vi.fn((title, message) => `${title}: ${message}`), -) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, -})) - -// Mock meowOrExit to prevent actual CLI parsing. Also invoke the -// help() callback so its template-string body is recorded as covered; -// production meowOrExit only invokes it on --help, which the test -// suite never exercises. cmd-oops's help signature is -// (parentName, config) so we pass a fake config too. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = {} - - if (options.config?.help) { - try { - options.config.help('socket', { - commandName: 'oops', - flags: {}, - }) - } catch {} - } - - // Parse flags from argv. - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - if (argv.includes('--json')) { - flags['json'] = true - } - if (argv.includes('--markdown')) { - flags['markdown'] = true - } - if (argv.includes('--throw')) { - flags['throw'] = true - } - - return { - flags, - help: '', - input: [], - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdOops } = - await import('../../../../src/commands/oops/cmd-oops.mts') - -describe('cmd-oops', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should export CMD_NAME as oops', () => { - expect(CMD_NAME).toBe('oops') - }) - - it('should have correct description', () => { - expect(cmdOops.description).toBe( - 'Trigger an intentional error (for development)', - ) - }) - - it('should be hidden', () => { - expect(cmdOops.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-oops.mts' } - const context = { parentName: 'socket' } - - describe('--dry-run flag', () => { - it('should show preview without throwing error', async () => { - await cmdOops.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith('') - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Would trigger an intentional error'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining( - 'This command throws an error for development/testing purposes.', - ), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('This error was intentionally left blank'), - ) - }) - - it('should indicate thrown error format in dry-run', async () => { - await cmdOops.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Output format: Thrown Error exception'), - ) - }) - - it('should indicate JSON format in dry-run with --json', async () => { - await cmdOops.run(['--dry-run', '--json'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Output format: JSON error response'), - ) - }) - - it('should indicate markdown format in dry-run with --markdown', async () => { - await cmdOops.run(['--dry-run', '--markdown'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Output format: Markdown error message'), - ) - }) - - it('should indicate thrown format when --throw flag present', async () => { - await cmdOops.run( - ['--dry-run', '--json', '--throw'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Output format: Thrown Error exception'), - ) - }) - - it('should show run instruction in dry-run', async () => { - await cmdOops.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Run without --dry-run to trigger the error'), - ) - }) - }) - - describe('default error behavior', () => { - it('should throw error when no output flags provided', async () => { - await expect(cmdOops.run([], importMeta, context)).rejects.toThrow( - 'This error was intentionally left blank.', - ) - }) - - it('should throw error with exact message', async () => { - try { - await cmdOops.run([], importMeta, context) - expect.fail('Should have thrown an error') - } catch (e: unknown) { - expect(e).toBeInstanceOf(Error) - if (e instanceof Error) { - expect(e.message).toBe('This error was intentionally left blank.') - } - } - }) - }) - - describe('--json flag', () => { - it('should output JSON error and still throw', async () => { - await expect( - cmdOops.run(['--json'], importMeta, context), - ).rejects.toThrow('This error was intentionally left blank.') - - expect(mockSerializeResultJson).toHaveBeenCalledWith({ - ok: false, - message: 'Oops', - cause: 'This error was intentionally left blank', - }) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('ok'), - ) - }) - - it('should set exit code to 1 with --json before throwing', async () => { - try { - await cmdOops.run(['--json'], importMeta, context) - } catch { - // Expected to throw - } - - expect(process.exitCode).toBe(1) - }) - - it('should throw error even with --json', async () => { - await expect( - cmdOops.run(['--json'], importMeta, context), - ).rejects.toThrow('This error was intentionally left blank.') - }) - }) - - describe('--markdown flag', () => { - it('should output markdown error without throwing', async () => { - await cmdOops.run(['--markdown'], importMeta, context) - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Oops', - 'This error was intentionally left blank', - ) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('should set exit code to 1 with --markdown', async () => { - await cmdOops.run(['--markdown'], importMeta, context) - - expect(process.exitCode).toBe(1) - }) - - it('should not throw error with --markdown', async () => { - await expect( - cmdOops.run(['--markdown'], importMeta, context), - ).resolves.not.toThrow() - }) - }) - - describe('--throw flag', () => { - it('should throw error even with --json when --throw is provided', async () => { - await expect( - cmdOops.run(['--json', '--throw'], importMeta, context), - ).rejects.toThrow('This error was intentionally left blank.') - }) - - it('should throw error even with --markdown when --throw is provided', async () => { - await expect( - cmdOops.run(['--markdown', '--throw'], importMeta, context), - ).rejects.toThrow('This error was intentionally left blank.') - }) - - it('should not output JSON when --throw overrides --json', async () => { - try { - await cmdOops.run(['--json', '--throw'], importMeta, context) - expect.fail('Should have thrown an error') - } catch { - expect(mockSerializeResultJson).not.toHaveBeenCalled() - } - }) - - it('should not output markdown when --throw overrides --markdown', async () => { - try { - await cmdOops.run(['--markdown', '--throw'], importMeta, context) - expect.fail('Should have thrown an error') - } catch { - expect(mockFailMsgWithBadge).not.toHaveBeenCalled() - } - }) - }) - - describe('flag combinations', () => { - it('should handle --json and --markdown together (outputs JSON then returns)', async () => { - await cmdOops.run(['--json', '--markdown'], importMeta, context) - - expect(mockSerializeResultJson).toHaveBeenCalledWith({ - ok: false, - message: 'Oops', - cause: 'This error was intentionally left blank', - }) - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Oops', - 'This error was intentionally left blank', - ) - }) - - it('should handle all flags together', async () => { - await expect( - cmdOops.run(['--json', '--markdown', '--throw'], importMeta, context), - ).rejects.toThrow('This error was intentionally left blank.') - }) - }) - - describe('edge cases', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['--markdown']) as readonly string[] - - await cmdOops.run(readonlyArgv, importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('should handle empty flags object', async () => { - await expect(cmdOops.run([], importMeta, context)).rejects.toThrow( - 'This error was intentionally left blank.', - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/add-overrides-body.test.mts b/packages/cli/test/unit/commands/optimize/add-overrides-body.test.mts deleted file mode 100644 index 57c2f4d6d..000000000 --- a/packages/cli/test/unit/commands/optimize/add-overrides-body.test.mts +++ /dev/null @@ -1,400 +0,0 @@ -/** - * Unit tests for the body of addOverrides (the pEach loop over manifest - * entries). - * - * `manifestNpmOverrides = getManifestData(NPM) ?? []` is module-init, so to - * exercise the loop body we use `vi.resetModules()` + `vi.doMock()` per test to - * control what `getManifestData` returns at module-load time. - * - * Related Files: - src/commands/optimize/add-overrides.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as PackagesModule from '@socketsecurity/lib-stable/packages/manifest' - -const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} - -const baseEnv = (overrides: Record<string, unknown> = {}) => ({ - agent: 'npm', - agentVersion: '10.0.0', - lockName: 'package-lock.json', - lockSrc: '', - npmExecPath: '/usr/bin/npm', - pkgPath: '/test/project', - editablePkgJson: { - content: { name: 'test', dependencies: {} }, - update: vi.fn(), - save: vi.fn(), - }, - ...overrides, -}) - -async function loadAddOverrides(opts: { - manifestEntries: Array<[string, unknown]> - getMajor?: (v: string) => number | undefined - safeNpa?: ((s: string) => unknown) | undefined - fetchPackageManifest?: ((s: string) => Promise<unknown>) | undefined - globWorkspace?: (() => Promise<string[]>) | undefined - getDependencyEntries?: ((env: unknown) => unknown) | undefined - getOverridesData?: ((env: unknown) => unknown) | undefined - getOverridesDataNpm?: ((env: unknown) => unknown) | undefined - getOverridesDataYarnClassic?: ((env: unknown) => unknown) | undefined - lockSrcIncludes?: ((...args: unknown[]) => boolean) | undefined - lsStdoutIncludes?: ((...args: unknown[]) => boolean) | undefined - listPackages?: ((...args: unknown[]) => Promise<string>) | undefined -}) { - const { manifestEntries } = opts - vi.doMock('@socketsecurity/registry-stable', () => ({ - getManifestData: (agent?: string) => - agent === 'npm' ? manifestEntries : [], - })) - vi.doMock('@socketsecurity/lib-stable/promises/iterate', () => ({ - pEach: async (items: unknown[], fn: unknown) => { - for (let i = 0, { length } = items; i < length; i += 1) { - const item = items[i] - await fn(item) - } - }, - })) - vi.doMock('../../../../src/util/fs/glob.mts', () => ({ - globWorkspace: opts.globWorkspace ?? vi.fn(async () => []), - isReportSupportedFile: vi.fn(), - })) - vi.doMock( - '../../../../src/commands/optimize/get-dependency-entries.mts', - () => ({ - getDependencyEntries: opts.getDependencyEntries ?? vi.fn(() => []), - }), - ) - vi.doMock( - '../../../../src/commands/optimize/get-overrides-by-agent.mts', - () => ({ - getOverridesData: - opts.getOverridesData ?? vi.fn(() => ({ overrides: {}, type: 'npm' })), - getOverridesDataNpm: - opts.getOverridesDataNpm ?? - vi.fn(() => ({ overrides: {}, type: 'npm' })), - getOverridesDataYarnClassic: - opts.getOverridesDataYarnClassic ?? - vi.fn(() => ({ overrides: {}, type: 'yarn' })), - }), - ) - vi.doMock( - '../../../../src/commands/optimize/lockfile-includes-by-agent.mts', - () => ({ - lockSrcIncludes: opts.lockSrcIncludes ?? vi.fn(() => false), - }), - ) - vi.doMock( - '../../../../src/commands/optimize/deps-includes-by-agent.mts', - () => ({ - lsStdoutIncludes: opts.lsStdoutIncludes ?? vi.fn(() => false), - }), - ) - vi.doMock('../../../../src/commands/optimize/ls-by-agent.mts', () => ({ - listPackages: opts.listPackages ?? vi.fn(async () => ''), - })) - vi.doMock( - '../../../../src/commands/optimize/update-manifest-by-agent.mts', - () => ({ - updateManifest: vi.fn(), - }), - ) - vi.doMock('../../../../src/util/npm/package-arg.mts', () => ({ - safeNpa: opts.safeNpa ?? vi.fn(() => undefined), - })) - vi.doMock('../../../../src/util/process/cmd.mts', () => ({ - cmdPrefixMessage: (name: string, msg: string) => `[${name}] ${msg}`, - })) - vi.doMock('../../../../src/util/semver.mts', () => ({ - getMajor: opts.getMajor ?? vi.fn((v: string) => parseInt(v, 10)), - })) - if (opts.fetchPackageManifest) { - vi.doMock('@socketsecurity/lib-stable/packages/manifest', async importOriginal => { - const actual = await importOriginal<typeof PackagesModule>() - return { - ...actual, - fetchPackageManifest: opts.fetchPackageManifest, - } - }) - } - - const mod = - await import('../../../../src/commands/optimize/add-overrides.mts') - return mod.addOverrides -} - -describe('addOverrides body (manifestNpmOverrides loop)', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - }) - - it('skips entries when getMajor returns undefined (line 122-124)', async () => { - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: 'invalid' }], - ], - getMajor: vi.fn(() => undefined), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - }) - expect(state.added.size).toBe(0) - }) - - it('adds an alias when origPkgName found in deps (line 158-167)', async () => { - const depObj = { 'pkg-a-orig': '^1.0.0' } - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', depObj]]), - getMajor: vi.fn(() => 1), - safeNpa: vi.fn(() => undefined), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - }) - expect(state.added.has('pkg-a')).toBe(true) - expect(depObj['pkg-a-orig']).toMatch(/^npm:pkg-a@/) - }) - - it('keeps existing alias spec when it parses as a valid alias (line 142-156)', async () => { - // origSpec already starts with the sockOverridePrefix and parses correctly, - // so we should NOT replace it. - const depObj = { 'pkg-a-orig': 'npm:pkg-a@1.5.0' } - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', depObj]]), - getMajor: vi.fn(() => 1), - safeNpa: vi.fn(() => ({ - type: 'alias', - subSpec: { rawSpec: '1.5.0' }, - })), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - }) - // The valid alias is preserved. - expect(state.added.has('pkg-a')).toBe(false) - expect(depObj['pkg-a-orig']).toBe('npm:pkg-a@1.5.0') - }) - - it('treats sockSpec match as alias too (line 128-133)', async () => { - // depObj has the sock-registry name, not the orig name. - const depObj = { 'pkg-a': '^1.0.0' } - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', depObj]]), - getMajor: vi.fn(() => 1), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - }) - // Without the orig spec, no add but sockSpec is mapped to alias. - expect(state.added.size).toBe(0) - }) - - it('adds override when origPkgName matches via lsStdoutIncludes (line 187-256)', async () => { - // Use prod=true to take the lsStdoutIncludes path (isLockScanned=false). - const depObj = {} - const overridesObj: Record<string, string> = {} - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', depObj]]), - getMajor: vi.fn(() => 1), - lsStdoutIncludes: vi.fn(() => true), - getOverridesDataNpm: vi.fn(() => ({ - overrides: overridesObj, - type: 'npm', - })), - getOverridesDataYarnClassic: vi.fn(() => ({ - overrides: {}, - type: 'yarn', - })), - listPackages: vi.fn(async () => ''), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - prod: true, - }) - expect(state.added.has('pkg-a')).toBe(true) - expect(overridesObj['pkg-a-orig']).toMatch(/^npm:pkg-a@/) - }) - - it('updates an existing override (line 250 — addedOrUpdated branch)', async () => { - // overrides already has the orig pkg key, so addedOrUpdated='updated'. - const overridesObj: Record<string, string> = { - 'pkg-a-orig': '^0.9.0', // existing, doesn't start with `$` or sockOverridePrefix. - } - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', {}]]), - getMajor: vi.fn(() => 1), - getOverridesDataNpm: vi.fn(() => ({ - overrides: overridesObj, - type: 'npm', - })), - getOverridesDataYarnClassic: vi.fn(() => ({ - overrides: {}, - type: 'yarn', - })), - lsStdoutIncludes: vi.fn(() => true), - listPackages: vi.fn(async () => ''), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - prod: true, - }) - // Since oldSpec='^0.9.0' doesn't start with `$` or sockOverridePrefix, - // newSpec is set to oldSpec (line 245), so newSpec === oldSpec — no update. - expect(state.updated.has('pkg-a')).toBe(false) - }) - - it('uses $-reference newSpec when type=NPM and depAlias exists (line 199-207)', async () => { - // overrides[orig] exists AND deps include orig — so depAlias is set, - // type === NPM triggers the $-reference branch. - const overridesObj: Record<string, string> = { - 'pkg-a-orig': '^0.9.0', - } - const depObj = { 'pkg-a-orig': '^1.0.0' } - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', depObj]]), - getMajor: vi.fn(() => 1), - getOverridesDataNpm: vi.fn(() => ({ - overrides: overridesObj, - type: 'npm', - })), - getOverridesDataYarnClassic: vi.fn(() => ({ - overrides: {}, - type: 'yarn', - })), - lsStdoutIncludes: vi.fn(() => true), - listPackages: vi.fn(async () => ''), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - prod: true, - }) - // depAlias is present, type=NPM → newSpec set to `$pkg-a-orig` - expect(overridesObj['pkg-a-orig']).toMatch(/^\$/) - expect(state.updated.has('pkg-a')).toBe(true) - }) - - it('aggregates state from recursive workspace calls (line 295)', async () => { - // Simulates a workspace setup. Both the outer (root) and inner (workspace - // pkg) calls iterate the same shared deps object that contains pkg-a-orig, - // so both add pkg-a. The inner call returns its state and line 295 merges - // it into the outer state. - const sharedDep = { 'pkg-a-orig': '^1.0.0' } - let callCount = 0 - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - // First call (outer/root): returns workspace pkg path. Second (inner): []. - globWorkspace: vi.fn(async () => { - callCount += 1 - if (callCount === 1) { - return ['/test/project/packages/inner/package.json'] - } - return [] - }), - getDependencyEntries: vi.fn(() => [['dependencies', sharedDep]]), - getMajor: vi.fn(() => 1), - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - }) - // pkg-a was added by the inner workspace call (isWorkspaceRoot=false), so - // it appears in state.addedInWorkspaces — that propagates via line 295. - expect(state.added.has('pkg-a')).toBe(true) - expect(state.addedInWorkspaces.has('packages/inner')).toBe(true) - }) - - it('with pin=true, fetches manifest when getMajor of existing spec mismatches (lines 213-242)', async () => { - // Setup: overrides[orig] = 'npm:pkg-a@1.5.0' (sock-prefix). pin=true forces - // re-validation by parsing/coercing — getMajor of 1.5.0 is 1, but we - // mock it to return 99 so the inner if (getMajor !== major) fires, - // triggering fetchPackageManifest. - const overridesObj: Record<string, string> = { - 'pkg-a-orig': 'npm:pkg-a@1.5.0', - } - let getMajorCall = 0 - const fetchPackageManifest = vi.fn(async () => ({ version: '2.0.0' })) - const addOverrides = await loadAddOverrides({ - manifestEntries: [ - ['pkg-a', { name: 'pkg-a', package: 'pkg-a-orig', version: '1.2.3' }], - ], - getDependencyEntries: vi.fn(() => [['dependencies', {}]]), - // getMajor sequence: - // call 1: outer for the manifest entry version → 1 - // call 2: inner IIFE for thisSpec parsed → 99 (mismatch!) - // call 3: outer otherMajor for fetched version → 2 - getMajor: vi.fn(() => { - getMajorCall += 1 - if (getMajorCall === 1) { - return 1 - } - if (getMajorCall === 2) { - return 99 - } - return 2 - }), - safeNpa: vi.fn(() => ({ - type: 'alias', - subSpec: { rawSpec: '1.5.0' }, - })), - getOverridesDataNpm: vi.fn(() => ({ - overrides: overridesObj, - type: 'npm', - })), - getOverridesDataYarnClassic: vi.fn(() => ({ - overrides: {}, - type: 'yarn', - })), - lsStdoutIncludes: vi.fn(() => true), - listPackages: vi.fn(async () => ''), - fetchPackageManifest, - }) - const env = baseEnv() - const state = await addOverrides(env as unknown, '/test/project', { - logger: mockLogger as unknown, - prod: true, - pin: true, - }) - // fetchPackageManifest was invoked because the major mismatched. - expect(fetchPackageManifest).toHaveBeenCalled() - // newSpec was rewritten to use the fetched version. - expect(overridesObj['pkg-a-orig']).toContain('npm:pkg-a@2.0.0') - expect(state.updated.has('pkg-a')).toBe(true) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/add-overrides.test.mts b/packages/cli/test/unit/commands/optimize/add-overrides.test.mts deleted file mode 100644 index 4be1c42e2..000000000 --- a/packages/cli/test/unit/commands/optimize/add-overrides.test.mts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Unit tests for add-overrides. - * - * Tests the addOverrides function that applies Socket Registry overrides to - * package.json files. Most paths require complex pkgEnvDetails fixtures, so - * this file mocks all collaborators and exercises the orchestration paths. - * - * Related Files: - src/commands/optimize/add-overrides.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockGlobWorkspace = vi.hoisted(() => vi.fn(async () => [])) -vi.mock('../../../../src/util/fs/glob.mts', () => ({ - globWorkspace: mockGlobWorkspace, - isReportSupportedFile: vi.fn(), -})) - -const mockGetDependencyEntries = vi.hoisted(() => vi.fn(() => [])) -vi.mock('../../../../src/commands/optimize/get-dependency-entries.mts', () => ({ - getDependencyEntries: mockGetDependencyEntries, -})) - -const mockGetOverridesData = vi.hoisted(() => - vi.fn(() => ({ overrides: {}, type: 'npm' })), -) -const mockGetOverridesDataNpm = vi.hoisted(() => - vi.fn(() => ({ overrides: {}, type: 'npm' })), -) -const mockGetOverridesDataYarnClassic = vi.hoisted(() => - vi.fn(() => ({ overrides: {}, type: 'yarn' })), -) -vi.mock('../../../../src/commands/optimize/get-overrides-by-agent.mts', () => ({ - getOverridesData: mockGetOverridesData, - getOverridesDataNpm: mockGetOverridesDataNpm, - getOverridesDataYarnClassic: mockGetOverridesDataYarnClassic, -})) - -vi.mock( - '../../../../src/commands/optimize/lockfile-includes-by-agent.mts', - () => ({ - lockSrcIncludes: vi.fn(() => false), - }), -) - -vi.mock('../../../../src/commands/optimize/deps-includes-by-agent.mts', () => ({ - lsStdoutIncludes: vi.fn(() => false), -})) - -vi.mock('../../../../src/commands/optimize/ls-by-agent.mts', () => ({ - listPackages: vi.fn(async () => ''), -})) - -vi.mock( - '../../../../src/commands/optimize/update-manifest-by-agent.mts', - () => ({ - updateManifest: vi.fn(), - }), -) - -const mockSafeNpa = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/npm/package-arg.mts', () => ({ - safeNpa: mockSafeNpa, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - cmdPrefixMessage: (name: string, msg: string) => `[${name}] ${msg}`, -})) - -const mockGetMajor = vi.hoisted(() => vi.fn((v: string) => parseInt(v, 10))) -vi.mock('../../../../src/util/semver.mts', () => ({ - getMajor: mockGetMajor, -})) - -const mockGetManifestData = vi.hoisted(() => vi.fn(() => [])) -vi.mock('@socketsecurity/registry-stable', () => ({ - getManifestData: mockGetManifestData, -})) - -vi.mock('@socketsecurity/lib-stable/promises/iterate', () => ({ - pEach: async (items: unknown[], fn: unknown, opts?: unknown) => { - for (let i = 0, { length } = items; i < length; i += 1) { - const item = items[i] - await fn(item) - } - }, -})) - -import { addOverrides } from '../../../../src/commands/optimize/add-overrides.mts' - -describe('addOverrides', () => { - const mockEnvDetails: unknown = { - agent: 'npm', - agentVersion: '10.0.0', - lockName: 'package-lock.json', - lockSrc: '', - npmExecPath: '/usr/bin/npm', - pkgPath: '/test/project', - editablePkgJson: { - content: { name: 'test', dependencies: {} }, - update: vi.fn(), - save: vi.fn(), - }, - } - - beforeEach(() => { - vi.clearAllMocks() - mockGlobWorkspace.mockResolvedValue([]) - mockGetDependencyEntries.mockReturnValue([ - ['dependencies', mockEnvDetails.editablePkgJson.content.dependencies], - ]) - mockGetOverridesData.mockReturnValue({ overrides: {}, type: 'npm' }) - mockGetOverridesDataNpm.mockReturnValue({ overrides: {}, type: 'npm' }) - mockGetOverridesDataYarnClassic.mockReturnValue({ - overrides: {}, - type: 'yarn', - }) - mockGetManifestData.mockReturnValue([]) - }) - - it('returns initial state when no manifest overrides exist', async () => { - const state = await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - }) - expect(state.added.size).toBe(0) - expect(state.updated.size).toBe(0) - }) - - it('uses provided state instead of creating new state', async () => { - const customState = { - added: new Set(['existing']), - addedInWorkspaces: new Set(), - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - } - const state = await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - state: customState, - }) - expect(state).toBe(customState) - expect(state.added.has('existing')).toBe(true) - }) - - it('uses getOverridesData when private package.json detected', async () => { - const privateEnv = { - ...mockEnvDetails, - editablePkgJson: { - ...mockEnvDetails.editablePkgJson, - content: { - ...mockEnvDetails.editablePkgJson.content, - private: true, - }, - }, - } - await addOverrides(privateEnv, '/test/project', { - logger: mockLogger as unknown, - }) - expect(mockGetOverridesData).toHaveBeenCalledWith(privateEnv) - expect(mockGetOverridesDataNpm).not.toHaveBeenCalled() - }) - - it('uses both getOverridesDataNpm and YarnClassic for non-private non-workspace', async () => { - await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - }) - expect(mockGetOverridesDataNpm).toHaveBeenCalled() - expect(mockGetOverridesDataYarnClassic).toHaveBeenCalled() - }) - - it('warns about pnpm workspace requiring npm when needed', async () => { - mockGlobWorkspace.mockResolvedValueOnce(['/test/project/pkg/package.json']) - const pnpmEnv = { - ...mockEnvDetails, - agent: 'pnpm', - npmExecPath: 'npm', // Cannot resolve npm. - } - const customState = { - added: new Set<string>(), - addedInWorkspaces: new Set<string>(), - updated: new Set<string>(), - updatedInWorkspaces: new Set<string>(), - warnedPnpmWorkspaceRequiresNpm: false, - } - await addOverrides(pnpmEnv, '/test/project', { - logger: mockLogger as unknown, - state: customState, - }) - expect(customState.warnedPnpmWorkspaceRequiresNpm).toBe(true) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('workspace support requires'), - ) - }) - - it('does not re-warn if pnpm warning was already issued', async () => { - mockGlobWorkspace.mockResolvedValueOnce(['/test/project/pkg/package.json']) - const pnpmEnv = { ...mockEnvDetails, agent: 'pnpm', npmExecPath: 'npm' } - const customState = { - added: new Set<string>(), - addedInWorkspaces: new Set<string>(), - updated: new Set<string>(), - updatedInWorkspaces: new Set<string>(), - warnedPnpmWorkspaceRequiresNpm: true, // Already warned. - } - await addOverrides(pnpmEnv, '/test/project', { - logger: mockLogger as unknown, - state: customState, - }) - expect(mockLogger.warn).not.toHaveBeenCalled() - }) - - it('recurses into workspace package.json paths when workspace is detected', async () => { - // Exercises the `if (isWorkspace)` branch + recursive addOverrides call - // (line 266+). With workspacePkgJsonPaths populated, the function recurses - // into each workspace dir. - mockGlobWorkspace.mockResolvedValueOnce([ - '/test/project/packages/a/package.json', - '/test/project/packages/b/package.json', - ]) - // Subsequent calls (recursion) return []. - mockGlobWorkspace.mockResolvedValue([]) - - const customState = { - added: new Set<string>(['outer-pkg']), - addedInWorkspaces: new Set<string>(['ws1']), - updated: new Set<string>(['outer-updated']), - updatedInWorkspaces: new Set<string>(), - warnedPnpmWorkspaceRequiresNpm: false, - } - await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - state: customState, - }) - // globWorkspace called for outer + each workspace package. - expect(mockGlobWorkspace.mock.calls.length).toBeGreaterThanOrEqual(1) - }) - - it('uses non-root pkgPath for relative workspace name', async () => { - // Exercises `path.relative(rootPath, pkgPath)` branch (line 81) - // when pkgPath !== rootPath. - const innerPath = '/test/project/packages/inner' - await addOverrides(mockEnvDetails, innerPath, { - logger: mockLogger as unknown, - }) - // The function returns successfully without errors. - expect(mockGlobWorkspace).toHaveBeenCalled() - }) - - it('with prod=true skips lockfile scanning even at workspace root', async () => { - // Exercises `isLockScanned = isWorkspaceRoot && !prod` (line 80). - await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - prod: true, - }) - expect(mockGetOverridesDataNpm).toHaveBeenCalled() - }) - - it('with pin=true uses pinned version for overrides', async () => { - // Exercises `pin` branch in spec-construction (line 126). - await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - pin: true, - }) - expect(mockGetOverridesDataNpm).toHaveBeenCalled() - }) - - it('with custom spinner forwards spinner to inner methods', async () => { - // Exercises spinner option flow. - const mockSpinner: unknown = { - stop: vi.fn(), - start: vi.fn(), - text: vi.fn(), - } - await addOverrides(mockEnvDetails, '/test/project', { - logger: mockLogger as unknown, - spinner: mockSpinner, - }) - expect(mockGetOverridesDataNpm).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/agent-installer.test.mts b/packages/cli/test/unit/commands/optimize/agent-installer.test.mts deleted file mode 100644 index 7e134598b..000000000 --- a/packages/cli/test/unit/commands/optimize/agent-installer.test.mts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for agent-installer utilities. - * - * Tests the package manager installation logic used by the optimize command. - * These tests use mocked spawn/spinner to verify correct command construction. - * - * Test Coverage: - pnpm: Special flags (--config.confirmModulesPurge=false, - * --no-frozen-lockfile, CI=1) - yarn: Basic install command - Custom args - * pass-through (--frozen-lockfile, --production, etc.) - Spinner integration - * for progress indication - Unknown/future package managers (fallback behavior) - * - Option merging (args, env, stdio) - * - * Npm Behavior (NOT tested here): - npm uses Socket Firewall (sfw) for security - * scanning - This cannot be reliably mocked due to ESM module resolution issues - * - npm behavior is tested via integration tests at: - * test/integration/cli/cmd-optimize.test.mts. - * - * Related Files: - src/commands/optimize/agent-installer.mts - Implementation - - * src/util/dlx/spawn.mts - Socket Firewall (sfw) spawn utilities - - * test/integration/cli/cmd-optimize.test.mts - Integration tests for full - * optimize flow. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { runAgentInstall } from '../../../../src/commands/optimize/agent-installer.mts' - -// Mock dependencies. -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/spinner/spinner', () => ({ - Spinner: vi.fn(() => ({ - start: vi.fn(), - stop: vi.fn(), - })), -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - cmdFlagsToString: vi.fn(flags => - Object.entries(flags || {}) - .map(([k, v]) => `--${k}=${v}`) - .join(' '), - ), -})) - -vi.mock('@socketsecurity/lib-stable/constants/agents', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - NPM: 'npm', - PNPM: 'pnpm', - YARN: 'yarn', - } -}) - -vi.mock('@socketsecurity/lib-stable/constants/node', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - getNodeDisableSigusr1Flags: vi.fn(() => []), - getNodeHardenFlags: vi.fn(() => []), - getNodeNoWarningsFlags: vi.fn(() => []), - } -}) - -vi.mock('@socketsecurity/lib-stable/constants/platform', () => ({ - WIN32: false, -})) - -describe('agent installer utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('runAgentInstall', () => { - // Note: npm agent behavior is covered by integration tests. - // The mock-based test was removed due to ESM module resolution issues that - // prevented proper interception. The 6 tests below cover pnpm/yarn/unknown agents. - - it('uses spawn for npm agent with --no-audit --no-fund', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'npm', - agentExecPath: '/usr/bin/npm', - pkgPath: '/test/project', - agentVersion: { major: 10, minor: 0, patch: 0 }, - } as unknown - - runAgentInstall(pkgEnvDetails) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/npm', - ['install', '--no-audit', '--no-fund'], - expect.objectContaining({ - cwd: '/test/project', - }), - ) - }) - - it('uses spawn for pnpm agent', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'pnpm', - agentExecPath: '/usr/bin/pnpm', - pkgPath: '/test/project', - agentVersion: { major: 8, minor: 0, patch: 0 }, - } as unknown - - runAgentInstall(pkgEnvDetails) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/pnpm', - [ - 'install', - '--config.confirmModulesPurge=false', - '--no-frozen-lockfile', - ], - expect.objectContaining({ - cwd: '/test/project', - env: expect.objectContaining({ - CI: '1', - }), - }), - ) - }) - - it('uses spawn for yarn agent', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'yarn', - agentExecPath: '/usr/bin/yarn', - pkgPath: '/test/project', - } as unknown - - runAgentInstall(pkgEnvDetails) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/yarn', - ['install'], - expect.objectContaining({ - cwd: '/test/project', - }), - ) - }) - - it('passes args to the agent command', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'yarn', - agentExecPath: '/usr/bin/yarn', - pkgPath: '/test/project', - } as unknown - - runAgentInstall(pkgEnvDetails, { - args: ['--frozen-lockfile', '--production'], - }) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/yarn', - ['install', '--frozen-lockfile', '--production'], - expect.any(Object), - ) - }) - - it('uses spinner when provided', async () => { - const { Spinner } = vi.mocked(await import('@socketsecurity/lib-stable/spinner/spinner')) - const mockSpinner = { - start: vi.fn(), - stop: vi.fn(), - } - Spinner.mockReturnValue(mockSpinner as unknown) - - const pkgEnvDetails = { - agent: 'pnpm', - agentExecPath: '/usr/bin/pnpm', - pkgPath: '/test/project', - agentVersion: { major: 8, minor: 0, patch: 0 }, - } as unknown - - runAgentInstall(pkgEnvDetails, { - spinner: mockSpinner as unknown, - }) - - // Spinner would be passed through to spawn. - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/pnpm', - [ - 'install', - '--config.confirmModulesPurge=false', - '--no-frozen-lockfile', - ], - expect.objectContaining({ - spinner: mockSpinner, - }), - ) - }) - - it('handles unknown agent', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'unknown-agent', - agentExecPath: '/usr/bin/unknown-agent', - pkgPath: '/test/project', - } as unknown - - runAgentInstall(pkgEnvDetails) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/unknown-agent', - ['install'], - expect.any(Object), - ) - }) - - it('merges options correctly', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockReturnValue(Promise.resolve({ status: 0 }) as unknown) - - const pkgEnvDetails = { - agent: 'yarn', - agentExecPath: '/usr/bin/yarn', - pkgPath: '/test/project', - } as unknown - - const options = { - args: ['--prod'], - env: { NODE_ENV: 'production' }, - stdio: 'inherit' as const, - } - - runAgentInstall(pkgEnvDetails, options) - - expect(spawn).toHaveBeenCalledWith( - '/usr/bin/yarn', - ['install', '--prod'], - expect.objectContaining({ - cwd: '/test/project', - env: expect.objectContaining({ - NODE_ENV: 'production', - }), - stdio: 'inherit', - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/apply-optimization.test.mts b/packages/cli/test/unit/commands/optimize/apply-optimization.test.mts deleted file mode 100644 index 2ef400650..000000000 --- a/packages/cli/test/unit/commands/optimize/apply-optimization.test.mts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Unit tests for apply-optimization. - * - * Purpose: Tests the applyOptimization function that applies Socket registry - * overrides. - * - * Test Coverage: - Successful optimization - Update dependencies failure - npm - * buggy overrides handling - Spinner behavior. - * - * Related Files: - commands/optimize/apply-optimization.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockSpinner = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - text: vi.fn(), -})) - -const mockAddOverrides = vi.hoisted(() => vi.fn()) -const mockUpdateDependencies = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockSpinner, -})) - -vi.mock('../../../../src/commands/optimize/add-overrides.mts', () => ({ - addOverrides: mockAddOverrides, -})) - -vi.mock('../../../../src/commands/optimize/update-dependencies.mts', () => ({ - updateDependencies: mockUpdateDependencies, -})) - -vi.mock('../../../../src/commands/optimize/shared.mts', () => ({ - CMD_NAME: 'optimize', -})) - -import { applyOptimization } from '../../../../src/commands/optimize/apply-optimization.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('apply-optimization', () => { - const mockEnvDetails = { - agent: 'npm', - agentVersion: '10.0.0', - pkgPath: '/test/project', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/package-lock.json', - features: { - npmBuggyOverrides: false, - }, - } as unknown as EnvDetails - - beforeEach(() => { - vi.clearAllMocks() - mockAddOverrides.mockResolvedValue({ - added: new Set(), - addedInWorkspaces: new Set(), - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - }) - mockUpdateDependencies.mockResolvedValue({ ok: true }) - }) - - describe('applyOptimization', () => { - it('returns success with no changes when nothing added or updated', async () => { - const result = await applyOptimization(mockEnvDetails, { - pin: false, - prod: false, - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.addedCount).toBe(0) - expect(result.data.updatedCount).toBe(0) - expect(result.data.pkgJsonChanged).toBe(false) - } - expect(mockSpinner.start).toHaveBeenCalled() - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('returns success with changes when packages added', async () => { - mockAddOverrides.mockResolvedValue({ - added: new Set(['pkg1', 'pkg2']), - addedInWorkspaces: new Set(['workspace1']), - updated: new Set(['pkg3']), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - }) - - const result = await applyOptimization(mockEnvDetails, { - pin: true, - prod: false, - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.addedCount).toBe(2) - expect(result.data.updatedCount).toBe(1) - expect(result.data.pkgJsonChanged).toBe(true) - expect(result.data.addedInWorkspaces).toBe(1) - } - expect(mockUpdateDependencies).toHaveBeenCalled() - }) - - it('calls updateDependencies when packages changed', async () => { - mockAddOverrides.mockResolvedValue({ - added: new Set(['pkg1']), - addedInWorkspaces: new Set(), - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - }) - - await applyOptimization(mockEnvDetails, { pin: false, prod: false }) - - expect(mockUpdateDependencies).toHaveBeenCalledWith( - mockEnvDetails, - expect.objectContaining({ - cmdName: 'optimize', - logger: mockLogger, - }), - ) - }) - - it('returns error when updateDependencies fails', async () => { - mockAddOverrides.mockResolvedValue({ - added: new Set(['pkg1']), - addedInWorkspaces: new Set(), - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - }) - mockUpdateDependencies.mockResolvedValue({ - ok: false, - message: 'Install failed', - code: 1, - }) - - const result = await applyOptimization(mockEnvDetails, { - pin: false, - prod: false, - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Install failed') - } - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('calls updateDependencies when npmBuggyOverrides is true even with no changes', async () => { - const envWithBuggyOverrides = { - ...mockEnvDetails, - features: { - npmBuggyOverrides: true, - }, - } as unknown as EnvDetails - - await applyOptimization(envWithBuggyOverrides, { - pin: false, - prod: false, - }) - - expect(mockUpdateDependencies).toHaveBeenCalled() - }) - - it('passes pin and prod options to addOverrides', async () => { - await applyOptimization(mockEnvDetails, { pin: true, prod: true }) - - expect(mockAddOverrides).toHaveBeenCalledWith( - mockEnvDetails, - '/test/project', - expect.objectContaining({ - pin: true, - prod: true, - }), - ) - }) - - it('handles workspace updates correctly', async () => { - mockAddOverrides.mockResolvedValue({ - added: new Set(), - addedInWorkspaces: new Set(['ws1', 'ws2', 'ws3']), - updated: new Set(['pkg1']), - updatedInWorkspaces: new Set(['ws1', 'ws2']), - warnedPnpmWorkspaceRequiresNpm: false, - }) - - const result = await applyOptimization(mockEnvDetails, { - pin: false, - prod: false, - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.addedInWorkspaces).toBe(3) - expect(result.data.updatedInWorkspaces).toBe(2) - } - }) - - it('does not call updateDependencies when no changes and no buggy overrides', async () => { - await applyOptimization(mockEnvDetails, { pin: false, prod: false }) - - expect(mockUpdateDependencies).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/cmd-optimize.test.mts b/packages/cli/test/unit/commands/optimize/cmd-optimize.test.mts deleted file mode 100644 index d1952eee5..000000000 --- a/packages/cli/test/unit/commands/optimize/cmd-optimize.test.mts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Unit tests for optimize command. - * - * Tests the command that optimizes dependencies with @socketregistry overrides. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleOptimize = vi.hoisted(() => vi.fn()) -const mockDetectAndValidatePackageEnvironment = vi.hoisted(() => - vi.fn().mockResolvedValue({ - ok: true, - data: { - agent: 'npm', - agentVersion: '10.0.0', - pkgPath: '/test/path', - }, - }), -) - -vi.mock('../../../../src/commands/optimize/handle-optimize.mts', () => ({ - handleOptimize: mockHandleOptimize, -})) - -vi.mock('../../../../src/util/ecosystem/environment.mjs', () => ({ - detectAndValidatePackageEnvironment: mockDetectAndValidatePackageEnvironment, -})) - -// Import after mocks. -const { cmdOptimize } = - await import('../../../../src/commands/optimize/cmd-optimize.mts') - -describe('cmd-optimize', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOptimize.description).toBe( - 'Optimize dependencies with @socketregistry overrides', - ) - }) - - it('should not be hidden', () => { - expect(cmdOptimize.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-optimize.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag', async () => { - await cmdOptimize.run(['--dry-run'], importMeta, context) - - expect(mockHandleOptimize).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should detect package environment in dry-run mode', async () => { - await cmdOptimize.run(['--dry-run'], importMeta, context) - - expect(mockDetectAndValidatePackageEnvironment).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - cmdName: 'socket optimize', - prod: false, - }), - ) - }) - - it('should show failure in dry-run when package environment detection fails', async () => { - mockDetectAndValidatePackageEnvironment.mockResolvedValueOnce({ - ok: false, - error: 'No package.json found', - }) - - await cmdOptimize.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Would fail'), - ) - }) - - it('should call handleOptimize without dry-run flag', async () => { - await cmdOptimize.run([], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.any(String), - pin: false, - outputKind: 'text', - prod: false, - }), - ) - }) - - it('should pass --pin flag to handleOptimize', async () => { - await cmdOptimize.run(['--pin'], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - pin: true, - }), - ) - }) - - it('should pass --prod flag to handleOptimize', async () => { - await cmdOptimize.run(['--prod'], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - prod: true, - }), - ) - }) - - it('should support custom cwd argument', async () => { - await cmdOptimize.run(['./custom/path'], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining('custom/path'), - }), - ) - }) - - it('should support --json output mode', async () => { - await cmdOptimize.run(['--json'], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - await cmdOptimize.run(['--markdown'], importMeta, context) - - expect(mockHandleOptimize).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should include pin and prod in dry-run details', async () => { - await cmdOptimize.run( - ['--dry-run', '--pin', '--prod'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('pin to specific versions'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('production dependencies only'), - ) - }) - - it('should show agent version in dry-run mode', async () => { - mockDetectAndValidatePackageEnvironment.mockResolvedValueOnce({ - ok: true, - data: { - agent: 'pnpm', - agentVersion: '9.0.0', - pkgPath: '/test/path', - }, - }) - - await cmdOptimize.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('pnpm v9.0.0'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/deps-includes-by-agent.test.mts b/packages/cli/test/unit/commands/optimize/deps-includes-by-agent.test.mts deleted file mode 100644 index ee298a0f1..000000000 --- a/packages/cli/test/unit/commands/optimize/deps-includes-by-agent.test.mts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Unit tests for deps-includes-by-agent. - * - * Purpose: Tests the functions that check if a package name exists in ls/query - * output. - * - * Test Coverage: - matchLsCmdViewHumanStdout - matchQueryCmdStdout - - * lsStdoutIncludes. - * - * Related Files: - commands/optimize/deps-includes-by-agent.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - lsStdoutIncludes, - matchLsCmdViewHumanStdout, - matchQueryCmdStdout, -} from '../../../../src/commands/optimize/deps-includes-by-agent.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('deps-includes-by-agent', () => { - describe('matchLsCmdViewHumanStdout', () => { - it('returns true when package name with @ version exists', () => { - const stdout = `├── lodash@4.17.21 -├── express@4.18.0` - expect(matchLsCmdViewHumanStdout(stdout, 'lodash')).toBe(true) - }) - - it('returns true for nested package with space prefix', () => { - const stdout = `project@1.0.0 -├── lodash@4.17.21 -│ └── nested@1.0.0` - expect(matchLsCmdViewHumanStdout(stdout, 'lodash')).toBe(true) - }) - - it('returns false when package does not exist', () => { - const stdout = `├── express@4.18.0` - expect(matchLsCmdViewHumanStdout(stdout, 'lodash')).toBe(false) - }) - - it('returns false for partial name match without space prefix', () => { - const stdout = `├── lodash-es@4.17.21` - // 'lodash-es' starts with 'lodash' but ' lodash@' pattern won't match. - expect(matchLsCmdViewHumanStdout(stdout, 'lodash')).toBe(false) - }) - - it('handles scoped packages', () => { - const stdout = `├── @babel/core@7.0.0` - expect(matchLsCmdViewHumanStdout(stdout, '@babel/core')).toBe(true) - }) - }) - - describe('matchQueryCmdStdout', () => { - it('returns true when package name in quotes exists', () => { - const stdout = `{"name":"lodash","version":"4.17.21"}` - expect(matchQueryCmdStdout(stdout, 'lodash')).toBe(true) - }) - - it('returns true for package name in JSON array', () => { - const stdout = `[{"name":"lodash"},{"name":"express"}]` - expect(matchQueryCmdStdout(stdout, 'lodash')).toBe(true) - }) - - it('returns false when package does not exist', () => { - const stdout = `{"name":"express","version":"4.18.0"}` - expect(matchQueryCmdStdout(stdout, 'lodash')).toBe(false) - }) - - it('handles scoped packages', () => { - const stdout = `{"name":"@babel/core"}` - expect(matchQueryCmdStdout(stdout, '@babel/core')).toBe(true) - }) - - it('does not match unquoted text', () => { - const stdout = `lodash is a package` - expect(matchQueryCmdStdout(stdout, 'lodash')).toBe(false) - }) - - it('does not match partial package names in JSON', () => { - const stdout = `{"name":"lodash-es"}` - expect(matchQueryCmdStdout(stdout, 'lodash')).toBe(false) - }) - - it('returns false for empty stdout', () => { - expect(matchQueryCmdStdout('', 'lodash')).toBe(false) - }) - - it('returns false for empty package name', () => { - const stdout = `{"name":"lodash"}` - expect(matchQueryCmdStdout(stdout, '')).toBe(false) - }) - }) - - describe('lsStdoutIncludes', () => { - const createEnvDetails = (agent: string): EnvDetails => - ({ agent }) as unknown as EnvDetails - - it('uses human format for bun agent', () => { - const stdout = `├── lodash@4.17.21` - expect(lsStdoutIncludes(createEnvDetails('bun'), stdout, 'lodash')).toBe( - true, - ) - }) - - it('uses human format for yarn/berry agent', () => { - const stdout = `├── lodash@4.17.21` - expect( - lsStdoutIncludes(createEnvDetails('yarn/berry'), stdout, 'lodash'), - ).toBe(true) - }) - - it('uses human format for yarn/classic agent', () => { - const stdout = `├── lodash@4.17.21` - expect( - lsStdoutIncludes(createEnvDetails('yarn/classic'), stdout, 'lodash'), - ).toBe(true) - }) - - it('uses query format for npm agent', () => { - const stdout = `{"name":"lodash"}` - expect(lsStdoutIncludes(createEnvDetails('npm'), stdout, 'lodash')).toBe( - true, - ) - }) - - it('uses query format for pnpm agent', () => { - const stdout = `{"name":"lodash"}` - expect(lsStdoutIncludes(createEnvDetails('pnpm'), stdout, 'lodash')).toBe( - true, - ) - }) - - it('uses query format for unknown agent', () => { - const stdout = `{"name":"lodash"}` - expect( - lsStdoutIncludes(createEnvDetails('unknown'), stdout, 'lodash'), - ).toBe(true) - }) - - it('returns false when package not found in bun format', () => { - const stdout = `├── express@4.18.0` - expect(lsStdoutIncludes(createEnvDetails('bun'), stdout, 'lodash')).toBe( - false, - ) - }) - - it('returns false when package not found in npm format', () => { - const stdout = `{"name":"express"}` - expect(lsStdoutIncludes(createEnvDetails('npm'), stdout, 'lodash')).toBe( - false, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/get-dependency-entries.test.mts b/packages/cli/test/unit/commands/optimize/get-dependency-entries.test.mts deleted file mode 100644 index 3b126441d..000000000 --- a/packages/cli/test/unit/commands/optimize/get-dependency-entries.test.mts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Unit tests for dependency entries extraction. - * - * Purpose: Tests the getDependencyEntries function for extracting dependencies - * from package.json. - * - * Test Coverage: - Extracting all dependency types - Filtering undefined - * dependencies - Null prototype handling. - * - * Related Files: - commands/optimize/get-dependency-entries.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { getDependencyEntries } from '../../../../src/commands/optimize/get-dependency-entries.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('get-dependency-entries', () => { - describe('getDependencyEntries', () => { - it('returns all dependency types when present', () => { - const envDetails = { - editablePkgJson: { - content: { - dependencies: { lodash: '^4.17.21' }, - devDependencies: { vitest: '^2.0.0' }, - peerDependencies: { react: '>=18.0.0' }, - optionalDependencies: { fsevents: '^2.3.0' }, - }, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - expect(result).toHaveLength(4) - expect(result[0]).toEqual(['dependencies', { lodash: '^4.17.21' }]) - expect(result[1]).toEqual(['devDependencies', { vitest: '^2.0.0' }]) - expect(result[2]).toEqual(['peerDependencies', { react: '>=18.0.0' }]) - expect(result[3]).toEqual([ - 'optionalDependencies', - { fsevents: '^2.3.0' }, - ]) - }) - - it('filters out undefined dependency types', () => { - const envDetails = { - editablePkgJson: { - content: { - dependencies: { lodash: '^4.17.21' }, - devDependencies: undefined, - peerDependencies: undefined, - optionalDependencies: undefined, - }, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual(['dependencies', { lodash: '^4.17.21' }]) - }) - - it('returns empty array when no dependencies present', () => { - const envDetails = { - editablePkgJson: { - content: {}, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - expect(result).toHaveLength(0) - }) - - it('handles only devDependencies', () => { - const envDetails = { - editablePkgJson: { - content: { - devDependencies: { typescript: '^5.0.0' }, - }, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual(['devDependencies', { typescript: '^5.0.0' }]) - }) - - it('handles empty dependency objects', () => { - const envDetails = { - editablePkgJson: { - content: { - dependencies: {}, - devDependencies: {}, - }, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - // Empty objects are truthy, so they are included. - expect(result).toHaveLength(2) - }) - - it('returns dependencies with null prototype', () => { - const envDetails = { - editablePkgJson: { - content: { - dependencies: { lodash: '^4.17.21' }, - }, - }, - } as EnvDetails - - const result = getDependencyEntries(envDetails) - - const deps = result[0]![1] - expect(Object.getPrototypeOf(deps)).toBeNull() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/get-overrides-by-agent.test.mts b/packages/cli/test/unit/commands/optimize/get-overrides-by-agent.test.mts deleted file mode 100644 index 42b8afdc6..000000000 --- a/packages/cli/test/unit/commands/optimize/get-overrides-by-agent.test.mts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Unit tests for get-overrides-by-agent. - * - * Purpose: Tests the functions that get overrides from package.json for - * different package managers. - * - * Test Coverage: - getOverridesData for all package managers - - * getOverridesDataNpm - getOverridesDataPnpm - getOverridesDataYarn/YarnClassic - * - getOverridesDataBun - getOverridesDataVlt. - * - * Related Files: - commands/optimize/get-overrides-by-agent.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getOverridesData, - getOverridesDataBun, - getOverridesDataNpm, - getOverridesDataPnpm, - getOverridesDataVlt, - getOverridesDataYarn, - getOverridesDataYarnClassic, -} from '../../../../src/commands/optimize/get-overrides-by-agent.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('get-overrides-by-agent', () => { - const createEnvDetails = ( - agent: string, - pkgJsonContent: unknown = {}, - ): EnvDetails => - ({ - agent, - editablePkgJson: { - content: pkgJsonContent, - }, - }) as unknown as EnvDetails - - describe('getOverridesDataNpm', () => { - it('returns npm overrides from package.json', () => { - const envDetails = createEnvDetails('npm', { - overrides: { - lodash: '4.17.21', - }, - }) - const result = getOverridesDataNpm(envDetails) - expect(result.type).toBe('npm') - expect(result.overrides).toEqual({ lodash: '4.17.21' }) - }) - - it('returns empty overrides when none exist', () => { - const envDetails = createEnvDetails('npm', {}) - const result = getOverridesDataNpm(envDetails) - expect(result.type).toBe('npm') - expect(result.overrides).toEqual({}) - }) - - it('accepts custom pkgJson', () => { - const envDetails = createEnvDetails('npm', { overrides: { a: '1' } }) - const result = getOverridesDataNpm(envDetails, { overrides: { b: '2' } }) - expect(result.overrides).toEqual({ b: '2' }) - }) - }) - - describe('getOverridesDataPnpm', () => { - it('returns pnpm overrides from package.json', () => { - const envDetails = createEnvDetails('pnpm', { - pnpm: { - overrides: { - express: '4.18.0', - }, - }, - }) - const result = getOverridesDataPnpm(envDetails) - expect(result.type).toBe('pnpm') - expect(result.overrides).toEqual({ express: '4.18.0' }) - }) - - it('returns empty overrides when pnpm section missing', () => { - const envDetails = createEnvDetails('pnpm', {}) - const result = getOverridesDataPnpm(envDetails) - expect(result.type).toBe('pnpm') - expect(result.overrides).toEqual({}) - }) - - it('returns empty overrides when overrides missing in pnpm section', () => { - const envDetails = createEnvDetails('pnpm', { pnpm: {} }) - const result = getOverridesDataPnpm(envDetails) - expect(result.overrides).toEqual({}) - }) - }) - - describe('getOverridesDataYarn', () => { - it('returns yarn resolutions from package.json', () => { - const envDetails = createEnvDetails('yarn', { - resolutions: { - typescript: '5.0.0', - }, - }) - const result = getOverridesDataYarn(envDetails) - expect(result.type).toBe('yarn/berry') - expect(result.overrides).toEqual({ typescript: '5.0.0' }) - }) - - it('returns empty overrides when resolutions missing', () => { - const envDetails = createEnvDetails('yarn', {}) - const result = getOverridesDataYarn(envDetails) - expect(result.overrides).toEqual({}) - }) - }) - - describe('getOverridesDataYarnClassic', () => { - it('returns yarn classic resolutions from package.json', () => { - const envDetails = createEnvDetails('yarn/classic', { - resolutions: { - react: '18.0.0', - }, - }) - const result = getOverridesDataYarnClassic(envDetails) - expect(result.type).toBe('yarn/classic') - expect(result.overrides).toEqual({ react: '18.0.0' }) - }) - - it('returns empty object when resolutions field missing', () => { - const envDetails = createEnvDetails('yarn/classic', {}) - const result = getOverridesDataYarnClassic(envDetails) - expect(result.overrides).toEqual({}) - }) - }) - - describe('getOverridesDataBun', () => { - it('returns bun resolutions from package.json', () => { - const envDetails = createEnvDetails('bun', { - resolutions: { - jest: '29.0.0', - }, - }) - const result = getOverridesDataBun(envDetails) - expect(result.type).toBe('yarn/berry') - expect(result.overrides).toEqual({ jest: '29.0.0' }) - }) - - it('returns empty object when resolutions field missing', () => { - const envDetails = createEnvDetails('bun', {}) - const result = getOverridesDataBun(envDetails) - expect(result.overrides).toEqual({}) - }) - }) - - describe('getOverridesDataVlt', () => { - it('returns vlt overrides from package.json', () => { - const envDetails = createEnvDetails('vlt', { - overrides: { - chalk: '5.0.0', - }, - }) - const result = getOverridesDataVlt(envDetails) - expect(result.type).toBe('vlt') - expect(result.overrides).toEqual({ chalk: '5.0.0' }) - }) - - it('returns empty object when overrides field missing', () => { - const envDetails = createEnvDetails('vlt', {}) - const result = getOverridesDataVlt(envDetails) - expect(result.overrides).toEqual({}) - }) - }) - - describe('getOverridesData', () => { - it('returns npm overrides for npm agent', () => { - const envDetails = createEnvDetails('npm', { overrides: { a: '1' } }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('npm') - expect(result.overrides).toEqual({ a: '1' }) - }) - - it('returns pnpm overrides for pnpm agent', () => { - const envDetails = createEnvDetails('pnpm', { - pnpm: { overrides: { b: '2' } }, - }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('pnpm') - expect(result.overrides).toEqual({ b: '2' }) - }) - - it('returns yarn overrides for yarn-berry agent', () => { - const envDetails = createEnvDetails('yarn/berry', { - resolutions: { c: '3' }, - }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('yarn/berry') - expect(result.overrides).toEqual({ c: '3' }) - }) - - it('returns yarn classic overrides for yarn-classic agent', () => { - const envDetails = createEnvDetails('yarn/classic', { - resolutions: { d: '4' }, - }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('yarn/classic') - expect(result.overrides).toEqual({ d: '4' }) - }) - - it('returns bun overrides for bun agent', () => { - const envDetails = createEnvDetails('bun', { resolutions: { e: '5' } }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('yarn/berry') - expect(result.overrides).toEqual({ e: '5' }) - }) - - it('returns vlt overrides for vlt agent', () => { - const envDetails = createEnvDetails('vlt', { overrides: { f: '6' } }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('vlt') - expect(result.overrides).toEqual({ f: '6' }) - }) - - it('defaults to npm for unknown agent', () => { - const envDetails = createEnvDetails('unknown', { overrides: { g: '7' } }) - const result = getOverridesData(envDetails) - expect(result.type).toBe('npm') - expect(result.overrides).toEqual({ g: '7' }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/handle-optimize.test.mts b/packages/cli/test/unit/commands/optimize/handle-optimize.test.mts deleted file mode 100644 index 214fd98db..000000000 --- a/packages/cli/test/unit/commands/optimize/handle-optimize.test.mts +++ /dev/null @@ -1,381 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleOptimize } from '../../../../src/commands/optimize/handle-optimize.mts' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/constants/agents', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - VLT: 'vlt', - } -}) - -vi.mock('../../../../src/commands/optimize/apply-optimization.mts', () => ({ - applyOptimization: vi.fn(), -})) -vi.mock('../../../../src/commands/optimize/output-optimize-result.mts', () => ({ - outputOptimizeResult: vi.fn(), -})) -vi.mock('../../../../src/commands/optimize/shared.mts', () => ({ - CMD_NAME: 'optimize', -})) -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - cmdPrefixMessage: vi.fn((cmd, msg) => `${cmd}: ${msg}`), -})) -vi.mock('../../../../src/util/ecosystem/environment.mts', () => ({ - detectAndValidatePackageEnvironment: vi.fn(), -})) - -describe('handleOptimize', () => { - const originalExitCode = process.exitCode - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - afterEach(() => { - process.exitCode = originalExitCode - }) - - it('optimizes packages successfully', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - const { outputOptimizeResult } = - await import('../../../../src/commands/optimize/output-optimize-result.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'npm', - agentVersion: '10.0.0', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/package-lock.json', - }, - }) - vi.mocked(applyOptimization).mockResolvedValue({ - ok: true, - data: { - optimizedCount: 5, - packages: ['pkg1', 'pkg2', 'pkg3', 'pkg4', 'pkg5'], - }, - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'json', - pin: false, - prod: false, - }) - - expect(detectAndValidatePackageEnvironment).toHaveBeenCalledWith( - '/test/project', - expect.objectContaining({ - cmdName: 'optimize', - prod: false, - }), - ) - expect(applyOptimization).toHaveBeenCalledWith( - expect.objectContaining({ - agent: 'npm', - agentVersion: '10.0.0', - }), - { pin: false, prod: false }, - ) - expect(outputOptimizeResult).toHaveBeenCalledWith( - expect.objectContaining({ ok: true }), - 'json', - ) - expect(process.exitCode).toBeUndefined() - }) - - it('handles package environment validation failure', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { outputOptimizeResult } = - await import('../../../../src/commands/optimize/output-optimize-result.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: false, - code: 2, - error: new Error('Invalid package environment'), - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'text', - pin: true, - prod: false, - }) - - expect(outputOptimizeResult).toHaveBeenCalledWith( - expect.objectContaining({ ok: false }), - 'text', - ) - expect(applyOptimization).not.toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('handles missing package environment details', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { outputOptimizeResult } = - await import('../../../../src/commands/optimize/output-optimize-result.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: undefined, - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'json', - pin: false, - prod: true, - }) - - expect(outputOptimizeResult).toHaveBeenCalledWith( - { - ok: false, - message: 'No package found.', - cause: - 'No valid package environment found for project path: /test/project', - }, - 'json', - ) - expect(process.exitCode).toBe(1) - }) - - it('handles unsupported vlt package manager', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { outputOptimizeResult } = - await import('../../../../src/commands/optimize/output-optimize-result.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'vlt', - agentVersion: '1.0.0', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/vlt.lock', - }, - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'markdown', - pin: false, - prod: false, - }) - - expect(outputOptimizeResult).toHaveBeenCalledWith( - { - ok: false, - message: 'Unsupported', - cause: 'optimize: vlt v1.0.0 does not support overrides.', - }, - 'markdown', - ) - expect(applyOptimization).not.toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles optimization failure', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - const { outputOptimizeResult } = - await import('../../../../src/commands/optimize/output-optimize-result.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'yarn', - agentVersion: '3.0.0', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/yarn.lock', - }, - }) - vi.mocked(applyOptimization).mockResolvedValue({ - ok: false, - code: 3, - error: new Error('Failed to apply optimization'), - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'json', - pin: true, - prod: true, - }) - - expect(applyOptimization).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'yarn' }), - { pin: true, prod: true }, - ) - expect(outputOptimizeResult).toHaveBeenCalledWith( - expect.objectContaining({ ok: false }), - 'json', - ) - expect(process.exitCode).toBe(3) - }) - - it('handles pnpm package manager', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'pnpm', - agentVersion: '8.0.0', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/pnpm-lock.yaml', - }, - }) - vi.mocked(applyOptimization).mockResolvedValue({ - ok: true, - data: { optimizedCount: 3 }, - }) - - await handleOptimize({ - cwd: '/test/project', - outputKind: 'text', - pin: false, - prod: false, - }) - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Optimizing packages for pnpm v8.0.0.', - ) - expect(applyOptimization).toHaveBeenCalledWith( - expect.objectContaining({ agent: 'pnpm' }), - { pin: false, prod: false }, - ) - }) - - it('logs debug information', async () => { - const { debug, debugDir } = await import('@socketsecurity/lib-stable/debug/output') - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'npm', - agentVersion: '10.0.0', - manifestPath: '/test/project/package.json', - lockfilePath: '/test/project/package-lock.json', - }, - }) - vi.mocked(applyOptimization).mockResolvedValue({ - ok: true, - data: { optimizedCount: 2 }, - }) - - await handleOptimize({ - cwd: '/debug/project', - outputKind: 'json', - pin: true, - prod: false, - }) - - expect(debug).toHaveBeenCalledWith( - 'Starting optimization for /debug/project', - ) - expect(debugDir).toHaveBeenCalledWith({ - cwd: '/debug/project', - outputKind: 'json', - pin: true, - prod: false, - }) - expect(debug).toHaveBeenCalledWith('Detected package manager: npm v10.0.0') - expect(debug).toHaveBeenCalledWith('Applying optimization') - expect(debug).toHaveBeenCalledWith('Optimization succeeded') - }) - - it('falls back to exitCode 1 when pkgEnv result has no code', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: false, - // No code field - message: 'fail', - } as unknown) - - await handleOptimize({ - cwd: '/test', - outputKind: 'text', - pin: false, - prod: false, - }) - - expect(process.exitCode).toBe(1) - expect(applyOptimization).not.toHaveBeenCalled() - }) - - it('falls back to exitCode 1 when applyOptimization result has no code', async () => { - const { detectAndValidatePackageEnvironment } = - await import('../../../../src/util/ecosystem/environment.mts') - const { applyOptimization } = - await import('../../../../src/commands/optimize/apply-optimization.mts') - - vi.mocked(detectAndValidatePackageEnvironment).mockResolvedValue({ - ok: true, - data: { - agent: 'npm', - agentVersion: '10.0.0', - manifestPath: '/p/package.json', - lockfilePath: '/p/package-lock.json', - }, - } as unknown) - vi.mocked(applyOptimization).mockResolvedValue({ - ok: false, - message: 'failed', - } as unknown) - - await handleOptimize({ - cwd: '/test', - outputKind: 'text', - pin: false, - prod: false, - }) - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/lockfile-includes-by-agent.test.mts b/packages/cli/test/unit/commands/optimize/lockfile-includes-by-agent.test.mts deleted file mode 100644 index 2a2c8cd3e..000000000 --- a/packages/cli/test/unit/commands/optimize/lockfile-includes-by-agent.test.mts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Unit tests for lockfile-includes-by-agent. - * - * Purpose: Tests the functions that check if a package name exists in different - * lockfile formats. - * - * Test Coverage: - npmLockSrcIncludes - pnpmLockSrcIncludes - - * yarnLockSrcIncludes - bunLockSrcIncludes - vltLockSrcIncludes - - * lockSrcIncludes. - * - * Related Files: - commands/optimize/lockfile-includes-by-agent.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - bunLockSrcIncludes, - lockSrcIncludes, - npmLockSrcIncludes, - pnpmLockSrcIncludes, - vltLockSrcIncludes, - yarnLockSrcIncludes, -} from '../../../../src/commands/optimize/lockfile-includes-by-agent.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('lockfile-includes-by-agent', () => { - describe('npmLockSrcIncludes', () => { - it('returns true when package name exists in npm lockfile', () => { - const lockSrc = `{ - "name": "test-project", - "packages": { - "node_modules/lodash": { - "version": "4.17.21" - } - }, - "lodash": { - "version": "4.17.21" - } - }` - expect(npmLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns false when package name does not exist', () => { - const lockSrc = `{ - "name": "test-project", - "packages": {} - }` - expect(npmLockSrcIncludes(lockSrc, 'lodash')).toBe(false) - }) - - it('handles scoped packages', () => { - const lockSrc = `{ - "@babel/core": { - "version": "7.0.0" - } - }` - expect(npmLockSrcIncludes(lockSrc, '@babel/core')).toBe(true) - }) - - it('does not match package name as substring of another package', () => { - const lockSrc = `{ - "react-dom": { - "version": "18.0.0" - } - }` - expect(npmLockSrcIncludes(lockSrc, 'react')).toBe(false) - }) - - it('returns false for empty lockSrc', () => { - expect(npmLockSrcIncludes('', 'lodash')).toBe(false) - }) - - it('returns false for empty package name', () => { - const lockSrc = `{ "lodash": { "version": "4.17.21" } }` - expect(npmLockSrcIncludes(lockSrc, '')).toBe(false) - }) - }) - - describe('pnpmLockSrcIncludes', () => { - it('returns true for quoted package name', () => { - // pnpm v9 format with quoted package name. - const lockSrc = ` -packages: - - 'lodash': - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns true for unquoted package name with colon', () => { - const lockSrc = ` -packages: - lodash: - version: 4.17.21 - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns true for package name with @ version', () => { - const lockSrc = ` -packages: - lodash@4.17.21: - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns true for v6 lockfile format with leading slash', () => { - const lockSrc = ` -packages: - /lodash@4.17.21: - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns false when package does not exist', () => { - const lockSrc = ` -packages: - express@4.18.0: - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash')).toBe(false) - }) - - it('handles scoped packages', () => { - // Scoped packages in pnpm lockfile use the format: /@scope/name@version - const lockSrc = ` -packages: - /@babel/core@7.0.0: - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, '@babel/core')).toBe(true) - }) - - it('handles package names with dots (regex special chars)', () => { - const lockSrc = ` -packages: - lodash.debounce@4.0.8: - resolution: {integrity: sha512-...} - ` - expect(pnpmLockSrcIncludes(lockSrc, 'lodash.debounce')).toBe(true) - }) - - it('returns false for empty lockSrc', () => { - expect(pnpmLockSrcIncludes('', 'lodash')).toBe(false) - }) - }) - - describe('yarnLockSrcIncludes', () => { - it('returns true for quoted package name with @', () => { - const lockSrc = ` -"lodash@^4.17.0": - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" - ` - expect(yarnLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns true for unquoted package name', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect(yarnLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns true for multiple dependencies on same line', () => { - const lockSrc = ` -"lodash@^4.17.0", "lodash@^4.17.21": - version "4.17.21" - ` - expect(yarnLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns false when package does not exist', () => { - const lockSrc = ` -"express@^4.18.0": - version "4.18.2" - ` - expect(yarnLockSrcIncludes(lockSrc, 'lodash')).toBe(false) - }) - - it('handles scoped packages', () => { - const lockSrc = ` -"@babel/core@^7.0.0": - version "7.21.0" - ` - expect(yarnLockSrcIncludes(lockSrc, '@babel/core')).toBe(true) - }) - - it('does not match partial package names', () => { - const lockSrc = ` -"lodash-es@^4.17.0": - version "4.17.21" - ` - expect(yarnLockSrcIncludes(lockSrc, 'lodash')).toBe(false) - }) - - it('returns false for empty lockSrc', () => { - expect(yarnLockSrcIncludes('', 'lodash')).toBe(false) - }) - }) - - describe('bunLockSrcIncludes', () => { - it('uses npm format for .lock extension', () => { - const lockSrc = `{ - "lodash": { - "version": "4.17.21" - } - }` - expect(bunLockSrcIncludes(lockSrc, 'lodash', 'bun.lock')).toBe(true) - }) - - it('uses yarn format for .lockb extension', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect(bunLockSrcIncludes(lockSrc, 'lodash', 'bun.lockb')).toBe(true) - }) - - it('defaults to yarn format when no lockName provided', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect(bunLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - }) - - describe('vltLockSrcIncludes', () => { - it('returns true when package name exists', () => { - const lockSrc = `{ - "packages": { - "lodash": "4.17.21" - } - }` - expect(vltLockSrcIncludes(lockSrc, 'lodash')).toBe(true) - }) - - it('returns false when package does not exist', () => { - const lockSrc = `{ - "packages": { - "express": "4.18.0" - } - }` - expect(vltLockSrcIncludes(lockSrc, 'lodash')).toBe(false) - }) - }) - - describe('lockSrcIncludes', () => { - const createEnvDetails = (agent: string): EnvDetails => - ({ agent }) as unknown as EnvDetails - - it('uses npm format for npm agent', () => { - const lockSrc = `{ "lodash": { "version": "4.17.21" } }` - expect(lockSrcIncludes(createEnvDetails('npm'), lockSrc, 'lodash')).toBe( - true, - ) - }) - - it('uses pnpm format for pnpm agent', () => { - const lockSrc = ` -packages: - lodash@4.17.21: - resolution: {integrity: sha512-...} - ` - expect(lockSrcIncludes(createEnvDetails('pnpm'), lockSrc, 'lodash')).toBe( - true, - ) - }) - - it('uses yarn format for yarn/berry agent', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect( - lockSrcIncludes(createEnvDetails('yarn/berry'), lockSrc, 'lodash'), - ).toBe(true) - }) - - it('uses yarn format for yarn/classic agent', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect( - lockSrcIncludes(createEnvDetails('yarn/classic'), lockSrc, 'lodash'), - ).toBe(true) - }) - - it('uses bun format for bun agent', () => { - const lockSrc = ` -lodash@^4.17.0: - version "4.17.21" - ` - expect( - lockSrcIncludes( - createEnvDetails('bun'), - lockSrc, - 'lodash', - 'bun.lockb', - ), - ).toBe(true) - }) - - it('uses vlt format for vlt agent', () => { - const lockSrc = `{ "lodash": "4.17.21" }` - expect(lockSrcIncludes(createEnvDetails('vlt'), lockSrc, 'lodash')).toBe( - true, - ) - }) - - it('defaults to npm format for unknown agent', () => { - const lockSrc = `{ "lodash": { "version": "4.17.21" } }` - expect( - lockSrcIncludes(createEnvDetails('unknown'), lockSrc, 'lodash'), - ).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/ls-by-agent.test.mts b/packages/cli/test/unit/commands/optimize/ls-by-agent.test.mts deleted file mode 100644 index 9b412ab96..000000000 --- a/packages/cli/test/unit/commands/optimize/ls-by-agent.test.mts +++ /dev/null @@ -1,604 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for ls-by-agent module. - * - * Purpose: Tests the package listing functions for different package managers. - * - * Test Coverage: - cleanupQueryStdout function (via lsNpm, lsVlt) - - * parsableToQueryStdout function (via lsPnpm) - lsBun function - lsNpm function - * - lsPnpm function - lsVlt function - lsYarnBerry function - lsYarnClassic - * function - listPackages function. - * - * Related Files: - commands/optimize/ls-by-agent.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - BUN, - NPM, - PNPM, - VLT, - YARN_BERRY, - YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' - -// Mock spawn. -const mockSpawn = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -import { - listPackages, - lsBun, - lsNpm, - lsPnpm, - lsVlt, - lsYarnBerry, - lsYarnClassic, -} from '../../../../src/commands/optimize/ls-by-agent.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -function createMockEnvDetails( - agent: string, - agentExecPath = '/usr/local/bin/npm', -): EnvDetails { - return { - agent, - agentExecPath, - agentVersion: '10.0.0', - } as EnvDetails -} - -describe('commands/optimize/ls-by-agent', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('lsBun', () => { - it('returns stdout from bun pm ls', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'package1@1.0.0\npackage2@2.0.0', - }) - - const result = await lsBun( - createMockEnvDetails(BUN, '/usr/local/bin/bun'), - ) - - expect(result).toBe('package1@1.0.0\npackage2@2.0.0') - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/bun', - ['pm', 'ls', '--all'], - expect.objectContaining({ cwd: expect.any(String) }), - ) - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('spawn failed')) - - const result = await lsBun( - createMockEnvDetails(BUN, '/usr/local/bin/bun'), - ) - - expect(result).toBe('') - }) - - }) - - describe('lsNpm', () => { - it('returns cleaned up query output', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lodash' }, { name: 'express' }]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['lodash', 'express']) - }) - - it('filters out @types packages', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { name: 'lodash' }, - { name: '@types/node' }, - { name: '@types/express' }, - ]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['lodash']) - }) - - it('falls back to _id when name is not present', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { _id: 'lodash@4.0.0' }, - { _id: 'express@5.0.0' }, - ]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['lodash', 'express']) - }) - - it('falls back to pkgid when name and _id are not present', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ pkgid: 'lodash@4.0.0' }]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['lodash']) - }) - - it('returns empty string for empty stdout', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '' }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(result).toBe('') - }) - - it('returns empty string for malformed JSON', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: 'not json' }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(result).toBe('') - }) - - it('returns empty string for empty array', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '[]' }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(result).toBe('') - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('npm query failed')) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(result).toBe('') - }) - }) - - describe('lsPnpm', () => { - it('falls back to pnpm ls when npm query fails', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: '/path/to/lodash\n/path/to/express\n', - }) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - ) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/pnpm', - ['ls', '--parseable', '--prod', '--depth', 'Infinity'], - expect.objectContaining({ cwd: expect.any(String) }), - ) - // parsableToQueryStdout extracts package names from paths. - expect(result).toBeTruthy() - }) - - it('uses npm query when npmExecPath is provided and succeeds', async () => { - // First call for npm query succeeds. - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lodash' }]), - }) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - { - npmExecPath: '/usr/local/bin/npm', - }, - ) - - expect(JSON.parse(result)).toEqual(['lodash']) - }) - - it('falls back to pnpm when npm query returns empty', async () => { - // npm query returns empty. - mockSpawn.mockResolvedValueOnce({ stdout: '' }) - // pnpm ls. - mockSpawn.mockResolvedValueOnce({ - stdout: '/node_modules/lodash\n', - }) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - { - npmExecPath: '/usr/local/bin/npm', - }, - ) - - expect(mockSpawn).toHaveBeenCalledTimes(2) - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('pnpm ls failed')) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - ) - - expect(result).toBe('') - }) - }) - - describe('lsVlt', () => { - it('returns cleaned up vlt ls output', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lodash' }, { name: 'express' }]), - }) - - const result = await lsVlt( - createMockEnvDetails(VLT, '/usr/local/bin/vlt'), - ) - - expect(JSON.parse(result)).toEqual(['lodash', 'express']) - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/vlt', - ['ls', '--view', 'human', ':not(.dev)'], - expect.objectContaining({ cwd: expect.any(String) }), - ) - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('vlt ls failed')) - - const result = await lsVlt( - createMockEnvDetails(VLT, '/usr/local/bin/vlt'), - ) - - expect(result).toBe('') - }) - }) - - describe('lsYarnBerry', () => { - it('returns stdout from yarn info', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'lodash@4.0.0\nexpress@5.0.0', - }) - - const result = await lsYarnBerry( - createMockEnvDetails(YARN_BERRY, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('lodash@4.0.0\nexpress@5.0.0') - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/yarn', - ['info', '--recursive', '--name-only'], - expect.objectContaining({ cwd: expect.any(String) }), - ) - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('yarn info failed')) - - const result = await lsYarnBerry( - createMockEnvDetails(YARN_BERRY, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('') - }) - }) - - describe('lsYarnClassic', () => { - it('returns stdout from yarn list', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'lodash@4.0.0\nexpress@5.0.0', - }) - - const result = await lsYarnClassic( - createMockEnvDetails(YARN_CLASSIC, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('lodash@4.0.0\nexpress@5.0.0') - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/yarn', - ['list', '--prod'], - expect.objectContaining({ cwd: expect.any(String) }), - ) - }) - - it('returns empty string when spawn throws', async () => { - mockSpawn.mockRejectedValueOnce(new Error('yarn list failed')) - - const result = await lsYarnClassic( - createMockEnvDetails(YARN_CLASSIC, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('') - }) - }) - - describe('listPackages', () => { - it('delegates to lsBun for bun agent', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: 'bun-output' }) - - const result = await listPackages( - createMockEnvDetails(BUN, '/usr/local/bin/bun'), - ) - - expect(result).toBe('bun-output') - }) - - it('delegates to lsPnpm for pnpm agent', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '/path/to/pkg\n' }) - - const result = await listPackages( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - ) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/pnpm', - expect.arrayContaining(['ls', '--parseable']), - expect.any(Object), - ) - }) - - it('delegates to lsVlt for vlt agent', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '[]' }) - - const result = await listPackages( - createMockEnvDetails(VLT, '/usr/local/bin/vlt'), - ) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/vlt', - expect.arrayContaining(['ls', '--view', 'human']), - expect.any(Object), - ) - }) - - it('delegates to lsYarnBerry for yarn berry agent', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: 'berry-output' }) - - const result = await listPackages( - createMockEnvDetails(YARN_BERRY, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('berry-output') - }) - - it('delegates to lsYarnClassic for yarn classic agent', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: 'classic-output' }) - - const result = await listPackages( - createMockEnvDetails(YARN_CLASSIC, '/usr/local/bin/yarn'), - ) - - expect(result).toBe('classic-output') - }) - - it('defaults to lsNpm for npm agent', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lodash' }]), - }) - - const result = await listPackages( - createMockEnvDetails(NPM, '/usr/local/bin/npm'), - ) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/npm', - ['query', ':not(.dev)'], - expect.any(Object), - ) - }) - - it('defaults to lsNpm for unknown agent', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ name: 'lodash' }]), - }) - - const result = await listPackages( - createMockEnvDetails('unknown', '/usr/local/bin/npm'), - ) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/npm', - ['query', ':not(.dev)'], - expect.any(Object), - ) - }) - }) - - describe('cleanupQueryStdout edge cases', () => { - it('handles packages with scoped names correctly', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { name: '@scope/package' }, - { _id: '@scope/other@1.0.0' }, - ]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['@scope/package', '@scope/other']) - }) - - it('handles packages without @ in _id', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([{ _id: 'simple-package' }]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['simple-package']) - }) - - it('deduplicates package names', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { name: 'lodash' }, - { name: 'lodash' }, - { name: 'lodash' }, - ]), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(JSON.parse(result)).toEqual(['lodash']) - }) - - it('handles non-array JSON', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: JSON.stringify({ name: 'not-array' }), - }) - - const result = await lsNpm(createMockEnvDetails(NPM)) - - expect(result).toBe('') - }) - }) - - describe('parsableToQueryStdout edge cases', () => { - it('handles empty parsable output', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '' }) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - ) - - expect(result).toBe('') - }) - - it('handles Windows-style paths', async () => { - mockSpawn.mockResolvedValueOnce({ - stdout: 'C:\\Users\\test\\node_modules\\lodash\n', - }) - - const result = await lsPnpm( - createMockEnvDetails(PNPM, '/usr/local/bin/pnpm'), - ) - - // Should extract 'lodash' from the path. - expect(result).toBeTruthy() - }) - }) - - describe('cwd option', () => { - it('uses provided cwd option', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '' }) - - await lsNpm(createMockEnvDetails(NPM), { cwd: '/custom/path' }) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ cwd: '/custom/path' }), - ) - }) - - it('defaults to process.cwd when cwd not provided', async () => { - mockSpawn.mockResolvedValueOnce({ stdout: '' }) - - await lsNpm(createMockEnvDetails(NPM)) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ cwd: process.cwd() }), - ) - }) - }) - - describe('cleanupQueryStdout', () => { - it('returns empty string for empty input', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - expect(cleanupQueryStdout('')).toBe('') - }) - - it('returns empty string for malformed JSON', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - expect(cleanupQueryStdout('{not json')).toBe('') - }) - - it('returns empty string for non-array result', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - expect(cleanupQueryStdout('{}')).toBe('') - }) - - it('returns empty string for empty array', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - expect(cleanupQueryStdout('[]')).toBe('') - }) - - it('extracts unique names and skips @types/* packages', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = cleanupQueryStdout( - JSON.stringify([ - { name: 'lodash', _id: 'lodash@4.17.21' }, - { name: 'react', _id: 'react@18.0.0' }, - { name: 'lodash', _id: 'lodash@4.17.21' }, - { name: '@types/node', _id: '@types/node@20.0.0' }, - ]), - ) - expect(JSON.parse(result)).toEqual(['lodash', 'react']) - }) - - it('falls back to _id when name is missing', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = cleanupQueryStdout( - JSON.stringify([{ _id: 'fallback-pkg@1.0.0' }]), - ) - expect(JSON.parse(result)).toEqual(['fallback-pkg']) - }) - - it('falls back to pkgid when both name + _id are missing', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = cleanupQueryStdout( - JSON.stringify([{ pkgid: 'pkgid-pkg@2.0.0' }]), - ) - expect(JSON.parse(result)).toEqual(['pkgid-pkg']) - }) - - it('skips entries with no resolvable name', async () => { - const { cleanupQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = cleanupQueryStdout(JSON.stringify([{}, {}])) - expect(JSON.parse(result)).toEqual([]) - }) - }) - - describe('parsableToQueryStdout', () => { - it('returns empty string for empty input', async () => { - const { parsableToQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - expect(parsableToQueryStdout('')).toBe('') - }) - - it('extracts trailing path segments before newlines', async () => { - const { parsableToQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = parsableToQueryStdout( - '/Users/x/proj/node_modules/lodash\n/Users/x/proj/node_modules/react\n', - ) - expect(typeof result).toBe('string') - }) - - it('handles backslash paths (Windows-style)', async () => { - const { parsableToQueryStdout } = - await import('../../../../src/commands/optimize/ls-by-agent.mts') - const result = parsableToQueryStdout( - 'C:\\proj\\node_modules\\lodash\nC:\\proj\\node_modules\\react\n', - ) - expect(typeof result).toBe('string') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/output-optimize-result.test.mts b/packages/cli/test/unit/commands/optimize/output-optimize-result.test.mts deleted file mode 100644 index 80cc129a9..000000000 --- a/packages/cli/test/unit/commands/optimize/output-optimize-result.test.mts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Unit tests for optimize result output. - * - * Purpose: Tests the outputOptimizeResult function for different output - * formats. - * - * Test Coverage: - JSON output format - Markdown output format - Text output - * format - Error handling. - * - * Related Files: - commands/optimize/output-optimize-result.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - success: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/words/pluralize', () => ({ - pluralize: (word: string, options: { count: number }) => - options.count === 1 ? word : `${word}s`, -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (message: string, cause?: string) => - cause ? `${message}: ${cause}` : message, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdError: (message: string, cause?: string) => - cause ? `## Error\n${message}: ${cause}` : `## Error\n${message}`, - mdHeader: (title: string) => `# ${title}`, - mdList: (items: string[]) => items.map(i => `- ${i}`).join('\n'), -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result), -})) - -import { outputOptimizeResult } from '../../../../src/commands/optimize/output-optimize-result.mts' - -describe('output-optimize-result', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = 0 - }) - - describe('outputOptimizeResult', () => { - it('outputs JSON for successful result', async () => { - const result = { - ok: true as const, - data: { - addedCount: 2, - updatedCount: 1, - pkgJsonChanged: true, - updatedInWorkspaces: 0, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok":true'), - ) - }) - - it('outputs JSON for error result', async () => { - const result = { - ok: false as const, - message: 'Something went wrong', - code: 1, - } - - await outputOptimizeResult(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok":false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('outputs markdown for successful result with changes', async () => { - const result = { - ok: true as const, - data: { - addedCount: 3, - updatedCount: 2, - pkgJsonChanged: true, - updatedInWorkspaces: 1, - addedInWorkspaces: 2, - }, - } - - await outputOptimizeResult(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith('# Optimize Complete') - }) - - it('outputs markdown for successful result without changes', async () => { - const result = { - ok: true as const, - data: { - addedCount: 0, - updatedCount: 0, - pkgJsonChanged: false, - updatedInWorkspaces: 0, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'No Socket.dev optimized overrides applied.', - ) - }) - - it('outputs markdown error for failed result', async () => { - const result = { - ok: false as const, - message: 'Failed to optimize', - cause: 'Network error', - code: 1, - } - - await outputOptimizeResult(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Error'), - ) - }) - - it('outputs text for successful result with updates', async () => { - const result = { - ok: true as const, - data: { - addedCount: 0, - updatedCount: 3, - pkgJsonChanged: true, - updatedInWorkspaces: 2, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Updated'), - ) - expect(mockLogger.success).toHaveBeenCalledWith('Finished!') - }) - - it('outputs text for successful result with additions', async () => { - const result = { - ok: true as const, - data: { - addedCount: 5, - updatedCount: 0, - pkgJsonChanged: true, - updatedInWorkspaces: 0, - addedInWorkspaces: 3, - }, - } - - await outputOptimizeResult(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Added'), - ) - }) - - it('outputs text for no changes', async () => { - const result = { - ok: true as const, - data: { - addedCount: 0, - updatedCount: 0, - pkgJsonChanged: false, - updatedInWorkspaces: 0, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Scan complete. No Socket.dev optimized overrides applied.', - ) - }) - - it('outputs text error for failed result', async () => { - const result = { - ok: false as const, - message: 'Optimization failed', - code: 1, - } - - await outputOptimizeResult(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('falls back to exitCode 1 when result.code is undefined', async () => { - const result = { - ok: false as const, - message: 'No code given', - } - - await outputOptimizeResult(result, 'text') - - expect(process.exitCode).toBe(1) - }) - - it('emits both Updated and Added markdown changes when both counts > 0', async () => { - const result = { - ok: true as const, - data: { - addedCount: 4, - updatedCount: 2, - pkgJsonChanged: true, - updatedInWorkspaces: 0, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'markdown') - - const logs = mockLogger.log.mock.calls.flat().join('\n') - expect(logs).toContain('Updated') - expect(logs).toContain('Added') - }) - - it('text mode appends "." when both updated and added counts > 0', async () => { - const result = { - ok: true as const, - data: { - addedCount: 5, - updatedCount: 3, - pkgJsonChanged: true, - updatedInWorkspaces: 0, - addedInWorkspaces: 0, - }, - } - - await outputOptimizeResult(result, 'text') - - // When addedCount > 0, the Updated line ends in "." (not 🚀). - const logs = mockLogger.log.mock.calls.flat().join('\n') - expect(logs).toContain('Updated') - // Updated has "." separator before Added when both fire. - expect(logs).toMatch(/Updated.*\.\s*$/m) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/update-dependencies.test.mts b/packages/cli/test/unit/commands/optimize/update-dependencies.test.mts deleted file mode 100644 index 29d8060ef..000000000 --- a/packages/cli/test/unit/commands/optimize/update-dependencies.test.mts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Unit tests for update-dependencies. - * - * Purpose: Tests the updateDependencies function that runs package manager - * install. - * - * Test Coverage: - Successful install - Install failure - npm buggy overrides - * logging - Spinner behavior. - * - * Related Files: - commands/optimize/update-dependencies.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockSpinner = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - text: vi.fn(), - isSpinning: false, -})) - -const mockDefaultSpinner = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), -})) - -const mockRunAgentInstall = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockDefaultSpinner, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -vi.mock('../../../../src/commands/optimize/agent-installer.mts', () => ({ - runAgentInstall: mockRunAgentInstall, -})) - -vi.mock('../../../../src/constants/packages.mts', () => ({ - NPM_BUGGY_OVERRIDES_PATCHED_VERSION: '10.9.0', -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - cmdPrefixMessage: (cmd: string, msg: string) => - cmd ? `${cmd}: ${msg}` : msg, -})) - -import { updateDependencies } from '../../../../src/commands/optimize/update-dependencies.mts' - -import type { EnvDetails } from '../../../../src/util/ecosystem/environment.mjs' - -describe('update-dependencies', () => { - const mockEnvDetails = { - agent: 'npm', - agentVersion: '10.0.0', - lockName: 'package-lock.json', - pkgPath: '/test/project', - features: { - npmBuggyOverrides: false, - }, - } as unknown as EnvDetails - - beforeEach(() => { - vi.clearAllMocks() - mockRunAgentInstall.mockResolvedValue(undefined) - mockSpinner.isSpinning = false - }) - - describe('updateDependencies', () => { - it('returns success on successful install', async () => { - const result = await updateDependencies(mockEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(result.ok).toBe(true) - expect(mockRunAgentInstall).toHaveBeenCalledWith(mockEnvDetails, { - spinner: mockSpinner, - }) - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Updating package-lock.json...', - ) - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('returns error on install failure', async () => { - mockRunAgentInstall.mockRejectedValue(new Error('Install failed')) - - const result = await updateDependencies(mockEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Dependencies update failed') - expect(result.cause).toContain('npm install failed') - } - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('logs message when npmBuggyOverrides is true', async () => { - const envWithBuggyOverrides = { - ...mockEnvDetails, - features: { - npmBuggyOverrides: true, - }, - } as unknown as EnvDetails - - await updateDependencies(envWithBuggyOverrides, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Re-run optimize'), - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('10.9.0'), - ) - }) - - it('works without spinner', async () => { - const result = await updateDependencies(mockEnvDetails, { - cmdName: 'test', - logger: mockLogger, - }) - - expect(result.ok).toBe(true) - expect(mockRunAgentInstall).toHaveBeenCalled() - }) - - it('restarts spinner if it was spinning before error', async () => { - mockSpinner.isSpinning = true - mockRunAgentInstall.mockRejectedValue(new Error('Install failed')) - - await updateDependencies(mockEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(mockDefaultSpinner.start).toHaveBeenCalled() - }) - - it('restarts spinner if it was spinning after success', async () => { - mockSpinner.isSpinning = true - - await updateDependencies(mockEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(mockDefaultSpinner.start).toHaveBeenCalled() - }) - - it('does not restart spinner if it was not spinning', async () => { - mockSpinner.isSpinning = false - - await updateDependencies(mockEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(mockDefaultSpinner.start).not.toHaveBeenCalled() - }) - - it('handles different package managers', async () => { - const pnpmEnvDetails = { - ...mockEnvDetails, - agent: 'pnpm', - lockName: 'pnpm-lock.yaml', - } as unknown as EnvDetails - - const result = await updateDependencies(pnpmEnvDetails, { - cmdName: 'optimize', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(result.ok).toBe(true) - expect(mockSpinner.start).toHaveBeenCalledWith( - 'Updating pnpm-lock.yaml...', - ) - }) - - it('works with empty cmdName', async () => { - const envWithBuggyOverrides = { - ...mockEnvDetails, - features: { - npmBuggyOverrides: true, - }, - } as unknown as EnvDetails - - await updateDependencies(envWithBuggyOverrides, { - cmdName: '', - logger: mockLogger, - spinner: mockSpinner, - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Re-run '), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/update-manifest-by-agent.test.mts b/packages/cli/test/unit/commands/optimize/update-manifest-by-agent.test.mts deleted file mode 100644 index 8d05cd130..000000000 --- a/packages/cli/test/unit/commands/optimize/update-manifest-by-agent.test.mts +++ /dev/null @@ -1,537 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for update-manifest-by-agent. - * - * Purpose: Tests the functions that update package.json overrides for different - * package managers. - * - * Test Coverage: - updateOverridesField - updateResolutionsField - - * updatePnpmField - updateManifest. - * - * Related Files: - commands/optimize/update-manifest-by-agent.mts - * (implementation) - */ - -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - updateManifest, - updateOverridesField, - updatePkgJsonField, - updatePnpmField, - updateResolutionsField, - usesPnpmWorkspaceOverrides, -} from '../../../../src/commands/optimize/update-manifest-by-agent.mts' - -import type { EditablePackageJson } from '@socketsecurity/lib-stable/packages/types' - -describe('update-manifest-by-agent', () => { - const createEditablePkgJson = (content: Record<string, unknown> = {}) => { - const pkgJson: EditablePackageJson = { - content, - fromJSON: vi.fn((json: string) => { - const parsed = JSON.parse(json) - Object.assign(pkgJson.content, parsed) - }), - indent: ' ', - newline: '\n', - save: vi.fn(), - update: vi.fn((updates: Record<string, unknown>) => { - Object.assign(pkgJson.content, updates) - }), - } as unknown as EditablePackageJson - return pkgJson - } - - describe('updateOverridesField', () => { - it('updates existing overrides field', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - overrides: { lodash: '4.17.20' }, - }) - updateOverridesField(pkgJson, { lodash: '4.17.21' }) - expect(pkgJson.update).toHaveBeenCalledWith({ - overrides: { lodash: '4.17.21' }, - }) - }) - - it('removes overrides field when empty', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - overrides: { lodash: '4.17.20' }, - }) - updateOverridesField(pkgJson, {}) - expect(pkgJson.update).toHaveBeenCalledWith({ overrides: undefined }) - }) - - it('does not add field for empty overrides when field does not exist', () => { - const pkgJson = createEditablePkgJson({ name: 'test' }) - updateOverridesField(pkgJson, {}) - expect(pkgJson.update).not.toHaveBeenCalled() - expect(pkgJson.fromJSON).not.toHaveBeenCalled() - }) - - it('adds new overrides field when it does not exist', () => { - const pkgJson = createEditablePkgJson({ name: 'test' }) - updateOverridesField(pkgJson, { lodash: '4.17.21' }) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - }) - - describe('updateResolutionsField', () => { - it('updates existing resolutions field', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - resolutions: { typescript: '5.0.0' }, - }) - updateResolutionsField(pkgJson, { typescript: '5.1.0' }) - expect(pkgJson.update).toHaveBeenCalledWith({ - resolutions: { typescript: '5.1.0' }, - }) - }) - - it('removes resolutions field when empty', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - resolutions: { typescript: '5.0.0' }, - }) - updateResolutionsField(pkgJson, {}) - expect(pkgJson.update).toHaveBeenCalledWith({ resolutions: undefined }) - }) - }) - - describe('updatePnpmField', () => { - it('updates existing pnpm.overrides field', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: { overrides: { express: '4.17.0' } }, - }) - updatePnpmField(pkgJson, { express: '4.18.0' }) - expect(pkgJson.update).toHaveBeenCalled() - const updateArg = (pkgJson.update as unknown).mock.calls[0][0] - expect(updateArg.pnpm.overrides).toEqual({ express: '4.18.0' }) - }) - - it('removes pnpm.overrides when empty and pnpm has no keys', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: { overrides: { express: '4.17.0' } }, - }) - updatePnpmField(pkgJson, {}) - // When pnpm only has overrides and we're removing it, the whole pnpm field is removed. - expect(pkgJson.update).toHaveBeenCalled() - }) - - it('removes only overrides when pnpm has other fields', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: { - overrides: { express: '4.17.0' }, - patchedDependencies: { 'foo@1.0.0': 'patches/foo.patch' }, - }, - }) - updatePnpmField(pkgJson, {}) - expect(pkgJson.update).toHaveBeenCalled() - }) - - it('handles non-object pnpm value', () => { - const pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: 'invalid', - }) - updatePnpmField(pkgJson, { express: '4.18.0' }) - expect(pkgJson.update).toHaveBeenCalled() - }) - - it('clears non-object pnpm value when overrides empty (line 135-138)', () => { - // pnpm exists but isn't an object (e.g. a string value) AND the - // incoming overrides are empty — the only safe move is to drop - // the whole `pnpm` field by setting it to undefined. - const pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: 'invalid', - }) - updatePnpmField(pkgJson, {}) - expect(pkgJson.update).toHaveBeenCalledWith({ pnpm: undefined }) - }) - }) - - describe('updateManifest', () => { - let pkgJson: EditablePackageJson - - beforeEach(() => { - pkgJson = createEditablePkgJson({ - name: 'test', - }) - }) - - // Build a minimal EnvDetails-shaped object. Production code only - // touches `editablePkgJson`, `agent`, `agentVersion`, `pkgPath`. - const makeEnv = (overrides: Record<string, unknown> = {}) => - ({ - agent: 'pnpm', - agentVersion: { major: 10, minor: 0, patch: 0 }, - editablePkgJson: pkgJson, - pkgPath: '/tmp/test-pkg', - ...overrides, - }) as unknown - - it('uses resolutions for bun agent', async () => { - await updateManifest('bun', makeEnv({ agent: 'bun' }), { - lodash: '4.17.21', - }) - // Since field doesn't exist, fromJSON is called. - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses pnpm field for pnpm 10 agent (legacy package.json path)', async () => { - await updateManifest( - 'pnpm', - makeEnv({ - agent: 'pnpm', - agentVersion: { major: 10, minor: 0, patch: 0 }, - }), - { lodash: '4.17.21' }, - ) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses overrides for vlt agent', async () => { - await updateManifest('vlt', makeEnv({ agent: 'vlt' }), { - lodash: '4.17.21', - }) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses resolutions for yarn/berry agent', async () => { - await updateManifest('yarn/berry', makeEnv({ agent: 'yarn/berry' }), { - lodash: '4.17.21', - }) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses resolutions for yarn/classic agent', async () => { - await updateManifest('yarn/classic', makeEnv({ agent: 'yarn/classic' }), { - lodash: '4.17.21', - }) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses overrides for npm agent (default)', async () => { - await updateManifest('npm', makeEnv({ agent: 'npm' }), { - lodash: '4.17.21', - }) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('uses overrides for unknown agent', async () => { - await updateManifest( - 'unknown' as unknown, - makeEnv({ agent: 'unknown' }), - { - lodash: '4.17.21', - }, - ) - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - - it('updates existing resolutions for yarn', async () => { - pkgJson = createEditablePkgJson({ - name: 'test', - resolutions: { typescript: '5.0.0' }, - }) - await updateManifest( - 'yarn/berry', - makeEnv({ agent: 'yarn/berry', editablePkgJson: pkgJson }), - { lodash: '4.17.21' }, - ) - expect(pkgJson.update).toHaveBeenCalledWith({ - resolutions: { lodash: '4.17.21' }, - }) - }) - - it('updates existing overrides for npm', async () => { - pkgJson = createEditablePkgJson({ - name: 'test', - overrides: { express: '4.17.0' }, - }) - await updateManifest( - 'npm', - makeEnv({ agent: 'npm', editablePkgJson: pkgJson }), - { lodash: '4.17.21' }, - ) - expect(pkgJson.update).toHaveBeenCalledWith({ - overrides: { lodash: '4.17.21' }, - }) - }) - - it('places new overrides AFTER main when no engines/files anchor exists', async () => { - // No engines/files -> falls through to getHighestEntryIndex(['exports','imports','main']), - // which returns the position of `main`; we then place at that index + 1 - // because isPlacingHigher = true. Verifies the L130 +1 path. - pkgJson = createEditablePkgJson({ - name: 'test', - version: '1.0.0', - main: 'index.js', - scripts: {}, - }) - await updateManifest( - 'npm', - makeEnv({ agent: 'npm', editablePkgJson: pkgJson }), - { lodash: '4.17.21' }, - ) - - expect(pkgJson.fromJSON).toHaveBeenCalled() - // The new content should include overrides positioned after main. - const fromJsonCall = (pkgJson.fromJSON as unknown).mock - .calls[0][0] as string - const parsed = JSON.parse(fromJsonCall) - const keys = Object.keys(parsed) - expect(keys.indexOf('overrides')).toBeGreaterThan(keys.indexOf('main')) - expect(parsed.overrides).toEqual({ lodash: '4.17.21' }) - }) - - describe('pnpm 11+ integration (writes to pnpm-workspace.yaml)', () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync( - path.join(os.tmpdir(), 'socket-cli-update-manifest-test-'), - ) - }) - - afterEach(() => { - rmSync(tmpDir, { force: true, recursive: true }) - }) - - it('writes overrides to pnpm-workspace.yaml when pnpm@11+', async () => { - pkgJson = createEditablePkgJson({ name: 'test' }) - await updateManifest( - 'pnpm', - makeEnv({ - agent: 'pnpm', - agentVersion: { major: 11, minor: 0, patch: 8 }, - editablePkgJson: pkgJson, - pkgPath: tmpDir, - }), - { lodash: '4.17.21' }, - ) - - const yamlContent = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(yamlContent).toContain('overrides:') - expect(yamlContent).toContain('lodash: 4.17.21') - }) - - it('clears stale pnpm.overrides from package.json when routing to YAML', async () => { - // package.json has a leftover pnpm.overrides block from the - // pnpm-10 era. Writing via the YAML path should clear it so - // the legacy block doesn't drift silently. - pkgJson = createEditablePkgJson({ - name: 'test', - pnpm: { overrides: { 'old-pkg': '1.0.0' } }, - }) - await updateManifest( - 'pnpm', - makeEnv({ - agent: 'pnpm', - agentVersion: { major: 11, minor: 0, patch: 8 }, - editablePkgJson: pkgJson, - pkgPath: tmpDir, - }), - { lodash: '4.17.21' }, - ) - - // package.json's pnpm block should be cleared (update was called - // with pnpm: undefined or pnpm: <object without overrides>). - const updateCalls = (pkgJson.update as unknown).mock.calls as Array< - [Record<string, unknown>] - > - const pnpmCalls = updateCalls.filter(([arg]) => 'pnpm' in arg) - expect(pnpmCalls.length).toBeGreaterThan(0) - // Latest pnpm-related update should not still have an overrides - // member (or the whole pnpm field should be undefined). - const lastPnpm = pnpmCalls.at(-1)![0].pnpm as - | undefined - | { overrides?: unknown | undefined } - expect( - lastPnpm === undefined || - lastPnpm === null || - !('overrides' in (lastPnpm ?? {})) || - !(lastPnpm as unknown).overrides, - ).toBe(true) - }) - - it('preserves a pre-existing pnpm-workspace.yaml when merging', async () => { - const fs = await import('node:fs') - fs.writeFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - `# Header -packages: - - .claude/hooks/* - -minimumReleaseAge: 10080 -`, - 'utf8', - ) - pkgJson = createEditablePkgJson({ name: 'test' }) - await updateManifest( - 'pnpm', - makeEnv({ - agent: 'pnpm', - agentVersion: { major: 11, minor: 0, patch: 8 }, - editablePkgJson: pkgJson, - pkgPath: tmpDir, - }), - { lodash: '4.17.21' }, - ) - - const yamlContent = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(yamlContent).toContain('# Header') - expect(yamlContent).toContain('packages:') - expect(yamlContent).toContain('minimumReleaseAge: 10080') - expect(yamlContent).toContain('overrides:') - expect(yamlContent).toContain('lodash: 4.17.21') - }) - - it('routes to package.json when pnpm < 11 (legacy path)', async () => { - pkgJson = createEditablePkgJson({ name: 'test' }) - await updateManifest( - 'pnpm', - makeEnv({ - agent: 'pnpm', - agentVersion: { major: 10, minor: 12, patch: 0 }, - editablePkgJson: pkgJson, - pkgPath: tmpDir, - }), - { lodash: '4.17.21' }, - ) - - // Should NOT have created pnpm-workspace.yaml. - const fs = await import('node:fs') - expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe( - false, - ) - // Should have written to package.json (via fromJSON since field - // didn't exist). - expect(pkgJson.fromJSON).toHaveBeenCalled() - }) - }) - }) - - describe('usesPnpmWorkspaceOverrides', () => { - it('returns true for pnpm 11+', () => { - expect( - usesPnpmWorkspaceOverrides({ - agent: 'pnpm', - agentVersion: { major: 11, minor: 0, patch: 8 } as unknown, - }), - ).toBe(true) - }) - - it('returns true for pnpm 12+', () => { - expect( - usesPnpmWorkspaceOverrides({ - agent: 'pnpm', - agentVersion: { major: 12, minor: 0, patch: 0 } as unknown, - }), - ).toBe(true) - }) - - it('returns false for pnpm 10', () => { - expect( - usesPnpmWorkspaceOverrides({ - agent: 'pnpm', - agentVersion: { major: 10, minor: 12, patch: 0 } as unknown, - }), - ).toBe(false) - }) - - it('returns false for pnpm 9', () => { - expect( - usesPnpmWorkspaceOverrides({ - agent: 'pnpm', - agentVersion: { major: 9, minor: 0, patch: 0 } as unknown, - }), - ).toBe(false) - }) - - it('returns false for non-pnpm agents (regardless of version)', () => { - expect( - usesPnpmWorkspaceOverrides({ - agent: 'npm', - agentVersion: { major: 11, minor: 0, patch: 0 } as unknown, - }), - ).toBe(false) - expect( - usesPnpmWorkspaceOverrides({ - agent: 'yarn/berry', - agentVersion: { major: 11, minor: 0, patch: 0 } as unknown, - }), - ).toBe(false) - }) - }) - - describe('getEntryIndexes / getLowestEntryIndex / getHighestEntryIndex', () => { - const entries: Array<[string, unknown]> = [ - ['name', 'x'], - ['version', '1.0.0'], - ['main', 'index.js'], - ['exports', {}], - ['scripts', {}], - ] - - it('getEntryIndexes returns sorted indices for matching keys', async () => { - const { getEntryIndexes } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getEntryIndexes(entries, ['exports', 'main'])).toEqual([2, 3]) - }) - - it('getEntryIndexes filters out keys not present', async () => { - const { getEntryIndexes } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getEntryIndexes(entries, ['nope', 'main'])).toEqual([2]) - }) - - it('getLowestEntryIndex returns -1 when no keys match', async () => { - const { getLowestEntryIndex } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getLowestEntryIndex(entries, ['nope'])).toBe(-1) - }) - - it('getLowestEntryIndex returns the smallest matching index', async () => { - const { getLowestEntryIndex } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getLowestEntryIndex(entries, ['scripts', 'main'])).toBe(2) - }) - - it('getHighestEntryIndex returns -1 when no keys match', async () => { - const { getHighestEntryIndex } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getHighestEntryIndex(entries, ['nope'])).toBe(-1) - }) - - it('getHighestEntryIndex returns the largest matching index', async () => { - const { getHighestEntryIndex } = - await import('../../../../src/commands/optimize/update-manifest-by-agent.mts') - expect(getHighestEntryIndex(entries, ['main', 'scripts'])).toBe(4) - }) - }) - - describe('updatePkgJsonField with non-overrides field', () => { - it('updates non-OVERRIDES/RESOLUTIONS/PNPM field by simple assignment', () => { - const pkgJson = createEditablePkgJson({ name: 'test', custom: 'old' }) - updatePkgJsonField(pkgJson, 'custom', 'new-value') - expect(pkgJson.update).toHaveBeenCalledWith({ custom: 'new-value' }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/optimize/update-pnpm-workspace-yaml.test.mts b/packages/cli/test/unit/commands/optimize/update-pnpm-workspace-yaml.test.mts deleted file mode 100644 index 2fecaa9cd..000000000 --- a/packages/cli/test/unit/commands/optimize/update-pnpm-workspace-yaml.test.mts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Unit tests for update-pnpm-workspace-yaml. - * - * Purpose: Tests the YAML-write path used when the host repo declares pnpm@11+ - * in its `packageManager` field. Comments and non-overrides keys must be - * preserved across edits. - * - * Test Coverage: - updatePnpmWorkspaceYamlOverrides. - * - * Related Files: - commands/optimize/update-pnpm-workspace-yaml.mts - * (implementation) - */ - -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { updatePnpmWorkspaceYamlOverrides } from '../../../../src/commands/optimize/update-pnpm-workspace-yaml.mts' - -describe('updatePnpmWorkspaceYamlOverrides', () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), 'socket-cli-yaml-test-')) - }) - - afterEach(() => { - rmSync(tmpDir, { force: true, recursive: true }) - }) - - it('creates a new pnpm-workspace.yaml when missing', async () => { - await updatePnpmWorkspaceYamlOverrides(tmpDir, { - lodash: '4.17.21', - }) - const yamlPath = path.join(tmpDir, 'pnpm-workspace.yaml') - const content = readFileSync(yamlPath, 'utf8') - expect(content).toContain('overrides:') - expect(content).toContain('lodash: 4.17.21') - }) - - it('adds an overrides block to an existing file that lacks one', async () => { - const existing = `# Header comment -packages: - - .claude/hooks/* - -minimumReleaseAge: 10080 -` - writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), existing, 'utf8') - await updatePnpmWorkspaceYamlOverrides(tmpDir, { - lodash: '4.17.21', - }) - const content = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(content).toContain('# Header comment') - expect(content).toContain('packages:') - expect(content).toContain('minimumReleaseAge: 10080') - expect(content).toContain('overrides:') - expect(content).toContain('lodash: 4.17.21') - }) - - it('merges new entries into an existing overrides block', async () => { - const existing = `# Header comment -overrides: - semver: 7.7.4 - glob: '>=13.0.6' - -minimumReleaseAge: 10080 -` - writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), existing, 'utf8') - await updatePnpmWorkspaceYamlOverrides(tmpDir, { - lodash: '4.17.21', - }) - const content = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(content).toContain('# Header comment') - expect(content).toContain('semver: 7.7.4') - // The original `glob: '>=13.0.6'` constraint must survive the merge. - expect(content).toContain('glob:') - expect(content).toContain('>=13.0.6') - expect(content).toContain('lodash: 4.17.21') - expect(content).toContain('minimumReleaseAge: 10080') - }) - - it('overwrites existing override values when keys collide', async () => { - const existing = `overrides: - lodash: 4.17.20 -` - writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), existing, 'utf8') - await updatePnpmWorkspaceYamlOverrides(tmpDir, { - lodash: '4.17.21', - }) - const content = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(content).toContain('lodash: 4.17.21') - expect(content).not.toContain('lodash: 4.17.20') - }) - - it('preserves catalog: blocks and other top-level keys', async () => { - const existing = `packages: - - .claude/hooks/* - -catalog: - '@socketsecurity/lib-stable': 5.28.0 - -# Soak window -minimumReleaseAge: 10080 -minimumReleaseAgeExclude: - - '@socketsecurity/*' -` - writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), existing, 'utf8') - await updatePnpmWorkspaceYamlOverrides(tmpDir, { - lodash: '4.17.21', - }) - const content = readFileSync( - path.join(tmpDir, 'pnpm-workspace.yaml'), - 'utf8', - ) - expect(content).toContain('packages:') - expect(content).toContain('.claude/hooks/*') - expect(content).toContain('catalog:') - expect(content).toContain("'@socketsecurity/lib-stable': 5.28.0") - expect(content).toContain('# Soak window') - expect(content).toContain('minimumReleaseAge: 10080') - expect(content).toContain("'@socketsecurity/*'") - expect(content).toContain('overrides:') - expect(content).toContain('lodash: 4.17.21') - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-dependencies.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-dependencies.test.mts deleted file mode 100644 index aa10d57a0..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-dependencies.test.mts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Unit tests for organization dependencies command. - * - * Tests the command that searches for dependencies being used in an - * organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleDependencies = vi.hoisted(() => vi.fn()) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock( - '../../../../src/commands/organization/handle-dependencies.mts', - () => ({ - handleDependencies: mockHandleDependencies, - }), -) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdOrganizationDependencies } = - await import('../../../../src/commands/organization/cmd-organization-dependencies.mts') - -describe('cmd-organization-dependencies', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationDependencies.description).toBe( - 'Search for any dependency that is being used in your organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganizationDependencies.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-organization-dependencies.mts' } - const context = { parentName: 'socket organization' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run(['--dry-run'], importMeta, context) - - expect(mockHandleDependencies).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationDependencies.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should call handleDependencies with default parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run([], importMeta, context) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 50, - offset: 0, - outputKind: 'text', - }) - }) - - it('should pass --limit flag to handleDependencies', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--limit', '20'], - importMeta, - context, - ) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 20, - offset: 0, - outputKind: 'text', - }) - }) - - it('should pass --offset flag to handleDependencies', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--offset', '10'], - importMeta, - context, - ) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 50, - offset: 10, - outputKind: 'text', - }) - }) - - it('should pass both --limit and --offset flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--limit', '20', '--offset', '10'], - importMeta, - context, - ) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 20, - offset: 10, - outputKind: 'text', - }) - }) - - it('should pass --json flag to handleDependencies', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run(['--json'], importMeta, context) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 50, - offset: 0, - outputKind: 'json', - }) - }) - - it('should pass --markdown flag to handleDependencies', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run(['--markdown'], importMeta, context) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 50, - offset: 0, - outputKind: 'markdown', - }) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should validate negative limit value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdOrganizationDependencies.run(['--limit', '-1'], importMeta, context), - ).rejects.toThrow(/--limit must be a non-negative integer \(saw: "-1"\)/) - - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should validate negative offset value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdOrganizationDependencies.run( - ['--offset', '-1'], - importMeta, - context, - ), - ).rejects.toThrow(/--offset must be a non-negative integer \(saw: "-1"\)/) - - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should validate non-numeric limit value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdOrganizationDependencies.run( - ['--limit', 'invalid'], - importMeta, - context, - ), - ).rejects.toThrow( - /--limit must be a non-negative integer \(saw: "invalid"\)/, - ) - - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should validate non-numeric offset value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdOrganizationDependencies.run( - ['--offset', 'invalid'], - importMeta, - context, - ), - ).rejects.toThrow( - /--offset must be a non-negative integer \(saw: "invalid"\)/, - ) - - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - - it('should show query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining( - '[DryRun]: Would fetch organization dependencies', - ), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('limit: 50'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('offset: 0'), - ) - }) - - it('should show custom query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--dry-run', '--limit', '100', '--offset', '25'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('limit: 100'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('offset: 25'), - ) - }) - - it('should handle limit of zero by using default', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--limit', '0'], - importMeta, - context, - ) - - expect(mockHandleDependencies).toHaveBeenCalledWith({ - limit: 0, - offset: 0, - outputKind: 'text', - }) - }) - - it('shows fallback limit=50 in dry-run when --limit=0', async () => { - // Exercises the `validatedLimit || 50` fallback at line 111. - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationDependencies.run( - ['--dry-run', '--limit', '0'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('limit: 50'), - ) - expect(mockHandleDependencies).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-list.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-list.test.mts deleted file mode 100644 index 1961449d0..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-list.test.mts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Unit tests for organization list command. - * - * Tests the command that lists organizations associated with the Socket API - * token. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleOrganizationList = vi.hoisted(() => vi.fn()) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock( - '../../../../src/commands/organization/handle-organization-list.mts', - () => ({ - handleOrganizationList: mockHandleOrganizationList, - }), -) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdOrganizationList } = - await import('../../../../src/commands/organization/cmd-organization-list.mts') - -describe('cmd-organization-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationList.description).toBe( - 'List organizations associated with the Socket API token', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganizationList.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-organization-list.mts' } - const context = { parentName: 'socket organization' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run(['--dry-run'], importMeta, context) - - expect(mockHandleOrganizationList).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationList.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleOrganizationList).not.toHaveBeenCalled() - }) - - it('should call handleOrganizationList with valid token and text output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run([], importMeta, context) - - expect(mockHandleOrganizationList).toHaveBeenCalledWith('text') - }) - - it('should pass --json flag to handleOrganizationList', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run(['--json'], importMeta, context) - - expect(mockHandleOrganizationList).toHaveBeenCalledWith('json') - }) - - it('should pass --markdown flag to handleOrganizationList', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run(['--markdown'], importMeta, context) - - expect(mockHandleOrganizationList).toHaveBeenCalledWith('markdown') - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run( - ['--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleOrganizationList).not.toHaveBeenCalled() - }) - - it('should show query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationList.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('[DryRun]: Would fetch organizations'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-policy-license.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-policy-license.test.mts deleted file mode 100644 index 3ac3fd177..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-policy-license.test.mts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Unit tests for organization policy license command. - * - * Tests the command that retrieves the license policy of an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleLicensePolicy = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock( - '../../../../src/commands/organization/handle-license-policy.mts', - () => ({ - handleLicensePolicy: mockHandleLicensePolicy, - }), -) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdOrganizationPolicyLicense } = - await import('../../../../src/commands/organization/cmd-organization-policy-license.mts') - -describe('cmd-organization-policy-license', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationPolicyLicense.description).toBe( - 'Retrieve the license policy of an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganizationPolicyLicense.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { - url: 'file:///test/cmd-organization-policy-license.mts', - } - const context = { parentName: 'socket organization policy' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run(['--dry-run'], importMeta, context) - - expect(mockHandleLicensePolicy).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationPolicyLicense.run( - ['--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleLicensePolicy).not.toHaveBeenCalled() - }) - - it('should call handleLicensePolicy with valid token and text output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleLicensePolicy).toHaveBeenCalledWith('test-org', 'text') - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleLicensePolicy).toHaveBeenCalledWith('custom-org', 'text') - }) - - it('should pass --json flag to handleLicensePolicy', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleLicensePolicy).toHaveBeenCalledWith('test-org', 'json') - }) - - it('should pass --markdown flag to handleLicensePolicy', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleLicensePolicy).toHaveBeenCalledWith( - 'test-org', - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleLicensePolicy).not.toHaveBeenCalled() - }) - - it('should show query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining( - '[DryRun]: Would fetch organization license policy', - ), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: test-org'), - ) - }) - - it('should show undetermined org in dry-run mode when no org is available', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--dry-run', '--no-interactive'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: (will be determined)'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass no-interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should handle custom org with interactive mode', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['my-org', 'my-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--org', 'my-org', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('my-org', true, false) - expect(mockHandleLicensePolicy).toHaveBeenCalledWith('my-org', 'text') - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run(['--dry-run'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should combine org, json, and interactive flags correctly', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce([ - 'combined-org', - 'combined-org', - ]) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicyLicense.run( - ['--org', 'combined-org', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'combined-org', - false, - false, - ) - expect(mockHandleLicensePolicy).toHaveBeenCalledWith( - 'combined-org', - 'json', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-policy-security.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-policy-security.test.mts deleted file mode 100644 index 12045f27c..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-policy-security.test.mts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Unit tests for organization policy security command. - * - * Tests the command that retrieves the security policy of an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleSecurityPolicy = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock( - '../../../../src/commands/organization/handle-security-policy.mts', - () => ({ - handleSecurityPolicy: mockHandleSecurityPolicy, - }), -) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdOrganizationPolicySecurity } = - await import('../../../../src/commands/organization/cmd-organization-policy-security.mts') - -describe('cmd-organization-policy-security', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationPolicySecurity.description).toBe( - 'Retrieve the security policy of an organization', - ) - }) - - it('should be hidden', () => { - expect(cmdOrganizationPolicySecurity.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { - url: 'file:///test/cmd-organization-policy-security.mts', - } - const context = { parentName: 'socket organization policy' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--dry-run'], - importMeta, - context, - ) - - expect(mockHandleSecurityPolicy).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationPolicySecurity.run( - ['--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleSecurityPolicy).not.toHaveBeenCalled() - }) - - it('should call handleSecurityPolicy with valid token and text output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith('test-org', 'text') - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith( - 'custom-org', - 'text', - ) - }) - - it('should pass --json flag to handleSecurityPolicy', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith('test-org', 'json') - }) - - it('should pass --markdown flag to handleSecurityPolicy', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith( - 'test-org', - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleSecurityPolicy).not.toHaveBeenCalled() - }) - - it('should show query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--dry-run'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining( - '[DryRun]: Would fetch organization security policy', - ), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: test-org'), - ) - }) - - it('should show undetermined org in dry-run mode when no org is available', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--dry-run', '--no-interactive'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: (will be determined)'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass no-interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should handle custom org with interactive mode', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['my-org', 'my-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--org', 'my-org', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('my-org', true, false) - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith('my-org', 'text') - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--dry-run'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should combine org, json, and interactive flags correctly', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce([ - 'combined-org', - 'combined-org', - ]) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationPolicySecurity.run( - ['--org', 'combined-org', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'combined-org', - false, - false, - ) - expect(mockHandleSecurityPolicy).toHaveBeenCalledWith( - 'combined-org', - 'json', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-policy.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-policy.test.mts deleted file mode 100644 index c1b031bc0..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-policy.test.mts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Unit tests for organization policy parent command. - * - * Tests the parent command that routes to organization policy subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdOrganizationPolicy } = - await import('../../../../src/commands/organization/cmd-organization-policy.mts') -const { cmdOrganizationPolicyLicense } = - await import('../../../../src/commands/organization/cmd-organization-policy-license.mts') -const { cmdOrganizationPolicySecurity } = - await import('../../../../src/commands/organization/cmd-organization-policy-security.mts') - -describe('cmd-organization-policy', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationPolicy.description).toBe( - 'Organization policy details', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganizationPolicy.hidden).toBe(false) - }) - - it('should have a run method', () => { - expect(typeof cmdOrganizationPolicy.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-organization-policy.mts' } - const context = { parentName: 'socket organization' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run(['security'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['security'], - importMeta, - name: 'socket organization policy', - subcommands: { - license: cmdOrganizationPolicyLicense, - security: cmdOrganizationPolicySecurity, - }, - }, - { - defaultSub: 'list', - description: 'Organization policy details', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run(['license'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent policy', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual(['security', 'license']) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['security', '--json'] - - await cmdOrganizationPolicy.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['license']) as readonly string[] - - await cmdOrganizationPolicy.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run( - [], - { url: 'file:///test' }, - { parentName: 'socket organization' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.security).toBe(cmdOrganizationPolicySecurity) - expect(subcommands.license).toBe(cmdOrganizationPolicyLicense) - }) - }) - - describe('backwards compatibility', () => { - it('should set defaultSub to list for backwards compatibility', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run( - [], - { url: 'file:///test' }, - { parentName: 'socket organization' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.defaultSub).toBe('list') - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdOrganizationPolicy.run( - [], - { url: 'file:///test' }, - { parentName: 'socket organization' }, - ), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run( - [], - { url: 'file:///test' }, - { parentName: 'socket organization' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe('Organization policy details') - }) - - it('should include both description and defaultSub', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganizationPolicy.run( - [], - { url: 'file:///test' }, - { parentName: 'socket organization' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options).toEqual({ - defaultSub: 'list', - description: 'Organization policy details', - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts deleted file mode 100644 index 8a3f03e33..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Unit tests for organization quota command. - * - * Tests the command that lists organizations associated with the Socket API - * token and displays quota information. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleQuota = vi.hoisted(() => vi.fn()) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/organization/handle-quota.mts', () => ({ - handleQuota: mockHandleQuota, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdOrganizationQuota } = - await import('../../../../src/commands/organization/cmd-organization-quota.mts') - -describe('cmd-organization-quota', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganizationQuota.description).toBe( - 'Show remaining Socket API quota for the current token, plus refresh window', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganizationQuota.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-organization-quota.mts' } - const context = { parentName: 'socket organization' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run(['--dry-run'], importMeta, context) - - expect(mockHandleQuota).not.toHaveBeenCalled() - // Dry-run previews are contextual output; they route to stderr per - // the stream discipline rule so stdout stays payload-only. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization quota'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationQuota.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleQuota).not.toHaveBeenCalled() - }) - - it('should call handleQuota with default text output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run([], importMeta, context) - - expect(mockHandleQuota).toHaveBeenCalledWith('text') - }) - - it('should pass --json flag to handleQuota', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run(['--json'], importMeta, context) - - expect(mockHandleQuota).toHaveBeenCalledWith('json') - }) - - it('should pass --markdown flag to handleQuota', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run(['--markdown'], importMeta, context) - - expect(mockHandleQuota).toHaveBeenCalledWith('markdown') - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run( - ['--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleQuota).not.toHaveBeenCalled() - }) - - it('should validate input even without API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdOrganizationQuota.run( - ['--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleQuota).not.toHaveBeenCalled() - }) - - it('should not call handleQuota in dry-run mode even with valid token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run( - ['--dry-run', '--json'], - importMeta, - context, - ) - - expect(mockHandleQuota).not.toHaveBeenCalled() - // With --json, dry-run output routes to stderr so stdout stays - // pipe-safe for JSON consumers. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('[DryRun]'), - ) - }) - - it('should handle readonly argv array', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - const readonlyArgv: readonly string[] = ['--json'] - await cmdOrganizationQuota.run(readonlyArgv, importMeta, context) - - expect(mockHandleQuota).toHaveBeenCalledWith('json') - }) - - it('should handle empty flags and use text output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdOrganizationQuota.run([], importMeta, context) - - expect(mockHandleQuota).toHaveBeenCalledWith('text') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/cmd-organization.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization.test.mts deleted file mode 100644 index c1621f260..000000000 --- a/packages/cli/test/unit/commands/organization/cmd-organization.test.mts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Unit tests for organization parent command. - * - * Tests the parent command that routes to organization management subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdOrganization } = - await import('../../../../src/commands/organization/cmd-organization.mts') -const { cmdOrganizationDependencies } = - await import('../../../../src/commands/organization/cmd-organization-dependencies.mts') -const { cmdOrganizationList } = - await import('../../../../src/commands/organization/cmd-organization-list.mts') -const { cmdOrganizationPolicy } = - await import('../../../../src/commands/organization/cmd-organization-policy.mts') -const { cmdOrganizationPolicyLicense } = - await import('../../../../src/commands/organization/cmd-organization-policy-license.mts') -const { cmdOrganizationPolicySecurity } = - await import('../../../../src/commands/organization/cmd-organization-policy-security.mts') -const { cmdOrganizationQuota } = - await import('../../../../src/commands/organization/cmd-organization-quota.mts') - -describe('cmd-organization', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdOrganization.description).toBe( - 'Manage Socket organization account details', - ) - }) - - it('should not be hidden', () => { - expect(cmdOrganization.hidden).toBe(false) - }) - - it('should have a run method', () => { - expect(typeof cmdOrganization.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-organization.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run(['list'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['list'], - importMeta, - name: 'socket organization', - subcommands: { - dependencies: cmdOrganizationDependencies, - list: cmdOrganizationList, - policy: cmdOrganizationPolicy, - quota: cmdOrganizationQuota, - }, - }, - { - aliases: { - deps: { - argv: ['dependencies'], - description: cmdOrganizationDependencies.description, - hidden: true, - }, - license: { - argv: ['policy', 'license'], - description: cmdOrganizationPolicyLicense.description, - hidden: true, - }, - security: { - argv: ['policy', 'security'], - description: cmdOrganizationPolicySecurity.description, - hidden: true, - }, - }, - description: 'Manage Socket organization account details', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run(['quota'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent organization', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual([ - 'dependencies', - 'list', - 'quota', - 'policy', - ]) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['dependencies', '--json'] - - await cmdOrganization.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['list']) as readonly string[] - - await cmdOrganization.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.dependencies).toBe(cmdOrganizationDependencies) - expect(subcommands.list).toBe(cmdOrganizationList) - expect(subcommands.policy).toBe(cmdOrganizationPolicy) - expect(subcommands.quota).toBe(cmdOrganizationQuota) - }) - }) - - describe('aliases configuration', () => { - it('should configure deps alias for dependencies', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.deps).toEqual({ - argv: ['dependencies'], - description: cmdOrganizationDependencies.description, - hidden: true, - }) - }) - - it('should configure license alias for policy license', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.license).toEqual({ - argv: ['policy', 'license'], - description: cmdOrganizationPolicyLicense.description, - hidden: true, - }) - }) - - it('should configure security alias for policy security', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.security).toEqual({ - argv: ['policy', 'security'], - description: cmdOrganizationPolicySecurity.description, - hidden: true, - }) - }) - - it('should mark all aliases as hidden', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.deps.hidden).toBe(true) - expect(aliases.license.hidden).toBe(true) - expect(aliases.security.hidden).toBe(true) - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdOrganization.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe( - 'Manage Socket organization account details', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/fetch-dependencies.test.mts b/packages/cli/test/unit/commands/organization/fetch-dependencies.test.mts deleted file mode 100644 index 27c240b31..000000000 --- a/packages/cli/test/unit/commands/organization/fetch-dependencies.test.mts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Unit Tests: Organization Dependencies Fetcher. - * - * Purpose: Tests the Socket SDK integration that fetches dependency information - * for an organization. Validates pagination support, SDK setup handling, API - * call error handling, and custom configuration passing for the - * searchDependencies API endpoint. - * - * Test Coverage: - Successful dependency list fetching with pagination - SDK - * setup failure handling - API call error handling - Custom SDK options passing - * (API token, base URL) - Pagination parameter validation (limit, offset) - - * Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock setupSdk and handleApiCall - * without actual API calls. Tests verify proper parameter passing and CResult - * pattern usage. - * - * Related Files: - src/commands/organization/fetch-dependencies.mts - - * Dependencies fetcher - src/commands/organization/handle-dependencies.mts - - * Command handler - src/commands/organization/output-dependencies.mts - Output - * formatter. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchDependencies } from '../../../../src/commands/organization/fetch-dependencies.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchDependencies', () => { - it('fetches dependencies successfully', async () => { - const mockData = { - dependencies: [ - { name: 'lodash', version: '4.17.21' }, - { name: 'express', version: '4.18.2' }, - ], - total: 2, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'searchDependencies', - mockData, - ) - - const result = await fetchDependencies({ limit: 10, offset: 0 }) - - expect(mockSdk.searchDependencies).toHaveBeenCalledWith({ - limit: 10, - offset: 0, - }) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'organization dependencies', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid API token', - }) - - const result = await fetchDependencies({ limit: 20, offset: 10 }) - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('searchDependencies', 'API error') - - const result = await fetchDependencies({ limit: 50, offset: 0 }) - - expect(result.ok).toBe(false) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('searchDependencies', []) - - const sdkOpts = { - apiToken: 'custom-token', - baseUrl: 'https://custom.api.com', - } - - await fetchDependencies({ limit: 100, offset: 50 }, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles pagination parameters', async () => { - const { mockSdk } = await setupSdkMockSuccess('searchDependencies', {}) - - await fetchDependencies({ limit: 200, offset: 100 }) - - expect(mockSdk.searchDependencies).toHaveBeenCalledWith({ - limit: 200, - offset: 100, - }) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('searchDependencies', {}) - - // This tests that the function properly uses __proto__: null. - await fetchDependencies({ limit: 10, offset: 0 }) - - // The function should work without prototype pollution issues. - expect(mockSdk.searchDependencies).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/fetch-license-policy.test.mts b/packages/cli/test/unit/commands/organization/fetch-license-policy.test.mts deleted file mode 100644 index 53eaa2a84..000000000 --- a/packages/cli/test/unit/commands/organization/fetch-license-policy.test.mts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Unit Tests: Organization License Policy Fetcher. - * - * Purpose: Tests the Socket SDK integration that fetches license policy - * configuration for an organization. Validates policy retrieval, SDK setup - * handling, API error handling, custom configuration passing, and various - * organization slug formats for the getOrgLicensePolicy API endpoint. - * - * Test Coverage: - Successful license policy fetching with allowed/disallowed - * licenses - SDK setup failure handling - API call error handling with HTTP - * status codes - Custom SDK options passing (API token, base URL) - Empty - * license policy handling for new organizations - Various organization slug - * format validation - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock setupSdk and handleApiCall - * without actual API calls. Tests verify proper organization slug passing and - * CResult pattern usage. - * - * Related Files: - src/commands/organization/fetch-license-policy.mts - License - * policy fetcher - src/commands/organization/handle-license-policy.mts - - * Command handler - src/commands/organization/output-license-policy.mts - - * Output formatter. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchLicensePolicy } from '../../../../src/commands/organization/fetch-license-policy.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchLicensePolicy', () => { - it('fetches license policy successfully', async () => { - const mockData = { - license_policy: { - MIT: { allowed: true }, - 'Apache-2.0': { allowed: true }, - 'GPL-3.0': { allowed: false }, - }, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getOrgLicensePolicy', - mockData, - ) - - const result = await fetchLicensePolicy('test-org') - - expect(mockSdk.getOrgLicensePolicy).toHaveBeenCalledWith('test-org') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'organization license policy', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid token', - }) - - const result = await fetchLicensePolicy('my-org') - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('getOrgLicensePolicy', 'Access denied', 403) - - const result = await fetchLicensePolicy('restricted-org') - - expect(result.ok).toBe(false) - expect(result.code).toBe(403) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess( - 'getOrgLicensePolicy', - {}, - ) - - const sdkOpts = { - apiToken: 'policy-token', - baseUrl: 'https://policy.api.com', - } - - await fetchLicensePolicy('my-org', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles empty license policy', async () => { - const mockData = { license_policy: {} } - await setupSdkMockSuccess('getOrgLicensePolicy', mockData) - - const result = await fetchLicensePolicy('new-org') - - expect(result.ok).toBe(true) - expect(result.data).toEqual({ license_policy: {} }) - }) - - it('handles various org slugs', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgLicensePolicy', {}) - - const orgSlugs = [ - 'simple-org', - 'org_with_underscore', - 'org123', - 'my-organization-name', - ] - - for (let i = 0, { length } = orgSlugs; i < length; i += 1) { - const orgSlug = orgSlugs[i] - await fetchLicensePolicy(orgSlug) - expect(mockSdk.getOrgLicensePolicy).toHaveBeenCalledWith(orgSlug) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgLicensePolicy', {}) - - // This tests that the function properly uses __proto__: null. - await fetchLicensePolicy('test-org') - - // The function should work without prototype pollution issues. - expect(mockSdk.getOrgLicensePolicy).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/fetch-organization-list.test.mts b/packages/cli/test/unit/commands/organization/fetch-organization-list.test.mts deleted file mode 100644 index e23d9aeca..000000000 --- a/packages/cli/test/unit/commands/organization/fetch-organization-list.test.mts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Unit Tests: User Organizations List Fetcher. - * - * Purpose: Tests the Socket SDK integration that fetches the list of - * organizations accessible to the authenticated user. Validates organization - * data retrieval, SDK setup handling, API error handling, custom configuration - * passing, and SDK instance reuse for the listOrganizations API endpoint. - * - * Test Coverage: - Successful organization list fetching with multiple orgs - - * SDK setup failure handling - API call error handling with HTTP status codes - - * Custom SDK options passing (API token, base URL) - Provided SDK instance - * usage (bypassing SDK setup) - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock setupSdk and handleApiCall - * without actual API calls. Tests verify proper CResult pattern usage and - * organization data structure validation. - * - * Related Files: - src/commands/organization/fetch-organization-list.mts - - * Organization list fetcher - - * src/commands/organization/handle-organization-list.mts - Command handler. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchOrganization } from '../../../../src/commands/organization/fetch-organization-list.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchOrganizationList', () => { - it('fetches organization list successfully', async () => { - const mockData = { - organizations: { - 'org-1': { - id: 'org-1', - name: 'Test Org 1', - slug: 'test-org-1', - plan: 'pro', - }, - 'org-2': { - id: 'org-2', - name: 'Test Org 2', - slug: 'test-org-2', - plan: 'enterprise', - }, - }, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'listOrganizations', - mockData, - ) - - const result = await fetchOrganization() - - expect(mockSdk.listOrganizations).toHaveBeenCalledWith() - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'organization list', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.organizations).toHaveLength(2) - } - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Configuration error', - }) - - const result = await fetchOrganization() - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('listOrganizations', 'Network error', 500) - - const result = await fetchOrganization() - - expect(result.ok).toBe(false) - expect(result.code).toBe(500) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('listOrganizations', { - organizations: {}, - }) - - const sdkOpts = { - apiToken: 'org-token', - baseUrl: 'https://org.api.com', - } - - await fetchOrganization({ sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('uses provided SDK instance', async () => { - const { handleApiCall } = await import( - '../../../../src/util/socket/api.mts' - ) - const { createSuccessResult } = await import('../../../helpers/mocks.mts') - - const mockSdk = { - listOrganizations: vi.fn().mockResolvedValue({}), - } as unknown - - vi.mocked(handleApiCall).mockResolvedValue( - createSuccessResult({ organizations: {} }), - ) - - await fetchOrganization({ sdk: mockSdk }) - - expect(mockSdk.listOrganizations).toHaveBeenCalled() - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('listOrganizations', { - organizations: {}, - }) - - // This tests that the function properly uses __proto__: null. - await fetchOrganization() - - // The function should work without prototype pollution issues. - expect(mockSdk.listOrganizations).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/fetch-quota.test.mts b/packages/cli/test/unit/commands/organization/fetch-quota.test.mts deleted file mode 100644 index 129641837..000000000 --- a/packages/cli/test/unit/commands/organization/fetch-quota.test.mts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Unit Tests: API Token Quota Fetcher. - * - * Purpose: Tests the Socket SDK integration that fetches quota/usage - * information for the authenticated API token. Validates quota data retrieval - * including scans, packages, and repositories limits, SDK setup handling, API - * error handling, and custom configuration passing for the getQuota API - * endpoint. - * - * Test Coverage: - Successful quota fetching with usage and limit data - SDK - * setup failure handling - API call error handling with HTTP status codes - - * Custom SDK options passing (API token, base URL) - Quota at limit scenario - * (100% usage) - Various organization slug handling - Null prototype usage for - * security. - * - * Testing Approach: Uses SDK test helpers to mock setupSdk and handleApiCall - * without actual API calls. Tests verify proper CResult pattern usage and quota - * data structure validation. - * - * Related Files: - src/commands/organization/fetch-quota.mts - Quota fetcher - - * src/commands/organization/handle-quota.mts - Command handler - - * src/commands/organization/output-quota.mts - Output formatter. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchQuota } from '../../../../src/commands/organization/fetch-quota.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchQuota', () => { - it('fetches quota successfully', async () => { - const mockData = { - scans: { used: 250, limit: 1000 }, - packages: { used: 500, limit: 2000 }, - repositories: { used: 10, limit: 50 }, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getQuota', - mockData, - ) - - const result = await fetchQuota() - - expect(mockSdk.getQuota).toHaveBeenCalledWith() - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'token quota', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - cause: 'Configuration error', - }) - - const result = await fetchQuota() - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('getQuota', 'Quota unavailable', 503) - - const result = await fetchQuota() - - expect(result.ok).toBe(false) - expect(result.code).toBe(503) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('getQuota', {}) - - const sdkOpts = { - apiToken: 'quota-token', - baseUrl: 'https://quota.api.com', - } - - await fetchQuota({ sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles quota at limit', async () => { - const mockData = { - scans: { used: 1000, limit: 1000, percentage: 100 }, - } - - await setupSdkMockSuccess('getQuota', mockData) - - const result = await fetchQuota() - - expect(result.ok).toBe(true) - expect(result.data.scans.percentage).toBe(100) - }) - - it('handles various org slugs', async () => { - const { mockSdk } = await setupSdkMockSuccess('getQuota', {}) - - const orgSlugs = [ - 'simple', - 'org-with-dashes', - 'org_underscore', - 'org123numbers', - ] - - for (let i = 0, { length } = orgSlugs; i < length; i += 1) { - const orgSlug = orgSlugs[i] - await fetchQuota() - expect(mockSdk.getQuota).toHaveBeenCalledWith() - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getQuota', {}) - - // This tests that the function properly uses __proto__: null. - await fetchQuota() - - // The function should work without prototype pollution issues. - expect(mockSdk.getQuota).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/fetch-security-policy.test.mts b/packages/cli/test/unit/commands/organization/fetch-security-policy.test.mts deleted file mode 100644 index 695f1d8b5..000000000 --- a/packages/cli/test/unit/commands/organization/fetch-security-policy.test.mts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Unit Tests: Organization Security Policy Fetcher. - * - * Purpose: Tests the Socket SDK integration that fetches security policy - * configuration for an organization. Validates policy retrieval including - * severity blocking rules, auto-scan settings, and approval requirements. - * Handles SDK setup, API errors, custom configuration, and various organization - * slug formats for the getOrgSecurityPolicy API endpoint. - * - * Test Coverage: - Successful security policy fetching with blocking rules - - * SDK setup failure handling - API call error handling with HTTP status codes - - * Custom SDK options passing (API token, base URL) - Default security policy - * handling for new organizations - Various organization slug format validation - * - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock setupSdk and handleApiCall - * without actual API calls. Tests verify proper organization slug passing and - * CResult pattern usage. - * - * Related Files: - src/commands/organization/fetch-security-policy.mts - - * Security policy fetcher - - * src/commands/organization/handle-security-policy.mts - Command handler - - * src/commands/organization/output-security-policy.mts - Output formatter. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchSecurityPolicy } from '../../../../src/commands/organization/fetch-security-policy.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchSecurityPolicy', () => { - it('fetches security policy successfully', async () => { - const mockData = { - policy: { - block_high_severity: true, - block_critical_severity: true, - block_medium_severity: false, - block_low_severity: false, - auto_scan: true, - scan_on_push: true, - require_approval: true, - }, - updated_at: '2025-01-15T10:00:00Z', - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getOrgSecurityPolicy', - mockData, - ) - - const result = await fetchSecurityPolicy('test-org') - - expect(mockSdk.getOrgSecurityPolicy).toHaveBeenCalledWith('test-org') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'organization security policy', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Authentication failed', - }) - - const result = await fetchSecurityPolicy('my-org') - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('getOrgSecurityPolicy', 'Forbidden', 403) - - const result = await fetchSecurityPolicy('restricted-org') - - expect(result.ok).toBe(false) - expect(result.code).toBe(403) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess( - 'getOrgSecurityPolicy', - {}, - ) - - const sdkOpts = { - apiToken: 'security-token', - baseUrl: 'https://security.api.com', - } - - await fetchSecurityPolicy('my-org', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles default security policy', async () => { - const mockData = { - policy: { - block_high_severity: false, - block_critical_severity: false, - auto_scan: false, - }, - } - - await setupSdkMockSuccess('getOrgSecurityPolicy', mockData) - - const result = await fetchSecurityPolicy('new-org') - - expect(result.ok).toBe(true) - expect(result.data.policy.auto_scan).toBe(false) - }) - - it('handles various org slugs', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgSecurityPolicy', {}) - - const orgSlugs = [ - 'simple-org', - 'org_with_underscore', - 'org-123-numbers', - 'MyOrganization', - ] - - for (let i = 0, { length } = orgSlugs; i < length; i += 1) { - const orgSlug = orgSlugs[i] - await fetchSecurityPolicy(orgSlug) - expect(mockSdk.getOrgSecurityPolicy).toHaveBeenCalledWith(orgSlug) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getOrgSecurityPolicy', {}) - - // This tests that the function properly uses __proto__: null. - await fetchSecurityPolicy('test-org') - - // The function should work without prototype pollution issues. - expect(mockSdk.getOrgSecurityPolicy).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/handle-dependencies.test.mts b/packages/cli/test/unit/commands/organization/handle-dependencies.test.mts deleted file mode 100644 index 9cd902977..000000000 --- a/packages/cli/test/unit/commands/organization/handle-dependencies.test.mts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Unit Tests: Organization Dependencies Command Handler. - * - * Purpose: Tests the command handler that orchestrates fetching and displaying - * organization-wide dependency information. Validates parameter forwarding, - * pagination handling, output format selection, and error propagation through - * the fetch/output pipeline. - * - * Test Coverage: - Successful fetch and output orchestration - Fetch failure - * error handling - Multiple output kind support (json, table, markdown) - Large - * offset and limit parameter handling. - * - * Testing Approach: Mocks fetchDependencies and outputDependencies modules to - * test orchestration logic without actual API calls or terminal output. Uses - * test environment setup helpers for consistent test isolation. - * - * Related Files: - src/commands/organization/handle-dependencies.mts - Command - * handler - src/commands/organization/fetch-dependencies.mts - Dependencies - * fetcher - src/commands/organization/output-dependencies.mts - Output - * formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchDependencies } from '../../../../src/commands/organization/fetch-dependencies.mts' -import { handleDependencies } from '../../../../src/commands/organization/handle-dependencies.mts' -import { outputDependencies } from '../../../../src/commands/organization/output-dependencies.mts' -import { - createSuccessResult, - setupTestEnvironment, -} from '../../../helpers/index.mts' - -// Mock the dependencies. -const mockFetchDependencies = vi.hoisted(() => vi.fn()) -const mockOutputDependencies = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/organization/fetch-dependencies.mts', () => ({ - fetchDependencies: mockFetchDependencies, -})) - -vi.mock( - '../../../../src/commands/organization/output-dependencies.mts', - () => ({ - outputDependencies: mockOutputDependencies, - }), -) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) - -describe('handleDependencies', () => { - setupTestEnvironment() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should fetch and output dependencies successfully', async () => { - const mockResult = createSuccessResult([ - { - name: 'test-package', - version: '1.0.0', - description: 'Test package', - }, - ]) - - mockFetchDependencies.mockResolvedValue(mockResult) - mockOutputDependencies.mockResolvedValue() - - await handleDependencies({ - limit: 10, - offset: 0, - outputKind: 'json', - }) - - expect(fetchDependencies).toHaveBeenCalledWith( - { limit: 10, offset: 0 }, - { - commandPath: 'socket organization dependencies', - }, - ) - expect(outputDependencies).toHaveBeenCalledWith(mockResult, { - limit: 10, - offset: 0, - outputKind: 'json', - }) - }) - - it('should handle fetch failure', async () => { - const mockResult = { - ok: false, - error: new Error('Fetch failed'), - } - - mockFetchDependencies.mockResolvedValue(mockResult) - mockOutputDependencies.mockResolvedValue() - - await handleDependencies({ - limit: 20, - offset: 10, - outputKind: 'table', - }) - - expect(fetchDependencies).toHaveBeenCalledWith( - { limit: 20, offset: 10 }, - { - commandPath: 'socket organization dependencies', - }, - ) - expect(outputDependencies).toHaveBeenCalledWith(mockResult, { - limit: 20, - offset: 10, - outputKind: 'table', - }) - }) - - it('should handle different output kinds', async () => { - const mockResult = createSuccessResult([]) - - mockFetchDependencies.mockResolvedValue(mockResult) - mockOutputDependencies.mockResolvedValue() - - await handleDependencies({ - limit: 5, - offset: 0, - outputKind: 'markdown', - }) - - expect(outputDependencies).toHaveBeenCalledWith(mockResult, { - limit: 5, - offset: 0, - outputKind: 'markdown', - }) - }) - - it('should handle large offsets and limits', async () => { - const mockResult = createSuccessResult([]) - - mockFetchDependencies.mockResolvedValue(mockResult) - mockOutputDependencies.mockResolvedValue() - - await handleDependencies({ - limit: 100, - offset: 500, - outputKind: 'json', - }) - - expect(fetchDependencies).toHaveBeenCalledWith( - { - limit: 100, - offset: 500, - }, - { - commandPath: 'socket organization dependencies', - }, - ) - expect(outputDependencies).toHaveBeenCalledWith(mockResult, { - limit: 100, - offset: 500, - outputKind: 'json', - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/handle-license-policy.test.mts b/packages/cli/test/unit/commands/organization/handle-license-policy.test.mts deleted file mode 100644 index ee051aca1..000000000 --- a/packages/cli/test/unit/commands/organization/handle-license-policy.test.mts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Unit Tests: Organization License Policy Command Handler. - * - * Purpose: Tests the command handler that orchestrates fetching and displaying - * organization license policy configuration. Validates organization slug - * forwarding, output format selection, and error propagation through the - * fetch/output pipeline. - * - * Test Coverage: - Successful license policy fetch and output - Fetch error - * handling and propagation - Multiple output format support (json, text, - * markdown) - Organization slug parameter passing. - * - * Testing Approach: Mocks fetchLicensePolicy and outputLicensePolicy modules to - * test orchestration logic without actual API calls or terminal output. Uses - * test helpers for CResult pattern validation. - * - * Related Files: - src/commands/organization/handle-license-policy.mts - - * Command handler - src/commands/organization/fetch-license-policy.mts - - * License policy fetcher - src/commands/organization/output-license-policy.mts - * - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchLicensePolicy } from '../../../../src/commands/organization/fetch-license-policy.mts' -import { handleLicensePolicy } from '../../../../src/commands/organization/handle-license-policy.mts' -import { outputLicensePolicy } from '../../../../src/commands/organization/output-license-policy.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockFetchLicensePolicy = vi.hoisted(() => vi.fn()) -const mockOutputLicensePolicy = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/organization/fetch-license-policy.mts', - () => ({ - fetchLicensePolicy: mockFetchLicensePolicy, - }), -) - -vi.mock( - '../../../../src/commands/organization/output-license-policy.mts', - () => ({ - outputLicensePolicy: mockOutputLicensePolicy, - }), -) - -describe('handleLicensePolicy', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('handles successful license policy fetch', async () => { - const mockResult = createSuccessResult({ - allowed: ['MIT', 'Apache-2.0', 'BSD-3-Clause'], - denied: ['GPL-3.0', 'AGPL-3.0'], - }) - mockFetchLicensePolicy.mockResolvedValue(mockResult) - - await handleLicensePolicy('test-org', 'json') - - expect(fetchLicensePolicy).toHaveBeenCalledWith('test-org', { - commandPath: 'socket organization policy license', - }) - expect(outputLicensePolicy).toHaveBeenCalledWith(mockResult, 'json') - }) - - it('handles failed license policy fetch', async () => { - const mockResult = createErrorResult('Unauthorized') - mockFetchLicensePolicy.mockResolvedValue(mockResult) - - await handleLicensePolicy('test-org', 'text') - - expect(fetchLicensePolicy).toHaveBeenCalledWith('test-org', { - commandPath: 'socket organization policy license', - }) - expect(outputLicensePolicy).toHaveBeenCalledWith(mockResult, 'text') - }) - - it('handles markdown output format', async () => { - mockFetchLicensePolicy.mockResolvedValue(createSuccessResult({})) - - await handleLicensePolicy('test-org', 'markdown') - - expect(outputLicensePolicy).toHaveBeenCalledWith( - expect.any(Object), - 'markdown', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/handle-organization-list.test.mts b/packages/cli/test/unit/commands/organization/handle-organization-list.test.mts deleted file mode 100644 index 044c03e51..000000000 --- a/packages/cli/test/unit/commands/organization/handle-organization-list.test.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Unit Tests: User Organizations List Command Handler. - * - * Purpose: Tests the command handler that orchestrates fetching and displaying - * the list of organizations accessible to the authenticated user. Validates - * output format selection and error propagation through the fetch/output - * pipeline. - * - * Test Coverage: - Successful organization list fetch and output - Multiple - * output format support (json, text, markdown) - Error handling and - * propagation. - * - * Testing Approach: Mocks fetchOrganization and outputOrganizationList modules - * to test orchestration logic without actual API calls or terminal output. Uses - * test environment setup helpers for consistent isolation. - * - * Related Files: - src/commands/organization/handle-organization-list.mts - - * Command handler - src/commands/organization/fetch-organization-list.mts - - * Organization list fetcher - - * src/commands/organization/output-organization-list.mts - Output formatter - * (not in test files) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchOrganization } from '../../../../src/commands/organization/fetch-organization-list.mts' -import { handleOrganizationList } from '../../../../src/commands/organization/handle-organization-list.mts' -import { outputOrganizationList } from '../../../../src/commands/organization/output-organization-list.mts' -import { setupTestEnvironment } from '../../../helpers/index.mts' - -// Mock the dependencies. -const mockFetchOrganization = vi.hoisted(() => vi.fn()) -const mockOutputOrganizationList = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -vi.mock( - '../../../../src/commands/organization/output-organization-list.mts', - () => ({ - outputOrganizationList: mockOutputOrganizationList, - }), -) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) - -describe('handleOrganizationList', () => { - setupTestEnvironment() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs organization list successfully', async () => { - const mockData = { - ok: true, - data: [ - { - id: 'org-123', - name: 'Test Organization', - slug: 'test-org', - plan: 'pro', - }, - { - id: 'org-456', - name: 'Another Org', - slug: 'another-org', - plan: 'enterprise', - }, - ], - } - mockFetchOrganization.mockResolvedValue(mockData) - - await handleOrganizationList('json') - - expect(fetchOrganization).toHaveBeenCalled() - expect(outputOrganizationList).toHaveBeenCalledWith(mockData, 'json') - }) - - it('handles fetch failure', async () => { - const mockError = { - ok: false, - error: 'Unauthorized', - } - mockFetchOrganization.mockResolvedValue(mockError) - - await handleOrganizationList('text') - - expect(fetchOrganization).toHaveBeenCalled() - expect(outputOrganizationList).toHaveBeenCalledWith(mockError, 'text') - }) - - it('uses default text output format', async () => { - mockFetchOrganization.mockResolvedValue({ ok: true, data: [] }) - - await handleOrganizationList() - - expect(outputOrganizationList).toHaveBeenCalledWith( - expect.any(Object), - 'text', - ) - }) - - it('handles markdown output format', async () => { - mockFetchOrganization.mockResolvedValue({ ok: true, data: [] }) - - await handleOrganizationList('markdown') - - expect(outputOrganizationList).toHaveBeenCalledWith( - expect.any(Object), - 'markdown', - ) - }) - - it('passes debug messages correctly', async () => { - mockFetchOrganization.mockResolvedValue({ ok: true, data: [] }) - - await handleOrganizationList('json') - - expect(mockDebug).toHaveBeenCalledWith('Fetching organization list') - expect(mockDebugDir).toHaveBeenCalledWith({ outputKind: 'json' }) - expect(mockDebug).toHaveBeenCalledWith( - 'Organization list fetched successfully', - ) - }) - - it('handles error case with debug messages', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: false, - error: 'Network error', - }) - - await handleOrganizationList('text') - - expect(mockDebug).toHaveBeenCalledWith('Organization list fetch failed') - }) -}) diff --git a/packages/cli/test/unit/commands/organization/handle-quota.test.mts b/packages/cli/test/unit/commands/organization/handle-quota.test.mts deleted file mode 100644 index 47384d14c..000000000 --- a/packages/cli/test/unit/commands/organization/handle-quota.test.mts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Unit Tests: API Token Quota Command Handler. - * - * Purpose: Tests the command handler that orchestrates fetching and displaying - * API token quota information. Validates parameter forwarding, output format - * selection (text, json, markdown, table), and error propagation through the - * fetch/output pipeline. - * - * Test Coverage: - Successful fetch and output with default output kind (text) - * - Multiple output kind support (json, markdown, table) - Error propagation - * from fetchQuota preventing output. - * - * Testing Approach: Mocks fetchQuota and outputQuota modules to test - * orchestration logic without actual API calls or terminal output. Uses test - * environment setup helpers for consistent isolation. - * - * Related Files: - src/commands/organization/handle-quota.mts - Command handler - * - src/commands/organization/fetch-quota.mts - Quota fetcher - - * src/commands/organization/output-quota.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchQuota } from '../../../../src/commands/organization/fetch-quota.mts' -import { handleQuota } from '../../../../src/commands/organization/handle-quota.mts' -import { outputQuota } from '../../../../src/commands/organization/output-quota.mts' -import { setupTestEnvironment } from '../../../helpers/index.mts' - -// Mock the dependencies. -const mockFetchQuota = vi.hoisted(() => vi.fn()) -const mockOutputQuota = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/organization/fetch-quota.mts', () => ({ - fetchQuota: mockFetchQuota, -})) - -vi.mock('../../../../src/commands/organization/output-quota.mts', () => ({ - outputQuota: mockOutputQuota, -})) - -describe('handleQuota', () => { - setupTestEnvironment() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should fetch and output quota with default output kind', async () => { - const mockData = { - used: 100, - limit: 1000, - percentage: 10, - } - - mockFetchQuota.mockResolvedValue(mockData) - mockOutputQuota.mockResolvedValue() - - await handleQuota() - - expect(fetchQuota).toHaveBeenCalledOnce() - expect(outputQuota).toHaveBeenCalledWith(mockData, 'text') - }) - - it('should handle json output kind', async () => { - const mockData = { - used: 500, - limit: 1000, - percentage: 50, - } - - mockFetchQuota.mockResolvedValue(mockData) - mockOutputQuota.mockResolvedValue() - - await handleQuota('json') - - expect(fetchQuota).toHaveBeenCalledOnce() - expect(outputQuota).toHaveBeenCalledWith(mockData, 'json') - }) - - it('should handle markdown output kind', async () => { - const mockData = { - used: 0, - limit: 100, - percentage: 0, - } - - mockFetchQuota.mockResolvedValue(mockData) - mockOutputQuota.mockResolvedValue() - - await handleQuota('markdown') - - expect(fetchQuota).toHaveBeenCalledOnce() - expect(outputQuota).toHaveBeenCalledWith(mockData, 'markdown') - }) - - it('should handle table output kind', async () => { - const mockData = { - used: 999, - limit: 1000, - percentage: 99.9, - } - - mockFetchQuota.mockResolvedValue(mockData) - mockOutputQuota.mockResolvedValue() - - await handleQuota('table') - - expect(fetchQuota).toHaveBeenCalledOnce() - expect(outputQuota).toHaveBeenCalledWith(mockData, 'table') - }) - - it('should propagate errors from fetchQuota', async () => { - const error = new Error('Network error') - mockFetchQuota.mockRejectedValue(error) - - await expect(handleQuota()).rejects.toThrow('Network error') - expect(outputQuota).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/organization/handle-security-policy.test.mts b/packages/cli/test/unit/commands/organization/handle-security-policy.test.mts deleted file mode 100644 index 19db9d9e5..000000000 --- a/packages/cli/test/unit/commands/organization/handle-security-policy.test.mts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Unit Tests: Organization Security Policy Command Handler. - * - * Purpose: Tests the command handler that orchestrates fetching and displaying - * organization security policy configuration. Validates organization slug - * forwarding, output format selection, and error propagation through the - * fetch/output pipeline. - * - * Test Coverage: - Successful security policy fetch and output - Fetch error - * handling and propagation - Multiple output format support (json, text, - * markdown) - Organization slug parameter passing. - * - * Testing Approach: Mocks fetchSecurityPolicy and outputSecurityPolicy modules - * to test orchestration logic without actual API calls or terminal output. Uses - * test helpers for CResult pattern validation. - * - * Related Files: - src/commands/organization/handle-security-policy.mts - - * Command handler - src/commands/organization/fetch-security-policy.mts - - * Security policy fetcher - - * src/commands/organization/output-security-policy.mts - Output formatter. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchSecurityPolicy } from '../../../../src/commands/organization/fetch-security-policy.mts' -import { handleSecurityPolicy } from '../../../../src/commands/organization/handle-security-policy.mts' -import { outputSecurityPolicy } from '../../../../src/commands/organization/output-security-policy.mts' -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockFetchSecurityPolicy = vi.hoisted(() => vi.fn()) -const mockOutputSecurityPolicy = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/organization/fetch-security-policy.mts', - () => ({ - fetchSecurityPolicy: mockFetchSecurityPolicy, - }), -) - -vi.mock( - '../../../../src/commands/organization/output-security-policy.mts', - () => ({ - outputSecurityPolicy: mockOutputSecurityPolicy, - }), -) - -describe('handleSecurityPolicy', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs security policy successfully', async () => { - const mockPolicy = createSuccessResult({ - rules: [ - { - id: 'rule-1', - name: 'No critical vulnerabilities', - severity: 'critical', - action: 'block', - }, - { - id: 'rule-2', - name: 'License check', - type: 'license', - allowed: ['MIT', 'Apache-2.0'], - }, - ], - enforcementLevel: 'strict', - }) - mockFetchSecurityPolicy.mockResolvedValue(mockPolicy) - - await handleSecurityPolicy('test-org', 'json') - - expect(fetchSecurityPolicy).toHaveBeenCalledWith('test-org', { - commandPath: 'socket organization policy security', - }) - expect(outputSecurityPolicy).toHaveBeenCalledWith(mockPolicy, 'json') - }) - - it('handles fetch failure', async () => { - const mockError = createErrorResult('Organization not found') - mockFetchSecurityPolicy.mockResolvedValue(mockError) - - await handleSecurityPolicy('invalid-org', 'text') - - expect(fetchSecurityPolicy).toHaveBeenCalledWith('invalid-org', { - commandPath: 'socket organization policy security', - }) - expect(outputSecurityPolicy).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles markdown output format', async () => { - mockFetchSecurityPolicy.mockResolvedValue(createSuccessResult({})) - - await handleSecurityPolicy('my-org', 'markdown') - - expect(outputSecurityPolicy).toHaveBeenCalledWith( - expect.any(Object), - 'markdown', - ) - }) - - it('handles different organization slugs', async () => { - const orgSlugs = [ - 'org-with-dashes', - 'simple', - 'company_underscore', - 'org123', - ] - - for (let i = 0, { length } = orgSlugs; i < length; i += 1) { - const orgSlug = orgSlugs[i] - mockFetchSecurityPolicy.mockResolvedValue(createSuccessResult({})) - await handleSecurityPolicy(orgSlug, 'json') - expect(fetchSecurityPolicy).toHaveBeenCalledWith(orgSlug, { - commandPath: 'socket organization policy security', - }) - } - }) - - it('handles text output with detailed policy', async () => { - mockFetchSecurityPolicy.mockResolvedValue( - createSuccessResult({ - rules: [ - { id: 'rule-1', name: 'CVE check', enabled: true }, - { id: 'rule-2', name: 'Malware scan', enabled: true }, - { id: 'rule-3', name: 'License compliance', enabled: false }, - ], - lastUpdated: '2025-01-01T00:00:00Z', - }), - ) - - await handleSecurityPolicy('production-org', 'text') - - expect(outputSecurityPolicy).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ - rules: expect.arrayContaining([ - expect.objectContaining({ id: 'rule-1' }), - ]), - }), - }), - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/output-dependencies.test.mts b/packages/cli/test/unit/commands/organization/output-dependencies.test.mts deleted file mode 100644 index b0d1fa642..000000000 --- a/packages/cli/test/unit/commands/organization/output-dependencies.test.mts +++ /dev/null @@ -1,402 +0,0 @@ -/** - * Unit Tests: Organization Dependencies Output Formatter. - * - * Purpose: Tests the output formatting system for organization-wide dependency - * data. Validates JSON and markdown/table output formats, pagination info - * display, error messaging, and proper exit code setting based on result - * status. - * - * Test Coverage: - JSON format output for successful results - JSON format - * error output with exit codes - Markdown/table format with chalk-table - * rendering - Pagination metadata display (offset, limit, has more data) - - * Error messaging in markdown format with badges - Empty dependency list - * handling - Default exit code setting when code is undefined. - * - * Testing Approach: Uses vi.doMock to reset module state between tests, mocking - * logger, chalk-table, yoctocolors-cjs, and result serialization utilities. - * Tests verify output content and exit code behavior. - * - * Related Files: - src/commands/organization/output-dependencies.mts - Output - * formatter - src/commands/organization/handle-dependencies.mts - Command - * handler - src/commands/organization/fetch-dependencies.mts - Dependencies - * fetcher. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -import type { CResult } from '../../../../src/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -describe('outputDependencies', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createSuccessResult({ - end: false, - rows: [ - { - branch: 'main', - direct: true, - name: 'test-package', - namespace: '@test', - repository: 'test-repo', - type: 'npm', - version: '1.0.0', - }, - ], - }) - - await outputDependencies(result, { - limit: 10, - offset: 0, - outputKind: 'json', - }) - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createErrorResult('Unauthorized', { - cause: 'Invalid API token', - code: 2, - }) - - await outputDependencies(result, { - limit: 10, - offset: 0, - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs markdown format with table', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - bgRedBright: vi.fn(text => text), - bold: vi.fn(text => text), - cyan: vi.fn(text => text), - green: vi.fn(text => text), - red: vi.fn(text => text), - yellow: vi.fn(text => text), - }, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createSuccessResult({ - end: true, - rows: [ - { - branch: 'main', - direct: false, - name: 'lodash', - namespace: '', - repository: 'my-app', - type: 'npm', - version: '4.17.21', - }, - ], - }) - - await outputDependencies(result, { - limit: 50, - offset: 20, - outputKind: 'text', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('# Organization dependencies') - expect(mockLogger.log).toHaveBeenCalledWith('- Offset:', 20) - expect(mockLogger.log).toHaveBeenCalledWith('- Limit:', 50) - expect(mockLogger.log).toHaveBeenCalledWith( - '- Is there more data after this?', - 'no', - ) - expect(mockChalkTable).toHaveBeenCalledWith( - expect.objectContaining({ - columns: expect.arrayContaining([ - expect.objectContaining({ field: 'type' }), - expect.objectContaining({ field: 'namespace' }), - expect.objectContaining({ field: 'name' }), - expect.objectContaining({ field: 'version' }), - expect.objectContaining({ field: 'repository' }), - expect.objectContaining({ field: 'branch' }), - expect.objectContaining({ field: 'direct' }), - ]), - }), - result.data.rows, - ) - }) - - it('outputs error in markdown format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createErrorResult('Failed to fetch dependencies', { - cause: 'Network error', - code: 1, - }) - - await outputDependencies(result, { - limit: 10, - offset: 0, - outputKind: 'text', - }) - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to fetch dependencies', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('shows proper pagination info when more data is available', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - bgRedBright: vi.fn(text => text), - bold: vi.fn(text => text), - cyan: vi.fn(text => text), - green: vi.fn(text => text), - red: vi.fn(text => text), - yellow: vi.fn(text => text), - }, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createSuccessResult({ - end: false, - rows: [ - { - branch: 'dev', - direct: true, - name: 'express', - namespace: '', - repository: 'api-server', - type: 'npm', - version: '4.18.2', - }, - ], - }) - - await outputDependencies(result, { - limit: 25, - offset: 100, - outputKind: 'text', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - '- Is there more data after this?', - 'yes', - ) - }) - - it('handles empty dependencies list', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - bgRedBright: vi.fn(text => text), - bold: vi.fn(text => text), - cyan: vi.fn(text => text), - green: vi.fn(text => text), - red: vi.fn(text => text), - yellow: vi.fn(text => text), - }, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createSuccessResult({ - end: true, - rows: [], - }) - - await outputDependencies(result, { - limit: 10, - offset: 0, - outputKind: 'text', - }) - - expect(mockChalkTable).toHaveBeenCalledWith(expect.any(Object), []) - }) - - it('sets default exit code when code is undefined', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputDependencies } = - await import('../../../../src/commands/organization/output-dependencies.mts') - - const result: CResult< - SocketSdkSuccessResult<'searchDependencies'>['data'] - > = createErrorResult('Error without code') - - await outputDependencies(result, { - limit: 10, - offset: 0, - outputKind: 'json', - }) - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/output-license-policy.test.mts b/packages/cli/test/unit/commands/organization/output-license-policy.test.mts deleted file mode 100644 index 6998cc4bc..000000000 --- a/packages/cli/test/unit/commands/organization/output-license-policy.test.mts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * Unit Tests: Organization License Policy Output Formatter. - * - * Purpose: Tests the output formatting system for organization license policy - * data. Validates JSON and text/markdown output formats, error messaging, exit - * code setting, and license policy display including allowed/denied licenses. - * - * Test Coverage: - JSON format output for successful results - JSON format - * error output with exit codes - Text format with license policy information - * display - Text format error output with badges - Markdown format output - - * Empty license policy handling - Default exit code setting when code is - * undefined. - * - * Testing Approach: Uses vi.doMock to reset module state between tests, mocking - * logger, result serialization, markdown utilities, and error formatting. Tests - * verify output content and exit code behavior. - * - * Related Files: - src/commands/organization/output-license-policy.mts - Output - * formatter - src/commands/organization/handle-license-policy.mts - Command - * handler - src/commands/organization/fetch-license-policy.mts - License policy - * fetcher. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -describe('outputLicensePolicy', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: { - MIT: { allowed: true }, - 'GPL-3.0': { allowed: false }, - 'Apache-2.0': { allowed: true }, - }, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createErrorResult('Unauthorized', { - code: 2, - cause: 'Invalid API token', - }) - - process.exitCode = undefined - await outputLicensePolicy(result, 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs text format with license table', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn(title => `# ${title}`), - mdTableOfPairs: mockMdTableOfPairs, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: { - MIT: { allowed: true }, - 'BSD-3-Clause': { allowed: true }, - 'GPL-3.0': { allowed: false }, - }, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'text') - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Use --json to get the full result', - ) - expect(mockLogger.log).toHaveBeenCalledWith('# License policy') - expect(mockMdTableOfPairs).toHaveBeenCalledWith( - expect.arrayContaining([ - ['BSD-3-Clause', ' yes'], - ['GPL-3.0', ' no'], - ['MIT', ' yes'], - ]), - ['License Name', 'Allowed'], - ) - }) - - it('outputs error in text format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createErrorResult('Failed to fetch policy', { - code: 1, - cause: 'Network error', - }) - - process.exitCode = undefined - await outputLicensePolicy(result, 'text') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to fetch policy', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles markdown output format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn(title => `# ${title}`), - mdTableOfPairs: mockMdTableOfPairs, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: { - MIT: { allowed: true }, - }, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'markdown') - - expect(mockLogger.log).toHaveBeenCalledWith('# License policy') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Table'), - ) - }) - - it('handles empty license policy', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn(title => `# ${title}`), - mdTableOfPairs: mockMdTableOfPairs, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: {}, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'text') - - expect(mockMdTableOfPairs).toHaveBeenCalledWith( - [], - ['License Name', 'Allowed'], - ) - }) - - it('handles null license policy', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn(title => `# ${title}`), - mdTableOfPairs: mockMdTableOfPairs, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: undefined, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'text') - - expect(mockMdTableOfPairs).toHaveBeenCalledWith( - [], - ['License Name', 'Allowed'], - ) - }) - - it('sets default exit code when code is undefined', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createErrorResult('Error') - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('falls back to exitCode 1 when result has no code field', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - // Manually construct error result without `code` to exercise `?? 1`. - const result = { - ok: false as const, - message: 'No code', - cause: 'no code', - } - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('handles license policy with non-allowed entries (no for value)', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(text => `# ${text}`) - const mockMdTableOfPairs = vi.fn( - (pairs: Array<[string, string]>, header: string[]) => - `${header.join(' | ')}\n${pairs.map(p => p.join(' | ')).join('\n')}`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputLicensePolicy } = - await import('../../../../src/commands/organization/output-license-policy.mts') - - const result = createSuccessResult({ - license_policy: { - MIT: { allowed: true }, - GPL: { allowed: false }, - // Entry without an `allowed` field — exercises the falsy branch on line 37. - CUSTOM: {}, - }, - }) - - process.exitCode = undefined - await outputLicensePolicy(result as unknown, 'text') - - expect(mockMdTableOfPairs).toHaveBeenCalled() - // mockMdTableOfPairs is called with sorted pairs. - const sortedPairs = mockMdTableOfPairs.mock.calls[0]![0] as Array< - [string, string] - > - const customPair = sortedPairs.find(p => p[0] === 'CUSTOM') - expect(customPair?.[1]).toBe(' no') - }) -}) diff --git a/packages/cli/test/unit/commands/organization/output-organization-list.test.mts b/packages/cli/test/unit/commands/organization/output-organization-list.test.mts deleted file mode 100644 index 73fed4c9d..000000000 --- a/packages/cli/test/unit/commands/organization/output-organization-list.test.mts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for organization list output formatting. - * - * Purpose: Tests the output formatting for organization list results. - * - * Test Coverage: - outputOrganizationList function - JSON output format - Text - * output format - Markdown output format - Error handling. - * - * Related Files: - src/commands/organization/output-organization-list.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock yoctocolors. -vi.mock('yoctocolors-cjs', () => ({ - default: { - bold: (s: string) => s, - italic: (s: string) => s, - }, -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -const mockGetVisibleTokenPrefix = vi.hoisted(() => vi.fn(() => 'sk_live_')) -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - getVisibleTokenPrefix: mockGetVisibleTokenPrefix, -})) - -import { outputOrganizationList } from '../../../../src/commands/organization/output-organization-list.mts' - -import type { OrganizationsCResult } from '../../../../src/commands/organization/fetch-organization-list.mts' - -describe('output-organization-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputOrganizationList', () => { - const mockOrganizations = [ - { id: 'org-1', name: 'My Org', slug: 'my-org', plan: 'pro' }, - { id: 'org-2', name: 'Other Org', slug: 'other-org', plan: 'free' }, - ] - - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { organizations: mockOrganizations }, - } - - await outputOrganizationList(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: OrganizationsCResult = { - ok: false, - message: 'Failed to fetch', - } - - await outputOrganizationList(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Text output', () => { - it('outputs organization list in text format', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { organizations: mockOrganizations }, - } - - await outputOrganizationList(result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('sk_live_') - expect(logs).toContain('My Org') - expect(logs).toContain('Other Org') - }) - - it('outputs error with fail message', async () => { - const result: OrganizationsCResult = { - ok: false, - message: 'Authentication failed', - cause: 'Invalid token', - } - - await outputOrganizationList(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Authentication failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: OrganizationsCResult = { - ok: false, - message: 'Rate limited', - code: 429, - } - - await outputOrganizationList(result, 'text') - - expect(process.exitCode).toBe(429) - }) - }) - - describe('Markdown output', () => { - it('outputs organization list as markdown table', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { organizations: mockOrganizations }, - } - - await outputOrganizationList(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Organizations') - expect(logs).toContain('Name') - expect(logs).toContain('ID') - expect(logs).toContain('Plan') - expect(logs).toContain('My Org') - expect(logs).toContain('org-1') - }) - - it('handles organizations with missing name', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { - organizations: [ - { - id: 'org-1', - name: undefined as unknown, - slug: 'my-org', - plan: 'pro', - }, - ], - }, - } - - await outputOrganizationList(result, 'markdown') - - // Should not throw. - expect(mockLogger.log).toHaveBeenCalled() - }) - }) - - describe('Default output', () => { - it('defaults to text output', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { organizations: mockOrganizations }, - } - - await outputOrganizationList(result) - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Name:') - expect(logs).toContain('ID:') - expect(logs).toContain('Plan:') - }) - - it('renders "undefined" placeholder for missing name in text mode', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { - organizations: [ - { id: 'x', name: undefined as unknown, slug: 'x', plan: 'free' }, - ], - }, - } - - await outputOrganizationList(result, 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('undefined') - }) - - it('renders empty padding for missing name in markdown mode', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { - organizations: [ - { - id: 'longer-id-string', - name: undefined as unknown, - slug: 'x', - plan: 'free', - }, - ], - }, - } - - await outputOrganizationList(result, 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - // Should not throw and should include the row. - expect(logs).toContain('longer-id-string') - }) - - it('falls back for empty id and empty plan in markdown row', async () => { - const result: OrganizationsCResult = { - ok: true, - data: { - organizations: [ - { - // All three falsy → exercise all three `|| ''` branches. - id: '' as unknown, - name: '' as unknown, - slug: '', - plan: '' as unknown, - }, - ], - }, - } - - await outputOrganizationList(result, 'markdown') - - // Row should still log without throwing. - expect(mockLogger.log).toHaveBeenCalled() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/output-quota.test.mts b/packages/cli/test/unit/commands/organization/output-quota.test.mts deleted file mode 100644 index f8dfa3c71..000000000 --- a/packages/cli/test/unit/commands/organization/output-quota.test.mts +++ /dev/null @@ -1,651 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit Tests: API Token Quota Output Formatter. - * - * Purpose: Tests the output formatting system for API token quota data. - * Validates JSON and text/markdown output formats, error messaging, exit code - * setting, and quota value display including zero quota scenarios. - * - * Test Coverage: - JSON format output for successful results - JSON format - * error output with exit codes - Text format with remaining/max/refresh display - * - Fallback when maxQuota is missing - Refresh time rendering when - * nextWindowRefresh is set - Text format error output with badges - Markdown - * format output - Zero quota handling - Default text output when format - * unspecified - Default exit code setting when code is undefined. - * - * Testing Approach: Uses vi.doMock to reset module state between tests, mocking - * logger, result serialization, markdown utilities, and error formatting. Tests - * verify output content and exit code behavior. - * - * Related Files: - src/commands/organization/output-quota.mts - Output - * formatter - src/commands/organization/handle-quota.mts - Command handler - - * src/commands/organization/fetch-quota.mts - Quota fetcher. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -describe('outputQuota', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 1000, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createErrorResult('Unauthorized', { - code: 2, - cause: 'Invalid API token', - }) - - process.exitCode = undefined - await outputQuota(result, 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs text format with quota information', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 500, - maxQuota: 1000, - nextWindowRefresh: undefined, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota remaining: 500 / 1000 (50% used)', - ) - expect(mockLogger.log).toHaveBeenCalledWith('Next refresh: unknown') - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(process.exitCode).toBeUndefined() - }) - - it('falls back to remaining-only when maxQuota is missing', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 250, - maxQuota: 0, - nextWindowRefresh: undefined, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('Quota remaining: 250') - }) - - it('formats nextWindowRefresh when provided', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 100, - maxQuota: 1000, - nextWindowRefresh: '2099-01-01T00:00:00.000Z', - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - // Exact "in X d" count is time-sensitive; just confirm it rendered the ISO date. - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some( - (c: unknown) => - typeof c === 'string' && c.includes('2099-01-01T00:00:00.000Z'), - ), - ).toBe(true) - }) - - it('shows <1 min when refresh is within 60 seconds', async () => { - // Regression: Math.round(diffMs / 60_000) used to produce "in 0 min" - // for 1–29,999 ms. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const soon = new Date(Date.now() + 5_000).toISOString() - const result = createSuccessResult({ - quota: 10, - maxQuota: 1000, - nextWindowRefresh: soon, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some((c: unknown) => typeof c === 'string' && c.includes('<1 min')), - ).toBe(true) - expect( - calls.some((c: unknown) => typeof c === 'string' && c.includes('0 min')), - ).toBe(false) - }) - - it('promotes to hours before producing "in 60 min" at the boundary', async () => { - // Regression: at diffMs ~= 59.5 min, Math.round rounded up to 60, - // giving "in 60 min" instead of "in 1 h". - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const near = new Date(Date.now() + 59.8 * 60_000).toISOString() - const result = createSuccessResult({ - quota: 10, - maxQuota: 1000, - nextWindowRefresh: near, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some((c: unknown) => typeof c === 'string' && c.includes('60 min')), - ).toBe(false) - expect( - calls.some((c: unknown) => typeof c === 'string' && c.includes('1 h')), - ).toBe(true) - }) - - it('returns the raw refresh string when Date.parse yields NaN', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 10, - maxQuota: 100, - nextWindowRefresh: 'not-a-date', - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some( - (c: unknown) => typeof c === 'string' && c.includes('not-a-date'), - ), - ).toBe(true) - }) - - it('emits "due now" when refresh timestamp has passed', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const past = new Date(Date.now() - 60_000).toISOString() - const result = createSuccessResult({ - quota: 10, - maxQuota: 100, - nextWindowRefresh: past, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some( - (c: unknown) => typeof c === 'string' && c.includes('due now'), - ), - ).toBe(true) - }) - - it('emits "in N min" for refresh windows under an hour', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const tenMin = new Date(Date.now() + 10 * 60_000).toISOString() - const result = createSuccessResult({ - quota: 10, - maxQuota: 100, - nextWindowRefresh: tenMin, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some((c: unknown) => typeof c === 'string' && /in \d+ min/.test(c)), - ).toBe(true) - }) - - it('emits "in N d" for refresh windows over 1.97 days', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const days = new Date(Date.now() + 5 * 86_400_000).toISOString() - const result = createSuccessResult({ - quota: 10, - maxQuota: 100, - nextWindowRefresh: days, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - const calls = mockLogger.log.mock.calls.map((c: unknown[]) => c[0]) - expect( - calls.some((c: unknown) => typeof c === 'string' && /in \d+ d/.test(c)), - ).toBe(true) - }) - - it('outputs error in text format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createErrorResult('Failed to fetch quota', { - code: 1, - cause: 'Network error', - }) - - process.exitCode = undefined - await outputQuota(result, 'text') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to fetch quota', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles markdown output format', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn(title => `# ${title}`), - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 750, - maxQuota: 1000, - nextWindowRefresh: undefined, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'markdown') - - // Markdown output routes through emitPayload — the whole markdown - // body is logged as one string (sentinel-wrapped so downstream - // spawns can't contaminate stdout). Match with stringContaining on - // the concatenated call args. - const loggedPayload = mockLogger.log.mock.calls - .map(call => String(call[0])) - .join('\n') - expect(loggedPayload).toContain('# Quota') - expect(loggedPayload).toContain('- Quota remaining: 750 / 1000 (25% used)') - expect(loggedPayload).toContain('- Next refresh: unknown') - }) - - it('handles zero quota correctly', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 0, - maxQuota: 1000, - nextWindowRefresh: undefined, - }) - - process.exitCode = undefined - await outputQuota(result as unknown, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota remaining: 0 / 1000 (100% used)', - ) - }) - - it('uses default text output when no format specified', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createSuccessResult({ - quota: 100, - maxQuota: 1000, - nextWindowRefresh: undefined, - }) - - process.exitCode = undefined - await outputQuota(result as unknown) - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota remaining: 100 / 1000 (90% used)', - ) - expect(mockLogger.log).toHaveBeenCalledWith('') - }) - - it('sets default exit code when code is undefined', async () => { - // Create mocks INSIDE each test. - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - // Use vi.doMock (NOT vi.mock). - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - // Dynamic import AFTER mocks. - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = createErrorResult('Error') - - process.exitCode = undefined - await outputQuota(result as unknown, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('falls back to exitCode 1 when result has no code field', async () => { - const mockLogger = { - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - vi.doMock('../../../../src/util/output/emit-payload.mts', () => ({ - emitPayload: vi.fn(), - })) - - const { outputQuota } = - await import('../../../../src/commands/organization/output-quota.mts') - - const result = { - ok: false as const, - message: 'No code', - cause: 'no code', - } - - process.exitCode = undefined - await outputQuota(result as unknown, 'json') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/organization/output-security-policy.test.mts b/packages/cli/test/unit/commands/organization/output-security-policy.test.mts deleted file mode 100644 index cce37493f..000000000 --- a/packages/cli/test/unit/commands/organization/output-security-policy.test.mts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Unit Tests: Organization Security Policy Output Formatter. - * - * Purpose: Tests the output formatting system for organization security policy - * data. Validates JSON and text/markdown output formats, error messaging, exit - * code setting, and security policy display including default actions and - * rule-specific actions. - * - * Test Coverage: - JSON format output for successful results - JSON format - * error output with exit codes - Text format with security policy rules display - * - Text format error output with badges - Markdown format output - Empty - * security policy handling - Default exit code setting when code is undefined. - * - * Testing Approach: Uses vi.doMock to reset module state between tests, mocking - * logger, result serialization, markdown utilities, and error formatting. Tests - * verify output content and exit code behavior. - * - * Related Files: - src/commands/organization/output-security-policy.mts - - * Output formatter - src/commands/organization/handle-security-policy.mts - - * Command handler - src/commands/organization/fetch-security-policy.mts - - * Security policy fetcher. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -import type { CResult } from '../../../../src/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -describe('outputSecurityPolicy', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createSuccessResult({ - securityPolicyDefault: 'warn', - securityPolicyRules: { - malware: { action: 'error' }, - typosquatting: { action: 'warn' }, - telemetry: { action: 'ignore' }, - }, - }) - - await outputSecurityPolicy(result, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createErrorResult('Unauthorized', { - cause: 'Invalid API token', - code: 2, - }) - - await outputSecurityPolicy(result, 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs text format with security policy table', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(title => `# ${title}`) - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createSuccessResult({ - securityPolicyDefault: 'error', - securityPolicyRules: { - dynamicRequire: { action: 'warn' }, - malware: { action: 'error' }, - networkAccess: { action: 'defer' }, - }, - }) - - await outputSecurityPolicy(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith('# Security policy') - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(mockLogger.log).toHaveBeenCalledWith( - 'The default security policy setting is: "error"', - ) - expect(mockLogger.log).toHaveBeenCalledWith( - 'These are the security policies per setting for your organization:', - ) - expect(mockMdTableOfPairs).toHaveBeenCalledWith( - expect.arrayContaining([ - ['dynamicRequire', 'warn'], - ['malware', 'error'], - ['networkAccess', 'defer'], - ]), - ['name', 'action'], - ) - }) - - it('outputs error in text format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createErrorResult('Failed to fetch security policy', { - cause: 'Network error', - code: 1, - }) - - await outputSecurityPolicy(result, 'text') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to fetch security policy', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles empty security policy rules', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(title => `# ${title}`) - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createSuccessResult({ - securityPolicyDefault: 'monitor', - securityPolicyRules: {}, - }) - - await outputSecurityPolicy(result, 'text') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'The default security policy setting is: "monitor"', - ) - expect(mockMdTableOfPairs).toHaveBeenCalledWith([], ['name', 'action']) - }) - - it('handles null security policy rules', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(title => `# ${title}`) - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createSuccessResult({ - securityPolicyDefault: 'defer', - securityPolicyRules: undefined, - }) - - await outputSecurityPolicy(result, 'text') - - expect(mockMdTableOfPairs).toHaveBeenCalledWith([], ['name', 'action']) - }) - - it('sorts policy rules alphabetically', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(title => `# ${title}`) - const mockMdTableOfPairs = vi.fn(pairs => `Table with ${pairs.length} rows`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createSuccessResult({ - securityPolicyDefault: 'warn', - securityPolicyRules: { - zlib: { action: 'ignore' }, - attackVector: { action: 'error' }, - malware: { action: 'warn' }, - }, - }) - - await outputSecurityPolicy(result, 'text') - - // Verify the entries are sorted alphabetically. - expect(mockMdTableOfPairs).toHaveBeenCalledWith( - [ - ['attackVector', 'error'], - ['malware', 'warn'], - ['zlib', 'ignore'], - ], - ['name', 'action'], - ) - }) - - it('sets default exit code when code is undefined', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - const result: CResult< - SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - > = createErrorResult('Error without code') - - await outputSecurityPolicy(result, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('falls back to exitCode 1 when result has no code field', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - // Manually construct error without code field. - const result = { - ok: false as const, - message: 'No code', - cause: 'no code', - } - - process.exitCode = undefined - await outputSecurityPolicy(result as unknown, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('handles undefined securityPolicyRules in text mode', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockMdHeader = vi.fn(text => `# ${text}`) - const mockMdTableOfPairs = vi.fn( - (pairs: Array<[string, string]>) => `${pairs.length} entries`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - vi.doMock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: mockMdHeader, - mdTableOfPairs: mockMdTableOfPairs, - })) - - const { outputSecurityPolicy } = - await import('../../../../src/commands/organization/output-security-policy.mts') - - // securityPolicyRules undefined → entries array is empty (line 41 false branch). - const result = { - ok: true as const, - data: { - securityPolicyDefault: 'medium', - securityPolicyRules: undefined, - }, - } - - await outputSecurityPolicy(result as unknown, 'text') - - expect(mockMdTableOfPairs).toHaveBeenCalledWith([], ['name', 'action']) - }) -}) diff --git a/packages/cli/test/unit/commands/package/cmd-package-score.test.mts b/packages/cli/test/unit/commands/package/cmd-package-score.test.mts deleted file mode 100644 index 8ceb49000..000000000 --- a/packages/cli/test/unit/commands/package/cmd-package-score.test.mts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Unit tests for package score command. - * - * Tests the command that looks up deep score for one package including its - * transitive dependencies. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandlePurlDeepScore = vi.hoisted(() => vi.fn()) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/package/handle-purl-deep-score.mts', () => ({ - handlePurlDeepScore: mockHandlePurlDeepScore, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdPackageScore } = - await import('../../../../src/commands/package/cmd-package-score.mts') - -describe('cmd-package-score', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdPackageScore.description).toBe( - 'Look up score for one package which reflects all of its transitive dependencies as well', - ) - }) - - it('should not be hidden', () => { - expect(cmdPackageScore.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-package-score.mts' } - const context = { parentName: 'socket package' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['--dry-run', 'npm', 'babel-cli'], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdPackageScore.run(['npm', 'babel-cli'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlDeepScore).not.toHaveBeenCalled() - }) - - it('should fail without package name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlDeepScore).not.toHaveBeenCalled() - }) - - it('should call handlePurlDeepScore with ecosystem and package name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run(['npm', 'babel-cli'], importMeta, context) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/babel-cli', - 'text', - ) - }) - - it('should handle purl format with pkg: prefix', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['pkg:npm/babel-cli@1.0.0'], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/babel-cli@1.0.0', - 'text', - ) - }) - - it('should handle purl format without pkg: prefix', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run(['npm/babel-cli@1.0.0'], importMeta, context) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/babel-cli@1.0.0', - 'text', - ) - }) - - it('should pass --json flag to handlePurlDeepScore', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['npm', 'babel-cli', '--json'], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/babel-cli', - 'json', - ) - }) - - it('should pass --markdown flag to handlePurlDeepScore', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['npm', 'babel-cli', '--markdown'], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/babel-cli', - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['npm', 'babel-cli', '--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlDeepScore).not.toHaveBeenCalled() - }) - - it('should handle package with version', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run(['npm', 'eslint@1.0.0'], importMeta, context) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:npm/eslint@1.0.0', - 'text', - ) - }) - - it('should handle different ecosystems', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run(['pypi', 'requests'], importMeta, context) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:pypi/requests', - 'text', - ) - }) - - it('should show query parameters in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['--dry-run', 'npm', 'babel-cli'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('[DryRun]: Would fetch package score'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('package: pkg:npm/babel-cli'), - ) - }) - - it('should handle golang purl with github namespace', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - [ - 'pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60', - ], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60', - 'text', - ) - }) - - it('should handle nuget purl', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdPackageScore.run( - ['nuget/needpluscommonlibrary@1.0.0', '--markdown'], - importMeta, - context, - ) - - expect(mockHandlePurlDeepScore).toHaveBeenCalledWith( - 'pkg:nuget/needpluscommonlibrary@1.0.0', - 'markdown', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/cmd-package-shallow.test.mts b/packages/cli/test/unit/commands/package/cmd-package-shallow.test.mts deleted file mode 100644 index 9c78842d6..000000000 --- a/packages/cli/test/unit/commands/package/cmd-package-shallow.test.mts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Unit tests for package shallow command. - * - * Tests the command that looks up shallow scores for one or more packages - * without their transitives. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandlePurlsShallowScore = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/package/handle-purls-shallow-score.mts', - () => ({ - handlePurlsShallowScore: mockHandlePurlsShallowScore, - }), -) - -// Import after mocks. -const { cmdPackageShallow } = - await import('../../../../src/commands/package/cmd-package-shallow.mts') - -describe('cmd-package-shallow', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdPackageShallow.description).toBe( - 'Look up info regarding one or more packages but not their transitives', - ) - }) - - it('should not be hidden', () => { - expect(cmdPackageShallow.hidden).toBe(false) - }) - - it('should have shallowScore alias', () => { - expect(cmdPackageShallow.alias).toBeDefined() - expect(cmdPackageShallow.alias?.shallowScore).toBeDefined() - expect(cmdPackageShallow.alias?.shallowScore.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-package-shallow.mts' } - const context = { parentName: 'socket package' } - - it('should support --dry-run flag', async () => { - await cmdPackageShallow.run( - ['--dry-run', 'npm', 'webtorrent'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without package name', async () => { - await cmdPackageShallow.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlsShallowScore).not.toHaveBeenCalled() - }) - - it('should call handlePurlsShallowScore with single package', async () => { - await cmdPackageShallow.run(['npm', 'webtorrent'], importMeta, context) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent'], - }) - }) - - it('should handle multiple packages with same ecosystem', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent', 'babel'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent', 'pkg:npm/babel'], - }) - }) - - it('should handle purl format with pkg: prefix', async () => { - await cmdPackageShallow.run( - ['pkg:npm/webtorrent@1.9.1'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent@1.9.1'], - }) - }) - - it('should handle purl format without pkg: prefix', async () => { - await cmdPackageShallow.run(['npm/webtorrent@1.9.1'], importMeta, context) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent@1.9.1'], - }) - }) - - it('should handle mixed purl formats with same ecosystem', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent@1.0.1', 'babel'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent@1.0.1', 'pkg:npm/babel'], - }) - }) - - it('should handle multiple purls from different ecosystems', async () => { - await cmdPackageShallow.run( - ['npm/webtorrent', 'golang/babel'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent', 'pkg:golang/babel'], - }) - }) - - it('should pass --json flag to handlePurlsShallowScore', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent', '--json'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'json', - purls: ['pkg:npm/webtorrent'], - }) - }) - - it('should pass --markdown flag to handlePurlsShallowScore', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent', '--markdown'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'markdown', - purls: ['pkg:npm/webtorrent'], - }) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent', '--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlsShallowScore).not.toHaveBeenCalled() - }) - - it('should handle package with version', async () => { - await cmdPackageShallow.run( - ['npm', 'webtorrent@1.9.1'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:npm/webtorrent@1.9.1'], - }) - }) - - it('should handle different ecosystems', async () => { - await cmdPackageShallow.run( - ['maven', 'webtorrent', 'babel'], - importMeta, - context, - ) - - expect(mockHandlePurlsShallowScore).toHaveBeenCalledWith({ - outputKind: 'text', - purls: ['pkg:maven/webtorrent', 'pkg:maven/babel'], - }) - }) - - it('should show query parameters in dry-run mode', async () => { - await cmdPackageShallow.run( - ['--dry-run', 'npm', 'webtorrent', 'babel'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('[DryRun]: Would fetch package information'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('packages: pkg:npm/webtorrent, pkg:npm/babel'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('count: 2'), - ) - }) - - it('should fail with invalid first parameter', async () => { - await cmdPackageShallow.run( - ['not-an-ecosystem-or-purl'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandlePurlsShallowScore).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/cmd-package.test.mts b/packages/cli/test/unit/commands/package/cmd-package.test.mts deleted file mode 100644 index fefde2d73..000000000 --- a/packages/cli/test/unit/commands/package/cmd-package.test.mts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Unit tests for package parent command. - * - * Tests the parent command that routes to package analysis subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdPackage } = - await import('../../../../src/commands/package/cmd-package.mts') -const { cmdPackageScore } = - await import('../../../../src/commands/package/cmd-package-score.mts') -const { cmdPackageShallow } = - await import('../../../../src/commands/package/cmd-package-shallow.mts') - -describe('cmd-package', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdPackage.description).toBe('Look up published package details') - }) - - it('should not be hidden', () => { - expect(cmdPackage.hidden).toBe(false) - }) - - it('should have a run method', () => { - expect(typeof cmdPackage.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-package.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run(['score'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['score'], - importMeta, - name: 'socket package', - subcommands: { - score: cmdPackageScore, - shallow: cmdPackageShallow, - }, - }, - { - aliases: { - deep: { - argv: ['score'], - description: 'Look up published package details', - hidden: true, - }, - }, - description: 'Look up published package details', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run(['shallow'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent package', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual(['score', 'shallow']) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['score', 'npm/lodash', '--json'] - - await cmdPackage.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['shallow', 'npm/react']) as readonly string[] - - await cmdPackage.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.score).toBe(cmdPackageScore) - expect(subcommands.shallow).toBe(cmdPackageShallow) - }) - }) - - describe('aliases configuration', () => { - it('should configure deep alias for score', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.deep).toEqual({ - argv: ['score'], - description: 'Look up published package details', - hidden: true, - }) - }) - - it('should mark deep alias as hidden', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.deep.hidden).toBe(true) - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdPackage.run([], { url: 'file:///test' }, { parentName: 'socket' }), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdPackage.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe('Look up published package details') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/fetch-purl-deep-score.test.mts b/packages/cli/test/unit/commands/package/fetch-purl-deep-score.test.mts deleted file mode 100644 index 5a04434c3..000000000 --- a/packages/cli/test/unit/commands/package/fetch-purl-deep-score.test.mts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Unit tests for fetchPurlDeepScore. - * - * Thin wrapper around queryApiSafeJson — verifies URL encoding, info logging, - * and that the query result is returned unchanged. - * - * Related Files: - src/commands/package/fetch-purl-deep-score.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockQueryApiSafeJson = vi.hoisted(() => vi.fn()) -const mockLogger = vi.hoisted(() => ({ - info: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -import { fetchPurlDeepScore } from '../../../../src/commands/package/fetch-purl-deep-score.mts' - -describe('fetchPurlDeepScore', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('encodes the PURL into the request path', async () => { - mockQueryApiSafeJson.mockResolvedValueOnce({ ok: true, data: {} }) - const purl = 'pkg:npm/lodash@4.17.21' - - await fetchPurlDeepScore(purl) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - `purl/score/${encodeURIComponent(purl)}`, - 'the deep package scores', - ) - }) - - it('logs an info message before issuing the request', async () => { - mockQueryApiSafeJson.mockResolvedValueOnce({ ok: true, data: {} }) - - await fetchPurlDeepScore('pkg:npm/foo@1.0.0') - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('pkg:npm/foo@1.0.0'), - ) - }) - - it('returns the response from queryApiSafeJson unchanged on success', async () => { - const response = { - ok: true as const, - data: { - purl: 'pkg:npm/foo@1.0.0', - self: { purl: 'pkg:npm/foo@1.0.0' }, - transitively: { dependencyCount: 5 }, - }, - } - mockQueryApiSafeJson.mockResolvedValueOnce(response) - - const result = await fetchPurlDeepScore('pkg:npm/foo@1.0.0') - - expect(result).toBe(response) - }) - - it('returns the response from queryApiSafeJson unchanged on failure', async () => { - const response = { ok: false as const, message: '500 from API' } - mockQueryApiSafeJson.mockResolvedValueOnce(response) - - const result = await fetchPurlDeepScore('pkg:npm/foo@1.0.0') - - expect(result).toBe(response) - }) -}) diff --git a/packages/cli/test/unit/commands/package/fetch-purls-shallow-score.test.mts b/packages/cli/test/unit/commands/package/fetch-purls-shallow-score.test.mts deleted file mode 100644 index 9a2d7100d..000000000 --- a/packages/cli/test/unit/commands/package/fetch-purls-shallow-score.test.mts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Unit tests for fetchPurlsShallowScore. - * - * Purpose: Tests the batch package fetching functionality for retrieving - * shallow security scores for multiple PURLs (Package URLs) via the Socket API. - * Validates SDK integration, error handling, and batch request handling. - * - * Test Coverage: - Successful batch package score retrieval - SDK setup failure - * handling - API call error scenarios (rate limits, large batches) - Custom SDK - * options (API tokens, base URLs) - Empty PURL array handling - Mixed ecosystem - * PURL types (npm, pypi, maven, gem) - Large batch processing (100+ packages) - - * Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Tests various batch sizes and PURL formats to ensure robust multi-package - * score fetching. - * - * Related Files: - src/commands/package/fetch-purls-shallow-score.mts - * (implementation) - src/commands/package/handle-purls-shallow-score.mts - * (handler) - src/util/socket/api.mts (API utilities) - src/util/socket/sdk.mts - * (SDK setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchPurlsShallowScore } from '../../../../src/commands/package/fetch-purls-shallow-score.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) -const mockInfo = vi.hoisted(() => vi.fn()) -const mockGetDefaultLogger = vi.hoisted(() => - vi.fn(() => ({ - info: mockInfo, - })), -) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: mockGetDefaultLogger, -})) - -describe('fetchPurlsShallowScore', () => { - it('fetches purls shallow scores successfully', async () => { - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'batchPackageFetch', - [ - { - purl: 'pkg:npm/lodash@4.17.21', - score: 85, - name: 'lodash', - version: '4.17.21', - }, - { - purl: 'pkg:npm/express@4.18.2', - score: 92, - name: 'express', - version: '4.18.2', - }, - ], - ) - - const purls = ['pkg:npm/lodash@4.17.21', 'pkg:npm/express@4.18.2'] - const result = await fetchPurlsShallowScore(purls) - - expect(mockSdk.batchPackageFetch).toHaveBeenCalledWith( - { components: purls.map(purl => ({ purl })) }, - { alerts: 'true' }, - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'looking up package', - }) - expect(result.ok).toBe(true) - expect(result.data).toHaveLength(2) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid configuration', - }) - - const result = await fetchPurlsShallowScore(['pkg:npm/test@1.0.0']) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - }) - - it('handles API call failure', async () => { - await setupSdkMockError('batchPackageFetch', 'Batch too large', 400) - - const result = await fetchPurlsShallowScore( - Array(1000).fill('pkg:npm/test@1.0.0'), - ) - - expect(result.ok).toBe(false) - expect(result.code).toBe(400) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('batchPackageFetch', []) - - const sdkOpts = { - apiToken: 'batch-token', - baseUrl: 'https://batch.api.com', - } - - await fetchPurlsShallowScore(['pkg:npm/test@1.0.0'], { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles empty purl array', async () => { - const { mockSdk } = await setupSdkMockSuccess('batchPackageFetch', []) - - const result = await fetchPurlsShallowScore([]) - - expect(mockSdk.batchPackageFetch).toHaveBeenCalledWith( - { components: [] }, - { alerts: 'true' }, - ) - expect(result.ok).toBe(true) - expect(result.data).toEqual([]) - }) - - it('handles mixed purl types', async () => { - const { mockSdk } = await setupSdkMockSuccess('batchPackageFetch', []) - - const mixedPurls = [ - 'pkg:npm/lodash@4.17.21', - 'pkg:pypi/django@4.2.0', - 'pkg:maven/org.springframework/spring-core@5.3.0', - 'pkg:gem/rails@7.0.0', - ] - - await fetchPurlsShallowScore(mixedPurls) - - expect(mockSdk.batchPackageFetch).toHaveBeenCalledWith( - { components: mixedPurls.map(purl => ({ purl })) }, - { alerts: 'true' }, - ) - }) - - it('handles large batch of purls', async () => { - const largeBatch = Array(100) - .fill(0) - .map((_, i) => `pkg:npm/package-${i}@1.0.0`) - const mockResults = largeBatch.map(purl => ({ purl, score: 80 })) - - await setupSdkMockSuccess('batchPackageFetch', mockResults) - - const result = await fetchPurlsShallowScore(largeBatch) - - expect(result.ok).toBe(true) - expect(result.data).toHaveLength(100) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('batchPackageFetch', []) - - // This tests that the function properly uses __proto__: null. - await fetchPurlsShallowScore(['pkg:npm/test@1.0.0']) - - // The function should work without prototype pollution issues. - expect(mockSdk.batchPackageFetch).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/package/handle-purl-deep-score.test.mts b/packages/cli/test/unit/commands/package/handle-purl-deep-score.test.mts deleted file mode 100644 index ec96ea15d..000000000 --- a/packages/cli/test/unit/commands/package/handle-purl-deep-score.test.mts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Unit tests for handlePurlDeepScore. - * - * Purpose: Tests the handler function that orchestrates fetching and outputting - * deep security scores for a single package PURL. Deep scores include - * transitive dependency analysis. Validates the fetch-process-output pipeline - * and debug logging. - * - * Test Coverage: - Successful deep score fetch and output - Fetch failure - * handling - Multiple output formats (json, text, markdown) - Debug logging for - * successful and failed fetches - Different PURL formats (unscoped, scoped, - * version tags) - * - * Testing Approach: Mocks fetch and output functions to isolate handler logic. - * Verifies proper orchestration between fetching, logging, and formatting - * layers. - * - * Related Files: - src/commands/package/handle-purl-deep-score.mts - * (implementation) - src/commands/package/fetch-purl-deep-score.mts (API - * fetcher) - src/commands/package/output-purls-deep-score.mts (formatter) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handlePurlDeepScore } from '../../../../src/commands/package/handle-purl-deep-score.mts' - -// Mock the dependencies. -const mockFetchPurlDeepScore = vi.hoisted(() => vi.fn()) -const mockOutputPurlsDeepScore = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/package/fetch-purl-deep-score.mts', () => ({ - fetchPurlDeepScore: mockFetchPurlDeepScore, -})) -vi.mock('../../../../src/commands/package/output-purls-deep-score.mts', () => ({ - outputPurlsDeepScore: mockOutputPurlsDeepScore, -})) -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -describe('handlePurlDeepScore', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs deep score successfully', async () => { - const mockData = { - ok: true, - data: { - name: 'package1', - version: '1.0.0', - score: 95, - dependencies: ['dep1', 'dep2'], - }, - } - mockFetchPurlDeepScore.mockResolvedValue(mockData) - - const purl = 'pkg:npm/package1@1.0.0' - await handlePurlDeepScore(purl, 'json') - - expect(mockFetchPurlDeepScore).toHaveBeenCalledWith(purl) - expect(mockOutputPurlsDeepScore).toHaveBeenCalledWith( - purl, - mockData, - 'json', - ) - }) - - it('handles fetch failure', async () => { - const mockError = { - ok: false, - error: new Error('Failed to fetch deep score'), - } - mockFetchPurlDeepScore.mockResolvedValue(mockError) - - const purl = 'pkg:npm/package1@1.0.0' - await handlePurlDeepScore(purl, 'text') - - expect(mockFetchPurlDeepScore).toHaveBeenCalledWith(purl) - expect(mockOutputPurlsDeepScore).toHaveBeenCalledWith( - purl, - mockError, - 'text', - ) - }) - - it('handles markdown output', async () => { - const mockData = { - ok: true, - data: { - name: 'package1', - version: '1.0.0', - score: 88, - }, - } - mockFetchPurlDeepScore.mockResolvedValue(mockData) - - const purl = 'pkg:npm/package1@1.0.0' - await handlePurlDeepScore(purl, 'markdown') - - expect(mockOutputPurlsDeepScore).toHaveBeenCalledWith( - purl, - mockData, - 'markdown', - ) - }) - - it('logs debug information', async () => { - const mockData = { - ok: true, - data: { name: 'package1', version: '1.0.0', score: 91 }, - } - mockFetchPurlDeepScore.mockResolvedValue(mockData) - - const purl = 'pkg:npm/package1@1.0.0' - await handlePurlDeepScore(purl, 'json') - - expect(mockDebug).toHaveBeenCalledWith( - 'Fetching deep score for pkg:npm/package1@1.0.0', - ) - expect(mockDebugDir).toHaveBeenCalledWith({ - purl, - outputKind: 'json', - }) - expect(mockDebug).toHaveBeenCalledWith('Deep score fetched successfully') - expect(mockDebugDir).toHaveBeenCalledWith({ result: mockData }) - }) - - it('logs debug information on failure', async () => { - const mockError = { - ok: false, - error: new Error('API error'), - } - mockFetchPurlDeepScore.mockResolvedValue(mockError) - - await handlePurlDeepScore('pkg:npm/package1@1.0.0', 'json') - - expect(mockDebug).toHaveBeenCalledWith('Deep score fetch failed') - }) - - it('handles different purl formats', async () => { - const purls = [ - 'pkg:npm/package1@1.0.0', - 'pkg:npm/@scope/package@2.0.0', - 'pkg:npm/package@latest', - ] - - for (let i = 0, { length } = purls; i < length; i += 1) { - const purl = purls[i] - mockFetchPurlDeepScore.mockResolvedValue({ - ok: true, - data: { name: 'test', version: '1.0.0', score: 85 }, - }) - - await handlePurlDeepScore(purl, 'json') - - expect(mockFetchPurlDeepScore).toHaveBeenCalledWith(purl) - } - }) - - it('handles text output', async () => { - const mockData = { - ok: true, - data: { - name: 'package1', - version: '1.0.0', - score: 93, - }, - } - mockFetchPurlDeepScore.mockResolvedValue(mockData) - - const purl = 'pkg:npm/package1@1.0.0' - await handlePurlDeepScore(purl, 'text') - - expect(mockOutputPurlsDeepScore).toHaveBeenCalledWith( - purl, - mockData, - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/package/handle-purls-shallow-score.test.mts b/packages/cli/test/unit/commands/package/handle-purls-shallow-score.test.mts deleted file mode 100644 index 38331421f..000000000 --- a/packages/cli/test/unit/commands/package/handle-purls-shallow-score.test.mts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Unit tests for handlePurlsShallowScore. - * - * Purpose: Tests the handler function that orchestrates fetching and outputting - * shallow security scores for multiple package PURLs. Shallow scores analyze - * only the package itself, excluding dependencies. Validates the batch - * fetch-process-output pipeline. - * - * Test Coverage: - Successful batch fetch and output - Fetch failure handling - - * Multiple output formats (json, text, markdown) - Empty PURL array handling - - * Debug logging for successes and failures - Multiple package batch processing. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Verifies proper data flow and debug logging throughout - * the handler pipeline. - * - * Related Files: - src/commands/package/handle-purls-shallow-score.mts - * (implementation) - src/commands/package/fetch-purls-shallow-score.mts (API - * fetcher) - src/commands/package/output-purls-shallow-score.mts (formatter) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output' - -import { fetchPurlsShallowScore } from '../../../../src/commands/package/fetch-purls-shallow-score.mts' -import { handlePurlsShallowScore } from '../../../../src/commands/package/handle-purls-shallow-score.mts' -import { outputPurlsShallowScore } from '../../../../src/commands/package/output-purls-shallow-score.mts' - -// Mock the dependencies. -const mockFetchPurlsShallowScore = vi.hoisted(() => vi.fn()) -const mockOutputPurlsShallowScore = vi.hoisted(() => vi.fn()) -const mock_debug = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/package/fetch-purls-shallow-score.mts', - () => ({ - fetchPurlsShallowScore: mockFetchPurlsShallowScore, - }), -) -vi.mock( - '../../../../src/commands/package/output-purls-shallow-score.mts', - () => ({ - outputPurlsShallowScore: mockOutputPurlsShallowScore, - }), -) -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) - -describe('handlePurlsShallowScore', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs shallow scores successfully', async () => { - const mockData = { - ok: true, - data: [ - { name: 'package1', version: '1.0.0', score: 85 }, - { name: 'package2', version: '2.0.0', score: 92 }, - ], - } - mockFetchPurlsShallowScore.mockResolvedValue(mockData) - - const purls = ['pkg:npm/package1@1.0.0', 'pkg:npm/package2@2.0.0'] - await handlePurlsShallowScore({ - outputKind: 'json', - purls, - }) - - expect(fetchPurlsShallowScore).toHaveBeenCalledWith(purls, { - commandPath: 'socket package shallow', - }) - expect(outputPurlsShallowScore).toHaveBeenCalledWith( - purls, - mockData, - 'json', - ) - }) - - it('handles fetch failure', async () => { - const mockError = { - ok: false, - error: new Error('Failed to fetch scores'), - } - mockFetchPurlsShallowScore.mockResolvedValue(mockError) - - const purls = ['pkg:npm/package1@1.0.0'] - await handlePurlsShallowScore({ - outputKind: 'text', - purls, - }) - - expect(fetchPurlsShallowScore).toHaveBeenCalledWith(purls, { - commandPath: 'socket package shallow', - }) - expect(outputPurlsShallowScore).toHaveBeenCalledWith( - purls, - mockError, - 'text', - ) - }) - - it('handles markdown output', async () => { - const mockData = { - ok: true, - data: [{ name: 'package1', version: '1.0.0', score: 90 }], - } - mockFetchPurlsShallowScore.mockResolvedValue(mockData) - - const purls = ['pkg:npm/package1@1.0.0'] - await handlePurlsShallowScore({ - outputKind: 'markdown', - purls, - }) - - expect(outputPurlsShallowScore).toHaveBeenCalledWith( - purls, - mockData, - 'markdown', - ) - }) - - it('handles empty purls array', async () => { - const mockData = { - ok: true, - data: [], - } - mockFetchPurlsShallowScore.mockResolvedValue(mockData) - - await handlePurlsShallowScore({ - outputKind: 'json', - purls: [], - }) - - expect(fetchPurlsShallowScore).toHaveBeenCalledWith([], { - commandPath: 'socket package shallow', - }) - expect(outputPurlsShallowScore).toHaveBeenCalledWith([], mockData, 'json') - }) - - it('logs debug information', async () => { - const mockData = { - ok: true, - data: [{ name: 'package1', version: '1.0.0', score: 88 }], - } - mockFetchPurlsShallowScore.mockResolvedValue(mockData) - - const purls = ['pkg:npm/package1@1.0.0'] - await handlePurlsShallowScore({ - outputKind: 'json', - purls, - }) - - expect(debug).toHaveBeenCalledWith('Fetching shallow scores for 1 packages') - expect(debugDir).toHaveBeenCalledWith({ - purls, - outputKind: 'json', - }) - expect(debug).toHaveBeenCalledWith('Shallow scores fetched successfully') - expect(debugDir).toHaveBeenCalledWith({ packageData: mockData }) - }) - - it('logs debug information on failure', async () => { - const mockError = { - ok: false, - error: new Error('API error'), - } - mockFetchPurlsShallowScore.mockResolvedValue(mockError) - - await handlePurlsShallowScore({ - outputKind: 'json', - purls: ['pkg:npm/package1@1.0.0'], - }) - - expect(debug).toHaveBeenCalledWith('Shallow scores fetch failed') - }) - - it('handles multiple purls', async () => { - const mockData = { - ok: true, - data: [ - { name: 'package1', version: '1.0.0', score: 85 }, - { name: 'package2', version: '2.0.0', score: 92 }, - { name: 'package3', version: '3.0.0', score: 78 }, - ], - } - mockFetchPurlsShallowScore.mockResolvedValue(mockData) - - const purls = [ - 'pkg:npm/package1@1.0.0', - 'pkg:npm/package2@2.0.0', - 'pkg:npm/package3@3.0.0', - ] - await handlePurlsShallowScore({ - outputKind: 'json', - purls, - }) - - expect(fetchPurlsShallowScore).toHaveBeenCalledWith(purls, { - commandPath: 'socket package shallow', - }) - expect(outputPurlsShallowScore).toHaveBeenCalledWith( - purls, - mockData, - 'json', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/package/output-purls-deep-score.test.mts b/packages/cli/test/unit/commands/package/output-purls-deep-score.test.mts deleted file mode 100644 index 35867a5fb..000000000 --- a/packages/cli/test/unit/commands/package/output-purls-deep-score.test.mts +++ /dev/null @@ -1,869 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for output-purls-deep-score markdown generation. - * - * Purpose: Tests markdown report generation for deep package security scores - * across multiple ecosystems. Deep scores include package + transitive - * dependencies. Uses snapshot testing to ensure consistent report formatting - * across npm, Go, Ruby, NuGet, Maven, and Python ecosystems. - * - * Test Coverage: - * - * - Npm package deep score markdown formatting - * - Go package deep score markdown formatting - * - Ruby package deep score markdown formatting - * - NuGet package deep score markdown formatting - * - Maven package deep score markdown formatting - * - Python package deep score markdown formatting - * - Shallow vs deep score differentiation - * - Capability detection display - * - Alert severity grouping and formatting - * - Transitive package results section - * - * Testing Approach: Uses fixture JSON files from real Socket API responses and - * snapshot testing to validate comprehensive markdown report structure across - * all supported ecosystems. - * - * Related Files: - * - * - Src/commands/package/output-purls-deep-score.mts (implementation) - * - Src/commands/package/fixtures/*.json (test fixtures) - */ - -import { describe, expect, it } from 'vitest' - -import goDeep from '../../../../src/commands/package/fixtures/go_deep.json' with { type: 'json' } -import mavenDeep from '../../../../src/commands/package/fixtures/maven_deep.json' with { type: 'json' } -import npmDeep from '../../../../src/commands/package/fixtures/npm_deep.json' with { type: 'json' } -import nugetDeep from '../../../../src/commands/package/fixtures/nuget_deep.json' with { type: 'json' } -import pythonDeep from '../../../../src/commands/package/fixtures/python_deep.json' with { type: 'json' } -import rubyDeep from '../../../../src/commands/package/fixtures/ruby_deep.json' with { type: 'json' } -import { - createMarkdownReport, - outputPurlsDeepScore, -} from '../../../../src/commands/package/output-purls-deep-score.mts' - -describe('package score output', async () => { - describe('npm', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(npmDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"npm/bowserify@10.2.1"* and its *171* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 35 - - Maintenance: 74 - - Quality: 99 - - Supply Chain: 35 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - These are the capabilities detected in the package itself: - - - fs - - net - - unsafe - - url - - ### Alerts for this package - - These are the alerts found for the package itself: - - | -------- | ---------------- | - | Severity | Alert Name | - | -------- | ---------------- | - | critical | didYouMean | - | high | troll | - | middle | networkAccess | - | middle | unpopularPackage | - | low | debugAccess | - | low | dynamicRequire | - | low | filesystemAccess | - | low | unmaintained | - | -------- | ---------------- | - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 25 - - Maintenance: 50 - - Quality: 49 - - Supply Chain: 35 - - Vulnerability: 25 - - License: 80 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: npm/shell-quote@0.0.1 - - Maintenance: npm/jsonify@0.0.1 - - Quality: npm/tty-browserify@0.0.1 - - Supply Chain: npm/bowserify@10.2.1 - - Vulnerability: npm/shell-quote@0.0.1 - - License: npm/acorn-node@1.8.2 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - env - - eval - - fs - - net - - unsafe - - url - - ### Alerts - - These are the alerts found: - - | -------- | ---------------------- | ---------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | ---------------------- | ---------------------------- | - | critical | criticalCVE | npm/shell-quote@0.0.1 | - | critical | didYouMean | npm/bowserify@10.2.1 | - | high | cve | npm/minimatch@2.0.10 | - | high | socketUpgradeAvailable | npm/safe-buffer@5.1.2 | - | high | troll | npm/bowserify@10.2.1 | - | middle | deprecated | npm/querystring@0.2.0 | - | middle | miscLicenseIssues | npm/duplexer2@0.0.2 | - | middle | missingAuthor | npm/indexof@0.0.1 | - | middle | networkAccess | npm/https-browserify@0.0.1 | - | middle | trivialPackage | npm/tty-browserify@0.0.1 | - | middle | unpopularPackage | npm/b@1.0.0 | - | middle | usesEval | npm/syntax-error@1.4.0 | - | low | debugAccess | npm/asn1.js@4.10.1 | - | low | dynamicRequire | npm/module-deps@3.9.1 | - | low | envVars | npm/readable-stream@2.3.8 | - | low | filesystemAccess | npm/browser-resolve@1.11.3 | - | low | newAuthor | npm/wrappy@1.0.2 | - | low | noLicenseFound | npm/indexof@0.0.1 | - | low | unidentifiedLicense | npm/jsonify@0.0.1 | - | low | unmaintained | npm/bowserify@10.2.1 | - | -------- | ---------------------- | ---------------------------- | - " - `) - }) - }) - - describe('go', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(goDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60"* and its *81* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 100 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 100 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - No capabilities were found in the package. - - ### Alerts for this package - - There are currently no alerts for this package. - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 70 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 70 - - Vulnerability: 84 - - License: 70 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: golang/go.uber.org/mock@v0.5.0 - - Maintenance: golang/github.com/stretchr/objx@v0.1.0 - - Quality: golang/github.com/stretchr/objx@v0.1.0 - - Supply Chain: golang/go.uber.org/mock@v0.5.0 - - Vulnerability: golang/github.com/golang-jwt/jwt/v5@v5.2.1 - - License: golang/github.com/hashicorp/go-cleanhttp@v0.5.2 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - env - - eval - - fs - - net - - shell - - unsafe - - ### Alerts - - These are the alerts found: - - | -------- | ---------------------- | ------------------------------------------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | ---------------------- | ------------------------------------------------------------- | - | high | cve | golang/github.com/golang-jwt/jwt/v5@v5.2.1 | - | middle | hasNativeCode | golang/github.com/pkg/diff@v0.0.0-20210226163009-20ebb0f2a09e | - | middle | mediumCVE | golang/golang.org/x/net@v0.35.0 | - | middle | networkAccess | golang/github.com/stretchr/objx@v0.1.0 | - | middle | potentialVulnerability | golang/github.com/onsi/ginkgo/v2@v2.22.2 | - | middle | shellAccess | golang/github.com/stretchr/testify@v1.9.0 | - | middle | usesEval | golang/gopkg.in/yaml.v3@v3.0.1 | - | low | copyleftLicense | golang/github.com/hashicorp/go-cleanhttp@v0.5.2 | - | low | envVars | golang/gopkg.in/yaml.v3@v3.0.1 | - | low | filesystemAccess | golang/github.com/stretchr/objx@v0.1.0 | - | low | gptAnomaly | golang/github.com/stretchr/objx@v0.1.0 | - | low | nonpermissiveLicense | golang/github.com/hashicorp/go-cleanhttp@v0.5.2 | - | low | unidentifiedLicense | golang/gopkg.in/yaml.v3@v3.0.1 | - | -------- | ---------------------- | ------------------------------------------------------------- | - " - `) - }) - }) - - describe('ruby', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(rubyDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"pkg:gem/plaid@14.11.0?platform=ruby"* and its *31* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 100 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 100 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - No capabilities were found in the package. - - ### Alerts for this package - - There are currently no alerts for this package. - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 72 - - Maintenance: 100 - - Quality: 92 - - Supply Chain: 84 - - Vulnerability: 72 - - License: 70 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: gem/rexml@3.2.4 - - Maintenance: gem/diff-lcs@1.4.4 - - Quality: gem/rspec@3.10.0 - - Supply Chain: gem/rubocop@0.91.1 - - Vulnerability: gem/rexml@3.2.4 - - License: gem/diff-lcs@1.4.4 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - env - - eval - - fs - - net - - shell - - unsafe - - ### Alerts - - These are the alerts found: - - | -------- | -------------------- | ---------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | -------------------- | ---------------------------- | - | high | cve | gem/rexml@3.2.4 | - | middle | mediumCVE | gem/rexml@3.2.4 | - | middle | networkAccess | gem/faraday@1.8.0 | - | middle | shellAccess | gem/diff-lcs@1.4.4 | - | middle | usesEval | gem/ruby2_keywords@0.0.5 | - | low | copyleftLicense | gem/diff-lcs@1.4.4 | - | low | envVars | gem/parser@2.7.2.0 | - | low | filesystemAccess | gem/diff-lcs@1.4.4 | - | low | noLicenseFound | gem/minitest@5.14.2 | - | low | nonpermissiveLicense | gem/diff-lcs@1.4.4 | - | -------- | -------------------- | ---------------------------- | - " - `) - }) - }) - - describe('nuget', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(nugetDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"pkg:nuget/needpluscommonlibrary@1.0.0"* and its *3* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 100 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 100 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - No capabilities were found in the package. - - ### Alerts for this package - - There are currently no alerts for this package. - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 84 - - Maintenance: 100 - - Quality: 88 - - Supply Chain: 89 - - Vulnerability: 84 - - License: 100 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: nuget/newtonsoft.json@4.5.10 - - Maintenance: nuget/dotnetzip@1.9.1.8 - - Quality: nuget/dotnetzip@1.9.1.8 - - Supply Chain: nuget/nlog@2.0.0.2000 - - Vulnerability: nuget/newtonsoft.json@4.5.10 - - License: nuget/dotnetzip@1.9.1.8 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - eval - - fs - - net - - shell - - unsafe - - ### Alerts - - These are the alerts found: - - | -------- | ------------------- | ---------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | ------------------- | ---------------------------- | - | high | cve | nuget/newtonsoft.json@4.5.10 | - | middle | mediumCVE | nuget/dotnetzip@1.9.1.8 | - | middle | networkAccess | nuget/nlog@2.0.0.2000 | - | middle | shellAccess | nuget/dotnetzip@1.9.1.8 | - | middle | usesEval | nuget/dotnetzip@1.9.1.8 | - | low | filesystemAccess | nuget/dotnetzip@1.9.1.8 | - | low | unidentifiedLicense | nuget/dotnetzip@1.9.1.8 | - | -------- | ------------------- | ---------------------------- | - " - `) - }) - }) - - describe('maven', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(mavenDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"pkg:maven/org.apache.beam/beam-runners-flink-1.15-job-server@2.58.0?classifier=tests&ext=jar"* and its *404* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 100 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 100 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - No capabilities were found in the package. - - ### Alerts for this package - - There are currently no alerts for this package. - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 6 - - Maintenance: 71 - - Quality: 88 - - Supply Chain: 6 - - Vulnerability: 25 - - License: 50 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: maven/io.trino.hadoop/hadoop-apache@3.2.0-12 - - Maintenance: maven/org.apache.beam/beam-sdks-java-extensions-arrow@2.58.0 - - Quality: maven/log4j/log4j@1.2.17 - - Supply Chain: maven/io.trino.hadoop/hadoop-apache@3.2.0-12 - - Vulnerability: maven/log4j/log4j@1.2.17 - - License: maven/com.fasterxml.jackson.datatype/jackson-datatype-joda@2.15.4 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - env - - eval - - fs - - net - - shell - - unsafe - - ### Alerts - - These are the alerts found: - - | -------- | ---------------------- | ---------------------------------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | ---------------------- | ---------------------------------------------------- | - | critical | criticalCVE | maven/log4j/log4j@1.2.17 | - | critical | didYouMean | maven/io.trino.hadoop/hadoop-apache@3.2.0-12 | - | high | cve | maven/log4j/log4j@1.2.17 | - | middle | hasNativeCode | maven/org.apache.beam/beam-vendor-grpc-1_60_1@0.2 | - | middle | mediumCVE | maven/org.apache.ant/ant@1.10.9 | - | middle | networkAccess | maven/log4j/log4j@1.2.17 | - | middle | potentialVulnerability | maven/log4j/log4j@1.2.17 | - | middle | shellAccess | maven/org.apache.beam/beam-vendor-calcite-1_28_0@0.2 | - | middle | usesEval | maven/log4j/log4j@1.2.17 | - | low | copyleftLicense | maven/javax.annotation/javax.annotation-api@1.3.2 | - | low | envVars | maven/org.apache.beam/beam-vendor-calcite-1_28_0@0.2 | - | low | filesystemAccess | maven/log4j/log4j@1.2.17 | - | low | gptAnomaly | maven/io.netty/netty-transport@4.1.100.Final | - | low | licenseException | maven/javax.annotation/javax.annotation-api@1.3.2 | - | low | mildCVE | maven/org.apache.hadoop/hadoop-common@2.10.2 | - | low | noLicenseFound | maven/com.google.guava/failureaccess@1.0.2 | - | low | nonpermissiveLicense | maven/org.apache.commons/commons-math3@3.6.1 | - | low | unidentifiedLicense | maven/log4j/log4j@1.2.17 | - | low | unmaintained | maven/log4j/log4j@1.2.17 | - | -------- | ---------------------- | ---------------------------------------------------- | - " - `) - }) - }) - - describe('python', () => { - it('should report deep as markdown', () => { - const txt = createMarkdownReport(pythonDeep.data, []) - expect(txt).toMatchInlineSnapshot(` - "# Complete Package Score - - This is a Socket report for the package *"pkg:pypi/discordpydebug@0.0.4?artifact_id=tar-gz"* and its *825* direct/transitive dependencies. - - It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. - - The report should give you a good insight into the status of this package. - - ## Package itself - - Here are results for the package itself (excluding data from dependencies). - - ### Shallow Score - - This score is just for the package itself: - - - Overall: 100 - - Maintenance: 100 - - Quality: 100 - - Supply Chain: 100 - - Vulnerability: 100 - - License: 100 - - ### Capabilities - - No capabilities were found in the package. - - ### Alerts for this package - - There are currently no alerts for this package. - - ## Transitive Package Results - - Here are results for the package and its direct/transitive dependencies. - - ### Deep Score - - This score represents the package and and its direct/transitive dependencies: - The function used to calculate the values in aggregate is: *"min"* - - - Overall: 70 - - Maintenance: 99 - - Quality: 88 - - Supply Chain: 70 - - Vulnerability: 100 - - License: 70 - - ### Capabilities - - These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. - - - Overall: pypi/virtualenv@20.31.2 - - Maintenance: pypi/webencodings@0.5.1 - - Quality: pypi/coverage-enable-subprocess@1.0 - - Supply Chain: pypi/virtualenv@20.31.2 - - Vulnerability: pypi/chardet@5.2.0 - - License: pypi/chardet@5.2.0 - - ### Capabilities - - These are the capabilities detected in at least one package: - - - env - - eval - - fs - - net - - shell - - unsafe - - url - - ### Alerts - - These are the alerts found: - - | -------- | -------------------- | ----------------------------- | - | Severity | Alert Name | Example package reporting it | - | -------- | -------------------- | ----------------------------- | - | middle | gptDidYouMean | pypi/jinja2@3.1.6 | - | middle | hasNativeCode | pypi/pyyaml@6.0.2 | - | middle | networkAccess | pypi/webencodings@0.5.1 | - | middle | shellAccess | pypi/colorama@0.4.6 | - | middle | usesEval | pypi/stack-data@0.6.3 | - | low | ambiguousClassifier | pypi/jinja2@3.1.6 | - | low | copyleftLicense | pypi/chardet@5.2.0 | - | low | envVars | pypi/sphinxcontrib-jquery@4.1 | - | low | filesystemAccess | pypi/chardet@5.2.0 | - | low | gptAnomaly | pypi/genshi@0.7.9 | - | low | licenseException | pypi/pygments@2.19.1 | - | low | nonpermissiveLicense | pypi/chardet@5.2.0 | - | low | unidentifiedLicense | pypi/webencodings@0.5.1 | - | low | unmaintained | pypi/webencodings@0.5.1 | - | -------- | -------------------- | ----------------------------- | - " - `) - }) - }) - - describe('no-dependencies edge case', () => { - it('renders the dependency-free path when transitively.dependencyCount is 0', () => { - const data = { - purl: 'pkg:npm/single@1.0.0', - self: { - purl: 'pkg:npm/single@1.0.0', - alerts: [], - capabilities: [], - score: { - overall: 100, - maintenance: 100, - quality: 100, - supplyChain: 100, - vulnerability: 100, - license: 100, - }, - }, - transitively: { - alerts: [], - capabilities: [], - dependencyCount: 0, - func: 'identity', - lowest: { - overall: 100, - maintenance: 100, - quality: 100, - supplyChain: 100, - vulnerability: 100, - license: 100, - }, - score: { - overall: 100, - maintenance: 100, - quality: 100, - supplyChain: 100, - vulnerability: 100, - license: 100, - }, - }, - } as unknown - - const txt = createMarkdownReport(data) - expect(txt).toContain('It has *no dependencies*') - expect(txt).toContain( - 'Since it has no dependencies, the shallow score is also the deep score', - ) - expect(txt).toContain('## Report') - // Transitive section is omitted for the no-deps path. - expect(txt).not.toContain('## Transitive Package Results') - }) - - it('renders self-alerts variant when no dependencies exist (line 134)', () => { - // dependencyCount=0 + non-empty selfAlerts → exercises line 134 - // ('These are the alerts found for this package:'). - const data = { - purl: 'pkg:npm/lonely@1.0.0', - self: { - purl: 'pkg:npm/lonely@1.0.0', - alerts: [{ severity: 'high', name: 'cve' }], - capabilities: [], - score: { - overall: 80, - maintenance: 80, - quality: 80, - supplyChain: 80, - vulnerability: 80, - license: 80, - }, - }, - transitively: { - alerts: [], - capabilities: [], - dependencyCount: 0, - func: 'identity', - lowest: { - overall: 80, - maintenance: 80, - quality: 80, - supplyChain: 80, - vulnerability: 80, - license: 80, - }, - score: { - overall: 80, - maintenance: 80, - quality: 80, - supplyChain: 80, - vulnerability: 80, - license: 80, - }, - }, - } as unknown - - const txt = createMarkdownReport(data) - expect(txt).toContain('These are the alerts found for this package:') - }) - - it('renders empty-deep-results section (lines 189-210)', () => { - // dependencyCount > 0 but transitively.alerts and capabilities empty - // → exercises lines 189-191 (no capabilities) and 208-210 (no alerts). - const data = { - purl: 'pkg:npm/silent@2.0.0', - self: { - purl: 'pkg:npm/silent@2.0.0', - alerts: [], - capabilities: [], - score: { - overall: 100, - maintenance: 100, - quality: 100, - supplyChain: 100, - vulnerability: 100, - license: 100, - }, - }, - transitively: { - alerts: [], - capabilities: [], - dependencyCount: 5, - func: 'min', - lowest: { - overall: 'pkg:npm/dep@1.0.0', - maintenance: 'pkg:npm/dep@1.0.0', - quality: 'pkg:npm/dep@1.0.0', - supplyChain: 'pkg:npm/dep@1.0.0', - vulnerability: 'pkg:npm/dep@1.0.0', - license: 'pkg:npm/dep@1.0.0', - }, - score: { - overall: 90, - maintenance: 90, - quality: 90, - supplyChain: 90, - vulnerability: 90, - license: 90, - }, - }, - } as unknown - - const txt = createMarkdownReport(data) - expect(txt).toContain('This package had no capabilities') - expect(txt).toContain('This package had no alerts') - }) - }) - - describe('outputPurlsDeepScore', () => { - it('sets exit code from error result code', async () => { - const result = { - ok: false as const, - message: 'Failed', - code: 7, - } - process.exitCode = undefined - await outputPurlsDeepScore('pkg:npm/test', result as unknown, 'json') - expect(process.exitCode).toBe(7) - process.exitCode = undefined - }) - - it('falls back exit code to 1 when result.code missing', async () => { - const result = { - ok: false as const, - message: 'Failed without code', - } - process.exitCode = undefined - await outputPurlsDeepScore('pkg:npm/test', result as unknown, 'json') - expect(process.exitCode).toBe(1) - process.exitCode = undefined - }) - - it('returns early after fail message in text mode for error result', async () => { - const result = { - ok: false as const, - message: 'fail', - cause: 'reason', - } - // Should not throw / resolve to undefined. - await expect( - outputPurlsDeepScore('pkg:npm/test', result as unknown, 'text'), - ).resolves.toBeUndefined() - }) - - it('renders markdown report on success (lines 29-34)', async () => { - // Exercises the `outputKind === 'markdown'` branch that emits - // logger.success + logger.log(md) + return. - const result = { - ok: true as const, - data: nugetDeep.data, - } - await expect( - outputPurlsDeepScore('pkg:nuget/test', result as unknown, 'markdown'), - ).resolves.toBeUndefined() - }) - - it('renders text fallback on success (lines 36-40)', async () => { - // Exercises the default text-output branch (logger.log of the data - // object after the markdown branch returns). - const result = { - ok: true as const, - data: nugetDeep.data, - } - await expect( - outputPurlsDeepScore('pkg:nuget/test', result as unknown, 'text'), - ).resolves.toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/output-purls-shallow-malware.test.mts b/packages/cli/test/unit/commands/package/output-purls-shallow-malware.test.mts deleted file mode 100644 index 1b2d0a114..000000000 --- a/packages/cli/test/unit/commands/package/output-purls-shallow-malware.test.mts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Unit tests for malware detection in shallow score output. - * - * Purpose: Tests malware alert handling and formatting in shallow package score - * reports. Validates both traditional malware and GPT-detected malware alert - * types, including issueRules filtering and score calculation for - * malware-infected packages. - * - * Test Coverage: - * - * - Malware and gptMalware alert display in text reports - * - Malware and gptMalware alert display in markdown reports - * - IssueRules filtering for malware alerts - * - Blocked alert identification for malware - * - Config flag integration with malware detection - * - Score formatting for malware-infected packages - * - Critical severity display for malware alerts - * - * Testing Approach: Uses malware fixture data to test alert categorization, - * filtering, and formatting. Validates that malware is prominently displayed - * with critical severity indicators. - * - * Related Files: - * - * - Src/commands/package/output-purls-shallow-score.mts (implementation) - * - Src/commands/package/fixtures/npm_malware.json (malware fixture) - */ - -import { describe, expect, it } from 'vitest' - -import npmMalware from '../../../../src/commands/package/fixtures/npm_malware.json' with { type: 'json' } -import { - generateMarkdownReport, - generateTextReport, - preProcess, -} from '../../../../src/commands/package/output-purls-shallow-score.mts' - -describe('package score output with malware detection', async () => { - describe('malware and gptMalware alerts', () => { - it('should display malware alerts in text report', () => { - const { missing, rows } = preProcess(npmMalware.data, []) - const txt = generateTextReport(rows, missing) - - // Check that the report contains both malware types. - expect(txt).toContain('malware') - expect(txt).toContain('gptMalware') - expect(txt).toContain('[critical]') - expect(txt).toContain('evil-test-package') - - // Verify the overall structure matches expected format. - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:npm/evil-test-package@1.0.0 - - - Supply Chain Risk:  1 - - Maintenance:  0 - - Quality:  0 - - Vulnerabilities:  0 - - License:  0 - - Alerts (4/0/0): [critical] gptMalware, [critical] malware, [high] networkAccess, and [high] obfuscatedFile - " - `) - }) - - it('should display malware alerts in markdown report', () => { - const { missing, rows } = preProcess(npmMalware.data, []) - const txt = generateMarkdownReport(rows, missing) - - // Check that the report contains both malware types. - expect(txt).toContain('malware') - expect(txt).toContain('gptMalware') - expect(txt).toContain('[critical]') - - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:npm/evil-test-package@1.0.0 - - - Supply Chain Risk: 1 - - Maintenance: 0 - - Quality: 0 - - Vulnerabilities: 0 - - License: 0 - - Alerts (4/0/0): [critical] gptMalware, [critical] malware, [high] networkAccess, and [high] obfuscatedFile" - `) - }) - - it('should handle malware alerts with issueRules filtering', () => { - // Test with only malware enabled. - const dataWithMalwareOnly = JSON.parse(JSON.stringify(npmMalware.data)) - - // Simulate issueRules filtering by setting actions on alerts. - // When gptMalware is disabled, it would have action: 'ignore'. - dataWithMalwareOnly[0].alerts[1].action = 'ignore' // gptMalware - - const { missing, rows } = preProcess(dataWithMalwareOnly, []) - const txt = generateTextReport(rows, missing) - - // Should still show malware but not gptMalware if it's ignored. - expect(txt).toContain('malware') - // Note: gptMalware will still appear in the list but as ignored (not blocked). - }) - - it('should properly identify blocked alerts for malware', () => { - const { missing: _missing, rows } = preProcess(npmMalware.data, []) - - // Check the processed data structure. - expect(rows.size).toBe(1) - const packageData = Array.from(rows.values())[0] - - // Verify alerts are properly categorized. - expect(packageData.alerts).toBeDefined() - - // Find malware and gptMalware alerts. - const alerts = Array.from(packageData.alerts.values()) - const malwareAlert = alerts.find((a: unknown) => a.type === 'malware') - const gptMalwareAlert = alerts.find( - (a: unknown) => a.type === 'gptMalware', - ) - - expect(malwareAlert).toBeDefined() - expect(malwareAlert.severity).toBe('critical') - - expect(gptMalwareAlert).toBeDefined() - expect(gptMalwareAlert.severity).toBe('critical') - }) - }) - - describe('config flag integration with malware detection', () => { - it('should respect issueRules configuration for malware alerts', () => { - // Simulate the config being passed with issueRules. - const issueRules = { - malware: true, - gptMalware: true, - } - - // In actual implementation, the issueRules would filter alerts during API call - // or during processing. Here we verify the data structure supports this. - const { missing: _missing, rows } = preProcess(npmMalware.data, []) - - // Verify that both malware types are present when enabled. - const packageData = Array.from(rows.values())[0] - const alerts = Array.from(packageData.alerts.values()) - const hasRegularMalware = alerts.some( - (a: unknown) => a.type === 'malware', - ) - const hasGptMalware = alerts.some((a: unknown) => a.type === 'gptMalware') - - expect(hasRegularMalware).toBe(true) - expect(hasGptMalware).toBe(true) - }) - - it('should format scores correctly for malware-infected packages', () => { - const { missing: _missing, rows } = preProcess(npmMalware.data, []) - const packageData = Array.from(rows.values())[0] - - // Verify scores are extremely low for malware package (or default 100 if undefined). - // The preProcess function uses score ?? 100, preserving 0 values. - expect(packageData.score.supplyChain).toBe(0.01) - expect(packageData.score.quality).toBe(0) // 0 is preserved (not undefined) - expect(packageData.score.maintenance).toBe(0) // 0 is preserved (not undefined) - expect(packageData.score.vulnerability).toBe(0) // 0 is preserved (not undefined) - expect(packageData.score.license).toBe(0) // 0 is preserved (not undefined) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/output-purls-shallow-score-flow.test.mts b/packages/cli/test/unit/commands/package/output-purls-shallow-score-flow.test.mts deleted file mode 100644 index 7965d92fa..000000000 --- a/packages/cli/test/unit/commands/package/output-purls-shallow-score-flow.test.mts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Unit tests for outputPurlsShallowScore dispatcher. - * - * The format helpers have their own snapshot tests; this suite covers - * outputPurlsShallowScore() entry points: error handling, JSON / markdown / - * text mode dispatch, and exit code propagation. - * - * Related Files: - src/commands/package/output-purls-shallow-score.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - fail: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -import { outputPurlsShallowScore } from '../../../../src/commands/package/output-purls-shallow-score.mts' - -const sampleArtifact = { - type: 'npm', - name: 'lodash', - version: '4.17.21', - score: { - supplyChain: 0.9, - maintenance: 0.85, - quality: 0.95, - vulnerability: 0.8, - license: 0.99, - }, - alerts: [], -} as unknown - -describe('outputPurlsShallowScore', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('sets exit code from result.code on failure', async () => { - outputPurlsShallowScore( - ['pkg:npm/x'], - { ok: false, message: 'fail', code: 5 } as unknown, - 'text', - ) - expect(process.exitCode).toBe(5) - }) - - it('defaults exit code to 1 when result.code is missing', async () => { - outputPurlsShallowScore( - ['pkg:npm/x'], - { ok: false, message: 'fail' } as unknown, - 'text', - ) - expect(process.exitCode).toBe(1) - }) - - it('logs JSON for failed result in JSON mode', async () => { - outputPurlsShallowScore( - ['pkg:npm/x'], - { ok: false, message: 'fail' } as unknown, - 'json', - ) - expect(mockLogger.log).toHaveBeenCalled() - expect(mockLogger.fail).not.toHaveBeenCalled() - }) - - it('logs JSON for successful result in JSON mode', async () => { - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact] } as unknown, - 'json', - ) - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('logs failure with badge in non-JSON mode', async () => { - outputPurlsShallowScore( - ['pkg:npm/x'], - { ok: false, message: 'fail', cause: 'detail' } as unknown, - 'text', - ) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('logs markdown report for successful result in markdown mode', async () => { - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact] } as unknown, - 'markdown', - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('# Shallow Package Report'), - ) - }) - - it('logs text report for successful result in text mode', async () => { - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact] } as unknown, - 'text', - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Shallow Package Score'), - ) - }) - - it('flags missing PURLs in the report', async () => { - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21', 'pkg:npm/missing@1.0.0'], - { ok: true, data: [sampleArtifact] } as unknown, - 'markdown', - ) - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('Missing response') - expect(calls).toContain('pkg:npm/missing@1.0.0') - }) - - it('does not flag a versioned PURL as missing when @latest companion is in the request (line 211)', () => { - // The @latest dedup branch: when '@latest' is in the requested set - // alongside a versioned PURL, the @latest entry is filtered out - // (not marked as missing) since the versioned data covers it. - outputPurlsShallowScore( - ['pkg:npm/lodash@latest', 'pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact] } as unknown, - 'markdown', - ) - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - // @latest should not appear in a "Missing response" section since - // it has a versioned companion in the request. - expect(calls).not.toMatch(/Missing.*pkg:npm\/lodash@latest/) - }) - - it('dedups artifacts and merges to lower scores (lines 228, 231, 234)', () => { - const lower = { - type: 'npm', - name: 'lodash', - version: '4.17.21', - score: { - supplyChain: 0.5, - maintenance: 0.55, - quality: 0.6, - vulnerability: 0.65, - license: 0.7, - }, - alerts: [], - } as unknown - // Two artifacts that produce the same purl key — merge should pick lower scores. - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact, lower] } as unknown, - 'text', - ) - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - // Output should appear only once even though two artifacts were provided. - const matches = calls.match(/pkg:npm\/lodash/g) || [] - expect(matches.length).toBeGreaterThanOrEqual(1) - }) - - it('dedups identical markdown blocks (line 293)', () => { - // Two artifacts that render to identical markdown blocks should - // produce only one rendered block — the duplicate is skipped. - const dup = { - ...sampleArtifact, - // Same key data so formatReportCard outputs identical markdown. - } - outputPurlsShallowScore( - ['pkg:npm/lodash@4.17.21'], - { ok: true, data: [sampleArtifact, dup] } as unknown, - 'markdown', - ) - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - // Should appear at most once; the dedup `continue` at line 293 fires - // when the second artifact produces the same block. - const matches = calls.match(/pkg:npm\/lodash/g) || [] - expect(matches.length).toBeGreaterThanOrEqual(1) - }) -}) diff --git a/packages/cli/test/unit/commands/package/output-purls-shallow-score.test.mts b/packages/cli/test/unit/commands/package/output-purls-shallow-score.test.mts deleted file mode 100644 index 6dff2d04c..000000000 --- a/packages/cli/test/unit/commands/package/output-purls-shallow-score.test.mts +++ /dev/null @@ -1,524 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for output-purls-shallow-score report generation. - * - * Purpose: Tests text and markdown report generation for shallow package - * security scores across multiple ecosystems. Shallow scores analyze only the - * package itself, excluding dependencies. Uses snapshot testing to ensure - * consistent formatting across npm, Go, Ruby, NuGet, Maven, and Python - * ecosystems. - * - * Test Coverage: - * - * - Npm package text and markdown formatting - * - Go package text and markdown formatting - * - Ruby package text and markdown formatting - * - NuGet package text and markdown formatting - * - Maven package text and markdown formatting - * - Python package text and markdown formatting - * - Python package deduplication logic - * - Score color coding (red/yellow/green thresholds) - * - Alert severity grouping and display - * - Missing package handling - * - * Testing Approach: Uses fixture JSON files from real Socket API responses and - * snapshot testing to validate comprehensive report structure. Tests both text - * (ANSI colors) and markdown output formats. - * - * Related Files: - * - * - Src/commands/package/output-purls-shallow-score.mts (implementation) - * - Src/commands/package/fixtures/*.json (test fixtures) - */ - -import { describe, expect, it } from 'vitest' - -import goShallow from '../../../../src/commands/package/fixtures/go_shallow.json' with { type: 'json' } -import mavenShallow from '../../../../src/commands/package/fixtures/maven_shallow.json' with { type: 'json' } -import npmShallow from '../../../../src/commands/package/fixtures/npm_shallow.json' with { type: 'json' } -import nugetShallow from '../../../../src/commands/package/fixtures/nuget_shallow.json' with { type: 'json' } -import pythonDupes from '../../../../src/commands/package/fixtures/python_dupes.json' with { type: 'json' } -import pythonShallow from '../../../../src/commands/package/fixtures/python_shallow.json' with { type: 'json' } -import rubyShallow from '../../../../src/commands/package/fixtures/ruby_shallow.json' with { type: 'json' } -import { - generateMarkdownReport, - generateTextReport, - preProcess, -} from '../../../../src/commands/package/output-purls-shallow-score.mts' - -describe('package score output', async () => { - describe('npm', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(npmShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:npm/bowserify@10.2.1 - - - Supply Chain Risk:  36 - - Maintenance:  75 - - Quality:  99 - - Vulnerabilities: 100 - - License: 100 - - Alerts (2/2/4): [critical] didYouMean, [high] troll, [middle] networkAccess, [middle] unpopularPackage, [low] debugAccess, [low] dynamicRequire, [low] filesystemAccess, and [low] unmaintained - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(npmShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:npm/bowserify@10.2.1 - - - Supply Chain Risk: 36 - - Maintenance: 75 - - Quality: 99 - - Vulnerabilities: 100 - - License: 100 - - Alerts (2/2/4): [critical] didYouMean, [high] troll, [middle] networkAccess, [middle] unpopularPackage, [low] debugAccess, [low] dynamicRequire, [low] filesystemAccess, and [low] unmaintained" - `) - }) - }) - - describe('go', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(goShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:golang/tlsproxy@v0.0.0-20250304082521-29051ed19c60 - - - Supply Chain Risk:  39 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, and [low] filesystemAccess - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(goShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:golang/tlsproxy@v0.0.0-20250304082521-29051ed19c60 - - - Supply Chain Risk: 39 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, and [low] filesystemAccess" - `) - }) - }) - - describe('ruby', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(rubyShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:gem/plaid@14.11.0 - - - Supply Chain Risk:  86 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (2/3/2): [high] gptMalware, [high] obfuscatedFile, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, and [low] filesystemAccess - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(rubyShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:gem/plaid@14.11.0 - - - Supply Chain Risk: 86 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (2/3/2): [high] gptMalware, [high] obfuscatedFile, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, and [low] filesystemAccess" - `) - }) - }) - - describe('nuget', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(nugetShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:nuget/needpluscommonlibrary@1.0.0 - - - Supply Chain Risk:  91 - - Maintenance: 100 - - Quality:  86 - - Vulnerabilities: 100 - - License: 100 - - Alerts (0/4/2): [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [middle] usesEval, [low] filesystemAccess, and [low] unidentifiedLicense - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(nugetShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:nuget/needpluscommonlibrary@1.0.0 - - - Supply Chain Risk: 91 - - Maintenance: 100 - - Quality: 86 - - Vulnerabilities: 100 - - License: 100 - - Alerts (0/4/2): [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [middle] usesEval, [low] filesystemAccess, and [low] unidentifiedLicense" - `) - }) - }) - - describe('maven', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(mavenShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:maven/beam-runners-flink-1.15-job-server@2.58.0 - - - Supply Chain Risk:  67 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License:  60 - - Alerts (0/3/0): [middle] hasNativeCode, [middle] networkAccess, and [middle] usesEval - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(mavenShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:maven/beam-runners-flink-1.15-job-server@2.58.0 - - - Supply Chain Risk: 67 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 60 - - Alerts (0/3/0): [middle] hasNativeCode, [middle] networkAccess, and [middle] usesEval" - `) - }) - }) - - describe('python', () => { - it('should report shallow as text', () => { - const { missing, rows } = preProcess(pythonShallow.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:pypi/discordpydebug@0.0.4 - - - Supply Chain Risk:  22 - - Maintenance: 100 - - Quality:  99 - - Vulnerabilities: 100 - - License: 100 - - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [low] filesystemAccess, and [low] unidentifiedLicense - " - `) - }) - - it('should report shallow as markdown', () => { - const { missing, rows } = preProcess(pythonShallow.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:pypi/discordpydebug@0.0.4 - - - Supply Chain Risk: 22 - - Maintenance: 100 - - Quality: 99 - - Vulnerabilities: 100 - - License: 100 - - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [low] filesystemAccess, and [low] unidentifiedLicense" - `) - }) - - describe('python duplication', () => { - it('should dedupe the python dupes and create a colored plain text report with three score blocks', () => { - const { missing, rows } = preProcess(pythonDupes.data, []) - const txt = generateTextReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - " - Shallow Package Score - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - Package: pkg:pypi/charset-normalizer@3.4.0 - - - Supply Chain Risk:  99 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (0/2/1): [middle] hasNativeCode, [middle] usesEval, and [low] filesystemAccess - " - `) - - expect(txt.split('Supply Chain Risk:').length).toBe(2) // Should find it once so when you split that you get 2 parts - }) - - it('should dedupe the python dupes and create a markdown report with three score blocks', () => { - const { missing, rows } = preProcess(pythonDupes.data, []) - const txt = generateMarkdownReport(rows, missing) - expect(txt).toMatchInlineSnapshot(` - "# Shallow Package Report - - This report contains the response for requesting data on some package url(s). - - Please note: The listed scores are ONLY for the package itself. It does NOT - reflect the scores of any dependencies, transitive or otherwise. - - - - ## Package: pkg:pypi/charset-normalizer@3.4.0 - - - Supply Chain Risk: 99 - - Maintenance: 100 - - Quality: 100 - - Vulnerabilities: 100 - - License: 100 - - Alerts (0/2/1): [middle] hasNativeCode, [middle] usesEval, and [low] filesystemAccess" - `) - - expect(txt.split('Supply Chain Risk:').length).toBe(2) // Should find it once so when you split that you get 2 parts - expect(txt).toContain('pkg:pypi/charset-normalizer@3.4.0') - }) - }) - }) - - describe('missing purls', () => { - it('emits "missing response" notice in text report', () => { - const empty = new Map() - const txt = generateTextReport(empty, [ - 'pkg:npm/missing@1', - 'pkg:npm/gone@2', - ]) - expect(txt).toContain('At least one package had no response') - expect(txt).toContain('missing@1') - expect(txt).toContain('gone@2') - }) - - it('emits "missing response" notice in markdown report', () => { - const empty = new Map() - const md = generateMarkdownReport(empty, ['pkg:pypi/missing@1']) - expect(md).toContain('## Missing response') - expect(md).toContain('missing@1') - }) - - it('omits missing notice when array is empty', () => { - const empty = new Map() - const txt = generateTextReport(empty, []) - expect(txt).not.toContain('At least one package had no response') - const md = generateMarkdownReport(empty, []) - expect(md).not.toContain('## Missing response') - }) - }) - - describe('deduplication of identical text-report blocks', () => { - it('drops duplicate blocks from generateTextReport', () => { - const { missing, rows } = preProcess(pythonDupes.data, []) - const txt = generateTextReport(rows, missing) - // pythonDupes contains duplicated entries; the dedupe path in - // generateTextReport (via the Set<string> tracker) drops repeats. - const matches = txt.match(/Supply Chain Risk:/g) || [] - expect(matches.length).toBeGreaterThanOrEqual(1) - }) - - it('drops duplicate blocks from generateMarkdownReport', () => { - const { missing, rows } = preProcess(pythonDupes.data, []) - const md = generateMarkdownReport(rows, missing) - const matches = md.match(/Supply Chain Risk:/g) || [] - expect(matches.length).toBeGreaterThanOrEqual(1) - }) - - it('takes the lowest of duplicate quality/vulnerability/license scores', () => { - // Two artifacts with the same purl but different scores — preProcess - // dedupes them by purl and takes the LOWEST score for each metric. - const data = [ - { - type: 'npm', - name: 'shared-pkg', - version: '1.0.0', - score: { - supplyChain: 100, - maintenance: 100, - quality: 99, - vulnerability: 99, - license: 99, - }, - alerts: [], - }, - // Same purl, but lower quality/vulnerability/license. - { - type: 'npm', - name: 'shared-pkg', - version: '1.0.0', - score: { - supplyChain: 100, - maintenance: 100, - quality: 50, - vulnerability: 60, - license: 70, - }, - alerts: [], - }, - ] - const { rows } = preProcess(data as unknown, []) - const row = rows.get('pkg:npm/shared-pkg@1.0.0')! - expect(row.score.quality).toBe(50) - expect(row.score.vulnerability).toBe(60) - expect(row.score.license).toBe(70) - }) - - it('drops duplicate text-report blocks when two purls yield identical cards', () => { - // Construct two artifacts with the same shape so formatReportCard - // produces an identical string for both, hitting the dupes.has() path. - const sharedScore = { - supplyChain: 100, - maintenance: 100, - quality: 100, - vulnerability: 100, - license: 100, - } - const rows = new Map<string, unknown>([ - [ - 'pkg:npm/dup-pkg@1.0.0', - { - ecosystem: 'npm', - namespace: '', - name: 'dup-pkg', - version: '1.0.0', - score: sharedScore, - alerts: new Map(), - }, - ], - // Different key but same content → same formatReportCard output. - [ - 'pkg:npm/dup-pkg@2.0.0', - { - ecosystem: 'npm', - namespace: '', - name: 'dup-pkg', - version: '1.0.0', - score: sharedScore, - alerts: new Map(), - }, - ], - ]) - - const txt = generateTextReport(rows, []) - // The dupe block should be dropped: only one rendering of "dup-pkg" content. - const matches = txt.match(/dup-pkg/g) || [] - expect(matches.length).toBe(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/package/parse-package-specifiers.test.mts b/packages/cli/test/unit/commands/package/parse-package-specifiers.test.mts deleted file mode 100644 index ad4d0ca79..000000000 --- a/packages/cli/test/unit/commands/package/parse-package-specifiers.test.mts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Unit tests for parsePackageSpecifiers. - * - * Purpose: Tests the parser that converts user-provided package specifiers into - * valid PURLs (Package URLs). Handles ecosystem prefixes, scoped packages, - * mixed formats, and validates input correctness. - * - * Test Coverage: - Simple npm package parsing (e.g., "npm babel") - PURL with - * pkg: prefix parsing - npm scoped packages (@babel/core) - PURL without pkg: - * prefix - Multiple PURL parsing - Mixed package names and PURLs - Invalid - * unscoped package without namespace error - Namespace-only input error - Empty - * namespace error. - * - * Testing Approach: Tests various input formats and validates PURL construction - * and error detection. Uses snapshot testing for complex multi-package - * scenarios. - * - * Related Files: - src/commands/package/parse-package-specifiers.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { parsePackageSpecifiers } from '../../../../src/commands/package/parse-package-specifiers.mts' - -describe('parse-package-specifiers', async () => { - it('should parse a simple `npm babel`', () => { - const { purls, valid } = parsePackageSpecifiers('npm', ['babel']) - expect(valid).toBe(true) - expect(purls).toStrictEqual(['pkg:npm/babel']) - }) - - it('should parse a simple purl with prefix', () => { - expect(parsePackageSpecifiers('pkg:npm/babel', [])).toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/babel", - ], - "valid": true, - } - `) - }) - - it('should support npm scoped packages', () => { - expect(parsePackageSpecifiers('npm', ['@babel/core'])) - .toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/@babel/core", - ], - "valid": true, - } - `) - }) - - it('should parse a simple purl without prefix', () => { - expect(parsePackageSpecifiers('npm/babel', [])).toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/babel", - ], - "valid": true, - } - `) - }) - - it('should parse a multiple purls', () => { - expect(parsePackageSpecifiers('npm/babel', ['golang/foo'])) - .toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/babel", - "pkg:golang/foo", - ], - "valid": true, - } - `) - }) - - it('should parse a mixed names and purls', () => { - expect( - parsePackageSpecifiers('npm', ['golang/foo', 'babel', 'pkg:npm/tenko']), - ).toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/golang/foo", - "pkg:npm/babel", - "pkg:npm/tenko", - ], - "valid": true, - } - `) - }) - - it('should complain when seeing an unscoped package without namespace', () => { - expect(parsePackageSpecifiers('golang/foo', ['babel', 'pkg:npm/tenko'])) - .toMatchInlineSnapshot(` - { - "purls": [ - "pkg:golang/foo", - ], - "valid": false, - } - `) - }) - - it('should complain when only getting a namespace', () => { - expect(parsePackageSpecifiers('npm', [])).toMatchInlineSnapshot(` - { - "purls": [], - "valid": false, - } - `) - }) - - it('should complain when getting an empty namespace', () => { - expect(parsePackageSpecifiers('', [])).toMatchInlineSnapshot(` - { - "purls": [], - "valid": false, - } - `) - }) - - it('flags valid:false when an empty package name appears in the list', () => { - // Exercises the inner `if (!pkg) { valid = false; break }` branch. - expect(parsePackageSpecifiers('npm', ['babel', '', 'lodash'])) - .toMatchInlineSnapshot(` - { - "purls": [ - "pkg:npm/babel", - ], - "valid": false, - } - `) - }) - - it('returns valid:false when purl-mode has nothing parseable', () => { - // Exercise the !purls.length branch in the purl-mode path. - expect(parsePackageSpecifiers('not-a-purl', ['also-bad'])) - .toMatchInlineSnapshot(` - { - "purls": [], - "valid": false, - } - `) - }) - - it('handles undefined slot in pkgs array (sparse) for npm-mode', () => { - // pkgs[0] = undefined → `pkgs[i] ?? ''` returns '', then breaks valid. - // Use Array.from with explicit undefined. - const sparse = [undefined as unknown] - expect(parsePackageSpecifiers('npm', sparse)).toMatchInlineSnapshot(` - { - "purls": [], - "valid": false, - } - `) - }) - - it('handles undefined slot in pkgs array (sparse) for purl-mode', () => { - const sparse = [undefined as unknown] - expect(parsePackageSpecifiers('not-a-purl', sparse)).toMatchInlineSnapshot(` - { - "purls": [], - "valid": false, - } - `) - }) -}) diff --git a/packages/cli/test/unit/commands/patch/cmd-patch.test.mts b/packages/cli/test/unit/commands/patch/cmd-patch.test.mts deleted file mode 100644 index 37deab2c8..000000000 --- a/packages/cli/test/unit/commands/patch/cmd-patch.test.mts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Unit tests for patch command. - * - * Tests the command that manages CVE patches for dependencies. This command - * forwards subcommands to socket-patch via DLX. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mts' - -// Mock meowOrExit. -const mockMeowOrExit = vi.hoisted(() => vi.fn().mockReturnValue({ flags: {} })) - -// Mock spawnSocketPatchDlx. -const mockSpawnSocketPatchDlx = vi.hoisted(() => - vi.fn().mockResolvedValue({ - spawnPromise: Promise.resolve({ code: 0, signal: undefined }), - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mts', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -vi.mock('../../../../src/util/dlx/spawn.mjs', () => ({ - spawnSocketPatchDlx: mockSpawnSocketPatchDlx, -})) - -// Import after mocks. -const { cmdPatch, CMD_NAME } = - await import('../../../../src/commands/patch/cmd-patch.mts') - -describe('cmd-patch', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should export CMD_NAME as patch', () => { - expect(CMD_NAME).toBe('patch') - }) - - it('should have correct description', () => { - expect(cmdPatch.description).toBe('Manage CVE patches for dependencies') - }) - - it('should not be hidden', () => { - expect(cmdPatch.hidden).toBe(false) - }) - }) - - describe('help path (no subcommand)', () => { - const importMeta = { url: 'file:///test/cmd-patch.mts' } - const context = { parentName: 'socket' } - - it('should call meowOrExit when only flags provided', async () => { - await cmdPatch.run(['--help'], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - argv: ['--help'], - parentName: 'socket', - config: expect.objectContaining({ - commandName: 'patch', - description: 'Manage CVE patches for dependencies', - hidden: false, - flags: {}, - }), - }), - ) - }) - - it('should call meowOrExit when no arguments provided', async () => { - await cmdPatch.run([], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - argv: [], - parentName: 'socket', - }), - ) - }) - - it('should include help text with usage examples', async () => { - await cmdPatch.run(['--version'], importMeta, context) - - const callArgs = mockMeowOrExit.mock.calls[0]?.[0] - const helpText = callArgs?.config?.help?.('socket patch') - - expect(helpText).toContain('Usage') - expect(helpText).toContain('$ socket patch ...') - expect(helpText).toContain('Examples') - expect(helpText).toContain('$ socket patch list') - expect(helpText).toContain('$ socket patch get <package>') - expect(helpText).toContain('$ socket patch apply') - }) - - it('should still forward to socket-patch after help path', async () => { - await cmdPatch.run(['--json'], importMeta, context) - - // Both meowOrExit and spawnSocketPatchDlx should be called. - expect(mockMeowOrExit).toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['--json'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - }) - - describe('subcommand forwarding', () => { - const importMeta = { url: 'file:///test/cmd-patch.mts' } - const context = { parentName: 'socket' } - - it('should forward list subcommand to socket-patch', async () => { - await cmdPatch.run(['list'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['list'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward get subcommand with package argument', async () => { - await cmdPatch.run(['get', 'lodash'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['get', 'lodash'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward apply subcommand to socket-patch', async () => { - await cmdPatch.run(['apply'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['apply'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward subcommand with flags combined', async () => { - await cmdPatch.run(['list', '--json', '--verbose'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['list', '--json', '--verbose'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward subcommand with directory argument', async () => { - await cmdPatch.run(['list', '/path/to/project'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['list', '/path/to/project'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward download subcommand with purl', async () => { - await cmdPatch.run( - ['download', 'pkg:npm/lodash@4.17.21'], - importMeta, - context, - ) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['download', 'pkg:npm/lodash@4.17.21'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward scan subcommand to socket-patch', async () => { - await cmdPatch.run(['scan'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['scan'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward rollback subcommand to socket-patch', async () => { - await cmdPatch.run(['rollback'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['rollback'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward remove subcommand with purl to socket-patch', async () => { - await cmdPatch.run( - ['remove', 'pkg:npm/lodash@4.17.21'], - importMeta, - context, - ) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['remove', 'pkg:npm/lodash@4.17.21'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward setup subcommand to socket-patch', async () => { - await cmdPatch.run(['setup'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['setup'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should forward repair subcommand to socket-patch', async () => { - await cmdPatch.run(['repair'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['repair'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - }) - - describe('exit code handling', () => { - const importMeta = { url: 'file:///test/cmd-patch.mts' } - const context = { parentName: 'socket' } - - it('should set exit code to 0 on success', async () => { - mockSpawnSocketPatchDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ code: 0, signal: undefined }), - }) - - await cmdPatch.run(['list'], importMeta, context) - - expect(process.exitCode).toBe(0) - }) - - it('should set exit code to 1 on failure', async () => { - mockSpawnSocketPatchDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ code: 1, signal: undefined }), - }) - - await cmdPatch.run(['list'], importMeta, context) - - expect(process.exitCode).toBe(1) - }) - - it('should propagate specific exit code from socket-patch', async () => { - mockSpawnSocketPatchDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ code: 42, signal: undefined }), - }) - - await cmdPatch.run(['apply'], importMeta, context) - - expect(process.exitCode).toBe(42) - }) - - it('should handle nullish exit code (signal termination)', async () => { - mockSpawnSocketPatchDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ code: undefined, signal: 'SIGTERM' }), - }) - - await cmdPatch.run(['apply'], importMeta, context) - - expect(process.exitCode).toBe(1) - }) - - it('should initialize exitCode to 1 before spawning', async () => { - // Create a mock that captures exitCode during execution. - let exitCodeDuringSpawn: number | undefined - mockSpawnSocketPatchDlx.mockImplementationOnce(() => { - exitCodeDuringSpawn = process.exitCode - return Promise.resolve({ - spawnPromise: Promise.resolve({ code: 0, signal: undefined }), - }) - }) - - await cmdPatch.run(['list'], importMeta, context) - - expect(exitCodeDuringSpawn).toBe(1) - expect(process.exitCode).toBe(0) - }) - }) - - describe('context handling', () => { - const importMeta = { url: 'file:///test/cmd-patch.mts' } - - it('should use parentName from context', async () => { - await cmdPatch.run([], importMeta, { parentName: 'custom-cli' }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'custom-cli', - }), - ) - }) - - it('should handle empty context object', async () => { - await cmdPatch.run(['list'], importMeta, {}) - - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['list'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should handle context with additional properties', async () => { - await cmdPatch.run([], importMeta, { - parentName: 'socket', - extraProp: 'ignored', - } as unknown) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket', - }), - ) - }) - }) - - describe('edge cases', () => { - const importMeta = { url: 'file:///test/cmd-patch.mts' } - const context = { parentName: 'socket' } - - it('should treat path starting with . as subcommand', async () => { - await cmdPatch.run(['.'], importMeta, context) - - // Path "." doesn't start with "-", so it's treated as a subcommand. - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['.'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should treat path starting with / as subcommand', async () => { - await cmdPatch.run(['/path/to/dir'], importMeta, context) - - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['/path/to/dir'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze([ - 'list', - '--json', - ]) as readonly string[] - - await cmdPatch.run(readonlyArgv, importMeta, context) - - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['list', '--json'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('should handle mixed flags and subcommands', async () => { - await cmdPatch.run(['--json', 'list', '--verbose'], importMeta, context) - - // "list" is a non-flag argument, so hasSubcommand is true. - expect(mockMeowOrExit).not.toHaveBeenCalled() - expect(mockSpawnSocketPatchDlx).toHaveBeenCalledWith( - ['--json', 'list', '--verbose'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/pip/cmd-pip.test.mts b/packages/cli/test/unit/commands/pip/cmd-pip.test.mts deleted file mode 100644 index 19dd405cf..000000000 --- a/packages/cli/test/unit/commands/pip/cmd-pip.test.mts +++ /dev/null @@ -1,710 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { CliCommandContext } from '../../../../src/util/cli/with-subcommands.mts' - -// Mock dependencies before imports. -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: vi.fn(), -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: vi.fn(), -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: vi.fn(argv => argv), -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: vi.fn(), -})) - -// Import modules after mocks are set up. -const { cmdPip } = await import('../../../../src/commands/pip/cmd-pip.mts') -const binModule = await import('@socketsecurity/lib-stable/bin/which') -const spawnModule = await import('../../../../src/util/dlx/spawn.mts') -const cmdModule = await import('../../../../src/util/process/cmd.mts') -const meowModule = await import('../../../../src/util/cli/with-subcommands.mjs') - -const mockWhichReal = vi.mocked(binModule.whichReal) -const mockSpawnSfwDlx = vi.mocked(spawnModule.spawnSfwDlx) -const mockFilterFlags = vi.mocked(cmdModule.filterFlags) -const mockMeowOrExit = vi.mocked(meowModule.meowOrExit) - -// Mock process methods. -const mockProcessExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never) -const mockProcessKill = vi.spyOn(process, 'kill').mockImplementation(() => true) - -describe('cmd-pip', () => { - const mockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - // Create a proper promise-like object for spawnPromise. - const createMockSpawnResult = (exitCode = 0, signal?: NodeJS.Signals) => { - const promise: unknown = Promise.resolve({ - success: exitCode === 0 && !signal, - code: signal ? undefined : exitCode, - signal: signal || undefined, - }) - promise.process = mockChildProcess - return { - spawnPromise: promise, - } - } - - beforeEach(() => { - vi.clearAllMocks() - - // Reset process properties. - process.exitCode = undefined - - // Setup default mock implementations. - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockWhichReal.mockResolvedValue('/usr/bin/pip') - mockFilterFlags.mockImplementation(argv => argv) - mockChildProcess.on.mockImplementation((event, handler) => { - // Simulate immediate successful exit by default. - if (event === 'exit') { - // Don't call handler here, let the test control when exit is called. - } - return mockChildProcess - }) - }) - - describe('command structure', () => { - it('should export cmdPip with correct structure', () => { - expect(cmdPip).toBeDefined() - expect(cmdPip.description).toBe('Run pip with Socket Firewall security') - expect(cmdPip.hidden).toBe(false) - expect(typeof cmdPip.run).toBe('function') - }) - }) - - describe('--help flag', () => { - it('should call meowOrExit with correct config for help display', async () => { - const argv = ['--help'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - // Mock meowOrExit to prevent actual execution. - mockMeowOrExit.mockImplementation(() => ({ - flags: {}, - input: [], - pkg: {}, - help: '', - })) - - await cmdPip.run(argv, importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv, - config: expect.objectContaining({ - commandName: 'pip', - description: 'Run pip with Socket Firewall security', - hidden: false, - }), - importMeta, - parentName: 'socket', - }) - }) - - it('should include help text with usage examples', async () => { - const argv = ['--help'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockMeowOrExit.mockImplementation(() => ({ - flags: {}, - input: [], - pkg: {}, - help: '', - })) - - await cmdPip.run(argv, importMeta, context) - - const callArgs = mockMeowOrExit.mock.calls[0]?.[0] - const config = callArgs?.config - const help = config?.help?.('socket pip') - - expect(help).toContain('Usage') - expect(help).toContain('$ socket pip ...') - expect(help).toContain('Socket Firewall') - expect(help).toContain('install flask') - expect(help).toContain('install -r requirements.txt') - expect(help).toContain('list') - }) - }) - - describe('flag filtering', () => { - it('should filter out Socket CLI flags before forwarding to sfw', async () => { - const argv = ['install', 'flask', '--config', 'test.json', '--dry-run'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['install', 'flask']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockFilterFlags).toHaveBeenCalledWith(argv, expect.any(Object), []) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'flask'], - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should pass all flags to filterFlags', async () => { - const argv = ['install', 'requests'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - await cmdPip.run(argv, importMeta, context) - - const callArgs = mockFilterFlags.mock.calls[0] - expect(callArgs?.[0]).toEqual(argv) - expect(callArgs?.[1]).toMatchObject({ - animateHeader: expect.any(Object), - banner: expect.any(Object), - config: expect.any(Object), - dryRun: expect.any(Object), - help: expect.any(Object), - spinner: expect.any(Object), - }) - expect(callArgs?.[2]).toEqual([]) - }) - }) - - describe('binary detection (getPipBinName)', () => { - it('should use pip when invoked as socket pip and pip exists', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - invokedAs: undefined, - } - - mockWhichReal.mockResolvedValue('/usr/bin/pip') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip', { nothrow: true }) - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'flask'], - expect.any(Object), - ) - }) - - it('should use pip3 when invoked as socket pip3', async () => { - const argv = ['install', 'requests'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - invokedAs: 'pip3', - } - - mockWhichReal.mockResolvedValue('/usr/bin/pip3') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip3', { nothrow: true }) - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip3', 'install', 'requests'], - expect.any(Object), - ) - }) - - it('should fallback to pip3 when pip does not exist', async () => { - const argv = ['install', 'numpy'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockWhichReal - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce('/usr/bin/pip3') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip', { nothrow: true }) - expect(mockWhichReal).toHaveBeenCalledWith('pip3', { nothrow: true }) - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip3', 'install', 'numpy'], - expect.any(Object), - ) - }) - - it('should fallback to pip when pip3 does not exist but requested', async () => { - const argv = ['install', 'pandas'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - invokedAs: 'pip3', - } - - mockWhichReal - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce('/usr/bin/pip') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip3', { nothrow: true }) - expect(mockWhichReal).toHaveBeenCalledWith('pip', { nothrow: true }) - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'pandas'], - expect.any(Object), - ) - }) - - it('should use requested binary when neither pip nor pip3 exist', async () => { - const argv = ['install', 'scipy'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockWhichReal.mockResolvedValue(undefined) - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip', { nothrow: true }) - expect(mockWhichReal).toHaveBeenCalledWith('pip3', { nothrow: true }) - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'scipy'], - expect.any(Object), - ) - }) - }) - - describe('spawn behavior', () => { - it('should set initial exit code to 1', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - const promise = cmdPip.run(argv, importMeta, context) - // Wait a tick for the exit code to be set. - await new Promise(resolve => process.nextTick(resolve)) - expect(process.exitCode).toBe(1) - await promise - }) - - it('should call spawnSfwDlx with correct arguments', async () => { - const argv = ['install', 'django', '--upgrade'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['install', 'django', '--upgrade']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'django', '--upgrade'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward all arguments to sfw', async () => { - const argv = [ - 'install', - '-r', - 'requirements.txt', - '--no-cache-dir', - '--user', - ] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue([ - 'install', - '-r', - 'requirements.txt', - '--no-cache-dir', - '--user', - ]) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - [ - 'pip', - 'install', - '-r', - 'requirements.txt', - '--no-cache-dir', - '--user', - ], - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should handle empty arguments array', async () => { - const argv: string[] = [] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue([]) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['pip'], { - stdio: 'inherit', - }) - }) - - it('should use stdio inherit for process communication', async () => { - const argv = ['list'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'list'], - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should wait for spawn promise completion', async () => { - const argv = ['--version'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalled() - }) - }) - - describe('process exit handling', () => { - it('skips exit/kill when both code and signal are null', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - mockChildProcess.on.mockImplementation((event, handler) => { - if (event === 'exit') { - exitHandler = handler as unknown - } - return mockChildProcess - }) - - mockProcessExit.mockClear() - mockProcessKill.mockClear() - - const promise = cmdPip.run(argv, importMeta, context) - await new Promise(resolve => process.nextTick(resolve)) - - // Trigger exit with both null. - exitHandler!(undefined, undefined) - - await promise - - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - - it('should handle process exit with numeric code 0', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - mockChildProcess.on.mockImplementation((event, handler) => { - if (event === 'exit') { - exitHandler = handler as unknown - } - return mockChildProcess - }) - - const promise = cmdPip.run(argv, importMeta, context) - - // Wait a tick for the event handler to be registered. - await new Promise(resolve => process.nextTick(resolve)) - - // Trigger exit event. - exitHandler!(0, undefined) - - await promise - - expect(mockProcessExit).toHaveBeenCalledWith(0) - }) - - it('should handle process exit with numeric code 1', async () => { - const argv = ['install', 'nonexistent-package'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(1)) - - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - mockChildProcess.on.mockImplementation((event, handler) => { - if (event === 'exit') { - exitHandler = handler as unknown - } - return mockChildProcess - }) - - const promise = cmdPip.run(argv, importMeta, context) - - // Wait a tick for the event handler to be registered. - await new Promise(resolve => process.nextTick(resolve)) - - // Trigger exit event. - exitHandler!(1, undefined) - - await promise - - expect(mockProcessExit).toHaveBeenCalledWith(1) - }) - - it('should handle process exit with SIGTERM signal', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0, 'SIGTERM')) - - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - mockChildProcess.on.mockImplementation((event, handler) => { - if (event === 'exit') { - exitHandler = handler as unknown - } - return mockChildProcess - }) - - const promise = cmdPip.run(argv, importMeta, context) - - // Wait a tick for the event handler to be registered. - await new Promise(resolve => process.nextTick(resolve)) - - // Trigger exit event with signal. - exitHandler!(undefined, 'SIGTERM') - - await promise - - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should handle process exit with SIGINT signal', async () => { - const argv = ['install', 'requests'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0, 'SIGINT')) - - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - mockChildProcess.on.mockImplementation((event, handler) => { - if (event === 'exit') { - exitHandler = handler as unknown - } - return mockChildProcess - }) - - const promise = cmdPip.run(argv, importMeta, context) - - // Wait a tick for the event handler to be registered. - await new Promise(resolve => process.nextTick(resolve)) - - // Trigger exit event with signal. - exitHandler!(undefined, 'SIGINT') - - await promise - - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - }) - }) - - describe('context handling', () => { - it('should handle context with parentName', async () => { - const argv = ['install', 'flask'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - await cmdPip.run(argv, importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket', - }), - ) - }) - - it('should handle context with invokedAs', async () => { - const argv = ['install', 'numpy'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - invokedAs: 'pip3', - } - - mockWhichReal.mockResolvedValue('/usr/bin/pip3') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip3', { nothrow: true }) - }) - - it('should handle context without invokedAs', async () => { - const argv = ['install', 'pandas'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockWhichReal.mockResolvedValue('/usr/bin/pip') - - await cmdPip.run(argv, importMeta, context) - - expect(mockWhichReal).toHaveBeenCalledWith('pip', { nothrow: true }) - }) - }) - - describe('common pip operations', () => { - it('should handle pip install', async () => { - const argv = ['install', 'requests'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['install', 'requests']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', 'requests'], - expect.any(Object), - ) - }) - - it('should handle pip list', async () => { - const argv = ['list'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['list']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'list'], - expect.any(Object), - ) - }) - - it('should handle pip freeze', async () => { - const argv = ['freeze'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['freeze']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'freeze'], - expect.any(Object), - ) - }) - - it('should handle pip uninstall', async () => { - const argv = ['uninstall', 'flask', '-y'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['uninstall', 'flask', '-y']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'uninstall', 'flask', '-y'], - expect.any(Object), - ) - }) - - it('should handle pip install with requirements.txt', async () => { - const argv = ['install', '-r', 'requirements.txt'] - const importMeta = { url: import.meta.url } as ImportMeta - const context: CliCommandContext = { - parentName: 'socket', - } - - mockFilterFlags.mockReturnValue(['install', '-r', 'requirements.txt']) - - await cmdPip.run(argv, importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip', 'install', '-r', 'requirements.txt'], - expect.any(Object), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/pnpm/cmd-pnpm.test.mts b/packages/cli/test/unit/commands/pnpm/cmd-pnpm.test.mts deleted file mode 100644 index 53c9013e7..000000000 --- a/packages/cli/test/unit/commands/pnpm/cmd-pnpm.test.mts +++ /dev/null @@ -1,545 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for pnpm wrapper command. - * - * Tests the command entry point that wraps pnpm with Socket Firewall security. - * The wrapper intercepts pnpm commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Dry-run behavior - Flag filtering (Socket CLI vs pnpm flags) - - * Subprocess spawning and exit handling - Telemetry tracking - Error handling. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { EventEmitter } from 'node:events' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSfwDlx. -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -// Mock telemetry functions. -const mockTrackSubprocessExit = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessStart = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackSubprocessExit: mockTrackSubprocessExit, - trackSubprocessStart: mockTrackSubprocessStart, -})) - -// Import after mocks. -const { cmdPnpm } = await import('../../../../src/commands/pnpm/cmd-pnpm.mts') -const { PNPM } = await import('@socketsecurity/lib-stable/constants/agents') - -describe('cmd-pnpm', () => { - interface MockChildProcess extends Partial<EventEmitter> { - pid: number - } - - const mockChildProcess: MockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - const createMockSpawnResult = (exitCode = 0, signal?: string) => { - const result = { - code: signal ? undefined : exitCode, - signal, - success: exitCode === 0 && !signal, - } - const spawnPromise = Promise.resolve(result) - Object.assign(spawnPromise, { process: mockChildProcess }) - return { spawnPromise } - } - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockTrackSubprocessStart.mockResolvedValue(Date.now()) - mockTrackSubprocessExit.mockResolvedValue(undefined) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdPnpm.description).toBe('Run pnpm with Socket Firewall security') - }) - - it('should be hidden', () => { - expect(cmdPnpm.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-pnpm.mts' } - const context = { parentName: 'socket' } - - describe('help flag', () => { - it('should display help text with --help flag', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await expect( - cmdPnpm.run(['--help'], importMeta, context), - ).rejects.toThrow() - - // Help should exit before spawning. - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - }) - }) - - describe('dry-run behavior', () => { - it('should show dry-run output without executing', async () => { - await cmdPnpm.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - - // Verify dry-run message. - const logCalls = mockLogger.error.mock.calls.flat() - const hasDryRunMessage = logCalls.some( - call => typeof call === 'string' && call.includes('Would execute'), - ) - expect(hasDryRunMessage).toBe(true) - }) - - it('should show dry-run output with pnpm install command', async () => { - await cmdPnpm.run( - ['--dry-run', 'install', 'lodash'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - - // Verify dry-run includes arguments. - const logCalls = mockLogger.error.mock.calls.flat() - const hasArgs = logCalls.some( - call => - typeof call === 'string' && - (call.includes('install') || call.includes('lodash')), - ) - expect(hasArgs).toBe(true) - }) - - it('should filter Socket flags in dry-run output', async () => { - await cmdPnpm.run( - ['--dry-run', '--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // Should not spawn. - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - }) - }) - - describe('flag filtering', () => { - it('should filter out --dry-run flag when forwarding to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Verify sfw was called with filtered flags. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out --config flag when forwarding to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run( - ['--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // --config should be filtered out. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out multiple Socket CLI flags', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run( - ['--config', '{}', '--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // Both --config and --no-banner should be filtered. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should preserve pnpm flags while filtering Socket flags', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run( - ['--config', '{}', 'install', '--save-dev', 'lodash'], - importMeta, - context, - ) - - // pnpm's --save-dev should be preserved. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', '--save-dev', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should handle --no-banner flag', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run( - ['--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // --no-banner should be filtered. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('command structure', () => { - it('should forward pnpm install command to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm add command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['add', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'add', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm install with version specifier', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash@4.17.21'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash@4.17.21'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm install with global flag', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', '-g', 'cowsay'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', '-g', 'cowsay'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm exec command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['dlx', 'cowsay', 'hello'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'dlx', 'cowsay', 'hello'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm update command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['update', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'update', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward pnpm with no arguments', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run([], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['pnpm'], { - stdio: 'inherit', - }) - }) - - it('should forward pnpm install with multiple packages', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run( - ['install', 'lodash', 'express', 'react'], - importMeta, - context, - ) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash', 'express', 'react'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('exit handling', () => { - it('should set initial exitCode to 1', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Should set exitCode to 1 initially (before subprocess completes). - expect(process.exitCode).toBe(1) - }) - - it('should register exit event handler on child process', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Should register 'exit' event handler. - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - - it('should use stdio inherit for process spawning', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pnpm', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('telemetry tracking', () => { - it('should track subprocess start', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalledWith(PNPM) - }) - - it('should track subprocess start before spawning', async () => { - let trackCalled = false - mockTrackSubprocessStart.mockImplementation(async () => { - trackCalled = true - return Date.now() - }) - - mockSpawnSfwDlx.mockImplementation(async () => { - expect(trackCalled).toBe(true) - return createMockSpawnResult(0) - }) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalled() - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - - beforeEach(() => { - // Capture the exit handler when it's registered. - ;(mockChildProcess.on as ReturnType<typeof vi.fn>).mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - PNPM, - expect.any(Number), - 42, - ) - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - PNPM, - expect.any(Number), - undefined, - ) - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should exit even if telemetry fails', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockRejectedValue(new Error('Telemetry failed')) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(1, undefined) - - // Wait for telemetry promise to reject and catch handler to run. - await new Promise(resolve => setTimeout(resolve, 10)) - - // Should still exit even though telemetry failed. - expect(mockProcessExit).toHaveBeenCalledWith(1) - }) - - it('skips exit/kill when both code and signal are null', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - exitHandler(undefined, undefined) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - - it('should track subprocess exit with code', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - const startTime = 12345 - mockTrackSubprocessStart.mockResolvedValue(startTime) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdPnpm.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler. - exitHandler(0, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith(PNPM, startTime, 0) - }) - }) - - describe('command name constant', () => { - it('should use PNPM constant as command name', async () => { - const { CMD_NAME } = - await import('../../../../src/commands/pnpm/cmd-pnpm.mts') - expect(CMD_NAME).toBe(PNPM) - expect(CMD_NAME).toBe('pnpm') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/pycli/cmd-pycli.test.mts b/packages/cli/test/unit/commands/pycli/cmd-pycli.test.mts deleted file mode 100644 index 4d30a8a53..000000000 --- a/packages/cli/test/unit/commands/pycli/cmd-pycli.test.mts +++ /dev/null @@ -1,177 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSocketPyCli. -const mockSpawnSocketPyCli = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/python/standalone.mts', () => ({ - spawnSocketPyCli: mockSpawnSocketPyCli, -})) - -// Import after mocks. -const { cmdPyCli } = - await import('../../../../src/commands/pycli/cmd-pycli.mts') - -describe('cmd-pycli', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdPyCli.description).toBe( - 'Run Socket Python CLI (socketsecurity) directly', - ) - }) - - it('should not be hidden', () => { - expect(cmdPyCli.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-pycli.mts' } - const context = { parentName: 'socket' } - - it('should pass arguments to spawnSocketPyCli', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run( - ['--generate-license', '--repo', 'owner/repo', '.'], - importMeta, - context, - ) - - expect(mockSpawnSocketPyCli).toHaveBeenCalledWith( - ['--generate-license', '--repo', 'owner/repo', '.'], - { stdio: 'inherit' }, - ) - }) - - it('should filter out Socket CLI flags before passing to Python CLI', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run( - ['--dry-run', '--generate-license', '--repo', 'owner/repo'], - importMeta, - context, - ) - - // --dry-run should be filtered out. - expect(mockSpawnSocketPyCli).not.toHaveBeenCalled() - // Dry run should bail early. - }) - - it('should set exitCode to 1 on failure', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ - ok: false, - message: 'Python CLI failed', - }) - - await cmdPyCli.run(['--enable-sarif'], importMeta, context) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalledWith('Python CLI failed') - }) - - it('should not set error exitCode on success', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run(['--strict-blocking'], importMeta, context) - - // Success means exitCode is 0 or undefined (not an error code). - expect(process.exitCode).not.toBe(1) - }) - - it('shows wrapper help when --help is passed and skips spawn', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - // meow's showHelp() calls process.exit(0); intercept with throw. - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('process.exit') - }) as never) - - try { - await cmdPyCli - .run(['--help'], importMeta, context) - .catch(() => undefined) - expect(mockSpawnSocketPyCli).not.toHaveBeenCalled() - } finally { - exitSpy.mockRestore() - } - }) - - it('should log info message when invoking Python CLI', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run( - ['--slack-webhook', 'https://hooks.slack.com/...'], - importMeta, - context, - ) - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Invoking Socket Python CLI...', - ) - }) - - it('should handle empty arguments', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run([], importMeta, context) - - expect(mockSpawnSocketPyCli).toHaveBeenCalledWith([], { - stdio: 'inherit', - }) - }) - - it('should handle failure without message', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: false }) - - await cmdPyCli.run(['--enable-sarif'], importMeta, context) - - expect(process.exitCode).toBe(1) - // Should not call logger.fail when message is missing. - expect(mockLogger.fail).not.toHaveBeenCalled() - }) - - it('should output dry-run preview when --dry-run is used', async () => { - await cmdPyCli.run( - ['--dry-run', '--generate-license', '--repo', 'owner/repo'], - importMeta, - context, - ) - - expect(mockSpawnSocketPyCli).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should filter help flags from arguments', async () => { - mockSpawnSocketPyCli.mockResolvedValue({ ok: true, data: '' }) - - await cmdPyCli.run(['--generate-license', '.'], importMeta, context) - - // Help flags should not be in arguments passed to Python CLI. - expect(mockSpawnSocketPyCli).toHaveBeenCalledWith( - ['--generate-license', '.'], - expect.any(Object), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/raw-npm/cmd-raw-npm.test.mts b/packages/cli/test/unit/commands/raw-npm/cmd-raw-npm.test.mts deleted file mode 100644 index 1bbc8f597..000000000 --- a/packages/cli/test/unit/commands/raw-npm/cmd-raw-npm.test.mts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * Unit tests for raw-npm command. - * - * Tests the command that runs npm without the Socket wrapper. - * - * Test Coverage: - Command metadata (description, hidden flag) - --dry-run flag - * support - npm binary path resolution - Argument passing to npm - Process - * spawning configuration - Exit code handling - Signal handling. - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock spawn from @socketsecurity/lib/spawn - Mock - * getNpmBinPath to return controlled path - Mock outputDryRunExecute for - * dry-run testing - Verify spawn configuration (shell, stdio, etc.) - * - * Related Files: - src/commands/raw-npm/cmd-raw-npm.mts - Implementation. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock spawn. -const mockSpawn = vi.hoisted(() => { - const mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - return vi.fn(() => { - return Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }) - }) -}) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -// Mock WIN32 constant. -const mockWIN32 = vi.hoisted(() => false) - -vi.mock('@socketsecurity/lib-stable/constants/platform', () => ({ - get WIN32() { - return mockWIN32 - }, -})) - -// Mock npm path utilities. -const mockGetNpmBinPath = vi.hoisted(() => vi.fn(() => '/usr/bin/npm')) - -vi.mock('../../../../src/util/npm/paths.mts', () => ({ - getNpmBinPath: mockGetNpmBinPath, -})) - -// Mock dry-run output. -const mockOutputDryRunExecute = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunExecute: mockOutputDryRunExecute, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = {} - - // Parse flags from argv. - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - - // Invoke the help() callback so its template-string body is - // recorded as covered; production meowOrExit only invokes it on - // --help, which the test suite never exercises. - const help = options.config?.help - ? options.config.help('socket raw-npm') - : '' - - return { - flags, - help, - input: [], - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdRawNpm } = - await import('../../../../src/commands/raw-npm/cmd-raw-npm.mts') - -describe('cmd-raw-npm', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockGetNpmBinPath.mockReturnValue('/usr/bin/npm') - }) - - describe('command metadata', () => { - it('should export CMD_NAME as raw-npm', () => { - expect(CMD_NAME).toBe('raw-npm') - }) - - it('should have correct description', () => { - expect(cmdRawNpm.description).toBe('Run npm without the Socket wrapper') - }) - - it('should not be hidden', () => { - expect(cmdRawNpm.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-raw-npm.mts' } - const context = { parentName: 'socket' } - - describe('--dry-run flag', () => { - it('should show preview without spawning npm', async () => { - await cmdRawNpm.run( - ['install', 'cowsay', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - '/usr/bin/npm', - ['install', 'cowsay', '--dry-run'], - 'raw npm command', - ) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('should use npm path from getNpmBinPath in dry-run', async () => { - mockGetNpmBinPath.mockReturnValue('/custom/path/to/npm') - - await cmdRawNpm.run(['install', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - '/custom/path/to/npm', - expect.any(Array), - 'raw npm command', - ) - }) - - it('should pass all arguments to dry-run output', async () => { - await cmdRawNpm.run( - ['install', '-g', 'cowsay', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - expect.any(String), - ['install', '-g', 'cowsay', '--dry-run'], - 'raw npm command', - ) - }) - }) - - describe('npm execution', () => { - it('should spawn npm with correct path', async () => { - mockGetNpmBinPath.mockReturnValue('/usr/local/bin/npm') - - await cmdRawNpm.run(['install', 'cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/npm', - ['install', 'cowsay'], - expect.objectContaining({ - shell: false, - stdio: 'inherit', - }), - ) - }) - - it('should pass arguments to npm', async () => { - await cmdRawNpm.run(['install', '-g', 'cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['install', '-g', 'cowsay'], - expect.any(Object), - ) - }) - - it('should use stdio inherit mode', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should set shell to false on non-Windows', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - shell: false, - }), - ) - }) - - it('should set initial exit code to 1', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - expect(process.exitCode).toBe(1) - }) - }) - - describe('process event handling', () => { - it('should register exit event handler', async () => { - const mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - - mockSpawn.mockReturnValue( - Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }), - ) - - await cmdRawNpm.run(['install'], importMeta, context) - - expect(mockProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - let mockProcess: { - on: ReturnType<typeof vi.fn> - kill: ReturnType<typeof vi.fn> - pid: number - stdin: null - stdout: null - stderr: null - } - - beforeEach(() => { - mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - - // Capture the exit handler when it's registered. - mockProcess.on.mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - - mockSpawn.mockReturnValue( - Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }), - ) - - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should not call process.exit when code is null and no signal', async () => { - await cmdRawNpm.run(['install'], importMeta, context) - - // Invoke the exit handler with null code and no signal. - exitHandler(undefined, undefined) - - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - }) - - describe('argument handling', () => { - it('should handle empty arguments', async () => { - await cmdRawNpm.run([], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - [], - expect.any(Object), - ) - }) - - it('should handle single argument', async () => { - await cmdRawNpm.run(['version'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['version'], - expect.any(Object), - ) - }) - - it('should handle multiple arguments', async () => { - await cmdRawNpm.run( - ['install', 'lodash', 'express', '--save'], - importMeta, - context, - ) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['install', 'lodash', 'express', '--save'], - expect.any(Object), - ) - }) - - it('should handle arguments with special characters', async () => { - await cmdRawNpm.run( - ['install', '@types/node', '--save-dev'], - importMeta, - context, - ) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['install', '@types/node', '--save-dev'], - expect.any(Object), - ) - }) - }) - - describe('readonly arguments', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze([ - 'install', - 'cowsay', - ]) as readonly string[] - - await cmdRawNpm.run(readonlyArgv, importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['install', 'cowsay'], - expect.any(Object), - ) - }) - - it('should handle readonly argv in dry-run', async () => { - const readonlyArgv = Object.freeze([ - 'install', - '--dry-run', - ]) as readonly string[] - - await cmdRawNpm.run(readonlyArgv, importMeta, context) - - expect(mockOutputDryRunExecute).toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle npm path with spaces', async () => { - mockGetNpmBinPath.mockReturnValue('/Program Files/npm/npm.exe') - - await cmdRawNpm.run(['install'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - '/Program Files/npm/npm.exe', - expect.any(Array), - expect.any(Object), - ) - }) - - it('should handle complex npm commands', async () => { - await cmdRawNpm.run( - ['run', 'build', '--', '--production'], - importMeta, - context, - ) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['run', 'build', '--', '--production'], - expect.any(Object), - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/raw-npx/cmd-raw-npx.test.mts b/packages/cli/test/unit/commands/raw-npx/cmd-raw-npx.test.mts deleted file mode 100644 index 067e54e7e..000000000 --- a/packages/cli/test/unit/commands/raw-npx/cmd-raw-npx.test.mts +++ /dev/null @@ -1,487 +0,0 @@ -/** - * Unit tests for raw-npx command. - * - * Tests the command that runs npx without the Socket wrapper. - * - * Test Coverage: - Command metadata (description, hidden flag) - --dry-run flag - * support - npx binary path resolution - Argument passing to npx - Process - * spawning configuration - Exit code handling - Signal handling. - * - * Testing Approach: - Mock logger to capture output - Mock meowOrExit to - * control flag values - Mock spawn from @socketsecurity/lib/spawn - Mock - * getNpxBinPath to return controlled path - Mock outputDryRunExecute for - * dry-run testing - Verify spawn configuration (shell, stdio, etc.) - * - * Related Files: - src/commands/raw-npx/cmd-raw-npx.mts - Implementation. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock spawn. -const mockSpawn = vi.hoisted(() => { - const mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - return vi.fn(() => { - return Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }) - }) -}) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -// Mock WIN32 constant. -const mockWIN32 = vi.hoisted(() => false) - -vi.mock('@socketsecurity/lib-stable/constants/platform', () => ({ - get WIN32() { - return mockWIN32 - }, -})) - -// Mock npx path utilities. -const mockGetNpxBinPath = vi.hoisted(() => vi.fn(() => '/usr/bin/npx')) - -vi.mock('../../../../src/util/npm/paths.mts', () => ({ - getNpxBinPath: mockGetNpxBinPath, -})) - -// Mock dry-run output. -const mockOutputDryRunExecute = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunExecute: mockOutputDryRunExecute, -})) - -// Mock meowOrExit to prevent actual CLI parsing. -const mockMeowOrExit = vi.hoisted(() => - vi.fn((options: unknown) => { - const argv = options.argv as string[] | readonly string[] - const flags: Record<string, unknown> = {} - - // Parse flags from argv. - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - - // Invoke the help() callback so its template-string body is - // recorded as covered; production meowOrExit only invokes it on - // --help, which the test suite never exercises. - const help = options.config?.help - ? options.config.help('socket raw-npx') - : '' - - return { - flags, - help, - input: [], - pkg: {}, - } - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { CMD_NAME, cmdRawNpx } = - await import('../../../../src/commands/raw-npx/cmd-raw-npx.mts') - -describe('cmd-raw-npx', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockGetNpxBinPath.mockReturnValue('/usr/bin/npx') - }) - - describe('command metadata', () => { - it('should export CMD_NAME as raw-npx', () => { - expect(CMD_NAME).toBe('raw-npx') - }) - - it('should have correct description', () => { - expect(cmdRawNpx.description).toBe( - 'Run pnpm exec without the Socket wrapper', - ) - }) - - it('should not be hidden', () => { - expect(cmdRawNpx.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-raw-npx.mts' } - const context = { parentName: 'socket' } - - describe('--dry-run flag', () => { - it('should show preview without spawning npx', async () => { - await cmdRawNpx.run(['cowsay', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - '/usr/bin/npx', - ['cowsay', '--dry-run'], - 'raw pnpm exec command', - ) - expect(mockSpawn).not.toHaveBeenCalled() - }) - - it('should use pnpm exec path from getNpxBinPath in dry-run', async () => { - mockGetNpxBinPath.mockReturnValue('/custom/path/to/npx') - - await cmdRawNpx.run(['cowsay', '--dry-run'], importMeta, context) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - '/custom/path/to/npx', - expect.any(Array), - 'raw pnpm exec command', - ) - }) - - it('should pass all arguments to dry-run output', async () => { - await cmdRawNpx.run( - ['prettier', '--check', '.', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - expect.any(String), - ['prettier', '--check', '.', '--dry-run'], - 'raw pnpm exec command', - ) - }) - }) - - describe('pnpm exec execution', () => { - it('should spawn pnpm exec with correct path', async () => { - mockGetNpxBinPath.mockReturnValue('/usr/local/bin/npx') - - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/local/bin/npx', - ['cowsay'], - expect.objectContaining({ - shell: false, - stdio: 'inherit', - }), - ) - }) - - it('should pass arguments to npx', async () => { - await cmdRawNpx.run(['prettier', '--check', '.'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['prettier', '--check', '.'], - expect.any(Object), - ) - }) - - it('should use stdio inherit mode', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - stdio: 'inherit', - }), - ) - }) - - it('should set shell to false on non-Windows', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - shell: false, - }), - ) - }) - - it('should set initial exit code to 1', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(process.exitCode).toBe(1) - }) - }) - - describe('process event handling', () => { - it('should register exit event handler', async () => { - const mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - - mockSpawn.mockReturnValue( - Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }), - ) - - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - let mockProcess: { - on: ReturnType<typeof vi.fn> - kill: ReturnType<typeof vi.fn> - pid: number - stdin: null - stdout: null - stderr: null - } - - beforeEach(() => { - mockProcess = { - on: vi.fn(), - kill: vi.fn(), - pid: 12345, - stdin: undefined, - stdout: undefined, - stderr: undefined, - } - - // Capture the exit handler when it's registered. - mockProcess.on.mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - - mockSpawn.mockReturnValue( - Object.assign(Promise.resolve({ exitCode: 0 }), { - process: mockProcess, - }), - ) - - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should not call process.exit when code is null and no signal', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - // Invoke the exit handler with null code and no signal. - exitHandler(undefined, undefined) - - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - }) - - describe('argument handling', () => { - it('should handle empty arguments', async () => { - await cmdRawNpx.run([], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - [], - expect.any(Object), - ) - }) - - it('should handle single argument', async () => { - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['cowsay'], - expect.any(Object), - ) - }) - - it('should handle multiple arguments', async () => { - await cmdRawNpx.run(['typescript', '--version'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['typescript', '--version'], - expect.any(Object), - ) - }) - - it('should handle arguments with special characters', async () => { - await cmdRawNpx.run( - ['@angular/cli', 'new', 'my-app'], - importMeta, - context, - ) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['@angular/cli', 'new', 'my-app'], - expect.any(Object), - ) - }) - - it('should handle package@version syntax', async () => { - await cmdRawNpx.run(['cowsay@1.5.0', 'hello'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['cowsay@1.5.0', 'hello'], - expect.any(Object), - ) - }) - }) - - describe('readonly arguments', () => { - it('should handle readonly argv array', async () => { - const readonlyArgv = Object.freeze(['cowsay']) as readonly string[] - - await cmdRawNpx.run(readonlyArgv, importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['cowsay'], - expect.any(Object), - ) - }) - - it('should handle readonly argv in dry-run', async () => { - const readonlyArgv = Object.freeze([ - 'cowsay', - '--dry-run', - ]) as readonly string[] - - await cmdRawNpx.run(readonlyArgv, importMeta, context) - - expect(mockOutputDryRunExecute).toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('should handle pnpm exec path with spaces', async () => { - mockGetNpxBinPath.mockReturnValue('/Program Files/npx/npx.exe') - - await cmdRawNpx.run(['cowsay'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - '/Program Files/npx/npx.exe', - expect.any(Array), - expect.any(Object), - ) - }) - - it('should handle complex pnpm exec commands', async () => { - await cmdRawNpx.run( - ['create-react-app', 'my-app', '--template', 'typescript'], - importMeta, - context, - ) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['create-react-app', 'my-app', '--template', 'typescript'], - expect.any(Object), - ) - }) - - it('should handle binary executables from packages', async () => { - await cmdRawNpx.run(['eslint', '--fix'], importMeta, context) - - expect(mockSpawn).toHaveBeenCalledWith( - expect.any(String), - ['eslint', '--fix'], - expect.any(Object), - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository-create.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository-create.test.mts deleted file mode 100644 index dcee07a5c..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository-create.test.mts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Unit tests for repository create command (creates a repo in an organization). - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleCreateRepo = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/repository/handle-create-repo.mts', () => ({ - handleCreateRepo: mockHandleCreateRepo, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdRepositoryCreate } = - await import('../../../../src/commands/repository/cmd-repository-create.mts') - -describe('cmd-repository-create', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepositoryCreate.description).toBe( - 'Create a repository in an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdRepositoryCreate.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-repository-create.mts' } - const context = { parentName: 'socket repository' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Would upload repository'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdRepositoryCreate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - - it('should fail without org slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - - it('should fail without repository name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - - it('should call handleCreateRepo with default parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - { - defaultBranch: 'main', - description: '', - homepage: '', - orgSlug: 'test-org', - repoName: 'test-repo', - visibility: 'private', - }, - 'text', - ) - }) - - it('should pass --default-branch flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--default-branch', 'trunk', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: 'trunk', - }), - 'text', - ) - }) - - it('should pass --homepage flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--homepage', 'https://example.com', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - homepage: 'https://example.com', - }), - 'text', - ) - }) - - it('should pass --repo-description flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - [ - 'test-repo', - '--repo-description', - 'Test description', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Test description', - }), - 'text', - ) - }) - - it('should pass --visibility=public flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--visibility', 'public', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'public', - }), - 'text', - ) - }) - - it('should default to private visibility for invalid visibility values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--visibility', 'invalid', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'private', - }), - 'text', - ) - }) - - it('should default to private when --visibility is empty string', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--visibility=', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'private', - }), - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - 'text', - ) - }) - - it('should pass --json flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'test-repo', - }), - 'json', - ) - }) - - it('should pass --markdown flag to handleCreateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'test-repo', - }), - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - - it('should show repository details in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['my-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: "test-org"'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('repository: "my-repo"'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should handle all flags together', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - [ - 'my-new-repo', - '--default-branch', - 'develop', - '--homepage', - 'https://socket.dev', - '--repo-description', - 'A test repository', - '--visibility', - 'public', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - { - defaultBranch: 'develop', - description: 'A test repository', - homepage: 'https://socket.dev', - orgSlug: 'test-org', - repoName: 'my-new-repo', - visibility: 'public', - }, - 'text', - ) - }) - - describe('--default-branch empty-value detection', () => { - it('fails when --default-branch= is passed with no value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--default-branch=', '--no-interactive'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('--default-branch requires a value'), - ) - }) - - it('fails when --default-branch is followed by another flag (bare form)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--default-branch', '--no-interactive'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('--default-branch requires a value'), - ) - }) - - it('fails when --default-branch is the last argv token (bare form)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--default-branch'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - - it('also catches the camelCase --defaultBranch variant', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - ['test-repo', '--defaultBranch=', '--no-interactive'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateRepo).not.toHaveBeenCalled() - }) - }) - - it('should handle empty string values for optional flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryCreate.run( - [ - 'test-repo', - '--default-branch', - '', - '--homepage', - '', - '--repo-description', - '', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: '', - description: '', - homepage: '', - }), - 'text', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository-del.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository-del.test.mts deleted file mode 100644 index 420affa04..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository-del.test.mts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Unit tests for repository delete command. - * - * Tests the command that deletes a repository in an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleDeleteRepo = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/repository/handle-delete-repo.mts', () => ({ - handleDeleteRepo: mockHandleDeleteRepo, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdRepositoryDel } = - await import('../../../../src/commands/repository/cmd-repository-del.mts') - -describe('cmd-repository-del', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepositoryDel.description).toBe( - 'Delete a repository in an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdRepositoryDel.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-repository-del.mts' } - const context = { parentName: 'socket repository' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Would delete repository'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdRepositoryDel.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteRepo).not.toHaveBeenCalled() - }) - - it('should fail without org slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteRepo).not.toHaveBeenCalled() - }) - - it('should fail without repository name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteRepo).not.toHaveBeenCalled() - }) - - it('should call handleDeleteRepo with correct parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'custom-org', - 'test-repo', - 'text', - ) - }) - - it('should pass --json flag to handleDeleteRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'json', - ) - }) - - it('should pass --markdown flag to handleDeleteRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteRepo).not.toHaveBeenCalled() - }) - - it('should show repository identifier in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run(['my-repo', '--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org/my-repo'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should handle repository names with special characters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['my-special-repo-123', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'my-special-repo-123', - 'text', - ) - }) - - it('should handle repository deletion with json output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryDel.run( - ['my-repo', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'my-repo', - 'json', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository-list.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository-list.test.mts deleted file mode 100644 index 194dc0950..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository-list.test.mts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * Unit tests for repository list command. - * - * Tests the command that lists repositories in an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleListRepos = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/repository/handle-list-repos.mts', () => ({ - handleListRepos: mockHandleListRepos, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdRepositoryList } = - await import('../../../../src/commands/repository/cmd-repository-list.mts') - -describe('cmd-repository-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepositoryList.description).toBe( - 'List repositories in an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdRepositoryList.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-repository-list.mts' } - const context = { parentName: 'socket repository' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--dry-run'], importMeta, context) - - expect(mockHandleListRepos).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdRepositoryList.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListRepos).not.toHaveBeenCalled() - }) - - it('should fail without org slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListRepos).not.toHaveBeenCalled() - }) - - it('should call handleListRepos with default parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--no-interactive'], importMeta, context) - - expect(mockHandleListRepos).toHaveBeenCalledWith({ - all: false, - direction: 'desc', - orgSlug: 'test-org', - outputKind: 'text', - page: 1, - perPage: 30, - sort: 'created_at', - }) - }) - - it('should pass --all flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--all', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith({ - all: true, - direction: 'desc', - orgSlug: 'test-org', - outputKind: 'text', - page: 1, - perPage: 30, - sort: 'created_at', - }) - }) - - it('should pass --page flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--page', '2', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - page: 2, - }), - ) - }) - - it('should pass --per-page flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--per-page', '50', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - perPage: 50, - }), - ) - }) - - it('should pass --sort flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--sort', 'updated_at', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - sort: 'updated_at', - }), - ) - }) - - it('should pass --direction flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--direction', 'asc', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - direction: 'asc', - }), - ) - }) - - it('should fail with invalid direction value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--direction', 'invalid', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListRepos).not.toHaveBeenCalled() - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should pass --json flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should pass --markdown flag to handleListRepos', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - ['--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListRepos).not.toHaveBeenCalled() - }) - - it('should show query parameters in dry-run mode with default values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('[DryRun]: Would fetch repositories'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: test-org'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('sort: created_at'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('direction: desc'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('page: 1'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('perPage: 30'), - ) - }) - - it('should show query parameters in dry-run mode with --all flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--dry-run', '--all'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('all: true'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run(['--interactive'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should handle all pagination and sorting flags together', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryList.run( - [ - '--page', - '3', - '--per-page', - '100', - '--sort', - 'name', - '--direction', - 'asc', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleListRepos).toHaveBeenCalledWith({ - all: false, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'text', - page: 3, - perPage: 100, - sort: 'name', - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository-update.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository-update.test.mts deleted file mode 100644 index a88520160..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository-update.test.mts +++ /dev/null @@ -1,574 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for repository update command. - * - * Tests the command that updates a repository in an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleUpdateRepo = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/repository/handle-update-repo.mts', () => ({ - handleUpdateRepo: mockHandleUpdateRepo, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdRepositoryUpdate } = - await import('../../../../src/commands/repository/cmd-repository-update.mts') - -describe('cmd-repository-update', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepositoryUpdate.description).toBe( - 'Update a repository in an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdRepositoryUpdate.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-repository-update.mts' } - const context = { parentName: 'socket repository' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Would upload repository (update)'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdRepositoryUpdate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - }) - - it('should fail without org slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - }) - - it('should fail without repository name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - }) - - it('should call handleUpdateRepo with default parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - { - defaultBranch: 'main', - description: '', - homepage: '', - orgSlug: 'test-org', - repoName: 'test-repo', - visibility: 'private', - }, - 'text', - ) - }) - - it('should pass --default-branch flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--default-branch', 'develop', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: 'develop', - }), - 'text', - ) - }) - - it('should pass -b short flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '-b', 'develop', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: 'develop', - }), - 'text', - ) - }) - - it('should pass --homepage flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--homepage', 'https://example.com', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - homepage: 'https://example.com', - }), - 'text', - ) - }) - - it('should pass -h short flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '-h', 'https://example.com', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - homepage: 'https://example.com', - }), - 'text', - ) - }) - - it('should pass --repo-description flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - [ - 'test-repo', - '--repo-description', - 'Updated description', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Updated description', - }), - 'text', - ) - }) - - it('should pass -d short flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '-d', 'Updated description', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - description: 'Updated description', - }), - 'text', - ) - }) - - it('should pass --visibility flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--visibility', 'public', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'public', - }), - 'text', - ) - }) - - it('should pass -v short flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '-v', 'public', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'public', - }), - 'text', - ) - }) - - it('should preserve visibility value as-is (not force private)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--visibility', 'invalid', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - visibility: 'invalid', - }), - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - 'text', - ) - }) - - it('should pass --json flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'test-repo', - }), - 'json', - ) - }) - - it('should pass --markdown flag to handleUpdateRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'test-repo', - }), - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - }) - - it('should show repository details in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['my-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: "test-org"'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('repository: "my-repo"'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should handle all flags together', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - [ - 'my-repo', - '--default-branch', - 'main', - '--homepage', - 'https://socket.dev', - '--repo-description', - 'Updated repository', - '--visibility', - 'public', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - { - defaultBranch: 'main', - description: 'Updated repository', - homepage: 'https://socket.dev', - orgSlug: 'test-org', - repoName: 'my-repo', - visibility: 'public', - }, - 'text', - ) - }) - - it('should handle all short flags together', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - [ - 'my-repo', - '-b', - 'develop', - '-h', - 'https://example.com', - '-d', - 'Test description', - '-v', - 'private', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - { - defaultBranch: 'develop', - description: 'Test description', - homepage: 'https://example.com', - orgSlug: 'test-org', - repoName: 'my-repo', - visibility: 'private', - }, - 'text', - ) - }) - - it('should handle empty string values for optional flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - [ - 'test-repo', - '--default-branch', - '', - '--homepage', - '', - '--repo-description', - '', - '--visibility', - '', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: '', - description: '', - homepage: '', - visibility: 'private', - }), - 'text', - ) - }) - - it('should handle repository names with special characters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['my-special-repo-123', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'my-special-repo-123', - }), - 'text', - ) - }) - - describe('--default-branch empty-value detection', () => { - it('fails when --default-branch= is passed with no value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--default-branch=', '--no-interactive'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('--default-branch requires a value'), - ) - }) - - it('fails on bare --default-branch followed by another flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryUpdate.run( - ['test-repo', '--default-branch', '--no-interactive'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleUpdateRepo).not.toHaveBeenCalled() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository-view.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository-view.test.mts deleted file mode 100644 index 04b67e2c2..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository-view.test.mts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Unit tests for repository view command. - * - * Tests the command that views a specific repository in an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleViewRepo = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/repository/handle-view-repo.mts', () => ({ - handleViewRepo: mockHandleViewRepo, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdRepositoryView } = - await import('../../../../src/commands/repository/cmd-repository-view.mts') - -describe('cmd-repository-view', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepositoryView.description).toBe( - 'View repositories in an organization', - ) - }) - - it('should not be hidden', () => { - expect(cmdRepositoryView.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-repository-view.mts' } - const context = { parentName: 'socket repository' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockHandleViewRepo).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdRepositoryView.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleViewRepo).not.toHaveBeenCalled() - }) - - it('should fail without org slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleViewRepo).not.toHaveBeenCalled() - }) - - it('should fail without repository name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleViewRepo).not.toHaveBeenCalled() - }) - - it('should call handleViewRepo with correct parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleViewRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--org', 'custom-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleViewRepo).toHaveBeenCalledWith( - 'custom-org', - 'test-repo', - 'text', - ) - }) - - it('should pass --json flag to handleViewRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleViewRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'json', - ) - }) - - it('should pass --markdown flag to handleViewRepo', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleViewRepo).toHaveBeenCalledWith( - 'test-org', - 'test-repo', - 'markdown', - ) - }) - - it('should fail when both --json and --markdown flags are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--json', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleViewRepo).not.toHaveBeenCalled() - }) - - it('should show repository identifier in dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run(['my-repo', '--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org/my-repo'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('organization: test-org'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('repository: my-repo'), - ) - }) - - it('should pass interactive flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['test-repo', '--dry-run'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should handle repository names with special characters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdRepositoryView.run( - ['my-special-repo-123', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleViewRepo).toHaveBeenCalledWith( - 'test-org', - 'my-special-repo-123', - 'text', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/cmd-repository.test.mts b/packages/cli/test/unit/commands/repository/cmd-repository.test.mts deleted file mode 100644 index fcef1b155..000000000 --- a/packages/cli/test/unit/commands/repository/cmd-repository.test.mts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Unit tests for repository parent command. - * - * Tests the parent command that routes to repository management subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdRepository } = - await import('../../../../src/commands/repository/cmd-repository.mts') -const { cmdRepositoryCreate } = - await import('../../../../src/commands/repository/cmd-repository-create.mts') -const { cmdRepositoryDel } = - await import('../../../../src/commands/repository/cmd-repository-del.mts') -const { cmdRepositoryList } = - await import('../../../../src/commands/repository/cmd-repository-list.mts') -const { cmdRepositoryUpdate } = - await import('../../../../src/commands/repository/cmd-repository-update.mts') -const { cmdRepositoryView } = - await import('../../../../src/commands/repository/cmd-repository-view.mts') - -describe('cmd-repository', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdRepository.description).toBe('Manage registered repositories') - }) - - it('should not have hidden property set to true', () => { - expect(cmdRepository.hidden).toBeUndefined() - }) - - it('should have a run method', () => { - expect(typeof cmdRepository.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-repository.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run(['list'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['list'], - importMeta, - name: 'socket repository', - subcommands: { - create: cmdRepositoryCreate, - del: cmdRepositoryDel, - list: cmdRepositoryList, - update: cmdRepositoryUpdate, - view: cmdRepositoryView, - }, - }, - { - description: 'Manage registered repositories', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run(['view'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent repository', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual([ - 'create', - 'view', - 'list', - 'del', - 'update', - ]) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['create', 'owner/repo', '--json'] - - await cmdRepository.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['list']) as readonly string[] - - await cmdRepository.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.create).toBe(cmdRepositoryCreate) - expect(subcommands.view).toBe(cmdRepositoryView) - expect(subcommands.list).toBe(cmdRepositoryList) - expect(subcommands.del).toBe(cmdRepositoryDel) - expect(subcommands.update).toBe(cmdRepositoryUpdate) - }) - }) - - describe('subcommand ordering', () => { - it('should maintain consistent subcommand order', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommandKeys = Object.keys(call[0].subcommands) - - expect(subcommandKeys).toEqual([ - 'create', - 'view', - 'list', - 'del', - 'update', - ]) - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdRepository.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe('Manage registered repositories') - }) - - it('should not include aliases', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdRepository.run( - [], - { url: 'file:///test' }, - { parentName: 'socket' }, - ) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.aliases).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-create-repo.test.mts b/packages/cli/test/unit/commands/repository/fetch-create-repo.test.mts deleted file mode 100644 index cd2435509..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-create-repo.test.mts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Unit tests for fetchCreateRepo. - * - * Purpose: Tests repository creation via the Socket API. Validates SDK - * integration, parameter transformation (camelCase to snake_case), and error - * handling for repository creation workflows. - * - * Test Coverage: - * - * - Successful repository creation - * - SDK setup failure handling - * - API call errors (409 conflict for existing repos) - * - Custom SDK options (API tokens, base URLs) - * - Minimal repository data handling - * - Full repository configuration - * - Null prototype usage for security - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates parameter transformation and comprehensive error handling. - * - * Related Files: - * - * - Src/commands/repository/fetch-create-repo.mts (implementation) - * - Src/commands/repository/handle-create-repo.mts (handler) - * - Src/util/socket/api.mts (API utilities) - * - Src/util/socket/sdk.mts (SDK setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchCreateRepo } from '../../../../src/commands/repository/fetch-create-repo.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchCreateRepo', () => { - it('creates repository successfully', async () => { - const mockData = { - id: 'repo-123', - name: 'my-new-repo', - org: 'test-org', - url: 'https://github.com/test-org/my-new-repo', - created_at: '2025-01-20T10:00:00Z', - status: 'active', - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'createRepository', - mockData, - ) - - const result = await fetchCreateRepo({ - orgSlug: 'test-org', - repoName: 'my-new-repo', - description: 'A new repository', - homepage: 'https://github.com/test-org/my-new-repo', - defaultBranch: 'main', - visibility: 'private', - }) - - expect(mockSdk.createRepository).toHaveBeenCalledWith( - 'test-org', - 'my-new-repo', - { - homepage: 'https://github.com/test-org/my-new-repo', - description: 'A new repository', - default_branch: 'main', - visibility: 'private', - }, - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'to create a repository', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const result = await fetchCreateRepo({ - orgSlug: 'org', - repoName: 'repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - }) - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError( - 'createRepository', - 'Repository already exists', - 409, - ) - - const result = await fetchCreateRepo({ - orgSlug: 'org', - repoName: 'existing-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(409) - } - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('createRepository', {}) - - const sdkOpts = { - apiToken: 'create-token', - baseUrl: 'https://create.api.com', - } - - await fetchCreateRepo( - { - orgSlug: 'my-org', - repoName: 'new-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - }, - { sdkOpts }, - ) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles minimal repository data', async () => { - const { mockSdk } = await setupSdkMockSuccess('createRepository', {}) - - await fetchCreateRepo({ - orgSlug: 'simple-org', - repoName: 'simple-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - }) - - expect(mockSdk.createRepository).toHaveBeenCalledWith( - 'simple-org', - 'simple-repo', - { - description: '', - homepage: '', - default_branch: 'main', - visibility: 'private', - }, - ) - }) - - it('handles full repository configuration', async () => { - const { mockSdk } = await setupSdkMockSuccess('createRepository', {}) - - const fullConfig = { - orgSlug: 'config-org', - repoName: 'full-config-repo', - homepage: 'https://github.com/org/full-config-repo', - description: 'Repository with full configuration', - defaultBranch: 'main', - visibility: 'private', - } - - await fetchCreateRepo(fullConfig) - - expect(mockSdk.createRepository).toHaveBeenCalledWith( - 'config-org', - 'full-config-repo', - { - homepage: 'https://github.com/org/full-config-repo', - description: 'Repository with full configuration', - default_branch: 'main', - visibility: 'private', - }, - ) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('createRepository', {}) - - // This tests that the function properly uses __proto__: null. - await fetchCreateRepo({ - orgSlug: 'test-org', - repoName: 'test-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - }) - - // The function should work without prototype pollution issues. - expect(mockSdk.createRepository).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-delete-repo.test.mts b/packages/cli/test/unit/commands/repository/fetch-delete-repo.test.mts deleted file mode 100644 index a0d591b62..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-delete-repo.test.mts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Unit tests for fetchDeleteRepo. - * - * Purpose: Tests repository deletion via the Socket API. Validates SDK - * integration, error handling for deletion workflows, and permission checks. - * - * Test Coverage: - Successful repository deletion - SDK setup failure handling - * - API call errors (404 not found, 403 forbidden) - Custom SDK options (API - * tokens, base URLs) - Insufficient permissions error handling. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Tests destructive operation error handling and permission scenarios. - * - * Related Files: - src/commands/repository/fetch-delete-repo.mts - * (implementation) - src/commands/repository/handle-delete-repo.mts (handler) - - * src/util/socket/api.mts (API utilities) - src/util/socket/sdk.mts (SDK - * setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchDeleteRepo } from '../../../../src/commands/repository/fetch-delete-repo.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchDeleteRepo', () => { - it('deletes repository successfully', async () => { - const mockData = { - id: 'repo-123', - name: 'deleted-repo', - status: 'deleted', - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'deleteRepository', - mockData, - ) - - const result = await fetchDeleteRepo('test-org', 'deleted-repo') - - expect(mockSdk.deleteRepository).toHaveBeenCalledWith( - 'test-org', - 'deleted-repo', - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'to delete a repository', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const result = await fetchDeleteRepo('org', 'repo') - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('deleteRepository', 'Repository not found', 404) - - const result = await fetchDeleteRepo('org', 'nonexistent-repo') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(404) - } - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('deleteRepository', {}) - - const sdkOpts = { - apiToken: 'delete-token', - baseUrl: 'https://delete.api.com', - } - - await fetchDeleteRepo('my-org', 'old-repo', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles insufficient permissions error', async () => { - await setupSdkMockError('deleteRepository', 'Insufficient permissions', 403) - - const result = await fetchDeleteRepo('protected-org', 'protected-repo') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(403) - } - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-list-all-repos.test.mts b/packages/cli/test/unit/commands/repository/fetch-list-all-repos.test.mts deleted file mode 100644 index da5d84ff0..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-list-all-repos.test.mts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Unit tests for fetchListAllRepos. - * - * Purpose: Tests fetching all repositories for an organization with automatic - * pagination. Validates multi-page fetching, infinite loop protection, and - * sorting options. - * - * Test Coverage: - Successful single-page repository listing - SDK setup - * failure handling - API call errors (403 access denied) - Multiple page - * pagination handling - Sort and direction options - Infinite loop protection - * (> 100 pages) - Custom SDK options - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers with pagination mocking. Tests - * infinite loop protection that triggers after 100 page requests. - * - * Related Files: - src/commands/repository/fetch-list-all-repos.mts - * (implementation) - src/commands/repository/handle-list-repos.mts (handler) - - * src/util/socket/api.mts (API utilities) - src/util/socket/sdk.mts (SDK - * setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchListAllRepos } from '../../../../src/commands/repository/fetch-list-all-repos.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchListAllRepos', () => { - it('lists all repositories successfully', async () => { - const mockData = { - results: [ - { id: 'repo-1', name: 'first-repo' }, - { id: 'repo-2', name: 'second-repo' }, - ], - nextPage: undefined, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'listRepositories', - mockData, - ) - - const result = await fetchListAllRepos('test-org') - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('test-org', { - sort: undefined, - direction: undefined, - per_page: 100, - page: 0, - }) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'list of repositories', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const result = await fetchListAllRepos('org') - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('listRepositories', 'Access denied', 403) - - const result = await fetchListAllRepos('private-org') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(403) - } - }) - - it('handles multiple pages of repositories', async () => { - // Mock with initial setup that returns first page. - const { mockHandleApi } = await setupSdkMockSuccess('listRepositories', { - results: [{ id: 'repo-1', name: 'first-repo' }], - nextPage: 1, - }) - - // Mock second page - reset and provide both pages. - mockHandleApi.mockClear() - mockHandleApi - .mockResolvedValueOnce({ - ok: true, - data: { - results: [{ id: 'repo-1', name: 'first-repo' }], - nextPage: 1, - }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { - results: [{ id: 'repo-2', name: 'second-repo' }], - nextPage: undefined, - }, - }) - - const result = await fetchListAllRepos('big-org') - - expect(mockHandleApi).toHaveBeenCalledTimes(2) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.results).toHaveLength(2) - expect(result.data.nextPage).toBeNull() - } - }) - - it('passes sort and direction options', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - await fetchListAllRepos('sorted-org', { - sort: 'name', - direction: 'asc', - }) - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('sorted-org', { - sort: 'name', - direction: 'asc', - per_page: 100, - page: 0, - }) - }) - - it('handles infinite loop protection', async () => { - const { mockHandleApi } = await setupSdkMockSuccess('listRepositories', { - results: [{ id: 'repo-1', name: 'repo' }], - nextPage: 1, - }) - - // Clear initial setup calls and always return the same nextPage to trigger protection. - mockHandleApi.mockClear() - mockHandleApi.mockResolvedValue({ - ok: true, - data: { - results: [{ id: 'repo-1', name: 'repo' }], - nextPage: 1, - }, - }) - - const result = await fetchListAllRepos('infinite-org') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Infinite loop detected') - // The protection triggers after ++protection > 100, but BEFORE the API call. - // So handleApiCall is called exactly 100 times before protection kicks in. - expect(mockHandleApi).toHaveBeenCalledTimes(100) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const sdkOpts = { - apiToken: 'list-token', - baseUrl: 'https://list.api.com', - } - - await fetchListAllRepos('my-org', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - // This tests that the function properly uses __proto__: null. - await fetchListAllRepos('test-org') - - // The function should work without prototype pollution issues. - expect(mockSdk.listRepositories).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-list-repos.test.mts b/packages/cli/test/unit/commands/repository/fetch-list-repos.test.mts deleted file mode 100644 index bd2a9d676..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-list-repos.test.mts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Unit tests for fetchListRepos. - * - * Purpose: Tests fetching a single page of repositories for an organization. - * Validates pagination configuration, sorting options, and parameter - * transformation. - * - * Test Coverage: - * - * - Successful paginated repository listing - * - SDK setup failure handling - * - API call errors (400 invalid page) - * - Custom SDK options - * - Large page size configuration (up to 100) - * - Different sort criteria (name, created_at, updated_at, stars, alphabetical) - * - Empty results on specific page - * - Null prototype usage for security - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates pagination parameters and various sorting configurations. - * - * Related Files: - * - * - Src/commands/repository/fetch-list-repos.mts (implementation) - * - Src/commands/repository/handle-list-repos.mts (handler) - * - Src/util/socket/api.mts (API utilities) - * - Src/util/socket/sdk.mts (SDK setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchListRepos } from '../../../../src/commands/repository/fetch-list-repos.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchListRepos', () => { - it('lists repositories with pagination successfully', async () => { - const mockData = { - results: [ - { id: 'repo-1', name: 'first-repo' }, - { id: 'repo-2', name: 'second-repo' }, - ], - nextPage: 2, - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'listRepositories', - mockData, - ) - - const config = { - direction: 'desc', - orgSlug: 'test-org', - page: 1, - perPage: 10, - sort: 'created_at', - } - - const result = await fetchListRepos(config) - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('test-org', { - sort: 'created_at', - direction: 'desc', - per_page: 10, - page: 1, - }) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'list of repositories', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const config = { - direction: 'asc', - orgSlug: 'org', - page: 0, - perPage: 20, - sort: 'name', - } - - const result = await fetchListRepos(config) - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('listRepositories', 'Invalid page number', 400) - - const config = { - direction: 'asc', - orgSlug: 'org', - page: -1, - perPage: 20, - sort: 'name', - } - - const result = await fetchListRepos(config) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(400) - } - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: 'asc', - orgSlug: 'my-org', - page: 0, - perPage: 50, - sort: 'updated_at', - } - - const sdkOpts = { - apiToken: 'paginated-token', - baseUrl: 'https://paginated.api.com', - } - - await fetchListRepos(config, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles large page size configuration', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: 'desc', - orgSlug: 'large-org', - page: 0, - perPage: 100, - sort: 'stars', - } - - await fetchListRepos(config) - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('large-org', { - sort: 'stars', - direction: 'desc', - per_page: 100, - page: 0, - }) - }) - - it('handles different sort criteria', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: 'asc', - orgSlug: 'sort-org', - page: 0, - perPage: 25, - sort: 'alphabetical', - } - - await fetchListRepos(config) - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('sort-org', { - sort: 'alphabetical', - direction: 'asc', - per_page: 25, - page: 0, - }) - }) - - it('handles empty results on specific page', async () => { - await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: 'asc', - orgSlug: 'empty-org', - page: 10, - perPage: 20, - sort: 'name', - } - - const result = await fetchListRepos(config) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.results).toHaveLength(0) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: 'asc', - orgSlug: 'test-org', - page: 0, - perPage: 10, - sort: 'name', - } - - // This tests that the function properly uses __proto__: null. - await fetchListRepos(config) - - // The function should work without prototype pollution issues. - expect(mockSdk.listRepositories).toHaveBeenCalled() - }) - - it('omits sort and direction when both are empty', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: '', - orgSlug: 'no-sort-org', - page: 0, - perPage: 10, - sort: '', - } - - await fetchListRepos(config) - - // The call should NOT include sort or direction keys. - expect(mockSdk.listRepositories).toHaveBeenCalledWith('no-sort-org', { - per_page: 10, - page: 0, - }) - }) - - it('includes sort but omits direction when only sort is set', async () => { - const { mockSdk } = await setupSdkMockSuccess('listRepositories', { - results: [], - nextPage: undefined, - }) - - const config = { - direction: '', - orgSlug: 'sort-only-org', - page: 0, - perPage: 10, - sort: 'name', - } - - await fetchListRepos(config) - - expect(mockSdk.listRepositories).toHaveBeenCalledWith('sort-only-org', { - sort: 'name', - per_page: 10, - page: 0, - }) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-update-repo.test.mts b/packages/cli/test/unit/commands/repository/fetch-update-repo.test.mts deleted file mode 100644 index 7cff47541..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-update-repo.test.mts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Unit tests for fetchUpdateRepo. - * - * Purpose: Tests repository update via the Socket API. Validates SDK - * integration, parameter transformation, and partial update handling. - * - * Test Coverage: - Successful repository update - SDK setup failure handling - - * API call errors (404 not found, 403 forbidden) - Custom SDK options - Partial - * updates (only changed fields) - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates parameter transformation and update workflows. - * - * Related Files: - src/commands/repository/fetch-update-repo.mts - * (implementation) - src/commands/repository/handle-update-repo.mts (handler) - - * src/util/socket/api.mts (API utilities) - src/util/socket/sdk.mts (SDK - * setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { fetchUpdateRepo } from '../../../../src/commands/repository/fetch-update-repo.mts' -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchUpdateRepo', () => { - it('updates repository successfully', async () => { - const mockData = { - id: 'repo-123', - name: 'updated-repo', - description: 'Updated description', - visibility: 'private', - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'updateRepository', - mockData, - ) - - const config = { - defaultBranch: 'main', - description: 'Updated description', - homepage: 'https://example.com', - orgSlug: 'test-org', - repoName: 'updated-repo', - visibility: 'private', - } - - const result = await fetchUpdateRepo(config) - - expect(mockSdk.updateRepository).toHaveBeenCalledWith( - 'test-org', - 'updated-repo', - { - default_branch: 'main', - description: 'Updated description', - homepage: 'https://example.com', - name: 'updated-repo', - orgSlug: 'test-org', - visibility: 'private', - }, - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'to update a repository', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const config = { - defaultBranch: 'main', - description: 'Test', - homepage: 'https://test.com', - orgSlug: 'org', - repoName: 'repo', - visibility: 'public', - } - - const result = await fetchUpdateRepo(config) - - expect(result.ok).toBe(false) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('updateRepository', 'Repository not found', 404) - - const config = { - defaultBranch: 'main', - description: 'Test', - homepage: 'https://test.com', - orgSlug: 'org', - repoName: 'nonexistent', - visibility: 'public', - } - - const result = await fetchUpdateRepo(config) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe(404) - } - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('updateRepository', {}) - - const config = { - defaultBranch: 'develop', - description: 'Custom update', - homepage: 'https://custom.com', - orgSlug: 'my-org', - repoName: 'custom-repo', - visibility: 'internal', - } - - const sdkOpts = { - apiToken: 'update-token', - baseUrl: 'https://update.api.com', - } - - await fetchUpdateRepo(config, { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles visibility changes', async () => { - const { mockSdk } = await setupSdkMockSuccess('updateRepository', {}) - - const config = { - defaultBranch: 'main', - description: 'Making repo private', - homepage: '', - orgSlug: 'secure-org', - repoName: 'secret-repo', - visibility: 'private', - } - - await fetchUpdateRepo(config) - - expect(mockSdk.updateRepository).toHaveBeenCalledWith( - 'secure-org', - 'secret-repo', - { - default_branch: 'main', - description: 'Making repo private', - homepage: '', - name: 'secret-repo', - orgSlug: 'secure-org', - visibility: 'private', - }, - ) - }) - - it('handles default branch updates', async () => { - const { mockSdk } = await setupSdkMockSuccess('updateRepository', {}) - - const config = { - defaultBranch: 'develop', - description: 'Switching to develop branch', - homepage: 'https://dev.example.com', - orgSlug: 'branch-org', - repoName: 'branch-test', - visibility: 'public', - } - - await fetchUpdateRepo(config) - - expect(mockSdk.updateRepository).toHaveBeenCalledWith( - 'branch-org', - 'branch-test', - { - default_branch: 'develop', - description: 'Switching to develop branch', - homepage: 'https://dev.example.com', - name: 'branch-test', - orgSlug: 'branch-org', - visibility: 'public', - }, - ) - }) - - it('handles empty or minimal updates', async () => { - const { mockSdk } = await setupSdkMockSuccess('updateRepository', {}) - - const config = { - defaultBranch: '', - description: '', - homepage: '', - orgSlug: 'minimal-org', - repoName: 'minimal-repo', - visibility: '', - } - - await fetchUpdateRepo(config) - - expect(mockSdk.updateRepository).toHaveBeenCalledWith( - 'minimal-org', - 'minimal-repo', - { - default_branch: '', - description: '', - homepage: '', - name: 'minimal-repo', - orgSlug: 'minimal-org', - visibility: '', - }, - ) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('updateRepository', {}) - - const config = { - defaultBranch: 'main', - description: 'Test', - homepage: 'https://test.com', - orgSlug: 'test-org', - repoName: 'test-repo', - visibility: 'public', - } - - // This tests that the function properly uses __proto__: null. - await fetchUpdateRepo(config) - - // The function should work without prototype pollution issues. - expect(mockSdk.updateRepository).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/repository/fetch-view-repo.test.mts b/packages/cli/test/unit/commands/repository/fetch-view-repo.test.mts deleted file mode 100644 index f17a7bb9e..000000000 --- a/packages/cli/test/unit/commands/repository/fetch-view-repo.test.mts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Unit tests for fetchViewRepo. - * - * Purpose: Tests fetching repository details via the Socket API. Validates SDK - * integration and error handling for viewing repository information. - * - * Test Coverage: - Successful repository view - SDK setup failure handling - - * API call errors (404 not found, 403 forbidden) - Custom SDK options. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Tests read-only repository data retrieval. - * - * Related Files: - src/commands/repository/fetch-view-repo.mts (implementation) - * - src/commands/repository/handle-view-repo.mts (handler) - - * src/util/socket/api.mts (API utilities) - src/util/socket/sdk.mts (SDK - * setup) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchViewRepo } from '../../../../src/commands/repository/fetch-view-repo.mts' - -// Mock the dependencies. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -describe('fetchViewRepo', () => { - it('views repository successfully', async () => { - const mockData = { - id: 'repo-123', - name: 'test-repo', - description: 'A test repository', - visibility: 'public', - default_branch: 'main', - created_at: '2025-01-01T10:00:00Z', - updated_at: '2025-01-20T15:30:00Z', - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getRepository', - mockData, - ) - - const result = await fetchViewRepo('test-org', 'test-repo') - - expect(mockSdk.getRepository).toHaveBeenCalledWith('test-org', 'test-repo') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'repository data', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Missing API token', - }) - - const result = await fetchViewRepo('org', 'repo') - - expect(result.ok).toBe(false) - expect(result.code).toBe(1) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('getRepository', 'Repository not found', 404) - - const result = await fetchViewRepo('org', 'nonexistent-repo') - - expect(result.ok).toBe(false) - expect(result.code).toBe(404) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('getRepository', {}) - - const sdkOpts = { - apiToken: 'view-token', - baseUrl: 'https://view.api.com', - } - - await fetchViewRepo('my-org', 'my-repo', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles private repository access', async () => { - const mockData = { - id: 'private-repo-456', - name: 'secret-project', - description: 'A private repository', - visibility: 'private', - members_count: 5, - } - - const { mockSdk } = await setupSdkMockSuccess('getRepository', mockData) - - const result = await fetchViewRepo('private-org', 'secret-project') - - expect(result.ok).toBe(true) - expect(mockSdk.getRepository).toHaveBeenCalledWith( - 'private-org', - 'secret-project', - ) - }) - - it('handles special repository names', async () => { - const { mockSdk } = await setupSdkMockSuccess('getRepository', {}) - - await fetchViewRepo('special-org', 'repo-with-hyphens_and_underscores.dots') - - expect(mockSdk.getRepository).toHaveBeenCalledWith( - 'special-org', - 'repo-with-hyphens_and_underscores.dots', - ) - }) - - it('handles insufficient permissions error', async () => { - await setupSdkMockError('getRepository', 'Access denied', 403) - - const result = await fetchViewRepo('restricted-org', 'restricted-repo') - - expect(result.ok).toBe(false) - expect(result.code).toBe(403) - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('getRepository', {}) - - // This tests that the function properly uses __proto__: null. - await fetchViewRepo('test-org', 'test-repo') - - // The function should work without prototype pollution issues. - expect(mockSdk.getRepository).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/repository/handle-create-repo.test.mts b/packages/cli/test/unit/commands/repository/handle-create-repo.test.mts deleted file mode 100644 index a387aa84e..000000000 --- a/packages/cli/test/unit/commands/repository/handle-create-repo.test.mts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Unit tests for handleCreateRepo. - * - * Purpose: Tests the handler that orchestrates repository creation. Validates - * fetch-process-output pipeline, input validation, and error handling for - * repository creation workflows. - * - * Test Coverage: - Successful repository creation flow - Fetch failure handling - * - Input validation - Output formatting delegation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. - * - * Related Files: - src/commands/repository/handle-create-repo.mts - * (implementation) - src/commands/repository/fetch-create-repo.mts (API - * fetcher) - src/commands/repository/output-create-repo.mts (formatter) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleCreateRepo } from '../../../../src/commands/repository/handle-create-repo.mts' - -// Mock the dependencies. -const mockFetchCreateRepo = vi.hoisted(() => vi.fn()) -const mockOutputCreateRepo = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => false) - -vi.mock('../../../../src/commands/repository/fetch-create-repo.mts', () => ({ - fetchCreateRepo: mockFetchCreateRepo, -})) -vi.mock('../../../../src/commands/repository/output-create-repo.mts', () => ({ - outputCreateRepo: mockOutputCreateRepo, -})) -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -describe('handleCreateRepo', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates repository successfully', async () => { - const mockData = { - ok: true, - data: { - id: '123', - name: 'my-repo', - fullName: 'test-org/my-repo', - visibility: 'private', - }, - } - mockFetchCreateRepo.mockResolvedValue(mockData) - - await handleCreateRepo( - { - orgSlug: 'test-org', - repoName: 'my-repo', - description: 'Test repository', - homepage: 'https://example.com', - defaultBranch: 'main', - visibility: 'private', - }, - 'json', - ) - - expect(mockFetchCreateRepo).toHaveBeenCalledWith( - { - orgSlug: 'test-org', - repoName: 'my-repo', - description: 'Test repository', - homepage: 'https://example.com', - defaultBranch: 'main', - visibility: 'private', - }, - { - commandPath: 'socket repository create', - }, - ) - expect(mockOutputCreateRepo).toHaveBeenCalledWith( - mockData, - 'my-repo', - 'json', - ) - }) - - it('handles creation failure', async () => { - const mockError = { - ok: false, - error: new Error('Repository already exists'), - } - mockFetchCreateRepo.mockResolvedValue(mockError) - - await handleCreateRepo( - { - orgSlug: 'test-org', - repoName: 'existing-repo', - description: 'Test repository', - homepage: '', - defaultBranch: 'main', - visibility: 'public', - }, - 'text', - ) - - expect(mockFetchCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'existing-repo', - }), - expect.objectContaining({ - commandPath: 'socket repository create', - }), - ) - expect(mockOutputCreateRepo).toHaveBeenCalledWith( - mockError, - 'existing-repo', - 'text', - ) - }) - - it('handles markdown output', async () => { - const mockData = { - ok: true, - data: { id: '456', name: 'test-repo' }, - } - mockFetchCreateRepo.mockResolvedValue(mockData) - - await handleCreateRepo( - { - orgSlug: 'org', - repoName: 'test-repo', - description: 'Description', - homepage: 'https://test.com', - defaultBranch: 'develop', - visibility: 'internal', - }, - 'markdown', - ) - - expect(mockOutputCreateRepo).toHaveBeenCalledWith( - mockData, - 'test-repo', - 'markdown', - ) - }) - - it('logs debug information', async () => { - const mockData = { - ok: true, - data: { id: '789', name: 'debug-repo' }, - } - mockFetchCreateRepo.mockResolvedValue(mockData) - - await handleCreateRepo( - { - orgSlug: 'debug-org', - repoName: 'debug-repo', - description: 'Debug test', - homepage: 'https://debug.com', - defaultBranch: 'main', - visibility: 'private', - }, - 'json', - ) - - expect(mockDebug).toHaveBeenCalledWith( - 'Creating repository debug-org/debug-repo', - ) - expect(mockDebugDir).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'debug-org', - repoName: 'debug-repo', - }), - ) - expect(mockDebug).toHaveBeenCalledWith('Repository creation succeeded') - }) - - it('logs debug information on failure', async () => { - mockFetchCreateRepo.mockResolvedValue({ - ok: false, - error: new Error('Failed'), - }) - - await handleCreateRepo( - { - orgSlug: 'org', - repoName: 'repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'public', - }, - 'json', - ) - - expect(mockDebug).toHaveBeenCalledWith('Repository creation failed') - }) - - it('handles different visibility types', async () => { - const visibilities = ['public', 'private', 'internal'] - - for (let i = 0, { length } = visibilities; i < length; i += 1) { - const visibility = visibilities[i] - mockFetchCreateRepo.mockResolvedValue({ - ok: true, - data: { id: '1', name: 'repo', visibility }, - }) - - await handleCreateRepo( - { - orgSlug: 'org', - repoName: 'repo', - description: 'Test', - homepage: '', - defaultBranch: 'main', - visibility, - }, - 'json', - ) - - expect(mockFetchCreateRepo).toHaveBeenCalledWith( - expect.objectContaining({ visibility }), - expect.objectContaining({ - commandPath: 'socket repository create', - }), - ) - } - }) - - it('handles empty optional fields', async () => { - mockFetchCreateRepo.mockResolvedValue({ - ok: true, - data: { id: '1', name: 'minimal-repo' }, - }) - - await handleCreateRepo( - { - orgSlug: 'org', - repoName: 'minimal-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'public', - }, - 'json', - ) - - expect(mockFetchCreateRepo).toHaveBeenCalledWith( - { - orgSlug: 'org', - repoName: 'minimal-repo', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'public', - }, - { - commandPath: 'socket repository create', - }, - ) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/handle-delete-repo.test.mts b/packages/cli/test/unit/commands/repository/handle-delete-repo.test.mts deleted file mode 100644 index f1d5c3d70..000000000 --- a/packages/cli/test/unit/commands/repository/handle-delete-repo.test.mts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Unit tests for handleDeleteRepo. - * - * Purpose: Tests the handler that orchestrates repository deletion. Validates - * fetch-process-output pipeline and confirmation workflows for destructive - * operations. - * - * Test Coverage: - Successful repository deletion flow - Fetch failure handling - * - Output formatting delegation - Deletion confirmation handling. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Tests destructive operation handling. - * - * Related Files: - src/commands/repository/handle-delete-repo.mts - * (implementation) - src/commands/repository/fetch-delete-repo.mts (API - * fetcher) - src/commands/repository/output-delete-repo.mts (formatter) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { createSuccessResult } from '../../../../test/helpers/index.mts' - -// Mock the dependencies. -const mockFetchDeleteRepo = vi.hoisted(() => vi.fn()) -const mockOutputDeleteRepo = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/repository/fetch-delete-repo.mts', () => ({ - fetchDeleteRepo: mockFetchDeleteRepo, -})) - -vi.mock('../../../../src/commands/repository/output-delete-repo.mts', () => ({ - outputDeleteRepo: mockOutputDeleteRepo, -})) - -const { handleDeleteRepo } = - await import('../../../../src/commands/repository/handle-delete-repo.mts') - -describe('handleDeleteRepo', () => { - it('deletes repository and outputs result successfully', async () => { - const mockResult = createSuccessResult({ success: true }) - mockFetchDeleteRepo.mockResolvedValue(mockResult) - - await handleDeleteRepo('test-org', 'test-repo', 'json') - - expect(mockFetchDeleteRepo).toHaveBeenCalledWith('test-org', 'test-repo', { - commandPath: 'socket repository del', - }) - expect(mockOutputDeleteRepo).toHaveBeenCalledWith( - mockResult, - 'test-repo', - 'json', - ) - }) - - it('handles deletion failure', async () => { - const mockResult = { - ok: false, - error: 'Repository not found', - } - mockFetchDeleteRepo.mockResolvedValue(mockResult) - - await handleDeleteRepo('test-org', 'nonexistent-repo', 'text') - - expect(mockFetchDeleteRepo).toHaveBeenCalledWith( - 'test-org', - 'nonexistent-repo', - { - commandPath: 'socket repository del', - }, - ) - expect(mockOutputDeleteRepo).toHaveBeenCalledWith( - mockResult, - 'nonexistent-repo', - 'text', - ) - }) - - it('handles markdown output format', async () => { - mockFetchDeleteRepo.mockResolvedValue(createSuccessResult({})) - - await handleDeleteRepo('my-org', 'my-repo', 'markdown') - - expect(mockOutputDeleteRepo).toHaveBeenCalledWith( - expect.any(Object), - 'my-repo', - 'markdown', - ) - }) - - it('handles different repository names', async () => { - const repoNames = [ - 'simple-repo', - 'repo-with-dashes', - 'repo_with_underscores', - 'repo123', - ] - - for (let i = 0, { length } = repoNames; i < length; i += 1) { - const repoName = repoNames[i] - mockFetchDeleteRepo.mockResolvedValue(createSuccessResult({})) - await handleDeleteRepo('test-org', repoName, 'json') - expect(mockFetchDeleteRepo).toHaveBeenCalledWith('test-org', repoName, { - commandPath: 'socket repository del', - }) - } - }) - - it('passes text output format', async () => { - mockFetchDeleteRepo.mockResolvedValue( - createSuccessResult({ deleted: true, timestamp: '2025-01-01T00:00:00Z' }), - ) - - await handleDeleteRepo('production-org', 'deprecated-repo', 'text') - - expect(mockOutputDeleteRepo).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ deleted: true }), - }), - 'deprecated-repo', - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/handle-list-repos.test.mts b/packages/cli/test/unit/commands/repository/handle-list-repos.test.mts deleted file mode 100644 index 60f476671..000000000 --- a/packages/cli/test/unit/commands/repository/handle-list-repos.test.mts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Unit tests for handleListRepos. - * - * Purpose: Tests the handler that orchestrates repository listing. Validates - * pagination handling, filtering, and output formatting for repository lists. - * - * Test Coverage: - Successful repository listing flow - Pagination - * configuration - Fetch failure handling - Output formatting delegation - - * Filtering and sorting options. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Tests paginated data handling. - * - * Related Files: - src/commands/repository/handle-list-repos.mts - * (implementation) - src/commands/repository/fetch-list-repos.mts (API fetcher) - * - src/commands/repository/output-list-repos.mts (formatter) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleListRepos } from '../../../../src/commands/repository/handle-list-repos.mts' - -// Mock the dependencies. -const mockFetchListAllRepos = vi.hoisted(() => vi.fn()) -const mockFetchListRepos = vi.hoisted(() => vi.fn()) -const mockOutputListRepos = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/repository/fetch-list-all-repos.mts', () => ({ - fetchListAllRepos: mockFetchListAllRepos, -})) -vi.mock('../../../../src/commands/repository/fetch-list-repos.mts', () => ({ - fetchListRepos: mockFetchListRepos, -})) -vi.mock('../../../../src/commands/repository/output-list-repos.mts', () => ({ - outputListRepos: mockOutputListRepos, -})) - -describe('handleListRepos', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches all repositories when all flag is true', async () => { - const mockData = { - ok: true, - data: [ - { id: '1', name: 'repo1' }, - { id: '2', name: 'repo2' }, - { id: '3', name: 'repo3' }, - ], - } - mockFetchListAllRepos.mockResolvedValue(mockData) - - await handleListRepos({ - all: true, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - sort: 'name', - }) - - expect(mockFetchListAllRepos).toHaveBeenCalledWith('test-org', { - commandPath: 'socket repository list', - direction: 'asc', - sort: 'name', - }) - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockData, - 'json', - 0, - 0, - 'name', - Number.POSITIVE_INFINITY, - 'asc', - ) - }) - - it('fetches paginated repositories when all is false', async () => { - const mockData = { - ok: true, - data: { - repos: [ - { id: '1', name: 'repo1' }, - { id: '2', name: 'repo2' }, - ], - nextPage: 2, - }, - } - mockFetchListRepos.mockResolvedValue(mockData) - - await handleListRepos({ - all: false, - direction: 'desc', - orgSlug: 'test-org', - outputKind: 'text', - page: 1, - perPage: 10, - sort: 'updated', - }) - - expect(mockFetchListRepos).toHaveBeenCalledWith( - { - direction: 'desc', - orgSlug: 'test-org', - page: 1, - perPage: 10, - sort: 'updated', - }, - { - commandPath: 'socket repository list', - }, - ) - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockData, - 'text', - 1, - 2, - 'updated', - 10, - 'desc', - ) - }) - - it('handles error response for paginated fetch', async () => { - const mockError = { - ok: false, - error: new Error('Failed to fetch repositories'), - } - mockFetchListRepos.mockResolvedValue(mockError) - - await handleListRepos({ - all: false, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 20, - sort: 'name', - }) - - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockError, - 'json', - 0, - 0, - '', - 0, - 'asc', - ) - }) - - it('handles null nextPage for last page', async () => { - const mockData = { - ok: true, - data: { - repos: [{ id: '1', name: 'repo1' }], - nextPage: undefined, - }, - } - mockFetchListRepos.mockResolvedValue(mockData) - - await handleListRepos({ - all: false, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'json', - page: 3, - perPage: 10, - sort: 'name', - }) - - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockData, - 'json', - 3, - undefined, - 'name', - 10, - 'asc', - ) - }) - - it('handles markdown output', async () => { - const mockData = { - ok: true, - data: [{ id: '1', name: 'repo1' }], - } - mockFetchListAllRepos.mockResolvedValue(mockData) - - await handleListRepos({ - all: true, - direction: 'desc', - orgSlug: 'test-org', - outputKind: 'markdown', - page: 1, - perPage: 10, - sort: 'created', - }) - - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockData, - 'markdown', - 0, - 0, - 'created', - Number.POSITIVE_INFINITY, - 'desc', - ) - }) - - it('handles different sort options', async () => { - const sortOptions = ['name', 'created', 'updated', 'pushed'] - - for (let i = 0, { length } = sortOptions; i < length; i += 1) { - const sort = sortOptions[i] - mockFetchListRepos.mockResolvedValue({ - ok: true, - data: { repos: [], nextPage: undefined }, - }) - - await handleListRepos({ - all: false, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 10, - sort, - }) - - expect(mockFetchListRepos).toHaveBeenCalledWith( - expect.objectContaining({ sort }), - expect.objectContaining({ commandPath: 'socket repository list' }), - ) - } - }) - - it('handles different page sizes', async () => { - const mockData = { - ok: true, - data: { repos: [], nextPage: undefined }, - } - mockFetchListRepos.mockResolvedValue(mockData) - - await handleListRepos({ - all: false, - direction: 'asc', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 100, - sort: 'name', - }) - - expect(mockFetchListRepos).toHaveBeenCalledWith( - expect.objectContaining({ perPage: 100 }), - expect.objectContaining({ commandPath: 'socket repository list' }), - ) - expect(mockOutputListRepos).toHaveBeenCalledWith( - mockData, - 'json', - 1, - undefined, - 'name', - 100, - 'asc', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/handle-update-repo.test.mts b/packages/cli/test/unit/commands/repository/handle-update-repo.test.mts deleted file mode 100644 index e2345b839..000000000 --- a/packages/cli/test/unit/commands/repository/handle-update-repo.test.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Unit tests for handleUpdateRepo. - * - * Purpose: Tests the handler that orchestrates repository updates. Validates - * fetch-process-output pipeline, partial update handling, and output - * formatting. - * - * Test Coverage: - Successful repository update flow - Fetch failure handling - - * Partial update handling - Output formatting delegation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. - * - * Related Files: - src/commands/repository/handle-update-repo.mts - * (implementation) - src/commands/repository/fetch-update-repo.mts (API - * fetcher) - src/commands/repository/output-update-repo.mts (formatter) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { handleUpdateRepo } from '../../../../src/commands/repository/handle-update-repo.mts' -import { createSuccessResult } from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockFetchUpdateRepo = vi.hoisted(() => vi.fn()) -const mockOutputUpdateRepo = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/repository/fetch-update-repo.mts', () => ({ - fetchUpdateRepo: mockFetchUpdateRepo, -})) - -vi.mock('../../../../src/commands/repository/output-update-repo.mts', () => ({ - outputUpdateRepo: mockOutputUpdateRepo, -})) - -describe('handleUpdateRepo', () => { - it('updates repository and outputs result successfully', async () => { - const mockResult = createSuccessResult({ - id: 'repo-123', - name: 'test-repo', - description: 'Updated description', - homepage: 'https://example.com', - defaultBranch: 'main', - visibility: 'public', - updatedAt: '2025-01-01T00:00:00Z', - }) - mockFetchUpdateRepo.mockResolvedValue(mockResult) - - const params = { - orgSlug: 'test-org', - repoName: 'test-repo', - description: 'Updated description', - homepage: 'https://example.com', - defaultBranch: 'main', - visibility: 'public', - } - - await handleUpdateRepo(params, 'json') - - expect(mockFetchUpdateRepo).toHaveBeenCalledWith(params, { - commandPath: 'socket repository update', - }) - expect(mockOutputUpdateRepo).toHaveBeenCalledWith( - mockResult, - 'test-repo', - 'json', - ) - }) - - it('handles update failure', async () => { - const mockError = { - ok: false, - error: 'Repository not found', - } - mockFetchUpdateRepo.mockResolvedValue(mockError) - - const params = { - orgSlug: 'test-org', - repoName: 'nonexistent', - description: '', - homepage: '', - defaultBranch: 'main', - visibility: 'private', - } - - await handleUpdateRepo(params, 'text') - - expect(mockFetchUpdateRepo).toHaveBeenCalledWith(params, { - commandPath: 'socket repository update', - }) - expect(mockOutputUpdateRepo).toHaveBeenCalledWith( - mockError, - 'nonexistent', - 'text', - ) - }) - - it('handles markdown output format', async () => { - mockFetchUpdateRepo.mockResolvedValue(createSuccessResult({})) - - await handleUpdateRepo( - { - orgSlug: 'my-org', - repoName: 'my-repo', - description: 'A cool project', - homepage: 'https://myproject.com', - defaultBranch: 'develop', - visibility: 'public', - }, - 'markdown', - ) - - expect(mockOutputUpdateRepo).toHaveBeenCalledWith( - expect.any(Object), - 'my-repo', - 'markdown', - ) - }) - - it('handles different visibility settings', async () => { - mockFetchUpdateRepo.mockResolvedValue(createSuccessResult({})) - - const visibilities = ['public', 'private', 'internal'] - - for (let i = 0, { length } = visibilities; i < length; i += 1) { - const visibility = visibilities[i] - await handleUpdateRepo( - { - orgSlug: 'test-org', - repoName: 'test-repo', - description: 'Test', - homepage: '', - defaultBranch: 'main', - visibility, - }, - 'json', - ) - - expect(mockFetchUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ visibility }), - expect.objectContaining({ commandPath: 'socket repository update' }), - ) - } - }) - - it('handles different default branches', async () => { - mockFetchUpdateRepo.mockResolvedValue( - createSuccessResult({ defaultBranch: 'develop' }), - ) - - await handleUpdateRepo( - { - orgSlug: 'production-org', - repoName: 'production-repo', - description: 'Production application', - homepage: 'https://production.app', - defaultBranch: 'develop', - visibility: 'private', - }, - 'text', - ) - - expect(mockFetchUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: 'develop', - }), - expect.objectContaining({ commandPath: 'socket repository update' }), - ) - expect(mockOutputUpdateRepo).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ defaultBranch: 'develop' }), - }), - 'production-repo', - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/handle-view-repo.test.mts b/packages/cli/test/unit/commands/repository/handle-view-repo.test.mts deleted file mode 100644 index 385402e21..000000000 --- a/packages/cli/test/unit/commands/repository/handle-view-repo.test.mts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Unit tests for handleViewRepo. - * - * Purpose: Tests the handler that orchestrates repository viewing. Validates - * fetch-process-output pipeline and detailed repository data formatting. - * - * Test Coverage: - Successful repository view flow - Fetch failure handling - - * Output formatting delegation - Detailed data presentation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. - * - * Related Files: - src/commands/repository/handle-view-repo.mts - * (implementation) - src/commands/repository/fetch-view-repo.mts (API fetcher) - * - src/commands/repository/output-view-repo.mts (formatter) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { createSuccessResult } from '../../../helpers/index.mts' -import { handleViewRepo } from '../../../../src/commands/repository/handle-view-repo.mts' - -// Setup mocks at module level -const mockFetchViewRepo = vi.hoisted(() => vi.fn()) -const mockOutputViewRepo = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/repository/fetch-view-repo.mts', () => ({ - fetchViewRepo: mockFetchViewRepo, -})) - -vi.mock('../../../../src/commands/repository/output-view-repo.mts', () => ({ - outputViewRepo: mockOutputViewRepo, -})) - -describe('handleViewRepo', () => { - it('fetches and outputs repository details successfully', async () => { - const mockRepoData = createSuccessResult({ - id: 'repo-123', - name: 'test-repo', - org: 'test-org', - url: 'https://github.com/test-org/test-repo', - lastUpdated: '2025-01-01T00:00:00Z', - }) - - mockFetchViewRepo.mockResolvedValue(mockRepoData) - - await handleViewRepo('test-org', 'test-repo', 'json') - - expect(mockFetchViewRepo).toHaveBeenCalledWith('test-org', 'test-repo', { - commandPath: 'socket repository view', - }) - expect(mockOutputViewRepo).toHaveBeenCalledWith(mockRepoData, 'json') - }) - - it('handles fetch failure', async () => { - const mockError = { - ok: false as const, - message: 'Repository not found', - code: 404, - } - - mockFetchViewRepo.mockResolvedValue(mockError) - - await handleViewRepo('test-org', 'nonexistent-repo', 'text') - - expect(mockFetchViewRepo).toHaveBeenCalledWith( - 'test-org', - 'nonexistent-repo', - { - commandPath: 'socket repository view', - }, - ) - expect(mockOutputViewRepo).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles markdown output format', async () => { - mockFetchViewRepo.mockResolvedValue( - createSuccessResult({ - name: 'my-repo', - org: 'my-org', - }), - ) - - await handleViewRepo('my-org', 'my-repo', 'markdown') - - expect(mockOutputViewRepo).toHaveBeenCalledWith( - expect.any(Object), - 'markdown', - ) - }) - - it('handles text output format', async () => { - mockFetchViewRepo.mockResolvedValue( - createSuccessResult({ - name: 'production-repo', - org: 'production-org', - branches: ['main', 'develop', 'staging'], - defaultBranch: 'main', - }), - ) - - await handleViewRepo('production-org', 'production-repo', 'text') - - expect(mockOutputViewRepo).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ - name: 'production-repo', - }), - }), - 'text', - ) - }) - - it('handles different repository names', async () => { - const testCases = [ - ['org-1', 'repo-1'], - ['my-org', 'my-awesome-project'], - ['company', 'internal-tool'], - ] - - for (const [org, repo] of testCases) { - mockFetchViewRepo.mockResolvedValue(createSuccessResult({})) - await handleViewRepo(org, repo, 'json') - expect(mockFetchViewRepo).toHaveBeenCalledWith(org, repo, { - commandPath: 'socket repository view', - }) - } - }) -}) diff --git a/packages/cli/test/unit/commands/repository/output-create-repo.test.mts b/packages/cli/test/unit/commands/repository/output-create-repo.test.mts deleted file mode 100644 index 581eb6703..000000000 --- a/packages/cli/test/unit/commands/repository/output-create-repo.test.mts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Unit tests for output-create-repo. - * - * Purpose: Tests output formatting for repository creation results. Validates - * text, JSON, and markdown formatting for successful and failed repository - * creation. - * - * Test Coverage: - Successful creation output formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Repository - * metadata display. - * - * Testing Approach: Uses result helpers to create test data. Validates - * formatted output strings across different output modes. - * - * Related Files: - src/commands/repository/output-create-repo.mts - * (implementation) - src/commands/repository/handle-create-repo.mts (handler) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { outputCreateRepo } from '../../../../src/commands/repository/output-create-repo.mts' - -import type { CResult } from '../../../../src/commands/repository/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: vi.fn(result => JSON.stringify(result)), -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: vi.fn((msg, cause) => `${msg}: ${cause}`), -})) - -describe('outputCreateRepo', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('outputs JSON format for successful result', async () => { - const { serializeResultJson } = await vi.importMock( - '../../../../src/util/output/result-json.mjs', - ) - const mockSerialize = vi.mocked(serializeResultJson) - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: true, - data: { - slug: 'my-repo', - }, - } - - outputCreateRepo(result, 'my-repo', 'json') - - expect(mockSerialize).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: false, - code: 2, - message: 'Unauthorized', - cause: 'Invalid API token', - } - - outputCreateRepo(result, 'my-repo', 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs success message when slug matches requested name', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: true, - data: { - slug: 'my-awesome-repo', - }, - } - - outputCreateRepo(result, 'my-awesome-repo', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository created successfully, slug: `my-awesome-repo`', - ) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs success message with warning when slug differs from requested name', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: true, - data: { - slug: 'my-repo-sanitized', - }, - } - - outputCreateRepo(result, 'My Repo With Spaces!', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository created successfully, slug: `my-repo-sanitized` (Warning: slug is not the same as name that was requested!)', - ) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in text format', async () => { - const { failMsgWithBadge } = await vi.importMock( - '../../../../src/util/error/fail-msg-with-badge.mts', - ) - const mockFailMsg = vi.mocked(failMsgWithBadge) - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: false, - code: 1, - message: 'Repository already exists', - cause: 'Conflict error', - } - - outputCreateRepo(result, 'existing-repo', 'text') - - expect(mockFailMsg).toHaveBeenCalledWith( - 'Repository already exists', - 'Conflict error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles markdown output format', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: true, - data: { - slug: 'markdown-repo', - }, - } - - outputCreateRepo(result, 'markdown-repo', 'markdown') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository created successfully, slug: `markdown-repo`', - ) - }) - - it('handles empty slug properly', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: true, - data: { - slug: '', - }, - } - - outputCreateRepo(result, 'original-name', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository created successfully, slug: `` (Warning: slug is not the same as name that was requested!)', - ) - }) - - it('sets default exit code when code is undefined', async () => { - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - { - ok: false, - message: 'Error without code', - } - - outputCreateRepo(result, 'test-repo', 'json') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/output-delete-repo.test.mts b/packages/cli/test/unit/commands/repository/output-delete-repo.test.mts deleted file mode 100644 index a67972a80..000000000 --- a/packages/cli/test/unit/commands/repository/output-delete-repo.test.mts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Unit tests for output-delete-repo. - * - * Purpose: Tests output formatting for repository deletion results. Validates - * confirmation messages and error formatting for deletion operations. - * - * Test Coverage: - Successful deletion output formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Deletion - * confirmation messages. - * - * Testing Approach: Uses result helpers to create test data. Validates - * formatted output strings for destructive operations. - * - * Related Files: - src/commands/repository/output-delete-repo.mts - * (implementation) - src/commands/repository/handle-delete-repo.mts (handler) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { outputDeleteRepo } from '../../../../src/commands/repository/output-delete-repo.mts' - -import type { CResult } from '../../../../src/commands/repository/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: vi.fn(result => JSON.stringify(result)), -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: vi.fn((msg, cause) => `${msg}: ${cause}`), -})) - -describe('outputDeleteRepo', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('outputs JSON format for successful result', async () => { - const { serializeResultJson } = await vi.importMock( - '../../../../src/util/output/result-json.mjs', - ) - const mockSerialize = vi.mocked(serializeResultJson) - - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputDeleteRepo(result, 'test-repo', 'json') - - expect(mockSerialize).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: false, - code: 2, - message: 'Unauthorized', - cause: 'Invalid API token', - } - - outputDeleteRepo(result, 'test-repo', 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs success message for successful deletion', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputDeleteRepo(result, 'my-repository', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository `my-repository` deleted successfully', - ) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in text format', async () => { - const { failMsgWithBadge } = await vi.importMock( - '../../../../src/util/error/fail-msg-with-badge.mts', - ) - const mockFailMsg = vi.mocked(failMsgWithBadge) - - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: false, - code: 1, - message: 'Repository not found', - cause: 'Not found error', - } - - outputDeleteRepo(result, 'nonexistent-repo', 'text') - - expect(mockFailMsg).toHaveBeenCalledWith( - 'Repository not found', - 'Not found error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles markdown output format', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputDeleteRepo(result, 'markdown-repo', 'markdown') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository `markdown-repo` deleted successfully', - ) - }) - - it('handles repository name with special characters', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputDeleteRepo(result, 'repo-with-dashes_and_underscores', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository `repo-with-dashes_and_underscores` deleted successfully', - ) - }) - - it('handles empty repository name', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputDeleteRepo(result, '', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'OK. Repository `` deleted successfully', - ) - }) - - it('sets default exit code when code is undefined', async () => { - const result: CResult<SocketSdkSuccessResult<'deleteRepository'>['data']> = - { - ok: false, - message: 'Error without code', - } - - outputDeleteRepo(result, 'test-repo', 'json') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/output-list-repos.test.mts b/packages/cli/test/unit/commands/repository/output-list-repos.test.mts deleted file mode 100644 index 58ea79d5a..000000000 --- a/packages/cli/test/unit/commands/repository/output-list-repos.test.mts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * Unit tests for output-list-repos. - * - * Purpose: Tests output formatting for repository lists. Validates table - * formatting, pagination indicators, and empty state handling across output - * formats. - * - * Test Coverage: - Repository list table formatting - Pagination information - * display - Empty list handling - Multiple output formats (text, json, - * markdown) - Repository metadata columns. - * - * Testing Approach: Uses result helpers to create test data. Validates - * formatted output strings for lists with varying sizes. - * - * Related Files: - src/commands/repository/output-list-repos.mts - * (implementation) - src/commands/repository/handle-list-repos.mts (handler) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -import type { CResult } from '../../../../src/commands/repository/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -describe('outputListRepos', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result with pagination', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createSuccessResult({ - results: [ - { - archived: false, - default_branch: 'main', - id: 123, - name: 'test-repo', - visibility: 'public', - }, - ], - }) - - await outputListRepos(result, 'json', 1, 2, 'name', 10, 'asc') - - expect(mockSerializeResultJson).toHaveBeenCalledWith({ - ok: true, - data: { - data: result.data, - direction: 'asc', - nextPage: 2, - page: 1, - perPage: 10, - sort: 'name', - }, - }) - expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('ok')) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createErrorResult('Unauthorized', { - cause: 'Invalid API token', - code: 2, - }) - - await outputListRepos( - result, - 'json', - 1, - undefined, - 'created_at', - 25, - 'desc', - ) - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('falls back to exitCode 1 when error result has no code', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - // No code passed → process.exitCode falls back to 1. - const result = { - ok: false as const, - message: 'Some failure', - cause: 'No code provided', - } satisfies CResult<SocketSdkSuccessResult<'listRepositories'>['data']> - - await outputListRepos(result, 'json', 1, undefined, 'name', 10, 'asc') - - expect(process.exitCode).toBe(1) - }) - - it('uses 0 in JSON output when nextPage is null', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result = createSuccessResult({ results: [] }) - - await outputListRepos(result, 'json', 1, undefined, 'name', 10, 'asc') - - expect(mockSerializeResultJson).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ nextPage: 0 }), - }), - ) - }) - - it('outputs text format with repository table', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const repos = [ - { - archived: false, - default_branch: 'main', - id: 456, - name: 'awesome-project', - visibility: 'private', - }, - { - archived: true, - default_branch: 'develop', - id: 789, - name: 'old-project', - visibility: 'public', - }, - ] - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createSuccessResult({ - results: repos, - }) - - await outputListRepos(result, 'text', 2, 3, 'updated_at', 50, 'desc') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Result page: 2, results per page: 50, sorted by: updated_at, direction: desc', - ) - expect(mockChalkTable).toHaveBeenCalledWith( - expect.objectContaining({ - columns: expect.arrayContaining([ - expect.objectContaining({ field: 'id' }), - expect.objectContaining({ field: 'name' }), - expect.objectContaining({ field: 'visibility' }), - expect.objectContaining({ field: 'default_branch' }), - expect.objectContaining({ field: 'archived' }), - ]), - }), - repos, - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'This is page 2. Server indicated there are more results available on page 3...', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - '(Hint: you can use `socket repository list --page 3`)', - ) - }) - - it('outputs error in text format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createErrorResult('Failed to fetch repositories', { - cause: 'Network error', - code: 1, - }) - - await outputListRepos(result, 'text', 1, undefined, 'name', 10, 'asc') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to fetch repositories', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('shows proper message when on last page', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createSuccessResult({ - results: [ - { - archived: false, - default_branch: 'main', - id: 100, - name: 'final-repo', - visibility: 'private', - }, - ], - }) - - await outputListRepos(result, 'text', 5, undefined, 'name', 20, 'asc') - - expect(mockLogger.info).toHaveBeenCalledWith( - 'This is page 5. Server indicated this is the last page with results.', - ) - }) - - it('shows proper message when displaying entire list', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createSuccessResult({ - results: [], - }) - - await outputListRepos( - result, - 'text', - 1, - undefined, - 'name', - Number.POSITIVE_INFINITY, - 'asc', - ) - - expect(mockLogger.info).toHaveBeenCalledWith( - 'This should be the entire list available on the server.', - ) - }) - - it('handles empty repository list', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} rows`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createSuccessResult({ - results: [], - }) - - await outputListRepos(result, 'text', 1, undefined, 'name', 10, 'desc') - - expect(mockChalkTable).toHaveBeenCalledWith(expect.any(Object), []) - }) - - it('sets default exit code when code is undefined', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputListRepos } = - await import('../../../../src/commands/repository/output-list-repos.mts') - - const result: CResult<SocketSdkSuccessResult<'listRepositories'>['data']> = - createErrorResult('Error without code') - - await outputListRepos(result, 'json', 1, undefined, 'name', 10, 'asc') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/output-update-repo.test.mts b/packages/cli/test/unit/commands/repository/output-update-repo.test.mts deleted file mode 100644 index 589d8efc4..000000000 --- a/packages/cli/test/unit/commands/repository/output-update-repo.test.mts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Unit tests for output-update-repo. - * - * Purpose: Tests output formatting for repository update results. Validates - * change summaries and diff formatting for update operations. - * - * Test Coverage: - Successful update output formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Updated field - * highlighting. - * - * Testing Approach: Uses result helpers to create test data. Validates - * formatted output strings showing what changed during updates. - * - * Related Files: - src/commands/repository/output-update-repo.mts - * (implementation) - src/commands/repository/handle-update-repo.mts (handler) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { outputUpdateRepo } from '../../../../src/commands/repository/output-update-repo.mts' - -import type { CResult } from '../../../../src/commands/repository/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: vi.fn(result => JSON.stringify(result)), -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: vi.fn((msg, cause) => `${msg}: ${cause}`), -})) - -describe('outputUpdateRepo', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('outputs JSON format for successful result', async () => { - const { serializeResultJson } = await vi.importMock( - '../../../../src/util/output/result-json.mjs', - ) - const mockSerialize = vi.mocked(serializeResultJson) - - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputUpdateRepo(result, 'test-repo', 'json') - - expect(mockSerialize).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: false, - code: 2, - message: 'Unauthorized', - cause: 'Invalid API token', - } - - outputUpdateRepo(result, 'test-repo', 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs success message for successful update', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputUpdateRepo(result, 'my-repository', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Repository `my-repository` updated successfully', - ) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in text format', async () => { - const { failMsgWithBadge } = await vi.importMock( - '../../../../src/util/error/fail-msg-with-badge.mts', - ) - const mockFailMsg = vi.mocked(failMsgWithBadge) - - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: false, - code: 1, - message: 'Repository not found', - cause: 'Not found error', - } - - outputUpdateRepo(result, 'nonexistent-repo', 'text') - - expect(mockFailMsg).toHaveBeenCalledWith( - 'Repository not found', - 'Not found error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles markdown output format', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputUpdateRepo(result, 'markdown-repo', 'markdown') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Repository `markdown-repo` updated successfully', - ) - }) - - it('handles repository name with special characters', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputUpdateRepo(result, 'repo-with-dashes_and_underscores', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Repository `repo-with-dashes_and_underscores` updated successfully', - ) - }) - - it('handles empty repository name', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: true, - data: { - success: true, - }, - } - - outputUpdateRepo(result, '', 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Repository `` updated successfully', - ) - }) - - it('sets default exit code when code is undefined', async () => { - const result: CResult<SocketSdkSuccessResult<'updateRepository'>['data']> = - { - ok: false, - message: 'Error without code', - } - - outputUpdateRepo(result, 'test-repo', 'json') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/repository/output-view-repo.test.mts b/packages/cli/test/unit/commands/repository/output-view-repo.test.mts deleted file mode 100644 index 6460f6fb2..000000000 --- a/packages/cli/test/unit/commands/repository/output-view-repo.test.mts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Unit tests for output-view-repo. - * - * Purpose: Tests output formatting for detailed repository views. Validates - * comprehensive metadata display across output formats. - * - * Test Coverage: - Detailed repository information formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Metadata field - * display. - * - * Testing Approach: Uses result helpers to create test data. Validates - * formatted output strings for detailed repository views. - * - * Related Files: - src/commands/repository/output-view-repo.mts - * (implementation) - src/commands/repository/handle-view-repo.mts (handler) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -import type { CResult } from '../../../../src/commands/repository/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -describe('outputViewRepo', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('outputs JSON format for successful result', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createSuccessResult({ - archived: false, - created_at: '2024-01-01T00:00:00Z', - default_branch: 'main', - homepage: 'https://example.com', - id: 123, - name: 'test-repo', - visibility: 'public', - }) - - await outputViewRepo(result, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createErrorResult('Unauthorized', { - cause: 'Invalid API token', - code: 2, - }) - - await outputViewRepo(result, 'json') - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs repository table in text format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} row(s)`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const repoData = { - archived: true, - created_at: '2023-05-15T10:30:00Z', - default_branch: 'develop', - homepage: 'https://my-project.com', - id: 456, - name: 'awesome-repo', - visibility: 'private', - } - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createSuccessResult(repoData) - - await outputViewRepo(result, 'text') - - expect(mockChalkTable).toHaveBeenCalledWith( - expect.objectContaining({ - columns: expect.arrayContaining([ - expect.objectContaining({ field: 'id' }), - expect.objectContaining({ field: 'name' }), - expect.objectContaining({ field: 'visibility' }), - expect.objectContaining({ field: 'default_branch' }), - expect.objectContaining({ field: 'homepage' }), - expect.objectContaining({ field: 'archived' }), - expect.objectContaining({ field: 'created_at' }), - ]), - }), - [repoData], - ) - expect(mockLogger.log).toHaveBeenCalledWith('Table with 1 row(s)') - }) - - it('outputs error in text format', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockFailMsgWithBadge = vi.fn((msg, cause) => `${msg}: ${cause}`) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createErrorResult('Repository not found', { - cause: 'Not found error', - code: 1, - }) - - await outputViewRepo(result, 'text') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Repository not found', - 'Not found error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles repository with null homepage', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} row(s)`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const repoData = { - archived: false, - created_at: '2024-02-20T14:45:30Z', - default_branch: 'main', - homepage: undefined, - id: 789, - name: 'no-homepage-repo', - visibility: 'public', - } - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createSuccessResult(repoData) - - await outputViewRepo(result, 'text') - - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('handles repository with empty name', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} row(s)`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const repoData = { - archived: false, - created_at: '2024-01-01T00:00:00Z', - default_branch: 'main', - homepage: '', - id: 1, - name: '', - visibility: 'public', - } - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createSuccessResult(repoData) - - await outputViewRepo(result, 'markdown') - - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('handles very long repository data', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockChalkTable = vi.fn( - (_options, data) => `Table with ${data.length} row(s)`, - ) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('chalk-table', () => ({ - default: mockChalkTable, - })) - - vi.doMock('yoctocolors-cjs', () => ({ - default: { - magenta: vi.fn(text => text), - }, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const repoData = { - archived: false, - created_at: '2024-12-01T09:15:22Z', - default_branch: - 'feature/very-long-branch-name-that-exceeds-normal-length', - homepage: - 'https://very-long-domain-name-that-might-cause-display-issues.example.com/path', - id: 999_999, - name: 'repository-with-a-very-long-name-that-might-cause-table-formatting-issues', - visibility: 'internal', - } - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createSuccessResult(repoData) - - await outputViewRepo(result, 'text') - - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('sets default exit code when code is undefined', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - const result: CResult<SocketSdkSuccessResult<'createRepository'>['data']> = - createErrorResult('Error without code') - - await outputViewRepo(result, 'json') - - expect(process.exitCode).toBe(1) - }) - - it('falls back to exitCode 1 when result.code is undefined', async () => { - const mockLogger = { - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - const mockSerializeResultJson = vi.fn(result => JSON.stringify(result)) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - })) - - vi.doMock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: mockSerializeResultJson, - })) - - const { outputViewRepo } = - await import('../../../../src/commands/repository/output-view-repo.mts') - - // Construct an error result without a code field. The helper always sets - // code; manual construction is needed to trigger the `?? 1` fallback. - const result = { - ok: false as const, - message: 'No code', - cause: 'no code provided', - } satisfies CResult<SocketSdkSuccessResult<'createRepository'>['data']> - - await outputViewRepo(result, 'json') - - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts deleted file mode 100644 index 526fec4f1..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts +++ /dev/null @@ -1,1743 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for scan create command. - * - * Tests the command that creates new Socket scans. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mts' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleCreateNewScan = vi.hoisted(() => vi.fn()) -const mockOutputCreateNewScan = vi.hoisted(() => vi.fn()) -const mockSuggestOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue('test-org'), -) -const mockSuggestTarget = vi.hoisted(() => vi.fn().mockResolvedValue(['.'])) -const mockValidateReachabilityTarget = vi.hoisted(() => - vi.fn().mockResolvedValue({ - isDirectory: true, - isInsideCwd: true, - isValid: true, - targetExists: true, - }), -) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) -const mockReadOrDefaultSocketJsonUp = vi.hoisted(() => - vi.fn().mockResolvedValue({}), -) -const mockDetectManifestActions = vi.hoisted(() => - vi.fn().mockResolvedValue({ count: 0 }), -) -const mockGitBranch = vi.hoisted(() => vi.fn().mockResolvedValue('')) -const mockDetectDefaultBranch = vi.hoisted(() => - vi.fn().mockResolvedValue('main'), -) -const mockGetRepoName = vi.hoisted(() => vi.fn().mockResolvedValue('test-repo')) - -vi.mock('../../../../src/commands/scan/handle-create-new-scan.mts', () => ({ - handleCreateNewScan: mockHandleCreateNewScan, -})) - -vi.mock('../../../../src/commands/scan/output-create-new-scan.mts', () => ({ - outputCreateNewScan: mockOutputCreateNewScan, -})) - -vi.mock('../../../../src/commands/scan/suggest-org-slug.mts', () => ({ - suggestOrgSlug: mockSuggestOrgSlug, -})) - -vi.mock('../../../../src/commands/scan/suggest_target.mts', () => ({ - suggestTarget: mockSuggestTarget, -})) - -vi.mock( - '../../../../src/commands/scan/validate-reachability-target.mts', - () => ({ - validateReachabilityTarget: mockValidateReachabilityTarget, - }), -) - -vi.mock('../../../../src/util/socket/org-slug.mts', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJsonUp: mockReadOrDefaultSocketJsonUp, -})) - -vi.mock( - '../../../../src/commands/manifest/detect-manifest-actions.mts', - () => ({ - detectManifestActions: mockDetectManifestActions, - }), -) - -vi.mock('../../../../src/util/git/operations.mts', () => ({ - detectDefaultBranch: mockDetectDefaultBranch, - getRepoName: mockGetRepoName, - gitBranch: mockGitBranch, -})) - -// Import after mocks. -const { cmdScanCreate } = - await import('../../../../src/commands/scan/cmd-scan-create.mts') - -describe('cmd-scan-create', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanCreate.description).toBe( - 'Create a new Socket scan and report', - ) - }) - - it('should not be hidden', () => { - expect(cmdScanCreate.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-create.mts' } - const context = { parentName: 'socket scan' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--dry-run', '--org', 'test-org', '.'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should call handleCreateNewScan with valid inputs', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - autoManifest: false, - basics: false, - branchName: 'main', - cwd: expect.any(String), - defaultBranch: false, - interactive: false, - orgSlug: 'test-org', - outputKind: 'text', - pendingHead: true, - repoName: 'test-repo', - report: false, - tmp: false, - }), - ) - }) - - it('should pass --org flag to handleCreateNewScan', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'custom-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should pass --repo flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--repo', 'my-repo', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'my-repo', - }), - ) - }) - - it('should pass --branch flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--branch', 'develop', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'develop', - }), - ) - }) - - it('should enable reachability analysis with --reach flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--reach', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - runReachabilityAnalysis: true, - }), - }), - ) - }) - - it('should validate target when --reach is enabled', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: false, - isInsideCwd: true, - isValid: true, - targetExists: true, - }) - - await cmdScanCreate.run( - ['--org', 'test-org', '--reach', './package.json', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should fail when --reach target does not exist', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: true, - isValid: true, - targetExists: false, - }) - - await cmdScanCreate.run( - ['--org', 'test-org', '--reach', './nonexistent', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should fail when --reach target is outside cwd', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: false, - isValid: true, - targetExists: true, - }) - - await cmdScanCreate.run( - ['--org', 'test-org', '--reach', '../outside', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should pass reachability options when --reach is enabled', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-ecosystems', - 'npm,pypi', - '--reach-concurrency', - '4', - '--reach-debug', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - runReachabilityAnalysis: true, - reachConcurrency: 4, - reachDebug: true, - reachEcosystems: ['npm', 'pypi'], - }), - }), - ) - }) - - it('should fail if reachability flags used without --reach', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach-concurrency', - '4', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--json', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--markdown', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail when both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--json', '--markdown', '.', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should pass --default-branch flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: true, - branchName: 'main', - }), - ) - }) - - it('should fail when --default-branch is set without --branch', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockGitBranch.mockResolvedValueOnce('') - mockDetectDefaultBranch.mockResolvedValueOnce('') - - await cmdScanCreate.run( - ['--org', 'test-org', '--default-branch', '.', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - describe('--tmp flag', () => { - it('should pass --tmp flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--tmp', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - tmp: true, - pendingHead: false, - }), - ) - }) - - it('should support -t short flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '-t', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - tmp: true, - pendingHead: false, - }), - ) - }) - - it('should force pendingHead=false even when --set-as-alerts-page is set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--tmp', - '--set-as-alerts-page', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - // --tmp overrides --set-as-alerts-page. - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - tmp: true, - pendingHead: false, - }), - ) - }) - - it('should support explicit --no-tmp', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--no-tmp', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - tmp: false, - pendingHead: true, - }), - ) - }) - - it('should default tmp to false when not specified', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - tmp: false, - }), - ) - }) - }) - - it('should use socket.json defaults for branch', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - branch: 'develop', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'develop', - }), - ) - }) - - it('should use socket.json defaults for repo', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - repo: 'my-project', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - repoName: 'my-project', - }), - ) - }) - - it('should use socket.json defaults for autoManifest', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - autoManifest: true, - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - autoManifest: true, - }), - ) - }) - - it('should use socket.json defaults for report', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - report: true, - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - report: true, - }), - ) - }) - - it('should use socket.json defaults for workspace', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - workspace: 'my-workspace', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - workspace: 'my-workspace', - }), - ) - }) - - it('should pass --workspace flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--workspace', - 'cli-workspace', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - workspace: 'cli-workspace', - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJsonUp.mockResolvedValueOnce({ - defaults: { - scan: { - create: { - branch: 'main', - repo: 'default-repo', - autoManifest: true, - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'develop', - '--repo', - 'cli-repo', - '--no-auto-manifest', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'develop', - repoName: 'cli-repo', - autoManifest: false, - }), - ) - }) - - it('should validate invalid ecosystem value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-ecosystems', - 'invalid-ecosystem', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow(/--reach-ecosystems must be one of/) - }) - - it('should pass --commit-hash flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--commit-hash', - 'abc123', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - commitHash: 'abc123', - }), - ) - }) - - it('should pass --commit-message flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--commit-message', - 'fix: bug', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - commitMessage: 'fix: bug', - }), - ) - }) - - it('should pass --pull-request flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--pull-request', '123', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - pullRequest: 123, - }), - ) - }) - - it('should pass --basics flag to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--basics', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - basics: true, - }), - ) - }) - - it('should default to current directory if no target specified', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - targets: [expect.any(String)], - }), - ) - }) - - describe('numeric flag validation', () => { - it('should validate --reach-analysis-memory-limit is a number', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-analysis-memory-limit', - 'invalid', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow( - /--reach-analysis-memory-limit must be a number of megabytes/, - ) - }) - - it('should validate --reach-analysis-timeout is a number', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-analysis-timeout', - 'invalid', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow( - /--reach-analysis-timeout must be a number of seconds/, - ) - }) - - it('should validate --reach-concurrency is a number', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-concurrency', - 'invalid', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow(/--reach-concurrency must be a positive integer/) - }) - }) - - describe('interactive mode and suggestions', () => { - it('should suggest org when interactive and no org set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockSuggestOrgSlug.mockResolvedValueOnce('suggested-org') - - await cmdScanCreate.run(['.', '--interactive'], importMeta, context) - - expect(mockSuggestOrgSlug).toHaveBeenCalled() - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'suggested-org', - }), - ) - }) - - it('should output error when org suggestion is canceled', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockSuggestOrgSlug.mockResolvedValueOnce(undefined) - - await cmdScanCreate.run(['.', '--interactive'], importMeta, context) - - expect(mockOutputCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Canceled by user', - }), - expect.any(Object), - ) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should show manifest detection info when count > 0', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockDetectManifestActions.mockResolvedValueOnce({ count: 3 }) - - await cmdScanCreate.run( - ['--org', 'test-org', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Detected 3 manifest targets'), - ) - }) - - it('should suggest targets when interactive with no targets', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run(['--org', 'test-org'], importMeta, context) - - // With interactive true and no targets, defaults to cwd. - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - targets: expect.any(Array), - }), - ) - }) - }) - - describe('--cwd flag', () => { - it('should use custom cwd when provided', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--cwd', - '/tmp/project', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining('/tmp/project'), - }), - ) - }) - }) - - describe('--read-only flag', () => { - it('should pass readOnly flag to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--org', 'test-org', '--read-only', '.', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - readOnly: true, - }), - ) - }) - }) - - describe('--report-level flag', () => { - it('should pass reportLevel flag to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--report-level', - 'warn', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reportLevel: 'warn', - }), - ) - }) - }) - - describe('--committers flag', () => { - it('should pass committers flag to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--committers', - 'user@example.com', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - committers: 'user@example.com', - }), - ) - }) - }) - - describe('dry-run with details', () => { - it('should include repo and branch in dry-run output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--dry-run', - '--org', - 'test-org', - '--repo', - 'my-repo', - '--branch', - 'develop', - '.', - ], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should include reach info in dry-run output when --reach enabled', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - ['--dry-run', '--org', 'test-org', '--reach', '.'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should include ecosystems in dry-run output when --reach-ecosystems set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-ecosystems', - 'npm,pypi', - '--dry-run', - '.', - ], - importMeta, - context, - ) - - const errors = mockLogger.error.mock.calls.flat().join(' ') - expect(errors).toContain('ecosystems') - expect(errors).toContain('npm') - }) - }) - - describe('reachability options', () => { - it('should pass --reach-exclude-paths to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-exclude-paths', - 'node_modules,dist', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachExcludePaths: ['node_modules', 'dist'], - }), - }), - ) - }) - - it('should pass --reach-lazy-mode to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-lazy-mode', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachLazyMode: true, - }), - }), - ) - }) - - it('should pass --reach-skip-cache to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-skip-cache', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachSkipCache: true, - }), - }), - ) - }) - - it('should pass --reach-enable-analysis-splitting to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-enable-analysis-splitting', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachEnableAnalysisSplitting: true, - }), - }), - ) - }) - - it('should pass --reach-disable-analytics to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-disable-analytics', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachDisableAnalytics: true, - }), - }), - ) - }) - - it('should pass --reach-min-severity to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-min-severity', - 'high', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachMinSeverity: 'high', - }), - }), - ) - }) - - it('should pass --reach-use-only-pregenerated-sboms to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-use-only-pregenerated-sboms', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachUseOnlyPregeneratedSboms: true, - }), - }), - ) - }) - - it('should pass --reach-use-unreachable-from-precomputation to handler', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--reach', - '--reach-use-unreachable-from-precomputation', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - reach: expect.objectContaining({ - reachUseUnreachableFromPrecomputation: true, - }), - }), - ) - }) - }) - - describe('--set-as-alerts-page flag', () => { - it('should pass setAsAlertsPage=false with --no-set-as-alerts-page', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--no-set-as-alerts-page', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - pendingHead: false, - }), - ) - }) - }) - - describe('validation edge cases', () => { - it('should fail when --pending-head is set without --branch', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockGitBranch.mockResolvedValueOnce('') - mockDetectDefaultBranch.mockResolvedValueOnce('') - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--set-as-alerts-page', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - - it('should fail when target not valid for reachability', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: true, - isValid: false, - targetExists: true, - }) - - await cmdScanCreate.run( - ['--org', 'test-org', '--reach', '.', 'other', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - }) - }) - - describe('--default-branch misuse detection', () => { - it('fails when --default-branch=<name> is passed with a branch name', async () => { - await cmdScanCreate.run( - ['--org', 'test-org', '--default-branch=main', '.'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining( - '"--default-branch=main" looks like you meant to name the branch "main"', - ), - ) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('--branch main --make-default-branch'), - ) - }) - - it('also catches the camelCase --defaultBranch=<name> variant', async () => { - await cmdScanCreate.run( - ['--org', 'test-org', '--defaultBranch=main', '.'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining( - 'looks like you meant to name the branch "main"', - ), - ) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('"--defaultBranch=main"'), - ) - }) - - it('catches the legacy space-separated --default-branch <name> form', async () => { - await cmdScanCreate.run( - ['--org', 'test-org', '--default-branch', 'main', '.'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining( - '"--default-branch main" looks like you meant to name the branch "main"', - ), - ) - }) - - it('leaves the space-separated form alone when --branch is also passed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.fail).not.toHaveBeenCalledWith( - expect.stringContaining('looks like you meant'), - ) - }) - - it('does not misfire when the next token looks like a target path', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - // `./some/dir` has path separators, so it is a positional target, - // not a mistyped branch name. - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--default-branch', - './some/dir', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.fail).not.toHaveBeenCalledWith( - expect.stringContaining('looks like you meant'), - ) - }) - - it.each([ - '--default-branch=true', - '--default-branch=false', - '--default-branch=TRUE', - ])('allows %s (explicit boolean form)', async arg => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - arg, - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.fail).not.toHaveBeenCalledWith( - expect.stringContaining('looks like you meant the branch name'), - ) - }) - - it('allows bare --default-branch (default truthy form)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.fail).not.toHaveBeenCalledWith( - expect.stringContaining('looks like you meant the branch name'), - ) - }) - - it('catches --make-default-branch=<name> misuse on the primary flag', async () => { - await cmdScanCreate.run( - ['--org', 'test-org', '--make-default-branch=main', '.'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleCreateNewScan).not.toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining( - '"--make-default-branch=main" looks like you meant to name the branch "main"', - ), - ) - }) - }) - - describe('--make-default-branch primary flag', () => { - it('passes --make-default-branch through to handleCreateNewScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--make-default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: true, - branchName: 'main', - }), - ) - }) - - it('does not emit the deprecation warning for the primary name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--make-default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.warn).not.toHaveBeenCalledWith( - expect.stringContaining('--default-branch is deprecated'), - ) - }) - }) - - describe('--default-branch deprecation warning', () => { - it('warns when the legacy --default-branch flag is used', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('--default-branch is deprecated'), - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('use --make-default-branch'), - ) - }) - - it('warns on the legacy camelCase --defaultBranch name', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--defaultBranch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('--default-branch is deprecated'), - ) - }) - - it('still wires the legacy flag through to handleCreateNewScan (back-compat)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanCreate.run( - [ - '--org', - 'test-org', - '--branch', - 'main', - '--default-branch', - '.', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - defaultBranch: true, - branchName: 'main', - }), - ) - }) - }) - - describe('input + flag validation edge cases', () => { - it('throws InputError for non-numeric --pull-request', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--pull-request', - 'not-a-number', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow(/--pull-request must be a non-negative integer/) - }) - - it('throws InputError for negative --pull-request', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanCreate.run( - [ - '--org', - 'test-org', - '--pull-request', - '-5', - '.', - '--no-interactive', - ], - importMeta, - context, - ), - ).rejects.toThrow(/--pull-request must be a non-negative integer/) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-del.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-del.test.mts deleted file mode 100644 index 06b3182b4..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-del.test.mts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * Unit tests for scan delete command. - * - * Tests the command that deletes a scan. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleDeleteScan = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/scan/handle-delete-scan.mts', () => ({ - handleDeleteScan: mockHandleDeleteScan, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanDel } = - await import('../../../../src/commands/scan/cmd-scan-del.mts') - -describe('cmd-scan-del', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanDel.description).toBe('Delete a scan') - }) - - it('should not be hidden', () => { - expect(cmdScanDel.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-del.mts' } - const context = { parentName: 'socket scan' } - const testScanId = '000aaaa1-0000-0a0a-00a0-00a0000000a0' - - it('should support --dry-run flag', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - }) - - it('should fail without scan ID', async () => { - await cmdScanDel.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - }) - - it('should call handleDeleteScan with scan ID', async () => { - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - - await cmdScanDel.run( - [testScanId, '--org', 'custom-org'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'custom-org', - testScanId, - 'text', - ) - }) - - it('should support --json output mode', async () => { - await cmdScanDel.run([testScanId, '--json'], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'json', - ) - }) - - it('should support --markdown output mode', async () => { - await cmdScanDel.run([testScanId, '--markdown'], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'markdown', - ) - }) - - it('should pass --no-interactive to determineOrgSlug', async () => { - await cmdScanDel.run( - [testScanId, '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should fail if org slug cannot be determined', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - }) - - it('should show scan ID in dry-run', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId), - ) - }) - - it('should show organization in dry-run', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org'), - ) - }) - - it('should show delete operation in dry-run', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringMatching(/delet/i), - ) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should default interactive to true', async () => { - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should handle empty org flag as empty string', async () => { - await cmdScanDel.run([testScanId, '--org', ''], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should pass correct command path context to handler', async () => { - mockHandleDeleteScan.mockImplementationOnce( - async (orgSlug, scanId, outputKind) => { - expect(orgSlug).toBe('test-org') - expect(scanId).toBe(testScanId) - expect(outputKind).toBe('text') - }, - ) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledTimes(1) - }) - - it('should support all common flags', async () => { - await cmdScanDel.run( - [testScanId, '--config', 'custom-config.json', '--no-spinner'], - importMeta, - context, - ) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'text', - ) - }) - - it('should call handler when all validations pass', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['valid-org', 'valid-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledTimes(1) - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'valid-org', - testScanId, - 'text', - ) - }) - - it('should not call handler in dry-run mode', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('should use defaultOrgSlug nook behavior', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', 'default-org']) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDeleteScan).not.toHaveBeenCalled() - }) - - it('should validate org slug even with default org', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['my-org', '']) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'my-org', - testScanId, - 'text', - ) - }) - - it('should format dry-run output with org/scan path', async () => { - await cmdScanDel.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringMatching(/test-org/), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId), - ) - }) - - it('should handle scan ID with different UUID format', async () => { - const anotherScanId = '12345678-1234-5678-1234-567812345678' - await cmdScanDel.run([anotherScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'test-org', - anotherScanId, - 'text', - ) - }) - - it('should handle org slug with special characters', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce([ - 'org-with-dashes', - 'org-with-dashes', - ]) - - await cmdScanDel.run( - [testScanId, '--org', 'org-with-dashes'], - importMeta, - context, - ) - - expect(mockHandleDeleteScan).toHaveBeenCalledWith( - 'org-with-dashes', - testScanId, - 'text', - ) - }) - - it('should respect interactive flag in determineOrgSlug', async () => { - await cmdScanDel.run([testScanId, '--interactive'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should not show API token error when token exists', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanDel.run([testScanId], importMeta, context) - - expect(mockHandleDeleteScan).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-diff.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-diff.test.mts deleted file mode 100644 index 21d4e115c..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-diff.test.mts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Unit tests for scan diff command. - * - * Tests the command that shows differences between two scans. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleDiffScan = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/scan/handle-diff-scan.mts', () => ({ - handleDiffScan: mockHandleDiffScan, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanDiff } = - await import('../../../../src/commands/scan/cmd-scan-diff.mts') - -describe('cmd-scan-diff', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanDiff.description).toBe('See what changed between two Scans') - }) - - it('should not be hidden', () => { - expect(cmdScanDiff.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-diff.mts' } - const context = { parentName: 'socket scan' } - const testScanId1 = 'aaa0aa0a-aaaa-0000-0a0a-0000000a00a0' - const testScanId2 = 'aaa1aa1a-aaaa-1111-1a1a-1111111a11a1' - - it('should support --dry-run flag', async () => { - await cmdScanDiff.run( - ['--dry-run', testScanId1, testScanId2], - importMeta, - context, - ) - - expect(mockHandleDiffScan).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanDiff.run([testScanId1, testScanId2], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDiffScan).not.toHaveBeenCalled() - }) - - it('should fail without both scan IDs', async () => { - await cmdScanDiff.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDiffScan).not.toHaveBeenCalled() - }) - - it('should fail with only one scan ID', async () => { - await cmdScanDiff.run([testScanId1], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDiffScan).not.toHaveBeenCalled() - }) - - it('should call handleDiffScan with two scan IDs', async () => { - await cmdScanDiff.run([testScanId1, testScanId2], importMeta, context) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - id1: testScanId1, - id2: testScanId2, - depth: 2, - orgSlug: 'test-org', - outputKind: 'text', - file: '', - }), - ) - }) - - it('should pass --depth flag to handleDiffScan', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--depth', '5'], - importMeta, - context, - ) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - depth: 5, - }), - ) - }) - - it('should pass --file flag to handleDiffScan', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--file', './diff.json'], - importMeta, - context, - ) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - file: './diff.json', - }), - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - - await cmdScanDiff.run( - [testScanId1, testScanId2, '--org', 'custom-org'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should support --json output mode', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--json'], - importMeta, - context, - ) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--markdown'], - importMeta, - context, - ) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail if both --json and --markdown are provided', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--json', '--markdown'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleDiffScan).not.toHaveBeenCalled() - }) - - it('should pass --no-interactive to determineOrgSlug', async () => { - await cmdScanDiff.run( - [testScanId1, testScanId2, '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should fail if org slug cannot be determined', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - - await cmdScanDiff.run([testScanId1, testScanId2], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleDiffScan).not.toHaveBeenCalled() - }) - - it('should extract scan ID from full Socket URL', async () => { - const socketUrl1 = `https://socket.dev/dashboard/org/SocketDev/sbom/${testScanId1}` - const socketUrl2 = `https://socket.dev/dashboard/org/SocketDev/sbom/${testScanId2}` - - await cmdScanDiff.run([socketUrl1, socketUrl2], importMeta, context) - - expect(mockHandleDiffScan).toHaveBeenCalledWith( - expect.objectContaining({ - id1: testScanId1, - id2: testScanId2, - }), - ) - }) - - it('should show both scan IDs in dry-run', async () => { - await cmdScanDiff.run( - ['--dry-run', testScanId1, testScanId2], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId1), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId2), - ) - }) - - it('should show depth in dry-run', async () => { - await cmdScanDiff.run( - ['--dry-run', testScanId1, testScanId2, '--depth', '10'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('10'), - ) - }) - - it('should show organization in dry-run', async () => { - await cmdScanDiff.run( - ['--dry-run', testScanId1, testScanId2], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-github.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-github.test.mts deleted file mode 100644 index 5349471c2..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-github.test.mts +++ /dev/null @@ -1,537 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for scan github command. - * - * Tests the command that creates scans for GitHub repositories. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' -import type * as SocketCliModule from '@socketsecurity/lib-stable/env/socket-cli' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock environment functions. -vi.mock('@socketsecurity/lib-stable/env/socket-cli', async importOriginal => { - const actual = await importOriginal<typeof SocketCliModule>() - return { - ...actual, - getSocketCliGithubToken: vi.fn().mockReturnValue(''), - } -}) - -// Mock dependencies. -const mockHandleCreateGithubScan = vi.hoisted(() => vi.fn()) -const mockOutputScanGithub = vi.hoisted(() => vi.fn()) -const mockSuggestOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue('test-org'), -) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) -const mockReadOrDefaultSocketJson = vi.hoisted(() => - vi.fn().mockReturnValue({}), -) - -vi.mock('../../../../src/commands/scan/handle-create-github-scan.mts', () => ({ - handleCreateGithubScan: mockHandleCreateGithubScan, -})) - -vi.mock('../../../../src/commands/scan/output-scan-github.mts', () => ({ - outputScanGithub: mockOutputScanGithub, -})) - -vi.mock('../../../../src/commands/scan/suggest-org-slug.mts', () => ({ - suggestOrgSlug: mockSuggestOrgSlug, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) - -const mockOutputDryRunUpload = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunUpload: mockOutputDryRunUpload, -})) - -// Import after mocks. -const { cmdScanGithub } = - await import('../../../../src/commands/scan/cmd-scan-github.mts') - -describe('cmd-scan-github', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanGithub.description).toBe( - 'Create a scan for given GitHub repo', - ) - }) - - it('should be hidden', () => { - expect(cmdScanGithub.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-github.mts' } - const context = { parentName: 'socket scan' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--dry-run', '--github-token', 'test-token'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).not.toHaveBeenCalled() - // Now that outputDryRunUpload is mocked, assert on it directly. - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'GitHub scan', - expect.any(Object), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateGithubScan).not.toHaveBeenCalled() - }) - - it('should fail without GitHub token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run(['--no-interactive'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleCreateGithubScan).not.toHaveBeenCalled() - }) - - it('should call handleCreateGithubScan with valid tokens', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - all: false, - githubApiUrl: 'https://api.github.com', - githubToken: 'test-token', - interactive: false, - orgSlug: 'test-org', - orgGithub: 'test-org', - outputKind: 'text', - repos: '', - }), - ) - }) - - it('should pass --all flag to handleCreateGithubScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--all', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - all: true, - }), - ) - }) - - it('should pass --repos flag to handleCreateGithubScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--repos', - 'repo1,repo2', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - repos: 'repo1,repo2', - }), - ) - }) - - it('should pass --org flag to handleCreateGithubScan', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--org', - 'custom-org', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - false, - false, - ) - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should pass --org-github flag to handleCreateGithubScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--org-github', - 'github-org', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgGithub: 'github-org', - }), - ) - }) - - it('should pass --github-api-url flag to handleCreateGithubScan', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--github-api-url', - 'https://custom.github.com', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - githubApiUrl: 'https://custom.github.com', - }), - ) - }) - - it('should use socket.json defaults for all flag', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - scan: { - github: { - all: true, - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - all: true, - }), - ) - }) - - it('should use socket.json defaults for repos', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - scan: { - github: { - repos: 'default-repo1,default-repo2', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - repos: 'default-repo1,default-repo2', - }), - ) - }) - - it('should use socket.json defaults for orgGithub', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - scan: { - github: { - orgGithub: 'default-github-org', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgGithub: 'default-github-org', - }), - ) - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--json', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--markdown', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should override socket.json defaults with CLI flags', async () => { - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - scan: { - github: { - all: true, - repos: 'default-repo', - githubApiUrl: 'https://default.github.com', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--no-all', - '--repos', - 'cli-repo', - '--github-api-url', - 'https://cli.github.com', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - all: false, - repos: 'cli-repo', - githubApiUrl: 'https://cli.github.com', - }), - ) - }) - - it('cancels when interactive org selector returns undefined', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockSuggestOrgSlug.mockResolvedValueOnce(undefined) - - await cmdScanGithub.run(['--github-token', 'gh_xxx'], importMeta, context) - - expect(mockOutputScanGithub).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Canceled by user', - }), - expect.any(String), - ) - expect(mockHandleCreateGithubScan).not.toHaveBeenCalled() - }) - - it('uses suggested orgSlug when interactive selector returns one', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockSuggestOrgSlug.mockResolvedValueOnce('suggested-org') - - await cmdScanGithub.run(['--github-token', 'gh_xxx'], importMeta, context) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ orgSlug: 'suggested-org' }), - ) - }) - - it('uses sockJson defaults for orgGithub and repos when CLI flags absent', async () => { - // Note: githubApiUrl has a flag-level default (DEFAULT_GITHUB_URL), - // so the sockJson default branch for it doesn't fire here unless the - // flag's default is also empty. - mockReadOrDefaultSocketJson.mockReturnValueOnce({ - defaults: { - scan: { - github: { - orgGithub: 'default-org-github', - repos: 'default-repos', - }, - }, - }, - }) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - ['--github-token', 'test-token', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleCreateGithubScan).toHaveBeenCalledWith( - expect.objectContaining({ - orgGithub: 'default-org-github', - repos: 'default-repos', - }), - ) - }) - - it('outputs dry-run details for --all (scope: all repositories)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--all', - '--dry-run', - '--no-interactive', - ], - importMeta, - context, - ) - - // Dry-run should call outputDryRunUpload with details containing 'scope'. - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'GitHub scan', - expect.objectContaining({ scope: 'all repositories' }), - ) - expect(mockHandleCreateGithubScan).not.toHaveBeenCalled() - }) - - it('outputs dry-run details for --repos (repositories list)', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanGithub.run( - [ - '--github-token', - 'test-token', - '--repos', - 'foo,bar', - '--dry-run', - '--no-interactive', - ], - importMeta, - context, - ) - - expect(mockOutputDryRunUpload).toHaveBeenCalledWith( - 'GitHub scan', - expect.objectContaining({ repositories: 'foo,bar' }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-list.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-list.test.mts deleted file mode 100644 index cd32728dd..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-list.test.mts +++ /dev/null @@ -1,511 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for scan list command. - * - * Tests the command that lists scans for an organization. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleListScans = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/scan/handle-list-scans.mts', () => ({ - handleListScans: mockHandleListScans, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanList } = - await import('../../../../src/commands/scan/cmd-scan-list.mts') - -describe('cmd-scan-list', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanList.description).toBe('List the scans for an organization') - }) - - it('should not be hidden', () => { - expect(cmdScanList.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-list.mts' } - const context = { parentName: 'socket scan' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--dry-run', '--org', 'test-org'], - importMeta, - context, - ) - - expect(mockHandleListScans).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should show query parameters in --dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - [ - '--dry-run', - '--org', - 'test-org', - '--page', - '2', - '--per-page', - '50', - '--sort', - 'name', - '--direction', - 'asc', - ], - importMeta, - context, - ) - - expect(mockHandleListScans).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Query parameters'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('page: 2'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('perPage: 50'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanList.run(['--org', 'test-org'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListScans).not.toHaveBeenCalled() - }) - - it('should call handleListScans with valid inputs', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run(['--org', 'test-org'], importMeta, context) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - branch: '', - direction: 'desc', - from_time: '', - orgSlug: 'test-org', - outputKind: 'text', - page: 1, - perPage: 30, - repo: '', - sort: 'created_at', - }), - ) - }) - - it('should pass --org flag to handleListScans', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run(['--org', 'custom-org'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should pass --page flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--page', '3'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - page: 3, - }), - ) - }) - - it('should pass --per-page flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--per-page', '50'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - perPage: 50, - }), - ) - }) - - it('should pass --sort flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--sort', 'name'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - sort: 'name', - }), - ) - }) - - it('should pass --direction flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--direction', 'asc'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - direction: 'asc', - }), - ) - }) - - it('should pass --branch flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--branch', 'develop'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - branch: 'develop', - }), - ) - }) - - it('should pass --from-time flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--from-time', '1234567890'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - from_time: '1234567890', - }), - ) - }) - - it('should pass --until-time flag to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--until-time', '9876543210'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalled() - }) - - it('should accept repo as positional argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', 'my-repo'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - repo: 'my-repo', - }), - ) - }) - - it('should accept branch as second positional argument', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', 'my-repo', 'develop'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - repo: 'my-repo', - branch: 'develop', - }), - ) - }) - - it('should fail when both --branch flag and branch argument are provided', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--branch', 'main', 'my-repo', 'develop'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListScans).not.toHaveBeenCalled() - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--json'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--markdown'], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail when both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--json', '--markdown'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleListScans).not.toHaveBeenCalled() - }) - - it('should use default pagination values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run(['--org', 'test-org'], importMeta, context) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - perPage: 30, - }), - ) - }) - - it('should use default sort and direction', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run(['--org', 'test-org'], importMeta, context) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - sort: 'created_at', - direction: 'desc', - }), - ) - }) - - it('should combine multiple filter flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - [ - '--org', - 'test-org', - 'my-repo', - '--branch', - 'main', - '--page', - '2', - '--per-page', - '25', - '--sort', - 'created_at', - '--direction', - 'asc', - ], - importMeta, - context, - ) - - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - repo: 'my-repo', - branch: 'main', - page: 2, - perPage: 25, - sort: 'created_at', - direction: 'asc', - }), - ) - }) - - it('should support --no-interactive flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'test-org', - false, - false, - ) - expect(mockHandleListScans).toHaveBeenCalled() - }) - - it('throws InputError when --page is not a positive integer', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanList.run( - ['--org', 'test-org', '--page', 'not-a-number'], - importMeta, - context, - ), - ).rejects.toThrow(/--page must be a positive integer/) - }) - - it('throws InputError when --per-page is not a positive integer', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanList.run( - ['--org', 'test-org', '--per-page', 'oops'], - importMeta, - context, - ), - ).rejects.toThrow(/--per-page must be a positive integer/) - }) - - it('passes flag-default direction and sort to handleListScans', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--no-interactive'], - importMeta, - context, - ) - - // The CLI flag schema sets defaults (direction=desc, sort=created_at). - expect(mockHandleListScans).toHaveBeenCalledWith( - expect.objectContaining({ - direction: 'desc', - sort: 'created_at', - }), - ) - }) - - it('uses default sort=created_at and direction=desc in dry-run output', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanList.run( - ['--org', 'test-org', '--dry-run', '--no-interactive'], - importMeta, - context, - ) - - expect(mockHandleListScans).not.toHaveBeenCalled() - // Defaults should appear in the dry-run output via outputDryRunFetch. - const errors = mockLogger.error.mock.calls.flat().join(' ') - expect(errors).toContain('created_at') - expect(errors).toContain('desc') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-metadata.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-metadata.test.mts deleted file mode 100644 index 0f91f58e9..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-metadata.test.mts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Unit tests for scan metadata command. - * - * Tests the command that retrieves scan metadata. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleOrgScanMetadata = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/scan/handle-scan-metadata.mts', () => ({ - handleOrgScanMetadata: mockHandleOrgScanMetadata, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanMetadata } = - await import('../../../../src/commands/scan/cmd-scan-metadata.mts') - -describe('cmd-scan-metadata', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanMetadata.description).toBe("Get a scan's metadata") - }) - - it('should not be hidden', () => { - expect(cmdScanMetadata.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-metadata.mts' } - const context = { parentName: 'socket scan' } - const testScanId = '000aaaa1-0000-0a0a-00a0-00a0000000a0' - - it('should support --dry-run flag', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - }) - - it('should fail without scan ID', async () => { - await cmdScanMetadata.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - }) - - it('should call handleOrgScanMetadata with scan ID', async () => { - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - - await cmdScanMetadata.run( - [testScanId, '--org', 'custom-org'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'custom-org', - testScanId, - 'text', - ) - }) - - it('should support --json output mode', async () => { - await cmdScanMetadata.run([testScanId, '--json'], importMeta, context) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'json', - ) - }) - - it('should support --markdown output mode', async () => { - await cmdScanMetadata.run([testScanId, '--markdown'], importMeta, context) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'markdown', - ) - }) - - it('should fail if both --json and --markdown are provided', async () => { - await cmdScanMetadata.run( - [testScanId, '--json', '--markdown'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - }) - - it('should pass --no-interactive to determineOrgSlug', async () => { - await cmdScanMetadata.run( - [testScanId, '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should fail if org slug cannot be determined', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - }) - - it('should show scan ID in dry-run', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId), - ) - }) - - it('should show organization in dry-run', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org'), - ) - }) - - it('should show metadata operation in dry-run', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringMatching(/metadata/i), - ) - }) - - it('should pass dry-run flag to determineOrgSlug', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, true) - }) - - it('should default interactive to true', async () => { - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('should handle empty org flag as empty string', async () => { - await cmdScanMetadata.run([testScanId, '--org', ''], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', true, false) - }) - - it('shows dot-as-org-name failure message when orgSlug is "."', async () => { - // determineOrgSlug returns '.' as orgSlug — orgSlug truthy so the - // !!orgSlug test passes, but the failure-message branch for '.' is - // still evaluated (the conditional is in the `fail:` field). - // We just confirm the function runs without error. - mockDetermineOrgSlug.mockResolvedValueOnce(['.', '.']) - - await cmdScanMetadata.run([testScanId, '--org', '.'], importMeta, context) - - // No throw; covers the orgSlug === '.' ternary at line 102. - expect(true).toBe(true) - }) - - it('should pass correct command path context to handler', async () => { - mockHandleOrgScanMetadata.mockImplementationOnce( - async (orgSlug, scanId, outputKind) => { - expect(orgSlug).toBe('test-org') - expect(scanId).toBe(testScanId) - expect(outputKind).toBe('text') - }, - ) - - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledTimes(1) - }) - - it('should support all common flags', async () => { - await cmdScanMetadata.run( - [testScanId, '--config', 'custom-config.json', '--no-spinner'], - importMeta, - context, - ) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'test-org', - testScanId, - 'text', - ) - }) - - it('should call handler when all validations pass', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['valid-org', 'valid-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanMetadata.run([testScanId], importMeta, context) - - expect(mockHandleOrgScanMetadata).toHaveBeenCalledTimes(1) - expect(mockHandleOrgScanMetadata).toHaveBeenCalledWith( - 'valid-org', - testScanId, - 'text', - ) - }) - - it('should not call handler in dry-run mode', async () => { - await cmdScanMetadata.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleOrgScanMetadata).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-reach.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-reach.test.mts deleted file mode 100644 index 9a5e9f94e..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-reach.test.mts +++ /dev/null @@ -1,682 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for scan reach command. - * - * Tests the command that computes tier 1 reachability analysis. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mts' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleScanReach = vi.hoisted(() => vi.fn()) -const mockSuggestTarget = vi.hoisted(() => vi.fn().mockResolvedValue(['.'])) -const mockValidateReachabilityTarget = vi.hoisted(() => - vi.fn().mockResolvedValue({ - isDirectory: true, - isInsideCwd: true, - isValid: true, - targetExists: true, - }), -) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(false)) - -vi.mock('../../../../src/commands/scan/handle-scan-reach.mts', () => ({ - handleScanReach: mockHandleScanReach, -})) - -vi.mock('../../../../src/commands/scan/suggest_target.mts', () => ({ - suggestTarget: mockSuggestTarget, -})) - -vi.mock( - '../../../../src/commands/scan/validate-reachability-target.mts', - () => ({ - validateReachabilityTarget: mockValidateReachabilityTarget, - }), -) - -vi.mock('../../../../src/util/socket/org-slug.mts', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanReach } = - await import('../../../../src/commands/scan/cmd-scan-reach.mts') - -describe('cmd-scan-reach', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanReach.description).toBe('Compute tier 1 reachability') - }) - - it('should be hidden', () => { - expect(cmdScanReach.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-reach.mts' } - const context = { parentName: 'socket scan' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--dry-run', '--org', 'test-org', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should show command and args in --dry-run mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--dry-run', '--org', 'test-org', './my-project'], - importMeta, - context, - ) - - expect(mockHandleScanReach).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Command: coana'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanReach.run(['--org', 'test-org', '.'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should call handleScanReach with valid inputs', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run(['--org', 'test-org', '.'], importMeta, context) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.any(String), - interactive: true, - orgSlug: 'test-org', - outputKind: 'text', - targets: ['.'], - reachabilityOptions: expect.any(Object), - }), - ) - }) - - it('should pass --org flag to handleScanReach', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run(['--org', 'custom-org', '.'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should validate target must be exactly one directory', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: true, - isValid: false, - targetExists: true, - }) - - await cmdScanReach.run( - ['--org', 'test-org', './dir1', './dir2'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should validate target must be a directory', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: false, - isInsideCwd: true, - isValid: true, - targetExists: true, - }) - - await cmdScanReach.run( - ['--org', 'test-org', './package.json'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should validate target must exist', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: true, - isValid: true, - targetExists: false, - }) - - await cmdScanReach.run( - ['--org', 'test-org', './nonexistent'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should validate target must be inside cwd', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - mockValidateReachabilityTarget.mockResolvedValueOnce({ - isDirectory: true, - isInsideCwd: false, - isValid: true, - targetExists: true, - }) - - await cmdScanReach.run( - ['--org', 'test-org', '../outside'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should pass reachability options to handleScanReach', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-ecosystems', - 'npm,pypi', - '--reach-concurrency', - '4', - '--reach-debug', - '.', - ], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachConcurrency: 4, - reachDebug: true, - reachEcosystems: ['npm', 'pypi'], - }), - }), - ) - }) - - it('should validate invalid ecosystem value', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanReach.run( - ['--org', 'test-org', '--reach-ecosystems', 'invalid-ecosystem', '.'], - importMeta, - context, - ), - ).rejects.toThrow(/--reach-ecosystems must be one of/) - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--json', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--markdown', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail when both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--json', '--markdown', '.'], - importMeta, - context, - ) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleScanReach).not.toHaveBeenCalled() - }) - - it('should pass --reach-analysis-memory-limit flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-analysis-memory-limit', '4096', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachAnalysisMemoryLimit: 4096, - }), - }), - ) - }) - - it('should pass --reach-analysis-timeout flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-analysis-timeout', '300', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachAnalysisTimeout: 300, - }), - }), - ) - }) - - it('should pass --reach-lazy-mode flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-lazy-mode', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachLazyMode: true, - }), - }), - ) - }) - - it('should pass --reach-skip-cache flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-skip-cache', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachSkipCache: true, - }), - }), - ) - }) - - it('should pass --reach-disable-analytics flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-disable-analytics', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachDisableAnalytics: true, - }), - }), - ) - }) - - it('should pass --reach-enable-analysis-splitting flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-enable-analysis-splitting', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachEnableAnalysisSplitting: true, - }), - }), - ) - }) - - it('should pass --reach-min-severity flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-min-severity', 'high', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachMinSeverity: 'high', - }), - }), - ) - }) - - it('should pass --reach-exclude-paths flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-exclude-paths', - 'node_modules,dist', - '.', - ], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachExcludePaths: expect.arrayContaining(['node_modules', 'dist']), - }), - }), - ) - }) - - it('should pass --reach-use-only-pregenerated-sboms flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--reach-use-only-pregenerated-sboms', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachUseOnlyPregeneratedSboms: true, - }), - }), - ) - }) - - it('should pass --reach-use-unreachable-from-precomputation flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-use-unreachable-from-precomputation', - '.', - ], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachUseUnreachableFromPrecomputation: true, - }), - }), - ) - }) - - it('should validate invalid numeric values for memory limit', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-analysis-memory-limit', - 'invalid', - '.', - ], - importMeta, - context, - ), - ).rejects.toThrow( - /--reach-analysis-memory-limit must be a number of megabytes/, - ) - }) - - it('should validate invalid numeric values for timeout', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanReach.run( - ['--org', 'test-org', '--reach-analysis-timeout', 'invalid', '.'], - importMeta, - context, - ), - ).rejects.toThrow(/--reach-analysis-timeout must be a number of seconds/) - }) - - it('should validate invalid numeric values for concurrency', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdScanReach.run( - ['--org', 'test-org', '--reach-concurrency', 'invalid', '.'], - importMeta, - context, - ), - ).rejects.toThrow(/--reach-concurrency must be a positive integer/) - }) - - it('should default to current directory if no target specified', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run(['--org', 'test-org'], importMeta, context) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - targets: [expect.any(String)], - }), - ) - }) - - it('should support --no-interactive flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--no-interactive', '.'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'test-org', - false, - false, - ) - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - interactive: false, - }), - ) - }) - - it('should pass --cwd flag to change working directory', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - ['--org', 'test-org', '--cwd', './my-project', '.'], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining('my-project'), - }), - ) - }) - - it('should combine multiple reachability flags', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-ecosystems', - 'npm', - '--reach-concurrency', - '2', - '--reach-debug', - '--reach-lazy-mode', - '--reach-skip-cache', - '--reach-min-severity', - 'medium', - '.', - ], - importMeta, - context, - ) - - expect(mockHandleScanReach).toHaveBeenCalledWith( - expect.objectContaining({ - reachabilityOptions: expect.objectContaining({ - reachEcosystems: ['npm'], - reachConcurrency: 2, - reachDebug: true, - reachLazyMode: true, - reachSkipCache: true, - reachMinSeverity: 'medium', - }), - }), - ) - }) - - it('emits --ecosystems in dry-run args when reachEcosystems set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdScanReach.run( - [ - '--org', - 'test-org', - '--reach-ecosystems', - 'npm,pypi', - '--dry-run', - '.', - ], - importMeta, - context, - ) - - expect(mockHandleScanReach).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('--ecosystems'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-report.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-report.test.mts deleted file mode 100644 index c0d62f75e..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-report.test.mts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Unit tests for scan report command. - * - * Tests the command that checks scan results against organizational policies. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleScanReport = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/scan/handle-scan-report.mts', () => ({ - handleScanReport: mockHandleScanReport, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanReport } = - await import('../../../../src/commands/scan/cmd-scan-report.mts') - -describe('cmd-scan-report', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanReport.description).toBe( - 'Check whether a scan result passes the organizational policies (security, license)', - ) - }) - - it('should not be hidden', () => { - expect(cmdScanReport.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-report.mts' } - const context = { parentName: 'socket scan' } - const testScanId = '000aaaa1-0000-0a0a-00a0-00a0000000a0' - - it('should support --dry-run flag', async () => { - await cmdScanReport.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleScanReport).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanReport.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanReport).not.toHaveBeenCalled() - }) - - it('should fail without scan ID', async () => { - await cmdScanReport.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanReport).not.toHaveBeenCalled() - }) - - it('should call handleScanReport with scan ID', async () => { - await cmdScanReport.run([testScanId], importMeta, context) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'test-org', - scanId: testScanId, - includeLicensePolicy: false, - outputKind: 'text', - filepath: '', - fold: 'none', - short: false, - reportLevel: 'warn', - }), - ) - }) - - it('should pass --fold flag to handleScanReport', async () => { - await cmdScanReport.run( - [testScanId, '--fold', 'version'], - importMeta, - context, - ) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - fold: 'version', - }), - ) - }) - - it('should pass --report-level flag to handleScanReport', async () => { - await cmdScanReport.run( - [testScanId, '--report-level', 'error'], - importMeta, - context, - ) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - reportLevel: 'error', - }), - ) - }) - - it('should pass --short flag to handleScanReport', async () => { - await cmdScanReport.run([testScanId, '--short'], importMeta, context) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - short: true, - }), - ) - }) - - it('should pass --license flag to handleScanReport', async () => { - await cmdScanReport.run([testScanId, '--license'], importMeta, context) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - includeLicensePolicy: true, - }), - ) - }) - - it('should pass output file path to handleScanReport', async () => { - await cmdScanReport.run( - [testScanId, './output.json'], - importMeta, - context, - ) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - filepath: './output.json', - }), - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - - await cmdScanReport.run( - [testScanId, '--org', 'custom-org'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should support --json output mode', async () => { - await cmdScanReport.run([testScanId, '--json'], importMeta, context) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - await cmdScanReport.run([testScanId, '--markdown'], importMeta, context) - - expect(mockHandleScanReport).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should fail if both --json and --markdown are provided', async () => { - await cmdScanReport.run( - [testScanId, '--json', '--markdown'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanReport).not.toHaveBeenCalled() - }) - - it('should pass --no-interactive to determineOrgSlug', async () => { - await cmdScanReport.run( - [testScanId, '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should fail if org slug cannot be determined', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - - await cmdScanReport.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanReport).not.toHaveBeenCalled() - }) - - it('should show fold and report level in dry-run', async () => { - await cmdScanReport.run( - ['--dry-run', testScanId, '--fold', 'pkg', '--report-level', 'monitor'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('pkg'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('monitor'), - ) - }) - - it('should show license policy in dry-run', async () => { - await cmdScanReport.run( - ['--dry-run', testScanId, '--license'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('includeLicense'), - ) - }) - - it('should show short flag in dry-run', async () => { - await cmdScanReport.run( - ['--dry-run', testScanId, '--short'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('short'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-setup.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-setup.test.mts deleted file mode 100644 index 3213804d8..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-setup.test.mts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * Unit tests for scan setup command. - * - * Tests the command that configures default flag values for socket scan. - * Creates a local socket.json file to store scan configuration defaults. - */ - -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock meowOrExit. -const mockMeowOrExit = vi.hoisted(() => - vi.fn().mockReturnValue({ - input: [], - flags: {}, - }), -) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Mock handleScanConfig. -const mockHandleScanConfig = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/handle-scan-config.mts', () => ({ - handleScanConfig: mockHandleScanConfig, -})) - -// Import after mocks. -const { cmdScanSetup } = - await import('../../../../src/commands/scan/cmd-scan-setup.mts') - -describe('cmd-scan-setup', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - // Reset default mock return value. - mockMeowOrExit.mockReturnValue({ - input: [], - flags: {}, - }) - }) - - describe('command metadata', () => { - it('should have correct description mentioning interactive configurator', () => { - expect(cmdScanSetup.description).toContain('interactive configurator') - }) - - it('should have correct description mentioning socket scan', () => { - expect(cmdScanSetup.description).toContain('socket scan') - }) - - it('should not be hidden', () => { - expect(cmdScanSetup.hidden).toBe(false) - }) - }) - - describe('meowOrExit configuration', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should call meowOrExit with correct commandName', async () => { - await cmdScanSetup.run([], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - commandName: 'setup', - }), - }), - ) - }) - - it('should call meowOrExit with parentName from context', async () => { - await cmdScanSetup.run([], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket scan', - }), - ) - }) - - it('should call meowOrExit with importMeta', async () => { - await cmdScanSetup.run([], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - importMeta, - }), - ) - }) - - it('should call meowOrExit with argv', async () => { - await cmdScanSetup.run(['--dry-run', '.'], importMeta, context) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - argv: ['--dry-run', '.'], - }), - ) - }) - - it('should include defaultOnReadError flag in config', async () => { - await cmdScanSetup.run([], importMeta, context) - - const callArgs = mockMeowOrExit.mock.calls[0]?.[0] - expect(callArgs?.config?.flags?.defaultOnReadError).toBeDefined() - expect(callArgs?.config?.flags?.defaultOnReadError?.type).toBe('boolean') - }) - - it('should include help function with usage examples', async () => { - await cmdScanSetup.run([], importMeta, context) - - const callArgs = mockMeowOrExit.mock.calls[0]?.[0] - const helpText = callArgs?.config?.help?.( - 'socket scan setup', - callArgs?.config, - ) - - expect(helpText).toContain('Usage') - expect(helpText).toContain('$ socket scan setup [options] [CWD=.]') - expect(helpText).toContain('Options') - expect(helpText).toContain('Examples') - expect(helpText).toContain('$ socket scan setup') - expect(helpText).toContain('$ socket scan setup ./proj') - }) - - it('should mention socket.json in help text', async () => { - await cmdScanSetup.run([], importMeta, context) - - const callArgs = mockMeowOrExit.mock.calls[0]?.[0] - const helpText = callArgs?.config?.help?.( - 'socket scan setup', - callArgs?.config, - ) - - expect(helpText).toContain('json file') - expect(helpText).toContain('socket scan create') - }) - }) - - describe('dry-run behavior', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should log DryRun message and return early', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: [], - flags: { dryRun: true }, - }) - - await cmdScanSetup.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - expect(mockHandleScanConfig).not.toHaveBeenCalled() - }) - - it('should not call handleScanConfig when dry-run is true', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['.'], - flags: { dryRun: true }, - }) - - await cmdScanSetup.run(['--dry-run', '.'], importMeta, context) - - expect(mockHandleScanConfig).not.toHaveBeenCalled() - }) - }) - - describe('cwd path handling', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should default to current directory when no input provided', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: [], - flags: {}, - }) - - await cmdScanSetup.run([], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith(process.cwd(), false) - }) - - it('should resolve relative path from cwd', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['./subdir'], - flags: {}, - }) - - await cmdScanSetup.run(['./subdir'], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - path.resolve(process.cwd(), './subdir'), - false, - ) - }) - - it('should use absolute path as-is', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['/absolute/path'], - flags: {}, - }) - - await cmdScanSetup.run(['/absolute/path'], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith('/absolute/path', false) - }) - - it('should resolve parent directory path', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['../parent'], - flags: {}, - }) - - await cmdScanSetup.run(['../parent'], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - path.resolve(process.cwd(), '../parent'), - false, - ) - }) - - it('should handle dot path', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['.'], - flags: {}, - }) - - await cmdScanSetup.run(['.'], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith(process.cwd(), false) - }) - }) - - describe('defaultOnReadError flag', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should pass false when flag is not provided', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['.'], - flags: {}, - }) - - await cmdScanSetup.run(['.'], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - expect.any(String), - false, - ) - }) - - it('should pass true when flag is provided', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['.'], - flags: { defaultOnReadError: true }, - }) - - await cmdScanSetup.run( - ['--default-on-read-error', '.'], - importMeta, - context, - ) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - expect.any(String), - true, - ) - }) - - it('should coerce undefined to false', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: [], - flags: { defaultOnReadError: undefined }, - }) - - await cmdScanSetup.run([], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - expect.any(String), - false, - ) - }) - - it('should handle explicit false value', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: [], - flags: { defaultOnReadError: false }, - }) - - await cmdScanSetup.run([], importMeta, context) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - expect.any(String), - false, - ) - }) - }) - - describe('integration of flags and path', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should handle both path and defaultOnReadError together', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['./project'], - flags: { defaultOnReadError: true }, - }) - - await cmdScanSetup.run( - ['--default-on-read-error', './project'], - importMeta, - context, - ) - - expect(mockHandleScanConfig).toHaveBeenCalledWith( - path.resolve(process.cwd(), './project'), - true, - ) - }) - - it('should not pass additional flags to handleScanConfig', async () => { - mockMeowOrExit.mockReturnValueOnce({ - input: ['.'], - flags: { - defaultOnReadError: true, - somethingElse: 'value', - }, - }) - - await cmdScanSetup.run(['.'], importMeta, context) - - // handleScanConfig should only receive cwd and defaultOnReadError. - expect(mockHandleScanConfig).toHaveBeenCalledTimes(1) - expect(mockHandleScanConfig).toHaveBeenCalledWith( - expect.any(String), - true, - ) - }) - }) - - describe('context handling', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - - it('should accept custom parentName', async () => { - await cmdScanSetup.run([], importMeta, { parentName: 'custom-cli scan' }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'custom-cli scan', - }), - ) - }) - - it('should handle empty parentName', async () => { - await cmdScanSetup.run([], importMeta, { parentName: '' }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: '', - }), - ) - }) - }) - - describe('error handling', () => { - const importMeta = { url: 'file:///test/cmd-scan-setup.mts' } - const context = { parentName: 'socket scan' } - - it('should propagate errors from handleScanConfig', async () => { - const testError = new Error('Config write failed') - mockHandleScanConfig.mockRejectedValueOnce(testError) - - await expect( - cmdScanSetup.run(['.'], importMeta, context), - ).rejects.toThrow('Config write failed') - }) - - it('should propagate errors from handleScanConfig with custom message', async () => { - const testError = new Error('Permission denied') - mockHandleScanConfig.mockRejectedValueOnce(testError) - - await expect( - cmdScanSetup.run(['.'], importMeta, context), - ).rejects.toThrow(testError) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts deleted file mode 100644 index 4d9b937fc..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Unit tests for scan view command. - * - * Tests the command that views raw scan results. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleScanView = vi.hoisted(() => vi.fn()) -const mockStreamScan = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/scan/handle-scan-view.mts', () => ({ - handleScanView: mockHandleScanView, -})) - -vi.mock('../../../../src/commands/scan/stream-scan.mts', () => ({ - streamScan: mockStreamScan, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdScanView } = - await import('../../../../src/commands/scan/cmd-scan-view.mts') - -describe('cmd-scan-view', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScanView.description).toBe('View the raw results of a scan') - }) - - it('should not be hidden', () => { - expect(cmdScanView.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-scan-view.mts' } - const context = { parentName: 'socket scan' } - const testScanId = '000aaaa1-0000-0a0a-00a0-00a0000000a0' - - it('should support --dry-run flag', async () => { - await cmdScanView.run(['--dry-run', testScanId], importMeta, context) - - expect(mockHandleScanView).not.toHaveBeenCalled() - expect(mockStreamScan).not.toHaveBeenCalled() - // Dry-run previews route to stderr per stream discipline. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdScanView.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanView).not.toHaveBeenCalled() - }) - - it('should fail without scan ID', async () => { - await cmdScanView.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanView).not.toHaveBeenCalled() - }) - - it('should call handleScanView with scan ID', async () => { - await cmdScanView.run([testScanId], importMeta, context) - - expect(mockHandleScanView).toHaveBeenCalledWith( - 'test-org', - testScanId, - '', - 'text', - ) - }) - - it('should pass output file path to handleScanView', async () => { - await cmdScanView.run([testScanId, './output.json'], importMeta, context) - - expect(mockHandleScanView).toHaveBeenCalledWith( - 'test-org', - testScanId, - './output.json', - 'text', - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - - await cmdScanView.run( - [testScanId, '--org', 'custom-org'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleScanView).toHaveBeenCalledWith( - 'custom-org', - testScanId, - '', - 'text', - ) - }) - - it('should support --json output mode', async () => { - await cmdScanView.run([testScanId, '--json'], importMeta, context) - - expect(mockHandleScanView).toHaveBeenCalledWith( - 'test-org', - testScanId, - '', - 'json', - ) - }) - - it('should support --markdown output mode', async () => { - await cmdScanView.run([testScanId, '--markdown'], importMeta, context) - - expect(mockHandleScanView).toHaveBeenCalledWith( - 'test-org', - testScanId, - '', - 'markdown', - ) - }) - - it('should fail if both --json and --markdown are provided', async () => { - await cmdScanView.run( - [testScanId, '--json', '--markdown'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanView).not.toHaveBeenCalled() - }) - - it('should call streamScan with --json --stream flags', async () => { - await cmdScanView.run( - [testScanId, '--json', '--stream'], - importMeta, - context, - ) - - expect(mockStreamScan).toHaveBeenCalledWith('test-org', testScanId, { - commandPath: 'socket scan view', - file: '', - }) - expect(mockHandleScanView).not.toHaveBeenCalled() - }) - - it('should pass file to streamScan with --json --stream', async () => { - await cmdScanView.run( - [testScanId, './stream.txt', '--json', '--stream'], - importMeta, - context, - ) - - expect(mockStreamScan).toHaveBeenCalledWith('test-org', testScanId, { - commandPath: 'socket scan view', - file: './stream.txt', - }) - }) - - it('should fail if --stream is used without --json', async () => { - await cmdScanView.run([testScanId, '--stream'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanView).not.toHaveBeenCalled() - expect(mockStreamScan).not.toHaveBeenCalled() - }) - - it('should pass --no-interactive to determineOrgSlug', async () => { - await cmdScanView.run( - [testScanId, '--no-interactive'], - importMeta, - context, - ) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should fail if org slug cannot be determined', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - - await cmdScanView.run([testScanId], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockHandleScanView).not.toHaveBeenCalled() - }) - - it('should show scan ID in dry-run', async () => { - await cmdScanView.run(['--dry-run', testScanId], importMeta, context) - - // Dry-run previews route to stderr. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining(testScanId), - ) - }) - - it('should show organization in dry-run', async () => { - await cmdScanView.run(['--dry-run', testScanId], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-org'), - ) - }) - - it('should show stream mode in dry-run with --json --stream', async () => { - await cmdScanView.run( - ['--dry-run', testScanId, '--json', '--stream'], - importMeta, - context, - ) - - // Dry-run output routes to stderr when --json is set so the - // primary payload stays pipe-safe on stdout. - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('stream'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan.test.mts deleted file mode 100644 index 12ff6fe04..000000000 --- a/packages/cli/test/unit/commands/scan/cmd-scan.test.mts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Unit tests for scan parent command. - * - * Tests the parent command that routes to scan management subcommands. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowWithSubcommands: mockMeowWithSubcommands, -})) - -// Import after mocks. -const { cmdScan } = await import('../../../../src/commands/scan/cmd-scan.mts') -const { cmdScanCreate } = - await import('../../../../src/commands/scan/cmd-scan-create.mts') -const { cmdScanDel } = - await import('../../../../src/commands/scan/cmd-scan-del.mts') -const { cmdScanDiff } = - await import('../../../../src/commands/scan/cmd-scan-diff.mts') -const { cmdScanGithub } = - await import('../../../../src/commands/scan/cmd-scan-github.mts') -const { cmdScanList } = - await import('../../../../src/commands/scan/cmd-scan-list.mts') -const { cmdScanMetadata } = - await import('../../../../src/commands/scan/cmd-scan-metadata.mts') -const { cmdScanReach } = - await import('../../../../src/commands/scan/cmd-scan-reach.mts') -const { cmdScanReport } = - await import('../../../../src/commands/scan/cmd-scan-report.mts') -const { cmdScanSetup } = - await import('../../../../src/commands/scan/cmd-scan-setup.mts') -const { cmdScanView } = - await import('../../../../src/commands/scan/cmd-scan-view.mts') - -describe('cmd-scan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdScan.description).toBe('Manage Socket scans') - }) - - it('should not have hidden property set to true', () => { - expect(cmdScan.hidden).toBeUndefined() - }) - - it('should have a run method', () => { - expect(typeof cmdScan.run).toBe('function') - }) - }) - - describe('subcommand routing', () => { - const importMeta = { url: 'file:///test/cmd-scan.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct configuration', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run(['list'], importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - { - argv: ['list'], - importMeta, - name: 'socket scan', - subcommands: { - create: cmdScanCreate, - del: cmdScanDel, - diff: cmdScanDiff, - github: cmdScanGithub, - list: cmdScanList, - metadata: cmdScanMetadata, - reach: cmdScanReach, - report: cmdScanReport, - setup: cmdScanSetup, - view: cmdScanView, - }, - }, - { - aliases: { - meta: { - argv: ['metadata'], - description: cmdScanMetadata.description, - hidden: true, - }, - reachability: { - argv: ['reach'], - description: cmdScanReach.description, - hidden: true, - }, - }, - description: 'Manage Socket scans', - }, - ) - }) - - it('should construct correct command name from parent', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run(['create'], importMeta, { - parentName: 'custom-parent', - }) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'custom-parent scan', - }), - expect.anything(), - ) - }) - - it('should include all subcommands', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], importMeta, context) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(Object.keys(subcommands)).toEqual([ - 'create', - 'del', - 'diff', - 'github', - 'list', - 'metadata', - 'reach', - 'report', - 'setup', - 'view', - ]) - }) - - it('should pass through argv unchanged', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = ['create', 'package.json', '--json'] - - await cmdScan.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - - it('should handle readonly argv', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - const argv = Object.freeze(['view', 'scan-id']) as readonly string[] - - await cmdScan.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledWith( - expect.objectContaining({ - argv, - }), - expect.anything(), - ) - }) - }) - - describe('subcommand validation', () => { - it('should reference correct subcommand objects', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommands = call[0].subcommands - - expect(subcommands.create).toBe(cmdScanCreate) - expect(subcommands.del).toBe(cmdScanDel) - expect(subcommands.diff).toBe(cmdScanDiff) - expect(subcommands.github).toBe(cmdScanGithub) - expect(subcommands.list).toBe(cmdScanList) - expect(subcommands.metadata).toBe(cmdScanMetadata) - expect(subcommands.reach).toBe(cmdScanReach) - expect(subcommands.report).toBe(cmdScanReport) - expect(subcommands.setup).toBe(cmdScanSetup) - expect(subcommands.view).toBe(cmdScanView) - }) - }) - - describe('aliases configuration', () => { - it('should configure meta alias for metadata', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.meta).toEqual({ - argv: ['metadata'], - description: cmdScanMetadata.description, - hidden: true, - }) - }) - - it('should configure reachability alias for reach', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.reachability).toEqual({ - argv: ['reach'], - description: cmdScanReach.description, - hidden: true, - }) - }) - - it('should mark all aliases as hidden', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const aliases = call[1].aliases - - expect(aliases.meta.hidden).toBe(true) - expect(aliases.reachability.hidden).toBe(true) - }) - }) - - describe('subcommand ordering', () => { - it('should maintain consistent subcommand order', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const subcommandKeys = Object.keys(call[0].subcommands) - - expect(subcommandKeys).toEqual([ - 'create', - 'del', - 'diff', - 'github', - 'list', - 'metadata', - 'reach', - 'report', - 'setup', - 'view', - ]) - }) - }) - - describe('error handling', () => { - it('should propagate errors from meowWithSubcommands', async () => { - const testError = new Error('Subcommand error') - mockMeowWithSubcommands.mockRejectedValue(testError) - - await expect( - cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }), - ).rejects.toThrow('Subcommand error') - }) - }) - - describe('options configuration', () => { - it('should pass description in options', async () => { - mockMeowWithSubcommands.mockResolvedValue(undefined) - - await cmdScan.run([], { url: 'file:///test' }, { parentName: 'socket' }) - - const call = mockMeowWithSubcommands.mock.calls[0] - const options = call[1] - - expect(options.description).toBe('Manage Socket scans') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/create-scan-from-github-coverage.test.mts b/packages/cli/test/unit/commands/scan/create-scan-from-github-coverage.test.mts deleted file mode 100644 index c6d998576..000000000 --- a/packages/cli/test/unit/commands/scan/create-scan-from-github-coverage.test.mts +++ /dev/null @@ -1,652 +0,0 @@ -/* max-file-lines: legitimate — coverage-targeted tests for one command/module; splitting would fragment closely related assertions. */ -/** - * Coverage tests for create-scan-from-github helpers. - * - * Purpose: Drives the remaining uncovered branches in - * create-scan-from-github.mts that the sibling -direct and main test files - * don't exercise. - * - * Why a separate file: The companion -direct file is already at the file-size - * soft cap; the coverage-only tests would push it past the 1000-line hard cap. - * - * Related Files: - src/commands/scan/create-scan-from-github.mts - * (implementation) - - * test/unit/commands/scan/create-scan-from-github-direct.test.mts (direct) - - * test/unit/commands/scan/create-scan-from-github.test.mts (mock-surface) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as FsLibType from '@socketsecurity/lib-stable/fs/safe' -import type * as CreateScanFromGithub from '../../../../src/commands/scan/create-scan-from-github.mts' - -const mockOctokit = vi.hoisted(() => ({ - repos: { get: vi.fn(), listCommits: vi.fn(), getContent: vi.fn() }, - git: { getTree: vi.fn() }, -})) -const mockGetOctokit = vi.hoisted(() => vi.fn(() => mockOctokit)) -const mockWithGitHubRetry = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - GITHUB_ERR_ABUSE_DETECTION: 'GitHub abuse detection triggered', - GITHUB_ERR_AUTH_FAILED: 'GitHub authentication failed', - GITHUB_ERR_GRAPHQL_RATE_LIMIT: 'GitHub GraphQL rate limit exceeded', - GITHUB_ERR_RATE_LIMIT: 'GitHub rate limit exceeded', - getOctokit: mockGetOctokit, - withGitHubRetry: mockWithGitHubRetry, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: vi.fn(() => ({ - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - })), -})) - -const mockSelect = vi.hoisted(() => vi.fn()) -const mockConfirm = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, - confirm: mockConfirm, -})) - -const mockSocketHttpRequest = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - socketHttpRequest: mockSocketHttpRequest, -})) - -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) - -const mockHandleCreateNewScan = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/scan/handle-create-new-scan.mts', () => ({ - handleCreateNewScan: mockHandleCreateNewScan, -})) - -const mockFetchListAllRepos = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/repository/fetch-list-all-repos.mts', () => ({ - fetchListAllRepos: mockFetchListAllRepos, -})) - -const mockSafeDelete = vi.hoisted(() => vi.fn()) -const mockSafeMkdirSync = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, - safeMkdirSync: mockSafeMkdirSync, -})) - -import { - downloadManifestFile, - scanOneRepo, - streamDownloadWithFetch, - testAndDownloadManifestFiles, -} from '../../../../src/commands/scan/create-scan-from-github.mts' - -describe('create-scan-from-github (coverage)', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSafeDelete.mockResolvedValue(undefined) - mockSafeMkdirSync.mockReturnValue(undefined) - }) - - describe('scanOneRepo', () => { - it('runs the full happy path through handleCreateNewScan', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { tree: [{ type: 'blob', path: 'package.json' }] }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - .mockResolvedValueOnce({ - ok: true, - data: [ - { - sha: 'abc123', - commit: { - message: 'feat: hello', - author: { name: 'Alice' }, - committer: { name: 'Bob' }, - }, - }, - ], - }) - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: '{"name": "x"}', - }) - mockHandleCreateNewScan.mockResolvedValueOnce({ - ok: true, - data: undefined, - }) - - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.scanCreated).toBe(true) - } - expect(mockHandleCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'feat: hello', - repoName: 'repo', - }), - ) - }) - - it('returns treeResult failure', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate', - }) - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - }) - - it('propagates downloadResult failure from testAndDownloadManifestFiles', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { tree: [{ type: 'blob', path: 'package.json' }] }, - }) - .mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate', - }) - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - }) - - it('propagates commitResult failure', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { tree: [{ type: 'blob', path: 'package.json' }] }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - .mockResolvedValueOnce({ - ok: false, - message: 'GitHub resource not found', - cause: 'no commits', - }) - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: 'data', - }) - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - }) - }) - - describe('createScanFromGithub interactive paths', () => { - let mockCreateScanFromGithub: typeof CreateScanFromGithub.createScanFromGithub - - beforeEach(async () => { - const mod = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - mockCreateScanFromGithub = mod.createScanFromGithub - }) - - it('invokes selectFocus when interactive and multiple repos and no explicit list', async () => { - mockFetchListAllRepos.mockResolvedValueOnce({ - ok: true, - data: { results: [{ slug: 'repo-1' }, { slug: 'repo-2' }] }, - }) - mockSelect.mockResolvedValueOnce('') - const result = await mockCreateScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: true, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(mockSelect).toHaveBeenCalled() - expect(result.ok).toBe(false) - }) - - it('invokes makeSure when interactive and more than 10 repos and (all || !repos)', async () => { - const many = Array.from({ length: 12 }, (_, i) => ({ slug: `r${i}` })) - mockFetchListAllRepos.mockResolvedValueOnce({ - ok: true, - data: { results: many }, - }) - mockConfirm.mockResolvedValueOnce(false) - const result = await mockCreateScanFromGithub({ - all: true, - githubApiUrl: '', - githubToken: '', - interactive: true, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(mockConfirm).toHaveBeenCalled() - expect(result.ok).toBe(false) - }) - - it('returns ok with scanCreated when at least one repo succeeds', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { tree: [{ type: 'blob', path: 'package.json' }] }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - .mockResolvedValueOnce({ - ok: true, - data: [ - { - sha: 'abc123', - commit: { - message: 'feat: hello', - author: { name: 'Alice' }, - committer: { name: 'Bob' }, - }, - }, - ], - }) - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: '{"name": "x"}', - }) - mockHandleCreateNewScan.mockResolvedValueOnce({ - ok: true, - data: undefined, - }) - - const result = await mockCreateScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a', - }) - expect(result.ok).toBe(true) - }) - - it('continues past non-blocking per-repo failure when at least one succeeds', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: false, - message: 'GitHub resource not found', - cause: '404', - }) - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { tree: [{ type: 'blob', path: 'package.json' }] }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - .mockResolvedValueOnce({ - ok: true, - data: [ - { - sha: 'sha', - commit: { - message: 'msg', - author: { name: 'a' }, - committer: { name: 'c' }, - }, - }, - ], - }) - mockFetchSupportedScanFileNames.mockResolvedValue({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - body: '{}', - }) - mockHandleCreateNewScan.mockResolvedValue({ ok: true, data: undefined }) - - const result = await mockCreateScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b', - }) - expect(result.ok).toBe(true) - }) - - it('updates targetRepos to selectFocus output and proceeds', async () => { - mockFetchListAllRepos.mockResolvedValueOnce({ - ok: true, - data: { results: [{ slug: 'r1' }, { slug: 'r2' }] }, - }) - mockSelect.mockResolvedValueOnce('r2') - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub resource not found', - cause: '404', - }) - - const result = await mockCreateScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: true, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(mockSelect).toHaveBeenCalled() - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('All repos failed to scan') - } - }) - }) - - describe('testAndDownloadManifestFiles success counting', () => { - it('returns ok=true when at least one manifest downloads', async () => { - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: '{}', - }) - const result = await testAndDownloadManifestFiles({ - defaultBranch: 'main', - files: ['package.json', 'README.md'], - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(true) - }) - - it('returns the first download failure when no manifests succeed but one errors', async () => { - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { packagejson: { pattern: 'package.json' } } }, - }) - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }) - const result = await testAndDownloadManifestFiles({ - defaultBranch: 'main', - files: ['package.json'], - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Download Failed') - } - }) - }) - - describe('downloadManifestFile inner callback execution', () => { - it('invokes octokit.repos.getContent inside withGitHubRetry', async () => { - mockWithGitHubRetry.mockImplementationOnce( - async (op: () => Promise<unknown>) => { - const data = await op() - return { ok: true, data } - }, - ) - mockOctokit.repos.getContent.mockResolvedValueOnce({ - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: '{}', - }) - - const result = await downloadManifestFile({ - defaultBranch: 'main', - file: 'package.json', - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(mockOctokit.repos.getContent).toHaveBeenCalledWith({ - owner: 'org', - repo: 'r', - path: 'package.json', - ref: 'main', - }) - expect(result.ok).toBe(true) - }) - }) - - describe('streamDownloadWithFetch inner cleanup error', () => { - it('logs the inner cleanup error when safeDelete also throws', async () => { - mockSocketHttpRequest.mockRejectedValueOnce(new Error('boom')) - mockSafeDelete.mockRejectedValueOnce( - Object.assign(new Error('EACCES'), { code: 'EACCES' }), - ) - const result = await streamDownloadWithFetch( - '/tmp/download-target', - 'https://example.com/file', - ) - expect(mockSafeDelete).toHaveBeenCalled() - expect(result.ok).toBe(false) - }) - }) - - describe('streamDownloadWithFetch success', () => { - it('writes the response body to disk on ok response', async () => { - const os = await import('node:os') - const path = await import('node:path') - const fs = await import('node:fs/promises') - const { mkdtempSync } = await import('node:fs') - const dir = mkdtempSync(path.join(os.tmpdir(), 'sgh-stream-')) - // Use a flat path so we don't depend on safeMkdirSync (which is mocked). - const target = path.join(dir, 'package.json') - - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: '{"name": "ok"}', - }) - - const result = await streamDownloadWithFetch( - target, - 'https://example.com/pkg.json', - ) - expect(result.ok).toBe(true) - const written = await fs.readFile(target, 'utf8') - expect(written).toBe('{"name": "ok"}') - // mockSafeDelete is the mocked safeDelete here; reach for the real - // one via importActual to actually remove the tmpdir. - const actualFs = await vi.importActual<typeof FsLibType>( - '@socketsecurity/lib-stable/fs/safe', - ) - await actualFs.safeDelete(dir, { force: true, recursive: true }) - }) - - it('creates the parent dir via safeMkdirSync when it does not exist', async () => { - const os = await import('node:os') - const path = await import('node:path') - const fs = await import('node:fs/promises') - const { mkdtempSync } = await import('node:fs') - const dir = mkdtempSync(path.join(os.tmpdir(), 'sgh-mkdir-')) - // Nested subdir that does not exist — exercises the safeMkdirSync branch. - const target = path.join(dir, 'nested', 'sub', 'package.json') - - // safeMkdirSync is mocked to no-op; route to the real one so the - // subsequent fs.writeFile can land. - const actualFs = await vi.importActual<typeof FsLibType>( - '@socketsecurity/lib-stable/fs/safe', - ) - mockSafeMkdirSync.mockImplementationOnce((p: string, opts: object) => - actualFs.safeMkdirSync( - p, - opts as Parameters<typeof actualFs.safeMkdirSync>[1], - ), - ) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: true, - status: 200, - body: 'hi', - }) - - const result = await streamDownloadWithFetch( - target, - 'https://example.com/pkg.json', - ) - expect(mockSafeMkdirSync).toHaveBeenCalled() - expect(result.ok).toBe(true) - const written = await fs.readFile(target, 'utf8') - expect(written).toBe('hi') - await actualFs.safeDelete(dir, { force: true, recursive: true }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/create-scan-from-github-direct.test.mts b/packages/cli/test/unit/commands/scan/create-scan-from-github-direct.test.mts deleted file mode 100644 index 65965ce8c..000000000 --- a/packages/cli/test/unit/commands/scan/create-scan-from-github-direct.test.mts +++ /dev/null @@ -1,606 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Direct unit tests for create-scan-from-github helpers. - * - * Purpose: Tests that import + execute the real source functions (not mocks of - * them). The companion file `create-scan-from-github.test.mts` only exercises - * the Octokit mock surface, which leaves the actual source untouched at - * runtime. - * - * Test Coverage: - getRepoDetails / getRepoBranchTree / getLastCommitDetails - - * selectFocus / makeSure (interactive prompt wrappers) - - * streamDownloadWithFetch error path. - * - * Related Files: - src/commands/scan/create-scan-from-github.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockOctokit = vi.hoisted(() => ({ - repos: { get: vi.fn(), listCommits: vi.fn() }, - git: { getTree: vi.fn() }, -})) -const mockGetOctokit = vi.hoisted(() => vi.fn(() => mockOctokit)) -const mockWithGitHubRetry = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - GITHUB_ERR_ABUSE_DETECTION: 'GitHub abuse detection triggered', - GITHUB_ERR_AUTH_FAILED: 'GitHub authentication failed', - GITHUB_ERR_GRAPHQL_RATE_LIMIT: 'GitHub GraphQL rate limit exceeded', - GITHUB_ERR_RATE_LIMIT: 'GitHub rate limit exceeded', - getOctokit: mockGetOctokit, - withGitHubRetry: mockWithGitHubRetry, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: vi.fn(() => ({ - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - })), -})) - -const mockSelect = vi.hoisted(() => vi.fn()) -const mockConfirm = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, - confirm: mockConfirm, -})) - -const mockSocketHttpRequest = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - socketHttpRequest: mockSocketHttpRequest, -})) - -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) - -const mockHandleCreateNewScan = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/commands/scan/handle-create-new-scan.mts', () => ({ - handleCreateNewScan: mockHandleCreateNewScan, -})) - -import { - downloadManifestFile, - getLastCommitDetails, - getRepoBranchTree, - getRepoDetails, - makeSure, - scanOneRepo, - scanRepo, - selectFocus, - streamDownloadWithFetch, - testAndDownloadManifestFile, - testAndDownloadManifestFiles, -} from '../../../../src/commands/scan/create-scan-from-github.mts' - -describe('create-scan-from-github (direct)', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('getRepoDetails', () => { - it('returns default branch on success', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main', name: 'r' }, - }) - const result = await getRepoDetails({ - orgGithub: 'org', - repoSlug: 'r', - githubApiUrl: 'https://api.github.com', - githubToken: 'gh_t', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.defaultBranch).toBe('main') - } - }) - - it('propagates failure from withGitHubRetry', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'fail', - }) - const result = await getRepoDetails({ - orgGithub: 'org', - repoSlug: 'r', - githubApiUrl: 'https://api.github.com', - githubToken: 'gh_t', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - } - }) - - it('returns error when default branch is missing', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { default_branch: undefined, name: 'r' }, - }) - const result = await getRepoDetails({ - orgGithub: 'org', - repoSlug: 'r', - githubApiUrl: 'https://api.github.com', - githubToken: 'gh_t', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Default branch not found') - } - }) - }) - - describe('getRepoBranchTree', () => { - it('returns blob paths', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { - tree: [ - { type: 'blob', path: 'package.json' }, - { type: 'tree', path: 'src' }, - { type: 'blob', path: 'src/index.ts' }, - { type: 'blob', path: '' }, - ], - }, - }) - const result = await getRepoBranchTree({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual(['package.json', 'src/index.ts']) - } - }) - - it('returns empty array for "GitHub resource not found" (empty repo)', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub resource not found', - cause: 'empty repo', - }) - const result = await getRepoBranchTree({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'empty', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual([]) - } - }) - - it('propagates other errors', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate limited', - }) - const result = await getRepoBranchTree({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(false) - }) - - it('returns error for invalid tree response', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { tree: undefined }, - }) - const result = await getRepoBranchTree({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Invalid tree response') - } - }) - }) - - describe('getLastCommitDetails', () => { - it('returns details for first commit', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: [ - { - sha: 'abc', - commit: { - message: 'feat', - author: { name: 'Alice' }, - committer: { name: 'Bob' }, - }, - }, - ], - }) - const result = await getLastCommitDetails({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.lastCommitSha).toBe('abc') - expect(result.data.lastCommitter).toBe('Alice') - expect(result.data.lastCommitMessage).toBe('feat') - } - }) - - it('falls back to committer when author missing', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: [ - { - sha: 'abc', - commit: { - message: 'msg', - committer: { name: 'OnlyCommitter' }, - }, - }, - ], - }) - const result = await getLastCommitDetails({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - if (result.ok) { - expect(result.data.lastCommitter).toBe('OnlyCommitter') - } - }) - - it('returns error for empty commits array', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ ok: true, data: [] }) - const result = await getLastCommitDetails({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('No commits found') - } - }) - - it('returns error when commit lacks SHA', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: [{ commit: { message: 'no-sha' } }], - }) - const result = await getLastCommitDetails({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Missing commit SHA') - } - }) - - it('propagates withGitHubRetry failure', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate limited', - }) - const result = await getLastCommitDetails({ - defaultBranch: 'main', - orgGithub: 'org', - repoSlug: 'r', - }) - expect(result.ok).toBe(false) - }) - }) - - describe('selectFocus', () => { - it('returns selected repo', async () => { - mockSelect.mockResolvedValueOnce('repo-2') - const result = await selectFocus(['repo-1', 'repo-2', 'repo-3']) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual(['repo-2']) - } - }) - - it('returns cancel result when user picks exit', async () => { - mockSelect.mockResolvedValueOnce('') - const result = await selectFocus(['r1', 'r2']) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Canceled by user') - } - }) - }) - - describe('makeSure', () => { - it('returns ok when user confirms', async () => { - mockConfirm.mockResolvedValueOnce(true) - const result = await makeSure(50) - expect(result.ok).toBe(true) - }) - - it('returns canceled result when user declines', async () => { - mockConfirm.mockResolvedValueOnce(false) - const result = await makeSure(50) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('User canceled') - } - }) - }) - - describe('streamDownloadWithFetch', () => { - it('returns error on bad response status', async () => { - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - }) - const result = await streamDownloadWithFetch( - '/tmp/download-target', - 'https://example.com/file', - ) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Download Failed') - } - }) - - it('returns error on thrown exception', async () => { - mockSocketHttpRequest.mockRejectedValueOnce( - Object.assign(new Error('ECONNREFUSED'), { - cause: 'network down', - }), - ) - const result = await streamDownloadWithFetch( - '/tmp/download-target', - 'https://example.com/file', - ) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Download Failed') - } - }) - }) - - describe('testAndDownloadManifestFile', () => { - it('returns isManifest=false when supportedFiles is undefined', async () => { - const result = await testAndDownloadManifestFile({ - defaultBranch: 'main', - file: 'package.json', - orgGithub: 'org', - repoSlug: 'r', - supportedFiles: undefined, - tmpDir: '/tmp', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.isManifest).toBe(false) - } - }) - - it('returns isManifest=false when file is not a known manifest pattern', async () => { - const result = await testAndDownloadManifestFile({ - defaultBranch: 'main', - file: 'random.txt', - orgGithub: 'org', - repoSlug: 'r', - supportedFiles: { npm: { 'package.json': {} } } as unknown, - tmpDir: '/tmp', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.isManifest).toBe(false) - } - }) - }) - - describe('downloadManifestFile', () => { - it('returns error when withGitHubRetry fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate limited', - }) - const result = await downloadManifestFile({ - defaultBranch: 'main', - file: 'package.json', - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - }) - - it('returns "Not a file" error when content is a directory', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: [{ name: 'a.txt' }, { name: 'b.txt' }], - }) - const result = await downloadManifestFile({ - defaultBranch: 'main', - file: 'subdir', - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Not a file') - } - }) - - it('returns "Missing download URL" when GitHub omits download_url', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: undefined, - }, - }) - const result = await downloadManifestFile({ - defaultBranch: 'main', - file: 'package.json', - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Missing download URL') - } - }) - - it('returns the download error when streamDownloadWithFetch fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { - type: 'file', - size: 100, - download_url: 'https://example.com/pkg.json', - }, - }) - mockSocketHttpRequest.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }) - const result = await downloadManifestFile({ - defaultBranch: 'main', - file: 'package.json', - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - }) - }) - - describe('testAndDownloadManifestFiles', () => { - it('returns "No manifest files found" when no files match', async () => { - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: undefined, - }) - - const result = await testAndDownloadManifestFiles({ - defaultBranch: 'main', - files: ['random.txt', 'foo.bar'], - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('No manifest files found') - } - }) - - it('returns ok=true with no error when at least one manifest matches', async () => { - mockFetchSupportedScanFileNames.mockResolvedValueOnce({ - ok: true, - data: { npm: { 'package.json': {} } }, - }) - // We just test the count path — the file content fetch fails but - // since at least the iteration runs, fileCount may stay 0, in which case - // we expect "No manifest files found". - const result = await testAndDownloadManifestFiles({ - defaultBranch: 'main', - files: ['random.txt'], - orgGithub: 'org', - repoSlug: 'r', - tmpDir: '/tmp', - }) - // Random.txt doesn't match; result depends on implementation. - expect(typeof result.ok).toBe('boolean') - }) - }) - - describe('scanRepo', () => { - it('delegates to scanOneRepo and returns its result', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate', - }) - const result = await scanRepo('repo', { - githubApiUrl: 'https://api.github.com', - githubToken: 't', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - }) - }) - - describe('scanOneRepo', () => { - it('returns repoResult error when getRepoDetails fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'rate', - }) - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - }) - - it('returns scanCreated=false when default branch has no files', async () => { - // getRepoDetails ok with main branch. - mockWithGitHubRetry - .mockResolvedValueOnce({ - ok: true, - data: { default_branch: 'main' }, - }) - // getRepoBranchTree ok with empty tree. - .mockResolvedValueOnce({ - ok: true, - data: { tree: [] }, - }) - const result = await scanOneRepo('repo', { - githubApiUrl: '', - githubToken: '', - orgSlug: 'o', - orgGithub: 'g', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.scanCreated).toBe(false) - } - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/create-scan-from-github.test.mts b/packages/cli/test/unit/commands/scan/create-scan-from-github.test.mts deleted file mode 100644 index 7e85caf4f..000000000 --- a/packages/cli/test/unit/commands/scan/create-scan-from-github.test.mts +++ /dev/null @@ -1,668 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for GitHub scan internal functions. - * - * Purpose: Tests the internal functions that interact with GitHub API during - * scans. Validates proper error handling, rate limit detection, and data - * extraction from GitHub API responses. - * - * Test Coverage: - getRepoDetails: fetching repository metadata - - * getRepoBranchTree: fetching file tree - getLastCommitDetails: fetching latest - * commit SHA - Error handling for rate limits and API failures. - * - * Testing Approach: Mocks Octokit to test the scan functions without actual - * GitHub API calls. Tests verify proper error propagation and user-friendly - * error messages. - * - * Related Files: - src/commands/scan/create-scan-from-github.mts - * (implementation) - src/util/git/github.mts (GitHub utilities) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockOctokit = vi.hoisted(() => ({ - repos: { - get: vi.fn(), - listCommits: vi.fn(), - getContent: vi.fn(), - }, - git: { - getTree: vi.fn(), - }, -})) - -const mockWithGitHubRetry = vi.hoisted(() => - vi.fn(async (operation: () => Promise<unknown>, context: string) => { - try { - const result = await operation() - return { ok: true, data: result } - } catch (e) { - return { - ok: false, - message: 'GitHub API error', - cause: `Error while ${context}: ${e instanceof Error ? e.message : String(e)}`, - } - } - }), -) - -// Mock dependencies. -vi.mock('../../../../src/util/git/github.mts', () => ({ - GITHUB_ERR_ABUSE_DETECTION: 'GitHub abuse detection triggered', - GITHUB_ERR_AUTH_FAILED: 'GitHub authentication failed', - GITHUB_ERR_GRAPHQL_RATE_LIMIT: 'GitHub GraphQL rate limit exceeded', - GITHUB_ERR_RATE_LIMIT: 'GitHub rate limit exceeded', - getOctokit: vi.fn(() => mockOctokit), - withGitHubRetry: mockWithGitHubRetry, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: vi.fn(() => ({ - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - })), -})) - -// Mock other dependencies to isolate the functions under test. -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: vi.fn().mockResolvedValue({ - ok: true, - data: ['package.json', 'package-lock.json', 'yarn.lock'], - }), - }), -) - -vi.mock('../../../../src/commands/scan/handle-create-new-scan.mts', () => ({ - handleCreateNewScan: vi.fn().mockResolvedValue({ ok: true, data: undefined }), -})) - -vi.mock('../../../../src/commands/repository/fetch-list-all-repos.mts', () => ({ - fetchListAllRepos: vi.fn().mockResolvedValue({ - ok: true, - data: { results: [{ slug: 'test-repo' }] }, - }), -})) - -// Import after mocks are set up. -// Note: We can't directly import the internal functions as they're not exported. -// Instead, we test them through the exported createScanFromGithub function -// or test the withGitHubRetry wrapper behavior. - -describe('GitHub scan API interactions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('getRepoDetails behavior', () => { - it('handles successful repo details fetch', async () => { - mockOctokit.repos.get.mockResolvedValue({ - data: { - default_branch: 'main', - name: 'test-repo', - full_name: 'org/test-repo', - }, - }) - - // Call the mock to simulate the behavior. - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.repos.get({ - owner: 'org', - repo: 'test-repo', - }) - return data - }, 'fetching repository details for org/test-repo') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.default_branch).toBe('main') - } - }) - - it('handles rate limit error from repo details', async () => { - mockOctokit.repos.get.mockRejectedValue( - new Error('API rate limit exceeded'), - ) - - // Simulate withGitHubRetry returning a rate limit error. - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: - 'GitHub API rate limit exceeded while fetching repository details. ' + - 'Try again in a few minutes.\n\n' + - 'To increase your rate limit:\n' + - '- Set GITHUB_TOKEN environment variable', - }) - - const result = await mockWithGitHubRetry( - async () => mockOctokit.repos.get({ owner: 'org', repo: 'test-repo' }), - 'fetching repository details', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - expect(result.cause).toContain('GITHUB_TOKEN') - } - }) - - it('handles 404 not found for repo', async () => { - mockOctokit.repos.get.mockRejectedValue(new Error('Not Found')) - - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub resource not found', - cause: - 'GitHub resource not found while fetching repository details. ' + - 'The repository may not exist or you may not have access.', - }) - - const result = await mockWithGitHubRetry( - async () => - mockOctokit.repos.get({ owner: 'org', repo: 'nonexistent' }), - 'fetching repository details', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub resource not found') - } - }) - }) - - describe('getRepoBranchTree behavior', () => { - it('handles successful tree fetch', async () => { - mockOctokit.git.getTree.mockResolvedValue({ - data: { - sha: 'abc123', - tree: [ - { type: 'blob', path: 'package.json' }, - { type: 'blob', path: 'src/index.ts' }, - { type: 'tree', path: 'src' }, - ], - }, - }) - - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.git.getTree({ - owner: 'org', - repo: 'test-repo', - tree_sha: 'main', - recursive: 'true', - }) - return data - }, 'fetching file tree for branch main in org/test-repo') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.tree).toHaveLength(3) - // Should filter to only blobs. - const files = result.data.tree - .filter((obj: unknown) => obj.type === 'blob') - .map((obj: unknown) => obj.path) - expect(files).toEqual(['package.json', 'src/index.ts']) - } - }) - - it('handles rate limit error during tree fetch', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'GitHub API rate limit exceeded while fetching file tree.', - }) - - const result = await mockWithGitHubRetry( - async () => mockOctokit.git.getTree({ owner: 'org', repo: 'test' }), - 'fetching file tree', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - } - }) - - it('handles empty tree (empty repo)', async () => { - mockOctokit.git.getTree.mockResolvedValue({ - data: { - sha: 'abc123', - tree: [], - }, - }) - - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.git.getTree({ - owner: 'org', - repo: 'empty-repo', - tree_sha: 'main', - recursive: 'true', - }) - return data - }, 'fetching file tree') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.tree).toHaveLength(0) - } - }) - }) - - describe('getLastCommitDetails behavior', () => { - it('handles successful commit fetch', async () => { - mockOctokit.repos.listCommits.mockResolvedValue({ - data: [ - { - sha: 'abc123def456', - commit: { - message: 'feat: add new feature', - author: { name: 'John Doe' }, - committer: { name: 'John Doe' }, - }, - }, - ], - }) - - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.repos.listCommits({ - owner: 'org', - repo: 'test-repo', - sha: 'main', - per_page: 1, - }) - return data - }, 'fetching latest commit SHA for org/test-repo') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data[0].sha).toBe('abc123def456') - expect(result.data[0].commit.message).toBe('feat: add new feature') - } - }) - - it('handles rate limit error during commit fetch (the original bug)', async () => { - // This is the exact scenario that caused "Cannot read properties of undefined (reading 'sha')". - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: - 'GitHub API rate limit exceeded while fetching latest commit SHA. ' + - 'Try again in a few minutes.', - }) - - const result = await mockWithGitHubRetry( - async () => - mockOctokit.repos.listCommits({ owner: 'org', repo: 'test' }), - 'fetching latest commit SHA', - ) - - // With the fix, we get a proper error instead of crashing. - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - // Should NOT crash with "Cannot read properties of undefined (reading 'sha')". - } - }) - - it('handles empty commits response', async () => { - mockOctokit.repos.listCommits.mockResolvedValue({ - data: [], - }) - - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.repos.listCommits({ - owner: 'org', - repo: 'empty-repo', - sha: 'main', - per_page: 1, - }) - return data - }, 'fetching latest commit') - - expect(result.ok).toBe(true) - if (result.ok) { - // The actual function checks for empty commits. - expect(result.data).toHaveLength(0) - } - }) - }) - - describe('downloadManifestFile behavior', () => { - it('handles successful file content fetch', async () => { - mockOctokit.repos.getContent.mockResolvedValue({ - data: { - type: 'file', - content: Buffer.from('{"name": "test"}').toString('base64'), - download_url: - 'https://raw.githubusercontent.com/org/repo/main/package.json', - size: 16, - }, - }) - - const result = await mockWithGitHubRetry(async () => { - const { data } = await mockOctokit.repos.getContent({ - owner: 'org', - repo: 'test-repo', - path: 'package.json', - ref: 'main', - }) - return data - }, 'fetching file content for package.json in org/test-repo') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.type).toBe('file') - expect(result.data.download_url).toContain('raw.githubusercontent.com') - } - }) - - it('handles rate limit during file fetch', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'GitHub API rate limit exceeded while fetching file content.', - }) - - const result = await mockWithGitHubRetry( - async () => - mockOctokit.repos.getContent({ - owner: 'org', - repo: 'test', - path: 'package.json', - }), - 'fetching file content', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - } - }) - }) -}) - -describe('error message quality', () => { - it('provides actionable error messages for rate limits', () => { - const errorResult = { - ok: false as const, - message: 'GitHub rate limit exceeded', - cause: - 'GitHub API rate limit exceeded while fetching commits. ' + - 'Try again in a few minutes.\n\n' + - 'To increase your rate limit:\n' + - '- Set GITHUB_TOKEN environment variable with a valid token\n' + - '- In GitHub Actions, GITHUB_TOKEN is automatically available', - } - - expect(errorResult.cause).toContain('GITHUB_TOKEN') - expect(errorResult.cause).toContain('GitHub Actions') - expect(errorResult.cause).toContain('Try again') - }) - - it('provides context-specific error messages', () => { - const contexts = [ - 'fetching repository details for org/repo', - 'fetching file tree for branch main in org/repo', - 'fetching latest commit SHA for org/repo', - 'fetching file content for package.json in org/repo', - ] - - for (let i = 0, { length } = contexts; i < length; i += 1) { - const context = contexts[i] - const errorResult = { - ok: false as const, - message: 'GitHub API error', - cause: `Unexpected error while ${context}: Network failure`, - } - - expect(errorResult.cause).toContain(context) - } - }) -}) - -// Regression tests: the bulk loop in createScanFromGithub used to -// swallow per-repo failures, so a rate-limited token returned ok:true -// with "0 manifests". These drive the full function through mocked -// octokit calls. -describe('createScanFromGithub rate-limit short-circuit', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns ok:false and stops the loop on GitHub rate limit', async () => { - // First call (getRepoDetails for repo-a) fails with rate limit. - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub rate limit exceeded', - cause: 'GitHub API rate limit exceeded.', - }) - - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - - const result = await createScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b,repo-c', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub rate limit exceeded') - } - // Short-circuit: only the first repo's getRepoDetails should have run. - expect(mockWithGitHubRetry).toHaveBeenCalledTimes(1) - }) - - it('returns ok:false and stops on GitHub GraphQL rate limit', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub GraphQL rate limit exceeded', - cause: 'GraphQL rate limit hit.', - }) - - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - - const result = await createScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b,repo-c', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub GraphQL rate limit exceeded') - } - expect(mockWithGitHubRetry).toHaveBeenCalledTimes(1) - }) - - it('returns ok:false and stops on GitHub abuse detection', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub abuse detection triggered', - cause: 'Secondary rate limit hit.', - }) - - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - - const result = await createScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b,repo-c', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub abuse detection triggered') - } - expect(mockWithGitHubRetry).toHaveBeenCalledTimes(1) - }) - - it('returns ok:false and stops on GitHub auth failure', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub authentication failed', - cause: 'Bad credentials.', - }) - - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - - const result = await createScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub authentication failed') - } - expect(mockWithGitHubRetry).toHaveBeenCalledTimes(1) - }) - - it('returns "All repos failed to scan" when every repo errors with a non-blocking reason', async () => { - // Each repo's getRepoDetails fails with a non-rate-limit error; - // the loop should finish all repos and return the catch-all error. - mockWithGitHubRetry.mockResolvedValue({ - ok: false, - message: 'GitHub resource not found', - cause: 'Not found.', - }) - - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - - const result = await createScanFromGithub({ - all: false, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: 'repo-a,repo-b', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('All repos failed to scan') - expect(result.cause).toContain('repo-a') - } - // Both repos should have been attempted (no short-circuit for 404). - expect(mockWithGitHubRetry).toHaveBeenCalledTimes(2) - }) - - it('uses fetchListAllRepos when all=true (lines 57-64)', async () => { - const { fetchListAllRepos } = - await import('../../../../src/commands/repository/fetch-list-all-repos.mts') - vi.mocked(fetchListAllRepos).mockResolvedValueOnce({ - ok: true, - data: { results: [{ slug: 'a' }, { slug: 'b' }] }, - } as unknown) - // Make all repos fail with a quick 404 so the test exits cleanly. - mockWithGitHubRetry.mockResolvedValue({ - ok: false, - message: 'GitHub resource not found', - cause: 'Not found.', - }) - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - const result = await createScanFromGithub({ - all: true, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(fetchListAllRepos).toHaveBeenCalled() - expect(result.ok).toBe(false) - }) - - it('returns ok:false on fetchListAllRepos failure (lines 61-62)', async () => { - const { fetchListAllRepos } = - await import('../../../../src/commands/repository/fetch-list-all-repos.mts') - vi.mocked(fetchListAllRepos).mockResolvedValueOnce({ - ok: false, - message: 'API Error', - cause: 'Something broke', - } as unknown) - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - const result = await createScanFromGithub({ - all: true, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('API Error') - } - }) - - it('returns "No repo found" when targetRepos is empty (lines 73-79)', async () => { - const { fetchListAllRepos } = - await import('../../../../src/commands/repository/fetch-list-all-repos.mts') - vi.mocked(fetchListAllRepos).mockResolvedValueOnce({ - ok: true, - data: { results: [] }, - } as unknown) - const { createScanFromGithub } = - await import('../../../../src/commands/scan/create-scan-from-github.mts') - const result = await createScanFromGithub({ - all: true, - githubApiUrl: '', - githubToken: '', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('No repo found') - } - }) -}) diff --git a/packages/cli/test/unit/commands/scan/exclude-paths.test.mts b/packages/cli/test/unit/commands/scan/exclude-paths.test.mts deleted file mode 100644 index de66b0636..000000000 --- a/packages/cli/test/unit/commands/scan/exclude-paths.test.mts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Unit tests for exclude-paths helpers. - * - * Validates the helpers that translate the user-facing --exclude-paths flag - * into projectIgnorePaths and Coana --exclude-dirs values. - */ - -import { describe, expect, it } from 'vitest' - -import { - applyFullExcludePaths, - assertNoNegationPatterns, - excludePathToProjectIgnorePath, - pathRelativeToTarget, - projectIgnorePathsToReachExcludePaths, -} from '../../../../src/commands/scan/exclude-paths.mts' -import { InputError } from '../../../../src/util/error/errors.mts' - -describe('exclude-paths', () => { - describe('assertNoNegationPatterns', () => { - it('allows positive patterns', () => { - expect(() => - assertNoNegationPatterns(['tests', 'packages/*']), - ).not.toThrow() - }) - - it('rejects negation patterns', () => { - expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( - InputError, - ) - expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( - "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", - ) - }) - }) - - describe('excludePathToProjectIgnorePath', () => { - it.each([ - ['packages/*', 'packages/*/**'], - ['tests', 'tests/**'], - ['tests/', 'tests/**'], - ['tests/**', 'tests/**'], - ])('converts %s to %s', (input, expected) => { - expect(excludePathToProjectIgnorePath(input)).toBe(expected) - }) - }) - - describe('projectIgnorePathsToReachExcludePaths', () => { - it('normalizes positive project ignore paths for Coana', () => { - expect( - projectIgnorePathsToReachExcludePaths( - ['tests', 'dist/', 'fixtures/**'], - { - cwd: '/repo', - target: '/repo', - }, - ), - ).toEqual([ - '**/tests', - '**/tests/**', - '**/dist', - '**/dist/**', - 'fixtures/**', - ]) - }) - - it('keeps project-root paths relative to nested Coana targets', () => { - expect( - projectIgnorePathsToReachExcludePaths( - ['tests/**', 'apps/api/tests/**', 'apps/api/packages/*/**'], - { - cwd: '/repo', - target: '/repo/apps/api', - }, - ), - ).toEqual(['tests/**', 'packages/*/**']) - }) - - it('returns no paths when project ignore paths use negation', () => { - expect( - projectIgnorePathsToReachExcludePaths( - ['fixtures/**', '!fixtures/keep'], - { - cwd: '/repo', - target: '/repo', - }, - ), - ).toEqual([]) - }) - - it('passes ** through verbatim from expandReachExcludePath', () => { - // Path that exactly equals target → translates to '**'. - expect( - projectIgnorePathsToReachExcludePaths(['apps/api'], { - cwd: '/repo', - target: 'apps/api', - }), - ).toEqual(['**']) - }) - - it('strips recursive ${target}/**/ prefix (line 175-178)', () => { - // When the path begins with target/**/, only the targetPrefix is - // sliced off; the **/ remainder stays. (The wrapping - // projectIgnorePaths→reach expansion adds the trailing /** variant.) - expect( - projectIgnorePathsToReachExcludePaths(['apps/api/**/dist'], { - cwd: '/repo', - target: 'apps/api', - }), - ).toEqual(['**/dist', '**/dist/**']) - }) - }) - - describe('applyFullExcludePaths', () => { - it('returns input config unchanged when no exclude paths are provided', () => { - const reachabilityOptions = { - excludePaths: [], - reachExcludePaths: ['existing'], - } as unknown - const socketConfig = { foo: 'bar' } as unknown - - const result = applyFullExcludePaths({ - cwd: '/repo', - reachabilityOptions, - socketConfig, - target: '.', - }) - - expect(result.effectiveSocketConfig).toBe(socketConfig) - expect(result.mergedReachabilityOptions).toBe(reachabilityOptions) - }) - - it('merges excludePaths into projectIgnorePaths and reach excludes', () => { - const reachabilityOptions = { - excludePaths: ['tests', 'fixtures'], - reachExcludePaths: ['existing-reach'], - } as unknown - const socketConfig = { - version: 2, - issueRules: { x: true }, - githubApp: {}, - projectIgnorePaths: ['cfg-ignore'], - } as unknown - - const result = applyFullExcludePaths({ - cwd: '/repo', - reachabilityOptions, - socketConfig, - target: '.', - }) - - expect(result.effectiveSocketConfig.projectIgnorePaths).toEqual( - expect.arrayContaining(['cfg-ignore']), - ) - expect(result.mergedReachabilityOptions.reachExcludePaths).toEqual( - expect.arrayContaining(['existing-reach']), - ) - }) - - it('initializes config defaults when socketConfig is missing fields', () => { - const result = applyFullExcludePaths({ - cwd: '/repo', - reachabilityOptions: { - excludePaths: ['tests'], - reachExcludePaths: [], - } as unknown, - socketConfig: undefined, - target: '.', - }) - - expect(result.effectiveSocketConfig).toBeDefined() - expect(result.effectiveSocketConfig?.version).toBe(2) - expect(result.effectiveSocketConfig?.projectIgnorePaths).toEqual( - expect.arrayContaining([expect.any(String)]), - ) - }) - }) - - describe('pathRelativeToTarget', () => { - it('returns the normalized path when target is "."', () => { - expect(pathRelativeToTarget('foo/bar', '.')).toBe('foo/bar') - }) - - it('returns the normalized path when target is empty string', () => { - expect(pathRelativeToTarget('foo/bar', '')).toBe('foo/bar') - }) - - it('returns "**" when path equals target', () => { - expect(pathRelativeToTarget('packages/cli', 'packages/cli')).toBe('**') - }) - - it('strips the target prefix from a nested path', () => { - expect(pathRelativeToTarget('packages/cli/src/foo', 'packages/cli')).toBe( - 'src/foo', - ) - }) - - it('strips the target prefix when path uses recursive **/ prefix (line 177)', () => { - // path = "packages/cli/**/dist/foo" with target = "packages/cli" - // → matches recursiveTargetPrefix "packages/cli/**/" branch. - expect( - pathRelativeToTarget('packages/cli/**/dist/foo', 'packages/cli'), - ).toBe('**/dist/foo') - }) - - it('returns undefined when path is outside the target', () => { - expect(pathRelativeToTarget('other/dir', 'packages/cli')).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-create-org-full-scan.test.mts b/packages/cli/test/unit/commands/scan/fetch-create-org-full-scan.test.mts deleted file mode 100644 index 77340c11d..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-create-org-full-scan.test.mts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Unit tests for fetchCreateOrgFullScan. - * - * Purpose: Tests creating organization-wide full security scans via the Socket - * API. Validates scan configuration, project selection, and scan - * initialization. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Scan - * configuration options - Project selection - Full scan parameters - Null - * prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/CreateOrgFullScan.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -describe('fetchCreateOrgFullScan', () => { - it('creates org full scan successfully', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'createFullScan', - { - scanId: 'scan-123', - status: 'pending', - }, - ) - - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Initial commit', - committers: 'john@example.com', - pullRequest: 42, - repoName: 'test-repo', - } - - const result = await fetchCreateOrgFullScan( - ['/path/to/package.json'], - 'test-org', - config, - ) - - expect(mockSdk.createFullScan).toHaveBeenCalledWith( - 'test-org', - ['/path/to/package.json'], - { - pathsRelativeTo: process.cwd(), - branch: 'main', - commit_hash: 'abc123', - commit_message: 'Initial commit', - committers: 'john@example.com', - pull_request: '42', - repo: 'test-repo', - }, - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'to create a scan', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - await setupSdkSetupFailure('Failed to setup SDK', { - cause: 'Invalid configuration', - }) - - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Initial commit', - committers: 'john@example.com', - pullRequest: 42, - repoName: 'test-repo', - } - - const result = await fetchCreateOrgFullScan( - ['/path/to/package.json'], - 'test-org', - config, - ) - - expect(result).toEqual({ - ok: false, - code: 1, - message: 'Failed to setup SDK', - cause: 'Invalid configuration', - }) - }) - - it('handles API call failure', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - await setupSdkMockError('createFullScan', 'Failed to create scan', 500) - - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Initial commit', - committers: 'john@example.com', - pullRequest: 42, - repoName: 'test-repo', - } - - const result = await fetchCreateOrgFullScan( - ['/path/to/package.json'], - 'test-org', - config, - ) - - expect(result.ok).toBe(false) - expect(result.code).toBe(500) - }) - - it('passes custom SDK options and scan options', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockSdk, mockSetupSdk } = await setupSdkMockSuccess( - 'createFullScan', - {}, - ) - - const config = { - branchName: 'develop', - commitHash: 'xyz789', - commitMessage: 'Feature commit', - committers: 'jane@example.com', - pullRequest: 123, - repoName: 'feature-repo', - } - - const options = { - cwd: '/custom/path', - defaultBranch: true, - pendingHead: false, - sdkOpts: { - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - }, - tmp: true, - } - - await fetchCreateOrgFullScan( - ['/path/to/package.json'], - 'custom-org', - config, - options, - ) - - expect(mockSetupSdk).toHaveBeenCalledWith(options.sdkOpts) - expect(mockSdk.createFullScan).toHaveBeenCalledWith( - 'custom-org', - ['/path/to/package.json'], - { - pathsRelativeTo: '/custom/path', - branch: 'develop', - commit_hash: 'xyz789', - commit_message: 'Feature commit', - committers: 'jane@example.com', - make_default_branch: true, - pull_request: '123', - repo: 'feature-repo', - set_as_pending_head: false, - tmp: true, - }, - ) - }) - - it('handles empty optional config values', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockSdk } = await setupSdkMockSuccess('createFullScan', {}) - - const config = { - branchName: '', - commitHash: '', - commitMessage: '', - committers: '', - pullRequest: 0, - repoName: 'test-repo', - } - - await fetchCreateOrgFullScan(['/path/to/package.json'], 'test-org', config) - - expect(mockSdk.createFullScan).toHaveBeenCalledWith( - 'test-org', - ['/path/to/package.json'], - { - pathsRelativeTo: process.cwd(), - repo: 'test-repo', - }, - ) - }) - - it('handles multiple package paths', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockSdk } = await setupSdkMockSuccess('createFullScan', {}) - - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Multi-package commit', - committers: 'dev@example.com', - pullRequest: 1, - repoName: 'mono-repo', - } - - const packagePaths = [ - '/path/to/frontend/package.json', - '/path/to/backend/package.json', - '/path/to/shared/package.json', - ] - - await fetchCreateOrgFullScan(packagePaths, 'mono-org', config) - - expect(mockSdk.createFullScan).toHaveBeenCalledWith( - 'mono-org', - packagePaths, - expect.objectContaining({ - pathsRelativeTo: process.cwd(), - }), - ) - }) - - it('uses null prototype for config and options', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockSdk } = await setupSdkMockSuccess('createFullScan', {}) - - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Test commit', - committers: 'test@example.com', - pullRequest: 1, - repoName: 'test-repo', - } - - // This tests that the function properly uses __proto__: null. - await fetchCreateOrgFullScan(['/path/to/package.json'], 'test-org', config) - - // The function should work without prototype pollution issues. - expect(mockSdk.createFullScan).toHaveBeenCalled() - }) - - it('handles edge cases for different org slugs and repo names', async () => { - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const { mockSdk } = await setupSdkMockSuccess('createFullScan', {}) - - const testCases = [ - ['org-with-dashes', 'repo-with-dashes'], - ['simple_org', 'repo_with_underscore'], - ['org123', 'repo.with.dots'], - ] - - for (const [org, repo] of testCases) { - const config = { - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'Test commit', - committers: 'test@example.com', - pullRequest: 1, - repoName: repo, - } - - await fetchCreateOrgFullScan(['/path/to/package.json'], org, config) - - expect(mockSdk.createFullScan).toHaveBeenCalledWith( - org, - ['/path/to/package.json'], - expect.objectContaining({ - pathsRelativeTo: process.cwd(), - repo, - }), - ) - } - }) - - it('omits repoName, scanType, and workspace when not provided', async () => { - const { mockSdk } = await setupSdkMockSuccess('createFullScan', { - id: 'scan-no-opts', - }) - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const config = { - branchName: '', - commitHash: '', - commitMessage: '', - committers: '', - pullRequest: 0, - // No repoName, no scanType, no workspace. - } - - await fetchCreateOrgFullScan( - ['/p/package.json'], - 'my-org', - config as unknown, - ) - - // Confirm the SDK was called WITHOUT those keys. - const callArgs = mockSdk.createFullScan.mock.calls[0]![2] - expect(callArgs.repo).toBeUndefined() - expect(callArgs.scan_type).toBeUndefined() - expect(callArgs.workspace).toBeUndefined() - }) - - it('includes scanType and workspace when both provided', async () => { - const { mockSdk } = await setupSdkMockSuccess('createFullScan', { - id: 'scan-with-opts', - }) - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - const config = { - branchName: 'main', - commitHash: 'abc', - commitMessage: 'msg', - committers: 'me', - pullRequest: 1, - repoName: 'r', - scanType: 'tier1', - workspace: 'workspace-1', - } - - await fetchCreateOrgFullScan( - ['/p/package.json'], - 'my-org', - config as unknown, - ) - - const callArgs = mockSdk.createFullScan.mock.calls[0]![2] - expect(callArgs.scan_type).toBe('tier1') - expect(callArgs.workspace).toBe('workspace-1') - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-delete-org-full-scan.test.mts b/packages/cli/test/unit/commands/scan/fetch-delete-org-full-scan.test.mts deleted file mode 100644 index fda0b06f0..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-delete-org-full-scan.test.mts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Unit tests for fetchDeleteOrgFullScan. - * - * Purpose: Tests deleting organization-wide full scans via the Socket API. - * Validates scan deletion and cleanup operations. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Null - * prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/DeleteOrgFullScan.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' -import { fetchDeleteOrgFullScan } from '../../../../src/commands/scan/fetch-delete-org-full-scan.mts' - -// Mock the dependencies. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -describe('fetchDeleteOrgFullScan', () => { - it('deletes scan successfully', async () => { - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'deleteFullScan', - { - deleted: true, - scanId: 'scan-123', - }, - ) - - const result = await fetchDeleteOrgFullScan('test-org', 'scan-123') - - expect(mockSdk.deleteFullScan).toHaveBeenCalledWith('test-org', 'scan-123') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'to delete a scan', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - await setupSdkSetupFailure('Failed to setup SDK', { - cause: 'Invalid configuration', - }) - - const result = await fetchDeleteOrgFullScan('org', 'scan-456') - - expect(result).toEqual({ - ok: false, - code: 1, - message: 'Failed to setup SDK', - cause: 'Invalid configuration', - }) - }) - - it('handles API call failure', async () => { - await setupSdkMockError('deleteFullScan', 'Scan not found', 404) - - const result = await fetchDeleteOrgFullScan('org', 'nonexistent-scan') - - expect(result.ok).toBe(false) - expect(result.code).toBe(404) - }) - - it('passes custom SDK options', async () => { - const { mockSetupSdk } = await setupSdkMockSuccess('deleteFullScan', {}) - - const sdkOpts = { - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - } - - await fetchDeleteOrgFullScan('org', 'scan', { sdkOpts }) - - expect(mockSetupSdk).toHaveBeenCalledWith(sdkOpts) - }) - - it('handles different org slugs and scan IDs', async () => { - const { mockSdk } = await setupSdkMockSuccess('deleteFullScan', {}) - - const testCases = [ - ['org-with-dashes', 'scan-123'], - ['simple_org', 'uuid-456-789'], - ['org123', 'scan_with_underscore'], - ] - - for (const [org, scanId] of testCases) { - await fetchDeleteOrgFullScan(org, scanId) - expect(mockSdk.deleteFullScan).toHaveBeenCalledWith(org, scanId) - } - }) - - it('uses null prototype for options', async () => { - const { mockSdk } = await setupSdkMockSuccess('deleteFullScan', {}) - - // This tests that the function properly uses __proto__: null. - await fetchDeleteOrgFullScan('org', 'scan') - - // The function should work without prototype pollution issues. - expect(mockSdk.deleteFullScan).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-diff-scan.test.mts b/packages/cli/test/unit/commands/scan/fetch-diff-scan.test.mts deleted file mode 100644 index fd0476d6f..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-diff-scan.test.mts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Unit tests for fetchDiffScan. - * - * Purpose: Tests fetching scan diffs via the Socket API. Compares two scans to - * identify changes in security posture. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Scan - * comparison - Diff calculation - Change detection - Null prototype usage for - * security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/DiffScan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchDiffScan } from '../../../../src/commands/scan/fetch-diff-scan.mts' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockQueryApiSafeJson = vi.hoisted(() => vi.fn()) -const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => 'test-token')) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, -})) - -describe('fetchDiffScan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches diff scan successfully', async () => { - const mockQueryApi = mockQueryApiSafeJson - - const mockDiffData = { - added: ['package-a@1.0.0'], - removed: ['package-b@1.0.0'], - modified: ['package-c@1.0.0 -> 1.1.0'], - issues: { - new: ['CVE-2023-001'], - resolved: ['CVE-2023-002'], - }, - } - - mockQueryApi.mockResolvedValue({ - ok: true, - data: mockDiffData, - }) - - const result = await fetchDiffScan({ - id1: 'scan-123', - id2: 'scan-456', - orgSlug: 'test-org', - }) - - expect(mockLogger.info).toHaveBeenCalledWith('Scan ID 1:', 'scan-123') - expect(mockLogger.info).toHaveBeenCalledWith('Scan ID 2:', 'scan-456') - expect(mockLogger.info).toHaveBeenCalledWith( - 'Note: this request may take some time if the scans are big', - ) - expect(mockQueryApi).toHaveBeenCalledWith( - 'orgs/test-org/full-scans/diff?before=scan-123&after=scan-456', - 'a scan diff', - ) - expect(result.ok).toBe(true) - expect(result.data).toEqual(mockDiffData) - }) - - it('handles API call failure', async () => { - const mockQueryApi = mockQueryApiSafeJson - - const error = { - ok: false, - code: 404, - message: 'Scan not found', - cause: 'One or both scans do not exist', - } - mockQueryApi.mockResolvedValue(error) - - const result = await fetchDiffScan({ - id1: 'nonexistent-scan', - id2: 'another-nonexistent-scan', - orgSlug: 'test-org', - }) - - expect(result).toEqual(error) - }) - - it('properly URL encodes scan IDs', async () => { - const mockQueryApi = mockQueryApiSafeJson - - mockQueryApi.mockResolvedValue({ - ok: true, - data: {}, - }) - - const specialCharsId1 = 'scan+with%special&chars' - const specialCharsId2 = 'another/scan?with=query' - - await fetchDiffScan({ - id1: specialCharsId1, - id2: specialCharsId2, - orgSlug: 'test-org', - }) - - expect(mockQueryApi).toHaveBeenCalledWith( - 'orgs/test-org/full-scans/diff?before=scan%2Bwith%25special%26chars&after=another%2Fscan%3Fwith%3Dquery', - 'a scan diff', - ) - }) - - it('handles different org slugs', async () => { - const mockQueryApi = mockQueryApiSafeJson - - mockQueryApi.mockResolvedValue({ - ok: true, - data: {}, - }) - - const testCases = [ - 'org-with-dashes', - 'simple_org', - 'org123', - 'long.org.name.with.dots', - ] - - for (let i = 0, { length } = testCases; i < length; i += 1) { - const orgSlug = testCases[i] - await fetchDiffScan({ - id1: 'scan-1', - id2: 'scan-2', - orgSlug, - }) - - expect(mockQueryApi).toHaveBeenCalledWith( - `orgs/${orgSlug}/full-scans/diff?before=scan-1&after=scan-2`, - 'a scan diff', - ) - } - }) - - it('handles empty diff results', async () => { - const mockQueryApi = mockQueryApiSafeJson - - const emptyDiffData = { - added: [], - removed: [], - modified: [], - issues: { - new: [], - resolved: [], - }, - } - - mockQueryApi.mockResolvedValue({ - ok: true, - data: emptyDiffData, - }) - - const result = await fetchDiffScan({ - id1: 'scan-identical-1', - id2: 'scan-identical-2', - orgSlug: 'test-org', - }) - - expect(result.ok).toBe(true) - expect(result.data).toEqual(emptyDiffData) - }) - - it('handles same scan IDs gracefully', async () => { - const mockQueryApi = mockQueryApiSafeJson - - mockQueryApi.mockResolvedValue({ - ok: true, - data: { - added: [], - removed: [], - modified: [], - issues: { new: [], resolved: [] }, - }, - }) - - await fetchDiffScan({ - id1: 'same-scan-id', - id2: 'same-scan-id', - orgSlug: 'test-org', - }) - - expect(mockLogger.info).toHaveBeenCalledWith('Scan ID 1:', 'same-scan-id') - expect(mockLogger.info).toHaveBeenCalledWith('Scan ID 2:', 'same-scan-id') - expect(mockQueryApi).toHaveBeenCalledWith( - 'orgs/test-org/full-scans/diff?before=same-scan-id&after=same-scan-id', - 'a scan diff', - ) - }) - - it('handles server timeout gracefully', async () => { - const mockQueryApi = mockQueryApiSafeJson - - const timeoutError = { - ok: false, - code: 504, - message: 'Gateway timeout', - cause: 'The request took too long to process', - } - mockQueryApi.mockResolvedValue(timeoutError) - - const result = await fetchDiffScan({ - id1: 'large-scan-1', - id2: 'large-scan-2', - orgSlug: 'test-org', - }) - - expect(result).toEqual(timeoutError) - }) - - it('uses null prototype internally', async () => { - const mockQueryApi = mockQueryApiSafeJson - - mockQueryApi.mockResolvedValue({ - ok: true, - data: {}, - }) - - // This tests that the function works without prototype pollution issues. - await fetchDiffScan({ - id1: 'scan-1', - id2: 'scan-2', - orgSlug: 'test-org', - }) - - // The function should work properly. - expect(mockQueryApi).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-list-scans.test.mts b/packages/cli/test/unit/commands/scan/fetch-list-scans.test.mts deleted file mode 100644 index 81c874f8c..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-list-scans.test.mts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Unit tests for fetchListScans. - * - * Purpose: Tests listing security scans via the Socket API. Validates scan - * history retrieval and filtering. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Scan - * filtering - Pagination - Sort options - Null prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/ListScans.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { setupSdkMockSuccess } from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchOrgFullScanList', () => { - it('fetches scan list successfully', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'listFullScans', - { - scans: [ - { id: 'scan-123', status: 'completed' }, - { id: 'scan-456', status: 'pending' }, - ], - }, - ) - - const config = { - branch: 'main', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: 'test-repo', - sort: 'created_at', - } - - const result = await fetchOrgFullScanList(config) - - expect(mockSdk.listFullScans).toHaveBeenCalledWith('test-org', { - branch: 'main', - repo: 'test-repo', - sort: 'created_at', - direction: 'desc', - from: '2023-01-01', - page: 1, - per_page: 10, - }) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'list of scans', - }) - expect(result.ok).toBe(true) - }) - - it('handles SDK setup failure', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - const { setupSdkSetupFailure } = - await import('../../../helpers/sdk-test-helpers.mts') - - await setupSdkSetupFailure('Failed to setup SDK', { - cause: 'Invalid configuration', - }) - - const config = { - branch: 'main', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: 'test-repo', - sort: 'created_at', - } - - const result = await fetchOrgFullScanList(config) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - expect(result.cause).toBe('Invalid configuration') - }) - - it('handles API call failure', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - const { setupSdkMockError } = - await import('../../../helpers/sdk-test-helpers.mts') - - await setupSdkMockError('listFullScans', 'API error', 500) - - const config = { - branch: 'main', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: 'test-repo', - sort: 'created_at', - } - - const result = await fetchOrgFullScanList(config) - - expect(result.ok).toBe(false) - expect(result.code).toBe(500) - }) - - it('passes custom SDK options', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockSdk, mockSetupSdk } = await setupSdkMockSuccess( - 'listFullScans', - {}, - ) - - const config = { - branch: 'develop', - direction: 'asc', - from_time: '2023-06-01', - orgSlug: 'custom-org', - page: 2, - perPage: 25, - repo: 'custom-repo', - sort: 'updated_at', - } - - const options = { - sdkOpts: { - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - }, - } - - await fetchOrgFullScanList(config, options) - - expect(mockSetupSdk).toHaveBeenCalledWith(options.sdkOpts) - expect(mockSdk.listFullScans).toHaveBeenCalledWith('custom-org', { - branch: 'develop', - repo: 'custom-repo', - sort: 'updated_at', - direction: 'asc', - from: '2023-06-01', - page: 2, - per_page: 25, - }) - }) - - it('handles empty optional config values', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockSdk } = await setupSdkMockSuccess('listFullScans', {}) - - const config = { - branch: '', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: '', - sort: 'created_at', - } - - await fetchOrgFullScanList(config) - - expect(mockSdk.listFullScans).toHaveBeenCalledWith('test-org', { - sort: 'created_at', - direction: 'desc', - from: '2023-01-01', - page: 1, - per_page: 10, - }) - }) - - it('handles different pagination parameters', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockSdk } = await setupSdkMockSuccess('listFullScans', {}) - - const testCases = [ - { page: 1, perPage: 10 }, - { page: 5, perPage: 25 }, - { page: 10, perPage: 50 }, - { page: 100, perPage: 1 }, - ] - - for (const { page, perPage } of testCases) { - const config = { - branch: 'main', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page, - perPage, - repo: 'test-repo', - sort: 'created_at', - } - - await fetchOrgFullScanList(config) - - expect(mockSdk.listFullScans).toHaveBeenCalledWith('test-org', { - branch: 'main', - repo: 'test-repo', - sort: 'created_at', - direction: 'desc', - from: '2023-01-01', - page, - per_page: perPage, - }) - } - }) - - it('handles different sort and direction combinations', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockSdk } = await setupSdkMockSuccess('listFullScans', {}) - - const testCases = [ - { sort: 'created_at', direction: 'asc' }, - { sort: 'created_at', direction: 'desc' }, - { sort: 'updated_at', direction: 'asc' }, - { sort: 'status', direction: 'desc' }, - ] - - for (const { direction, sort } of testCases) { - const config = { - branch: 'main', - direction, - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: 'test-repo', - sort, - } - - await fetchOrgFullScanList(config) - - expect(mockSdk.listFullScans).toHaveBeenCalledWith('test-org', { - branch: 'main', - repo: 'test-repo', - sort, - direction, - from: '2023-01-01', - page: 1, - per_page: 10, - }) - } - }) - - it('uses null prototype for config and options', async () => { - const { fetchOrgFullScanList } = - await import('../../../../src/commands/scan/fetch-list-scans.mts') - - const { mockSdk } = await setupSdkMockSuccess('listFullScans', {}) - - const config = { - branch: 'main', - direction: 'desc', - from_time: '2023-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 10, - repo: 'test-repo', - sort: 'created_at', - } - - // This tests that the function properly uses __proto__: null. - await fetchOrgFullScanList(config) - - // The function should work without prototype pollution issues. - expect(mockSdk.listFullScans).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-report-data.test.mts b/packages/cli/test/unit/commands/scan/fetch-report-data.test.mts deleted file mode 100644 index 8395545f5..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-report-data.test.mts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * Unit tests for fetchReportData. - * - * Purpose: Tests fetching detailed scan report data via the Socket API. - * Retrieves comprehensive scan results including alerts and scores. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Detailed - * report retrieval - Alert data - Score information - Null prototype usage for - * security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/ReportData.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { createErrorResult } from '../../../../test/helpers/index.mts' - -describe('fetchScanData', () => { - let mockSetupSdk: ReturnType<typeof vi.fn> - let mockQueryApiSafeText: ReturnType<typeof vi.fn> - let mockHandleApiCallNoSpinner: ReturnType<typeof vi.fn> - let mockFormatErrorWithDetail: ReturnType<typeof vi.fn> - let mockLogger: Record<string, ReturnType<typeof vi.fn>> - let mockSpinner: Record<string, ReturnType<typeof vi.fn>> - - beforeEach(async () => { - vi.resetModules() - - mockSetupSdk = vi.fn() - mockQueryApiSafeText = vi.fn() - mockHandleApiCallNoSpinner = vi.fn() - mockFormatErrorWithDetail = vi.fn((msg, _e) => msg) - - mockLogger = { - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - } - - mockSpinner = { - start: vi.fn(), - stop: vi.fn(), - } - - vi.doMock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), - })) - - vi.doMock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, - })) - - vi.doMock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockSpinner, - })) - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - handleApiCallNoSpinner: mockHandleApiCallNoSpinner, - queryApiSafeText: mockQueryApiSafeText, - })) - - vi.doMock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, - })) - - vi.doMock('../../../../src/util/error/errors.mjs', () => ({ - formatErrorWithDetail: mockFormatErrorWithDetail, - })) - }) - - it('handles SDK setup failure', async () => { - const error = createErrorResult('Failed to setup SDK', { - code: 1, - cause: 'Invalid configuration', - }) - - mockSetupSdk.mockResolvedValue(error) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - expect(mockSetupSdk).toHaveBeenCalled() - }) - - it('fetches scan data successfully', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - const scanData = [ - { id: '1', type: 'alert', severity: 'high' }, - { id: '2', type: 'alert', severity: 'medium' }, - ] - const ndJsonResponse = scanData.map(d => JSON.stringify(d)).join('\n') - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: ndJsonResponse, - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.scan).toHaveLength(2) - expect(result.data.securityPolicy).toEqual({ rules: [] }) - } - expect(mockSpinner.start).toHaveBeenCalled() - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('handles invalid JSON in scan response', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - // Return invalid JSON. - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: 'not valid json\n{"valid": true}', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Invalid Socket API response') - } - }) - - it('handles scan result API error', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockResolvedValue({ - ok: false, - message: 'API error', - cause: 'Network failure', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - }) - - it('handles security policy API error', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: '{"id": "1"}', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: false, - message: 'Policy fetch failed', - cause: 'Forbidden', - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - }) - - it('includes license policy when specified', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: '{"id": "1"}', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - await fetchScanData('test-org', 'scan-123', { includeLicensePolicy: true }) - - expect(mockQueryApiSafeText).toHaveBeenCalledWith( - expect.stringContaining('include_license_details=true'), - ) - }) - - it('handles thrown errors during scan fetch', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockRejectedValue(new Error('Network timeout')) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - }) - - it('handles thrown errors during policy fetch', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: '{"id": "1"}', - }) - - mockHandleApiCallNoSpinner.mockRejectedValue(new Error('Auth failed')) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(false) - }) - - it('passes SDK options when provided', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: '{"id": "1"}', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - await fetchScanData('test-org', 'scan-123', { - sdkOpts: { apiToken: 'custom-token' }, - }) - - expect(mockSetupSdk).toHaveBeenCalledWith( - expect.objectContaining({ apiToken: 'custom-token' }), - ) - }) - - it('filters empty lines from ndjson response', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - // Include empty lines. - const ndJsonResponse = '{"id": "1"}\n\n{"id": "2"}\n\n' - - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - data: ndJsonResponse, - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.scan).toHaveLength(2) - } - }) - - it('returns "Failed to fetch" error when scan data is empty (parses to empty)', async () => { - const mockSdk = { getOrgSecurityPolicy: vi.fn() } - mockSetupSdk.mockResolvedValue({ ok: true, data: mockSdk }) - - // Empty/whitespace input produces a scan.data that is undefined / not an array. - mockQueryApiSafeText.mockResolvedValue({ - ok: true, - // Single completely empty line — yields empty array which IS an Array. - data: ' ', - }) - - mockHandleApiCallNoSpinner.mockResolvedValue({ - ok: true, - data: { rules: [] }, - }) - - const { fetchScanData } = - await import('../../../../src/commands/scan/fetch-report-data.mts') - - const result = await fetchScanData('test-org', 'scan-123') - - // Whitespace-only NDJSON should still parse to an array (possibly empty) - // — not the "not an array" branch. Fine, just confirm no crash. - if (result.ok) { - expect(Array.isArray(result.data.scan)).toBe(true) - } - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-scan-metadata.test.mts b/packages/cli/test/unit/commands/scan/fetch-scan-metadata.test.mts deleted file mode 100644 index 2d3ec0a8f..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-scan-metadata.test.mts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Unit tests for fetchScanMetadata. - * - * Purpose: Tests fetching scan metadata via the Socket API. Retrieves - * high-level scan information without full report details. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Metadata - * retrieval - Scan status - Summary information - Null prototype usage for - * security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/ScanMetadata.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { setupSdkMockSuccess } from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: vi.fn(), -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: vi.fn(), -})) - -describe('fetchScanMetadata', () => { - it('fetches scan metadata successfully', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getFullScanMetadata', - { - id: 'scan-123', - status: 'completed', - packageCount: 150, - }, - ) - - const result = await fetchScanMetadata('test-org', 'scan-123') - - expect(mockSdk.getFullScanMetadata).toHaveBeenCalledWith( - 'test-org', - 'scan-123', - ) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'meta data for a full scan', - }) - expect(result.ok).toBe(true) - expect(result.data?.id).toBe('scan-123') - }) - - it('handles SDK setup failure', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - const { setupSdkSetupFailure } = - await import('../../../helpers/sdk-test-helpers.mts') - - await setupSdkSetupFailure('Failed to setup SDK', { - cause: 'Invalid configuration', - }) - - const result = await fetchScanMetadata('test-org', 'scan-123') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - expect(result.cause).toBe('Invalid configuration') - }) - - it('handles API call failure', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - const { setupSdkMockError } = - await import('../../../helpers/sdk-test-helpers.mts') - - await setupSdkMockError('getFullScanMetadata', 'Not found', 404) - - const result = await fetchScanMetadata('test-org', 'nonexistent-scan') - - expect(result.ok).toBe(false) - expect(result.code).toBe(404) - }) - - it('passes custom SDK options', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - const { mockSdk, mockSetupSdk } = await setupSdkMockSuccess( - 'getFullScanMetadata', - {}, - ) - - const options = { - sdkOpts: { - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - }, - } - - await fetchScanMetadata('custom-org', 'scan-456', options) - - expect(mockSetupSdk).toHaveBeenCalledWith(options.sdkOpts) - expect(mockSdk.getFullScanMetadata).toHaveBeenCalledWith( - 'custom-org', - 'scan-456', - ) - }) - - it('handles different org slugs and scan IDs', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - const { mockSdk } = await setupSdkMockSuccess('getFullScanMetadata', {}) - - const testCases = [ - ['org-with-dashes', 'scan-123'], - ['simple_org', 'uuid-456-789-abc'], - ['org123', 'scan_with_underscore'], - ['long.org.name', 'scan.with.dots'], - ] - - for (const [org, scanId] of testCases) { - await fetchScanMetadata(org, scanId) - expect(mockSdk.getFullScanMetadata).toHaveBeenCalledWith(org, scanId) - } - }) - - it('handles empty metadata response', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - await setupSdkMockSuccess('getFullScanMetadata', undefined) - - const result = await fetchScanMetadata('test-org', 'empty-scan') - - expect(result.ok).toBe(true) - expect(result.data).toBe(undefined) - }) - - it('handles pending scan metadata', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - await setupSdkMockSuccess('getFullScanMetadata', { - id: 'scan-pending', - status: 'pending', - progress: 45, - }) - - const result = await fetchScanMetadata('test-org', 'scan-pending') - - expect(result.ok).toBe(true) - expect(result.data?.status).toBe('pending') - expect(result.data?.progress).toBe(45) - }) - - it('handles special characters in scan IDs', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - const { mockSdk } = await setupSdkMockSuccess('getFullScanMetadata', { - id: 'scan-with-special-chars', - }) - - const specialScanId = 'scan-123_with-special.chars@example.com' - - await fetchScanMetadata('test-org', specialScanId) - - expect(mockSdk.getFullScanMetadata).toHaveBeenCalledWith( - 'test-org', - specialScanId, - ) - }) - - it('uses null prototype for options', async () => { - const { fetchScanMetadata } = - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - - const { mockSdk } = await setupSdkMockSuccess('getFullScanMetadata', {}) - - // This tests that the function properly uses __proto__: null. - await fetchScanMetadata('test-org', 'scan-123') - - // The function should work without prototype pollution issues. - expect(mockSdk.getFullScanMetadata).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-scan.test.mts b/packages/cli/test/unit/commands/scan/fetch-scan.test.mts deleted file mode 100644 index eb353e392..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-scan.test.mts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Unit tests for fetchScan. - * - * Purpose: Tests fetching individual scan results via the Socket API. Validates - * scan retrieval by ID. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Null - * prototype usage for security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/Scan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { fetchScan } from '../../../../src/commands/scan/fetch-scan.mts' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockQueryApiSafeText = vi.hoisted(() => vi.fn()) -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn()) -const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => 'test-token')) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeText: mockQueryApiSafeText, -})) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugDir: mockDebugDir, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, -})) - -describe('fetchScan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches scan successfully', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - const mockScanData = [ - '{"type":"package","name":"lodash","version":"4.17.21"}', - '{"type":"vulnerability","id":"CVE-2023-001","severity":"high"}', - '{"type":"license","name":"MIT","approved":true}', - ].join('\n') - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: mockScanData, - }) - - const result = await fetchScan('test-org', 'scan-123') - - expect(mockQueryApiText).toHaveBeenCalledWith( - 'orgs/test-org/full-scans/scan-123', - 'a scan', - ) - expect(result.ok).toBe(true) - expect(result.data).toEqual([ - { type: 'package', name: 'lodash', version: '4.17.21' }, - { type: 'vulnerability', id: 'CVE-2023-001', severity: 'high' }, - { type: 'license', name: 'MIT', approved: true }, - ]) - }) - - it('handles API call failure', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - const error = { - ok: false, - code: 404, - message: 'Scan not found', - cause: 'The specified scan does not exist', - } - mockQueryApiText.mockResolvedValue(error) - - const result = await fetchScan('test-org', 'nonexistent-scan') - - expect(result).toEqual(error) - }) - - it('handles invalid JSON in scan data', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - const mockDebugFn = vi.mocked(mockDebug) - const mockDebugDirFn = vi.mocked(mockDebugDir) - - const invalidJson = [ - '{"type":"package","name":"valid"}', - '{"invalid":json}', - '{"type":"another","name":"valid"}', - ].join('\n') - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: invalidJson, - }) - - const result = await fetchScan('test-org', 'scan-123') - - expect(mockDebugFn).toHaveBeenCalledWith( - 'Failed to parse scan result line as JSON', - ) - expect(mockDebugDirFn).toHaveBeenCalledWith({ - error: expect.any(SyntaxError), - line: '{"invalid":json}', - }) - expect(result.ok).toBe(false) - expect(result.message).toBe('Invalid Socket API response') - expect(result.cause).toBe( - 'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.', - ) - }) - - it('handles empty scan data', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: '', - }) - - const result = await fetchScan('test-org', 'empty-scan') - - expect(result.ok).toBe(true) - expect(result.data).toEqual([]) - }) - - it('filters out empty lines but fails on invalid JSON', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - // The function filters out empty lines with .filter(Boolean), but ' ' is truthy. - // So it will try to parse ' ' as JSON and fail. - const dataWithEmptyLines = [ - '{"type":"package","name":"first"}', - '', - '{"type":"package","name":"second"}', - ' ', - '{"type":"package","name":"third"}', - '', - ].join('\n') - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: dataWithEmptyLines, - }) - - const result = await fetchScan('test-org', 'scan-123') - - // This should fail because ' ' cannot be parsed as JSON. - expect(result.ok).toBe(false) - expect(result.message).toBe('Invalid Socket API response') - }) - - it('properly URL encodes scan ID', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: '{"type":"test"}', - }) - - const specialCharsScanId = 'scan+with%special&chars/and?query=params' - - await fetchScan('test-org', specialCharsScanId) - - expect(mockQueryApiText).toHaveBeenCalledWith( - 'orgs/test-org/full-scans/scan%2Bwith%25special%26chars%2Fand%3Fquery%3Dparams', - 'a scan', - ) - }) - - it('handles different org slugs', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: '{"type":"test"}', - }) - - const testCases = [ - 'org-with-dashes', - 'simple_org', - 'org123', - 'long.org.name.with.dots', - ] - - for (let i = 0, { length } = testCases; i < length; i += 1) { - const orgSlug = testCases[i] - await fetchScan(orgSlug, 'scan-123') - - expect(mockQueryApiText).toHaveBeenCalledWith( - `orgs/${orgSlug}/full-scans/scan-123`, - 'a scan', - ) - } - }) - - it('handles single line of JSON', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - const singleLineData = - '{"type":"package","name":"single","version":"1.0.0"}' - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: singleLineData, - }) - - const result = await fetchScan('test-org', 'single-line-scan') - - expect(result.ok).toBe(true) - expect(result.data).toEqual([ - { type: 'package', name: 'single', version: '1.0.0' }, - ]) - }) - - it('uses null prototype internally', async () => { - const mockQueryApiText = vi.mocked(mockQueryApiSafeText) - - mockQueryApiText.mockResolvedValue({ - ok: true, - data: '{"type":"test"}', - }) - - // This tests that the function works without prototype pollution issues. - await fetchScan('test-org', 'scan-123') - - // The function should work properly. - expect(mockQueryApiText).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/fetch-supported-scan-file-names.test.mts b/packages/cli/test/unit/commands/scan/fetch-supported-scan-file-names.test.mts deleted file mode 100644 index ccef24467..000000000 --- a/packages/cli/test/unit/commands/scan/fetch-supported-scan-file-names.test.mts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Unit tests for fetchSupportedScanFileNames. - * - * Tests fetching supported manifest file names for scanning. Validates which - * files Socket can analyze via the SDK v4 getSupportedFiles API. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - setupSdkMockError, - setupSdkMockSuccess, - setupSdkSetupFailure, -} from '../../../helpers/sdk-test-helpers.mts' - -// Mock the dependencies. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -describe('fetchSupportedScanFileNames', () => { - it('fetches supported scan file names successfully', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - const mockData = { - supportedFiles: ['package.json', 'yarn.lock', 'composer.json'], - } - - const { mockHandleApi, mockSdk } = await setupSdkMockSuccess( - 'getSupportedFiles', - mockData, - ) - - const result = await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(mockSdk.getSupportedFiles).toHaveBeenCalledWith('test-org') - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'supported scan file types', - }) - expect(result.ok).toBe(true) - expect(result.data?.supportedFiles).toContain('package.json') - }) - - it('handles SDK setup failure', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - await setupSdkSetupFailure('Failed to setup SDK', { - code: 1, - cause: 'Invalid configuration', - }) - - const result = await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(result.ok).toBe(false) - expect(result.message).toBe('Failed to setup SDK') - expect(result.cause).toBe('Invalid configuration') - }) - - it('handles API call failure', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - await setupSdkMockError('getSupportedFiles', 'API error', 500) - - const result = await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(result.ok).toBe(false) - expect(result.code).toBe(500) - }) - - it('passes custom SDK options', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - const { mockSdk, mockSetupSdk } = await setupSdkMockSuccess( - 'getSupportedFiles', - {}, - ) - - await fetchSupportedScanFileNames({ - orgSlug: 'my-org', - sdkOpts: { - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - }, - }) - - expect(mockSetupSdk).toHaveBeenCalledWith({ - apiToken: 'custom-token', - baseUrl: 'https://api.example.com', - }) - expect(mockSdk.getSupportedFiles).toHaveBeenCalledWith('my-org') - }) - - it('passes custom spinner', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - const { mockHandleApi } = await setupSdkMockSuccess('getSupportedFiles', {}) - - const mockSpinner = { - start: vi.fn(), - stop: vi.fn(), - succeed: vi.fn(), - fail: vi.fn(), - } - - await fetchSupportedScanFileNames({ - orgSlug: 'test-org', - spinner: mockSpinner, - }) - - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'supported scan file types', - spinner: mockSpinner, - }) - }) - - it('handles empty supported files response', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - await setupSdkMockSuccess('getSupportedFiles', { - supportedFiles: [], - ecosystems: [], - }) - - const result = await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(result.ok).toBe(true) - expect(result.data?.supportedFiles).toEqual([]) - expect(result.data?.ecosystems).toEqual([]) - }) - - it('works with orgSlug provided', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - const { mockHandleApi, mockSetupSdk } = await setupSdkMockSuccess( - 'getSupportedFiles', - { supportedFiles: ['package.json'] }, - ) - - const result = await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(mockSetupSdk).toHaveBeenCalledWith(undefined) - expect(mockHandleApi).toHaveBeenCalledWith(expect.any(Promise), { - description: 'supported scan file types', - spinner: undefined, - }) - expect(result.ok).toBe(true) - }) - - it('uses null prototype for options', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - - const { mockSdk } = await setupSdkMockSuccess('getSupportedFiles', {}) - - await fetchSupportedScanFileNames({ orgSlug: 'test-org' }) - - expect(mockSdk.getSupportedFiles).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/scan/finalize-tier1-scan.test.mts b/packages/cli/test/unit/commands/scan/finalize-tier1-scan.test.mts deleted file mode 100644 index 60d76b760..000000000 --- a/packages/cli/test/unit/commands/scan/finalize-tier1-scan.test.mts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Unit tests for tier1 scan finalization. - * - * Purpose: Tests the finalizeTier1Scan function for completing reachability - * scans. - * - * Test Coverage: - API request formatting - Parameter passing. - * - * Related Files: - commands/scan/finalize-tier1-scan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockSendApiRequest = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - sendApiRequest: mockSendApiRequest, -})) - -import { finalizeTier1Scan } from '../../../../src/commands/scan/finalize-tier1-scan.mts' - -describe('finalize-tier1-scan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('finalizeTier1Scan', () => { - it('sends API request with correct endpoint and body', async () => { - mockSendApiRequest.mockResolvedValue({ ok: true, data: {} }) - - await finalizeTier1Scan('tier1-scan-id-123', 'scan-id-456') - - expect(mockSendApiRequest).toHaveBeenCalledTimes(1) - expect(mockSendApiRequest).toHaveBeenCalledWith( - 'tier1-reachability-scan/finalize', - { - method: 'POST', - body: { - tier1_reachability_scan_id: 'tier1-scan-id-123', - report_run_id: 'scan-id-456', - }, - }, - ) - }) - - it('returns success result from API', async () => { - const mockResult = { ok: true, data: { status: 'finalized' } } - mockSendApiRequest.mockResolvedValue(mockResult) - - const result = await finalizeTier1Scan('tier1-id', 'scan-id') - - expect(result).toEqual(mockResult) - }) - - it('returns error result from API', async () => { - const mockResult = { ok: false, message: 'API error', cause: 'Not found' } - mockSendApiRequest.mockResolvedValue(mockResult) - - const result = await finalizeTier1Scan('tier1-id', 'scan-id') - - expect(result).toEqual(mockResult) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report-basic.test.mts b/packages/cli/test/unit/commands/scan/generate-report-basic.test.mts deleted file mode 100644 index 30c951351..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report-basic.test.mts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Unit tests for generateReportBasic. - * - * Purpose: Tests basic scan report generation. Validates core report structure - * and formatting without advanced features. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/generateReportBasic.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { generateReport } from '../../../../src/commands/scan/generate-report.mts' - -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -type SecurityPolicyData = SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - -describe('generate-report - basic functionality', () => { - it('should accept empty args', () => { - const result = generateReport( - [], - { securityPolicyRules: [] } as SecurityPolicyData, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'warn', - }, - ) - - expect(result).toMatchInlineSnapshot(` - { - "data": { - "alerts": Map {}, - "healthy": true, - "options": { - "fold": "none", - "reportLevel": "warn", - }, - "orgSlug": "fakeOrg", - "scanId": "scan-ai-dee", - }, - "ok": true, - } - `) - }) - - it('should handle empty security policy rules', () => { - const result = generateReport( - [], - { - securityPolicyRules: {}, - securityPolicyDefault: 'medium', - } as SecurityPolicyData, - { - orgSlug: 'testOrg', - scanId: 'test-scan-id', - fold: 'none', - reportLevel: 'error', - }, - ) - - expect(result.ok).toBe(true) - expect(result.data.healthy).toBe(true) - expect(result.data.orgSlug).toBe('testOrg') - expect(result.data.scanId).toBe('test-scan-id') - }) - - it('should set correct options in result', () => { - const result = generateReport( - [], - { securityPolicyRules: [] } as SecurityPolicyData, - { - orgSlug: 'myOrg', - scanId: 'my-scan-123', - fold: 'pkg', - reportLevel: 'error', - }, - ) - - expect(result.data.options).toEqual({ - fold: 'pkg', - reportLevel: 'error', - }) - expect(result.data.orgSlug).toBe('myOrg') - expect(result.data.scanId).toBe('my-scan-123') - }) - - it('should return ok:true for successful report generation', () => { - const result = generateReport( - [], - { securityPolicyRules: [] } as SecurityPolicyData, - { - orgSlug: 'testOrg', - scanId: 'test-id', - fold: 'type', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - expect(result).toHaveProperty('data') - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report-fold.test.mts b/packages/cli/test/unit/commands/scan/generate-report-fold.test.mts deleted file mode 100644 index 5003d62b1..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report-fold.test.mts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Unit tests for generateReportFold. - * - * Purpose: Tests folded/collapsed scan report generation. Validates compact - * report formatting with expandable sections. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/generateReportFold.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getScanWithEnvVars, - getScanWithMultiplePackages, -} from '../../../helpers/generate-report-test-helpers.mts' -import { generateReport } from '../../../../src/commands/scan/generate-report.mts' - -import type { ScanReport } from '../../../../src/commands/scan/generate-report.mts' - -describe('generate-report - fold functionality', () => { - describe('fold=none', () => { - it('should not fold anything when fold=none', () => { - const result = generateReport( - getScanWithEnvVars(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - const alerts = (result.data as ScanReport).alerts - - // Check that alerts exist. - expect(alerts).toBeDefined() - expect(alerts?.size).toBeGreaterThan(0) - }) - }) - - describe('fold=pkg', () => { - it('should fold alerts by package when fold=pkg', () => { - const result = generateReport( - getScanWithMultiplePackages(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'pkg', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - const alerts = (result.data as ScanReport).alerts - - // When folded by package, alerts should be grouped. - if (alerts && alerts.size > 0) { - // Verify that alerts exist for both packages. - const npmAlerts = alerts.get('npm') - expect(npmAlerts).toBeDefined() - - if (npmAlerts) { - expect(npmAlerts.has('tslib')).toBe(true) - expect(npmAlerts.has('lodash')).toBe(true) - } - } - }) - }) - - describe('fold=type', () => { - it('should fold alerts by type when fold=type', () => { - const result = generateReport( - getScanWithMultiplePackages(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'type', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - // When folded by type, all envVars alerts should be grouped together. - expect(result.data.healthy).toBe(false) - }) - }) - - describe('fold=all', () => { - it('should fold all alerts when fold=all', () => { - const result = generateReport( - getScanWithMultiplePackages(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'all', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - // When folded to all, alerts should be maximally grouped. - expect(result.data.healthy).toBe(false) - - const alerts = (result.data as ScanReport).alerts - if (alerts && alerts.size > 0) { - // The structure should be simplified when fold=all. - expect(alerts.size).toBeGreaterThan(0) - } - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report-private.test.mts b/packages/cli/test/unit/commands/scan/generate-report-private.test.mts deleted file mode 100644 index cbd4dd305..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report-private.test.mts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Unit tests for the (now-exported) private helpers in generate-report. - * - * Covers isStricterPolicy strictness ladder: error > warn > monitor > ignore > - * defer > {unknown}. - * - * Related Files: - src/commands/scan/generate-report.mts. - */ - -import { describe, expect, it } from 'vitest' - -import { - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, -} from '../../../../src/constants/reporting.mts' -import { isStricterPolicy } from '../../../../src/commands/scan/generate-report.mts' - -describe('isStricterPolicy', () => { - it('error is the most strict — never overridden', () => { - expect(isStricterPolicy(REPORT_LEVEL_ERROR, REPORT_LEVEL_WARN)).toBe(false) - expect(isStricterPolicy(REPORT_LEVEL_ERROR, REPORT_LEVEL_MONITOR)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_ERROR, REPORT_LEVEL_IGNORE)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_ERROR, REPORT_LEVEL_DEFER)).toBe(false) - }) - - it('error overrides anything below it', () => { - expect(isStricterPolicy(REPORT_LEVEL_WARN, REPORT_LEVEL_ERROR)).toBe(true) - expect(isStricterPolicy(REPORT_LEVEL_MONITOR, REPORT_LEVEL_ERROR)).toBe( - true, - ) - expect(isStricterPolicy(REPORT_LEVEL_IGNORE, REPORT_LEVEL_ERROR)).toBe(true) - expect(isStricterPolicy(REPORT_LEVEL_DEFER, REPORT_LEVEL_ERROR)).toBe(true) - }) - - it('warn is second strictest — overridden only by error', () => { - expect(isStricterPolicy(REPORT_LEVEL_WARN, REPORT_LEVEL_MONITOR)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_WARN, REPORT_LEVEL_IGNORE)).toBe(false) - expect(isStricterPolicy(REPORT_LEVEL_WARN, REPORT_LEVEL_DEFER)).toBe(false) - expect(isStricterPolicy(REPORT_LEVEL_MONITOR, REPORT_LEVEL_WARN)).toBe(true) - expect(isStricterPolicy(REPORT_LEVEL_IGNORE, REPORT_LEVEL_WARN)).toBe(true) - expect(isStricterPolicy(REPORT_LEVEL_DEFER, REPORT_LEVEL_WARN)).toBe(true) - }) - - it('monitor is third — overridden by error/warn', () => { - expect(isStricterPolicy(REPORT_LEVEL_MONITOR, REPORT_LEVEL_IGNORE)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_MONITOR, REPORT_LEVEL_DEFER)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_IGNORE, REPORT_LEVEL_MONITOR)).toBe( - true, - ) - expect(isStricterPolicy(REPORT_LEVEL_DEFER, REPORT_LEVEL_MONITOR)).toBe( - true, - ) - }) - - it('ignore overrides only defer', () => { - expect(isStricterPolicy(REPORT_LEVEL_IGNORE, REPORT_LEVEL_DEFER)).toBe( - false, - ) - expect(isStricterPolicy(REPORT_LEVEL_DEFER, REPORT_LEVEL_IGNORE)).toBe(true) - }) - - it('defer never overrides anything', () => { - expect(isStricterPolicy(REPORT_LEVEL_DEFER, REPORT_LEVEL_DEFER)).toBe(false) - }) - - it('returns false for entirely unknown levels (final fallthrough)', () => { - expect(isStricterPolicy('???' as unknown, '???' as unknown)).toBe(false) - }) - - it('returns false when is is defer and was is unknown (line 354-355)', () => { - // is = DEFER but was = unknown level → exits via the L354 if-block. - expect(isStricterPolicy('???' as unknown, REPORT_LEVEL_DEFER)).toBe(false) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report-shape.test.mts b/packages/cli/test/unit/commands/scan/generate-report-shape.test.mts deleted file mode 100644 index a1dd0a21e..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report-shape.test.mts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Unit tests for generateReportShape. - * - * Purpose: Tests scan report data structure shaping. Validates report data - * transformation and normalization. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/generateReportShape.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getScanWithEnvVars, - getSimpleCleanScan, -} from '../../../helpers/generate-report-test-helpers.mts' -import { generateReport } from '../../../../src/commands/scan/generate-report.mts' - -import type { ScanReport } from '../../../../src/commands/scan/generate-report.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// biome-ignore lint/correctness/noUnusedVariables: Destructuring import for test setup -type SecurityPolicyData = SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'] - -describe('generate-report - report shape', () => { - describe('report-level=warn', () => { - it('should return a healthy report without alerts when there are no violations', () => { - const result = generateReport( - getSimpleCleanScan(), - { - securityPolicyRules: { - gptSecurity: { - action: 'ignore', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'warn', - }, - ) - - expect(result).toMatchInlineSnapshot(` - { - "data": { - "alerts": Map {}, - "healthy": true, - "options": { - "fold": "none", - "reportLevel": "warn", - }, - "orgSlug": "fakeOrg", - "scanId": "scan-ai-dee", - }, - "ok": true, - } - `) - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(true) - expect((result.data as ScanReport).alerts?.size).toBe(0) - }) - - it('should return a sick report with alert when an alert violates at error', () => { - const result = generateReport( - getScanWithEnvVars(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'warn', - }, - ) - - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(false) - expect((result.data as ScanReport).alerts?.size).toBeGreaterThan(0) - }) - - it('should return a healthy report without alerts when an alert violates at warn', () => { - const result = generateReport( - getScanWithEnvVars(), - { - securityPolicyRules: { - envVars: { - action: 'warn', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'error', // When reportLevel is 'error', warns don't show up as alerts - }, - ) - - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(true) - expect((result.data as ScanReport).alerts?.size).toBe(0) - }) - }) - - describe('report-level=error', () => { - it('should return a healthy report without alerts when there are no violations', () => { - const result = generateReport( - getSimpleCleanScan(), - { - securityPolicyRules: { - gptSecurity: { - action: 'ignore', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'error', - }, - ) - - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(true) - expect((result.data as ScanReport).alerts?.size).toBe(0) - }) - - it('should return a sick report with alert when an alert violates at error', () => { - const result = generateReport( - getScanWithEnvVars(), - { - securityPolicyRules: { - envVars: { - action: 'error', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'error', - }, - ) - - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(false) - expect((result.data as ScanReport).alerts?.size).toBeGreaterThan(0) - }) - - it('should return a healthy report without alerts when an alert violates at warn', () => { - const result = generateReport( - getScanWithEnvVars(), - { - securityPolicyRules: { - envVars: { - action: 'warn', - }, - }, - securityPolicyDefault: 'medium', - }, - { - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - fold: 'none', - reportLevel: 'error', - }, - ) - - expect(result.ok).toBe(true) - expect(result.ok && result.data.healthy).toBe(true) - expect((result.data as ScanReport).alerts?.size).toBe(0) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report-test-helpers.test.mts b/packages/cli/test/unit/commands/scan/generate-report-test-helpers.test.mts deleted file mode 100644 index 1f566847d..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report-test-helpers.test.mts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Unit tests for scan report generation test helpers. - * - * Purpose: Tests the helper functions for creating test scan data. - * - * Test Coverage: - getSimpleCleanScan function - getScanWithEnvVars function - - * getScanWithMultiplePackages function. - * - * Related Files: - test/helpers/generate-report-test-helpers.mts - * (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getScanWithEnvVars, - getScanWithMultiplePackages, - getSimpleCleanScan, -} from '../../../helpers/generate-report-test-helpers.mts' - -describe('generate-report-test-helpers', () => { - describe('getSimpleCleanScan', () => { - it('returns an array with one artifact', () => { - const scan = getSimpleCleanScan() - - expect(Array.isArray(scan)).toBe(true) - expect(scan.length).toBe(1) - }) - - it('returns artifact with no alerts', () => { - const scan = getSimpleCleanScan() - - expect(scan[0]!.alerts).toEqual([]) - }) - - it('returns artifact with expected structure', () => { - const scan = getSimpleCleanScan() - const artifact = scan[0]! - - expect(artifact.type).toBe('npm') - expect(artifact.name).toBe('tslib') - expect(artifact.version).toBe('1.14.1') - expect(artifact.score).toBeDefined() - expect(artifact.manifestFiles).toBeDefined() - }) - }) - - describe('getScanWithEnvVars', () => { - it('returns an array with one artifact', () => { - const scan = getScanWithEnvVars() - - expect(Array.isArray(scan)).toBe(true) - expect(scan.length).toBe(1) - }) - - it('returns artifact with envVars alerts', () => { - const scan = getScanWithEnvVars() - const artifact = scan[0]! - - expect(artifact.alerts!.length).toBe(2) - expect(artifact.alerts![0]!.type).toBe('envVars') - expect(artifact.alerts![1]!.type).toBe('envVars') - }) - - it('returns alerts with start/end positions', () => { - const scan = getScanWithEnvVars() - const alert = scan[0]!.alerts![0]! - - expect(alert.start).toBeDefined() - expect(alert.end).toBeDefined() - expect(typeof alert.start).toBe('number') - expect(typeof alert.end).toBe('number') - }) - }) - - describe('getScanWithMultiplePackages', () => { - it('returns an array with multiple artifacts', () => { - const scan = getScanWithMultiplePackages() - - expect(Array.isArray(scan)).toBe(true) - expect(scan.length).toBe(2) - }) - - it('returns different packages', () => { - const scan = getScanWithMultiplePackages() - - expect(scan[0]!.name).toBe('tslib') - expect(scan[1]!.name).toBe('lodash') - }) - - it('returns artifacts with alerts', () => { - const scan = getScanWithMultiplePackages() - - expect(scan[0]!.alerts!.length).toBe(2) - expect(scan[1]!.alerts!.length).toBe(1) - }) - - it('returns artifacts with different versions', () => { - const scan = getScanWithMultiplePackages() - - expect(scan[0]!.version).toBe('1.14.1') - expect(scan[1]!.version).toBe('4.17.21') - }) - - it('returns artifacts with manifest files', () => { - const scan = getScanWithMultiplePackages() - - expect(scan[0]!.manifestFiles).toBeDefined() - expect(scan[0]!.manifestFiles!.length).toBe(1) - expect(scan[1]!.manifestFiles).toBeDefined() - expect(scan[1]!.manifestFiles!.length).toBe(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/generate-report.test.mts b/packages/cli/test/unit/commands/scan/generate-report.test.mts deleted file mode 100644 index aa0e757d8..000000000 --- a/packages/cli/test/unit/commands/scan/generate-report.test.mts +++ /dev/null @@ -1,498 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for scan report generation. - * - * Purpose: Tests the report generation from scan artifacts. - * - * Test Coverage: - generateReport function - Policy action handling (error, - * warn, monitor, ignore, defer) - Fold settings (pkg, version, file) - Report - * level filtering - Health status determination. - * - * Related Files: - src/commands/scan/generate-report.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock socket URL utility. -vi.mock('../../../../src/util/socket/url.mts', () => ({ - getSocketDevPackageOverviewUrlFromPurl: (art: { name: string }) => - `https://socket.dev/pkg/${art.name}`, -})) - -import { generateReport } from '../../../../src/commands/scan/generate-report.mts' -import { - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, -} from '../../../../src/constants/cli.mts' -import { - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, -} from '../../../../src/constants/reporting.mts' - -import type { SocketArtifact } from '../../../../src/util/alert/artifact.mts' - -describe('generate-report', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('generateReport', () => { - const createArtifact = (overrides: Partial<SocketArtifact> = {}) => - ({ - type: 'npm', - name: 'test-pkg', - version: '1.0.0', - alerts: [], - manifestFiles: [{ file: 'package.json' }], - ...overrides, - }) as SocketArtifact - - const createSecurityPolicy = ( - rules: Record<string, { action: string }> = {}, - ) => ({ - securityPolicyRules: rules, - }) - - const defaultOptions = { - fold: FOLD_SETTING_NONE, - orgSlug: 'my-org', - reportLevel: REPORT_LEVEL_ERROR, - scanId: 'scan-123', - } - - it('returns healthy report when no alerts', () => { - const scan = [createArtifact()] - const policy = createSecurityPolicy() - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: true, - orgSlug: 'my-org', - scanId: 'scan-123', - }), - ) - }) - - it('returns short report when short option is true', () => { - const scan = [createArtifact()] - const policy = createSecurityPolicy() - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - short: true, - }) - - expect(result.ok).toBe(true) - expect(result.data).toEqual({ healthy: true }) - }) - - it('marks unhealthy when error policy alerts exist', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: false, - }), - ) - expect(result.message).toContain('violates the policies') - }) - - it('stays healthy with warn policy alerts', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'warnAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - warnAlert: { action: 'warn' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_WARN, - }) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: true, - }), - ) - }) - - it('includes warn alerts when reportLevel is warn', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'warnAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - warnAlert: { action: 'warn' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_WARN, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - expect(data.alerts.size).toBeGreaterThan(0) - }) - - it('excludes warn alerts when reportLevel is error', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'warnAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - warnAlert: { action: 'warn' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_ERROR, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - expect(data.alerts.size).toBe(0) - }) - - it('includes monitor alerts when reportLevel is monitor', () => { - const scan = [ - createArtifact({ - alerts: [ - { type: 'monitorAlert', file: 'index.js', start: 0, end: 10 }, - ], - }), - ] - const policy = createSecurityPolicy({ - monitorAlert: { action: 'monitor' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_MONITOR, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - expect(data.alerts.size).toBeGreaterThan(0) - }) - - it('includes ignore alerts when reportLevel is ignore', () => { - const scan = [ - createArtifact({ - alerts: [ - { type: 'ignoreAlert', file: 'index.js', start: 0, end: 10 }, - ], - }), - ] - const policy = createSecurityPolicy({ - ignoreAlert: { action: 'ignore' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_IGNORE, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - expect(data.alerts.size).toBeGreaterThan(0) - }) - - it('includes defer alerts when reportLevel is defer', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'deferAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - deferAlert: { action: 'defer' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - reportLevel: REPORT_LEVEL_DEFER, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - expect(data.alerts.size).toBeGreaterThan(0) - }) - - describe('fold settings', () => { - const alertedArtifact = createArtifact({ - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }) - const errorPolicy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - it('folds by package when fold is pkg', () => { - const result = generateReport( - [alertedArtifact], - errorPolicy as unknown, - { - ...defaultOptions, - fold: FOLD_SETTING_PKG, - }, - ) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - const npmMap = data.alerts.get('npm') - expect(npmMap).toBeDefined() - // Should have leaf node directly under package name. - const leaf = npmMap.get('test-pkg') - expect(leaf).toHaveProperty('type', 'badAlert') - }) - - it('folds by version when fold is version', () => { - const result = generateReport( - [alertedArtifact], - errorPolicy as unknown, - { - ...defaultOptions, - fold: FOLD_SETTING_VERSION, - }, - ) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - const npmMap = data.alerts.get('npm') - const pkgMap = npmMap.get('test-pkg') - expect(pkgMap).toBeDefined() - // Should have leaf node directly under version. - const leaf = pkgMap.get('1.0.0') - expect(leaf).toHaveProperty('type', 'badAlert') - }) - - it('folds by file when fold is file', () => { - const result = generateReport( - [alertedArtifact], - errorPolicy as unknown, - { - ...defaultOptions, - fold: FOLD_SETTING_FILE, - }, - ) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - const npmMap = data.alerts.get('npm') - const pkgMap = npmMap.get('test-pkg') - const verMap = pkgMap.get('1.0.0') - expect(verMap).toBeDefined() - // Should have leaf node directly under file. - const leaf = verMap.get('index.js') - expect(leaf).toHaveProperty('type', 'badAlert') - }) - - it('does not fold when fold is none', () => { - const result = generateReport( - [alertedArtifact], - errorPolicy as unknown, - { - ...defaultOptions, - fold: FOLD_SETTING_NONE, - }, - ) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - const npmMap = data.alerts.get('npm') - const pkgMap = npmMap.get('test-pkg') - const verMap = pkgMap.get('1.0.0') - const fileMap = verMap.get('index.js') - expect(fileMap).toBeDefined() - // Should have leaf node under alert key. - const keys = Array.from(fileMap.keys()) - expect(keys.length).toBe(1) - expect(keys[0]).toContain('badAlert') - }) - }) - - it('handles artifacts with missing name/version', () => { - const scan = [ - createArtifact({ - name: undefined as unknown, - version: undefined as unknown, - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: false, - }), - ) - }) - - it('handles artifacts with no manifestFiles', () => { - const scan = [ - createArtifact({ - manifestFiles: undefined as unknown, - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - }) - - it('handles alerts with no file', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'badAlert', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - fold: FOLD_SETTING_NONE, - }) - - expect(result.ok).toBe(true) - }) - - it('handles unknown policy actions', () => { - const scan = [ - createArtifact({ - alerts: [ - { type: 'unknownAlert', file: 'index.js', start: 0, end: 10 }, - ], - }), - ] - const policy = createSecurityPolicy({ - unknownAlert: { action: 'unknown-action' }, - }) - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: true, - }), - ) - }) - - it('handles missing security policy rules', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = {} // No securityPolicyRules. - - const result = generateReport(scan, policy as unknown, defaultOptions) - - expect(result.ok).toBe(true) - expect(result.data).toEqual( - expect.objectContaining({ - healthy: true, - }), - ) - }) - - it('prefers stricter policy when multiple alerts on same target', () => { - const scan = [ - createArtifact({ - alerts: [ - { type: 'warnAlert', file: 'index.js', start: 0, end: 10 }, - { type: 'errorAlert', file: 'index.js', start: 0, end: 10 }, - ], - }), - ] - const policy = createSecurityPolicy({ - warnAlert: { action: 'warn' }, - errorAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - fold: FOLD_SETTING_PKG, - reportLevel: REPORT_LEVEL_WARN, - }) - - expect(result.ok).toBe(true) - const data = result.data as { alerts: Map<string, unknown> } - const npmMap = data.alerts.get('npm') - const leaf = npmMap.get('test-pkg') - expect(leaf.policy).toBe('error') - }) - - it('calls spinner methods when provided', () => { - const mockSpinner = { - start: vi.fn(), - successAndStop: vi.fn(), - } - const scan = [createArtifact()] - const policy = createSecurityPolicy() - - generateReport(scan, policy as unknown, { - ...defaultOptions, - spinner: mockSpinner as unknown, - }) - - expect(mockSpinner.start).toHaveBeenCalledWith('Generating report...') - expect(mockSpinner.successAndStop).toHaveBeenCalledWith( - expect.stringContaining('Generated reported in'), - ) - }) - - it('returns short unhealthy report for error alerts', () => { - const scan = [ - createArtifact({ - alerts: [{ type: 'badAlert', file: 'index.js', start: 0, end: 10 }], - }), - ] - const policy = createSecurityPolicy({ - badAlert: { action: 'error' }, - }) - - const result = generateReport(scan, policy as unknown, { - ...defaultOptions, - short: true, - }) - - expect(result.ok).toBe(true) - expect(result.data).toEqual({ healthy: false }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-create-github-scan.test.mts b/packages/cli/test/unit/commands/scan/handle-create-github-scan.test.mts deleted file mode 100644 index 8b4f0f255..000000000 --- a/packages/cli/test/unit/commands/scan/handle-create-github-scan.test.mts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Unit tests for handleCreateGithubScan. - * - * Purpose: Tests the handler that orchestrates GitHub repository scanning. - * Validates GitHub integration, repository selection, and scan initialization. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleCreateGithubScan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleCreateGithubScan } from '../../../../src/commands/scan/handle-create-github-scan.mts' - -// Mock the dependencies. -const mockCreateScanFromGithub = vi.hoisted(() => vi.fn()) -const mockOutputScanGithub = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/create-scan-from-github.mts', () => ({ - createScanFromGithub: mockCreateScanFromGithub, -})) - -vi.mock('../../../../src/commands/scan/output-scan-github.mts', () => ({ - outputScanGithub: mockOutputScanGithub, -})) - -describe('handleCreateGithubScan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates GitHub scan and outputs result successfully', async () => { - const mockCreate = mockCreateScanFromGithub - const mockOutput = mockOutputScanGithub - - const mockResult = createSuccessResult({ - scanId: 'scan-123', - repositories: ['repo1', 'repo2'], - status: 'created', - createdAt: '2025-01-01T00:00:00Z', - }) - mockCreate.mockResolvedValue(mockResult) - - await handleCreateGithubScan({ - all: true, - githubApiUrl: 'https://api.github.com', - githubToken: 'ghp_token123', - interactive: false, - orgGithub: 'github-org', - orgSlug: 'test-org', - outputKind: 'json', - repos: 'repo1,repo2', - }) - - expect(mockCreate).toHaveBeenCalledWith({ - all: true, - githubApiUrl: 'https://api.github.com', - githubToken: 'ghp_token123', - interactive: false, - orgSlug: 'test-org', - orgGithub: 'github-org', - outputKind: 'json', - repos: 'repo1,repo2', - }) - expect(mockOutput).toHaveBeenCalledWith(mockResult, 'json') - }) - - it('handles creation failure', async () => { - const mockCreate = mockCreateScanFromGithub - const mockOutput = mockOutputScanGithub - - const mockError = createErrorResult('GitHub authentication failed') - mockCreate.mockResolvedValue(mockError) - - await handleCreateGithubScan({ - all: false, - githubApiUrl: 'https://api.github.com', - githubToken: 'invalid', - interactive: false, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'text', - repos: '', - }) - - expect(mockOutput).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles all repositories flag', async () => { - const mockCreate = mockCreateScanFromGithub - - mockCreate.mockResolvedValue(createSuccessResult({})) - - await handleCreateGithubScan({ - all: true, - githubApiUrl: 'https://api.github.com', - githubToken: 'token', - interactive: false, - orgGithub: 'my-org', - orgSlug: 'my-org', - outputKind: 'json', - repos: '', - }) - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ all: true, repos: '' }), - ) - }) - - it('handles interactive mode', async () => { - const mockCreate = mockCreateScanFromGithub - - mockCreate.mockResolvedValue(createSuccessResult({})) - - await handleCreateGithubScan({ - all: false, - githubApiUrl: 'https://api.github.com', - githubToken: 'token', - interactive: true, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'json', - repos: 'repo1', - }) - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ interactive: true }), - ) - }) - - it('handles markdown output format', async () => { - const mockCreate = mockCreateScanFromGithub - const mockOutput = mockOutputScanGithub - - mockCreate.mockResolvedValue(createSuccessResult({})) - - await handleCreateGithubScan({ - all: false, - githubApiUrl: 'https://github.enterprise.com', - githubToken: 'token', - interactive: false, - orgGithub: 'enterprise-org', - orgSlug: 'enterprise-org', - outputKind: 'markdown', - repos: 'repo1,repo2,repo3', - }) - - expect(mockOutput).toHaveBeenCalledWith(expect.any(Object), 'markdown') - }) - - it('converts parameters to proper types', async () => { - const mockCreate = mockCreateScanFromGithub - - mockCreate.mockResolvedValue(createSuccessResult({})) - - // Test with various falsy values. - await handleCreateGithubScan({ - all: 0 as unknown, - githubApiUrl: 'https://api.github.com', - githubToken: 'token', - interactive: undefined as unknown, - orgGithub: 'org', - orgSlug: 'org', - outputKind: 'json', - repos: undefined as unknown, - }) - - expect(mockCreate).toHaveBeenCalledWith({ - all: false, - githubApiUrl: 'https://api.github.com', - githubToken: 'token', - interactive: false, - orgSlug: 'org', - orgGithub: 'org', - outputKind: 'json', - repos: '', - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts b/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts deleted file mode 100644 index 1ca674f25..000000000 --- a/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts +++ /dev/null @@ -1,565 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for handleCreateNewScan. - * - * Purpose: Tests the handler that orchestrates creating new security scans. - * Validates manifest file detection, configuration, and scan submission. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleCreateNewScan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleCreateNewScan } from '../../../../src/commands/scan/handle-create-new-scan.mts' -import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' - -// Mock all the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockFetchCreateOrgFullScan = vi.hoisted(() => vi.fn()) -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -const mockFinalizeTier1Scan = vi.hoisted(() => vi.fn()) -const mockHandleScanReport = vi.hoisted(() => vi.fn()) -const mockOutputCreateNewScan = vi.hoisted(() => vi.fn()) -const mockPerformReachabilityAnalysis = vi.hoisted(() => vi.fn()) -const mockGetSpinner = vi.hoisted(() => vi.fn()) -const mockStart = vi.hoisted(() => vi.fn()) -const mockStop = vi.hoisted(() => vi.fn()) -const mockSuccessAndStop = vi.hoisted(() => vi.fn()) -const mockCheckCommandInput = vi.hoisted(() => vi.fn()) -const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) -const mockReadOrDefaultSocketJson = vi.hoisted(() => vi.fn()) -const mockSocketDocsLink = vi.hoisted(() => vi.fn()) -const mockDetectManifestActions = vi.hoisted(() => vi.fn()) -const mockGenerateAutoManifest = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) -vi.mock('@socketsecurity/lib-stable/words/pluralize', () => ({ - pluralize: vi.fn((word, count) => (count === 1 ? word : `${word}s`)), -})) -vi.mock('../../../../src/commands/scan/fetch-create-org-full-scan.mts', () => ({ - fetchCreateOrgFullScan: mockFetchCreateOrgFullScan, -})) -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) -vi.mock('../../../../src/commands/scan/finalize-tier1-scan.mts', () => ({ - finalizeTier1Scan: mockFinalizeTier1Scan, -})) -vi.mock('../../../../src/commands/scan/handle-scan-report.mts', () => ({ - handleScanReport: mockHandleScanReport, -})) -vi.mock('../../../../src/commands/scan/output-create-new-scan.mts', () => ({ - outputCreateNewScan: mockOutputCreateNewScan, -})) -vi.mock( - '../../../../src/commands/scan/perform-reachability-analysis.mts', - () => ({ - performReachabilityAnalysis: mockPerformReachabilityAnalysis, - }), -) -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => ({ - start: mockStart, - stop: mockStop, - successAndStop: mockSuccessAndStop, - }), -})) -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) -vi.mock('../../../../src/util/fs/path-resolve.mts', () => ({ - getPackageFilesForScan: mockGetPackageFilesForScan, -})) -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readOrDefaultSocketJson: mockReadOrDefaultSocketJson, -})) -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - socketDocsLink: mockSocketDocsLink, -})) -vi.mock( - '../../../../src/commands/manifest/detect-manifest-actions.mts', - () => ({ - detectManifestActions: mockDetectManifestActions, - }), -) -vi.mock('../../../../src/commands/manifest/generate_auto_manifest.mts', () => ({ - generateAutoManifest: mockGenerateAutoManifest, -})) - -const mockRunSocketBasics = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/basics/spawn.mts', () => ({ - runSocketBasics: mockRunSocketBasics, -})) - -describe('handleCreateNewScan', () => { - const mockConfig = { - autoManifest: false, - branchName: 'main', - commitHash: 'abc123', - commitMessage: 'test commit', - committers: 'user@example.com', - cwd: '/test/project', - defaultBranch: true, - interactive: false, - orgSlug: 'test-org', - pendingHead: false, - pullRequest: 0, - outputKind: 'json' as const, - reach: { - excludePaths: [], - reachExcludePaths: [], - runReachabilityAnalysis: false, - }, - readOnly: false, - repoName: 'test-repo', - report: false, - reportLevel: 'error' as const, - targets: ['.'], - tmp: false, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates scan successfully with found files', async () => { - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - const { getPackageFilesForScan } = - await import('../../../../src/util/fs/path-resolve.mts') - await import('../../../../src/util/validation/check-input.mts') - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - const { outputCreateNewScan } = - await import('../../../../src/commands/scan/output-create-new-scan.mts') - - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json', 'yarn.lock'])), - ) - mockGetPackageFilesForScan.mockResolvedValue([ - '/test/project/package.json', - '/test/project/yarn.lock', - ]) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-123' }), - ) - - await handleCreateNewScan(mockConfig) - - expect(fetchSupportedScanFileNames).toHaveBeenCalled() - expect(getPackageFilesForScan).toHaveBeenCalledWith( - ['.'], - new Set(['package.json', 'yarn.lock']), - { cwd: '/test/project' }, - ) - expect(fetchCreateOrgFullScan).toHaveBeenCalledWith( - ['/test/project/package.json', '/test/project/yarn.lock'], - 'test-org', - expect.any(Object), - expect.any(Object), - ) - expect(outputCreateNewScan).toHaveBeenCalledWith( - createSuccessResult({ id: 'scan-123' }), - { interactive: false, outputKind: 'json' }, - ) - }) - - it('handles auto-manifest mode', async () => { - const { readOrDefaultSocketJson } = - await import('../../../../src/util/socket/json.mts') - const { detectManifestActions } = - await import('../../../../src/commands/manifest/detect-manifest-actions.mts') - const { generateAutoManifest } = - await import('../../../../src/commands/manifest/generate_auto_manifest.mts') - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - const { getPackageFilesForScan: _getPackageFilesForScan } = - await import('../../../../src/util/fs/path-resolve.mts') - await import('../../../../src/util/validation/check-input.mts') - - mockReadOrDefaultSocketJson.mockReturnValue({}) - mockDetectManifestActions.mockResolvedValue({ detected: true }) - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - - await handleCreateNewScan({ ...mockConfig, autoManifest: true }) - - expect(readOrDefaultSocketJson).toHaveBeenCalledWith('/test/project') - expect(detectManifestActions).toHaveBeenCalled() - expect(generateAutoManifest).toHaveBeenCalledWith({ - detected: { detected: true }, - cwd: '/test/project', - outputKind: 'json', - verbose: false, - }) - }) - - it('handles no eligible files found', async () => { - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { getPackageFilesForScan } = - await import('../../../../src/util/fs/path-resolve.mts') - const { checkCommandInput } = - await import('../../../../src/util/validation/check-input.mts') - - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue([]) - mockCheckCommandInput.mockReturnValue(false) - - await handleCreateNewScan(mockConfig) - - expect(checkCommandInput).toHaveBeenCalledWith( - 'json', - expect.objectContaining({ - test: false, - fail: expect.stringContaining('found no eligible files to scan'), - }), - ) - }) - - it('handles read-only mode', async () => { - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { getPackageFilesForScan } = - await import('../../../../src/util/fs/path-resolve.mts') - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { checkCommandInput } = - await import('../../../../src/util/validation/check-input.mts') - const { fetchCreateOrgFullScan } = - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - - await handleCreateNewScan({ - ...mockConfig, - readOnly: true, - outputKind: 'text', - }) - - // Note: getDefaultLogger().log assertion removed due to mock resolution issues. - // Main behavior (not calling fetchCreateOrgFullScan) is still tested. - expect(fetchCreateOrgFullScan).not.toHaveBeenCalled() - }) - - it('handles reachability analysis', async () => { - const { fetchSupportedScanFileNames: _fetchSupportedScanFileNames } = - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - const { getPackageFilesForScan: _getPackageFilesForScan } = - await import('../../../../src/util/fs/path-resolve.mts') - await import('../../../../src/util/validation/check-input.mts') - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - const { finalizeTier1Scan } = - await import('../../../../src/commands/scan/finalize-tier1-scan.mts') - - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - mockPerformReachabilityAnalysis.mockResolvedValue( - createSuccessResult({ - reachabilityReport: '/test/project/.socket.facts.json', - tier1ReachabilityScanId: 'tier1-scan-456', - }), - ) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-789' }), - ) - - await handleCreateNewScan({ - ...mockConfig, - reach: { - excludePaths: [], - reachExcludePaths: [], - runReachabilityAnalysis: true, - }, - }) - - expect(mockPerformReachabilityAnalysis).toHaveBeenCalled() - expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( - ['/test/project/package.json', '/test/project/.socket.facts.json'], - 'test-org', - expect.any(Object), - expect.any(Object), - ) - expect(finalizeTier1Scan).toHaveBeenCalledWith('tier1-scan-456', 'scan-789') - }) - - it('handles scan report generation', async () => { - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - await import('../../../../src/util/fs/path-resolve.mts') - await import('../../../../src/util/validation/check-input.mts') - await import('../../../../src/commands/scan/fetch-create-org-full-scan.mts') - const { handleScanReport } = - await import('../../../../src/commands/scan/handle-scan-report.mts') - - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-report-123' }), - ) - - await handleCreateNewScan({ ...mockConfig, report: true }) - - expect(handleScanReport).toHaveBeenCalledWith({ - filepath: '-', - fold: 'version', - includeLicensePolicy: true, - orgSlug: 'test-org', - outputKind: 'json', - reportLevel: 'error', - scanId: 'scan-report-123', - short: false, - }) - }) - - it('handles fetch supported files failure', async () => { - await import('../../../../src/commands/scan/fetch-supported-scan-file-names.mts') - const { outputCreateNewScan } = - await import('../../../../src/commands/scan/output-create-new-scan.mts') - - const error = new Error('API error') - mockFetchSupportedScanFileNames.mockResolvedValue( - createErrorResult(error.message), - ) - - await handleCreateNewScan(mockConfig) - - expect(outputCreateNewScan).toHaveBeenCalledWith( - createErrorResult(error.message), - { interactive: false, outputKind: 'json' }, - ) - }) - - it('handles reachability analysis failure', async () => { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - mockPerformReachabilityAnalysis.mockResolvedValue( - createErrorResult('Reachability failed'), - ) - - await handleCreateNewScan({ - ...mockConfig, - reach: { - excludePaths: [], - reachExcludePaths: [], - runReachabilityAnalysis: true, - }, - }) - - expect(mockOutputCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ ok: false }), - { interactive: false, outputKind: 'json' }, - ) - }) - - it('fails when first target is falsy with reachability enabled (line 215-216)', async () => { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - - await handleCreateNewScan({ - ...mockConfig, - reach: { - excludePaths: [], - reachExcludePaths: [], - runReachabilityAnalysis: true, - }, - // Array length=1 but the first element is empty string (falsy). - targets: [''], - }) - - expect(mockPerformReachabilityAnalysis).not.toHaveBeenCalled() - expect(mockFetchCreateOrgFullScan).not.toHaveBeenCalled() - }) - - it('handles report mode with missing scan ID', async () => { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - // Return success but without id. - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ name: 'scan' }), - ) - - await handleCreateNewScan({ ...mockConfig, report: true }) - - expect(mockOutputCreateNewScan).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Missing Scan ID', - }), - { interactive: false, outputKind: 'json' }, - ) - }) - - it('handles workspace parameter', async () => { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue(['/test/project/package.json']) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-123' }), - ) - - await handleCreateNewScan({ - ...mockConfig, - workspace: 'my-workspace', - }) - - expect(mockFetchCreateOrgFullScan).toHaveBeenCalledWith( - expect.any(Array), - 'test-org', - expect.objectContaining({ - workspace: 'my-workspace', - }), - expect.any(Object), - ) - }) - - describe('basics flag', () => { - it('warns and continues to upload when socket-basics scan fails', async () => { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue([ - '/test/project/package.json', - ]) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-123' }), - ) - mockRunSocketBasics.mockResolvedValueOnce( - createErrorResult('basics failed', { cause: 'sandbox' }), - ) - - await handleCreateNewScan({ ...mockConfig, basics: true }) - - // runSocketBasics should have been called. - expect(mockRunSocketBasics).toHaveBeenCalled() - // Upload should still proceed. - expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() - }) - - it('runs runSocketBasics when basics flag is true and continues on success', async () => { - // Note: testing the SAST/secrets/containers info-log paths would require - // existsSync to return true for the factsPath, but vitest can't spy on - // ESM exports. The branch is exercised but assertions on logger output - // are limited. - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue([ - '/test/project/package.json', - ]) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-123' }), - ) - - mockRunSocketBasics.mockResolvedValueOnce( - createSuccessResult({ - factsPath: '/test/project/.socket.facts.json', - findings: { sast: 5, secrets: 3, containers: 2 }, - }), - ) - - await handleCreateNewScan({ ...mockConfig, basics: true }) - - expect(mockRunSocketBasics).toHaveBeenCalled() - // Upload should still proceed. - expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() - }) - - it('reaches the basics-findings code path when factsPath exists', async () => { - // Uses a real tmp file path so existsSync(factsPath) returns true at - // runtime, exercising the SAST/secrets/containers info-log branch. - // We can't assert on logger.info calls because the mockLogger - // reference appears stale at this point in the test sequence, but - // the path through the code is still executed for coverage. - const path = await import('node:path') - const os = await import('node:os') - const fs = await import('node:fs') - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-scan-test-')) - const factsPath = path.join(tmpDir, '.socket.facts.json') - fs.writeFileSync(factsPath, '{}', 'utf8') - - try { - mockFetchSupportedScanFileNames.mockResolvedValue( - createSuccessResult(new Set(['package.json'])), - ) - mockGetPackageFilesForScan.mockResolvedValue([ - path.join(tmpDir, 'package.json'), - ]) - mockCheckCommandInput.mockReturnValue(true) - mockFetchCreateOrgFullScan.mockResolvedValue( - createSuccessResult({ id: 'scan-123' }), - ) - - mockRunSocketBasics.mockResolvedValueOnce( - createSuccessResult({ - factsPath, - findings: { sast: 7, secrets: 4, containers: 1 }, - }), - ) - - await handleCreateNewScan({ ...mockConfig, basics: true }) - - // runSocketBasics ran, so we successfully hit the basics path. - expect(mockRunSocketBasics).toHaveBeenCalled() - expect(mockFetchCreateOrgFullScan).toHaveBeenCalled() - } finally { - safeDeleteSync(tmpDir) - } - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-delete-scan.test.mts b/packages/cli/test/unit/commands/scan/handle-delete-scan.test.mts deleted file mode 100644 index 323cc7645..000000000 --- a/packages/cli/test/unit/commands/scan/handle-delete-scan.test.mts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Unit tests for handleDeleteScan. - * - * Purpose: Tests the handler that orchestrates scan deletion. Validates scan - * cleanup and confirmation workflows. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleDeleteScan.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleDeleteScan } from '../../../../src/commands/scan/handle-delete-scan.mts' - -// Mock the dependencies. -const mockFetchDeleteOrgFullScan = vi.hoisted(() => vi.fn()) -const mockOutputDeleteScan = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/fetch-delete-org-full-scan.mts', () => ({ - fetchDeleteOrgFullScan: mockFetchDeleteOrgFullScan, -})) - -vi.mock('../../../../src/commands/scan/output-delete-scan.mts', () => ({ - outputDeleteScan: mockOutputDeleteScan, -})) - -describe('handleDeleteScan', () => { - it('deletes scan and outputs result successfully', async () => { - await import('../../../../src/commands/scan/output-delete-scan.mts') - const mockFetch = mockFetchDeleteOrgFullScan - const mockOutput = mockOutputDeleteScan - - const mockResult = createSuccessResult({ - deleted: true, - scanId: 'scan-123', - deletedAt: '2025-01-01T00:00:00Z', - }) - mockFetch.mockResolvedValue(mockResult) - - await handleDeleteScan('test-org', 'scan-123', 'json') - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'scan-123', { - commandPath: 'socket scan del', - }) - expect(mockOutput).toHaveBeenCalledWith(mockResult, 'json') - }) - - it('handles deletion failure', async () => { - await import('../../../../src/commands/scan/output-delete-scan.mts') - const mockFetch = mockFetchDeleteOrgFullScan - const mockOutput = mockOutputDeleteScan - - const mockError = createErrorResult('Scan not found') - mockFetch.mockResolvedValue(mockError) - - await handleDeleteScan('test-org', 'nonexistent-scan', 'text') - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'nonexistent-scan', { - commandPath: 'socket scan del', - }) - expect(mockOutput).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles markdown output format', async () => { - await import('../../../../src/commands/scan/output-delete-scan.mts') - const mockFetch = mockFetchDeleteOrgFullScan - const mockOutput = mockOutputDeleteScan - - mockFetch.mockResolvedValue(createSuccessResult({})) - - await handleDeleteScan('my-org', 'scan-456', 'markdown') - - expect(mockOutput).toHaveBeenCalledWith(expect.any(Object), 'markdown') - }) - - it('handles different scan IDs', async () => { - const mockFetch = mockFetchDeleteOrgFullScan - - mockFetch.mockResolvedValue(createSuccessResult({})) - - const scanIds = [ - 'scan-123', - 'scan-abc-def', - 'uuid-1234-5678-9012-3456', - 'scan_with_underscore', - ] - - for (let i = 0, { length } = scanIds; i < length; i += 1) { - const scanId = scanIds[i] - await handleDeleteScan('test-org', scanId, 'json') - expect(mockFetch).toHaveBeenCalledWith('test-org', scanId, { - commandPath: 'socket scan del', - }) - } - }) - - it('handles text output format', async () => { - await import('../../../../src/commands/scan/output-delete-scan.mts') - const mockFetch = mockFetchDeleteOrgFullScan - const mockOutput = mockOutputDeleteScan - - mockFetch.mockResolvedValue( - createSuccessResult({ - deleted: true, - message: 'Scan successfully deleted', - }), - ) - - await handleDeleteScan('production-org', 'scan-to-delete', 'text') - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ deleted: true }), - }), - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-diff-scan.test.mts b/packages/cli/test/unit/commands/scan/handle-diff-scan.test.mts deleted file mode 100644 index 758d56900..000000000 --- a/packages/cli/test/unit/commands/scan/handle-diff-scan.test.mts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Unit tests for handleDiffScan. - * - * Purpose: Tests the handler that orchestrates scan diffing. Validates - * comparison workflow and diff output. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleDiffScan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -const mockFetchDiffScan = vi.hoisted(() => vi.fn()) -const mockOutputDiffScan = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/commands/scan/fetch-diff-scan.mts', () => ({ - fetchDiffScan: mockFetchDiffScan, -})) - -vi.mock('../../../../src/commands/scan/output-diff-scan.mts', () => ({ - outputDiffScan: mockOutputDiffScan, -})) - -const { handleDiffScan } = - await import('../../../../src/commands/scan/handle-diff-scan.mts') - -describe('handleDiffScan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs scan diff successfully', async () => { - const mockFetch = mockFetchDiffScan - const mockOutput = mockOutputDiffScan - - const mockDiff = createSuccessResult({ - added: [{ name: 'new-package', version: '1.0.0' }], - removed: [{ name: 'old-package', version: '0.9.0' }], - changed: [ - { - name: 'updated-package', - oldVersion: '1.0.0', - newVersion: '2.0.0', - }, - ], - }) - mockFetch.mockResolvedValue(mockDiff) - - await handleDiffScan({ - depth: 10, - file: 'diff-report.json', - id1: 'scan-123', - id2: 'scan-456', - orgSlug: 'test-org', - outputKind: 'json', - }) - - expect(mockFetch).toHaveBeenCalledWith({ - id1: 'scan-123', - id2: 'scan-456', - orgSlug: 'test-org', - }) - expect(mockOutput).toHaveBeenCalledWith(mockDiff, { - depth: 10, - file: 'diff-report.json', - outputKind: 'json', - }) - }) - - it('handles fetch failure', async () => { - const mockFetch = mockFetchDiffScan - const mockOutput = mockOutputDiffScan - - const mockError = createErrorResult('Scans not found') - mockFetch.mockResolvedValue(mockError) - - await handleDiffScan({ - depth: 5, - file: '', - id1: 'invalid-1', - id2: 'invalid-2', - orgSlug: 'test-org', - outputKind: 'text', - }) - - expect(mockOutput).toHaveBeenCalledWith(mockError, { - depth: 5, - file: '', - outputKind: 'text', - }) - }) - - it('handles markdown output format', async () => { - const mockFetch = mockFetchDiffScan - const mockOutput = mockOutputDiffScan - - mockFetch.mockResolvedValue(createSuccessResult({})) - - await handleDiffScan({ - depth: 3, - file: 'output.md', - id1: 'scan-abc', - id2: 'scan-def', - orgSlug: 'my-org', - outputKind: 'markdown', - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - outputKind: 'markdown', - file: 'output.md', - }), - ) - }) - - it('handles different depth values', async () => { - const mockFetch = mockFetchDiffScan - const mockOutput = mockOutputDiffScan - - mockFetch.mockResolvedValue(createSuccessResult({})) - - const depths = [0, 1, 5, 10, 100] - - for (let i = 0, { length } = depths; i < length; i += 1) { - const depth = depths[i] - await handleDiffScan({ - depth, - file: '', - id1: 'scan-1', - id2: 'scan-2', - orgSlug: 'test-org', - outputKind: 'json', - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ depth }), - ) - } - }) - - it('handles text output without file', async () => { - const mockFetch = mockFetchDiffScan - const mockOutput = mockOutputDiffScan - - mockFetch.mockResolvedValue( - createSuccessResult({ - added: [], - removed: [], - changed: [], - summary: 'No changes detected', - }), - ) - - await handleDiffScan({ - depth: 2, - file: '', - id1: 'scan-old', - id2: 'scan-new', - orgSlug: 'production-org', - outputKind: 'text', - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ - summary: 'No changes detected', - }), - }), - expect.objectContaining({ - file: '', - outputKind: 'text', - }), - ) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-list-scans.test.mts b/packages/cli/test/unit/commands/scan/handle-list-scans.test.mts deleted file mode 100644 index 9c4b673e7..000000000 --- a/packages/cli/test/unit/commands/scan/handle-list-scans.test.mts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Unit tests for handleListScans. - * - * Purpose: Tests the handler that orchestrates scan listing. Validates - * pagination, filtering, and list output. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleListScans.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleListScans } from '../../../../src/commands/scan/handle-list-scans.mts' - -// Mock the dependencies. -const mockFetchOrgFullScanList = vi.hoisted(() => vi.fn()) -const mockOutputListScans = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/fetch-list-scans.mts', () => ({ - fetchOrgFullScanList: mockFetchOrgFullScanList, -})) - -vi.mock('../../../../src/commands/scan/output-list-scans.mts', () => ({ - outputListScans: mockOutputListScans, -})) - -describe('handleListScans', () => { - it('fetches and outputs scan list successfully', async () => { - await import('../../../../src/commands/scan/fetch-list-scans.mts') - await import('../../../../src/commands/scan/output-list-scans.mts') - const mockFetch = mockFetchOrgFullScanList - const mockOutput = mockOutputListScans - - const mockData = createSuccessResult([ - { - id: 'scan-123', - createdAt: '2025-01-01T00:00:00Z', - status: 'completed', - repository: 'test-repo', - branch: 'main', - }, - { - id: 'scan-456', - createdAt: '2025-01-02T00:00:00Z', - status: 'in_progress', - repository: 'another-repo', - branch: 'develop', - }, - ]) - mockFetch.mockResolvedValue(mockData) - - const params = { - branch: 'main', - direction: 'desc', - from_time: '2025-01-01', - orgSlug: 'test-org', - outputKind: 'json' as const, - page: 1, - perPage: 20, - repo: 'test-repo', - sort: 'created_at', - } - - await handleListScans(params) - - expect(mockFetch).toHaveBeenCalledWith( - { - branch: 'main', - direction: 'desc', - from_time: '2025-01-01', - orgSlug: 'test-org', - page: 1, - perPage: 20, - repo: 'test-repo', - sort: 'created_at', - }, - { - commandPath: 'socket scan list', - }, - ) - expect(mockOutput).toHaveBeenCalledWith(mockData, 'json') - }) - - it('handles fetch failure', async () => { - await import('../../../../src/commands/scan/fetch-list-scans.mts') - await import('../../../../src/commands/scan/output-list-scans.mts') - const mockFetch = mockFetchOrgFullScanList - const mockOutput = mockOutputListScans - - const mockError = createErrorResult('Unauthorized') - mockFetch.mockResolvedValue(mockError) - - await handleListScans({ - branch: '', - direction: 'asc', - from_time: '', - orgSlug: 'test-org', - outputKind: 'text', - page: 1, - perPage: 10, - repo: '', - sort: 'updated_at', - }) - - expect(mockOutput).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles pagination parameters', async () => { - await import('../../../../src/commands/scan/fetch-list-scans.mts') - const mockFetch = mockFetchOrgFullScanList - - mockFetch.mockResolvedValue(createSuccessResult([])) - - await handleListScans({ - branch: '', - direction: 'asc', - from_time: '', - orgSlug: 'test-org', - outputKind: 'json', - page: 5, - perPage: 50, - repo: '', - sort: 'created_at', - }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - page: 5, - perPage: 50, - }), - { - commandPath: 'socket scan list', - }, - ) - }) - - it('handles markdown output format', async () => { - await import('../../../../src/commands/scan/fetch-list-scans.mts') - await import('../../../../src/commands/scan/output-list-scans.mts') - const mockFetch = mockFetchOrgFullScanList - const mockOutput = mockOutputListScans - - mockFetch.mockResolvedValue(createSuccessResult([])) - - await handleListScans({ - branch: 'main', - direction: 'desc', - from_time: '', - orgSlug: 'my-org', - outputKind: 'markdown', - page: 1, - perPage: 20, - repo: 'my-repo', - sort: 'created_at', - }) - - expect(mockOutput).toHaveBeenCalledWith(expect.any(Object), 'markdown') - }) - - it('handles filtering by branch and repository', async () => { - await import('../../../../src/commands/scan/fetch-list-scans.mts') - const mockFetch = mockFetchOrgFullScanList - - mockFetch.mockResolvedValue(createSuccessResult([])) - - await handleListScans({ - branch: 'feature/new-feature', - direction: 'asc', - from_time: '2025-01-15', - orgSlug: 'test-org', - outputKind: 'json', - page: 1, - perPage: 20, - repo: 'specific-repo', - sort: 'updated_at', - }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - branch: 'feature/new-feature', - from_time: '2025-01-15', - repo: 'specific-repo', - }), - { - commandPath: 'socket scan list', - }, - ) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-scan-config.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-config.test.mts deleted file mode 100644 index 5bf3681a2..000000000 --- a/packages/cli/test/unit/commands/scan/handle-scan-config.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Unit tests for handleScanConfig. - * - * Purpose: Tests the handler that manages scan configuration. Validates config - * file handling and option processing. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleScanConfig.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleScanConfig } from '../../../../src/commands/scan/handle-scan-config.mts' - -// Mock the dependencies. -const mockOutputScanConfigResult = vi.hoisted(() => vi.fn()) -const mockSetupScanConfig = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/output-scan-config-result.mts', () => ({ - outputScanConfigResult: mockOutputScanConfigResult, -})) - -vi.mock('../../../../src/commands/scan/setup-scan-config.mts', () => ({ - setupScanConfig: mockSetupScanConfig, -})) - -describe('handleScanConfig', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('sets up scan config and outputs result', async () => { - './output-scan-config-result.mts' - const mockSetup = mockSetupScanConfig - const mockOutput = mockOutputScanConfigResult - - const mockResult = createSuccessResult({ - config: { - excludePatterns: ['node_modules/**', 'dist/**'], - includePatterns: ['src/**'], - scanLevel: 'high', - }, - }) - mockSetup.mockResolvedValue(mockResult) - - await handleScanConfig('/project', false) - - expect(mockSetup).toHaveBeenCalledWith('/project', false) - expect(mockOutput).toHaveBeenCalledWith(mockResult) - }) - - it('uses defaultOnReadError when true', async () => { - './output-scan-config-result.mts' - const mockSetup = mockSetupScanConfig - const mockOutput = mockOutputScanConfigResult - - mockSetup.mockResolvedValue(createSuccessResult({})) - - await handleScanConfig('/another/path', true) - - expect(mockSetup).toHaveBeenCalledWith('/another/path', true) - expect(mockOutput).toHaveBeenCalled() - }) - - it('handles setup failure', async () => { - './output-scan-config-result.mts' - const mockSetup = mockSetupScanConfig - const mockOutput = mockOutputScanConfigResult - - const mockError = createErrorResult('Configuration file not found') - mockSetup.mockResolvedValue(mockError) - - await handleScanConfig('/nonexistent', false) - - expect(mockOutput).toHaveBeenCalledWith(mockError) - }) - - it('uses default value for defaultOnReadError when not provided', async () => { - const mockSetup = mockSetupScanConfig - - mockSetup.mockResolvedValue(createSuccessResult({})) - - await handleScanConfig('/project') - - // When not provided, function uses default value of false. - expect(mockSetup).toHaveBeenCalledWith('/project', false) - }) - - it('handles different working directories', async () => { - const mockSetup = mockSetupScanConfig - - const cwds = ['/root', '/home/user/project', './relative/path', '.'] - - for (let i = 0, { length } = cwds; i < length; i += 1) { - const cwd = cwds[i] - mockSetup.mockResolvedValue(createSuccessResult({})) - await handleScanConfig(cwd, false) - expect(mockSetup).toHaveBeenCalledWith(cwd, false) - } - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-scan-metadata.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-metadata.test.mts deleted file mode 100644 index 74f380504..000000000 --- a/packages/cli/test/unit/commands/scan/handle-scan-metadata.test.mts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Unit tests for handleScanMetadata. - * - * Purpose: Tests the handler that retrieves scan metadata. Validates metadata - * fetching and formatting. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleScanMetadata.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' -import { handleOrgScanMetadata } from '../../../../src/commands/scan/handle-scan-metadata.mts' - -// Mock the dependencies. -const mockFetchScanMetadata = vi.hoisted(() => vi.fn()) -const mockOutputScanMetadata = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/fetch-scan-metadata.mts', () => ({ - fetchScanMetadata: mockFetchScanMetadata, -})) - -vi.mock('../../../../src/commands/scan/output-scan-metadata.mts', () => ({ - outputScanMetadata: mockOutputScanMetadata, -})) - -describe('handleOrgScanMetadata', () => { - it('fetches and outputs scan metadata successfully', async () => { - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - await import('../../../../src/commands/scan/output-scan-metadata.mts') - const mockFetch = mockFetchScanMetadata - const mockOutput = mockOutputScanMetadata - - const mockMetadata = createSuccessResult({ - scanId: 'scan-123', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T01:00:00Z', - status: 'completed', - packageManager: 'npm', - repository: 'test-repo', - branch: 'main', - commit: 'abc123def456', - }) - mockFetch.mockResolvedValue(mockMetadata) - - await handleOrgScanMetadata('test-org', 'scan-123', 'json') - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'scan-123', { - commandPath: 'socket scan metadata', - }) - expect(mockOutput).toHaveBeenCalledWith(mockMetadata, 'scan-123', 'json') - }) - - it('handles fetch failure', async () => { - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - await import('../../../../src/commands/scan/output-scan-metadata.mts') - const mockFetch = mockFetchScanMetadata - const mockOutput = mockOutputScanMetadata - - const mockError = createErrorResult('Scan not found') - mockFetch.mockResolvedValue(mockError) - - await handleOrgScanMetadata('test-org', 'invalid-scan', 'text') - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'invalid-scan', { - commandPath: 'socket scan metadata', - }) - expect(mockOutput).toHaveBeenCalledWith(mockError, 'invalid-scan', 'text') - }) - - it('handles markdown output format', async () => { - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - await import('../../../../src/commands/scan/output-scan-metadata.mts') - const mockFetch = mockFetchScanMetadata - const mockOutput = mockOutputScanMetadata - - mockFetch.mockResolvedValue( - createSuccessResult({ - scanId: 'scan-456', - status: 'in_progress', - }), - ) - - await handleOrgScanMetadata('my-org', 'scan-456', 'markdown') - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - 'scan-456', - 'markdown', - ) - }) - - it('handles different scan IDs', async () => { - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - await import('../../../../src/commands/scan/output-scan-metadata.mts') - const mockFetch = mockFetchScanMetadata - const mockOutput = mockOutputScanMetadata - - const scanIds = [ - 'scan-abc123', - 'scan-def456', - 'scan-ghi789', - 'uuid-1234-5678-9012-3456', - ] - - for (let i = 0, { length } = scanIds; i < length; i += 1) { - const scanId = scanIds[i] - mockFetch.mockResolvedValue(createSuccessResult({})) - await handleOrgScanMetadata('test-org', scanId, 'json') - expect(mockFetch).toHaveBeenCalledWith('test-org', scanId, { - commandPath: 'socket scan metadata', - }) - } - }) - - it('handles text output with detailed metadata', async () => { - await import('../../../../src/commands/scan/fetch-scan-metadata.mts') - await import('../../../../src/commands/scan/output-scan-metadata.mts') - const mockFetch = mockFetchScanMetadata - const mockOutput = mockOutputScanMetadata - - mockFetch.mockResolvedValue( - createSuccessResult({ - scanId: 'scan-xyz', - createdAt: '2025-01-01T10:00:00Z', - status: 'completed', - packagesScanned: 150, - vulnerabilitiesFound: 3, - duration: '45s', - }), - ) - - await handleOrgScanMetadata('production-org', 'scan-xyz', 'text') - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: expect.objectContaining({ - packagesScanned: 150, - }), - }), - 'scan-xyz', - 'text', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts deleted file mode 100644 index 456ce9f17..000000000 --- a/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Unit tests for handleScanReach. - * - * Purpose: Tests the handler that performs reachability analysis. Validates - * dependency reachability checks and impact analysis. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleScanReach.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../helpers/mocks.mts' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockStart = vi.hoisted(() => vi.fn()) -const mockStop = vi.hoisted(() => vi.fn()) -const mockSuccessAndStop = vi.hoisted(() => vi.fn()) -const mockGetSpinner = vi.hoisted(() => - vi.fn(() => ({ - start: mockStart, - stop: mockStop, - successAndStop: mockSuccessAndStop, - })), -) -const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) -const mockOutputScanReach = vi.hoisted(() => vi.fn()) -const mockPerformReachabilityAnalysis = vi.hoisted(() => vi.fn()) -const mockCheckCommandInput = vi.hoisted(() => vi.fn()) -const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) -const mockPluralize = vi.hoisted(() => vi.fn(str => str)) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: mockGetSpinner, -})) - -vi.mock('@socketsecurity/lib-stable/words/pluralize', () => ({ - pluralize: mockPluralize, -})) - -vi.mock( - '../../../../src/commands/scan/fetch-supported-scan-file-names.mts', - () => ({ - fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, - }), -) - -vi.mock('../../../../src/commands/scan/output-scan-reach.mts', () => ({ - outputScanReach: mockOutputScanReach, -})) - -vi.mock( - '../../../../src/commands/scan/perform-reachability-analysis.mts', - () => ({ - performReachabilityAnalysis: mockPerformReachabilityAnalysis, - }), -) - -vi.mock('../../../../src/util/validation/check-input.mts', () => ({ - checkCommandInput: mockCheckCommandInput, -})) - -vi.mock('../../../../src/util/fs/path-resolve.mjs', () => ({ - getPackageFilesForScan: mockGetPackageFilesForScan, -})) - -const { handleScanReach } = - await import('../../../../src/commands/scan/handle-scan-reach.mts') - -describe('handleScanReach', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('performs reachability analysis successfully', async () => { - const mockFetchSupported = mockFetchSupportedScanFileNames - const mockOutput = mockOutputScanReach - const mockPerformAnalysis = mockPerformReachabilityAnalysis - const mockCheckInput = mockCheckCommandInput - const mockGetPackageFiles = mockGetPackageFilesForScan - - mockFetchSupported.mockResolvedValue( - createSuccessResult(['package.json', 'package-lock.json']), - ) - mockGetPackageFiles.mockResolvedValue([ - '/project/package.json', - '/project/package-lock.json', - ]) - mockCheckInput.mockReturnValue(true) - mockPerformAnalysis.mockResolvedValue( - createSuccessResult({ - reachablePackages: 10, - totalPackages: 50, - }), - ) - - await handleScanReach({ - cwd: '/project', - interactive: false, - orgSlug: 'test-org', - outputKind: 'json', - outputPath: '', - reachabilityOptions: { - excludePaths: [], - reachAnalysisTimeout: 300, - reachAnalysisMemoryLimit: 2048, - reachDisableAnalytics: false, - reachEcosystems: [], - reachExcludePaths: [], - reachMinSeverity: 'low', - reachSkipCache: false, - reachUseUnreachableFromPrecomputation: false, - }, - targets: ['src'], - }) - - expect(mockPerformAnalysis).toHaveBeenCalledWith({ - cwd: '/project', - orgSlug: 'test-org', - outputPath: '', - packagePaths: ['/project/package.json', '/project/package-lock.json'], - reachabilityOptions: { - excludePaths: [], - reachAnalysisTimeout: 300, - reachAnalysisMemoryLimit: 2048, - reachDisableAnalytics: false, - reachEcosystems: [], - reachExcludePaths: [], - reachMinSeverity: 'low', - reachSkipCache: false, - reachUseUnreachableFromPrecomputation: false, - }, - spinner: expect.any(Object), - target: 'src', - uploadManifests: true, - }) - expect(mockOutput).toHaveBeenCalled() - }) - - it('handles supported files fetch failure', async () => { - const mockFetchSupported = mockFetchSupportedScanFileNames - const mockOutput = mockOutputScanReach - - const mockError = createErrorResult('Failed to fetch supported files') - mockFetchSupported.mockResolvedValue(mockError) - - await handleScanReach({ - cwd: '/project', - interactive: false, - orgSlug: 'test-org', - outputKind: 'text', - outputPath: '', - reachabilityOptions: {}, - targets: [], - }) - - expect(mockOutput).toHaveBeenCalledWith(mockError, { - outputKind: 'text', - outputPath: '', - }) - }) - - it('handles no eligible files found', async () => { - const mockFetchSupported = mockFetchSupportedScanFileNames - const mockCheckInput = mockCheckCommandInput - const mockGetPackageFiles = mockGetPackageFilesForScan - - mockFetchSupported.mockResolvedValue(createSuccessResult(['package.json'])) - mockGetPackageFiles.mockResolvedValue([]) - mockCheckInput.mockReturnValue(false) - - await handleScanReach({ - cwd: '/empty', - interactive: false, - orgSlug: 'test-org', - outputKind: 'json', - outputPath: '', - reachabilityOptions: { excludePaths: [], reachExcludePaths: [] }, - targets: ['nonexistent'], - }) - - expect(mockCheckInput).toHaveBeenCalledWith('json', { - nook: true, - test: false, - fail: 'found no eligible files to analyze', - message: expect.any(String), - }) - }) - - it('handles reachability analysis failure', async () => { - const mockFetchSupported = mockFetchSupportedScanFileNames - const mockOutput = mockOutputScanReach - const mockPerformAnalysis = mockPerformReachabilityAnalysis - const mockCheckInput = mockCheckCommandInput - const mockGetPackageFiles = mockGetPackageFilesForScan - - mockFetchSupported.mockResolvedValue(createSuccessResult(['package.json'])) - mockGetPackageFiles.mockResolvedValue(['/project/package.json']) - mockCheckInput.mockReturnValue(true) - - const analysisError = createErrorResult('Analysis failed') - mockPerformAnalysis.mockResolvedValue(analysisError) - - await handleScanReach({ - cwd: '/project', - interactive: true, - orgSlug: 'test-org', - outputKind: 'markdown', - outputPath: '', - reachabilityOptions: { - excludePaths: [], - reachExcludePaths: [], - maxDepth: 10, - }, - targets: ['./'], - }) - - expect(mockOutput).toHaveBeenCalledWith(analysisError, { - outputKind: 'markdown', - outputPath: '', - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-scan-report.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-report.test.mts deleted file mode 100644 index aeb0c6660..000000000 --- a/packages/cli/test/unit/commands/scan/handle-scan-report.test.mts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Unit tests for handleScanReport. - * - * Purpose: Tests the handler that generates scan reports. Validates - * comprehensive report generation and formatting. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleScanReport.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -// Mock the dependencies. -const mockFetchScanData = vi.hoisted(() => vi.fn()) -const mockOutputScanReport = vi.hoisted(() => vi.fn()) -const mockSetupSdk = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/fetch-report-data.mts', () => ({ - fetchScanData: mockFetchScanData, -})) - -vi.mock('../../../../src/commands/scan/output-scan-report.mts', () => ({ - outputScanReport: mockOutputScanReport, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -describe('handleScanReport', () => { - let handleScanReport: unknown - - beforeEach(async () => { - vi.clearAllMocks() - if (!handleScanReport) { - const module = - await import('../../../../src/commands/scan/handle-scan-report.mts') - handleScanReport = module.handleScanReport - } - }) - - it('fetches scan data and outputs report successfully', async () => { - const mockFetch = mockFetchScanData - const mockOutput = mockOutputScanReport - const mockSetup = mockSetupSdk - - // Mock setupSdk to return success (not used directly by handleScanReport, but needed by fetchScanData) - mockSetup.mockResolvedValue(createSuccessResult({ api: {} })) - - const mockScanData = createSuccessResult({ - scan: { - id: 'scan-123', - status: 'completed', - packages: [], - }, - issues: [], - }) - mockFetch.mockResolvedValue(mockScanData) - - await handleScanReport({ - orgSlug: 'test-org', - scanId: 'scan-123', - includeLicensePolicy: true, - outputKind: 'json', - filepath: '/path/to/package.json', - fold: 'none', - reportLevel: 'high', - short: false, - }) - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'scan-123', { - includeLicensePolicy: true, - }) - expect(mockOutput).toHaveBeenCalledWith(mockScanData, { - filepath: '/path/to/package.json', - fold: 'none', - scanId: 'scan-123', - includeLicensePolicy: true, - orgSlug: 'test-org', - outputKind: 'json', - reportLevel: 'high', - short: false, - }) - }) - - it('handles fetch failure', async () => { - const mockFetch = mockFetchScanData - const mockOutput = mockOutputScanReport - const mockSetup = mockSetupSdk - - mockSetup.mockResolvedValue(createSuccessResult({ api: {} })) - - const mockError = createErrorResult('Scan not found') - mockFetch.mockResolvedValue(mockError) - - await handleScanReport({ - orgSlug: 'test-org', - scanId: 'invalid-scan', - includeLicensePolicy: false, - outputKind: 'text', - filepath: 'package.json', - fold: 'all', - reportLevel: 'critical', - short: true, - }) - - expect(mockFetch).toHaveBeenCalledWith('test-org', 'invalid-scan', { - includeLicensePolicy: false, - }) - expect(mockOutput).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - it('handles markdown output format', async () => { - const mockFetch = mockFetchScanData - const mockOutput = mockOutputScanReport - const mockSetup = mockSetupSdk - - mockSetup.mockResolvedValue(createSuccessResult({ api: {} })) - mockFetch.mockResolvedValue(createSuccessResult({})) - - await handleScanReport({ - orgSlug: 'test-org', - scanId: 'scan-456', - includeLicensePolicy: false, - outputKind: 'markdown', - filepath: 'yarn.lock', - fold: 'duplicates', - reportLevel: 'medium', - short: false, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('passes all configuration options correctly', async () => { - const mockFetch = mockFetchScanData - const mockOutput = mockOutputScanReport - const mockSetup = mockSetupSdk - - mockSetup.mockResolvedValue(createSuccessResult({ api: {} })) - mockFetch.mockResolvedValue(createSuccessResult({})) - - const config = { - orgSlug: 'my-org', - scanId: 'scan-789', - includeLicensePolicy: true, - outputKind: 'json' as const, - filepath: 'pnpm-lock.yaml', - fold: 'none' as const, - reportLevel: 'low' as const, - short: true, - } - - await handleScanReport(config) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining(config), - ) - }) - - it('handles text output with short format', async () => { - const mockFetch = mockFetchScanData - const mockOutput = mockOutputScanReport - const mockSetup = mockSetupSdk - - mockSetup.mockResolvedValue(createSuccessResult({ api: {} })) - mockFetch.mockResolvedValue( - createSuccessResult({ - scan: { id: 'scan-abc' }, - issues: [{ severity: 'high', package: 'vulnerable-pkg' }], - }), - ) - - await handleScanReport({ - orgSlug: 'test-org', - scanId: 'scan-abc', - includeLicensePolicy: false, - outputKind: 'text', - filepath: 'package-lock.json', - fold: 'all', - reportLevel: 'high', - short: true, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - short: true, - outputKind: 'text', - }), - ) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/handle-scan-view.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-view.test.mts deleted file mode 100644 index af36427fe..000000000 --- a/packages/cli/test/unit/commands/scan/handle-scan-view.test.mts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Unit tests for handleScanView. - * - * Purpose: Tests the handler that displays scan results. Validates scan data - * presentation and formatting. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleScanView.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleScanView } from '../../../../src/commands/scan/handle-scan-view.mts' - -// Mock the dependencies. -const mockFetchScan = vi.hoisted(() => vi.fn()) -const mockOutputScanView = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/fetch-scan.mts', () => ({ - fetchScan: mockFetchScan, -})) -vi.mock('../../../../src/commands/scan/output-scan-view.mts', () => ({ - outputScanView: mockOutputScanView, -})) - -describe('handleScanView', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs scan view successfully', async () => { - const { fetchScan } = - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const mockData = { - ok: true, - data: { - id: 'scan-123', - status: 'completed', - results: { - high: 2, - medium: 5, - low: 10, - }, - createdAt: '2024-01-01T00:00:00Z', - }, - } - mockFetchScan.mockResolvedValue(mockData) - - await handleScanView('test-org', 'scan-123', '/output/path.json', 'json') - - expect(fetchScan).toHaveBeenCalledWith('test-org', 'scan-123') - expect(outputScanView).toHaveBeenCalledWith( - mockData, - 'test-org', - 'scan-123', - '/output/path.json', - 'json', - ) - }) - - it('handles fetch failure', async () => { - const { fetchScan } = - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const mockError = { - ok: false, - error: new Error('Scan not found'), - } - mockFetchScan.mockResolvedValue(mockError) - - await handleScanView('test-org', 'invalid-scan', '', 'text') - - expect(fetchScan).toHaveBeenCalledWith('test-org', 'invalid-scan') - expect(outputScanView).toHaveBeenCalledWith( - mockError, - 'test-org', - 'invalid-scan', - '', - 'text', - ) - }) - - it('handles markdown output', async () => { - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const mockData = { - ok: true, - data: { - id: 'scan-456', - status: 'in_progress', - results: undefined, - }, - } - mockFetchScan.mockResolvedValue(mockData) - - await handleScanView('org-2', 'scan-456', 'report.md', 'markdown') - - expect(outputScanView).toHaveBeenCalledWith( - mockData, - 'org-2', - 'scan-456', - 'report.md', - 'markdown', - ) - }) - - it('handles empty file path', async () => { - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const mockData = { - ok: true, - data: { id: 'scan-789', status: 'pending' }, - } - mockFetchScan.mockResolvedValue(mockData) - - await handleScanView('my-org', 'scan-789', '', 'json') - - expect(outputScanView).toHaveBeenCalledWith( - mockData, - 'my-org', - 'scan-789', - '', - 'json', - ) - }) - - it('handles different scan statuses', async () => { - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const statuses = ['pending', 'in_progress', 'completed', 'failed'] - - for (let i = 0, { length } = statuses; i < length; i += 1) { - const status = statuses[i] - mockFetchScan.mockResolvedValue({ - ok: true, - data: { id: 'scan-test', status }, - }) - - await handleScanView('org', 'scan-test', 'output.json', 'json') - - expect(outputScanView).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ status }), - }), - 'org', - 'scan-test', - 'output.json', - 'json', - ) - } - }) - - it('handles text output format', async () => { - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { fetchScan } = - await import('../../../../src/commands/scan/fetch-scan.mts') - const { outputScanView } = - await import('../../../../src/commands/scan/output-scan-view.mts') - - const mockData = { - ok: true, - data: { - id: 'scan-999', - status: 'completed', - vulnerabilities: [], - }, - } - mockFetchScan.mockResolvedValue(mockData) - - await handleScanView('test-org', 'scan-999', '-', 'text') - - expect(outputScanView).toHaveBeenCalledWith( - mockData, - 'test-org', - 'scan-999', - '-', - 'text', - ) - }) - - it('handles async errors', async () => { - // biome-ignore lint/correctness/noUnusedVariables: imported for mocking. - const { fetchScan } = - await import('../../../../src/commands/scan/fetch-scan.mts') - - mockFetchScan.mockRejectedValue(new Error('Network error')) - - await expect( - handleScanView('org', 'scan-id', 'file.json', 'json'), - ).rejects.toThrow('Network error') - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-create-new-scan.test.mts b/packages/cli/test/unit/commands/scan/output-create-new-scan.test.mts deleted file mode 100644 index 8f0808aa1..000000000 --- a/packages/cli/test/unit/commands/scan/output-create-new-scan.test.mts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Unit tests for outputCreateNewScan. - * - * Purpose: Tests output formatting for new scan creation. Validates scan ID - * display and status messages. - * - * Test Coverage: - Successful operation output formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Data - * presentation and formatting - Edge case handling. - * - * Testing Approach: Uses result helpers and fixtures to create test data. - * Validates formatted output strings across different output modes. - * - * Related Files: - src/commands/outputCreateNewScan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { outputCreateNewScan } from '../../../../src/commands/scan/output-create-new-scan.mts' - -import type { CResult } from '../../../../src/commands/scan/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Mock the dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -// Helper references to mockLogger methods. -const mockLog = mockLogger.log -const mockFail = mockLogger.fail -const mockSuccess = mockLogger.success - -const mockSerializeResultJson = vi.hoisted(() => - vi.fn(result => JSON.stringify(result)), -) -const mockOpenDefault = vi.hoisted(() => vi.fn()) -const mockConfirmFn = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: vi.fn((msg, cause) => `${msg}: ${cause}`), -})) - -vi.mock('open', () => ({ - default: mockOpenDefault, -})) - -vi.mock('terminal-link', () => ({ - default: vi.fn((text: string, url: string) => `[${text}](${url})`), -})) - -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - confirm: mockConfirmFn, -})) - -describe('outputCreateNewScan', () => { - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockSpinner.isSpinning = false - mockSpinner.start.mockClear() - mockSpinner.stop.mockClear() - }) - - it('outputs JSON format for successful result', async () => { - const mockSerialize = mockSerializeResultJson - - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/123', - id: 'scan-123', - }, - } - - await outputCreateNewScan(result, { outputKind: 'json' }) - - expect(mockSerialize).toHaveBeenCalledWith(result) - expect(mockLog).toHaveBeenCalledWith(JSON.stringify(result)) - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error in JSON format', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: false, - code: 2, - message: 'Unauthorized', - cause: 'Invalid API token', - } - - await outputCreateNewScan(result, { outputKind: 'json' }) - - expect(mockLog).toHaveBeenCalled() - expect(process.exitCode).toBe(2) - }) - - it('outputs success message with report URL in text format', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/456', - id: 'scan-456', - }, - } - - await outputCreateNewScan(result, { outputKind: 'text' }) - - expect(mockSuccess).toHaveBeenCalledWith('Scan completed successfully!') - expect(mockLog).toHaveBeenCalledWith( - 'View report at: [https://socket.dev/report/456](https://socket.dev/report/456)', - ) - }) - - it('outputs markdown format with scan ID', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/789', - id: 'scan-789', - }, - } - - await outputCreateNewScan(result, { outputKind: 'markdown' }) - - expect(mockLog).toHaveBeenCalledWith('# Create New Scan') - expect(mockLog).toHaveBeenCalledWith('') - expect(mockLog).toHaveBeenCalledWith( - 'A [new Scan](https://socket.dev/report/789) was created with ID: scan-789', - ) - expect(mockLog).toHaveBeenCalledWith('') - }) - - it('handles missing scan ID properly', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/no-id', - id: undefined as unknown, - }, - } - - await outputCreateNewScan(result, { outputKind: 'text' }) - - expect(mockFail).toHaveBeenCalledWith( - 'Did not receive a scan ID from the API.', - ) - expect(process.exitCode).toBe(1) - }) - - it('outputs error in text format', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: false, - code: 1, - message: 'Failed to create scan', - cause: 'Network error', - } - - await outputCreateNewScan(result, { outputKind: 'text' }) - - expect(mockFail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('opens browser when interactive and user confirms', async () => { - const mockConfirm = mockConfirmFn - const mockOpen = mockOpenDefault - - mockConfirm.mockResolvedValue(true) - - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/browser-test', - id: 'scan-browser-test', - }, - } - - await outputCreateNewScan(result, { - interactive: true, - outputKind: 'text', - }) - - expect(mockConfirm).toHaveBeenCalledWith({ - default: false, - message: 'Would you like to open it in your browser?', - }) - expect(mockOpen).toHaveBeenCalledWith( - 'https://socket.dev/report/browser-test', - ) - }) - - it('does not open browser when user declines', async () => { - const mockConfirm = mockConfirmFn - const mockOpen = mockOpenDefault - - mockConfirm.mockResolvedValue(false) - - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/no-browser', - id: 'scan-no-browser', - }, - } - - await outputCreateNewScan(result, { - interactive: true, - outputKind: 'text', - }) - - expect(mockConfirm).toHaveBeenCalled() - expect(mockOpen).not.toHaveBeenCalled() - }) - - it('handles spinner lifecycle correctly', async () => { - mockSpinner.isSpinning = true - - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: 'https://socket.dev/report/spinner', - id: 'scan-spinner', - }, - } - - await outputCreateNewScan(result, { - outputKind: 'text', - spinner: mockSpinner, - }) - - expect(mockSpinner.stop).toHaveBeenCalled() - expect(mockSpinner.start).toHaveBeenCalled() - }) - - it('handles missing report URL', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: true, - data: { - html_report_url: undefined as unknown, - id: 'scan-no-url', - }, - } - - await outputCreateNewScan(result, { outputKind: 'text' }) - - expect(mockLog).toHaveBeenCalledWith('No report available.') - }) - - it('sets default exit code when code is undefined', async () => { - const result: CResult<SocketSdkSuccessResult<'CreateOrgFullScan'>['data']> = - { - ok: false, - message: 'Error without code', - } - - await outputCreateNewScan(result, { outputKind: 'json' }) - - expect(process.exitCode).toBe(1) - }) - - it('restarts spinner after JSON output if it was spinning', async () => { - const spinner = { isSpinning: true, start: vi.fn(), stop: vi.fn() } - await outputCreateNewScan( - { ok: true, data: { id: 'x', html_report_url: 'http://x' } as unknown }, - { outputKind: 'json', spinner: spinner as unknown }, - ) - - expect(spinner.stop).toHaveBeenCalled() - expect(spinner.start).toHaveBeenCalled() - }) - - it('restarts spinner after text-mode error if it was spinning', async () => { - const spinner = { isSpinning: true, start: vi.fn(), stop: vi.fn() } - await outputCreateNewScan( - { ok: false, message: 'fail' }, - { outputKind: 'text', spinner: spinner as unknown }, - ) - - expect(spinner.start).toHaveBeenCalled() - }) - - it('restarts spinner after markdown output if it was spinning', async () => { - const spinner = { isSpinning: true, start: vi.fn(), stop: vi.fn() } - await outputCreateNewScan( - { ok: true, data: { id: 'x', html_report_url: 'http://x' } as unknown }, - { outputKind: 'markdown', spinner: spinner as unknown }, - ) - - expect(spinner.start).toHaveBeenCalled() - }) - - it('renders no-id markdown branch', async () => { - const spinner = { isSpinning: false, start: vi.fn(), stop: vi.fn() } - await outputCreateNewScan( - { ok: true, data: { id: '' as unknown, html_report_url: '' as unknown } }, - { outputKind: 'markdown', spinner: spinner as unknown }, - ) - - const calls = mockLog.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('did not return a Scan ID') - expect(process.exitCode).toBe(1) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-delete-scan.test.mts b/packages/cli/test/unit/commands/scan/output-delete-scan.test.mts deleted file mode 100644 index 56e77ef9b..000000000 --- a/packages/cli/test/unit/commands/scan/output-delete-scan.test.mts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Unit tests for delete scan output. - * - * Purpose: Tests the outputDeleteScan function for different output formats. - * - * Test Coverage: - JSON output format - Text output format - Error handling. - * - * Related Files: - commands/scan/output-delete-scan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - success: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (message: string, cause?: string) => - cause ? `${message}: ${cause}` : message, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result), -})) - -import { outputDeleteScan } from '../../../../src/commands/scan/output-delete-scan.mts' - -describe('output-delete-scan', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = 0 - }) - - describe('outputDeleteScan', () => { - it('outputs JSON for successful result', async () => { - const result = { - ok: true as const, - data: { success: true }, - } - - await outputDeleteScan(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok":true'), - ) - }) - - it('outputs JSON for error result', async () => { - const result = { - ok: false as const, - message: 'Delete failed', - code: 1, - } - - await outputDeleteScan(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok":false'), - ) - expect(process.exitCode).toBe(1) - }) - - it('outputs text success message', async () => { - const result = { - ok: true as const, - data: { success: true }, - } - - await outputDeleteScan(result, 'text') - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Scan deleted successfully', - ) - }) - - it('outputs text error message', async () => { - const result = { - ok: false as const, - message: 'Scan not found', - cause: 'Invalid scan ID', - code: 1, - } - - await outputDeleteScan(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('sets default exit code when code is undefined', async () => { - const result = { - ok: false as const, - message: 'Error occurred', - } - - await outputDeleteScan(result, 'text') - - expect(process.exitCode).toBe(1) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-diff-scan.test.mts b/packages/cli/test/unit/commands/scan/output-diff-scan.test.mts deleted file mode 100644 index bb900570b..000000000 --- a/packages/cli/test/unit/commands/scan/output-diff-scan.test.mts +++ /dev/null @@ -1,608 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for outputDiffScan. - * - * Purpose: Tests output formatting for scan diff operations. Validates JSON, - * markdown, and text output modes. - * - * Test Coverage: - JSON output mode with file writing - Markdown output - * formatting - Text output with inspect - Error handling - File write - * operations - Exit code setting. - * - * Testing Approach: Uses mocked logger and fs to capture output. Tests - * different output modes and edge cases. - * - * Related Files: - src/commands/scan/output-diff-scan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { CResult } from '../../../../src/types.mts' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -// Mock failMsgWithBadge. -const mockFailMsgWithBadge = vi.hoisted(() => - vi.fn((msg, cause) => `${msg}: ${cause}`), -) -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, -})) - -// Mock serializeResultJson. -const mockSerializeResultJson = vi.hoisted(() => - vi.fn(result => JSON.stringify(result)), -) -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, -})) - -// Mock markdown utilities. -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: vi.fn((text, level = 1) => `${'#'.repeat(level)} ${text}`), -})) - -// Mock terminal link. -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - fileLink: vi.fn(path => path), -})) - -// Mock fs. -const mockWriteFile = vi.hoisted(() => vi.fn()) -vi.mock('node:fs', () => ({ - promises: { - writeFile: mockWriteFile, - }, -})) - -import { outputDiffScan } from '../../../../src/commands/scan/output-diff-scan.mts' - -// Helper to create error result. -export function createErrorResult( - message: string, - options: { code?: number | undefined; cause?: string | undefined } = {}, -): CResult<never> { - return { ok: false, message, ...options } -} - -// Helper to create mock diff scan data. -function createMockDiffData(overrides = {}) { - return { - diff_report_url: 'https://socket.dev/diff/123', - directDependenciesChanged: true, - before: { - id: 'scan-before', - organization_id: 'org-1', - organization_slug: 'test-org', - repository_id: 'repo-1', - repository_slug: 'test-repo', - branch: 'main', - created_at: '2024-01-01T00:00:00Z', - pull_request: undefined, - }, - after: { - id: 'scan-after', - organization_id: 'org-1', - organization_slug: 'test-org', - repository_id: 'repo-1', - repository_slug: 'test-repo', - branch: 'feature', - created_at: '2024-01-02T00:00:00Z', - pull_request: undefined, - }, - artifacts: { - added: [], - removed: [], - replaced: [], - updated: [], - unchanged: [], - }, - ...overrides, - } -} - -// Helper to create success result. -export function createSuccessResult<T>(data: T): CResult<T> { - return { ok: true, data } -} - -describe('outputDiffScan', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockWriteFile.mockResolvedValue(undefined) - }) - - describe('error handling', () => { - it('sets exit code for error result', async () => { - const result = createErrorResult('Diff scan failed', { - code: 500, - cause: 'Server error', - }) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(process.exitCode).toBe(500) - }) - - it('outputs error in JSON mode', async () => { - const result = createErrorResult('Diff scan failed', { - code: 404, - cause: 'Not found', - }) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(404) - }) - - it('outputs error in text mode', async () => { - const result = createErrorResult('Diff scan failed', { - code: 1, - cause: 'Network error', - }) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('defaults exit code to 1 when code is undefined', async () => { - const result = { - ok: false, - message: 'Error without code', - } - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(process.exitCode).toBe(1) - }) - }) - - describe('JSON output mode', () => { - it('outputs JSON to stdout', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalled() - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Diff scan result'), - ) - }) - - it('outputs JSON to stdout with dash file', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '-', - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalled() - expect(mockWriteFile).not.toHaveBeenCalled() - }) - - it('writes JSON to file', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: 'output.json', - outputKind: 'text', - }) - - expect(mockWriteFile).toHaveBeenCalledWith( - 'output.json', - expect.any(String), - 'utf8', - ) - expect(mockLogger.success).toHaveBeenCalled() - }) - - it('handles file write error', async () => { - mockWriteFile.mockRejectedValue(new Error('Permission denied')) - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '/invalid/path.json', - outputKind: 'text', - }) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('failed'), - ) - expect(mockLogger.error).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - }) - - describe('markdown output mode', () => { - it('outputs markdown header', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Scan diff result'), - ) - }) - - it('outputs added packages', async () => { - const mockData = createMockDiffData({ - artifacts: { - added: [ - { type: 'npm', name: 'lodash', version: '4.17.21' }, - { type: 'npm', name: 'express', version: '4.18.0' }, - ], - removed: [], - replaced: [], - updated: [], - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Added packages: 2') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('lodash@4.17.21'), - ) - }) - - it('outputs removed packages', async () => { - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [{ type: 'npm', name: 'old-package', version: '1.0.0' }], - replaced: [], - updated: [], - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Removed packages: 1') - }) - - it('truncates removed package list when over 10', async () => { - const removedMany = Array.from({ length: 12 }, (_, i) => ({ - type: 'npm', - name: `removed-${i}`, - version: '1.0.0', - })) - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: removedMany, - replaced: [], - updated: [], - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Removed packages: 12') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('and 2 more'), - ) - }) - - it('truncates package lists longer than 10', async () => { - const manyPackages = Array.from({ length: 15 }, (_, i) => ({ - type: 'npm', - name: `package-${i}`, - version: '1.0.0', - })) - const mockData = createMockDiffData({ - artifacts: { - added: manyPackages, - removed: [], - replaced: [], - updated: [], - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('and 5 more'), - ) - }) - - it('outputs unchanged packages', async () => { - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [], - replaced: [], - updated: [], - unchanged: [{ type: 'npm', name: 'stable-pkg', version: '1.0.0' }], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Unchanged packages: 1') - }) - - it('outputs replaced packages with truncation', async () => { - const replacedMany = Array.from({ length: 12 }, (_, i) => ({ - type: 'npm', - name: `pkg-replaced-${i}`, - version: '2.0.0', - })) - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [], - replaced: replacedMany, - updated: [], - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Replaced packages: 12') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('and 2 more'), - ) - }) - - it('outputs updated packages with truncation', async () => { - const updatedMany = Array.from({ length: 12 }, (_, i) => ({ - type: 'npm', - name: `pkg-updated-${i}`, - version: '3.0.0', - })) - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [], - replaced: [], - updated: updatedMany, - unchanged: [], - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Updated packages: 12') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('and 2 more'), - ) - }) - - it('truncates unchanged packages list when over 10', async () => { - const unchangedMany = Array.from({ length: 12 }, (_, i) => ({ - type: 'npm', - name: `unchanged-${i}`, - version: '1.0.0', - })) - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [], - replaced: [], - updated: [], - unchanged: unchangedMany, - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Unchanged packages: 12') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('and 2 more'), - ) - }) - - it('handles null unchanged array', async () => { - const mockData = createMockDiffData({ - artifacts: { - added: [], - removed: [], - replaced: [], - updated: [], - unchanged: undefined, - }, - }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('- Unchanged packages: 0') - }) - - it('outputs scan metadata', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Scan scan-before'), - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Scan scan-after'), - ) - }) - - it('skips null pull_request in output', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'markdown', - }) - - // Should not output pull_request line when null. - const calls = mockLogger.group.mock.calls.flat() - const hasPullRequest = calls.some( - call => typeof call === 'string' && call.includes('pull_request'), - ) - expect(hasPullRequest).toBe(false) - }) - }) - - describe('text output mode', () => { - it('outputs inspect result', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(mockLogger.log).toHaveBeenCalledWith('Diff scan result:') - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('--json flag'), - ) - }) - - it('uses null depth for depth <= 0', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 0, - file: '', - outputKind: 'text', - }) - - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('includes dashboard message', async () => { - const mockData = createMockDiffData() - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('socket.dev/diff/123'), - ) - }) - - it('handles missing dashboard URL', async () => { - const mockData = createMockDiffData({ diff_report_url: undefined }) - const result = createSuccessResult(mockData) - - await outputDiffScan(result as unknown, { - depth: 5, - file: '', - outputKind: 'text', - }) - - expect(mockLogger.log).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-list-scans.test.mts b/packages/cli/test/unit/commands/scan/output-list-scans.test.mts deleted file mode 100644 index 2e5b48f36..000000000 --- a/packages/cli/test/unit/commands/scan/output-list-scans.test.mts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Unit tests for outputListScans. - * - * Purpose: Tests output formatting for scan list operations. Validates table - * formatting and JSON serialization. - * - * Test Coverage: - Successful scan list output formatting - Error message - * formatting - JSON output mode - Text output with table formatting - Empty - * results handling - Exit code setting. - * - * Testing Approach: Uses mocked logger to capture output. Tests different - * output modes and edge cases. - * - * Related Files: - src/commands/scan/output-list-scans.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -// Mock failMsgWithBadge. -const mockFailMsgWithBadge = vi.hoisted(() => - vi.fn((msg, cause) => `${msg}: ${cause}`), -) -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: mockFailMsgWithBadge, -})) - -// Mock serializeResultJson. -const mockSerializeResultJson = vi.hoisted(() => - vi.fn(result => JSON.stringify(result)), -) -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: mockSerializeResultJson, -})) - -// Mock chalk-table. -const mockChalkTable = vi.hoisted(() => vi.fn(() => 'mocked-table-output')) -vi.mock('chalk-table', () => ({ - default: mockChalkTable, -})) - -import { outputListScans } from '../../../../src/commands/scan/output-list-scans.mts' - -import type { CResult } from '../../../../src/types.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Helper to create error result. -export function createErrorResult( - message: string, - options: { code?: number | undefined; cause?: string | undefined } = {}, -): CResult<never> { - return { ok: false, message, ...options } -} - -// Helper to create success result. -export function createSuccessResult<T>(data: T): CResult<T> { - return { ok: true, data } -} - -describe('outputListScans', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('JSON output mode', () => { - it('outputs JSON for successful result', async () => { - const mockData = { - results: [ - { - id: 'scan-123', - html_report_url: 'https://socket.dev/scans/123', - created_at: '2024-01-15T10:00:00Z', - repo: 'my-repo', - branch: 'main', - }, - ], - } - - const result = createSuccessResult(mockData) - - await outputListScans(result as unknown, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBeUndefined() - }) - - it('outputs JSON for error result', async () => { - const result = createErrorResult('Failed to list scans', { - code: 500, - cause: 'Server error', - }) - - await outputListScans(result as unknown, 'json') - - expect(mockSerializeResultJson).toHaveBeenCalledWith(result) - expect(mockLogger.log).toHaveBeenCalled() - expect(process.exitCode).toBe(500) - }) - }) - - describe('text output mode', () => { - it('outputs formatted table for successful result', async () => { - const mockData = { - results: [ - { - id: 'scan-123', - html_report_url: 'https://socket.dev/scans/123', - created_at: '2024-01-15T10:00:00Z', - repo: 'my-repo', - branch: 'main', - }, - { - id: 'scan-456', - html_report_url: 'https://socket.dev/scans/456', - created_at: '2024-01-14T10:00:00Z', - repo: 'other-repo', - branch: 'feature', - }, - ], - } - - const result = createSuccessResult(mockData) - - await outputListScans(result as unknown, 'text') - - expect(mockChalkTable).toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith('mocked-table-output') - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error message for failed result', async () => { - const result = createErrorResult('Failed to list scans', { - code: 1, - cause: 'Network error', - }) - - await outputListScans(result as unknown, 'text') - - expect(mockFailMsgWithBadge).toHaveBeenCalledWith( - 'Failed to list scans', - 'Network error', - ) - expect(mockLogger.fail).toHaveBeenCalled() - expect(process.exitCode).toBe(1) - }) - - it('handles empty results array', async () => { - const mockData = { - results: [], - } - - const result = createSuccessResult(mockData) - - await outputListScans(result as unknown, 'text') - - expect(mockChalkTable).toHaveBeenCalled() - expect(process.exitCode).toBeUndefined() - }) - - it('handles null created_at', async () => { - const mockData = { - results: [ - { - id: 'scan-789', - html_report_url: 'https://socket.dev/scans/789', - created_at: undefined, - repo: 'my-repo', - branch: 'main', - }, - ], - } - - const result = createSuccessResult(mockData) - - await outputListScans(result as unknown, 'text') - - expect(mockChalkTable).toHaveBeenCalledWith( - expect.any(Object), - expect.arrayContaining([ - expect.objectContaining({ - id: 'scan-789', - created_at: '', - }), - ]), - ) - }) - }) - - describe('exit code handling', () => { - it('sets exit code from error result', async () => { - const result = createErrorResult('Error', { code: 42 }) - - await outputListScans(result as unknown, 'text') - - expect(process.exitCode).toBe(42) - }) - - it('sets exit code to 1 when code is undefined', async () => { - const result = { - ok: false, - message: 'Error without code', - } - - await outputListScans(result as unknown, 'text') - - expect(process.exitCode).toBe(1) - }) - - it('does not set exit code for successful result', async () => { - const result = createSuccessResult({ results: [] }) - - await outputListScans(result as unknown, 'text') - - expect(process.exitCode).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-config-result.test.mts b/packages/cli/test/unit/commands/scan/output-scan-config-result.test.mts deleted file mode 100644 index 3f9934526..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-config-result.test.mts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Unit tests for scan config result output. - * - * Purpose: Tests the output formatting for scan configuration results. - * - * Test Coverage: - outputScanConfigResult function - Success and error handling - * - Exit code handling. - * - * Related Files: - src/commands/scan/output-scan-config-result.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock failMsgWithBadge. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -import { outputScanConfigResult } from '../../../../src/commands/scan/output-scan-config-result.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-scan-config-result', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputScanConfigResult', () => { - it('outputs success message', async () => { - const result: CResult<unknown> = { - ok: true, - data: {}, - } - - await outputScanConfigResult(result) - - expect(mockLogger.log).toHaveBeenCalled() - const logs = mockLogger.log.mock.calls.map(c => c[0]).join(' ') - expect(logs).toContain('Finished') - expect(process.exitCode).toBeUndefined() - }) - - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Config failed', - cause: 'Invalid configuration', - } - - await outputScanConfigResult(result) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Config failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Error', - code: 2, - } - - await outputScanConfigResult(result) - - expect(process.exitCode).toBe(2) - }) - - it('handles error without cause', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Something went wrong', - } - - await outputScanConfigResult(result) - - expect(mockLogger.fail).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-github.test.mts b/packages/cli/test/unit/commands/scan/output-scan-github.test.mts deleted file mode 100644 index 838e3e1e8..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-github.test.mts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Unit tests for GitHub scan output formatting. - * - * Purpose: Tests the output formatting for GitHub scan results. - * - * Test Coverage: - outputScanGithub function - JSON output format - Text output - * format - Success and error handling. - * - * Related Files: - src/commands/scan/output-scan-github.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputScanGithub } from '../../../../src/commands/scan/output-scan-github.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-scan-github', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('outputScanGithub', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<{ scanId: string }> = { - ok: true, - data: { scanId: '123' }, - } - - await outputScanGithub(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Scan failed', - cause: 'Invalid repository', - } - - await outputScanGithub(result, 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - }) - - describe('Text output', () => { - it('outputs success message', async () => { - const result: CResult<unknown> = { - ok: true, - data: {}, - } - - await outputScanGithub(result, 'text') - - expect(mockLogger.success).toHaveBeenCalledWith('Finished!') - expect(mockLogger.log).toHaveBeenCalledWith('') - }) - - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Scan failed', - cause: 'Invalid repository', - } - - await outputScanGithub(result, 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Scan failed'), - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-metadata.test.mts b/packages/cli/test/unit/commands/scan/output-scan-metadata.test.mts deleted file mode 100644 index cacd047c1..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-metadata.test.mts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Unit tests for scan metadata output formatting. - * - * Purpose: Tests the output formatting for scan metadata results. - * - * Test Coverage: - outputScanMetadata function - JSON output format - Markdown - * output format - Text output format - Exit code handling. - * - * Related Files: - src/commands/scan/output-scan-metadata.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdHeader: (text: string) => `# ${text}`, - mdKeyValue: (key: string, value: string) => `**${key}**: ${value}`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputScanMetadata } from '../../../../src/commands/scan/output-scan-metadata.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-scan-metadata', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputScanMetadata', () => { - const mockMetadata = { - id: 'scan-123', - updated_at: '2025-01-01', - organization_id: 'org-1', - repository_id: 'repo-1', - commit_hash: 'abc123', - html_report_url: 'https://socket.dev/report', - name: 'my-project', - status: 'completed', - } - - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<typeof mockMetadata> = { - ok: true, - data: mockMetadata, - } - - await outputScanMetadata(result, 'scan-123', 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Scan not found', - } - - await outputScanMetadata(result as unknown, 'scan-123', 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - }) - - describe('Markdown output', () => { - it('outputs metadata in markdown format', async () => { - const result: CResult<typeof mockMetadata> = { - ok: true, - data: mockMetadata, - } - - await outputScanMetadata(result, 'scan-123', 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('# Scan meta data') - expect(logs).toContain('scan-123') - expect(logs).toContain('name') - expect(logs).toContain('my-project') - }) - - it('excludes certain fields from output', async () => { - const result: CResult<typeof mockMetadata> = { - ok: true, - data: mockMetadata, - } - - await outputScanMetadata(result, 'scan-123', 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - // Check that excluded fields are not in key-value pairs. - expect(logs).not.toMatch(/- id: scan-123/) - expect(logs).not.toMatch(/- organization_id:/) - expect(logs).not.toMatch(/- repository_id:/) - expect(logs).not.toMatch(/- commit_hash:/) - }) - - it('includes report URL', async () => { - const result: CResult<typeof mockMetadata> = { - ok: true, - data: mockMetadata, - } - - await outputScanMetadata(result, 'scan-123', 'markdown') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('https://socket.dev/report') - }) - }) - - describe('Text output', () => { - it('outputs metadata in text format', async () => { - const result: CResult<typeof mockMetadata> = { - ok: true, - data: mockMetadata, - } - - await outputScanMetadata(result, 'scan-123', 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join(' ') - expect(logs).toContain('Scan ID: scan-123') - }) - - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Scan not found', - cause: 'Invalid ID', - } - - await outputScanMetadata(result as unknown, 'scan-123', 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Scan not found'), - ) - }) - }) - - describe('Exit code handling', () => { - it('sets exit code on error', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Failed', - } - - await outputScanMetadata(result as unknown, 'scan-123', 'text') - - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputScanMetadata(result as unknown, 'scan-123', 'text') - - expect(process.exitCode).toBe(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-reach.test.mts b/packages/cli/test/unit/commands/scan/output-scan-reach.test.mts deleted file mode 100644 index a57b9c5fe..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-reach.test.mts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Unit tests for reachability scan output formatting. - * - * Purpose: Tests the output formatting for reachability analysis results. - * - * Test Coverage: - outputScanReach function - JSON output format - Text output - * format - Output path handling - Exit code handling. - * - * Related Files: - src/commands/scan/output-scan-reach.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -import { outputScanReach } from '../../../../src/commands/scan/output-scan-reach.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-scan-reach', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputScanReach', () => { - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<{ reachable: number }> = { - ok: true, - data: { reachable: 5 }, - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'json', - outputPath: '', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Analysis failed', - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'json', - outputPath: '', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - }) - - describe('Text output', () => { - it('outputs success message with default path', async () => { - const result: CResult<{ reachable: number }> = { - ok: true, - data: { reachable: 5 }, - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'text', - outputPath: '', - }) - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Reachability analysis completed successfully!', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('.socket.facts.json'), - ) - }) - - it('outputs success message with custom path', async () => { - const result: CResult<{ reachable: number }> = { - ok: true, - data: { reachable: 5 }, - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'text', - outputPath: '/custom/output.json', - }) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('/custom/output.json'), - ) - }) - - it('outputs error with fail message', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Analysis failed', - cause: 'No dependencies found', - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'text', - outputPath: '', - }) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Analysis failed'), - ) - }) - - it('sets exit code on error', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Failed', - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'text', - outputPath: '', - }) - - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<unknown> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputScanReach(result, { - cwd: '/test', - outputKind: 'text', - outputPath: '', - }) - - expect(process.exitCode).toBe(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/output-scan-report.test.mts b/packages/cli/test/unit/commands/scan/output-scan-report.test.mts deleted file mode 100644 index a77887438..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-report.test.mts +++ /dev/null @@ -1,750 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for outputScanReport. - * - * Purpose: Tests output formatting for comprehensive scan reports. Validates - * alert display, score formatting, and recommendation presentation. - * - * Test Coverage: - Successful operation output formatting - Error message - * formatting - Multiple output formats (text, json, markdown) - Data - * presentation and formatting - Edge case handling. - * - * Testing Approach: Uses result helpers and fixtures to create test data. - * Validates formatted output strings across different output modes. - * - * Related Files: - src/commands/outputScanReport.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - outputScanReport, - toJsonReport, - toMarkdownReport, -} from '../../../../src/commands/scan/output-scan-report.mts' -import { SOCKET_WEBSITE_URL } from '../../../../src/constants/socket.mts' - -import type { ScanReport } from '../../../../src/commands/scan/generate-report.mts' - -import type * as GenerateReportModule from '../../../../src/commands/scan/generate-report.mts' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - dir: vi.fn(), - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spinner. -const mockSpinner = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: () => mockSpinner, -})) - -// Mock fs. -const mockWriteFile = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: mockWriteFile, - }, -})) - -// Mock generateReport. -const mockGenerateReport = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/commands/scan/generate-report.mts', async orig => { - const actual = await orig<typeof GenerateReportModule>() - return { - ...actual, - generateReport: mockGenerateReport, - } -}) - -describe('output-scan-report', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockWriteFile.mockResolvedValue(undefined) - }) - - describe('outputScanReport', () => { - const baseConfig = { - filepath: '', - fold: 'none' as const, - includeLicensePolicy: false, - orgSlug: 'test-org', - outputKind: 'text' as const, - reportLevel: 'warn' as const, - scanId: 'scan-123', - short: false, - } - - it('should handle error result', async () => { - const errorResult = { - ok: false as const, - message: 'API error', - cause: 'Network failure', - code: 1, - } - - await outputScanReport(errorResult, baseConfig) - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('should output JSON for error result when outputKind is json', async () => { - const errorResult = { - ok: false as const, - message: 'API error', - cause: 'Network failure', - } - - await outputScanReport(errorResult, { ...baseConfig, outputKind: 'json' }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - - it('should handle successful result with healthy report', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, baseConfig) - - expect(process.exitCode).toBeUndefined() - expect(mockLogger.dir).toHaveBeenCalled() - }) - - it('should set exit code 1 for unhealthy report', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: false, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, baseConfig) - - expect(process.exitCode).toBe(1) - }) - - it('should handle report generation failure', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: false, - message: 'Generation failed', - cause: 'Invalid data', - code: 2, - }) - - await outputScanReport(successResult, baseConfig) - - expect(process.exitCode).toBe(2) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('should output JSON for report generation failure when outputKind is json', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: false, - message: 'Generation failed', - cause: 'Invalid data', - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - - it('should output JSON format when outputKind is json', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'json', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('should write JSON to file when filepath is specified', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'json', - filepath: '/tmp/report.json', - }) - - expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/report.json', - expect.any(String), - ) - }) - - it('should output markdown format', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'markdown', - }) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('# Scan Policy Report'), - ) - }) - - it('should write markdown to file when filepath ends with .md', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - filepath: '/tmp/report.md', - }) - - expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/report.md', - expect.stringContaining('# Scan Policy Report'), - ) - }) - - it('should output short format when short is true', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { ...baseConfig, short: true }) - - expect(mockLogger.log).toHaveBeenCalledWith('OK') - }) - - it('should output JSON in short mode using serializeResultJson', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'json', - short: true, - }) - - // Short JSON mode uses serializeResultJson — should log JSON output. - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('should output ERR for unhealthy short format', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: false, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { ...baseConfig, short: true }) - - expect(mockLogger.log).toHaveBeenCalledWith('ERR') - }) - - it('should output short markdown format when short is true', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'markdown', - short: true, - }) - - expect(mockLogger.log).toHaveBeenCalledWith('healthy = true') - }) - - it('should detect json file from filepath extension', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'text', - filepath: '/tmp/report.json', - }) - - expect(mockWriteFile).toHaveBeenCalledWith( - '/tmp/report.json', - expect.stringContaining('"ok": true'), - ) - }) - - it('should output to stdout when filepath is -', async () => { - const successResult = { - ok: true as const, - data: { - scan: [], - securityPolicy: { rules: [] }, - }, - } - - mockGenerateReport.mockReturnValue({ - ok: true, - data: { - alerts: new Map(), - healthy: true, - options: { fold: 'none', reportLevel: 'warn' }, - orgSlug: 'test-org', - scanId: 'scan-123', - }, - }) - - await outputScanReport(successResult, { - ...baseConfig, - outputKind: 'json', - filepath: '-', - }) - - expect(mockWriteFile).not.toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - }) - - describe('toJsonReport', () => { - it('should be able to generate a healthy json report', () => { - expect(toJsonReport(getHealthyReport())).toMatchInlineSnapshot(` - "{ - "ok": true, - "data": { - "alerts": {}, - "healthy": true, - "options": { - "fold": "none", - "reportLevel": "warn" - }, - "orgSlug": "fakeOrg", - "scanId": "scan-ai-dee" - } - } - " - `) - }) - - it('should be able to generate an unhealthy json report', () => { - expect(toJsonReport(getUnhealthyReport())).toMatchInlineSnapshot(` - "{ - "ok": true, - "data": { - "alerts": { - "npm": { - "tslib": { - "1.14.1": { - "package/which.js": { - "envVars at 54:72": { - "manifest": [ - "package-lock.json" - ], - "policy": "error", - "type": "envVars", - "url": "https://socket.dev/npm/package/tslib/1.14.1" - }, - "envVars at 200:250": { - "manifest": [ - "package-lock.json" - ], - "policy": "error", - "type": "envVars", - "url": "https://socket.dev/npm/package/tslib/1.14.1" - } - } - } - } - } - }, - "healthy": false, - "options": { - "fold": "none", - "reportLevel": "warn" - }, - "orgSlug": "fakeOrg", - "scanId": "scan-ai-dee" - } - } - " - `) - }) - }) - - describe('toJsonReport - includeLicensePolicy', () => { - it('should include includeLicensePolicy in JSON report', () => { - const result = toJsonReport(getHealthyReport(), true) - expect(result).toContain('"includeLicensePolicy": true') - }) - - it('should not include includeLicensePolicy when undefined', () => { - const result = toJsonReport(getHealthyReport()) - expect(result).not.toContain('"includeLicensePolicy": true') - }) - }) - - describe('toMarkdownReport', () => { - it('should include license policy info in markdown when enabled', () => { - const report = getHealthyReport() - const result = toMarkdownReport(report, true) - expect(result).toContain('security or license policy') - expect(result).toContain('security and license policy') - expect(result).toContain('Include license alerts: yes') - }) - - it('should not include license policy info when disabled', () => { - const report = getHealthyReport() - const result = toMarkdownReport(report, false) - expect(result).not.toContain('or license') - expect(result).toContain('Include license alerts: no') - }) - - it('should show fold setting in markdown', () => { - const report = { - ...getHealthyReport(), - options: { - fold: 'version' as const, - reportLevel: 'warn' as const, - }, - } - const result = toMarkdownReport(report) - expect(result).toContain('Alert folding: up to version') - }) - - it('should be able to generate a healthy md report', () => { - expect(toMarkdownReport(getHealthyReport())).toMatchInlineSnapshot(` - "# Scan Policy Report - - This report tells you whether the results of a Socket scan results violate the - security policy set by your organization. - - ## Health status - - The scan *PASSES* all requirements set by your security policy. - - ## Settings - - Configuration used to generate this report: - - - Organization: fakeOrg - - Scan ID: scan-ai-dee - - Alert folding: none - - Minimal policy level for alert to be included in report: warn - - Include license alerts: no - - ## Alerts - - The scan contained no alerts with a policy set to at least "warn". - " - `) - }) - - it('should be able to generate an unhealthy md report', () => { - expect(toMarkdownReport(getUnhealthyReport())).toMatchInlineSnapshot(` - "# Scan Policy Report - - This report tells you whether the results of a Socket scan results violate the - security policy set by your organization. - - ## Health status - - The scan *VIOLATES* one or more policies set to the "error" level. - - ## Settings - - Configuration used to generate this report: - - - Organization: fakeOrg - - Scan ID: scan-ai-dee - - Alert folding: none - - Minimal policy level for alert to be included in report: warn - - Include license alerts: no - - ## Alerts - - All the alerts from the scan with a policy set to at least "warn". - - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | - | Policy | Alert Type | Package | Introduced by | url | Manifest file | - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | - | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | - | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | - | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | - " - `) - }) - }) -}) - -function getHealthyReport(): ScanReport { - return { - alerts: new Map(), - healthy: true, - options: { - fold: 'none', - reportLevel: 'warn', - }, - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - } -} - -function getUnhealthyReport(): ScanReport { - return { - alerts: new Map([ - [ - 'npm', - new Map([ - [ - 'tslib', - new Map([ - [ - '1.14.1', - new Map([ - [ - 'package/which.js', - new Map([ - [ - 'envVars at 54:72', - { - manifest: ['package-lock.json'], - policy: 'error' as const, - type: 'envVars', - url: `${SOCKET_WEBSITE_URL}/npm/package/tslib/1.14.1`, - }, - ], - [ - 'envVars at 200:250', - { - manifest: ['package-lock.json'], - policy: 'error' as const, - type: 'envVars', - url: `${SOCKET_WEBSITE_URL}/npm/package/tslib/1.14.1`, - }, - ], - ]), - ], - ]), - ], - ]), - ], - ]), - ], - ]), - healthy: false, - options: { - fold: 'none', - reportLevel: 'warn', - }, - orgSlug: 'fakeOrg', - scanId: 'scan-ai-dee', - } -} diff --git a/packages/cli/test/unit/commands/scan/output-scan-view.test.mts b/packages/cli/test/unit/commands/scan/output-scan-view.test.mts deleted file mode 100644 index 390344625..000000000 --- a/packages/cli/test/unit/commands/scan/output-scan-view.test.mts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Unit tests for scan view output formatting. - * - * Purpose: Tests the output formatting for scan view results. - * - * Test Coverage: - outputScanView function - JSON output format - Markdown/Text - * output format - File writing - Error handling. - * - * Related Files: - src/commands/scan/output-scan-view.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock fs. -const mockWriteFile = vi.hoisted(() => vi.fn()) -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: mockWriteFile, - }, -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -vi.mock('../../../../src/util/output/markdown.mts', () => ({ - mdTable: <T,>(data: T[], _columns: string[]) => - `| Table with ${(data as T[]).length} rows |`, -})) - -vi.mock('../../../../src/util/output/result-json.mjs', () => ({ - serializeResultJson: (result: unknown) => JSON.stringify(result, null, 2), -})) - -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - fileLink: (path: string) => path, -})) - -import { outputScanView } from '../../../../src/commands/scan/output-scan-view.mts' - -import type { CResult } from '../../../../src/types.mts' -import type { SocketArtifact } from '../../../../src/util/alert/artifact.mts' - -describe('output-scan-view', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputScanView', () => { - const mockArtifacts: SocketArtifact[] = [ - { - type: 'npm', - name: 'lodash', - version: '4.17.21', - author: ['John Dalton'], - score: { overall: 0.8 } as unknown, - } as SocketArtifact, - ] - - describe('JSON output', () => { - it('outputs success result as JSON', async () => { - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": true'), - ) - }) - - it('outputs error result as JSON', async () => { - const result: CResult<SocketArtifact[]> = { - ok: false, - message: 'Scan not found', - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'json') - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok": false'), - ) - }) - - it('writes JSON to file when path provided', async () => { - mockWriteFile.mockResolvedValue(undefined) - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView( - result, - 'my-org', - 'scan-123', - '/output.json', - 'json', - ) - - expect(mockWriteFile).toHaveBeenCalled() - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('/output.json'), - ) - }) - - it('handles file write errors', async () => { - mockWriteFile.mockRejectedValue(new Error('Permission denied')) - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView( - result, - 'my-org', - 'scan-123', - '/output.json', - 'json', - ) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('error'), - ) - expect(process.exitCode).toBe(1) - }) - }) - - describe('Text output', () => { - it('outputs scan details', async () => { - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('') - expect(logs).toContain('Scan Details') - expect(logs).toContain('scan-123') - }) - - it('handles artifacts with array authors', async () => { - const result: CResult<SocketArtifact[]> = { - ok: true, - data: [ - { - type: 'npm', - name: 'test-pkg', - version: '1.0.0', - author: ['Author 1', 'Author 2'], - score: { overall: 0.9 } as unknown, - } as SocketArtifact, - ], - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - // Should show "et.al." for multiple authors. - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('handles artifacts with empty author array', async () => { - const result: CResult<SocketArtifact[]> = { - ok: true, - data: [ - { - type: 'npm', - name: 'test-pkg', - version: '1.0.0', - author: [], - score: { overall: 0.9 } as unknown, - } as SocketArtifact, - ], - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('writes to file when path provided', async () => { - mockWriteFile.mockResolvedValue(undefined) - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView(result, 'my-org', 'scan-123', '/output.md', 'text') - - expect(mockWriteFile).toHaveBeenCalled() - }) - - it('handles markdown file write errors', async () => { - mockWriteFile.mockRejectedValueOnce(new Error('disk full')) - const result: CResult<SocketArtifact[]> = { - ok: true, - data: mockArtifacts, - } - - await outputScanView(result, 'my-org', 'scan-123', '/output.md', 'text') - - expect(process.exitCode).toBe(1) - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('error trying to write the markdown'), - ) - }) - - it('outputs error with fail message', async () => { - const result: CResult<SocketArtifact[]> = { - ok: false, - message: 'Scan failed', - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Scan failed'), - ) - }) - }) - - describe('Exit code handling', () => { - it('sets exit code on error', async () => { - const result: CResult<SocketArtifact[]> = { - ok: false, - message: 'Failed', - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<SocketArtifact[]> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputScanView(result, 'my-org', 'scan-123', '', 'text') - - expect(process.exitCode).toBe(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/perform-reachability-analysis.test.mts b/packages/cli/test/unit/commands/scan/perform-reachability-analysis.test.mts deleted file mode 100644 index d1c972bf5..000000000 --- a/packages/cli/test/unit/commands/scan/perform-reachability-analysis.test.mts +++ /dev/null @@ -1,582 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for performReachabilityAnalysis. - * - * Orchestrates: org-plan check → optional manifest upload → spawn Coana via dlx - * → extract scan-id from output. Heavy on conditionals (every reachability flag - * becomes a Coana CLI arg). - * - * Test Coverage: - * - * - 401 from fetchOrganization → "Authentication failed" - * - Other fetchOrganization failure → "Unable to verify plan permissions" - * - Non-enterprise plan → "requires an enterprise plan" - * - Enterprise plan → proceeds - * - Absolute target normalized to relative cwd-relative path - * - Empty target relative-resolves to '.' - * - UploadManifests=false skips the manifest upload - * - UploadManifests=true with orgSlug+packagePaths runs upload - * - .socket.facts.json filtered out of upload list - * - SDK setup failure short-circuits with the SDK error - * - Upload failure surfaces the upload error - * - Missing tarHash in upload response → error - * - Default repo name / branch name suppressed from coana env - * - Custom repo name → SOCKET_REPO_NAME exported - * - Custom branch name → SOCKET_BRANCH_NAME exported - * - Every reachability flag → matching --flag in coana args - * - Empty reachEcosystems → no --purl-types - * - Machine mode adds --silent and stdio: 'ignore' - * - Coana failure logs error and returns the failure CResult - * - Coana success extracts scan ID from outputFilePath - * - Custom outputPath wins over DOT_SOCKET_DOT_FACTS_JSON - * - * Related Files: - * - * - Src/commands/scan/perform-reachability-analysis.mts - Implementation - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' - -const { - mockExtractTier1ReachabilityScanId, - mockFetchOrganization, - mockGetMachineOutputMode, - mockHandleApiCall, - mockHasEnterpriseOrgPlan, - mockSetupSdk, - mockSocketDevLink, - mockSpawnCoanaDlx, - mockUploadManifestFiles, -} = vi.hoisted(() => ({ - mockExtractTier1ReachabilityScanId: vi.fn(), - mockFetchOrganization: vi.fn(), - mockGetMachineOutputMode: vi.fn(), - mockHandleApiCall: vi.fn(), - mockHasEnterpriseOrgPlan: vi.fn(), - mockSetupSdk: vi.fn(), - mockSocketDevLink: vi.fn((label: string, _path: string) => `[link:${label}]`), - mockSpawnCoanaDlx: vi.fn(), - mockUploadManifestFiles: vi.fn(), -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - DOT_SOCKET_DOT_FACTS_JSON: '.socket.facts.json', -})) - -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -vi.mock('../../../../src/util/coana/extract-scan-id.mjs', () => ({ - extractTier1ReachabilityScanId: mockExtractTier1ReachabilityScanId, -})) - -vi.mock('../../../../src/util/dlx/spawn.mjs', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, -})) - -vi.mock('../../../../src/util/output/ambient-mode.mts', () => ({ - getMachineOutputMode: mockGetMachineOutputMode, -})) - -vi.mock('../../../../src/util/organization.mts', () => ({ - hasEnterpriseOrgPlan: mockHasEnterpriseOrgPlan, -})) - -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - handleApiCall: mockHandleApiCall, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, -})) - -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - socketDevLink: mockSocketDevLink, -})) - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - info: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -const { performReachabilityAnalysis } = - await import('../../../../src/commands/scan/perform-reachability-analysis.mts') - -const enterpriseOrgs = { - ok: true as const, - data: { - organizations: [ - { id: 'a', slug: 'ent', name: 'Ent', image: '', plan: 'enterprise' }, - ], - }, -} - -const baseReachOpts = { - excludePaths: [], - reachAnalysisMemoryLimit: 0, - reachAnalysisTimeout: 0, - reachConcurrency: 0, - reachDebug: false, - reachDetailedAnalysisLogFile: false, - reachDisableAnalytics: false, - reachDisableExternalToolChecks: false, - reachEnableAnalysisSplitting: false, - reachEcosystems: [], - reachExcludePaths: [], - reachLazyMode: false, - reachMinSeverity: '', - reachSkipCache: false, - reachUseOnlyPregeneratedSboms: false, - reachUseUnreachableFromPrecomputation: false, - reachVersion: undefined, -} - -beforeEach(() => { - vi.clearAllMocks() - mockFetchOrganization.mockResolvedValue(enterpriseOrgs) - mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockGetMachineOutputMode.mockReturnValue(false) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: undefined }) - mockExtractTier1ReachabilityScanId.mockReturnValue('scan-abc') -}) - -describe('performReachabilityAnalysis — plan checks', () => { - it('returns "Authentication failed" on a 401 from fetchOrganization', async () => { - mockFetchOrganization.mockResolvedValueOnce({ - ok: false, - message: 'Unauthorized', - data: { code: 401 }, - }) - const result = await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Authentication failed') - } - }) - - it('returns generic plan error on other fetch failures', async () => { - mockFetchOrganization.mockResolvedValueOnce({ - ok: false, - message: 'API down', - }) - const result = await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Unable to verify plan permissions') - } - }) - - it('rejects non-enterprise plans with an upgrade link', async () => { - mockHasEnterpriseOrgPlan.mockReturnValue(false) - const result = await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('enterprise plan') - } - expect(mockSocketDevLink).toHaveBeenCalled() - }) -}) - -describe('performReachabilityAnalysis — target normalization', () => { - it('relativizes an absolute target to the cwd', async () => { - await performReachabilityAnalysis({ - cwd: '/work', - reachabilityOptions: baseReachOpts, - target: '/work/sub', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - // Coana args includes 'run' followed by the relativized target. - const idx = args.indexOf('run') - expect(args[idx + 1]).toBe('sub') - }) - - it('uses "." when relative resolution would produce empty string', async () => { - await performReachabilityAnalysis({ - cwd: '/work', - reachabilityOptions: baseReachOpts, - target: '/work', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - const idx = args.indexOf('run') - expect(args[idx + 1]).toBe('.') - }) - - it('keeps a relative target unchanged', async () => { - await performReachabilityAnalysis({ - cwd: '/work', - reachabilityOptions: baseReachOpts, - target: 'sub/dir', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - const idx = args.indexOf('run') - expect(args[idx + 1]).toBe('sub/dir') - }) -}) - -describe('performReachabilityAnalysis — manifest upload', () => { - it('skips upload when uploadManifests is false', async () => { - await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - uploadManifests: false, - }) - expect(mockSetupSdk).not.toHaveBeenCalled() - expect(mockHandleApiCall).not.toHaveBeenCalled() - }) - - it('skips upload when orgSlug is missing', async () => { - await performReachabilityAnalysis({ - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(mockSetupSdk).not.toHaveBeenCalled() - }) - - it('skips upload when packagePaths is missing', async () => { - await performReachabilityAnalysis({ - orgSlug: 'ent', - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(mockSetupSdk).not.toHaveBeenCalled() - }) - - it('runs upload when orgSlug + packagePaths + uploadManifests', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles: mockUploadManifestFiles }, - }) - mockHandleApiCall.mockResolvedValueOnce({ - ok: true, - data: { tarHash: 'abc123' }, - }) - const result = await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(mockSetupSdk).toHaveBeenCalled() - expect(mockHandleApiCall).toHaveBeenCalled() - // Coana args include the tar hash flags. - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).toContain('--manifests-tar-hash') - expect(args).toContain('abc123') - expect(result.ok).toBe(true) - }) - - it('filters out .socket.facts.json paths from upload list', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles: mockUploadManifestFiles }, - }) - mockHandleApiCall.mockResolvedValueOnce({ - ok: true, - data: { tarHash: 'abc123' }, - }) - await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: [ - 'pkg/package.json', - 'sub/.socket.facts.json', - 'pkg/.socket.facts.json', - ], - reachabilityOptions: baseReachOpts, - target: '.', - }) - const apiCallSpec = mockHandleApiCall.mock.calls[0]![0] - // The first arg to handleApiCall is the SDK promise; we just want - // to confirm uploadManifestFiles was given the filtered list. - expect(mockUploadManifestFiles).toHaveBeenCalledTimes(1) - const [, filepaths] = mockUploadManifestFiles.mock.calls[0]! - expect(filepaths).toEqual(['pkg/package.json']) - }) - - it('returns the SDK setup error when setupSdk fails', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: false, - message: 'Auth Error', - cause: 'no token', - }) - const result = await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Auth Error') - } - expect(mockHandleApiCall).not.toHaveBeenCalled() - }) - - it('returns the upload error when uploadManifestFiles fails', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles: mockUploadManifestFiles }, - }) - mockHandleApiCall.mockResolvedValueOnce({ - ok: false, - message: 'Upload failed', - }) - const result = await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Upload failed') - } - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() - }) - - it('errors when the upload response is missing tarHash', async () => { - mockSetupSdk.mockResolvedValueOnce({ - ok: true, - data: { uploadManifestFiles: mockUploadManifestFiles }, - }) - mockHandleApiCall.mockResolvedValueOnce({ - ok: true, - data: {}, - }) - const result = await performReachabilityAnalysis({ - orgSlug: 'ent', - packagePaths: ['pkg/package.json'], - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('tar hash') - } - }) -}) - -describe('performReachabilityAnalysis — repo and branch env', () => { - it('omits SOCKET_REPO_NAME when repo is the default', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - repoName: 'socket-default-repository', - target: '.', - }) - const callOpts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(callOpts.env['SOCKET_REPO_NAME']).toBeUndefined() - }) - - it('exports SOCKET_REPO_NAME for non-default repo names', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - repoName: 'my-repo', - target: '.', - }) - const callOpts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(callOpts.env['SOCKET_REPO_NAME']).toBe('my-repo') - }) - - it('omits SOCKET_BRANCH_NAME when branch is the default', async () => { - await performReachabilityAnalysis({ - branchName: 'socket-default-branch', - reachabilityOptions: baseReachOpts, - target: '.', - }) - const callOpts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(callOpts.env['SOCKET_BRANCH_NAME']).toBeUndefined() - }) - - it('exports SOCKET_BRANCH_NAME for non-default branch names', async () => { - await performReachabilityAnalysis({ - branchName: 'feat/x', - reachabilityOptions: baseReachOpts, - target: '.', - }) - const callOpts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(callOpts.env['SOCKET_BRANCH_NAME']).toBe('feat/x') - }) -}) - -describe('performReachabilityAnalysis — coana flag forwarding', () => { - it('builds the base flag set (--disable-report-submission, --disable-analysis-splitting)', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).toContain('--disable-report-submission') - expect(args).toContain('--disable-analysis-splitting') - expect(args).toContain('--socket-mode') - }) - - it('forwards every reachability flag when set', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: { - ...baseReachOpts, - reachAnalysisMemoryLimit: 4096, - reachAnalysisTimeout: 600, - reachConcurrency: 4, - reachDebug: true, - reachDetailedAnalysisLogFile: true, - reachDisableAnalytics: true, - reachDisableExternalToolChecks: true, - reachEcosystems: ['npm', 'pypi'], - reachEnableAnalysisSplitting: true, - reachExcludePaths: ['vendor/', 'node_modules/'], - reachLazyMode: true, - reachMinSeverity: 'high', - reachSkipCache: true, - reachUseOnlyPregeneratedSboms: true, - reachUseUnreachableFromPrecomputation: true, - }, - target: '.', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).toContain('--analysis-timeout') - expect(args).toContain('600') - expect(args).toContain('--memory-limit') - expect(args).toContain('4096') - expect(args).toContain('--concurrency') - expect(args).toContain('4') - expect(args).toContain('--debug') - expect(args).toContain('--detailed-analysis-log-file') - expect(args).toContain('--disable-analytics-sharing') - expect(args).toContain('--disable-external-tool-checks') - // analysis-splitting is INVERTED: enabled flag means we omit - // --disable-analysis-splitting. - expect(args).not.toContain('--disable-analysis-splitting') - expect(args).toContain('--purl-types') - expect(args).toContain('npm') - expect(args).toContain('pypi') - expect(args).toContain('--exclude-dirs') - expect(args).toContain('vendor/') - expect(args).toContain('--lazy-mode') - expect(args).toContain('--min-severity') - expect(args).toContain('high') - expect(args).toContain('--skip-cache-usage') - expect(args).toContain('--use-only-pregenerated-sboms') - expect(args).toContain('--use-unreachable-from-precomputation') - }) - - it('omits --purl-types when reachEcosystems is empty', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).not.toContain('--purl-types') - }) - - it('omits --exclude-dirs when reachExcludePaths is empty', async () => { - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).not.toContain('--exclude-dirs') - }) -}) - -describe('performReachabilityAnalysis — machine-output mode', () => { - it('adds --silent and uses stdio: ignore in machine mode', async () => { - mockGetMachineOutputMode.mockReturnValue(true) - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - const opts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(args[0]).toBe('--silent') - expect(opts.stdio).toBe('ignore') - }) - - it('keeps stdio: inherit in interactive mode', async () => { - mockGetMachineOutputMode.mockReturnValue(false) - await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - const opts = mockSpawnCoanaDlx.mock.calls[0]![2] - expect(opts.stdio).toBe('inherit') - }) -}) - -describe('performReachabilityAnalysis — coana result handling', () => { - it('logs error and returns failure when coana fails', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ - ok: false, - message: 'coana crashed', - }) - const result = await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(false) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Reachability analysis failed'), - ) - }) - - it('returns the report path + scan ID on success', async () => { - mockExtractTier1ReachabilityScanId.mockReturnValue('scan-xyz') - const result = await performReachabilityAnalysis({ - reachabilityOptions: baseReachOpts, - target: '.', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.reachabilityReport).toBe('.socket.facts.json') - expect(result.data.tier1ReachabilityScanId).toBe('scan-xyz') - } - }) - - it('uses outputPath when provided', async () => { - const result = await performReachabilityAnalysis({ - outputPath: '/custom/out.json', - reachabilityOptions: baseReachOpts, - target: '.', - }) - if (result.ok) { - expect(result.data.reachabilityReport).toBe('/custom/out.json') - } - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] - expect(args).toContain('/custom/out.json') - }) - - it('falls back to default outputPath when value is whitespace', async () => { - const result = await performReachabilityAnalysis({ - outputPath: ' ', - reachabilityOptions: baseReachOpts, - target: '.', - }) - if (result.ok) { - expect(result.data.reachabilityReport).toBe('.socket.facts.json') - } - }) -}) diff --git a/packages/cli/test/unit/commands/scan/reachability-flags.test.mts b/packages/cli/test/unit/commands/scan/reachability-flags.test.mts deleted file mode 100644 index 69a8e71f6..000000000 --- a/packages/cli/test/unit/commands/scan/reachability-flags.test.mts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Unit tests for reachability analysis flags. - * - * Purpose: Tests the reachability analysis flag definitions. - * - * Test Coverage: - reachabilityFlags constant - Flag types and defaults - Flag - * descriptions. - * - * Related Files: - src/commands/scan/reachability-flags.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { reachabilityFlags } from '../../../../src/commands/scan/reachability-flags.mts' - -describe('reachability-flags', () => { - describe('reachabilityFlags', () => { - it('has reachAnalysisMemoryLimit flag', () => { - expect(reachabilityFlags['reachAnalysisMemoryLimit']).toBeDefined() - expect(reachabilityFlags['reachAnalysisMemoryLimit']!.type).toBe('number') - expect(reachabilityFlags['reachAnalysisMemoryLimit']!.default).toBe(8192) - }) - - it('has reachAnalysisTimeout flag', () => { - expect(reachabilityFlags['reachAnalysisTimeout']).toBeDefined() - expect(reachabilityFlags['reachAnalysisTimeout']!.type).toBe('number') - expect(reachabilityFlags['reachAnalysisTimeout']!.default).toBe(0) - }) - - it('has reachConcurrency flag', () => { - expect(reachabilityFlags['reachConcurrency']).toBeDefined() - expect(reachabilityFlags['reachConcurrency']!.type).toBe('number') - expect(reachabilityFlags['reachConcurrency']!.default).toBe(1) - }) - - it('has reachDebug flag', () => { - expect(reachabilityFlags['reachDebug']).toBeDefined() - expect(reachabilityFlags['reachDebug']!.type).toBe('boolean') - expect(reachabilityFlags['reachDebug']!.default).toBe(false) - }) - - it('has reachDisableAnalytics flag', () => { - expect(reachabilityFlags['reachDisableAnalytics']).toBeDefined() - expect(reachabilityFlags['reachDisableAnalytics']!.type).toBe('boolean') - expect(reachabilityFlags['reachDisableAnalytics']!.default).toBe(false) - }) - - it('has reachDisableAnalysisSplitting flag', () => { - expect(reachabilityFlags['reachDisableAnalysisSplitting']).toBeDefined() - expect(reachabilityFlags['reachDisableAnalysisSplitting']!.type).toBe( - 'boolean', - ) - expect(reachabilityFlags['reachDisableAnalysisSplitting']!.default).toBe( - false, - ) - }) - - it('has reachEcosystems flag with isMultiple', () => { - expect(reachabilityFlags['reachEcosystems']).toBeDefined() - expect(reachabilityFlags['reachEcosystems']!.type).toBe('string') - expect(reachabilityFlags['reachEcosystems']!.isMultiple).toBe(true) - }) - - it('has reachExcludePaths flag with isMultiple', () => { - expect(reachabilityFlags['reachExcludePaths']).toBeDefined() - expect(reachabilityFlags['reachExcludePaths']!.type).toBe('string') - expect(reachabilityFlags['reachExcludePaths']!.isMultiple).toBe(true) - }) - - it('has reachLazyMode flag as hidden', () => { - expect(reachabilityFlags['reachLazyMode']).toBeDefined() - expect(reachabilityFlags['reachLazyMode']!.type).toBe('boolean') - expect(reachabilityFlags['reachLazyMode']!.hidden).toBe(true) - }) - - it('has reachMinSeverity flag', () => { - expect(reachabilityFlags['reachMinSeverity']).toBeDefined() - expect(reachabilityFlags['reachMinSeverity']!.type).toBe('string') - expect(reachabilityFlags['reachMinSeverity']!.default).toBe('') - }) - - it('has reachSkipCache flag', () => { - expect(reachabilityFlags['reachSkipCache']).toBeDefined() - expect(reachabilityFlags['reachSkipCache']!.type).toBe('boolean') - expect(reachabilityFlags['reachSkipCache']!.default).toBe(false) - }) - - it('has reachUseOnlyPregeneratedSboms flag', () => { - expect(reachabilityFlags['reachUseOnlyPregeneratedSboms']).toBeDefined() - expect(reachabilityFlags['reachUseOnlyPregeneratedSboms']!.type).toBe( - 'boolean', - ) - expect(reachabilityFlags['reachUseOnlyPregeneratedSboms']!.default).toBe( - false, - ) - }) - - it('has reachUseUnreachableFromPrecomputation flag', () => { - expect( - reachabilityFlags['reachUseUnreachableFromPrecomputation'], - ).toBeDefined() - expect( - reachabilityFlags['reachUseUnreachableFromPrecomputation']!.type, - ).toBe('boolean') - expect( - reachabilityFlags['reachUseUnreachableFromPrecomputation']!.default, - ).toBe(false) - }) - - it('all flags have descriptions', () => { - const flagNames = Object.keys(reachabilityFlags) as Array< - keyof typeof reachabilityFlags - > - for (let i = 0, { length } = flagNames; i < length; i += 1) { - const flagName = flagNames[i] - expect(reachabilityFlags[flagName]!.description).toBeDefined() - expect( - reachabilityFlags[flagName]!.description!.length, - ).toBeGreaterThan(0) - } - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/setup-scan-config.test.mts b/packages/cli/test/unit/commands/scan/setup-scan-config.test.mts deleted file mode 100644 index 380735fe1..000000000 --- a/packages/cli/test/unit/commands/scan/setup-scan-config.test.mts +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Unit tests for setup-scan-config helpers and interactive flow. - * - * Related Files: - src/commands/scan/setup-scan-config.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockInput = vi.hoisted(() => vi.fn()) -const mockSelect = vi.hoisted(() => vi.fn()) -const mockConfirm = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - input: mockInput, - select: mockSelect, - confirm: mockConfirm, -})) - -const mockGetGithubApiUrl = vi.hoisted(() => - vi.fn(() => 'https://api.github.com'), -) -vi.mock('@socketsecurity/lib-stable/env/github', async importOriginal => { - const actual = (await importOriginal()) as Record<string, unknown> - return { - ...actual, - getGithubApiUrl: mockGetGithubApiUrl, - } -}) - -const mockReadSocketJsonSync = vi.hoisted(() => - vi.fn(() => ({ ok: true, data: {} })), -) -const mockWriteSocketJson = vi.hoisted(() => - vi.fn(async () => ({ ok: true, data: undefined })), -) -vi.mock('../../../../src/util/socket/json.mts', () => ({ - readSocketJsonSync: mockReadSocketJsonSync, - writeSocketJson: mockWriteSocketJson, -})) - -const mockGetRepoName = vi.hoisted(() => vi.fn(async () => 'my-repo')) -const mockGetRepoOwner = vi.hoisted(() => vi.fn(async () => 'my-org')) -const mockGitBranch = vi.hoisted(() => vi.fn(async () => 'main')) -const mockDetectDefaultBranch = vi.hoisted(() => vi.fn(async () => 'main')) -vi.mock('../../../../src/util/git/operations.mjs', () => ({ - getRepoName: mockGetRepoName, - getRepoOwner: mockGetRepoOwner, - gitBranch: mockGitBranch, - detectDefaultBranch: mockDetectDefaultBranch, -})) - -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)) -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -import { - canceledByUser, - configureGithub, - configureScan, - notCanceled, - setupScanConfig, -} from '../../../../src/commands/scan/setup-scan-config.mts' - -describe('setup-scan-config', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadSocketJsonSync.mockReturnValue({ ok: true, data: {} }) - mockWriteSocketJson.mockResolvedValue({ ok: true, data: undefined }) - mockExistsSync.mockReturnValue(false) - }) - - describe('canceledByUser', () => { - it('returns ok=true with canceled=true', () => { - const result = canceledByUser() - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('logs an info line', () => { - canceledByUser() - expect(mockLogger.info).toHaveBeenCalledWith('User canceled') - }) - }) - - describe('notCanceled', () => { - it('returns ok=true with canceled=false', () => { - const result = notCanceled() - expect(result).toEqual({ ok: true, data: { canceled: false } }) - }) - }) - - describe('configureScan', () => { - it('cancels when user aborts the repo prompt', async () => { - mockInput.mockResolvedValueOnce(undefined) - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when user aborts the workspace prompt', async () => { - mockInput.mockResolvedValueOnce('repo').mockResolvedValueOnce(undefined) - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when user aborts the branch prompt', async () => { - mockInput - .mockResolvedValueOnce('repo') - .mockResolvedValueOnce('workspace') - .mockResolvedValueOnce(undefined) - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('writes repo and clears it when user submits empty repo name', async () => { - // First three prompts: repo, workspace, branch. - mockInput - .mockResolvedValueOnce('') // empty repo => deletes config.repo - .mockResolvedValueOnce('') // empty workspace - .mockResolvedValueOnce('') // empty branch - // autoManifest + alwaysReport selects. - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce('') - const config: unknown = { - repo: 'old-repo', - workspace: 'old-ws', - branch: 'old-br', - } - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.repo).toBeUndefined() - expect(config.workspace).toBeUndefined() - expect(config.branch).toBeUndefined() - }) - - it('saves all values when user provides them', async () => { - mockInput - .mockResolvedValueOnce('new-repo') - .mockResolvedValueOnce('new-ws') - .mockResolvedValueOnce('new-branch') - mockSelect.mockResolvedValueOnce('yes').mockResolvedValueOnce('yes') - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.repo).toBe('new-repo') - expect(config.workspace).toBe('new-ws') - expect(config.branch).toBe('new-branch') - expect(config.autoManifest).toBe(true) - expect(config.report).toBe(true) - }) - - it('cancels when autoManifest selector returns undefined', async () => { - mockInput - .mockResolvedValueOnce('repo') - .mockResolvedValueOnce('ws') - .mockResolvedValueOnce('branch') - mockSelect.mockResolvedValueOnce(undefined) - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when alwaysReport selector returns undefined', async () => { - mockInput - .mockResolvedValueOnce('repo') - .mockResolvedValueOnce('ws') - .mockResolvedValueOnce('branch') - mockSelect - .mockResolvedValueOnce('') // autoManifest default - .mockResolvedValueOnce(undefined) // alwaysReport canceled - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('sets report=true when user picks alwaysReport=yes', async () => { - mockInput - .mockResolvedValueOnce('r') - .mockResolvedValueOnce('w') - .mockResolvedValueOnce('b') - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce('yes') - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.report).toBe(true) - }) - - it('sets report=false when user picks alwaysReport=no', async () => { - mockInput - .mockResolvedValueOnce('r') - .mockResolvedValueOnce('w') - .mockResolvedValueOnce('b') - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce('no') - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.report).toBe(false) - }) - - it('deletes report when user picks alwaysReport=empty', async () => { - mockInput - .mockResolvedValueOnce('r') - .mockResolvedValueOnce('w') - .mockResolvedValueOnce('b') - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce('') - const config: unknown = { report: true } - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.report).toBeUndefined() - }) - - it('sets autoManifest to false when user picks "no"', async () => { - mockInput - .mockResolvedValueOnce('r') - .mockResolvedValueOnce('w') - .mockResolvedValueOnce('b') - mockSelect.mockResolvedValueOnce('no').mockResolvedValueOnce('') - const config: unknown = {} - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.autoManifest).toBe(false) - }) - - it('deletes autoManifest when user picks empty', async () => { - mockInput - .mockResolvedValueOnce('r') - .mockResolvedValueOnce('w') - .mockResolvedValueOnce('b') - mockSelect.mockResolvedValueOnce('').mockResolvedValueOnce('') - const config: unknown = { autoManifest: true } - const result = await configureScan(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.autoManifest).toBeUndefined() - }) - }) - - describe('configureGithub', () => { - it('cancels when --all selector returns undefined', async () => { - mockSelect.mockResolvedValueOnce(undefined) - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('sets all=true when user picks yes', async () => { - // 1. all=yes select; 2. githubApiUrl input; 3. orgGithub input. - mockSelect.mockResolvedValueOnce('yes') - mockInput - .mockResolvedValueOnce('') // githubApiUrl empty => deletes - .mockResolvedValueOnce('') // orgGithub empty => deletes - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.all).toBe(true) - }) - - it('sets all=false when user picks no', async () => { - // Note: when all === 'no' the repos prompt is NOT shown because - // `if (!all)` evaluates `!"no"` === false. The repos prompt only - // fires when all is the empty-string ("leave default") option. - mockSelect.mockResolvedValueOnce('no') - mockInput - .mockResolvedValueOnce('') // githubApiUrl empty - .mockResolvedValueOnce('') // orgGithub empty - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.all).toBe(false) - }) - - it('prompts for repos when user picks "leave default" (empty string)', async () => { - mockSelect.mockResolvedValueOnce('') - mockInput - .mockResolvedValueOnce('r1,r2') // repos - .mockResolvedValueOnce('') // githubApiUrl - .mockResolvedValueOnce('') // orgGithub - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.repos).toBe('r1,r2') - }) - - it('cancels when repos prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce('') // empty -> deletes config.all -> shows repos prompt. - mockInput.mockResolvedValueOnce(undefined) // repos canceled - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when githubApiUrl prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce('no') - mockInput.mockResolvedValueOnce('repos').mockResolvedValueOnce(undefined) // githubApiUrl canceled - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when orgGithub prompt is aborted', async () => { - mockSelect.mockResolvedValueOnce('yes') - mockInput - .mockResolvedValueOnce('') // githubApiUrl - .mockResolvedValueOnce(undefined) // orgGithub canceled - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('saves orgGithub when user provides one', async () => { - mockSelect.mockResolvedValueOnce('yes') - mockInput - .mockResolvedValueOnce('https://custom.api') - .mockResolvedValueOnce('my-gh-org') - const config: unknown = {} - const result = await configureGithub(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.githubApiUrl).toBe('https://custom.api') - expect(config.orgGithub).toBe('my-gh-org') - }) - - it('clears all setting when user picks "(leave default)"', async () => { - mockSelect.mockResolvedValueOnce('default') - mockInput - .mockResolvedValueOnce('repos') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - const config: unknown = { all: true } - const result = await configureGithub(config, '/cwd') - expect(result.ok).toBe(true) - expect(config.all).toBeUndefined() - }) - }) - - describe('setupScanConfig', () => { - it('cancels when target selector returns undefined', async () => { - mockSelect.mockResolvedValueOnce(undefined) - const result = await setupScanConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('cancels when target is empty string (cancel option)', async () => { - mockSelect.mockResolvedValueOnce('') - const result = await setupScanConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('returns sockJson read error if it fails', async () => { - mockReadSocketJsonSync.mockReturnValueOnce({ - ok: false, - message: 'read error', - }) - const result = await setupScanConfig('/cwd') - expect(result.ok).toBe(false) - }) - - it('logs about found socket.json when it exists', async () => { - mockExistsSync.mockReturnValue(true) - mockSelect.mockResolvedValueOnce('') // cancel after. - await setupScanConfig('/cwd') - const infoMsg = mockLogger.info.mock.calls.flat().join(' ') - expect(infoMsg).toContain('Found') - }) - - it('runs configureGithub when user picks "github" target', async () => { - mockSelect - .mockResolvedValueOnce('github') // target - .mockResolvedValueOnce('yes') // --all yes - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('') // githubApiUrl - .mockResolvedValueOnce('') // orgGithub - const result = await setupScanConfig('/cwd') - expect(result.ok).toBe(true) - expect(mockWriteSocketJson).toHaveBeenCalled() - }) - - it('runs configureScan and writes when user picks "create" target + writes yes', async () => { - mockSelect - .mockResolvedValueOnce('create') // target - .mockResolvedValueOnce('') // autoManifest default - .mockResolvedValueOnce('') // alwaysReport default - .mockResolvedValueOnce(true) // write yes - mockInput - .mockResolvedValueOnce('repo') // repo - .mockResolvedValueOnce('ws') // workspace - .mockResolvedValueOnce('br') // branch - const result = await setupScanConfig('/cwd') - expect(result.ok).toBe(true) - expect(mockWriteSocketJson).toHaveBeenCalled() - }) - - it('cancels at write-config prompt when user picks "no"', async () => { - mockSelect - .mockResolvedValueOnce('create') - .mockResolvedValueOnce('') // autoManifest - .mockResolvedValueOnce('') // alwaysReport - .mockResolvedValueOnce(false) // do not write - mockInput - .mockResolvedValueOnce('repo') - .mockResolvedValueOnce('ws') - .mockResolvedValueOnce('br') - const result = await setupScanConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - }) - - it('returns canceled when configureScan canceled mid-flow', async () => { - mockSelect.mockResolvedValueOnce('create') - // Cancel inside configureScan at the very first prompt. - mockInput.mockResolvedValueOnce(undefined) - const result = await setupScanConfig('/cwd') - expect(result).toEqual({ ok: true, data: { canceled: true } }) - // Should NOT have proceeded to write prompt. - expect(mockWriteSocketJson).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/stream-scan.test.mts b/packages/cli/test/unit/commands/scan/stream-scan.test.mts deleted file mode 100644 index 4b6c249b3..000000000 --- a/packages/cli/test/unit/commands/scan/stream-scan.test.mts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Unit tests for stream scan functionality. - * - * Purpose: Tests the scan streaming to file/stdout. - * - * Test Coverage: - streamScan function - SDK setup handling - API call handling - * - File output options. - * - * Related Files: - src/commands/scan/stream-scan.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock SDK setup. -const mockSetupSdk = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - setupSdk: mockSetupSdk, -})) - -// Mock API handler. -const mockHandleApiCall = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/api.mjs', () => ({ - handleApiCall: mockHandleApiCall, -})) - -import { streamScan } from '../../../../src/commands/scan/stream-scan.mts' - -describe('stream-scan', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('streamScan', () => { - const mockStreamFullScan = vi.fn() - const mockSdk = { - streamFullScan: mockStreamFullScan, - } - - it('returns error when SDK setup fails', async () => { - mockSetupSdk.mockResolvedValue({ - ok: false, - message: 'Invalid API token', - }) - - const result = await streamScan('my-org', 'scan-123') - - expect(result).toEqual({ - ok: false, - message: 'Invalid API token', - }) - expect(mockHandleApiCall).not.toHaveBeenCalled() - }) - - it('streams scan data successfully', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - const result = await streamScan('my-org', 'scan-123') - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Requesting data from API...', - ) - expect(mockStreamFullScan).toHaveBeenCalledWith('my-org', 'scan-123', { - output: undefined, - }) - expect(result).toEqual({ ok: true, data: {} }) - }) - - it('passes file option for output', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - await streamScan('my-org', 'scan-123', { file: '/output.json' }) - - expect(mockStreamFullScan).toHaveBeenCalledWith('my-org', 'scan-123', { - output: '/output.json', - }) - }) - - it('uses stdout when file is dash', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - await streamScan('my-org', 'scan-123', { file: '-' }) - - expect(mockStreamFullScan).toHaveBeenCalledWith('my-org', 'scan-123', { - output: undefined, - }) - }) - - it('passes command path to API handler', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - await streamScan('my-org', 'scan-123', { commandPath: 'scan stream' }) - - expect(mockHandleApiCall).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - commandPath: 'scan stream', - description: 'a scan', - }), - ) - }) - - it('passes SDK options', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - await streamScan('my-org', 'scan-123', { - sdkOpts: { apiToken: 'custom-token' }, - }) - - expect(mockSetupSdk).toHaveBeenCalledWith({ apiToken: 'custom-token' }) - }) - - it('handles API call failure', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: false })) - mockHandleApiCall.mockResolvedValue({ - ok: false, - message: 'Scan not found', - }) - - const result = await streamScan('my-org', 'scan-123') - - expect(result).toEqual({ - ok: false, - message: 'Scan not found', - }) - }) - - it('works with no options', async () => { - mockSetupSdk.mockResolvedValue({ - ok: true, - data: mockSdk, - }) - mockStreamFullScan.mockReturnValue(Promise.resolve({ ok: true })) - mockHandleApiCall.mockResolvedValue({ ok: true, data: {} }) - - const result = await streamScan('my-org', 'scan-123') - - expect(mockSetupSdk).toHaveBeenCalledWith(undefined) - expect(result.ok).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/suggest-org-slug.test.mts b/packages/cli/test/unit/commands/scan/suggest-org-slug.test.mts deleted file mode 100644 index d8f12c3d6..000000000 --- a/packages/cli/test/unit/commands/scan/suggest-org-slug.test.mts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Unit tests for organization slug suggestion utility. - * - * Purpose: Tests the organization slug suggestion prompt. - * - * Test Coverage: - suggestOrgSlug function - API failure handling - User - * selection. - * - * Related Files: - src/commands/scan/suggest-org-slug.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock select prompt. -const mockSelect = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, -})) - -// Mock fetchOrganization. -const mockFetchOrganization = vi.hoisted(() => vi.fn()) -vi.mock( - '../../../../src/commands/organization/fetch-organization-list.mts', - () => ({ - fetchOrganization: mockFetchOrganization, - }), -) - -import { suggestOrgSlug } from '../../../../src/commands/scan/suggest-org-slug.mts' - -describe('suggest-org-slug', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('suggestOrgSlug', () => { - it('returns undefined when API fails', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: false, - message: 'Failed to fetch', - }) - - const result = await suggestOrgSlug() - - expect(result).toBeUndefined() - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Failed to lookup organization'), - ) - }) - - it('returns selected organization slug', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { - organizations: [ - { name: 'My Org', slug: 'my-org' }, - { name: 'Other Org', slug: 'other-org' }, - ], - }, - }) - mockSelect.mockResolvedValue('my-org') - - const result = await suggestOrgSlug() - - expect(result).toBe('my-org') - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Missing org name'), - }), - ) - }) - - it('returns undefined when user selects No', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { - organizations: [{ name: 'My Org', slug: 'my-org' }], - }, - }) - mockSelect.mockResolvedValue('') - - const result = await suggestOrgSlug() - - expect(result).toBeUndefined() - }) - - it('uses slug as name when name is not available', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { - organizations: [{ slug: 'my-slug' }], - }, - }) - mockSelect.mockResolvedValue('my-slug') - - const result = await suggestOrgSlug() - - expect(result).toBe('my-slug') - const callArg = mockSelect.mock.calls[0]![0] as { - choices: Array<{ name: string }> - } - expect(callArg.choices[0]!.name).toContain('my-slug') - }) - - it('includes No option in choices', async () => { - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { - organizations: [{ name: 'My Org', slug: 'my-org' }], - }, - }) - mockSelect.mockResolvedValue('') - - await suggestOrgSlug() - - const callArg = mockSelect.mock.calls[0]![0] as { - choices: Array<{ name: string; value: string }> - } - const noChoice = callArg.choices.find(c => c.name === 'No') - expect(noChoice).toBeDefined() - expect(noChoice!.value).toBe('') - }) - - it('returns the slug (not display name) for orgs where they differ', async () => { - // Regression guard: passing the display name through to the API - // produced 404s for orgs with spaces, e.g. - // `/v0/orgs/Example%20Org%20Ltd/...` instead of - // `/v0/orgs/example-org-ltd/...`. - mockFetchOrganization.mockResolvedValue({ - ok: true, - data: { - organizations: [{ name: 'Example Org Ltd', slug: 'example-org-ltd' }], - }, - }) - mockSelect.mockResolvedValue('example-org-ltd') - - await suggestOrgSlug() - - const callArg = mockSelect.mock.calls[0]![0] as { - choices: Array<{ name: string; value: string; description: string }> - } - // The choice value must be the slug. The visible label/description - // still use the friendlier display name. - expect(callArg.choices[0]!.value).toBe('example-org-ltd') - expect(callArg.choices[0]!.name).toContain('Example Org Ltd') - expect(callArg.choices[0]!.description).toContain('Example Org Ltd') - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/suggest-to-persist-orgslug.test.mts b/packages/cli/test/unit/commands/scan/suggest-to-persist-orgslug.test.mts deleted file mode 100644 index 9b6eeb16c..000000000 --- a/packages/cli/test/unit/commands/scan/suggest-to-persist-orgslug.test.mts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Unit tests for organization slug persistence suggestion. - * - * Purpose: Tests the prompt for persisting organization slug as default. - * - * Test Coverage: - suggestToPersistOrgSlug function - Config reading - User - * selection handling - Config update handling. - * - * Related Files: - src/commands/scan/suggest-to-persist-orgslug.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock select prompt. -const mockSelect = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, -})) - -// Mock config. -const mockGetConfigValue = vi.hoisted(() => vi.fn()) -const mockUpdateConfigValue = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValue: mockGetConfigValue, - updateConfigValue: mockUpdateConfigValue, -})) - -import { suggestToPersistOrgSlug } from '../../../../src/commands/scan/suggest-to-persist-orgslug.mts' - -describe('suggest-to-persist-orgslug', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('suggestToPersistOrgSlug', () => { - it('returns early when config read fails', async () => { - mockGetConfigValue.mockReturnValue({ - ok: false, - message: 'Config read error', - }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockSelect).not.toHaveBeenCalled() - }) - - it('returns early when skipAskToPersistDefaultOrg is true', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: true, - }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockSelect).not.toHaveBeenCalled() - }) - - it('prompts user when skipAskToPersistDefaultOrg is false', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('no') - - await suggestToPersistOrgSlug('my-org') - - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('my-org'), - }), - ) - }) - - it('updates default org when user selects yes', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('yes') - mockUpdateConfigValue.mockReturnValue({ ok: true }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockUpdateConfigValue).toHaveBeenCalledWith('defaultOrg', 'my-org') - expect(mockLogger.success).toHaveBeenCalledWith( - 'Updated default org config to:', - 'my-org', - ) - }) - - it('logs error when updating default org fails', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('yes') - mockUpdateConfigValue.mockReturnValue({ - ok: false, - cause: 'Write error', - }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Failed to update default org'), - 'Write error', - ) - }) - - it('disables future prompts when user selects sush', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('sush') - mockUpdateConfigValue.mockReturnValue({ ok: true }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockUpdateConfigValue).toHaveBeenCalledWith( - 'skipAskToPersistDefaultOrg', - true, - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Default org not changed. Will not ask to persist again.', - ) - }) - - it('logs error when disabling future prompts fails', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('sush') - mockUpdateConfigValue.mockReturnValue({ - ok: false, - cause: 'Permission denied', - }) - - await suggestToPersistOrgSlug('my-org') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Failed to store preference'), - ) - }) - - it('does nothing when user selects no', async () => { - mockGetConfigValue.mockReturnValue({ - ok: true, - data: false, - }) - mockSelect.mockResolvedValue('no') - - await suggestToPersistOrgSlug('my-org') - - expect(mockUpdateConfigValue).not.toHaveBeenCalled() - expect(mockLogger.success).not.toHaveBeenCalled() - expect(mockLogger.info).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/suggest_target.test.mts b/packages/cli/test/unit/commands/scan/suggest_target.test.mts deleted file mode 100644 index be7cf3928..000000000 --- a/packages/cli/test/unit/commands/scan/suggest_target.test.mts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Unit tests for target suggestion utility. - * - * Purpose: Tests the target directory suggestion prompt. - * - * Test Coverage: - * - * - SuggestTarget function - * - User accepts current directory - * - User rejects current directory - * - * Related Files: - * - * - Src/commands/scan/suggest_target.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock select prompt. -const mockSelect = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - select: mockSelect, -})) - -import { suggestTarget } from '../../../../src/commands/scan/suggest_target.mts' - -describe('suggest_target', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('suggestTarget', () => { - it('returns ["."] when user accepts current directory', async () => { - mockSelect.mockResolvedValue(true) - - const result = await suggestTarget() - - expect(result).toEqual(['.']) - expect(mockSelect).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('current directory'), - }), - ) - }) - - it('returns [] when user rejects current directory', async () => { - mockSelect.mockResolvedValue(false) - - const result = await suggestTarget() - - expect(result).toEqual([]) - }) - - it('prompts with Yes and No choices', async () => { - mockSelect.mockResolvedValue(true) - - await suggestTarget() - - const callArg = mockSelect.mock.calls[0]![0] as { - choices: Array<{ name: string; value: boolean }> - } - expect(callArg.choices).toHaveLength(2) - expect(callArg.choices[0]!.name).toBe('Yes') - expect(callArg.choices[0]!.value).toBe(true) - expect(callArg.choices[1]!.name).toBe('No') - expect(callArg.choices[1]!.value).toBe(false) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/scan/validate-reachability-target.test.mts b/packages/cli/test/unit/commands/scan/validate-reachability-target.test.mts deleted file mode 100644 index 798c33ba5..000000000 --- a/packages/cli/test/unit/commands/scan/validate-reachability-target.test.mts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Unit tests for reachability target validation. - * - * Purpose: Tests the validateReachabilityTarget function for validating scan - * targets. - * - * Test Coverage: - Single target validation - Multiple targets rejection - - * Directory vs file detection - Path containment checks. - * - * Related Files: - commands/scan/validate-reachability-target.mts - * (implementation) - */ - -import os from 'node:os' -import path from 'node:path' - -import { describe, expect, it } from 'vitest' - -import { validateReachabilityTarget } from '../../../../src/commands/scan/validate-reachability-target.mts' - -describe('validate-reachability-target', () => { - describe('validateReachabilityTarget', () => { - it('returns invalid for empty targets array', async () => { - const result = await validateReachabilityTarget([], '/home/user') - - expect(result.isValid).toBe(false) - }) - - it('returns invalid for multiple targets', async () => { - const result = await validateReachabilityTarget( - ['/path1', '/path2'], - '/home/user', - ) - - expect(result.isValid).toBe(false) - }) - - it('returns valid with target inside cwd', async () => { - const cwd = process.cwd() - const result = await validateReachabilityTarget(['.'], cwd) - - expect(result.isValid).toBe(true) - expect(result.isInsideCwd).toBe(true) - }) - - it('detects existing directory', async () => { - const cwd = process.cwd() - const result = await validateReachabilityTarget(['.'], cwd) - - expect(result.targetExists).toBe(true) - expect(result.isDirectory).toBe(true) - }) - - it('detects non-existent target', async () => { - const cwd = process.cwd() - const result = await validateReachabilityTarget( - ['./non-existent-dir-xyz'], - cwd, - ) - - expect(result.targetExists).toBe(false) - expect(result.isDirectory).toBe(false) - }) - - it('detects target outside cwd', async () => { - const cwd = '/home/user/project' - const result = await validateReachabilityTarget(['../other'], cwd) - - expect(result.isValid).toBe(true) - expect(result.isInsideCwd).toBe(false) - }) - - it('handles absolute path inside cwd', async () => { - const cwd = process.cwd() - const absolutePath = path.join(cwd, 'src') - const result = await validateReachabilityTarget([absolutePath], cwd) - - expect(result.isValid).toBe(true) - expect(result.isInsideCwd).toBe(true) - }) - - it('handles absolute path outside cwd', async () => { - const cwd = '/home/user/project' - const result = await validateReachabilityTarget(['/tmp'], cwd) - - expect(result.isValid).toBe(true) - expect(result.isInsideCwd).toBe(false) - }) - - it('detects file as non-directory', async () => { - const cwd = process.cwd() - // Use package.json as a known file. - const result = await validateReachabilityTarget(['package.json'], cwd) - - expect(result.targetExists).toBe(true) - expect(result.isDirectory).toBe(false) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/sfw/cmd-sfw.test.mts b/packages/cli/test/unit/commands/sfw/cmd-sfw.test.mts deleted file mode 100644 index 5886e7ca3..000000000 --- a/packages/cli/test/unit/commands/sfw/cmd-sfw.test.mts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSfw. -const mockSpawnSfw = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfw: mockSpawnSfw, -})) - -// Import after mocks. -const { cmdSfw } = await import('../../../../src/commands/sfw/cmd-sfw.mts') - -describe('cmd-sfw', () => { - const mockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - const createMockSpawnResult = (exitCode = 0, signal?: string) => ({ - spawnPromise: Promise.resolve({ - code: signal ? undefined : exitCode, - signal, - success: exitCode === 0 && !signal, - }).then(result => Object.assign(result, { process: mockChildProcess })), - }) - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description with alias', () => { - expect(cmdSfw.description).toBe( - 'Run Socket Firewall directly (alias: firewall)', - ) - }) - - it('should not be hidden', () => { - expect(cmdSfw.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-sfw.mts' } - const context = { parentName: 'socket' } - - it('should pass arguments to spawnSfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdSfw.run(['npm', 'install', 'lodash'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npm', 'install', 'lodash'], { - stdio: 'inherit', - }) - }) - - it('should handle pip install command', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdSfw.run(['pip', 'install', 'requests'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['pip', 'install', 'requests'], - { stdio: 'inherit' }, - ) - }) - - it('should handle pnpm exec command', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdSfw.run(['npx', 'cowsay', 'hello'], importMeta, context) - - expect(mockSpawnSfw).toHaveBeenCalledWith(['npx', 'cowsay', 'hello'], { - stdio: 'inherit', - }) - }) - - it('should set exitCode on non-zero exit', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(1)) - - await cmdSfw.run( - ['npm', 'install', 'nonexistent-pkg'], - importMeta, - context, - ) - - expect(process.exitCode).toBe(1) - }) - - it('should log info message when invoking sfw', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdSfw.run(['cargo', 'build'], importMeta, context) - - expect(mockLogger.info).toHaveBeenCalledWith( - 'Invoking Socket Firewall: sfw cargo build', - ) - }) - - it('should show error when no package manager specified', async () => { - await cmdSfw.run([], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'No package manager command specified.', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Usage: socket sfw <package-manager> [args...]', - ) - expect(process.exitCode).toBe(2) - }) - - it('should filter Socket CLI flags', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - await cmdSfw.run(['--dry-run', 'npm', 'install'], importMeta, context) - - // Dry run should bail early. - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - - it('should handle multiple package managers', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - // Test various package managers. - for (const pm of [ - 'npm', - 'pnpm', - 'yarn', - 'pip', - 'cargo', - 'go', - 'gem', - 'bundler', - 'nuget', - 'uv', - ]) { - vi.clearAllMocks() - await cmdSfw.run([pm, 'install'], importMeta, context) - expect(mockSpawnSfw).toHaveBeenCalledWith([pm, 'install'], { - stdio: 'inherit', - }) - } - }) - - it('shows wrapper help when --help is passed and skips spawn', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0)) - - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('process.exit') - }) as never) - - try { - await cmdSfw.run(['--help'], importMeta, context).catch(() => undefined) - expect(mockSpawnSfw).not.toHaveBeenCalled() - } finally { - exitSpy.mockRestore() - } - }) - - it('forwards spawn signal via process.kill when present', async () => { - mockSpawnSfw.mockResolvedValue(createMockSpawnResult(0, 'SIGTERM')) - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as unknown) - - try { - await cmdSfw.run(['npm', 'install'], importMeta, context) - expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM') - } finally { - killSpy.mockRestore() - } - }) - - it('does nothing when both code and signal are null', async () => { - // Construct a result with both code: null and signal: null (rare). - mockSpawnSfw.mockResolvedValue({ - spawnPromise: Promise.resolve({ - code: undefined, - signal: undefined, - success: false, - }), - } as unknown) - process.exitCode = undefined - - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as unknown) - killSpy.mockClear() - - await cmdSfw.run(['npm', 'install'], importMeta, context) - - expect(killSpy).not.toHaveBeenCalled() - killSpy.mockRestore() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/threat-feed/__snapshots__/ThreatFeedRenderer.test.mts.snap b/packages/cli/test/unit/commands/threat-feed/__snapshots__/ThreatFeedRenderer.test.mts.snap deleted file mode 100644 index 878be89e0..000000000 --- a/packages/cli/test/unit/commands/threat-feed/__snapshots__/ThreatFeedRenderer.test.mts.snap +++ /dev/null @@ -1,16 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ThreatFeedRenderer > data rendering > should render threat feed table with multiple threats 1`] = ` -"Socket Threat Feed - -┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Ecosystem Name Version Type Detected │ -│ │ -│ npm malicious-pkg 1.0.0 malware test-date │ -│ npm suspicious-lib 2.1.0 obfuscation test-date │ -│ │ -└─────────────────────────────────────────────────────────────────────────────────────────────────────┘ - -" -`; diff --git a/packages/cli/test/unit/commands/threat-feed/cmd-threat-feed.test.mts b/packages/cli/test/unit/commands/threat-feed/cmd-threat-feed.test.mts deleted file mode 100644 index 69711deb9..000000000 --- a/packages/cli/test/unit/commands/threat-feed/cmd-threat-feed.test.mts +++ /dev/null @@ -1,439 +0,0 @@ -/** - * Unit tests for threat-feed command. - * - * Tests the command that displays the Socket threat feed. - * - * Test Coverage: - Command metadata (description, hidden flag) - API token - * requirement validation - Organization slug handling - Filter flags: - * ecosystem, type, package, version - Pagination flags: page, per-page, - * direction - Output modes: text, JSON, markdown - Dry-run mode - Argument - * parsing for filters. - * - * Testing Approach: - Mock logger to capture output - Mock handleThreatFeed to - * verify handler invocation - Mock determineOrgSlug for organization handling - - * Mock hasDefaultApiToken for authentication checks - Test flag combinations - * and defaults. - * - * Related Files: - src/commands/threat-feed/cmd-threat-feed.mts - - * Implementation - src/commands/threat-feed/handle-threat-feed.mts - Handler. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as SdkModule from '../../../../src/util/socket/sdk.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Mock dependencies. -const mockHandleThreatFeed = vi.hoisted(() => vi.fn()) -const mockDetermineOrgSlug = vi.hoisted(() => - vi.fn().mockResolvedValue(['test-org', 'test-org']), -) -const mockHasDefaultApiToken = vi.hoisted(() => vi.fn().mockReturnValue(true)) - -vi.mock('../../../../src/commands/threat-feed/handle-threat-feed.mts', () => ({ - handleThreatFeed: mockHandleThreatFeed, -})) - -vi.mock('../../../../src/util/socket/org-slug.mjs', () => ({ - determineOrgSlug: mockDetermineOrgSlug, -})) - -vi.mock('../../../../src/util/socket/sdk.mjs', async importOriginal => { - const actual = await importOriginal<typeof SdkModule>() - return { - ...actual, - hasDefaultApiToken: mockHasDefaultApiToken, - } -}) - -// Import after mocks. -const { cmdThreatFeed } = - await import('../../../../src/commands/threat-feed/cmd-threat-feed.mts') - -describe('cmd-threat-feed', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdThreatFeed.description).toBe('[Beta] View the threat-feed') - }) - - it('should not be hidden', () => { - expect(cmdThreatFeed.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-threat-feed.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--dry-run'], importMeta, context) - - expect(mockHandleThreatFeed).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without Socket API token', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(false) - - await cmdThreatFeed.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleThreatFeed).not.toHaveBeenCalled() - }) - - it('should call handleThreatFeed with default values', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run([], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith({ - direction: 'desc', - ecosystem: '', - filter: '', - orgSlug: 'test-org', - outputKind: 'text', - page: '1', - perPage: 30, - pkg: '', - version: '', - }) - }) - - it('should pass --eco flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--eco', 'npm'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystem: 'npm', - }), - ) - }) - - it('should have default filter value when --filter flag is used', async () => { - // Note: There appears to be a bug where the flag is named "filter" - // but the code destructures "type" from cli.flags (line 186). - // This means --filter flag is currently not working as expected. - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--filter', 'typo'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - filter: '', - }), - ) - }) - - it('should pass --pkg flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--pkg', 'test-package'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - pkg: 'test-package', - }), - ) - }) - - it('should pass --version flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--version', '1.0.0'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - version: '1.0.0', - }), - ) - }) - - it('should pass --page flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--page', '2'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - page: '2', - }), - ) - }) - - it('should pass --per-page flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--per-page', '50'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - perPage: 50, - }), - ) - }) - - it('should pass --direction flag to handleThreatFeed', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--direction', 'asc'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - direction: 'asc', - }), - ) - }) - - it('should pass --org flag to determineOrgSlug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['custom-org', 'custom-org']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--org', 'custom-org'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith( - 'custom-org', - true, - false, - ) - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: 'custom-org', - }), - ) - }) - - it('should support --json output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--json'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'json', - }), - ) - }) - - it('should support --markdown output mode', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--markdown'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - outputKind: 'markdown', - }), - ) - }) - - it('should parse ecosystem from arguments', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['npm'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystem: 'npm', - }), - ) - }) - - it('should parse type filter from arguments', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['typo'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - filter: 'typo', - }), - ) - }) - - it('should parse version from arguments', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['1.2.3'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - version: '1.2.3', - }), - ) - }) - - it('should parse package name from arguments', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['my-package'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - pkg: 'my-package', - }), - ) - }) - - it('should parse multiple arguments correctly', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['npm', 'typo', '1.0.0'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystem: 'npm', - filter: 'typo', - version: '1.0.0', - }), - ) - }) - - it('should validate per-page as numeric', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--per-page', 'invalid'], importMeta, context) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - perPage: 30, - }), - ) - }) - - it('throws InputError on negative --per-page', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await expect( - cmdThreatFeed.run(['--per-page', '-1'], importMeta, context), - ).rejects.toThrow(/--per-page must be a positive integer/) - }) - - it('warns about extra positional args beyond the recognized slots', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run( - ['npm', 'lodash', 'extra-arg-1', 'extra-arg-2'], - importMeta, - context, - ) - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('excessive args'), - ) - }) - - it('should fail if both --json and --markdown are set', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--json', '--markdown'], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleThreatFeed).not.toHaveBeenCalled() - }) - - it('should fail without organization slug', async () => { - mockDetermineOrgSlug.mockResolvedValueOnce(['', '']) - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run([], importMeta, context) - - // Exit code 2 = invalid usage/validation failure. - expect(process.exitCode).toBe(2) - expect(mockHandleThreatFeed).not.toHaveBeenCalled() - }) - - it('should handle --no-interactive flag', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run(['--no-interactive'], importMeta, context) - - expect(mockDetermineOrgSlug).toHaveBeenCalledWith('', false, false) - }) - - it('should show dry-run output with all parameters', async () => { - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run( - [ - '--dry-run', - '--eco', - 'npm', - '--filter', - 'mal', - '--pkg', - 'test-pkg', - '--version', - '1.0.0', - '--page', - '2', - '--per-page', - '50', - '--direction', - 'asc', - ], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('threat feed data'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('npm'), - ) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('test-pkg'), - ) - }) - - it('should combine flags and arguments correctly', async () => { - // Note: --filter flag doesn't work due to bug (see earlier test comment). - mockHasDefaultApiToken.mockReturnValueOnce(true) - - await cmdThreatFeed.run( - ['npm', '--filter', 'mal', '--pkg', 'test-package'], - importMeta, - context, - ) - - expect(mockHandleThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - ecosystem: 'npm', - filter: '', - pkg: 'test-package', - }), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/threat-feed/fetch-threat-feed.test.mts b/packages/cli/test/unit/commands/threat-feed/fetch-threat-feed.test.mts deleted file mode 100644 index 2c5555933..000000000 --- a/packages/cli/test/unit/commands/threat-feed/fetch-threat-feed.test.mts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Unit tests for fetchThreatFeed. - * - * Purpose: Tests fetching threat intelligence feed via the Socket API. - * Retrieves current security threat information and malware reports. - * - * Test Coverage: - Successful API operation - SDK setup failure handling - API - * call error scenarios - Custom SDK options (API tokens, base URLs) - Threat - * data retrieval - Feed pagination - Filter options - Null prototype usage for - * security. - * - * Testing Approach: Uses SDK test helpers to mock Socket API interactions. - * Validates comprehensive error handling and API integration. - * - * Related Files: - src/commands/ThreatFeed.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createErrorResult, - createSuccessResult, -} from '../../../../test/helpers/index.mts' - -describe('fetchThreatFeed', () => { - beforeEach(async () => { - vi.resetModules() - }) - - it('fetches threat feed successfully', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - const mockData = { - threats: [ - { - id: 'threat-1', - package: 'malicious-package', - version: '1.0.0', - severity: 'critical', - type: 'malware', - discovered: '2025-01-20T10:00:00Z', - }, - { - id: 'threat-2', - package: 'vulnerable-lib', - version: '2.3.1', - severity: 'high', - type: 'vulnerability', - discovered: '2025-01-19T15:00:00Z', - }, - ], - total: 2, - updated_at: '2025-01-20T12:00:00Z', - } - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult(mockData)) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - const result = await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: 'high', - orgSlug: 'test-org', - page: '1', - perPage: 100, - pkg: 'test-package', - version: '1.0.0', - }) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - expect.stringContaining('orgs/test-org/threat-feed'), - 'the Threat Feed data', - ) - expect(result.ok).toBe(true) - expect(result.data).toEqual(mockData) - }) - - it('handles SDK setup failure', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - const error = createErrorResult('Failed to fetch threat feed', { - code: 1, - cause: 'Invalid configuration', - }) - mockQueryApiSafeJson.mockResolvedValue(error) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - const result = await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: '', - orgSlug: 'my-org', - page: '1', - perPage: 50, - pkg: '', - version: '', - }) - - expect(result).toEqual(error) - }) - - it('handles API call failure', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue( - createErrorResult('Threat feed service unavailable', { code: 503 }), - ) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - const result = await fetchThreatFeed({ - direction: 'asc', - ecosystem: 'npm', - filter: '', - orgSlug: 'org', - page: '1', - perPage: 10, - pkg: '', - version: '', - }) - - expect(result.ok).toBe(false) - expect(result.code).toBe(503) - }) - - it('passes custom SDK options', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult({})) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: 'critical', - orgSlug: 'custom-org', - page: '2', - perPage: 50, - pkg: '', - version: '', - }) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - expect.stringContaining('filter=critical'), - 'the Threat Feed data', - ) - }) - - it('handles filtering by severity levels', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult({ threats: [] })) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: 'critical,high', - orgSlug: 'test-org', - page: '1', - perPage: 100, - pkg: '', - version: '', - }) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - expect.stringContaining('filter=critical%2Chigh'), - 'the Threat Feed data', - ) - }) - - it('handles pagination parameters', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult({ threats: [] })) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - await fetchThreatFeed({ - direction: 'asc', - ecosystem: 'npm', - filter: '', - orgSlug: 'test-org', - page: '5', - perPage: 25, - pkg: '', - version: '', - }) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - expect.stringMatching(/page_cursor=5.*per_page=25/), - 'the Threat Feed data', - ) - }) - - it('handles date range filtering', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult({ threats: [] })) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: '', - orgSlug: 'test-org', - page: '1', - perPage: 100, - pkg: 'specific-package', - version: '1.2.3', - }) - - expect(mockQueryApiSafeJson).toHaveBeenCalledWith( - expect.stringMatching(/name=specific-package.*version=1\.2\.3/), - 'the Threat Feed data', - ) - }) - - it('uses null prototype for options', async () => { - const mockQueryApiSafeJson = vi.fn() - - vi.doMock('../../../../src/util/socket/api.mjs', () => ({ - queryApiSafeJson: mockQueryApiSafeJson, - })) - - mockQueryApiSafeJson.mockResolvedValue(createSuccessResult({})) - - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - // This tests that the function properly uses __proto__: null. - await fetchThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: '', - orgSlug: 'test-org', - page: '1', - perPage: 100, - pkg: '', - version: '', - }) - - // The function should work without prototype pollution issues. - expect(mockQueryApiSafeJson).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/threat-feed/handle-threat-feed.test.mts b/packages/cli/test/unit/commands/threat-feed/handle-threat-feed.test.mts deleted file mode 100644 index e45cf147c..000000000 --- a/packages/cli/test/unit/commands/threat-feed/handle-threat-feed.test.mts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Unit tests for handleThreatFeed. - * - * Purpose: Tests the handler that orchestrates threat feed retrieval. Validates - * feed fetching, filtering, and output formatting. - * - * Test Coverage: - Successful operation flow - Fetch failure handling - Input - * validation - Output formatting delegation - Error propagation. - * - * Testing Approach: Mocks fetch and output functions to isolate handler - * orchestration logic. Validates proper data flow through the handler - * pipeline. - * - * Related Files: - src/commands/handleThreatFeed.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleThreatFeed } from '../../../../src/commands/threat-feed/handle-threat-feed.mts' - -// Mock the dependencies. -vi.mock('../../../../src/commands/threat-feed/fetch-threat-feed.mts', () => ({ - fetchThreatFeed: vi.fn(), -})) -vi.mock('../../../../src/commands/threat-feed/output-threat-feed.mts', () => ({ - outputThreatFeed: vi.fn(), -})) - -describe('handleThreatFeed', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fetches and outputs threat feed successfully', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const mockData = { - ok: true, - data: [ - { - id: 'threat-1', - package: 'malicious-pkg', - version: '1.0.0', - ecosystem: 'npm', - severity: 'high', - description: 'Malware detected', - }, - { - id: 'threat-2', - package: 'suspicious-pkg', - version: '2.0.0', - ecosystem: 'npm', - severity: 'medium', - }, - ], - } - vi.mocked(fetchThreatFeed).mockResolvedValue(mockData) - - await handleThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: 'malware', - orgSlug: 'test-org', - outputKind: 'json', - page: '1', - perPage: 20, - pkg: '', - version: '', - }) - - expect(fetchThreatFeed).toHaveBeenCalledWith({ - direction: 'desc', - ecosystem: 'npm', - filter: 'malware', - orgSlug: 'test-org', - page: '1', - perPage: 20, - pkg: '', - version: '', - }) - expect(outputThreatFeed).toHaveBeenCalledWith(mockData, 'json') - }) - - it('handles fetch failure', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const mockError = { - ok: false, - error: new Error('Failed to fetch threat feed'), - } - vi.mocked(fetchThreatFeed).mockResolvedValue(mockError) - - await handleThreatFeed({ - direction: 'asc', - ecosystem: 'pypi', - filter: '', - orgSlug: 'test-org', - outputKind: 'text', - page: '2', - perPage: 10, - pkg: '', - version: '', - }) - - expect(outputThreatFeed).toHaveBeenCalledWith(mockError, 'text') - }) - - it('handles specific package and version filter', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed: _outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const mockData = { - ok: true, - data: [ - { - id: 'threat-3', - package: 'specific-pkg', - version: '1.2.3', - ecosystem: 'npm', - }, - ], - } - vi.mocked(fetchThreatFeed).mockResolvedValue(mockData) - - await handleThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: '', - orgSlug: 'my-org', - outputKind: 'json', - page: '1', - perPage: 10, - pkg: 'specific-pkg', - version: '1.2.3', - }) - - expect(fetchThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - pkg: 'specific-pkg', - version: '1.2.3', - }), - ) - }) - - it('handles markdown output', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const mockData = { - ok: true, - data: [], - } - vi.mocked(fetchThreatFeed).mockResolvedValue(mockData) - - await handleThreatFeed({ - direction: 'asc', - ecosystem: 'rubygems', - filter: 'vulnerability', - orgSlug: 'org', - outputKind: 'markdown', - page: '1', - perPage: 50, - pkg: '', - version: '', - }) - - expect(outputThreatFeed).toHaveBeenCalledWith(mockData, 'markdown') - }) - - it('handles different ecosystems', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed: _outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const ecosystems = ['npm', 'pypi', 'rubygems', 'maven', 'nuget'] - - for (let i = 0, { length } = ecosystems; i < length; i += 1) { - const ecosystem = ecosystems[i] - vi.mocked(fetchThreatFeed).mockResolvedValue({ - ok: true, - data: [], - }) - - await handleThreatFeed({ - direction: 'desc', - ecosystem, - filter: '', - orgSlug: 'test-org', - outputKind: 'json', - page: '1', - perPage: 20, - pkg: '', - version: '', - }) - - expect(fetchThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ ecosystem }), - ) - } - }) - - it('handles different filter types', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - - const filters = ['malware', 'vulnerability', 'typosquat', 'supply-chain'] - - for (let i = 0, { length } = filters; i < length; i += 1) { - const filter = filters[i] - vi.mocked(fetchThreatFeed).mockResolvedValue({ - ok: true, - data: [], - }) - - await handleThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter, - orgSlug: 'test-org', - outputKind: 'json', - page: '1', - perPage: 20, - pkg: '', - version: '', - }) - - expect(fetchThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ filter }), - ) - } - }) - - it('handles pagination', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed: _outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - vi.mocked(fetchThreatFeed).mockResolvedValue({ - ok: true, - data: [], - }) - - await handleThreatFeed({ - direction: 'asc', - ecosystem: 'npm', - filter: '', - orgSlug: 'test-org', - outputKind: 'json', - page: '10', - perPage: 100, - pkg: '', - version: '', - }) - - expect(fetchThreatFeed).toHaveBeenCalledWith( - expect.objectContaining({ - page: '10', - perPage: 100, - }), - ) - }) - - it('handles empty threat feed', async () => { - const { fetchThreatFeed } = - await import('../../../../src/commands/threat-feed/fetch-threat-feed.mts') - const { outputThreatFeed } = - await import('../../../../src/commands/threat-feed/output-threat-feed.mts') - - const mockData = { - ok: true, - data: [], - } - vi.mocked(fetchThreatFeed).mockResolvedValue(mockData) - - await handleThreatFeed({ - direction: 'desc', - ecosystem: 'npm', - filter: 'nonexistent', - orgSlug: 'test-org', - outputKind: 'text', - page: '1', - perPage: 20, - pkg: '', - version: '', - }) - - expect(outputThreatFeed).toHaveBeenCalledWith(mockData, 'text') - }) -}) diff --git a/packages/cli/test/unit/commands/uninstall/cmd-uninstall-completion.test.mts b/packages/cli/test/unit/commands/uninstall/cmd-uninstall-completion.test.mts deleted file mode 100644 index 1d6db8cb7..000000000 --- a/packages/cli/test/unit/commands/uninstall/cmd-uninstall-completion.test.mts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Unit tests for uninstall completion command. - * - * Tests the command that uninstalls bash tab completion for Socket CLI. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock dependencies. -const mockHandleUninstallCompletion = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -) -const mockOutputDryRunDelete = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/commands/uninstall/handle-uninstall-completion.mts', - () => ({ - handleUninstallCompletion: mockHandleUninstallCompletion, - }), -) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunDelete: mockOutputDryRunDelete, -})) - -// Import after mocks. -const { cmdUninstallCompletion } = - await import('../../../../src/commands/uninstall/cmd-uninstall-completion.mts') - -describe('cmd-uninstall-completion', () => { - const originalExitCode = process.exitCode - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = originalExitCode - }) - - afterEach(() => { - process.exitCode = originalExitCode - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdUninstallCompletion.description).toBe( - 'Uninstall bash completion for Socket CLI', - ) - }) - - it('should not be hidden', () => { - expect(cmdUninstallCompletion.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-uninstall-completion.mts' } - const context = { parentName: 'socket uninstall' } - - it('should use default target name "socket" when no name provided', async () => { - await cmdUninstallCompletion.run([], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should use custom target name when provided', async () => { - await cmdUninstallCompletion.run(['sd'], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('sd') - }) - - it('should handle command name with special characters', async () => { - await cmdUninstallCompletion.run(['socket-dev'], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket-dev') - }) - - describe('dry-run mode', () => { - it('should output dry-run message for default target', async () => { - await cmdUninstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalledWith( - 'bash completion', - 'completion for "socket" from ~/.bashrc', - ) - expect(mockHandleUninstallCompletion).not.toHaveBeenCalled() - }) - - it('should output dry-run message for custom target', async () => { - await cmdUninstallCompletion.run( - ['sd', '--dry-run'], - importMeta, - context, - ) - - expect(mockOutputDryRunDelete).toHaveBeenCalledWith( - 'bash completion', - 'completion for "sd" from ~/.bashrc', - ) - expect(mockHandleUninstallCompletion).not.toHaveBeenCalled() - }) - - it('should return early and not call handler in dry-run mode', async () => { - await cmdUninstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockHandleUninstallCompletion).not.toHaveBeenCalled() - }) - - it('should include correct resource type in dry-run output', async () => { - await cmdUninstallCompletion.run(['--dry-run'], importMeta, context) - - const [resourceType] = mockOutputDryRunDelete.mock.calls[0] - expect(resourceType).toBe('bash completion') - }) - - it('should include target file path in dry-run identifier', async () => { - await cmdUninstallCompletion.run( - ['my-cmd', '--dry-run'], - importMeta, - context, - ) - - const [, identifier] = mockOutputDryRunDelete.mock.calls[0] - expect(identifier).toContain('my-cmd') - expect(identifier).toContain('~/.bashrc') - }) - }) - - describe('actual execution', () => { - it('should call handleUninstallCompletion without dry-run', async () => { - await cmdUninstallCompletion.run([], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledTimes(1) - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should not output dry-run message during actual execution', async () => { - await cmdUninstallCompletion.run([], importMeta, context) - - expect(mockOutputDryRunDelete).not.toHaveBeenCalled() - }) - - it('should convert target name to string', async () => { - await cmdUninstallCompletion.run(['test-cmd'], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('test-cmd') - expect(typeof mockHandleUninstallCompletion.mock.calls[0][0]).toBe( - 'string', - ) - }) - }) - - describe('flag parsing', () => { - it('should handle --dry-run flag correctly', async () => { - await cmdUninstallCompletion.run(['--dry-run'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalled() - expect(mockHandleUninstallCompletion).not.toHaveBeenCalled() - }) - - it('should handle --dryRun flag correctly', async () => { - await cmdUninstallCompletion.run(['--dryRun'], importMeta, context) - - expect(mockOutputDryRunDelete).toHaveBeenCalled() - expect(mockHandleUninstallCompletion).not.toHaveBeenCalled() - }) - - it('should prioritize first input argument as target name', async () => { - await cmdUninstallCompletion.run( - ['custom', 'ignored'], - importMeta, - context, - ) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('custom') - }) - }) - - describe('edge cases', () => { - it('should handle empty string as target name', async () => { - await cmdUninstallCompletion.run([''], importMeta, context) - - // Empty string should be converted to string "socket" as default. - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket') - }) - - it('should handle whitespace-only target name', async () => { - await cmdUninstallCompletion.run([' '], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith(' ') - }) - - it('should handle target name with path separators', async () => { - await cmdUninstallCompletion.run( - ['./custom-socket'], - importMeta, - context, - ) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith( - './custom-socket', - ) - }) - - it('should handle target name with special shell characters', async () => { - await cmdUninstallCompletion.run(['socket$dev'], importMeta, context) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket$dev') - }) - }) - - describe('multiple arguments', () => { - it('should only use first argument as target name', async () => { - await cmdUninstallCompletion.run( - ['first', 'second', 'third'], - importMeta, - context, - ) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('first') - }) - - it('should ignore extra arguments after target name', async () => { - await cmdUninstallCompletion.run( - ['socket', '--extra'], - importMeta, - context, - ) - - expect(mockHandleUninstallCompletion).toHaveBeenCalledWith('socket') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/uninstall/cmd-uninstall.test.mts b/packages/cli/test/unit/commands/uninstall/cmd-uninstall.test.mts deleted file mode 100644 index 7028bddb0..000000000 --- a/packages/cli/test/unit/commands/uninstall/cmd-uninstall.test.mts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Unit tests for uninstall parent command. - * - * Tests the root command that provides access to subcommands for uninstalling - * Socket CLI features like tab completion. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock meowWithSubcommands. -const mockMeowWithSubcommands = vi.hoisted(() => vi.fn()) - -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowWithSubcommands: mockMeowWithSubcommands, - } - }, -) - -// Import after mocks. -const { cmdUninstall } = - await import('../../../../src/commands/uninstall/cmd-uninstall.mts') - -describe('cmd-uninstall', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdUninstall.description).toBe( - 'Uninstall Socket CLI tab completion', - ) - }) - - it('should not be hidden', () => { - expect(cmdUninstall.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-uninstall.mts' } - const context = { parentName: 'socket' } - - it('should call meowWithSubcommands with correct parameters', async () => { - const argv = ['completion'] - - await cmdUninstall.run(argv, importMeta, context) - - expect(mockMeowWithSubcommands).toHaveBeenCalledTimes(1) - - const [meowConfig, options] = mockMeowWithSubcommands.mock.calls[0] - - // Verify config structure. - expect(meowConfig).toMatchObject({ - argv, - name: 'socket uninstall', - importMeta, - subcommands: expect.objectContaining({ - completion: expect.objectContaining({ - description: expect.any(String), - hidden: expect.any(Boolean), - run: expect.any(Function), - }), - }), - }) - - // Verify options. - expect(options).toMatchObject({ - description: 'Uninstall Socket CLI tab completion', - }) - }) - - it('should include completion subcommand', async () => { - await cmdUninstall.run([], importMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.subcommands).toHaveProperty('completion') - expect(meowConfig.subcommands.completion).toMatchObject({ - description: expect.any(String), - hidden: expect.any(Boolean), - run: expect.any(Function), - }) - }) - - it('should construct correct command name from parentName', async () => { - const customContext = { parentName: 'custom-socket' } - - await cmdUninstall.run([], importMeta, customContext) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.name).toBe('custom-socket uninstall') - }) - - it('should pass through argv to meowWithSubcommands', async () => { - const customArgv = ['completion', '--help'] - - await cmdUninstall.run(customArgv, importMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.argv).toBe(customArgv) - }) - - it('should pass through importMeta to meowWithSubcommands', async () => { - const customImportMeta = { url: 'file:///custom/path.mts' } - - await cmdUninstall.run([], customImportMeta, context) - - const [meowConfig] = mockMeowWithSubcommands.mock.calls[0] - - expect(meowConfig.importMeta).toBe(customImportMeta) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/uninstall/handle-uninstall-completion.test.mts b/packages/cli/test/unit/commands/uninstall/handle-uninstall-completion.test.mts deleted file mode 100644 index 48e20a11f..000000000 --- a/packages/cli/test/unit/commands/uninstall/handle-uninstall-completion.test.mts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Unit Tests: Tab Completion Uninstallation Handler. - * - * Purpose: Tests the command handler that removes shell tab completion support - * for the Socket CLI. Validates the orchestration between teardown and output - * modules for different shell environments (bash, zsh, fish, powershell). - * - * Test Coverage: - Successful completion uninstallation for various shells - - * Uninstallation failure handling - Multiple shell target support (bash, zsh, - * fish, powershell) - Shell target parameter passing - Async error propagation. - * - * Testing Approach: Mocks teardownTabCompletion and outputUninstallCompletion - * modules to test the handler's orchestration logic without actual file system - * modifications. Tests verify correct parameter passing and CResult pattern - * handling. - * - * Related Files: - src/commands/uninstall/handle-uninstall-completion.mts - - * Command handler - src/commands/uninstall/teardown-tab-completion.mts - - * Completion removal logic - - * src/commands/uninstall/output-uninstall-completion.mts - Output formatting. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { handleUninstallCompletion } from '../../../../src/commands/uninstall/handle-uninstall-completion.mts' - -// Mock the dependencies. -vi.mock( - '../../../../src/commands/uninstall/output-uninstall-completion.mts', - () => ({ - outputUninstallCompletion: vi.fn(), - }), -) -vi.mock( - '../../../../src/commands/uninstall/teardown-tab-completion.mts', - () => ({ - teardownTabCompletion: vi.fn(), - }), -) - -describe('handleUninstallCompletion', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('uninstalls completion successfully', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: true, - value: 'Completion uninstalled successfully', - }) - - await handleUninstallCompletion('bash') - - expect(teardownTabCompletion).toHaveBeenCalledWith('bash') - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: true, - value: 'Completion uninstalled successfully', - }, - 'bash', - ) - }) - - it('handles uninstallation failure', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - const error = new Error('Failed to uninstall completion') - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: false, - error, - }) - - await handleUninstallCompletion('zsh') - - expect(teardownTabCompletion).toHaveBeenCalledWith('zsh') - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: false, - error, - }, - 'zsh', - ) - }) - - it('handles different shell targets', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - const shells = ['bash', 'zsh', 'fish', 'powershell'] - - for (let i = 0, { length } = shells; i < length; i += 1) { - const shell = shells[i] - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: true, - value: `Completion for ${shell} uninstalled`, - }) - - await handleUninstallCompletion(shell) - - expect(teardownTabCompletion).toHaveBeenCalledWith(shell) - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: true, - value: `Completion for ${shell} uninstalled`, - }, - shell, - ) - } - }) - - it('handles empty target name', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: false, - error: new Error('Invalid shell target'), - }) - - await handleUninstallCompletion('') - - expect(teardownTabCompletion).toHaveBeenCalledWith('') - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: false, - error: new Error('Invalid shell target'), - }, - '', - ) - }) - - it('handles unsupported shell', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: false, - error: new Error('Unsupported shell: tcsh'), - }) - - await handleUninstallCompletion('tcsh') - - expect(teardownTabCompletion).toHaveBeenCalledWith('tcsh') - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: false, - error: new Error('Unsupported shell: tcsh'), - }, - 'tcsh', - ) - }) - - it('handles completion not found', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - const { outputUninstallCompletion } = - await import('../../../../src/commands/uninstall/output-uninstall-completion.mts') - - vi.mocked(teardownTabCompletion).mockResolvedValue({ - ok: false, - error: new Error('Completion not found'), - }) - - await handleUninstallCompletion('bash') - - expect(teardownTabCompletion).toHaveBeenCalledWith('bash') - expect(outputUninstallCompletion).toHaveBeenCalledWith( - { - ok: false, - error: new Error('Completion not found'), - }, - 'bash', - ) - }) - - it('handles async errors', async () => { - const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - - vi.mocked(teardownTabCompletion).mockRejectedValue(new Error('Async error')) - - await expect(handleUninstallCompletion('bash')).rejects.toThrow( - 'Async error', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/uninstall/output-uninstall-completion.test.mts b/packages/cli/test/unit/commands/uninstall/output-uninstall-completion.test.mts deleted file mode 100644 index bcd87c268..000000000 --- a/packages/cli/test/unit/commands/uninstall/output-uninstall-completion.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Unit tests for uninstall completion output formatting. - * - * Purpose: Tests the output formatting for tab completion uninstallation - * results. - * - * Test Coverage: - outputUninstallCompletion function - Success output with - * removal instructions - Remaining completions notification - Error handling. - * - * Related Files: - src/commands/uninstall/output-uninstall-completion.mts - * (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock utilities. -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: (msg: string, cause?: string) => - cause ? `${msg}: ${cause}` : msg, -})) - -import { outputUninstallCompletion } from '../../../../src/commands/uninstall/output-uninstall-completion.mts' - -import type { CResult } from '../../../../src/types.mts' - -describe('output-uninstall-completion', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - describe('outputUninstallCompletion', () => { - describe('success output', () => { - it('outputs uninstall message', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Tab completion removed successfully', - data: { action: 'removed', left: [] }, - } - - await outputUninstallCompletion(result, 'socket') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Tab completion removed successfully') - }) - - it('outputs complete -r command for manual removal', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Uninstalled', - data: { action: 'removed', left: [] }, - } - - await outputUninstallCompletion(result, 'socket') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('complete -r socket') - }) - - it('mentions tab completion will not be in next terminal', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Uninstalled', - data: { action: 'removed', left: [] }, - } - - await outputUninstallCompletion(result, 'socket') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Next time you open a terminal') - expect(logs).toContain('no longer be there') - }) - - it('lists remaining completions when present', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Uninstalled', - data: { action: 'removed', left: ['socket-npm', 'socket-npx'] }, - } - - await outputUninstallCompletion(result, 'socket') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('Detected more Socket Alias completions') - expect(logs).toContain('socket-npm') - expect(logs).toContain('socket-npx') - }) - - it('does not show remaining message when list is empty', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Uninstalled', - data: { action: 'removed', left: [] }, - } - - await outputUninstallCompletion(result, 'socket') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).not.toContain('Detected more Socket Alias completions') - }) - - it('uses target name in complete -r command', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: true, - message: 'Uninstalled', - data: { action: 'removed', left: [] }, - } - - await outputUninstallCompletion(result, 'my-custom-command') - - const logs = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(logs).toContain('complete -r my-custom-command') - }) - }) - - describe('error output', () => { - it('outputs error with fail message', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: false, - message: 'Uninstallation failed', - cause: 'File not found', - } - - await outputUninstallCompletion(result, 'socket') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Uninstallation failed'), - ) - expect(process.exitCode).toBe(1) - }) - - it('uses custom exit code when provided', async () => { - const result: CResult<{ action: string; left: string[] }> = { - ok: false, - message: 'Failed', - code: 2, - } - - await outputUninstallCompletion(result, 'socket') - - expect(process.exitCode).toBe(2) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/uninstall/teardown-tab-completion.test.mts b/packages/cli/test/unit/commands/uninstall/teardown-tab-completion.test.mts deleted file mode 100644 index fddf16ee0..000000000 --- a/packages/cli/test/unit/commands/uninstall/teardown-tab-completion.test.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Unit tests for teardownTabCompletion. - * - * Removes Socket CLI tab-completion lines from the user's ~/.bashrc. Mocks - * node:fs so tests don't touch the real filesystem. - * - * Test Coverage: - * - * - GetBashrcDetails error pass-through - * - .bashrc absent → "not found" action - * - .bashrc present without our block → "missing" action - * - .bashrc present with our block → removes the full block - * - .bashrc present where the block was edited → falls back to removing - * sourcingCommand / completionCommand individually - * - FindRemainingCompletionSetups discovers other targets - * - HomePath unset edge case (skip the bashrc lookup entirely) - * - * Related Files: - * - * - Src/commands/uninstall/teardown-tab-completion.mts - Implementation - * - Src/util/cli/completion.mts - getBashrcDetails / COMPLETION_CMD_PREFIX - * - Src/constants/paths.mts - homePath - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockExistsSync, mockReadFileSync, mockWriteFileSync } = vi.hoisted( - () => ({ - mockExistsSync: vi.fn(), - mockReadFileSync: vi.fn(), - mockWriteFileSync: vi.fn(), - }), -) - -vi.mock('node:fs', () => ({ - default: { - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - }, - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, -})) - -const { mockGetBashrcDetails } = vi.hoisted(() => ({ - mockGetBashrcDetails: vi.fn(), -})) - -vi.mock('../../../../src/util/cli/completion.mts', () => ({ - COMPLETION_CMD_PREFIX: 'source <(socket install completion ', - getBashrcDetails: mockGetBashrcDetails, -})) - -const { mockHomePath } = vi.hoisted(() => ({ - mockHomePath: { value: '/home/test' as string }, -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - get homePath() { - return mockHomePath.value - }, -})) - -const { teardownTabCompletion } = - await import('../../../../src/commands/uninstall/teardown-tab-completion.mts') - -const validDetails = { - ok: true as const, - data: { - completionCommand: 'source <(socket install completion socket)', - sourcingCommand: 'eval "$(socket install completion socket)"', - toAddToBashrc: - '# Socket CLI tab completion\nsource <(socket install completion socket)\n', - }, -} - -beforeEach(() => { - vi.clearAllMocks() - mockHomePath.value = '/home/test' - mockGetBashrcDetails.mockReturnValue(validDetails) -}) - -describe('teardownTabCompletion', () => { - it('passes through getBashrcDetails errors', async () => { - mockGetBashrcDetails.mockReturnValue({ - ok: false, - message: 'unknown shell', - cause: 'unsupported', - }) - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(false) - expect(mockExistsSync).not.toHaveBeenCalled() - }) - - it('returns "not found" action when ~/.bashrc does not exist', async () => { - mockExistsSync.mockReturnValue(false) - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.action).toBe('not found') - expect(result.data.left).toEqual([]) - } - expect(mockReadFileSync).not.toHaveBeenCalled() - }) - - it('returns "missing" when ~/.bashrc has no completion block', async () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('export PATH=$PATH:/usr/local/bin\n') - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.action).toBe('missing') - expect(result.data.left).toEqual([]) - } - expect(mockWriteFileSync).not.toHaveBeenCalled() - }) - - it('removes the completion block when present', async () => { - mockExistsSync.mockReturnValue(true) - const before = `export PATH=$PATH:/usr/local/bin\n${validDetails.data.toAddToBashrc}\nalias ll='ls -la'\n` - mockReadFileSync.mockReturnValue(before) - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.action).toBe('removed') - } - expect(mockWriteFileSync).toHaveBeenCalledTimes(1) - const written = mockWriteFileSync.mock.calls[0]![1] - expect(written).not.toContain(validDetails.data.toAddToBashrc) - }) - - it('falls back to removing sourcing/completion lines individually when the block was edited', async () => { - mockExistsSync.mockReturnValue(true) - // Manually-edited bashrc: someone removed the comment and reorganized. - const partial = `${validDetails.data.toAddToBashrc}\n${validDetails.data.sourcingCommand}\n${validDetails.data.completionCommand}\n` - mockReadFileSync.mockReturnValue(partial) - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - const written = mockWriteFileSync.mock.calls[0]![1] as string - expect(written).not.toContain(validDetails.data.sourcingCommand) - expect(written).not.toContain(validDetails.data.completionCommand) - }) - - it('reports remaining completion-prefix lines in the "left" array', async () => { - mockExistsSync.mockReturnValue(true) - const otherTarget = 'source <(socket install completion otherCli)' - const before = `${validDetails.data.toAddToBashrc}\n${otherTarget}\n` - mockReadFileSync.mockReturnValue(before) - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.left).toEqual(['otherCli)']) - } - }) - - it('reports remaining setups in the "missing" branch too', async () => { - mockExistsSync.mockReturnValue(true) - const otherTarget = 'source <(socket install completion otherCli)' - mockReadFileSync.mockReturnValue(otherTarget + '\n') - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.action).toBe('missing') - expect(result.data.left).toEqual(['otherCli)']) - } - }) - - it('skips bashrc handling when homePath is empty', async () => { - mockHomePath.value = '' - const result = await teardownTabCompletion('socket') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.action).toBe('not found') - } - expect(mockExistsSync).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/uv/cmd-uv.test.mts b/packages/cli/test/unit/commands/uv/cmd-uv.test.mts deleted file mode 100644 index 2e7b4f7ea..000000000 --- a/packages/cli/test/unit/commands/uv/cmd-uv.test.mts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Unit Tests: Socket UV Command. - * - * Purpose: Tests the uv wrapper command that forwards uv operations to Socket - * Firewall (sfw). Validates argument forwarding, flag filtering, exit code - * handling, and signal propagation. - * - * Test Coverage: - Command metadata (description, hidden status) - Argument - * forwarding to sfw via spawnSfwDlx - Socket CLI flag filtering (removes - * --config, --org, etc.) - Exit code defaults and handling - Signal propagation - * from child process - Integration with meowOrExit for --help handling. - * - * Testing Approach: Mocks spawnSfwDlx to simulate child process behavior - * without actual execution. Uses EventEmitter to simulate process exit events - * and signal handling. Validates that Socket CLI flags are filtered out before - * forwarding to sfw. - * - * Related Files: - src/commands/uv/cmd-uv.mts - UV wrapper command - * implementation - src/util/dlx/spawn.mts - DLX spawn utilities - - * src/util/process/cmd.mts - Flag filtering utilities. - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { setupTestEnvironment } from '../../../helpers/index.mts' - -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/cli/with-subcommands.mjs', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -const { cmdUv } = await import('../../../../src/commands/uv/cmd-uv.mts') - -describe('cmd-uv', () => { - setupTestEnvironment() - - beforeEach(() => { - mockFilterFlags.mockReturnValue([]) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdUv.description).toBe('Run uv with Socket Firewall security') - }) - - it('should not be hidden', () => { - expect(cmdUv.hidden).toBe(false) - }) - - it('should have a run function', () => { - expect(typeof cmdUv.run).toBe('function') - }) - - it('renders help text via the meow help callback', async () => { - mockMeowOrExit.mockImplementation(args => { - const helpText = args.config.help('socket uv') - expect(helpText).toContain('socket uv') - return { - flags: {}, - help: helpText, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - const EventEmitter = (await import('node:events')).default - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - mockSpawnPromise.process = mockChildProcess - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise: mockSpawnPromise }) - mockFilterFlags.mockReturnValue([]) - const runPromise = cmdUv.run( - [], - { url: import.meta.url }, - { parentName: 'socket' }, - ) - setImmediate(() => mockChildProcess.emit('exit', 0, undefined)) - await runPromise - expect(mockMeowOrExit).toHaveBeenCalled() - }) - }) - - describe('run', () => { - it('should call meowOrExit with correct config', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['pip', 'install', 'flask']) - - const runPromise = cmdUv.run( - ['pip', 'install', 'flask'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockMeowOrExit).toHaveBeenCalledWith({ - argv: ['pip', 'install', 'flask'], - config: expect.objectContaining({ - commandName: 'uv', - description: 'Run uv with Socket Firewall security', - hidden: false, - }), - importMeta: { url: import.meta.url }, - parentName: 'socket', - }) - }) - - it('should forward filtered arguments to spawnSfwDlx', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['pip', 'sync']) - - const runPromise = cmdUv.run( - ['pip', 'sync', '--config', 'socket.config.json'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['uv', 'pip', 'sync'], { - stdio: 'inherit', - }) - }) - - it('should filter out Socket CLI flags', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - const filteredArgs = ['run', 'script.py'] - mockFilterFlags.mockReturnValue(filteredArgs) - - const runPromise = cmdUv.run( - ['run', 'script.py', '--org', 'my-org'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Simulate successful exit. - setImmediate(() => { - mockChildProcess.emit('exit', 0, undefined) - }) - - await runPromise - - expect(mockFilterFlags).toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['uv', ...filteredArgs], { - stdio: 'inherit', - }) - }) - - it('should set default exit code to 1', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['pip', 'install', 'flask']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - process.exitCode = undefined - - cmdUv.run( - ['pip', 'install', 'flask'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Check that exit code was set to 1 before child process exits. - await vi.waitFor(() => { - expect(process.exitCode).toBe(1) - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with code', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['pip', 'install', 'flask']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdUv.run( - ['pip', 'install', 'flask'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with code 0. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('should handle child process exit with signal', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: undefined, - signal: 'SIGTERM', - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['pip', 'install', 'flask']) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - - cmdUv.run( - ['pip', 'install', 'flask'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - - // Wait for event listeners to be registered. - await new Promise(resolve => { - setImmediate(resolve) - }) - - // Simulate exit with signal. - mockChildProcess.emit('exit', undefined, 'SIGTERM') - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - - mockKill.mockRestore() - }) - - it('should handle empty arguments', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue([]) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdUv.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['uv'], { stdio: 'inherit' }) - - mockExit.mockRestore() - }) - - it('should handle context with parentName', async () => { - const mockChildProcess = new EventEmitter() - const mockSpawnPromise = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - ;(mockSpawnPromise as unknown).process = mockChildProcess - - mockSpawnSfwDlx.mockResolvedValue({ - spawnPromise: mockSpawnPromise, - }) - - mockFilterFlags.mockReturnValue(['--version']) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - - cmdUv.run(['--version'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - // Simulate successful exit. - mockChildProcess.emit('exit', 0, undefined) - - // Wait for event handler to execute. - await new Promise(resolve => { - setImmediate(resolve) - }) - - expect(mockMeowOrExit).toHaveBeenCalledWith( - expect.objectContaining({ - parentName: 'socket', - }), - ) - - mockExit.mockRestore() - }) - }) -}) diff --git a/packages/cli/test/unit/commands/whoami/cmd-whoami.test.mts b/packages/cli/test/unit/commands/whoami/cmd-whoami.test.mts deleted file mode 100644 index 208fb3e17..000000000 --- a/packages/cli/test/unit/commands/whoami/cmd-whoami.test.mts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * Unit tests for whoami command. - * - * Tests the command that displays authentication status and token information. - * - * Test Coverage: - * - * - Command metadata (description, hidden flag) - * - Token detection from environment variable - * - Token detection from config file - * - No token (unauthenticated) scenario - * - Output formatting (JSON, text) - * - Token masking and display - * - * Testing Approach: - * - * - Mock logger to capture output - * - Mock meowOrExit to control flag values and prevent process.exit - * - Mock getDefaultApiToken from util/socket/sdk.mts - * - Mock getVisibleTokenPrefix from util/socket/sdk.mts - * - Mock SOCKET_CLI_API_TOKEN environment variable - * - Mock getConfigValueOrUndef from util/config.mts - * - Test output format for authenticated/unauthenticated states - * - Verify token display masking (TOKEN_PREFIX + visible prefix + ...) - * - * Related Files: - * - * - Src/commands/whoami/cmd-whoami.mts - Implementation - * - Src/util/socket/sdk.mts - Token utilities - * - Src/util/config.mts - Config file utilities - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as LoggerModule from '@socketsecurity/lib-stable/logger' -import type * as WithSubcommandsModule from '../../../../src/util/cli/with-subcommands.mjs' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', async importOriginal => { - const actual = await importOriginal<typeof LoggerModule>() - return { - ...actual, - getDefaultLogger: () => mockLogger, - } -}) - -// Create mock functions with hoisting. -const { - mockApiToken, - mockGetConfigValueOrUndef, - mockGetDefaultApiToken, - mockGetVisibleTokenPrefix, - mockMeowOrExit, -} = vi.hoisted(() => { - return { - mockApiToken: { value: undefined as string | undefined }, - mockGetConfigValueOrUndef: vi.fn(), - mockGetDefaultApiToken: vi.fn(), - mockGetVisibleTokenPrefix: vi.fn(), - mockMeowOrExit: vi.fn((args: unknown) => { - const flags: Record<string, unknown> = {} - // Parse simple flags from argv. - const argv = args.argv as string[] | readonly string[] - if (argv.includes('--json')) { - flags['json'] = true - } - if (argv.includes('--markdown')) { - flags['markdown'] = true - } - if (argv.includes('--dry-run')) { - flags['dryRun'] = true - } - // Invoke the help() callback so its template-string body is - // recorded as covered; production meowOrExit only invokes it on - // --help, which the test suite never exercises. cmd-whoami's - // help signature is (command, config) so we pass a fake config. - if (args.config?.help) { - try { - args.config.help('socket whoami', { flags: {} }) - } catch {} - } - return { - flags, - help: '', - input: [], - pkg: {}, - } - }), - } -}) - -// Mock SOCKET_CLI_API_TOKEN environment variable. -vi.mock('../../../../src/env/socket-cli-api-token.mts', () => ({ - get SOCKET_CLI_API_TOKEN() { - return mockApiToken.value - }, -})) - -// Mock config utilities. -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, -})) - -// Mock SDK utilities. -vi.mock('../../../../src/util/socket/sdk.mjs', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, - getVisibleTokenPrefix: mockGetVisibleTokenPrefix, - hasDefaultApiToken: vi.fn(() => false), -})) - -// Mock TOKEN_PREFIX constant to avoid security check false positives. -const MOCK_TOKEN_PREFIX = 'test_' -vi.mock('../../../../src/constants/socket.mjs', () => ({ - TOKEN_PREFIX: MOCK_TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH: MOCK_TOKEN_PREFIX.length, -})) - -// Mock meowOrExit to prevent actual CLI parsing and process.exit. -vi.mock( - '../../../../src/util/cli/with-subcommands.mjs', - async importOriginal => { - const actual = await importOriginal<typeof WithSubcommandsModule>() - return { - ...actual, - meowOrExit: mockMeowOrExit, - } - }, -) - -// Import after mocks. -const { cmdWhoami } = - await import('../../../../src/commands/whoami/cmd-whoami.mts') - -describe('cmd-whoami', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockApiToken.value = undefined - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdWhoami.description).toBe( - 'Check Socket CLI authentication status', - ) - }) - - it('should not be hidden', () => { - expect(cmdWhoami.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-whoami.mts' } - const context = { parentName: 'socket' } - - describe('authenticated with token from environment variable', () => { - beforeEach(() => { - mockApiToken.value = 'test_fake_token_12345' - mockGetDefaultApiToken.mockReturnValue('test_fake_token_12345') - mockGetVisibleTokenPrefix.mockReturnValue('test1') - }) - - it('should display authenticated status in text format', async () => { - await cmdWhoami.run([], importMeta, context) - - expect(mockGetDefaultApiToken).toHaveBeenCalled() - expect(mockGetVisibleTokenPrefix).toHaveBeenCalled() - expect(mockLogger.success).toHaveBeenCalledWith( - 'Authenticated with Socket', - ) - expect(mockLogger.log).toHaveBeenCalledWith(' Token: test_test1...') - expect(mockLogger.log).toHaveBeenCalledWith( - ' Source: Environment variable (SOCKET_SECURITY_API_KEY)', - ) - }) - - it('should display authenticated status in JSON format', async () => { - await cmdWhoami.run(['--json'], importMeta, context) - - expect(mockGetDefaultApiToken).toHaveBeenCalled() - expect(mockGetVisibleTokenPrefix).toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalled() - - const jsonOutput = mockLogger.log.mock.calls[0][0] - const result = JSON.parse(jsonOutput) - - expect(result).toEqual({ - ok: true, - data: { - authenticated: true, - location: 'Environment variable (SOCKET_SECURITY_API_KEY)', - token: 'test_test1...', - }, - }) - }) - }) - - describe('authenticated with token from config file', () => { - beforeEach(() => { - mockApiToken.value = undefined - mockGetConfigValueOrUndef.mockReturnValue('test_fake_config_token') - mockGetDefaultApiToken.mockReturnValue('test_fake_config_token') - mockGetVisibleTokenPrefix.mockReturnValue('confi') - }) - - it('should display config file as token source in text format', async () => { - await cmdWhoami.run([], importMeta, context) - - expect(mockGetDefaultApiToken).toHaveBeenCalled() - expect(mockGetVisibleTokenPrefix).toHaveBeenCalled() - expect(mockLogger.success).toHaveBeenCalledWith( - 'Authenticated with Socket', - ) - expect(mockLogger.log).toHaveBeenCalledWith(' Token: test_confi...') - expect(mockLogger.log).toHaveBeenCalledWith( - ' Source: Config file (~/.config/socket/config.toml)', - ) - }) - - it('should display config file as token source in JSON format', async () => { - await cmdWhoami.run(['--json'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalled() - - const jsonOutput = mockLogger.log.mock.calls[0][0] - const result = JSON.parse(jsonOutput) - - expect(result).toEqual({ - ok: true, - data: { - authenticated: true, - location: 'Config file (~/.config/socket/config.toml)', - token: 'test_confi...', - }, - }) - }) - }) - - describe('not authenticated (no token)', () => { - beforeEach(() => { - mockApiToken.value = undefined - mockGetConfigValueOrUndef.mockReturnValue(undefined) - mockGetDefaultApiToken.mockReturnValue(undefined) - }) - - it('should display unauthenticated status in text format', async () => { - await cmdWhoami.run([], importMeta, context) - - expect(mockGetDefaultApiToken).toHaveBeenCalled() - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Not authenticated with Socket', - ) - expect(mockLogger.log).toHaveBeenCalledWith('') - expect(mockLogger.log).toHaveBeenCalledWith( - 'To authenticate, run one of:', - ) - expect(mockLogger.log).toHaveBeenCalledWith(' socket login') - expect(mockLogger.log).toHaveBeenCalledWith( - ' export SOCKET_SECURITY_API_KEY=<your-token>', - ) - }) - - it('should display unauthenticated status in JSON format', async () => { - await cmdWhoami.run(['--json'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalled() - - const jsonOutput = mockLogger.log.mock.calls[0][0] - const result = JSON.parse(jsonOutput) - - expect(result).toEqual({ - ok: true, - data: { - authenticated: false, - location: undefined, - token: undefined, - }, - }) - }) - - it('should not call getVisibleTokenPrefix when unauthenticated', async () => { - await cmdWhoami.run([], importMeta, context) - - expect(mockGetVisibleTokenPrefix).not.toHaveBeenCalled() - }) - - it('should not display token when unauthenticated', async () => { - await cmdWhoami.run([], importMeta, context) - - const logCalls = mockLogger.log.mock.calls - .map(call => call[0]) - .join('\n') - expect(logCalls).not.toContain('test_') - }) - }) - - describe('token priority', () => { - it('should prioritize environment variable over config file', async () => { - mockApiToken.value = 'test_env_token_value' - mockGetConfigValueOrUndef.mockReturnValue('test_gtoken') - mockGetDefaultApiToken.mockReturnValue('test_env_token_value') - mockGetVisibleTokenPrefix.mockReturnValue('envto') - - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - ' Source: Environment variable (SOCKET_SECURITY_API_KEY)', - ) - }) - }) - - describe('token masking', () => { - it('should mask token with prefix and ellipsis', async () => { - mockGetDefaultApiToken.mockReturnValue('test_masked_token_abc') - mockGetVisibleTokenPrefix.mockReturnValue('abcde') - - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith(' Token: test_abcde...') - }) - - it('should show correct visible prefix length', async () => { - mockGetDefaultApiToken.mockReturnValue('test_longer_token_xyz') - mockGetVisibleTokenPrefix.mockReturnValue('xyz98') - - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith(' Token: test_xyz98...') - }) - }) - - describe('output format flags', () => { - beforeEach(() => { - mockGetDefaultApiToken.mockReturnValue('test_2345') - mockGetVisibleTokenPrefix.mockReturnValue('test1') - }) - - it('should default to text format when no format flag provided', async () => { - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.success).toHaveBeenCalledWith( - 'Authenticated with Socket', - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Token:'), - ) - }) - - it('should use JSON format when --json flag provided', async () => { - await cmdWhoami.run(['--json'], importMeta, context) - - expect(mockLogger.success).not.toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"ok":'), - ) - }) - }) - - describe('edge cases', () => { - it('should handle empty token gracefully', async () => { - mockGetDefaultApiToken.mockReturnValue('') - mockGetConfigValueOrUndef.mockReturnValue('') - - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Not authenticated with Socket', - ) - }) - - it('should not fail when config returns null', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - mockGetConfigValueOrUndef.mockReturnValue(undefined) - - await cmdWhoami.run([], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Not authenticated with Socket', - ) - }) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/wrapper/add-socket-wrapper.test.mts b/packages/cli/test/unit/commands/wrapper/add-socket-wrapper.test.mts deleted file mode 100644 index 8b931e9df..000000000 --- a/packages/cli/test/unit/commands/wrapper/add-socket-wrapper.test.mts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Unit tests for addSocketWrapper. - * - * Purpose: Tests adding Socket wrapper scripts to package managers. Validates - * wrapper installation for npm, pnpm, and yarn. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/addSocketWrapper.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { addSocketWrapper } from '../../../../src/commands/wrapper/add-socket-wrapper.mts' - -// Mock the dependencies. -vi.mock('node:fs', () => ({ - promises: { - appendFile: vi.fn(), - }, -})) - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -describe('addSocketWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('successfully adds wrapper aliases to file', async () => { - await import('@socketsecurity/lib-stable/logger') - const fs = await import('node:fs') - const mockAppendFile = vi.mocked(fs.promises.appendFile) - - mockAppendFile.mockResolvedValue(undefined) - - await addSocketWrapper('/home/user/.bashrc') - - expect(fs.promises.appendFile).toHaveBeenCalledWith( - '/home/user/.bashrc', - 'alias npm="socket npm"\nalias npx="socket npx"\n', - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('The alias was added to /home/user/.bashrc'), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'This will only be active in new terminal sessions going forward.', - ) - expect(mockLogger.log).toHaveBeenCalledWith(' source /home/user/.bashrc') - }) - - it('handles file write error', async () => { - const fs = await import('node:fs') - const mockAppendFile = vi.mocked(fs.promises.appendFile) - const error = new Error('Permission denied') - - mockAppendFile.mockRejectedValue(error) - - // The FileSystemError wraps the cause in the message; the path is - // stored on the `.path` property (not embedded in the message) to - // avoid display.formatErrorForDisplay double-printing it. Assert on - // the message shape + the path property separately. - await expect(addSocketWrapper('/etc/protected-file')).rejects.toThrow( - /failed to append socket aliases \(Permission denied\)/, - ) - await expect(addSocketWrapper('/etc/protected-file')).rejects.toMatchObject( - { - name: 'FileSystemError', - path: '/etc/protected-file', - }, - ) - - expect(fs.promises.appendFile).toHaveBeenCalledWith( - '/etc/protected-file', - 'alias npm="socket npm"\nalias npx="socket npx"\n', - ) - }) - - it('adds correct aliases content', async () => { - const fs = await import('node:fs') - const mockAppendFile = vi.mocked(fs.promises.appendFile) - let capturedContent = '' - - mockAppendFile.mockImplementation(async (_file, content) => { - capturedContent = content as string - }) - - await addSocketWrapper('/home/user/.zshrc') - - expect(capturedContent).toBe( - 'alias npm="socket npm"\nalias npx="socket npx"\n', - ) - }) - - it('logs disable instructions', async () => { - await import('@socketsecurity/lib-stable/logger') - const fs = await import('node:fs') - const mockAppendFile = vi.mocked(fs.promises.appendFile) - - mockAppendFile.mockResolvedValue(undefined) - - await addSocketWrapper('/home/user/.bashrc') - - expect(mockLogger.log).toHaveBeenCalledWith( - ' If you want to disable it at any time, run `socket wrapper --disable`', - ) - }) - - it('handles different shell config files', async () => { - const fs = await import('node:fs') - const mockAppendFile = vi.mocked(fs.promises.appendFile) - const shells = [ - '/home/user/.bashrc', - '/home/user/.zshrc', - '/home/user/.bash_profile', - '/home/user/.profile', - ] - - for (let i = 0, { length } = shells; i < length; i += 1) { - const shellFile = shells[i] - vi.clearAllMocks() - mockAppendFile.mockResolvedValue(undefined) - - await addSocketWrapper(shellFile) - - expect(fs.promises.appendFile).toHaveBeenCalledWith( - shellFile, - 'alias npm="socket npm"\nalias npx="socket npx"\n', - ) - } - }) -}) diff --git a/packages/cli/test/unit/commands/wrapper/check-socket-wrapper-setup.test.mts b/packages/cli/test/unit/commands/wrapper/check-socket-wrapper-setup.test.mts deleted file mode 100644 index c16f064ce..000000000 --- a/packages/cli/test/unit/commands/wrapper/check-socket-wrapper-setup.test.mts +++ /dev/null @@ -1,148 +0,0 @@ - -/** - * @file Unit tests for checkSocketWrapperSetup. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockReadFileSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - readFileSync: mockReadFileSync, - default: { - readFileSync: mockReadFileSync, - }, -})) - -import { checkSocketWrapperSetup } from '../../../../src/commands/wrapper/check-socket-wrapper-setup.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -describe('checkSocketWrapperSetup', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('detects npm alias in file', () => { - mockReadFileSync.mockReturnValue('alias npm="socket npm"\nother content') - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(true) - expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.bashrc', 'utf8') - }) - - it('detects pnpm exec alias in file', () => { - mockReadFileSync.mockReturnValue('alias npx="socket npx"\nother content') - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(true) - }) - - it('detects both aliases in file', () => { - mockReadFileSync.mockReturnValue( - 'alias npm="socket npm"\nalias npx="socket npx"\nother content', - ) - - const result = checkSocketWrapperSetup('/home/user/.zshrc') - - expect(result).toBe(true) - }) - - it('returns false when no aliases found', () => { - mockReadFileSync.mockReturnValue('some other content\nno aliases here') - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(false) - }) - - it('returns false for empty file', () => { - mockReadFileSync.mockReturnValue('') - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(false) - }) - - it('logs instructions when wrapper is set up', () => { - mockReadFileSync.mockReturnValue('alias npm="socket npm"') - - checkSocketWrapperSetup('/home/user/.bashrc') - - expect(mockLogger.log).toHaveBeenCalledWith( - 'The Socket npm/npx wrapper is set up in your bash profile (/home/user/.bashrc).', - ) - expect(mockLogger.log).toHaveBeenCalledWith(' source /home/user/.bashrc') - }) - - it('ignores partial alias matches', () => { - mockReadFileSync.mockReturnValue( - 'alias npm="other-tool npm"\nalias npx="other-tool npx"', - ) - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(false) - }) - - it('handles multiline file with aliases mixed in', () => { - mockReadFileSync.mockReturnValue( - `#!/bin/bash -# User bashrc -export PATH=$PATH:/usr/local/bin -alias npm="socket npm" -alias ll="ls -la" -export NODE_ENV=development`, - ) - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(true) - }) - - it('is case-sensitive for alias detection', () => { - mockReadFileSync.mockReturnValue('ALIAS NPM="SOCKET NPM"') - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(false) - }) - - it('handles files with Windows line endings', () => { - mockReadFileSync.mockReturnValue( - 'line1\r\nalias npm="socket npm"\r\nalias npx="socket npx"\r\n', - ) - - const result = checkSocketWrapperSetup('/home/user/.bashrc') - - expect(result).toBe(false) - }) - - it('returns false when readFileSync throws (deleted/unreadable file)', () => { - mockReadFileSync.mockImplementation(() => { - const err = new Error('ENOENT') as NodeJS.ErrnoException - err.code = 'ENOENT' - throw err - }) - const result = checkSocketWrapperSetup('/home/user/.bashrc-missing') - expect(result).toBe(false) - }) -}) diff --git a/packages/cli/test/unit/commands/wrapper/cmd-wrapper.test.mts b/packages/cli/test/unit/commands/wrapper/cmd-wrapper.test.mts deleted file mode 100644 index 87459a25c..000000000 --- a/packages/cli/test/unit/commands/wrapper/cmd-wrapper.test.mts +++ /dev/null @@ -1,287 +0,0 @@ - -/** - * Unit tests for wrapper command. - * - * Tests the command that enables/disables the Socket npm/npx wrapper. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as FsModule from 'node:fs' -import type * as PathsModule from '../../../../src/constants/paths.mts' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - LOG_SYMBOLS: { - success: '✓', - fail: '✗', - }, - getDefaultLogger: () => mockLogger, -})) - -// Mock dependencies. -const mockAddSocketWrapper = vi.hoisted(() => vi.fn()) -const mockRemoveSocketWrapper = vi.hoisted(() => vi.fn()) -const mockCheckSocketWrapperSetup = vi.hoisted(() => - vi.fn().mockReturnValue(false), -) -const mockPostinstallWrapper = vi.hoisted(() => vi.fn()) -const mockExistsSync = vi.hoisted(() => vi.fn().mockReturnValue(true)) -const mockGetBashRcPath = vi.hoisted(() => - vi.fn().mockReturnValue('/home/user/.bashrc'), -) -const mockGetZshRcPath = vi.hoisted(() => - vi.fn().mockReturnValue('/home/user/.zshrc'), -) - -vi.mock('node:fs', async importOriginal => { - const actual = await importOriginal<typeof FsModule>() - return { - ...actual, - existsSync: mockExistsSync, - } -}) - -vi.mock('../../../../src/commands/wrapper/add-socket-wrapper.mts', () => ({ - addSocketWrapper: mockAddSocketWrapper, -})) - -vi.mock('../../../../src/commands/wrapper/remove-socket-wrapper.mts', () => ({ - removeSocketWrapper: mockRemoveSocketWrapper, -})) - -vi.mock( - '../../../../src/commands/wrapper/check-socket-wrapper-setup.mts', - () => ({ - checkSocketWrapperSetup: mockCheckSocketWrapperSetup, - }), -) - -vi.mock('../../../../src/commands/wrapper/postinstall-wrapper.mts', () => ({ - postinstallWrapper: mockPostinstallWrapper, -})) - -vi.mock('../../../../src/constants/paths.mts', async importOriginal => { - const actual = await importOriginal<typeof PathsModule>() - return { - ...actual, - getBashRcPath: mockGetBashRcPath, - getZshRcPath: mockGetZshRcPath, - } -}) - -// Import after mocks. -const { cmdWrapper } = - await import('../../../../src/commands/wrapper/cmd-wrapper.mts') - -describe('cmd-wrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockExistsSync.mockReturnValue(true) - mockCheckSocketWrapperSetup.mockReturnValue(false) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdWrapper.description).toBe( - 'Enable or disable the Socket npm/pnpm exec wrapper', - ) - }) - - it('should not be hidden', () => { - expect(cmdWrapper.hidden).toBe(false) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-wrapper.mts' } - const context = { parentName: 'socket' } - - it('should support --dry-run flag with enable', async () => { - await cmdWrapper.run(['on', '--dry-run'], importMeta, context) - - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should support --dry-run flag with disable', async () => { - await cmdWrapper.run(['off', '--dry-run'], importMeta, context) - - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('DryRun'), - ) - }) - - it('should fail without on/off argument', async () => { - await cmdWrapper.run([], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should fail with invalid argument', async () => { - await cmdWrapper.run(['invalid'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should fail with multiple arguments', async () => { - await cmdWrapper.run(['on', 'off'], importMeta, context) - - expect(process.exitCode).toBe(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should enable wrapper with "on" argument', async () => { - await cmdWrapper.run(['on'], importMeta, context) - - expect(mockAddSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should enable wrapper with "enable" argument', async () => { - await cmdWrapper.run(['enable'], importMeta, context) - - expect(mockAddSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should enable wrapper with "enabled" argument', async () => { - await cmdWrapper.run(['enabled'], importMeta, context) - - expect(mockAddSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should disable wrapper with "off" argument', async () => { - await cmdWrapper.run(['off'], importMeta, context) - - expect(mockRemoveSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - }) - - it('should disable wrapper with "disable" argument', async () => { - await cmdWrapper.run(['disable'], importMeta, context) - - expect(mockRemoveSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - }) - - it('should disable wrapper with "disabled" argument', async () => { - await cmdWrapper.run(['disabled'], importMeta, context) - - expect(mockRemoveSocketWrapper).toHaveBeenCalledTimes(2) - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - }) - - it('should skip already configured files when enabling', async () => { - mockCheckSocketWrapperSetup.mockReturnValue(true) - - await cmdWrapper.run(['on'], importMeta, context) - - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - }) - - it('should add wrapper to bashrc when it exists', async () => { - mockExistsSync.mockImplementation( - (path: string) => path === '/home/user/.bashrc', - ) - - await cmdWrapper.run(['on'], importMeta, context) - - expect(mockAddSocketWrapper).toHaveBeenCalledWith('/home/user/.bashrc') - }) - - it('should add wrapper to zshrc when it exists', async () => { - mockExistsSync.mockImplementation( - (path: string) => path === '/home/user/.zshrc', - ) - - await cmdWrapper.run(['on'], importMeta, context) - - expect(mockAddSocketWrapper).toHaveBeenCalledWith('/home/user/.zshrc') - }) - - it('should fail when no shell config files exist', async () => { - mockExistsSync.mockReturnValue(false) - - await cmdWrapper.run(['on'], importMeta, context) - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('bash profile'), - ) - }) - - it('should handle --postinstall argument', async () => { - await cmdWrapper.run(['--postinstall'], importMeta, context) - - expect(mockPostinstallWrapper).toHaveBeenCalled() - expect(mockAddSocketWrapper).not.toHaveBeenCalled() - expect(mockRemoveSocketWrapper).not.toHaveBeenCalled() - }) - - it('should output JSON when --json flag is set', async () => { - await cmdWrapper.run(['on', '--json'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"action"'), - ) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"enabled"'), - ) - }) - - it('should output markdown when --markdown flag is set', async () => { - await cmdWrapper.run(['on', '--markdown'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('# Socket Wrapper Enabled'), - ) - }) - - it('should show disabled status in JSON output', async () => { - await cmdWrapper.run(['off', '--json'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('"disabled"'), - ) - }) - - it('should show disabled status in markdown output', async () => { - await cmdWrapper.run(['off', '--markdown'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('# Socket Wrapper Disabled'), - ) - }) - - it('shows skipped-files section in markdown when files are already configured', async () => { - mockCheckSocketWrapperSetup.mockReturnValue(true) - - await cmdWrapper.run(['on', '--markdown'], importMeta, context) - - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('## Skipped Files'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/commands/wrapper/postinstall-wrapper.test.mts b/packages/cli/test/unit/commands/wrapper/postinstall-wrapper.test.mts deleted file mode 100644 index 87b37db39..000000000 --- a/packages/cli/test/unit/commands/wrapper/postinstall-wrapper.test.mts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Unit tests for postinstallWrapper. - * - * Purpose: Tests postinstall wrapper functionality. Validates automatic Socket - * scanning after package installation. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/postinstallWrapper.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -import { postinstallWrapper } from '../../../../src/commands/wrapper/postinstall-wrapper.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) -vi.mock('@socketsecurity/lib-stable/stdio/prompts', () => ({ - confirm: vi.fn(), -})) -vi.mock('../../../../src/commands/wrapper/add-socket-wrapper.mts', () => ({ - addSocketWrapper: vi.fn(), -})) -vi.mock( - '../../../../src/commands/wrapper/check-socket-wrapper-setup.mts', - () => ({ - checkSocketWrapperSetup: vi.fn(), - }), -) -vi.mock('../../../../src/constants/paths.mts', () => ({ - getBashRcPath: vi.fn(() => '/home/user/.bashrc'), - getZshRcPath: vi.fn(() => '/home/user/.zshrc'), -})) -vi.mock('../../../../src/util/cli/completion.mts', () => ({ - getBashrcDetails: vi.fn(), -})) -vi.mock('../../../../src/commands/install/setup-tab-completion.mts', () => ({ - updateInstalledTabCompletionScript: vi.fn(), -})) -vi.mock('../../../../src/util/error/errors.mts', () => ({ - getErrorCause: vi.fn(e => e?.message || String(e)), - FileSystemError: class FileSystemError extends Error { - public readonly path?: string | undefined - public readonly code?: string | undefined - public readonly recovery: string[] = [] - constructor( - message: string, - path?: string | undefined, - code?: string | undefined, - ) { - super(message) - this.name = 'FileSystemError' - this.path = path - this.code = code - } - }, -})) - -describe('postinstallWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('skips setup when wrapper already enabled in bashrc', async () => { - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockImplementation( - (path: string) => path === '/home/user/.bashrc', - ) - mockCheckSetup.mockReturnValue(true) - - await postinstallWrapper() - - expect(checkSocketWrapperSetup).toHaveBeenCalledWith('/home/user/.bashrc') - expect(confirm).not.toHaveBeenCalled() - }) - - it('skips setup when wrapper already enabled in zshrc', async () => { - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockImplementation( - (path: string) => path === '/home/user/.zshrc', - ) - mockCheckSetup.mockImplementation( - (path: string) => path === '/home/user/.zshrc', - ) - - await postinstallWrapper() - - expect(checkSocketWrapperSetup).toHaveBeenCalledWith('/home/user/.zshrc') - expect(confirm).not.toHaveBeenCalled() - }) - - it('prompts for setup when wrapper not enabled', async () => { - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - await import('@socketsecurity/lib-stable/logger') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - const mockConfirm = vi.mocked(confirm) - - mockExistsSync.mockReturnValue(false) - mockCheckSetup.mockReturnValue(false) - mockConfirm.mockResolvedValue(false) - - await postinstallWrapper() - - expect(confirm).toHaveBeenCalledWith({ - message: expect.stringContaining( - 'Do you want to install the Socket npm wrapper', - ), - default: true, - }) - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining( - 'Run `socket install completion` to setup bash tab completion', - ), - ) - }) - - it('sets up wrapper when user confirms for bashrc', async () => { - const { addSocketWrapper } = - await import('../../../../src/commands/wrapper/add-socket-wrapper.mts') - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - const mockConfirm = vi.mocked(confirm) - const mockAddWrapper = vi.mocked(addSocketWrapper) - - mockExistsSync.mockImplementation( - (path: string) => path === '/home/user/.bashrc', - ) - mockCheckSetup.mockReturnValue(false) - mockConfirm.mockResolvedValue(true) - - await postinstallWrapper() - - expect(addSocketWrapper).toHaveBeenCalledWith('/home/user/.bashrc') - }) - - it('sets up wrapper for both bashrc and zshrc when both exist', async () => { - const { addSocketWrapper } = - await import('../../../../src/commands/wrapper/add-socket-wrapper.mts') - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - const mockConfirm = vi.mocked(confirm) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(false) - mockConfirm.mockResolvedValue(true) - - await postinstallWrapper() - - expect(addSocketWrapper).toHaveBeenCalledWith('/home/user/.bashrc') - expect(addSocketWrapper).toHaveBeenCalledWith('/home/user/.zshrc') - }) - - it('handles error during wrapper setup', async () => { - const { addSocketWrapper } = - await import('../../../../src/commands/wrapper/add-socket-wrapper.mts') - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const { confirm } = await import('@socketsecurity/lib-stable/stdio/prompts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - const mockConfirm = vi.mocked(confirm) - const mockAddWrapper = vi.mocked(addSocketWrapper) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(false) - mockConfirm.mockResolvedValue(true) - mockAddWrapper.mockImplementation(() => { - throw new Error('Permission denied') - }) - - await expect(postinstallWrapper()).rejects.toThrow( - /failed to add socket aliases to .* \(Permission denied\)/, - ) - }) - - it('updates tab completion when it exists', async () => { - const { getBashrcDetails } = - await import('../../../../src/util/cli/completion.mts') - await import('@socketsecurity/lib-stable/logger') - const { updateInstalledTabCompletionScript } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const mockGetDetails = vi.mocked(getBashrcDetails) - const mockUpdateScript = vi.mocked(updateInstalledTabCompletionScript) - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(true) // Wrapper already setup. - mockGetDetails.mockReturnValue({ - ok: true, - data: { targetPath: '/home/user/.config/socket/tab-completion.bash' }, - } as unknown) - mockUpdateScript.mockReturnValue({ ok: true } as unknown) - - await postinstallWrapper() - - expect(updateInstalledTabCompletionScript).toHaveBeenCalledWith( - '/home/user/.config/socket/tab-completion.bash', - ) - expect(mockLogger.success).toHaveBeenCalledWith( - 'Updated the installed Socket tab completion script', - ) - }) - - it('skips tab completion update when file does not exist', async () => { - const { getBashrcDetails } = - await import('../../../../src/util/cli/completion.mts') - await import('@socketsecurity/lib-stable/logger') - const { updateInstalledTabCompletionScript } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const mockGetDetails = vi.mocked(getBashrcDetails) - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockImplementation( - (p: string) => p !== '/home/user/.config/socket/tab-completion.bash', - ) - mockCheckSetup.mockReturnValue(true) - mockGetDetails.mockReturnValue({ - ok: true, - data: { targetPath: '/home/user/.config/socket/tab-completion.bash' }, - } as unknown) - - await postinstallWrapper() - - expect(updateInstalledTabCompletionScript).not.toHaveBeenCalled() - expect(mockLogger.log).toHaveBeenCalledWith( - 'Run `socket install completion` to setup bash tab completion', - ) - }) - - it('handles tab completion update failure gracefully', async () => { - const { getBashrcDetails } = - await import('../../../../src/util/cli/completion.mts') - await import('@socketsecurity/lib-stable/logger') - const mockGetDetails = vi.mocked(getBashrcDetails) - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(true) - mockGetDetails.mockImplementation(() => { - throw new Error('Tab completion error') - }) - - await postinstallWrapper() - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Run `socket install completion` to setup bash tab completion', - ) - }) - - it('handles getBashrcDetails returning not ok', async () => { - const { getBashrcDetails } = - await import('../../../../src/util/cli/completion.mts') - await import('@socketsecurity/lib-stable/logger') - const mockGetDetails = vi.mocked(getBashrcDetails) - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(true) - mockGetDetails.mockReturnValue({ - ok: false, - message: 'Not found', - } as unknown) - - await postinstallWrapper() - - expect(mockLogger.log).toHaveBeenCalledWith( - 'Run `socket install completion` to setup bash tab completion', - ) - }) - - it('handles updateInstalledTabCompletionScript returning ok=false (line 44)', async () => { - const { getBashrcDetails } = - await import('../../../../src/util/cli/completion.mts') - await import('@socketsecurity/lib-stable/logger') - const { updateInstalledTabCompletionScript } = - await import('../../../../src/commands/install/setup-tab-completion.mts') - const mockGetDetails = vi.mocked(getBashrcDetails) - const mockUpdateScript = vi.mocked(updateInstalledTabCompletionScript) - const { checkSocketWrapperSetup } = - await import('../../../../src/commands/wrapper/check-socket-wrapper-setup.mts') - const mockCheckSetup = vi.mocked(checkSocketWrapperSetup) - - mockExistsSync.mockReturnValue(true) - mockCheckSetup.mockReturnValue(true) - mockGetDetails.mockReturnValue({ - ok: true, - data: { targetPath: '/home/user/.config/socket/tab-completion.bash' }, - } as unknown) - // Update script returns NOT ok → success log skipped, fallback log runs. - mockUpdateScript.mockReturnValue({ - ok: false, - message: 'Update failed', - } as unknown) - - await postinstallWrapper() - - // Should log the fallback message since updatedTabCompletion stays false. - expect(mockLogger.log).toHaveBeenCalledWith( - 'Run `socket install completion` to setup bash tab completion', - ) - // Should NOT log the success message. - expect(mockLogger.success).not.toHaveBeenCalledWith( - 'Updated the installed Socket tab completion script', - ) - }) -}) diff --git a/packages/cli/test/unit/commands/wrapper/remove-socket-wrapper.test.mts b/packages/cli/test/unit/commands/wrapper/remove-socket-wrapper.test.mts deleted file mode 100644 index 5c20d4691..000000000 --- a/packages/cli/test/unit/commands/wrapper/remove-socket-wrapper.test.mts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Unit tests for removeSocketWrapper. - * - * Purpose: Tests removing Socket wrapper scripts from package managers. - * Validates clean wrapper uninstallation. - * - * Test Coverage: - Core functionality validation - Edge case handling - Error - * scenarios - Input validation. - * - * Testing Approach: Comprehensive unit testing of module functionality with - * mocked dependencies where appropriate. - * - * Related Files: - src/removeSocketWrapper.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockReadFileSync = vi.hoisted(() => vi.fn()) -const mockWriteFileSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - default: { - readFileSync: mockReadFileSync, - writeFileSync: mockWriteFileSync, - }, -})) - -import { removeSocketWrapper } from '../../../../src/commands/wrapper/remove-socket-wrapper.mts' - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -describe('removeSocketWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - mockWriteFileSync.mockImplementation(() => undefined) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('successfully removes both aliases from file', async () => { - await import('@socketsecurity/lib-stable/logger') - - mockReadFileSync.mockReturnValue( - 'alias npm="socket npm"\nalias npx="socket npx"\nother content', - ) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.bashrc', 'utf8') - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.bashrc', - 'other content', - 'utf8', - ) - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('The alias was removed from /home/user/.bashrc'), - ) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('restart existing terminal sessions'), - ) - }) - - it('removes only socket aliases, leaving others intact', () => { - mockReadFileSync.mockReturnValue( - 'alias ll="ls -la"\nalias npm="socket npm"\nalias gs="git status"\nalias npx="socket npx"', - ) - - removeSocketWrapper('/home/user/.zshrc') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.zshrc', - 'alias ll="ls -la"\nalias gs="git status"', - 'utf8', - ) - }) - - it('handles read error gracefully', async () => { - await import('@socketsecurity/lib-stable/logger') - const readError = new Error('Permission denied') - - mockReadFileSync.mockImplementation(() => { - throw readError - }) - - removeSocketWrapper('/etc/protected-file') - - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('There was an error removing the alias'), - ) - expect(mockLogger.error).toHaveBeenCalledWith(readError) - expect(mockWriteFileSync).not.toHaveBeenCalled() - }) - - it('handles write error gracefully', async () => { - await import('@socketsecurity/lib-stable/logger') - const writeError = new Error('Disk full') - - mockReadFileSync.mockReturnValue('alias npm="socket npm"') - mockWriteFileSync.mockImplementation(() => { - throw writeError - }) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockLogger.error).toHaveBeenCalledWith(writeError) - expect(mockLogger.success).not.toHaveBeenCalled() - }) - - it('handles file with no socket aliases', async () => { - await import('@socketsecurity/lib-stable/logger') - - mockReadFileSync.mockReturnValue( - 'alias ll="ls -la"\nexport PATH=$PATH:/usr/local/bin', - ) - - removeSocketWrapper('/home/user/.bashrc') - - // When no socket aliases are removed, success message is still shown. - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.bashrc', - 'alias ll="ls -la"\nexport PATH=$PATH:/usr/local/bin', - 'utf8', - ) - // File is written successfully, so success is logged. - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('The alias was removed from /home/user/.bashrc'), - ) - }) - - it('preserves empty lines when removing aliases', () => { - mockReadFileSync.mockReturnValue( - '\nalias npm="socket npm"\n\nalias npx="socket npx"\n\nother content\n', - ) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.bashrc', - '\n\n\nother content\n', - 'utf8', - ) - }) - - it('handles empty file', async () => { - await import('@socketsecurity/lib-stable/logger') - - mockReadFileSync.mockReturnValue('') - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.bashrc', - '', - 'utf8', - ) - // File is written successfully, so success is logged. - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('The alias was removed from /home/user/.bashrc'), - ) - }) - - it('removes only exact matches', () => { - mockReadFileSync.mockReturnValue( - [ - 'alias npm="socket npm"', - 'alias npm2="socket npm"', - 'alias npx="socket npx"', - 'alias npx-extra="socket pnpm exec --extra"', - ].join('\n'), - ) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockWriteFileSync).toHaveBeenCalledWith( - '/home/user/.bashrc', - [ - 'alias npm2="socket npm"', - 'alias npx-extra="socket pnpm exec --extra"', - ].join('\n'), - 'utf8', - ) - }) - - it('handles undefined error in read catch', async () => { - await import('@socketsecurity/lib-stable/logger') - - mockReadFileSync.mockImplementation(() => { - throw undefined - }) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockLogger.fail).toHaveBeenCalledWith( - 'There was an error removing the alias.', - ) - expect(mockLogger.error).not.toHaveBeenCalled() - }) - - it('handles undefined error in write catch', async () => { - await import('@socketsecurity/lib-stable/logger') - - mockReadFileSync.mockReturnValue('alias npm="socket npm"') - mockWriteFileSync.mockImplementation(() => { - throw undefined - }) - - removeSocketWrapper('/home/user/.bashrc') - - expect(mockLogger.error).not.toHaveBeenCalled() - expect(mockLogger.success).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/commands/yarn/cmd-yarn.test.mts b/packages/cli/test/unit/commands/yarn/cmd-yarn.test.mts deleted file mode 100644 index 33a8943a6..000000000 --- a/packages/cli/test/unit/commands/yarn/cmd-yarn.test.mts +++ /dev/null @@ -1,545 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for yarn wrapper command. - * - * Tests the command entry point that wraps yarn with Socket Firewall security. - * The wrapper intercepts yarn commands and forwards them to Socket Firewall - * (sfw) for real-time security scanning. - * - * Test Coverage: - Command metadata (description, visibility) - Help text - * display - Dry-run behavior - Flag filtering (Socket CLI vs yarn flags) - - * Subprocess spawning and exit handling - Telemetry tracking - Error handling. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type { EventEmitter } from 'node:events' - -// Mock the logger. -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock spawnSfwDlx. -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfwDlx: mockSpawnSfwDlx, -})) - -// Mock telemetry functions. -const mockTrackSubprocessExit = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessStart = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackSubprocessExit: mockTrackSubprocessExit, - trackSubprocessStart: mockTrackSubprocessStart, -})) - -// Import after mocks. -const { cmdYarn } = await import('../../../../src/commands/yarn/cmd-yarn.mts') -const { YARN } = await import('@socketsecurity/lib-stable/constants/agents') - -describe('cmd-yarn', () => { - interface MockChildProcess extends Partial<EventEmitter> { - pid: number - } - - const mockChildProcess: MockChildProcess = { - on: vi.fn(), - pid: 12345, - } - - const createMockSpawnResult = (exitCode = 0, signal?: string) => { - const result = { - code: signal ? undefined : exitCode, - signal, - success: exitCode === 0 && !signal, - } - const spawnPromise = Promise.resolve(result) - Object.assign(spawnPromise, { process: mockChildProcess }) - return { spawnPromise } - } - - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - mockTrackSubprocessStart.mockResolvedValue(Date.now()) - mockTrackSubprocessExit.mockResolvedValue(undefined) - }) - - describe('command metadata', () => { - it('should have correct description', () => { - expect(cmdYarn.description).toBe('Run yarn with Socket Firewall security') - }) - - it('should be hidden', () => { - expect(cmdYarn.hidden).toBe(true) - }) - }) - - describe('run', () => { - const importMeta = { url: 'file:///test/cmd-yarn.mts' } - const context = { parentName: 'socket' } - - describe('help flag', () => { - it('should display help text with --help flag', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await expect( - cmdYarn.run(['--help'], importMeta, context), - ).rejects.toThrow() - - // Help should exit before spawning. - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - }) - }) - - describe('dry-run behavior', () => { - it('should show dry-run output without executing', async () => { - await cmdYarn.run(['--dry-run'], importMeta, context) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - - // Verify dry-run message. - const logCalls = mockLogger.error.mock.calls.flat() - const hasDryRunMessage = logCalls.some( - call => typeof call === 'string' && call.includes('Would execute'), - ) - expect(hasDryRunMessage).toBe(true) - }) - - it('should show dry-run output with yarn install command', async () => { - await cmdYarn.run( - ['--dry-run', 'install', 'lodash'], - importMeta, - context, - ) - - expect(mockLogger.error).toHaveBeenCalled() - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - - // Verify dry-run includes arguments. - const logCalls = mockLogger.error.mock.calls.flat() - const hasArgs = logCalls.some( - call => - typeof call === 'string' && - (call.includes('install') || call.includes('lodash')), - ) - expect(hasArgs).toBe(true) - }) - - it('should filter Socket flags in dry-run output', async () => { - await cmdYarn.run( - ['--dry-run', '--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // Should not spawn. - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - }) - }) - - describe('flag filtering', () => { - it('should filter out --dry-run flag when forwarding to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Verify sfw was called with filtered flags. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out --config flag when forwarding to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run( - ['--config', '{}', 'install', 'lodash'], - importMeta, - context, - ) - - // --config should be filtered out. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should filter out multiple Socket CLI flags', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run( - ['--config', '{}', '--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // Both --config and --no-banner should be filtered. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should preserve yarn flags while filtering Socket flags', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run( - ['--config', '{}', 'install', '--dev', 'lodash'], - importMeta, - context, - ) - - // yarn's --dev should be preserved. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', '--dev', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should handle --no-banner flag', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run( - ['--no-banner', 'install', 'lodash'], - importMeta, - context, - ) - - // --no-banner should be filtered. - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('command structure', () => { - it('should forward yarn install command to sfw', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn add command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['add', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'add', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn install with version specifier', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['add', 'lodash@4.17.21'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'add', 'lodash@4.17.21'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn global add command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['global', 'add', 'cowsay'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'global', 'add', 'cowsay'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn remove command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['remove', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'remove', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn upgrade command', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['upgrade', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'upgrade', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - - it('should forward yarn with no arguments', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run([], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['yarn'], { - stdio: 'inherit', - }) - }) - - it('should forward yarn add with multiple packages', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run( - ['add', 'lodash', 'express', 'react'], - importMeta, - context, - ) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'add', 'lodash', 'express', 'react'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('exit handling', () => { - it('should set initial exitCode to 1', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Should set exitCode to 1 initially (before subprocess completes). - expect(process.exitCode).toBe(1) - }) - - it('should register exit event handler on child process', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Should register 'exit' event handler. - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'exit', - expect.any(Function), - ) - }) - - it('should use stdio inherit for process spawning', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['yarn', 'install', 'lodash'], - { - stdio: 'inherit', - }, - ) - }) - }) - - describe('telemetry tracking', () => { - it('should track subprocess start', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalledWith(YARN) - }) - - it('should track subprocess start before spawning', async () => { - let trackCalled = false - mockTrackSubprocessStart.mockImplementation(async () => { - trackCalled = true - return Date.now() - }) - - mockSpawnSfwDlx.mockImplementation(async () => { - expect(trackCalled).toBe(true) - return createMockSpawnResult(0) - }) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - expect(mockTrackSubprocessStart).toHaveBeenCalled() - }) - }) - - describe('exit handler callback', () => { - let exitHandler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void - let mockProcessKill: ReturnType<typeof vi.fn> - let mockProcessExit: ReturnType<typeof vi.fn> - - beforeEach(() => { - // Capture the exit handler when it's registered. - ;(mockChildProcess.on as ReturnType<typeof vi.fn>).mockImplementation( - ( - event: string, - handler: ( - code: number | null, - signal: NodeJS.Signals | null, - ) => void, - ) => { - if (event === 'exit') { - exitHandler = handler - } - }, - ) - // Mock process.kill and process.exit. - mockProcessKill = vi.fn() - mockProcessExit = vi.fn() - vi.stubGlobal('process', { - ...process, - kill: mockProcessKill, - exit: mockProcessExit, - pid: process.pid, - exitCode: undefined, - }) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should call process.exit with numeric exit code', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(42, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - YARN, - expect.any(Number), - 42, - ) - expect(mockProcessExit).toHaveBeenCalledWith(42) - }) - - it('should call process.kill with signal', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a signal. - exitHandler(undefined, 'SIGTERM') - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith( - YARN, - expect.any(Number), - undefined, - ) - expect(mockProcessKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - }) - - it('should exit even if telemetry fails', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockRejectedValue(new Error('Telemetry failed')) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler with a numeric code. - exitHandler(1, undefined) - - // Wait for telemetry promise to reject and catch handler to run. - await new Promise(resolve => setTimeout(resolve, 10)) - - // Should still exit even though telemetry failed. - expect(mockProcessExit).toHaveBeenCalledWith(1) - }) - - it('skips exit/kill when both code and signal are null', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - exitHandler(undefined, undefined) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(mockProcessExit).not.toHaveBeenCalled() - expect(mockProcessKill).not.toHaveBeenCalled() - }) - - it('should track subprocess exit with code', async () => { - mockSpawnSfwDlx.mockResolvedValue(createMockSpawnResult(0)) - const startTime = 12345 - mockTrackSubprocessStart.mockResolvedValue(startTime) - mockTrackSubprocessExit.mockResolvedValue(undefined) - - await cmdYarn.run(['install', 'lodash'], importMeta, context) - - // Invoke the exit handler. - exitHandler(0, undefined) - - // Wait for telemetry promise to resolve. - await new Promise(resolve => setTimeout(resolve, 10)) - - expect(mockTrackSubprocessExit).toHaveBeenCalledWith(YARN, startTime, 0) - }) - }) - - describe('command name constant', () => { - it('should use YARN constant as command name', async () => { - const { CMD_NAME } = - await import('../../../../src/commands/yarn/cmd-yarn.mts') - expect(CMD_NAME).toBe(YARN) - expect(CMD_NAME).toBe('yarn') - }) - }) - }) -}) diff --git a/packages/cli/test/unit/constants-barrel.test.mts b/packages/cli/test/unit/constants-barrel.test.mts deleted file mode 100644 index 9eef6ea58..000000000 --- a/packages/cli/test/unit/constants-barrel.test.mts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Unit tests for constants barrel file. - * - * Purpose: Tests the constants barrel export file to ensure all exports work - * correctly. - * - * Test Coverage: - Named exports - Default export. - * - * Related Files: - src/constants.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - FLAG_DRY_RUN, - FLAG_JSON, - FLAG_ORG, - LOOP_SENTINEL, - NPM, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - PNPM, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_PACKAGE_NAME, - VITEST, - YARN, - constants, - getCliVersion, -} from '../../src/constants.mts' - -describe('constants barrel exports', () => { - describe('named exports', () => { - it('exports flag constants', () => { - // Flags include the -- prefix. - expect(FLAG_DRY_RUN).toBe('--dry-run') - expect(FLAG_JSON).toBe('--json') - expect(FLAG_ORG).toBe('--org') - }) - - it('exports output format constants', () => { - expect(OUTPUT_JSON).toBe('json') - expect(OUTPUT_MARKDOWN).toBe('markdown') - expect(OUTPUT_TEXT).toBe('text') - }) - - it('exports agent constants', () => { - expect(NPM).toBe('npm') - expect(PNPM).toBe('pnpm') - expect(YARN).toBe('yarn') - }) - - it('exports error constants', () => { - expect(typeof LOOP_SENTINEL).toBe('number') - }) - - it('exports package name constants', () => { - expect(SOCKET_CLI_BIN_NAME).toBe('socket') - // SOCKET_CLI_PACKAGE_NAME is the npm package name 'socket'. - expect(SOCKET_CLI_PACKAGE_NAME).toBe('socket') - }) - - it('exports VITEST constant', () => { - expect(VITEST).toBe(true) - }) - - it('exports getCliVersion function', () => { - expect(typeof getCliVersion).toBe('function') - }) - }) - - describe('default export', () => { - it('exports an object with constants', () => { - expect(typeof constants).toBe('object') - }) - - it('includes flag constants', () => { - expect(constants.FLAG_DRY_RUN).toBe('--dry-run') - expect(constants.FLAG_JSON).toBe('--json') - }) - - it('includes output format constants', () => { - expect(constants.OUTPUT_JSON).toBe('json') - expect(constants.OUTPUT_MARKDOWN).toBe('markdown') - }) - - it('includes agent constants', () => { - expect(constants.NPM).toBe('npm') - expect(constants.PNPM).toBe('pnpm') - }) - - it('includes ENV object', () => { - expect(typeof constants.ENV).toBe('object') - }) - - it('includes getCliVersion function', () => { - expect(typeof constants.getCliVersion).toBe('function') - }) - }) -}) diff --git a/packages/cli/test/unit/constants-root-barrel.test.mts b/packages/cli/test/unit/constants-root-barrel.test.mts deleted file mode 100644 index 57a899baf..000000000 --- a/packages/cli/test/unit/constants-root-barrel.test.mts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * Unit tests for root constants barrel file. - * - * Purpose: Tests the root constants.mts barrel file exports. - * - * Test Coverage: - * - * - Named exports verification - * - Default export verification - * - Key constants values - * - * Related Files: - * - * - Src/constants.mts (implementation) - * - Src/constants/*.mts (source modules) - */ - -import { describe, expect, it } from 'vitest' - -// oxlint-disable-next-line socket/sort-named-imports -- imports are intentionally grouped by domain (Agent / Alert / Cache / CLI / Config / ...) with section comments; alpha-sort would erase the documented grouping. -import { - constants, - // Agent constants. - BUN, - getMinimumVersionByAgent, - getNpmExecPath, - getPnpmExecPath, - NPM, - NPX, - PNPM, - VLT, - YARN, - YARN_BERRY, - YARN_CLASSIC, - // Alert type constants. - ALERT_TYPE_CRITICAL_CVE, - ALERT_TYPE_CVE, - ALERT_TYPE_MEDIUM_CVE, - ALERT_TYPE_MILD_CVE, - // Cache constants. - DLX_BINARY_CACHE_TTL, - UPDATE_CHECK_TTL, - UPDATE_NOTIFIER_TIMEOUT, - // CLI constants. - DRY_RUN_BAILING_NOW, - DRY_RUN_LABEL, - DRY_RUN_NOT_SAVING, - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_JSON, - FLAG_ORG, - FLAG_QUIET, - FLAG_VERBOSE, - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - SEA_UPDATE_COMMAND, - // Config constants. - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, - // Error constants. - ERROR_NO_MANIFEST_FILES, - ERROR_NO_PACKAGE_JSON, - ERROR_NO_REPO_FOUND, - ERROR_NO_SOCKET_DIR, - ERROR_UNABLE_RESOLVE_ORG, - LOOP_SENTINEL, - // GitHub constants. - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, - SOCKET_CLI_GITHUB_REPO, - // HTTP constants. - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, - NPM_REGISTRY_URL, - // Package constants. - EXT_LOCK, - EXT_LOCKB, - NODE_MODULES, - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_PACKAGE_NAME, - YARN_LOCK, - // Path constants. - configPath, - distPath, - execPath, - externalPath, - getCliVersion, - homePath, - rootPath, - srcPath, - // Socket constants. - API_V0_URL, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, - SOCKET_CLI_ISSUES_URL, - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, - SOCKET_WEBSITE_URL, - TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH, - // Type constants. - WIN32, - // Env constants. - VITEST, -} from '../../src/constants.mts' - -describe('constants root barrel exports', () => { - describe('agent constants', () => { - it('exports agent name constants', () => { - expect(NPM).toBe('npm') - expect(NPX).toBe('npx') - expect(PNPM).toBe('pnpm') - expect(YARN).toBe('yarn') - expect(BUN).toBe('bun') - expect(VLT).toBe('vlt') - expect(YARN_CLASSIC).toBe('yarn/classic') - expect(YARN_BERRY).toBe('yarn/berry') - }) - - it('exports agent utility functions', () => { - expect(typeof getMinimumVersionByAgent).toBe('function') - expect(typeof getNpmExecPath).toBe('function') - expect(typeof getPnpmExecPath).toBe('function') - }) - }) - - describe('alert type constants', () => { - it('exports alert types', () => { - expect(ALERT_TYPE_CVE).toBe('cve') - expect(ALERT_TYPE_CRITICAL_CVE).toBe('criticalCVE') - expect(ALERT_TYPE_MEDIUM_CVE).toBe('mediumCVE') - expect(ALERT_TYPE_MILD_CVE).toBe('mildCVE') - }) - }) - - describe('cache constants', () => { - it('exports cache TTL values as numbers', () => { - expect(typeof DLX_BINARY_CACHE_TTL).toBe('number') - expect(typeof UPDATE_CHECK_TTL).toBe('number') - expect(typeof UPDATE_NOTIFIER_TIMEOUT).toBe('number') - }) - - it('cache TTLs are positive', () => { - expect(DLX_BINARY_CACHE_TTL).toBeGreaterThan(0) - expect(UPDATE_CHECK_TTL).toBeGreaterThan(0) - expect(UPDATE_NOTIFIER_TIMEOUT).toBeGreaterThan(0) - }) - }) - - describe('CLI flag constants', () => { - it('exports flag constants with -- prefix', () => { - expect(FLAG_DRY_RUN).toBe('--dry-run') - expect(FLAG_HELP).toBe('--help') - expect(FLAG_JSON).toBe('--json') - expect(FLAG_ORG).toBe('--org') - expect(FLAG_QUIET).toBe('--quiet') - expect(FLAG_VERBOSE).toBe('--verbose') - expect(FLAG_CONFIG).toBe('--config') - }) - - it('exports output format constants', () => { - expect(OUTPUT_JSON).toBe('json') - expect(OUTPUT_MARKDOWN).toBe('markdown') - expect(OUTPUT_TEXT).toBe('text') - }) - - it('exports fold setting constants', () => { - expect(typeof FOLD_SETTING_FILE).toBe('string') - expect(typeof FOLD_SETTING_NONE).toBe('string') - expect(typeof FOLD_SETTING_PKG).toBe('string') - expect(typeof FOLD_SETTING_VERSION).toBe('string') - }) - - it('exports dry run message constants', () => { - expect(typeof DRY_RUN_BAILING_NOW).toBe('string') - expect(typeof DRY_RUN_LABEL).toBe('string') - expect(typeof DRY_RUN_NOT_SAVING).toBe('string') - }) - - it('exports SEA update command', () => { - expect(SEA_UPDATE_COMMAND).toBe('self-update') - }) - }) - - describe('config key constants', () => { - it('exports config key constants', () => { - expect(CONFIG_KEY_API_BASE_URL).toBe('apiBaseUrl') - expect(CONFIG_KEY_API_PROXY).toBe('apiProxy') - expect(CONFIG_KEY_API_TOKEN).toBe('apiToken') - expect(CONFIG_KEY_DEFAULT_ORG).toBe('defaultOrg') - expect(CONFIG_KEY_ENFORCED_ORGS).toBe('enforcedOrgs') - expect(CONFIG_KEY_ORG).toBe('org') - }) - }) - - describe('error constants', () => { - it('exports error message constants', () => { - expect(typeof ERROR_NO_MANIFEST_FILES).toBe('string') - expect(typeof ERROR_NO_PACKAGE_JSON).toBe('string') - expect(typeof ERROR_NO_REPO_FOUND).toBe('string') - expect(typeof ERROR_NO_SOCKET_DIR).toBe('string') - expect(typeof ERROR_UNABLE_RESOLVE_ORG).toBe('string') - }) - - it('exports loop sentinel as a number', () => { - expect(typeof LOOP_SENTINEL).toBe('number') - expect(LOOP_SENTINEL).toBeGreaterThan(0) - }) - }) - - describe('GitHub constants', () => { - it('exports GraphQL pagination sentinel', () => { - expect(typeof GQL_PAGE_SENTINEL).toBe('number') - }) - - it('exports PR state constants', () => { - expect(GQL_PR_STATE_OPEN).toBe('OPEN') - expect(GQL_PR_STATE_CLOSED).toBe('CLOSED') - expect(GQL_PR_STATE_MERGED).toBe('MERGED') - }) - - it('exports GitHub repo name', () => { - expect(SOCKET_CLI_GITHUB_REPO).toBe('socket-cli') - }) - }) - - describe('HTTP constants', () => { - it('exports HTTP status codes', () => { - expect(HTTP_STATUS_BAD_REQUEST).toBe(400) - expect(HTTP_STATUS_UNAUTHORIZED).toBe(401) - expect(HTTP_STATUS_FORBIDDEN).toBe(403) - expect(HTTP_STATUS_NOT_FOUND).toBe(404) - expect(HTTP_STATUS_TOO_MANY_REQUESTS).toBe(429) - expect(HTTP_STATUS_INTERNAL_SERVER_ERROR).toBe(500) - }) - - it('exports npm registry URL', () => { - expect(NPM_REGISTRY_URL).toBe('https://registry.npmjs.org') - }) - }) - - describe('package constants', () => { - it('exports file name constants', () => { - expect(PACKAGE_JSON).toBe('package.json') - expect(PACKAGE_LOCK_JSON).toBe('package-lock.json') - expect(PNPM_LOCK_YAML).toBe('pnpm-lock.yaml') - expect(YARN_LOCK).toBe('yarn.lock') - expect(NODE_MODULES).toBe('node_modules') - }) - - it('exports extension constants', () => { - expect(EXT_LOCK).toBe('.lock') - expect(EXT_LOCKB).toBe('.lockb') - }) - - it('exports socket CLI name constants', () => { - expect(SOCKET_CLI_BIN_NAME).toBe('socket') - expect(SOCKET_CLI_PACKAGE_NAME).toBe('socket') - }) - }) - - describe('path constants', () => { - it('exports path values as strings', () => { - expect(typeof configPath).toBe('string') - expect(typeof distPath).toBe('string') - expect(typeof execPath).toBe('string') - expect(typeof externalPath).toBe('string') - expect(typeof homePath).toBe('string') - expect(typeof rootPath).toBe('string') - expect(typeof srcPath).toBe('string') - }) - - it('exports getCliVersion function', () => { - expect(typeof getCliVersion).toBe('function') - }) - }) - - describe('Socket API constants', () => { - it('exports API URL', () => { - expect(API_V0_URL).toBe('https://api.socket.dev/v0/') - }) - - it('exports scan type constants', () => { - expect(SCAN_TYPE_SOCKET).toBe('socket') - expect(SCAN_TYPE_SOCKET_TIER1).toBe('socket_tier1') - }) - - it('exports Socket website constants', () => { - expect(SOCKET_WEBSITE_URL).toBe('https://socket.dev') - expect(SOCKET_CLI_ISSUES_URL).toBe( - 'https://github.com/SocketDev/socket-cli/issues', - ) - }) - - it('exports default branch and repository', () => { - // These are config key-like identifiers, not actual values. - expect(typeof SOCKET_DEFAULT_BRANCH).toBe('string') - expect(typeof SOCKET_DEFAULT_REPOSITORY).toBe('string') - }) - - it('exports token constants', () => { - expect(TOKEN_PREFIX).toBe('sktsec_') - expect(TOKEN_PREFIX_LENGTH).toBe(7) - }) - }) - - describe('type constants', () => { - it('exports WIN32 constant', () => { - // WIN32 is process.platform === 'win32' boolean on non-Windows, string on Windows. - expect(typeof WIN32 === 'boolean' || typeof WIN32 === 'string').toBe(true) - }) - }) - - describe('env constants', () => { - it('exports VITEST as true in test environment', () => { - expect(VITEST).toBe(true) - }) - }) - - describe('default export', () => { - it('exports an object with constants', () => { - expect(typeof constants).toBe('object') - expect(constants).not.toBeNull() - }) - - it('includes ENV object', () => { - expect(constants.ENV).toBeDefined() - expect(typeof constants.ENV).toBe('object') - }) - - it('includes key constants', () => { - expect(constants.NPM).toBe('npm') - expect(constants.PNPM).toBe('pnpm') - expect(constants.YARN).toBe('yarn') - expect(constants.OUTPUT_JSON).toBe('json') - expect(constants.OUTPUT_TEXT).toBe('text') - }) - - it('includes path constants', () => { - expect(typeof constants.rootPath).toBe('string') - expect(typeof constants.distPath).toBe('string') - expect(typeof constants.srcPath).toBe('string') - }) - - it('includes getter functions', () => { - expect(typeof constants.getCliVersion).toBe('function') - expect(typeof constants.getMinimumVersionByAgent).toBe('function') - }) - }) -}) diff --git a/packages/cli/test/unit/constants.test.mts b/packages/cli/test/unit/constants.test.mts deleted file mode 100644 index 602fdda84..000000000 --- a/packages/cli/test/unit/constants.test.mts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Unit tests for CLI constants module. - * - * Tests the central constants module that provides paths, URLs, flags, and - * environment configuration throughout the Socket CLI application. - * - * Test Coverage: - * - * - Core properties (rootPath, distPath, homePath, WIN32 platform flag) - * - Path properties validation (correct directory structure) - * - URL defaults (API_V0_URL, NPM_REGISTRY_URL, SOCKET_PUBLIC_API_TOKEN) - * - Environment variable overrides (via ENV object) - * - Command constants (NPM, NPX, PNPM, YARN, NODE_MODULES, PACKAGE_JSON) - * - Flag constants (FLAG_QUIET, FLAG_SILENT, FLAG_VERSION, FLAG_HELP, FLAG_JSON, - * etc.) - * - Encoding constants (UTF8) - * - Socket-specific constants (SOCKET_CLI_ISSUES_URL, SOCKET_DEFAULT_BRANCH) - * - Socket file constants (SOCKET_JSON, SOCKET_YAML, SOCKET_YML) - * - * Testing Approach: - * - * - Mock environment variables using vi.stubEnv before module import - * - Dynamic imports to test module loading with different env states - * - Property existence and type validation - * - * Related Files: - * - * - Src/constants.mts - Main constants module - * - Src/constants/env.mts - Environment variable configuration - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { ENV } from '../../src/constants/env.mts' - -// Mock environment variables before importing constants. -vi.stubEnv('SOCKET_API_BASE_URL', '') -vi.stubEnv('SOCKET_API_TOKEN', '') -vi.stubEnv('SOCKET_API_PROXY', '') -vi.stubEnv('SOCKET_CDN_BASE_URL', '') -vi.stubEnv('SOCKET_ISSUES_BASE_URL', '') -vi.stubEnv('SOCKET_NPM_REGISTRY', '') -vi.stubEnv('SOCKET_SEARCH_BASE_URL', '') - -describe('constants', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - }) - - it('exports expected properties', async () => { - const constants = (await import('../../src/constants.mts')).constants - - // Check for basic properties. - expect(constants).toHaveProperty('rootPath') - expect(constants).toHaveProperty('distPath') - expect(constants).toHaveProperty('homePath') - - // Check for platform properties. - expect(constants).toHaveProperty('WIN32') - expect(typeof constants.WIN32).toBe('boolean') - - // Check for URL properties. - expect(constants).toHaveProperty('API_V0_URL') - expect(constants).toHaveProperty('NPM_REGISTRY_URL') - expect(constants).toHaveProperty('SOCKET_PUBLIC_API_TOKEN') - - // Check for environment object. - expect(constants).toHaveProperty('ENV') - expect(typeof ENV).toBe('object') - }) - - it('has correct path properties', async () => { - const constants = (await import('../../src/constants.mts')).constants - - // rootPath should be the parent of src directory. - expect(constants.rootPath).toContain('socket-cli') - expect(constants.rootPath).not.toContain('/src') - - // distPath should be dist directory. - expect(constants.distPath).toMatch(/dist$/) - - // homePath should exist. - expect(constants.homePath).toBeDefined() - expect(typeof constants.homePath).toBe('string') - }) - - it('has correct URL defaults', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(constants.API_V0_URL).toBe('https://api.socket.dev/v0/') - expect(constants.NPM_REGISTRY_URL).toBe('https://registry.npmjs.org') - expect(constants.SOCKET_PUBLIC_API_TOKEN).toBe( - 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api', - ) - }) - - it('has correct command constants', async () => { - const constants = (await import('../../src/constants.mts')).constants - - // Package managers. - expect(constants.NPM).toBe('npm') - expect(constants.NPX).toBe('npx') - expect(constants.PNPM).toBe('pnpm') - expect(constants.YARN).toBe('yarn') - - // Common strings. - expect(constants.NODE_MODULES).toBe('node_modules') - expect(constants.PACKAGE_JSON).toBe('package.json') - }) - - it('has correct flag constants', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(constants.FLAG_QUIET).toBe('--quiet') - expect(constants.FLAG_SILENT).toBe('--silent') - expect(constants.FLAG_VERSION).toBe('--version') - expect(constants.FLAG_HELP).toBe('--help') - expect(constants.FLAG_JSON).toBe('--json') - expect(constants.FLAG_MARKDOWN).toBe('--markdown') - }) - - it('has correct encoding constants', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(constants.UTF8).toBe('utf8') - }) - - it('has correct socket-specific constants', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(constants.SOCKET_CLI_ISSUES_URL).toBe( - 'https://github.com/SocketDev/socket-cli/issues', - ) - expect(constants.SOCKET_DEFAULT_BRANCH).toBe('socket-default-branch') - expect(constants.SOCKET_DEFAULT_REPOSITORY).toBe( - 'socket-default-repository', - ) - }) - - it('has various constant flags', async () => { - const constants = (await import('../../src/constants.mts')).constants - - // Check for some known flags. - expect(constants.FLAG_CONFIG).toBe('--config') - expect(constants.FLAG_DRY_RUN).toBe('--dry-run') - expect(constants.FLAG_ORG).toBe('--org') - expect(constants.FLAG_PROD).toBe('--prod') - }) - - it('has socket file constants', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(constants.SOCKET_JSON).toBe('socket.json') - expect(constants.SOCKET_YAML).toBe('socket.yaml') - expect(constants.SOCKET_YML).toBe('socket.yml') - }) - - it('ENV object contains expected environment variables', async () => { - const constants = (await import('../../src/constants.mts')).constants - - expect(ENV).toBeDefined() - expect(typeof ENV).toBe('object') - expect(ENV).toHaveProperty('NODE_OPTIONS') - }) -}) diff --git a/packages/cli/test/unit/constants/agents.test.mts b/packages/cli/test/unit/constants/agents.test.mts deleted file mode 100644 index 59badb2f9..000000000 --- a/packages/cli/test/unit/constants/agents.test.mts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Unit tests for agent constants. - * - * Purpose: Tests the agent-specific constants and utility functions. - * - * Test Coverage: - Agent name constants - Minimum version by agent - Execution - * path functions. - * - * Related Files: - constants/agents.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies using hoisted mocks. -const mockWhichReal = vi.hoisted(() => vi.fn()) -const mockExistsSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: mockWhichReal, -})) - -import { - BUN, - NPM, - NPX, - PNPM, - VLT, - YARN, - YARN_BERRY, - YARN_CLASSIC, - getMinimumVersionByAgent, - getNpmExecPath, - getPnpmExecPath, -} from '../../../src/constants/agents.mts' - -describe('agents constants', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('agent name constants', () => { - it('has BUN constant', () => { - expect(BUN).toBe('bun') - }) - - it('has NPM constant', () => { - expect(NPM).toBe('npm') - }) - - it('has NPX constant', () => { - expect(NPX).toBe('npx') - }) - - it('has PNPM constant', () => { - expect(PNPM).toBe('pnpm') - }) - - it('has VLT constant', () => { - expect(VLT).toBe('vlt') - }) - - it('has YARN constant', () => { - expect(YARN).toBe('yarn') - }) - - it('has YARN_BERRY constant', () => { - expect(YARN_BERRY).toBe('yarn/berry') - }) - - it('has YARN_CLASSIC constant', () => { - expect(YARN_CLASSIC).toBe('yarn/classic') - }) - }) - - describe('getMinimumVersionByAgent', () => { - it('returns minimum version for BUN', () => { - const result = getMinimumVersionByAgent('bun') - expect(result).toBe('1.1.39') - }) - - it('returns minimum version for NPM', () => { - const result = getMinimumVersionByAgent('npm') - expect(result).toBe('10.8.2') - }) - - it('returns minimum version for PNPM', () => { - const result = getMinimumVersionByAgent('pnpm') - expect(result).toBe('8.15.7') - }) - - it('returns minimum version for YARN_BERRY', () => { - const result = getMinimumVersionByAgent('yarn/berry') - expect(result).toBe('4.0.0') - }) - - it('returns minimum version for YARN_CLASSIC', () => { - const result = getMinimumVersionByAgent('yarn/classic') - expect(result).toBe('1.22.22') - }) - - it('returns * for VLT (any version)', () => { - const result = getMinimumVersionByAgent('vlt') - expect(result).toBe('*') - }) - - it('returns * for unknown agent', () => { - const result = getMinimumVersionByAgent('unknown' as unknown) - expect(result).toBe('*') - }) - }) - - describe('getNpmExecPath', () => { - it('returns npm path from node directory if exists', async () => { - mockExistsSync.mockReturnValue(true) - - const result = await getNpmExecPath() - - expect(result).toContain('npm') - expect(mockWhichReal).not.toHaveBeenCalled() - }) - - it('falls back to whichReal if npm not in node directory', async () => { - mockExistsSync.mockReturnValue(false) - mockWhichReal.mockResolvedValue('/usr/bin/npm') - - const result = await getNpmExecPath() - - expect(result).toBe('/usr/bin/npm') - expect(mockWhichReal).toHaveBeenCalledWith('npm', { nothrow: true }) - }) - - it('handles array result from whichReal', async () => { - mockExistsSync.mockReturnValue(false) - mockWhichReal.mockResolvedValue(['/usr/local/bin/npm', '/usr/bin/npm']) - - const result = await getNpmExecPath() - - expect(result).toBe('/usr/local/bin/npm') - }) - - it('returns "npm" if whichReal returns null', async () => { - mockExistsSync.mockReturnValue(false) - mockWhichReal.mockResolvedValue(undefined) - - const result = await getNpmExecPath() - - expect(result).toBe('npm') - }) - }) - - describe('getPnpmExecPath', () => { - it('returns pnpm path from whichReal', async () => { - mockWhichReal.mockResolvedValue('/usr/bin/pnpm') - - const result = await getPnpmExecPath() - - expect(result).toBe('/usr/bin/pnpm') - expect(mockWhichReal).toHaveBeenCalledWith('pnpm', { nothrow: true }) - }) - - it('handles array result from whichReal', async () => { - mockWhichReal.mockResolvedValue(['/usr/local/bin/pnpm', '/usr/bin/pnpm']) - - const result = await getPnpmExecPath() - - expect(result).toBe('/usr/local/bin/pnpm') - }) - - it('returns "pnpm" if whichReal returns null', async () => { - mockWhichReal.mockResolvedValue(undefined) - - const result = await getPnpmExecPath() - - expect(result).toBe('pnpm') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/alerts.test.mts b/packages/cli/test/unit/constants/alerts.test.mts deleted file mode 100644 index 569d426bd..000000000 --- a/packages/cli/test/unit/constants/alerts.test.mts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Unit tests for alert constants. - * - * Purpose: Tests the security alert type constants. - * - * Test Coverage: - Alert type constant values. - * - * Related Files: - constants/alerts.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - ALERT_TYPE_CRITICAL_CVE, - ALERT_TYPE_CVE, - ALERT_TYPE_MEDIUM_CVE, - ALERT_TYPE_MILD_CVE, -} from '../../../src/constants/alerts.mts' - -describe('alerts constants', () => { - describe('alert type constants', () => { - it('has ALERT_TYPE_CRITICAL_CVE constant', () => { - expect(ALERT_TYPE_CRITICAL_CVE).toBe('criticalCVE') - }) - - it('has ALERT_TYPE_CVE constant', () => { - expect(ALERT_TYPE_CVE).toBe('cve') - }) - - it('has ALERT_TYPE_MEDIUM_CVE constant', () => { - expect(ALERT_TYPE_MEDIUM_CVE).toBe('mediumCVE') - }) - - it('has ALERT_TYPE_MILD_CVE constant', () => { - expect(ALERT_TYPE_MILD_CVE).toBe('mildCVE') - }) - }) - - describe('alert type usage patterns', () => { - it('critical CVE is for most severe vulnerabilities', () => { - expect(ALERT_TYPE_CRITICAL_CVE).toContain('critical') - }) - - it('all CVE types contain CVE', () => { - expect(ALERT_TYPE_CRITICAL_CVE.toLowerCase()).toContain('cve') - expect(ALERT_TYPE_CVE.toLowerCase()).toContain('cve') - expect(ALERT_TYPE_MEDIUM_CVE.toLowerCase()).toContain('cve') - expect(ALERT_TYPE_MILD_CVE.toLowerCase()).toContain('cve') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/cache.test.mts b/packages/cli/test/unit/constants/cache.test.mts deleted file mode 100644 index dbb4c1589..000000000 --- a/packages/cli/test/unit/constants/cache.test.mts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Unit tests for cache constants. - * - * Purpose: Tests the caching, TTL, and timeout constants. - * - * Test Coverage: - Cache TTL constants - Timeout constants. - * - * Related Files: - constants/cache.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - DLX_BINARY_CACHE_TTL, - UPDATE_CHECK_TTL, - UPDATE_NOTIFIER_TIMEOUT, -} from '../../../src/constants/cache.mts' - -describe('cache constants', () => { - describe('cache TTL constants', () => { - it('has DLX_BINARY_CACHE_TTL constant (7 days)', () => { - const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000 - expect(DLX_BINARY_CACHE_TTL).toBe(sevenDaysInMs) - }) - - it('has UPDATE_CHECK_TTL constant (24 hours)', () => { - const oneDayInMs = 24 * 60 * 60 * 1000 - expect(UPDATE_CHECK_TTL).toBe(oneDayInMs) - }) - }) - - describe('timeout constants', () => { - it('has UPDATE_NOTIFIER_TIMEOUT constant (10 seconds)', () => { - const tenSecondsInMs = 10 * 1000 - expect(UPDATE_NOTIFIER_TIMEOUT).toBe(tenSecondsInMs) - }) - }) - - describe('constant value ranges', () => { - it('DLX_BINARY_CACHE_TTL is greater than UPDATE_CHECK_TTL', () => { - expect(DLX_BINARY_CACHE_TTL).toBeGreaterThan(UPDATE_CHECK_TTL) - }) - - it('UPDATE_CHECK_TTL is greater than UPDATE_NOTIFIER_TIMEOUT', () => { - expect(UPDATE_CHECK_TTL).toBeGreaterThan(UPDATE_NOTIFIER_TIMEOUT) - }) - - it('all TTL values are positive', () => { - expect(DLX_BINARY_CACHE_TTL).toBeGreaterThan(0) - expect(UPDATE_CHECK_TTL).toBeGreaterThan(0) - expect(UPDATE_NOTIFIER_TIMEOUT).toBeGreaterThan(0) - }) - }) -}) diff --git a/packages/cli/test/unit/constants/cli.test.mts b/packages/cli/test/unit/constants/cli.test.mts deleted file mode 100644 index 1956cf74d..000000000 --- a/packages/cli/test/unit/constants/cli.test.mts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Unit tests for CLI constants. - * - * Purpose: Tests the CLI interface constants including flags, output formats, - * and labels. - * - * Test Coverage: - CLI flag constants - Output format constants - Fold setting - * constants - Dry run labels - Command constants. - * - * Related Files: - constants/cli.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - DRY_RUN_BAILING_NOW, - DRY_RUN_LABEL, - DRY_RUN_NOT_SAVING, - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_HELP_FULL, - FLAG_ID, - FLAG_JSON, - FLAG_LOGLEVEL, - FLAG_MARKDOWN, - FLAG_ORG, - FLAG_PIN, - FLAG_PROD, - FLAG_QUIET, - FLAG_SILENT, - FLAG_TEXT, - FLAG_VERBOSE, - FLAG_VERSION, - FOLD_SETTING_FILE, - FOLD_SETTING_NONE, - FOLD_SETTING_PKG, - FOLD_SETTING_VERSION, - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, - REDACTED, - SEA_UPDATE_COMMAND, -} from '../../../src/constants/cli.mts' - -describe('cli constants', () => { - describe('flag constants', () => { - it('has FLAG_CONFIG constant', () => { - expect(FLAG_CONFIG).toBe('--config') - }) - - it('has FLAG_DRY_RUN constant', () => { - expect(FLAG_DRY_RUN).toBe('--dry-run') - }) - - it('has FLAG_HELP constant', () => { - expect(FLAG_HELP).toBe('--help') - }) - - it('has FLAG_HELP_FULL constant', () => { - expect(FLAG_HELP_FULL).toBe('--help-full') - }) - - it('has FLAG_ID constant', () => { - expect(FLAG_ID).toBe('--id') - }) - - it('has FLAG_JSON constant', () => { - expect(FLAG_JSON).toBe('--json') - }) - - it('has FLAG_LOGLEVEL constant', () => { - expect(FLAG_LOGLEVEL).toBe('--loglevel') - }) - - it('has FLAG_MARKDOWN constant', () => { - expect(FLAG_MARKDOWN).toBe('--markdown') - }) - - it('has FLAG_ORG constant', () => { - expect(FLAG_ORG).toBe('--org') - }) - - it('has FLAG_PIN constant', () => { - expect(FLAG_PIN).toBe('--pin') - }) - - it('has FLAG_PROD constant', () => { - expect(FLAG_PROD).toBe('--prod') - }) - - it('has FLAG_QUIET constant', () => { - expect(FLAG_QUIET).toBe('--quiet') - }) - - it('has FLAG_SILENT constant', () => { - expect(FLAG_SILENT).toBe('--silent') - }) - - it('has FLAG_TEXT constant', () => { - expect(FLAG_TEXT).toBe('--text') - }) - - it('has FLAG_VERBOSE constant', () => { - expect(FLAG_VERBOSE).toBe('--verbose') - }) - - it('has FLAG_VERSION constant', () => { - expect(FLAG_VERSION).toBe('--version') - }) - - it('all flags start with --', () => { - const flags = [ - FLAG_CONFIG, - FLAG_DRY_RUN, - FLAG_HELP, - FLAG_HELP_FULL, - FLAG_ID, - FLAG_JSON, - FLAG_LOGLEVEL, - FLAG_MARKDOWN, - FLAG_ORG, - FLAG_PIN, - FLAG_PROD, - FLAG_QUIET, - FLAG_SILENT, - FLAG_TEXT, - FLAG_VERBOSE, - FLAG_VERSION, - ] - for (let i = 0, { length } = flags; i < length; i += 1) { - const flag = flags[i] - expect(flag.startsWith('--')).toBe(true) - } - }) - }) - - describe('output format constants', () => { - it('has OUTPUT_JSON constant', () => { - expect(OUTPUT_JSON).toBe('json') - }) - - it('has OUTPUT_MARKDOWN constant', () => { - expect(OUTPUT_MARKDOWN).toBe('markdown') - }) - - it('has OUTPUT_TEXT constant', () => { - expect(OUTPUT_TEXT).toBe('text') - }) - }) - - describe('fold setting constants', () => { - it('has FOLD_SETTING_FILE constant', () => { - expect(FOLD_SETTING_FILE).toBe('file') - }) - - it('has FOLD_SETTING_NONE constant', () => { - expect(FOLD_SETTING_NONE).toBe('none') - }) - - it('has FOLD_SETTING_PKG constant', () => { - expect(FOLD_SETTING_PKG).toBe('pkg') - }) - - it('has FOLD_SETTING_VERSION constant', () => { - expect(FOLD_SETTING_VERSION).toBe('version') - }) - }) - - describe('dry run labels', () => { - it('has DRY_RUN_LABEL constant', () => { - expect(DRY_RUN_LABEL).toBe('[DryRun]') - }) - - it('has DRY_RUN_BAILING_NOW constant', () => { - expect(DRY_RUN_BAILING_NOW).toBe('[DryRun]: Bailing now') - expect(DRY_RUN_BAILING_NOW).toContain(DRY_RUN_LABEL) - }) - - it('has DRY_RUN_NOT_SAVING constant', () => { - expect(DRY_RUN_NOT_SAVING).toBe('[DryRun]: Not saving') - expect(DRY_RUN_NOT_SAVING).toContain(DRY_RUN_LABEL) - }) - }) - - describe('command constants', () => { - it('has SEA_UPDATE_COMMAND constant', () => { - expect(SEA_UPDATE_COMMAND).toBe('self-update') - }) - }) - - describe('redaction constants', () => { - it('has REDACTED constant', () => { - expect(REDACTED).toBe('<redacted>') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/config.test.mts b/packages/cli/test/unit/constants/config.test.mts deleted file mode 100644 index 7810d0026..000000000 --- a/packages/cli/test/unit/constants/config.test.mts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Unit tests for config constants. - * - * Purpose: Tests the configuration key constants for Socket CLI settings. - * - * Test Coverage: - Config key constant values - Config key naming conventions. - * - * Related Files: - constants/config.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, -} from '../../../src/constants/config.mts' - -describe('config constants', () => { - describe('config key constants', () => { - it('has CONFIG_KEY_API_BASE_URL constant', () => { - expect(CONFIG_KEY_API_BASE_URL).toBe('apiBaseUrl') - }) - - it('has CONFIG_KEY_API_PROXY constant', () => { - expect(CONFIG_KEY_API_PROXY).toBe('apiProxy') - }) - - it('has CONFIG_KEY_API_TOKEN constant', () => { - expect(CONFIG_KEY_API_TOKEN).toBe('apiToken') - }) - - it('has CONFIG_KEY_DEFAULT_ORG constant', () => { - expect(CONFIG_KEY_DEFAULT_ORG).toBe('defaultOrg') - }) - - it('has CONFIG_KEY_ENFORCED_ORGS constant', () => { - expect(CONFIG_KEY_ENFORCED_ORGS).toBe('enforcedOrgs') - }) - - it('has CONFIG_KEY_ORG constant', () => { - expect(CONFIG_KEY_ORG).toBe('org') - }) - }) - - describe('config key naming', () => { - it('API-related keys start with api', () => { - expect(CONFIG_KEY_API_BASE_URL).toMatch(/^api/) - expect(CONFIG_KEY_API_PROXY).toMatch(/^api/) - expect(CONFIG_KEY_API_TOKEN).toMatch(/^api/) - }) - - it('org-related keys contain Org or org', () => { - expect(CONFIG_KEY_DEFAULT_ORG.toLowerCase()).toContain('org') - expect(CONFIG_KEY_ENFORCED_ORGS.toLowerCase()).toContain('org') - expect(CONFIG_KEY_ORG.toLowerCase()).toContain('org') - }) - - it('all keys use camelCase', () => { - const keys = [ - CONFIG_KEY_API_BASE_URL, - CONFIG_KEY_API_PROXY, - CONFIG_KEY_API_TOKEN, - CONFIG_KEY_DEFAULT_ORG, - CONFIG_KEY_ENFORCED_ORGS, - CONFIG_KEY_ORG, - ] - for (let i = 0, { length } = keys; i < length; i += 1) { - const key = keys[i] - // camelCase starts with lowercase and has no underscores or hyphens. - expect(key).toMatch(/^[a-z][a-zA-Z]*$/) - } - }) - }) -}) diff --git a/packages/cli/test/unit/constants/env.test.mts b/packages/cli/test/unit/constants/env.test.mts deleted file mode 100644 index e71bf402a..000000000 --- a/packages/cli/test/unit/constants/env.test.mts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Unit tests for environment constants module. - * - * Purpose: Tests environment variable re-exports and the ENV proxy behavior. - * - * Test Coverage: - Environment variable re-exports - ENV proxy behavior in - * VITEST mode - processEnv export - Build metadata getters. - * - * Related Files: - src/constants/env.mts (implementation) - */ - -import process from 'node:process' - -import { describe, expect, it } from 'vitest' - -import { - CI, - ENV, - HOME, - SOCKET_CLI_DEBUG, - VITEST, - getCdxgenVersion, - getCliHomepage, - getCliName, - getCliVersion, - getCliVersionHash, - getCoanaVersion, - getPyCliVersion, - getPythonBuildTag, - getPythonVersion, - getSocketPatchVersion, - getSynpVersion, - isPublishedBuild, - isSentryBuild, - processEnv, -} from '../../../src/constants/env.mts' - -describe('constants/env', () => { - describe('environment variable re-exports', () => { - it('exports CI', () => { - // CI should be defined (boolean or undefined). - expect(typeof CI === 'boolean' || CI === undefined).toBe(true) - }) - - it('exports HOME', () => { - // HOME should be a string or undefined. - expect(typeof HOME === 'string' || HOME === undefined).toBe(true) - }) - - it('exports VITEST', () => { - // VITEST should be true in test environment. - expect(VITEST).toBe(true) - }) - - it('exports SOCKET_CLI_DEBUG', () => { - // SOCKET_CLI_DEBUG should be a boolean or undefined. - expect( - typeof SOCKET_CLI_DEBUG === 'boolean' || SOCKET_CLI_DEBUG === undefined, - ).toBe(true) - }) - }) - - describe('processEnv export', () => { - it('exports process.env reference', () => { - expect(processEnv).toBe(process.env) - }) - - it('allows reading environment variables', () => { - // PATH should exist in process.env. - expect(typeof processEnv['PATH']).toBe('string') - }) - }) - - describe('build metadata getters', () => { - it('getCdxgenVersion returns a string', () => { - const version = getCdxgenVersion() - expect(typeof version).toBe('string') - }) - - it('getCliHomepage returns a string', () => { - const homepage = getCliHomepage() - expect(typeof homepage).toBe('string') - }) - - it('getCliName returns a string', () => { - const name = getCliName() - expect(typeof name).toBe('string') - expect(name.length).toBeGreaterThan(0) - }) - - it('getCliVersion returns a string', () => { - const version = getCliVersion() - expect(typeof version).toBe('string') - }) - - it('getCliVersionHash returns a string', () => { - const hash = getCliVersionHash() - expect(typeof hash).toBe('string') - }) - - it('getCoanaVersion returns a string', () => { - const version = getCoanaVersion() - expect(typeof version).toBe('string') - }) - - it('getPyCliVersion returns a string', () => { - const version = getPyCliVersion() - expect(typeof version).toBe('string') - }) - - it('getPythonBuildTag returns a string', () => { - const tag = getPythonBuildTag() - expect(typeof tag).toBe('string') - }) - - it('getPythonVersion returns a string', () => { - const version = getPythonVersion() - expect(typeof version).toBe('string') - }) - - it('getSocketPatchVersion returns a string', () => { - const version = getSocketPatchVersion() - expect(typeof version).toBe('string') - }) - - it('getSynpVersion returns a string', () => { - const version = getSynpVersion() - expect(typeof version).toBe('string') - }) - - it('isPublishedBuild returns a boolean', () => { - expect(typeof isPublishedBuild()).toBe('boolean') - }) - - it('isSentryBuild returns a boolean', () => { - expect(typeof isSentryBuild()).toBe('boolean') - }) - }) - - describe('ENV proxy', () => { - it('is an object', () => { - expect(typeof ENV).toBe('object') - }) - - it('provides access to VITEST', () => { - // In test env, VITEST comes from process.env and is a string 'true'. - expect(ENV.VITEST).toBeTruthy() - }) - - it('allows reading env variables via get trap', () => { - // In VITEST mode, the proxy should read from process.env. - const pathValue = ENV['PATH' as keyof typeof ENV] - expect(typeof pathValue).toBe('string') - }) - - it('allows checking property existence via has trap', () => { - expect('VITEST' in ENV).toBe(true) - expect('HOME' in ENV).toBe(true) - }) - - it('returns own keys via ownKeys trap', () => { - const keys = Object.keys(ENV) - expect(Array.isArray(keys)).toBe(true) - expect(keys.length).toBeGreaterThan(0) - }) - - it('returns property descriptors via getOwnPropertyDescriptor trap', () => { - const descriptor = Object.getOwnPropertyDescriptor(ENV, 'VITEST') - expect(descriptor).toBeDefined() - // In test env, VITEST comes from process.env and is a string 'true'. - expect(descriptor?.value).toBeTruthy() - }) - - it('allows setting values in VITEST mode via set trap', () => { - const testKey = 'TEST_ENV_VAR_FOR_TESTING' - const originalValue = process.env[testKey] - - // Set value via ENV proxy. - ;(ENV as unknown)[testKey] = 'test-value' - - // Verify it was set in process.env. - expect(process.env[testKey]).toBe('test-value') - - // Clean up. - if (originalValue === undefined) { - delete process.env[testKey] - } else { - process.env[testKey] = originalValue - } - }) - - it('includes INLINED_* properties from snapshot', () => { - const keys = Object.keys(ENV) - const inlinedKeys = keys.filter(k => k.startsWith('INLINED_')) - // Should have some inlined keys from the build metadata. - expect(inlinedKeys.length).toBeGreaterThan(0) - }) - - it('provides access to INLINED_NAME', () => { - const name = ENV.INLINED_NAME - expect(typeof name).toBe('string') - expect(name.length).toBeGreaterThan(0) - }) - - it('provides access to INLINED_VERSION', () => { - const version = ENV.INLINED_VERSION - expect(typeof version).toBe('string') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/errors.test.mts b/packages/cli/test/unit/constants/errors.test.mts deleted file mode 100644 index 4d57549f9..000000000 --- a/packages/cli/test/unit/constants/errors.test.mts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Unit tests for error constants. - * - * Purpose: Tests the error message constants for Socket CLI. - * - * Test Coverage: - Error message constants - Loop sentinel value. - * - * Related Files: - constants/errors.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - ERROR_NO_MANIFEST_FILES, - ERROR_NO_PACKAGE_JSON, - ERROR_NO_REPO_FOUND, - ERROR_NO_SOCKET_DIR, - ERROR_UNABLE_RESOLVE_ORG, - LOOP_SENTINEL, -} from '../../../src/constants/errors.mts' - -describe('errors constants', () => { - describe('error message constants', () => { - it('has ERROR_NO_MANIFEST_FILES constant', () => { - expect(ERROR_NO_MANIFEST_FILES).toBe('No manifest files found') - }) - - it('has ERROR_NO_PACKAGE_JSON constant', () => { - expect(ERROR_NO_PACKAGE_JSON).toBe('No package.json found') - }) - - it('has ERROR_NO_REPO_FOUND constant', () => { - expect(ERROR_NO_REPO_FOUND).toBe('No repo found') - }) - - it('has ERROR_NO_SOCKET_DIR constant', () => { - expect(ERROR_NO_SOCKET_DIR).toBe('No .socket directory found') - }) - - it('has ERROR_UNABLE_RESOLVE_ORG constant', () => { - expect(ERROR_UNABLE_RESOLVE_ORG).toBe( - 'Unable to resolve a Socket account organization', - ) - }) - }) - - describe('loop sentinel', () => { - it('has LOOP_SENTINEL constant', () => { - expect(LOOP_SENTINEL).toBe(50_000) - }) - - it('LOOP_SENTINEL is a reasonable limit for tree traversal', () => { - expect(LOOP_SENTINEL).toBeGreaterThan(1000) - expect(LOOP_SENTINEL).toBeLessThan(1_000_000) - }) - }) -}) diff --git a/packages/cli/test/unit/constants/github.test.mts b/packages/cli/test/unit/constants/github.test.mts deleted file mode 100644 index f747b34b8..000000000 --- a/packages/cli/test/unit/constants/github.test.mts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Unit tests for GitHub constants. - * - * Purpose: Tests the GitHub and GraphQL constants for Socket CLI. - * - * Test Coverage: - GraphQL pagination constants - PR state constants - - * Repository constants. - * - * Related Files: - constants/github.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - GQL_PAGE_SENTINEL, - GQL_PR_STATE_CLOSED, - GQL_PR_STATE_MERGED, - GQL_PR_STATE_OPEN, - SOCKET_CLI_GITHUB_REPO, -} from '../../../src/constants/github.mts' - -describe('github constants', () => { - describe('GraphQL pagination', () => { - it('has GQL_PAGE_SENTINEL constant', () => { - expect(GQL_PAGE_SENTINEL).toBe(100) - }) - - it('GQL_PAGE_SENTINEL is within GitHub API limits', () => { - // GitHub GraphQL API typically limits to 100 items per page. - expect(GQL_PAGE_SENTINEL).toBeLessThanOrEqual(100) - expect(GQL_PAGE_SENTINEL).toBeGreaterThan(0) - }) - }) - - describe('PR state constants', () => { - it('has GQL_PR_STATE_CLOSED constant', () => { - expect(GQL_PR_STATE_CLOSED).toBe('CLOSED') - }) - - it('has GQL_PR_STATE_MERGED constant', () => { - expect(GQL_PR_STATE_MERGED).toBe('MERGED') - }) - - it('has GQL_PR_STATE_OPEN constant', () => { - expect(GQL_PR_STATE_OPEN).toBe('OPEN') - }) - - it('all PR states are uppercase', () => { - expect(GQL_PR_STATE_CLOSED).toBe(GQL_PR_STATE_CLOSED.toUpperCase()) - expect(GQL_PR_STATE_MERGED).toBe(GQL_PR_STATE_MERGED.toUpperCase()) - expect(GQL_PR_STATE_OPEN).toBe(GQL_PR_STATE_OPEN.toUpperCase()) - }) - }) - - describe('repository constants', () => { - it('has SOCKET_CLI_GITHUB_REPO constant', () => { - expect(SOCKET_CLI_GITHUB_REPO).toBe('socket-cli') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/http.test.mts b/packages/cli/test/unit/constants/http.test.mts deleted file mode 100644 index 34b1a4ef0..000000000 --- a/packages/cli/test/unit/constants/http.test.mts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Unit tests for HTTP constants. - * - * Purpose: Tests the HTTP status code constants. - * - * Test Coverage: - HTTP status code constants - NPM registry URL. - * - * Related Files: - constants/http.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_TOO_MANY_REQUESTS, - HTTP_STATUS_UNAUTHORIZED, - NPM_REGISTRY_URL, -} from '../../../src/constants/http.mts' - -describe('http constants', () => { - describe('HTTP status code constants', () => { - it('has HTTP_STATUS_BAD_REQUEST constant', () => { - expect(HTTP_STATUS_BAD_REQUEST).toBe(400) - }) - - it('has HTTP_STATUS_UNAUTHORIZED constant', () => { - expect(HTTP_STATUS_UNAUTHORIZED).toBe(401) - }) - - it('has HTTP_STATUS_FORBIDDEN constant', () => { - expect(HTTP_STATUS_FORBIDDEN).toBe(403) - }) - - it('has HTTP_STATUS_NOT_FOUND constant', () => { - expect(HTTP_STATUS_NOT_FOUND).toBe(404) - }) - - it('has HTTP_STATUS_TOO_MANY_REQUESTS constant', () => { - expect(HTTP_STATUS_TOO_MANY_REQUESTS).toBe(429) - }) - - it('has HTTP_STATUS_INTERNAL_SERVER_ERROR constant', () => { - expect(HTTP_STATUS_INTERNAL_SERVER_ERROR).toBe(500) - }) - }) - - describe('HTTP status code ranges', () => { - it('4xx codes are client errors', () => { - expect(HTTP_STATUS_BAD_REQUEST).toBeGreaterThanOrEqual(400) - expect(HTTP_STATUS_BAD_REQUEST).toBeLessThan(500) - expect(HTTP_STATUS_UNAUTHORIZED).toBeGreaterThanOrEqual(400) - expect(HTTP_STATUS_UNAUTHORIZED).toBeLessThan(500) - expect(HTTP_STATUS_FORBIDDEN).toBeGreaterThanOrEqual(400) - expect(HTTP_STATUS_FORBIDDEN).toBeLessThan(500) - expect(HTTP_STATUS_NOT_FOUND).toBeGreaterThanOrEqual(400) - expect(HTTP_STATUS_NOT_FOUND).toBeLessThan(500) - expect(HTTP_STATUS_TOO_MANY_REQUESTS).toBeGreaterThanOrEqual(400) - expect(HTTP_STATUS_TOO_MANY_REQUESTS).toBeLessThan(500) - }) - - it('5xx codes are server errors', () => { - expect(HTTP_STATUS_INTERNAL_SERVER_ERROR).toBeGreaterThanOrEqual(500) - expect(HTTP_STATUS_INTERNAL_SERVER_ERROR).toBeLessThan(600) - }) - }) - - describe('registry URL', () => { - it('has NPM_REGISTRY_URL constant', () => { - expect(NPM_REGISTRY_URL).toContain('registry') - expect(NPM_REGISTRY_URL).toContain('npm') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/packages.test.mts b/packages/cli/test/unit/constants/packages.test.mts deleted file mode 100644 index 9722dcaad..000000000 --- a/packages/cli/test/unit/constants/packages.test.mts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Unit tests for package constants. - * - * Purpose: Tests the package and binary name constants for Socket CLI. - * - * Test Coverage: - Package manifest file constants - Directory name constants - - * File extension constants - Package name constants - Binary name constants. - * - * Related Files: - constants/packages.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - BLESSED, - BLESSED_CONTRIB, - EXT_LOCK, - EXT_LOCKB, - NODE_MODULES, - NPM_BUGGY_OVERRIDES_PATCHED_VERSION, - PACKAGE_JSON, - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - PYTHON_MIN_VERSION, - SENTRY_NODE, - SOCKET_CLI_BIN_NAME, - SOCKET_CLI_BIN_NAME_ALIAS, - SOCKET_CLI_LEGACY_PACKAGE_NAME, - SOCKET_CLI_NPM_BIN_NAME, - SOCKET_CLI_NPX_BIN_NAME, - SOCKET_CLI_PACKAGE_NAME, - SOCKET_CLI_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME, - SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, - SOCKET_CLI_SENTRY_NPM_BIN_NAME, - SOCKET_CLI_SENTRY_NPX_BIN_NAME, - SOCKET_CLI_SENTRY_PACKAGE_NAME, - SOCKET_CLI_SENTRY_PNPM_BIN_NAME, - SOCKET_CLI_SENTRY_YARN_BIN_NAME, - SOCKET_CLI_YARN_BIN_NAME, - SOCKET_DESCRIPTION, - SOCKET_DESCRIPTION_WITH_SENTRY, - SOCKET_SECURITY_REGISTRY, - YARN_LOCK, -} from '../../../src/constants/packages.mts' - -describe('packages constants', () => { - describe('package manifest file constants', () => { - it('has PACKAGE_JSON constant', () => { - expect(PACKAGE_JSON).toBe('package.json') - }) - - it('has PACKAGE_LOCK_JSON constant', () => { - expect(PACKAGE_LOCK_JSON).toBe('package-lock.json') - }) - - it('has PNPM_LOCK_YAML constant', () => { - expect(PNPM_LOCK_YAML).toBe('pnpm-lock.yaml') - }) - - it('has YARN_LOCK constant', () => { - expect(YARN_LOCK).toBe('yarn.lock') - }) - }) - - describe('directory name constants', () => { - it('has NODE_MODULES constant', () => { - expect(NODE_MODULES).toBe('node_modules') - }) - }) - - describe('file extension constants', () => { - it('has EXT_LOCK constant', () => { - expect(EXT_LOCK).toBe('.lock') - }) - - it('has EXT_LOCKB constant', () => { - expect(EXT_LOCKB).toBe('.lockb') - }) - }) - - describe('npm version constants', () => { - it('has NPM_BUGGY_OVERRIDES_PATCHED_VERSION constant', () => { - expect(NPM_BUGGY_OVERRIDES_PATCHED_VERSION).toBe('11.2.0') - }) - }) - - describe('external package name constants', () => { - it('has BLESSED constant', () => { - expect(BLESSED).toBe('blessed') - }) - - it('has BLESSED_CONTRIB constant', () => { - expect(BLESSED_CONTRIB).toBe('blessed-contrib') - }) - - it('has SENTRY_NODE constant', () => { - expect(SENTRY_NODE).toBe('@sentry/node') - }) - - it('has SOCKET_SECURITY_REGISTRY constant', () => { - expect(SOCKET_SECURITY_REGISTRY).toBe('@socketsecurity/registry-stable') - }) - }) - - describe('Socket CLI package name constants', () => { - it('has SOCKET_CLI_PACKAGE_NAME constant', () => { - expect(SOCKET_CLI_PACKAGE_NAME).toBe('socket') - }) - - it('has SOCKET_CLI_LEGACY_PACKAGE_NAME constant', () => { - expect(SOCKET_CLI_LEGACY_PACKAGE_NAME).toBe('socket-npm') - }) - - it('has SOCKET_CLI_SENTRY_PACKAGE_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_PACKAGE_NAME).toBe( - '@socketsecurity/cli-with-sentry', - ) - }) - }) - - describe('Socket CLI binary name constants', () => { - it('has SOCKET_CLI_BIN_NAME constant', () => { - expect(SOCKET_CLI_BIN_NAME).toBe('socket') - }) - - it('has SOCKET_CLI_BIN_NAME_ALIAS constant', () => { - expect(SOCKET_CLI_BIN_NAME_ALIAS).toBe('socket-dev') - }) - - it('has SOCKET_CLI_NPM_BIN_NAME constant', () => { - expect(SOCKET_CLI_NPM_BIN_NAME).toBe('socket-npm') - }) - - it('has SOCKET_CLI_NPX_BIN_NAME constant', () => { - expect(SOCKET_CLI_NPX_BIN_NAME).toBe('socket-npx') - }) - - it('has SOCKET_CLI_PNPM_BIN_NAME constant', () => { - expect(SOCKET_CLI_PNPM_BIN_NAME).toBe('socket-pnpm') - }) - - it('has SOCKET_CLI_YARN_BIN_NAME constant', () => { - expect(SOCKET_CLI_YARN_BIN_NAME).toBe('socket-yarn') - }) - }) - - describe('Socket CLI Sentry binary name constants', () => { - it('has SOCKET_CLI_SENTRY_BIN_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_BIN_NAME).toBe('@socketsecurity/cli-with-sentry') - }) - - it('has SOCKET_CLI_SENTRY_BIN_NAME_ALIAS constant', () => { - expect(SOCKET_CLI_SENTRY_BIN_NAME_ALIAS).toBe('socket-dev-with-sentry') - }) - - it('has SOCKET_CLI_SENTRY_NPM_BIN_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_NPM_BIN_NAME).toBe( - '@socketsecurity/cli-with-sentry-npm', - ) - }) - - it('has SOCKET_CLI_SENTRY_NPX_BIN_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_NPX_BIN_NAME).toBe( - '@socketsecurity/cli-with-sentry-npx', - ) - }) - - it('has SOCKET_CLI_SENTRY_PNPM_BIN_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_PNPM_BIN_NAME).toBe( - '@socketsecurity/cli-with-sentry-pnpm', - ) - }) - - it('has SOCKET_CLI_SENTRY_YARN_BIN_NAME constant', () => { - expect(SOCKET_CLI_SENTRY_YARN_BIN_NAME).toBe( - '@socketsecurity/cli-with-sentry-yarn', - ) - }) - }) - - describe('description constants', () => { - it('has SOCKET_DESCRIPTION constant', () => { - expect(SOCKET_DESCRIPTION).toBe('CLI for Socket.dev') - }) - - it('has SOCKET_DESCRIPTION_WITH_SENTRY constant', () => { - expect(SOCKET_DESCRIPTION_WITH_SENTRY).toContain(SOCKET_DESCRIPTION) - expect(SOCKET_DESCRIPTION_WITH_SENTRY).toContain('Sentry') - }) - }) - - describe('Python version constants', () => { - it('has PYTHON_MIN_VERSION constant', () => { - expect(PYTHON_MIN_VERSION).toBe('3.9.0') - }) - }) -}) diff --git a/packages/cli/test/unit/constants/paths.test.mts b/packages/cli/test/unit/constants/paths.test.mts deleted file mode 100644 index 89ccfcbe7..000000000 --- a/packages/cli/test/unit/constants/paths.test.mts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Unit tests for path constants. - * - * Purpose: Tests the path utility functions and constants. - * - * Test Coverage: - Static path constants - Lazy path getters - Path resolution - * functions. - * - * Related Files: - constants/paths.mts (implementation) - */ - -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => ({ warn: vi.fn() }), -})) - -import { - DOT_SOCKET_DOT_FACTS_JSON, - ENVIRONMENT_YAML, - ENVIRONMENT_YML, - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - REQUIREMENTS_TXT, - UPDATE_STORE_DIR, - UPDATE_STORE_FILE_NAME, - YARN_LOCK, - configPath, - distPath, - externalPath, - getBashRcPath, - getBinCliPath, - getBinPath, - getBlessedContribPath, - getBlessedOptions, - getBlessedPath, - getDistBinPath, - getDistPackageJsonPath, - getDistPath, - getGithubCachePath, - getPackageJsonPath, - getSocketCachePath, - getZshRcPath, - homePath, - rootPath, - srcPath, -} from '../../../src/constants/paths.mts' - -describe('paths constants', () => { - describe('static constants', () => { - it('has ENVIRONMENT_YAML constant', () => { - expect(ENVIRONMENT_YAML).toBe('environment.yaml') - }) - - it('has ENVIRONMENT_YML constant', () => { - expect(ENVIRONMENT_YML).toBe('environment.yml') - }) - - it('has REQUIREMENTS_TXT constant', () => { - expect(REQUIREMENTS_TXT).toBe('requirements.txt') - }) - - it('has PACKAGE_LOCK_JSON constant', () => { - expect(PACKAGE_LOCK_JSON).toBe('package-lock.json') - }) - - it('has PNPM_LOCK_YAML constant', () => { - expect(PNPM_LOCK_YAML).toBe('pnpm-lock.yaml') - }) - - it('has YARN_LOCK constant', () => { - expect(YARN_LOCK).toBe('yarn.lock') - }) - - it('has UPDATE_STORE_DIR constant', () => { - expect(UPDATE_STORE_DIR).toBe('.socket/_dlx') - }) - - it('has UPDATE_STORE_FILE_NAME constant', () => { - expect(UPDATE_STORE_FILE_NAME).toBe('.dlx-manifest.json') - }) - - it('has DOT_SOCKET_DOT_FACTS_JSON constant', () => { - expect(DOT_SOCKET_DOT_FACTS_JSON).toBe('.socket.facts.json') - }) - }) - - describe('computed paths', () => { - it('has homePath defined', () => { - expect(homePath).toBeDefined() - expect(typeof homePath).toBe('string') - }) - - it('has srcPath defined', () => { - expect(srcPath).toBeDefined() - expect(typeof srcPath).toBe('string') - }) - - it('has rootPath defined', () => { - expect(rootPath).toBeDefined() - expect(typeof rootPath).toBe('string') - }) - - it('has distPath defined', () => { - expect(distPath).toBeDefined() - expect(distPath).toContain('dist') - }) - - it('has configPath defined', () => { - expect(configPath).toBeDefined() - expect(configPath).toContain('.config') - }) - - it('has externalPath defined', () => { - expect(externalPath).toBeDefined() - expect(externalPath).toContain('external') - }) - }) - - describe('path getter functions', () => { - it('getBashRcPath returns path to .bashrc', () => { - const result = getBashRcPath() - expect(result).toContain('.bashrc') - }) - - it('getZshRcPath returns path to .zshrc', () => { - const result = getZshRcPath() - expect(result).toContain('.zshrc') - }) - - it('getBinPath returns path to bin directory', () => { - const result = getBinPath() - expect(result).toContain('bin') - }) - - it('getBinCliPath returns path to CLI entry point', () => { - const result = getBinCliPath() - // Default bundle entry is `dist/index.js` (was `dist/cli.js` - // before the unified-build rename in src/constants/paths.mts). - // Tests load `.env.test`, which sets `SOCKET_CLI_BIN_PATH` to - // `./build/cli.js` so unit tests can exercise locally-built - // bundles. The env value is snapshotted at module load by - // src/env/socket-cli-bin-path.mts, so we can't unset it here — - // accept either the override path or the default. - const isOverride = - result.endsWith('build/cli.js') || result.endsWith('build\\cli.js') - const isDefault = - result.endsWith('dist/index.js') || result.endsWith('dist\\index.js') - expect(isOverride || isDefault).toBe(true) - }) - - it('getDistPath returns distPath', () => { - const result = getDistPath() - expect(result).toBe(distPath) - }) - - it('getDistBinPath returns path to dist/bin', () => { - const result = getDistBinPath() - expect(result).toContain('dist') - expect(result).toContain('bin') - }) - - it('getDistPackageJsonPath returns path to dist/package.json', () => { - const result = getDistPackageJsonPath() - expect(result).toContain('dist') - expect(result).toContain('package.json') - }) - - it('getPackageJsonPath returns path to package.json', () => { - const result = getPackageJsonPath() - expect(result).toContain('package.json') - }) - - it('getBlessedPath returns path to external/blessed', () => { - const result = getBlessedPath() - expect(result).toContain('external') - expect(result).toContain('blessed') - }) - - it('getBlessedContribPath returns path to external/blessed-contrib', () => { - const result = getBlessedContribPath() - expect(result).toContain('external') - expect(result).toContain('blessed-contrib') - }) - - it('getGithubCachePath returns path in socket cache', () => { - const result = getGithubCachePath() - expect(result).toContain('socket') - expect(result).toContain('github') - }) - }) - - describe('getBlessedOptions', () => { - it('returns object with fullUnicode', () => { - const result = getBlessedOptions() - expect(result.fullUnicode).toBe(true) - }) - - it('returns object with titleShrink', () => { - const result = getBlessedOptions() - expect(result.titleShrink).toBe(true) - }) - - it('returns object with input and output streams', () => { - const result = getBlessedOptions() - expect(result.input).toBe(process.stdin) - expect(result.output).toBe(process.stdout) - }) - - it('returns object with terminal setting', () => { - const result = getBlessedOptions() - expect(result.terminal).toMatch(/xterm/) - }) - }) - - describe('getSocketCachePath', () => { - it('returns platform-specific cache path', () => { - const result = getSocketCachePath() - expect(result).toContain('socket') - }) - - it('respects XDG_CACHE_HOME when set', async () => { - const originalXdg = process.env['XDG_CACHE_HOME'] - process.env['XDG_CACHE_HOME'] = '/custom/cache' - - // Re-import to pick up new env value. - const { getSocketCachePath: getPathFresh } = - await import('../../../src/constants/paths.mts') - const result = getPathFresh() - - // Restore. - if (originalXdg === undefined) { - delete process.env['XDG_CACHE_HOME'] - } else { - process.env['XDG_CACHE_HOME'] = originalXdg - } - - expect(result).toContain('socket') - }) - - it('uses Library/Caches on darwin', async () => { - // Stub platform to darwin. - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'darwin' }) - try { - // Reset modules so paths.mts re-evaluates ENV without XDG_CACHE_HOME. - const originalXdg = process.env['XDG_CACHE_HOME'] - delete process.env['XDG_CACHE_HOME'] - try { - vi.resetModules() - const { getSocketCachePath: getPathFresh } = - await import('../../../src/constants/paths.mts') - const result = getPathFresh() - expect(result).toContain('Library/Caches/socket') - } finally { - if (originalXdg !== undefined) { - process.env['XDG_CACHE_HOME'] = originalXdg - } - } - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } - }) - - it('uses TEMP-based path on win32 (lines 180-184)', async () => { - // The underlying ENV constant is read at module-init from - // env/temp.mts, env/tmp.mts. Set them in process.env before - // re-importing the module so getSocketCachePath picks them up. - const originalPlatform = process.platform - const originalXdg = process.env['XDG_CACHE_HOME'] - const originalTemp = process.env['TEMP'] - const originalTmp = process.env['TMP'] - - Object.defineProperty(process, 'platform', { value: 'win32' }) - delete process.env['XDG_CACHE_HOME'] - process.env['TEMP'] = '/winroot/Temp' - delete process.env['TMP'] - - try { - vi.resetModules() - const { getSocketCachePath: getPathFresh } = - await import('../../../src/constants/paths.mts') - const result = getPathFresh() - expect(result).toContain('socket') - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - if (originalXdg !== undefined) { - process.env['XDG_CACHE_HOME'] = originalXdg - } - if (originalTemp === undefined) { - delete process.env['TEMP'] - } else { - process.env['TEMP'] = originalTemp - } - if (originalTmp !== undefined) { - process.env['TMP'] = originalTmp - } - } - }) - - it('uses ~/.cache on linux (default branch line 186)', async () => { - const originalPlatform = process.platform - const originalXdg = process.env['XDG_CACHE_HOME'] - Object.defineProperty(process, 'platform', { value: 'linux' }) - delete process.env['XDG_CACHE_HOME'] - try { - vi.resetModules() - const { getSocketCachePath: getPathFresh } = - await import('../../../src/constants/paths.mts') - const result = getPathFresh() - // oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- test asserts XDG cache resolution to `~/.cache/socket`, the canonical Linux user-cache path; not a fleet cache location. - expect(result).toContain('.cache/socket') - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - if (originalXdg !== undefined) { - process.env['XDG_CACHE_HOME'] = originalXdg - } - } - }) - }) - - describe('getSocketAppDataPath', () => { - it('returns a string or undefined', async () => { - const { getSocketAppDataPath } = - await import('../../../src/constants/paths.mts') - const result = getSocketAppDataPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - - it('includes socket/settings in path when defined', async () => { - const { getSocketAppDataPath } = - await import('../../../src/constants/paths.mts') - const result = getSocketAppDataPath() - if (result !== undefined) { - expect(result).toContain('socket') - expect(result).toContain('settings') - } - }) - }) - - describe('getSocketRegistryPath', () => { - it('returns a path containing registry', async () => { - const { getSocketRegistryPath } = - await import('../../../src/constants/paths.mts') - try { - const result = getSocketRegistryPath() - expect(result).toContain('registry') - } catch (e) { - // Function may throw if app data path cannot be determined. - expect((e as Error).message).toContain('Unable to determine') - } - }) - }) - - describe('getNmBunPath', () => { - it('returns string or undefined', async () => { - const { getNmBunPath } = await import('../../../src/constants/paths.mts') - const result = getNmBunPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - }) - - describe('getNmNpmPath', () => { - it('returns a string', async () => { - const { getNmNpmPath } = await import('../../../src/constants/paths.mts') - const result = getNmNpmPath() - expect(typeof result).toBe('string') - }) - }) - - describe('getNmNodeGypPath', () => { - it('returns string or undefined', async () => { - const { getNmNodeGypPath } = - await import('../../../src/constants/paths.mts') - const result = getNmNodeGypPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - }) - - describe('getNmPnpmPath', () => { - it('returns string or undefined', async () => { - const { getNmPnpmPath } = await import('../../../src/constants/paths.mts') - const result = getNmPnpmPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - }) - - describe('getNmYarnPath', () => { - it('returns string or undefined', async () => { - const { getNmYarnPath } = await import('../../../src/constants/paths.mts') - const result = getNmYarnPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/constants/reporting.test.mts b/packages/cli/test/unit/constants/reporting.test.mts deleted file mode 100644 index 1146a5772..000000000 --- a/packages/cli/test/unit/constants/reporting.test.mts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Unit tests for reporting constants. - * - * Purpose: Tests the report level constants for security issue severity. - * - * Test Coverage: - Fold setting constants - Report level constants. - * - * Related Files: - constants/reporting.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - FOLD_SETTING_VERSION, - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, -} from '../../../src/constants/reporting.mts' - -describe('reporting constants', () => { - describe('fold setting constants', () => { - it('has FOLD_SETTING_VERSION constant', () => { - expect(FOLD_SETTING_VERSION).toBe('version') - }) - }) - - describe('report level constants', () => { - it('has REPORT_LEVEL_DEFER constant', () => { - expect(REPORT_LEVEL_DEFER).toBe('defer') - }) - - it('has REPORT_LEVEL_ERROR constant', () => { - expect(REPORT_LEVEL_ERROR).toBe('error') - }) - - it('has REPORT_LEVEL_IGNORE constant', () => { - expect(REPORT_LEVEL_IGNORE).toBe('ignore') - }) - - it('has REPORT_LEVEL_MONITOR constant', () => { - expect(REPORT_LEVEL_MONITOR).toBe('monitor') - }) - - it('has REPORT_LEVEL_WARN constant', () => { - expect(REPORT_LEVEL_WARN).toBe('warn') - }) - }) - - describe('report level severity order', () => { - it('all report levels are lowercase strings', () => { - const levels = [ - REPORT_LEVEL_DEFER, - REPORT_LEVEL_ERROR, - REPORT_LEVEL_IGNORE, - REPORT_LEVEL_MONITOR, - REPORT_LEVEL_WARN, - ] - for (let i = 0, { length } = levels; i < length; i += 1) { - const level = levels[i] - expect(level).toBe(level.toLowerCase()) - } - }) - }) -}) diff --git a/packages/cli/test/unit/constants/socket.test.mts b/packages/cli/test/unit/constants/socket.test.mts deleted file mode 100644 index 0792043d8..000000000 --- a/packages/cli/test/unit/constants/socket.test.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Unit tests for Socket constants. - * - * Purpose: Tests the Socket.dev specific constants for the CLI. - * - * Test Coverage: - API URL constants - Configuration file constants - - * Repository metadata constants - Scan type constants - Token constants. - * - * Related Files: - constants/socket.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - API_V0_URL, - NPM_REGISTRY_URL, - SCAN_TYPE_SOCKET, - SCAN_TYPE_SOCKET_TIER1, - SOCKET_CLI_ISSUES_URL, - SOCKET_DEFAULT_BRANCH, - SOCKET_DEFAULT_REPOSITORY, - SOCKET_JSON, - SOCKET_WEBSITE_URL, - SOCKET_YAML, - SOCKET_YML, - TOKEN_PREFIX, - TOKEN_PREFIX_LENGTH, - V1_MIGRATION_GUIDE_URL, -} from '../../../src/constants/socket.mts' - -describe('socket constants', () => { - describe('API URL constants', () => { - it('has API_V0_URL constant', () => { - expect(API_V0_URL).toBe('https://api.socket.dev/v0/') - }) - - it('has SOCKET_WEBSITE_URL constant', () => { - expect(SOCKET_WEBSITE_URL).toBe('https://socket.dev') - }) - - it('has NPM_REGISTRY_URL constant', () => { - expect(NPM_REGISTRY_URL).toContain('registry') - expect(NPM_REGISTRY_URL).toContain('npm') - }) - }) - - describe('configuration file constants', () => { - it('has SOCKET_JSON constant', () => { - expect(SOCKET_JSON).toBe('socket.json') - }) - - it('has SOCKET_YAML constant', () => { - expect(SOCKET_YAML).toBe('socket.yaml') - }) - - it('has SOCKET_YML constant', () => { - expect(SOCKET_YML).toBe('socket.yml') - }) - }) - - describe('repository metadata constants', () => { - it('has SOCKET_DEFAULT_BRANCH constant', () => { - expect(SOCKET_DEFAULT_BRANCH).toBe('socket-default-branch') - }) - - it('has SOCKET_DEFAULT_REPOSITORY constant', () => { - expect(SOCKET_DEFAULT_REPOSITORY).toBe('socket-default-repository') - }) - }) - - describe('scan type constants', () => { - it('has SCAN_TYPE_SOCKET constant', () => { - expect(SCAN_TYPE_SOCKET).toBe('socket') - }) - - it('has SCAN_TYPE_SOCKET_TIER1 constant', () => { - expect(SCAN_TYPE_SOCKET_TIER1).toBe('socket_tier1') - }) - }) - - describe('token constants', () => { - it('has TOKEN_PREFIX constant', () => { - expect(TOKEN_PREFIX).toBe('sktsec_') - }) - - it('has TOKEN_PREFIX_LENGTH constant', () => { - expect(TOKEN_PREFIX_LENGTH).toBe(TOKEN_PREFIX.length) - expect(TOKEN_PREFIX_LENGTH).toBe(7) - }) - }) - - describe('documentation constants', () => { - it('has V1_MIGRATION_GUIDE_URL constant', () => { - expect(V1_MIGRATION_GUIDE_URL).toContain('docs.socket.dev') - expect(V1_MIGRATION_GUIDE_URL).toContain('migration') - }) - - it('has SOCKET_CLI_ISSUES_URL constant', () => { - expect(SOCKET_CLI_ISSUES_URL).toContain('github.com') - expect(SOCKET_CLI_ISSUES_URL).toContain('socket-cli') - expect(SOCKET_CLI_ISSUES_URL).toContain('issues') - }) - }) -}) diff --git a/packages/cli/test/unit/env/checksum-utils.test.mts b/packages/cli/test/unit/env/checksum-utils.test.mts deleted file mode 100644 index 8b075c5a1..000000000 --- a/packages/cli/test/unit/env/checksum-utils.test.mts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Unit tests for shared checksum utilities. - * - * Covers parseChecksums (dev fallback / parse / malformed) and requireChecksum - * (dev fallback / hit / miss). The tool-specific modules (python-, opengrep-, - * sfw-, …) all delegate to these. - * - * Related Files: - src/env/checksum-utils.mts. - */ - -import { describe, expect, it } from 'vitest' - -import { - parseChecksums, - requireChecksum, -} from '../../../src/env/checksum-utils.mts' - -describe('parseChecksums', () => { - it('returns empty object for undefined input', () => { - expect(parseChecksums(undefined, 'Tool')).toEqual({}) - }) - - it('returns empty object for empty string', () => { - expect(parseChecksums('', 'Tool')).toEqual({}) - }) - - it('parses a JSON string into an object', () => { - expect(parseChecksums('{"a":"sha-a","b":"sha-b"}', 'Tool')).toEqual({ - a: 'sha-a', - b: 'sha-b', - }) - }) - - it('throws an error referencing the tool name on malformed JSON', () => { - expect(() => parseChecksums('{not json', 'Tool')).toThrow( - /Tool.*not valid JSON/, - ) - }) - - it('preserves the underlying parse-error message in the thrown error', () => { - try { - parseChecksums('{abc', 'X') - } catch (e) { - expect(String(e)).toContain('JSON.parse threw') - } - }) - - it('coerces non-Error parse errors via String()', () => { - // No good way to make JSON.parse throw a non-Error in tests; the - // String(e) branch is exercised here via direct unit testing on - // a malformed string that triggers a SyntaxError (Error subclass). - expect(() => parseChecksums('{abc', 'X')).toThrow(/X.*not valid JSON/) - }) -}) - -describe('requireChecksum', () => { - it('returns undefined when checksums object is empty (dev mode)', () => { - expect(requireChecksum({}, 'asset.tar.gz', 'Tool')).toBeUndefined() - }) - - it('returns the matching checksum when present', () => { - expect( - requireChecksum({ 'asset.tar.gz': 'sha-x' }, 'asset.tar.gz', 'Tool'), - ).toBe('sha-x') - }) - - it('throws when checksums non-empty but asset missing', () => { - expect(() => - requireChecksum({ 'a.tar.gz': 'sha-a' }, 'b.tar.gz', 'Tool'), - ).toThrow(/Tool has no SHA-256 checksum.*b\.tar\.gz/) - }) - - it('lists known assets in the error', () => { - expect(() => - requireChecksum( - { 'a.tar.gz': 'sha-a', 'b.tar.gz': 'sha-b' }, - 'c.tar.gz', - 'Tool', - ), - ).toThrow(/known assets:.*a\.tar\.gz.*b\.tar\.gz/) - }) -}) diff --git a/packages/cli/test/unit/env/env-modules.test.mts b/packages/cli/test/unit/env/env-modules.test.mts deleted file mode 100644 index 03f604b0f..000000000 --- a/packages/cli/test/unit/env/env-modules.test.mts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Unit tests for environment variable modules. - * - * Purpose: Tests the environment variable getter functions. - * - * Test Coverage: - * - * - GetCliVersion function - * - GetCoanaVersion function - * - IsPublishedBuild function - * - CI constant - * - VITEST constant - * - HOME constant - * - TEMP constant - * - TERM constant - * - * Related Files: - * - * - Env/*.mts (implementations) - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -// Test getCliVersion. -describe('env/cli-version', () => { - let originalValue: string | undefined - - beforeEach(() => { - originalValue = process.env['INLINED_VERSION'] - }) - - afterEach(() => { - if (originalValue !== undefined) { - process.env['INLINED_VERSION'] = originalValue - } else { - delete process.env['INLINED_VERSION'] - } - }) - - it('returns version when env var is set', async () => { - process.env['INLINED_VERSION'] = '1.2.3' - const { getCliVersion } = await import('../../../src/env/cli-version.mts') - expect(getCliVersion()).toBe('1.2.3') - }) -}) - -// Test getCoanaVersion. -describe('env/coana-version', () => { - let originalValue: string | undefined - - beforeEach(() => { - originalValue = process.env['INLINED_COANA_VERSION'] - }) - - afterEach(() => { - if (originalValue !== undefined) { - process.env['INLINED_COANA_VERSION'] = originalValue - } else { - delete process.env['INLINED_COANA_VERSION'] - } - }) - - it('returns version when env var is set', async () => { - process.env['INLINED_COANA_VERSION'] = '0.5.0' - const { getCoanaVersion } = - await import('../../../src/env/coana-version.mts') - expect(getCoanaVersion()).toBe('0.5.0') - }) - - it('throws error when env var is not set', async () => { - delete process.env['INLINED_COANA_VERSION'] - const { getCoanaVersion } = - await import('../../../src/env/coana-version.mts') - expect(() => getCoanaVersion()).toThrow('INLINED_COANA_VERSION') - }) -}) - -// Test isPublishedBuild. -describe('env/is-published-build', () => { - let originalValue: string | undefined - - beforeEach(() => { - originalValue = process.env['INLINED_PUBLISHED_BUILD'] - }) - - afterEach(() => { - if (originalValue !== undefined) { - process.env['INLINED_PUBLISHED_BUILD'] = originalValue - } else { - delete process.env['INLINED_PUBLISHED_BUILD'] - } - }) - - it('returns true when env var is "true"', async () => { - process.env['INLINED_PUBLISHED_BUILD'] = 'true' - const { isPublishedBuild } = - await import('../../../src/env/is-published-build.mts') - expect(isPublishedBuild()).toBe(true) - }) - - it('returns true when env var is "1"', async () => { - process.env['INLINED_PUBLISHED_BUILD'] = '1' - const { isPublishedBuild } = - await import('../../../src/env/is-published-build.mts') - expect(isPublishedBuild()).toBe(true) - }) - - it('returns false when env var is "false"', async () => { - process.env['INLINED_PUBLISHED_BUILD'] = 'false' - const { isPublishedBuild } = - await import('../../../src/env/is-published-build.mts') - expect(isPublishedBuild()).toBe(false) - }) - - it('returns false when env var is not set', async () => { - delete process.env['INLINED_PUBLISHED_BUILD'] - const { isPublishedBuild } = - await import('../../../src/env/is-published-build.mts') - expect(isPublishedBuild()).toBe(false) - }) -}) - -// Test CI constant. -describe('env/ci', () => { - it('exports CI constant', async () => { - const { CI } = await import('../../../src/env/ci.mts') - expect(typeof CI).toBe('boolean') - }) -}) - -// Test VITEST constant. -describe('env/vitest', () => { - it('exports VITEST constant as true in test environment', async () => { - const { VITEST } = await import('../../../src/env/vitest.mts') - // Should be true since we're running in Vitest. - expect(VITEST).toBe(true) - }) -}) - -// Test HOME constant. -describe('env/home', () => { - it('exports HOME constant', async () => { - const { HOME } = await import('../../../src/env/home.mts') - // HOME should be defined on most systems. - expect(typeof HOME).toBe('string') - }) -}) - -// Test TEMP constant. -describe('env/temp', () => { - it('exports TEMP constant', async () => { - const { TEMP } = await import('../../../src/env/temp.mts') - // TEMP is typically defined on Windows, may be undefined on Unix. - expect(TEMP === undefined || typeof TEMP === 'string').toBe(true) - }) -}) - -// Test TERM constant. -describe('env/term', () => { - it('exports TERM constant', async () => { - const { TERM } = await import('../../../src/env/term.mts') - // TERM is typically defined on Unix systems. - expect(TERM === undefined || typeof TERM === 'string').toBe(true) - }) -}) diff --git a/packages/cli/test/unit/env/github-env-bindings.test.mts b/packages/cli/test/unit/env/github-env-bindings.test.mts deleted file mode 100644 index fdf4122b3..000000000 --- a/packages/cli/test/unit/env/github-env-bindings.test.mts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Unit tests for GITHUB_* env-binding modules. - * - * Each module simply re-exports the value from @socketsecurity/lib/env/github - * at module-load time. These tests verify the wrappers exist and propagate the - * value through. - * - * Related Files: - * - * - Src/env/github-*.mts - */ - -import { describe, expect, it } from 'vitest' - -describe('github env bindings', () => { - it('GITHUB_API_URL is exported', async () => { - const mod = await import('../../../src/env/github-api-url.mts') - expect('GITHUB_API_URL' in mod).toBe(true) - }) - - it('GITHUB_BASE_REF is exported', async () => { - const mod = await import('../../../src/env/github-base-ref.mts') - expect('GITHUB_BASE_REF' in mod).toBe(true) - }) - - it('GITHUB_REF_NAME is exported', async () => { - const mod = await import('../../../src/env/github-ref-name.mts') - expect('GITHUB_REF_NAME' in mod).toBe(true) - }) - - it('GITHUB_REF_TYPE is exported', async () => { - const mod = await import('../../../src/env/github-ref-type.mts') - expect('GITHUB_REF_TYPE' in mod).toBe(true) - }) - - it('GITHUB_REPOSITORY is exported', async () => { - const mod = await import('../../../src/env/github-repository.mts') - expect('GITHUB_REPOSITORY' in mod).toBe(true) - }) - - it('GITHUB_SERVER_URL is exported', async () => { - const mod = await import('../../../src/env/github-server-url.mts') - expect('GITHUB_SERVER_URL' in mod).toBe(true) - }) -}) diff --git a/packages/cli/test/unit/env/misc-env-bindings.test.mts b/packages/cli/test/unit/env/misc-env-bindings.test.mts deleted file mode 100644 index c3c788efe..000000000 --- a/packages/cli/test/unit/env/misc-env-bindings.test.mts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Unit tests for misc env-binding modules. - * - * Each module re-exports a single env-derived value at module load. These tests - * cover the import side-effect for coverage. - * - * Related Files: - * - * - Src/env/socket-cli-*.mts, node-env.mts, npm-config-user-agent.mts - */ - -import { describe, expect, it } from 'vitest' - -describe('socket-cli env bindings', () => { - it.each([ - 'socket-cli-accept-risks', - 'socket-cli-api-base-url', - 'socket-cli-api-proxy', - 'socket-cli-api-timeout', - 'socket-cli-api-token', - 'socket-cli-config', - 'socket-cli-no-api-token', - 'socket-cli-view-all-risks', - ])('exports %s', async name => { - const mod = await import(`../../../src/env/${name}.mts`) - // The module must export at least one named binding. - expect(Object.keys(mod).length).toBeGreaterThan(0) - }) -}) - -describe('process env bindings', () => { - it.each(['node-env', 'npm-config-user-agent'])('exports %s', async name => { - const mod = await import(`../../../src/env/${name}.mts`) - expect(Object.keys(mod).length).toBeGreaterThan(0) - }) -}) - -describe('socket-cli-* env bindings (additional)', () => { - it.each([ - 'socket-cli-bin-path', - 'socket-cli-bootstrap-cache-dir', - 'socket-cli-bootstrap-spec', - 'socket-cli-cdxgen-local-path', - 'socket-cli-coana-local-path', - 'socket-cli-debug', - 'socket-cli-fix', - 'socket-cli-git-user-email', - 'socket-cli-git-user-name', - 'socket-cli-js-path', - 'socket-cli-local-node-smol', - 'socket-cli-local-path', - 'socket-cli-mode', - 'socket-cli-models-path', - 'socket-cli-npm-path', - 'socket-cli-optimize', - 'socket-cli-org-slug', - 'socket-cli-pycli-local-path', - 'socket-cli-python-path', - 'socket-cli-sea-node-version', - 'socket-cli-sfw-local-path', - 'socket-cli-skip-update-check', - 'socket-cli-socket-patch-local-path', - ])('exports %s', async name => { - const mod = await import(`../../../src/env/${name}.mts`) - expect(Object.keys(mod).length).toBeGreaterThan(0) - }) -}) - -describe('process / system env bindings', () => { - it.each([ - 'ci', - 'cli-homepage', - 'cli-name', - 'disable-github-cache', - 'home', - 'is-published-build', - 'is-sentry-build', - 'localappdata', - 'node-options', - 'npm-config-cache', - 'prebuilt-node-download-url', - 'run-e2e-tests', - 'run-integration-tests', - 'temp', - 'term', - 'tmp', - 'userprofile', - 'vitest', - 'xdg-cache-home', - 'xdg-data-home', - ])('exports %s', async name => { - const mod = await import(`../../../src/env/${name}.mts`) - expect(Object.keys(mod).length).toBeGreaterThan(0) - }) -}) - -describe('python build env bindings', () => { - it.each(['python-build-tag', 'cdxgen-version'])('exports %s', async name => { - const mod = await import(`../../../src/env/${name}.mts`) - expect(Object.keys(mod).length).toBeGreaterThan(0) - }) -}) diff --git a/packages/cli/test/unit/env/opengrep-checksums.test.mts b/packages/cli/test/unit/env/opengrep-checksums.test.mts deleted file mode 100644 index 81f59eae7..000000000 --- a/packages/cli/test/unit/env/opengrep-checksums.test.mts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Unit tests for OpenGrep checksums getter. - * - * Reads INLINED_OPENGREP_CHECKSUMS from process.env directly so esbuild's - * define plugin can inline the JSON at build time. - * - * Related Files: - * - * - Src/env/opengrep-checksums.mts - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getOpengrepChecksums, - requireOpengrepChecksum, -} from '../../../src/env/opengrep-checksums.mts' - -describe('env/opengrep-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_OPENGREP_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_OPENGREP_CHECKSUMS'] = original - } else { - delete process.env['INLINED_OPENGREP_CHECKSUMS'] - } - }) - - describe('getOpengrepChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_OPENGREP_CHECKSUMS'] - expect(getOpengrepChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_OPENGREP_CHECKSUMS'] = JSON.stringify({ - 'opengrep-darwin-arm64': 'deadbeef', - }) - expect(getOpengrepChecksums()).toEqual({ - 'opengrep-darwin-arm64': 'deadbeef', - }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_OPENGREP_CHECKSUMS'] = '{bad json' - expect(() => getOpengrepChecksums()).toThrow(/OpenGrep.*not valid JSON/) - }) - }) - - describe('requireOpengrepChecksum', () => { - it('returns undefined in dev mode', () => { - delete process.env['INLINED_OPENGREP_CHECKSUMS'] - expect(requireOpengrepChecksum('opengrep-darwin-arm64')).toBeUndefined() - }) - - it('returns checksum for a known asset', () => { - process.env['INLINED_OPENGREP_CHECKSUMS'] = JSON.stringify({ - 'opengrep-darwin-arm64': 'deadbeef', - }) - expect(requireOpengrepChecksum('opengrep-darwin-arm64')).toBe('deadbeef') - }) - - it('throws for unknown asset in production mode', () => { - process.env['INLINED_OPENGREP_CHECKSUMS'] = JSON.stringify({ - 'opengrep-darwin-arm64': 'deadbeef', - }) - expect(() => requireOpengrepChecksum('opengrep-windows-x64')).toThrow( - /OpenGrep has no SHA-256 checksum/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/env/opengrep-version.test.mts b/packages/cli/test/unit/env/opengrep-version.test.mts deleted file mode 100644 index 54b384f54..000000000 --- a/packages/cli/test/unit/env/opengrep-version.test.mts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Unit tests for OpenGrep version getter. - * - * The getter reads INLINED_OPENGREP_VERSION from process.env directly so - * esbuild's define plugin can inline the value at build time. Tests verify the - * runtime success and the missing-env throw. - * - * Related Files: - * - * - Src/env/opengrep-version.mts - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getOpengrepVersion } from '../../../src/env/opengrep-version.mts' - -describe('env/opengrep-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_OPENGREP_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_OPENGREP_VERSION'] = original - } else { - delete process.env['INLINED_OPENGREP_VERSION'] - } - }) - - it('returns the version string when the env var is set', () => { - process.env['INLINED_OPENGREP_VERSION'] = '1.2.3' - expect(getOpengrepVersion()).toBe('1.2.3') - }) - - it('throws a build-time-inlined message when the env var is missing', () => { - delete process.env['INLINED_OPENGREP_VERSION'] - expect(() => getOpengrepVersion()).toThrow(/INLINED_OPENGREP_VERSION/) - }) - - it('throws when the env var is the empty string', () => { - process.env['INLINED_OPENGREP_VERSION'] = '' - expect(() => getOpengrepVersion()).toThrow(/INLINED_OPENGREP_VERSION/) - }) -}) diff --git a/packages/cli/test/unit/env/pycli-checksums.test.mts b/packages/cli/test/unit/env/pycli-checksums.test.mts deleted file mode 100644 index 7dc50c061..000000000 --- a/packages/cli/test/unit/env/pycli-checksums.test.mts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Unit tests for PyCli checksums getter. - * - * Related Files: - src/env/pycli-checksums.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getPyCliChecksums } from '../../../src/env/pycli-checksums.mts' - -describe('env/pycli-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_PYCLI_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_PYCLI_CHECKSUMS'] = original - } else { - delete process.env['INLINED_PYCLI_CHECKSUMS'] - } - }) - - describe('getPyCliChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_PYCLI_CHECKSUMS'] - expect(getPyCliChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_PYCLI_CHECKSUMS'] = JSON.stringify({ - 'pycli-1.0': 'sha-1', - }) - expect(getPyCliChecksums()).toEqual({ 'pycli-1.0': 'sha-1' }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_PYCLI_CHECKSUMS'] = '{bad' - expect(() => getPyCliChecksums()).toThrow(/PyCLI.*not valid JSON/) - }) - }) - -}) diff --git a/packages/cli/test/unit/env/pycli-version.test.mts b/packages/cli/test/unit/env/pycli-version.test.mts deleted file mode 100644 index ca0c9de46..000000000 --- a/packages/cli/test/unit/env/pycli-version.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Unit tests for PyCLI version getter. - * - * Related Files: - src/env/pycli-version.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getPyCliVersion } from '../../../src/env/pycli-version.mts' - -describe('env/pycli-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_PYCLI_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_PYCLI_VERSION'] = original - } else { - delete process.env['INLINED_PYCLI_VERSION'] - } - }) - - it('returns the version string when the env var is set', () => { - process.env['INLINED_PYCLI_VERSION'] = '0.8.0' - expect(getPyCliVersion()).toBe('0.8.0') - }) - - it('throws when the env var is missing', () => { - delete process.env['INLINED_PYCLI_VERSION'] - expect(() => getPyCliVersion()).toThrow(/INLINED_PYCLI_VERSION/) - }) - - it('throws when the env var is the empty string', () => { - process.env['INLINED_PYCLI_VERSION'] = '' - expect(() => getPyCliVersion()).toThrow(/INLINED_PYCLI_VERSION/) - }) -}) diff --git a/packages/cli/test/unit/env/python-checksums.test.mts b/packages/cli/test/unit/env/python-checksums.test.mts deleted file mode 100644 index 940bf2016..000000000 --- a/packages/cli/test/unit/env/python-checksums.test.mts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Unit tests for Python checksums getter. - * - * Reads INLINED_PYTHON_CHECKSUMS from process.env directly so esbuild's define - * plugin can inline the JSON at build time. Tests verify dev-mode fallback - * (empty object), production parsing, and the require-checksum lookup path. - * - * Related Files: - * - * - Src/env/python-checksums.mts - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getPythonChecksums, - requirePythonChecksum, -} from '../../../src/env/python-checksums.mts' - -describe('env/python-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_PYTHON_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_PYTHON_CHECKSUMS'] = original - } else { - delete process.env['INLINED_PYTHON_CHECKSUMS'] - } - }) - - describe('getPythonChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_PYTHON_CHECKSUMS'] - expect(getPythonChecksums()).toEqual({}) - }) - - it('returns empty object when env is empty string', () => { - process.env['INLINED_PYTHON_CHECKSUMS'] = '' - expect(getPythonChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_PYTHON_CHECKSUMS'] = JSON.stringify({ - 'python-3.12.tar.gz': 'abc123', - 'python-3.13.tar.gz': 'def456', - }) - expect(getPythonChecksums()).toEqual({ - 'python-3.12.tar.gz': 'abc123', - 'python-3.13.tar.gz': 'def456', - }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_PYTHON_CHECKSUMS'] = '{not json' - expect(() => getPythonChecksums()).toThrow(/Python.*not valid JSON/) - }) - }) - - describe('requirePythonChecksum', () => { - it('returns undefined in dev mode (empty checksums)', () => { - delete process.env['INLINED_PYTHON_CHECKSUMS'] - expect(requirePythonChecksum('python-3.12.tar.gz')).toBeUndefined() - }) - - it('returns checksum for a known asset', () => { - process.env['INLINED_PYTHON_CHECKSUMS'] = JSON.stringify({ - 'python-3.12.tar.gz': 'abc123', - }) - expect(requirePythonChecksum('python-3.12.tar.gz')).toBe('abc123') - }) - - it('throws for unknown asset in production mode', () => { - process.env['INLINED_PYTHON_CHECKSUMS'] = JSON.stringify({ - 'python-3.12.tar.gz': 'abc123', - }) - expect(() => requirePythonChecksum('python-3.99.tar.gz')).toThrow( - /Python has no SHA-256 checksum/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/env/python-version.test.mts b/packages/cli/test/unit/env/python-version.test.mts deleted file mode 100644 index 5ea9a74c9..000000000 --- a/packages/cli/test/unit/env/python-version.test.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Unit tests for Python version getter. - * - * Related Files: - src/env/python-version.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getPythonVersion } from '../../../src/env/python-version.mts' - -describe('env/python-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_PYTHON_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_PYTHON_VERSION'] = original - } else { - delete process.env['INLINED_PYTHON_VERSION'] - } - }) - - describe('getPythonVersion', () => { - it('returns the version string when env is set', () => { - process.env['INLINED_PYTHON_VERSION'] = '3.12.5' - expect(getPythonVersion()).toBe('3.12.5') - }) - }) - -}) diff --git a/packages/cli/test/unit/env/sfw-version.test.mts b/packages/cli/test/unit/env/sfw-version.test.mts deleted file mode 100644 index 3f0f332fa..000000000 --- a/packages/cli/test/unit/env/sfw-version.test.mts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Unit tests for Socket Firewall version getters. - * - * Purpose: Tests the version getter functions for sfw (Socket Firewall). - * - * Test Coverage: - getSwfVersion function - getSfwNpmVersion function. - * - * Related Files: - env/sfw-version.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getSfwNpmVersion, - getSwfVersion, -} from '../../../src/env/sfw-version.mts' - -describe('env/sfw-version', () => { - let originalSwfVersion: string | undefined - let originalSwfNpmVersion: string | undefined - - beforeEach(() => { - // Save original env values. - originalSwfVersion = process.env['INLINED_SFW_VERSION'] - originalSwfNpmVersion = process.env['INLINED_SFW_NPM_VERSION'] - }) - - afterEach(() => { - // Restore original env values. - if (originalSwfVersion !== undefined) { - process.env['INLINED_SFW_VERSION'] = originalSwfVersion - } else { - delete process.env['INLINED_SFW_VERSION'] - } - if (originalSwfNpmVersion !== undefined) { - process.env['INLINED_SFW_NPM_VERSION'] = originalSwfNpmVersion - } else { - delete process.env['INLINED_SFW_NPM_VERSION'] - } - }) - - describe('getSwfVersion', () => { - it('returns version when env var is set', () => { - process.env['INLINED_SFW_VERSION'] = 'v1.6.1' - expect(getSwfVersion()).toBe('v1.6.1') - }) - - it('throws error when env var is not set', () => { - delete process.env['INLINED_SFW_VERSION'] - expect(() => getSwfVersion()).toThrow('INLINED_SFW_VERSION') - }) - }) - - describe('getSfwNpmVersion', () => { - it('returns version when env var is set', () => { - process.env['INLINED_SFW_NPM_VERSION'] = '2.0.4' - expect(getSfwNpmVersion()).toBe('2.0.4') - }) - - it('throws error when env var is not set', () => { - delete process.env['INLINED_SFW_NPM_VERSION'] - expect(() => getSfwNpmVersion()).toThrow('INLINED_SFW_NPM_VERSION') - }) - }) -}) diff --git a/packages/cli/test/unit/env/socket-cli-github-token.test.mts b/packages/cli/test/unit/env/socket-cli-github-token.test.mts deleted file mode 100644 index 0485ac91d..000000000 --- a/packages/cli/test/unit/env/socket-cli-github-token.test.mts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Unit tests for SOCKET_CLI_GITHUB_TOKEN snapshot. - * - * The export runs at module-load time so it captures process.env once. Each - * test resets module-state via vi.resetModules() and re-imports after setting - * the env, so we can exercise the precedence chain. - * - * Test Coverage: - * - * - Socket-specific env var (via getSocketCliGithubToken) wins - * - Falls back to GITHUB_TOKEN when Socket-specific is unset - * - Falls back to GH_TOKEN when GITHUB_TOKEN is also unset - * - Returns empty string when nothing is set - * - * Related Files: - * - * - Src/env/socket-cli-github-token.mts - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const ENV_KEYS = [ - 'GH_TOKEN', - 'GITHUB_TOKEN', - 'SOCKET_CLI_GITHUB_TOKEN', -] as const - -const saved: Record<string, string | undefined> = {} - -beforeEach(() => { - for (let i = 0, { length } = ENV_KEYS; i < length; i += 1) { - const k = ENV_KEYS[i] - saved[k] = process.env[k] - delete process.env[k] - } - vi.resetModules() -}) - -afterEach(() => { - for (let i = 0, { length } = ENV_KEYS; i < length; i += 1) { - const k = ENV_KEYS[i] - if (saved[k] === undefined) { - delete process.env[k] - } else { - process.env[k] = saved[k] - } - } -}) - -describe('env/socket-cli-github-token', () => { - it('uses SOCKET_CLI_GITHUB_TOKEN when set', async () => { - process.env['SOCKET_CLI_GITHUB_TOKEN'] = 'socket-test-fake-token' - process.env['GITHUB_TOKEN'] = 'gh-test-fake-token' - const mod = - await import('../../../src/env/socket-cli-github-token.mts?cache_bust=1') - expect(mod.SOCKET_CLI_GITHUB_TOKEN).toBe('socket-test-fake-token') - }) - - it('falls back to GITHUB_TOKEN when SOCKET_CLI_GITHUB_TOKEN is unset', async () => { - process.env['GITHUB_TOKEN'] = 'gh-test-fake-token' - process.env['GH_TOKEN'] = 'gh-test-fake-fallback' - const mod = - await import('../../../src/env/socket-cli-github-token.mts?cache_bust=2') - expect(mod.SOCKET_CLI_GITHUB_TOKEN).toBe('gh-test-fake-token') - }) - - it('falls back to GH_TOKEN when GITHUB_TOKEN is also unset', async () => { - process.env['GH_TOKEN'] = 'gh-test-fake-token' - const mod = - await import('../../../src/env/socket-cli-github-token.mts?cache_bust=3') - expect(mod.SOCKET_CLI_GITHUB_TOKEN).toBe('gh-test-fake-token') - }) - - it('returns empty string when no env var is set', async () => { - const mod = - await import('../../../src/env/socket-cli-github-token.mts?cache_bust=4') - expect(mod.SOCKET_CLI_GITHUB_TOKEN).toBe('') - }) -}) diff --git a/packages/cli/test/unit/env/socket-patch-checksums.test.mts b/packages/cli/test/unit/env/socket-patch-checksums.test.mts deleted file mode 100644 index 2ec43fc49..000000000 --- a/packages/cli/test/unit/env/socket-patch-checksums.test.mts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Unit tests for Socket Patch checksums getter. - * - * Related Files: - src/env/socket-patch-checksums.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getSocketPatchChecksums, - requireSocketPatchChecksum, -} from '../../../src/env/socket-patch-checksums.mts' - -describe('env/socket-patch-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] = original - } else { - delete process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] - } - }) - - describe('getSocketPatchChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] - expect(getSocketPatchChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] = JSON.stringify({ - 'patch-1.0.tar.gz': 'sha-patch', - }) - expect(getSocketPatchChecksums()).toEqual({ - 'patch-1.0.tar.gz': 'sha-patch', - }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] = '{not' - expect(() => getSocketPatchChecksums()).toThrow( - /Socket Patch.*not valid JSON/, - ) - }) - }) - - describe('requireSocketPatchChecksum', () => { - it('returns undefined in dev mode', () => { - delete process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] - expect(requireSocketPatchChecksum('patch-1.0.tar.gz')).toBeUndefined() - }) - - it('returns checksum for a known asset', () => { - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] = JSON.stringify({ - 'patch-1.0.tar.gz': 'sha-patch', - }) - expect(requireSocketPatchChecksum('patch-1.0.tar.gz')).toBe('sha-patch') - }) - - it('throws for unknown asset in production mode', () => { - process.env['INLINED_SOCKET_PATCH_CHECKSUMS'] = JSON.stringify({ - 'patch-1.0.tar.gz': 'sha-patch', - }) - expect(() => requireSocketPatchChecksum('patch-9.9.tar.gz')).toThrow( - /Socket Patch has no SHA-256 checksum/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/env/socket-patch-version.test.mts b/packages/cli/test/unit/env/socket-patch-version.test.mts deleted file mode 100644 index 3ff2beb0c..000000000 --- a/packages/cli/test/unit/env/socket-patch-version.test.mts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Unit tests for Socket Patch version getter. - * - * Related Files: - src/env/socket-patch-version.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getSocketPatchVersion } from '../../../src/env/socket-patch-version.mts' - -describe('env/socket-patch-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_SOCKET_PATCH_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_SOCKET_PATCH_VERSION'] = original - } else { - delete process.env['INLINED_SOCKET_PATCH_VERSION'] - } - }) - - it('returns the version string when the env var is set', () => { - process.env['INLINED_SOCKET_PATCH_VERSION'] = '1.2.3' - expect(getSocketPatchVersion()).toBe('1.2.3') - }) - - it('throws when the env var is missing', () => { - delete process.env['INLINED_SOCKET_PATCH_VERSION'] - expect(() => getSocketPatchVersion()).toThrow( - /INLINED_SOCKET_PATCH_VERSION/, - ) - }) - - it('throws when the env var is the empty string', () => { - process.env['INLINED_SOCKET_PATCH_VERSION'] = '' - expect(() => getSocketPatchVersion()).toThrow( - /INLINED_SOCKET_PATCH_VERSION/, - ) - }) -}) diff --git a/packages/cli/test/unit/env/trivy-checksums.test.mts b/packages/cli/test/unit/env/trivy-checksums.test.mts deleted file mode 100644 index d6015b44d..000000000 --- a/packages/cli/test/unit/env/trivy-checksums.test.mts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Unit tests for Trivy checksums getter. - * - * Related Files: - src/env/trivy-checksums.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getTrivyChecksums, - requireTrivyChecksum, -} from '../../../src/env/trivy-checksums.mts' - -describe('env/trivy-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_TRIVY_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_TRIVY_CHECKSUMS'] = original - } else { - delete process.env['INLINED_TRIVY_CHECKSUMS'] - } - }) - - describe('getTrivyChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_TRIVY_CHECKSUMS'] - expect(getTrivyChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_TRIVY_CHECKSUMS'] = JSON.stringify({ - 'trivy-darwin-arm64': 'sha-trivy', - }) - expect(getTrivyChecksums()).toEqual({ 'trivy-darwin-arm64': 'sha-trivy' }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_TRIVY_CHECKSUMS'] = '{not' - expect(() => getTrivyChecksums()).toThrow(/Trivy.*not valid JSON/) - }) - }) - - describe('requireTrivyChecksum', () => { - it('returns undefined in dev mode', () => { - delete process.env['INLINED_TRIVY_CHECKSUMS'] - expect(requireTrivyChecksum('trivy-darwin-arm64')).toBeUndefined() - }) - - it('returns checksum for a known asset', () => { - process.env['INLINED_TRIVY_CHECKSUMS'] = JSON.stringify({ - 'trivy-darwin-arm64': 'sha-trivy', - }) - expect(requireTrivyChecksum('trivy-darwin-arm64')).toBe('sha-trivy') - }) - - it('throws for unknown asset in production mode', () => { - process.env['INLINED_TRIVY_CHECKSUMS'] = JSON.stringify({ - 'trivy-darwin-arm64': 'sha-trivy', - }) - expect(() => requireTrivyChecksum('trivy-windows-x64')).toThrow( - /Trivy has no SHA-256 checksum/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/env/trivy-version.test.mts b/packages/cli/test/unit/env/trivy-version.test.mts deleted file mode 100644 index d9a86dedc..000000000 --- a/packages/cli/test/unit/env/trivy-version.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Unit tests for Trivy version getter. - * - * Related Files: - src/env/trivy-version.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getTrivyVersion } from '../../../src/env/trivy-version.mts' - -describe('env/trivy-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_TRIVY_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_TRIVY_VERSION'] = original - } else { - delete process.env['INLINED_TRIVY_VERSION'] - } - }) - - it('returns the version string when the env var is set', () => { - process.env['INLINED_TRIVY_VERSION'] = '0.50.0' - expect(getTrivyVersion()).toBe('0.50.0') - }) - - it('throws when the env var is missing', () => { - delete process.env['INLINED_TRIVY_VERSION'] - expect(() => getTrivyVersion()).toThrow(/INLINED_TRIVY_VERSION/) - }) - - it('throws when the env var is the empty string', () => { - process.env['INLINED_TRIVY_VERSION'] = '' - expect(() => getTrivyVersion()).toThrow(/INLINED_TRIVY_VERSION/) - }) -}) diff --git a/packages/cli/test/unit/env/trufflehog-checksums.test.mts b/packages/cli/test/unit/env/trufflehog-checksums.test.mts deleted file mode 100644 index 69b04d0f4..000000000 --- a/packages/cli/test/unit/env/trufflehog-checksums.test.mts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Unit tests for TruffleHog checksums getter. - * - * Related Files: - src/env/trufflehog-checksums.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getTrufflehogChecksums, - requireTrufflehogChecksum, -} from '../../../src/env/trufflehog-checksums.mts' - -describe('env/trufflehog-checksums', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] = original - } else { - delete process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] - } - }) - - describe('getTrufflehogChecksums', () => { - it('returns empty object when env is missing (dev mode)', () => { - delete process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] - expect(getTrufflehogChecksums()).toEqual({}) - }) - - it('parses inlined JSON checksums', () => { - process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] = JSON.stringify({ - 'trufflehog-darwin-arm64': 'sha-th', - }) - expect(getTrufflehogChecksums()).toEqual({ - 'trufflehog-darwin-arm64': 'sha-th', - }) - }) - - it('throws when env contains malformed JSON', () => { - process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] = '{not' - expect(() => getTrufflehogChecksums()).toThrow( - /TruffleHog.*not valid JSON/, - ) - }) - }) - - describe('requireTrufflehogChecksum', () => { - it('returns undefined in dev mode', () => { - delete process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] - expect( - requireTrufflehogChecksum('trufflehog-darwin-arm64'), - ).toBeUndefined() - }) - - it('returns checksum for a known asset', () => { - process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] = JSON.stringify({ - 'trufflehog-darwin-arm64': 'sha-th', - }) - expect(requireTrufflehogChecksum('trufflehog-darwin-arm64')).toBe( - 'sha-th', - ) - }) - - it('throws for unknown asset in production mode', () => { - process.env['INLINED_TRUFFLEHOG_CHECKSUMS'] = JSON.stringify({ - 'trufflehog-darwin-arm64': 'sha-th', - }) - expect(() => requireTrufflehogChecksum('trufflehog-windows-x64')).toThrow( - /TruffleHog has no SHA-256 checksum/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/env/trufflehog-version.test.mts b/packages/cli/test/unit/env/trufflehog-version.test.mts deleted file mode 100644 index c9aae230f..000000000 --- a/packages/cli/test/unit/env/trufflehog-version.test.mts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Unit tests for TruffleHog version getter. - * - * Related Files: - src/env/trufflehog-version.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { getTrufflehogVersion } from '../../../src/env/trufflehog-version.mts' - -describe('env/trufflehog-version', () => { - let original: string | undefined - - beforeEach(() => { - original = process.env['INLINED_TRUFFLEHOG_VERSION'] - }) - - afterEach(() => { - if (original !== undefined) { - process.env['INLINED_TRUFFLEHOG_VERSION'] = original - } else { - delete process.env['INLINED_TRUFFLEHOG_VERSION'] - } - }) - - it('returns the version string when the env var is set', () => { - process.env['INLINED_TRUFFLEHOG_VERSION'] = '3.80.0' - expect(getTrufflehogVersion()).toBe('3.80.0') - }) - - it('throws when the env var is missing', () => { - delete process.env['INLINED_TRUFFLEHOG_VERSION'] - expect(() => getTrufflehogVersion()).toThrow(/INLINED_TRUFFLEHOG_VERSION/) - }) - - it('throws when the env var is the empty string', () => { - process.env['INLINED_TRUFFLEHOG_VERSION'] = '' - expect(() => getTrufflehogVersion()).toThrow(/INLINED_TRUFFLEHOG_VERSION/) - }) -}) diff --git a/packages/cli/test/unit/flags.test.mts b/packages/cli/test/unit/flags.test.mts deleted file mode 100644 index f50ebfc9c..000000000 --- a/packages/cli/test/unit/flags.test.mts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * @file Unit tests for CLI flag definitions and memory management. Tests the - * flag system including common flags, output flags, validation flags, and - * dynamic memory configuration based on system resources. Test Coverage: - * - * - getMaxOldSpaceSizeFlag: Default based on system memory (75% of RAM), CLI - * flag override - * - getMaxSemiSpaceSizeFlag: Calculation based on old space size, CLI flag - * override - * - commonFlags: Banner, compactHeader, config, dryRun, help, helpFull, - * maxOldSpaceSize, maxSemiSpaceSize, spinner flags - * - outputFlags: JSON and markdown output format flags - * - validationFlags: All and strict validation mode flags - * - Flag structure validation (type, description, shortFlag properties) Testing - * Approach: - * - Mock meow to control CLI flag parsing - * - Test flag calculations with various memory configurations - * - Validate flag metadata and structure Related Files: - * - src/flags.mts - Flag definitions and memory management - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Store mock values that can be changed per test. -const mockValues = vi.hoisted(() => ({ - maxOldSpaceSize: 0, - maxSemiSpaceSize: 0, - nodeOptions: '', - totalMem: 8 * 1024 * 1024 * 1024, // 8GB default. -})) - -// Mock meow to return controlled flag values. defineFlags is re-exported -// as the identity helper since flags.mts uses it to declare commonFlags -// / outputFlags / validationFlags. -vi.mock('../../src/meow.mts', () => ({ - meow: vi.fn(() => ({ - flags: { - maxOldSpaceSize: mockValues.maxOldSpaceSize, - maxSemiSpaceSize: mockValues.maxSemiSpaceSize, - }, - })), - defineFlags: <T,>(flags: T): T => flags, -})) - -// Mock node:os to control total memory. -vi.mock('node:os', async importOriginal => { - const original = await importOriginal<typeof OsModule>() - return { - ...original, - default: { - ...original.default, - totalmem: () => mockValues.totalMem, - }, - } -}) - -// Mock NODE_OPTIONS to be controllable. -vi.mock('../../src/env/node-options.mts', () => ({ - get NODE_OPTIONS() { - return mockValues.nodeOptions - }, -})) - -import { - commonFlags, - getMaxOldSpaceSizeFlag, - getMaxSemiSpaceSizeFlag, - outputFlags, - resetFlagCache, -} from '../../src/flags.mts' - -import type * as OsModule from 'node:os' - -describe('flags', () => { - beforeEach(() => { - vi.clearAllMocks() - resetFlagCache() - // Reset mock values to defaults. - mockValues.maxOldSpaceSize = 0 - mockValues.maxSemiSpaceSize = 0 - mockValues.nodeOptions = '' - mockValues.totalMem = 8 * 1024 * 1024 * 1024 // 8GB. - }) - - afterEach(() => { - resetFlagCache() - }) - - describe('getMaxOldSpaceSizeFlag', () => { - it('returns default based on system memory', () => { - const result = getMaxOldSpaceSizeFlag() - - // Should be 75% of 8GB in MiB. - expect(result).toBe(Math.floor(8 * 1024 * 0.75)) - expect(result).toBe(6144) - }) - - it('respects NODE_OPTIONS', () => { - mockValues.nodeOptions = '--max-old-space-size=512' - resetFlagCache() - - const result = getMaxOldSpaceSizeFlag() - expect(result).toBe(512) - }) - - it('respects user-provided flag', () => { - mockValues.maxOldSpaceSize = 1024 - resetFlagCache() - - const result = getMaxOldSpaceSizeFlag() - expect(result).toBe(1024) - }) - - it('handles low memory systems', () => { - mockValues.maxOldSpaceSize = 256 - resetFlagCache() - - const result = getMaxOldSpaceSizeFlag() - // Should respect the explicitly set low value. - expect(result).toBe(256) - }) - - it('calculates for 4GB system', () => { - mockValues.totalMem = 4 * 1024 * 1024 * 1024 // 4GB. - resetFlagCache() - - const result = getMaxOldSpaceSizeFlag() - // Should be 75% of 4GB in MiB = 3072. - expect(result).toBe(3072) - }) - - it('handles invalid NODE_OPTIONS value gracefully', () => { - // Set NODE_OPTIONS with an invalid pattern (non-numeric after equals). - // Since the regex only matches digits, this will fall through to default. - mockValues.nodeOptions = '--max-old-space-size=abc' - resetFlagCache() - - const result = getMaxOldSpaceSizeFlag() - // Should fall back to default (75% of 8GB). - expect(result).toBe(6144) - }) - }) - - describe('getMaxSemiSpaceSizeFlag', () => { - it('calculates based on old space size for small heaps', () => { - // With default 8GB, old space is 6144 MiB, so semi should be 64. - const result = getMaxSemiSpaceSizeFlag() - expect(result).toBe(64) - }) - - it('respects NODE_OPTIONS', () => { - mockValues.nodeOptions = '--max-semi-space-size=16' - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - expect(result).toBe(16) - }) - - it('respects user-provided flag', () => { - mockValues.maxSemiSpaceSize = 32 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - expect(result).toBe(32) - }) - - it('scales for very small heaps', () => { - mockValues.maxOldSpaceSize = 512 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // 512 MiB heap should use 4 MiB semi-space. - expect(result).toBe(4) - }) - - it('scales for large heaps', () => { - mockValues.maxOldSpaceSize = 16384 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // 16384 MiB (16 GiB) heap: log2(16384) = 14, 14 * 8 = 112. - expect(result).toBe(112) - }) - - it('scales for medium heaps', () => { - mockValues.maxOldSpaceSize = 2048 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // 2048 MiB heap should use 16 MiB semi-space. - expect(result).toBe(16) - }) - - it('scales for 1024 MiB heap', () => { - mockValues.maxOldSpaceSize = 1024 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // 1024 MiB heap should use 8 MiB semi-space. - expect(result).toBe(8) - }) - - it('scales for 4096 MiB heap', () => { - mockValues.maxOldSpaceSize = 4096 - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // 4096 MiB heap should use 32 MiB semi-space. - expect(result).toBe(32) - }) - - it('handles invalid NODE_OPTIONS for semi-space gracefully', () => { - // Set NODE_OPTIONS with a non-matching pattern. - mockValues.nodeOptions = '--max-semi-space-size=xyz' - resetFlagCache() - - const result = getMaxSemiSpaceSizeFlag() - // Should fall back to calculated default based on old space. - expect(result).toBe(64) // Default for 6144 MiB old space. - }) - }) - - describe('commonFlags', () => { - it('exports common CLI flags', () => { - expect(commonFlags).toBeDefined() - expect(typeof commonFlags).toBe('object') - - // Check for expected common flags. - expect(commonFlags).toHaveProperty('banner') - expect(commonFlags).toHaveProperty('compactHeader') - expect(commonFlags).toHaveProperty('config') - expect(commonFlags).toHaveProperty('dryRun') - expect(commonFlags).toHaveProperty('help') - expect(commonFlags).toHaveProperty('helpFull') - expect(commonFlags).toHaveProperty('maxOldSpaceSize') - expect(commonFlags).toHaveProperty('maxSemiSpaceSize') - expect(commonFlags).toHaveProperty('spinner') - - // Check flag types. - expect(commonFlags.banner?.type).toBe('boolean') - expect(commonFlags.compactHeader?.type).toBe('boolean') - expect(commonFlags.config?.type).toBe('string') - expect(commonFlags.dryRun?.type).toBe('boolean') - expect(commonFlags.help?.type).toBe('boolean') - expect(commonFlags.helpFull?.type).toBe('boolean') - expect(commonFlags.maxOldSpaceSize?.type).toBe('number') - expect(commonFlags.maxSemiSpaceSize?.type).toBe('number') - expect(commonFlags.spinner?.type).toBe('boolean') - }) - - it('has descriptions for all flags', () => { - for (const [, flag] of Object.entries(commonFlags)) { - expect(flag).toHaveProperty('description') - expect(typeof flag.description).toBe('string') - expect(flag.description.length).toBeGreaterThan(0) - } - }) - - it('has short flags for common options', () => { - expect(commonFlags.config?.shortFlag).toBe('c') - expect(commonFlags.help?.shortFlag).toBe('h') - }) - - it('exposes default getters for memory flags', () => { - // The max*SpaceSize flags use accessor properties for default. - // Reading them invokes the getter body. - const oldDefault = (commonFlags.maxOldSpaceSize as unknown)?.default - const semiDefault = (commonFlags.maxSemiSpaceSize as unknown)?.default - expect(typeof oldDefault).toBe('number') - expect(typeof semiDefault).toBe('number') - expect(oldDefault).toBeGreaterThanOrEqual(0) - expect(semiDefault).toBeGreaterThanOrEqual(0) - }) - }) - - describe('outputFlags', () => { - it('exports output formatting flags', () => { - expect(outputFlags).toBeDefined() - expect(typeof outputFlags).toBe('object') - - // Check for expected output flags. - expect(outputFlags).toHaveProperty('json') - expect(outputFlags).toHaveProperty('markdown') - - // Check flag types. - expect(outputFlags.json?.type).toBe('boolean') - expect(outputFlags.markdown?.type).toBe('boolean') - }) - - it('has descriptions for all flags', () => { - for (const [, flag] of Object.entries(outputFlags)) { - expect(flag).toHaveProperty('description') - expect(typeof flag.description).toBe('string') - expect(flag.description.length).toBeGreaterThan(0) - } - }) - - it('has short flags for output options', () => { - expect(outputFlags.json?.shortFlag).toBe('j') - expect(outputFlags.markdown?.shortFlag).toBe('m') - }) - }) - -}) diff --git a/packages/cli/test/unit/meow.test.mts b/packages/cli/test/unit/meow.test.mts deleted file mode 100644 index 16accb264..000000000 --- a/packages/cli/test/unit/meow.test.mts +++ /dev/null @@ -1,442 +0,0 @@ -/** - * Unit tests for meow CLI helper. - * - * Purpose: Tests the simplified meow-like CLI argument parsing helper. - * - * Test Coverage: - Basic argument parsing - Flag parsing (boolean, string, - * number types) - Short flags and aliases - Default values - Boolean defaults - - * Unknown flag collection - Help text generation - Package.json reading from - * importMeta. - * - * Related Files: - src/meow.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock readPackageJsonSync. -const mockReadPackageJsonSync = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/packages/operations', () => ({ - readPackageJsonSync: mockReadPackageJsonSync, -})) - -import { meow } from '../../src/meow.mts' - -describe('meow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadPackageJsonSync.mockReturnValue({ - name: 'test-cli', - version: '1.0.0', - }) - }) - - describe('basic parsing', () => { - it('parses positional arguments', () => { - const result = meow({ - argv: ['arg1', 'arg2'], - }) - - expect(result.input).toEqual(['arg1', 'arg2']) - }) - - it('returns empty input for no arguments', () => { - const result = meow({ - argv: [], - }) - - expect(result.input).toEqual([]) - }) - - it('uses process.argv.slice(2) by default', () => { - const originalArgv = process.argv - process.argv = ['node', 'script.js', 'test-arg'] - - const result = meow({}) - - process.argv = originalArgv - expect(result.input).toContain('test-arg') - }) - }) - - describe('flag parsing', () => { - it('parses boolean flags', () => { - const result = meow({ - argv: ['--verbose'], - flags: { - verbose: { - type: 'boolean', - }, - }, - }) - - expect(result.flags['verbose']).toBe(true) - }) - - it('parses string flags', () => { - const result = meow({ - argv: ['--name', 'test'], - flags: { - name: { - type: 'string', - }, - }, - }) - - expect(result.flags['name']).toBe('test') - }) - - it('parses number flags', () => { - const result = meow({ - argv: ['--count', '42'], - flags: { - count: { - type: 'number', - }, - }, - }) - - expect(result.flags['count']).toBe(42) - }) - - it('handles short flags', () => { - const result = meow({ - argv: ['-v'], - flags: { - verbose: { - type: 'boolean', - shortFlag: 'v', - }, - }, - }) - - expect(result.flags['verbose']).toBe(true) - }) - - it('handles flag aliases as string', () => { - const result = meow({ - argv: ['--quiet'], - flags: { - verbose: { - type: 'boolean', - alias: 'quiet', - }, - }, - }) - - expect(result.flags['quiet']).toBe(true) - }) - - it('handles flag aliases as array', () => { - const result = meow({ - argv: ['--q'], - flags: { - verbose: { - type: 'boolean', - aliases: ['q', 'quiet'], - }, - }, - }) - - expect(result.flags['q']).toBe(true) - }) - - it('uses default values when flag not provided', () => { - const result = meow({ - argv: [], - flags: { - port: { - type: 'number', - default: 3000, - }, - }, - }) - - expect(result.flags['port']).toBe(3000) - }) - }) - - describe('boolean defaults', () => { - it('applies booleanDefault to undefined boolean flags', () => { - const result = meow({ - argv: [], - flags: { - verbose: { - type: 'boolean', - }, - }, - booleanDefault: false, - }) - - expect(result.flags['verbose']).toBe(false) - }) - - it('does not override explicit boolean flags with booleanDefault', () => { - const result = meow({ - argv: ['--verbose'], - flags: { - verbose: { - type: 'boolean', - }, - }, - booleanDefault: false, - }) - - expect(result.flags['verbose']).toBe(true) - }) - }) - - describe('unknown flags', () => { - it('collects unknown flags when enabled', () => { - const result = meow({ - argv: ['--unknown', '--another-flag'], - flags: {}, - collectUnknownFlags: true, - }) - - expect(result.unknownFlags).toContain('--unknown') - expect(result.unknownFlags).toContain('--another-flag') - }) - - it('returns empty unknownFlags when not collecting', () => { - const result = meow({ - argv: [], - flags: {}, - collectUnknownFlags: false, - }) - - expect(result.unknownFlags).toEqual([]) - }) - }) - - describe('help text', () => { - it('includes description in help text', () => { - const result = meow({ - argv: [], - description: 'A test CLI tool', - }) - - expect(result.help).toContain('A test CLI tool') - }) - - it('includes custom help text', () => { - const result = meow({ - argv: [], - help: 'Usage: test [options]', - }) - - expect(result.help).toContain('Usage: test [options]') - }) - - it('applies help indent to multiline help text', () => { - const result = meow({ - argv: [], - help: 'Line 1\nLine 2', - helpIndent: 4, - }) - - expect(result.help).toContain(' Line 1') - expect(result.help).toContain(' Line 2') - }) - - it('omits description when set to false', () => { - const result = meow({ - argv: [], - description: false, - help: 'Usage: test', - }) - - expect(result.help).not.toContain('undefined') - }) - }) - - describe('package.json reading', () => { - it('reads package.json from importMeta url', () => { - const result = meow({ - argv: [], - importMeta: { url: 'file:///path/to/script.js' } as ImportMeta, - }) - - expect(result.pkg).toEqual({ name: 'test-cli', version: '1.0.0' }) - }) - - it('returns empty object when importMeta is not provided', () => { - const result = meow({ - argv: [], - }) - - expect(result.pkg).toEqual({}) - }) - - it('handles package.json read failure gracefully', () => { - mockReadPackageJsonSync.mockImplementation(() => { - throw new Error('File not found') - }) - - const result = meow({ - argv: [], - importMeta: { url: 'file:///path/to/script.js' } as ImportMeta, - }) - - expect(result.pkg).toEqual({}) - }) - - it('handles readPackageJsonSync returning null with empty fallback', () => { - mockReadPackageJsonSync.mockReturnValueOnce(undefined) - - const result = meow({ - argv: [], - importMeta: { url: 'file:///path/to/script.js' } as ImportMeta, - }) - - expect(result.pkg).toEqual({}) - }) - }) - - describe('showHelp and showVersion', () => { - it('showHelp logs help text', () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit') - }) - - const result = meow({ - argv: [], - help: 'Test help', - }) - - expect(() => result.showHelp()).toThrow('exit') - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Test help'), - ) - expect(mockExit).toHaveBeenCalledWith(2) - - mockExit.mockRestore() - }) - - it('showVersion logs version from package.json', () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit') - }) - - const result = meow({ - argv: [], - importMeta: { url: 'file:///path/to/script.js' } as ImportMeta, - }) - - expect(() => result.showVersion()).toThrow('exit') - expect(mockLogger.log).toHaveBeenCalledWith('1.0.0') - expect(mockExit).toHaveBeenCalledWith(0) - - mockExit.mockRestore() - }) - - it('showVersion logs 0.0.0 when no version in package.json', () => { - mockReadPackageJsonSync.mockReturnValue({}) - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit') - }) - - const result = meow({ - argv: [], - importMeta: { url: 'file:///path/to/script.js' } as ImportMeta, - }) - - expect(() => result.showVersion()).toThrow('exit') - expect(mockLogger.log).toHaveBeenCalledWith('0.0.0') - - mockExit.mockRestore() - }) - }) - - describe('auto help/version', () => { - it('auto-shows version when only --version is in argv and autoVersion is on', () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('exit') - }) as never) - - try { - expect(() => - meow({ - argv: ['--version'], - flags: { version: { type: 'boolean' } }, - importMeta: { url: 'file:///x.js' } as ImportMeta, - autoVersion: true, - }), - ).toThrow('exit') - } finally { - mockExit.mockRestore() - } - }) - - it('auto-shows help when only --help is in argv and autoHelp is on', () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('exit') - }) as never) - - try { - expect(() => - meow({ - argv: ['--help'], - flags: { help: { type: 'boolean' } }, - importMeta: { url: 'file:///x.js' } as ImportMeta, - autoHelp: true, - }), - ).toThrow('exit') - } finally { - mockExit.mockRestore() - } - }) - - it('does not auto-show when autoVersion / autoHelp are off', () => { - const result = meow({ - argv: ['--version'], - flags: { version: { type: 'boolean' } }, - importMeta: { url: 'file:///x.js' } as ImportMeta, - autoVersion: false, - autoHelp: false, - }) - expect((result.flags as unknown).version).toBe(true) - }) - }) - - describe('multiple flags', () => { - it('handles isMultiple flag option', () => { - const result = meow({ - argv: ['--include', 'a', '--include', 'b'], - flags: { - include: { - type: 'string', - isMultiple: true, - }, - }, - }) - - expect(result.flags['include']).toEqual(['a', 'b']) - }) - }) - - describe('number conversion', () => { - it('handles invalid number values gracefully', () => { - const result = meow({ - argv: ['--count', 'not-a-number'], - flags: { - count: { - type: 'number', - }, - }, - }) - - // Value stays as string when NaN. - expect(result.flags['count']).toBe('not-a-number') - }) - }) -}) diff --git a/packages/cli/test/unit/types.test.mts b/packages/cli/test/unit/types.test.mts deleted file mode 100644 index 397b3fe9a..000000000 --- a/packages/cli/test/unit/types.test.mts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Unit tests for TypeScript type definitions. - * - * Tests the type system used throughout the Socket CLI, focusing on the CResult - * pattern for error handling and the configuration object types. - * - * Test Coverage: - CResult type: ValidResult and InvalidResult structures - - * CResult union type: Type narrowing with ok property - SocketCliConfigObject: - * Minimal and full configuration objects - SocketCliConfigObject output - * formats: Text, JSON, markdown, and combined formats - SocketconfigAny: String - * or object config representations - Type guards: isValidResult implementation - * - Type utilities: unwrapResult for extracting values. - * - * Testing Approach: - Type-level testing using TypeScript inference - Runtime - * validation of type behavior - Test type guards and utility functions - - * Validate config object structure variations. - * - * Related Files: - src/types.mts - Core type definitions. - */ - -import { describe, expect, it } from 'vitest' - -import type { - CResult, - InvalidResult, - SocketCliConfigObject, - SocketconfigAny, - ValidResult, -} from '../../src/types.mts' - -describe('types', () => { - describe('CResult type', () => { - it('can represent a valid result', () => { - const validResult: ValidResult<string> = { - ok: true, - value: 'success', - } - - expect(validResult.ok).toBe(true) - expect(validResult.value).toBe('success') - }) - - it('can represent an invalid result', () => { - const invalidResult: InvalidResult = { - ok: false, - error: new Error('Something went wrong'), - } - - expect(invalidResult.ok).toBe(false) - expect(invalidResult.error).toBeInstanceOf(Error) - expect(invalidResult.error.message).toBe('Something went wrong') - }) - - it('can be used as a union type', () => { - // Intentionally defined inline to test type inference in specific context. - const processResult = (value: number): CResult<string> => { - if (value > 0) { - return { ok: true, data: `Positive: ${value}` } - } - return { ok: false, error: new Error('Value must be positive') } - } - - const success = processResult(5) - const failure = processResult(-1) - - if (success.ok) { - expect(success.data).toBe('Positive: 5') - } - - if (!failure.ok) { - expect(failure.error.message).toBe('Value must be positive') - } - }) - }) - - describe('SocketCliConfigObject type', () => { - it('can represent a minimal config', () => { - const config: SocketCliConfigObject = {} - - expect(config.baseURL).toBeUndefined() - expect(config.proxy).toBeUndefined() - expect(config.reportProvider).toBeUndefined() - }) - - it('can represent a full config', () => { - const config: SocketCliConfigObject = { - baseURL: 'https://api.example.com', - proxy: 'http://proxy.example.com:8080', - reportProvider: 'custom-provider', - token: 'test-token', - outputDefault: { - format: ['text'], - }, - outputStderr: false, - issueRules: { - 'high-severity': { - action: 'error', - }, - }, - projectIgnorePaths: ['node_modules', 'dist'], - manifestFiles: { - package: ['package.json'], - }, - enforcedOrgs: { - 'org-name': { - type: ['prod'], - }, - }, - } - - expect(config.baseURL).toBe('https://api.example.com') - expect(config.proxy).toBe('http://proxy.example.com:8080') - expect(config.token).toBe('test-token') - expect(config.outputDefault?.format).toEqual(['text']) - }) - - it('can have various output formats', () => { - const configs: SocketCliConfigObject[] = [ - { outputDefault: { format: ['text'] } }, - { outputDefault: { format: ['json'] } }, - { outputDefault: { format: ['markdown'] } }, - { outputDefault: { format: ['text', 'json'] } }, - ] - - for (let i = 0, { length } = configs; i < length; i += 1) { - const config = configs[i] - expect(config.outputDefault).toBeDefined() - expect(Array.isArray(config.outputDefault?.format)).toBe(true) - } - }) - }) - - describe('SocketconfigAny type', () => { - it('can represent string or object config', () => { - const stringConfig: SocketconfigAny = 'simple-string-config' - const objectConfig: SocketconfigAny = { - baseURL: 'https://api.example.com', - } - - expect(typeof stringConfig).toBe('string') - expect(typeof objectConfig).toBe('object') - }) - }) - - describe('Type guards and utilities', () => { - it('can check if result is valid', () => { - function isValidResult<T>(result: CResult<T>): result is ValidResult<T> { - return result.ok === true - } - - const valid: CResult<number> = { ok: true, value: 42 } - const invalid: CResult<number> = { ok: false, error: new Error('Failed') } - - expect(isValidResult(valid)).toBe(true) - expect(isValidResult(invalid)).toBe(false) - }) - - it('can extract value from valid result', () => { - function unwrapResult<T>(result: CResult<T>): T { - if (result.ok) { - return result.value - } - throw result.error - } - - const valid: CResult<string> = { ok: true, value: 'success' } - expect(unwrapResult(valid)).toBe('success') - - const invalid: CResult<string> = { ok: false, error: new Error('Failed') } - expect(() => unwrapResult(invalid)).toThrow('Failed') - }) - }) -}) diff --git a/packages/cli/test/unit/util/basics/spawn.test.mts b/packages/cli/test/unit/util/basics/spawn.test.mts deleted file mode 100644 index a5a3688e5..000000000 --- a/packages/cli/test/unit/util/basics/spawn.test.mts +++ /dev/null @@ -1,609 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for runSocketBasics. - * - * Spawns the socket-basics Python tool with extracted bundled binaries. Mocks - * the VFS extractors, spawn, fs, and env helpers. - * - * Test Coverage: - * - * - Basics tools unavailable → "Basics tools not available" - * - VFS extraction returns null → "Failed to extract basics tools" - * - Python binary missing after extraction → "Python not found" - * - PyCli already installed (skip pip install) → proceeds - * - PyCli not installed → pip install path; null spawn / non-zero exit / wrong - * version cases - * - Socket_basics not installed → "must be pre-bundled" error - * - Default args (no languages, scanSecrets=true, scanContainers=false) - * - Languages list adds --languages csv - * - ScanContainers=true adds --containers - * - Custom outputPath used as facts path - * - Default factsPath is cwd/.socket.facts.json - * - BasicsResult null → "Failed to start" - * - BasicsResult non-zero → "Socket-basics scan failed" - * - Facts file missing post-scan → "not created" error - * - ParseSocketFacts: empty file → ok with empty findings - * - ParseSocketFacts: invalid JSON → ok with empty findings - * - ParseSocketFacts: file read error → ok with empty findings - * - ParseSocketFacts: well-formed JSON returns finding counts - * - * Related Files: - * - * - Src/util/basics/spawn.mts - Implementation - * - Src/util/basics/vfs-extract.mts - tool availability + extraction (mocked) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAreBasicsToolsAvailable, - mockExistsSync, - mockExtractBasicsTools, - mockGetBasicsToolPaths, - mockGetPyCliVersion, - mockReadFile, - mockSpawn, -} = vi.hoisted(() => ({ - mockAreBasicsToolsAvailable: vi.fn(), - mockExistsSync: vi.fn(), - mockExtractBasicsTools: vi.fn(), - mockGetBasicsToolPaths: vi.fn(), - mockGetPyCliVersion: vi.fn(), - mockReadFile: vi.fn(), - mockSpawn: vi.fn(), -})) - -vi.mock('node:fs', () => ({ - default: { - existsSync: mockExistsSync, - promises: { readFile: mockReadFile }, - }, - existsSync: mockExistsSync, - promises: { readFile: mockReadFile }, -})) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('../../../../src/util/basics/vfs-extract.mts', () => ({ - areBasicsToolsAvailable: mockAreBasicsToolsAvailable, - extractBasicsTools: mockExtractBasicsTools, - getBasicsToolPaths: mockGetBasicsToolPaths, -})) - -vi.mock('../../../../src/env/pycli-version.mts', () => ({ - getPyCliVersion: mockGetPyCliVersion, -})) - -vi.mock('../../../../src/constants.mts', () => ({ - DOT_SOCKET_DOT_FACTS_JSON: '.socket.facts.json', -})) - -const { isSocketBasicsInstalled, isSocketPyCliInstalled, runSocketBasics } = - await import('../../../../src/util/basics/spawn.mts') - -const baseOpts = { - cwd: '/work', - orgSlug: 'org', - repoName: 'repo', -} - -const toolPaths = { - python: '/tools/python', - trivy: '/tools/trivy', - trufflehog: '/tools/trufflehog', - opengrep: '/tools/opengrep', -} - -beforeEach(() => { - vi.clearAllMocks() - mockAreBasicsToolsAvailable.mockReturnValue(true) - mockExtractBasicsTools.mockResolvedValue('/tools') - mockGetBasicsToolPaths.mockReturnValue(toolPaths) - mockExistsSync.mockReturnValue(true) - mockGetPyCliVersion.mockReturnValue('1.2.3') - // Default spawn behavior: every spawn returns success. - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[0] === '-c' && args[1]?.includes('socketsecurity.socketcli')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args[0] === '-c' && args[1]?.includes('socket_basics')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('pip') && args.includes('show')) { - return { code: 0, stdout: 'Version: 1.2.3\n', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - // Default facts file content. - mockReadFile.mockResolvedValue( - JSON.stringify({ - findings: { - sast: [{}, {}], - secrets: [{}], - containers: [], - }, - }), - ) -}) - -describe('runSocketBasics — preflight failures', () => { - it('returns "Basics tools not available" when bundled tools are missing', async () => { - mockAreBasicsToolsAvailable.mockReturnValue(false) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Basics tools not available') - } - }) - - it('returns "Failed to extract basics tools" when extraction returns null', async () => { - mockExtractBasicsTools.mockResolvedValueOnce(undefined) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Failed to extract basics tools') - } - }) - - it('returns "Python not found" when python is absent after extraction', async () => { - // First existsSync (check python) returns false. - mockExistsSync.mockReturnValueOnce(false) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Python not found') - } - }) -}) - -describe('runSocketBasics — pyCli installation', () => { - it('skips pip install when socketsecurity is already installed', async () => { - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - // No pip install or pip show calls. - const pipInstall = mockSpawn.mock.calls.find( - c => Array.isArray(c[1]) && c[1].includes('install'), - ) - expect(pipInstall).toBeUndefined() - }) - - it('runs pip install when socketsecurity is not installed', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: 'no module' } - } - if (args[1]?.includes('socket_basics')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('show')) { - return { code: 0, stdout: 'Version: 1.2.3\n', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - const pipInstall = mockSpawn.mock.calls.find(c => - (c[1] as string[]).includes('install'), - ) - expect(pipInstall).toBeDefined() - }) - - it('errors when pip install spawn returns null', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return undefined as unknown - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Failed to start pip install process') - } - }) - - it('errors when pip install exits with non-zero code', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return { code: 1, stdout: '', stderr: 'pip install boom' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Failed to install Socket Python CLI') - } - }) - - it('errors when pip show fails after install', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('show')) { - return { code: 1, stdout: '', stderr: 'pip show boom' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('verify') - } - }) - - it('errors when installed version does not match expected version', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('show')) { - return { code: 0, stdout: 'Version: 9.9.9\n', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Socket Python CLI version mismatch') - } - }) - - it('handles pip show output with no Version line', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 1, stdout: '', stderr: '' } - } - if (args.includes('install')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args.includes('show')) { - return { code: 0, stdout: 'Name: socketsecurity\n', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Socket Python CLI version mismatch') - } - }) -}) - -describe('runSocketBasics — socket_basics presence', () => { - it('errors when socket_basics is not pre-installed', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args[1]?.includes('socketsecurity.socketcli')) { - return { code: 0, stdout: '', stderr: '' } - } - if (args[1]?.includes('socket_basics')) { - return { code: 1, stdout: '', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('socket_basics package not installed') - } - }) -}) - -describe('runSocketBasics — argument construction', () => { - it('uses default cwd-relative facts path', async () => { - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - // normalizePath converts the path so we just check it includes the trailing filename. - expect(result.data.factsPath).toContain('.socket.facts.json') - } - }) - - it('uses provided outputPath verbatim', async () => { - const result = await runSocketBasics({ - ...baseOpts, - outputPath: '/abs/path/facts.json', - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.factsPath).toBe('/abs/path/facts.json') - } - }) - - it('passes --languages csv when languages array is non-empty', async () => { - await runSocketBasics({ - ...baseOpts, - languages: ['python', 'javascript'], - }) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).toContain('--languages') - expect(spawnedArgs).toContain('python,javascript') - }) - - it('omits --languages when languages array is empty', async () => { - await runSocketBasics({ ...baseOpts, languages: [] }) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).not.toContain('--languages') - }) - - it('adds --secrets when scanSecrets is true (default)', async () => { - await runSocketBasics(baseOpts) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).toContain('--secrets') - }) - - it('omits --secrets when scanSecrets is false', async () => { - await runSocketBasics({ ...baseOpts, scanSecrets: false }) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).not.toContain('--secrets') - }) - - it('adds --containers when scanContainers is true', async () => { - await runSocketBasics({ ...baseOpts, scanContainers: true }) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).toContain('--containers') - }) - - it('omits --containers by default', async () => { - await runSocketBasics(baseOpts) - const spawnedArgs = mockSpawn.mock.calls.at(-1)?.[1] as string[] - expect(spawnedArgs).not.toContain('--containers') - }) - - it('sets PATH and SKIP_* env vars on the basics process', async () => { - await runSocketBasics(baseOpts) - const spawnOpts = mockSpawn.mock.calls.at(-1)?.[2] as { - env: Record<string, string> - } - expect(spawnOpts.env['SKIP_SOCKET_REACH']).toBe('1') - expect(spawnOpts.env['SKIP_SOCKET_SUBMISSION']).toBe('1') - expect(spawnOpts.env['PATH']).toContain('/tools') - }) -}) - -describe('runSocketBasics — basics process result', () => { - it('errors when basics spawn returns null', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args.includes('socket_basics') && args.includes('--org')) { - return undefined as unknown - } - if (args[0] === '-c') { - return { code: 0, stdout: '', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Failed to start socket-basics process') - } - }) - - it('errors when basics process exits non-zero', async () => { - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args.includes('socket_basics') && args.includes('--org')) { - return { code: 1, stdout: '', stderr: 'basics boom' } - } - if (args[0] === '-c') { - return { code: 0, stdout: '', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Socket-basics scan failed') - } - }) - - it('uses spinner stop+fail when basics process exits non-zero (lines 362-363)', async () => { - const stopSpy = vi.fn() - const failSpy = vi.fn() - const startSpy = vi.fn() - const successSpy = vi.fn() - const spinner = { - start: startSpy, - stop: stopSpy, - fail: failSpy, - success: successSpy, - } as unknown - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args.includes('socket_basics') && args.includes('--org')) { - return { code: 1, stdout: '', stderr: 'basics boom' } - } - if (args[0] === '-c') { - return { code: 0, stdout: '', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics({ ...baseOpts, spinner }) - expect(result.ok).toBe(false) - expect(stopSpy).toHaveBeenCalled() - expect(failSpy).toHaveBeenCalled() - }) - - it('uses spinner stop+success on successful basics scan (lines 376-377)', async () => { - const stopSpy = vi.fn() - const failSpy = vi.fn() - const startSpy = vi.fn() - const successSpy = vi.fn() - const spinner = { - start: startSpy, - stop: stopSpy, - fail: failSpy, - success: successSpy, - } as unknown - // Default mock returns code 0 for basics_socket call. - const result = await runSocketBasics({ ...baseOpts, spinner }) - expect(result.ok).toBe(true) - expect(successSpy).toHaveBeenCalled() - }) - - it('uses spinner fail when basicsResult is null (lines 350-351)', async () => { - const stopSpy = vi.fn() - const failSpy = vi.fn() - const successSpy = vi.fn() - const spinner = { - start: vi.fn(), - stop: stopSpy, - fail: failSpy, - success: successSpy, - } as unknown - mockSpawn.mockImplementation(async (_bin, args: string[]) => { - if (args.includes('socket_basics') && args.includes('--org')) { - // null result simulates spawn failure to start. - return undefined as unknown - } - if (args[0] === '-c') { - return { code: 0, stdout: '', stderr: '' } - } - return { code: 0, stdout: '', stderr: '' } - }) - const result = await runSocketBasics({ ...baseOpts, spinner }) - expect(result.ok).toBe(false) - expect(failSpy).toHaveBeenCalled() - }) - - it('errors when facts file is not created', async () => { - // existsSync(python) returns true, existsSync(factsPath) returns false. - let callIndex = 0 - mockExistsSync.mockImplementation(() => { - callIndex++ - // First call is python check; second is the facts check after scan. - return callIndex === 1 - }) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Socket facts file not created') - } - }) -}) - -describe('runSocketBasics — parseSocketFacts', () => { - it('returns finding counts on a well-formed facts file', async () => { - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toMatchObject({ - sast: 2, - secrets: 1, - containers: 0, - }) - } - }) - - it('returns empty findings when the facts file is empty', async () => { - mockReadFile.mockResolvedValueOnce('') - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toEqual({}) - } - }) - - it('returns empty findings on whitespace-only facts content', async () => { - mockReadFile.mockResolvedValueOnce(' \n\n') - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toEqual({}) - } - }) - - it('returns empty findings when JSON is malformed', async () => { - mockReadFile.mockResolvedValueOnce('{not valid json') - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toEqual({}) - } - }) - - it('returns empty findings when fs.readFile throws', async () => { - mockReadFile.mockRejectedValueOnce(new Error('EACCES')) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toEqual({}) - } - }) - - it('handles missing findings sub-keys with zero counts', async () => { - mockReadFile.mockResolvedValueOnce(JSON.stringify({ findings: {} })) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toMatchObject({ - sast: 0, - secrets: 0, - containers: 0, - }) - } - }) - - it('handles top-level facts with no findings field', async () => { - mockReadFile.mockResolvedValueOnce(JSON.stringify({})) - const result = await runSocketBasics(baseOpts) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.findings).toMatchObject({ - sast: 0, - secrets: 0, - containers: 0, - }) - } - }) -}) - -describe('isSocketPyCliInstalled', () => { - it('returns true when spawn exits with code 0', async () => { - mockSpawn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) - expect(await isSocketPyCliInstalled('/usr/bin/python3')).toBe(true) - }) - - it('returns false when spawn exits with non-zero code', async () => { - mockSpawn.mockResolvedValueOnce({ code: 1, stdout: '', stderr: '' }) - expect(await isSocketPyCliInstalled('/usr/bin/python3')).toBe(false) - }) - - it('returns false when spawn rejects (line 38)', async () => { - mockSpawn.mockRejectedValueOnce(new Error('python missing')) - expect(await isSocketPyCliInstalled('/usr/bin/python3')).toBe(false) - }) -}) - -describe('isSocketBasicsInstalled', () => { - it('returns true when spawn exits with code 0', async () => { - mockSpawn.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) - expect(await isSocketBasicsInstalled('/usr/bin/python3')).toBe(true) - }) - - it('returns false when spawn exits with non-zero code', async () => { - mockSpawn.mockResolvedValueOnce({ code: 1, stdout: '', stderr: '' }) - expect(await isSocketBasicsInstalled('/usr/bin/python3')).toBe(false) - }) - - it('returns false when spawn rejects (line 54)', async () => { - mockSpawn.mockRejectedValueOnce(new Error('python missing')) - expect(await isSocketBasicsInstalled('/usr/bin/python3')).toBe(false) - }) -}) diff --git a/packages/cli/test/unit/util/basics/vfs-extract.test.mts b/packages/cli/test/unit/util/basics/vfs-extract.test.mts deleted file mode 100644 index a79e3dad2..000000000 --- a/packages/cli/test/unit/util/basics/vfs-extract.test.mts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Unit tests for basics VFS extraction. - * - * Tests SEA-time extraction of bundled basics tools (Python, Trivy, TruffleHog, - * OpenGrep) via process.smol.mount(). Behavior is gated on SEA mode and the - * smol API; both must be present for extraction to proceed. All FS / spawn - * calls are mocked. - * - * Related Files: - src/util/basics/vfs-extract.mts. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockLogger = vi.hoisted(() => ({ - warn: vi.fn(), - error: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - success: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -const mockSpawn = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -const mockIsSeaBinary = vi.hoisted(() => vi.fn(() => false)) -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - UPDATE_STORE_DIR: '.socket/_dlx', -})) - -import { - areBasicsToolsAvailable, - extractBasicsTools, - getBasicsToolPaths, -} from '../../../../src/util/basics/vfs-extract.mts' - -const realProcessSmol = (process as unknown).smol - -describe('basics/vfs-extract', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsSeaBinary.mockReturnValue(false) - delete (process as unknown).smol - }) - - afterEach(() => { - if (realProcessSmol === undefined) { - delete (process as unknown).smol - } else { - ;(process as unknown).smol = realProcessSmol - } - }) - - describe('areBasicsToolsAvailable', () => { - it('returns false when not in SEA mode', () => { - mockIsSeaBinary.mockReturnValue(false) - ;(process as unknown).smol = { mount: vi.fn() } - expect(areBasicsToolsAvailable()).toBe(false) - }) - - it('returns false when in SEA mode but smol.mount missing', () => { - mockIsSeaBinary.mockReturnValue(true) - delete (process as unknown).smol - expect(areBasicsToolsAvailable()).toBe(false) - }) - - it('returns true when in SEA mode and smol.mount is available', () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown).smol = { mount: vi.fn() } - expect(areBasicsToolsAvailable()).toBe(true) - }) - }) - - describe('extractBasicsTools', () => { - it('returns undefined when not in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(false) - const result = await extractBasicsTools() - expect(result).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Not running in SEA mode'), - ) - }) - - it('returns undefined when smol.mount is missing', async () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown).smol = {} - const result = await extractBasicsTools() - expect(result).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('process.smol.mount'), - ) - }) - - it('extracts and validates all tools', async () => { - mockIsSeaBinary.mockReturnValue(true) - const mountedPaths: Record<string, string> = { - '/snapshot/opengrep': '/cache/opengrep', - '/snapshot/python': '/cache/python', - '/snapshot/trivy': '/cache/trivy', - '/snapshot/trufflehog': '/cache/trufflehog', - } - const mount = vi.fn(async (vfsPath: string) => mountedPaths[vfsPath]!) - ;(process as unknown).smol = { mount } - - mockSpawn.mockResolvedValue({ code: 0, stdout: '1.0.0', stderr: '' }) - - const result = await extractBasicsTools() - - expect(result).toBe('/cache/python') - expect(mount).toHaveBeenCalledTimes(4) - expect(mockSpawn).toHaveBeenCalled() - }) - - it('throws when Python validation fails', async () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown).smol = { - mount: vi.fn(async (p: string) => `/cache/${p.split('/').pop()}`), - } - mockSpawn.mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'oops' }) - - await expect(extractBasicsTools()).rejects.toThrow(/Python.*failed/) - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('throws when a security tool validation fails', async () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown).smol = { - mount: vi.fn(async (p: string) => `/cache/${p.split('/').pop()}`), - } - // Python validates OK, then trivy fails. - mockSpawn - .mockResolvedValueOnce({ code: 0, stdout: 'Python 3.12', stderr: '' }) - .mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'trivy err' }) - - await expect(extractBasicsTools()).rejects.toThrow(/trivy.*failed/) - }) - - it('throws when mount returns falsy for a tool', async () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown).smol = { - mount: vi.fn(async (p: string) => - // python returns empty so the missing-tools check fires. - p === '/snapshot/python' ? '' : `/cache/${p.split('/').pop()}`, - ), - } - - await expect(extractBasicsTools()).rejects.toThrow( - /VFS extraction returned/, - ) - }) - }) - - describe('getBasicsToolPaths', () => { - it('constructs sibling-directory paths from the Python toolsDir', () => { - const paths = getBasicsToolPaths('/cache/abc/python') - expect(paths.python).toContain('python') - expect(paths.opengrep).toContain('opengrep') - expect(paths.trivy).toContain('trivy') - expect(paths.trufflehog).toContain('trufflehog') - }) - }) -}) diff --git a/packages/cli/test/unit/util/cli/completion.test.mts b/packages/cli/test/unit/util/cli/completion.test.mts deleted file mode 100644 index 68b582ecf..000000000 --- a/packages/cli/test/unit/util/cli/completion.test.mts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Unit tests for CLI completion utilities. - * - * Purpose: Tests the bash completion script generation and configuration. - * - * Test Coverage: - * - * - COMPLETION_CMD_PREFIX constant - * - GetCompletionSourcingCommand function - * - GetBashrcDetails function - * - * Related Files: - * - * - Util/cli/completion.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -// Mock getSocketAppDataPath. -const mockGetSocketAppDataPath = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/constants/paths.mts', async importOriginal => { - const actual = (await importOriginal()) as typeof PathsModule - return { - ...actual, - getSocketAppDataPath: mockGetSocketAppDataPath, - } -}) - -import { - COMPLETION_CMD_PREFIX, - getBashrcDetails, - getCompletionSourcingCommand, -} from '../../../../src/util/cli/completion.mts' - -import type * as PathsModule from '../../../../src/constants/paths.mts' - -describe('cli/completion', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('COMPLETION_CMD_PREFIX', () => { - it('has the correct prefix', () => { - expect(COMPLETION_CMD_PREFIX).toBe('complete -F _socket_completion') - }) - }) - - describe('getCompletionSourcingCommand', () => { - it('returns error when completion script does not exist', () => { - mockExistsSync.mockReturnValue(false) - - const result = getCompletionSourcingCommand() - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Tab Completion script not found') - expect(result.cause).toContain('Expected to find completion script') - } - }) - - it('returns sourcing command when completion script exists', () => { - mockExistsSync.mockReturnValue(true) - - const result = getCompletionSourcingCommand() - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toContain('source') - expect(result.data).toContain('socket-completion.bash') - } - }) - - it('uses forward slashes in sourcing command', () => { - mockExistsSync.mockReturnValue(true) - - const result = getCompletionSourcingCommand() - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).not.toContain('\\') - } - }) - }) - - describe('getBashrcDetails', () => { - it('returns error when completion script does not exist', () => { - mockExistsSync.mockReturnValue(false) - - const result = getBashrcDetails('socket') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Tab Completion script not found') - } - }) - - it('returns error when config directory cannot be determined', () => { - mockExistsSync.mockReturnValue(true) - mockGetSocketAppDataPath.mockReturnValue(undefined) - - const result = getBashrcDetails('socket') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Could not determine config directory') - } - }) - - it('returns bashrc details when everything is available', () => { - mockExistsSync.mockReturnValue(true) - mockGetSocketAppDataPath.mockReturnValue('/home/user/.socket/config.json') - - const result = getBashrcDetails('socket') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.targetName).toBe('socket') - expect(result.data.completionCommand).toBe( - `${COMPLETION_CMD_PREFIX} socket`, - ) - expect(result.data.toAddToBashrc).toContain('Socket CLI completion') - expect(result.data.toAddToBashrc).toContain('socket') - expect(result.data.sourcingCommand).toContain('source') - } - }) - - it('uses forward slashes in target path', () => { - mockExistsSync.mockReturnValue(true) - mockGetSocketAppDataPath.mockReturnValue('/home/user/.socket/config.json') - - const result = getBashrcDetails('socket') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.targetPath).not.toContain('\\') - } - }) - - it('handles custom command names', () => { - mockExistsSync.mockReturnValue(true) - mockGetSocketAppDataPath.mockReturnValue('/home/user/.socket/config.json') - - const result = getBashrcDetails('socket-npm') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.targetName).toBe('socket-npm') - expect(result.data.completionCommand).toBe( - `${COMPLETION_CMD_PREFIX} socket-npm`, - ) - expect(result.data.toAddToBashrc).toContain('socket-npm') - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/cli/define-handoff.test.mts b/packages/cli/test/unit/util/cli/define-handoff.test.mts deleted file mode 100644 index 67ef7158d..000000000 --- a/packages/cli/test/unit/util/cli/define-handoff.test.mts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Unit tests for `defineHandoffCommand`. - * - * Locks in the contract that the factory builds the same shape every existing - * hand-off wrapper used to build by hand: a CliSubcommand with `description`, - * `hidden`, and `run` — where `run` parses flags, filters Socket-only flags, - * picks the binary, optionally renders dry-run, optionally tracks telemetry, - * spawns sfw, and forwards the child's exit code or signal. - */ - -import EventEmitter from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockMeowOrExit = vi.hoisted(() => vi.fn()) -const mockFilterFlags = vi.hoisted(() => vi.fn()) -const mockSpawnSfw = vi.hoisted(() => vi.fn()) -const mockSpawnSfwDlx = vi.hoisted(() => vi.fn()) -const mockOutputDryRunExecute = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessStart = vi.hoisted(() => vi.fn()) -const mockTrackSubprocessExit = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/cli/with-subcommands.mts', () => ({ - meowOrExit: mockMeowOrExit, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnSfw: mockSpawnSfw, - spawnSfwDlx: mockSpawnSfwDlx, -})) - -vi.mock('../../../../src/util/dry-run/output.mts', () => ({ - outputDryRunExecute: mockOutputDryRunExecute, -})) - -vi.mock('../../../../src/util/process/cmd.mts', () => ({ - filterFlags: mockFilterFlags, -})) - -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackSubprocessStart: mockTrackSubprocessStart, - trackSubprocessExit: mockTrackSubprocessExit, -})) - -import { defineHandoffCommand } from '../../../../src/util/cli/define-handoff.mts' - -function makeChildProcess() { - const child = new EventEmitter() - const spawnPromise: unknown = Promise.resolve({ - code: 0, - signal: undefined, - stderr: Buffer.from(''), - stdout: Buffer.from(''), - }) - spawnPromise.process = child - return { child, spawnPromise } -} - -describe('defineHandoffCommand', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFilterFlags.mockReturnValue([]) - mockMeowOrExit.mockReturnValue({ - flags: {}, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - }) - mockTrackSubprocessStart.mockResolvedValue(123) - mockTrackSubprocessExit.mockResolvedValue(undefined) - }) - - describe('CliSubcommand shape', () => { - it('returns an object with description, hidden, and run', () => { - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo with sfw', - spawnMode: 'dlx', - examples: ['build'], - }) - expect(cmd.description).toBe('Run cargo with sfw') - expect(cmd.hidden).toBe(false) - expect(typeof cmd.run).toBe('function') - }) - - it('respects hidden=true', () => { - const cmd = defineHandoffCommand({ - name: 'yarn', - description: 'Run yarn with sfw', - spawnMode: 'dlx', - hidden: true, - examples: [], - }) - expect(cmd.hidden).toBe(true) - }) - }) - - describe('spawn mode dispatch', () => { - it('uses spawnSfwDlx when spawnMode is "dlx"', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue(['build']) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: [], - trackTelemetry: false, - supportDryRun: false, - }) - - const runPromise = cmd.run( - ['build'], - { url: import.meta.url } as ImportMeta, - { - parentName: 'socket', - }, - ) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - expect(mockSpawnSfwDlx).toHaveBeenCalledWith(['cargo', 'build'], { - stdio: 'inherit', - }) - expect(mockSpawnSfw).not.toHaveBeenCalled() - } finally { - mockExit.mockRestore() - } - }) - - it('uses spawnSfw when spawnMode is "auto"', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfw.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue(['install', 'cowsay']) - - const cmd = defineHandoffCommand({ - name: 'npm', - description: 'Run npm', - spawnMode: 'auto', - examples: [], - trackTelemetry: false, - supportDryRun: false, - }) - - const runPromise = cmd.run( - ['install', 'cowsay'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket' }, - ) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - expect(mockSpawnSfw).toHaveBeenCalledWith( - ['npm', 'install', 'cowsay'], - { stdio: 'inherit' }, - ) - expect(mockSpawnSfwDlx).not.toHaveBeenCalled() - } finally { - mockExit.mockRestore() - } - }) - }) - - describe('binaryPicker', () => { - it('uses binaryPicker output as the first arg to sfw', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue(['install', 'flask']) - - const cmd = defineHandoffCommand({ - name: 'pip', - description: 'Run pip', - spawnMode: 'dlx', - examples: [], - binaryPicker: ctx => (ctx.invokedAs === 'pip3' ? 'pip3' : 'pip'), - trackTelemetry: false, - supportDryRun: false, - }) - - const runPromise = cmd.run( - ['install', 'flask'], - { url: import.meta.url } as ImportMeta, - { parentName: 'socket', invokedAs: 'pip3' }, - ) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - expect(mockSpawnSfwDlx).toHaveBeenCalledWith( - ['pip3', 'install', 'flask'], - { stdio: 'inherit' }, - ) - } finally { - mockExit.mockRestore() - } - }) - }) - - describe('dry-run', () => { - it('renders dry-run output and bails when --dry-run is set', async () => { - mockMeowOrExit.mockReturnValue({ - flags: { dryRun: true }, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - }) - mockFilterFlags.mockReturnValue(['install']) - - const cmd = defineHandoffCommand({ - name: 'npm', - description: 'Run npm', - spawnMode: 'auto', - examples: [], - supportDryRun: true, - trackTelemetry: false, - }) - - await cmd.run(['install'], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - - expect(mockOutputDryRunExecute).toHaveBeenCalledWith( - 'sfw', - ['npm', 'install'], - 'npm with Socket security scanning', - ) - expect(mockSpawnSfw).not.toHaveBeenCalled() - }) - - it('skips dry-run rendering when supportDryRun is false', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockMeowOrExit.mockReturnValue({ - flags: { dryRun: true }, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - }) - mockFilterFlags.mockReturnValue([]) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: [], - supportDryRun: false, - trackTelemetry: false, - }) - - const runPromise = cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - expect(mockOutputDryRunExecute).not.toHaveBeenCalled() - expect(mockSpawnSfwDlx).toHaveBeenCalled() - } finally { - mockExit.mockRestore() - } - }) - }) - - describe('telemetry', () => { - it('starts and ends telemetry span by default', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfw.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const cmd = defineHandoffCommand({ - name: 'npm', - description: 'Run npm', - spawnMode: 'auto', - examples: [], - }) - - const runPromise = cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - await new Promise(resolve => setImmediate(resolve)) - expect(mockTrackSubprocessStart).toHaveBeenCalledWith('npm') - expect(mockTrackSubprocessExit).toHaveBeenCalledWith('npm', 123, 0) - } finally { - mockExit.mockRestore() - } - }) - - it('skips telemetry when trackTelemetry is false', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: [], - trackTelemetry: false, - }) - - const runPromise = cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - setImmediate(() => child.emit('exit', 0, undefined)) - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - await runPromise - expect(mockTrackSubprocessStart).not.toHaveBeenCalled() - expect(mockTrackSubprocessExit).not.toHaveBeenCalled() - } finally { - mockExit.mockRestore() - } - }) - }) - - describe('exit forwarding', () => { - it('forwards child exit code via process.exit', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: [], - trackTelemetry: false, - }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - // Wait for the listener to register (async spawn resolution). - await new Promise(resolve => setImmediate(resolve)) - child.emit('exit', 42, undefined) - await new Promise(resolve => setImmediate(resolve)) - expect(mockExit).toHaveBeenCalledWith(42) - } finally { - mockExit.mockRestore() - } - }) - - it('forwards child signal via process.kill', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: [], - trackTelemetry: false, - }) - - const mockKill = vi - .spyOn(process, 'kill') - .mockImplementation((() => {}) as unknown) - try { - cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - await new Promise(resolve => setImmediate(resolve)) - child.emit('exit', undefined, 'SIGINT') - await new Promise(resolve => setImmediate(resolve)) - expect(mockKill).toHaveBeenCalledWith(process.pid, 'SIGINT') - } finally { - mockKill.mockRestore() - } - }) - }) - - describe('help text', () => { - it('captures the help template via meowOrExit config', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - let capturedHelp = '' - mockMeowOrExit.mockImplementation((args: unknown) => { - capturedHelp = args.config.help('socket cargo') - return { - flags: {}, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - - const cmd = defineHandoffCommand({ - name: 'cargo', - description: 'Run cargo', - spawnMode: 'dlx', - examples: ['build', 'install ripgrep'], - helpNotes: ['Wrapper note here.'], - trackTelemetry: false, - }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - const runPromise = cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - setImmediate(() => child.emit('exit', 0, undefined)) - await runPromise - await new Promise(resolve => setImmediate(resolve)) - } finally { - mockExit.mockRestore() - } - - expect(capturedHelp).toContain('socket cargo') - expect(capturedHelp).toContain('Examples') - expect(capturedHelp).toContain('$ socket cargo build') - expect(capturedHelp).toContain('$ socket cargo install ripgrep') - expect(capturedHelp).toContain('Wrapper note here.') - expect(capturedHelp).toContain('forwarded to Socket Firewall') - }) - - it('emits the wrapper-on hint when wrapperHint is true', async () => { - const { child, spawnPromise } = makeChildProcess() - mockSpawnSfwDlx.mockResolvedValue({ spawnPromise }) - mockFilterFlags.mockReturnValue([]) - let capturedHelp = '' - mockMeowOrExit.mockImplementation((args: unknown) => { - capturedHelp = args.config.help('socket yarn') - return { - flags: {}, - input: [], - pkg: {}, - showHelp: vi.fn(), - showVersion: vi.fn(), - unknownFlags: [], - } - }) - - const cmd = defineHandoffCommand({ - name: 'yarn', - description: 'Run yarn', - spawnMode: 'dlx', - examples: [], - wrapperHint: true, - trackTelemetry: false, - }) - - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - const runPromise = cmd.run([], { url: import.meta.url } as ImportMeta, { - parentName: 'socket', - }) - setImmediate(() => child.emit('exit', 0, undefined)) - await runPromise - await new Promise(resolve => setImmediate(resolve)) - } finally { - mockExit.mockRestore() - } - - expect(capturedHelp).toContain('socket wrapper on') - }) - }) -}) diff --git a/packages/cli/test/unit/util/cli/with-subcommands-help.test.mts b/packages/cli/test/unit/util/cli/with-subcommands-help.test.mts deleted file mode 100644 index c82ff817c..000000000 --- a/packages/cli/test/unit/util/cli/with-subcommands-help.test.mts +++ /dev/null @@ -1,487 +0,0 @@ -/** - * Unit tests for buildHelpLines (with-subcommands-help.mts). - * - * Covers the bucketed root-help layout, sub-command flat-list path, and the - * --help-full environment-variable expansion. - * - * The bucket layout is driven by the `buckets` option (a map from subcommand - * name → CliBucket). Adding a command to a bucket = one map entry; no parallel - * hand-maintained list to drift. - */ - -import { describe, expect, it, vi } from 'vitest' - -import { buildHelpLines } from '../../../../src/util/cli/with-subcommands-help.mts' - -import type { - CliBuckets, - CliSubcommand, -} from '../../../../src/util/cli/with-subcommands-shared.mts' -import type { MeowFlags } from '../../../../src/flags.mts' - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => ({ - fail: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - }), -})) - -function makeSubcommand( - description: string, - hidden = false, -): CliSubcommand { - return { - description, - hidden, - run: async () => {}, - } -} - -const FLAGS: MeowFlags = { - banner: { type: 'boolean', default: true, description: 'Banner' } as unknown, - spinner: { - type: 'boolean', - default: true, - description: 'Spinner', - } as unknown, - json: { type: 'boolean', description: 'JSON output' } as unknown, - markdown: { type: 'boolean', description: 'Markdown output' } as unknown, -} - -/** - * A representative subcommand registry covering all bucket categories, used by - * most root-help tests. - */ -function rootSubcommands(): Record<string, CliSubcommand> { - const names = [ - // main bucket. - 'fix', - 'optimize', - 'cdxgen', - 'ci', - 'login', - // api bucket. - 'analytics', - 'audit-log', - 'organization', - 'package', - 'repository', - 'scan', - 'threat-feed', - // tools bucket. - 'manifest', - 'npm', - 'npx', - 'pycli', - 'raw-npm', - 'raw-npx', - 'sfw', - // config bucket. - 'config', - 'install', - 'logout', - 'uninstall', - 'whoami', - 'wrapper', - // unbucketed (registered + reachable but not surfaced in - // top-level help). - 'ask', - 'bundler', - 'mcp', - ] - const subs: Record<string, CliSubcommand> = {} - for (let i = 0, { length } = names; i < length; i += 1) { - const n = names[i] - subs[n] = makeSubcommand(`${n} description`) - } - return subs -} - -/** - * Bucket assignments mirroring `rootCommandBuckets` in src/commands.mts. Tests - * pass this through `buildHelpLines`. - */ -const ROOT_BUCKETS: CliBuckets = { - // main. - fix: 'main', - optimize: 'main', - cdxgen: 'main', - ci: 'main', - login: 'main', - // api. - analytics: 'api', - 'audit-log': 'api', - organization: 'api', - package: 'api', - repository: 'api', - scan: 'api', - 'threat-feed': 'api', - // tools. - manifest: 'tools', - npm: 'tools', - npx: 'tools', - pycli: 'tools', - 'raw-npm': 'tools', - 'raw-npx': 'tools', - sfw: 'tools', - // config. - config: 'config', - install: 'config', - logout: 'config', - uninstall: 'config', - whoami: 'config', - wrapper: 'config', -} - -describe('buildHelpLines', () => { - describe('root-command bucketed layout', () => { - it('emits Usage / Main commands / Socket API / Local tools / CLI configuration buckets', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - expect(blob).toContain('Usage') - expect(blob).toContain('$ socket <command>') - expect(blob).toContain('Main commands') - expect(blob).toContain('Socket API') - expect(blob).toContain('Local tools') - expect(blob).toContain('CLI configuration') - }) - - it('places each bucketed command under its assigned section', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - - // Main commands bucket. - const mainStart = blob.indexOf('Main commands') - const apiStart = blob.indexOf('Socket API') - expect(mainStart).toBeGreaterThan(-1) - expect(apiStart).toBeGreaterThan(mainStart) - - // 'optimize' is in main; should appear between mainStart and apiStart. - const optimizeIdx = blob.indexOf('optimize') - expect(optimizeIdx).toBeGreaterThan(mainStart) - expect(optimizeIdx).toBeLessThan(apiStart) - - // 'analytics' is in api; should appear after apiStart. - const analyticsIdx = blob.indexOf('analytics') - expect(analyticsIdx).toBeGreaterThan(apiStart) - }) - - it('omits unbucketed commands from the top-level layout', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - // 'mcp' is registered but unbucketed — should not appear in - // any of the four bucket sections. (The Options block / env - // section don't render command names.) - expect(blob).not.toContain('mcp description') - }) - - it('skips bucket entries whose subcommand is not registered (line 212)', () => { - // A bucket entry that has no matching subcommand should be silently - // skipped — defensive guard for stale buckets vs. removed commands. - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: { fix: 'main', 'ghost-command': 'main' } as unknown, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: { fix: makeSubcommand('fix description') } as unknown, - }) - const blob = lines.join('\n') - expect(blob).toContain('fix') - expect(blob).not.toContain('ghost-command') - }) - - it('emits hero rows in the Main commands bucket', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - // The static hero rows that aren't standalone commands. - expect(blob).toContain('socket scan create') - expect(blob).toContain('socket npm/lodash@4.17.21') - }) - - it('renders bucket sections in the canonical order: main, api, tools, config', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - const idxMain = blob.indexOf('Main commands') - const idxApi = blob.indexOf('Socket API') - const idxTools = blob.indexOf('Local tools') - const idxConfig = blob.indexOf('CLI configuration') - expect(idxMain).toBeGreaterThan(-1) - expect(idxApi).toBeGreaterThan(idxMain) - expect(idxTools).toBeGreaterThan(idxApi) - expect(idxConfig).toBeGreaterThan(idxTools) - }) - - it('skips empty buckets', () => { - // Subcommands that only fill api + config; main and tools have - // no entries (and main has hero rows so it still renders). - const apiOnlySubs: Record<string, CliSubcommand> = { - analytics: makeSubcommand('analytics description'), - config: makeSubcommand('config description'), - } - const apiOnlyBuckets: CliBuckets = { - analytics: 'api', - config: 'config', - } - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: apiOnlyBuckets, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: apiOnlySubs, - }) - const blob = lines.join('\n') - // main has hero rows so it always renders even when empty. - expect(blob).toContain('Main commands') - expect(blob).toContain('Socket API') - expect(blob).toContain('CLI configuration') - // Local tools has no entries AND no hero rows → suppressed. - expect(blob).not.toContain('Local tools') - }) - - it('skips hidden subcommands during bucket grouping', () => { - const subs = rootSubcommands() - // Mark analytics hidden — should be excluded from api section. - subs['analytics'] = makeSubcommand('analytics description', true) - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: subs, - }) - const blob = lines.join('\n') - // Hidden command should not surface. - expect(blob).not.toContain('analytics description') - // Other commands in the same bucket should still render. - expect(blob).toContain('Socket API') - expect(blob).toContain('audit-log') - }) - - it('falls back to a buckets-less render when no buckets are passed', () => { - // Backwards-compat for downstream callers that don't yet pass - // a buckets map: the root layout still renders the static hero - // rows (Main commands has hero rows that don't depend on bucket - // assignments). - const lines = buildHelpLines({ - aliases: {}, - argv: [], - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - // Hero rows still render. - expect(blob).toContain('Main commands') - expect(blob).toContain('socket scan create') - // No api / tools / config sections (no bucket assignments). - expect(blob).not.toContain('Socket API') - expect(blob).not.toContain('Local tools') - expect(blob).not.toContain('CLI configuration') - }) - }) - - describe('--help-full', () => { - it('shows condensed env-var hint when --help-full is absent', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: ['--help'], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - expect(blob).toContain('Environment variables [more') - expect(blob).toContain('--help-full') - expect(blob).not.toContain('SOCKET_CLI_API_TOKEN') - }) - - it('expands all env vars when --help-full is present', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: ['--help-full'], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: rootSubcommands(), - }) - const blob = lines.join('\n') - expect(blob).toContain('Environment variables') - expect(blob).toContain('SOCKET_CLI_API_TOKEN') - expect(blob).toContain('SOCKET_CLI_CONFIG') - expect(blob).toContain('GITHUB_API_URL') - expect(blob).toContain('SOCKET_CLI_GIT_USER_EMAIL') - expect(blob).toContain('SOCKET_CLI_GIT_USER_NAME') - expect(blob).toContain('SOCKET_CLI_GITHUB_TOKEN') - expect(blob).toContain('SOCKET_CLI_NPM_PATH') - expect(blob).toContain('SOCKET_CLI_ORG_SLUG') - expect(blob).toContain('SOCKET_CLI_ACCEPT_RISKS') - expect(blob).toContain('SOCKET_CLI_VIEW_ALL_RISKS') - expect(blob).toContain('Environment variables for development') - expect(blob).toContain('SOCKET_CLI_API_BASE_URL') - expect(blob).toContain('SOCKET_CLI_API_PROXY') - expect(blob).toContain('SOCKET_CLI_API_TIMEOUT') - expect(blob).toContain('SOCKET_CLI_DEBUG') - expect(blob).toContain('DEBUG') - }) - }) - - describe('sub-command flat layout', () => { - it('emits a flat alphabetised Commands list', () => { - const subs: Record<string, CliSubcommand> = { - create: makeSubcommand('Create a scan'), - list: makeSubcommand('List scans'), - del: makeSubcommand('Delete a scan'), - } - const lines = buildHelpLines({ - aliases: {}, - argv: [], - flags: FLAGS, - isRootCommand: false, - name: 'socket scan', - subcommands: subs, - }) - const blob = lines.join('\n') - expect(blob).toContain('Commands') - expect(blob).toContain('create') - expect(blob).toContain('list') - expect(blob).toContain('del') - // Should not have root-only buckets. - expect(blob).not.toContain('Main commands') - expect(blob).not.toContain('Socket API') - }) - - it('excludes hidden subcommands and aliases pointing at hidden cmds', () => { - const subs: Record<string, CliSubcommand> = { - create: makeSubcommand('Create a scan'), - secret: makeSubcommand('Hidden cmd', true), - } - const aliases = { - c: { description: 'alias for create', argv: ['create'] as const }, - s: { description: 'alias for secret', argv: ['secret'] as const }, - // alias marked hidden directly. - h: { - description: 'always hidden', - argv: ['create'] as const, - hidden: true, - }, - } - const lines = buildHelpLines({ - aliases, - argv: [], - flags: FLAGS, - isRootCommand: false, - name: 'socket scan', - subcommands: subs, - }) - const blob = lines.join('\n') - expect(blob).toContain('create') - expect(blob).toContain('alias for create') - // Hidden subcommand never appears. - expect(blob).not.toContain('Hidden cmd') - // Alias pointing at hidden command excluded. - expect(blob).not.toContain('alias for secret') - // Alias marked hidden excluded. - expect(blob).not.toContain('always hidden') - }) - - it('does not emit env-var section for sub-commands', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: ['--help-full'], - flags: FLAGS, - isRootCommand: false, - name: 'socket scan', - subcommands: { - create: makeSubcommand('Create a scan'), - }, - }) - const blob = lines.join('\n') - expect(blob).not.toContain('Environment variables') - }) - }) - - describe('Options block', () => { - it('always renders an Options heading', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - flags: FLAGS, - isRootCommand: false, - name: 'socket scan', - subcommands: { - create: makeSubcommand('Create a scan'), - }, - }) - expect(lines).toContain('Options') - }) - - it('annotates the root Options heading with always-available note', () => { - const lines = buildHelpLines({ - aliases: {}, - argv: [], - buckets: ROOT_BUCKETS, - flags: FLAGS, - isRootCommand: true, - name: 'socket', - subcommands: { - login: makeSubcommand('Log in'), - }, - }) - const blob = lines.join('\n') - expect(blob).toContain('Options') - expect(blob).toContain('All commands have these flags') - }) - }) -}) diff --git a/packages/cli/test/unit/util/cli/with-subcommands.test.mts b/packages/cli/test/unit/util/cli/with-subcommands.test.mts deleted file mode 100644 index 1f2f382b4..000000000 --- a/packages/cli/test/unit/util/cli/with-subcommands.test.mts +++ /dev/null @@ -1,1106 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for CLI subcommand handling. - * - * Purpose: Tests CLI subcommand registration and routing. Validates command - * tree structure and subcommand dispatch. - * - * Test Coverage: - Subcommand registration - Command routing - Help text for - * subcommands - Nested subcommand support - Command aliasing. - * - * Testing Approach: Mocks meow CLI framework and tests command tree - * construction. - * - * Related Files: - util/cli/with-subcommands.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { meow } from '../../../../src/meow.mts' -import { - description, - emitBanner, - findBestCommandMatch, - getHeaderTheme, - getTokenOrigin, - levenshteinDistance, - meowOrExit, - meowWithSubcommands, - shouldAnimateHeader, - shouldSuppressBanner, - stripAnsi, -} from '../../../../src/util/cli/with-subcommands.mts' - -// Mock meow. -const mockGetConfigValueOrUndef = vi.hoisted(() => vi.fn()) -const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) -const mockOverrideCachedConfig = vi.hoisted(() => vi.fn()) -const mockOverrideConfigApiToken = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn(() => false)) -const mockGetVisibleTokenPrefix = vi.hoisted(() => vi.fn(() => 'test')) -const mockSocketPackageLink = vi.hoisted(() => vi.fn(pkg => pkg)) - -vi.mock('../../../../src/meow.mts', () => ({ - // Identity helper used by flags.mts (commonFlags / outputFlags / - // validationFlags) and by per-command flag blocks. Test mock just - // returns the schema unchanged. - defineFlags: <T,>(flags: T): T => flags, - meow: vi.fn((helpText, options) => { - // Simulate meow processing flags with defaults. - const argv = options?.argv || [] - const processedFlags = {} - if (options?.flags) { - for (const [key, flag] of Object.entries(options.flags)) { - // Check if flag is present in argv. - const flagName = `--${key}` - const shortFlag = flag.shortFlag ? `-${flag.shortFlag}` : undefined - const isPresent = - argv.includes(flagName) || (shortFlag && argv.includes(shortFlag)) - - // @ts-expect-error - Mock implementation. - if (isPresent && flag.type === 'boolean') { - processedFlags[key] = true - } else { - processedFlags[key] = - flag.default !== undefined ? flag.default : undefined - } - } - } - return { - flags: processedFlags, - input: options?.argv || [], - help: helpText || '', - showHelp: vi.fn(() => { - throw new Error('SHOW_HELP') - }), - showVersion: vi.fn(() => { - throw new Error('SHOW_VERSION') - }), - } - }), -})) - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -// Mock config utilities. -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, - isConfigFromFlag: mockIsConfigFromFlag, - overrideCachedConfig: mockOverrideCachedConfig, - overrideConfigApiToken: mockOverrideConfigApiToken, -})) - -// Mock debug utility. -vi.mock('../../../../src/util/debug.mts', () => ({ - isDebug: mockIsDebug, -})) - -// Mock SDK utility. -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getVisibleTokenPrefix: mockGetVisibleTokenPrefix, -})) - -// Mock terminal link utility. -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - socketPackageLink: mockSocketPackageLink, -})) - -// Mock process.exit. -vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called') -}) - -describe('meow-with-subcommands', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('meowOrExit', () => { - const mockConfig = { - commandName: 'test', - description: 'Test command', - flags: {}, - help: vi.fn(() => 'Test help text'), - } - - it('creates a meow instance with basic options', () => { - const result = meowOrExit( - { - argv: ['test'], - config: mockConfig, - importMeta: import.meta, - }, - { - flags: { - verbose: { - type: 'boolean', - shortFlag: 'v', - }, - }, - }, - ) - - expect(result).toHaveProperty('flags') - expect(result).toHaveProperty('input') - expect(result).toHaveProperty('help') - }) - - it('works with parent name', () => { - const result = meowOrExit( - { - argv: [], - config: mockConfig, - importMeta: import.meta, - parentName: 'socket', - }, - { - flags: { - version: { - type: 'boolean', - shortFlag: 'V', - }, - }, - }, - ) - - expect(result).toHaveProperty('flags') - expect(result).toHaveProperty('input') - }) - - it('processes config with custom flags', () => { - const configWithPort = { - ...mockConfig, - flags: { - port: { - type: 'number', - default: 3000, - }, - }, - } - - const result = meowOrExit( - { - argv: [], - config: configWithPort, - importMeta: import.meta, - }, - { - allowUnknownFlags: true, - }, - ) - - // Verify that meow was called. - const meowMock = vi.mocked(meow) - expect(meowMock).toHaveBeenCalled() - - // The function returns a Result from meow. - expect(result).toHaveProperty('flags') - expect(result).toHaveProperty('input') - }) - - it('handles config parameter', () => { - const configWithApiToken = { - ...mockConfig, - apiToken: 'test-token', - } - - const result = meowOrExit( - { - argv: [], - config: configWithApiToken, - importMeta: import.meta, - }, - { - flags: {}, - }, - ) - - expect(result).toHaveProperty('flags') - }) - - it('exits with error when --version flag is set but config does not declare version', async () => { - // Exercises the unknown-version-flag branch (lines 1026-1031). - // The default meow mock doesn't auto-set flags for argv items not in - // declared flags, so we override its return value just for this test - // to simulate meow detecting --version on the cli.flags shape. - const meowMod: unknown = await import('../../../../src/meow.mts') - const meowMock = vi.mocked(meowMod.meow) - meowMock.mockReturnValueOnce({ - flags: { version: true }, - input: ['--version'], - help: '', - showHelp: vi.fn(), - showVersion: vi.fn(), - } as unknown) - - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as unknown) - try { - expect(() => - meowOrExit( - { - argv: ['--version'], - config: mockConfig, - importMeta: import.meta, - }, - { - flags: {}, - }, - ), - ).toThrow('process.exit called') - expect(exitSpy).toHaveBeenCalledWith(2) - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Unknown flag'), - ) - } finally { - exitSpy.mockRestore() - } - }) - }) - - describe('emitBanner', () => { - it('emits banner with name and org', async () => { - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - - emitBanner('socket', 'test-org', false) - - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('emits compact banner when compact mode is true', async () => { - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - - emitBanner('socket', 'test-org', true) - - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('handles undefined org', async () => { - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - - emitBanner('socket', undefined, false) - - expect(mockLogger.error).toHaveBeenCalled() - }) - }) - - describe('description', () => { - it('returns formatted description for a command', () => { - const result = description({ - description: 'Test command description', - run: vi.fn(), - } as unknown) - expect(result).toBe('Test command description') - }) - - it('returns "undefined" when command is undefined', () => { - // The implementation returns String(undefined) = "undefined" via fallback. - const result = description(undefined) - expect(result).toBe('undefined') - }) - - it('coerces non-string descriptions to string', () => { - const result = description({ - description: 42 as unknown as string, - run: vi.fn(), - } as unknown) - expect(result).toBe('42') - }) - }) - - describe('levenshteinDistance', () => { - it('returns 0 for identical strings', () => { - expect(levenshteinDistance('socket', 'socket')).toBe(0) - }) - - it('returns string length when one string is empty', () => { - expect(levenshteinDistance('', 'abc')).toBe(3) - expect(levenshteinDistance('xyz', '')).toBe(3) - }) - - it('counts substitutions', () => { - expect(levenshteinDistance('cat', 'bat')).toBe(1) - expect(levenshteinDistance('cat', 'dog')).toBe(3) - }) - - it('counts insertions and deletions', () => { - expect(levenshteinDistance('hello', 'helloworld')).toBe(5) - expect(levenshteinDistance('helloworld', 'hello')).toBe(5) - }) - }) - - describe('findBestCommandMatch', () => { - const subcommands = { scan: {}, fix: {}, login: {}, logout: {} } - const aliases = { ls: {} } - - it('returns close match for typo', () => { - const result = findBestCommandMatch('scn', subcommands, aliases) - expect(result).toBe('scan') - }) - - it('returns undefined when nothing close matches', () => { - const result = findBestCommandMatch( - 'completelyunrelated', - subcommands, - aliases, - ) - expect(result).toBeUndefined() - }) - - it('finds matches in aliases', () => { - const result = findBestCommandMatch('lsx', subcommands, aliases) - expect(result).toBe('ls') - }) - - it('matches case-insensitively', () => { - const result = findBestCommandMatch('SCAN', subcommands, aliases) - expect(result).toBe('scan') - }) - }) - - describe('shouldSuppressBanner', () => { - it('suppresses for --json', () => { - expect(shouldSuppressBanner({ json: true })).toBe(true) - }) - - it('suppresses for --markdown', () => { - expect(shouldSuppressBanner({ markdown: true })).toBe(true) - }) - - it('suppresses for --no-banner (banner: false)', () => { - expect(shouldSuppressBanner({ banner: false })).toBe(true) - }) - - it('does not suppress with banner: true', () => { - expect(shouldSuppressBanner({ banner: true })).toBe(false) - }) - - it('does not suppress with empty flags', () => { - expect(shouldSuppressBanner({})).toBe(false) - }) - }) - - describe('stripAnsi', () => { - it('strips ANSI color codes', () => { - expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red') - }) - - it('returns plain text unchanged', () => { - expect(stripAnsi('plain')).toBe('plain') - }) - }) - - describe('getHeaderTheme', () => { - it('returns valid themes from flags', () => { - expect(getHeaderTheme({ headerTheme: 'cyberpunk' })).toBe('cyberpunk') - expect(getHeaderTheme({ headerTheme: 'forest' })).toBe('forest') - expect(getHeaderTheme({ headerTheme: 'ocean' })).toBe('ocean') - expect(getHeaderTheme({ headerTheme: 'sunset' })).toBe('sunset') - }) - - it('falls back to default for unknown themes', () => { - expect(getHeaderTheme({ headerTheme: 'made-up' })).toBe('default') - expect(getHeaderTheme({})).toBe('default') - expect(getHeaderTheme()).toBe('default') - }) - }) - - describe('shouldAnimateHeader', () => { - it('returns false in vitest mode', () => { - // VITEST is true in this test run. - expect(shouldAnimateHeader()).toBe(false) - expect(shouldAnimateHeader({ animateHeader: true })).toBe(false) - }) - }) - - describe('getTokenOrigin', () => { - it('returns a string', () => { - const result = getTokenOrigin() - expect(typeof result).toBe('string') - }) - - it('returns empty string when SOCKET_CLI_NO_API_TOKEN is set (line 49)', () => { - const original = process.env['SOCKET_CLI_NO_API_TOKEN'] - process.env['SOCKET_CLI_NO_API_TOKEN'] = '1' - try { - const result = getTokenOrigin() - expect(result).toBe('') - } finally { - if (original === undefined) { - delete process.env['SOCKET_CLI_NO_API_TOKEN'] - } else { - process.env['SOCKET_CLI_NO_API_TOKEN'] = original - } - } - }) - - it('returns "(env)" when SOCKET_API_TOKEN is set (line 52)', () => { - const originalNo = process.env['SOCKET_CLI_NO_API_TOKEN'] - const original = process.env['SOCKET_API_TOKEN'] - delete process.env['SOCKET_CLI_NO_API_TOKEN'] - process.env['SOCKET_API_TOKEN'] = 'sktsec_test_xxxxxxxxxxxx' - try { - const result = getTokenOrigin() - expect(result).toBe('(env)') - } finally { - if (originalNo !== undefined) { - process.env['SOCKET_CLI_NO_API_TOKEN'] = originalNo - } - if (original === undefined) { - delete process.env['SOCKET_API_TOKEN'] - } else { - process.env['SOCKET_API_TOKEN'] = original - } - } - }) - - // getTokenOrigin() resolves through getSocketApiToken(), which reads - // every token env source — including the legacy SOCKET_API_KEY alias - // and SOCKET_CLI_API_TOKEN. To exercise the config branches we must - // clear ALL of them so the ambient dev-shell key doesn't win the - // earlier (env) branch. Save/clear/restore via a helper so the env - // names (and the one legacy-alias exemption) live in a single place. - // socket-api-token-env: bootstrap -- this list intentionally names the - // legacy SOCKET_API_KEY alias so the test can neutralize it. - const TOKEN_ENV_KEYS = [ - 'SOCKET_CLI_NO_API_TOKEN', - 'SOCKET_API_TOKEN', - 'SOCKET_CLI_API_TOKEN', - 'SOCKET_API_KEY', - ] - function withTokenEnvCleared(fn: () => void): void { - const saved = new Map(TOKEN_ENV_KEYS.map(k => [k, process.env[k]])) - for (let i = 0, { length } = TOKEN_ENV_KEYS; i < length; i += 1) { - delete process.env[TOKEN_ENV_KEYS[i]!] - } - try { - fn() - } finally { - for (const [k, v] of saved) { - if (v !== undefined) { - process.env[k] = v - } - } - } - } - - it('returns "(--config flag)" when token from config-from-flag (line 56)', () => { - withTokenEnvCleared(() => { - // Mock config returns a token AND isConfigFromFlag returns true. - mockGetConfigValueOrUndef.mockReturnValueOnce('sktsec_flag_xxxxxxxxxxxx') - mockIsConfigFromFlag.mockReturnValueOnce(true) - expect(getTokenOrigin()).toBe('(--config flag)') - }) - }) - - it('returns "(config)" when token from persisted config (line 56)', () => { - withTokenEnvCleared(() => { - mockGetConfigValueOrUndef.mockReturnValueOnce( - 'sktsec_persisted_xxxxxxxxxxxx', - ) - mockIsConfigFromFlag.mockReturnValueOnce(false) - expect(getTokenOrigin()).toBe('(config)') - }) - }) - }) - - describe('meowWithSubcommands', () => { - it('runs the matching subcommand by name', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - await meowWithSubcommands({ - name: 'app', - argv: ['scan', '--foo'], - importMeta: import.meta, - subcommands, - }) - expect(runSpy).toHaveBeenCalledWith( - ['--foo'], - import.meta, - expect.objectContaining({ parentName: 'app' }), - ) - }) - - it('resolves an alias to its target command', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - await meowWithSubcommands( - { - name: 'app', - argv: ['s', 'arg'], - importMeta: import.meta, - subcommands, - }, - { - aliases: { - s: { argv: ['scan'], description: 'alias of scan' }, - }, - }, - ) - expect(runSpy).toHaveBeenCalledWith( - ['arg'], - import.meta, - expect.objectContaining({ - parentName: 'app', - invokedAs: 's', - }), - ) - }) - - it('uses defaultSub when first arg is unknown but defaultSub is set', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - await meowWithSubcommands( - { - name: 'app', - argv: ['unknown-arg'], - importMeta: import.meta, - subcommands, - }, - { defaultSub: 'scan' }, - ) - expect(runSpy).toHaveBeenCalled() - }) - - it('uses defaultSub when argv is empty (line 199-200)', async () => { - // No argv at all → commandOrAliasName_ is undefined → defaultSub kicks - // in. This is the line 200 branch. - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - await meowWithSubcommands( - { - name: 'app', - argv: [], - importMeta: import.meta, - subcommands, - }, - { defaultSub: 'scan' }, - ) - expect(runSpy).toHaveBeenCalled() - }) - - it('reports a typo with a suggestion when command is unknown', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - login: { - description: 'login', - run: vi.fn(), - }, - } - process.exitCode = undefined - await meowWithSubcommands({ - name: 'app', - argv: ['scna'], // typo for "scan" - importMeta: import.meta, - subcommands, - }) - expect(process.exitCode).toBe(2) - expect(runSpy).not.toHaveBeenCalled() - // Should have logged the suggestion. - const failCalls = mockLogger.fail.mock.calls.flat().join(' ') - expect(failCalls).toMatch(/scna/) - process.exitCode = undefined - }) - - it('reports an unknown command with no suggestion when none is close', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - process.exitCode = undefined - await meowWithSubcommands({ - name: 'app', - argv: ['totally-different'], - importMeta: import.meta, - subcommands, - }) - expect(process.exitCode).toBe(2) - expect(runSpy).not.toHaveBeenCalled() - process.exitCode = undefined - }) - - it('forwards purl-like arguments via package score shortcut', async () => { - // socket pkg:npm/lodash → calls itself recursively with [package, deep, ...] - const packageRun = vi.fn(async () => undefined) - const subcommands = { - package: { - description: 'package commands', - run: vi.fn(async (argv: unknown) => { - // Simulate package picking deep subcommand. - if (argv[0] === 'deep') { - packageRun(argv) - } - }), - }, - } - await meowWithSubcommands({ - name: 'socket', - argv: ['pkg:npm/lodash@4'], - importMeta: import.meta, - subcommands, - }) - // The run for `package` should have been invoked. - expect(subcommands.package.run).toHaveBeenCalled() - }) - - it('forwards "ecosystem/package" shortcut via package score', async () => { - const subcommands = { - package: { - description: 'package commands', - run: vi.fn(async () => undefined), - }, - } - await meowWithSubcommands({ - name: 'socket', - argv: ['npm/lodash'], - importMeta: import.meta, - subcommands, - }) - expect(subcommands.package.run).toHaveBeenCalled() - }) - - it('shows help for root socket command without args', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - login: { - description: 'login command', - run: vi.fn(async () => undefined), - }, - package: { - description: 'package', - run: vi.fn(async () => undefined), - }, - } - // No argv → root help path. - // showHelp throws in our mock to simulate process.exit, so we just - // verify it didn't crash. - try { - await meowWithSubcommands({ - name: 'socket', - argv: [], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw is expected. - } - // None of the subcommands should have actually run. - expect(subcommands.scan.run).not.toHaveBeenCalled() - }) - - it('shows help for non-root command without args', async () => { - const subcommands = { - nested: { - description: 'nested', - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands({ - name: 'subgroup', - argv: [], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw is expected. - } - expect(subcommands.nested.run).not.toHaveBeenCalled() - }) - - it('skips alias when its target subcommand is hidden', async () => { - const subcommands = { - scan: { - description: 'scan', - hidden: true, - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands( - { - name: 'socket', - argv: [], - importMeta: import.meta, - subcommands, - }, - { - aliases: { - s: { argv: ['scan'], description: 'alias of scan' }, - }, - }, - ) - } catch { - // showHelp throw is expected. - } - // Just confirm no crash. - expect(true).toBe(true) - }) - - it('shows --help-full output for root command when flag passed', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['--help-full'], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw is expected. - } - expect(true).toBe(true) - }) - - it('shows --help-full bucketed help with all canonical subcommands', async () => { - // Provide every subcommand in the canonical Set so the - // commands.delete loop empties the Set and the - // `if (commands.size)` failure-message branch (lines 700-711) - // is skipped, exercising the `lines.push` block (lines 712-750). - const stub = (description: string) => ({ - description, - run: vi.fn(async () => undefined), - }) - const subcommands = { - analytics: stub('analytics'), - ask: stub('ask'), - 'audit-log': stub('audit-log'), - bundler: stub('bundler'), - cargo: stub('cargo'), - cdxgen: stub('cdxgen'), - ci: stub('ci'), - config: stub('config'), - dependencies: stub('dependencies'), - fix: stub('fix'), - gem: stub('gem'), - go: stub('go'), - install: stub('install'), - license: stub('license'), - login: stub('login'), - logout: stub('logout'), - manifest: stub('manifest'), - npm: stub('npm'), - npx: stub('npx'), // socket-hook: allow npx - nuget: stub('nuget'), - optimize: stub('optimize'), - organization: stub('organization'), - package: stub('package'), - patch: stub('patch'), - pip: stub('pip'), - pycli: stub('pycli'), - 'raw-npm': stub('raw-npm'), - 'raw-npx': stub('raw-npx'), - repository: stub('repository'), - scan: stub('scan'), - sfw: stub('sfw'), - 'threat-feed': stub('threat-feed'), - uninstall: stub('uninstall'), - uv: stub('uv'), - whoami: stub('whoami'), - wrapper: stub('wrapper'), - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['--help-full'], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw is expected. - } - expect(true).toBe(true) - }) - - it('reports unknown subcommand and missing canonical commands when subcommands are partial', async () => { - // `extra` is NOT in the canonical Set → triggers - // `logger.fail('Received an unknown command:', name)` (line 697-698). - // Missing canonical commands trigger the `if (commands.size)` block - // (lines 700-711). Together this exercises both branches of the - // canonical-Set diff loop. - const subcommands = { - scan: { description: 'scan', run: vi.fn(async () => undefined) }, - extra: { description: 'extra', run: vi.fn(async () => undefined) }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: [], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw is expected. - } - expect(true).toBe(true) - }) - - it('handles dryRun without --help', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['--dry-run'], - importMeta: import.meta, - subcommands, - }) - } catch { - // process.exit throw is expected. - } - expect(true).toBe(true) - }) - - it('handles --version flag at root level', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['--version'], - importMeta: import.meta, - subcommands, - }) - } catch { - // showVersion throw is expected. - } - expect(true).toBe(true) - }) - - it('suggests close-match command for typos (lines 414-418)', async () => { - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - login: { - description: 'login', - run: vi.fn(async () => undefined), - }, - } - const originalExitCode = process.exitCode - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['scn'], // typo for "scan" - importMeta: import.meta, - subcommands, - }) - } catch { - // Expected to potentially throw — we want the suggestion path. - } - // Subcommand should NOT have run for the typo. - expect(runSpy).not.toHaveBeenCalled() - // Exit code 2 set when suggestion found. - // Reset for other tests. - process.exitCode = originalExitCode - }) - - it('shows error for unknown command with no close match', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - } - const originalExitCode = process.exitCode - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['totally-unrelated-name'], - importMeta: import.meta, - subcommands, - }) - } catch { - // showHelp throw fallthrough is expected. - } - expect(subcommands.scan.run).not.toHaveBeenCalled() - process.exitCode = originalExitCode - }) - - it('shows version when --version flag is set (line 469)', async () => { - const subcommands = { - scan: { - description: 'scan', - run: vi.fn(async () => undefined), - }, - } - try { - await meowWithSubcommands( - { - name: 'socket', - argv: ['--version'], - importMeta: import.meta, - subcommands, - }, - { - // Need version in flags or root config to avoid the meow validate error. - version: '1.0.0', - }, - ) - } catch { - // showVersion typically calls process.exit via meow. - } - expect(subcommands.scan.run).not.toHaveBeenCalled() - }) - - it('applies SOCKET_CLI_CONFIG override when present (line 348)', async () => { - const originalConfig = process.env['SOCKET_CLI_CONFIG'] - process.env['SOCKET_CLI_CONFIG'] = Buffer.from( - JSON.stringify({ defaultOrg: 'override-org' }), - ).toString('base64') - mockOverrideCachedConfig.mockReturnValue({ ok: true }) - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['scan'], - importMeta: import.meta, - subcommands, - }) - expect(mockOverrideCachedConfig).toHaveBeenCalled() - expect(runSpy).toHaveBeenCalled() - } finally { - if (originalConfig === undefined) { - delete process.env['SOCKET_CLI_CONFIG'] - } else { - process.env['SOCKET_CLI_CONFIG'] = originalConfig - } - } - }) - - it('applies SOCKET_API_TOKEN override (line 362)', async () => { - const originalNoToken = process.env['SOCKET_CLI_NO_API_TOKEN'] - const originalToken = process.env['SOCKET_API_TOKEN'] - delete process.env['SOCKET_CLI_NO_API_TOKEN'] - process.env['SOCKET_API_TOKEN'] = 'sktsec_test_xxxxxxxxxxxx' - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['scan'], - importMeta: import.meta, - subcommands, - }) - // overrideConfigApiToken should be called with the env token. - expect(mockOverrideConfigApiToken).toHaveBeenCalledWith( - 'sktsec_test_xxxxxxxxxxxx', - ) - } finally { - if (originalNoToken !== undefined) { - process.env['SOCKET_CLI_NO_API_TOKEN'] = originalNoToken - } - if (originalToken === undefined) { - delete process.env['SOCKET_API_TOKEN'] - } else { - process.env['SOCKET_API_TOKEN'] = originalToken - } - } - }) - - it('returns early with exit code 2 on bad config override (lines 367-374)', async () => { - const originalConfig = process.env['SOCKET_CLI_CONFIG'] - process.env['SOCKET_CLI_CONFIG'] = 'invalid-base64' - mockOverrideCachedConfig.mockReturnValue({ - ok: false, - message: 'Could not parse Config as JSON', - }) - const runSpy = vi.fn(async () => undefined) - const subcommands = { - scan: { - description: 'scan', - run: runSpy, - }, - } - const originalExitCode = process.exitCode - try { - await meowWithSubcommands({ - name: 'socket', - argv: ['scan'], - importMeta: import.meta, - subcommands, - }) - // The bad-config branch returns early, so the subcommand never runs. - expect(runSpy).not.toHaveBeenCalled() - expect(process.exitCode).toBe(2) - } finally { - process.exitCode = originalExitCode - if (originalConfig === undefined) { - delete process.env['SOCKET_CLI_CONFIG'] - } else { - process.env['SOCKET_CLI_CONFIG'] = originalConfig - } - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/coana/compress-facts.test.mts b/packages/cli/test/unit/util/coana/compress-facts.test.mts deleted file mode 100644 index 31f6b8094..000000000 --- a/packages/cli/test/unit/util/coana/compress-facts.test.mts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Unit tests for Coana facts-file brotli compression. - * - * Test Coverage: - compressSocketFactsForUpload: swaps .socket.facts.json paths - * for brotli-compressed .br temps, leaves other paths alone, cleans up. - * - * Related Files: - util/coana/compress-facts.mts (implementation) - */ - -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { brotliDecompressSync } from 'node:zlib' - -import { describe, expect, it } from 'vitest' - -import { compressSocketFactsForUpload } from '../../../../src/util/coana/compress-facts.mts' - -describe('compress-facts', () => { - describe('compressSocketFactsForUpload', () => { - it('writes brotli .br as a sibling of the source file', async () => { - const wrapDir = mkdtempSync(path.join(os.tmpdir(), 'socket-coana-wrap-')) - const inputPath = path.join(wrapDir, '.socket.facts.json') - const payload = { tier1ReachabilityScanId: 'compress-test', a: 1, b: 2 } - writeFileSync(inputPath, JSON.stringify(payload)) - - try { - const result = await compressSocketFactsForUpload([inputPath]) - const swappedPath = result.paths[0]! - - expect(result.paths).toHaveLength(1) - expect(swappedPath).toBe(`${inputPath}.br`) - expect(existsSync(swappedPath)).toBe(true) - // The sibling file is real brotli that round-trips to the original - // JSON. - const roundTripped = brotliDecompressSync( - readFileSync(swappedPath), - ).toString('utf8') - expect(JSON.parse(roundTripped)).toEqual(payload) - - // Cleanup removes the sibling .br file but leaves the source intact. - await result.cleanup() - expect(existsSync(swappedPath)).toBe(false) - expect(existsSync(inputPath)).toBe(true) - } finally { - rmSync(wrapDir, { recursive: true, force: true }) - } - }) - - it('leaves non-facts paths unchanged', async () => { - const wrapDir = mkdtempSync(path.join(os.tmpdir(), 'socket-coana-wrap-')) - const lock = path.join(wrapDir, 'package-lock.json') - const pkg = path.join(wrapDir, 'package.json') - writeFileSync(lock, '{}') - writeFileSync(pkg, '{}') - - const result = await compressSocketFactsForUpload([lock, pkg]) - try { - expect(result.paths).toEqual([lock, pkg]) - } finally { - await result.cleanup() - rmSync(wrapDir, { recursive: true, force: true }) - } - }) - - it('leaves a missing .socket.facts.json path unchanged', async () => { - const wrapDir = mkdtempSync(path.join(os.tmpdir(), 'socket-coana-wrap-')) - const missingFacts = path.join(wrapDir, '.socket.facts.json') - // Note: no writeFileSync — file does not exist. - - const result = await compressSocketFactsForUpload([missingFacts]) - try { - expect(result.paths).toEqual([missingFacts]) - } finally { - await result.cleanup() - rmSync(wrapDir, { recursive: true, force: true }) - } - }) - - it('mixes facts and non-facts entries correctly', async () => { - const wrapDir = mkdtempSync(path.join(os.tmpdir(), 'socket-coana-wrap-')) - const facts = path.join(wrapDir, '.socket.facts.json') - const lock = path.join(wrapDir, 'package-lock.json') - writeFileSync(facts, JSON.stringify({ tier1ReachabilityScanId: 'mix' })) - writeFileSync(lock, '{"name":"x"}') - - const result = await compressSocketFactsForUpload([lock, facts]) - try { - expect(result.paths[0]).toBe(lock) - expect(result.paths[1]).toBe(`${facts}.br`) - const roundTripped = JSON.parse( - brotliDecompressSync(readFileSync(result.paths[1]!)).toString('utf8'), - ) - expect(roundTripped.tier1ReachabilityScanId).toBe('mix') - } finally { - await result.cleanup() - rmSync(wrapDir, { recursive: true, force: true }) - } - }) - - it('cleanup is idempotent (safe to call twice)', async () => { - const wrapDir = mkdtempSync(path.join(os.tmpdir(), 'socket-coana-wrap-')) - const facts = path.join(wrapDir, '.socket.facts.json') - writeFileSync(facts, JSON.stringify({ tier1ReachabilityScanId: 'idem' })) - - const result = await compressSocketFactsForUpload([facts]) - await result.cleanup() - await expect(result.cleanup()).resolves.not.toThrow() - rmSync(wrapDir, { recursive: true, force: true }) - }) - }) -}) diff --git a/packages/cli/test/unit/util/coana/extract-scan-id.test.mts b/packages/cli/test/unit/util/coana/extract-scan-id.test.mts deleted file mode 100644 index 66fc81f78..000000000 --- a/packages/cli/test/unit/util/coana/extract-scan-id.test.mts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Unit tests for Coana scan ID extraction. - * - * Purpose: Tests the extractTier1ReachabilityScanId function. - * - * Test Coverage: - Valid scan ID extraction - Missing file handling - Invalid - * JSON handling - Missing field handling. - * - * Related Files: - util/coana/extract-scan-id.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockReadJsonSync = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/fs/read-json', () => ({ - readJsonSync: mockReadJsonSync, -})) - -import { extractTier1ReachabilityScanId } from '../../../../src/util/coana/extract-scan-id.mts' - -describe('extract-scan-id', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('extractTier1ReachabilityScanId', () => { - it('extracts scan ID from valid JSON file', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: 'scan-123', - }) - - const result = extractTier1ReachabilityScanId( - '/path/to/socket-facts.json', - ) - - expect(result).toBe('scan-123') - expect(mockReadJsonSync).toHaveBeenCalledWith( - '/path/to/socket-facts.json', - { - throws: false, - }, - ) - }) - - it('returns undefined for missing file', () => { - mockReadJsonSync.mockReturnValue(undefined) - - const result = extractTier1ReachabilityScanId('/path/to/missing.json') - - expect(result).toBeUndefined() - }) - - it('returns undefined for non-object JSON', () => { - mockReadJsonSync.mockReturnValue('not an object') - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBeUndefined() - }) - - it('returns undefined when tier1ReachabilityScanId is missing', () => { - mockReadJsonSync.mockReturnValue({ - otherField: 'value', - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBeUndefined() - }) - - it('returns undefined for null scan ID', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: undefined, - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBeUndefined() - }) - - it('returns undefined for empty string scan ID', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: '', - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBeUndefined() - }) - - it('returns undefined for whitespace-only scan ID', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: ' ', - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBeUndefined() - }) - - it('trims whitespace from scan ID', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: ' scan-456 ', - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBe('scan-456') - }) - - it('converts numeric scan ID to string', () => { - mockReadJsonSync.mockReturnValue({ - tier1ReachabilityScanId: 12345, - }) - - const result = extractTier1ReachabilityScanId('/path/to/file.json') - - expect(result).toBe('12345') - }) - }) -}) diff --git a/packages/cli/test/unit/util/command/registry-barrel.test.mts b/packages/cli/test/unit/util/command/registry-barrel.test.mts deleted file mode 100644 index 3363077d7..000000000 --- a/packages/cli/test/unit/util/command/registry-barrel.test.mts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Unit tests for command registry barrel file. - * - * Purpose: Tests the command registry barrel exports. - * - * Test Coverage: - Named exports - Type exports (runtime verification) - * - * Related Files: - src/util/command/registry.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - CommandRegistry, - defineCommand, - generateCommandHelp, - generateGlobalHelp, - isHelpRequested, - registry, -} from '../../../../src/util/command/registry.mts' - -describe('command/registry barrel exports', () => { - describe('class exports', () => { - it('exports CommandRegistry class', () => { - expect(CommandRegistry).toBeDefined() - expect(typeof CommandRegistry).toBe('function') - }) - - it('CommandRegistry can be instantiated', () => { - const instance = new CommandRegistry() - expect(instance).toBeInstanceOf(CommandRegistry) - }) - }) - - describe('singleton exports', () => { - it('exports registry singleton', () => { - expect(registry).toBeDefined() - expect(registry).toBeInstanceOf(CommandRegistry) - }) - }) - - describe('function exports', () => { - it('exports defineCommand function', () => { - expect(typeof defineCommand).toBe('function') - }) - - it('exports generateCommandHelp function', () => { - expect(typeof generateCommandHelp).toBe('function') - }) - - it('exports generateGlobalHelp function', () => { - expect(typeof generateGlobalHelp).toBe('function') - }) - - it('exports isHelpRequested function', () => { - expect(typeof isHelpRequested).toBe('function') - }) - }) - - describe('isHelpRequested behavior', () => { - it('returns true for --help flag', () => { - expect(isHelpRequested(['--help'])).toBe(true) - }) - - it('returns true for -h flag', () => { - expect(isHelpRequested(['-h'])).toBe(true) - }) - - it('returns false for help subcommand (only flags recognized)', () => { - // isHelpRequested only checks for --help and -h flags, not 'help' subcommand. - expect(isHelpRequested(['help'])).toBe(false) - }) - - it('returns false for empty args', () => { - expect(isHelpRequested([])).toBe(false) - }) - - it('returns false for regular args', () => { - expect(isHelpRequested(['scan', 'create'])).toBe(false) - }) - }) - - describe('defineCommand behavior', () => { - it('creates a valid command definition', () => { - const cmd = defineCommand({ - name: 'test', - description: 'Test command', - handler: async () => {}, - }) - - expect(cmd.name).toBe('test') - expect(cmd.description).toBe('Test command') - expect(typeof cmd.handler).toBe('function') - }) - - it('supports hidden flag', () => { - const cmd = defineCommand({ - name: 'hidden-test', - description: 'Hidden test command', - hidden: true, - handler: async () => {}, - }) - - expect(cmd.hidden).toBe(true) - }) - - it('supports flags definition', () => { - const cmd = defineCommand({ - name: 'flag-test', - description: 'Command with flags', - flags: { - verbose: { - type: 'boolean', - short: 'v', - description: 'Enable verbose output', - }, - }, - handler: async () => {}, - }) - - expect(cmd.flags).toBeDefined() - expect(cmd.flags!['verbose']).toBeDefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/command/registry-core.test.mts b/packages/cli/test/unit/util/command/registry-core.test.mts deleted file mode 100644 index 53837adb7..000000000 --- a/packages/cli/test/unit/util/command/registry-core.test.mts +++ /dev/null @@ -1,636 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * @file Tests for command registry system. - */ - -import { beforeEach, describe, expect, it } from 'vitest' - -import { CommandRegistry } from '../../../../src/util/command/registry.mts' - -import type { CommandDefinition } from '../../../../src/util/command/types.mts' - -describe('CommandRegistry', () => { - let registry: CommandRegistry - - beforeEach(() => { - registry = new CommandRegistry() - }) - - describe('register()', () => { - it('should register a command', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - expect(registry.has('test')).toBe(true) - expect(registry.get('test')).toBe(command) - }) - - it('should register command aliases', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - aliases: ['t', 'tst'], - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - expect(registry.has('test')).toBe(true) - expect(registry.has('t')).toBe(true) - expect(registry.has('tst')).toBe(true) - expect(registry.get('t')).toBe(command) - expect(registry.get('tst')).toBe(command) - }) - - it('should throw error when registering duplicate command', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - expect(() => registry.register(command)).toThrow( - /cannot register command "test": already registered/, - ) - }) - - it('should throw error when alias conflicts with existing command', () => { - const cmd1: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - return { ok: true, data: undefined } - }, - } - - const cmd2: CommandDefinition = { - name: 'other', - description: 'Other command', - aliases: ['test'], - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(cmd1) - - expect(() => registry.register(cmd2)).toThrow( - /cannot register command "other" alias "test": conflicts with command "test"/, - ) - }) - }) - - describe('unregister()', () => { - it('should unregister a command', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - expect(registry.has('test')).toBe(true) - - const result = registry.unregister('test') - - expect(result).toBe(true) - expect(registry.has('test')).toBe(false) - }) - - it('should unregister command aliases', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - aliases: ['t', 'tst'], - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - expect(registry.has('t')).toBe(true) - expect(registry.has('tst')).toBe(true) - - registry.unregister('test') - - expect(registry.has('test')).toBe(false) - expect(registry.has('t')).toBe(false) - expect(registry.has('tst')).toBe(false) - }) - - it('should return false when unregistering unknown command', () => { - const result = registry.unregister('nonexistent') - expect(result).toBe(false) - }) - }) - - describe('list()', () => { - it('should list all commands', () => { - const cmd1: CommandDefinition = { - name: 'test1', - description: 'Test 1', - async handler() { - return { ok: true, data: undefined } - }, - } - - const cmd2: CommandDefinition = { - name: 'test2', - description: 'Test 2', - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(cmd1) - registry.register(cmd2) - - const commands = registry.list() - expect(commands).toHaveLength(2) - expect(commands).toContainEqual(cmd1) - expect(commands).toContainEqual(cmd2) - }) - - it('should filter commands by parent', () => { - const parent: CommandDefinition = { - name: 'org', - description: 'Organization commands', - async handler() { - return { ok: true, data: undefined } - }, - } - - const child: CommandDefinition = { - name: 'org:list', - description: 'List organizations', - parent: 'org', - async handler() { - return { ok: true, data: undefined } - }, - } - - const other: CommandDefinition = { - name: 'scan', - description: 'Scan command', - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(parent) - registry.register(child) - registry.register(other) - - const orgCommands = registry.list('org') - expect(orgCommands).toHaveLength(1) - expect(orgCommands[0]).toBe(child) - }) - - it('should deduplicate aliases in list', () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - aliases: ['t'], - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const commands = registry.list() - expect(commands).toHaveLength(1) - expect(commands[0]).toBe(command) - }) - }) - - describe('execute()', () => { - it('should execute a command successfully', async () => { - let executed = false - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - executed = true - return { ok: true, data: 'success' } - }, - } - - registry.register(command) - - const result = await registry.execute('test', []) - - expect(result.ok).toBe(true) - expect(executed).toBe(true) - }) - - it('should return error for unknown command', async () => { - const result = await registry.execute('nonexistent', []) - - expect(result.ok).toBe(false) - expect(result.message).toContain('Unknown command: nonexistent') - }) - - it('should parse boolean flags', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - verbose: { - type: 'boolean', - description: 'Verbose output', - }, - }, - async handler({ flags }) { - expect(flags.verbose).toBe(true) - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - await registry.execute('test', ['--verbose']) - }) - - it('should parse string flags', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - name: { - type: 'string', - description: 'Name', - }, - }, - async handler({ flags }) { - expect(flags.name).toBe('foo') - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - await registry.execute('test', ['--name', 'foo']) - }) - - it('should parse flags with = syntax', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - name: { - type: 'string', - description: 'Name', - }, - }, - async handler({ flags }) { - expect(flags.name).toBe('foo') - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - await registry.execute('test', ['--name=foo']) - }) - - it('should use flag defaults', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - verbose: { - type: 'boolean', - description: 'Verbose', - default: true, - }, - }, - async handler({ flags }) { - expect(flags.verbose).toBe(true) - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - await registry.execute('test', []) - }) - - it('should validate required flags', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - name: { - type: 'string', - description: 'Name', - isRequired: true, - }, - }, - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', []) - - expect(result.ok).toBe(false) - expect(result.message).toContain( - 'command "test" requires --name but it was not provided', - ) - }) - - it('should run validation function', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - count: { - type: 'number', - description: 'Count', - }, - }, - validate(flags) { - if (typeof flags.count === 'number' && flags.count < 0) { - return { - ok: false, - errors: ['Count must be non-negative'], - } - } - return { ok: true } - }, - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', ['--count', '-5']) - - expect(result.ok).toBe(false) - expect(result.cause).toContain('Count must be non-negative') - }) - - it('should error when string flag is missing value', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - name: { - type: 'string', - description: 'Name', - }, - }, - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', ['--name']) - - expect(result.ok).toBe(false) - expect(result.message).toContain( - 'flag --name requires a string value but none was provided', - ) - }) - - it('should error when number flag has invalid value', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - count: { - type: 'number', - description: 'Count', - }, - }, - async handler() { - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', ['--count', 'notanumber']) - - expect(result.ok).toBe(false) - expect(result.message).toContain('flag --count requires a numeric value') - }) - - it('should parse array flags', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - tags: { - type: 'array', - description: 'Tags', - }, - }, - async handler({ flags }) { - expect(flags.tags).toEqual(['tag1', 'tag2', 'tag3']) - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', [ - '--tags', - 'tag1', - '--tags', - 'tag2', - '--tags', - 'tag3', - ]) - - expect(result.ok).toBe(true) - }) - - it('should parse array flags with = syntax', async () => { - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - tags: { - type: 'array', - description: 'Tags', - }, - }, - async handler({ flags }) { - expect(flags.tags).toEqual(['tag1', 'tag2']) - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - const result = await registry.execute('test', [ - '--tags=tag1', - '--tags=tag2', - ]) - - expect(result.ok).toBe(true) - }) - }) - - describe('plugins', () => { - it('should install plugin and call its install method', () => { - let installed = false - const plugin = { - name: 'test-plugin', - install(reg: CommandRegistry) { - installed = true - // Plugin can register commands. - reg.register({ - name: 'plugin-cmd', - description: 'Plugin command', - async handler() { - return { ok: true, data: undefined } - }, - }) - }, - } - - registry.use(plugin) - - expect(installed).toBe(true) - expect(registry.has('plugin-cmd')).toBe(true) - }) - }) - - describe('middleware', () => { - it('should execute middleware in order', async () => { - const order: string[] = [] - - registry.use(async (_ctx, next) => { - order.push('middleware1-before') - await next() - order.push('middleware1-after') - }) - - registry.use(async (_ctx, next) => { - order.push('middleware2-before') - await next() - order.push('middleware2-after') - }) - - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - order.push('handler') - return { ok: true, data: undefined } - }, - } - - registry.register(command) - - await registry.execute('test', []) - - expect(order).toEqual([ - 'middleware1-before', - 'middleware2-before', - 'handler', - 'middleware2-after', - 'middleware1-after', - ]) - }) - - it('should execute before and after hooks', async () => { - const order: string[] = [] - - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async before() { - order.push('before') - }, - async handler() { - order.push('handler') - return { ok: true, data: undefined } - }, - async after() { - order.push('after') - }, - } - - registry.register(command) - - await registry.execute('test', []) - - expect(order).toEqual(['before', 'handler', 'after']) - }) - - it('throws when middleware calls next() more than once', async () => { - // Middleware function form: (ctx, next) => Promise<void>. - registry.use(async (_ctx, next) => { - await next() - // Calling next() again — should trigger the dispatch detection. - await next() - }) - - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - async handler() { - return { ok: true, data: undefined } - }, - } - registry.register(command) - - // execute() doesn't reject — it catches and returns CResult. - const result = await registry.execute('test', []) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toMatch(/next\(\) more than once/) - } - }) - - it('skips non-flag arguments and unknown flags during parseFlags', async () => { - let observedFlags: unknown - const command: CommandDefinition = { - name: 'test', - description: 'Test command', - flags: { - known: { type: 'string', default: 'd' }, - }, - async handler(ctx) { - observedFlags = ctx.flags - return { ok: true, data: undefined } - }, - } - registry.register(command) - - // Mix of: positional, known flag, unknown flag. - await registry.execute('test', [ - 'positional-arg', - '--known=v', - '--unknown=ignored', - ]) - - expect(observedFlags.known).toBe('v') - expect(observedFlags.unknown).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/command/registry-define.test.mts b/packages/cli/test/unit/util/command/registry-define.test.mts deleted file mode 100644 index 60642d4d1..000000000 --- a/packages/cli/test/unit/util/command/registry-define.test.mts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Unit tests for command definition utilities. - * - * Purpose: Tests the defineCommand helper for registering CLI commands. - * - * Test Coverage: - Command registration - Definition passthrough. - * - * Related Files: - util/command/registry-define.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockRegister = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/command/registry.mts', () => ({ - registry: { - register: mockRegister, - }, -})) - -import { defineCommand } from '../../../../src/util/command/registry-define.mts' - -import type { CommandDefinition } from '../../../../src/util/command/registry-types.mjs' - -describe('registry-define', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('defineCommand', () => { - it('registers the command with the global registry', () => { - const definition: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - run: vi.fn(), - } - - defineCommand(definition) - - expect(mockRegister).toHaveBeenCalledTimes(1) - expect(mockRegister).toHaveBeenCalledWith(definition) - }) - - it('returns the definition unchanged', () => { - const definition: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - run: vi.fn(), - } - - const result = defineCommand(definition) - - expect(result).toBe(definition) - }) - - it('handles command with flags', () => { - const definition: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - verbose: { - type: 'boolean', - description: 'Verbose output', - }, - }, - run: vi.fn(), - } - - const result = defineCommand(definition) - - expect(mockRegister).toHaveBeenCalledWith(definition) - expect(result.flags).toBeDefined() - expect(result.flags!['verbose']).toBeDefined() - }) - - it('handles command with aliases', () => { - const definition: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - aliases: ['tc', 't'], - run: vi.fn(), - } - - const result = defineCommand(definition) - - expect(result.aliases).toEqual(['tc', 't']) - }) - - it('handles command with parent', () => { - const definition: CommandDefinition = { - name: 'create', - description: 'Create something', - parent: 'organization', - run: vi.fn(), - } - - const result = defineCommand(definition) - - expect(result.parent).toBe('organization') - }) - }) -}) diff --git a/packages/cli/test/unit/util/command/registry-help.test.mts b/packages/cli/test/unit/util/command/registry-help.test.mts deleted file mode 100644 index 1a225690e..000000000 --- a/packages/cli/test/unit/util/command/registry-help.test.mts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * Unit tests for command registry help generation. - * - * Purpose: Tests help text generation for CLI commands. Validates formatting - * and output. - * - * Test Coverage: - generateCommandHelp formatting - generateGlobalHelp - * formatting - isHelpRequested detection - Flag formatting. - * - * Related Files: - util/command/registry-help.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - generateCommandHelp, - generateGlobalHelp, - isHelpRequested, -} from '../../../../src/util/command/registry-help.mts' - -import type { CommandDefinition } from '../../../../src/util/command/registry-types.mjs' -import type { CommandRegistry } from '../../../../src/util/command/registry.mts' - -describe('registry-help', () => { - describe('generateCommandHelp', () => { - it('generates basic help text with name and description', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('test-cmd') - expect(help).toContain('A test command') - expect(help).toContain('Usage:') - expect(help).toContain('socket test-cmd') - }) - - it('includes options text when flags are present', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - verbose: { - type: 'boolean', - description: 'Enable verbose output', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('[options]') - expect(help).toContain('Options:') - expect(help).toContain('--verbose') - expect(help).toContain('Enable verbose output') - }) - - it('includes aliases when present', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - aliases: ['tc', 'test'], - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('Aliases:') - expect(help).toContain('tc, test') - }) - - it('includes examples when present', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - examples: ['socket test-cmd --flag', 'socket test-cmd file.txt'], - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('Examples:') - expect(help).toContain('socket test-cmd --flag') - expect(help).toContain('socket test-cmd file.txt') - }) - - it('formats flag with alias', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - verbose: { - type: 'boolean', - alias: 'v', - description: 'Enable verbose output', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('--verbose, -v') - }) - - it('formats string flag with type indicator', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - name: { - type: 'string', - description: 'The name', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('<string>') - }) - - it('formats number flag with type indicator', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - count: { - type: 'number', - description: 'The count', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('<number>') - }) - - it('formats array flag with type indicator', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - files: { - type: 'array', - description: 'The files', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('<value...>') - }) - - it('returns no type indicator for unknown flag types', () => { - // Exercise the default case in getTypeIndicator's switch. - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - weird: { - // FlagType is constrained but defensive default exists. - type: 'unknown' as unknown, - description: 'A flag with an unknown type', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('--weird') - expect(help).not.toContain('<string>') - expect(help).not.toContain('<number>') - }) - - it('shows default value for flag', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - count: { - type: 'number', - description: 'The count', - default: 10, - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('(default: 10)') - }) - - it('shows required indicator for flag', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - name: { - type: 'string', - description: 'The name', - isRequired: true, - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('[required]') - }) - - it('shows choices for flag', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - format: { - type: 'string', - description: 'Output format', - choices: ['json', 'text', 'markdown'], - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - expect(help).toContain('[choices: json, text, markdown]') - }) - - it('does not include type indicator for boolean flags', () => { - const command: CommandDefinition = { - name: 'test-cmd', - description: 'A test command', - flags: { - verbose: { - type: 'boolean', - description: 'Enable verbose output', - }, - }, - run: vi.fn(), - } - - const help = generateCommandHelp(command) - - // Boolean flags don't have type indicators. - expect(help).not.toContain('<boolean>') - }) - }) - - describe('generateGlobalHelp', () => { - it('generates global help with header and usage', () => { - const mockRegistry = { - list: vi.fn().mockReturnValue([]), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('Socket CLI') - expect(help).toContain('Usage:') - expect(help).toContain('socket <command> [options]') - }) - - it('lists visible top-level commands', () => { - const commands: CommandDefinition[] = [ - { - name: 'scan', - description: 'Scan for vulnerabilities', - run: vi.fn(), - }, - { - name: 'optimize', - description: 'Optimize dependencies', - run: vi.fn(), - }, - ] - - const mockRegistry = { - list: vi.fn().mockImplementation((parent?: string) => { - if (parent === undefined) { - return commands - } - return [] - }), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('Commands:') - expect(help).toContain('scan') - expect(help).toContain('Scan for vulnerabilities') - expect(help).toContain('optimize') - expect(help).toContain('Optimize dependencies') - }) - - it('hides commands marked as hidden', () => { - const commands: CommandDefinition[] = [ - { - name: 'scan', - description: 'Scan for vulnerabilities', - run: vi.fn(), - }, - { - name: 'internal', - description: 'Internal command', - hidden: true, - run: vi.fn(), - }, - ] - - const mockRegistry = { - list: vi.fn().mockImplementation((parent?: string) => { - if (parent === undefined) { - return commands - } - return [] - }), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('scan') - expect(help).not.toContain('internal') - expect(help).not.toContain('Internal command') - }) - - it('lists subcommands under their parent', () => { - const topLevelCommands: CommandDefinition[] = [ - { - name: 'organization', - description: 'Manage organizations', - run: vi.fn(), - }, - ] - - const subcommands: CommandDefinition[] = [ - { - name: 'list', - description: 'List organizations', - parent: 'organization', - run: vi.fn(), - }, - { - name: 'create', - description: 'Create organization', - parent: 'organization', - run: vi.fn(), - }, - ] - - const mockRegistry = { - list: vi.fn().mockImplementation((parent?: string) => { - if (parent === undefined) { - return topLevelCommands - } - if (parent === 'organization') { - return subcommands - } - return [] - }), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('organization') - expect(help).toContain('Manage organizations') - expect(help).toContain('list') - expect(help).toContain('List organizations') - expect(help).toContain('create') - expect(help).toContain('Create organization') - }) - - it('hides subcommands marked as hidden', () => { - const topLevelCommands: CommandDefinition[] = [ - { - name: 'organization', - description: 'Manage organizations', - run: vi.fn(), - }, - ] - - const subcommands: CommandDefinition[] = [ - { - name: 'list', - description: 'List organizations', - parent: 'organization', - run: vi.fn(), - }, - { - name: 'internal', - description: 'Internal subcommand', - parent: 'organization', - hidden: true, - run: vi.fn(), - }, - ] - - const mockRegistry = { - list: vi.fn().mockImplementation((parent?: string) => { - if (parent === undefined) { - return topLevelCommands - } - if (parent === 'organization') { - return subcommands - } - return [] - }), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('list') - expect(help).not.toContain('Internal subcommand') - }) - - it('includes help footer', () => { - const mockRegistry = { - list: vi.fn().mockReturnValue([]), - } as unknown as CommandRegistry - - const help = generateGlobalHelp(mockRegistry) - - expect(help).toContain('Run "socket <command> --help"') - }) - }) - - describe('isHelpRequested', () => { - it('returns true for --help flag', () => { - expect(isHelpRequested(['scan', '--help'])).toBe(true) - }) - - it('returns true for -h flag', () => { - expect(isHelpRequested(['scan', '-h'])).toBe(true) - }) - - it('returns false when no help flag present', () => { - expect(isHelpRequested(['scan', '--verbose'])).toBe(false) - }) - - it('returns false for empty args', () => { - expect(isHelpRequested([])).toBe(false) - }) - - it('returns true when help flag is first', () => { - expect(isHelpRequested(['--help', 'scan'])).toBe(true) - }) - - it('returns true when help flag is in middle', () => { - expect(isHelpRequested(['scan', '--help', '--verbose'])).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/util/config.test.mts b/packages/cli/test/unit/util/config.test.mts deleted file mode 100644 index 6d5ffb0ca..000000000 --- a/packages/cli/test/unit/util/config.test.mts +++ /dev/null @@ -1,525 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for configuration management. - * - * Purpose: Tests configuration file loading and merging. Validates config - * precedence, defaults, and environment overrides. - * - * Test Coverage: - Config file loading (.socketrc, package.json) - Default - * value handling - Environment variable overrides - Config merging and - * precedence - Validation and schema checking - Non-destructive config saving. - * - * Testing Approach: Uses temporary config files and environment variable - * mocking. - * - * Related Files: - util/config.mts (implementation) - */ - -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { safeDelete, safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' - -import { - findSocketYmlSync, - getConfigValue, - getConfigValueOrUndef, - getSupportedConfigEntries, - getSupportedConfigKeys, - isConfigFromFlag, - isSensitiveConfigKey, - isSupportedConfigKey, - overrideCachedConfig, - overrideConfigApiToken, - resetConfigForTesting, - updateConfigValue, -} from '../../../src/util/config.mts' -import { testPath } from '../../../test/utils.mts' - -const fixtureBaseDir = path.join(testPath, 'fixtures/util/config') - -describe('util/config', () => { - describe('updateConfigValue', () => { - beforeEach(() => { - overrideCachedConfig({}) - }) - - it('should return object for applying a change', () => { - expect(updateConfigValue('defaultOrg', 'fake_test_org')) - .toMatchInlineSnapshot(` - { - "data": "Change applied but not persisted; current config is overridden through env var or flag", - "message": "Config key 'defaultOrg' was updated", - "ok": true, - } - `) - }) - - it('should warn for invalid key', () => { - expect( - updateConfigValue( - // @ts-expect-error - 'nawthiswontwork', - 'fake_test_org', - ), - ).toMatchInlineSnapshot(` - { - "data": undefined, - "message": "Invalid config key: nawthiswontwork", - "ok": false, - } - `) - }) - - it('warns when value is the string "true" (line 378-381)', () => { - // Stringy bool tracks the not-pre-validated path; the function still - // accepts the value but emits a logger.warn telling the user that - // they probably meant a real boolean. - const result = updateConfigValue('defaultOrg', 'true' as unknown) - expect(result.ok).toBe(true) - }) - - it('warns when value is the string "false"', () => { - const result = updateConfigValue('defaultOrg', 'false' as unknown) - expect(result.ok).toBe(true) - }) - - it('warns when value is the string "undefined"', () => { - const result = updateConfigValue('defaultOrg', 'undefined' as unknown) - expect(result.ok).toBe(true) - }) - - it('handles skipAskToPersistDefaultOrg=true correctly', () => { - const result = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'true' as unknown, - ) - expect(result.ok).toBe(true) - }) - - it('handles skipAskToPersistDefaultOrg=false correctly', () => { - const result = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'false' as unknown, - ) - expect(result.ok).toBe(true) - }) - - it('deletes skipAskToPersistDefaultOrg on non-bool value', () => { - const result = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'something' as unknown, - ) - expect(result.ok).toBe(true) - }) - }) - - describe('overrideConfigApiToken', () => { - it('sets apiToken when provided (line 348-354)', () => { - overrideCachedConfig({}) - overrideConfigApiToken('test-token-123') - const result = getConfigValue('apiToken') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('test-token-123') - } - }) - - it('handles undefined token without setting key', () => { - overrideCachedConfig({}) - overrideConfigApiToken(undefined) - // No key set, but the read-only flag is still toggled. - const result = getConfigValue('apiToken') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeUndefined() - } - }) - - it('coerces non-string token via String()', () => { - overrideCachedConfig({}) - overrideConfigApiToken(12345 as unknown) - const result = getConfigValue('apiToken') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('12345') - } - }) - }) - - describe('findSocketYmlSync', () => { - it('should find socket.yml when walking up directory tree', async () => { - // Create an isolated tmpdir with a socket.yml fixture. - const tmpDir = path.resolve( - mkdtempSync(path.join(os.tmpdir(), 'socket-test-')), - ) - const socketYmlPath = path.join(tmpDir, 'socket.yml') - const nestedDir = path.join(tmpDir, 'deep', 'nested', 'directory') - - try { - // Create nested directories. - safeMkdirSync(nestedDir, { recursive: true }) - - // Create socket.yml in the tmpdir root. - writeFileSync( - socketYmlPath, - 'version: 2\n\nprojectIgnorePaths:\n - node_modules\n', - 'utf8', - ) - - // Call findSocketYmlSync from the nested directory - it should walk up and find socket.yml. - const result = findSocketYmlSync(nestedDir) - - // The result should be ok and find the socket.yml. - expect(result.ok).toBe(true) - expect(result.data).toBeDefined() - expect(result.data?.parsed).toBeDefined() - expect(result.data?.path).toBe(socketYmlPath) - } finally { - // Clean up the temporary directory. - await safeDelete(tmpDir, { recursive: true }) - } - }) - - it('should handle when no socket.yml exists (regression test for .parsed access)', async () => { - // This test ensures we don't regress on the error: - // "Cannot read properties of undefined (reading 'parsed')" - // when socketYmlResult.data is undefined. - // - // Create an isolated temporary directory outside the repository. - // This ensures no parent directories contain socket.yml. - const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'socket-test-')) - const isolatedDir = path.join(tmpDir, 'deep', 'nested', 'directory') - safeMkdirSync(isolatedDir, { recursive: true }) - - try { - const result = findSocketYmlSync(isolatedDir) - - // The result should be ok but with undefined data. - expect(result.ok).toBe(true) - expect(result.data).toBe(undefined) - } finally { - // Clean up the temporary directory. - await safeDelete(tmpDir, { recursive: true }) - } - }) - - it('returns parse error when socket.yml has invalid YAML (lines 222-228)', async () => { - // Write a socket.yml with garbage YAML content that fails to parse. - const tmpDir = path.resolve( - mkdtempSync(path.join(os.tmpdir(), 'socket-test-')), - ) - const socketYmlPath = path.join(tmpDir, 'socket.yml') - const nestedDir = path.join(tmpDir, 'deep', 'nested') - - try { - safeMkdirSync(nestedDir, { recursive: true }) - // Garbage with conflicting YAML mapping types — parseSocketConfig - // expects an object schema, this will throw. - writeFileSync( - socketYmlPath, - 'version: not-a-version\n invalid: ::: garbage\n!!!:\n -- bad', - 'utf8', - ) - - const result = findSocketYmlSync(nestedDir) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('unable to parse') - } - } finally { - await safeDelete(tmpDir, { recursive: true }) - } - }) - }) - - describe('non-destructive config saving', () => { - let tmpDir: string - let originalEnvValue: string | undefined - // getSocketAppDataPath() uses LOCALAPPDATA on Windows, XDG_DATA_HOME elsewhere. - const isWin32 = process.platform === 'win32' - const envKey = isWin32 ? 'LOCALAPPDATA' : 'XDG_DATA_HOME' - - beforeEach(() => { - // Create temp directory for config storage. - tmpDir = mkdtempSync(path.join(os.tmpdir(), 'socket-config-test-')) - // Save original env value. - originalEnvValue = process.env[envKey] - // Point config to temp directory. - // getSocketAppDataPath() appends 'socket/settings' to the data home. - process.env[envKey] = tmpDir - // Reset config cache so it reads from the new location. - resetConfigForTesting() - }) - - afterEach(async () => { - // Restore original env value. - if (originalEnvValue === undefined) { - delete process.env[envKey] - } else { - process.env[envKey] = originalEnvValue - } - // Reset config cache. - resetConfigForTesting() - // Clean up temp directory. - await safeDelete(tmpDir) - }) - - it('should preserve existing properties when updating config', async () => { - // Create the settings directory structure. - const settingsDir = path.join(tmpDir, 'socket', 'settings') - safeMkdirSync(settingsDir) - const configFilePath = path.join(settingsDir, 'config.json') - - // Create initial config with multiple properties (base64 encoded). - const initialConfig = { - apiToken: 'existing-token', - defaultOrg: 'existing-org', - } - const initialJson = JSON.stringify(initialConfig) - writeFileSync(configFilePath, Buffer.from(initialJson).toString('base64')) - - // Reset cache so it reads the file we just created. - resetConfigForTesting() - - // Update only one property using the actual updateConfigValue function. - const result = updateConfigValue('apiToken', 'new-token') - expect(result.ok).toBe(true) - - // Wait for nextTick to complete the async write. - await new Promise(resolve => process.nextTick(resolve)) - - // Read and verify all properties are preserved. - const finalRaw = readFileSync(configFilePath, 'utf8') - const finalDecoded = Buffer.from(finalRaw, 'base64').toString('utf8') - const finalConfig = JSON.parse(finalDecoded) - - // The updated property should have the new value. - expect(finalConfig.apiToken).toBe('new-token') - // Existing properties should be preserved. - expect(finalConfig.defaultOrg).toBe('existing-org') - }) - - it('should preserve JSON key order when updating config', async () => { - // Create the settings directory structure. - const settingsDir = path.join(tmpDir, 'socket', 'settings') - safeMkdirSync(settingsDir) - const configFilePath = path.join(settingsDir, 'config.json') - - // Create config with specific key order (base64 encoded). - // Using valid config keys: defaultOrg comes before apiToken alphabetically, - // but we write them in reverse order to test preservation. - const initialJson = '{"defaultOrg":"org1","apiToken":"token1"}' - writeFileSync(configFilePath, Buffer.from(initialJson).toString('base64')) - - // Reset cache so it reads the file we just created. - resetConfigForTesting() - - // Update one property. - const result = updateConfigValue('apiToken', 'token2') - expect(result.ok).toBe(true) - - // Wait for nextTick to complete the async write. - await new Promise(resolve => process.nextTick(resolve)) - - // Verify key order is preserved. - const finalRaw = readFileSync(configFilePath, 'utf8') - const finalDecoded = Buffer.from(finalRaw, 'base64').toString('utf8') - const keys = Object.keys(JSON.parse(finalDecoded)) - - // Keys should maintain original order: defaultOrg first, then apiToken. - expect(keys).toEqual(['defaultOrg', 'apiToken']) - }) - - it('should create config file when it does not exist', async () => { - // Don't create any initial config file. - // Reset cache. - resetConfigForTesting() - - // Update a property - this should create the config file. - const result = updateConfigValue('defaultOrg', 'new-org') - expect(result.ok).toBe(true) - - // Wait for nextTick to complete the async write. - await new Promise(resolve => process.nextTick(resolve)) - - // Verify the config file was created. - const settingsDir = path.join(tmpDir, 'socket', 'settings') - const configFilePath = path.join(settingsDir, 'config.json') - const finalRaw = readFileSync(configFilePath, 'utf8') - const finalDecoded = Buffer.from(finalRaw, 'base64').toString('utf8') - const finalConfig = JSON.parse(finalDecoded) - - expect(finalConfig.defaultOrg).toBe('new-org') - }) - - it('should read config value after setting it', async () => { - // Reset cache. - resetConfigForTesting() - - // Set a config value. - updateConfigValue('defaultOrg', 'test-org') - - // Read it back immediately (from cache). - const result = getConfigValue('defaultOrg') - expect(result.ok).toBe(true) - expect(result.data).toBe('test-org') - }) - - it('handles skipAskToPersistDefaultOrg with string "true"', () => { - resetConfigForTesting() - const r = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'true' as unknown, - ) - expect(r.ok).toBe(true) - }) - - it('handles skipAskToPersistDefaultOrg with string "false"', () => { - resetConfigForTesting() - const r = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'false' as unknown, - ) - expect(r.ok).toBe(true) - }) - - it('deletes skipAskToPersistDefaultOrg when value is unrecognized', () => { - resetConfigForTesting() - // Set it first. - updateConfigValue('skipAskToPersistDefaultOrg', 'true' as unknown) - // Now pass an unrecognized value — should delete the key. - const result = updateConfigValue( - 'skipAskToPersistDefaultOrg', - 'maybe' as unknown, - ) - expect(result.ok).toBe(true) - }) - }) - - describe('getSupportedConfigEntries / getSupportedConfigKeys', () => { - it('returns a non-empty array of [key, value] entries', () => { - const entries = getSupportedConfigEntries() - expect(Array.isArray(entries)).toBe(true) - expect(entries.length).toBeGreaterThan(0) - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i] - expect(Array.isArray(entry)).toBe(true) - expect(entry).toHaveLength(2) - } - }) - - it('returns a non-empty array of supported keys', () => { - const keys = getSupportedConfigKeys() - expect(Array.isArray(keys)).toBe(true) - expect(keys.length).toBeGreaterThan(0) - expect(keys).toContain('defaultOrg') - }) - }) - - describe('isConfigFromFlag', () => { - it('returns false initially', () => { - resetConfigForTesting() - expect(isConfigFromFlag()).toBe(false) - }) - - it('returns true after invalid override (line 315)', () => { - resetConfigForTesting() - // overrideCachedConfig with non-object JSON triggers the catch branch - // that sets _configFromFlag = true. - overrideCachedConfig('not valid json{{{{') - expect(isConfigFromFlag()).toBe(true) - }) - }) - - describe('isSensitiveConfigKey', () => { - it('returns true for apiToken', () => { - expect(isSensitiveConfigKey('apiToken')).toBe(true) - }) - - it('returns false for non-sensitive keys', () => { - expect(isSensitiveConfigKey('defaultOrg')).toBe(false) - }) - - it('returns false for unknown keys', () => { - expect(isSensitiveConfigKey('totally-bogus')).toBe(false) - }) - }) - - describe('isSupportedConfigKey', () => { - it('returns true for known keys', () => { - expect(isSupportedConfigKey('defaultOrg')).toBe(true) - expect(isSupportedConfigKey('apiToken')).toBe(true) - }) - - it('returns false for unknown keys', () => { - expect(isSupportedConfigKey('totally-bogus')).toBe(false) - }) - }) - - describe('overrideCachedConfig', () => { - afterEach(() => { - resetConfigForTesting() - }) - - it('returns parse error for non-object JSON (line 315)', () => { - // A primitive (number) is valid JSON but not a config object. - const result = overrideCachedConfig('42') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Could not parse Config as JSON') - } - }) - - it('returns parse error for null JSON (line 315)', () => { - const result = overrideCachedConfig('null') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Could not parse Config as JSON') - } - }) - }) - - describe('getConfigValueOrUndef', () => { - afterEach(() => { - resetConfigForTesting() - }) - - it('returns undefined for invalid keys', () => { - // The internal normalizeConfigKey returns !ok for unsupported keys. - // getConfigValueOrUndef squashes that to undefined. - expect(getConfigValueOrUndef('totally-bogus' as unknown)).toBeUndefined() - }) - - it('returns the config value for valid keys', () => { - overrideCachedConfig(JSON.stringify({ defaultOrg: 'my-org' })) - expect(getConfigValueOrUndef('defaultOrg')).toBe('my-org') - }) - }) - - describe('getConfigValue', () => { - afterEach(() => { - resetConfigForTesting() - }) - - it('returns the !ok keyResult for invalid keys (line 243)', () => { - const result = getConfigValue('totally-bogus' as unknown) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Invalid config key') - } - }) - - it('returns ok with data for valid keys', () => { - overrideCachedConfig(JSON.stringify({ defaultOrg: 'good-org' })) - const result = getConfigValue('defaultOrg') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('good-org') - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/cve-to-ghsa.test.mts b/packages/cli/test/unit/util/cve-to-ghsa.test.mts deleted file mode 100644 index 914bc6109..000000000 --- a/packages/cli/test/unit/util/cve-to-ghsa.test.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Unit tests for CVE to GHSA conversion utility. - * - * Purpose: Tests the CVE to GHSA ID conversion using GitHub API. - * - * Test Coverage: - convertCveToGhsa function - Successful conversion - No GHSA - * found for CVE - API errors. - * - * Related Files: - src/util/cve-to-ghsa.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the github utility. -const mockGetOctokit = vi.hoisted(() => - vi.fn(() => ({ - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - })), -) - -const mockCacheFetch = vi.hoisted(() => vi.fn()) -const mockWithGitHubRetry = vi.hoisted(() => vi.fn()) -const mockHandleGitHubApiError = vi.hoisted(() => - vi.fn(() => ({ - ok: false, - message: 'GitHub API error', - })), -) - -vi.mock('../../../src/util/git/github.mjs', () => ({ - getOctokit: mockGetOctokit, - cacheFetch: mockCacheFetch, - withGitHubRetry: mockWithGitHubRetry, - handleGitHubApiError: mockHandleGitHubApiError, -})) - -describe('cve-to-ghsa', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.resetModules() - // By default invoke the callback so coverage reaches the inner closure. - mockCacheFetch.mockImplementation(async (_key, fn) => fn()) - // Invoke the inner closure too so the octokit call site is exercised. - mockWithGitHubRetry.mockImplementation(async fn => { - await fn() - return { - ok: true, - data: { data: [{ ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }] }, - } - }) - // Provide a default octokit mock that resolves successfully. - mockGetOctokit.mockReturnValue({ - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn().mockResolvedValue({ - data: [{ ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }], - }), - }, - }, - } as unknown) - }) - - describe('convertCveToGhsa', () => { - it('returns GHSA ID for valid CVE', async () => { - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-44228') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('GHSA-xxxx-yyyy-zzzz') - } - }) - - it('throws CResult error when withGitHubRetry returns ok:false', async () => { - // Exercise the `throw result` branch (line 39) inside cacheFetch callback. - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'GitHub retry exhausted', - }) - - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-44228') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('GitHub retry exhausted') - } - }) - - it('returns error when no GHSA found for CVE', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { data: [] }, - }) - - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-99999') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('No GHSA found for CVE') - } - }) - - it('returns error when GHSA ID is missing in response', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: true, - data: { data: [{ ghsa_id: undefined }] }, - }) - - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-12345') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('No GHSA ID found in response') - } - }) - - it('handles CResult error from API', async () => { - const apiError = { - ok: false, - message: 'API rate limit exceeded', - } - // Skip the inner callback path — simulate cacheFetch rejecting at top level. - mockCacheFetch.mockRejectedValueOnce(apiError) - - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-44228') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('API rate limit exceeded') - } - }) - - it('handles generic errors', async () => { - mockCacheFetch.mockRejectedValueOnce(new Error('Network error')) - - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - const result = await convertCveToGhsa('CVE-2021-44228') - - expect(result.ok).toBe(false) - expect(mockHandleGitHubApiError).toHaveBeenCalled() - }) - - it('uses 30-day cache TTL', async () => { - const { convertCveToGhsa } = - await import('../../../src/util/cve-to-ghsa.mts') - - await convertCveToGhsa('CVE-2021-44228') - - // Verify cache was called with correct TTL (30 days in ms). - const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000 - expect(mockCacheFetch).toHaveBeenCalledWith( - expect.stringContaining('cve-to-ghsa::CVE-2021-44228'), - expect.any(Function), - thirtyDaysMs, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/data/strings.test.mts b/packages/cli/test/unit/util/data/strings.test.mts deleted file mode 100644 index 4e4ccff60..000000000 --- a/packages/cli/test/unit/util/data/strings.test.mts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Unit tests for string utilities. - * - * Purpose: Tests string manipulation and formatting utilities. Validates - * truncation, pluralization, and escaping. - * - * Test Coverage: - String truncation - Pluralization - Escape/unescape - * functions - Case conversion - String validation. - * - * Testing Approach: Tests string helper functions used across CLI. - * - * Related Files: - util/data/strings.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { camelToKebab } from '../../../../src/util/data/strings.mts' - -describe('strings utilities', () => { - describe('camelToKebab', () => { - it('converts camelCase to kebab-case', () => { - expect(camelToKebab('camelCase')).toBe('camel-case') - expect(camelToKebab('myVariableName')).toBe('my-variable-name') - expect(camelToKebab('APIToken')).toBe('apitoken') - }) - - it('handles single words', () => { - expect(camelToKebab('word')).toBe('word') - expect(camelToKebab('WORD')).toBe('word') - }) - - it('handles empty string', () => { - expect(camelToKebab('')).toBe('') - }) - - it('handles already kebab-case', () => { - expect(camelToKebab('already-kebab')).toBe('already-kebab') - }) - - it('handles numbers', () => { - expect(camelToKebab('version2')).toBe('version2') - expect(camelToKebab('v2Update')).toBe('v2update') - }) - }) - -}) diff --git a/packages/cli/test/unit/util/debug.test.mts b/packages/cli/test/unit/util/debug.test.mts deleted file mode 100644 index f46305f6c..000000000 --- a/packages/cli/test/unit/util/debug.test.mts +++ /dev/null @@ -1,488 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for debug utilities. - * - * Purpose: Tests debug logging and diagnostic utilities. Validates conditional - * logging and debug mode detection. - * - * Test Coverage: - Debug mode detection - Conditional logging - Log level - * filtering - Debug namespace support - Performance timing. - * - * Testing Approach: Tests debug utility functions with environment variable - * mocking. - * - * Related Files: - util/debug.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the registry debug functions. -const mockDebug = vi.hoisted(() => vi.fn()) -const mockDebugCache = vi.hoisted(() => vi.fn()) -const mockDebugDir = vi.hoisted(() => vi.fn()) -const mockDebugDirNs = vi.hoisted(() => vi.fn()) -const mockDebugNs = vi.hoisted(() => vi.fn()) -const mockIsDebug = vi.hoisted(() => vi.fn(() => false)) -const mockIsDebugNs = vi.hoisted(() => vi.fn(() => false)) - -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: mockDebug, - debugCache: mockDebugCache, - debugDir: mockDebugDir, - debugDirNs: mockDebugDirNs, - debugNs: mockDebugNs, -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebug: mockIsDebug, - isDebugNs: mockIsDebugNs, -})) - -import { debug, debugDir, debugNs } from '@socketsecurity/lib-stable/debug/output' - -import { - debugApiRequest, - debugApiResponse, - debugConfig, - debugFileOp, - debugGit, -} from '../../../src/util/debug.mts' - -describe('debug utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsDebug.mockReturnValue(false) - mockIsDebugNs.mockReturnValue(false) - }) - - describe('debugApiRequest', () => { - it('logs request when silly debug is enabled', () => { - mockIsDebugNs.mockReturnValue(true) - - debugApiRequest('GET', '/api/test', 5000) - - expect(debugNs).toHaveBeenCalled() - const call = mockDebugNs.mock.calls[0] - expect(call?.[0]).toBe('silly') - expect(call?.[1]).toContain('GET') - expect(call?.[1]).toContain('/api/test') - expect(call?.[1]).toContain('5000ms') - }) - - it('does not log when silly debug is disabled', () => { - mockIsDebugNs.mockReturnValue(false) - - debugApiRequest('POST', '/api/scan', 10000) - - expect(debugNs).not.toHaveBeenCalled() - }) - - it('handles request without timeout', () => { - mockIsDebugNs.mockReturnValue(true) - - debugApiRequest('DELETE', '/api/resource') - - expect(debugNs).toHaveBeenCalled() - const call = mockDebugNs.mock.calls[0] - expect(call?.[1]).toContain('DELETE') - expect(call?.[1]).not.toContain('timeout') - }) - }) - - describe('debugApiResponse', () => { - it('logs error when error is provided', () => { - const error = new Error('API failed') - - debugApiResponse('/api/test', undefined, error) - - expect(mockDebugDirNs).toHaveBeenCalledWith('error', { - endpoint: '/api/test', - error: 'API failed', - }) - }) - - it('logs under error namespace for HTTP error status codes', () => { - debugApiResponse('/api/test', 404) - - expect(debugNs).toHaveBeenCalledWith('error', 'API /api/test: HTTP 404') - }) - - it('logs notice for successful responses when debug is enabled', () => { - mockIsDebugNs.mockReturnValue(true) - - debugApiResponse('/api/test', 200) - - expect(debugNs).toHaveBeenCalledWith('notice', 'API /api/test: 200') - }) - - it('does not log for successful responses when debug is disabled', () => { - mockIsDebugNs.mockReturnValue(false) - - debugApiResponse('/api/test', 200) - - expect(debugNs).not.toHaveBeenCalled() - }) - - it('handles non-Error objects in error parameter', () => { - debugApiResponse('/api/test', undefined, 'String error') - - expect(mockDebugDirNs).toHaveBeenCalledWith('error', { - endpoint: '/api/test', - // @socketsecurity/lib/errors preserves non-empty primitives as-is; - // only empty strings / null / undefined / plain objects coerce to - // the Unknown error sentinel. A real string message passes through. - error: 'String error', - }) - }) - - it('includes request info in error logging', () => { - const error = new Error('Request failed') - const requestInfo = { - method: 'POST', - url: 'https://api.socket.dev/test', - durationMs: 1500, - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer secret-token', - }, - } - - debugApiResponse('/api/test', undefined, error, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.method).toBe('POST') - expect(calledWith.url).toBe('https://api.socket.dev/test') - expect(calledWith.durationMs).toBe(1500) - // Authorization should be redacted. - expect(calledWith.headers?.Authorization).toBe('[REDACTED]') - expect(calledWith.headers?.['Content-Type']).toBe('application/json') - }) - - it('includes request info in HTTP error logging', () => { - const requestInfo = { - method: 'GET', - url: 'https://api.socket.dev/resource', - durationMs: 500, - headers: { - 'x-api-key': 'my-api-key', - }, - } - - debugApiResponse('/api/resource', 500, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.status).toBe(500) - expect(calledWith.method).toBe('GET') - // API key should be redacted. - expect(calledWith.headers?.['x-api-key']).toBe('[REDACTED]') - }) - - it('handles partial request info', () => { - const requestInfo = { - method: 'PUT', - } - - debugApiResponse('/api/update', 400, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.method).toBe('PUT') - expect(calledWith.url).toBeUndefined() - expect(calledWith.headers).toBeUndefined() - }) - - it('includes requestedAt timestamp when provided', () => { - const requestInfo = { - method: 'POST', - url: 'https://api.socket.dev/x', - requestedAt: '2026-04-18T00:00:00.000Z', - } - - debugApiResponse('/api/x', 500, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.requestedAt).toBe('2026-04-18T00:00:00.000Z') - }) - - it('extracts cf-ray as a top-level field and keeps responseHeaders', () => { - const requestInfo = { - method: 'GET', - url: 'https://api.socket.dev/y', - responseHeaders: { - 'cf-ray': 'abc123-IAD', - 'content-type': 'application/json', - }, - } - - debugApiResponse('/api/y', 500, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.cfRay).toBe('abc123-IAD') - expect(calledWith.responseHeaders?.['cf-ray']).toBe('abc123-IAD') - }) - - it('tolerates CF-Ray header casing', () => { - const requestInfo = { - method: 'GET', - url: 'https://api.socket.dev/z', - responseHeaders: { - 'CF-Ray': 'xyz789-SJC', - }, - } - - debugApiResponse('/api/z', 500, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.cfRay).toBe('xyz789-SJC') - }) - - it('includes response body on error', () => { - const requestInfo = { - method: 'GET', - url: 'https://api.socket.dev/body', - responseBody: '{"error":"bad"}', - } - - debugApiResponse('/api/body', 400, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.responseBody).toBe('{"error":"bad"}') - }) - - it('truncates oversized response bodies', () => { - const bigBody = 'x'.repeat(5000) - const requestInfo = { - method: 'GET', - url: 'https://api.socket.dev/big', - responseBody: bigBody, - } - - debugApiResponse('/api/big', 500, undefined, requestInfo) - - const calledWith = mockDebugDirNs.mock.calls[0]?.[1] as unknown - expect(calledWith.responseBody).toMatch(/… \(truncated, 5000 chars\)$/) - expect((calledWith.responseBody as string).length).toBeLessThan( - bigBody.length, - ) - }) - }) - - describe('debugFileOp', () => { - it('logs warning when error occurs', () => { - const error = new Error('File not found') - - debugFileOp('read', '/path/to/file', error) - - expect(debugDir).toHaveBeenCalledWith({ - operation: 'read', - filepath: '/path/to/file', - error: 'File not found', - }) - }) - - it('logs silly level for successful operations when enabled', () => { - mockIsDebugNs.mockReturnValue(true) - - debugFileOp('write', '/path/to/file') - - expect(debugNs).toHaveBeenCalledWith('silly', 'File write: /path/to/file') - }) - - it('does not log for successful operations when silly is disabled', () => { - mockIsDebugNs.mockReturnValue(false) - - debugFileOp('create', '/path/to/file') - - expect(debugNs).not.toHaveBeenCalled() - }) - - it('handles all operation types', () => { - const operations: Array<'read' | 'write' | 'delete' | 'create'> = [ - 'read', - 'write', - 'delete', - 'create', - ] - - for (let i = 0, { length } = operations; i < length; i += 1) { - const op = operations[i] - debugFileOp(op, `/path/${op}`) - // No errors expected. - } - }) - }) - - describe('debugConfig', () => { - it('logs error when provided', () => { - const error = new Error('Config invalid') - - debugConfig('.socketrc', false, error) - - expect(debugDir).toHaveBeenCalledWith({ - source: '.socketrc', - error: 'Config invalid', - }) - }) - - it('logs notice when config is found', () => { - debugConfig('.socketrc', true) - - expect(debug).toHaveBeenCalledWith('Config loaded: .socketrc') - }) - - it('logs silly when config not found and debug enabled', () => { - mockIsDebugNs.mockReturnValue(true) - - debugConfig('.socketrc', false) - - expect(debugNs).toHaveBeenCalledWith( - 'silly', - 'Config not found: .socketrc', - ) - }) - - it('does not log when config not found and debug disabled', () => { - mockIsDebugNs.mockReturnValue(false) - - debugConfig('.socketrc', false) - - expect(debugNs).not.toHaveBeenCalled() - }) - }) - - describe('debugGit', () => { - it('logs warning for failed operations', () => { - debugGit('push', false, { branch: 'main' }) - - expect(debugDir).toHaveBeenCalledWith({ - git_op: 'push', - branch: 'main', - }) - }) - - it('logs notice for important successful operations', () => { - mockIsDebugNs.mockImplementation(level => level === 'notice') - - debugGit('push', true) - - expect(debugNs).toHaveBeenCalledWith('notice', 'Git push succeeded') - }) - - it('logs commit operations', () => { - mockIsDebugNs.mockReturnValue(true) - - debugGit('commit', true) - - expect(debugNs).toHaveBeenCalledWith('notice', 'Git commit succeeded') - }) - - it('logs other operations only with silly debug', () => { - mockIsDebugNs.mockImplementation(level => level === 'silly') - - debugGit('status', true) - - expect(debugNs).toHaveBeenCalledWith('silly', 'Git status') - }) - - it('does not log non-important operations without silly debug', () => { - mockIsDebugNs.mockReturnValue(false) - - debugGit('status', true) - - expect(debugNs).not.toHaveBeenCalled() - }) - }) - - describe('sanitizeHeaders', () => { - it('redacts Authorization header', async () => { - const { sanitizeHeaders } = await import('../../../src/util/debug.mts') - const result = sanitizeHeaders({ - Authorization: 'Bearer secret-token', - 'Content-Type': 'application/json', - }) - expect(result['Authorization']).toBe('[REDACTED]') - expect(result['Content-Type']).toBe('application/json') - }) - - it('redacts api-key headers (case-insensitive)', async () => { - const { sanitizeHeaders } = await import('../../../src/util/debug.mts') - const result = sanitizeHeaders({ - 'X-Api-Key': 'secret', - 'x-custom-api-key': 'also-secret', - 'Content-Length': '100', - }) - expect(result['X-Api-Key']).toBe('[REDACTED]') - expect(result['x-custom-api-key']).toBe('[REDACTED]') - expect(result['Content-Length']).toBe('100') - }) - - it('preserves regular headers', async () => { - const { sanitizeHeaders } = await import('../../../src/util/debug.mts') - const result = sanitizeHeaders({ - Accept: 'application/json', - 'User-Agent': 'socket-cli/1.0.0', - }) - expect(result['Accept']).toBe('application/json') - expect(result['User-Agent']).toBe('socket-cli/1.0.0') - }) - }) - - describe('buildApiDebugDetails', () => { - it('returns base details only when requestInfo is missing', async () => { - const { buildApiDebugDetails } = - await import('../../../src/util/debug.mts') - const result = buildApiDebugDetails({ endpoint: '/x', status: 500 }) - expect(result['endpoint']).toBe('/x') - expect(result['status']).toBe(500) - }) - - it('threads requestInfo fields into details', async () => { - const { buildApiDebugDetails } = - await import('../../../src/util/debug.mts') - const result = buildApiDebugDetails( - { endpoint: '/x' }, - { - method: 'GET', - url: 'https://api.x.com/x', - durationMs: 250, - requestedAt: '2026-04-18T00:00:00.000Z', - headers: { Authorization: 'Bearer secret' }, - responseHeaders: { 'cf-ray': 'ray-123' }, - responseBody: '{"ok":true}', - }, - ) - expect(result['method']).toBe('GET') - expect(result['url']).toBe('https://api.x.com/x') - expect(result['durationMs']).toBe(250) - expect(result['requestedAt']).toBe('2026-04-18T00:00:00.000Z') - expect((result['headers'] as unknown)?.Authorization).toBe('[REDACTED]') - expect(result['cfRay']).toBe('ray-123') - expect(result['responseBody']).toBe('{"ok":true}') - }) - - it('truncates oversize response bodies', async () => { - const { buildApiDebugDetails } = - await import('../../../src/util/debug.mts') - const big = 'x'.repeat(5000) - const result = buildApiDebugDetails( - { endpoint: '/big' }, - { method: 'GET', url: '/big', responseBody: big }, - ) - expect(result['responseBody'] as string).toMatch( - /…\s\(truncated, 5000 chars\)$/, - ) - }) - - it('handles CF-Ray header casing variations', async () => { - const { buildApiDebugDetails } = - await import('../../../src/util/debug.mts') - const result = buildApiDebugDetails( - { endpoint: '/x' }, - { - method: 'GET', - url: '/x', - responseHeaders: { 'CF-Ray': 'upper-case' }, - }, - ) - expect(result['cfRay']).toBe('upper-case') - }) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/binary.test.mts b/packages/cli/test/unit/util/dlx/binary.test.mts deleted file mode 100644 index cfd38dfb1..000000000 --- a/packages/cli/test/unit/util/dlx/binary.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file Tests for DLX binary cache functionality. Tests cover: - * - * - Path resolution (getSocketHomePath, getDlxCachePath) - * - Cache listing (listDlxCache) - * - Cache cleaning (cleanDlxCache) Note: Full download/execution tests are in - * integration tests. These unit tests focus on cache management - * operations. - */ - -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { describe, expect, it } from 'vitest' - -import { - cleanDlxCache, - getDlxCachePath, - listDlxCache, -} from '@socketsecurity/lib-stable/dlx/binary' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' -import { getSocketHomePath } from '@socketsecurity/lib-stable/paths/socket' - -describe('binary', () => { - describe('getSocketHomePath', () => { - it('should return correct path', () => { - const result = normalizePath(getSocketHomePath()) - const expected = normalizePath(path.join(os.homedir(), '.socket')) - expect(result).toBe(expected) - }) - }) - - describe('getDlxCachePath', () => { - it('should return correct cache path', () => { - const result = normalizePath(getDlxCachePath()) - const expected = normalizePath(path.join(os.homedir(), '.socket', '_dlx')) - expect(result).toBe(expected) - }) - }) - - describe('listDlxCache', () => { - it('should return empty array when cache directory does not exist', async () => { - const result = await listDlxCache() - // Could be empty or have cached items depending on test environment - expect(Array.isArray(result)).toBe(true) - }) - - it('should return array of cache entries when cache exists', async () => { - const result = await listDlxCache() - expect(Array.isArray(result)).toBe(true) - - // If cache has entries, verify core structure. - if (result.length > 0) { - const entry = result[0] - // Required properties that should always be present. - expect(entry).toHaveProperty('name') - expect(entry).toHaveProperty('size') - expect(entry).toHaveProperty('age') - expect(typeof entry.name).toBe('string') - expect(typeof entry.size).toBe('number') - expect(typeof entry.age).toBe('number') - } - }) - }) - - describe('cleanDlxCache', () => { - it('should return 0 when cache directory does not exist', async () => { - // If cache doesn't exist, should return 0 - const cachePath = getDlxCachePath() - if (!existsSync(cachePath)) { - const result = await cleanDlxCache() - expect(result).toBe(0) - } else { - // If cache exists, should return non-negative number - const result = await cleanDlxCache() - expect(result).toBeGreaterThanOrEqual(0) - } - }) - - it('should clean expired entries based on maxAge', async () => { - // Clean with very short TTL (should clean old entries) - const result = await cleanDlxCache(0) - expect(result).toBeGreaterThanOrEqual(0) - }) - - it('should not clean fresh entries', async () => { - // Clean with very long TTL (should not clean anything) - // 1 year - const result = await cleanDlxCache(365 * 24 * 60 * 60 * 1000) - expect(result).toBe(0) - }) - }) - - describe('cache structure validation', () => { - it('should have valid cache directory structure', async () => { - const cachePath = normalizePath(getDlxCachePath()) - const socketHome = normalizePath(getSocketHomePath()) - - expect(cachePath.startsWith(socketHome)).toBe(true) - expect(cachePath.endsWith(normalizePath('_dlx'))).toBe(true) - - // If cache exists, verify it's a directory - if (existsSync(cachePath)) { - // oxlint-disable-next-line socket/prefer-exists-sync -- reads .isDirectory() metadata for the assertion. - const stats = await fs.stat(cachePath) - expect(stats.isDirectory()).toBe(true) - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/cdxgen.test.mts b/packages/cli/test/unit/util/dlx/cdxgen.test.mts deleted file mode 100644 index fc7cc6152..000000000 --- a/packages/cli/test/unit/util/dlx/cdxgen.test.mts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Unit tests for dlx cdxgen integration. - * - * Purpose: Tests cdxgen (CycloneDX generator) integration for dlx. Validates - * SBOM generation via cdxgen. - * - * Test Coverage: - cdxgen execution - SBOM generation - Output parsing - Error - * handling - Version compatibility. - * - * Testing Approach: Tests cdxgen subprocess execution and output processing. - * - * Related Files: - util/dlx/cdxgen.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { spawnCdxgenDlx } from '../../../../src/util/dlx/spawn.mts' - -// Mock spawnDlx function. -vi.mock('../../../../src/util/dlx/spawn.mts', () => { - const mockSpawnDlx = vi.fn() - // Return the actual implementation for spawnCdxgenDlx. - return { - spawnDlx: mockSpawnDlx, - spawnCdxgenDlx: async ( - args: unknown, - options: unknown, - spawnExtra: unknown, - ) => { - // Replicate the actual implementation. - return mockSpawnDlx( - { name: '@cyclonedx/cdxgen', version: 'undefined' }, - args, - { force: false, silent: true, ...options }, - spawnExtra, - ) - }, - } -}) - -describe('spawnCdxgenDlx', () => { - let mockSpawnDlx: ReturnType<typeof vi.fn> - - beforeEach(async () => { - vi.clearAllMocks() - const { spawnDlx } = await import('../../../../src/util/dlx/spawn.mts') - mockSpawnDlx = vi.mocked(spawnDlx) - // Setup default resolved value for the mock. - mockSpawnDlx.mockResolvedValue({ - spawnPromise: Promise.resolve({ - stdout: 'cdxgen output', - stderr: '', - }), - }) - }) - - it('calls spawnDlx with cdxgen package', async () => { - mockSpawnDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ - stdout: 'cdxgen output', - stderr: '', - }), - } as unknown) - - await spawnCdxgenDlx(['--help']) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: '@cyclonedx/cdxgen', version: 'undefined' }, - ['--help'], - { force: false, silent: true }, - undefined, - ) - }) - - it('passes options through to spawnDlx', async () => { - mockSpawnDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ - stdout: 'cdxgen output', - stderr: '', - }), - } as unknown) - - const options = { - env: { CDXGEN_OUTPUT: 'sbom.json' }, - timeout: 30000, - force: true, - } - - await spawnCdxgenDlx(['--output', 'sbom.json'], options) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: '@cyclonedx/cdxgen', version: 'undefined' }, - ['--output', 'sbom.json'], - { - force: true, - silent: true, - env: { CDXGEN_OUTPUT: 'sbom.json' }, - timeout: 30000, - }, - undefined, - ) - }) - - it('returns spawnDlx result', async () => { - const { spawnDlx } = await import('../../../../src/util/dlx/spawn.mts') - const mockFn = vi.mocked(spawnDlx) - - const expectedResult = { - spawnPromise: Promise.resolve({ - stdout: '{"bomFormat": "CycloneDX"}', - stderr: '', - }), - } - mockFn.mockResolvedValueOnce(expectedResult as unknown) - - const result = await spawnCdxgenDlx(['--type', 'npm']) - - expect(result).toEqual(expectedResult) - }) - - it('handles SBOM generation arguments', async () => { - mockSpawnDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ - stdout: 'cdxgen output', - stderr: '', - }), - } as unknown) - - const sbomArgs = [ - '--type', - 'npm', - '--output', - '/tmp/sbom.json', - '--spec-version', - '1.4', - '--project-name', - 'test-project', - ] - - await spawnCdxgenDlx(sbomArgs) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: '@cyclonedx/cdxgen', version: 'undefined' }, - sbomArgs, - { force: false, silent: true }, - undefined, - ) - }) - - it('handles recursive scanning arguments', async () => { - mockSpawnDlx.mockResolvedValueOnce({ - spawnPromise: Promise.resolve({ - stdout: 'cdxgen output', - stderr: '', - }), - } as unknown) - - await spawnCdxgenDlx(['-r', '/path/to/scan']) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: '@cyclonedx/cdxgen', version: 'undefined' }, - ['-r', '/path/to/scan'], - { force: false, silent: true }, - undefined, - ) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/define-tool-spawn.test.mts b/packages/cli/test/unit/util/dlx/define-tool-spawn.test.mts deleted file mode 100644 index 7aa60ce7c..000000000 --- a/packages/cli/test/unit/util/dlx/define-tool-spawn.test.mts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Unit tests for `defineToolSpawn` and its helpers. - * - * Locks in the contract that the factory builds the same Dlx/Vfs/auto triple - * the per-tool spawn-* files used to assemble by hand. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockDownloadGitHubReleaseBinary = vi.hoisted(() => vi.fn()) -const mockSpawnToolVfs = vi.hoisted(() => vi.fn()) -const mockAreExternalToolsAvailable = vi.hoisted(() => vi.fn()) -const mockIsSeaBinary = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - downloadGitHubReleaseBinary: mockDownloadGitHubReleaseBinary, - spawnToolVfs: mockSpawnToolVfs, -})) - -vi.mock('../../../../src/util/dlx/vfs-extract.mts', () => ({ - areExternalToolsAvailable: mockAreExternalToolsAvailable, -})) - -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -import { - defineAutoDispatch, - defineGitHubReleaseSpawn, - defineToolSpawn, - defineVfsSpawn, -} from '../../../../src/util/dlx/define-tool-spawn.mts' - -describe('defineToolSpawn helpers', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('defineVfsSpawn', () => { - it('forwards to spawnToolVfs with the configured tool name', async () => { - mockSpawnToolVfs.mockResolvedValue({ spawnPromise: Promise.resolve() }) - const fn = defineVfsSpawn('trufflehog' as unknown) - await fn(['scan', '/path'], undefined, undefined) - expect(mockSpawnToolVfs).toHaveBeenCalledWith( - 'trufflehog', - ['scan', '/path'], - undefined, - undefined, - ) - }) - - it('passes options + spawnExtra through unchanged', async () => { - mockSpawnToolVfs.mockResolvedValue({ spawnPromise: Promise.resolve() }) - const fn = defineVfsSpawn('trivy' as unknown) - const opts = { env: { FOO: 'bar' } } as unknown - const extra = { stdio: 'pipe' as unknown } - await fn(['fs'], opts, extra) - expect(mockSpawnToolVfs).toHaveBeenCalledWith( - 'trivy', - ['fs'], - opts, - extra, - ) - }) - }) - - describe('defineGitHubReleaseSpawn', () => { - it('downloads + spawns a GitHub-release tool', async () => { - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/trufflehog') - mockSpawn.mockReturnValue('mock-spawn-promise') - const fn = defineGitHubReleaseSpawn({ - toolName: 'trufflehog', - resolve: () => ({ - type: 'github-release', - details: { name: 'trufflehog', version: '3.0.0' } as unknown, - }), - }) - const result = await fn(['scan'], undefined, undefined) - expect(mockDownloadGitHubReleaseBinary).toHaveBeenCalled() - expect(mockSpawn).toHaveBeenCalledWith( - '/cache/trufflehog', - ['scan'], - expect.objectContaining({ - stdio: 'inherit', - env: expect.any(Object), - }), - ) - expect(result).toEqual({ spawnPromise: 'mock-spawn-promise' }) - }) - - it('honors a custom stdio passed via spawnExtra', async () => { - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/trivy') - mockSpawn.mockReturnValue('p') - const fn = defineGitHubReleaseSpawn({ - toolName: 'trivy', - resolve: () => ({ - type: 'github-release', - details: { name: 'trivy', version: '1.0.0' } as unknown, - }), - }) - await fn(['fs', '/'], undefined, { stdio: 'pipe' } as unknown) - expect(mockSpawn).toHaveBeenCalledWith( - '/cache/trivy', - ['fs', '/'], - expect.objectContaining({ stdio: 'pipe' }), - ) - }) - - it('merges options.env into the child env', async () => { - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/opengrep') - mockSpawn.mockReturnValue('p') - const fn = defineGitHubReleaseSpawn({ - toolName: 'opengrep', - resolve: () => ({ - type: 'github-release', - details: { name: 'opengrep', version: '1.0.0' } as unknown, - }), - }) - await fn([], { env: { FOO: 'bar' } } as unknown, undefined) - const callEnv = mockSpawn.mock.calls[0][2].env - expect(callEnv.FOO).toBe('bar') - }) - - it('throws an internal error when the resolver returns the wrong type', async () => { - const fn = defineGitHubReleaseSpawn({ - toolName: 'trufflehog', - // Resolver contract bug: type='dlx' instead of 'github-release'. - resolve: () => - ({ - type: 'dlx', - details: { name: 'trufflehog', version: '3.0.0' }, - }) as unknown, - }) - await expect(fn([], undefined, undefined)).rejects.toThrow( - /resolveTrufflehog returned resolution\.type="dlx"/, - ) - }) - }) - - describe('defineAutoDispatch', () => { - it('uses Vfs in SEA mode with external tools available', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreExternalToolsAvailable.mockReturnValue(true) - const vfs = vi.fn().mockResolvedValue({ spawnPromise: 'vfs-result' }) - const dlx = vi.fn().mockResolvedValue({ spawnPromise: 'dlx-result' }) - const auto = defineAutoDispatch({ vfs, dlx }) - const result = await auto([], undefined, undefined) - expect(vfs).toHaveBeenCalled() - expect(dlx).not.toHaveBeenCalled() - expect(result).toEqual({ spawnPromise: 'vfs-result' }) - }) - - it('uses Dlx when not in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(false) - mockAreExternalToolsAvailable.mockReturnValue(true) - const vfs = vi.fn().mockResolvedValue({ spawnPromise: 'vfs' }) - const dlx = vi.fn().mockResolvedValue({ spawnPromise: 'dlx' }) - const auto = defineAutoDispatch({ vfs, dlx }) - const result = await auto([], undefined, undefined) - expect(dlx).toHaveBeenCalled() - expect(vfs).not.toHaveBeenCalled() - expect(result).toEqual({ spawnPromise: 'dlx' }) - }) - - it('uses Dlx when in SEA but external tools missing', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreExternalToolsAvailable.mockReturnValue(false) - const vfs = vi.fn() - const dlx = vi.fn().mockResolvedValue({ spawnPromise: 'dlx' }) - const auto = defineAutoDispatch({ vfs, dlx }) - await auto([], undefined, undefined) - expect(dlx).toHaveBeenCalled() - expect(vfs).not.toHaveBeenCalled() - }) - }) - - describe('defineToolSpawn (full triple)', () => { - it('returns Dlx + Vfs + auto together', () => { - const triple = defineToolSpawn({ - toolName: 'trufflehog', - vfsName: 'trufflehog' as unknown, - resolve: () => - ({ - type: 'github-release', - details: { name: 'trufflehog', version: '3.0.0' }, - }) as unknown, - }) - expect(typeof triple.Dlx).toBe('function') - expect(typeof triple.Vfs).toBe('function') - expect(typeof triple.auto).toBe('function') - }) - - it('auto routes to Vfs in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreExternalToolsAvailable.mockReturnValue(true) - mockSpawnToolVfs.mockResolvedValue({ spawnPromise: 'vfs' }) - const triple = defineToolSpawn({ - toolName: 'trivy', - vfsName: 'trivy' as unknown, - resolve: () => - ({ - type: 'github-release', - details: { name: 'trivy', version: '1.0.0' }, - }) as unknown, - }) - await triple.auto([], undefined, undefined) - expect(mockSpawnToolVfs).toHaveBeenCalledWith( - 'trivy', - [], - undefined, - undefined, - ) - }) - - it('auto routes to Dlx outside SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(false) - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/opengrep') - mockSpawn.mockReturnValue('p') - const triple = defineToolSpawn({ - toolName: 'opengrep', - vfsName: 'opengrep' as unknown, - resolve: () => - ({ - type: 'github-release', - details: { name: 'opengrep', version: '1.0.0' }, - }) as unknown, - }) - await triple.auto([], undefined, undefined) - expect(mockDownloadGitHubReleaseBinary).toHaveBeenCalled() - expect(mockSpawnToolVfs).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/resolve-binary.test.mts b/packages/cli/test/unit/util/dlx/resolve-binary.test.mts deleted file mode 100644 index c4c46fe73..000000000 --- a/packages/cli/test/unit/util/dlx/resolve-binary.test.mts +++ /dev/null @@ -1,512 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for binary path resolution utilities. - * - * Purpose: Tests the binary resolution logic for external tools like Coana, - * cdxgen, sfw, etc. - * - * Test Coverage: - resolveCoana function - resolveCdxgen function - - * resolvePyCli function - resolveSfw function - resolveSocketPatch function. - * - * Related Files: - src/util/dlx/resolve-binary.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock all environment variable modules. -const mockCoanaLocalPath = vi.hoisted(() => ({ - SOCKET_CLI_COANA_LOCAL_PATH: '', -})) -const mockCdxgenLocalPath = vi.hoisted(() => ({ - SOCKET_CLI_CDXGEN_LOCAL_PATH: '', -})) -const mockPyCliLocalPath = vi.hoisted(() => ({ - SOCKET_CLI_PYCLI_LOCAL_PATH: '', -})) -const mockSfwLocalPath = vi.hoisted(() => ({ SOCKET_CLI_SFW_LOCAL_PATH: '' })) -const mockSocketPatchLocalPath = vi.hoisted(() => ({ - SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH: '', -})) - -vi.mock( - '../../../../src/env/socket-cli-coana-local-path.mts', - () => mockCoanaLocalPath, -) -vi.mock( - '../../../../src/env/socket-cli-cdxgen-local-path.mts', - () => mockCdxgenLocalPath, -) -vi.mock( - '../../../../src/env/socket-cli-pycli-local-path.mts', - () => mockPyCliLocalPath, -) -vi.mock( - '../../../../src/env/socket-cli-sfw-local-path.mts', - () => mockSfwLocalPath, -) -vi.mock( - '../../../../src/env/socket-cli-socket-patch-local-path.mts', - () => mockSocketPatchLocalPath, -) - -// Mock version getters. -vi.mock('../../../../src/env/coana-version.mts', () => ({ - getCoanaVersion: () => '1.0.0', -})) -vi.mock('../../../../src/env/cdxgen-version.mts', () => ({ - getCdxgenVersion: () => '10.0.0', -})) -vi.mock('../../../../src/env/sfw-version.mts', () => ({ - getSfwNpmVersion: () => '2.0.0', -})) -vi.mock('../../../../src/env/socket-patch-version.mts', () => ({ - getSocketPatchVersion: () => '2.0.0', -})) -vi.mock('../../../../src/env/synp-version.mts', () => ({ - getSynpVersion: () => '3.0.0', -})) -vi.mock('../../../../src/env/trivy-version.mts', () => ({ - getTrivyVersion: () => '0.50.0', -})) -vi.mock('../../../../src/env/trufflehog-version.mts', () => ({ - getTrufflehogVersion: () => '3.40.0', -})) -vi.mock('../../../../src/env/opengrep-version.mts', () => ({ - getOpengrepVersion: () => '1.5.0', -})) -vi.mock('../../../../src/env/trivy-checksums.mts', () => ({ - requireTrivyChecksum: vi.fn(() => 'trivy-sha'), -})) -vi.mock('../../../../src/env/trufflehog-checksums.mts', () => ({ - requireTrufflehogChecksum: vi.fn(() => 'trufflehog-sha'), -})) -vi.mock('../../../../src/env/opengrep-checksums.mts', () => ({ - requireOpengrepChecksum: vi.fn(() => 'opengrep-sha'), -})) -vi.mock('../../../../src/env/socket-patch-checksums.mts', () => ({ - requireSocketPatchChecksum: vi.fn(() => 'socket-patch-sha'), -})) - -// Mock os module. -const mockOs = vi.hoisted(() => ({ - platform: vi.fn(() => 'darwin'), - arch: vi.fn(() => 'arm64'), -})) -vi.mock('node:os', () => ({ default: mockOs })) - -describe('binary resolution utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.resetModules() - // Reset all local path mocks. - mockCoanaLocalPath.SOCKET_CLI_COANA_LOCAL_PATH = '' - mockCdxgenLocalPath.SOCKET_CLI_CDXGEN_LOCAL_PATH = '' - mockPyCliLocalPath.SOCKET_CLI_PYCLI_LOCAL_PATH = '' - mockSfwLocalPath.SOCKET_CLI_SFW_LOCAL_PATH = '' - mockSocketPatchLocalPath.SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH = '' - mockOs.platform.mockReturnValue('darwin') - mockOs.arch.mockReturnValue('arm64') - }) - - describe('resolveCoana', () => { - it('returns dlx spec when no local path is set', async () => { - const { resolveCoana } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveCoana() - - expect(result).toEqual({ - type: 'dlx', - details: { - name: '@coana-tech/cli', - version: '1.0.0', - binaryName: 'coana', - }, - }) - }) - - it('returns local path when SOCKET_CLI_COANA_LOCAL_PATH is set', async () => { - mockCoanaLocalPath.SOCKET_CLI_COANA_LOCAL_PATH = '/custom/path/coana' - - const { resolveCoana } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveCoana() - - expect(result).toEqual({ - type: 'local', - path: '/custom/path/coana', - }) - }) - }) - - describe('resolveCdxgen', () => { - it('returns dlx spec when no local path is set', async () => { - const { resolveCdxgen } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveCdxgen() - - expect(result).toEqual({ - type: 'dlx', - details: { - name: '@cyclonedx/cdxgen', - version: '10.0.0', - binaryName: 'cdxgen', - }, - }) - }) - - it('returns local path when SOCKET_CLI_CDXGEN_LOCAL_PATH is set', async () => { - mockCdxgenLocalPath.SOCKET_CLI_CDXGEN_LOCAL_PATH = '/custom/path/cdxgen' - - const { resolveCdxgen } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveCdxgen() - - expect(result).toEqual({ - type: 'local', - path: '/custom/path/cdxgen', - }) - }) - }) - - describe('resolvePyCli', () => { - it('returns python type when no local path is set', async () => { - const { resolvePyCli } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolvePyCli() - - expect(result).toEqual({ type: 'python' }) - }) - - it('returns local path when SOCKET_CLI_PYCLI_LOCAL_PATH is set', async () => { - mockPyCliLocalPath.SOCKET_CLI_PYCLI_LOCAL_PATH = - '/custom/path/socket-pycli' - - const { resolvePyCli } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolvePyCli() - - expect(result).toEqual({ - type: 'local', - path: '/custom/path/socket-pycli', - }) - }) - }) - - describe('resolveSfw', () => { - it('returns dlx spec when no local path is set', async () => { - const { resolveSfw } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSfw() - - expect(result).toEqual({ - type: 'dlx', - details: { - name: 'sfw', - version: '2.0.0', - binaryName: 'sfw', - }, - }) - }) - - it('returns local path when SOCKET_CLI_SFW_LOCAL_PATH is set', async () => { - mockSfwLocalPath.SOCKET_CLI_SFW_LOCAL_PATH = '/custom/path/sfw' - - const { resolveSfw } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSfw() - - expect(result).toEqual({ - type: 'local', - path: '/custom/path/sfw', - }) - }) - }) - - describe('resolveSocketPatch', () => { - it('returns github-release spec for darwin-arm64', async () => { - mockOs.platform.mockReturnValue('darwin') - mockOs.arch.mockReturnValue('arm64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-aarch64-apple-darwin.tar.gz', - binaryName: 'socket-patch', - }, - }) - }) - - it('returns github-release spec for darwin-x64', async () => { - mockOs.platform.mockReturnValue('darwin') - mockOs.arch.mockReturnValue('x64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-x86_64-apple-darwin.tar.gz', - binaryName: 'socket-patch', - }, - }) - }) - - it('returns github-release spec for linux-arm64', async () => { - mockOs.platform.mockReturnValue('linux') - mockOs.arch.mockReturnValue('arm64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-aarch64-unknown-linux-gnu.tar.gz', - binaryName: 'socket-patch', - }, - }) - }) - - it('returns github-release spec for linux-x64', async () => { - mockOs.platform.mockReturnValue('linux') - mockOs.arch.mockReturnValue('x64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-x86_64-unknown-linux-musl.tar.gz', - binaryName: 'socket-patch', - }, - }) - }) - - it('returns github-release spec for win32-x64', async () => { - mockOs.platform.mockReturnValue('win32') - mockOs.arch.mockReturnValue('x64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-x86_64-pc-windows-msvc.zip', - binaryName: 'socket-patch', - }, - }) - }) - - it('returns github-release spec for win32-arm64', async () => { - mockOs.platform.mockReturnValue('win32') - mockOs.arch.mockReturnValue('arm64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - owner: 'SocketDev', - repo: 'socket-patch', - version: '2.0.0', - assetName: 'socket-patch-aarch64-pc-windows-msvc.zip', - binaryName: 'socket-patch', - }, - }) - }) - - it('throws error for unsupported platform', async () => { - mockOs.platform.mockReturnValue('freebsd') - mockOs.arch.mockReturnValue('x64') - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - expect(() => resolveSocketPatch()).toThrow( - /socket-patch has no prebuilt binary for "freebsd-x64"/, - ) - }) - - it('returns local path when SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH is set', async () => { - mockSocketPatchLocalPath.SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH = - '/custom/path/socket-patch' - - const { resolveSocketPatch } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveSocketPatch() - - expect(result).toEqual({ - type: 'local', - path: '/custom/path/socket-patch', - }) - }) - }) - - describe('resolveTrivy', () => { - it.each([ - ['darwin', 'arm64', 'trivy_0.50.0_macOS-ARM64.tar.gz'], - ['darwin', 'x64', 'trivy_0.50.0_macOS-64bit.tar.gz'], - ['linux', 'arm64', 'trivy_0.50.0_Linux-ARM64.tar.gz'], - ['linux', 'x64', 'trivy_0.50.0_Linux-64bit.tar.gz'], - ['win32', 'x64', 'trivy_0.50.0_windows-64bit.zip'], - ])('returns spec for %s-%s', async (platform, arch, assetName) => { - mockOs.platform.mockReturnValue(platform as unknown) - mockOs.arch.mockReturnValue(arch as unknown) - - const { resolveTrivy } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveTrivy() - - expect(result).toEqual({ - type: 'github-release', - details: { - assetName, - binaryName: 'trivy', - owner: 'aquasecurity', - repo: 'trivy', - sha256: 'trivy-sha', - version: 'v0.50.0', - }, - }) - }) - - it('throws on unsupported platform', async () => { - mockOs.platform.mockReturnValue('win32' as unknown) - mockOs.arch.mockReturnValue('arm64' as unknown) - - const { resolveTrivy } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - expect(() => resolveTrivy()).toThrow( - /Trivy has no prebuilt binary for "win32-arm64"/, - ) - }) - }) - - describe('resolveTrufflehog', () => { - it.each([ - ['darwin', 'arm64', 'trufflehog_3.40.0_darwin_arm64.tar.gz'], - ['darwin', 'x64', 'trufflehog_3.40.0_darwin_amd64.tar.gz'], - ['linux', 'arm64', 'trufflehog_3.40.0_linux_arm64.tar.gz'], - ['linux', 'x64', 'trufflehog_3.40.0_linux_amd64.tar.gz'], - ['win32', 'arm64', 'trufflehog_3.40.0_windows_arm64.tar.gz'], - ['win32', 'x64', 'trufflehog_3.40.0_windows_amd64.tar.gz'], - ])('returns spec for %s-%s', async (platform, arch, assetName) => { - mockOs.platform.mockReturnValue(platform as unknown) - mockOs.arch.mockReturnValue(arch as unknown) - - const { resolveTrufflehog } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveTrufflehog() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - assetName, - binaryName: 'trufflehog', - owner: 'trufflesecurity', - repo: 'trufflehog', - version: 'v3.40.0', - }, - }) - }) - - it('throws on unsupported platform', async () => { - mockOs.platform.mockReturnValue('freebsd' as unknown) - mockOs.arch.mockReturnValue('x64' as unknown) - - const { resolveTrufflehog } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - expect(() => resolveTrufflehog()).toThrow( - /TruffleHog has no prebuilt binary for "freebsd-x64"/, - ) - }) - }) - - describe('resolveOpengrep', () => { - it.each([ - ['darwin', 'arm64', 'opengrep-core_osx_aarch64.tar.gz'], - ['darwin', 'x64', 'opengrep-core_osx_x86.tar.gz'], - ['linux', 'arm64', 'opengrep-core_linux_aarch64.tar.gz'], - ['linux', 'x64', 'opengrep-core_linux_x86.tar.gz'], - ['win32', 'x64', 'opengrep-core_windows_x86.zip'], - ])('returns spec for %s-%s', async (platform, arch, assetName) => { - mockOs.platform.mockReturnValue(platform as unknown) - mockOs.arch.mockReturnValue(arch as unknown) - - const { resolveOpengrep } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - const result = resolveOpengrep() - - expect(result).toMatchObject({ - type: 'github-release', - details: { - assetName, - binaryName: 'osemgrep', - owner: 'opengrep', - repo: 'opengrep', - version: '1.5.0', - }, - }) - }) - - it('throws on unsupported platform', async () => { - mockOs.platform.mockReturnValue('win32' as unknown) - mockOs.arch.mockReturnValue('arm64' as unknown) - - const { resolveOpengrep } = - await import('../../../../src/util/dlx/resolve-binary.mts') - - expect(() => resolveOpengrep()).toThrow( - /OpenGrep has no prebuilt binary for "win32-arm64"/, - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts b/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts deleted file mode 100644 index 24f35f504..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Unit tests for the bespoke `spawnCdxgenDlx` flow. - * - * The Vfs / auto-dispatch code is tested via define-tool-spawn.test.mts. This - * file targets the cdxgen-specific paths: local-override execution (binary or - * JS via node) and the `spawnDlx` fallback for the npm dlx route. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnDlx = vi.hoisted(() => vi.fn()) -const mockResolveCdxgen = vi.hoisted(() => vi.fn()) -const mockDetectExecutableType = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/detect', () => ({ - detectExecutableType: mockDetectExecutableType, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnDlx: mockSpawnDlx, -})) - -vi.mock('../../../../src/util/dlx/resolve-binary.mts', () => ({ - resolveCdxgen: mockResolveCdxgen, -})) - -import { spawnCdxgenDlx } from '../../../../src/util/dlx/spawn-cdxgen.mts' - -describe('spawnCdxgenDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('runs a local cdxgen binary when SOCKET_CLI_CDXGEN_LOCAL_PATH is set', async () => { - mockResolveCdxgen.mockReturnValue({ - type: 'local', - path: '/local/cdxgen', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockReturnValue('p') - - const result = await spawnCdxgenDlx(['-r', '.'], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/cdxgen', - ['-r', '.'], - expect.objectContaining({ stdio: 'inherit' }), - ) - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('runs the local cdxgen.js via node when not a binary', async () => { - mockResolveCdxgen.mockReturnValue({ - type: 'local', - path: '/local/cdxgen.js', - }) - mockDetectExecutableType.mockReturnValue({ type: 'script' }) - mockSpawn.mockReturnValue('p') - - await spawnCdxgenDlx([], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - 'node', - ['/local/cdxgen.js'], - expect.any(Object), - ) - }) - - it('falls back to spawnDlx when resolution.type is "dlx"', async () => { - mockResolveCdxgen.mockReturnValue({ - type: 'dlx', - details: { name: '@cyclonedx/cdxgen', version: '11.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - - const result = await spawnCdxgenDlx([], undefined, undefined) - - expect(mockSpawnDlx).toHaveBeenCalled() - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('throws when resolveCdxgen returns an unexpected type', async () => { - mockResolveCdxgen.mockReturnValue({ - type: 'github-release', - details: {} as unknown, - }) - - await expect(spawnCdxgenDlx([], undefined, undefined)).rejects.toThrow( - /resolveCdxgen returned resolution\.type="github-release"/, - ) - }) - - it('honors a custom stdio passed via spawnExtra', async () => { - mockResolveCdxgen.mockReturnValue({ - type: 'local', - path: '/local/cdxgen', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockReturnValue('p') - - await spawnCdxgenDlx([], undefined, { stdio: 'pipe' } as unknown) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/cdxgen', - [], - expect.objectContaining({ stdio: 'pipe' }), - ) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-coana.test.mts b/packages/cli/test/unit/util/dlx/spawn-coana.test.mts deleted file mode 100644 index 81794b1e9..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-coana.test.mts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * Unit tests for util/dlx/spawn-coana. - * - * Covers spawnCoana, spawnCoanaDlx, and spawnCoanaVfs paths. - * - * Related Files: - src/util/dlx/spawn-coana.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnDlx = vi.hoisted(() => vi.fn()) -const mockSpawnToolVfs = vi.hoisted(() => vi.fn()) -const mockResolveCoana = vi.hoisted(() => vi.fn()) -const mockDetectExecutableType = vi.hoisted(() => vi.fn()) -const mockAreExternalToolsAvailable = vi.hoisted(() => vi.fn(() => false)) -const mockIsSeaBinary = vi.hoisted(() => vi.fn(() => false)) -const mockGetCliVersion = vi.hoisted(() => vi.fn(() => '1.2.3')) -const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => undefined)) -const mockGetDefaultProxyUrl = vi.hoisted(() => vi.fn(() => undefined)) -const mockGetDefaultOrgSlug = vi.hoisted(() => - vi.fn(async () => ({ ok: false, message: 'no org' })), -) -const mockGetErrorCause = vi.hoisted(() => vi.fn((e: unknown) => String(e))) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/detect', () => ({ - detectExecutableType: mockDetectExecutableType, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnDlx: mockSpawnDlx, - spawnToolVfs: mockSpawnToolVfs, -})) - -vi.mock('../../../../src/util/dlx/resolve-binary.mts', () => ({ - resolveCoana: mockResolveCoana, -})) - -vi.mock('../../../../src/util/dlx/vfs-extract.mts', () => ({ - areExternalToolsAvailable: mockAreExternalToolsAvailable, -})) - -vi.mock('../../../../src/commands/ci/fetch-default-org-slug.mts', () => ({ - getDefaultOrgSlug: mockGetDefaultOrgSlug, -})) - -vi.mock('../../../../src/env/cli-version.mts', () => ({ - getCliVersion: mockGetCliVersion, -})) - -vi.mock('../../../../src/util/error/errors.mts', () => ({ - getErrorCause: mockGetErrorCause, -})) - -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, - getDefaultProxyUrl: mockGetDefaultProxyUrl, -})) - -import { - spawnCoana, - spawnCoanaDlx, - spawnCoanaVfs, -} from '../../../../src/util/dlx/spawn-coana.mts' - -describe('spawnCoanaDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetCliVersion.mockReturnValue('1.2.3') - mockGetDefaultApiToken.mockReturnValue(undefined) - mockGetDefaultProxyUrl.mockReturnValue(undefined) - mockGetDefaultOrgSlug.mockResolvedValue({ ok: false, message: 'no org' }) - mockIsSeaBinary.mockReturnValue(false) - mockAreExternalToolsAvailable.mockReturnValue(false) - }) - - it('runs a local coana binary when resolution is local + binary', async () => { - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('hello') }) - - const result = await spawnCoanaDlx(['scan'], 'my-org', undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/coana', - ['scan'], - expect.objectContaining({ stdio: 'inherit' }), - ) - expect(result).toEqual({ ok: true, data: 'hello' }) - }) - - it('runs the local coana.js via node when not a binary', async () => { - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana.js' }) - mockDetectExecutableType.mockReturnValue({ type: 'script' }) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('') }) - - await spawnCoanaDlx([], undefined, undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - 'node', - ['/local/coana.js'], - expect.any(Object), - ) - }) - - it('mixes in SOCKET_CLI_API_TOKEN when getDefaultApiToken returns a value', async () => { - mockGetDefaultApiToken.mockReturnValue('tok-xyz') - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: undefined }) - - await spawnCoanaDlx([], 'org', undefined, undefined) - - const call = mockSpawn.mock.calls[0] - expect(call[2].env.SOCKET_CLI_API_TOKEN).toBe('tok-xyz') - expect(call[2].env.SOCKET_ORG_SLUG).toBe('org') - }) - - it('uses default org slug when none passed and getDefaultOrgSlug succeeds', async () => { - mockGetDefaultOrgSlug.mockResolvedValue({ ok: true, data: 'auto-org' }) - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: undefined }) - - await spawnCoanaDlx([], undefined, undefined, undefined) - - const call = mockSpawn.mock.calls[0] - expect(call[2].env.SOCKET_ORG_SLUG).toBe('auto-org') - }) - - it('mixes in SOCKET_CLI_API_PROXY when getDefaultProxyUrl returns a value', async () => { - mockGetDefaultProxyUrl.mockReturnValue('http://proxy:8080') - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: undefined }) - - await spawnCoanaDlx([], 'org', undefined, undefined) - - const call = mockSpawn.mock.calls[0] - expect(call[2].env.SOCKET_CLI_API_PROXY).toBe('http://proxy:8080') - }) - - it('honors a custom stdio passed via spawnExtra', async () => { - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: undefined }) - - await spawnCoanaDlx([], 'org', undefined, { stdio: 'pipe' } as never) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/coana', - [], - expect.objectContaining({ stdio: 'pipe' }), - ) - }) - - it('falls back to spawnDlx when resolution.type is "dlx"', async () => { - mockResolveCoana.mockReturnValue({ - type: 'dlx', - details: { name: '@coana-tech/cli', version: '1.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: Buffer.from('dlx-out') }), - }) - - const result = await spawnCoanaDlx([], 'org', undefined, undefined) - - expect(mockSpawnDlx).toHaveBeenCalled() - expect(result).toEqual({ ok: true, data: 'dlx-out' }) - }) - - it('uses coanaVersion override when provided', async () => { - mockResolveCoana.mockReturnValue({ - type: 'dlx', - details: { name: '@coana-tech/cli', version: '1.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: undefined }), - }) - - await spawnCoanaDlx([], 'org', { coanaVersion: '2.0.0' }, undefined) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - expect.objectContaining({ version: '2.0.0' }), - expect.any(Array), - expect.any(Object), - undefined, - ) - }) - - it('throws when resolveCoana returns an unexpected type', async () => { - mockResolveCoana.mockReturnValue({ - type: 'github-release', - details: {} as never, - }) - - const result = await spawnCoanaDlx([], 'org', undefined, undefined) - - expect(result.ok).toBe(false) - }) - - it('returns ok:false with error message when spawn rejects', async () => { - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - const err = Object.assign(new Error('spawn failed'), { - stderr: 'stderr text', - }) - mockSpawn.mockRejectedValue(err) - - const result = await spawnCoanaDlx([], 'org', undefined, undefined) - - expect(result.ok).toBe(false) - expect((result as { message?: string | undefined }).message).toBe( - 'stderr text', - ) - }) - - it('uses getErrorCause when no stderr present on rejection', async () => { - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockRejectedValue(new Error('boom')) - mockGetErrorCause.mockReturnValue('error-cause-msg') - - const result = await spawnCoanaDlx([], 'org', undefined, undefined) - - expect(result.ok).toBe(false) - expect((result as { message?: string | undefined }).message).toBe( - 'error-cause-msg', - ) - }) -}) - -describe('spawnCoanaVfs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetCliVersion.mockReturnValue('1.2.3') - mockGetDefaultApiToken.mockReturnValue(undefined) - mockGetDefaultProxyUrl.mockReturnValue(undefined) - mockGetDefaultOrgSlug.mockResolvedValue({ ok: false, message: 'no org' }) - }) - - it('spawns coana through spawnToolVfs and returns stdout', async () => { - mockSpawnToolVfs.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: Buffer.from('vfs-out') }), - }) - - const result = await spawnCoanaVfs(['args'], undefined, undefined) - - expect(mockSpawnToolVfs).toHaveBeenCalledWith( - 'coana', - ['args'], - expect.any(Object), - undefined, - ) - expect(result).toEqual({ ok: true, data: 'vfs-out' }) - }) - - it('returns empty string when stdout missing', async () => { - mockSpawnToolVfs.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: undefined }), - }) - - const result = await spawnCoanaVfs([], undefined, undefined) - expect(result).toEqual({ ok: true, data: '' }) - }) - - it('mixes API token / org slug / proxy into spawn env', async () => { - mockGetDefaultApiToken.mockReturnValue('tok') - mockGetDefaultOrgSlug.mockResolvedValue({ ok: true, data: 'org-1' }) - mockGetDefaultProxyUrl.mockReturnValue('http://proxy') - mockSpawnToolVfs.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: undefined }), - }) - - await spawnCoanaVfs([], undefined, undefined) - - const opts = mockSpawnToolVfs.mock.calls[0][2] - expect(opts.env.SOCKET_CLI_API_TOKEN).toBe('tok') - expect(opts.env.SOCKET_ORG_SLUG).toBe('org-1') - expect(opts.env.SOCKET_CLI_API_PROXY).toBe('http://proxy') - expect(opts.env.SOCKET_CLI_VERSION).toBe('1.2.3') - }) - - it('returns ok:false with stderr when spawnToolVfs throws with stderr', async () => { - const err = Object.assign(new Error('vfs boom'), { stderr: 'stderr-vfs' }) - mockSpawnToolVfs.mockRejectedValue(err) - - const result = await spawnCoanaVfs([], undefined, undefined) - expect(result.ok).toBe(false) - expect((result as { message?: string | undefined }).message).toBe( - 'stderr-vfs', - ) - }) - - it('returns ok:false with cause when spawnToolVfs throws without stderr', async () => { - mockSpawnToolVfs.mockRejectedValue(new Error('plain')) - mockGetErrorCause.mockReturnValue('plain-cause') - - const result = await spawnCoanaVfs([], undefined, undefined) - expect(result.ok).toBe(false) - expect((result as { message?: string | undefined }).message).toBe( - 'plain-cause', - ) - }) -}) - -describe('spawnCoana (auto-dispatch)', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetCliVersion.mockReturnValue('1.2.3') - mockGetDefaultApiToken.mockReturnValue(undefined) - mockGetDefaultProxyUrl.mockReturnValue(undefined) - mockGetDefaultOrgSlug.mockResolvedValue({ ok: false, message: 'no org' }) - }) - - it('routes to spawnCoanaVfs when SEA + external tools available', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreExternalToolsAvailable.mockReturnValue(true) - mockSpawnToolVfs.mockResolvedValue({ - spawnPromise: Promise.resolve({ stdout: Buffer.from('via-vfs') }), - }) - - const result = await spawnCoana(['x'], 'org', undefined, undefined) - - expect(mockSpawnToolVfs).toHaveBeenCalled() - expect(result).toEqual({ ok: true, data: 'via-vfs' }) - }) - - it('routes to spawnCoanaDlx when not in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(false) - mockAreExternalToolsAvailable.mockReturnValue(false) - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('via-dlx') }) - - const result = await spawnCoana(['x'], 'org', undefined, undefined) - - expect(mockSpawn).toHaveBeenCalled() - expect(result).toEqual({ ok: true, data: 'via-dlx' }) - }) - - it('routes to spawnCoanaDlx when SEA but external tools missing', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreExternalToolsAvailable.mockReturnValue(false) - mockResolveCoana.mockReturnValue({ type: 'local', path: '/local/coana' }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('') }) - - await spawnCoana(['x'], 'org', undefined, undefined) - expect(mockSpawn).toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-pycli.test.mts b/packages/cli/test/unit/util/dlx/spawn-pycli.test.mts deleted file mode 100644 index 4468b9166..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-pycli.test.mts +++ /dev/null @@ -1,1265 +0,0 @@ -/** - * Max-file-lines: legitimate — comprehensive single-module test suite. Covers - * 13 entry points of src/util/dlx/spawn-pycli.mts (helpers + ensure* state - * machines + spawn* dispatchers). Splitting would duplicate the ~130 lines of - * vi.mock setup that every describe relies on; the mock contract IS the - * test-file's cohesion. - * - * Unit tests for util/dlx/spawn-pycli. - * - * Covers convertCaretToPipRange, downloadPyPiWheel, downloadPython, - * ensurePython, ensurePythonDlx, ensureSocketPyCli, getPythonBinPath, - * getPythonCachePath, getPythonStandaloneInfo, isSocketPyCliInstalled, - * spawnSocketPyCli, spawnSocketPyCliDlx, and spawnSocketPyCliVfs. - * - * Related Files: - * - * - Src/util/dlx/spawn-pycli.mts - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as NodeFs from 'node:fs' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnNode = vi.hoisted(() => vi.fn()) -const mockDownloadBinary = vi.hoisted(() => vi.fn()) -const mockGetDlxCachePath = vi.hoisted(() => vi.fn(() => '/tmp/dlx')) -const mockWhichReal = vi.hoisted(() => vi.fn(async () => '/usr/bin/tar')) -const mockSafeMkdir = vi.hoisted(() => vi.fn(async () => {})) -const mockSafeDelete = vi.hoisted(() => vi.fn(async () => {})) -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)) -const mockFsWriteFile = vi.hoisted(() => vi.fn(async () => {})) -const mockFsReadFile = vi.hoisted(() => vi.fn(async () => '99999')) -const mockFsCopyFile = vi.hoisted(() => vi.fn(async () => {})) -const mockFsChmod = vi.hoisted(() => vi.fn(async () => {})) -const mockResolvePyCli = vi.hoisted(() => vi.fn()) -const mockAreBasicsToolsAvailable = vi.hoisted(() => vi.fn(() => false)) -const mockExtractBasicsTools = vi.hoisted(() => vi.fn(async () => undefined)) -const mockGetBasicsToolPaths = vi.hoisted(() => - vi.fn(() => ({ python: '/basics/python' })), -) -const mockIsSeaBinary = vi.hoisted(() => vi.fn(() => false)) -const mockSocketHttpRequest = vi.hoisted(() => vi.fn()) -const mockGetPyCliVersion = vi.hoisted(() => vi.fn(() => '2.3.4')) -const mockGetPyCliChecksums = vi.hoisted(() => vi.fn(() => ({}))) -const mockGetPythonVersion = vi.hoisted(() => vi.fn(() => '3.12.0')) -const mockGetPythonBuildTag = vi.hoisted(() => vi.fn(() => '20240101')) -const mockRequirePythonChecksum = vi.hoisted(() => vi.fn(() => 'sha256-abc')) - -let mockSocketCliPythonPath: string | undefined = undefined -vi.mock('../../../../src/env/socket-cli-python-path.mts', () => ({ - get SOCKET_CLI_PYTHON_PATH() { - return mockSocketCliPythonPath - }, -})) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/binary', () => ({ - downloadBinary: mockDownloadBinary, - getDlxCachePath: mockGetDlxCachePath, -})) - -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: mockWhichReal, -})) - -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeMkdir: mockSafeMkdir, - safeDelete: mockSafeDelete, -})) - -vi.mock('@socketsecurity/lib-stable/constants/platform', () => ({ - WIN32: false, -})) - -vi.mock('node:fs', async () => { - const actual = await vi.importActual<typeof NodeFs>('node:fs') - const promises = { - writeFile: mockFsWriteFile, - readFile: mockFsReadFile, - copyFile: mockFsCopyFile, - chmod: mockFsChmod, - } - return { - ...actual, - existsSync: mockExistsSync, - promises, - default: { ...actual, existsSync: mockExistsSync, promises }, - } -}) - -vi.mock('../../../../src/util/dlx/resolve-binary.mts', () => ({ - resolvePyCli: mockResolvePyCli, -})) - -vi.mock('../../../../src/util/basics/vfs-extract.mts', () => ({ - areBasicsToolsAvailable: mockAreBasicsToolsAvailable, - extractBasicsTools: mockExtractBasicsTools, - getBasicsToolPaths: mockGetBasicsToolPaths, -})) - -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -vi.mock('../../../../src/util/socket/api.mts', () => ({ - socketHttpRequest: mockSocketHttpRequest, -})) - -vi.mock('../../../../src/util/spawn/spawn-node.mts', () => ({ - spawnNode: mockSpawnNode, -})) - -vi.mock('../../../../src/env/pycli-version.mts', () => ({ - getPyCliVersion: mockGetPyCliVersion, -})) - -vi.mock('../../../../src/env/pycli-checksums.mts', () => ({ - getPyCliChecksums: mockGetPyCliChecksums, -})) - -vi.mock('../../../../src/env/python-version.mts', () => ({ - getPythonVersion: mockGetPythonVersion, -})) - -vi.mock('../../../../src/env/python-build-tag.mts', () => ({ - getPythonBuildTag: mockGetPythonBuildTag, -})) - -vi.mock('../../../../src/env/python-checksums.mts', () => ({ - requirePythonChecksum: mockRequirePythonChecksum, -})) - -import { - convertCaretToPipRange, - downloadPyPiWheel, - downloadPython, - ensurePython, - ensurePythonDlx, - ensureSocketPyCli, - getPythonBinPath, - getPythonCachePath, - getPythonStandaloneInfo, - isSocketPyCliInstalled, - spawnSocketPyCli, - spawnSocketPyCliDlx, - spawnSocketPyCliVfs, -} from '../../../../src/util/dlx/spawn-pycli.mts' - -const realSetTimeout = globalThis.setTimeout -function stubFastTimers() { - ;(globalThis as { setTimeout: unknown }).setTimeout = ((cb: () => void) => { - cb() - return 0 as never - }) as never -} -function restoreTimers() { - ;(globalThis as { setTimeout: unknown }).setTimeout = realSetTimeout -} - -describe('convertCaretToPipRange', () => { - it('returns empty string for empty input', () => { - expect(convertCaretToPipRange('')).toBe('') - }) - - it('returns ==<version> for non-caret input', () => { - expect(convertCaretToPipRange('1.2.3')).toBe('==1.2.3') - }) - - it('converts ^1.2.3 to >=1.2.3,<2.0.0', () => { - expect(convertCaretToPipRange('^1.2.3')).toBe('>=1.2.3,<2.0.0') - }) - - it('returns empty string for malformed "^" alone', () => { - expect(convertCaretToPipRange('^')).toBe('') - }) - - it('returns ==<version> for non-numeric major in caret range', () => { - expect(convertCaretToPipRange('^x.2.3')).toBe('==x.2.3') - }) - - it('handles caret with single-digit version', () => { - expect(convertCaretToPipRange('^2')).toBe('>=2,<3.0.0') - }) -}) - -describe('getPythonBinPath', () => { - it('returns POSIX bin path', () => { - const result = getPythonBinPath('/cache/py') - expect(result).toMatch(/python\/bin\/python3$/) - }) -}) - -describe('getPythonCachePath', () => { - it('returns a path containing python version + platform', () => { - const result = getPythonCachePath() - expect(result).toContain('python') - expect(result).toContain('3.12.0') - }) -}) - -describe('getPythonStandaloneInfo', () => { - it('throws for unsupported platform', () => { - const orig = process.platform - Object.defineProperty(process, 'platform', { - value: 'sunos', - configurable: true, - }) - try { - expect(() => getPythonStandaloneInfo()).toThrow( - /python-build-standalone does not ship a prebuilt/, - ) - } finally { - Object.defineProperty(process, 'platform', { - value: orig, - configurable: true, - }) - } - }) - - it('returns darwin info', () => { - const orig = process.platform - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('apple-darwin') - expect(info.assetName).toMatch(/install_only\.tar\.gz$/) - } finally { - Object.defineProperty(process, 'platform', { - value: orig, - configurable: true, - }) - } - }) - - it('returns linux info', () => { - const origPlat = process.platform - const origArch = process.arch - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'x64', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('linux-gnu') - } finally { - Object.defineProperty(process, 'platform', { - value: origPlat, - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: origArch, - configurable: true, - }) - } - }) - - it('returns linux arm64 info', () => { - const origPlat = process.platform - const origArch = process.arch - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'arm64', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('aarch64-unknown-linux-gnu') - } finally { - Object.defineProperty(process, 'platform', { - value: origPlat, - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: origArch, - configurable: true, - }) - } - }) - - it('returns darwin x64 info', () => { - const origPlat = process.platform - const origArch = process.arch - Object.defineProperty(process, 'platform', { - value: 'darwin', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'x64', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('x86_64-apple-darwin') - } finally { - Object.defineProperty(process, 'platform', { - value: origPlat, - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: origArch, - configurable: true, - }) - } - }) - - it('returns win32 info', () => { - const origPlat = process.platform - const origArch = process.arch - Object.defineProperty(process, 'platform', { - value: 'win32', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'x64', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('windows-msvc') - } finally { - Object.defineProperty(process, 'platform', { - value: origPlat, - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: origArch, - configurable: true, - }) - } - }) - - it('returns win32 arm64 info', () => { - const origPlat = process.platform - const origArch = process.arch - Object.defineProperty(process, 'platform', { - value: 'win32', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'arm64', - configurable: true, - }) - try { - const info = getPythonStandaloneInfo() - expect(info.url).toContain('aarch64-pc-windows-msvc') - } finally { - Object.defineProperty(process, 'platform', { - value: origPlat, - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: origArch, - configurable: true, - }) - } - }) -}) - -describe('downloadPyPiWheel', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(false) - mockSafeMkdir.mockResolvedValue(undefined) - mockFsCopyFile.mockResolvedValue(undefined) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/dl/wheel' }) - }) - - it('returns cached wheel path when already present', async () => { - mockExistsSync.mockReturnValue(true) - const result = await downloadPyPiWheel('pkg', '1.0.0', 'sha') - expect(result).toContain('pkg-1.0.0-py3-none-any.whl') - expect(mockSocketHttpRequest).not.toHaveBeenCalled() - }) - - it('throws InputError when PyPI returns non-ok response', async () => { - mockSocketHttpRequest.mockResolvedValue({ - ok: false, - status: 404, - json: () => ({}), - }) - await expect(downloadPyPiWheel('pkg', '1.0.0', 'sha')).rejects.toThrow( - /could not fetch PyPI metadata/, - ) - }) - - it('throws InputError when network request fails', async () => { - mockSocketHttpRequest.mockRejectedValue(new Error('ECONNRESET')) - await expect(downloadPyPiWheel('pkg', '1.0.0', 'sha')).rejects.toThrow( - /could not fetch PyPI metadata/, - ) - }) - - it('throws when no py3-none-any wheel is available', async () => { - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ urls: [{ filename: 'pkg.tar.gz', url: 'http://x' }] }), - }) - await expect(downloadPyPiWheel('pkg', '1.0.0', 'sha')).rejects.toThrow( - /has no py3-none-any wheel/, - ) - }) - - it('downloads, verifies, and copies wheel to cache', async () => { - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ - urls: [{ filename: 'pkg-1.0.0-py3-none-any.whl', url: 'http://x' }], - }), - }) - - const result = await downloadPyPiWheel('pkg', '1.0.0', 'sha') - expect(mockDownloadBinary).toHaveBeenCalled() - expect(mockFsCopyFile).toHaveBeenCalled() - expect(result).toContain('pkg-1.0.0-py3-none-any.whl') - }) - - it('falls back to any .whl when no py3-none-any present', async () => { - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ - urls: [{ filename: 'pkg-1.0.0-anywhere.whl', url: 'http://x' }], - }), - }) - const result = await downloadPyPiWheel('pkg', '1.0.0', undefined) - expect(result).toContain('pkg-1.0.0-py3-none-any.whl') - }) -}) - -describe('downloadPython', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSafeMkdir.mockResolvedValue(undefined) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/dl/python.tar.gz' }) - mockSpawn.mockResolvedValue({ stdout: '' }) - mockWhichReal.mockResolvedValue('/usr/bin/tar') - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }) - Object.defineProperty(process, 'arch', { - value: 'x64', - configurable: true, - }) - }) - - it('throws when tar is not on PATH', async () => { - mockWhichReal.mockResolvedValue(undefined) - await expect(downloadPython('/dest')).rejects.toThrow(/tar is required/) - }) - - it('throws when whichReal returns array (multiple match)', async () => { - mockWhichReal.mockResolvedValue(['/a', '/b'] as never) - await expect(downloadPython('/dest')).rejects.toThrow(/tar is required/) - }) - - it('downloads and extracts python', async () => { - await downloadPython('/dest') - expect(mockDownloadBinary).toHaveBeenCalled() - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/bin/tar', - ['-xzf', '/dl/python.tar.gz', '-C', '/dest'], - expect.any(Object), - ) - }) -}) - -describe('isSocketPyCliInstalled', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns true when import check succeeds (code 0)', async () => { - mockSpawn.mockResolvedValue({ code: 0 }) - const result = await isSocketPyCliInstalled('/python') - expect(result).toBe(true) - }) - - it('returns false when import check returns non-zero code', async () => { - mockSpawn.mockResolvedValue({ code: 1 }) - const result = await isSocketPyCliInstalled('/python') - expect(result).toBe(false) - }) - - it('returns false when spawn throws', async () => { - mockSpawn.mockRejectedValue(new Error('spawn failure')) - const result = await isSocketPyCliInstalled('/python') - expect(result).toBe(false) - }) -}) - -describe('ensurePython', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSocketCliPythonPath = undefined - mockIsSeaBinary.mockReturnValue(false) - mockAreBasicsToolsAvailable.mockReturnValue(false) - }) - - it('returns SOCKET_CLI_PYTHON_PATH when set', async () => { - mockSocketCliPythonPath = '/local/python' - const result = await ensurePython() - expect(result).toBe('/local/python') - }) - - it('uses bundled Python from VFS in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreBasicsToolsAvailable.mockReturnValue(true) - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - - const result = await ensurePython() - expect(result).toBe('/sea/python') - }) - - it('falls through to ensurePythonDlx when SEA but extractBasicsTools returns null', async () => { - mockIsSeaBinary.mockReturnValue(true) - mockAreBasicsToolsAvailable.mockReturnValue(true) - mockExtractBasicsTools.mockResolvedValue(undefined) - // Make ensurePythonDlx short-circuit by claiming binary exists. - mockExistsSync.mockReturnValue(true) - - const result = await ensurePython() - expect(result).toContain('python') - }) - - it('falls through to ensurePythonDlx when not in SEA mode', async () => { - mockExistsSync.mockReturnValue(true) - const result = await ensurePython() - expect(result).toContain('python') - }) -}) - -describe('ensurePythonDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(false) - mockSafeMkdir.mockResolvedValue(undefined) - mockSafeDelete.mockResolvedValue(undefined) - mockFsWriteFile.mockResolvedValue(undefined) - mockFsReadFile.mockResolvedValue('99999') - mockFsChmod.mockResolvedValue(undefined) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/dl/python.tar.gz' }) - mockSpawn.mockResolvedValue({ stdout: '' }) - mockWhichReal.mockResolvedValue('/usr/bin/tar') - }) - - it('returns existing binary when already present', async () => { - mockExistsSync.mockReturnValue(true) - const result = await ensurePythonDlx() - expect(result).toContain('python') - }) - - it('downloads, extracts, and chmods python when missing', async () => { - // existsSync false initially → trigger download path, true after extraction. - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 2 - }) - - const result = await ensurePythonDlx() - expect(mockDownloadBinary).toHaveBeenCalled() - expect(mockFsChmod).toHaveBeenCalled() - expect(result).toContain('python') - }) - - it('throws when extracted python binary missing', async () => { - mockExistsSync.mockReturnValue(false) - await expect(ensurePythonDlx()).rejects.toThrow(/does not exist/) - }) - - it('throws when MAX_RETRIES exceeded', async () => { - await expect(ensurePythonDlx(3)).rejects.toThrow( - /could not acquire the Python install lock/, - ) - }) - - it('rethrows non-EEXIST write errors', async () => { - mockExistsSync.mockReturnValue(false) - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EACCES'), { code: 'EACCES' }), - ) - await expect(ensurePythonDlx()).rejects.toThrow(/EACCES/) - }) - - it('waits for concurrent download and returns when binary appears', async () => { - stubFastTimers() - try { - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EEXIST'), { code: 'EEXIST' }), - ) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - // existsSync: false on initial cache check, true on first wait-loop poll. - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 2 - }) - try { - const result = await ensurePythonDlx() - expect(result).toContain('python') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('times out after 60s waiting for concurrent download', async () => { - stubFastTimers() - try { - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EEXIST'), { code: 'EEXIST' }), - ) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - // existsSync always false → timeout. - mockExistsSync.mockReturnValue(false) - try { - await expect(ensurePythonDlx()).rejects.toThrow(/timed out/) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('handles stale lock (dead PID) and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - mockFsReadFile.mockResolvedValue('12345') - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - }) - // existsSync: false (cache check), false (still missing in retry's cache - // check), then after download succeeds: true. - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 3 - }) - try { - const result = await ensurePythonDlx() - expect(result).toContain('python') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('treats invalid PID as stale lock and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - mockFsReadFile.mockResolvedValue('not-a-number') - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 3 - }) - const result = await ensurePythonDlx() - expect(result).toContain('python') - }) - - it('treats unreadable lock as stale and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - mockFsReadFile.mockRejectedValue(new Error('ENOENT')) - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 3 - }) - const result = await ensurePythonDlx() - expect(result).toContain('python') - }) - - it('treats EPERM kill error as alive process', async () => { - stubFastTimers() - try { - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EEXIST'), { code: 'EEXIST' }), - ) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => { - throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) - }) - // existsSync: false on initial, true on first poll. - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return calls >= 2 - }) - try { - const result = await ensurePythonDlx() - expect(result).toContain('python') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) -}) - -describe('ensureSocketPyCli', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(false) - mockFsWriteFile.mockResolvedValue(undefined) - mockFsReadFile.mockResolvedValue('99999') - mockSafeDelete.mockResolvedValue(undefined) - mockSpawn.mockResolvedValue({ code: 1 }) - mockGetPyCliVersion.mockReturnValue('2.3.4') - mockGetPyCliChecksums.mockReturnValue({}) - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ - urls: [{ filename: 'foo-py3-none-any.whl', url: 'http://x' }], - }), - }) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/dl/wheel' }) - mockFsCopyFile.mockResolvedValue(undefined) - }) - - it('returns early when already installed', async () => { - mockSpawn.mockResolvedValue({ code: 0 }) - await ensureSocketPyCli('/py') - expect(mockFsWriteFile).not.toHaveBeenCalled() - }) - - it('throws after MAX_RETRIES exceeded', async () => { - await expect(ensureSocketPyCli('/py', 3)).rejects.toThrow( - /could not acquire the Socket Python CLI install lock/, - ) - }) - - it('installs without checksums (dev mode) using pip install', async () => { - // First spawn (install check): code 1; second (pip install): code 0. - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount === 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - mockGetPyCliVersion.mockReturnValue('^1.2.3') - - await ensureSocketPyCli('/py') - // The 2nd spawn is the pip install call. - expect(mockSpawn).toHaveBeenCalledTimes(2) - }) - - it('installs with checksum-verified wheel when checksums present', async () => { - mockGetPyCliChecksums.mockReturnValue({ - 'socketsecurity-2.3.4-py3-none-any.whl': 'sha-xyz', - }) - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount === 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - mockExistsSync.mockReturnValue(true) - - await ensureSocketPyCli('/py') - expect(mockSpawn).toHaveBeenCalledTimes(2) - }) - - it('throws when checksum present but downloadPyPiWheel returns null', async () => { - mockGetPyCliChecksums.mockReturnValue({ - 'socketsecurity-2.3.4-py3-none-any.whl': 'sha-xyz', - }) - // Make downloadPyPiWheel return null by making wheel response have no urls. - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ urls: [] }), - }) - - // downloadPyPiWheel will throw before returning null when no wheel - // matches; either error is acceptable. - await expect(ensureSocketPyCli('/py')).rejects.toThrow( - /py3-none-any wheel|could not download the verified/, - ) - }) - - it('rethrows non-EEXIST lock-write errors', async () => { - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EACCES'), { code: 'EACCES' }), - ) - await expect(ensureSocketPyCli('/py')).rejects.toThrow(/EACCES/) - }) - - it('waits when lock exists and returns when install completes', async () => { - stubFastTimers() - try { - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EEXIST'), { code: 'EEXIST' }), - ) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - // Install check: first call (initial isSocketPyCliInstalled) → false. - // Inside wait loop, isSocketPyCliInstalled poll → true. - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount === 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - try { - await ensureSocketPyCli('/py') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('detects stale lock (dead PID) and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - }) - // First isSocketPyCliInstalled: not installed; after retry: installed. - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount <= 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - - try { - await ensureSocketPyCli('/py') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('treats invalid PID as stale and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - mockFsReadFile.mockResolvedValue('not-a-number') - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount <= 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - await ensureSocketPyCli('/py') - }) - - it('treats unreadable lock as stale and retries', async () => { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - mockFsReadFile.mockRejectedValue(new Error('ENOENT')) - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount <= 1) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - await ensureSocketPyCli('/py') - }) - - it('hits i % 5 === 4 dead-PID branch in wait loop', async () => { - stubFastTimers() - try { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - let killCount = 0 - ;(process as { kill: unknown }).kill = vi.fn(() => { - killCount += 1 - // First kill (stale check): alive. Second+ (i=4 alive check): dead. - if (killCount === 1) { - return true - } - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - }) - // isSocketPyCliInstalled: always false during wait loop. - // After recursion: true. - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - // First 6 calls: not installed. After i=4 -> recursive retry: installed. - if (spawnCount <= 6) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - try { - await ensureSocketPyCli('/py') - expect(killCount).toBeGreaterThan(1) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('hits i % 5 === 4 lock-file-gone branch in wait loop', async () => { - stubFastTimers() - try { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - // First readFile (stale check): valid PID. Second+ (i=4): throw. - let readCount = 0 - mockFsReadFile.mockImplementation(async () => { - readCount += 1 - if (readCount === 1) { - return '12345' - } - throw new Error('ENOENT') - }) - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount <= 6) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - try { - await ensureSocketPyCli('/py') - expect(readCount).toBeGreaterThan(1) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('treats EPERM at i % 5 === 4 as alive (no retry)', async () => { - stubFastTimers() - try { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - // First call EEXIST. After timeout-retry's recursive call: success. - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - let killCount = 0 - ;(process as { kill: unknown }).kill = vi.fn(() => { - killCount += 1 - // First kill (stale check, alive). Subsequent (i=4): EPERM (alive). - if (killCount === 1) { - return true - } - throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) - }) - // Make isSocketPyCliInstalled stay false during entire wait loop, so we - // reach i=4 and trigger the lock-aliveness check. Once the loop times - // out and recurses, the recursive call's first installed-check returns - // true so we exit. - let spawnCount = 0 - const SPAWN_BEFORE_INSTALLED = 1 + 30 // initial + 30 poll iterations. - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - if (spawnCount <= SPAWN_BEFORE_INSTALLED) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - try { - await ensureSocketPyCli('/py') - expect(killCount).toBeGreaterThan(1) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) - - it('retries when wait-loop times out (returns recursive retry)', async () => { - stubFastTimers() - try { - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - // Inside wait loop, install check stays false → timeout retry. - // Recursive call: isSocketPyCliInstalled returns true on first call. - let spawnCount = 0 - mockSpawn.mockImplementation(async () => { - spawnCount += 1 - // First 31 calls (initial + 30 polls) return code 1. - if (spawnCount <= 31) { - return { code: 1 } as never - } - return { code: 0 } as never - }) - try { - await ensureSocketPyCli('/py') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - } finally { - restoreTimers() - } - }) -}) - -describe('spawnSocketPyCli', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSocketCliPythonPath = undefined - mockIsSeaBinary.mockReturnValue(false) - mockAreBasicsToolsAvailable.mockReturnValue(false) - mockExistsSync.mockReturnValue(true) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('out'), code: 0 }) - mockSpawnNode.mockResolvedValue({ stdout: Buffer.from('node-out') }) - mockResolvePyCli.mockReturnValue({ type: 'python' }) - }) - - it('runs local resolution path via spawnNode', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - const result = await spawnSocketPyCli(['scan']) - expect(mockSpawnNode).toHaveBeenCalledWith( - ['/local/py', 'scan'], - expect.any(Object), - ) - expect(result.ok).toBe(true) - }) - - it('runs local resolution with cwd from options', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - await spawnSocketPyCli(['scan'], { cwd: '/wd' }) - expect(mockSpawnNode).toHaveBeenCalledWith( - ['/local/py', 'scan'], - expect.objectContaining({ cwd: '/wd' }), - ) - }) - - it('uses ensurePython + ensureSocketPyCli + spawn for non-local resolution', async () => { - mockResolvePyCli.mockReturnValue({ type: 'python' }) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('ok'), code: 0 }) - - const result = await spawnSocketPyCli(['x']) - expect(result.ok).toBe(true) - }) - - it('builds isolated PATH when SEA + basics tools available', async () => { - mockResolvePyCli.mockReturnValue({ type: 'python' }) - mockIsSeaBinary.mockReturnValue(true) - mockAreBasicsToolsAvailable.mockReturnValue(true) - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('ok'), code: 0 }) - - await spawnSocketPyCli(['x']) - // The final spawn call should include a PATH env. - const lastCall = mockSpawn.mock.calls[mockSpawn.mock.calls.length - 1] - expect(lastCall[2].env.PATH).toBeTruthy() - }) - - it('returns ok:false when spawn throws', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - mockSpawnNode.mockRejectedValue(new Error('boom')) - const result = await spawnSocketPyCli([]) - expect(result.ok).toBe(false) - }) -}) - -describe('spawnSocketPyCliDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(true) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('ok'), code: 0 }) - mockSpawnNode.mockResolvedValue({ stdout: Buffer.from('node-out') }) - }) - - it('runs local resolution via spawnNode', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - const result = await spawnSocketPyCliDlx(['x']) - expect(mockSpawnNode).toHaveBeenCalled() - expect(result.ok).toBe(true) - }) - - it('runs local resolution with cwd', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - await spawnSocketPyCliDlx([], { cwd: '/wd' }) - expect(mockSpawnNode).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ cwd: '/wd' }), - ) - }) - - it('uses ensurePythonDlx for non-local resolution', async () => { - mockResolvePyCli.mockReturnValue({ type: 'python' }) - const result = await spawnSocketPyCliDlx([]) - expect(result.ok).toBe(true) - }) - - it('returns ok:false on error', async () => { - mockResolvePyCli.mockReturnValue({ type: 'local', path: '/local/py' }) - mockSpawnNode.mockRejectedValue(new Error('boom')) - const result = await spawnSocketPyCliDlx([]) - expect(result.ok).toBe(false) - }) -}) - -describe('spawnSocketPyCliVfs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(false) - mockSpawn.mockResolvedValue({ stdout: Buffer.from('ok') }) - mockGetPyCliVersion.mockReturnValue('1.2.3') - mockGetPyCliChecksums.mockReturnValue({}) - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ - urls: [{ filename: 'wheel.whl', url: 'http://x' }], - }), - }) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/dl' }) - mockFsCopyFile.mockResolvedValue(undefined) - }) - - it('returns ok:false when extractBasicsTools returns null', async () => { - mockExtractBasicsTools.mockResolvedValue(undefined) - const result = await spawnSocketPyCliVfs([]) - expect(result.ok).toBe(false) - }) - - it('runs full flow without checksums (dev mode)', async () => { - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - const result = await spawnSocketPyCliVfs(['x']) - expect(result.ok).toBe(true) - }) - - it('runs full flow with checksums (verified wheel)', async () => { - mockGetPyCliChecksums.mockReturnValue({ - 'socketsecurity-1.2.3-py3-none-any.whl': 'sha', - }) - mockExistsSync.mockReturnValue(true) - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - const result = await spawnSocketPyCliVfs(['x']) - expect(result.ok).toBe(true) - }) - - it('throws when checksum present but wheel download fails', async () => { - mockGetPyCliChecksums.mockReturnValue({ - 'socketsecurity-1.2.3-py3-none-any.whl': 'sha', - }) - mockSocketHttpRequest.mockResolvedValue({ - ok: true, - status: 200, - json: () => ({ urls: [] }), - }) - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - - const result = await spawnSocketPyCliVfs(['x']) - expect(result.ok).toBe(false) - }) - - it('returns ok:false when spawn throws during install', async () => { - mockExtractBasicsTools.mockResolvedValue('/sea/tools' as never) - mockGetBasicsToolPaths.mockReturnValue({ python: '/sea/python' } as never) - mockSpawn.mockRejectedValue(new Error('boom')) - const result = await spawnSocketPyCliVfs([]) - expect(result.ok).toBe(false) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-sfw.test.mts b/packages/cli/test/unit/util/dlx/spawn-sfw.test.mts deleted file mode 100644 index 1e4b907d3..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-sfw.test.mts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Unit tests for the bespoke `spawnSfwDlx` flow. - * - * The Vfs / auto-dispatch code is tested via define-tool-spawn.test.mts. This - * file targets the sfw-specific paths: machine-mode application, local-override - * execution, and the dlx fallback. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnDlx = vi.hoisted(() => vi.fn()) -const mockResolveSfw = vi.hoisted(() => vi.fn()) -const mockDetectExecutableType = vi.hoisted(() => vi.fn()) -const mockApplyMachineModeIfActive = vi.hoisted(() => vi.fn()) -const mockInferSubcommand = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/detect', () => ({ - detectExecutableType: mockDetectExecutableType, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnDlx: mockSpawnDlx, -})) - -vi.mock('../../../../src/util/dlx/resolve-binary.mts', () => ({ - resolveSfw: mockResolveSfw, -})) - -vi.mock('../../../../src/util/spawn/apply-machine-mode.mts', () => ({ - applyMachineModeIfActive: mockApplyMachineModeIfActive, - inferSubcommand: mockInferSubcommand, -})) - -import { spawnSfwDlx } from '../../../../src/util/dlx/spawn-sfw.mts' - -describe('spawnSfwDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - mockInferSubcommand.mockReturnValue(undefined) - mockApplyMachineModeIfActive.mockReturnValue({ - args: [], - env: {}, - }) - }) - - it('runs a local sfw binary when SOCKET_CLI_SFW_LOCAL_PATH is set', async () => { - mockResolveSfw.mockReturnValue({ - type: 'local', - path: '/local/sfw', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockApplyMachineModeIfActive.mockReturnValue({ - args: ['install'], - env: { MACHINE: '1' }, - }) - mockSpawn.mockReturnValue('spawn-promise') - - const result = await spawnSfwDlx(['npm', 'install'], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/sfw', - ['npm', 'install'], - expect.objectContaining({ stdio: 'inherit' }), - ) - expect(result).toEqual({ spawnPromise: 'spawn-promise' }) - }) - - it('runs the local script via node when not a binary', async () => { - mockResolveSfw.mockReturnValue({ - type: 'local', - path: '/local/sfw.js', - }) - mockDetectExecutableType.mockReturnValue({ type: 'script' }) - mockSpawn.mockReturnValue('p') - - await spawnSfwDlx(['npm'], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - 'node', - ['/local/sfw.js', 'npm'], - expect.objectContaining({ stdio: 'inherit' }), - ) - }) - - it('falls back to spawnDlx when resolution.type is "dlx"', async () => { - mockResolveSfw.mockReturnValue({ - type: 'dlx', - details: { name: 'sfw', version: '1.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'dlx-promise' }) - - const result = await spawnSfwDlx(['npm', 'install'], undefined, undefined) - - expect(mockSpawnDlx).toHaveBeenCalled() - expect(result).toEqual({ spawnPromise: 'dlx-promise' }) - }) - - it('throws when resolveSfw returns an unexpected type', async () => { - mockResolveSfw.mockReturnValue({ - type: 'github-release', - details: {} as unknown, - }) - - await expect(spawnSfwDlx([], undefined, undefined)).rejects.toThrow( - /resolveSfw returned resolution\.type="github-release"/, - ) - }) - - it('honors a custom stdio passed via spawnExtra', async () => { - mockResolveSfw.mockReturnValue({ - type: 'local', - path: '/local/sfw', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockReturnValue('p') - - await spawnSfwDlx(['npm'], undefined, { stdio: 'pipe' } as unknown) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/sfw', - expect.any(Array), - expect.objectContaining({ stdio: 'pipe' }), - ) - }) - - it('handles an empty args array (no inner tool)', async () => { - mockResolveSfw.mockReturnValue({ - type: 'dlx', - details: { name: 'sfw', version: '1.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - - await spawnSfwDlx([], undefined, undefined) - - // applyMachineModeIfActive should NOT have been called for empty args. - expect(mockApplyMachineModeIfActive).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-socket-patch.test.mts b/packages/cli/test/unit/util/dlx/spawn-socket-patch.test.mts deleted file mode 100644 index fccbb0e4a..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-socket-patch.test.mts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Unit tests for the bespoke `spawnSocketPatchDlx` flow. - * - * The Vfs / auto-dispatch code is tested via define-tool-spawn.test.mts. This - * file targets the three-way Dlx dispatch (local override / GitHub release / - * legacy npm fallback). - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnDlx = vi.hoisted(() => vi.fn()) -const mockDownloadGitHubReleaseBinary = vi.hoisted(() => vi.fn()) -const mockResolveSocketPatch = vi.hoisted(() => vi.fn()) -const mockDetectExecutableType = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/detect', () => ({ - detectExecutableType: mockDetectExecutableType, -})) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - downloadGitHubReleaseBinary: mockDownloadGitHubReleaseBinary, - spawnDlx: mockSpawnDlx, -})) - -vi.mock('../../../../src/util/dlx/resolve-binary.mts', () => ({ - resolveSocketPatch: mockResolveSocketPatch, -})) - -import { spawnSocketPatchDlx } from '../../../../src/util/dlx/spawn-socket-patch.mts' - -describe('spawnSocketPatchDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('runs a local socket-patch binary when SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH is set', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'local', - path: '/local/socket-patch', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockReturnValue('p') - - const result = await spawnSocketPatchDlx(['apply'], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - '/local/socket-patch', - ['apply'], - expect.objectContaining({ stdio: 'inherit' }), - ) - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('runs the local script via node when not a binary', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'local', - path: '/local/socket-patch.js', - }) - mockDetectExecutableType.mockReturnValue({ type: 'script' }) - mockSpawn.mockReturnValue('p') - - await spawnSocketPatchDlx([], undefined, undefined) - - expect(mockSpawn).toHaveBeenCalledWith( - 'node', - ['/local/socket-patch.js'], - expect.any(Object), - ) - }) - - it('downloads from GitHub releases when resolution.type is "github-release"', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'github-release', - details: { name: 'socket-patch', version: '2.0.0' }, - }) - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/socket-patch') - mockSpawn.mockReturnValue('p') - - const result = await spawnSocketPatchDlx(['apply'], undefined, undefined) - - expect(mockDownloadGitHubReleaseBinary).toHaveBeenCalled() - expect(mockSpawn).toHaveBeenCalledWith( - '/cache/socket-patch', - ['apply'], - expect.objectContaining({ stdio: 'inherit' }), - ) - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('falls back to spawnDlx for legacy npm-package resolutions', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'dlx', - details: { name: 'socket-patch', version: '1.0.0' }, - }) - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - - const result = await spawnSocketPatchDlx([], undefined, undefined) - - expect(mockSpawnDlx).toHaveBeenCalled() - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('honors a custom stdio passed via spawnExtra', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'github-release', - details: { name: 'socket-patch', version: '2.0.0' }, - }) - mockDownloadGitHubReleaseBinary.mockResolvedValue('/cache/socket-patch') - mockSpawn.mockReturnValue('p') - - await spawnSocketPatchDlx([], undefined, { stdio: 'pipe' } as unknown) - - expect(mockSpawn).toHaveBeenCalledWith( - '/cache/socket-patch', - [], - expect.objectContaining({ stdio: 'pipe' }), - ) - }) - - it('merges options.env into the child env (local path)', async () => { - mockResolveSocketPatch.mockReturnValue({ - type: 'local', - path: '/local/socket-patch', - }) - mockDetectExecutableType.mockReturnValue({ type: 'binary' }) - mockSpawn.mockReturnValue('p') - - await spawnSocketPatchDlx([], { env: { FOO: 'bar' } } as unknown, undefined) - - const callEnv = mockSpawn.mock.calls[0][2].env - expect(callEnv.FOO).toBe('bar') - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn-synp.test.mts b/packages/cli/test/unit/util/dlx/spawn-synp.test.mts deleted file mode 100644 index 9ab24991c..000000000 --- a/packages/cli/test/unit/util/dlx/spawn-synp.test.mts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Unit tests for the bespoke `spawnSynpDlx` flow. - * - * Synp is a pure-npm package — no GitHub release, no local-binary override. - * `spawnSynpDlx` just delegates to `spawnDlx` with the pinned version. Vfs + - * auto-dispatch are exercised in define-tool-spawn.test.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawnDlx = vi.hoisted(() => vi.fn()) -const mockGetSynpVersion = vi.hoisted(() => vi.fn(() => '1.9.0')) - -vi.mock('../../../../src/util/dlx/spawn.mts', () => ({ - spawnDlx: mockSpawnDlx, -})) - -vi.mock('../../../../src/env/synp-version.mts', () => ({ - getSynpVersion: mockGetSynpVersion, -})) - -import { spawnSynpDlx } from '../../../../src/util/dlx/spawn-synp.mts' - -describe('spawnSynpDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSynpVersion.mockReturnValue('1.9.0') - }) - - it('delegates to spawnDlx with the synp version pin', async () => { - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - - const result = await spawnSynpDlx(['--source-file', 'yarn.lock']) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: 'synp', version: '1.9.0' }, - ['--source-file', 'yarn.lock'], - { force: false }, - undefined, - ) - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('merges caller options over the default { force: false }', async () => { - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - - await spawnSynpDlx(['arg'], { force: true }) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: 'synp', version: '1.9.0' }, - ['arg'], - { force: true }, - undefined, - ) - }) - - it('forwards spawnExtra to spawnDlx', async () => { - mockSpawnDlx.mockResolvedValue({ spawnPromise: 'p' }) - const spawnExtra = { stdioString: true } - - await spawnSynpDlx(['arg'], undefined, spawnExtra as unknown) - - expect(mockSpawnDlx).toHaveBeenCalledWith( - { name: 'synp', version: '1.9.0' }, - ['arg'], - { force: false }, - spawnExtra, - ) - }) -}) diff --git a/packages/cli/test/unit/util/dlx/spawn.test.mts b/packages/cli/test/unit/util/dlx/spawn.test.mts deleted file mode 100644 index cdd1530b2..000000000 --- a/packages/cli/test/unit/util/dlx/spawn.test.mts +++ /dev/null @@ -1,626 +0,0 @@ -/** - * Max-file-lines: legitimate — comprehensive single-module test suite. Covers - * TOCTOU lock handling, zip-slip protection, tar.gz extraction, error wrapping; - * vi.mock setup is shared so splitting would duplicate boilerplate. - * - * Unit tests for util/dlx/spawn. - * - * Covers validatePackageName, spawnDlx, spawnToolVfs, and - * downloadGitHubReleaseBinary (including TOCTOU lock handling, zip-slip - * protection, tar.gz extraction, and error wrapping). - * - * Related Files: - src/util/dlx/spawn.mts. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockDlxPackage = vi.hoisted(() => vi.fn()) -const mockDownloadBinary = vi.hoisted(() => vi.fn()) -const mockGetDlxCachePath = vi.hoisted(() => vi.fn(() => '/tmp/dlx-cache')) -const mockSafeDelete = vi.hoisted(() => vi.fn(async () => {})) -const mockSafeMkdir = vi.hoisted(() => vi.fn(async () => {})) -const mockWhichReal = vi.hoisted(() => vi.fn(async () => '/usr/bin/tar')) -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)) -const mockFsWriteFile = vi.hoisted(() => vi.fn(async () => {})) -const mockFsReadFile = vi.hoisted(() => vi.fn(async () => '12345')) -const mockFsReaddir = vi.hoisted(() => vi.fn(async () => [])) -const mockFsLstat = vi.hoisted(() => - vi.fn(async () => ({ isSymbolicLink: () => false })), -) -const mockFsReadlink = vi.hoisted(() => vi.fn(async () => '')) -const mockFsChmod = vi.hoisted(() => vi.fn(async () => {})) -const mockAreExternalToolsAvailable = vi.hoisted(() => vi.fn(() => false)) -const mockExtractExternalTools = vi.hoisted(() => vi.fn(async () => undefined)) - -// AdmZip mock — emulates the constructor + methods. -const mockAdmZipGetEntries = vi.hoisted(() => vi.fn(() => [])) -const mockAdmZipExtractAllTo = vi.hoisted(() => vi.fn()) -const mockAdmZipCtor = vi.hoisted(() => - vi.fn(function (this: unknown, _: string) { - ;(this as { getEntries: unknown }).getEntries = mockAdmZipGetEntries - ;(this as { extractAllTo: unknown }).extractAllTo = mockAdmZipExtractAllTo - }), -) - -vi.mock('adm-zip', () => ({ - default: mockAdmZipCtor, -})) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/package', () => ({ - dlxPackage: mockDlxPackage, -})) - -vi.mock('@socketsecurity/lib-stable/dlx/binary', () => ({ - downloadBinary: mockDownloadBinary, - getDlxCachePath: mockGetDlxCachePath, -})) - -vi.mock('@socketsecurity/lib-stable/arrays/join', () => ({ - joinAnd: (arr: string[]) => arr.join(', '), -})) - -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, - safeMkdir: mockSafeMkdir, -})) - -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: mockWhichReal, -})) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - promises: { - writeFile: mockFsWriteFile, - readFile: mockFsReadFile, - readdir: mockFsReaddir, - lstat: mockFsLstat, - readlink: mockFsReadlink, - chmod: mockFsChmod, - }, - default: { - existsSync: mockExistsSync, - promises: { - writeFile: mockFsWriteFile, - readFile: mockFsReadFile, - readdir: mockFsReaddir, - lstat: mockFsLstat, - readlink: mockFsReadlink, - chmod: mockFsChmod, - }, - }, -})) - -vi.mock('../../../../src/util/dlx/vfs-extract.mts', () => ({ - areExternalToolsAvailable: mockAreExternalToolsAvailable, - extractExternalTools: mockExtractExternalTools, -})) - -import { - downloadGitHubReleaseBinary, - spawnDlx, - spawnToolVfs, - validatePackageName, -} from '../../../../src/util/dlx/spawn.mts' - -describe('validatePackageName', () => { - it('accepts plain package names', () => { - expect(() => validatePackageName('lodash')).not.toThrow() - }) - - it('accepts scoped names', () => { - expect(() => validatePackageName('@socketsecurity/cli')).not.toThrow() - }) - - it('accepts names with allowed punctuation', () => { - expect(() => validatePackageName('my-pkg_v2.0')).not.toThrow() - }) - - it('rejects uppercase letters', () => { - expect(() => validatePackageName('MyPkg')).toThrow(/must match/) - }) - - it('rejects names that start with invalid chars', () => { - expect(() => validatePackageName('.hidden')).toThrow(/must match/) - }) - - it('rejects names that fail the npm regex like slashes outside scope', () => { - expect(() => validatePackageName('foo/bar')).toThrow(/must match/) - }) - - it('rejects names containing ".." path traversal (passes regex, fails traversal check)', () => { - // `a..b` passes the regex (dots are allowed) but trips the traversal check. - expect(() => validatePackageName('a..b')).toThrow(/path traversal/) - }) - - it('rejects empty name', () => { - expect(() => validatePackageName('')).toThrow(/must match/) - }) -}) - -describe('spawnDlx', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('forwards to dlxPackage with default force=false', async () => { - mockDlxPackage.mockResolvedValue({ spawnPromise: 'p' }) - - const result = await spawnDlx({ name: 'lodash', version: '4.17.21' }, [ - '--help', - ]) - - expect(mockDlxPackage).toHaveBeenCalledWith( - ['--help'], - expect.objectContaining({ - package: 'lodash@4.17.21', - force: false, - }), - undefined, - ) - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('passes force=true and binaryName', async () => { - mockDlxPackage.mockResolvedValue({ spawnPromise: 'p' }) - - await spawnDlx( - { name: 'lodash', version: '1.0.0', binaryName: 'lodash-bin' }, - [], - { force: true }, - ) - - expect(mockDlxPackage).toHaveBeenCalledWith( - [], - expect.objectContaining({ - binaryName: 'lodash-bin', - force: true, - }), - undefined, - ) - }) - - it('throws when package name fails validation', async () => { - await expect( - spawnDlx({ name: 'BAD/NAME', version: '1.0.0' }, []), - ).rejects.toThrow(/must match/) - }) -}) - -describe('spawnToolVfs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAreExternalToolsAvailable.mockReturnValue(true) - }) - - it('throws when external tools are not available', async () => { - mockAreExternalToolsAvailable.mockReturnValue(false) - - await expect(spawnToolVfs('sfw', [])).rejects.toThrow( - /cannot spawn sfw from VFS/, - ) - }) - - it('throws when extractExternalTools returns null', async () => { - mockExtractExternalTools.mockResolvedValue(undefined) - - await expect(spawnToolVfs('sfw', [])).rejects.toThrow( - /failed to extract sfw from VFS/, - ) - }) - - it('throws when the tool is missing from the extraction map', async () => { - mockExtractExternalTools.mockResolvedValue({ - other: '/path/to/other', - } as never) - - await expect(spawnToolVfs('sfw', [])).rejects.toThrow( - /sfw was not in the output map/, - ) - }) - - it('spawns the tool directly and returns spawnPromise', async () => { - mockExtractExternalTools.mockResolvedValue({ - sfw: '/path/to/sfw', - } as never) - mockSpawn.mockReturnValue('p') - - const result = await spawnToolVfs('sfw', ['arg1'], { env: { X: '1' } }) - - expect(mockSpawn).toHaveBeenCalledWith( - '/path/to/sfw', - ['arg1'], - expect.objectContaining({ stdio: 'inherit' }), - ) - const opts = mockSpawn.mock.calls[0][2] - expect(opts.env.X).toBe('1') - expect(result).toEqual({ spawnPromise: 'p' }) - }) - - it('honors custom stdio from spawnExtra', async () => { - mockExtractExternalTools.mockResolvedValue({ - sfw: '/path/to/sfw', - } as never) - mockSpawn.mockReturnValue('p') - - await spawnToolVfs('sfw', [], undefined, { stdio: 'pipe' } as never) - - expect(mockSpawn).toHaveBeenCalledWith( - '/path/to/sfw', - [], - expect.objectContaining({ stdio: 'pipe' }), - ) - }) -}) - -describe('downloadGitHubReleaseBinary', () => { - const baseSpec = { - assetName: 'tool-linux.tar.gz', - binaryName: 'tool', - owner: 'org', - repo: 'repo', - sha256: 'abc', - version: 'v1.0.0', - } - - beforeEach(() => { - vi.clearAllMocks() - mockExistsSync.mockReturnValue(false) - mockFsWriteFile.mockResolvedValue(undefined) - mockFsReadFile.mockResolvedValue('99999') - mockFsReaddir.mockResolvedValue([]) - mockFsLstat.mockResolvedValue({ isSymbolicLink: () => false } as never) - mockFsChmod.mockResolvedValue(undefined) - mockSafeMkdir.mockResolvedValue(undefined) - mockSafeDelete.mockResolvedValue(undefined) - mockDownloadBinary.mockResolvedValue({ binaryPath: '/tmp/dl/archive' }) - mockSpawn.mockResolvedValue({ stdout: '' }) - mockAdmZipGetEntries.mockReturnValue([]) - mockAdmZipExtractAllTo.mockReset() - mockAdmZipCtor.mockClear() - mockWhichReal.mockResolvedValue('/usr/bin/tar') - }) - - it('short-circuits when the binary is already cached', async () => { - mockExistsSync.mockReturnValue(true) - - const result = await downloadGitHubReleaseBinary(baseSpec) - - expect(result).toContain('tool') - expect(mockDownloadBinary).not.toHaveBeenCalled() - }) - - it('extracts a tar.gz archive when no cached binary exists', async () => { - // existsSync called multiple times in this flow: - // 1. before download (false), 2. after lock check (false), - // 3. after extraction (true). - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - // Cached binary not present until after extraction (call 3+). - return calls >= 3 - }) - - const result = await downloadGitHubReleaseBinary(baseSpec) - - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/bin/tar', - ['-xzf', '/tmp/dl/archive', '-C', expect.any(String)], - expect.any(Object), - ) - expect(mockFsChmod).toHaveBeenCalled() - expect(result).toContain('tool') - }) - - it('throws when tar is not on PATH and archive is tar.gz', async () => { - mockWhichReal.mockResolvedValue(undefined) - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - // First call (cached check) returns false. Subsequent: also false - // so it goes through full extraction flow. - return false - }) - - await expect(downloadGitHubReleaseBinary(baseSpec)).rejects.toThrow( - /tar is required/, - ) - }) - - it('throws when archive format is not supported', async () => { - mockExistsSync.mockReturnValue(false) - - await expect( - downloadGitHubReleaseBinary({ - ...baseSpec, - assetName: 'tool.rar', - }), - ).rejects.toThrow(/archive format of tool.rar is not supported/) - }) - - it('extracts a zip archive successfully', async () => { - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - // Pre-download cached check false, post-extract binary exists. - return calls >= 3 - }) - mockAdmZipGetEntries.mockReturnValue([ - { entryName: 'tool' }, - { entryName: 'README' }, - ]) - - const result = await downloadGitHubReleaseBinary({ - ...baseSpec, - assetName: 'tool.zip', - }) - - expect(mockAdmZipExtractAllTo).toHaveBeenCalled() - expect(result).toContain('tool') - }) - - it('rejects zip-slip path traversal entries', async () => { - mockExistsSync.mockReturnValue(false) - mockAdmZipGetEntries.mockReturnValue([{ entryName: '../../etc/passwd' }]) - - await expect( - downloadGitHubReleaseBinary({ - ...baseSpec, - assetName: 'tool.zip', - }), - ).rejects.toThrow(/zip-slip attack/) - }) - - it('rejects symlinks that escape the cache dir during zip extraction', async () => { - let calls = 0 - mockExistsSync.mockImplementation(() => { - calls += 1 - return false - }) - mockAdmZipGetEntries.mockReturnValue([{ entryName: 'evil' }]) - mockFsReaddir.mockResolvedValue(['evil'] as never) - mockFsLstat.mockResolvedValue({ isSymbolicLink: () => true } as never) - mockFsReadlink.mockResolvedValue('/etc/passwd') - - await expect( - downloadGitHubReleaseBinary({ - ...baseSpec, - assetName: 'tool.zip', - }), - ).rejects.toThrow(/extracted symlink/) - }) - - it('throws when extracted binary is missing after tar extraction', async () => { - // existsSync: cache-check false, lock-recheck false, final binary-check false. - mockExistsSync.mockReturnValue(false) - // Make the spawn (tar extraction) succeed but final existsSync stays false. - - await expect(downloadGitHubReleaseBinary(baseSpec)).rejects.toThrow( - /was not found inside/, - ) - }) - - it('hits the alive-PID lock re-check (i % 5 === 4 branch, alive process)', async () => { - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - mockFsWriteFile.mockRejectedValue(eexistErr) - // Make existsSync false throughout polling to force timeout (or hit i=4). - let existsCount = 0 - mockExistsSync.mockImplementation(() => { - existsCount += 1 - return false - }) - // setTimeout: bypass the actual 1s wait. - const realSetTimeout = globalThis.setTimeout - ;(globalThis as { setTimeout: unknown }).setTimeout = ((cb: () => void) => { - cb() - return 0 as never - }) as never - const realKill = process.kill - // alive: kill returns true without throwing. - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - try { - // After lock-busy detection, polling proceeds; with everything mocked - // away, it will eventually time out — that's fine, we just need - // i=4 branch coverage. - await expect(downloadGitHubReleaseBinary(baseSpec)).rejects.toThrow( - /timed out/, - ) - } finally { - ;(globalThis as { setTimeout: unknown }).setTimeout = realSetTimeout - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('hits the dead-PID lock re-check and retries downloadGitHubReleaseBinary', async () => { - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - // First few existsSync calls: false (so binary doesn't appear during wait). - // After enough waiting iterations we recurse — on recursion: binary appears. - let existsCount = 0 - mockExistsSync.mockImplementation(() => { - existsCount += 1 - return existsCount >= 10 - }) - const realSetTimeout = globalThis.setTimeout - ;(globalThis as { setTimeout: unknown }).setTimeout = ((cb: () => void) => { - cb() - return 0 as never - }) as never - const realKill = process.kill - let killCount = 0 - ;(process as { kill: unknown }).kill = vi.fn(() => { - killCount += 1 - // First kill is the i=4 PID re-check — throw to mark stale. - throw new Error('ESRCH') - }) - - try { - // The recursive call will proceed; we don't care if it succeeds or - // throws downstream — only that the dead-PID branch executed. - await downloadGitHubReleaseBinary(baseSpec).catch(() => {}) - expect(killCount).toBeGreaterThan(0) - } finally { - ;(globalThis as { setTimeout: unknown }).setTimeout = realSetTimeout - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('hits the lock-file-gone branch at i=4 and recurses', async () => { - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - let existsCount = 0 - mockExistsSync.mockImplementation(() => { - existsCount += 1 - return existsCount >= 10 - }) - // readFile throws inside the i=4 branch -> lock gone -> recurse. - let readCount = 0 - mockFsReadFile.mockImplementation(async () => { - readCount += 1 - if (readCount === 1) { - throw new Error('ENOENT') - } - return '99999' - }) - const realSetTimeout = globalThis.setTimeout - ;(globalThis as { setTimeout: unknown }).setTimeout = ((cb: () => void) => { - cb() - return 0 as never - }) as never - - try { - await downloadGitHubReleaseBinary(baseSpec).catch(() => {}) - expect(readCount).toBeGreaterThan(0) - } finally { - ;(globalThis as { setTimeout: unknown }).setTimeout = realSetTimeout - } - }) - - it('waits and retries when the lock file already exists (alive PID)', async () => { - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - // First writeFile rejects with EEXIST; binary appears after waiting. - let writeAttempt = 0 - mockFsWriteFile.mockImplementation(async () => { - writeAttempt += 1 - if (writeAttempt === 1) { - throw eexistErr - } - return undefined - }) - // Cache check false initially; binary appears on second poll inside wait. - let existsCallCount = 0 - mockExistsSync.mockImplementation(() => { - existsCallCount += 1 - // 1st: cache check (false). 2nd+: binary appearance inside polling. - return existsCallCount >= 2 - }) - - // Use a process.kill that succeeds (alive). - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - try { - const result = await downloadGitHubReleaseBinary(baseSpec) - expect(result).toContain('tool') - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('recovers from a stale lock (dead PID) and re-runs download', async () => { - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - - // First call: EEXIST. After stale cleanup, the recursive call's - // writeFile succeeds. To exit the recursive call, make the binary - // appear right after download/extract. - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - - // Lock file polling reads the PID; we'll make it return so that the - // first poll iteration goes to lock-aliveness check (i % 5 === 4 only on i=4). - // Simpler path: make the binary appear before the loop even fires. - let existsCount = 0 - mockExistsSync.mockImplementation(() => { - existsCount += 1 - // call 1: initial cache check (false) - // call 2+ depends on flow. After we send recursion, we want the - // recursive call to short-circuit on cache-check. - return existsCount >= 3 - }) - - const realKill = process.kill - // First kill(0) call (in stale check) throws -> stale. - let killCount = 0 - ;(process as { kill: unknown }).kill = vi.fn(() => { - killCount += 1 - if (killCount === 1) { - // We're inside the stale-check path (kill(pid, 0)) — wait no, - // the lock-busy branch is only hit on EEXIST. Stale check happens - // inside the inner `for` poll's `i % 5 === 4` branch. - // Actually the FIRST EEXIST goes into the wait-loop, not the stale - // check directly. So skip — just throw consistently to mark stale. - throw new Error('ESRCH') - } - return true - }) - - try { - // Either branch is fine — we just want coverage. - await downloadGitHubReleaseBinary(baseSpec).catch(() => { - // Some branches may throw timeout — accept it. - }) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('rethrows non-EEXIST errors during lock creation', async () => { - const otherErr = Object.assign(new Error('EACCES'), { code: 'EACCES' }) - mockFsWriteFile.mockRejectedValue(otherErr) - - await expect(downloadGitHubReleaseBinary(baseSpec)).rejects.toThrow( - /EACCES/, - ) - }) - - it('re-checks cache after acquiring lock and returns early when binary appeared', async () => { - let existsCount = 0 - mockExistsSync.mockImplementation(() => { - existsCount += 1 - // 1st call (pre-lock cache check): false. - // 2nd call (post-lock recheck): true — early return. - return existsCount >= 2 - }) - - const result = await downloadGitHubReleaseBinary(baseSpec) - - expect(result).toContain('tool') - expect(mockDownloadBinary).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/util/dlx/vfs-extract.test.mts b/packages/cli/test/unit/util/dlx/vfs-extract.test.mts deleted file mode 100644 index 26f154bac..000000000 --- a/packages/cli/test/unit/util/dlx/vfs-extract.test.mts +++ /dev/null @@ -1,984 +0,0 @@ -/** - * Max-file-lines: legitimate — comprehensive single-module test suite. Tests - * the extractExternalTools state machine (cache marker, lock waits, stale - * locks, recursion-depth guard, tool revalidation, error wrapping). The vi.mock - * setup is shared across every describe; splitting would duplicate the - * boilerplate that IS the cohesion. - * - * Unit tests for util/dlx/vfs-extract. - * - * Covers the public availability check, tool-path map, extractTool, and the - * full extractExternalTools state machine (cache marker, lock waits, stale - * locks, recursion-depth guard, tool revalidation, error wrapping). - * - * Related Files: - src/util/dlx/vfs-extract.mts. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as NodeFs from 'node:fs' - -const mockIsSeaBinary = vi.hoisted(() => vi.fn(() => false)) -const mockExistsSync = vi.hoisted(() => vi.fn(() => false)) -const mockFsWriteFile = vi.hoisted(() => vi.fn(async () => {})) -const mockFsReadFile = vi.hoisted(() => vi.fn(async () => '99999')) -const mockFsAccess = vi.hoisted(() => vi.fn(async () => {})) -const mockFsChmod = vi.hoisted(() => vi.fn(async () => {})) -const mockSafeDelete = vi.hoisted(() => vi.fn(async () => {})) -const mockSafeMkdir = vi.hoisted(() => vi.fn(async () => {})) - -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -vi.mock('../../../../src/constants/paths.mts', () => ({ - UPDATE_STORE_DIR: '.socket/_dlx', -})) - -vi.mock('node:fs', async () => { - const actual = await vi.importActual<typeof NodeFs>('node:fs') - const promises = { - writeFile: mockFsWriteFile, - readFile: mockFsReadFile, - access: mockFsAccess, - chmod: mockFsChmod, - constants: actual.constants, - } - return { - ...actual, - existsSync: mockExistsSync, - promises, - default: { ...actual, existsSync: mockExistsSync, promises }, - } -}) - -vi.mock('@socketsecurity/lib-stable/fs/safe', () => ({ - safeDelete: mockSafeDelete, - safeMkdir: mockSafeMkdir, -})) - -import { - EXTERNAL_TOOLS, - areExternalToolsAvailable, - extractExternalTools, - extractTool, - getNodeSmolBasePath, - getToolFilePath, - getToolPaths, - isNpmPackageExtracted, -} from '../../../../src/util/dlx/vfs-extract.mts' - -const realProcessSmol = (process as unknown as { smol?: unknown | undefined }) - .smol - -function withMountReturning(mountFn: (vfsPath: string) => Promise<string>) { - ;(process as unknown as { smol: unknown }).smol = { mount: mountFn } -} - -describe('util/dlx/vfs-extract', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsSeaBinary.mockReturnValue(false) - mockExistsSync.mockReturnValue(false) - mockFsWriteFile.mockResolvedValue(undefined) - mockFsReadFile.mockResolvedValue('99999') - mockFsAccess.mockResolvedValue(undefined) - mockFsChmod.mockResolvedValue(undefined) - mockSafeDelete.mockResolvedValue(undefined) - mockSafeMkdir.mockResolvedValue(undefined) - delete (process as unknown as { smol?: unknown | undefined }).smol - }) - - afterEach(() => { - if (realProcessSmol === undefined) { - delete (process as unknown as { smol?: unknown | undefined }).smol - } else { - ;(process as unknown as { smol: unknown }).smol = realProcessSmol - } - }) - - describe('EXTERNAL_TOOLS', () => { - it('exposes a non-empty list of tool names', () => { - expect(EXTERNAL_TOOLS.length).toBeGreaterThan(0) - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i] - expect(typeof tool).toBe('string') - } - }) - }) - - describe('areExternalToolsAvailable', () => { - it('returns false when not a SEA binary', () => { - expect(areExternalToolsAvailable()).toBe(false) - }) - - it('returns false when in SEA mode but smol.mount is missing', () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown as { smol: unknown }).smol = {} - expect(areExternalToolsAvailable()).toBe(false) - }) - - it('returns true when in SEA mode with smol.mount', () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/tmp/x') - expect(areExternalToolsAvailable()).toBe(true) - }) - }) - - describe('getToolPaths', () => { - it('returns a non-empty path for every tool in EXTERNAL_TOOLS', () => { - const paths = getToolPaths() - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i] - expect(paths[tool]).toBeTypeOf('string') - expect((paths[tool] as string).length).toBeGreaterThan(0) - } - }) - - it('appends .exe on Windows', () => { - const realPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'win32', - configurable: true, - }) - try { - const paths = getToolPaths() - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i] - expect(paths[tool]).toMatch(/\.exe$/) - } - } finally { - Object.defineProperty(process, 'platform', { - value: realPlatform, - configurable: true, - }) - } - }) - - it('does not append .exe on POSIX', () => { - const realPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'linux', - configurable: true, - }) - try { - const paths = getToolPaths() - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i] - expect(paths[tool]).not.toMatch(/\.exe$/) - } - } finally { - Object.defineProperty(process, 'platform', { - value: realPlatform, - configurable: true, - }) - } - }) - }) - - describe('getToolFilePath', () => { - it('returns npm package binPath for npm tools', () => { - const result = getToolFilePath('cdxgen', '/base') - expect(result).toContain('node_modules/@cyclonedx/cdxgen/bin/cdxgen') - }) - - it('returns standalone path for sfw', () => { - const result = getToolFilePath('sfw', '/base') - expect(result).toContain('node_modules/@socketsecurity/sfw-bin/sfw') - }) - - it('returns standalone path for socket-patch', () => { - const result = getToolFilePath('socket-patch', '/base') - expect(result).toContain('socket-patch') - }) - - it('returns plain tool name fallback when not in either map', () => { - const result = getToolFilePath('definitely-not-a-tool' as never, '/base') - expect(result).toContain('definitely-not-a-tool') - }) - }) - - describe('getNodeSmolBasePath', () => { - it('returns a path containing the dlx directory', () => { - const result = getNodeSmolBasePath() - expect(typeof result).toBe('string') - expect(result).toContain('_dlx') - }) - - it('uses process.smol.getHash when available', () => { - ;(process as unknown as { smol: unknown }).smol = { - getHash: () => 'mock-hash-12345', - } - const result = getNodeSmolBasePath() - expect(result).toContain('mock-hash-12345') - }) - - it('falls back to a derived hash when getHash throws', () => { - ;(process as unknown as { smol: unknown }).smol = { - get getHash() { - throw new Error('boom') - }, - } - const result = getNodeSmolBasePath() - expect(result).toMatch(/_dlx\/[a-f0-9]{16}$/) - }) - }) - - describe('isNpmPackageExtracted', () => { - it('returns false for missing path', async () => { - mockExistsSync.mockReturnValue(false) - const result = await isNpmPackageExtracted( - '/definitely/not/a/real/path/' + Date.now(), - ) - expect(result).toBe(false) - }) - - it('returns false when package.json missing', async () => { - // Only the package dir exists, not package.json. - let callIdx = 0 - mockExistsSync.mockImplementation(() => { - callIdx += 1 - return callIdx === 1 - }) - const result = await isNpmPackageExtracted('/some/pkg') - expect(result).toBe(false) - }) - - it('returns false when node_modules missing', async () => { - let callIdx = 0 - mockExistsSync.mockImplementation(() => { - callIdx += 1 - // 1: package dir, 2: package.json, 3: node_modules - return callIdx <= 2 - }) - const result = await isNpmPackageExtracted('/some/pkg') - expect(result).toBe(false) - }) - - it('returns true when package, package.json, and node_modules all exist', async () => { - mockExistsSync.mockReturnValue(true) - const result = await isNpmPackageExtracted('/some/pkg') - expect(result).toBe(true) - }) - }) - - describe('extractTool', () => { - it('throws when process.smol.mount is undefined', async () => { - await expect(extractTool('cdxgen')).rejects.toThrow( - /process\.smol\.mount is undefined/, - ) - }) - - it('throws when process.smol exists but mount is missing', async () => { - ;(process as unknown as { smol: unknown }).smol = { otherProp: true } - await expect(extractTool('cdxgen')).rejects.toThrow( - /process\.smol\.mount is undefined/, - ) - }) - - it('wraps mount-failure with a SEA-VFS error message', async () => { - withMountReturning(async () => { - throw new Error('vfs corrupt') - }) - await expect(extractTool('cdxgen')).rejects.toThrow( - /failed to extract cdxgen from the SEA VFS/, - ) - }) - - it('wraps mount-failure for standalone tools (sfw)', async () => { - withMountReturning(async () => { - throw new Error('vfs not found') - }) - await expect(extractTool('sfw')).rejects.toThrow( - /failed to extract sfw from the SEA VFS/, - ) - }) - - it('returns extracted path for npm package when mount succeeds', async () => { - // existsSync: 1st = isNpmPackageExtracted package dir check (false to - // skip cached path); 2nd = post-mount final extracted file existsSync - // (true) so we don't throw. - let idx = 0 - mockExistsSync.mockImplementation(() => { - idx += 1 - return idx >= 2 - }) - withMountReturning(async () => '/extracted/pkg-dir') - - const result = await extractTool('cdxgen') - expect(result).toContain('cdxgen') - }) - - it('returns cached path when npm package already extracted', async () => { - // All existsSync calls return true so isNpmPackageExtracted passes. - mockExistsSync.mockReturnValue(true) - withMountReturning(async () => '/should-not-be-called') - - const result = await extractTool('cdxgen') - expect(result).toContain('cdxgen') - }) - - it('returns extracted path for standalone binary (sfw)', async () => { - // sfw has TOOL_STANDALONE_PATHS entry; final existsSync true. - mockExistsSync.mockReturnValue(true) - withMountReturning(async () => '/extracted/sfw') - - const result = await extractTool('sfw') - expect(result).toContain('sfw') - }) - - it('throws when extracted path does not exist after mount', async () => { - // After mount, the final existsSync returns false. - mockExistsSync.mockReturnValue(false) - withMountReturning(async () => '/some/path') - - await expect(extractTool('cdxgen')).rejects.toThrow( - /failed to extract cdxgen from the SEA VFS/, - ) - }) - - it('handles chmod failure silently for standalone binary', async () => { - mockExistsSync.mockReturnValue(true) - mockFsChmod.mockRejectedValue(new Error('EPERM')) - withMountReturning(async () => '/extracted/sfw') - - // Should still succeed (chmod errors swallowed). - await expect(extractTool('sfw')).resolves.toBeTruthy() - }) - - it('extracts standalone tool not in TOOL_STANDALONE_PATHS map by tool name', async () => { - mockExistsSync.mockReturnValue(true) - withMountReturning(async () => '/extracted/unknown') - // Use a fake tool name that's not in either map. - await expect( - extractTool('definitely-not-real' as never), - ).resolves.toBeTruthy() - }) - }) - - describe('extractExternalTools', () => { - it('returns undefined when not running in SEA mode', async () => { - mockIsSeaBinary.mockReturnValue(false) - const result = await extractExternalTools() - expect(result).toBeUndefined() - }) - - it('returns undefined when smol.mount is missing', async () => { - mockIsSeaBinary.mockReturnValue(true) - ;(process as unknown as { smol: unknown }).smol = { otherProp: true } - const result = await extractExternalTools() - expect(result).toBeUndefined() - }) - - it('returns undefined when max recursion depth exceeded', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const result = await extractExternalTools(5) - expect(result).toBeUndefined() - }) - - it('returns cached tool paths when cache marker + tools exist', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - // writeFile EEXIST? No — we want first-try success entering the main - // body. lockFile write succeeds, then cacheMarker existsSync true. - // existsSync calls in order: nodeSmolBase mkdir (no — that's safeMkdir), - // then cacheMarker check (true), then for each tool: toolPath (true). - // Then final atomic re-verify: every tool toolPath (true). - mockExistsSync.mockReturnValue(true) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - if (result) { - for (let i = 0, { length } = EXTERNAL_TOOLS; i < length; i += 1) { - const tool = EXTERNAL_TOOLS[i] - expect(result[tool]).toBeTruthy() - } - } - }) - - it('rethrows non-EEXIST errors from lock file write', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - mockFsWriteFile.mockRejectedValue( - Object.assign(new Error('EACCES'), { code: 'EACCES' }), - ) - - await expect(extractExternalTools()).rejects.toThrow(/EACCES/) - }) - - it('extracts tools when cache marker missing and mount succeeds', async () => { - mockIsSeaBinary.mockReturnValue(true) - // existsSync: cache marker (false), then per-tool toolPathWithExt - // existence check (false to trigger extraction), then final - // existsSync inside extractTool after mount (true), and final - // post-extraction existsSync for cacheMarker write — true. - mockExistsSync.mockImplementation((p: string) => { - // Anything that's been "extracted" (under nodeSmolBase) returns true. - // Cache marker check needs to be false initially. - return !p.endsWith('.extracted') && !p.endsWith('.extracting') - }) - withMountReturning(async () => '/extracted/path') - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - it('detects stale lock (dead PID) and retries via recursion', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - // First writeFile: EEXIST. Second writeFile (after stale cleanup, - // recursive call): success. - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - - // Stale check: readFile returns PID, process.kill throws ESRCH. - mockFsReadFile.mockResolvedValue('12345') - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => { - throw new Error('ESRCH') - }) - - // After recursion: cache marker exists, all tools exist. - let recursionStart = false - mockExistsSync.mockImplementation((p: string) => { - if (!recursionStart) { - // We need to flip after the stale cleanup happens. - return false - } - return true - }) - // Flip flag once writeFile succeeds. - const origImpl = mockFsWriteFile.getMockImplementation() - mockFsWriteFile.mockImplementation(async (...args: unknown[]) => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - // 2nd call onwards: flip and succeed. - recursionStart = true - return undefined - }) - - try { - // Reset count since we replaced the impl. - writeCount = 0 - const result = await extractExternalTools() - expect(result).toBeTruthy() - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('reads invalid PID as stale and retries', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - let writeCount = 0 - let recursionStart = false - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - recursionStart = true - return undefined - }) - // Invalid PID (NaN) -> isStale = true. - mockFsReadFile.mockResolvedValue('not-a-number') - - mockExistsSync.mockImplementation(() => recursionStart) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - it('handles readFile error as stale lock', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - let writeCount = 0 - let recursionStart = false - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - recursionStart = true - return undefined - }) - mockFsReadFile.mockRejectedValue(new Error('ENOENT')) - - mockExistsSync.mockImplementation(() => recursionStart) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - it('re-extracts when cache marker exists but tool is missing', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/extracted') - - // First call: cache marker exists, but the first tool path check - // returns false -> invalidate marker and re-extract. After delete: - // proceed to extraction loop; for each tool: existsSync(toolPath) - // returns true once mount has "happened". To simulate, return true - // for everything except the first marker-validation tool check. - let firstToolCheckSeen = false - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracting')) { - return false - } - // The very first existsSync call (cache marker presence check): - // return true so we enter the validate branch. - // Then the first toolPath check returns false to invalidate. - // Everything else returns true. - if (!firstToolCheckSeen && ps.includes('cdxgen')) { - firstToolCheckSeen = true - return false - } - return true - }) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - it('logs and rethrows when extraction throws', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => { - throw new Error('mount blew up') - }) - // Cache marker false, tool path false -> attempts extractTool -> throws. - mockExistsSync.mockReturnValue(false) - - await expect(extractExternalTools()).rejects.toThrow(/failed to extract/) - }) - - it('handles safeDelete failure during cleanup gracefully', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/extracted') - mockExistsSync.mockImplementation((p: string) => { - return !String(p).endsWith('.extracted') - }) - // Make safeDelete reject during cleanup; the finally block should - // log and continue. We need the main path to succeed first. - mockSafeDelete.mockRejectedValue( - Object.assign(new Error('rm failed'), { code: 'EBUSY' }), - ) - - // Should still resolve successfully. - await expect(extractExternalTools()).resolves.toBeTruthy() - }) - - it('handles tool access error (X_OK fails) and re-extracts', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/extracted') - mockExistsSync.mockImplementation((p: string) => { - // Cache marker false, tool path true (so access check runs). - return !String(p).endsWith('.extracted') - }) - // First access throws (not executable), then re-extract proceeds. - let accessCount = 0 - mockFsAccess.mockImplementation(async () => { - accessCount += 1 - if (accessCount === 1) { - throw new Error('EACCES') - } - }) - - await expect(extractExternalTools()).resolves.toBeTruthy() - }) - - it('handles TOCTOU: cached tools vanish on stillValid re-check (re-extracts)', async () => { - // Cache marker exists; first-pass validate(=existsSync per tool)=true; - // second-pass stillValid(=existsSync per tool)=false → falls through - // to safeDelete + recursive extractExternalTools. - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - let pass = 0 - let toolChecks = 0 - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracting')) { - return false - } - // Cache marker: true on first call (validation phase). After - // recursion: marker is consulted again — false → enter extraction. - if (ps.endsWith('.extracted')) { - pass += 1 - return pass === 1 - } - // Tool path checks: count, true for first 9 (validation), false for - // the next 1 (stillValid first re-check), then true for the rest. - toolChecks += 1 - if (toolChecks <= EXTERNAL_TOOLS.length) { - return true - } - if (toolChecks === EXTERNAL_TOOLS.length + 1) { - return false - } - return true - }) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - it('keeps cached path when tool is already extracted and accessible', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/should-not-call') - mockExistsSync.mockImplementation((p: string) => { - return !String(p).endsWith('.extracted') - }) - mockFsAccess.mockResolvedValue(undefined) - - const result = await extractExternalTools() - expect(result).toBeTruthy() - }) - - describe('lock-busy polling loop', () => { - const realSetTimeout = globalThis.setTimeout - beforeEach(() => { - ;(globalThis as { setTimeout: unknown }).setTimeout = (( - cb: () => void, - ) => { - cb() - return 0 as never - }) as never - }) - afterEach(() => { - ;(globalThis as { setTimeout: unknown }).setTimeout = realSetTimeout - }) - - it('returns tool paths when cache marker appears during wait', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - mockFsWriteFile.mockRejectedValue(eexistErr) - // process.kill: alive (valid lock). - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // After EEXIST and stale-check, we enter the wait loop. Make - // cacheMarker appear immediately on first poll; all tool paths exist. - mockExistsSync.mockReturnValue(true) - - try { - const result = await extractExternalTools() - expect(result).toBeTruthy() - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('detects missing tool after other-process extraction and retries', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // 1st phase: cache marker true, but first tool false (missing). - // After recursive call: everything succeeds. - let phase1Done = false - let toolMissCount = 0 - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracting')) { - return false - } - if (!phase1Done && ps.endsWith('.extracted')) { - return true - } - // First tool path check in validation: return false. - if (!phase1Done && ps.includes('cdxgen') && toolMissCount === 0) { - toolMissCount += 1 - phase1Done = true - return false - } - return true - }) - - try { - await extractExternalTools().catch(() => {}) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('hits the i % 5 === 4 cache-marker re-check branch', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - const realKill = process.kill - // Keep kill alive (valid lock) so we proceed into poll loop. - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // Cache marker: false during all polls, then true at i=4 cache-marker - // re-check (which triggers recursion). After recursion: success. - let existsCalls = 0 - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracted')) { - existsCalls += 1 - // First few calls: false (inside polling loop). - // Around call 6+ (after i=4 wait check): true. - return existsCalls >= 6 - } - return true - }) - - try { - await extractExternalTools().catch(() => {}) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('hits the i % 5 === 4 dead-PID branch in wait loop', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - - const realKill = process.kill - let killCount = 0 - ;(process as { kill: unknown }).kill = vi.fn(() => { - killCount += 1 - // First kill (stale check): alive (so we go into wait loop). - // Second+ kill (i=4 alive check): dead. - if (killCount === 1) { - return true - } - throw new Error('ESRCH') - }) - - // Cache marker false through wait loop. - mockExistsSync.mockImplementation((p: string) => { - return !String(p).endsWith('.extracted') - }) - - try { - await extractExternalTools().catch(() => {}) - expect(killCount).toBeGreaterThan(1) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('hits the i % 5 === 4 lock-file-gone branch in wait loop', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw eexistErr - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // First readFile (stale check): valid PID (so we enter wait loop). - // Second+ readFile (i=4 alive check): throws. - let readCount = 0 - mockFsReadFile.mockImplementation(async () => { - readCount += 1 - if (readCount === 1) { - return '12345' - } - throw new Error('ENOENT') - }) - mockExistsSync.mockImplementation((p: string) => { - return !String(p).endsWith('.extracted') - }) - - try { - await extractExternalTools().catch(() => {}) - expect(readCount).toBeGreaterThan(1) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('throws timeout when waiting hits 60 iterations without completion', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - mockFsWriteFile.mockRejectedValue(eexistErr) - - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // existsSync: never true for marker; true for everything else - // (though we shouldn't reach tool checks). - mockExistsSync.mockImplementation((p: string) => { - return !String(p).endsWith('.extracted') - }) - - try { - await expect(extractExternalTools()).rejects.toThrow(/timed out/) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('in-loop TOCTOU: marker becomes true, tools pass, stillValid fails → recurse', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - - let writeCount = 0 - mockFsWriteFile.mockImplementation(async () => { - writeCount += 1 - if (writeCount === 1) { - throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - } - return undefined - }) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // We want the FIRST in-loop iteration (line 261) to see marker=true, - // then for the validation pass: 9 tool checks all true, then the - // stillValid pass: at least one returns false → enter 290-291, - // then safeDelete + recurse. - let markerCalls = 0 - let toolCalls = 0 - let recursed = false - const toolCount = EXTERNAL_TOOLS.length - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracting')) { - return false - } - if (ps.endsWith('.extracted')) { - markerCalls += 1 - // First marker call (line 361 pre-lock) before EEXIST: false. - // After EEXIST + entering wait loop: first marker check (line 261) - // returns true. - if (markerCalls === 1) { - return false - } - // After recursion (markerCalls >= 3): also false so we extract. - if (recursed) { - return false - } - return true - } - toolCalls += 1 - // Validation pass: first 9 tool checks true. stillValid first - // re-check: false → enter 290-291. - if (!recursed && toolCalls <= toolCount) { - return true - } - if (!recursed && toolCalls === toolCount + 1) { - recursed = true - return false - } - return true - }) - - try { - await extractExternalTools().catch(() => {}) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('post-loop final check: marker true and all tools present', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - mockFsWriteFile.mockRejectedValue(eexistErr) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - // Count marker existsSync calls. The loop performs ~72 marker - // checks (60 at line 261 + 12 at line 302). The 73rd marker check - // is the post-loop "Final check before throwing timeout" at line 327. - let markerChecks = 0 - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracted')) { - markerChecks += 1 - // First 72 marker checks (inside loop): false. 73rd+: true - // (post-loop final). - return markerChecks > 72 - } - return true - }) - - try { - const result = await extractExternalTools() - expect(result).toBeTruthy() - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - - it('post-loop final check: marker true but tool missing → timeout', async () => { - mockIsSeaBinary.mockReturnValue(true) - withMountReturning(async () => '/m') - const eexistErr = Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - mockFsWriteFile.mockRejectedValue(eexistErr) - const realKill = process.kill - ;(process as { kill: unknown }).kill = vi.fn(() => true) - - let markerChecks = 0 - mockExistsSync.mockImplementation((p: string) => { - const ps = String(p) - if (ps.endsWith('.extracted')) { - markerChecks += 1 - return markerChecks > 72 - } - if (ps.includes('cdxgen')) { - return false - } - return true - }) - - try { - await expect(extractExternalTools()).rejects.toThrow(/timed out/) - } finally { - ;(process as { kill: unknown }).kill = realKill - } - }) - }) - }) -}) diff --git a/packages/cli/test/unit/util/dry-run/output.test.mts b/packages/cli/test/unit/util/dry-run/output.test.mts deleted file mode 100644 index e5c7bb2ca..000000000 --- a/packages/cli/test/unit/util/dry-run/output.test.mts +++ /dev/null @@ -1,274 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Dry-run previews are contextual output and always route to stderr -// per the stream discipline rule (CLAUDE.md SHARED STANDARDS). The -// mocks capture both streams separately so we can assert routing. -const mockStdoutLog = vi.fn() -const mockStderrLog = vi.fn() -const mockLog = vi.fn((...args: unknown[]) => { - mockStderrLog(...args) -}) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => ({ - log: (...args: unknown[]) => { - mockStdoutLog(...args) - }, - error: (...args: unknown[]) => { - mockLog(...args) - }, - }), -})) - -const { - outputDryRunDelete, - outputDryRunExecute, - outputDryRunFetch, - outputDryRunPreview, - outputDryRunUpload, - outputDryRunWrite, -} = await import('../../../../src/util/dry-run/output.mts') - -describe('dry-run output utilities', () => { - beforeEach(() => { - mockLog.mockClear() - mockStdoutLog.mockClear() - mockStderrLog.mockClear() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('outputDryRunFetch', () => { - it('should output basic message without query params', () => { - outputDryRunFetch('test data') - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('[DryRun]: Would fetch test data') - expect(output).toContain('This is a read-only operation') - expect(output).toContain('Run without --dry-run') - expect(output).not.toContain('Query parameters') - }) - - it('should output query parameters when provided', () => { - outputDryRunFetch('threat feed data', { - organization: 'my-org', - ecosystem: 'npm', - page: 1, - }) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('[DryRun]: Would fetch threat feed data') - expect(output).toContain('Query parameters:') - expect(output).toContain('organization: my-org') - expect(output).toContain('ecosystem: npm') - expect(output).toContain('page: 1') - }) - - it('should skip undefined and empty string values', () => { - outputDryRunFetch('analytics data', { - scope: 'org', - repo: undefined, - filter: '', - time: '30 days', - }) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('scope: org') - expect(output).toContain('time: 30 days') - expect(output).not.toContain('repo:') - expect(output).not.toContain('filter:') - }) - - it('should handle boolean values', () => { - outputDryRunFetch('config settings', { - showFullTokens: false, - enabled: true, - }) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('showFullTokens: false') - expect(output).toContain('enabled: true') - }) - - it('should handle numeric values including zero', () => { - outputDryRunFetch('paginated data', { - page: 5, - perPage: 30, - offset: 0, - }) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('page: 5') - expect(output).toContain('perPage: 30') - expect(output).toContain('offset: 0') - }) - - it('should not show query parameters section for empty params object', () => { - outputDryRunFetch('empty params', {}) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('[DryRun]: Would fetch empty params') - expect(output).not.toContain('Query parameters') - }) - - it('should format different value types correctly', () => { - outputDryRunFetch('mixed types', { - stringVal: 'hello', - numVal: 42, - boolTrue: true, - boolFalse: false, - }) - - const output = mockStderrLog.mock.calls.map(call => call[0]).join('\n') - expect(output).toContain('stringVal: hello') - expect(output).toContain('numVal: 42') - expect(output).toContain('boolTrue: true') - expect(output).toContain('boolFalse: false') - }) - }) - - describe('outputDryRunPreview', () => { - it('renders summary + actions + success line', () => { - outputDryRunPreview({ - summary: 'create a thing', - actions: [ - { - type: 'create', - description: 'add file', - target: '/tmp/x', - details: { mode: '0644' }, - }, - ], - wouldSucceed: true, - }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('[DryRun]: create a thing') - expect(output).toContain('Actions that would be performed') - expect(output).toContain('[create] add file → /tmp/x') - expect(output).toContain('mode: "0644"') - expect(output).toContain('Would complete successfully') - }) - - it('renders no-actions and would-fail variants', () => { - outputDryRunPreview({ - summary: 'no-op', - actions: [], - wouldSucceed: false, - }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('No actions would be performed') - expect(output).toContain('Would fail') - }) - - it('skips success/fail line when wouldSucceed is undefined', () => { - outputDryRunPreview({ - summary: 's', - actions: [{ type: 'modify', description: 'd' }], - }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).not.toContain('Would complete') - expect(output).not.toContain('Would fail') - }) - }) - - describe('outputDryRunExecute', () => { - it('renders command + arguments', () => { - outputDryRunExecute('cmd', ['--flag', 'value'], 'do the thing') - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('Would execute do the thing') - expect(output).toContain('Command: cmd') - expect(output).toContain('Arguments: --flag value') - }) - - it('omits arguments line when args is empty + uses default description', () => { - outputDryRunExecute('cmd', []) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('Would execute external command') - expect(output).not.toContain('Arguments:') - }) - }) - - describe('outputDryRunWrite', () => { - it('renders changes list', () => { - outputDryRunWrite('/tmp/x.json', 'update config', ['key=value']) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('Would update config') - expect(output).toContain('Target file: /tmp/x.json') - expect(output).toContain('- key=value') - }) - - it('omits changes section when empty', () => { - outputDryRunWrite('/tmp/x.json', 'update config') - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).not.toContain('Changes:') - }) - }) - - describe('outputDryRunUpload', () => { - it('renders nested object details', () => { - outputDryRunUpload('scan', { - orgSlug: 'my-org', - meta: { branch: 'main', ref: 'abc' }, - }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('Would upload scan') - expect(output).toContain('orgSlug: "my-org"') - expect(output).toContain('meta:') - expect(output).toContain('branch: "main"') - expect(output).toContain('ref: "abc"') - }) - - it('handles primitive values', () => { - outputDryRunUpload('config', { count: 5 }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('count: 5') - }) - - it('handles undefined detail values as primitives', () => { - outputDryRunUpload('thing', { value: undefined }) - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('value: undefined') - }) - }) - - describe('outputDryRunDelete', () => { - it('renders identifier + warning', () => { - outputDryRunDelete('repository', 'my-org/my-repo') - - const output = mockStderrLog.mock.calls.map(c => c[0]).join('\n') - expect(output).toContain('Would delete repository') - expect(output).toContain('Target: my-org/my-repo') - expect(output).toContain('cannot be undone') - }) - }) - - describe('stream routing', () => { - it('routes all dry-run preview text to stderr by default', () => { - outputDryRunFetch('anything', { k: 'v' }) - - expect(mockStdoutLog).not.toHaveBeenCalled() - expect(mockStderrLog).toHaveBeenCalled() - }) - - it('still routes to stderr even without explicit machine mode', () => { - // Dry-run previews are context, not payload — stderr always. - // This replaces the prior "stays on stdout by default" test. - outputDryRunFetch('anything', { k: 'v' }) - - expect(mockStdoutLog).not.toHaveBeenCalled() - expect(mockStderrLog).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/util/ecosystem/environment.test.mts b/packages/cli/test/unit/util/ecosystem/environment.test.mts deleted file mode 100644 index 5b1e019c1..000000000 --- a/packages/cli/test/unit/util/ecosystem/environment.test.mts +++ /dev/null @@ -1,961 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for ecosystem environment detection. - * - * Purpose: Tests ecosystem environment detection (Node.js, Python, Go, etc.). - * Validates runtime version detection. - * - * Test Coverage: - Runtime version detection - Package manager detection - - * Environment variable parsing - Multiple ecosystem support - Version string - * parsing. - * - * Testing Approach: Uses mocked subprocess calls to test environment detection. - * - * Related Files: - util/ecosystem/environment.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - AGENTS, - detectAndValidatePackageEnvironment, - detectPackageEnvironment, - getAgentExecPath, - getAgentVersion, - preferWindowsCmdShim, - resolveBinPathSync, -} from '../../../../src/util/ecosystem/environment.mts' - -// Mock the dependencies. -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFileSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - default: { - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - }, -})) - -const mockDefault = vi.hoisted(() => vi.fn()) -const mockParse = vi.hoisted(() => vi.fn()) -const mockValid = vi.hoisted(() => vi.fn()) -const mockSatisfies = vi.hoisted(() => vi.fn()) -const mockMajor = vi.hoisted(() => vi.fn()) -const mockMinor = vi.hoisted(() => vi.fn()) -const mockPatch = vi.hoisted(() => vi.fn()) -const mockCoerce = vi.hoisted(() => vi.fn()) - -vi.mock('browserslist', () => ({ - default: mockDefault.mockReturnValue([]), -})) - -const mockWhichBin = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: mockWhichBin, -})) - -const mockReadFileBinary = vi.hoisted(() => vi.fn()) -const mockReadFileUtf8 = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - readFileBinary: mockReadFileBinary, - readFileUtf8: mockReadFileUtf8, -})) - -const mockReadPackageJson = vi.hoisted(() => vi.fn()) -const mockToEditablePackageJson = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/packages/operations', () => ({ - readPackageJson: mockReadPackageJson, -})) -vi.mock('@socketsecurity/lib-stable/packages/edit', () => ({ - toEditablePackageJson: mockToEditablePackageJson, -})) - -const mockSpawn = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -const mockFindUp = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/fs/find-up.mts', () => ({ - findUp: mockFindUp, -})) - -vi.mock('@socketregistry/hyrious__bun.lockb/index.cjs', () => ({ - parse: mockParse, -})) - -const mockGetNpmExecPath = vi.hoisted(() => vi.fn()) -const mockGetPnpmExecPath = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/constants/agents.mts', async importOriginal => { - const actual: unknown = await importOriginal() - return { - ...actual, - getNpmExecPath: mockGetNpmExecPath, - getPnpmExecPath: mockGetPnpmExecPath, - } -}) - -vi.mock('semver', () => ({ - default: { - parse: mockParse, - valid: mockValid, - satisfies: mockSatisfies, - major: mockMajor, - minor: mockMinor, - patch: mockPatch, - coerce: mockCoerce, - lt: vi.fn(() => false), - }, -})) - -describe('package-environment', () => { - beforeEach(() => { - vi.clearAllMocks() - // Default mock behavior for spawn to get package manager version. - mockSpawn.mockResolvedValue({ stdout: '10.0.0', stderr: '', code: 0 }) - // Default mock behavior for toEditablePackageJson. - mockToEditablePackageJson.mockImplementation(async pkgJson => ({ - content: pkgJson, - path: '/project/package.json', - })) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('AGENTS', () => { - it('contains all expected package managers', () => { - expect(AGENTS).toContain('npm') - expect(AGENTS).toContain('pnpm') - expect(AGENTS).toContain('bun') - expect(AGENTS).toContain('vlt') - expect(AGENTS.length).toBeGreaterThan(0) - }) - }) - - describe('resolveBinPathSync', () => { - it('returns input path when file does not exist', () => { - mockExistsSync.mockReturnValue(false) - const result = resolveBinPathSync('/nonexistent/npm') - expect(result).toBe('/nonexistent/npm') - }) - - it('returns input path when shim regex does not match', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('echo "not a node shim"\n' as unknown) - const result = resolveBinPathSync('/usr/local/bin/some-tool') - expect(result).toBe('/usr/local/bin/some-tool') - }) - - it('extracts the underlying npm-cli.js when found', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue( - 'node "/usr/lib/node_modules/npm/bin/npm-cli.js" "$@"\n' as unknown, - ) - const result = resolveBinPathSync('/usr/local/bin/npm') - expect(result).toBe('/usr/lib/node_modules/npm/bin/npm-cli.js') - }) - - it('resolves relative shim path against bin dir', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue( - 'node "../lib/npm-cli.js" "$@"\n' as unknown, - ) - const result = resolveBinPathSync('/usr/local/bin/npm') - // Resolves "../lib/npm-cli.js" relative to /usr/local/bin/. - expect(result).toContain('npm-cli.js') - expect(result.startsWith('/')).toBe(true) - }) - - it('returns input path when readFileSync throws', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockImplementation(() => { - throw new Error('I/O error') - }) - const result = resolveBinPathSync('/usr/local/bin/npm') - expect(result).toBe('/usr/local/bin/npm') - }) - }) - - describe('preferWindowsCmdShim', () => { - it('returns input path on POSIX (no .cmd shim)', () => { - // On the test runner platform (POSIX) the function should bail - // immediately and return the input. - const result = preferWindowsCmdShim('/usr/local/bin/npm', 'npm') - expect(result).toBe('/usr/local/bin/npm') - }) - - it('returns input for non-absolute paths', () => { - const result = preferWindowsCmdShim('npm', 'npm') - expect(result).toBe('npm') - }) - }) - - describe('getAgentExecPath', () => { - it('returns getNpmExecPath when it exists for npm agent', async () => { - mockGetNpmExecPath.mockResolvedValue('/usr/local/bin/npm') - mockExistsSync.mockReturnValue(true) - const result = await getAgentExecPath('npm') - expect(result).toBe('/usr/local/bin/npm') - }) - - it('falls back to whichReal when getNpmExecPath does not exist', async () => { - mockGetNpmExecPath.mockResolvedValue('/missing/npm') - mockWhichBin.mockResolvedValue('/usr/bin/npm') - // existsSync returns false for npmPath, npmInNodeDir; we want to - // exercise the whichReal fallback at lines 337-341. - mockExistsSync.mockReturnValue(false) - const result = await getAgentExecPath('npm') - expect(typeof result).toBe('string') - expect(result.length).toBeGreaterThan(0) - }) - - it('returns binName when whichReal returns null for npm', async () => { - mockGetNpmExecPath.mockResolvedValue('/missing/npm') - mockWhichBin.mockResolvedValue(undefined) - mockExistsSync.mockReturnValue(false) - const result = await getAgentExecPath('npm') - // Falls back to bare 'npm' string when whichReal returns null. - expect(result).toBe('npm') - }) - - it('returns getPnpmExecPath when it exists for pnpm agent', async () => { - mockGetPnpmExecPath.mockResolvedValue('/usr/local/bin/pnpm') - mockExistsSync.mockReturnValue(true) - const result = await getAgentExecPath('pnpm') - expect(result).toBe('/usr/local/bin/pnpm') - }) - - it('falls back to whichReal when getPnpmExecPath does not exist', async () => { - mockGetPnpmExecPath.mockResolvedValue('/missing/pnpm') - mockExistsSync.mockReturnValue(false) - mockWhichBin.mockResolvedValue('/found/pnpm') - const result = await getAgentExecPath('pnpm') - expect(typeof result).toBe('string') - }) - - it('uses whichReal for non-npm/pnpm agents (yarn-classic)', async () => { - mockWhichBin.mockResolvedValue('/usr/local/bin/yarn') - const result = await getAgentExecPath('yarn-classic') - expect(typeof result).toBe('string') - }) - - it('returns array first element when whichReal returns array', async () => { - mockWhichBin.mockResolvedValue(['/first/yarn', '/second/yarn']) - const result = await getAgentExecPath('yarn-classic') - expect(result).toBe('/first/yarn') - }) - }) - - describe('getAgentVersion', () => { - it('returns coerced semver version on successful spawn', async () => { - mockSpawn.mockResolvedValue({ - stdout: '10.8.2', - code: 0, - }) - mockCoerce.mockReturnValue({ version: '10.8.2' }) - const result = await getAgentVersion('npm', '/usr/local/bin/npm', '/cwd') - expect(mockSpawn).toHaveBeenCalled() - expect(result).toEqual({ version: '10.8.2' }) - }) - - it('returns undefined when spawn returns null/undefined', async () => { - mockSpawn.mockResolvedValue(undefined) - const result = await getAgentVersion('npm', '/usr/local/bin/npm', '/cwd') - expect(result).toBeUndefined() - }) - - it('returns undefined and logs when spawn rejects', async () => { - mockSpawn.mockRejectedValue(new Error('command failed')) - const result = await getAgentVersion('npm', '/usr/local/bin/npm', '/cwd') - expect(result).toBeUndefined() - }) - - it('returns undefined when stdout is non-coerceable', async () => { - mockSpawn.mockResolvedValue({ - stdout: 'not-a-version', - code: 0, - }) - mockCoerce.mockReturnValue(undefined) - const result = await getAgentVersion('npm', '/usr/local/bin/npm', '/cwd') - expect(result).toBeUndefined() - }) - - it('handles Buffer stdout (calls .toString())', async () => { - mockSpawn.mockResolvedValue({ - stdout: Buffer.from('10.8.2'), - code: 0, - }) - mockCoerce.mockReturnValue({ version: '10.8.2' }) - const result = await getAgentVersion('npm', '/usr/local/bin/npm', '/cwd') - expect(result).toEqual({ version: '10.8.2' }) - }) - }) - - describe('detectPackageEnvironment', () => { - it('detects npm environment with package-lock.json', async () => { - const { findUp } = await import('../../../../src/util/fs/find-up.mts') - const mockFindUpImported = vi.mocked(findUp) - - // Mock finding package-lock.json. - mockFindUpImported.mockResolvedValue('/project/package-lock.json') - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockExistsSync.mockReturnValue(true) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - expect(result.agent).toBe('npm') - // Skip lockName, lockPath, and agentExecPath - mocks not working properly with vitest - // expect(result.lockName).toBe('package-lock.json') - // expect(result.lockPath).toBe('/project/package-lock.json') - // expect(result.agentExecPath).toBe('/usr/local/bin/npm') - expect(result.agentExecPath).toBeTruthy() - }) - - it('detects pnpm environment with pnpm-lock.yaml', async () => { - const { findUp } = await import('../../../../src/util/fs/find-up.mts') - const mockFindUpImported = vi.mocked(findUp) - - // Mock finding pnpm-lock.yaml. - mockFindUpImported.mockImplementation(async files => { - // When called with an array of lock file names, return the pnpm lock. - if (Array.isArray(files) && files.includes('pnpm-lock.yaml')) { - return '/project/pnpm-lock.yaml' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/pnpm') - mockReadFileUtf8.mockResolvedValue('lockfileVersion: 5.4') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockExistsSync.mockReturnValue(true) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - expect(result.agent).toBe('pnpm') - // Skip lockName, lockPath, and agentExecPath - mocks not working properly with vitest - // expect(result.lockName).toBe('pnpm-lock.yaml') - // expect(result.lockPath).toBe('/project/pnpm-lock.yaml') - // expect(result.agentExecPath).toBe('/usr/local/bin/pnpm') - expect(result.agentExecPath).toBeTruthy() - }) - - it('detects yarn environment with yarn.lock', async () => { - const { findUp } = await import('../../../../src/util/fs/find-up.mts') - const mockFindUpImported = vi.mocked(findUp) - - // Mock finding yarn.lock. - mockFindUpImported.mockImplementation(async files => { - // When called with an array of lock file names, return the yarn lock. - if (Array.isArray(files) && files.includes('yarn.lock')) { - return '/project/yarn.lock' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/yarn') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockExistsSync.mockReturnValue(true) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - // Yarn classic returns 'yarn/classic', not just 'yarn'. - expect(result.agent).toMatch(/yarn/) - // Skip lockName, lockPath, and agentExecPath - mocks not working properly with vitest - // expect(result.lockName).toBe('yarn.lock') - // expect(result.lockPath).toBe('/project/yarn.lock') - // expect(result.agentExecPath).toBe('/usr/local/bin/yarn') - expect(result.agentExecPath).toBeTruthy() - }) - - it('detects bun environment with bun.lockb', async () => { - const { findUp } = await import('../../../../src/util/fs/find-up.mts') - const mockFindUpImported = vi.mocked(findUp) - - // Mock finding bun.lockb. - mockFindUpImported.mockImplementation(async files => { - // When called with an array of lock file names, return the bun lock. - if (Array.isArray(files) && files.includes('bun.lockb')) { - return '/project/bun.lockb' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/bun') - // Mock Bun lockfile binary content. - const mockBunContent = Buffer.from([0]) - mockReadFileBinary.mockResolvedValue(mockBunContent) - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockExistsSync.mockReturnValue(true) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - expect(result.agent).toBe('bun') - // Skip lockName, lockPath, and agentExecPath - mocks not working properly with vitest - // expect(result.lockName).toBe('bun.lockb') - // expect(result.lockPath).toBe('/project/bun.lockb') - // expect(result.agentExecPath).toBe('/usr/local/bin/bun') - expect(result.agentExecPath).toBeTruthy() - }) - - it('returns error when no package.json found', async () => { - mockFindUp.mockResolvedValue(undefined) - - const onUnknown = vi.fn(() => 'npm') - const result = await detectPackageEnvironment({ - cwd: '/project', - onUnknown, - }) - - expect(onUnknown).toHaveBeenCalled() - expect(result.agent).toBe('npm') - }) - - it('detects multiple lockfiles', async () => { - // First call returns package-lock.json. - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockImplementation(path => { - const pathStr = String(path) - return ( - pathStr.includes('yarn.lock') || - pathStr.includes('package-lock.json') || - pathStr.includes('package.json') - ) - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - expect(result.agent).toBe('npm') - // Skip lockName check - mocks not working properly with vitest - // expect(result.lockName).toBeTruthy() - }) - - it('determines Node version from package engines', async () => { - mockFindUp.mockImplementation(async file => { - if (Array.isArray(file)) { - if (file.includes('package-lock.json')) { - return '/project/package-lock.json' - } - } else if (file === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - engines: { - node: '>=18.0.0', - }, - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockExistsSync.mockReturnValue(true) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - // Node version info is in the pkgRequirements property. - expect(result.pkgRequirements?.node).toBe('>=18.0.0') - }) - - it('detects browser targets from browserslist', async () => { - const mockBrowserslist = (await import('browserslist')).default as unknown - - mockFindUp.mockImplementation(async files => { - if ( - Array.isArray(files) && - files.some(f => f.includes('package-lock.json')) - ) { - return '/project/package-lock.json' - } - return undefined - }) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockBrowserslist.mockReturnValue(['chrome 90', 'firefox 88']) - - const result = await detectPackageEnvironment({ cwd: '/project' }) - - // Browsers info might be in result.browsers array. - expect(result.browsers || mockBrowserslist()).toEqual([ - 'chrome 90', - 'firefox 88', - ]) - }) - }) - - describe('detectAndValidatePackageEnvironment', () => { - beforeEach(() => { - mockSpawn.mockResolvedValue({ stdout: '10.0.0', stderr: '', code: 0 }) - mockToEditablePackageJson.mockImplementation(async pkgJson => ({ - content: pkgJson, - path: '/project/package.json', - })) - // Mock semver functions for version checks. - mockCoerce.mockImplementation((v: string) => ({ - version: v.replace(/^v/, ''), - major: parseInt(v.replace(/^v/, '').split('.')[0] || '0', 10), - minor: parseInt(v.replace(/^v/, '').split('.')[1] || '0', 10), - patch: parseInt(v.replace(/^v/, '').split('.')[2] || '0', 10), - })) - mockSatisfies.mockReturnValue(true) - mockMajor.mockImplementation((v: unknown) => v?.major ?? 18) - }) - - it('returns success when all validations pass', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.agent).toBe('npm') - } - }) - - it('returns error when agent is not supported', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - // Return false for agent support check. - mockSatisfies.mockReturnValue(false) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Version mismatch') - } - }) - - it('returns error when no lockfile is found', async () => { - mockFindUp.mockResolvedValue(undefined) - mockExistsSync.mockReturnValue(false) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue(undefined) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Missing lockfile') - } - }) - - it('returns error when lockfile is empty', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - // Mock empty lockfile. - mockReadFileUtf8.mockResolvedValue('') - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Empty lockfile') - } - }) - - it('returns error when --prod is used with unsupported agent', async () => { - // Test that the validation catches --prod with unsupported agents. - // This tests the validation path indirectly since mocking the full - // environment detection for bun is complex. - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockReadFileUtf8.mockResolvedValue('lock content') - - // For npm, --prod is supported, so this should succeed. - const result = await detectAndValidatePackageEnvironment('/project', { - prod: true, - }) - - // Just verify we can pass prod option. - expect(result).toBeDefined() - }) - - it('logs warning for unknown package manager', async () => { - const mockLogger = { - warn: vi.fn(), - error: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - } - mockFindUp.mockResolvedValue(undefined) - mockExistsSync.mockReturnValue(false) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - - await detectAndValidatePackageEnvironment('/project', { - cmdName: 'test-cmd', - logger: mockLogger as unknown, - }) - - // The onUnknown callback should have been called. - expect(mockLogger.warn).toHaveBeenCalled() - }) - - it('logs warning when lockfile is found outside cwd', async () => { - const mockLogger = { - warn: vi.fn(), - error: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - } - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - // Return a path outside the cwd. - return '/other/project/package-lock.json' - } - if (files === 'package.json') { - return '/other/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockReadFileUtf8.mockResolvedValue('lock content') - - const result = await detectAndValidatePackageEnvironment('/project', { - cmdName: 'test-cmd', - logger: mockLogger as unknown, - }) - - // In VITEST mode, the lockPath is redacted in the warning. - if (result.ok) { - expect(mockLogger.warn).toHaveBeenCalled() - } - }) - - it('returns error when node version is not supported', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - mockReadFileUtf8.mockResolvedValue('lock content') - // First return true for agent, then false for node. - let callCount = 0 - mockSatisfies.mockImplementation(() => { - callCount++ - return callCount === 1 // true for agent, false for node. - }) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Version mismatch') - } - }) - - it('returns error when package node engine requirements are not met', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - engines: { - node: '>=22.0.0', - }, - }) - mockReadFileUtf8.mockResolvedValue('lock content') - // Return true for agent and node supported, but false for pkgSupports. - let callCount = 0 - mockSatisfies.mockImplementation(() => { - callCount++ - // First two calls return true (agent supported, node supported). - // Third call returns false (pkgSupports.agent). - // Fourth call returns false (pkgSupports.node). - return callCount <= 2 - }) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Engine mismatch') - } - }) - - it('returns error when package.json is missing', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - // Return true for path existence, but make pkgPath undefined by not having editablePkgJson. - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - // Return undefined to simulate missing package.json. - mockReadPackageJson.mockResolvedValue(undefined) - mockToEditablePackageJson.mockResolvedValue(undefined) - mockReadFileUtf8.mockResolvedValue('lock content') - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(false) - if (!result.ok) { - // The validation checks for lockfile presence first, and - // editablePkgJson being undefined makes lockName undefined. - expect(result.message).toBe('Missing lockfile') - } - }) - - it('detects agent from packageManager field (lines 324-332)', async () => { - // packageManager: "pnpm@8.15.7" → agent='pnpm' inferred from the field - // before any lockfile lookup. - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('pnpm-lock.yaml')) { - return '/project/pnpm-lock.yaml' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/pnpm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - packageManager: 'pnpm@8.15.7', - }) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.agent).toBe('pnpm') - } - }) - - it('falls back to npm when packageManager has no @ separator', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - // No '@' → atSignIndex < 0 → falls through to lockfile inference. - packageManager: 'invalid-format', - }) - - const result = await detectAndValidatePackageEnvironment('/project') - - // Falls through to LOCKS lookup (package-lock.json → npm). - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.agent).toBe('npm') - } - }) - - it('lowers pkgMinAgentVersion from package engines field (lines 366-373)', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - // Engines pin npm to >=8.0.0 and node to >=16.0.0 — both < the - // minimum supported defaults, so they lower pkgMin*Version. - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - engines: { - npm: '>=8.0.0', - node: '>=16.0.0', - }, - }) - // Stub semver.lt: unknown coerced < default is true. - mockSatisfies.mockReturnValue(true) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(true) - }) - - it('lowers pkgMinNodeVersion from browserslist node targets (lines 387-399)', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('package-lock.json')) { - return '/project/package-lock.json' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/npm') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - browserslist: ['node 16.0.0', 'node 18.0.0', 'chrome 120'], - }) - // browserslist returns the targets sorted by browserslist itself; we - // also want the node-* filter to keep ['node 16.0.0', 'node 18.0.0']. - const mockBrowserslist = (await import('browserslist')).default as unknown - mockBrowserslist.mockReturnValue([ - 'node 16.0.0', - 'node 18.0.0', - 'chrome 120', - ]) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(true) - }) - - it('detects yarn-berry when yarn-classic agent has major > 1 (lines 348-349)', async () => { - mockFindUp.mockImplementation(async files => { - if (Array.isArray(files) && files.includes('yarn.lock')) { - return '/project/yarn.lock' - } - if (files === 'package.json') { - return '/project/package.json' - } - return undefined - }) - mockExistsSync.mockReturnValue(true) - mockWhichBin.mockResolvedValue('/usr/local/bin/yarn') - mockReadPackageJson.mockResolvedValue({ - name: 'test-project', - version: '1.0.0', - }) - // yarn version 4.x → coerced major 4 > 1 → upgrades classic to berry. - mockSpawn.mockResolvedValue({ stdout: '4.5.0', stderr: '', code: 0 }) - - const result = await detectAndValidatePackageEnvironment('/project') - - expect(result.ok).toBe(true) - if (result.ok) { - // Agent name uses '/' as the separator between flavor variants. - expect(result.data.agent).toBe('yarn/berry') - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/ecosystem/lockfile-readers.test.mts b/packages/cli/test/unit/util/ecosystem/lockfile-readers.test.mts deleted file mode 100644 index 1a5f97ddb..000000000 --- a/packages/cli/test/unit/util/ecosystem/lockfile-readers.test.mts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Unit tests for the LOCKS map and per-agent lockfile readers. - * - * Most callers exercise these through detectPackageEnvironment integration - * tests; this file covers the bun-specific reader paths that the higher-level - * tests don't reach (.lock vs .lockb dispatch, parseBunLockb fallback to - * spawning `bun`, and the wrapReader catch-returns-undefined branch). - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockReadFileBinary = vi.hoisted(() => vi.fn()) -const mockReadFileUtf8 = vi.hoisted(() => vi.fn()) -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockParseBunLockb = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - readFileBinary: mockReadFileBinary, - readFileUtf8: mockReadFileUtf8, -})) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, -})) - -vi.mock('@socketregistry/hyrious__bun.lockb/index.cjs', () => ({ - parse: mockParseBunLockb, -})) - -import { - LOCKS, - readLockFileByAgent, -} from '../../../../src/util/ecosystem/lockfile-readers.mts' - -describe('lockfile-readers', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('LOCKS', () => { - it('maps bun lockfiles to bun', () => { - expect(LOCKS['bun.lock']).toBe('bun') - expect(LOCKS['bun.lockb']).toBe('bun') - }) - - it('maps npm lockfiles to npm', () => { - expect(LOCKS['package-lock.json']).toBe('npm') - expect(LOCKS['npm-shrinkwrap.json']).toBe('npm') - }) - - it('maps pnpm lockfile to pnpm', () => { - expect(LOCKS['pnpm-lock.yaml']).toBe('pnpm') - }) - - it('maps yarn.lock to yarn/classic', () => { - // Agent name uses '/' as the separator between flavor variants. - expect(LOCKS['yarn.lock']).toBe('yarn/classic') - }) - - it('maps the hidden .package-lock.json to npm', () => { - expect(LOCKS['node_modules/.package-lock.json']).toBe('npm') - }) - - it('iterates in significant order: bun first, hidden npm last', () => { - const keys = Object.keys(LOCKS) - expect(keys[0]).toBe('bun.lock') - expect(keys[keys.length - 1]).toBe('node_modules/.package-lock.json') - }) - }) - - describe('readLockFileByAgent (npm/pnpm/etc.)', () => { - it('returns the utf8 content for npm', async () => { - mockReadFileUtf8.mockResolvedValue('{"lockfileVersion": 3}') - const reader = readLockFileByAgent.get('npm')! - const result = await reader( - '/repo/package-lock.json', - '/usr/bin/npm', - '/repo', - ) - expect(result).toBe('{"lockfileVersion": 3}') - }) - - it('returns undefined when the underlying read throws (catch branch)', async () => { - mockReadFileUtf8.mockRejectedValue(new Error('EACCES')) - const reader = readLockFileByAgent.get('pnpm')! - const result = await reader( - '/repo/pnpm-lock.yaml', - '/usr/bin/pnpm', - '/repo', - ) - expect(result).toBeUndefined() - }) - }) - - describe('readLockFileByAgent.bun (.lock)', () => { - it('uses the default utf8 reader for bun.lock files', async () => { - mockReadFileUtf8.mockResolvedValue('bun lockfile contents') - const reader = readLockFileByAgent.get('bun')! - const result = await reader('/repo/bun.lock', '/usr/bin/bun', '/repo') - expect(result).toBe('bun lockfile contents') - expect(mockReadFileUtf8).toHaveBeenCalledWith('/repo/bun.lock') - }) - }) - - describe('readLockFileByAgent.bun (.lockb)', () => { - it('parses the lockfile via parseBunLockb when the buffer is readable', async () => { - const buffer = Buffer.from('binary-lockfile-content') - mockReadFileBinary.mockResolvedValue(buffer) - mockParseBunLockb.mockReturnValue('parsed yaml output') - const reader = readLockFileByAgent.get('bun')! - const result = await reader('/repo/bun.lockb', '/usr/bin/bun', '/repo') - expect(mockParseBunLockb).toHaveBeenCalledWith(buffer) - expect(result).toBe('parsed yaml output') - }) - - it('falls back to spawning bun bun.lockb when the parser throws', async () => { - const buffer = Buffer.from('corrupt-binary') - mockReadFileBinary.mockResolvedValue(buffer) - mockParseBunLockb.mockImplementation(() => { - throw new Error('parse failed') - }) - mockSpawn.mockResolvedValue({ - stdout: 'spawned bun output', - stderr: '', - code: 0, - }) - const reader = readLockFileByAgent.get('bun')! - const result = await reader('/repo/bun.lockb', '/usr/bin/bun', '/repo') - expect(mockSpawn).toHaveBeenCalledWith( - '/usr/bin/bun', - ['/repo/bun.lockb'], - expect.objectContaining({ cwd: '/repo' }), - ) - expect(result).toBe('spawned bun output') - }) - - it('falls back to spawning when readFileBinary returns nothing', async () => { - mockReadFileBinary.mockResolvedValue(undefined) - mockSpawn.mockResolvedValue({ - stdout: 'spawned', - stderr: '', - code: 0, - }) - const reader = readLockFileByAgent.get('bun')! - const result = await reader('/repo/bun.lockb', '/usr/bin/bun', '/repo') - expect(mockParseBunLockb).not.toHaveBeenCalled() - expect(result).toBe('spawned') - }) - }) - - describe('readLockFileByAgent.bun (unknown extension)', () => { - it('returns undefined for an unrecognized extension', async () => { - const reader = readLockFileByAgent.get('bun')! - const result = await reader('/repo/bun.json', '/usr/bin/bun', '/repo') - expect(result).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/ecosystem/requirements.test.mts b/packages/cli/test/unit/util/ecosystem/requirements.test.mts deleted file mode 100644 index 496613b57..000000000 --- a/packages/cli/test/unit/util/ecosystem/requirements.test.mts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Unit tests for ecosystem requirements validation. - * - * Purpose: Tests ecosystem requirements checking. Validates minimum version - * requirements and compatibility. - * - * Test Coverage: - Version requirement parsing - Compatibility checking - - * Minimum version validation - Range support (semver) - Error messaging for - * incompatible versions. - * - * Testing Approach: Tests semver-based requirement validation logic. - * - * Related Files: - util/ecosystem/requirements.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - getRequirements, - getRequirementsKey, -} from '../../../../src/util/ecosystem/requirements.mts' - -// Mock the requirements.json module. -vi.mock('../../../../src/util/ecosystem/requirements.json', () => ({ - default: { - api: { - 'scan:create': { - quota: 10, - permissions: ['create', 'scan'], - }, - 'organization:view': { - permissions: ['read'], - }, - }, - }, -})) - -describe('requirements utilities', () => { - describe('getRequirements', () => { - it('loads requirements configuration', () => { - const requirements = getRequirements() - expect(requirements).toBeDefined() - expect(requirements).toHaveProperty('api') - }) - - it('caches requirements after first load', () => { - const requirements1 = getRequirements() - const requirements2 = getRequirements() - expect(requirements1).toBe(requirements2) - }) - }) - - describe('getRequirementsKey', () => { - it('converts basic command path to key', () => { - expect(getRequirementsKey('socket scan')).toBe('scan') - expect(getRequirementsKey('socket organization')).toBe('organization') - }) - - it('converts nested command path to key with colons', () => { - expect(getRequirementsKey('socket scan create')).toBe('scan:create') - expect(getRequirementsKey('socket organization view')).toBe( - 'organization:view', - ) - }) - - it('handles multiple spaces', () => { - expect(getRequirementsKey('socket scan create')).toBe(':scan:create') - expect(getRequirementsKey('socket organization view')).toBe( - ':organization:view', - ) - }) - - it('handles path with colon separator', () => { - expect(getRequirementsKey('socket: scan')).toBe(':scan') - expect(getRequirementsKey('socket: scan create')).toBe(':scan:create') - }) - - it('handles path without socket prefix', () => { - expect(getRequirementsKey('scan create')).toBe('scan:create') - expect(getRequirementsKey('organization view')).toBe('organization:view') - }) - - it('handles single command', () => { - expect(getRequirementsKey('login')).toBe('login') - expect(getRequirementsKey('logout')).toBe('logout') - }) - - it('handles empty string', () => { - expect(getRequirementsKey('')).toBe('') - }) - - it('handles deeply nested commands', () => { - expect(getRequirementsKey('socket repos create test')).toBe( - 'repos:create:test', - ) - expect(getRequirementsKey('socket organization member add')).toBe( - 'organization:member:add', - ) - }) - - it('preserves non-space special characters', () => { - expect(getRequirementsKey('socket scan-create')).toBe('scan-create') - expect(getRequirementsKey('socket org_view')).toBe('org_view') - }) - }) -}) diff --git a/packages/cli/test/unit/util/ecosystem/types.test.mts b/packages/cli/test/unit/util/ecosystem/types.test.mts deleted file mode 100644 index 9e28de6b4..000000000 --- a/packages/cli/test/unit/util/ecosystem/types.test.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Unit tests for ecosystem type definitions. - * - * Purpose: Tests ecosystem type utilities and type guards. Validates TypeScript - * type narrowing for ecosystems. - * - * Test Coverage: - Type guard functions - Ecosystem detection - Type narrowing - * - Runtime type checking - Ecosystem enum validation. - * - * Testing Approach: Tests TypeScript type utilities with runtime validation. - * - * Related Files: - util/ecosystem/types.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - ALL_ECOSYSTEMS, - getEcosystemChoicesForMeow, -} from '../../../../src/util/ecosystem/types.mts' - -describe('ecosystem utilities', () => { - describe('ALL_ECOSYSTEMS', () => { - it('contains expected ecosystems', () => { - expect(ALL_ECOSYSTEMS).toContain('npm') - expect(ALL_ECOSYSTEMS).toContain('pypi') - expect(ALL_ECOSYSTEMS).toContain('cargo') - expect(ALL_ECOSYSTEMS).toContain('gem') - expect(ALL_ECOSYSTEMS).toContain('maven') - expect(ALL_ECOSYSTEMS).toContain('docker') - }) - - it('has unique values', () => { - const uniqueValues = new Set(ALL_ECOSYSTEMS) - expect(uniqueValues.size).toBe(ALL_ECOSYSTEMS.length) - }) - - it('is an array', () => { - expect(Array.isArray(ALL_ECOSYSTEMS)).toBe(true) - }) - }) - - describe('getEcosystemChoicesForMeow', () => { - it('returns array of all ecosystems', () => { - const choices = getEcosystemChoicesForMeow() - expect(Array.isArray(choices)).toBe(true) - expect(choices).toEqual([...ALL_ECOSYSTEMS]) - }) - - it('returns a new array instance', () => { - const choices1 = getEcosystemChoicesForMeow() - const choices2 = getEcosystemChoicesForMeow() - expect(choices1).not.toBe(choices2) - expect(choices1).toEqual(choices2) - }) - }) - -}) diff --git a/packages/cli/test/unit/util/ecosystem/windows-shims.test.mts b/packages/cli/test/unit/util/ecosystem/windows-shims.test.mts deleted file mode 100644 index b2e42997b..000000000 --- a/packages/cli/test/unit/util/ecosystem/windows-shims.test.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Unit tests for the Windows-shim resolution helpers. - * - * The helpers behave as no-ops on POSIX, so most assertions pivot on stubbing - * the WIN32 constant from @socketsecurity/lib via vi.mock(). - */ - -import { describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFileSync = vi.hoisted(() => vi.fn()) - -// Mock fs so each test can dictate file existence and shim contents. -vi.mock('node:fs', async importOriginal => { - const actual: unknown = await importOriginal() - return { - ...actual, - default: { - ...actual.default, - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - }, - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - } -}) - -// Toggle the WIN32 export per test. Ships at module load, so we re-import -// the SUT inside each branch that needs a different platform. -const mockWin32 = vi.hoisted(() => ({ WIN32: false })) -vi.mock('@socketsecurity/lib-stable/constants/platform', () => mockWin32) - -import { - preferWindowsCmdShim, - resolveBinPathSync, -} from '../../../../src/util/ecosystem/windows-shims.mts' - -describe('windows-shims', () => { - describe('resolveBinPathSync', () => { - it('returns the input path verbatim when the file does not exist', () => { - mockExistsSync.mockReturnValue(false) - expect(resolveBinPathSync('/nonexistent/npm')).toBe('/nonexistent/npm') - }) - - it('returns the input path when the file content has no shim pattern', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('echo "not a shim"\n') - expect(resolveBinPathSync('/usr/bin/some-tool')).toBe( - '/usr/bin/some-tool', - ) - }) - - it('extracts the absolute npm-cli.js path from a node-style shim', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue( - 'node "/usr/lib/node_modules/npm/bin/npm-cli.js" "$@"\n', - ) - expect(resolveBinPathSync('/usr/local/bin/npm')).toBe( - '/usr/lib/node_modules/npm/bin/npm-cli.js', - ) - }) - - it('extracts the pnpm shim path', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('node "/opt/pnpm/dist/pnpm.cjs" "$@"\n') - expect(resolveBinPathSync('/usr/local/bin/pnpm')).toBe( - '/opt/pnpm/dist/pnpm.cjs', - ) - }) - - it('extracts the yarn shim path (.mjs extension)', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('node "/opt/yarn/lib/yarn.mjs" "$@"\n') - expect(resolveBinPathSync('/usr/local/bin/yarn')).toBe( - '/opt/yarn/lib/yarn.mjs', - ) - }) - - it('resolves a relative shim path against the bin dir', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('node "../lib/npm-cli.js" "$@"\n') - const result = resolveBinPathSync('/usr/local/bin/npm') - // path.resolve('/usr/local/bin', '../lib/npm-cli.js') - expect(result).toContain('npm-cli.js') - expect(result.startsWith('/')).toBe(true) - }) - - it('returns the input path when readFileSync throws', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockImplementation(() => { - throw new Error('EACCES') - }) - expect(resolveBinPathSync('/usr/local/bin/npm')).toBe( - '/usr/local/bin/npm', - ) - }) - }) - - describe('preferWindowsCmdShim (POSIX)', () => { - it('returns the input path verbatim on POSIX (WIN32 = false)', () => { - mockWin32.WIN32 = false - expect(preferWindowsCmdShim('/usr/local/bin/npm', 'npm')).toBe( - '/usr/local/bin/npm', - ) - }) - }) - - describe('preferWindowsCmdShim (Windows)', () => { - it('returns the input for non-absolute paths (line 68-69)', () => { - mockWin32.WIN32 = true - try { - expect(preferWindowsCmdShim('npm', 'npm')).toBe('npm') - } finally { - mockWin32.WIN32 = false - } - }) - - it('returns the input when path already has an extension (line 73-74)', () => { - mockWin32.WIN32 = true - try { - // Absolute POSIX path with an extension — exercises the - // extname-not-empty branch on non-Windows test environments. - expect(preferWindowsCmdShim('/usr/local/bin/npm.exe', 'npm')).toBe( - '/usr/local/bin/npm.exe', - ) - } finally { - mockWin32.WIN32 = false - } - }) - - it('returns the input when basename does not match binName (line 79-80)', () => { - mockWin32.WIN32 = true - try { - expect(preferWindowsCmdShim('/usr/local/bin/wrong', 'npm')).toBe( - '/usr/local/bin/wrong', - ) - } finally { - mockWin32.WIN32 = false - } - }) - - it('returns the .cmd shim when one exists in the same dir (line 83-84)', () => { - mockWin32.WIN32 = true - mockExistsSync.mockReturnValue(true) - try { - const result = preferWindowsCmdShim('/usr/local/bin/npm', 'npm') - // path.join → /usr/local/bin/npm.cmd - expect(result).toContain('npm.cmd') - } finally { - mockWin32.WIN32 = false - } - }) - - it('falls back to input when .cmd shim does not exist (line 84)', () => { - mockWin32.WIN32 = true - mockExistsSync.mockReturnValue(false) - try { - expect(preferWindowsCmdShim('/usr/local/bin/npm', 'npm')).toBe( - '/usr/local/bin/npm', - ) - } finally { - mockWin32.WIN32 = false - } - }) - - it('basename comparison is case-insensitive', () => { - mockWin32.WIN32 = true - mockExistsSync.mockReturnValue(true) - try { - const result = preferWindowsCmdShim('/usr/local/bin/NPM', 'npm') - // Match succeeds because lowercased equality holds. - expect(result).toContain('.cmd') - } finally { - mockWin32.WIN32 = false - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/error/display.test.mts b/packages/cli/test/unit/util/error/display.test.mts deleted file mode 100644 index 793b25781..000000000 --- a/packages/cli/test/unit/util/error/display.test.mts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Unit tests for error display utilities. - * - * Purpose: Tests the error formatting and display utilities. - * - * Test Coverage: - formatErrorForDisplay function - formatErrorCompact function - * - formatErrorForTerminal function - formatErrorForJson function - - * formatExternalCliError function - formatWarning function - formatSuccess - * function - formatInfo function. - * - * Related Files: - src/util/error/display.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -// Mock debug namespace checks. -vi.mock('../../../../src/util/debug.mts', () => ({ - debugDirNs: vi.fn(), - debugNs: vi.fn(), - isDebugNs: () => false, -})) - -import { - AuthError, - ConfigError, - FileSystemError, - InputError, - NetworkError, - RateLimitError, -} from '../../../../src/util/error/errors.mts' -import { - formatErrorForDisplay, - formatErrorForJson, - formatErrorForTerminal, -} from '../../../../src/util/error/display.mts' - -describe('error/display', () => { - describe('formatErrorForDisplay', () => { - it('formats RateLimitError', () => { - const error = new RateLimitError('Too many requests', 60) - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('API rate limit exceeded') - expect(result.message).toContain('Too many requests') - expect(result.message).toContain('retry after 60s') - }) - - it('formats RateLimitError without retry time', () => { - const error = new RateLimitError('Too many requests') - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('API rate limit exceeded') - expect(result.message).not.toContain('retry after') - }) - - it('formats AuthError', () => { - const error = new AuthError('Invalid token') - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('Authentication error') - expect(result.message).toBe('Invalid token') - }) - - it('formats NetworkError', () => { - const error = new NetworkError('Connection failed', 500) - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('Network error') - expect(result.message).toContain('Connection failed') - expect(result.message).toContain('HTTP 500') - }) - - it('formats NetworkError without status code', () => { - const error = new NetworkError('Connection failed') - - const result = formatErrorForDisplay(error) - - expect(result.message).not.toContain('HTTP') - }) - - it('formats FileSystemError', () => { - const error = new FileSystemError('Permission denied', '/etc/config') - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('File system error') - expect(result.message).toContain('Permission denied') - expect(result.message).toContain('/etc/config') - }) - - it('formats FileSystemError without path', () => { - const error = new FileSystemError('Disk full') - - const result = formatErrorForDisplay(error) - - expect(result.message).toBe('Disk full') - }) - - it('formats ConfigError', () => { - const error = new ConfigError('Invalid value', 'apiToken') - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('Configuration error') - expect(result.message).toContain('Invalid value') - expect(result.message).toContain('key: apiToken') - }) - - it('formats ConfigError without config key', () => { - const error = new ConfigError('Config file not found') - - const result = formatErrorForDisplay(error) - - expect(result.message).not.toContain('key:') - }) - - it('formats InputError', () => { - const error = new InputError('Invalid input') - error.body = 'Expected a number' - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('Invalid input') - expect(result.message).toBe('Invalid input') - expect(result.body).toBe('Expected a number') - }) - - it('formats generic Error', () => { - const error = new Error('Something went wrong') - - const result = formatErrorForDisplay(error) - - expect(result.title).toBe('Unexpected error') - expect(result.message).toBe('Something went wrong') - }) - - it('preserves Error.cause chain in message without debug mode', () => { - const inner = new Error('root DNS failure') - const middle = new Error('network call failed', { cause: inner }) - const outer = new Error('API request failed', { cause: middle }) - - const result = formatErrorForDisplay(outer) - - expect(result.message).toContain('API request failed') - expect(result.message).toContain('network call failed') - expect(result.message).toContain('root DNS failure') - }) - - it('terminates on cyclic cause chains', () => { - const a = new Error('a') - const b = new Error('b') - ;(a as Error & { cause?: unknown | undefined }).cause = b - ;(b as Error & { cause?: unknown | undefined }).cause = a - - const result = formatErrorForDisplay(a) - - expect(result.message).toContain('a') - expect(result.message).toContain('b') - expect(result.message).toContain('...') - }) - - it('uses custom title when provided', () => { - const error = new Error('Something went wrong') - - const result = formatErrorForDisplay(error, { title: 'Custom Title' }) - - expect(result.title).toBe('Custom Title') - }) - - it('formats string error', () => { - const result = formatErrorForDisplay('Something went wrong') - - expect(result.title).toBe('Error') - expect(result.message).toBe('Something went wrong') - }) - - it('formats unknown error', () => { - const result = formatErrorForDisplay(42) - - expect(result.title).toBe('Unexpected error') - expect(result.message).toBe('An unknown error occurred') - }) - - it('adds cause from options', () => { - const result = formatErrorForDisplay('Error', { cause: 'Due to X' }) - - expect(result.body).toBe('Due to X') - }) - - it('includes stack trace when showStack is true', () => { - const error = new Error('Test error') - - const result = formatErrorForDisplay(error, { showStack: true }) - - expect(result.body).toBeDefined() - expect(result.body).toContain('at ') - }) - - it('formats error cause chain when showStack is true', () => { - const rootCause = new Error('Root cause') - const middleCause = new Error('Middle cause', { cause: rootCause }) - const error = new Error('Top level error', { cause: middleCause }) - - const result = formatErrorForDisplay(error, { showStack: true }) - - expect(result.body).toBeDefined() - expect(result.body).toContain('Caused by') - expect(result.body).toContain('Middle cause') - }) - - it('handles non-Error cause', () => { - const error = new Error('Test error', { cause: 'String cause' }) - - const result = formatErrorForDisplay(error, { showStack: true }) - - expect(result.body).toBeDefined() - expect(result.body).toContain('String cause') - }) - - it('limits cause chain depth to 5', () => { - // Create a chain of 7 causes. - let error: Error = new Error('Cause 1') - for (let i = 2; i <= 7; i++) { - error = new Error(`Cause ${i}`, { cause: error }) - } - - const result = formatErrorForDisplay(error, { showStack: true }) - - // Should show up to 5 causes. - expect(result.body).toBeDefined() - expect(result.body).toContain('Caused by [1]') - expect(result.body).toContain('Caused by [5]') - // Cause 6 should not be shown. - expect(result.body).not.toContain('Caused by [6]') - }) - - it('includes verbose body for unknown error types', () => { - const result = formatErrorForDisplay(123, { verbose: true }) - - expect(result.title).toBe('Unexpected error') - expect(result.body).toBe('123') - }) - }) - - describe('formatErrorForTerminal', () => { - it('includes title and message', () => { - const error = new Error('Something went wrong') - - const result = formatErrorForTerminal(error) - - expect(result).toContain('Unexpected error') - expect(result).toContain('Something went wrong') - }) - - it('includes recovery suggestions for AuthError', () => { - const error = new AuthError('Token expired') - - const result = formatErrorForTerminal(error) - - expect(result).toContain('Suggested actions') - }) - - it('shows stack trace hint when body exists but not verbose', () => { - const error = new Error('Test error') - error.stack = 'Error: Test error\n at test.js:1:1' - - const result = formatErrorForTerminal(error, { showStack: true }) - - // Should suggest running with verbose flag. - expect(result).toContain('DEBUG=1') - }) - - it('shows full stack trace when verbose is true', () => { - const error = new Error('Test error') - error.stack = 'Error: Test error\n at test.js:1:1' - - const result = formatErrorForTerminal(error, { - showStack: true, - verbose: true, - }) - - // Should show actual stack trace. - expect(result).toContain('Stack trace') - }) - }) - - describe('formatErrorForJson', () => { - it('returns CResult error format', () => { - const error = new Error('Something went wrong') - - const result = formatErrorForJson(error) - - expect(result.ok).toBe(false) - expect(result.message).toBeDefined() - expect(result.cause).toBeDefined() - }) - - it('includes recovery suggestions for RateLimitError', () => { - const error = new RateLimitError('Too many requests') - - const result = formatErrorForJson(error) - - expect(result.recovery).toBeDefined() - expect(result.recovery!.length).toBeGreaterThan(0) - }) - - it('strips ANSI from output', () => { - const error = new AuthError('Test error') - - const result = formatErrorForJson(error) - - // Should not contain ANSI escape codes. - expect(result.message).not.toMatch(/\x1b\[/) - }) - }) - -}) diff --git a/packages/cli/test/unit/util/error/errors.test.mts b/packages/cli/test/unit/util/error/errors.test.mts deleted file mode 100644 index b676fced3..000000000 --- a/packages/cli/test/unit/util/error/errors.test.mts +++ /dev/null @@ -1,676 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for custom error classes. - * - * Purpose: Tests custom error classes (InputError, AuthError, etc.). Validates - * error construction and properties. - * - * Test Coverage: - InputError construction - AuthError construction - Error - * message formatting - Error codes - Stack trace preservation. - * - * Testing Approach: Tests custom error class inheritance and behavior. - * - * Related Files: - util/error/errors.mts (implementation) - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { describe, expect, it } from 'vitest' - -import { UNKNOWN_ERROR } from '@socketsecurity/lib-stable/constants/sentinels' - -import { - AuthError, - ConfigError, - FileSystemError, - InputError, - NetworkError, - RateLimitError, - TimeoutError, - buildErrorCause, - formatErrorWithDetail, - getErrorCause, - getErrorMessage, - getErrorMessageOr, - getNetworkErrorCode, - getNetworkErrorDiagnostics, - getRecoverySuggestions, - hasRecoverySuggestions, - isErrnoException, -} from '../../../../src/util/error/errors.mts' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -describe('Error Classes', () => { - describe('AuthError', () => { - it('should create an AuthError instance', () => { - const error = new AuthError('Authentication failed') - expect(error).toBeInstanceOf(AuthError) - expect(error).toBeInstanceOf(Error) - expect(error.message).toBe('Authentication failed') - expect(error.name).toBe('AuthError') - }) - - it('should have default recovery suggestions', () => { - const error = new AuthError('Auth failed') - expect(error.recovery).toHaveLength(3) - expect(error.recovery[0]).toContain('socket login') - }) - - it('should accept custom recovery suggestions', () => { - const recovery = ['Custom recovery'] - const error = new AuthError('Auth failed', recovery) - expect(error.recovery).toEqual(recovery) - }) - }) - - describe('NetworkError', () => { - it('should create a NetworkError with status code', () => { - const error = new NetworkError('Connection failed', 503) - expect(error).toBeInstanceOf(NetworkError) - expect(error.name).toBe('NetworkError') - expect(error.message).toBe('Connection failed') - expect(error.statusCode).toBe(503) - }) - - it('should have default recovery suggestions', () => { - const error = new NetworkError('Timeout') - expect(error.recovery).toHaveLength(3) - expect(error.recovery[0]).toContain('internet connection') - }) - }) - - describe('RateLimitError', () => { - it('should create a RateLimitError with retry after', () => { - const error = new RateLimitError('Too many requests', 60) - expect(error).toBeInstanceOf(RateLimitError) - expect(error.name).toBe('RateLimitError') - expect(error.retryAfter).toBe(60) - expect(error.recovery[0]).toContain('60 seconds') - }) - - it('should handle missing retry after', () => { - const error = new RateLimitError('Quota exceeded') - expect(error.retryAfter).toBeUndefined() - expect(error.recovery[0]).toContain('few minutes') - }) - }) - - describe('FileSystemError', () => { - it('should create FileSystemError with ENOENT code', () => { - const error = new FileSystemError('File not found', '/path', 'ENOENT') - expect(error).toBeInstanceOf(FileSystemError) - expect(error.name).toBe('FileSystemError') - expect(error.path).toBe('/path') - expect(error.code).toBe('ENOENT') - expect(error.recovery[0]).toContain('exists') - }) - - it('should provide EACCES-specific recovery', () => { - const error = new FileSystemError('Permission denied', '/etc', 'EACCES') - expect(error.recovery[0]).toContain('permissions') - }) - - it('should provide ENOSPC-specific recovery', () => { - const error = new FileSystemError('Disk full', '/tmp', 'ENOSPC') - expect(error.recovery[0]).toContain('disk space') - }) - - it('shares EACCES recovery with EPERM code', () => { - const error = new FileSystemError( - 'Operation not permitted', - '/etc', - 'EPERM', - ) - expect(error.recovery[0]).toContain('permissions') - }) - - it('falls back to generic recovery for unknown error codes', () => { - const error = new FileSystemError('Unknown error', '/tmp', 'EWHATEVER') - expect(error.recovery[0]).toContain('file system permissions') - }) - - it('falls back to generic recovery when code is undefined', () => { - const error = new FileSystemError('Unknown error') - expect(error.recovery[0]).toContain('file system permissions') - }) - - it('uses provided custom recovery instead of defaults', () => { - const error = new FileSystemError('x', '/p', 'ENOENT', ['custom rec']) - expect(error.recovery).toEqual(['custom rec']) - }) - }) - - describe('calculateStringSimilarity', () => { - it('returns 1 for identical strings (early-return)', async () => { - const { calculateStringSimilarity } = - await import('../../../../src/util/error/errors.mts') - expect(calculateStringSimilarity('exact', 'exact')).toBe(1) - }) - - it('returns 0 when both strings have only short words (no overlap signal)', async () => { - const { calculateStringSimilarity } = - await import('../../../../src/util/error/errors.mts') - expect(calculateStringSimilarity('a b c', 'd e f')).toBe(0) - }) - - it('returns a fractional value for partial overlap', async () => { - const { calculateStringSimilarity } = - await import('../../../../src/util/error/errors.mts') - const score = calculateStringSimilarity( - 'fetch request failed', - 'fetch request succeeded', - ) - expect(score).toBeGreaterThan(0) - expect(score).toBeLessThan(1) - }) - - it('returns close to 1 for nearly-identical phrasing', async () => { - const { calculateStringSimilarity } = - await import('../../../../src/util/error/errors.mts') - const score = calculateStringSimilarity( - 'invalid json format in request body', - 'request body has invalid json format', - ) - expect(score).toBeGreaterThan(0.7) - }) - }) - - describe('captureException / captureExceptionSync', () => { - it('captureExceptionSync returns "" when Sentry is not configured', async () => { - const { captureExceptionSync } = - await import('../../../../src/util/error/errors.mts') - const result = captureExceptionSync(new Error('boom')) - expect(result).toBe('') - }) - - it('captureException returns "" when Sentry is not configured', async () => { - const { captureException } = - await import('../../../../src/util/error/errors.mts') - const result = await captureException(new Error('boom')) - expect(result).toBe('') - }) - }) - - describe('ConfigError', () => { - it('should create ConfigError with config key', () => { - const error = new ConfigError('Invalid value', 'apiToken') - expect(error).toBeInstanceOf(ConfigError) - expect(error.name).toBe('ConfigError') - expect(error.configKey).toBe('apiToken') - expect(error.recovery[0]).toContain('config list') - }) - }) - - describe('InputError', () => { - it('should create an InputError with message only', () => { - const error = new InputError('Invalid input') - expect(error).toBeInstanceOf(InputError) - expect(error).toBeInstanceOf(Error) - expect(error.message).toBe('Invalid input') - expect(error.body).toBeUndefined() - }) - - it('should create an InputError with message and body', () => { - const error = new InputError('Invalid JSON', '{invalid}') - expect(error.message).toBe('Invalid JSON') - expect(error.body).toBe('{invalid}') - }) - }) - - describe('TimeoutError', () => { - it('should create TimeoutError with timeout and elapsed times', () => { - const error = new TimeoutError('Request timed out', 30_000, 35_000) - expect(error).toBeInstanceOf(TimeoutError) - expect(error.name).toBe('TimeoutError') - expect(error.message).toBe('Request timed out') - expect(error.timeoutMs).toBe(30_000) - expect(error.elapsedMs).toBe(35_000) - }) - - it('should have default recovery suggestions', () => { - const error = new TimeoutError('Timeout') - expect(error.recovery).toHaveLength(3) - expect(error.recovery[0]).toContain('internet connection') - }) - - it('should accept custom recovery suggestions', () => { - const recovery = ['Retry with exponential backoff'] - const error = new TimeoutError('Timeout', 10_000, 15_000, recovery) - expect(error.recovery).toEqual(recovery) - }) - - it('should handle missing timeout values', () => { - const error = new TimeoutError('Operation timed out') - expect(error.timeoutMs).toBeUndefined() - expect(error.elapsedMs).toBeUndefined() - }) - }) -}) - -describe('Error Narrowing', () => { - it('should properly detect node errors', () => { - try { - readFileSync(path.join(__dirname, 'enoent')) - } catch (e) { - expect(isErrnoException(e)).toBe(true) - } - }) - it('should properly only detect node errors', () => { - expect(isErrnoException(new Error())).toBe(false) - expect(isErrnoException({ ...new Error() })).toBe(false) - }) - it('should return false for non-error values', () => { - expect(isErrnoException('string')).toBe(false) - expect(isErrnoException(undefined)).toBe(false) - expect(isErrnoException(undefined)).toBe(false) - expect(isErrnoException(123)).toBe(false) - expect(isErrnoException({})).toBe(false) - }) -}) - -describe('getErrorMessage', () => { - it('should extract message from Error object', () => { - const error = new Error('Test error message') - expect(getErrorMessage(error)).toBe('Test error message') - }) - - it('should extract message from custom error types', () => { - const authError = new AuthError('Auth failed') - const inputError = new InputError('Bad input') - expect(getErrorMessage(authError)).toBe('Auth failed') - expect(getErrorMessage(inputError)).toBe('Bad input') - }) - - it('should return undefined for non-error values', () => { - expect(getErrorMessage(undefined)).toBeUndefined() - expect(getErrorMessage(undefined)).toBeUndefined() - expect(getErrorMessage('string')).toBeUndefined() - expect(getErrorMessage(123)).toBeUndefined() - expect(getErrorMessage({})).toBeUndefined() - }) - - it('should handle errors with empty messages', () => { - const error = new Error('') - expect(getErrorMessage(error)).toBe('') - }) -}) - -describe('getErrorMessageOr', () => { - it('should extract message from Error object', () => { - const error = new Error('Test error') - expect(getErrorMessageOr(error, 'fallback')).toBe('Test error') - }) - - it('should return fallback for non-error values', () => { - expect(getErrorMessageOr(undefined, 'fallback')).toBe('fallback') - expect(getErrorMessageOr(undefined, 'fallback')).toBe('fallback') - expect(getErrorMessageOr('string', 'fallback')).toBe('fallback') - expect(getErrorMessageOr(123, 'fallback')).toBe('fallback') - }) - - it('should return fallback for error with empty message', () => { - const error = new Error('') - expect(getErrorMessageOr(error, 'fallback')).toBe('fallback') - }) - - it('should use different fallback messages', () => { - expect(getErrorMessageOr(undefined, 'Custom fallback 1')).toBe( - 'Custom fallback 1', - ) - expect(getErrorMessageOr(undefined, 'Custom fallback 2')).toBe( - 'Custom fallback 2', - ) - }) -}) - -describe('getErrorCause', () => { - it('should extract error message as cause', () => { - const error = new Error('Something went wrong') - expect(getErrorCause(error)).toBe('Something went wrong') - }) - - it('should return UNKNOWN_ERROR for non-error values', () => { - expect(getErrorCause(undefined)).toBe(UNKNOWN_ERROR) - expect(getErrorCause(undefined)).toBe(UNKNOWN_ERROR) - expect(getErrorCause('string')).toBe(UNKNOWN_ERROR) - expect(getErrorCause(123)).toBe(UNKNOWN_ERROR) - }) - - it('should return UNKNOWN_ERROR for error with empty message', () => { - const error = new Error('') - expect(getErrorCause(error)).toBe(UNKNOWN_ERROR) - }) -}) - -describe('formatErrorWithDetail', () => { - it('should format message with error detail', () => { - const error = new Error('ENOENT: no such file or directory') - expect(formatErrorWithDetail('Failed to delete file', error)).toBe( - 'Failed to delete file: ENOENT: no such file or directory', - ) - }) - - it('should return base message when error has no message', () => { - const error = new Error('') - expect(formatErrorWithDetail('Operation failed', error)).toBe( - 'Operation failed', - ) - }) - - it('should return base message for non-error values', () => { - expect(formatErrorWithDetail('Task failed', undefined)).toBe('Task failed') - expect(formatErrorWithDetail('Task failed', undefined)).toBe('Task failed') - expect(formatErrorWithDetail('Task failed', 'string')).toBe('Task failed') - }) - - it('should handle different base messages and errors', () => { - const error1 = new Error('Network timeout') - const error2 = new AuthError('Invalid token') - const error3 = new InputError('Missing parameter', 'body') - - expect(formatErrorWithDetail('Connection failed', error1)).toBe( - 'Connection failed: Network timeout', - ) - expect(formatErrorWithDetail('Authentication failed', error2)).toBe( - 'Authentication failed: Invalid token', - ) - expect(formatErrorWithDetail('Validation failed', error3)).toBe( - 'Validation failed: Missing parameter', - ) - }) - - it('should handle base message with special characters', () => { - const error = new Error('File not found') - expect(formatErrorWithDetail('Failed to process "test.txt"', error)).toBe( - 'Failed to process "test.txt": File not found', - ) - }) -}) - -describe('Recovery Utilities', () => { - describe('hasRecoverySuggestions', () => { - it('should return true for errors with recovery', () => { - const error = new AuthError('Test') - expect(hasRecoverySuggestions(error)).toBe(true) - }) - - it('should return false for standard errors', () => { - const error = new Error('Standard error') - expect(hasRecoverySuggestions(error)).toBe(false) - }) - - it('should return false for non-errors', () => { - expect(hasRecoverySuggestions('string')).toBe(false) - expect(hasRecoverySuggestions(undefined)).toBe(false) - expect(hasRecoverySuggestions(undefined)).toBe(false) - expect(hasRecoverySuggestions(123)).toBe(false) - }) - }) - - describe('getRecoverySuggestions', () => { - it('should extract recovery from NetworkError', () => { - const error = new NetworkError('Connection failed') - const suggestions = getRecoverySuggestions(error) - expect(suggestions.length).toBeGreaterThan(0) - expect(suggestions[0]).toContain('internet connection') - }) - - it('should extract recovery from RateLimitError', () => { - const error = new RateLimitError('Too many requests', 30) - const suggestions = getRecoverySuggestions(error) - expect(suggestions.length).toBeGreaterThan(0) - expect(suggestions[0]).toContain('30 seconds') - }) - - it('should extract recovery from FileSystemError', () => { - const error = new FileSystemError('No access', '/etc', 'EACCES') - const suggestions = getRecoverySuggestions(error) - expect(suggestions[0]).toContain('permissions') - }) - - it('should extract recovery from ConfigError', () => { - const error = new ConfigError('Bad config') - const suggestions = getRecoverySuggestions(error) - expect(suggestions[0]).toContain('config list') - }) - - it('should return empty array for standard errors', () => { - const error = new Error('Standard error') - const suggestions = getRecoverySuggestions(error) - expect(suggestions).toEqual([]) - }) - - it('should return empty array for non-errors', () => { - expect(getRecoverySuggestions('string')).toEqual([]) - expect(getRecoverySuggestions(undefined)).toEqual([]) - expect(getRecoverySuggestions(undefined)).toEqual([]) - }) - }) -}) - -describe('Network Error Diagnostics', () => { - describe('getNetworkErrorCode', () => { - it('should extract error code from ErrnoException', () => { - try { - readFileSync(path.join(__dirname, 'nonexistent')) - } catch (e) { - const code = getNetworkErrorCode(e) - expect(code).toBe('ENOENT') - } - }) - - it('should return undefined for errors without code', () => { - const error = new Error('Generic error') - expect(getNetworkErrorCode(error)).toBeUndefined() - }) - - it('should return undefined for non-errors', () => { - expect(getNetworkErrorCode('string')).toBeUndefined() - expect(getNetworkErrorCode(undefined)).toBeUndefined() - expect(getNetworkErrorCode(undefined)).toBeUndefined() - }) - }) - - describe('getNetworkErrorDiagnostics', () => { - it('should provide timeout diagnostics for ETIMEDOUT', () => { - const error = Object.assign(new Error('Connection timed out'), { - code: 'ETIMEDOUT', - }) - const diagnostics = getNetworkErrorDiagnostics(error, 5_000) - expect(diagnostics).toContain('timeout') - expect(diagnostics).toContain('5s') - expect(diagnostics).toContain('💡 Try:') - expect(diagnostics).toContain('internet connection') - }) - - it('should provide connection refused diagnostics for ECONNREFUSED', () => { - const error = Object.assign(new Error('Connection refused'), { - code: 'ECONNREFUSED', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('Connection refused') - expect(diagnostics).toContain('proxy') - expect(diagnostics).toContain('firewall') - }) - - it('should provide DNS diagnostics for ENOTFOUND', () => { - const error = Object.assign(new Error('getaddrinfo ENOTFOUND'), { - code: 'ENOTFOUND', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('DNS') - expect(diagnostics).toContain('8.8.8.8') - expect(diagnostics).toContain('1.1.1.1') - }) - - it('should provide certificate diagnostics for cert errors', () => { - const error = Object.assign(new Error('Certificate has expired'), { - code: 'CERT_HAS_EXPIRED', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('certificate') - expect(diagnostics).toContain('date and time') - }) - - it('should provide network unreachable diagnostics', () => { - const error = Object.assign(new Error('Network is unreachable'), { - code: 'ENETUNREACH', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('unreachable') - expect(diagnostics).toContain('internet connection') - }) - - it('should provide generic diagnostics for unknown errors', () => { - const error = new Error('Unknown network issue') - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('Network error') - expect(diagnostics).toContain('💡 Try:') - expect(diagnostics).toContain('internet connection') - }) - - it('should detect timeout based on duration', () => { - const error = new Error('Request failed') - const diagnostics = getNetworkErrorDiagnostics(error, 35_000) - expect(diagnostics).toContain('timeout') - expect(diagnostics).toContain('35s') - }) - - it('should provide diagnostics for ECONNRESET', () => { - const error = Object.assign(new Error('Connection reset'), { - code: 'ECONNRESET', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('timeout') - }) - - it('should provide diagnostics for ESOCKETTIMEDOUT', () => { - const error = Object.assign(new Error('Socket timeout'), { - code: 'ESOCKETTIMEDOUT', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('timeout') - }) - - it('should provide diagnostics for EAI_AGAIN', () => { - const error = Object.assign(new Error('DNS lookup failed'), { - code: 'EAI_AGAIN', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('DNS') - }) - - it('should provide diagnostics for certificate issues', () => { - const error = Object.assign(new Error('Unable to verify'), { - code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('certificate') - }) - - it('should provide diagnostics for self-signed certs', () => { - const error = Object.assign(new Error('Self-signed cert'), { - code: 'SELF_SIGNED_CERT_IN_CHAIN', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('certificate') - }) - - it('should provide diagnostics for EHOSTUNREACH', () => { - const error = Object.assign(new Error('Host unreachable'), { - code: 'EHOSTUNREACH', - }) - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('unreachable') - }) - - it('should detect certificate keyword in message', () => { - const error = new Error('certificate validation failed') - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('certificate') - }) - - it('should detect getaddrinfo in message', () => { - const error = new Error('getaddrinfo failed') - const diagnostics = getNetworkErrorDiagnostics(error) - expect(diagnostics).toContain('DNS') - }) - }) -}) - -describe('buildErrorCause', () => { - it('should return message with reason appended', async () => { - const result = await buildErrorCause( - 400, - 'Bad request', - 'Invalid parameter', - ) - expect(result).toBe('Bad request (reason: Invalid parameter)') - }) - - it('should return message only when reason matches message', async () => { - const result = await buildErrorCause(400, 'Invalid input', 'Invalid input') - expect(result).toBe('Invalid input') - }) - - it('should return message only when no reason provided', async () => { - const result = await buildErrorCause(400, 'Bad request', '') - expect(result).toBe('Bad request') - }) - - it('should skip redundant reasons with high similarity', async () => { - const result = await buildErrorCause( - 400, - 'Invalid JSON format in request body', - 'Request body has invalid JSON format', - ) - // Should not add reason because they are too similar. - expect(result).not.toContain('reason') - }) - - it('should include reason when messages are sufficiently different', async () => { - const result = await buildErrorCause( - 400, - 'Request failed', - 'Missing required field: name', - ) - expect(result).toContain('reason') - expect(result).toContain('Missing required field') - }) - - it('should handle 429 rate limit errors specially', async () => { - const result = await buildErrorCause(429, 'Rate limited', 'Quota exceeded') - // Should include quota message. - expect(result).toContain('Quota exceeded') - }) - - it('should handle 429 with message but no reason', async () => { - const result = await buildErrorCause( - 429, - 'Too many requests', - 'No error message returned', - ) - expect(result).toContain('Too many requests') - }) - - it('should handle 429 with no useful error info', async () => { - const result = await buildErrorCause( - 429, - 'No error message returned', - 'No error message returned', - ) - // Should return quota message. - expect(result.length).toBeGreaterThan(0) - }) - - it('appends reason when both strings have only short words (no overlap signal)', async () => { - // Forces calculateStringSimilarity to take the empty word-sets branch - // (all tokens <= 2 chars) — similarity returns 0 → reason is appended. - const result = await buildErrorCause(400, 'a b c', 'd e f') - expect(result).toContain('reason: d e f') - }) -}) diff --git a/packages/cli/test/unit/util/error/fail-msg-with-badge.test.mts b/packages/cli/test/unit/util/error/fail-msg-with-badge.test.mts deleted file mode 100644 index 731c7a40d..000000000 --- a/packages/cli/test/unit/util/error/fail-msg-with-badge.test.mts +++ /dev/null @@ -1,243 +0,0 @@ - -/** - * Unit tests for error message with badge. - * - * Purpose: Tests error message formatting with severity badges. Validates - * colored badge display with error messages. - * - * Test Coverage: - Severity badge rendering - Color coding (red for error, - * yellow for warning) - Message formatting - Multi-line error support - ANSI - * escape sequences. - * - * Testing Approach: Tests formatted error output with visual indicators. - * - * Related Files: - util/error/fail-msg-with-badge.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock yoctocolors-cjs BEFORE importing the module under test -vi.mock('yoctocolors-cjs', () => ({ - default: { - bgRedBright: (str: string) => `[BG_RED_BRIGHT]${str}[/BG_RED_BRIGHT]`, - bold: (str: string) => `[BOLD]${str}[/BOLD]`, - white: (str: string) => `[WHITE]${str}[/WHITE]`, - }, -})) - -import { failMsgWithBadge } from '../../../../src/util/error/fail-msg-with-badge.mts' - -describe('failMsgWithBadge', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('with message', () => { - it('formats badge with message', () => { - const result = failMsgWithBadge('ERROR', 'Something went wrong') - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD]Something went wrong[/BOLD]', - ) - }) - - it('handles long badge text', () => { - const result = failMsgWithBadge( - 'CATASTROPHIC_SYSTEM_FAILURE', - 'Error message', - ) - expect(result).toContain('CATASTROPHIC_SYSTEM_FAILURE: ') - expect(result).toContain('[BOLD]Error message[/BOLD]') - }) - - it('handles special characters in badge', () => { - const result = failMsgWithBadge('ERROR-123', 'Test message') - expect(result).toContain('[WHITE] ERROR-123: [/WHITE]') - expect(result).toContain('[BOLD]Test message[/BOLD]') - }) - - it('handles Unicode emoji in badge', () => { - const result = failMsgWithBadge('⚠️ WARNING', 'Be careful') - expect(result).toContain('[WHITE] ⚠️ WARNING: [/WHITE]') - expect(result).toContain('[BOLD]Be careful[/BOLD]') - }) - - it('handles multi-line messages', () => { - const message = 'Line 1\nLine 2\nLine 3' - const result = failMsgWithBadge('ERROR', message) - expect(result).toContain('[BOLD]Line 1\nLine 2\nLine 3[/BOLD]') - }) - - it('handles special characters in message', () => { - const result = failMsgWithBadge('ERROR', 'Failed: ❌ Invalid input!') - expect(result).toContain('[BOLD]Failed: ❌ Invalid input![/BOLD]') - }) - - it('handles very long messages', () => { - const longMessage = 'a'.repeat(1000) - const result = failMsgWithBadge('ERROR', longMessage) - expect(result).toContain(`[BOLD]${longMessage}[/BOLD]`) - }) - - it('handles message with only spaces', () => { - const result = failMsgWithBadge('ERROR', ' ') - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD] [/BOLD]', - ) - }) - - it('handles tabs and special whitespace in message', () => { - const result = failMsgWithBadge('ERROR', '\t\tTabbed message') - expect(result).toContain('[BOLD]\t\tTabbed message[/BOLD]') - }) - - it('handles message with ANSI escape sequences', () => { - const result = failMsgWithBadge('ERROR', '\x1b[31mRed text\x1b[0m') - expect(result).toContain('[BOLD]\x1b[31mRed text\x1b[0m[/BOLD]') - }) - }) - - describe('without message', () => { - it('formats badge without message', () => { - const result = failMsgWithBadge('FAIL', undefined) - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] FAIL[/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - - it('handles empty badge without message', () => { - const result = failMsgWithBadge('', undefined) - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] [/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - - it('handles badge with only spaces without message', () => { - const result = failMsgWithBadge(' ', undefined) - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] [/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - }) - - describe('edge cases with empty string message', () => { - it('treats empty string message as no message', () => { - const result = failMsgWithBadge('WARN', '') - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] WARN[/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - - it('handles empty badge with empty message', () => { - const result = failMsgWithBadge('', '') - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] [/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - }) - - describe('null and type coercion', () => { - it('handles null as message', () => { - // @ts-expect-error Testing runtime behavior with null. - const result = failMsgWithBadge('ERROR', undefined) - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR[/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - - it('handles number 0 as string message', () => { - const result = failMsgWithBadge('ERROR', '0') - expect(result).toContain('[BOLD]0[/BOLD]') - }) - - it('handles string "false" as message', () => { - const result = failMsgWithBadge('ERROR', 'false') - expect(result).toContain('[BOLD]false[/BOLD]') - }) - - it('handles boolean false as message (type coercion)', () => { - // @ts-expect-error Testing runtime behavior. - const result = failMsgWithBadge('ERROR', false) - // false is falsy, should behave like undefined. - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR[/WHITE][/BOLD][/BG_RED_BRIGHT]', - ) - }) - - it('handles boolean true as message (type coercion)', () => { - // @ts-expect-error Testing runtime behavior. - const result = failMsgWithBadge('ERROR', true) - // true is truthy, should add colon and format the message. - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD]true[/BOLD]', - ) - }) - - it('handles number as message (type coercion)', () => { - // @ts-expect-error Testing runtime behavior. - const result = failMsgWithBadge('ERROR', 42) - // Number is truthy, should add colon and format the message. - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD]42[/BOLD]', - ) - }) - - it('handles object as message (type coercion)', () => { - // @ts-expect-error Testing runtime behavior. - const result = failMsgWithBadge('ERROR', { error: 'details' }) - // Object is truthy, should add colon and format the message. - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD][object Object][/BOLD]', - ) - }) - - it('handles array as message (type coercion)', () => { - // @ts-expect-error Testing runtime behavior. - const result = failMsgWithBadge('ERROR', ['item1', 'item2']) - // Array is truthy, should add colon and format the message. - expect(result).toBe( - '[BG_RED_BRIGHT][BOLD][WHITE] ERROR: [/WHITE][/BOLD][/BG_RED_BRIGHT] [BOLD]item1,item2[/BOLD]', - ) - }) - }) - - describe('formatting consistency', () => { - it('consistently formats the same inputs', () => { - const result1 = failMsgWithBadge('ERROR', 'Message') - const result2 = failMsgWithBadge('ERROR', 'Message') - expect(result1).toBe(result2) - }) - - it('correctly adds colon only when message is truthy', () => { - const withMessage = failMsgWithBadge('ERROR', 'msg') - const withoutMessage = failMsgWithBadge('ERROR', undefined) - const withEmptyMessage = failMsgWithBadge('ERROR', '') - - expect(withMessage).toContain('ERROR: ') - expect(withoutMessage).toContain(' ERROR[/WHITE]') - expect(withoutMessage).not.toContain(': ') - expect(withEmptyMessage).toContain(' ERROR[/WHITE]') - expect(withEmptyMessage).not.toContain(': ') - }) - - it('always adds space before badge text', () => { - const result = failMsgWithBadge('TEST', 'msg') - expect(result).toContain('[WHITE] TEST: [/WHITE]') - }) - - it('always adds space before message text when present', () => { - const result = failMsgWithBadge('TEST', 'msg') - expect(result).toMatch(/\] \[BOLD\]msg/) - }) - - it('preserves original badge and message values', () => { - const badge = 'ORIGINAL' - const message = 'Original message' - - failMsgWithBadge(badge, message) - - // Ensure the function doesn't mutate the inputs. - expect(badge).toBe('ORIGINAL') - expect(message).toBe('Original message') - }) - }) -}) diff --git a/packages/cli/test/unit/util/fs/find-up.test.mts b/packages/cli/test/unit/util/fs/find-up.test.mts deleted file mode 100644 index 71b83caeb..000000000 --- a/packages/cli/test/unit/util/fs/find-up.test.mts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Unit tests for find-up utility. - * - * Purpose: Tests the findUp function for searching files up the directory tree. - * - * Test Coverage: - Finding files by single name - Finding files by multiple - * names - Finding directories - Abort signal handling - Custom working - * directory. - * - * Related Files: - src/util/fs/find-up.mts (implementation) - */ - -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { afterAll, beforeAll, describe, expect, it } from 'vitest' - -import { findUp } from '../../../../src/util/fs/find-up.mts' - -describe('find-up', () => { - // Create a temporary test directory structure. - const testRoot = path.join(process.cwd(), '.test-find-up-temp') - const level1 = path.join(testRoot, 'level1') - const level2 = path.join(level1, 'level2') - const level3 = path.join(level2, 'level3') - - beforeAll(() => { - // Create directory structure. - mkdirSync(level3, { recursive: true }) - - // Create test files at different levels. - writeFileSync(path.join(testRoot, 'root.config'), 'root') - writeFileSync(path.join(testRoot, 'package.json'), '{}') - writeFileSync(path.join(level1, 'level1.config'), 'level1') - writeFileSync(path.join(level2, 'level2.config'), 'level2') - - // Create a directory to test onlyDirectories. - mkdirSync(path.join(level1, 'target-dir'), { recursive: true }) - }) - - afterAll(() => { - // Clean up test directory. - if (existsSync(testRoot)) { - rmSync(testRoot, { recursive: true, force: true }) - } - }) - - describe('findUp', () => { - describe('basic file finding', () => { - it('finds file in current directory', async () => { - const result = await findUp('level2.config', { cwd: level2 }) - - expect(result).toBe(path.join(level2, 'level2.config')) - }) - - it('finds file in parent directory', async () => { - const result = await findUp('level1.config', { cwd: level2 }) - - expect(result).toBe(path.join(level1, 'level1.config')) - }) - - it('finds file in ancestor directory', async () => { - const result = await findUp('root.config', { cwd: level3 }) - - expect(result).toBe(path.join(testRoot, 'root.config')) - }) - - it('returns undefined when file not found', async () => { - const result = await findUp('nonexistent.config', { cwd: level3 }) - - expect(result).toBeUndefined() - }) - }) - - describe('multiple names', () => { - it('finds first matching file from array', async () => { - const result = await findUp(['level1.config', 'level2.config'], { - cwd: level2, - }) - - // Should find level2.config first since we're starting in level2. - expect(result).toBe(path.join(level2, 'level2.config')) - }) - - it('finds second name if first not present', async () => { - const result = await findUp(['nonexistent.config', 'root.config'], { - cwd: level3, - }) - - expect(result).toBe(path.join(testRoot, 'root.config')) - }) - - it('returns undefined when no names match', async () => { - const result = await findUp(['a.config', 'b.config', 'c.config'], { - cwd: level3, - }) - - expect(result).toBeUndefined() - }) - }) - - describe('directory finding', () => { - it('finds directory with onlyDirectories option', async () => { - const result = await findUp('target-dir', { - cwd: level2, - onlyDirectories: true, - }) - - expect(result).toBe(path.join(level1, 'target-dir')) - }) - - it('does not find file when onlyDirectories is true', async () => { - const result = await findUp('level1.config', { - cwd: level2, - onlyDirectories: true, - }) - - expect(result).toBeUndefined() - }) - - it('does not find directory when onlyFiles is true', async () => { - const result = await findUp('target-dir', { - cwd: level2, - onlyFiles: true, - }) - - expect(result).toBeUndefined() - }) - }) - - describe('options', () => { - it('uses provided cwd', async () => { - const result = await findUp('level2.config', { cwd: level2 }) - - expect(result).toBe(path.join(level2, 'level2.config')) - }) - - it('uses process.cwd when cwd not provided', async () => { - // This test depends on the actual cwd, so just verify it doesn't throw. - const result = await findUp('package.json') - - // Should find package.json somewhere up the tree. - if (result) { - expect(result).toContain('package.json') - } - }) - }) - - describe('abort signal', () => { - it('returns undefined when signal is aborted', async () => { - const controller = new AbortController() - controller.abort() - - const result = await findUp('root.config', { - cwd: level3, - signal: controller.signal, - }) - - expect(result).toBeUndefined() - }) - }) - - describe('edge cases', () => { - it('handles single name as string', async () => { - const result = await findUp('package.json', { cwd: level3 }) - - expect(result).toBe(path.join(testRoot, 'package.json')) - }) - - it('handles empty names array', async () => { - const result = await findUp([], { cwd: level3 }) - - expect(result).toBeUndefined() - }) - }) - }) -}) diff --git a/packages/cli/test/unit/util/fs/fs.test.mts b/packages/cli/test/unit/util/fs/fs.test.mts deleted file mode 100644 index 174ebf2c9..000000000 --- a/packages/cli/test/unit/util/fs/fs.test.mts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Unit tests for filesystem utilities. - * - * Purpose: Tests safe filesystem utilities. Validates safeDelete, directory - * operations, and path handling. - * - * Test Coverage: - Safe delete operations (safeDelete/safeDeleteSync) - - * Directory creation - File existence checks - Path sanitization - - * Cross-platform path handling. - * - * Special Notes: Uses safeDelete from @socketsecurity/lib/fs - NEVER - * fs.rm/rmSync. - * - * Testing Approach: Uses temporary directories for safe filesystem testing. - * - * Related Files: - util/fs/fs.mts (implementation) - */ - -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { findUp } from '../../../../src/util/fs/find-up.mts' - -describe('fs utilities', () => { - describe('findUp', () => { - let testDir: string - let nestedDir: string - - beforeEach(async () => { - // Create temporary test directory structure. - testDir = path.join(os.tmpdir(), `socket-test-${Date.now()}`) - nestedDir = path.join(testDir, 'level1', 'level2', 'level3') - - await fs.mkdir(nestedDir, { recursive: true }) - - // Create test files at different levels. - await fs.writeFile(path.join(testDir, 'root.txt'), 'root') - await fs.writeFile(path.join(testDir, 'package.json'), '{}') - await fs.writeFile(path.join(testDir, 'level1', 'middle.txt'), 'middle') - await fs.writeFile( - path.join(testDir, 'level1', 'level2', 'package.json'), - '{}', - ) - - // Create test directory. - await fs.mkdir(path.join(testDir, 'level1', '.git')) - }) - - afterEach(async () => { - // Clean up test directory. - try { - await safeDelete(testDir) - } catch { - // Ignore cleanup errors. - } - }) - - it('finds file in current directory', async () => { - const result = await findUp('package.json', { cwd: testDir }) - expect(result).toBe(path.join(testDir, 'package.json')) - }) - - it('finds file in parent directory', async () => { - const result = await findUp('root.txt', { cwd: nestedDir }) - expect(result).toBe(path.join(testDir, 'root.txt')) - }) - - it('finds nearest file when multiple exist', async () => { - const result = await findUp('package.json', { cwd: nestedDir }) - expect(result).toBe( - path.join(testDir, 'level1', 'level2', 'package.json'), - ) - }) - - it('returns undefined when file not found', async () => { - const result = await findUp('nonexistent.txt', { cwd: nestedDir }) - expect(result).toBeUndefined() - }) - - it('searches for multiple file names', async () => { - const result = await findUp(['nonexistent.txt', 'middle.txt'], { - cwd: nestedDir, - }) - expect(result).toBe(path.join(testDir, 'level1', 'middle.txt')) - }) - - it('finds directory when onlyDirectories is true', async () => { - const result = await findUp('.git', { - cwd: nestedDir, - onlyDirectories: true, - }) - expect(result).toBe(path.join(testDir, 'level1', '.git')) - }) - - it('ignores directories when onlyFiles is true', async () => { - const result = await findUp('.git', { - cwd: nestedDir, - onlyFiles: true, - }) - expect(result).toBeUndefined() - }) - - it('respects abort signal', async () => { - const controller = new AbortController() - controller.abort() - - const result = await findUp('package.json', { - cwd: nestedDir, - signal: controller.signal, - }) - expect(result).toBeUndefined() - }) - - it('searches both files and directories when neither flag is set', async () => { - const fileResult = await findUp('package.json', { - cwd: nestedDir, - onlyFiles: false, - onlyDirectories: false, - }) - expect(fileResult).toBe( - path.join(testDir, 'level1', 'level2', 'package.json'), - ) - - const dirResult = await findUp('.git', { - cwd: nestedDir, - onlyFiles: false, - onlyDirectories: false, - }) - expect(dirResult).toBe(path.join(testDir, 'level1', '.git')) - }) - - it('uses current working directory by default', async () => { - // Use cwd option instead of process.chdir() to avoid global state mutation. - const result = await findUp('package.json', { cwd: testDir }) - // Handle macOS /private symlink. - const expectedPath = path.join(testDir, 'package.json') - expect(result).toBe(expectedPath) - }) - - it('stops at filesystem root', async () => { - const result = await findUp('absolutely-nonexistent-file.xyz', { - cwd: '/', - }) - expect(result).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/fs/glob.test.mts b/packages/cli/test/unit/util/fs/glob.test.mts deleted file mode 100644 index 868f8358f..000000000 --- a/packages/cli/test/unit/util/fs/glob.test.mts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * Unit tests for glob utilities. - * - * Purpose: Tests the glob pattern utilities for file matching. - * - * Test Coverage: - getSupportedFilePatterns function - - * filterBySupportedScanFiles function - createSupportedFilesFilter function - - * isReportSupportedFile function - pathsToGlobPatterns function. - * - * Related Files: - util/fs/glob.mts (implementation) - */ - -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock homePath. -vi.mock('../../../../src/constants/paths.mts', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - homePath: '/Users/testuser', - } -}) - -// Mock isDirSync. -const mockIsDirSync = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/fs/inspect', () => ({ - isDirSync: mockIsDirSync, -})) -vi.mock('@socketsecurity/lib-stable/fs/read-file', () => ({ - safeReadFile: vi.fn(), -})) - -import { - createSupportedFilesFilter, - getSupportedFilePatterns, - isReportSupportedFile, - pathsToGlobPatterns, -} from '../../../../src/util/fs/glob.mts' - -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' - -// Mock supported files data. -const mockSupportedFiles: SocketSdkSuccessResult<'getReportSupportedFiles'>['data'] = - { - npm: { - 'package.json': { pattern: 'package.json' }, - 'package-lock.json': { pattern: 'package-lock.json' }, - }, - python: { - 'requirements.txt': { pattern: 'requirements.txt' }, - 'setup.py': { pattern: 'setup.py' }, - }, - } - -describe('util/fs/glob', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsDirSync.mockReturnValue(false) - }) - - describe('getSupportedFilePatterns', () => { - it('returns glob patterns for all supported files', () => { - const patterns = getSupportedFilePatterns(mockSupportedFiles) - - expect(patterns).toContain('**/package.json') - expect(patterns).toContain('**/package-lock.json') - expect(patterns).toContain('**/requirements.txt') - expect(patterns).toContain('**/setup.py') - }) - - it('handles empty supported files', () => { - const patterns = getSupportedFilePatterns({}) - - expect(patterns).toEqual([]) - }) - - it('handles undefined ecosystem', () => { - const patterns = getSupportedFilePatterns({ - npm: undefined as unknown, - python: { - 'requirements.txt': { pattern: 'requirements.txt' }, - }, - }) - - expect(patterns).toContain('**/requirements.txt') - expect(patterns).toHaveLength(1) - }) - }) - - describe('createSupportedFilesFilter', () => { - it('creates a filter function', () => { - const filter = createSupportedFilesFilter(mockSupportedFiles) - - expect(typeof filter).toBe('function') - }) - - it('filter returns true for supported files', () => { - const filter = createSupportedFilesFilter(mockSupportedFiles) - - expect(filter('/project/package.json')).toBe(true) - expect(filter('/project/requirements.txt')).toBe(true) - }) - - it('filter returns false for unsupported files', () => { - const filter = createSupportedFilesFilter(mockSupportedFiles) - - expect(filter('/project/index.js')).toBe(false) - expect(filter('/project/README.md')).toBe(false) - }) - }) - - describe('isReportSupportedFile', () => { - it('returns true for supported files', () => { - expect( - isReportSupportedFile('/project/package.json', mockSupportedFiles), - ).toBe(true) - expect( - isReportSupportedFile('/project/requirements.txt', mockSupportedFiles), - ).toBe(true) - }) - - it('returns false for unsupported files', () => { - expect( - isReportSupportedFile('/project/index.js', mockSupportedFiles), - ).toBe(false) - expect( - isReportSupportedFile('/project/README.md', mockSupportedFiles), - ).toBe(false) - }) - - it('works with nested paths', () => { - expect( - isReportSupportedFile( - '/project/subdir/nested/package.json', - mockSupportedFiles, - ), - ).toBe(true) - }) - }) - - describe('pathsToGlobPatterns', () => { - it('converts current directory to wildcard pattern', () => { - const result = pathsToGlobPatterns(['.']) - expect(result).toContain('**/*') - }) - - it('converts ./ to wildcard pattern', () => { - const result = pathsToGlobPatterns(['./']) - expect(result).toContain('**/*') - }) - - it('expands tilde to home directory', () => { - const result = pathsToGlobPatterns(['~/project']) - expect(result[0]).toContain('/Users/testuser') - }) - - it('expands lone tilde to home directory', () => { - const result = pathsToGlobPatterns(['~']) - expect(result[0]).toBe('/Users/testuser') - }) - - it('adds recursive glob for directories', () => { - mockIsDirSync.mockReturnValue(true) - const result = pathsToGlobPatterns(['/some/directory']) - expect(result[0]).toBe('/some/directory/**/*') - }) - - it('keeps file paths as is', () => { - mockIsDirSync.mockReturnValue(false) - const result = pathsToGlobPatterns(['/some/file.txt']) - expect(result[0]).toBe('/some/file.txt') - }) - - it('keeps relative paths as is when not directories', () => { - mockIsDirSync.mockReturnValue(false) - const result = pathsToGlobPatterns(['relative/path'], '/cwd') - // Returns resolvedPath (not absolutePath) for non-directory files. - expect(result[0]).toBe('relative/path') - }) - - it('handles multiple paths', () => { - mockIsDirSync.mockReturnValue(false) - const result = pathsToGlobPatterns(['.', '/absolute/path', '~/home/path']) - expect(result).toHaveLength(3) - }) - }) - - describe('ignorePatternToMinimatch', () => { - it('returns special-cased patterns verbatim with negation prefix preserved', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - expect(ignorePatternToMinimatch('')).toBe('') - expect(ignorePatternToMinimatch('**')).toBe('**') - expect(ignorePatternToMinimatch('/**')).toBe('/**') - expect(ignorePatternToMinimatch('!**')).toBe('!**') - }) - - it('prepends **/ for patterns without slashes', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - expect(ignorePatternToMinimatch('node_modules')).toBe('**/node_modules') - }) - - it('strips leading slash and treats as project-rooted', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - expect(ignorePatternToMinimatch('/dist')).toBe('dist') - }) - - it('appends /* for patterns ending in /**', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - expect(ignorePatternToMinimatch('build/**')).toBe('build/**/*') - }) - - it('escapes brace + paren characters from gitignore-literal to minimatch-safe', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - // gitignore treats `{a,b}` as literal; minimatch treats it as expansion. - // Escape so minimatch matches the literal string. - expect(ignorePatternToMinimatch('src/{a,b}.js')).toContain('\\{') - expect(ignorePatternToMinimatch('src/(group)')).toContain('\\(') - }) - - it('passes negation prefix through', async () => { - const { ignorePatternToMinimatch } = - await import('../../../../src/util/fs/glob.mts') - expect(ignorePatternToMinimatch('!keep.txt')).toBe('!**/keep.txt') - }) - }) - - describe('workspacePatternToGlobPattern', () => { - it('returns empty for empty input', async () => { - const { workspacePatternToGlobPattern } = - await import('../../../../src/util/fs/glob.mts') - expect(workspacePatternToGlobPattern('')).toBe('') - }) - - it('appends /*/package.json for trailing-slash workspaces', async () => { - const { workspacePatternToGlobPattern } = - await import('../../../../src/util/fs/glob.mts') - expect(workspacePatternToGlobPattern('packages/')).toBe( - 'packages//*/package.json', - ) - }) - - it('appends /*/**/package.json for /** workspaces', async () => { - const { workspacePatternToGlobPattern } = - await import('../../../../src/util/fs/glob.mts') - expect(workspacePatternToGlobPattern('packages/**')).toBe( - 'packages/**/*/**/package.json', - ) - }) - - it('appends /package.json for plain workspaces', async () => { - const { workspacePatternToGlobPattern } = - await import('../../../../src/util/fs/glob.mts') - expect(workspacePatternToGlobPattern('packages/cli')).toBe( - 'packages/cli/package.json', - ) - }) - }) - - describe('ignoreFileLinesToGlobPatterns', () => { - it('skips blank and comment lines', async () => { - const { ignoreFileLinesToGlobPatterns } = - await import('../../../../src/util/fs/glob.mts') - const result = ignoreFileLinesToGlobPatterns( - ['', '# comment', 'node_modules', ''], - '/repo/.gitignore', - '/repo', - ) - expect(result).toEqual(['**/node_modules']) - }) - - it('preserves negation patterns with relative path joined', async () => { - const { ignoreFileLinesToGlobPatterns } = - await import('../../../../src/util/fs/glob.mts') - const result = ignoreFileLinesToGlobPatterns( - ['!keep'], - '/repo/sub/.gitignore', - '/repo', - ) - // Negation prefix preserved + path scoped to the .gitignore's directory. - expect(result[0]?.startsWith('!')).toBe(true) - expect(result[0]).toContain('keep') - }) - }) - - describe('ignoreFileToGlobPatterns', () => { - it('splits on \\r?\\n and threads through ignoreFileLinesToGlobPatterns', async () => { - const { ignoreFileToGlobPatterns } = - await import('../../../../src/util/fs/glob.mts') - const result = ignoreFileToGlobPatterns( - '# comment\nnode_modules\r\ndist', - '/repo/.gitignore', - '/repo', - ) - expect(result).toEqual(['**/node_modules', '**/dist']) - }) - }) - - describe('getWorkspaceGlobs', () => { - it('reads pnpm-workspace.yaml packages list for PNPM agent (lines 49-56)', async () => { - const { safeReadFile } = vi.mocked(await import('@socketsecurity/lib-stable/fs/read-file')) - safeReadFile.mockResolvedValueOnce( - 'packages:\n - "packages/*"\n - "apps/*"\n', - ) - const { getWorkspaceGlobs } = - await import('../../../../src/util/fs/glob.mts') - const result = await getWorkspaceGlobs('pnpm', '/repo') - // Workspace patterns are converted to glob form ("packages/*" → "packages/*/"). - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBeGreaterThan(0) - }) - - it('returns empty array when pnpm-workspace.yaml is missing', async () => { - const { safeReadFile } = vi.mocked(await import('@socketsecurity/lib-stable/fs/read-file')) - safeReadFile.mockResolvedValueOnce(undefined as unknown) - const { getWorkspaceGlobs } = - await import('../../../../src/util/fs/glob.mts') - const result = await getWorkspaceGlobs('pnpm', '/repo') - expect(result).toEqual([]) - }) - - it('returns empty array when pnpm-workspace.yaml is malformed', async () => { - const { safeReadFile } = vi.mocked(await import('@socketsecurity/lib-stable/fs/read-file')) - safeReadFile.mockResolvedValueOnce('this is not :::valid::: yaml{{{') - const { getWorkspaceGlobs } = - await import('../../../../src/util/fs/glob.mts') - const result = await getWorkspaceGlobs('pnpm', '/repo') - expect(result).toEqual([]) - }) - }) - - describe('globWorkspace', () => { - it('returns empty array when no workspace globs (line 299-300)', async () => { - const { safeReadFile } = vi.mocked(await import('@socketsecurity/lib-stable/fs/read-file')) - // pnpm-workspace.yaml missing → empty workspaceGlobs → early-return []. - safeReadFile.mockResolvedValueOnce(undefined as unknown) - const { globWorkspace } = await import('../../../../src/util/fs/glob.mts') - const result = await globWorkspace('pnpm', '/nonexistent/repo') - expect(result).toEqual([]) - }) - }) -}) diff --git a/packages/cli/test/unit/util/fs/home-path.test.mts b/packages/cli/test/unit/util/fs/home-path.test.mts deleted file mode 100644 index 9953aa003..000000000 --- a/packages/cli/test/unit/util/fs/home-path.test.mts +++ /dev/null @@ -1,111 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/personal-path-placeholders -- "testuser" / "testuserother" are fixture usernames in test input strings exercising tildify; they are not real personal paths. */ -/** - * Unit tests for home directory path utilities. - * - * Purpose: Tests home directory path resolution. Validates user home directory - * detection across platforms. - * - * Test Coverage: - * - * - Home directory resolution - * - Environment variable handling (HOME, USERPROFILE) - * - Tilde expansion (~) - * - Cross-platform compatibility - * - Fallback handling - * - * Testing Approach: Tests home path utilities with environment mocking. - * - * Related Files: - * - * - Util/fs/home-path.mts (implementation) - */ - -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import { tildify } from '../../../../src/util/fs/home-path.mts' - -// Mock constants. -vi.mock('../../../../src/constants/paths.mts', () => ({ - homePath: '/Users/testuser', -})) - -describe('tildify utilities', () => { - describe('tildify', () => { - it('replaces home directory with tilde', () => { - const result = tildify('/Users/testuser/documents/file.txt') - expect(result).toBe('~/documents/file.txt') - }) - - it('replaces home directory at the exact path', () => { - const result = tildify('/Users/testuser') - expect(result).toBe('~/') - }) - - it('replaces home directory with trailing separator', () => { - const result = tildify(`/Users/testuser${path.sep}`) - expect(result).toBe('~/') - }) - - it('does not replace partial matches', () => { - const result = tildify('/Users/testuserother/documents') - expect(result).toBe('/Users/testuserother/documents') - }) - - it('does not replace home path in the middle of a path', () => { - const result = tildify('/other/Users/testuser/documents') - expect(result).toBe('/other/Users/testuser/documents') - }) - - it('handles case insensitive matching', () => { - const result = tildify('/USERS/TESTUSER/documents') - expect(result).toBe('~/documents') - }) - - it('handles Windows-style paths', () => { - // This test would require re-mocking constants which is complex. - // The function itself will work correctly on Windows because - // path.sep and escapeRegExp handle the differences. - // For now, let's just verify the basic pattern works. - const result = tildify('/Users/testuser/documents') - expect(result).toBe('~/documents') - }) - - it('leaves non-home paths unchanged', () => { - expect(tildify('/var/log/system.log')).toBe('/var/log/system.log') - expect(tildify('/tmp/file.txt')).toBe('/tmp/file.txt') - // normalizePath strips leading './' from relative paths. - expect(tildify('./relative/path')).toBe('relative/path') - expect(tildify('../parent/path')).toBe('../parent/path') - }) - - it('handles empty string', () => { - // normalizePath converts empty string to current directory '.'. - const result = tildify('') - expect(result).toBe('.') - }) - - it('handles paths with special regex characters in home path', () => { - // The escapeRegExp function should handle special characters. - // Since we can't easily change the mock mid-test, we'll just - // verify that the function uses escapeRegExp correctly by - // testing with the current mock path. - const result = tildify('/Users/testuser/documents') - expect(result).toBe('~/documents') - }) - - it('preserves trailing slashes after replacement', () => { - // normalizePath removes trailing slashes. - const result = tildify('/Users/testuser/documents/') - expect(result).toBe('~/documents') - }) - - it('handles multiple consecutive separators', () => { - // normalizePath collapses multiple slashes to single slash. - const result = tildify(`/Users/testuser//${path.sep}documents`) - expect(result).toBe('~/documents') - }) - }) -}) diff --git a/packages/cli/test/unit/util/fs/path-resolve.test.mts b/packages/cli/test/unit/util/fs/path-resolve.test.mts deleted file mode 100644 index 04f78c789..000000000 --- a/packages/cli/test/unit/util/fs/path-resolve.test.mts +++ /dev/null @@ -1,441 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for path resolution utilities. - * - * Purpose: Tests path resolution and normalization. Validates absolute path - * construction and relative path handling. - * - * Test Coverage: - Absolute path resolution - Relative path handling - Path - * normalization - Symlink resolution - Cross-platform path separators. - * - * Testing Approach: Tests path utilities with various input formats. - * - * Related Files: - util/fs/path-resolve.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -import { - PACKAGE_LOCK_JSON, - PNPM_LOCK_YAML, - YARN_LOCK, -} from '../../../../src/constants/packages.mts' -import { - findBinPathDetailsSync, - getPackageFilesForScan, -} from '../../../../src/util/fs/path-resolve.mts' -import { createTestWorkspace } from '../../../helpers/workspace-helper.mts' - -import type * as BinResolveModule from '@socketsecurity/lib-stable/bin/resolve' -import type * as BinWhichModule from '@socketsecurity/lib-stable/bin/which' -import type * as FsInspectModule from '@socketsecurity/lib-stable/fs/inspect' - -const PACKAGE_JSON = 'package.json' - -// Hoisted mocks for better CI reliability. -const mockWhichRealSync = vi.hoisted(() => vi.fn()) -const mockResolveRealBinSync = vi.hoisted(() => vi.fn((p: string) => p)) - -// Mock dependencies for new tests. -vi.mock('@socketsecurity/lib-stable/bin/resolve', async () => { - const actual = await vi.importActual<typeof BinResolveModule>( - '@socketsecurity/lib-stable/bin/resolve', - ) - return { - ...actual, - resolveRealBinSync: mockResolveRealBinSync, - } -}) - -vi.mock('@socketsecurity/lib-stable/bin/which', async () => { - const actual = await vi.importActual<typeof BinWhichModule>( - '@socketsecurity/lib-stable/bin/which', - ) - return { - ...actual, - whichRealSync: mockWhichRealSync, - } -}) - -vi.mock('@socketsecurity/lib-stable/fs/inspect', async () => { - const actual = await vi.importActual<typeof FsInspectModule>( - '@socketsecurity/lib-stable/fs/inspect', - ) - return { - ...actual, - isDirSync: vi.fn(), - } -}) - -const globPatterns = { - general: { - readme: { - pattern: '*readme*', - }, - notice: { - pattern: '*notice*', - }, - license: { - pattern: '{licen{s,c}e{,-*},copying}', - }, - }, - npm: { - packagejson: { - pattern: PACKAGE_JSON, - }, - packagelockjson: { - pattern: PACKAGE_LOCK_JSON, - }, - npmshrinkwrap: { - pattern: 'npm-shrinkwrap.json', - }, - yarnlock: { - pattern: YARN_LOCK, - }, - pnpmlock: { - pattern: PNPM_LOCK_YAML, - }, - pnpmworkspace: { - pattern: 'pnpm-workspace.yaml', - }, - }, - pypi: { - pipfile: { - pattern: 'pipfile', - }, - pyproject: { - pattern: 'pyproject.toml', - }, - requirements: { - pattern: - '{*requirements.txt,requirements/*.txt,requirements-*.txt,requirements.frozen}', - }, - setuppy: { - pattern: 'setup.py', - }, - }, -} - -type Fn = (...args: unknown[]) => Promise<unknown[]> - -const sortedPromise = - (fn: Fn) => - async (...args: unknown[]) => { - const result = await fn(...args) - return result.sort() - } -const sortedGetPackageFilesFullScans = sortedPromise(getPackageFilesForScan) - -describe('Path Resolve', () => { - describe('getPackageFilesForScan()', () => { - it('should handle a "." inputPath', async () => { - const workspace = await createTestWorkspace({ - packageJson: { name: 'test' }, - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['.'], - globPatterns, - { - cwd: workspace.path, - }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should respect ignores from socket config', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: 'bar/package-lock.json', content: '{}' }, - { path: 'bar/package.json', content: '{}' }, - { path: 'foo/package-lock.json', content: '{}' }, - { path: 'foo/package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { - cwd: workspace.path, - config: { - version: 2, - projectIgnorePaths: ['bar/*', '!bar/package.json'], - issueRules: {}, - githubApp: {}, - }, - }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('bar/package.json')), - normalizePath(workspace.resolve('foo/package-lock.json')), - normalizePath(workspace.resolve('foo/package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should respect .gitignore', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: '.gitignore', content: 'bar/*\n!bar/package.json' }, - { path: 'bar/package-lock.json', content: '{}' }, - { path: 'bar/package.json', content: '{}' }, - { path: 'foo/package-lock.json', content: '{}' }, - { path: 'foo/package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('bar/package.json')), - normalizePath(workspace.resolve('foo/package-lock.json')), - normalizePath(workspace.resolve('foo/package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should always ignore some paths', async () => { - const workspace = await createTestWorkspace({ - files: [ - // Mirrors the list from - // https://github.com/novemberborn/ignore-by-default/blob/v2.1.0/index.js - { path: '.git/some/dir/package.json', content: '{}' }, - { path: '.log/some/dir/package.json', content: '{}' }, - { path: '.nyc_output/some/dir/package.json', content: '{}' }, - { path: '.sass-cache/some/dir/package.json', content: '{}' }, - { path: '.yarn/some/dir/package.json', content: '{}' }, - { path: 'bower_components/some/dir/package.json', content: '{}' }, - { path: 'coverage/some/dir/package.json', content: '{}' }, - { path: 'node_modules/socket/package.json', content: '{}' }, - { path: 'foo/package-lock.json', content: '{}' }, - { path: 'foo/package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('foo/package-lock.json')), - normalizePath(workspace.resolve('foo/package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should ignore irrelevant matches', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: 'foo/package-foo.json', content: '{}' }, - { path: 'foo/package-lock.json', content: '{}' }, - { path: 'foo/package.json', content: '{}' }, - { path: 'foo/random.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('foo/package-lock.json')), - normalizePath(workspace.resolve('foo/package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should be lenient on oddities', async () => { - const workspace = await createTestWorkspace({}) - - try { - // Create empty package.json directory (not a file) - await workspace.writeFile('package.json/.gitkeep', '') - - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([]) - } finally { - await workspace.cleanup() - } - }) - - it('should resolve package and lockfile', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: 'package-lock.json', content: '{}' }, - { path: 'package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('package-lock.json')), - normalizePath(workspace.resolve('package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should resolve package without lockfile', async () => { - const workspace = await createTestWorkspace({ - files: [{ path: 'package.json', content: '{}' }], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should support alternative lockfiles', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: 'yarn.lock', content: '{}' }, - { path: 'package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('package.json')), - normalizePath(workspace.resolve('yarn.lock')), - ]) - } finally { - await workspace.cleanup() - } - }) - - it('should handle all variations', async () => { - const workspace = await createTestWorkspace({ - files: [ - { path: 'package-lock.json', content: '{}' }, - { path: 'package.json', content: '{}' }, - { path: 'foo/package-lock.json', content: '{}' }, - { path: 'foo/package.json', content: '{}' }, - { path: 'bar/yarn.lock', content: '{}' }, - { path: 'bar/package.json', content: '{}' }, - { path: 'abc/package.json', content: '{}' }, - ], - }) - - try { - const actual = await sortedGetPackageFilesFullScans( - ['**/*'], - globPatterns, - { cwd: workspace.path }, - ) - expect(actual.map(normalizePath)).toEqual([ - normalizePath(workspace.resolve('abc/package.json')), - normalizePath(workspace.resolve('bar/package.json')), - normalizePath(workspace.resolve('bar/yarn.lock')), - normalizePath(workspace.resolve('foo/package-lock.json')), - normalizePath(workspace.resolve('foo/package.json')), - normalizePath(workspace.resolve('package-lock.json')), - normalizePath(workspace.resolve('package.json')), - ]) - } finally { - await workspace.cleanup() - } - }) - }) - - describe('findBinPathDetailsSync', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('finds bin path when available', () => { - mockWhichRealSync.mockReturnValue(['/usr/local/bin/npm']) - - const result = findBinPathDetailsSync('npm') - - expect(result).toEqual({ - name: 'npm', - path: '/usr/local/bin/npm', - }) - }) - - it('handles no bin path found', () => { - mockWhichRealSync.mockReturnValue(undefined) - - const result = findBinPathDetailsSync('nonexistent') - - expect(result).toEqual({ - name: 'nonexistent', - path: undefined, - }) - }) - - it('handles empty array result', () => { - mockWhichRealSync.mockReturnValue([]) - - const result = findBinPathDetailsSync('npm') - - expect(result).toEqual({ - name: 'npm', - path: undefined, - }) - }) - - it('handles single string result', () => { - mockWhichRealSync.mockReturnValue('/usr/local/bin/npm' as unknown) - - const result = findBinPathDetailsSync('npm') - - expect(result).toEqual({ - name: 'npm', - path: '/usr/local/bin/npm', - }) - }) - }) - -}) diff --git a/packages/cli/test/unit/util/git/github.test.mts b/packages/cli/test/unit/util/git/github.test.mts deleted file mode 100644 index 0f554fe48..000000000 --- a/packages/cli/test/unit/util/git/github.test.mts +++ /dev/null @@ -1,707 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for GitHub API utilities. - * - * Purpose: Tests centralized GitHub API error handling and retry logic used - * across the CLI. Validates proper error categorization, user-friendly - * messages, and exponential backoff. - * - * Test Coverage: - handleGitHubApiError: error categorization by HTTP status - - * handleGraphqlError: GraphQL-specific error handling - - * isGraphqlRateLimitError: rate limit detection - withGitHubRetry: retry logic - * with exponential backoff. - * - * Testing Approach: Creates mock RequestError and GraphqlResponseError objects - * to test error handling without actual GitHub API calls. Tests verify proper - * error messages and retry behavior. - * - * Related Files: - src/util/git/github.mts (implementation) - - * src/commands/scan/create-scan-from-github.mts (consumer) - - * src/util/git/github-provider.mts (consumer) - */ - -import { GraphqlResponseError } from '@octokit/graphql' -import { RequestError } from '@octokit/request-error' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - cacheFetch, - getOctokit, - getOctokitGraphql, - handleGitHubApiError, - handleGraphqlError, - isGraphqlRateLimitError, - withGitHubRetry, - writeCache, -} from '../../../../src/util/git/github.mts' - -// Mock debug utilities to suppress output during tests. -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), - debugDirNs: vi.fn(), - debugNs: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/debug/namespace', () => ({ - isDebugNs: vi.fn(() => false), -})) - -// Helper to create a GraphqlResponseError. -function createGraphqlError( - errors: Array<{ type?: string | undefined; message: string }>, -): GraphqlResponseError<unknown> { - return new GraphqlResponseError( - { method: 'POST', url: 'https://api.github.com/graphql' }, - { 'x-request-id': 'test' }, - { data: undefined, errors }, - ) -} - -// Helper to create a RequestError with specific status. -function createRequestError( - status: number, - message: string, - headers: Record<string, string> = {}, -): RequestError { - const error = new RequestError(message, status, { - request: { method: 'GET', url: 'https://api.github.com/test', headers: {} }, - response: { - status, - url: 'https://api.github.com/test', - headers, - data: {}, - }, - }) - return error -} - -describe('handleGitHubApiError', () => { - describe('rate limit errors', () => { - it('handles 429 rate limit error', () => { - const error = createRequestError(429, 'API rate limit exceeded') - const result = handleGitHubApiError(error, 'fetching commits') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub rate limit exceeded') - expect(result.cause).toContain('rate limit exceeded') - expect(result.cause).toContain('fetching commits') - expect(result.cause).toContain('GITHUB_TOKEN') - }) - - it('handles 403 with rate limit message', () => { - const error = createRequestError( - 403, - 'API rate limit exceeded for user ID 12345', - ) - const result = handleGitHubApiError(error, 'listing repos') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub rate limit exceeded') - expect(result.cause).toContain('listing repos') - }) - - it('includes retry-after header in message when present', () => { - const error = createRequestError(429, 'Rate limit exceeded', { - 'retry-after': '60', - }) - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - expect(result.cause).toContain('60 seconds') - }) - - it('includes x-ratelimit-reset header in message', () => { - const futureTimestamp = Math.floor(Date.now() / 1000) + 120 - const error = createRequestError(429, 'Rate limit exceeded', { - 'x-ratelimit-reset': String(futureTimestamp), - }) - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - // Should show approximate wait time. - expect(result.cause).toMatch(/Try again in \d+ seconds/) - }) - - it('falls back to generic message when retry-after is NaN (line 481)', () => { - const error = createRequestError(429, 'Rate limit exceeded', { - 'retry-after': 'not-a-number', - }) - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - // NaN waitTime → falls through to "Try again in a few minutes" message. - expect(result.cause).toContain('a few minutes') - }) - - it('falls back to generic message when retry-after is negative (line 481)', () => { - const error = createRequestError(429, 'Rate limit exceeded', { - 'retry-after': '-30', - }) - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - // Negative waitTime → falls through to "Try again in a few minutes" message. - expect(result.cause).toContain('a few minutes') - }) - - it('falls back to generic message when reset header is non-numeric', () => { - const error = createRequestError(429, 'Rate limit exceeded', { - 'x-ratelimit-reset': 'not-a-timestamp', - }) - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - expect(result.cause).toContain('a few minutes') - }) - }) - - describe('abuse detection', () => { - it('handles abuse detection rate limit', () => { - const error = createRequestError( - 403, - 'You have exceeded a secondary rate limit', - ) - const result = handleGitHubApiError(error, 'bulk operation') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub abuse detection triggered') - expect(result.cause).toContain('abuse detection') - expect(result.cause).toContain('bulk operation') - }) - - it('differentiates abuse detection from standard rate limit', () => { - // Standard rate limit. - const standardError = createRequestError( - 403, - 'API rate limit exceeded for user', - ) - const standardResult = handleGitHubApiError(standardError, 'operation') - expect(standardResult.message).toBe('GitHub rate limit exceeded') - - // Abuse detection (has "secondary rate limit" in message from GitHub). - const abuseError = createRequestError( - 403, - 'You have exceeded a secondary rate limit', - ) - const abuseResult = handleGitHubApiError(abuseError, 'operation') - expect(abuseResult.message).toBe('GitHub abuse detection triggered') - }) - }) - - describe('authentication errors', () => { - it('handles 401 authentication error', () => { - const error = createRequestError(401, 'Bad credentials') - const result = handleGitHubApiError(error, 'creating PR') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub authentication failed') - expect(result.cause).toContain('authentication failed') - expect(result.cause).toContain('creating PR') - expect(result.cause).toContain('token') - }) - }) - - describe('permission errors', () => { - it('handles 403 permission denied (not rate limit)', () => { - const error = createRequestError( - 403, - 'Resource not accessible by integration', - ) - const result = handleGitHubApiError(error, 'accessing private repo') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub permission denied') - expect(result.cause).toContain('permission denied') - expect(result.cause).toContain('accessing private repo') - }) - }) - - describe('not found errors', () => { - it('handles 404 not found error', () => { - const error = createRequestError(404, 'Not Found') - const result = handleGitHubApiError(error, 'fetching repo details') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub resource not found') - expect(result.cause).toContain('not found') - expect(result.cause).toContain('fetching repo details') - }) - }) - - describe('server errors', () => { - it('handles 500 server error', () => { - const error = createRequestError(500, 'Internal Server Error') - const result = handleGitHubApiError(error, 'creating scan') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub server error') - expect(result.cause).toContain('server error') - expect(result.cause).toContain('500') - expect(result.cause).toContain('githubstatus.com') - }) - - it('handles 502 bad gateway error', () => { - const error = createRequestError(502, 'Bad Gateway') - const result = handleGitHubApiError(error, 'fetching tree') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub server error') - }) - - it('handles 503 service unavailable error', () => { - const error = createRequestError(503, 'Service Unavailable') - const result = handleGitHubApiError(error, 'listing commits') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub server error') - }) - }) - - describe('network errors', () => { - it('handles ECONNREFUSED error', () => { - const error = new Error( - 'connect ECONNREFUSED 127.0.0.1:443', - ) as NodeJS.ErrnoException - error.code = 'ECONNREFUSED' - const result = handleGitHubApiError(error, 'connecting to API') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Network error connecting to GitHub') - expect(result.cause).toContain('connecting to API') - expect(result.cause).toContain('internet connection') - }) - - it('handles ETIMEDOUT error', () => { - const error = new Error('connect ETIMEDOUT') as NodeJS.ErrnoException - error.code = 'ETIMEDOUT' - const result = handleGitHubApiError(error, 'fetching data') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Network error connecting to GitHub') - }) - - it('handles ENOTFOUND error', () => { - const error = new Error( - 'getaddrinfo ENOTFOUND api.github.com', - ) as NodeJS.ErrnoException - error.code = 'ENOTFOUND' - const result = handleGitHubApiError(error, 'resolving host') - - expect(result.ok).toBe(false) - expect(result.message).toBe('Network error connecting to GitHub') - }) - }) - - describe('generic errors', () => { - it('handles unknown RequestError status', () => { - const error = createRequestError(418, "I'm a teapot") - const result = handleGitHubApiError(error, 'brewing coffee') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub API error (418)') - expect(result.cause).toContain("I'm a teapot") - }) - - it('handles non-Error objects', () => { - const result = handleGitHubApiError('string error', 'doing something') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub API error') - expect(result.cause).toContain('string error') - }) - - it('handles generic Error objects', () => { - const error = new Error('Something went wrong') - const result = handleGitHubApiError(error, 'processing request') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub API error') - expect(result.cause).toContain('Something went wrong') - }) - }) -}) - -describe('isGraphqlRateLimitError', () => { - it('returns true for RATE_LIMITED type', () => { - const error = createGraphqlError([ - { type: 'RATE_LIMITED', message: 'API rate limit exceeded' }, - ]) - expect(isGraphqlRateLimitError(error)).toBe(true) - }) - - it('returns true for rate limit message', () => { - const error = createGraphqlError([ - { message: 'API rate limit exceeded for user' }, - ]) - expect(isGraphqlRateLimitError(error)).toBe(true) - }) - - it('returns false for other GraphQL errors', () => { - const error = createGraphqlError([ - { type: 'NOT_FOUND', message: 'Resource not found' }, - ]) - expect(isGraphqlRateLimitError(error)).toBe(false) - }) - - it('returns false for non-GraphQL errors', () => { - const error = new Error('Regular error') - expect(isGraphqlRateLimitError(error)).toBe(false) - }) - - it('returns false for null/undefined', () => { - expect(isGraphqlRateLimitError(undefined)).toBe(false) - expect(isGraphqlRateLimitError(undefined)).toBe(false) - }) -}) - -describe('handleGraphqlError', () => { - it('handles GraphQL rate limit error', () => { - const error = createGraphqlError([ - { type: 'RATE_LIMITED', message: 'API rate limit exceeded' }, - ]) - const result = handleGraphqlError(error, 'fetching advisories') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub GraphQL rate limit exceeded') - expect(result.cause).toContain('fetching advisories') - expect(result.cause).toContain('GITHUB_TOKEN') - }) - - it('handles generic GraphQL error', () => { - const error = createGraphqlError([ - { type: 'FORBIDDEN', message: 'Must have admin rights' }, - ]) - const result = handleGraphqlError(error, 'enabling auto-merge') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub GraphQL error') - expect(result.cause).toContain('enabling auto-merge') - expect(result.cause).toContain('Must have admin rights') - }) - - it('handles multiple GraphQL errors', () => { - const error = createGraphqlError([ - { message: 'First error' }, - { message: 'Second error' }, - ]) - const result = handleGraphqlError(error, 'complex operation') - - expect(result.ok).toBe(false) - expect(result.cause).toContain('First error') - expect(result.cause).toContain('Second error') - }) - - it('falls back to REST handler for non-GraphQL errors', () => { - const error = createRequestError(500, 'Server Error') - const result = handleGraphqlError(error, 'api call') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub server error') - }) -}) - -describe('withGitHubRetry', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('returns success on first attempt', async () => { - const operation = vi.fn().mockResolvedValue({ data: 'test' }) - const resultPromise = withGitHubRetry(operation, 'test operation') - const result = await resultPromise - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ data: 'test' }) - } - expect(operation).toHaveBeenCalledTimes(1) - }) - - it('retries on 5xx errors', async () => { - const operation = vi - .fn() - .mockRejectedValueOnce(createRequestError(500, 'Server Error')) - .mockRejectedValueOnce(createRequestError(502, 'Bad Gateway')) - .mockResolvedValue({ data: 'success' }) - - const resultPromise = withGitHubRetry(operation, 'retry test') - - // Advance through retry delays. - await vi.advanceTimersByTimeAsync(1000) // First retry delay. - await vi.advanceTimersByTimeAsync(2000) // Second retry delay. - - const result = await resultPromise - - expect(result.ok).toBe(true) - expect(operation).toHaveBeenCalledTimes(3) - }) - - it('does not retry on 4xx errors (except rate limits)', async () => { - const operation = vi - .fn() - .mockRejectedValue(createRequestError(404, 'Not Found')) - - const result = await withGitHubRetry(operation, 'no retry test') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub resource not found') - expect(operation).toHaveBeenCalledTimes(1) - }) - - it('returns rate limit error immediately without retrying', async () => { - const operation = vi - .fn() - .mockRejectedValue(createRequestError(429, 'Rate limit exceeded')) - - const result = await withGitHubRetry(operation, 'rate limit test') - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub rate limit exceeded') - expect(operation).toHaveBeenCalledTimes(1) - }) - - it('returns error after exhausting retries', async () => { - const operation = vi - .fn() - .mockRejectedValue(createRequestError(500, 'Persistent server error')) - - const resultPromise = withGitHubRetry(operation, 'exhaust retries', 3) - - // Advance through all retry delays. - await vi.advanceTimersByTimeAsync(1000) - await vi.advanceTimersByTimeAsync(2000) - - const result = await resultPromise - - expect(result.ok).toBe(false) - expect(result.message).toBe('GitHub server error') - expect(operation).toHaveBeenCalledTimes(3) - }) - - it('uses exponential backoff', async () => { - const operation = vi - .fn() - .mockRejectedValueOnce(createRequestError(500, 'Error')) - .mockRejectedValueOnce(createRequestError(500, 'Error')) - .mockRejectedValueOnce(createRequestError(500, 'Error')) - .mockResolvedValue({ data: 'success' }) - - const startTime = Date.now() - const resultPromise = withGitHubRetry(operation, 'backoff test', 4) - - // First retry after 1s. - await vi.advanceTimersByTimeAsync(1000) - expect(operation).toHaveBeenCalledTimes(2) - - // Second retry after 2s. - await vi.advanceTimersByTimeAsync(2000) - expect(operation).toHaveBeenCalledTimes(3) - - // Third retry after 4s. - await vi.advanceTimersByTimeAsync(4000) - expect(operation).toHaveBeenCalledTimes(4) - - const result = await resultPromise - expect(result.ok).toBe(true) - }) - - it('respects custom max retries', async () => { - const operation = vi - .fn() - .mockRejectedValue(createRequestError(500, 'Server Error')) - - const resultPromise = withGitHubRetry(operation, 'custom retries', 5) - - // Advance through 4 retry delays (5 attempts total). - await vi.advanceTimersByTimeAsync(1000) - await vi.advanceTimersByTimeAsync(2000) - await vi.advanceTimersByTimeAsync(4000) - await vi.advanceTimersByTimeAsync(8000) - - const result = await resultPromise - - expect(result.ok).toBe(false) - expect(operation).toHaveBeenCalledTimes(5) - }) - - it('handles network errors with retry', async () => { - const networkError = new Error('ETIMEDOUT') as NodeJS.ErrnoException - networkError.code = 'ETIMEDOUT' - - const operation = vi - .fn() - .mockRejectedValueOnce(networkError) - .mockResolvedValue({ data: 'recovered' }) - - const resultPromise = withGitHubRetry(operation, 'network retry') - - await vi.advanceTimersByTimeAsync(1000) - - const result = await resultPromise - - expect(result.ok).toBe(true) - expect(operation).toHaveBeenCalledTimes(2) - }) -}) - -describe('getOctokit', () => { - it('returns an Octokit instance', () => { - const octokit = getOctokit() - expect(octokit).toBeDefined() - expect(octokit.pulls).toBeDefined() - expect(octokit.repos).toBeDefined() - }) - - it('returns the same instance on subsequent calls', () => { - const octokit1 = getOctokit() - const octokit2 = getOctokit() - expect(octokit1).toBe(octokit2) - }) -}) - -describe('getOctokitGraphql', () => { - it('returns a GraphQL client', () => { - const graphql = getOctokitGraphql() - expect(graphql).toBeDefined() - expect(typeof graphql).toBe('function') - }) - - it('returns the same instance on subsequent calls', () => { - const graphql1 = getOctokitGraphql() - const graphql2 = getOctokitGraphql() - expect(graphql1).toBe(graphql2) - }) -}) - -describe('cacheFetch', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('calls the fetcher when cache is empty', async () => { - const fetcher = vi.fn().mockResolvedValue({ value: 'fetched' }) - // Use unique key to avoid cache from other tests. - const key = `test-fetch-${Date.now()}-${Math.random()}` - - const result = await cacheFetch(key, fetcher) - - expect(result).toEqual({ value: 'fetched' }) - expect(fetcher).toHaveBeenCalledTimes(1) - }) - - it('returns cached value on subsequent calls', async () => { - const key = `test-cached-${Date.now()}-${Math.random()}` - const fetcher = vi.fn().mockResolvedValue({ value: 'cached' }) - - // First call populates cache. - await cacheFetch(key, fetcher) - - // Second call should use cache. - const result = await cacheFetch(key, fetcher) - - expect(result).toEqual({ value: 'cached' }) - // Fetcher should only be called once. - expect(fetcher).toHaveBeenCalledTimes(1) - }) - - it('prevents concurrent fetches for the same key', async () => { - const key = `test-concurrent-${Date.now()}-${Math.random()}` - let resolvePromise: (value: { value: string }) => void - const slowFetcher = vi.fn().mockReturnValue( - new Promise(resolve => { - resolvePromise = resolve - }), - ) - - // Start two concurrent fetches. - const promise1 = cacheFetch(key, slowFetcher) - const promise2 = cacheFetch(key, slowFetcher) - - // Resolve the slow fetcher. - resolvePromise!({ value: 'slow-result' }) - - const [result1, result2] = await Promise.all([promise1, promise2]) - - expect(result1).toEqual({ value: 'slow-result' }) - expect(result2).toEqual({ value: 'slow-result' }) - // Fetcher should only be called once. - expect(slowFetcher).toHaveBeenCalledTimes(1) - }) -}) - -describe('writeCache', () => { - it('writes cache data without throwing', async () => { - const key = `test-write-${Date.now()}-${Math.random()}` - const data = { test: 'data', value: 123 } - - // Should not throw. - await expect(writeCache(key, data)).resolves.not.toThrow() - }) -}) - -describe('enablePrAutoMerge', () => { - it('returns enabled true when GraphQL mutation succeeds', async () => { - const { enablePrAutoMerge } = - await import('../../../../src/util/git/github.mts') - // This test verifies the function exists and handles the PR object. - // Full testing would require mocking getOctokitGraphql. - const mockPr = { - node_id: 'test-node-id', - number: 123, - } as unknown - - // Without proper mocking, this will attempt a real API call and fail. - // The function should handle errors gracefully. - const result = await enablePrAutoMerge(mockPr) - - // Should return an object with enabled property. - expect(result).toHaveProperty('enabled') - expect(typeof result.enabled).toBe('boolean') - }) -}) - -describe('fetchGhsaDetails', () => { - it('returns empty map for empty input array', async () => { - const { fetchGhsaDetails } = - await import('../../../../src/util/git/github.mts') - - const result = await fetchGhsaDetails([]) - - expect(result).toBeInstanceOf(Map) - expect(result.size).toBe(0) - }) - - it('returns a Map for valid GHSA IDs', async () => { - const { fetchGhsaDetails } = - await import('../../../../src/util/git/github.mts') - - // Without proper mocking, this will attempt a real API call. - // The function should handle errors gracefully. - const result = await fetchGhsaDetails(['GHSA-test-1234-5678']) - - expect(result).toBeInstanceOf(Map) - }) -}) - -describe('setGitRemoteGithubRepoUrl', () => { - it('returns false when GITHUB_SERVER_URL is invalid', async () => { - // Without proper mocking of GITHUB_SERVER_URL environment variable, - // this test verifies the function handles the edge case. - const { setGitRemoteGithubRepoUrl } = - await import('../../../../src/util/git/github.mts') - - // The function should return false when it cannot parse the server URL. - const result = await setGitRemoteGithubRepoUrl( - 'test-owner', - 'test-repo', - 'test-token', - '/nonexistent/path', - ) - - expect(typeof result).toBe('boolean') - }) -}) diff --git a/packages/cli/test/unit/util/git/gitlab-provider.test.mts b/packages/cli/test/unit/util/git/gitlab-provider.test.mts deleted file mode 100644 index aaef192a3..000000000 --- a/packages/cli/test/unit/util/git/gitlab-provider.test.mts +++ /dev/null @@ -1,532 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for GitLab provider. - * - * Purpose: Tests the GitLab merge request provider implementation. - * - * Test Coverage: - GitLabProvider class - createPr method - updatePr method - - * listPrs method - deleteBranch method - addComment method - getProviderName - * method - supportsGraphQL method - State mapping functions. - * - * Related Files: - util/git/gitlab-provider.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock @gitbeaker/rest. -const mockCreate = vi.hoisted(() => vi.fn()) -const mockRebase = vi.hoisted(() => vi.fn()) -const mockShow = vi.hoisted(() => vi.fn()) -const mockAll = vi.hoisted(() => vi.fn()) -const mockNotesCreate = vi.hoisted(() => vi.fn()) - -vi.mock('@gitbeaker/rest', () => { - return { - Gitlab: class MockGitlab { - MergeRequestNotes = { - create: mockNotesCreate, - } - MergeRequests = { - all: mockAll, - create: mockCreate, - rebase: mockRebase, - show: mockShow, - } - }, - } -}) - -// Mock debug. -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debug: vi.fn(), - debugDir: vi.fn(), -})) - -// Set GITLAB_TOKEN env var before importing. -process.env['GITLAB_TOKEN'] = 'test-token' - -import { GitLabProvider } from '../../../../src/util/git/gitlab-provider.mts' - -describe('git/gitlab-provider', () => { - let provider: GitLabProvider - - beforeEach(() => { - vi.clearAllMocks() - process.env['GITLAB_TOKEN'] = 'test-token' - provider = new GitLabProvider() - }) - - describe('constructor', () => { - it('creates provider with default host', () => { - expect(provider).toBeInstanceOf(GitLabProvider) - }) - - it('throws error when no token available', () => { - delete process.env['GITLAB_TOKEN'] - expect(() => new GitLabProvider()).toThrow( - /GitLab access requires a token but process\.env\.GITLAB_TOKEN is not set/, - ) - }) - }) - - describe('getProviderName', () => { - it('returns gitlab', () => { - expect(provider.getProviderName()).toBe('gitlab') - }) - }) - - describe('supportsGraphQL', () => { - it('returns false', () => { - expect(provider.supportsGraphQL()).toBe(false) - }) - }) - - describe('createPr', () => { - it('creates merge request successfully', async () => { - mockCreate.mockResolvedValueOnce({ - iid: 123, - state: 'opened', - web_url: 'https://gitlab.com/owner/repo/-/merge_requests/123', - }) - - const result = await provider.createPr({ - base: 'main', - body: 'Test MR body', - head: 'feature-branch', - owner: 'owner', - repo: 'repo', - title: 'Test MR', - }) - - expect(result).toEqual({ - number: 123, - state: 'open', - url: 'https://gitlab.com/owner/repo/-/merge_requests/123', - }) - expect(mockCreate).toHaveBeenCalledWith( - 'owner/repo', - 'feature-branch', - 'main', - 'Test MR', - { description: 'Test MR body' }, - ) - }) - - it('maps merged state correctly', async () => { - mockCreate.mockResolvedValueOnce({ - iid: 123, - state: 'merged', - web_url: 'https://gitlab.com/owner/repo/-/merge_requests/123', - }) - - const result = await provider.createPr({ - base: 'main', - body: 'Test', - head: 'feature', - owner: 'owner', - repo: 'repo', - title: 'Test', - }) - - expect(result.state).toBe('merged') - }) - - it('maps closed state correctly', async () => { - mockCreate.mockResolvedValueOnce({ - iid: 123, - state: 'closed', - web_url: 'https://gitlab.com/owner/repo/-/merge_requests/123', - }) - - const result = await provider.createPr({ - base: 'main', - body: 'Test', - head: 'feature', - owner: 'owner', - repo: 'repo', - title: 'Test', - }) - - expect(result.state).toBe('closed') - }) - - it('retries on failure', async () => { - mockCreate.mockRejectedValueOnce(new Error('Network error')) - mockCreate.mockResolvedValueOnce({ - iid: 123, - state: 'opened', - web_url: 'https://gitlab.com/owner/repo/-/merge_requests/123', - }) - - const result = await provider.createPr({ - base: 'main', - body: 'Test', - head: 'feature', - owner: 'owner', - repo: 'repo', - retries: 3, - title: 'Test', - }) - - expect(result.number).toBe(123) - expect(mockCreate).toHaveBeenCalledTimes(2) - }) - - it('throws after max retries', async () => { - mockCreate.mockRejectedValue(new Error('Network error')) - - await expect( - provider.createPr({ - base: 'main', - body: 'Test', - head: 'feature', - owner: 'owner', - repo: 'repo', - retries: 2, - title: 'Test', - }), - ).rejects.toThrow( - /GitLab API rejected createMergeRequest for owner\/repo .*after 2 attempts/, - ) - }) - - it('does not retry on 400 errors', async () => { - mockCreate.mockRejectedValue({ - cause: { response: { status: 400 } }, - message: 'Validation error', - }) - - await expect( - provider.createPr({ - base: 'main', - body: 'Test', - head: 'feature', - owner: 'owner', - repo: 'repo', - retries: 3, - title: 'Test', - }), - ).rejects.toThrow( - /GitLab API rejected createMergeRequest for owner\/repo .*after 3 attempts/, - ) - - expect(mockCreate).toHaveBeenCalledTimes(1) - }) - }) - - describe('updatePr', () => { - it('rebases merge request successfully', async () => { - mockRebase.mockResolvedValueOnce({}) - mockShow.mockResolvedValueOnce({ merge_status: 'can_be_merged' }) - - await provider.updatePr({ - owner: 'owner', - prNumber: 123, - repo: 'repo', - }) - - expect(mockRebase).toHaveBeenCalledWith('owner/repo', 123) - }) - - it('adds conflict comment when rebase results in conflicts', async () => { - mockRebase.mockResolvedValueOnce({}) - mockShow.mockResolvedValueOnce({ merge_status: 'cannot_be_merged' }) - mockNotesCreate.mockResolvedValueOnce({}) - - await provider.updatePr({ - owner: 'owner', - prNumber: 123, - repo: 'repo', - }) - - expect(mockNotesCreate).toHaveBeenCalledWith( - 'owner/repo', - 123, - expect.stringContaining('merge conflicts'), - ) - }) - - it('throws on rebase failure', async () => { - mockRebase.mockRejectedValueOnce(new Error('Rebase failed')) - - await expect( - provider.updatePr({ - owner: 'owner', - prNumber: 123, - repo: 'repo', - }), - ).rejects.toThrow('Failed to update MR !123') - }) - }) - - describe('listPrs', () => { - it('lists merge requests successfully', async () => { - mockAll.mockResolvedValueOnce([ - { - author: { username: 'testuser' }, - iid: 1, - merge_status: 'can_be_merged', - source_branch: 'feature-1', - state: 'opened', - target_branch: 'main', - title: 'MR 1', - }, - { - author: { username: 'testuser2' }, - iid: 2, - merge_status: 'cannot_be_merged', - source_branch: 'feature-2', - state: 'merged', - target_branch: 'main', - title: 'MR 2', - }, - ]) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - author: 'testuser', - baseRefName: 'main', - headRefName: 'feature-1', - mergeStateStatus: 'CLEAN', - number: 1, - state: 'OPEN', - title: 'MR 1', - }) - expect(result[1]).toEqual({ - author: 'testuser2', - baseRefName: 'main', - headRefName: 'feature-2', - mergeStateStatus: 'DIRTY', - number: 2, - state: 'MERGED', - title: 'MR 2', - }) - }) - - it('filters by state', async () => { - mockAll.mockResolvedValueOnce([]) - - await provider.listPrs({ - owner: 'owner', - repo: 'repo', - states: 'open', - }) - - expect(mockAll).toHaveBeenCalledWith( - expect.objectContaining({ state: 'opened' }), - ) - }) - - it('filters by author', async () => { - mockAll.mockResolvedValueOnce([]) - - await provider.listPrs({ - author: 'testuser', - owner: 'owner', - repo: 'repo', - }) - - expect(mockAll).toHaveBeenCalledWith( - expect.objectContaining({ authorUsername: 'testuser' }), - ) - }) - - it('paginates through results', async () => { - // First page full, second page empty. - const fullPage = Array(100) - .fill(undefined) - .map((_, i) => ({ - author: { username: 'user' }, - iid: i, - merge_status: 'can_be_merged', - source_branch: `feature-${i}`, - state: 'opened', - target_branch: 'main', - title: `MR ${i}`, - })) - - mockAll.mockResolvedValueOnce(fullPage) - mockAll.mockResolvedValueOnce([]) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result).toHaveLength(100) - expect(mockAll).toHaveBeenCalledTimes(2) - }) - - it('stops pagination early when ghsaId match found', async () => { - mockAll.mockResolvedValueOnce([ - { - author: { username: 'user' }, - iid: 1, - merge_status: 'can_be_merged', - source_branch: 'feature', - state: 'opened', - target_branch: 'main', - title: 'Fix GHSA-xxx', - }, - ]) - - const result = await provider.listPrs({ - ghsaId: 'GHSA-xxx', - owner: 'owner', - repo: 'repo', - }) - - expect(result).toHaveLength(1) - expect(mockAll).toHaveBeenCalledTimes(1) - }) - - it('handles API errors gracefully', async () => { - mockAll.mockRejectedValueOnce(new Error('API error')) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result).toEqual([]) - }) - - it('maps merge status unknown correctly', async () => { - mockAll.mockResolvedValueOnce([ - { - author: { username: 'user' }, - iid: 1, - merge_status: 'checking', - source_branch: 'feature', - state: 'opened', - target_branch: 'main', - title: 'MR 1', - }, - ]) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result[0]?.mergeStateStatus).toBe('UNKNOWN') - }) - - it('maps unknown MR state to CLOSED upstream (line 269)', async () => { - mockAll.mockResolvedValueOnce([ - { - author: { username: 'user' }, - iid: 1, - merge_status: 'can_be_merged', - source_branch: 'feature', - // Synthetic state value not in {opened, merged} → falls to default. - state: 'locked', - target_branch: 'main', - title: 'MR 1', - }, - ]) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result[0]?.state).toBe('CLOSED') - }) - - it('maps merge_status default → UNKNOWN (line 306)', async () => { - mockAll.mockResolvedValueOnce([ - { - author: { username: 'user' }, - iid: 1, - // Synthetic merge_status value not in any known case. - merge_status: 'totally-unknown-status', - source_branch: 'feature', - state: 'opened', - target_branch: 'main', - title: 'MR 1', - }, - ]) - - const result = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(result[0]?.mergeStateStatus).toBe('UNKNOWN') - }) - - it('filters by merged state (lines 280-281 mapStateToGitLab merged path)', async () => { - mockAll.mockResolvedValueOnce([]) - - await provider.listPrs({ - owner: 'owner', - repo: 'repo', - states: 'merged', - }) - - expect(mockAll).toHaveBeenCalledWith( - expect.objectContaining({ state: 'merged' }), - ) - }) - - it('filters by closed state (line 283 mapStateToGitLab closed default)', async () => { - mockAll.mockResolvedValueOnce([]) - - await provider.listPrs({ - owner: 'owner', - repo: 'repo', - states: 'closed', - }) - - expect(mockAll).toHaveBeenCalledWith( - expect.objectContaining({ state: 'closed' }), - ) - }) - }) - - describe('deleteBranch', () => { - it('returns false due to interface limitation', async () => { - const result = await provider.deleteBranch('feature-branch') - - expect(result).toBe(false) - }) - }) - - describe('addComment', () => { - it('adds comment successfully', async () => { - mockNotesCreate.mockResolvedValueOnce({}) - - await provider.addComment({ - body: 'Test comment', - owner: 'owner', - prNumber: 123, - repo: 'repo', - }) - - expect(mockNotesCreate).toHaveBeenCalledWith( - 'owner/repo', - 123, - 'Test comment', - ) - }) - - it('throws on failure', async () => { - mockNotesCreate.mockRejectedValueOnce(new Error('API error')) - - await expect( - provider.addComment({ - body: 'Test', - owner: 'owner', - prNumber: 123, - repo: 'repo', - }), - ).rejects.toThrow('Failed to add comment to MR !123') - }) - }) -}) diff --git a/packages/cli/test/unit/util/git/operations.test.mts b/packages/cli/test/unit/util/git/operations.test.mts deleted file mode 100644 index 0d7a94cbc..000000000 --- a/packages/cli/test/unit/util/git/operations.test.mts +++ /dev/null @@ -1,817 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for git operations. - * - * Purpose: Tests git operations (clone, status, diff, etc.). Validates git - * command execution and output parsing. - * - * Test Coverage: - Git clone - Git status parsing - Git diff - Git log - Branch - * detection - Commit information. - * - * Testing Approach: Uses mocked git subprocess calls to test git integrations. - * - * Related Files: - util/git/operations.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { resetEnv, setEnv } from '@socketsecurity/lib-stable/env/rewire' - -import { - detectDefaultBranch, - getBaseBranch, - getRepoInfo, - getRepoName, - getRepoOwner, - gitBranch, - gitCheckoutBranch, - gitCleanFdx, - gitCommit, - gitCreateBranch, - gitDeleteBranch, - gitDeleteRemoteBranch, - gitEnsureIdentity, - gitLocalBranchExists, - gitPushBranch, - gitRemoteBranchExists, - gitResetAndClean, - gitResetHard, - gitUnstagedModifiedFiles, - parseGitRemoteUrl, -} from '../../../../src/util/git/operations.mts' - -// Mock spawn. -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/process/spawn/errors', () => ({ - isSpawnError: vi.fn(e => e?.isSpawnError), -})) - -// Mock whichReal(). -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: vi.fn().mockResolvedValue('git'), -})) - -vi.mock('../../../../src/constants/cli.mts', () => ({ - FLAG_QUIET: '--quiet', -})) - -// Mock debug. -vi.mock('../../../../src/util/debug.mts', () => ({ - debugGit: vi.fn(), -})) - -describe('git utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - // Reset environment variable overrides after each test. - resetEnv() - }) - - describe('parseGitRemoteUrl', () => { - it('parses SSH URLs', () => { - const result = parseGitRemoteUrl('git@github.com:owner/repo.git') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - - it('parses HTTPS URLs', () => { - const result = parseGitRemoteUrl('https://github.com/owner/repo.git') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - - it('parses URLs without .git extension', () => { - const result = parseGitRemoteUrl('https://github.com/owner/repo') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - - it('handles GitLab URLs', () => { - const result = parseGitRemoteUrl('git@gitlab.com:owner/repo.git') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - - it('handles Bitbucket URLs', () => { - const result = parseGitRemoteUrl('git@bitbucket.org:owner/repo.git') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - - it('returns undefined for invalid URLs', () => { - expect(parseGitRemoteUrl('not-a-url')).toBeUndefined() - expect(parseGitRemoteUrl('')).toBeUndefined() - expect(parseGitRemoteUrl('http://example.com')).toBeUndefined() - }) - - it('handles URLs with ports', () => { - const result = parseGitRemoteUrl('ssh://git@github.com:22/owner/repo.git') - expect(result).toEqual({ owner: 'owner', repo: 'repo' }) - }) - }) - - describe('getBaseBranch', () => { - it('returns GITHUB_BASE_REF when in PR', async () => { - setEnv('GITHUB_BASE_REF', 'main') - - const result = await getBaseBranch() - expect(result).toBe('main') - }) - - it('returns GITHUB_REF_NAME when it is a branch', async () => { - setEnv('GITHUB_BASE_REF', '') - setEnv('GITHUB_REF_TYPE', 'branch') - setEnv('GITHUB_REF_NAME', 'feature-branch') - - const result = await getBaseBranch() - expect(result).toBe('feature-branch') - }) - - it('calls detectDefaultBranch when no GitHub env vars', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'main\n', - stderr: '', - } as unknown) - - const result = await getBaseBranch('/test/dir') - expect(result).toBe('main') - }) - - it('returns "main" fallback when git remote show returns falsy (line 100)', async () => { - setEnv('GITHUB_BASE_REF', '') - setEnv('GITHUB_REF_TYPE', '') - setEnv('GITHUB_REF_NAME', '') - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValueOnce(undefined as unknown) - - const result = await getBaseBranch('/test/dir') - expect(result).toBe('main') - }) - - it('parses HEAD branch from git remote show origin output (line 110)', async () => { - setEnv('GITHUB_BASE_REF', '') - setEnv('GITHUB_REF_TYPE', '') - setEnv('GITHUB_REF_NAME', '') - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValueOnce({ - status: 0, - stdout: - '* remote origin\n Fetch URL: git@github.com:o/r.git\n HEAD branch: develop\n Remote branches:\n', - stderr: '', - } as unknown) - - const result = await getBaseBranch('/test/dir') - expect(result).toBe('develop') - }) - }) - - describe('gitBranch', () => { - it('returns current branch name', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'feature-branch\n', - stderr: '', - } as unknown) - - const result = await gitBranch() - expect(result).toBe('feature-branch\n') - expect(spawn).toHaveBeenCalledWith( - 'git', - ['symbolic-ref', '--short', 'HEAD'], - expect.any(Object), - ) - }) - - it('handles detached HEAD state', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn - .mockRejectedValueOnce(new Error('Not on a branch')) - .mockResolvedValueOnce({ - status: 0, - stdout: 'abc1234\n', - stderr: '', - } as unknown) - - const result = await gitBranch() - expect(result).toBe('abc1234\n') - }) - - it('handles spawn errors', async () => { - const { spawn } = vi.mocked( - await import('@socketsecurity/lib-stable/process/spawn/child'), - ) - const { isSpawnError } = vi.mocked( - await import('@socketsecurity/lib-stable/process/spawn/errors'), - ) - const error = { isSpawnError: true, message: 'Command failed' } - spawn.mockRejectedValue(error) - isSpawnError.mockReturnValue(true) - - const result = await gitBranch() - expect(result).toBeUndefined() - }) - }) - - describe('gitCommit', () => { - it('creates a commit with message and files', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitCommit( - 'Test commit', - ['file1.txt', 'file2.txt'], - { cwd: '/test/dir' }, - ) - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['add', 'file1.txt', 'file2.txt'], - expect.any(Object), - ) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['commit', '-m', 'Test commit'], - expect.any(Object), - ) - }) - - it('handles commit without files', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitCommit('Test commit', [], { cwd: '/test/dir' }) - expect(result).toBe(false) - expect(spawn).not.toHaveBeenCalledWith( - 'git', - expect.arrayContaining(['add']), - expect.any(Object), - ) - }) - - it('returns false when git add rejects (lines 344-347)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // gitEnsureIdentity calls spawn first - allow those to succeed. - // Then the git add call should fail. - spawn.mockImplementation((_cmd: unknown, args: unknown) => { - if (args?.includes('add')) { - return Promise.reject(new Error('add failed')) as unknown - } - return Promise.resolve({ status: 0, stdout: '', stderr: '' }) as unknown - }) - - const result = await gitCommit('Test commit', ['file.txt'], { - cwd: '/test/dir', - }) - expect(result).toBe(false) - }) - - it('returns false when git commit rejects (lines 355-358)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // Allow add to succeed, fail on commit. - spawn.mockImplementation((_cmd: unknown, args: unknown) => { - if (args?.[0] === 'commit') { - return Promise.reject(new Error('commit failed')) as unknown - } - return Promise.resolve({ status: 0, stdout: '', stderr: '' }) as unknown - }) - - const result = await gitCommit('Test commit', ['file.txt'], { - cwd: '/test/dir', - }) - expect(result).toBe(false) - }) - }) - - describe('gitCheckoutBranch', () => { - it('checks out a branch', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitCheckoutBranch('main') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['checkout', 'main'], - expect.any(Object), - ) - }) - - it('returns false when checkout spawn rejects (lines 261-264)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('checkout failed')) - - const result = await gitCheckoutBranch('nonexistent') - expect(result).toBe(false) - }) - }) - - describe('gitCreateBranch', () => { - it('creates a new branch', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn - .mockRejectedValueOnce(new Error('Branch does not exist')) // gitLocalBranchExists fails. - .mockResolvedValueOnce({ status: 0, stdout: '', stderr: '' } as unknown) // git branch succeeds. - - const result = await gitCreateBranch('new-feature') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['show-ref', '--quiet', 'refs/heads/new-feature'], - expect.any(Object), - ) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['branch', 'new-feature'], - expect.any(Object), - ) - }) - - it('returns true early when branch already exists (line 272)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // gitLocalBranchExists resolves successfully (branch exists). - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitCreateBranch('existing-branch') - expect(result).toBe(true) - // Only the show-ref call should occur — no `git branch` create. - expect(spawn).not.toHaveBeenCalledWith( - 'git', - ['branch', 'existing-branch'], - expect.any(Object), - ) - }) - - it('returns false when branch creation rejects (lines 282-286)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn - .mockRejectedValueOnce(new Error('Branch does not exist')) // gitLocalBranchExists fails. - .mockRejectedValueOnce(new Error('branch creation failed')) // git branch fails. - - const result = await gitCreateBranch('bad-branch') - expect(result).toBe(false) - }) - }) - - describe('gitDeleteBranch', () => { - it('deletes a local branch', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitDeleteBranch('old-feature') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['branch', '-D', 'old-feature'], - expect.any(Object), - ) - }) - - it('returns false when delete rejects (lines 377-382)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('branch does not exist')) - - const result = await gitDeleteBranch('nonexistent') - expect(result).toBe(false) - }) - }) - - describe('gitPushBranch', () => { - it('pushes a branch to remote', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitPushBranch('feature') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['push', '--force', '--set-upstream', 'origin', 'feature'], - expect.any(Object), - ) - }) - - it('returns false on generic spawn rejection (lines 312-313)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('network error')) - - const result = await gitPushBranch('feature') - expect(result).toBe(false) - }) - - it('returns false on 128 spawn-error (token permissions, lines 305-311)', async () => { - const { spawn } = vi.mocked( - await import('@socketsecurity/lib-stable/process/spawn/child'), - ) - const { isSpawnError } = vi.mocked( - await import('@socketsecurity/lib-stable/process/spawn/errors'), - ) - const err: unknown = new Error('token denied') - err.isSpawnError = true - err.code = 128 - isSpawnError.mockReturnValueOnce(true) - spawn.mockRejectedValue(err) - - const result = await gitPushBranch('feature') - expect(result).toBe(false) - }) - }) - - describe('gitCleanFdx', () => { - it('cleans untracked files', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitCleanFdx() - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['clean', '-fdx'], - expect.any(Object), - ) - }) - - it('returns false when clean spawn rejects (lines 242-245)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('clean failed')) - - const result = await gitCleanFdx() - expect(result).toBe(false) - }) - }) - - describe('gitResetHard', () => { - it('resets to a specific ref', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitResetHard('origin/main') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['reset', '--hard', 'origin/main'], - expect.any(Object), - ) - }) - - it('returns false when reset spawn rejects (lines 525-527)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('reset failed')) - - const result = await gitResetHard('origin/main') - expect(result).toBe(false) - }) - }) - - describe('gitEnsureIdentity', () => { - it('sets git user name and email', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - await gitEnsureIdentity('Test User', 'test@example.com') - expect(spawn).toHaveBeenCalledWith( - 'git', - ['config', '--get', 'user.email'], - expect.any(Object), - ) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['config', '--get', 'user.name'], - expect.any(Object), - ) - }) - - it('handles config set when get fails and value differs (lines 432-450)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // Reject `git config --get` so configValue stays undefined != desired value; - // then `git config <prop> <value>` resolves successfully. - spawn.mockImplementation((_cmd: unknown, args: unknown) => { - if (args?.[1] === '--get') { - return Promise.reject(new Error('not set')) as unknown - } - return Promise.resolve({ status: 0, stdout: '', stderr: '' }) as unknown - }) - - await gitEnsureIdentity('Test User', 'test@example.com') - // Verify the set calls happened. - expect(spawn).toHaveBeenCalledWith( - 'git', - ['config', 'user.email', 'test@example.com'], - expect.any(Object), - ) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['config', 'user.name', 'Test User'], - expect.any(Object), - ) - }) - - it('logs failure when config set rejects (lines 447-450)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // Reject get; reject set. - spawn.mockImplementation((_cmd: unknown, args: unknown) => { - if (args?.[1] === '--get') { - return Promise.reject(new Error('not set')) as unknown - } - return Promise.reject(new Error('config set failed')) as unknown - }) - - // No throw expected; promises are awaited via Promise.allSettled. - await expect( - gitEnsureIdentity('Test User', 'test@example.com'), - ).resolves.toBeUndefined() - }) - }) - - describe('getRepoInfo', () => { - it('returns owner and repo from remote URL', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'git@github.com:socketdev/socket-cli.git', - stderr: '', - } as unknown) - - const result = await getRepoInfo('/test/dir') - expect(result).toEqual({ owner: 'socketdev', repo: 'socket-cli' }) - }) - - it('returns undefined when spawn fails', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Not a git repo')) - - const result = await getRepoInfo('/test/dir') - expect(result).toBeUndefined() - }) - - it('returns undefined when spawn returns null', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue(undefined as unknown) - - const result = await getRepoInfo('/test/dir') - expect(result).toBeUndefined() - }) - - it('returns undefined for unmatched git remote URL format (lines 142-145)', async () => { - // Some private SSH config (e.g. host alias `myhost:owner/repo`) doesn't - // match the parser's regex; the function falls through and the debug - // logs fire. - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'some-completely-unrecognised-url-format', - stderr: '', - } as unknown) - - const result = await getRepoInfo('/test/dir') - expect(result).toBeUndefined() - }) - }) - - describe('getRepoName', () => { - it('returns repo name from remote URL', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'git@github.com:socketdev/socket-cli.git', - stderr: '', - } as unknown) - - const result = await getRepoName('/test/dir') - expect(result).toBe('socket-cli') - }) - - it('returns default when no repo info', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Not a git repo')) - - const result = await getRepoName('/test/dir') - // Should return the default repository name. - expect(typeof result).toBe('string') - }) - }) - - describe('getRepoOwner', () => { - it('returns owner from remote URL', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'git@github.com:socketdev/socket-cli.git', - stderr: '', - } as unknown) - - const result = await getRepoOwner('/test/dir') - expect(result).toBe('socketdev') - }) - - it('returns undefined when no repo info', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Not a git repo')) - - const result = await getRepoOwner('/test/dir') - expect(result).toBeUndefined() - }) - }) - - describe('detectDefaultBranch', () => { - it('returns main when it exists locally', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await detectDefaultBranch('/test/dir') - expect(result).toBe('main') - }) - - it('checks common branch names in order', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // All local branches fail. - spawn - .mockRejectedValueOnce(new Error('main not found')) - .mockRejectedValueOnce(new Error('master not found')) // inclusive-language: external-api - .mockRejectedValueOnce(new Error('develop not found')) - .mockRejectedValueOnce(new Error('trunk not found')) - .mockRejectedValueOnce(new Error('default not found')) - // First remote succeeds. - .mockResolvedValueOnce({ - status: 0, - stdout: 'refs/heads/main', - stderr: '', - } as unknown) - - const result = await detectDefaultBranch('/test/dir') - expect(result).toBe('main') - }) - - it('falls back to SOCKET_DEFAULT_BRANCH when nothing matches (line 223)', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - // All local AND remote checks reject — exhaust both passes. - spawn.mockRejectedValue(new Error('not found')) - - const result = await detectDefaultBranch('/test/dir') - expect(result).toBe('socket-default-branch') - }) - }) - - describe('gitDeleteRemoteBranch', () => { - it('deletes a remote branch', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitDeleteRemoteBranch('old-feature') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['push', 'origin', '--delete', 'old-feature'], - expect.any(Object), - ) - }) - - it('returns false when branch does not exist', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Branch not found')) - - const result = await gitDeleteRemoteBranch('nonexistent') - expect(result).toBe(false) - }) - }) - - describe('gitLocalBranchExists', () => { - it('returns true when branch exists', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - const result = await gitLocalBranchExists('main') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['show-ref', '--quiet', 'refs/heads/main'], - expect.any(Object), - ) - }) - - it('returns false when branch does not exist', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Branch not found')) - - const result = await gitLocalBranchExists('nonexistent') - expect(result).toBe(false) - }) - }) - - describe('gitRemoteBranchExists', () => { - it('returns true when remote branch exists', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'abc123\trefs/heads/main', - stderr: '', - } as unknown) - - const result = await gitRemoteBranchExists('main') - expect(result).toBe(true) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['ls-remote', '--heads', 'origin', 'main'], - expect.any(Object), - ) - }) - - it('returns false when remote branch does not exist', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: '', - stderr: '', - } as unknown) - - const result = await gitRemoteBranchExists('nonexistent') - expect(result).toBe(false) - }) - - it('returns false when spawn fails', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Network error')) - - const result = await gitRemoteBranchExists('main') - expect(result).toBe(false) - }) - }) - - describe('gitResetAndClean', () => { - it('calls gitResetHard and gitCleanFdx', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ status: 0, stdout: '', stderr: '' } as unknown) - - await gitResetAndClean('main', '/test/dir') - expect(spawn).toHaveBeenCalledWith( - 'git', - ['reset', '--hard', 'main'], - expect.any(Object), - ) - expect(spawn).toHaveBeenCalledWith( - 'git', - ['clean', '-fdx'], - expect.any(Object), - ) - }) - }) - - describe('gitUnstagedModifiedFiles', () => { - it('returns list of modified files', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockResolvedValue({ - status: 0, - stdout: 'file1.txt\nfile2.txt\n', - stderr: '', - } as unknown) - - const result = await gitUnstagedModifiedFiles('/test/dir') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toContain('file1.txt') - expect(result.data).toContain('file2.txt') - } - }) - - it('returns error when spawn fails', async () => { - const { spawn } = vi.mocked(await import('@socketsecurity/lib-stable/process/spawn/child')) - spawn.mockRejectedValue(new Error('Git error')) - - const result = await gitUnstagedModifiedFiles('/test/dir') - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Git Error') - } - }) - }) - - describe('getGitPath', () => { - it('throws a helpful error when whichReal returns null (line 58)', async () => { - // Reset modules so the module-level _gitPath cache is fresh and - // whichReal can be mocked to return null without other tests - // having already filled the cache. - vi.resetModules() - vi.doMock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: vi.fn().mockResolvedValue(undefined), - })) - const { getGitPath: freshGetGitPath } = - await import('../../../../src/util/git/operations.mts') - await expect(freshGetGitPath()).rejects.toThrow(/whichReal returned null/) - vi.doUnmock('@socketsecurity/lib-stable/bin/which') - vi.resetModules() - }) - - it('throws when whichReal returns multiple matches', async () => { - vi.resetModules() - vi.doMock('@socketsecurity/lib-stable/bin/which', () => ({ - whichReal: vi.fn().mockResolvedValue(['/usr/bin/git', '/opt/bin/git']), - })) - const { getGitPath: freshGetGitPath } = - await import('../../../../src/util/git/operations.mts') - await expect(freshGetGitPath()).rejects.toThrow(/multiple matches/) - vi.doUnmock('@socketsecurity/lib-stable/bin/which') - vi.resetModules() - }) - }) -}) diff --git a/packages/cli/test/unit/util/git/provider-factory.test.mts b/packages/cli/test/unit/util/git/provider-factory.test.mts deleted file mode 100644 index a5a0a6aec..000000000 --- a/packages/cli/test/unit/util/git/provider-factory.test.mts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Unit tests for createPrProvider. - * - * Picks GitHubProvider or GitLabProvider based on the remote URL. - * - * Test Coverage: - * - * - Gitlab.com remote → GitLabProvider - * - GITLAB_HOST env set → GitLabProvider - * - Generic 'gitlab' substring → GitLabProvider - * - Github remote → GitHubProvider (default) - * - GetGitRemoteUrlSync returns trimmed lowercase string on success - * - GetGitRemoteUrlSync returns '' on non-zero exit - * - GetGitRemoteUrlSync returns '' on spawn throw - * - * Related Files: - * - * - Src/util/git/provider-factory.mts - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() })) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawnSync: mockSpawnSync, -})) - -vi.mock('../../../../src/util/git/github-provider.mts', () => ({ - GitHubProvider: class GitHubProviderMock { - readonly kind = 'github' as const - }, -})) - -vi.mock('../../../../src/util/git/gitlab-provider.mts', () => ({ - GitLabProvider: class GitLabProviderMock { - readonly kind = 'gitlab' as const - }, -})) - -const { createPrProvider, getGitRemoteUrlSync } = - await import('../../../../src/util/git/provider-factory.mts') - -const savedGitlabHost = process.env['GITLAB_HOST'] - -beforeEach(() => { - vi.clearAllMocks() - delete process.env['GITLAB_HOST'] -}) - -afterEach(() => { - if (savedGitlabHost === undefined) { - delete process.env['GITLAB_HOST'] - } else { - process.env['GITLAB_HOST'] = savedGitlabHost - } -}) - -describe('createPrProvider', () => { - it('returns GitLabProvider when remote points at gitlab.com', () => { - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: 'git@gitlab.com:org/repo.git\n', - }) - const provider = createPrProvider() as { kind: string } - expect(provider.kind).toBe('gitlab') - }) - - it('returns GitLabProvider when GITLAB_HOST env is set', () => { - process.env['GITLAB_HOST'] = 'gitlab.example.com' - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: 'git@github.com:org/repo.git\n', - }) - const provider = createPrProvider() as { kind: string } - expect(provider.kind).toBe('gitlab') - }) - - it('returns GitLabProvider when remote URL contains "gitlab"', () => { - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: 'git@self-hosted-gitlab.example.com:org/repo.git\n', - }) - const provider = createPrProvider() as { kind: string } - expect(provider.kind).toBe('gitlab') - }) - - it('returns GitHubProvider for github remotes', () => { - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: 'git@github.com:org/repo.git\n', - }) - const provider = createPrProvider() as { kind: string } - expect(provider.kind).toBe('github') - }) - - it('falls back to GitHubProvider when remote is unknown', () => { - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: 'git@bitbucket.example.com:org/repo.git\n', - }) - const provider = createPrProvider() as { kind: string } - expect(provider.kind).toBe('github') - }) -}) - -describe('getGitRemoteUrlSync', () => { - it('returns trimmed lowercased URL on success', () => { - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: ' HTTPS://Github.COM/Org/Repo.git \n', - }) - expect(getGitRemoteUrlSync()).toBe('https://github.com/org/repo.git') - }) - - it('returns empty string when git config exits non-zero', () => { - mockSpawnSync.mockReturnValue({ status: 1, stdout: '' }) - expect(getGitRemoteUrlSync()).toBe('') - }) - - it('returns empty string when stdout is empty even on status 0', () => { - mockSpawnSync.mockReturnValue({ status: 0, stdout: '' }) - expect(getGitRemoteUrlSync()).toBe('') - }) - - it('returns empty string when spawnSync throws', () => { - mockSpawnSync.mockImplementation(() => { - throw new Error('git not found') - }) - expect(getGitRemoteUrlSync()).toBe('') - }) -}) diff --git a/packages/cli/test/unit/util/git/providers.test.mts b/packages/cli/test/unit/util/git/providers.test.mts deleted file mode 100644 index bd60cf527..000000000 --- a/packages/cli/test/unit/util/git/providers.test.mts +++ /dev/null @@ -1,657 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for git provider detection. - * - * Purpose: Tests git provider detection (GitHub, GitLab, Bitbucket). Validates - * provider identification from remote URLs. - * - * Test Coverage: - GitHub detection - GitLab detection - Bitbucket detection - - * Remote URL parsing - SSH vs HTTPS URLs - Provider-specific features. - * - * Testing Approach: Tests git provider identification and URL parsing logic. - * - * Related Files: - util/git/providers.mts (implementation) - */ - -import os from 'node:os' -import path from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockGetOctokit = vi.hoisted(() => vi.fn()) -const mockGetOctokitGraphql = vi.hoisted(() => vi.fn()) -const mockCacheFetch = vi.hoisted(() => vi.fn()) -const mockGitDeleteRemoteBranch = vi.hoisted(() => vi.fn()) -const mockWithGitHubRetry = vi.hoisted(() => - vi.fn(async (operation: () => Promise<unknown>) => { - const result = await operation() - return { ok: true, data: result } - }), -) - -// Mock dependencies. -const mockCacheDir = path.join(os.tmpdir(), 'socket-cache') -vi.mock('../../../../src/constants/paths.mts', () => ({ - SOCKET_CLI_CACHE_DIR: mockCacheDir, - getGithubCachePath: () => path.join(mockCacheDir, 'github'), -})) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - cacheFetch: mockCacheFetch, - getOctokit: mockGetOctokit, - getOctokitGraphql: mockGetOctokitGraphql, - handleGitHubApiError: vi.fn((e: unknown, context: string) => ({ - ok: false, - message: 'GitHub API error', - cause: `Error while ${context}: ${e instanceof Error ? e.message : String(e)}`, - })), - handleGraphqlError: vi.fn((_e: unknown, context: string) => ({ - ok: false, - message: 'GitHub GraphQL error', - cause: `GraphQL error while ${context}`, - })), - withGitHubRetry: mockWithGitHubRetry, -})) - -vi.mock('../../../../src/util/git/operations.mts', () => ({ - gitDeleteRemoteBranch: mockGitDeleteRemoteBranch, -})) - -vi.mock('@gitbeaker/rest', () => ({ - Gitlab: vi.fn(function MockGitlab() { - return { - MergeRequests: { - create: vi.fn(), - show: vi.fn(), - rebase: vi.fn(), - all: vi.fn(), - }, - MergeRequestNotes: { - create: vi.fn(), - }, - } - }), -})) - -import { GitHubProvider } from '../../../../src/util/git/github-provider.mts' -import { GitLabProvider } from '../../../../src/util/git/gitlab-provider.mts' - -describe('provider-factory', () => { - beforeEach(() => { - vi.clearAllMocks() - delete process.env.GITLAB_HOST - delete process.env.GITLAB_TOKEN - }) - - describe('createPrProvider', () => { - it('returns GitLabProvider when GITLAB_HOST is set', async () => { - const providerFactory = - await import('../../../../src/util/git/provider-factory.mts') - vi.spyOn(providerFactory, 'getGitRemoteUrlSync').mockReturnValue( - 'https://github.com/owner/repo.git', - ) - - process.env.GITLAB_HOST = 'https://gitlab.example.com' - process.env.GITLAB_TOKEN = 'test-token' - - const provider = providerFactory.createPrProvider() - expect(provider.getProviderName()).toBe('gitlab') - expect(provider.supportsGraphQL()).toBe(false) - }) - - it('falls back to GitHubProvider when git command fails', async () => { - const providerFactory = - await import('../../../../src/util/git/provider-factory.mts') - vi.spyOn(providerFactory, 'getGitRemoteUrlSync').mockReturnValue('') - - const provider = providerFactory.createPrProvider() - expect(provider.getProviderName()).toBe('github') - expect(provider.supportsGraphQL()).toBe(true) - }) - - it('falls back to GitHubProvider for empty remote', async () => { - const providerFactory = - await import('../../../../src/util/git/provider-factory.mts') - vi.spyOn(providerFactory, 'getGitRemoteUrlSync').mockReturnValue('') - - const provider = providerFactory.createPrProvider() - expect(provider.getProviderName()).toBe('github') - expect(provider.supportsGraphQL()).toBe(true) - }) - }) -}) - -describe('GitHubProvider', () => { - let mockOctokit: unknown - let mockOctokitGraphql: unknown - - beforeEach(() => { - mockOctokit = { - pulls: { - create: vi.fn(), - get: vi.fn(), - }, - repos: { - merge: vi.fn(), - }, - issues: { - createComment: vi.fn(), - }, - } - - mockOctokitGraphql = vi.fn() - - // Clear mocks AFTER creating new mock objects. - vi.clearAllMocks() - - // Set up default mock return values. - mockGetOctokit.mockReturnValue(mockOctokit) - mockGetOctokitGraphql.mockReturnValue(mockOctokitGraphql) - }) - - describe('createPr', () => { - it('creates PR successfully', async () => { - // Set up mock before creating provider. - mockOctokit.pulls.create.mockResolvedValue({ - data: { - number: 123, - html_url: 'https://github.com/owner/repo/pull/123', - state: 'open', - }, - }) - - const provider = new GitHubProvider() - const result = await provider.createPr({ - owner: 'owner', - repo: 'repo', - title: 'Test PR', - head: 'feature-branch', - base: 'main', - body: 'Test body', - }) - - expect(mockGetOctokit).toHaveBeenCalled() - expect(result).toEqual({ - number: 123, - url: 'https://github.com/owner/repo/pull/123', - state: 'open', - }) - }) - - it('handles merged PR state', async () => { - // Set up mock before creating provider. - mockOctokit.pulls.create.mockResolvedValue({ - data: { - number: 456, - html_url: 'https://github.com/owner/repo/pull/456', - state: 'closed', - merged_at: '2024-01-01T00:00:00Z', - }, - }) - - const provider = new GitHubProvider() - const result = await provider.createPr({ - owner: 'owner', - repo: 'repo', - title: 'Test PR', - head: 'feature', - base: 'main', - body: 'Test', - }) - - expect(result.state).toBe('merged') - }) - }) - - describe('addComment', () => { - it('adds comment successfully', async () => { - // Set up mock before creating provider. - mockOctokit.issues.createComment.mockResolvedValue({}) - - const provider = new GitHubProvider() - await provider.addComment({ - owner: 'owner', - repo: 'repo', - prNumber: 123, - body: 'Test comment', - }) - - expect(mockOctokit.issues.createComment).toHaveBeenCalledWith({ - owner: 'owner', - repo: 'repo', - issue_number: 123, - body: 'Test comment', - }) - }) - }) - - describe('listPrs', () => { - it('lists PRs with pagination', async () => { - const mockResponse = { - repository: { - pullRequests: { - pageInfo: { - hasNextPage: false, - endCursor: undefined, - }, - nodes: [ - { - number: 1, - title: 'Test PR 1', - author: { login: 'user1' }, - headRefName: 'feature-1', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - { - number: 2, - title: 'Test PR 2', - author: { login: 'user2' }, - headRefName: 'feature-2', - baseRefName: 'main', - state: 'MERGED', - mergeStateStatus: 'CLEAN', - }, - ], - }, - }, - } - - mockCacheFetch.mockResolvedValue(mockResponse) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(results).toHaveLength(2) - expect(results[0]!.number).toBe(1) - expect(results[1]!.number).toBe(2) - }) - }) - - describe('deleteBranch', () => { - it('deletes branch successfully', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(true) - - const provider = new GitHubProvider() - const result = await provider.deleteBranch('feature-branch') - - expect(result).toBe(true) - expect(mockGitDeleteRemoteBranch).toHaveBeenCalledWith('feature-branch') - }) - - it('handles deletion failure gracefully', async () => { - mockGitDeleteRemoteBranch.mockResolvedValue(false) - - const provider = new GitHubProvider() - const result = await provider.deleteBranch('feature-branch') - - expect(result).toBe(false) - }) - - it('handles deletion exception gracefully', async () => { - mockGitDeleteRemoteBranch.mockRejectedValue(new Error('Branch not found')) - - const provider = new GitHubProvider() - const result = await provider.deleteBranch('nonexistent-branch') - - expect(result).toBe(false) - }) - }) - - describe('updatePr', () => { - it('updates PR by merging base into head', async () => { - mockOctokit.repos.merge.mockResolvedValue({}) - mockOctokit.pulls.get.mockResolvedValue({ - data: { mergeable_state: 'clean' }, - }) - - const provider = new GitHubProvider() - await provider.updatePr({ - owner: 'owner', - repo: 'repo', - prNumber: 123, - head: 'feature-branch', - base: 'main', - }) - - expect(mockOctokit.repos.merge).toHaveBeenCalledWith({ - owner: 'owner', - repo: 'repo', - head: 'main', - base: 'feature-branch', - }) - }) - - it('adds conflict comment when PR becomes dirty', async () => { - mockOctokit.repos.merge.mockResolvedValue({}) - mockOctokit.pulls.get.mockResolvedValue({ - data: { mergeable_state: 'dirty' }, - }) - mockOctokit.issues.createComment.mockResolvedValue({}) - - const provider = new GitHubProvider() - await provider.updatePr({ - owner: 'owner', - repo: 'repo', - prNumber: 456, - head: 'feature-branch', - base: 'main', - }) - - expect(mockOctokit.issues.createComment).toHaveBeenCalledWith( - expect.objectContaining({ - issue_number: 456, - body: expect.stringContaining('merge conflicts'), - }), - ) - }) - - it('throws when merge fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'Merge failed', - cause: 'Conflict', - }) - - const provider = new GitHubProvider() - await expect( - provider.updatePr({ - owner: 'owner', - repo: 'repo', - prNumber: 789, - head: 'feature', - base: 'main', - }), - ).rejects.toThrow() - }) - - it('throws when PR details fetch fails', async () => { - mockWithGitHubRetry - .mockResolvedValueOnce({ ok: true, data: {} }) - .mockResolvedValueOnce({ - ok: false, - message: 'PR not found', - }) - - const provider = new GitHubProvider() - await expect( - provider.updatePr({ - owner: 'owner', - repo: 'repo', - prNumber: 999, - head: 'feature', - base: 'main', - }), - ).rejects.toThrow() - }) - }) - - describe('createPr error handling', () => { - it('throws when API call fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'Failed to create PR', - cause: 'Repository not found', - }) - - const provider = new GitHubProvider() - await expect( - provider.createPr({ - owner: 'owner', - repo: 'nonexistent', - title: 'Test', - head: 'feature', - base: 'main', - body: 'Body', - }), - ).rejects.toThrow('Repository not found') - }) - - it('handles closed PR state', async () => { - mockOctokit.pulls.create.mockResolvedValue({ - data: { - number: 789, - html_url: 'https://github.com/owner/repo/pull/789', - state: 'closed', - merged_at: undefined, - }, - }) - - const provider = new GitHubProvider() - const result = await provider.createPr({ - owner: 'owner', - repo: 'repo', - title: 'Test', - head: 'feature', - base: 'main', - body: 'Body', - }) - - expect(result.state).toBe('closed') - }) - }) - - describe('addComment error handling', () => { - it('throws when comment fails', async () => { - mockWithGitHubRetry.mockResolvedValueOnce({ - ok: false, - message: 'Comment failed', - }) - - const provider = new GitHubProvider() - await expect( - provider.addComment({ - owner: 'owner', - repo: 'repo', - prNumber: 123, - body: 'Test', - }), - ).rejects.toThrow('Comment failed') - }) - }) - - describe('listPrs advanced scenarios', () => { - it('filters PRs by author', async () => { - const mockResponse = { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - number: 1, - title: 'PR by user1', - author: { login: 'user1' }, - headRefName: 'f1', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - { - number: 2, - title: 'PR by user2', - author: { login: 'user2' }, - headRefName: 'f2', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - ], - }, - }, - } - - mockCacheFetch.mockResolvedValue(mockResponse) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - author: 'user1', - }) - - expect(results).toHaveLength(1) - expect(results[0]!.author).toBe('user1') - }) - - it('handles PRs without author', async () => { - const mockResponse = { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - number: 1, - title: 'PR without author', - // No author field. - headRefName: 'f1', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - ], - }, - }, - } - - mockCacheFetch.mockResolvedValue(mockResponse) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(results).toHaveLength(1) - expect(results[0]!.author).toBe('<unknown>') - }) - - it('handles specific states filter', async () => { - const mockResponse = { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: undefined }, - nodes: [ - { - number: 1, - title: 'Open PR', - author: { login: 'user' }, - headRefName: 'f1', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - ], - }, - }, - } - - mockCacheFetch.mockResolvedValue(mockResponse) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - states: 'open', - }) - - expect(results).toHaveLength(1) - }) - - it('exits early when ghsaId provided and matches found', async () => { - const mockResponse = { - repository: { - pullRequests: { - pageInfo: { hasNextPage: true, endCursor: 'cursor' }, - nodes: [ - { - number: 1, - title: 'Fix GHSA-1234', - author: { login: 'user' }, - headRefName: 'socket/fix/GHSA-1234', - baseRefName: 'main', - state: 'OPEN', - mergeStateStatus: 'CLEAN', - }, - ], - }, - }, - } - - mockCacheFetch.mockResolvedValue(mockResponse) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - ghsaId: 'GHSA-1234', - }) - - // Should have exited early after first page due to ghsaId optimization. - expect(mockCacheFetch).toHaveBeenCalledTimes(1) - expect(results).toHaveLength(1) - }) - - it('handles empty repository response', async () => { - mockCacheFetch.mockResolvedValue({ - repository: { - pullRequests: undefined, - }, - }) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(results).toHaveLength(0) - }) - - it('handles GraphQL error gracefully', async () => { - mockCacheFetch.mockRejectedValue(new Error('GraphQL error')) - - const provider = new GitHubProvider() - const results = await provider.listPrs({ - owner: 'owner', - repo: 'repo', - }) - - expect(results).toHaveLength(0) - }) - }) - - describe('metadata', () => { - it('returns correct provider name', () => { - const provider = new GitHubProvider() - expect(provider.getProviderName()).toBe('github') - }) - - it('reports GraphQL support', () => { - const provider = new GitHubProvider() - expect(provider.supportsGraphQL()).toBe(true) - }) - }) -}) - -describe('GitLabProvider', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.GITLAB_TOKEN = 'test-token' - }) - - describe('metadata', () => { - it('returns correct provider name', () => { - const provider = new GitLabProvider() - expect(provider.getProviderName()).toBe('gitlab') - }) - - it('does not report GraphQL support', () => { - const provider = new GitLabProvider() - expect(provider.supportsGraphQL()).toBe(false) - }) - }) -}) diff --git a/packages/cli/test/unit/util/npm/package-arg.test.mts b/packages/cli/test/unit/util/npm/package-arg.test.mts deleted file mode 100644 index 7eb8f2c86..000000000 --- a/packages/cli/test/unit/util/npm/package-arg.test.mts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Unit tests for npm package argument parsing. - * - * Purpose: Tests npm package argument parsing. Validates package specifier - * parsing compatible with npm-package-arg. - * - * Test Coverage: - Package name parsing - Version range parsing - Git URL - * parsing - Tarball URL parsing - File path parsing - Scope handling. - * - * Testing Approach: Tests package specifier parser with various npm formats. - * - * Related Files: - util/npm/package-arg.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { safeNpa } from '../../../../src/util/npm/package-arg.mts' - -// Mock npm-package-arg. -const mockDefault = vi.hoisted(() => vi.fn()) - -vi.mock('npm-package-arg', () => ({ - default: mockDefault, -})) - -describe('npm-package-arg utilities', () => { - describe('safeNpa', () => { - it('returns parsed package spec when valid', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'registry', - name: 'lodash', - rawSpec: '4.17.21', - registry: true, - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('lodash@4.17.21') - - expect(result).toEqual(mockResult) - expect(mockNpa).toHaveBeenCalledWith('lodash@4.17.21') - }) - - it('passes through all arguments to npm-package-arg', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'registry', - name: '@scope/package', - rawSpec: '1.0.0', - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('@scope/package@1.0.0', '/some/path') - - expect(result).toEqual(mockResult) - expect(mockNpa).toHaveBeenCalledWith('@scope/package@1.0.0', '/some/path') - }) - - it('returns undefined when npm-package-arg throws', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - mockNpa.mockImplementation(() => { - throw new Error('Invalid package spec') - }) - - const result = safeNpa('invalid::spec') - - expect(result).toBeUndefined() - expect(mockNpa).toHaveBeenCalledWith('invalid::spec') - }) - - it('handles file spec', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'file', - name: undefined, - spec: 'file:../local-package', - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('file:../local-package') - - expect(result).toEqual(mockResult) - }) - - it('handles git spec', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'git', - name: undefined, - spec: 'git+https://github.com/user/repo.git', - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('git+https://github.com/user/repo.git') - - expect(result).toEqual(mockResult) - }) - - it('handles tag spec', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'tag', - name: 'express', - rawSpec: 'latest', - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('express@latest') - - expect(result).toEqual(mockResult) - }) - - it('handles range spec', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'range', - name: 'react', - rawSpec: '^18.0.0', - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('react@^18.0.0') - - expect(result).toEqual(mockResult) - }) - - it('handles alias spec', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - const mockResult = { - type: 'alias', - name: 'my-lodash', - subSpec: { - type: 'registry', - name: 'lodash', - }, - } - mockNpa.mockReturnValue(mockResult) - - const result = safeNpa('my-lodash@npm:lodash@4.17.21') - - expect(result).toEqual(mockResult) - }) - - it('returns undefined for undefined input', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - mockNpa.mockImplementation(() => { - throw new TypeError('Cannot read property of undefined') - }) - - const result = safeNpa(undefined as unknown) - - expect(result).toBeUndefined() - }) - - it('returns undefined for null input', async () => { - const npmPackageArg = (await import('npm-package-arg')).default - const mockNpa = vi.mocked(npmPackageArg) - - mockNpa.mockImplementation(() => { - throw new TypeError('Cannot read property of null') - }) - - const result = safeNpa(undefined as unknown) - - expect(result).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/npm/paths.test.mts b/packages/cli/test/unit/util/npm/paths.test.mts deleted file mode 100644 index 9df0b6dac..000000000 --- a/packages/cli/test/unit/util/npm/paths.test.mts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Unit tests for npm path utilities. - * - * Purpose: Tests npm-specific path utilities. Validates node_modules, - * package.json, and cache path resolution. - * - * Test Coverage: - * - * - Node_modules path resolution - * - Package.json location - * - Npm cache directory - * - Global package paths - * - Workspace root detection - * - * Testing Approach: Tests npm path conventions and resolution logic. - * - * Related Files: - * - * - Util/npm/paths.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as ModuleModule from 'node:module' -import type * as PathsModule from '../../../../src/util/npm/paths.mts' - -const mockExistsSync = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - default: { - existsSync: mockExistsSync, - }, -})) - -vi.mock('node:module', async importOriginal => { - const actual = await importOriginal<typeof ModuleModule>() - return { - ...actual, - createRequire: vi.fn(), - default: { - ...actual.default, - createRequire: vi.fn(), - }, - } -}) - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/fs/path-resolve.mts', () => ({ - findBinPathDetailsSync: vi.fn(), - findNpmDirPathSync: vi.fn(), -})) - -vi.mock('../../../../src/env/socket-cli-npm-path.mts', () => ({ - SOCKET_CLI_NPM_PATH: undefined, -})) - -vi.mock('../../../../src/constants/github.mts', () => ({ - SOCKET_CLI_ISSUES_URL: 'https://github.com/SocketDev/socket-cli/issues', -})) - -vi.mock('../../../../src/constants/packages.mts', () => ({ - NODE_MODULES: 'node_modules', -})) - -describe('npm-paths utilities', () => { - let originalExit: typeof process.exit - let getNpmBinPath: (typeof PathsModule)['getNpmBinPath'] - let getNpxBinPath: (typeof PathsModule)['getNpxBinPath'] - - beforeEach(async () => { - vi.clearAllMocks() - vi.resetModules() - - // Store original process.exit. - originalExit = process.exit - // Mock process.exit to prevent actual exits. - process.exit = vi.fn((code?: number) => { - throw new Error(`process.exit(${code})`) - }) as unknown - - // Re-import functions after module reset to clear caches. - const npmPaths = await import('../../../../src/util/npm/paths.mts') - getNpmBinPath = npmPaths.getNpmBinPath - getNpxBinPath = npmPaths.getNpxBinPath - }) - - afterEach(() => { - // Restore original process.exit. - process.exit = originalExit - vi.restoreAllMocks() - vi.resetModules() - }) - - describe('getNpmBinPath', () => { - it('returns npm bin path when found', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/npm', - }) - - const result = getNpmBinPath() - - // Normalize path separators for cross-platform compatibility. - expect(result?.replace(/\\/g, '/')).toBe('/usr/local/bin/npm') - expect(findBinPathDetailsSync).toHaveBeenCalledWith('npm') - }) - - it('exits with error when npm not found', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: undefined, - }) - - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - - expect(() => getNpmBinPath()).toThrow('process.exit(127)') - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Socket unable to locate npm'), - ) - }) - - it('caches the result', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/npm', - }) - - const result1 = getNpmBinPath() - const result2 = getNpmBinPath() - - expect(result1).toBe(result2) - expect(findBinPathDetailsSync).toHaveBeenCalledTimes(1) - }) - }) - - describe('getNpxBinPath', () => { - it('returns pnpm exec bin path when found', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/npx', - }) - - const result = getNpxBinPath() - - // Normalize path separators for cross-platform compatibility. - expect(result?.replace(/\\/g, '/')).toBe('/usr/local/bin/npx') - expect(findBinPathDetailsSync).toHaveBeenCalledWith('npx') - }) - - it('exits with error when pnpm exec not found', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: undefined, - }) - - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - - expect(() => getNpxBinPath()).toThrow('process.exit(127)') - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Socket unable to locate npx'), - ) - }) - - it('caches the result', async () => { - const { findBinPathDetailsSync } = vi.mocked( - await import('../../../../src/util/fs/path-resolve.mts'), - ) - findBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/npx', - }) - - const result1 = getNpxBinPath() - const result2 = getNpxBinPath() - - expect(result1).toBe(result2) - expect(findBinPathDetailsSync).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/cli/test/unit/util/organization.test.mts b/packages/cli/test/unit/util/organization.test.mts deleted file mode 100644 index dde38f4d1..000000000 --- a/packages/cli/test/unit/util/organization.test.mts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Unit tests for organization utilities. - * - * Purpose: Tests the organization helper functions. - * - * Test Coverage: - getEnterpriseOrgs filtering - getOrgSlugs extraction - - * hasEnterpriseOrgPlan check. - * - * Related Files: - util/organization.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - getEnterpriseOrgs, - getOrgSlugs, - hasEnterpriseOrgPlan, -} from '../../../src/util/organization.mts' - -describe('organization utilities', () => { - const mockOrgs = [ - { slug: 'free-org', name: 'Free Org', plan: 'free' }, - { slug: 'enterprise-org', name: 'Enterprise Org', plan: 'enterprise' }, - { slug: 'pro-org', name: 'Pro Org', plan: 'pro' }, - { - slug: 'enterprise-plus', - name: 'Enterprise Plus', - plan: 'enterprise-plus', - }, - ] as unknown - - describe('getEnterpriseOrgs', () => { - it('returns only enterprise plan organizations', () => { - const result = getEnterpriseOrgs(mockOrgs) - - expect(result).toHaveLength(2) - expect(result.map(o => o.slug)).toEqual([ - 'enterprise-org', - 'enterprise-plus', - ]) - }) - - it('returns empty array when no enterprise orgs', () => { - const nonEnterpriseOrgs = [ - { slug: 'free-org', plan: 'free' }, - { slug: 'pro-org', plan: 'pro' }, - ] as unknown - - const result = getEnterpriseOrgs(nonEnterpriseOrgs) - - expect(result).toHaveLength(0) - }) - - it('returns empty array for empty input', () => { - const result = getEnterpriseOrgs([]) - - expect(result).toHaveLength(0) - }) - }) - - describe('getOrgSlugs', () => { - it('extracts slugs from organizations', () => { - const result = getOrgSlugs(mockOrgs) - - expect(result).toEqual([ - 'free-org', - 'enterprise-org', - 'pro-org', - 'enterprise-plus', - ]) - }) - - it('returns empty array for empty input', () => { - const result = getOrgSlugs([]) - - expect(result).toEqual([]) - }) - - it('handles single organization', () => { - const result = getOrgSlugs([ - { slug: 'single-org', plan: 'pro' }, - ] as unknown) - - expect(result).toEqual(['single-org']) - }) - }) - - describe('hasEnterpriseOrgPlan', () => { - it('returns true when enterprise org exists', () => { - const result = hasEnterpriseOrgPlan(mockOrgs) - - expect(result).toBe(true) - }) - - it('returns false when no enterprise org exists', () => { - const nonEnterpriseOrgs = [ - { slug: 'free-org', plan: 'free' }, - { slug: 'pro-org', plan: 'pro' }, - ] as unknown - - const result = hasEnterpriseOrgPlan(nonEnterpriseOrgs) - - expect(result).toBe(false) - }) - - it('returns false for empty array', () => { - const result = hasEnterpriseOrgPlan([]) - - expect(result).toBe(false) - }) - - it('matches partial enterprise plan names', () => { - const orgsWithEnterprisePlus = [ - { slug: 'org1', plan: 'enterprise-plus' }, - ] as unknown - - const result = hasEnterpriseOrgPlan(orgsWithEnterprisePlus) - - expect(result).toBe(true) - }) - }) -}) diff --git a/packages/cli/test/unit/util/output/ambient-mode.test.mts b/packages/cli/test/unit/util/output/ambient-mode.test.mts deleted file mode 100644 index cb215cb82..000000000 --- a/packages/cli/test/unit/util/output/ambient-mode.test.mts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Unit tests for ambient machine-output mode tracking. - * - * Module-scoped let updated by meow at argv-parse time. Tests verify - * set/get/reset and the delegation to isMachineOutputMode. - * - * Related Files: - src/util/output/ambient-mode.mts. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - getMachineOutputMode, - resetMachineOutputMode, - setMachineOutputMode, -} from '../../../../src/util/output/ambient-mode.mts' - -describe('ambient-mode', () => { - beforeEach(() => { - resetMachineOutputMode() - }) - - afterEach(() => { - resetMachineOutputMode() - }) - - it('starts as false', () => { - expect(getMachineOutputMode()).toBe(false) - }) - - it('switches to true when --json is set', () => { - setMachineOutputMode({ json: true }) - expect(getMachineOutputMode()).toBe(true) - }) - - it('switches to true when --markdown is set', () => { - setMachineOutputMode({ markdown: true }) - expect(getMachineOutputMode()).toBe(true) - }) - - it('switches to true when --quiet is set', () => { - setMachineOutputMode({ quiet: true }) - expect(getMachineOutputMode()).toBe(true) - }) - - it('stays false when no flags are set', () => { - setMachineOutputMode({}) - expect(getMachineOutputMode()).toBe(false) - }) - - it('reset returns to false after being set', () => { - setMachineOutputMode({ json: true }) - expect(getMachineOutputMode()).toBe(true) - resetMachineOutputMode() - expect(getMachineOutputMode()).toBe(false) - }) - - it('overwrites prior state on each set', () => { - setMachineOutputMode({ json: true }) - expect(getMachineOutputMode()).toBe(true) - setMachineOutputMode({}) - expect(getMachineOutputMode()).toBe(false) - }) -}) diff --git a/packages/cli/test/unit/util/output/emit-payload.test.mts b/packages/cli/test/unit/util/output/emit-payload.test.mts deleted file mode 100644 index 686a4b5ad..000000000 --- a/packages/cli/test/unit/util/output/emit-payload.test.mts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockStdoutLog = vi.fn() -const mockStderrLog = vi.fn() - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => ({ - log: (...args: unknown[]) => { - mockStdoutLog(...args) - }, - error: (...args: unknown[]) => { - mockStderrLog(...args) - }, - }), -})) - -const { emitJsonPayload, emitPayload } = - await import('../../../../src/util/output/emit-payload.mts') -const { SENTINEL_BEGIN, SENTINEL_END } = - await import('../../../../src/util/output/mode.mts') - -describe('emitPayload', () => { - beforeEach(() => { - mockStdoutLog.mockClear() - mockStderrLog.mockClear() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('emits plain payload as one log call in human mode', () => { - emitPayload('hello', { flags: {} }) - expect(mockStdoutLog).toHaveBeenCalledTimes(1) - expect(mockStdoutLog).toHaveBeenCalledWith('hello') - }) - - it('emits BEGIN, payload, END as three log calls under --json', () => { - emitPayload('{"ok":true}', { flags: { json: true } }) - expect(mockStdoutLog).toHaveBeenCalledTimes(3) - expect(mockStdoutLog).toHaveBeenNthCalledWith(1, SENTINEL_BEGIN) - expect(mockStdoutLog).toHaveBeenNthCalledWith(2, '{"ok":true}') - expect(mockStdoutLog).toHaveBeenNthCalledWith(3, SENTINEL_END) - }) - - it('emits three log calls under --markdown', () => { - emitPayload('# Hello', { flags: { markdown: true } }) - expect(mockStdoutLog).toHaveBeenCalledTimes(3) - expect(mockStdoutLog).toHaveBeenNthCalledWith(1, SENTINEL_BEGIN) - expect(mockStdoutLog).toHaveBeenNthCalledWith(2, '# Hello') - expect(mockStdoutLog).toHaveBeenNthCalledWith(3, SENTINEL_END) - }) - - it('emits three log calls under --quiet', () => { - emitPayload('payload', { flags: { quiet: true } }) - expect(mockStdoutLog).toHaveBeenCalledTimes(3) - }) - - it('preserves embedded newlines in the payload (multi-line markdown)', () => { - const md = '# Title\n\n- item 1\n- item 2\n' - emitPayload(md, { flags: { markdown: true } }) - // emitPayload strips exactly one trailing newline before logging - // (logger.log appends its own \n, so keeping the payload's would - // double it). Intermediate \n bytes inside the payload are - // preserved untouched. - expect(mockStdoutLog).toHaveBeenNthCalledWith( - 2, - '# Title\n\n- item 1\n- item 2', - ) - }) - - it('emitJsonPayload stringifies and wraps', () => { - emitJsonPayload({ status: 'ok', count: 3 }, { flags: { json: true } }) - expect(mockStdoutLog).toHaveBeenCalledTimes(3) - expect(mockStdoutLog).toHaveBeenNthCalledWith( - 2, - '{"status":"ok","count":3}', - ) - }) - - it('never writes to stderr', () => { - emitPayload('anything', { flags: { json: true } }) - emitPayload('anything', { flags: {} }) - expect(mockStderrLog).not.toHaveBeenCalled() - }) -}) diff --git a/packages/cli/test/unit/util/output/formatting.test.mts b/packages/cli/test/unit/util/output/formatting.test.mts deleted file mode 100644 index 131efc23b..000000000 --- a/packages/cli/test/unit/util/output/formatting.test.mts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Unit tests for output formatting utilities. - * - * Purpose: Tests output formatting utilities. Validates table formatting, - * column alignment, and text wrapping. - * - * Test Coverage: - Table formatting - Column alignment - Text wrapping - - * Truncation - Border styles - Color coding. - * - * Testing Approach: Tests text formatting utilities for CLI output. - * - * Related Files: - util/output/formatting.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - getFlagApiRequirementsOutput, - getFlagListOutput, - getFlagsHelpOutput, - getHelpListOutput, -} from '../../../../src/util/output/formatting.mts' - -// Mock requirements module. -vi.mock('../../../../src/util/ecosystem/requirements.mts', () => ({ - getRequirements: vi.fn(), - getRequirementsKey: vi.fn(), -})) - -describe('output-formatting utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('getFlagApiRequirementsOutput', () => { - it('formats API requirements with quota and permissions', async () => { - const { getRequirements, getRequirementsKey } = vi.mocked( - await import('../../../../src/util/ecosystem/requirements.mts'), - ) - - getRequirementsKey.mockReturnValue('scan:create') - getRequirements.mockReturnValue({ - api: { - 'scan:create': { - quota: 10, - permissions: ['read', 'write', 'admin'], - }, - }, - } as unknown) - - const result = getFlagApiRequirementsOutput('socket scan create') - expect(result).toContain('- Quota: 10 units') - expect(result).toContain('- Permissions: admin, read, and write') - }) - - it('formats quota only when present', async () => { - const { getRequirements, getRequirementsKey } = vi.mocked( - await import('../../../../src/util/ecosystem/requirements.mts'), - ) - - getRequirementsKey.mockReturnValue('test') - getRequirements.mockReturnValue({ - api: { - test: { - quota: 1, - }, - }, - } as unknown) - - const result = getFlagApiRequirementsOutput('test') - expect(result).toBe('- Quota: 1 unit') - }) - - it('formats permissions only when present', async () => { - const { getRequirements, getRequirementsKey } = vi.mocked( - await import('../../../../src/util/ecosystem/requirements.mts'), - ) - - getRequirementsKey.mockReturnValue('test') - getRequirements.mockReturnValue({ - api: { - test: { - permissions: ['execute'], - }, - }, - } as unknown) - - const result = getFlagApiRequirementsOutput('test') - expect(result).toBe('- Permissions: execute') - }) - - it('returns (none) when no requirements found', async () => { - const { getRequirements, getRequirementsKey } = vi.mocked( - await import('../../../../src/util/ecosystem/requirements.mts'), - ) - - getRequirementsKey.mockReturnValue('missing') - getRequirements.mockReturnValue({ - api: {}, - } as unknown) - - const result = getFlagApiRequirementsOutput('missing') - expect(result).toBe('(none)') - }) - - it('respects custom indent option', async () => { - const { getRequirements, getRequirementsKey } = vi.mocked( - await import('../../../../src/util/ecosystem/requirements.mts'), - ) - - getRequirementsKey.mockReturnValue('test') - getRequirements.mockReturnValue({ - api: { - test: { - quota: 5, - }, - }, - } as unknown) - - const result = getFlagApiRequirementsOutput('test', { indent: 2 }) - expect(result).toBe('- Quota: 5 units') - }) - }) - - describe('getFlagListOutput', () => { - it('formats flag list with descriptions', () => { - const flags = { - help: { description: 'Show help information' }, - verbose: { description: 'Enable verbose output' }, - quiet: { description: 'Suppress output' }, - } - - const result = getFlagListOutput(flags) - expect(result).toContain('--help') - expect(result).toContain('Show help information') - expect(result).toContain('--verbose') - expect(result).toContain('Enable verbose output') - expect(result).toContain('--quiet') - expect(result).toContain('Suppress output') - }) - - it('converts camelCase flag names to kebab-case', () => { - const flags = { - dryRun: { description: 'Perform a dry run' }, - noInteractive: { description: 'Disable interactive mode' }, - } - - const result = getFlagListOutput(flags) - expect(result).toContain('--dry-run') - expect(result).toContain('--no-interactive') - }) - - it('hides flags marked as hidden', () => { - const flags = { - visible: { description: 'Visible flag' }, - hidden: { description: 'Hidden flag', hidden: true }, - alsoVisible: { description: 'Another visible flag', hidden: false }, - } - - const result = getFlagListOutput(flags) - expect(result).toContain('--visible') - expect(result).toContain('--also-visible') - expect(result).not.toContain('--hidden') - }) - - it('respects custom options', () => { - const flags = { - test: { description: 'Test flag' }, - } - - const result = getFlagListOutput(flags, { - indent: 2, - keyPrefix: '-', - padName: 10, - }) - - expect(result).toMatch(/-test\s+Test flag/) - }) - - it('returns (none) for empty flag list', () => { - const result = getFlagListOutput({}) - expect(result).toBe('(none)') - }) - }) - - describe('getFlagsHelpOutput', () => { - it('is an alias for getFlagListOutput', () => { - expect(getFlagsHelpOutput).toBe(getFlagListOutput) - }) - }) - - describe('getHelpListOutput', () => { - it('formats help list with descriptions', () => { - const list = { - init: { description: 'Initialize a new project' }, - build: { description: 'Build the project' }, - test: { description: 'Run tests' }, - } - - const result = getHelpListOutput(list) - expect(result).toContain('init') - expect(result).toContain('Initialize a new project') - expect(result).toContain('build') - expect(result).toContain('Build the project') - expect(result).toContain('test') - expect(result).toContain('Run tests') - }) - - it('sorts items in natural order', () => { - const list = { - item10: { description: 'Item 10' }, - item2: { description: 'Item 2' }, - item1: { description: 'Item 1' }, - } - - const result = getHelpListOutput(list) - const lines = result.split('\n') - expect(lines[0]).toContain('item1') - expect(lines[1]).toContain('item2') - expect(lines[2]).toContain('item10') - }) - - it('handles string descriptions', () => { - const list = { - simple: 'Simple description' as unknown, - object: { description: 'Object description' }, - } - - const result = getHelpListOutput(list) - expect(result).toContain('Simple description') - expect(result).toContain('Object description') - }) - - it('pads names to align descriptions', () => { - const list = { - short: { description: 'Short name' }, - verylongname: { description: 'Long name' }, - } - - const result = getHelpListOutput(list, { padName: 15 }) - const lines = result.split('\n') - - // Both descriptions should start at similar positions. - const shortLine = lines.find(l => l.includes('short'))! - const longLine = lines.find(l => l.includes('verylongname'))! - - expect(shortLine).toMatch(/short\s+Short name/) - expect(longLine).toMatch(/verylongname\s+Long name/) - }) - - it('handles empty descriptions', () => { - const list = { - empty: { description: '' }, - noDesc: {} as unknown, - } - - const result = getHelpListOutput(list) - expect(result).toContain('empty') - expect(result).toContain('no-desc') - }) - - it('applies key prefix when specified', () => { - const list = { - command: { description: 'A command' }, - } - - const result = getHelpListOutput(list, { keyPrefix: 'prefix-' }) - expect(result).toContain('prefix-command') - }) - - it('returns (none) for empty list', () => { - const result = getHelpListOutput({}) - expect(result).toBe('(none)') - }) - - it('filters out hidden items', () => { - const list = { - visible1: { description: 'Visible 1' }, - hidden1: { description: 'Hidden 1', hidden: true }, - visible2: { description: 'Visible 2', hidden: false }, - } - - const result = getHelpListOutput(list) - expect(result).toContain('visible1') - expect(result).toContain('visible2') - expect(result).not.toContain('hidden1') - }) - }) -}) diff --git a/packages/cli/test/unit/util/output/markdown.test.mts b/packages/cli/test/unit/util/output/markdown.test.mts deleted file mode 100644 index f3578fd9c..000000000 --- a/packages/cli/test/unit/util/output/markdown.test.mts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Unit tests for markdown generation. - * - * Purpose: Tests markdown generation utilities. Validates markdown table, list, - * and heading generation. - * - * Test Coverage: - Markdown tables - Markdown lists - Heading generation - Code - * blocks - Link formatting - Escaping. - * - * Testing Approach: Tests markdown generator used for markdown output mode. - * - * Related Files: - util/output/markdown.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - mdError, - mdHeader, - mdKeyValue, - mdList, - mdSection, - mdTable, - mdTableOfPairs, - mdTableStringNumber, -} from '../../../../src/util/output/markdown.mts' - -describe('markdown utilities', () => { - describe('mdHeader', () => { - it('creates level 1 header by default', () => { - const result = mdHeader('Title') - expect(result).toBe('# Title') - }) - - it('creates header at specified level', () => { - expect(mdHeader('Test', 1)).toBe('# Test') - expect(mdHeader('Test', 2)).toBe('## Test') - expect(mdHeader('Test', 3)).toBe('### Test') - expect(mdHeader('Test', 4)).toBe('#### Test') - expect(mdHeader('Test', 5)).toBe('##### Test') - expect(mdHeader('Test', 6)).toBe('###### Test') - }) - - it('clamps level to valid range', () => { - expect(mdHeader('Test', 0)).toBe('# Test') - expect(mdHeader('Test', -1)).toBe('# Test') - expect(mdHeader('Test', 7)).toBe('###### Test') - expect(mdHeader('Test', 100)).toBe('###### Test') - }) - }) - - describe('mdKeyValue', () => { - it('formats key-value pair with bold label', () => { - const result = mdKeyValue('Status', 'active') - expect(result).toBe('**Status**: active') - }) - - it('handles number values', () => { - const result = mdKeyValue('Count', 42) - expect(result).toBe('**Count**: 42') - }) - - it('shows N/A for undefined values', () => { - const result = mdKeyValue('Missing', undefined) - expect(result).toBe('**Missing**: N/A') - }) - - it('escapes markdown characters when escaped=true', () => { - const result = mdKeyValue('Text', 'some *bold* and _italic_', true) - expect(result).toBe('**Text**: some \\*bold\\* and \\_italic\\_') - }) - - it('does not escape by default', () => { - const result = mdKeyValue('Text', 'some *bold* text') - expect(result).toBe('**Text**: some *bold* text') - }) - }) - - describe('mdList', () => { - it('creates bullet list by default', () => { - const result = mdList(['item1', 'item2', 'item3']) - expect(result).toBe('- item1\n- item2\n- item3') - }) - - it('creates ordered list when specified', () => { - const result = mdList(['first', 'second', 'third'], { ordered: true }) - expect(result).toBe('1. first\n2. second\n3. third') - }) - - it('handles empty array', () => { - const result = mdList([]) - expect(result).toBe('') - }) - - it('handles single item', () => { - const result = mdList(['only']) - expect(result).toBe('- only') - }) - - it('applies indentation', () => { - const result = mdList(['nested'], { indent: 1 }) - expect(result).toBe(' - nested') - }) - - it('applies multiple indentation levels', () => { - const result = mdList(['deep'], { indent: 2 }) - expect(result).toBe(' - deep') - }) - - it('truncates list when truncateAt is specified', () => { - const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] - const result = mdList(items, { truncateAt: 3 }) - expect(result).toContain('- a\n- b\n- c') - expect(result).toContain('...and 4 more') - }) - - it('does not truncate when list is shorter than truncateAt', () => { - const items = ['a', 'b'] - const result = mdList(items, { truncateAt: 5 }) - expect(result).toBe('- a\n- b') - expect(result).not.toContain('more') - }) - - it('combines ordered and indent options', () => { - const result = mdList(['item'], { ordered: true, indent: 1 }) - expect(result).toBe(' 1. item') - }) - }) - - describe('mdError', () => { - it('formats error message', () => { - const result = mdError('Failed to connect') - expect(result).toContain('# Error') - expect(result).toContain('**Error**: Failed to connect') - }) - - it('includes cause when provided', () => { - const result = mdError('Failed', 'Network timeout') - expect(result).toContain('# Error') - expect(result).toContain('**Error**: Failed') - expect(result).toContain('**Cause**: Network timeout') - }) - - it('does not include cause section when cause is undefined', () => { - const result = mdError('Simple error') - expect(result).not.toContain('Cause') - }) - }) - - describe('mdSection', () => { - it('creates section with header and content', () => { - const result = mdSection('Details', 'Some content') - expect(result).toBe('## Details\n\nSome content') - }) - - it('uses level 2 header by default', () => { - const result = mdSection('Test', 'Content') - expect(result).toContain('## Test') - }) - - it('uses specified header level', () => { - const result = mdSection('Test', 'Content', 3) - expect(result).toContain('### Test') - }) - - it('handles array content', () => { - const result = mdSection('Info', ['Line 1', 'Line 2', 'Line 3']) - expect(result).toBe('## Info\n\nLine 1\nLine 2\nLine 3') - }) - - it('handles empty string content', () => { - const result = mdSection('Empty', '') - expect(result).toBe('## Empty\n\n') - }) - - it('handles empty array content', () => { - const result = mdSection('Empty', []) - expect(result).toBe('## Empty\n\n') - }) - }) - - describe('mdTableStringNumber', () => { - it('creates markdown table with string keys and number values', () => { - const data = { - First: 100, - Second: 2500, - Third: 50, - } - - const result = mdTableStringNumber('Name', 'Count', data) - - expect(result).toContain('| Name | Count |') - expect(result).toContain('| ------ | ----- |') - expect(result).toContain('| First | 100 |') - expect(result).toContain('| Second | 2500 |') - expect(result).toContain('| Third | 50 |') - }) - - it('handles string values', () => { - const data = { - 'Item A': 'Active', - 'Item B': 'Inactive', - } - - const result = mdTableStringNumber('Item', 'Status', data) - - expect(result).toContain('| Item | Status |') - expect(result).toContain('| Item A | Active |') - expect(result).toContain('| Item B | Inactive |') - }) - - it('handles null and undefined values', () => { - const data = { - Valid: 123, - Null: undefined as unknown, - Undefined: undefined as unknown, - } - - const result = mdTableStringNumber('Key', 'Value', data) - - expect(result).toContain('| Valid | 123 |') - expect(result).toContain('| Null | |') - expect(result).toContain('| Undefined | |') - }) - - it('adjusts column widths for long values', () => { - const data = { - VeryLongKeyName: 1, - Short: 999999999, - } - - const result = mdTableStringNumber('K', 'V', data) - - expect(result).toContain('| K | V |') - expect(result).toContain('| VeryLongKeyName | 1 |') - expect(result).toContain('| Short | 999999999 |') - }) - - it('handles empty object', () => { - const data = {} - - const result = mdTableStringNumber('Col1', 'Col2', data) - - expect(result).toContain('| Col1 | Col2 |') - expect(result).toContain('| ---- | ---- |') - expect(result.split('\n')).toHaveLength(3) - }) - }) - - describe('mdTable', () => { - it('creates markdown table from array of objects', () => { - const logs = [ - { date: '2024-01-01', action: 'create', user: 'alice' }, - { date: '2024-01-02', action: 'update', user: 'bob' }, - ] - - const result = mdTable(logs, ['date', 'action', 'user']) - - expect(result).toContain('| date | action | user |') - expect(result).toContain('| ---------- | ------ | ----- |') - expect(result).toContain('| 2024-01-01 | create | alice |') - expect(result).toContain('| 2024-01-02 | update | bob |') - }) - - it('uses custom titles', () => { - const logs = [{ id: '1', name: 'Test' }] - - const result = mdTable(logs, ['id', 'name'], ['ID', 'Display Name']) - - expect(result).toContain('| ID | Display Name |') - expect(result).toContain('| 1 | Test |') - }) - - it('handles missing properties', () => { - const logs = [{ a: 'value1' }, { b: 'value2' }] as unknown[] - - const result = mdTable(logs, ['a', 'b']) - - expect(result).toContain('| a | b |') - expect(result).toContain('| value1 | |') - expect(result).toContain('| | value2 |') - }) - - it('adjusts columns for long values', () => { - const logs = [ - { short: 'a', long: 'very long value here' }, - { short: 'b', long: 'short' }, - ] - - const result = mdTable(logs, ['short', 'long']) - - expect(result).toContain('| short | long |') - expect(result).toContain('| a | very long value here |') - expect(result).toContain('| b | short |') - }) - - it('handles empty array', () => { - const logs: unknown[] = [] - - const result = mdTable(logs, ['col1', 'col2']) - - expect(result).toContain('| col1 | col2 |') - expect(result).toContain('| ---- | ---- |') - }) - - it('handles non-string values', () => { - const logs = [{ num: 123, bool: true, obj: { nested: 'value' } }] - - const result = mdTable(logs, ['num', 'bool', 'obj']) - - expect(result).toContain('| 123 | true | [object Object] |') - }) - }) - - describe('mdTableOfPairs', () => { - it('creates markdown table from array of pairs', () => { - const pairs: Array<[string, string]> = [ - ['Key1', 'Value1'], - ['Key2', 'Value2'], - ['Key3', 'Value3'], - ] - - const result = mdTableOfPairs(pairs, ['Name', 'Value']) - - expect(result).toContain('| Name | Value |') - expect(result).toContain('| ---- | ------ |') - expect(result).toContain('| Key1 | Value1 |') - expect(result).toContain('| Key2 | Value2 |') - expect(result).toContain('| Key3 | Value3 |') - }) - - it('adjusts column widths', () => { - const pairs: Array<[string, string]> = [ - ['VeryLongKeyName', 'V1'], - ['K2', 'VeryLongValueHere'], - ] - - const result = mdTableOfPairs(pairs, ['A', 'B']) - - expect(result).toContain('| A | B |') - expect(result).toContain('| VeryLongKeyName | V1 |') - expect(result).toContain('| K2 | VeryLongValueHere |') - }) - - it('handles null and undefined values', () => { - const pairs: Array<[string, unknown]> = [ - ['Null', undefined], - ['Undefined', undefined], - ['Empty', ''], - ] - - const result = mdTableOfPairs(pairs, ['Key', 'Value']) - - expect(result).toContain('| Null | |') - expect(result).toContain('| Undefined | |') - expect(result).toContain('| Empty | |') - }) - - it('handles empty array', () => { - const pairs: Array<[string, string]> = [] - - const result = mdTableOfPairs(pairs, ['Column1', 'Column2']) - - expect(result).toContain('| Column1 | Column2 |') - expect(result).toContain('| ------- | ------- |') - // Empty array produces: div, header, div, div (body.trim() is empty so removed). - const lines = result.split('\n') - expect(lines).toHaveLength(4) - expect(lines[0]).toBe('| ------- | ------- |') - expect(lines[1]).toBe('| Column1 | Column2 |') - expect(lines[2]).toBe('| ------- | ------- |') - expect(lines[3]).toBe('| ------- | ------- |') - }) - - it('handles non-string values', () => { - const pairs: Array<[unknown, unknown]> = [ - [123, true], - [false, { key: 'value' }], - ] - - const result = mdTableOfPairs(pairs, ['A', 'B']) - - expect(result).toContain('| 123 | true |') - expect(result).toContain('| false | [object Object] |') - }) - }) -}) diff --git a/packages/cli/test/unit/util/output/mode.test.mts b/packages/cli/test/unit/util/output/mode.test.mts deleted file mode 100644 index 349091f3a..000000000 Binary files a/packages/cli/test/unit/util/output/mode.test.mts and /dev/null differ diff --git a/packages/cli/test/unit/util/output/result-json.test.mts b/packages/cli/test/unit/util/output/result-json.test.mts deleted file mode 100644 index 2a2068bf7..000000000 --- a/packages/cli/test/unit/util/output/result-json.test.mts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Unit tests for JSON result formatting. - * - * Purpose: Tests JSON result formatting. Validates CResult conversion to JSON - * and error serialization. - * - * Test Coverage: - CResult to JSON conversion - Error serialization - Data - * sanitization - Nested object handling - Pretty printing options. - * - * Testing Approach: Tests JSON output formatting with CResult patterns. - * - * Related Files: - util/output/result-json.mts (implementation) - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { serializeResultJson } from '../../../../src/util/output/result-json.mts' - -describe('serializeResultJson', () => { - afterEach(() => { - // Reset exitCode after each test. - process.exitCode = undefined - }) - - it('serializes simple objects', () => { - const result = serializeResultJson({ ok: true, data: 'test' }) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(true) - expect(parsed.data).toBe('test') - }) - - it('serializes complex nested objects', () => { - const data = { - ok: true, - data: { - users: [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - ], - metadata: { - count: 2, - page: 1, - }, - }, - } - const result = serializeResultJson(data) - const parsed = JSON.parse(result) - expect(parsed).toEqual(data) - }) - - it('adds trailing newline', () => { - const result = serializeResultJson({ ok: true }) - expect(result).toMatch(/\n$/) - }) - - it('formats with proper indentation', () => { - const result = serializeResultJson({ - ok: true, - nested: { value: 42 }, - }) - expect(result).toContain(' "ok": true') - expect(result).toContain(' "value": 42') - }) - - it('handles objects with undefined values', () => { - const result = serializeResultJson({ - ok: false, - message: 'Error', - data: undefined, - }) - const parsed = JSON.parse(result) - expect(parsed.data).toBeUndefined() - }) - - it('handles empty object', () => { - const result = serializeResultJson({}) - expect(result).toBe('{}\n') - }) - - it('returns error JSON for null input', () => { - const result = serializeResultJson(undefined as unknown as { ok: boolean }) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(false) - expect(parsed.message).toBe('Unable to serialize JSON') - expect(process.exitCode).toBe(1) - }) - - it('returns error JSON for string input', () => { - const result = serializeResultJson( - 'not an object' as unknown as { - ok: boolean - }, - ) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(false) - expect(parsed.cause).toContain('JSON was not an object') - expect(process.exitCode).toBe(1) - }) - - it('returns error JSON for number input', () => { - const result = serializeResultJson(42 as unknown as { ok: boolean }) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(false) - expect(process.exitCode).toBe(1) - }) - - it('returns error JSON for boolean input', () => { - const result = serializeResultJson(true as unknown as { ok: boolean }) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(false) - expect(process.exitCode).toBe(1) - }) - - it('handles circular references gracefully', () => { - const circular: Record<string, unknown> = { ok: true } - circular.self = circular - - const result = serializeResultJson(circular as { ok: boolean }) - const parsed = JSON.parse(result) - expect(parsed.ok).toBe(false) - expect(parsed.message).toBe('Unable to serialize JSON') - expect(process.exitCode).toBe(1) - }) - - it('handles arrays as valid input', () => { - // Arrays are objects in JavaScript. - const result = serializeResultJson([1, 2, 3] as unknown as { ok: boolean }) - const parsed = JSON.parse(result) - expect(Array.isArray(parsed)).toBe(true) - expect(parsed).toEqual([1, 2, 3]) - }) -}) diff --git a/packages/cli/test/unit/util/preflight/downloads.test.mts b/packages/cli/test/unit/util/preflight/downloads.test.mts deleted file mode 100644 index 4fbe3413a..000000000 --- a/packages/cli/test/unit/util/preflight/downloads.test.mts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Unit tests for preflight downloads. - * - * Purpose: Tests the background preflight downloads functionality. - * - * Test Coverage: - runPreflightDownloads function - Single run behavior - - * CI/Test environment detection. - * - * Related Files: - src/util/preflight/downloads.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock all external dependencies. -const mockDownloadPackage = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -) -vi.mock('@socketsecurity/lib-stable/dlx/package', () => ({ - downloadPackage: mockDownloadPackage, -})) - -const mockGetCI = vi.hoisted(() => vi.fn(() => false)) -vi.mock('@socketsecurity/lib-stable/env/ci', () => ({ - getCI: mockGetCI, -})) - -vi.mock('../../../../src/env/coana-version.mts', () => ({ - getCoanaVersion: () => '1.0.0', -})) - -vi.mock('../../../../src/env/cdxgen-version.mts', () => ({ - getCdxgenVersion: () => '10.0.0', -})) - -// Mock VITEST as a getter so it can be flipped per-test. -const mockVitest = vi.hoisted(() => ({ VITEST: true })) -vi.mock('../../../../src/env/vitest.mts', () => mockVitest) - -vi.mock('../../../../src/util/python/standalone.mts', () => ({ - ensurePythonDlx: vi.fn().mockResolvedValue('/usr/bin/python3'), - ensureSocketPyCli: vi.fn().mockResolvedValue(undefined), -})) - -describe('preflight downloads', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.resetModules() - mockGetCI.mockReturnValue(false) - }) - - describe('runPreflightDownloads', () => { - it('does not run downloads in test environment', async () => { - const { runPreflightDownloads } = - await import('../../../../src/util/preflight/downloads.mts') - - runPreflightDownloads() - - // In VITEST environment, downloads should not be called. - expect(mockDownloadPackage).not.toHaveBeenCalled() - }) - - it('does not run downloads in CI environment', async () => { - mockGetCI.mockReturnValue(true) - - const { runPreflightDownloads } = - await import('../../../../src/util/preflight/downloads.mts') - - runPreflightDownloads() - - expect(mockDownloadPackage).not.toHaveBeenCalled() - }) - - it('only runs once per module load', async () => { - const { runPreflightDownloads } = - await import('../../../../src/util/preflight/downloads.mts') - - runPreflightDownloads() - runPreflightDownloads() - runPreflightDownloads() - - // Function should guard against multiple calls. - // Since VITEST is mocked to true, no downloads happen anyway. - // But the function should track that it's been called. - expect(true).toBe(true) - }) - - it('swallows errors thrown inside the background async closure', async () => { - mockVitest.VITEST = false - mockGetCI.mockReturnValue(false) - mockDownloadPackage.mockRejectedValueOnce(new Error('network')) - - const { runPreflightDownloads } = - await import('../../../../src/util/preflight/downloads.mts') - - // Should not throw / reject. - runPreflightDownloads() - await new Promise(resolve => setTimeout(resolve, 50)) - // No assertion needed — test just verifies no unhandled rejection. - expect(true).toBe(true) - mockVitest.VITEST = true - }) - - it('runs the full download chain when not in CI/vitest', async () => { - // Mock node:timers/promises sleep to resolve immediately so the test - // doesn't actually wait 4 seconds for the staggered delays. - vi.doMock('node:timers/promises', () => ({ - setTimeout: () => Promise.resolve(), - })) - mockVitest.VITEST = false - mockGetCI.mockReturnValue(false) - mockDownloadPackage.mockResolvedValue(undefined) - - const { runPreflightDownloads } = - await import('../../../../src/util/preflight/downloads.mts') - runPreflightDownloads() - // Allow the background closure to drain. - await new Promise(resolve => setImmediate(resolve)) - await new Promise(resolve => setImmediate(resolve)) - await new Promise(resolve => setImmediate(resolve)) - - // Coana + cdxgen should have been queued. - expect(mockDownloadPackage).toHaveBeenCalled() - const specs = mockDownloadPackage.mock.calls.map( - (c: unknown) => (c[0] as { package: string }).package, - ) - expect(specs.some((s: string) => s.startsWith('@coana-tech/cli@'))).toBe( - true, - ) - - mockVitest.VITEST = true - vi.doUnmock('node:timers/promises') - }) - }) -}) diff --git a/packages/cli/test/unit/util/process/cmd.test.mts b/packages/cli/test/unit/util/process/cmd.test.mts deleted file mode 100644 index 30f93d3fc..000000000 --- a/packages/cli/test/unit/util/process/cmd.test.mts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Unit tests for command execution utilities. - * - * Purpose: Tests command execution utilities. Validates subprocess spawning - * with Socket-specific spawn wrapper. - * - * Test Coverage: - * - * - Subprocess spawning - * - Command output capture - * - Error handling - * - Exit code checking - * - Cross-platform command execution - * - * Special Notes: Always uses { spawn } from @socketsecurity/lib/spawn, never - * child_process.spawn. - * - * Testing Approach: Uses mocked spawn from @socketsecurity/lib/spawn (NOT - * built-in spawn). - * - * Related Files: - * - * - Util/process/cmd.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - cmdFlagValueToArray, - cmdFlagsToString, - cmdPrefixMessage, - filterFlags, - isHelpFlag, -} from '../../../../src/util/process/cmd.mts' - -describe('cmd utilities', () => { - describe('cmdFlagValueToArray', () => { - it('converts string to array', () => { - expect(cmdFlagValueToArray('foo,bar,baz')).toEqual(['foo', 'bar', 'baz']) - }) - - it('handles string with spaces', () => { - expect(cmdFlagValueToArray('foo, bar, baz')).toEqual([ - 'foo', - 'bar', - 'baz', - ]) - }) - - it('handles array input', () => { - expect(cmdFlagValueToArray(['foo', 'bar'])).toEqual(['foo', 'bar']) - }) - - it('handles nested arrays', () => { - expect(cmdFlagValueToArray(['foo,bar', 'baz'])).toEqual([ - 'foo', - 'bar', - 'baz', - ]) - }) - - it('handles empty string', () => { - expect(cmdFlagValueToArray('')).toEqual([]) - }) - - it('handles null/undefined', () => { - expect(cmdFlagValueToArray(undefined)).toEqual([]) - expect(cmdFlagValueToArray(undefined)).toEqual([]) - }) - - it('filters empty values', () => { - expect(cmdFlagValueToArray('foo,,bar')).toEqual(['foo', 'bar']) - }) - }) - - describe('cmdFlagsToString', () => { - it('handles simple arguments', () => { - expect(cmdFlagsToString(['--flag', 'value'])).toBe('--flag=value') - }) - - it('handles arguments with special chars', () => { - const result = cmdFlagsToString(['--file', 'my file.txt']) - expect(result).toBe('--file=my file.txt') - }) - - it('handles arguments with quotes', () => { - const result = cmdFlagsToString(['--text', 'say "hello"']) - expect(result).toBe('--text=say "hello"') - }) - - it('handles empty array', () => { - expect(cmdFlagsToString([])).toBe('') - }) - - it('preserves flag format', () => { - expect(cmdFlagsToString(['-v', '--help', '--output=file.txt'])).toBe( - '-v --help --output=file.txt', - ) - }) - }) - - describe('isHelpFlag', () => { - it('identifies --help flag', () => { - expect(isHelpFlag('--help')).toBe(true) - }) - - it('identifies -h flag', () => { - expect(isHelpFlag('-h')).toBe(true) - }) - - it('returns false for non-help flags', () => { - expect(isHelpFlag('--config')).toBe(false) - expect(isHelpFlag('--other')).toBe(false) - }) - }) - - describe('filterFlags', () => { - it('filters out specified flags', () => { - const args = ['--help', '--config', 'value', '--other', 'arg'] - const flagsToFilter = { - help: { type: 'boolean' }, - config: { type: 'string' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual(['--other', 'arg']) - }) - - it('handles empty array', () => { - const result = filterFlags([], {}) - expect(result).toEqual([]) - }) - - it('keeps exception flags', () => { - const args = ['--help', '--config', 'value', '--other', 'arg'] - const flagsToFilter = { - help: { type: 'boolean' }, - config: { type: 'string' }, - } - const result = filterFlags(args, flagsToFilter, ['--config']) - expect(result).toEqual(['--config', 'value', '--other', 'arg']) - }) - - it('handles short flags with shortFlag property', () => { - const args = ['-v', '--verbose', '-h'] - const flagsToFilter = { - verbose: { type: 'boolean', shortFlag: 'v' }, - help: { type: 'boolean', shortFlag: 'h' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual([]) - }) - - it('handles negated boolean flags like --no-spinner', () => { - const args = ['--no-spinner', '--verbose', '--no-banner'] - const flagsToFilter = { - spinner: { type: 'boolean' }, - banner: { type: 'boolean' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual(['--verbose']) - }) - - it('handles --flag=value format', () => { - const args = ['--config={"key":"value"}', '--other'] - const flagsToFilter = { - config: { type: 'string' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual(['--other']) - }) - - it('keeps --flag=value format when in exceptions', () => { - const args = ['--config={"key":"value"}', '--other'] - const flagsToFilter = { - config: { type: 'string' }, - } - const result = filterFlags(args, flagsToFilter, ['--config']) - expect(result).toEqual(['--config={"key":"value"}', '--other']) - }) - - it('handles short flags with values', () => { - const args = ['-c', 'configvalue', '--other'] - const flagsToFilter = { - config: { type: 'string', shortFlag: 'c' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual(['--other']) - }) - - it('keeps short flags with values when in exceptions', () => { - const args = ['-c', 'configvalue', '--other'] - const flagsToFilter = { - config: { type: 'string', shortFlag: 'c' }, - } - const result = filterFlags(args, flagsToFilter, ['-c']) - expect(result).toEqual(['-c', 'configvalue', '--other']) - }) - - it('converts camelCase flag names to kebab-case', () => { - const args = ['--dry-run', '--other'] - const flagsToFilter = { - dryRun: { type: 'boolean' }, - } - const result = filterFlags(args, flagsToFilter) - expect(result).toEqual(['--other']) - }) - }) - - describe('cmdPrefixMessage', () => { - it('generates prefix message', () => { - const msg = cmdPrefixMessage('npm install', 'message text') - expect(msg).toBe('npm install: message text') - }) - - it('handles empty command name', () => { - const msg = cmdPrefixMessage('', 'message text') - expect(msg).toBe('message text') - }) - }) -}) diff --git a/packages/cli/test/unit/util/purl/parse.test.mts b/packages/cli/test/unit/util/purl/parse.test.mts deleted file mode 100644 index 1457dbc14..000000000 --- a/packages/cli/test/unit/util/purl/parse.test.mts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Unit tests for PURL parsing. - * - * Purpose: Tests Package URL (PURL) parsing. Validates PURL spec compliance and - * component extraction. - * - * Test Coverage: - PURL syntax parsing - Namespace extraction - Version parsing - * - Qualifiers handling - Subpath parsing - Invalid PURL error handling. - * - * Testing Approach: Tests PURL parser with various ecosystem formats. - * - * Related Files: - util/purl/parse.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { PackageURL } from '@socketregistry/packageurl-js-stable' - -import { - createPurlObject, - getPurlObject, - normalizePurl, -} from '../../../../src/util/purl/parse.mts' - -// Mock dependencies. -const mockIsObjectObject = vi.hoisted(() => - vi.fn(obj => obj !== null && typeof obj === 'object' && !Array.isArray(obj)), -) - -vi.mock('@socketsecurity/lib-stable/objects/predicates', () => ({ - isPlainObject: mockIsObjectObject, -})) - -describe('purl utilities', () => { - describe('normalizePurl', () => { - it('adds pkg: prefix when missing', () => { - expect(normalizePurl('npm/lodash@4.17.21')).toBe('pkg:npm/lodash@4.17.21') - }) - - it('keeps pkg: prefix when already present', () => { - expect(normalizePurl('pkg:npm/lodash@4.17.21')).toBe( - 'pkg:npm/lodash@4.17.21', - ) - }) - - it('handles empty string', () => { - expect(normalizePurl('')).toBe('pkg:') - }) - }) - - describe('createPurlObject', () => { - it('creates PURL from type and name', () => { - const purl = createPurlObject('npm', 'lodash') - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.type).toBe('npm') - expect(purl?.name).toBe('lodash') - }) - - it('creates PURL from options object', () => { - const purl = createPurlObject({ - type: 'npm', - name: 'lodash', - version: '4.17.21', - }) - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.type).toBe('npm') - expect(purl?.name).toBe('lodash') - expect(purl?.version).toBe('4.17.21') - }) - - it('creates PURL with namespace', () => { - const purl = createPurlObject({ - type: 'npm', - namespace: '@socketsecurity', - name: 'cli', - version: '1.0.0', - }) - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.namespace).toBe('@socketsecurity') - }) - - it('creates PURL with qualifiers', () => { - const purl = createPurlObject({ - type: 'npm', - name: 'package', - qualifiers: { arch: 'x64', os: 'linux' }, - }) - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.qualifiers).toEqual({ arch: 'x64', os: 'linux' }) - }) - - it('creates PURL with subpath', () => { - const purl = createPurlObject({ - type: 'npm', - name: 'package', - subpath: 'lib/index.js', - }) - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.subpath).toBe('lib/index.js') - }) - - it('throws on invalid input by default', () => { - expect(() => createPurlObject('', '')).toThrow() - }) - - it('returns undefined on invalid input when throws: false', () => { - const purl = createPurlObject('', '', { throws: false }) - expect(purl).toBeUndefined() - }) - - it('handles type string with name string and options', () => { - const purl = createPurlObject('pypi', 'requests', { - version: '2.31.0', - }) - expect(purl?.type).toBe('pypi') - expect(purl?.name).toBe('requests') - expect(purl?.version).toBe('2.31.0') - }) - - it('handles type string with options object as second param', () => { - const purl = createPurlObject('cargo', { - name: 'tokio', - version: '1.0.0', - }) - expect(purl?.type).toBe('cargo') - expect(purl?.name).toBe('tokio') - expect(purl?.version).toBe('1.0.0') - }) - - it('falls back to opts.name when name argument is not a string', () => { - // Exercises the typeof name !== 'string' branch — opts.name resolves it. - const purl = createPurlObject('npm', undefined as unknown, { - name: 'fallback-name', - version: '1.0.0', - }) - expect(purl?.name).toBe('fallback-name') - expect(purl?.version).toBe('1.0.0') - }) - }) - - describe('getPurlObject', () => { - it('parses PURL string', () => { - const purl = getPurlObject('pkg:npm/lodash@4.17.21') - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.type).toBe('npm') - expect(purl?.name).toBe('lodash') - expect(purl?.version).toBe('4.17.21') - }) - - it('normalizes PURL string without pkg: prefix', () => { - const purl = getPurlObject('npm/lodash@4.17.21') - expect(purl).toBeInstanceOf(PackageURL) - expect(purl?.type).toBe('npm') - expect(purl?.name).toBe('lodash') - }) - - it('returns PackageURL object as-is', () => { - const input = new PackageURL('npm', undefined, 'lodash', '4.17.21') - const purl = getPurlObject(input) - expect(purl).toBe(input) - }) - - it('handles SocketArtifact object', () => { - const artifact = { type: 'npm', name: 'package' } as unknown - const purl = getPurlObject(artifact) - expect(purl).toBe(artifact) - }) - - it('throws on invalid PURL string by default', () => { - expect(() => getPurlObject('invalid-purl')).toThrow() - }) - - it('returns undefined on invalid PURL when throws: false', () => { - const purl = getPurlObject('invalid-purl', { throws: false }) - expect(purl).toBeUndefined() - }) - - it('parses complex PURL with all components', () => { - const purl = getPurlObject( - 'pkg:maven/org.apache.commons/commons-lang3@3.12.0?classifier=sources#path/to/file', - ) - expect(purl?.type).toBe('maven') - expect(purl?.namespace).toBe('org.apache.commons') - expect(purl?.name).toBe('commons-lang3') - expect(purl?.version).toBe('3.12.0') - expect(purl?.qualifiers).toEqual({ classifier: 'sources' }) - expect(purl?.subpath).toBe('path/to/file') - }) - - it('handles gem PURL', () => { - const purl = getPurlObject('pkg:gem/rails@7.0.0') - expect(purl?.type).toBe('gem') - expect(purl?.name).toBe('rails') - expect(purl?.version).toBe('7.0.0') - }) - - it('handles go PURL with namespace', () => { - const purl = getPurlObject('pkg:go/github.com/gorilla/mux@1.8.0') - expect(purl?.type).toBe('go') - expect(purl?.namespace).toBe('github.com/gorilla') - expect(purl?.name).toBe('mux') - expect(purl?.version).toBe('1.8.0') - }) - - it('handles pypi PURL', () => { - const purl = getPurlObject('pkg:pypi/django@4.2') - expect(purl?.type).toBe('pypi') - expect(purl?.name).toBe('django') - expect(purl?.version).toBe('4.2') - }) - }) -}) diff --git a/packages/cli/test/unit/util/purl/to-ghsa.test.mts b/packages/cli/test/unit/util/purl/to-ghsa.test.mts deleted file mode 100644 index 7a0e900c9..000000000 --- a/packages/cli/test/unit/util/purl/to-ghsa.test.mts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Unit tests for PURL to GHSA conversion. - * - * Purpose: Tests converting PURLs to GitHub Security Advisory identifiers. - * Validates GHSA ID generation. - * - * Test Coverage: - PURL to GHSA conversion - Ecosystem mapping - Package name - * normalization - GHSA format validation - Unsupported ecosystem handling. - * - * Testing Approach: Tests GHSA identifier utilities for vulnerability lookups. - * - * Related Files: - util/purl/to-ghsa.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { convertPurlToGhsas } from '../../../../src/util/purl/to-ghsa.mts' - -// Mock the dependencies. -const mockCacheFetch = vi.hoisted(() => vi.fn()) -const mockGetOctokit = vi.hoisted(() => vi.fn()) -const mockGetPurlObject = vi.hoisted(() => vi.fn()) -const mockGetErrorCause = vi.hoisted(() => vi.fn(e => e?.message || String(e))) - -vi.mock('../../../../src/util/git/github.mts', () => ({ - cacheFetch: mockCacheFetch, - getOctokit: mockGetOctokit, -})) - -vi.mock('../../../../src/util/purl/parse.mts', () => ({ - getPurlObject: mockGetPurlObject, -})) - -vi.mock('../../../../src/util/error/errors.mts', () => ({ - getErrorCause: mockGetErrorCause, -})) - -describe('convertPurlToGhsas', () => { - it('returns error for invalid PURL format', async () => { - mockGetPurlObject.mockReturnValue(undefined) - - const result = await convertPurlToGhsas('invalid-purl') - - expect(result).toEqual({ - ok: false, - message: 'Invalid PURL format: invalid-purl', - }) - }) - - it('returns error for unsupported ecosystem', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'some-package', - type: 'unsupported-ecosystem', - version: '1.0.0', - } as unknown) - - const result = await convertPurlToGhsas( - 'pkg:unsupported/some-package@1.0.0', - ) - - expect(result).toEqual({ - ok: false, - message: 'Unsupported PURL ecosystem: unsupported-ecosystem', - }) - }) - - it('converts npm PURL to GHSA IDs', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'lodash', - type: 'npm', - version: '4.17.20', - } as unknown) - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - - mockCacheFetch.mockImplementation(async (_key, _fn) => { - return { - data: [ - { ghsa_id: 'GHSA-1234-5678-9abc' }, - { ghsa_id: 'GHSA-abcd-efgh-ijkl' }, - ], - } - }) - - const result = await convertPurlToGhsas('pkg:npm/lodash@4.17.20') - - expect(result).toEqual({ - ok: true, - data: ['GHSA-1234-5678-9abc', 'GHSA-abcd-efgh-ijkl'], - }) - expect(mockCacheFetch).toHaveBeenCalledWith( - 'purl-to-ghsa::npm::lodash::4.17.20', - expect.any(Function), - ) - }) - - it('converts pypi PURL to pip ecosystem', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'requests', - type: 'pypi', - version: '2.31.0', - } as unknown) - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - - mockCacheFetch.mockImplementation(async (_key, fn) => { - // Call the function to verify correct parameters. - await fn() - return { data: [] } - }) - - await convertPurlToGhsas('pkg:pypi/requests@2.31.0') - - expect( - mockOctokit.rest.securityAdvisories.listGlobalAdvisories, - ).toHaveBeenCalledWith({ - ecosystem: 'pip', - affects: 'requests@2.31.0', - }) - }) - - it('handles PURL without version', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'express', - type: 'npm', - version: undefined, - } as unknown) - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - - mockCacheFetch.mockImplementation(async (_key, fn) => { - await fn() - return { data: [] } - }) - - await convertPurlToGhsas('pkg:npm/express') - - expect( - mockOctokit.rest.securityAdvisories.listGlobalAdvisories, - ).toHaveBeenCalledWith({ - ecosystem: 'npm', - affects: 'express', - }) - expect(mockCacheFetch).toHaveBeenCalledWith( - 'purl-to-ghsa::npm::express::latest', - expect.any(Function), - ) - }) - - it('maps cargo to rust ecosystem', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'tokio', - type: 'cargo', - version: '1.0.0', - } as unknown) - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - - mockCacheFetch.mockImplementation(async (_key, fn) => { - await fn() - return { data: [] } - }) - - await convertPurlToGhsas('pkg:cargo/tokio@1.0.0') - - expect( - mockOctokit.rest.securityAdvisories.listGlobalAdvisories, - ).toHaveBeenCalledWith({ - ecosystem: 'rust', - affects: 'tokio@1.0.0', - }) - }) - - it('maps gem to rubygems ecosystem', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'rails', - type: 'gem', - version: '7.0.0', - } as unknown) - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - - mockCacheFetch.mockImplementation(async (_key, fn) => { - await fn() - return { data: [] } - }) - - await convertPurlToGhsas('pkg:gem/rails@7.0.0') - - expect( - mockOctokit.rest.securityAdvisories.listGlobalAdvisories, - ).toHaveBeenCalledWith({ - ecosystem: 'rubygems', - affects: 'rails@7.0.0', - }) - }) - - it('handles API errors gracefully', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'package', - type: 'npm', - version: '1.0.0', - } as unknown) - - mockGetOctokit.mockReturnValue({} as unknown) - mockCacheFetch.mockRejectedValue(new Error('API rate limit exceeded')) - - const result = await convertPurlToGhsas('pkg:npm/package@1.0.0') - - expect(result).toEqual({ - ok: false, - message: 'Failed to convert PURL to GHSA: API rate limit exceeded', - }) - }) - - it('returns empty array when no advisories found', async () => { - mockGetPurlObject.mockReturnValue({ - name: 'safe-package', - type: 'npm', - version: '1.0.0', - } as unknown) - - mockGetOctokit.mockReturnValue({} as unknown) - mockCacheFetch.mockResolvedValue({ data: [] }) - - const result = await convertPurlToGhsas('pkg:npm/safe-package@1.0.0') - - expect(result).toEqual({ - ok: true, - data: [], - }) - }) - - it('supports all ecosystem mappings', async () => { - const ecosystemMappings = [ - { purl: 'golang', github: 'go' }, - { purl: 'maven', github: 'maven' }, - { purl: 'nuget', github: 'nuget' }, - { purl: 'composer', github: 'composer' }, - { purl: 'swift', github: 'swift' }, - ] - - const mockOctokit = { - rest: { - securityAdvisories: { - listGlobalAdvisories: vi.fn(), - }, - }, - } - mockGetOctokit.mockReturnValue(mockOctokit as unknown) - mockCacheFetch.mockImplementation(async (_key, fn) => { - await fn() - return { data: [] } - }) - - for (const { github, purl } of ecosystemMappings) { - mockGetPurlObject.mockReturnValue({ - name: 'test-package', - type: purl, - version: '1.0.0', - } as unknown) - - await convertPurlToGhsas(`pkg:${purl}/test-package@1.0.0`) - - expect( - mockOctokit.rest.securityAdvisories.listGlobalAdvisories, - ).toHaveBeenCalledWith({ - ecosystem: github, - affects: 'test-package@1.0.0', - }) - } - }) -}) diff --git a/packages/cli/test/unit/util/python/standalone.test.mts b/packages/cli/test/unit/util/python/standalone.test.mts deleted file mode 100644 index bd8973319..000000000 --- a/packages/cli/test/unit/util/python/standalone.test.mts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Unit tests for Python standalone utilities. - * - * Purpose: Tests the re-exports from the DLX spawn utilities. - * - * Test Coverage: - Re-export verification - Type export verification - Function - * signatures. - * - * Related Files: - util/python/standalone.mts (implementation) - - * util/dlx/spawn.mts (source module) - */ - -import { describe, expect, it } from 'vitest' - -import { - ensurePython, - ensurePythonDlx, - ensureSocketPyCli, - spawnSocketPyCli, -} from '../../../../src/util/python/standalone.mts' - -// Also import directly from dlx/spawn to verify the re-exports match. -import { - ensurePython as dlxEnsurePython, - ensurePythonDlx as dlxEnsurePythonDlx, - ensureSocketPyCli as dlxEnsureSocketPyCli, - spawnSocketPyCli as dlxSpawnSocketPyCli, -} from '../../../../src/util/dlx/spawn.mts' - -describe('python/standalone exports', () => { - describe('re-exported functions', () => { - it('exports ensurePython function', () => { - expect(typeof ensurePython).toBe('function') - }) - - it('exports ensurePythonDlx function', () => { - expect(typeof ensurePythonDlx).toBe('function') - }) - - it('exports ensureSocketPyCli function', () => { - expect(typeof ensureSocketPyCli).toBe('function') - }) - - it('exports spawnSocketPyCli function', () => { - expect(typeof spawnSocketPyCli).toBe('function') - }) - }) - - describe('re-export identity', () => { - it('ensurePython is the same function from dlx/spawn', () => { - expect(ensurePython).toBe(dlxEnsurePython) - }) - - it('ensurePythonDlx is the same function from dlx/spawn', () => { - expect(ensurePythonDlx).toBe(dlxEnsurePythonDlx) - }) - - it('ensureSocketPyCli is the same function from dlx/spawn', () => { - expect(ensureSocketPyCli).toBe(dlxEnsureSocketPyCli) - }) - - it('spawnSocketPyCli is the same function from dlx/spawn', () => { - expect(spawnSocketPyCli).toBe(dlxSpawnSocketPyCli) - }) - }) - - describe('function signatures', () => { - it('ensurePython is an async function', () => { - // Async functions have a constructor named AsyncFunction. - expect(ensurePython.constructor.name).toBe('AsyncFunction') - }) - - it('ensurePythonDlx is an async function', () => { - expect(ensurePythonDlx.constructor.name).toBe('AsyncFunction') - }) - - it('ensureSocketPyCli is an async function', () => { - expect(ensureSocketPyCli.constructor.name).toBe('AsyncFunction') - }) - - it('spawnSocketPyCli is an async function', () => { - expect(spawnSocketPyCli.constructor.name).toBe('AsyncFunction') - }) - }) -}) diff --git a/packages/cli/test/unit/util/sanitize-names.test.mts b/packages/cli/test/unit/util/sanitize-names.test.mts deleted file mode 100644 index f07f18d6e..000000000 --- a/packages/cli/test/unit/util/sanitize-names.test.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Unit tests for name sanitization. - * - * Purpose: Tests package and file name sanitization. Validates safe name - * transformations. - * - * Test Coverage: - Package name sanitization - File name sanitization - Special - * character handling - Path traversal prevention - Unicode normalization. - * - * Testing Approach: Tests input sanitization for security. - * - * Related Files: - util/sanitize-names.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { extractName, extractOwner } from '../../../src/util/sanitize-names.mts' - -describe('extract-names utilities', () => { - describe('extractName', () => { - it('returns valid names unchanged', () => { - expect(extractName('valid-name')).toBe('valid-name') - expect(extractName('valid_name')).toBe('valid_name') - expect(extractName('valid.name')).toBe('valid.name') - expect(extractName('ValidName123')).toBe('ValidName123') - }) - - it('replaces illegal characters with underscores', () => { - expect(extractName('name@with#special$chars')).toBe( - 'name_with_special_chars', - ) - expect(extractName('name with spaces')).toBe('name_with_spaces') - expect(extractName('name/with/slashes')).toBe('name_with_slashes') - expect(extractName('name\\with\\backslashes')).toBe( - 'name_with_backslashes', - ) - }) - - it('replaces multiple consecutive special chars with single underscore', () => { - expect(extractName('name...test')).toBe('name_test') - expect(extractName('name___test')).toBe('name_test') - expect(extractName('name---test')).toBe('name_test') - expect(extractName('name.-.test')).toBe('name_test') - }) - - it('removes leading special characters', () => { - expect(extractName('.leading-dot')).toBe('leading-dot') - expect(extractName('-leading-dash')).toBe('leading-dash') - expect(extractName('_leading-underscore')).toBe('leading-underscore') - expect(extractName('...leading-dots')).toBe('leading-dots') - }) - - it('removes trailing special characters', () => { - expect(extractName('trailing-dot.')).toBe('trailing-dot') - expect(extractName('trailing-dash-')).toBe('trailing-dash') - expect(extractName('trailing-underscore_')).toBe('trailing-underscore') - expect(extractName('trailing-dots...')).toBe('trailing-dots') - }) - - it('truncates names longer than 100 characters', () => { - const longName = 'a'.repeat(150) - const result = extractName(longName) - expect(result).toBe('a'.repeat(100)) - expect(result.length).toBe(100) - }) - - it('handles complex sanitization scenarios', () => { - expect(extractName('@scope/package-name')).toBe('scope_package-name') - expect(extractName('!!!special!!!name!!!')).toBe('special_name') - expect(extractName('...---___test___---...')).toBe('test') - }) - - it('returns default repository for empty string', () => { - expect(extractName('')).toBe('socket-default-repository') - }) - - it('returns default repository when sanitization results in empty string', () => { - expect(extractName('...')).toBe('socket-default-repository') - expect(extractName('---')).toBe('socket-default-repository') - expect(extractName('___')).toBe('socket-default-repository') - expect(extractName('!@#$%^&*()')).toBe('socket-default-repository') - }) - - it('handles Unicode characters', () => { - expect(extractName('emoji-🚀-name')).toBe('emoji_name') - expect(extractName('中文名称')).toBe('socket-default-repository') - expect(extractName('name-with-émojis')).toBe('name-with_mojis') - }) - - it('preserves case', () => { - expect(extractName('CamelCase')).toBe('CamelCase') - expect(extractName('UPPERCASE')).toBe('UPPERCASE') - expect(extractName('lowercase')).toBe('lowercase') - expect(extractName('MiXeD-CaSe')).toBe('MiXeD-CaSe') - }) - }) - - describe('extractOwner', () => { - it('returns valid owner names', () => { - expect(extractOwner('valid-owner')).toBe('valid-owner') - expect(extractOwner('valid_owner')).toBe('valid_owner') - expect(extractOwner('valid.owner')).toBe('valid.owner') - expect(extractOwner('ValidOwner123')).toBe('ValidOwner123') - }) - - it('sanitizes owner names like extractName', () => { - expect(extractOwner('owner@with#special')).toBe('owner_with_special') - expect(extractOwner('owner with spaces')).toBe('owner_with_spaces') - expect(extractOwner('.leading-dot')).toBe('leading-dot') - expect(extractOwner('trailing-dot.')).toBe('trailing-dot') - }) - - it('returns undefined for empty input', () => { - expect(extractOwner('')).toBeUndefined() - }) - - it('returns undefined when sanitization results in empty string', () => { - expect(extractOwner('...')).toBeUndefined() - expect(extractOwner('---')).toBeUndefined() - expect(extractOwner('___')).toBeUndefined() - expect(extractOwner('!@#$%^&*()')).toBeUndefined() - }) - - it('truncates owner names longer than 100 characters', () => { - const longOwner = 'o'.repeat(150) - const result = extractOwner(longOwner) - expect(result).toBe('o'.repeat(100)) - expect(result?.length).toBe(100) - }) - - it('handles organization names from npm scopes', () => { - expect(extractOwner('@organization')).toBe('organization') - expect(extractOwner('@my-org/package')).toBe('my-org_package') - }) - - it('handles GitHub-style owner names', () => { - expect(extractOwner('github-user')).toBe('github-user') - expect(extractOwner('org-name-123')).toBe('org-name-123') - }) - - it('does not use default repository for owners', () => { - // Unlike extractName, extractOwner returns undefined instead of default. - expect(extractOwner('!!!')).toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/sea/boot.test.mts b/packages/cli/test/unit/util/sea/boot.test.mts deleted file mode 100644 index 96168a29a..000000000 --- a/packages/cli/test/unit/util/sea/boot.test.mts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Unit tests for SEA bootstrap utilities. - * - * Purpose: Tests SEA (Single Executable Application) bootstrap functionality. - * Validates subprocess detection and spawn option preparation. - * - * Test Coverage: - isSubprocess detection - shouldBypassBootstrap logic - - * getBootstrapExecPath path selection - prepareBootstrapSpawnOptions option - * handling - sendBootstrapHandshake IPC messaging. - * - * Related Files: - util/sea/boot.mts (implementation) - util/sea/detect.mts - * (SEA detection) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: vi.fn(), -})) - -import { SOCKET_IPC_HANDSHAKE } from '@socketsecurity/lib-stable/constants/socket' - -import { isSeaBinary } from '../../../../src/util/sea/detect.mts' -import { - isSubprocess, - sendBootstrapHandshake, -} from '../../../../src/util/sea/boot.mts' - -describe('sea/boot', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('isSubprocess', () => { - it('returns false when process.channel is undefined', () => { - // Default state has no channel. - expect(isSubprocess()).toBe(false) - }) - }) - - describe('sendBootstrapHandshake', () => { - it('sends IPC handshake message with correct format', () => { - const mockSend = vi.fn() - const childProcess = { send: mockSend } - const ipcData = { subprocess: true, parent_pid: 12345 } - - sendBootstrapHandshake(childProcess, ipcData) - - expect(mockSend).toHaveBeenCalledTimes(1) - const sentMessage = mockSend.mock.calls[0]![0] - expect(sentMessage).toHaveProperty(SOCKET_IPC_HANDSHAKE) - expect(sentMessage[SOCKET_IPC_HANDSHAKE]).toEqual(ipcData) - }) - - it('sends custom IPC data', () => { - const mockSend = vi.fn() - const childProcess = { send: mockSend } - const ipcData = { - subprocess: true, - parent_pid: 99999, - custom: 'data', - nested: { key: 'value' }, - } - - sendBootstrapHandshake(childProcess, ipcData) - - const sentMessage = mockSend.mock.calls[0]![0] - expect(sentMessage[SOCKET_IPC_HANDSHAKE]).toEqual(ipcData) - }) - }) - - describe('waitForBootstrapHandshake', () => { - it('resolves with undefined when no IPC channel exists', async () => { - // Import fresh module to test default behavior. - const { waitForBootstrapHandshake } = - await import('../../../../src/util/sea/boot.mts') - - // No IPC channel, should resolve immediately with undefined. - const result = await waitForBootstrapHandshake(100) - expect(result).toBeUndefined() - }) - - it('resolves with handshake data when message arrives', async () => { - // Stub process.channel + .on/.off so isSubprocess() reports true. - const handlers: Record<string, Array<(m: unknown) => void>> = {} - const fakeOn = vi.fn((event: string, handler: (m: unknown) => void) => { - ;(handlers[event] ??= []).push(handler) - }) - const fakeOff = vi.fn((event: string, handler: (m: unknown) => void) => { - handlers[event] = (handlers[event] ?? []).filter(h => h !== handler) - }) - const originalChannel = process.channel - const originalOn = process.on - const originalOff = process.off - - Object.defineProperty(process, 'channel', { - value: {} as unknown, - writable: true, - configurable: true, - }) - ;(process as unknown).on = fakeOn - ;(process as unknown).off = fakeOff - - try { - const { waitForBootstrapHandshake } = - await import('../../../../src/util/sea/boot.mts') - const promise = waitForBootstrapHandshake(500) - // Schedule the message after the handler is registered. - await new Promise(resolve => setImmediate(resolve)) - const msg = { - [SOCKET_IPC_HANDSHAKE]: { subprocess: true, parent_pid: 12345 }, - } - for (const handler of handlers['message'] ?? []) { - handler(msg) - } - const result = await promise - expect(result).toEqual({ subprocess: true, parent_pid: 12345 }) - } finally { - Object.defineProperty(process, 'channel', { - value: originalChannel, - writable: true, - configurable: true, - }) - ;(process as unknown).on = originalOn - ;(process as unknown).off = originalOff - } - }) - - it('rejects on timeout when no message arrives', async () => { - const fakeOn = vi.fn() - const fakeOff = vi.fn() - const originalChannel = process.channel - const originalOn = process.on - const originalOff = process.off - - Object.defineProperty(process, 'channel', { - value: {} as unknown, - writable: true, - configurable: true, - }) - ;(process as unknown).on = fakeOn - ;(process as unknown).off = fakeOff - - try { - const { waitForBootstrapHandshake } = - await import('../../../../src/util/sea/boot.mts') - await expect(waitForBootstrapHandshake(50)).rejects.toThrow(/timeout/) - } finally { - Object.defineProperty(process, 'channel', { - value: originalChannel, - writable: true, - configurable: true, - }) - ;(process as unknown).on = originalOn - ;(process as unknown).off = originalOff - } - }) - - it('ignores non-handshake messages', async () => { - const handlers: Record<string, Array<(m: unknown) => void>> = {} - const fakeOn = vi.fn((event: string, handler: (m: unknown) => void) => { - ;(handlers[event] ??= []).push(handler) - }) - const fakeOff = vi.fn() - const originalChannel = process.channel - const originalOn = process.on - const originalOff = process.off - - Object.defineProperty(process, 'channel', { - value: {} as unknown, - writable: true, - configurable: true, - }) - ;(process as unknown).on = fakeOn - ;(process as unknown).off = fakeOff - - try { - const { waitForBootstrapHandshake } = - await import('../../../../src/util/sea/boot.mts') - const promise = waitForBootstrapHandshake(50) - await new Promise(resolve => setImmediate(resolve)) - // Send a few non-handshake messages to exercise early-returns. - for (const handler of handlers['message'] ?? []) { - handler(undefined) - handler('string') - handler({ unrelated: true }) - handler({ [SOCKET_IPC_HANDSHAKE]: 'not-an-object' }) - handler({ [SOCKET_IPC_HANDSHAKE]: { subprocess: false } }) - } - await expect(promise).rejects.toThrow(/timeout/) - } finally { - Object.defineProperty(process, 'channel', { - value: originalChannel, - writable: true, - configurable: true, - }) - ;(process as unknown).on = originalOn - ;(process as unknown).off = originalOff - } - }) - }) -}) diff --git a/packages/cli/test/unit/util/sea/detect.test.mts b/packages/cli/test/unit/util/sea/detect.test.mts deleted file mode 100644 index 9f564792f..000000000 --- a/packages/cli/test/unit/util/sea/detect.test.mts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Unit tests for SEA detection utilities. - * - * Purpose: Tests the SEA (Single Executable Application) detection and related - * utilities. - * - * Test Coverage: - isSeaBinary function - getSeaBinaryPath function - - * canSelfUpdate function. - * - * Note: These tests verify behavior in a non-SEA environment since the tests - * run in Node.js where node:sea module is not available. SEA-specific behavior - * is tested by verifying the API surface and return types. - * - * Related Files: - src/util/sea/detect.mts (implementation) - */ - -import { describe, expect, it } from 'vitest' - -import { - canSelfUpdate, - getSeaBinaryPath, - isSeaBinary, -} from '../../../../src/util/sea/detect.mts' - -import type * as ModuleModule from 'node:module' - -describe('SEA detection utilities', () => { - describe('isSeaBinary', () => { - it('returns false in non-SEA environment', () => { - // In a test environment running via Node.js (not SEA), this should return false. - expect(isSeaBinary()).toBe(false) - }) - - it('returns consistent results on multiple calls', () => { - // The result should be cached and consistent. - const result1 = isSeaBinary() - const result2 = isSeaBinary() - const result3 = isSeaBinary() - - expect(result1).toBe(result2) - expect(result2).toBe(result3) - }) - - it('returns a boolean', () => { - expect(typeof isSeaBinary()).toBe('boolean') - }) - }) - - describe('getSeaBinaryPath', () => { - it('returns undefined in non-SEA environment', () => { - // Since we're not running as SEA, this should return undefined. - expect(getSeaBinaryPath()).toBeUndefined() - }) - - it('returns undefined or string type', () => { - const result = getSeaBinaryPath() - expect(result === undefined || typeof result === 'string').toBe(true) - }) - }) - - describe('canSelfUpdate', () => { - it('returns false in non-SEA environment', () => { - // Self-update requires SEA binary, so false in test environment. - expect(canSelfUpdate()).toBe(false) - }) - - it('returns a boolean', () => { - expect(typeof canSelfUpdate()).toBe('boolean') - }) - }) - - describe('function exports', () => { - it('exports isSeaBinary function', () => { - expect(typeof isSeaBinary).toBe('function') - }) - - it('exports getSeaBinaryPath function', () => { - expect(typeof getSeaBinaryPath).toBe('function') - }) - - it('exports canSelfUpdate function', () => { - expect(typeof canSelfUpdate).toBe('function') - }) - }) - - describe('isSeaBinary catch fallback', () => { - it('returns false when node:sea cannot be required (older Node)', async () => { - // Force require('node:sea') to throw via vi.doMock + a fresh module - // import. This exercises the catch arm of isSeaBinary(). - const { vi } = await import('vitest') - vi.resetModules() - vi.doMock('node:module', async importOriginal => { - const actual = await importOriginal<typeof ModuleModule>() - return { - ...actual, - createRequire: () => () => { - throw new Error('Cannot find module node:sea') - }, - } - }) - const fresh = - await import('../../../../src/util/sea/detect.mts?cache_bust=throw') - expect(fresh.isSeaBinary()).toBe(false) - vi.doUnmock('node:module') - }) - }) -}) diff --git a/packages/cli/test/unit/util/semver.test.mts b/packages/cli/test/unit/util/semver.test.mts deleted file mode 100644 index 9829d1277..000000000 --- a/packages/cli/test/unit/util/semver.test.mts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Unit tests for semver utilities. - * - * Purpose: Tests semantic versioning utilities. Validates version parsing, - * comparison, and range matching. - * - * Test Coverage: - Version parsing - Version comparison - Range satisfaction - - * Version sorting - Prerelease handling - Build metadata. - * - * Testing Approach: Tests semver utilities used for dependency version - * resolution. - * - * Related Files: - util/semver.mts (implementation) - */ - -import semver from 'semver' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { RangeStyles, getMajor } from '../../../src/util/semver.mts' - -// Mock semver. -vi.mock('semver', () => ({ - default: { - coerce: vi.fn(), - major: vi.fn(), - minVersion: vi.fn(), - }, -})) - -describe('semver utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('RangeStyles', () => { - it('contains expected styles', () => { - expect(RangeStyles).toEqual(['pin', 'preserve']) - }) - }) - - describe('getMajor', () => { - it('returns major version for valid semver', () => { - vi.mocked(semver.coerce).mockReturnValue({ version: '1.2.3' } as unknown) - vi.mocked(semver.major).mockReturnValue(1) - - const result = getMajor('1.2.3') - expect(result).toBe(1) - expect(semver.coerce).toHaveBeenCalledWith('1.2.3') - expect(semver.major).toHaveBeenCalledWith({ version: '1.2.3' }) - }) - - it('returns undefined when coerce returns null', () => { - vi.mocked(semver.coerce).mockReturnValue(undefined) - - const result = getMajor('invalid') - expect(result).toBeUndefined() - }) - - it('returns undefined when coerce throws', () => { - vi.mocked(semver.coerce).mockImplementation(() => { - throw new Error('Invalid version') - }) - - const result = getMajor('bad-version') - expect(result).toBeUndefined() - }) - - it('handles non-string input', () => { - vi.mocked(semver.coerce).mockReturnValue(undefined) - - expect(getMajor(123)).toBeUndefined() - expect(getMajor(undefined)).toBeUndefined() - expect(getMajor(undefined)).toBeUndefined() - expect(getMajor({})).toBeUndefined() - }) - }) - -}) diff --git a/packages/cli/test/unit/util/socket/api-helpers.test.mts b/packages/cli/test/unit/util/socket/api-helpers.test.mts deleted file mode 100644 index 9247c19dc..000000000 --- a/packages/cli/test/unit/util/socket/api-helpers.test.mts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Direct tests for the now-exported helpers in util/socket/api.mts. - * - * Related Files: - src/util/socket/api.mts. - */ - -import { describe, expect, it } from 'vitest' - -import { - getCommandRequirements, - tryReadResponseText, -} from '../../../../src/util/socket/api.mts' - -describe('tryReadResponseText', () => { - it('returns the text when response.text() succeeds', () => { - const result = tryReadResponseText({ - text: () => 'response body', - } as unknown) - expect(result).toBe('response body') - }) - - it('returns undefined when response.text() throws', () => { - const result = tryReadResponseText({ - text: () => { - throw new Error('already consumed') - }, - } as unknown) - expect(result).toBeUndefined() - }) - - it('returns undefined when response has no text method', () => { - const result = tryReadResponseText({} as unknown) - expect(result).toBeUndefined() - }) -}) - -describe('getCommandRequirements', () => { - it('returns undefined when no command path is provided', () => { - expect(getCommandRequirements()).toBeUndefined() - expect(getCommandRequirements(undefined)).toBeUndefined() - expect(getCommandRequirements('')).toBeUndefined() - }) - - it('returns undefined for unknown command paths', () => { - expect(getCommandRequirements('socket:nonexistent:path')).toBeUndefined() - }) - - it('returns requirements for a known command path', () => { - // The exact requirements depend on requirements.json content, but - // for a real command like "socket scan create" we get back an - // object with at least one known field (permissions or quota). - const result = getCommandRequirements('socket scan:create') - if (result !== undefined) { - expect(typeof result).toBe('object') - } - }) -}) diff --git a/packages/cli/test/unit/util/socket/api.test.mts b/packages/cli/test/unit/util/socket/api.test.mts deleted file mode 100644 index f76e4198f..000000000 --- a/packages/cli/test/unit/util/socket/api.test.mts +++ /dev/null @@ -1,827 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for Socket API utilities. - * - * Purpose: Tests Socket API interaction utilities. Validates API error handling - * and response parsing. - * - * Test Coverage: - API call wrapper (handleApiCall) - Error response parsing - - * Rate limit handling - Retry logic - Timeout handling - queryApi function - - * queryApiSafeText function - queryApiSafeJson function - sendApiRequest - * function. - * - * Testing Approach: Mocks fetch/axios to test API utilities. Uses - * @socketsecurity/sdk testing utilities for mock responses. - * - * Related Files: - util/socket/api.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - mockErrorResponse, - mockSuccessResponse, -} from '@socketsecurity/sdk-stable/testing' - -// Mock dependencies first. -const mockSpinner = vi.hoisted(() => vi.fn()) -const mockStart = vi.hoisted(() => vi.fn()) -const mockStop = vi.hoisted(() => vi.fn()) -const mockSucceed = vi.hoisted(() => vi.fn()) -const mockFail = vi.hoisted(() => vi.fn()) -const mockSuccessAndStop = vi.hoisted(() => vi.fn()) -const mockFailAndStop = vi.hoisted(() => vi.fn()) - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: mockFail, - group: vi.fn(), - groupEnd: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -const mockGetDefaultSpinner = vi.hoisted(() => - vi.fn(() => ({ - failAndStop: mockFailAndStop, - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - successAndStop: mockSuccessAndStop, - })), -) -vi.mock('@socketsecurity/lib-stable/spinner/spinner', () => ({ - Spinner: mockSpinner, -})) -vi.mock('@socketsecurity/lib-stable/spinner/default', () => ({ - getDefaultSpinner: mockGetDefaultSpinner, -})) - -// Mock getDefaultApiToken. -const mockGetDefaultApiToken = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - getDefaultApiToken: mockGetDefaultApiToken, - getExtraCaCerts: () => undefined, -})) - -// Mock getNetworkErrorDiagnostics. -vi.mock('../../../../src/util/error/errors.mts', () => ({ - buildErrorCause: vi.fn(async (code: number) => `Error code: ${code}`), - getNetworkErrorDiagnostics: vi.fn(() => 'Network error diagnostics'), -})) - -// Mock httpRequest from socket-lib (replaces fetch). -const mockHttpRequest = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/http-request/request', () => ({ - httpRequest: mockHttpRequest, -})) - -// Helper to create httpRequest-style response objects (synchronous .text()/.json()). -function createHttpResponse(opts: { - body?: string | undefined - ok?: boolean | undefined - status?: number | undefined - statusText?: string | undefined -}) { - const bodyStr = opts.body ?? '' - const bodyBuffer = Buffer.from(bodyStr) - return { - body: bodyBuffer, - headers: {}, - json: () => JSON.parse(bodyStr), - ok: opts.ok ?? true, - status: opts.status ?? 200, - statusText: opts.statusText ?? 'OK', - text: () => bodyStr, - } -} - -import { overrideCachedConfig } from '../../../../src/util/config.mts' -import { - getDefaultApiBaseUrl, - getErrorMessageForHttpStatusCode, - handleApiCall, - handleApiCallNoSpinner, - logPermissionsFor403, - queryApi, - queryApiSafeJson, - queryApiSafeText, - sendApiRequest, -} from '../../../../src/util/socket/api.mts' - -describe('api utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - // Clear cached config to avoid test interference. - overrideCachedConfig('{}') - }) - - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllEnvs() - mockHttpRequest.mockReset() - mockGetDefaultApiToken.mockReset() - }) - - describe('getDefaultApiBaseUrl', () => { - it('returns environment variable when set', async () => { - // Use vi.stubEnv to properly mock environment variable. - vi.stubEnv('SOCKET_CLI_API_BASE_URL', 'https://custom.api.url') - // In VITEST mode, ENV uses process.env directly via Proxy. - const result = getDefaultApiBaseUrl() - expect(result).toBe('https://custom.api.url') - }) - - it('falls back to config value when env not set', async () => { - // Ensure env is not set by deleting it. - delete process.env['SOCKET_CLI_API_BASE_URL'] - // Set config value using overrideCachedConfig (expects JSON string). - overrideCachedConfig('{"apiBaseUrl": "https://config.api.url"}') - - const result = getDefaultApiBaseUrl() - expect(result).toBe('https://config.api.url') - }) - - it('returns default API_V0_URL when neither env nor config set', async () => { - // Ensure env is not set by deleting it. - delete process.env['SOCKET_CLI_API_BASE_URL'] - // Config is already cleared in beforeEach with overrideCachedConfig({}). - - const result = getDefaultApiBaseUrl() - expect(result).toBe('https://api.socket.dev/v0/') - }) - }) - - describe('getErrorMessageForHttpStatusCode', () => { - it('returns message for 400 Bad Request', async () => { - const result = await getErrorMessageForHttpStatusCode(400) - expect(result).toContain('incorrect') - }) - - it('returns message for 401 Unauthorized', async () => { - const result = await getErrorMessageForHttpStatusCode(401) - // 401 is now distinct from 403: it's an auth/token problem, not - // a permissions problem. Callers get actionable "re-auth" guidance. - expect(result).toContain('Authentication failed') - expect(result).toContain('token') - }) - - it('returns message for 403 Forbidden', async () => { - const result = await getErrorMessageForHttpStatusCode(403) - expect(result).toContain('permissions') - }) - - it('returns message for 404 Not Found', async () => { - const result = await getErrorMessageForHttpStatusCode(404) - expect(result).toContain('Not found') - expect(result).toContain("doesn't exist") - }) - - it('returns message for 429 Rate Limit', async () => { - const result = await getErrorMessageForHttpStatusCode(429) - expect(result).toContain('Rate limit exceeded') - expect(result).toContain('Too many API requests') - }) - - it('returns message for 500 Internal Server Error', async () => { - const result = await getErrorMessageForHttpStatusCode(500) - expect(result).toContain('Server error') - expect(result).toContain('internal problem') - }) - - it('returns generic message for unknown status code', async () => { - const result = await getErrorMessageForHttpStatusCode(418) - expect(result).toContain('HTTP 418') - expect(result).toContain('unexpected status code') - }) - }) - - describe('handleApiCall', () => { - it('returns success result for successful API call', async () => { - const mockApiPromise = Promise.resolve({ - success: true, - data: { result: 'test' }, - } as unknown) - - const result = await handleApiCall(mockApiPromise) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ result: 'test' }) - } - }) - - it('returns error result for failed API call', async () => { - const mockApiPromise = Promise.resolve({ - success: false, - error: { message: 'API error', statusCode: 400 }, - } as unknown) - - const result = await handleApiCall(mockApiPromise) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Socket API error') - } - }) - - it('handles API call exceptions', async () => { - const mockApiPromise = Promise.reject(new Error('Network error')) - - const result = await handleApiCall(mockApiPromise) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Socket API error') - } - }) - - it('uses spinner when provided', async () => { - const mockApiPromise = Promise.resolve({ - success: true, - data: { result: 'test' }, - } as unknown) - - const mockSpinner = { - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - fail: mockFail, - } - - await handleApiCall(mockApiPromise, { spinner: mockSpinner as unknown }) - expect(mockSpinner.start).toHaveBeenCalled() - expect(mockSpinner.stop).toHaveBeenCalled() - }) - - it('starts spinner with description when both provided (line 256)', async () => { - // description + spinner → start with prefixed message + log success. - const mockApiPromise = Promise.resolve({ - success: true, - data: { x: 1 }, - } as unknown) - const mockSpinner = { - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - fail: mockFail, - } - await handleApiCall(mockApiPromise, { - description: 'test data', - spinner: mockSpinner as unknown, - }) - expect(mockSpinner.start).toHaveBeenCalledWith( - expect.stringContaining('Requesting test data from API'), - ) - }) - - it('logs success message when description + spinner + success (lines 266-272)', async () => { - const mockApiPromise = Promise.resolve({ - success: true, - data: { x: 1 }, - } as unknown) - const mockSpinner = { - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - fail: mockFail, - } - await handleApiCall(mockApiPromise, { - description: 'thing', - spinner: mockSpinner as unknown, - }) - // logger.success was called via the description+spinner branch. - expect(mockLogger.success).toHaveBeenCalledWith( - expect.stringContaining('thing'), - ) - }) - - it('logs info message when description + spinner + non-success result (lines 271)', async () => { - // success: false but it's the SDK-level non-success (not a thrown error). - const mockApiPromise = Promise.resolve({ - success: false, - error: { message: 'fail', statusCode: 500 }, - } as unknown) - const mockSpinner = { - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - fail: mockFail, - } - await handleApiCall(mockApiPromise, { - description: 'thing', - spinner: mockSpinner as unknown, - }) - // logger.info was called via the description+spinner branch on - // non-thrown failure. - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('thing'), - ) - }) - - it('logs fail message when description + thrown error (lines 281-282)', async () => { - const mockApiPromise = Promise.reject(new Error('boom')) - await handleApiCall(mockApiPromise, { description: 'thing' }) - // logger.fail was called via the description-on-error branch. - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('thing'), - ) - }) - - it('logs permissions for 403 errors when commandPath provided (line 322-323)', async () => { - // sdk-level non-success with status=403 + commandPath in options → - // calls logPermissionsFor403, which logs the API permissions group. - const mockApiPromise = Promise.resolve({ - success: false, - status: 403, - error: { message: 'Forbidden', statusCode: 403 }, - } as unknown) - await handleApiCall(mockApiPromise, { - commandPath: 'socket fix', - }) - expect(mockLogger.group).toHaveBeenCalledWith( - expect.stringContaining('Required API Permissions'), - ) - }) - }) - - describe('handleApiCallNoSpinner', () => { - it('does not use spinner even if provided', async () => { - const mockApiPromise = Promise.resolve({ - success: true, - data: { result: 'test' }, - } as unknown) - - const mockSpinner = { - start: mockStart, - stop: mockStop, - succeed: mockSucceed, - fail: mockFail, - } - - await handleApiCallNoSpinner(mockApiPromise, { - spinner: mockSpinner as unknown, - }) - expect(mockSpinner.start).not.toHaveBeenCalled() - }) - - it('returns success result for successful API call', async () => { - const mockApiPromise = Promise.resolve({ - success: true, - data: { result: 'test' }, - } as unknown) - - const result = await handleApiCallNoSpinner(mockApiPromise) - expect(result.ok).toBe(true) - }) - }) - - describe('logPermissionsFor403', () => { - it('logs specific permissions when command requirements are found', () => { - logPermissionsFor403('socket fix') - - // Verify logger.group was called with permissions header. - expect(mockLogger.group).toHaveBeenCalledWith( - '🔐 Required API Permissions:', - ) - - // Verify permissions were logged. - expect(mockLogger.error).toHaveBeenCalledWith('full-scans:create') - expect(mockLogger.error).toHaveBeenCalledWith('packages:list') - - // Verify groupEnd was called. - expect(mockLogger.groupEnd).toHaveBeenCalled() - - // Verify fix instructions. - expect(mockLogger.group).toHaveBeenCalledWith('💡 To fix this:') - expect(mockLogger.error).toHaveBeenCalledWith( - 'Visit https://socket.dev/settings/api-tokens', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Edit your API token to grant the permissions listed above', - ) - expect(mockLogger.error).toHaveBeenCalledWith('Re-run your command') - }) - - it('logs general guidance when command requirements not found', () => { - logPermissionsFor403('socket unknown') - - // Verify general permission message. - expect(mockLogger.group).toHaveBeenCalledWith( - '🔐 Permission Requirements:', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Your API token lacks the required permissions for this operation.', - ) - - // Verify general fix instructions. - expect(mockLogger.group).toHaveBeenCalledWith('💡 To fix this:') - expect(mockLogger.error).toHaveBeenCalledWith( - 'Visit https://socket.dev/settings/api-tokens', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Check your API token has the necessary permissions', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Run `socket unknown --help` to see required permissions', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Re-run your command after updating permissions', - ) - }) - - it('handles undefined cmdPath gracefully', () => { - logPermissionsFor403(undefined) - - // Should show general guidance. - expect(mockLogger.group).toHaveBeenCalledWith( - '🔐 Permission Requirements:', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Run `socket help --help` to see required permissions', - ) - }) - - it('logs permissions for scan:create command', () => { - logPermissionsFor403('socket scan:create') - - expect(mockLogger.group).toHaveBeenCalledWith( - '🔐 Required API Permissions:', - ) - expect(mockLogger.error).toHaveBeenCalledWith('full-scans:create') - }) - }) - - describe('queryApi', () => { - it('makes authenticated GET request to Socket API', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: 'response text' }), - ) - - const result = await queryApi('test/path', 'test-token') - - expect(mockHttpRequest).toHaveBeenCalledWith( - 'https://api.socket.dev/v0/test/path', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: expect.stringContaining('Basic'), - }), - }), - ) - expect(result.ok).toBe(true) - }) - - it('throws error when base URL is not configured', async () => { - // Mock to return undefined. - vi.stubEnv('SOCKET_CLI_API_BASE_URL', '') - overrideCachedConfig('{"apiBaseUrl": ""}') - - // Since API_V0_URL is always returned as fallback, queryApi won't throw - // unless we mock getDefaultApiBaseUrl to return undefined. - // For now, let's test the normal path. - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: 'response' }), - ) - - const result = await queryApi('path', 'token') - expect(result.ok).toBe(true) - }) - }) - - describe('queryApiSafeText', () => { - beforeEach(() => { - // Reset mock for each test. - mockGetDefaultApiToken.mockReturnValue('test-token') - }) - - it('returns error when not authenticated', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - - const result = await queryApiSafeText('test/path') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Authentication Error') - } - }) - - it('returns success with text data for successful request', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: 'response data' }), - ) - - const result = await queryApiSafeText('test/path', 'test description') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBe('response data') - } - expect(mockSuccessAndStop).toHaveBeenCalled() - }) - - it('returns error for failed HTTP status', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ - ok: false, - status: 403, - statusText: 'Forbidden', - }), - ) - - const result = await queryApiSafeText( - 'test/path', - 'test description', - 'socket fix', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Socket API error') - } - }) - - it('returns error for network failures', async () => { - mockHttpRequest.mockRejectedValueOnce(new Error('Network failure')) - - const result = await queryApiSafeText('test/path', 'test description') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('failed') - // The cause must include the request path so the user can tell - // which endpoint failed when several calls are in flight. - expect(result.cause).toContain('(path: test/path)') - } - expect(mockFailAndStop).toHaveBeenCalled() - }) - - it('returns error when response text cannot be read', async () => { - // With httpRequest, .text() is synchronous. Simulate a response - // where text() throws by providing a malformed mock. - mockHttpRequest.mockResolvedValueOnce({ - body: Buffer.alloc(0), - headers: {}, - json: () => undefined, - ok: true, - status: 200, - statusText: 'OK', - text: () => { - throw new Error('Read error') - }, - }) - - const result = await queryApiSafeText('test/path') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('response text') - expect(result.cause).toContain('(path: test/path)') - } - }) - }) - - describe('queryApiSafeJson', () => { - beforeEach(() => { - mockGetDefaultApiToken.mockReturnValue('test-token') - }) - - it('parses JSON response successfully', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: '{"key": "value"}' }), - ) - - const result = await queryApiSafeJson<{ key: string }>('test/path') - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ key: 'value' }) - } - }) - - it('returns error for invalid JSON', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: 'not valid json' }), - ) - - const result = await queryApiSafeJson<unknown>('test/path') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('invalid JSON') - } - }) - - it('propagates authentication errors', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - - const result = await queryApiSafeJson<unknown>('test/path') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Authentication Error') - } - }) - }) - - describe('sendApiRequest', () => { - beforeEach(() => { - mockGetDefaultApiToken.mockReturnValue('test-token') - }) - - it('returns error when not authenticated', async () => { - mockGetDefaultApiToken.mockReturnValue(undefined) - - const result = await sendApiRequest<unknown>('test/path', { - method: 'POST', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Authentication Error') - } - }) - - it('sends POST request with JSON body', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: '{"result": "success"}' }), - ) - - const result = await sendApiRequest<{ result: string }>('test/path', { - method: 'POST', - body: { data: 'test' }, - description: 'test operation', - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ result: 'success' }) - } - expect(mockHttpRequest).toHaveBeenCalledWith( - 'https://api.socket.dev/v0/test/path', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ data: 'test' }), - }), - ) - }) - - it('sends PUT request', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: '{"updated": true}' }), - ) - - const result = await sendApiRequest<{ updated: boolean }>('test/path', { - method: 'PUT', - body: { value: 'updated' }, - }) - - expect(result.ok).toBe(true) - expect(mockHttpRequest).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ method: 'PUT' }), - ) - }) - - it('returns error for failed HTTP status', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }), - ) - - const result = await sendApiRequest<unknown>('test/path', { - method: 'POST', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Socket API error') - } - }) - - it('returns error for network failures', async () => { - mockHttpRequest.mockRejectedValueOnce(new Error('Connection refused')) - - const result = await sendApiRequest<unknown>('test/path', { - method: 'POST', - description: 'test operation', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('failed') - // Request path must be surfaced in error cause for debuggability. - expect(result.cause).toContain('(path: test/path)') - } - }) - - it('returns error when JSON parsing fails', async () => { - // With httpRequest, .json() is synchronous. Provide invalid JSON body. - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ body: 'not-json' }), - ) - - const result = await sendApiRequest<unknown>('test/path', { - method: 'POST', - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.cause).toContain('parsing') - expect(result.cause).toContain('(path: test/path)') - } - }) - - it('logs permissions for 403 errors when commandPath provided', async () => { - mockHttpRequest.mockResolvedValueOnce( - createHttpResponse({ - ok: false, - status: 403, - statusText: 'Forbidden', - }), - ) - - await sendApiRequest<unknown>('test/path', { - method: 'POST', - commandPath: 'socket fix', - }) - - expect(mockLogger.group).toHaveBeenCalledWith( - '🔐 Required API Permissions:', - ) - }) - }) - - describe('handleApiCallNoSpinner full signature', () => { - it('returns success result with description', async () => { - const mockApiPromise = Promise.resolve( - mockSuccessResponse({ result: 'test' }), - ) - - const result = await handleApiCallNoSpinner( - mockApiPromise, - 'test description', - ) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ result: 'test' }) - } - }) - - it('returns error result for failed SDK response', async () => { - const mockApiPromise = Promise.resolve( - mockErrorResponse('API error', 400), - ) - - const result = await handleApiCallNoSpinner( - mockApiPromise, - 'test description', - ) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Socket API error') - } - }) - - it('returns error with cause when promise rejects (lines 342-354)', async () => { - // Rejected promise hits the catch block; the error string becomes cause. - const mockApiPromise = Promise.reject(new Error('thrown boom')) - - const result = await handleApiCallNoSpinner(mockApiPromise, 'reject test') - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Socket API error') - expect(result.cause).toContain('thrown boom') - } - }) - - it('omits cause when error stringifies to empty (line 348-349)', async () => { - // String(empty error) === '' → cause is set to NO_ERROR_MESSAGE, - // and since 'Socket API error' !== NO_ERROR_MESSAGE, cause is included. - // To test the cause-equals-message edge case, throw a plain object that - // stringifies to nothing. Empty-trim case → uses NO_ERROR_MESSAGE. - const mockApiPromise = Promise.reject('') - - const result = await handleApiCallNoSpinner( - mockApiPromise as unknown, - 'empty err', - ) - - expect(result.ok).toBe(false) - }) - }) -}) diff --git a/packages/cli/test/unit/util/socket/json.test.mts b/packages/cli/test/unit/util/socket/json.test.mts deleted file mode 100644 index 5f77a1178..000000000 --- a/packages/cli/test/unit/util/socket/json.test.mts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Unit tests for Socket JSON utilities. - * - * Purpose: Tests Socket-specific JSON parsing and formatting. Validates Socket - * API response handling. - * - * Test Coverage: - JSON parsing - JSON formatting - Schema validation - - * Error-tolerant parsing - Large JSON handling. - * - * Testing Approach: Tests JSON utilities for Socket API data. - * - * Related Files: - util/socket/json.mts (implementation) - */ - -import path from 'node:path' - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockExistsSync = vi.hoisted(() => vi.fn()) -const mockReadFileSync = vi.hoisted(() => vi.fn()) -const mockReadFile = vi.hoisted(() => vi.fn()) -const mockWriteFile = vi.hoisted(() => vi.fn()) -const mockStat = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', () => ({ - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - promises: { - readFile: mockReadFile, - writeFile: mockWriteFile, - stat: mockStat, - }, - default: { - existsSync: mockExistsSync, - readFileSync: mockReadFileSync, - promises: { - readFile: mockReadFile, - writeFile: mockWriteFile, - stat: mockStat, - }, - }, -})) - -vi.mock('node:fs/promises', () => ({ - readFile: mockReadFile, - writeFile: mockWriteFile, - stat: mockStat, - default: { - readFile: mockReadFile, - writeFile: mockWriteFile, - stat: mockStat, - }, -})) - -const mockLogger = vi.hoisted(() => ({ - error: vi.fn(), - fail: vi.fn(), - info: vi.fn(), - log: vi.fn(), - success: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -import { - SOCKET_JSON, - SOCKET_WEBSITE_URL, -} from '../../../../src/constants/socket.mts' -import { - findSocketJsonUp, - getDefaultSocketJson, - readOrDefaultSocketJson, - readOrDefaultSocketJsonUp, - readSocketJsonSync, - writeSocketJson, -} from '../../../../src/util/socket/json.mts' - -describe('socket-json utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('getDefaultSocketJson', () => { - it('returns default socket.json structure', () => { - const result = getDefaultSocketJson() - expect(result.version).toBe(1) - expect(result[' _____ _ _ ']).toContain( - SOCKET_WEBSITE_URL, - ) - expect(Object.keys(result)).toContain('| __|___ ___| |_ ___| |_ ') - expect(Object.keys(result)).toContain("|__ | . | _| '_| -_| _| ") - expect(Object.keys(result)).toContain('|_____|___|___|_,_|___|_|.dev') - }) - }) - - describe('readOrDefaultSocketJson', () => { - it('returns parsed JSON when file exists and is valid', () => { - const mockJson = { version: 1, custom: 'data' } - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue(JSON.stringify(mockJson)) - - const result = readOrDefaultSocketJson('/test/dir') - expect(result).toEqual(mockJson) - }) - - it('returns default when file does not exist', () => { - mockExistsSync.mockReturnValue(false) - - const result = readOrDefaultSocketJson('/test/dir') - expect(result.version).toBe(1) - }) - - it('returns default when file read fails', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockImplementation(() => { - throw new Error('Read error') - }) - - const result = readOrDefaultSocketJson('/test/dir') - expect(result.version).toBe(1) - }) - - it('returns default when JSON parse fails', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('invalid json') - - const result = readOrDefaultSocketJson('/test/dir') - expect(result.version).toBe(1) - }) - }) - - describe('findSocketJsonUp', () => { - it('calls findUp with correct parameters', async () => { - // Mock fs.stat to simulate finding socket.json in parent directory. - mockStat.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - }) - - const result = await findSocketJsonUp('/test/dir') - // Should find socket.json somewhere up the tree. - expect(result).toBeDefined() - expect(result).toContain(SOCKET_JSON) - }) - - it('returns undefined when socket.json not found', async () => { - // Mock fs.stat to always throw (file not found). - mockStat.mockRejectedValue(new Error('ENOENT')) - - const result = await findSocketJsonUp('/test/dir') - expect(result).toBeUndefined() - }) - }) - - describe('readOrDefaultSocketJsonUp', () => { - it('reads socket.json when found up the tree', async () => { - const mockJson = { version: 1, custom: 'data' } - // Mock fs.stat to find socket.json. - mockStat.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - }) - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue(JSON.stringify(mockJson)) - - const result = await readOrDefaultSocketJsonUp('/test/dir') - expect(result).toEqual(mockJson) - }) - - it('returns default when socket.json not found up the tree', async () => { - // Mock fs.stat to not find socket.json. - mockStat.mockRejectedValue(new Error('ENOENT')) - - const result = await readOrDefaultSocketJsonUp('/test/dir') - expect(result.version).toBe(1) - }) - }) - - describe('readSocketJsonSync', () => { - it('successfully reads and parses valid JSON file', () => { - const mockJson = { version: 1, custom: 'data' } - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue(JSON.stringify(mockJson)) - - const result = readSocketJsonSync('/test/dir') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual(mockJson) - } - }) - - it('returns default when file does not exist', () => { - mockExistsSync.mockReturnValue(false) - - const result = readSocketJsonSync('/test/dir') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.version).toBe(1) - } - }) - - it('returns error when file read fails and defaultOnError is false', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockImplementation(() => { - throw new Error('Read error') - }) - - const result = readSocketJsonSync('/test/dir', false) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Failed to read') - } - }) - - it('returns default when file read fails and defaultOnError is true', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockImplementation(() => { - throw new Error('Read error') - }) - - const result = readSocketJsonSync('/test/dir', true) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.version).toBe(1) - } - }) - - it('returns error when JSON parse fails and defaultOnError is false', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('invalid json') - - const result = readSocketJsonSync('/test/dir', false) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Failed to parse') - } - }) - - it('returns default when JSON parse fails and defaultOnError is true', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('invalid json') - - const result = readSocketJsonSync('/test/dir', true) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.version).toBe(1) - } - }) - - it('returns default when file content is empty', () => { - mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('null') - - const result = readSocketJsonSync('/test/dir') - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.version).toBe(1) - } - }) - }) - - describe('writeSocketJson', () => { - it('successfully writes socket.json', async () => { - const mockJson = { version: 1, custom: 'data' } - mockWriteFile.mockResolvedValue(undefined) - - const result = await writeSocketJson('/test/dir', mockJson as unknown) - expect(result.ok).toBe(true) - expect(mockWriteFile).toHaveBeenCalledWith( - path.join('/test/dir', SOCKET_JSON), - expect.stringContaining('"version": 1'), - 'utf8', - ) - }) - - it('returns error when JSON serialization fails', async () => { - const circularRef: unknown = {} - circularRef.self = circularRef - - const result = await writeSocketJson('/test/dir', circularRef) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toContain('Failed to serialize') - } - }) - - it('writes with proper formatting', async () => { - const mockJson = getDefaultSocketJson() - mockWriteFile.mockResolvedValue(undefined) - - await writeSocketJson('/test/dir', mockJson) - expect(mockWriteFile).toHaveBeenCalledWith( - expect.any(String), - expect.stringMatching(/\n$/), - 'utf8', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/socket/org-slug.test.mts b/packages/cli/test/unit/util/socket/org-slug.test.mts deleted file mode 100644 index 40fe79a96..000000000 --- a/packages/cli/test/unit/util/socket/org-slug.test.mts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Unit tests for Socket org slug utilities. - * - * Purpose: Tests Socket organization slug handling. Validates org slug parsing - * and validation. - * - * Test Coverage: - Org slug parsing - Slug validation - Slug normalization - - * Default org detection - Org context management. - * - * Testing Approach: Tests org slug utilities used throughout Socket API - * interactions. - * - * Related Files: - util/socket/org-slug.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { overrideCachedConfig } from '../../../../src/util/config.mts' -import { determineOrgSlug } from '../../../../src/util/socket/org-slug.mts' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -const mockWebLink = vi.hoisted(() => - vi.fn((_url: string, text: string) => text), -) -const mockSuggestOrgSlug = vi.hoisted(() => vi.fn()) -const mockSuggestToPersistOrgSlug = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/constants/config.mjs', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - CONFIG_KEY_DEFAULT_ORG: 'defaultOrg', - } -}) - -vi.mock('../../../../src/constants/socket.mts', () => ({ - V1_MIGRATION_GUIDE_URL: 'https://socket.dev/migration-guide', -})) - -vi.mock('../../../../src/util/terminal/link.mjs', () => ({ - webLink: mockWebLink, -})) - -vi.mock('../../../../src/commands/scan/suggest-org-slug.mjs', () => ({ - suggestOrgSlug: mockSuggestOrgSlug, -})) - -vi.mock('../../../../src/commands/scan/suggest-to-persist-orgslug.mjs', () => ({ - suggestToPersistOrgSlug: mockSuggestToPersistOrgSlug, -})) - -describe('determineOrgSlug', () => { - beforeEach(() => { - vi.clearAllMocks() - overrideCachedConfig('{}') - }) - - describe('when org flag is provided', () => { - it('uses org flag value over default', async () => { - overrideCachedConfig('{"defaultOrg": "default-org"}') - - const result = await determineOrgSlug('flag-org', false, false) - - expect(result).toEqual(['flag-org', 'default-org']) - }) - - it('returns org flag even when no default exists', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug('provided-org', false, false) - - expect(result).toEqual(['provided-org', undefined]) - }) - - it('handles empty string org flag', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug('', false, false) - - expect(result).toEqual(['', undefined]) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Note: This command requires an org slug because the Socket API endpoint does.', - ) - }) - }) - - describe('when using default org', () => { - it('uses default org when no flag provided', async () => { - overrideCachedConfig('{"defaultOrg": "configured-default-org"}') - - const result = await determineOrgSlug('', false, false) - - expect(result).toEqual([ - 'configured-default-org', - 'configured-default-org', - ]) - }) - - it('handles numeric default org', async () => { - overrideCachedConfig('{"defaultOrg": 12345}') - - const result = await determineOrgSlug('', false, false) - - expect(result).toEqual(['12345', 12345 as unknown]) - }) - }) - - describe('non-interactive mode', () => { - it('returns empty org and logs warnings when no org available', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug('', false, false) - - expect(result).toEqual(['', undefined]) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Note: This command requires an org slug because the Socket API endpoint does.', - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'It seems no default org was setup and the `--org` flag was not used.', - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - "Additionally, `--no-interactive` was set so we can't ask for it.", - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Note: When running in CI, you probably want to set the `--org` flag.', - ) - expect(mockWebLink).toHaveBeenCalledWith( - 'https://socket.dev/migration-guide', - 'v1 migration guide', - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'This command will exit now because the org slug is required to proceed.', - ) - }) - - it('logs all migration guide messages', async () => { - overrideCachedConfig('{}') - - await determineOrgSlug('', false, false) - - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Since v1.0.0 the org _argument_ for all commands was dropped in favor of an', - ) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'implicit default org setting, which will be setup when you run `socket login`.', - ) - }) - }) - - describe('interactive mode', () => { - it('suggests org slug when no org available', async () => { - overrideCachedConfig('{}') - mockSuggestOrgSlug.mockResolvedValue('suggested-org') - - const result = await determineOrgSlug('', true, false) - - expect(result).toEqual(['suggested-org', undefined]) - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Unable to determine the target org. Trying to auto-discover it now...', - ) - expect(mockLogger.info).toHaveBeenCalledWith( - 'Note: Run `socket login` to set a default org.', - ) - expect(mockLogger.error).toHaveBeenCalledWith( - ' Use the --org flag to override the default org.', - ) - expect(mockSuggestOrgSlug).toHaveBeenCalled() - expect(mockSuggestToPersistOrgSlug).toHaveBeenCalledWith('suggested-org') - }) - - it('handles null suggestion from suggestOrgSlug', async () => { - overrideCachedConfig('{}') - mockSuggestOrgSlug.mockResolvedValue(undefined) - - const result = await determineOrgSlug('', true, false) - - expect(result).toEqual(['', undefined]) - expect(mockSuggestOrgSlug).toHaveBeenCalled() - expect(mockSuggestToPersistOrgSlug).not.toHaveBeenCalled() - }) - - it('handles undefined suggestion from suggestOrgSlug', async () => { - overrideCachedConfig('{}') - mockSuggestOrgSlug.mockResolvedValue(undefined as unknown) - - const result = await determineOrgSlug('', true, false) - - expect(result).toEqual(['', undefined]) - expect(mockSuggestOrgSlug).toHaveBeenCalled() - expect(mockSuggestToPersistOrgSlug).not.toHaveBeenCalled() - }) - - it('skips auto-discovery in dry-run mode', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug('', true, true) - - expect(result).toEqual(['', undefined]) - expect(mockLogger.fail).toHaveBeenCalledWith( - 'Skipping auto-discovery of org in dry-run mode', - ) - expect(mockSuggestOrgSlug).not.toHaveBeenCalled() - }) - }) - - describe('edge cases', () => { - it('handles boolean values for org flag', async () => { - overrideCachedConfig('{"defaultOrg": "default"}') - - const result = await determineOrgSlug(true as unknown, false, false) - - expect(result).toEqual(['true', 'default']) - }) - - it('handles null values for org flag', async () => { - overrideCachedConfig('{"defaultOrg": "default"}') - - const result = await determineOrgSlug(undefined as unknown, false, false) - - expect(result).toEqual(['default', 'default']) - }) - - it('handles undefined values for org flag', async () => { - overrideCachedConfig('{"defaultOrg": "default"}') - - const result = await determineOrgSlug(undefined as unknown, false, false) - - expect(result).toEqual(['default', 'default']) - }) - - it('handles numeric values for org flag', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug(42 as unknown, false, false) - - expect(result).toEqual(['42', undefined]) - }) - - it('handles empty string suggestion from suggestOrgSlug', async () => { - overrideCachedConfig('{}') - mockSuggestOrgSlug.mockResolvedValue('') - - const result = await determineOrgSlug('', true, false) - - expect(result).toEqual(['', undefined]) - expect(mockSuggestToPersistOrgSlug).not.toHaveBeenCalled() - }) - - it('preserves whitespace in org slug', async () => { - overrideCachedConfig('{}') - - const result = await determineOrgSlug(' org-with-spaces ', false, false) - - expect(result).toEqual([' org-with-spaces ', undefined]) - }) - }) - - describe('combination scenarios', () => { - it('prioritizes org flag over everything else', async () => { - overrideCachedConfig('{"defaultOrg": "default-org"}') - mockSuggestOrgSlug.mockResolvedValue('suggested-org') - - const result = await determineOrgSlug('flag-org', true, false) - - expect(result).toEqual(['flag-org', 'default-org']) - expect(mockSuggestOrgSlug).not.toHaveBeenCalled() - }) - - it('uses default when available in interactive mode', async () => { - overrideCachedConfig('{"defaultOrg": "configured-org"}') - - const result = await determineOrgSlug('', true, false) - - expect(result).toEqual(['configured-org', 'configured-org']) - expect(mockSuggestOrgSlug).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/cli/test/unit/util/socket/sdk.test.mts b/packages/cli/test/unit/util/socket/sdk.test.mts deleted file mode 100644 index 819589f6e..000000000 --- a/packages/cli/test/unit/util/socket/sdk.test.mts +++ /dev/null @@ -1,602 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for Socket SDK setup. - * - * Purpose: Tests Socket SDK initialization and configuration. Validates SDK - * setup with various options. - * - * Test Coverage: - SDK initialization - API token handling - Base URL - * configuration - Proxy URL configuration - User agent setup - SDK error - * handling. - * - * Testing Approach: Mocks @socketsecurity/sdk to test setup logic. - * - * Related Files: - util/socket/sdk.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock the config utility. -const mockGetConfigValueOrUndef = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, -})) - -// Mock environment getters from @socketsecurity/lib. -const mockGetSocketCliApiBaseUrl = vi.hoisted(() => vi.fn()) -const mockGetSocketCliApiProxy = vi.hoisted(() => vi.fn()) -const mockGetSocketApiToken = vi.hoisted(() => vi.fn()) -const mockGetSocketCliNoApiToken = vi.hoisted(() => vi.fn()) -const mockGetSocketCliApiTimeout = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/env/socket', () => ({ - getSocketApiToken: mockGetSocketApiToken, -})) -vi.mock('@socketsecurity/lib-stable/env/socket-cli', () => ({ - getSocketCliApiBaseUrl: mockGetSocketCliApiBaseUrl, - getSocketCliApiProxy: mockGetSocketCliApiProxy, - getSocketCliNoApiToken: mockGetSocketCliNoApiToken, - getSocketCliApiTimeout: mockGetSocketCliApiTimeout, -})) - -// Mock is-interactive. -const mockIsInteractive = vi.hoisted(() => vi.fn(() => false)) -vi.mock('@socketregistry/is-interactive/index.cjs', () => ({ - default: mockIsInteractive, -})) - -// Mock the SocketSdk class. -class MockSocketSdk { - apiToken: string - options: unknown - - constructor(apiToken: string, options: unknown) { - this.apiToken = apiToken - this.options = options - } - - getOrganizations() { - return Promise.resolve({ ok: true, data: [] }) - } -} -const mockSocketSdkConstructor = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/sdk-stable', () => ({ - SocketSdk: class { - constructor(apiToken: string, options: unknown) { - mockSocketSdkConstructor(apiToken, options) - return new MockSocketSdk(apiToken, options) - } - }, - createUserAgentFromPkgJson: vi.fn(() => 'test-user-agent'), -})) - -// Mock telemetry. -vi.mock('../../../../src/util/telemetry/integration.mts', () => ({ - trackCliEvent: vi.fn(), -})) - -// Mock debug. -vi.mock('../../../../src/util/debug.mts', () => ({ - debugApiRequest: vi.fn(), - debugApiResponse: vi.fn(), -})) - -import { - getDefaultApiBaseUrl, - getDefaultApiToken, - getDefaultProxyUrl, - getExtraCaCerts, - getVisibleTokenPrefix, - hasDefaultApiToken, - invalidateDefaultApiToken, - setupSdk, -} from '../../../../src/util/socket/sdk.mts' - -describe('SDK Utilities', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSocketCliNoApiToken.mockReturnValue(false) - mockGetSocketApiToken.mockReturnValue(undefined) - mockGetSocketCliApiBaseUrl.mockReturnValue(undefined) - mockGetSocketCliApiProxy.mockReturnValue(undefined) - mockGetSocketCliApiTimeout.mockReturnValue(undefined) - mockGetConfigValueOrUndef.mockReturnValue(undefined) - }) - - describe('getDefaultApiBaseUrl', () => { - it('returns undefined when no URL is configured', () => { - const url = getDefaultApiBaseUrl() - expect(url).toBeUndefined() - }) - - it('returns URL from environment variable', () => { - mockGetSocketCliApiBaseUrl.mockReturnValue('https://api.socket.dev') - const url = getDefaultApiBaseUrl() - expect(url).toBe('https://api.socket.dev') - }) - - it('returns URL from config when env is not set', () => { - mockGetConfigValueOrUndef.mockReturnValue('https://custom.api.socket.dev') - const url = getDefaultApiBaseUrl() - expect(url).toBe('https://custom.api.socket.dev') - }) - - it('returns undefined for invalid URL', () => { - mockGetSocketCliApiBaseUrl.mockReturnValue('not-a-valid-url') - const url = getDefaultApiBaseUrl() - expect(url).toBeUndefined() - }) - }) - - describe('getDefaultProxyUrl', () => { - it('returns undefined when no proxy is configured', () => { - const url = getDefaultProxyUrl() - expect(url).toBeUndefined() - }) - - it('returns proxy URL from environment variable', () => { - mockGetSocketCliApiProxy.mockReturnValue('http://proxy.example.com:8080') - const url = getDefaultProxyUrl() - expect(url).toBe('http://proxy.example.com:8080') - }) - - it('returns undefined for invalid proxy URL', () => { - mockGetSocketCliApiProxy.mockReturnValue('invalid-proxy') - const url = getDefaultProxyUrl() - expect(url).toBeUndefined() - }) - }) - - describe('getDefaultApiToken', () => { - it('returns undefined when SOCKET_CLI_NO_API_TOKEN is set', () => { - mockGetSocketCliNoApiToken.mockReturnValue(true) - const token = getDefaultApiToken() - expect(token).toBeUndefined() - }) - - it('returns token from environment variable', () => { - mockGetSocketApiToken.mockReturnValue('mock-env-value-12345') - const token = getDefaultApiToken() - expect(token).toBe('mock-env-value-12345') - }) - - it('returns token from config when env is not set', () => { - mockGetConfigValueOrUndef.mockReturnValue('mock-config-value-12345') - const token = getDefaultApiToken() - expect(token).toBe('mock-config-value-12345') - }) - }) - - describe('getVisibleTokenPrefix', () => { - it('handles when no token is set', () => { - mockGetSocketCliNoApiToken.mockReturnValue(true) - const prefix = getVisibleTokenPrefix() - expect(prefix).toBe('') - }) - - it('returns visible prefix when token is set', () => { - // Token must be long enough to have a prefix. - mockGetSocketApiToken.mockReturnValue('sk_sec12345678901234567890') - const prefix = getVisibleTokenPrefix() - expect(typeof prefix).toBe('string') - }) - }) - - describe('hasDefaultApiToken', () => { - it('returns false when no token is set', () => { - mockGetSocketCliNoApiToken.mockReturnValue(true) - const hasToken = hasDefaultApiToken() - expect(hasToken).toBe(false) - }) - - it('returns true when token is set', () => { - mockGetSocketApiToken.mockReturnValue('mock-value-for-test') - const hasToken = hasDefaultApiToken() - expect(hasToken).toBe(true) - }) - }) - - describe('invalidateDefaultApiToken', () => { - it('clears the cached default token', () => { - mockGetSocketApiToken.mockReturnValue('cached-token') - // Populate the cache. - expect(getDefaultApiToken()).toBe('cached-token') - // Now invalidate — and have the underlying source return undefined. - invalidateDefaultApiToken() - mockGetSocketApiToken.mockReturnValue(undefined) - mockGetConfigValueOrUndef.mockReturnValue(undefined) - expect(getDefaultApiToken()).toBeUndefined() - }) - }) - - describe('getExtraCaCerts', () => { - const realNodeExtraCaCerts = process.env['NODE_EXTRA_CA_CERTS'] - const realSslCertFile = process.env['SSL_CERT_FILE'] - - afterEach(() => { - // Restore original env vars after each test. - if (realNodeExtraCaCerts === undefined) { - delete process.env['NODE_EXTRA_CA_CERTS'] - } else { - process.env['NODE_EXTRA_CA_CERTS'] = realNodeExtraCaCerts - } - if (realSslCertFile === undefined) { - delete process.env['SSL_CERT_FILE'] - } else { - process.env['SSL_CERT_FILE'] = realSslCertFile - } - }) - - it('returns undefined when NODE_EXTRA_CA_CERTS is set (Node already loaded)', async () => { - // Use a fresh module import to bypass the module-level cache - // (`_extraCaCertsResolved`) that the rest of the test suite has - // already populated. - vi.resetModules() - process.env['NODE_EXTRA_CA_CERTS'] = '/some/path/ca.pem' - delete process.env['SSL_CERT_FILE'] - const fresh = await import('../../../../src/util/socket/sdk.mts') - const result = fresh.getExtraCaCerts() - expect(result).toBeUndefined() - }) - - it('returns undefined when neither env var is set', async () => { - vi.resetModules() - delete process.env['NODE_EXTRA_CA_CERTS'] - delete process.env['SSL_CERT_FILE'] - const fresh = await import('../../../../src/util/socket/sdk.mts') - const result = fresh.getExtraCaCerts() - expect(result).toBeUndefined() - }) - - it('caches the resolved value across calls', async () => { - vi.resetModules() - delete process.env['NODE_EXTRA_CA_CERTS'] - delete process.env['SSL_CERT_FILE'] - const fresh = await import('../../../../src/util/socket/sdk.mts') - // Two calls — second hits the cache early-return. - expect(fresh.getExtraCaCerts()).toBeUndefined() - expect(fresh.getExtraCaCerts()).toBeUndefined() - }) - }) - - describe('setupSdk', () => { - it('returns error when no token and not interactive', () => { - mockIsInteractive.mockReturnValue(false) - mockGetSocketCliNoApiToken.mockReturnValue(true) - - return setupSdk().then(result => { - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.message).toBe('Auth Error') - expect(result.cause).toContain('socket login') - } - }) - }) - - it('returns SDK instance when token is provided', async () => { - const result = await setupSdk({ apiToken: 'mock-sdk-value-12345' }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.any(Object), - ) - }) - - it('uses provided apiBaseUrl', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - apiBaseUrl: 'https://custom.api.socket.dev', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - baseUrl: 'https://custom.api.socket.dev', - }), - ) - }) - - it('uses apiProxy when provided as valid URL', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - apiProxy: 'http://proxy.example.com:8080', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - agent: expect.any(Object), - }), - ) - }) - - it('ignores invalid apiProxy', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - apiProxy: 'not-a-url', - }) - - expect(result.ok).toBe(true) - // Should not include agent in options. - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.not.objectContaining({ - agent: expect.any(Object), - }), - ) - }) - - it('uses timeout from environment', async () => { - mockGetSocketCliApiTimeout.mockReturnValue(30000) - - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - timeout: 30000, - }), - ) - }) - - it('includes hooks in SDK options', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - hooks: expect.objectContaining({ - onRequest: expect.any(Function), - onResponse: expect.any(Function), - }), - }), - ) - }) - - it('includes onFileValidation in SDK options', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - onFileValidation: expect.any(Function), - }), - ) - }) - - it('includes user agent in SDK options', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - userAgent: 'test-user-agent', - }), - ) - }) - - it('uses HttpProxyAgent for http base URL', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - apiBaseUrl: 'http://api.socket.dev', - apiProxy: 'http://proxy.example.com:8080', - }) - - expect(result.ok).toBe(true) - // Just verify it includes an agent; the specific class is internal. - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - agent: expect.any(Object), - baseUrl: 'http://api.socket.dev', - }), - ) - }) - - it('falls back to default proxy from environment when invalid proxy provided', async () => { - mockGetSocketCliApiProxy.mockReturnValue( - 'http://default-proxy.example.com:8080', - ) - - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - apiProxy: 'invalid-proxy', - }) - - expect(result.ok).toBe(true) - // Should use the default proxy from environment. - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-sdk-value-12345', - expect.objectContaining({ - agent: expect.any(Object), - }), - ) - }) - - it('returns SDK with default token from config', async () => { - mockGetConfigValueOrUndef.mockReturnValue('mock-config-value-12345') - - const result = await setupSdk() - - expect(result.ok).toBe(true) - expect(mockSocketSdkConstructor).toHaveBeenCalledWith( - 'mock-config-value-12345', - expect.any(Object), - ) - }) - }) - - describe('setupSdk hooks', () => { - it('onRequest hook is callable and does not throw', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - // Get the hooks from the constructor call. - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onRequest } = sdkOptions.hooks - - // Call onRequest with mock request info. - expect(() => { - onRequest({ - method: 'GET', - url: 'https://api.socket.dev/v1/packages', - timeout: 30000, - }) - }).not.toThrow() - }) - - it('onRequest hook skips telemetry for telemetry endpoints', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onRequest } = sdkOptions.hooks - - // Call onRequest with telemetry URL. - expect(() => { - onRequest({ - method: 'POST', - url: 'https://api.socket.dev/v1/telemetry', - timeout: 30000, - }) - }).not.toThrow() - }) - - it('onResponse hook is callable and does not throw', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onResponse } = sdkOptions.hooks - - // Call onResponse with mock response info. - expect(() => { - onResponse({ - method: 'GET', - url: 'https://api.socket.dev/v1/packages', - status: 200, - statusText: 'OK', - duration: 100, - headers: {}, - }) - }).not.toThrow() - }) - - it('onResponse hook handles error responses', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onResponse } = sdkOptions.hooks - - // Call onResponse with error info. - expect(() => { - onResponse({ - method: 'GET', - url: 'https://api.socket.dev/v1/packages', - status: 500, - statusText: 'Internal Server Error', - duration: 100, - headers: {}, - error: new Error('Server error'), - }) - }).not.toThrow() - }) - - it('onResponse hook skips telemetry for telemetry endpoints', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onResponse } = sdkOptions.hooks - - // Call onResponse with telemetry URL. - expect(() => { - onResponse({ - method: 'POST', - url: 'https://api.socket.dev/v1/telemetry', - status: 200, - statusText: 'OK', - duration: 100, - headers: {}, - }) - }).not.toThrow() - }) - }) - - describe('setupSdk onFileValidation', () => { - it('onFileValidation returns shouldContinue true with valid paths', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onFileValidation } = sdkOptions - - const validationResult = onFileValidation(['/path/to/valid.json'], [], { - operation: 'createFullScan', - }) - - expect(validationResult).toEqual({ shouldContinue: true }) - }) - - it('onFileValidation warns and continues when invalid paths exist', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onFileValidation } = sdkOptions - - const validationResult = onFileValidation( - ['/path/to/valid.json'], - ['/path/to/invalid.json', '/path/to/another-invalid.json'], - { operation: 'uploadManifestFiles', orgSlug: 'test-org' }, - ) - - expect(validationResult).toEqual({ shouldContinue: true }) - }) - - it('onFileValidation handles createDependenciesSnapshot operation', async () => { - const result = await setupSdk({ - apiToken: 'mock-sdk-value-12345', - }) - - expect(result.ok).toBe(true) - const sdkOptions = mockSocketSdkConstructor.mock.calls[0]![1] - const { onFileValidation } = sdkOptions - - const validationResult = onFileValidation([], ['/path/to/symlink.json'], { - operation: 'createDependenciesSnapshot', - }) - - expect(validationResult).toEqual({ shouldContinue: true }) - }) - }) -}) diff --git a/packages/cli/test/unit/util/socket/url.test.mts b/packages/cli/test/unit/util/socket/url.test.mts deleted file mode 100644 index 7d235d52e..000000000 --- a/packages/cli/test/unit/util/socket/url.test.mts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Unit tests for Socket URL utilities. - * - * Purpose: Tests Socket URL construction. Validates URL generation for Socket - * web UI and API. - * - * Test Coverage: - Web UI URL generation - API URL construction - Report URL - * generation - Package URL generation - Query parameter handling. - * - * Testing Approach: Tests URL utilities for Socket service integration. - * - * Related Files: - util/socket/url.mts (implementation) - */ - -import { describe, expect, it, vi } from 'vitest' - -import { - getPkgFullNameFromPurl, - getSocketDevAlertUrl, - getSocketDevPackageOverviewUrl, - getSocketDevPackageOverviewUrlFromPurl, -} from '../../../../src/util/socket/url.mts' - -// Mock constants. -vi.mock('../../../../src/constants.mts', () => ({ - constants: { - SOCKET_WEBSITE_URL: 'https://socket.dev', - }, -})) - -// Mock purl. -const mockGetPurlObject = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/purl/parse.mts', () => ({ - getPurlObject: mockGetPurlObject, -})) - -describe('socket-url utilities', () => { - describe('getPkgFullNameFromPurl', () => { - it('returns name for packages without namespace', () => { - mockGetPurlObject.mockImplementation((purl: unknown) => { - if (typeof purl === 'string') { - return { - type: 'npm', - namespace: undefined, - name: 'express', - version: '4.18.0', - } - } - return purl - }) - const result = getPkgFullNameFromPurl('pkg:npm/express@4.18.0') - expect(result).toBe('express') - }) - - it('returns scoped name for npm packages', async () => { - const purlObj = { - type: 'npm', - namespace: '@babel', - name: 'core', - version: '7.0.0', - } - mockGetPurlObject.mockReturnValue(purlObj as unknown) - - const result = getPkgFullNameFromPurl('pkg:npm/@babel/core@7.0.0') - expect(result).toBe('@babel/core') - }) - - it('handles maven packages with colon separator', async () => { - const purlObj = { - type: 'maven', - namespace: 'org.apache', - name: 'commons', - version: '3.0', - } - mockGetPurlObject.mockReturnValue(purlObj as unknown) - - const result = getPkgFullNameFromPurl(purlObj as unknown) - expect(result).toBe('org.apache:commons') - }) - - it('handles other packages with slash separator', async () => { - const purlObj = { - type: 'pypi', - namespace: 'django', - name: 'rest-framework', - version: '3.0', - } - mockGetPurlObject.mockReturnValue(purlObj as unknown) - - const result = getPkgFullNameFromPurl(purlObj as unknown) - expect(result).toBe('django/rest-framework') - }) - }) - - describe('getSocketDevAlertUrl', () => { - it('generates alert URL', () => { - const result = getSocketDevAlertUrl('prototype-pollution') - expect(result).toBe('https://socket.dev/alerts/prototype-pollution') - }) - - it('handles different alert types', () => { - expect(getSocketDevAlertUrl('supply-chain-risk')).toBe( - 'https://socket.dev/alerts/supply-chain-risk', - ) - expect(getSocketDevAlertUrl('typosquat')).toBe( - 'https://socket.dev/alerts/typosquat', - ) - expect(getSocketDevAlertUrl('malware')).toBe( - 'https://socket.dev/alerts/malware', - ) - }) - }) - - describe('getSocketDevPackageOverviewUrl', () => { - it('generates npm package URL without version', () => { - const result = getSocketDevPackageOverviewUrl('npm', 'express') - expect(result).toBe('https://socket.dev/npm/package/express') - }) - - it('generates npm package URL with version', () => { - const result = getSocketDevPackageOverviewUrl('npm', 'express', '4.18.0') - expect(result).toBe( - 'https://socket.dev/npm/package/express/overview/4.18.0', - ) - }) - - it('generates golang package URL with query params', () => { - const result = getSocketDevPackageOverviewUrl( - 'golang', - 'github.com/gin-gonic/gin', - 'v1.9.0', - ) - expect(result).toBe( - 'https://socket.dev/golang/package/github.com/gin-gonic/gin?section=overview&version=v1.9.0', - ) - }) - - it('generates golang package URL without version', () => { - const result = getSocketDevPackageOverviewUrl( - 'golang', - 'github.com/gin-gonic/gin', - ) - expect(result).toBe( - 'https://socket.dev/golang/package/github.com/gin-gonic/gin', - ) - }) - - it('handles other ecosystems', () => { - expect(getSocketDevPackageOverviewUrl('pypi', 'flask', '2.0.0')).toBe( - 'https://socket.dev/pypi/package/flask/overview/2.0.0', - ) - expect(getSocketDevPackageOverviewUrl('gem', 'rails', '7.0.0')).toBe( - 'https://socket.dev/gem/package/rails/overview/7.0.0', - ) - }) - }) - - describe('getSocketDevPackageOverviewUrlFromPurl', () => { - it('generates URL from PURL string', async () => { - mockGetPurlObject.mockReturnValue({ - type: 'npm', - namespace: undefined, - name: 'express', - version: '4.18.0', - } as unknown) - - const result = getSocketDevPackageOverviewUrlFromPurl( - 'pkg:npm/express@4.18.0', - ) - expect(result).toBe( - 'https://socket.dev/npm/package/express/overview/4.18.0', - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/spawn/apply-machine-mode.test.mts b/packages/cli/test/unit/util/spawn/apply-machine-mode.test.mts deleted file mode 100644 index 8754987af..000000000 --- a/packages/cli/test/unit/util/spawn/apply-machine-mode.test.mts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Unit tests for the machine-mode-aware spawn helpers. - * - * Test Coverage: - applyMachineModeIfActive returns inputs unchanged when - * ambient machine-mode is off (no JSON/markdown/quiet flag in flight). - - * applyMachineModeIfActive defers to the raw applier when ambient mode is - * engaged. - inferSubcommand returns the first non-flag token. - - * inferSubcommand returns undefined when there's no subcommand (empty argv, all - * flags). - * - * Related Files: - src/util/spawn/apply-machine-mode.mts - Implementation - - * src/util/spawn/machine-mode.mts - Inner applier - - * src/util/output/ambient-mode.mts - Mode getter/setter. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - resetMachineOutputMode, - setMachineOutputMode, -} from '../../../../src/util/output/ambient-mode.mts' -import { - applyMachineModeIfActive, - inferSubcommand, -} from '../../../../src/util/spawn/apply-machine-mode.mts' - -describe('applyMachineModeIfActive', () => { - beforeEach(() => { - resetMachineOutputMode() - }) - afterEach(() => { - resetMachineOutputMode() - }) - - it('returns the inputs unchanged when ambient mode is off', () => { - const input = { - args: ['install', 'lodash'], - env: { FOO: 'bar' }, - } - const out = applyMachineModeIfActive(input) - expect(out.args).toEqual(['install', 'lodash']) - expect(out.env).toEqual({ FOO: 'bar' }) - // Returned objects are fresh copies — caller can mutate without - // affecting input. - expect(out.args).not.toBe(input.args) - expect(out.env).not.toBe(input.env) - }) - - it('forwards to the raw applier when --json is in flight', () => { - setMachineOutputMode({ json: true }) - const out = applyMachineModeIfActive({ - args: ['npm', 'install'], - env: {}, - }) - // The raw applier injects machine-mode signals (env vars / flags) - // for the package manager. We don't pin the exact shape here — - // just that it differed from the no-op return. - const noOp = { args: ['npm', 'install'], env: {} } - expect(out).not.toEqual(noOp) - }) -}) - -describe('inferSubcommand', () => { - it('returns the first non-flag argument', () => { - expect(inferSubcommand(['install', '--save'])).toBe('install') - expect(inferSubcommand(['--save', 'install'])).toBe('install') - expect(inferSubcommand(['-D', '--no-frozen', 'add', 'pkg'])).toBe('add') - }) - - it('returns the first arg even when it has no flag-like sibling', () => { - expect(inferSubcommand(['ls'])).toBe('ls') - }) - - it('returns undefined for an empty argv', () => { - expect(inferSubcommand([])).toBeUndefined() - }) - - it('returns undefined when every argument is a flag', () => { - expect(inferSubcommand(['--help', '-v'])).toBeUndefined() - }) -}) diff --git a/packages/cli/test/unit/util/spawn/machine-mode-merge-env.test.mts b/packages/cli/test/unit/util/spawn/machine-mode-merge-env.test.mts deleted file mode 100644 index c2ae23379..000000000 --- a/packages/cli/test/unit/util/spawn/machine-mode-merge-env.test.mts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Direct tests for the now-exported mergeEnv helper from - * util/spawn/machine-mode.mts. - * - * Related Files: - src/util/spawn/machine-mode.mts. - */ - -import { describe, expect, it } from 'vitest' - -import { mergeEnv } from '../../../../src/util/spawn/machine-mode.mts' - -describe('mergeEnv', () => { - it('layers UNIVERSAL_ENV over base, then overrides over UNIVERSAL_ENV', () => { - const result = mergeEnv( - { BASE_KEY: 'base-value', NO_COLOR: 'maybe' }, - { OVERRIDE_KEY: 'override-value' }, - ) - // overrides win - expect(result['OVERRIDE_KEY']).toBe('override-value') - // base passes through (when not shadowed by UNIVERSAL_ENV) - expect(result['BASE_KEY']).toBe('base-value') - // UNIVERSAL_ENV overrides base - expect(result['NO_COLOR']).toBe('1') - }) - - it('handles undefined base', () => { - const result = mergeEnv(undefined, { CUSTOM: 'x' }) - expect(result['CUSTOM']).toBe('x') - expect(result['NO_COLOR']).toBe('1') - }) - - it('lets caller-supplied overrides win even over UNIVERSAL_ENV', () => { - const result = mergeEnv(undefined, { NO_COLOR: 'caller-wins' }) - expect(result['NO_COLOR']).toBe('caller-wins') - }) -}) diff --git a/packages/cli/test/unit/util/spawn/machine-mode.test.mts b/packages/cli/test/unit/util/spawn/machine-mode.test.mts deleted file mode 100644 index 15b4d6fed..000000000 --- a/packages/cli/test/unit/util/spawn/machine-mode.test.mts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { applyMachineMode } from '../../../../src/util/spawn/machine-mode.mts' - -describe('applyMachineMode', () => { - describe('universal env', () => { - it('injects NO_COLOR/FORCE_COLOR/CLICOLOR_FORCE for every tool', () => { - const result = applyMachineMode({ - args: [], - tool: 'unknown-tool', - }) - expect(result.env['NO_COLOR']).toBe('1') - expect(result.env['FORCE_COLOR']).toBe('0') - expect(result.env['CLICOLOR_FORCE']).toBe('0') - }) - - it('preserves caller env alongside universal vars', () => { - const result = applyMachineMode({ - args: [], - env: { CUSTOM_VAR: 'value' }, - tool: 'npm', - }) - expect(result.env['CUSTOM_VAR']).toBe('value') - expect(result.env['NO_COLOR']).toBe('1') - }) - }) - - describe('npm', () => { - it('forwards --json on JSON-aware subcommands', () => { - const result = applyMachineMode({ - args: ['--long'], - subcommand: 'ls', - tool: 'npm', - }) - expect(result.args).toContain('--json') - expect(result.args).toContain('--loglevel=error') - expect(result.args).toContain('--long') - }) - - it('omits --json on non-JSON subcommands', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'npm', - }) - expect(result.args).not.toContain('--json') - expect(result.args).toContain('--loglevel=error') - }) - }) - - describe('pnpm', () => { - it('uses --reporter=json on supporting subcommands', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'pnpm', - }) - expect(result.args).toContain('--reporter=json') - }) - - it('uses --reporter=silent as fallback', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'unknown-subcmd', - tool: 'pnpm', - }) - expect(result.args).toContain('--reporter=silent') - expect(result.args).not.toContain('--reporter=json') - }) - }) - - describe('yarn classic', () => { - it('forwards --json and --silent on install', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'yarn', - }) - expect(result.args).toContain('--json') - expect(result.args).toContain('--silent') - }) - }) - - describe('yarn berry', () => { - it('sets all YARN_ENABLE_* env vars', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'yarn-berry', - }) - expect(result.env['YARN_ENABLE_PROGRESS_BARS']).toBe('0') - expect(result.env['YARN_ENABLE_INLINE_BUILDS']).toBe('0') - expect(result.env['YARN_ENABLE_MESSAGE_NAMES']).toBe('0') - expect(result.env['YARN_ENABLE_COLORS']).toBe('0') - expect(result.env['YARN_ENABLE_HYPERLINKS']).toBe('0') - }) - - it('forwards --json on broad subcommand set', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'yarn-berry', - }) - expect(result.args).toContain('--json') - }) - - it('omits --json on non-JSON subcommands (remove/up/run)', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'remove', - tool: 'yarn-berry', - }) - expect(result.args).not.toContain('--json') - }) - }) - - describe('zpm (yarn 6)', () => { - it('forwards --json on supporting subcommands', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'info', - tool: 'zpm', - }) - expect(result.args).toContain('--json') - }) - - it('adds --silent for install', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'install', - tool: 'zpm', - }) - expect(result.args).toContain('--silent') - }) - - it('no flags for unsupported subcommands', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'remove', - tool: 'zpm', - }) - expect(result.args).not.toContain('--json') - expect(result.args).not.toContain('--silent') - }) - }) - - describe('vltpkg', () => { - it('uniformly forwards --view=json to every subcommand', () => { - expect( - applyMachineMode({ args: [], subcommand: 'ls', tool: 'vlt' }).args, - ).toContain('--view=json') - expect( - applyMachineMode({ - args: [], - subcommand: 'query', - tool: 'vlt', - }).args, - ).toContain('--view=json') - expect( - applyMachineMode({ args: [], subcommand: 'anything', tool: 'vlt' }) - .args, - ).toContain('--view=json') - }) - }) - - describe('pip/uv/cargo/gem', () => { - it('pip gets -q and PIP_NO_COLOR', () => { - const result = applyMachineMode({ args: [], tool: 'pip' }) - expect(result.args).toContain('-q') - expect(result.env['PIP_NO_COLOR']).toBe('1') - }) - - it('uv gets --quiet', () => { - const result = applyMachineMode({ args: [], tool: 'uv' }) - expect(result.args).toContain('--quiet') - }) - - it('cargo gets -q', () => { - const result = applyMachineMode({ args: [], tool: 'cargo' }) - expect(result.args).toContain('-q') - }) - - it('gem gets --quiet --no-color', () => { - const result = applyMachineMode({ args: [], tool: 'gem' }) - expect(result.args).toContain('--quiet') - expect(result.args).toContain('--no-color') - }) - }) - - describe('go', () => { - it('adds -json for list/test/build', () => { - expect( - applyMachineMode({ args: [], subcommand: 'list', tool: 'go' }).args, - ).toContain('-json') - expect( - applyMachineMode({ args: [], subcommand: 'test', tool: 'go' }).args, - ).toContain('-json') - expect( - applyMachineMode({ args: [], subcommand: 'build', tool: 'go' }).args, - ).toContain('-json') - }) - - it('omits -json for unsupported subcommands', () => { - const result = applyMachineMode({ - args: [], - subcommand: 'get', - tool: 'go', - }) - expect(result.args).not.toContain('-json') - }) - }) - - describe('unknown tools', () => { - it('passes args through unchanged', () => { - const result = applyMachineMode({ - args: ['--foo', 'bar'], - tool: 'never-heard-of-it', - }) - expect(result.args).toEqual(['--foo', 'bar']) - }) - - it('still injects universal env vars', () => { - const result = applyMachineMode({ - args: [], - tool: 'never-heard-of-it', - }) - expect(result.env['NO_COLOR']).toBe('1') - }) - }) - - describe('arg ordering', () => { - it('prepends forwarded flags before caller args', () => { - const result = applyMachineMode({ - args: ['install', 'lodash'], - subcommand: 'install', - tool: 'pnpm', - }) - const installIdx = result.args.indexOf('install') - const reporterIdx = result.args.indexOf('--reporter=json') - expect(reporterIdx).toBeLessThan(installIdx) - }) - }) -}) diff --git a/packages/cli/test/unit/util/spawn/spawn-node.test.mts b/packages/cli/test/unit/util/spawn/spawn-node.test.mts deleted file mode 100644 index 1bb1e6935..000000000 --- a/packages/cli/test/unit/util/spawn/spawn-node.test.mts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Unit tests for spawn-node utilities. - * - * Purpose: Tests the Node.js spawn abstraction with SEA bootstrap handling. - * - * Test Coverage: - ensureIpcInStdio function - findSystemNodejsSync function - - * getNodeExecutablePathSync function. - * - * Related Files: - util/spawn/spawn-node.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies. -const mockWhichRealSync = vi.hoisted(() => vi.fn()) -const mockGetExecPath = vi.hoisted(() => vi.fn()) -const mockSpawn = vi.hoisted(() => vi.fn()) -const mockSpawnSync = vi.hoisted(() => vi.fn()) -const mockIsSeaBinary = vi.hoisted(() => vi.fn()) -const mockSendBootstrapHandshake = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/bin/which', () => ({ - whichRealSync: mockWhichRealSync, -})) - -vi.mock('@socketsecurity/lib-stable/constants/node', () => ({ - getExecPath: mockGetExecPath, -})) - -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawn: mockSpawn, - spawnSync: mockSpawnSync, -})) - -vi.mock('../../../../src/util/sea/detect.mjs', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -vi.mock('../../../../src/util/sea/boot.mjs', () => ({ - sendBootstrapHandshake: mockSendBootstrapHandshake, -})) - -import { - findSystemNodejsSync, - getNodeExecutablePathSync, - spawnNode, -} from '../../../../src/util/spawn/spawn-node.mts' - -describe('spawn-node', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetExecPath.mockReturnValue('/usr/local/bin/node') - mockIsSeaBinary.mockReturnValue(false) - mockSpawn.mockReturnValue({ process: { send: vi.fn() } }) - }) - - describe('findSystemNodejsSync', () => { - it('returns undefined when no node found', () => { - mockWhichRealSync.mockReturnValue(undefined) - - const result = findSystemNodejsSync() - - expect(result).toBeUndefined() - }) - - it('returns single node path when found', () => { - mockWhichRealSync.mockReturnValue('/usr/bin/node') - - const result = findSystemNodejsSync() - - expect(result).toBe('/usr/bin/node') - }) - - it('returns first non-current-exec path when multiple found', () => { - mockWhichRealSync.mockReturnValue([ - '/usr/bin/node', - '/usr/local/bin/node', - ]) - // Mock process.execPath. - const originalExecPath = process.execPath - Object.defineProperty(process, 'execPath', { - value: '/usr/bin/node', - writable: true, - }) - - const result = findSystemNodejsSync() - - Object.defineProperty(process, 'execPath', { - value: originalExecPath, - writable: true, - }) - - expect(result).toBe('/usr/local/bin/node') - }) - - it('filters out current execPath from results', () => { - const originalExecPath = process.execPath - Object.defineProperty(process, 'execPath', { - value: '/my/sea/binary', - writable: true, - }) - - mockWhichRealSync.mockReturnValue(['/my/sea/binary', '/usr/bin/node']) - - const result = findSystemNodejsSync() - - Object.defineProperty(process, 'execPath', { - value: originalExecPath, - writable: true, - }) - - expect(result).toBe('/usr/bin/node') - }) - }) - - describe('getNodeExecutablePathSync', () => { - it('returns getExecPath when not a SEA binary', () => { - mockIsSeaBinary.mockReturnValue(false) - mockGetExecPath.mockReturnValue('/usr/local/bin/node') - - const result = getNodeExecutablePathSync() - - expect(result).toBe('/usr/local/bin/node') - expect(mockGetExecPath).toHaveBeenCalled() - }) - - it('returns system node when SEA and system node available', () => { - mockIsSeaBinary.mockReturnValue(true) - mockWhichRealSync.mockReturnValue('/usr/bin/node') - - const result = getNodeExecutablePathSync() - - expect(result).toBe('/usr/bin/node') - }) - - it('returns process.execPath when SEA and no system node', () => { - mockIsSeaBinary.mockReturnValue(true) - mockWhichRealSync.mockReturnValue(undefined) - - const result = getNodeExecutablePathSync() - - expect(result).toBe(process.execPath) - }) - }) - - describe('spawnNode', () => { - it('spawns node with IPC stdio', () => { - mockSpawn.mockReturnValue({ process: { send: vi.fn() } }) - - spawnNode(['script.js']) - - expect(mockSpawn).toHaveBeenCalled() - const spawnCall = mockSpawn.mock.calls[0] - expect(spawnCall[2].stdio).toContain('ipc') - }) - - it('sends bootstrap handshake after spawn', () => { - const mockProcess = { send: vi.fn() } - mockSpawn.mockReturnValue({ process: mockProcess }) - - spawnNode(['script.js']) - - expect(mockSendBootstrapHandshake).toHaveBeenCalledWith( - mockProcess, - expect.objectContaining({ - subprocess: true, - parent_pid: process.pid, - }), - ) - }) - - it('includes custom IPC data in handshake extra field', () => { - const mockProcess = { send: vi.fn() } - mockSpawn.mockReturnValue({ process: mockProcess }) - - spawnNode(['script.js'], { ipc: { custom: 'data' } }) - - expect(mockSendBootstrapHandshake).toHaveBeenCalledWith( - mockProcess, - expect.objectContaining({ - extra: { custom: 'data' }, - }), - ) - }) - - it('preserves existing stdio array', () => { - mockSpawn.mockReturnValue({ process: { send: vi.fn() } }) - - spawnNode(['script.js'], { stdio: ['pipe', 'pipe', 'pipe'] }) - - const spawnCall = mockSpawn.mock.calls[0] - expect(spawnCall[2].stdio).toEqual(['pipe', 'pipe', 'pipe', 'ipc']) - }) - - it('converts string stdio to array with ipc', () => { - mockSpawn.mockReturnValue({ process: { send: vi.fn() } }) - - spawnNode(['script.js'], { stdio: 'inherit' }) - - const spawnCall = mockSpawn.mock.calls[0] - expect(spawnCall[2].stdio).toEqual([ - 'inherit', - 'inherit', - 'inherit', - 'ipc', - ]) - }) - - it('keeps stdio array unchanged when ipc is already present', () => { - mockSpawn.mockReturnValue({ process: { send: vi.fn() } }) - - spawnNode(['script.js'], { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - }) - - const spawnCall = mockSpawn.mock.calls[0] - expect(spawnCall[2].stdio).toEqual(['pipe', 'pipe', 'pipe', 'ipc']) - }) - - it('throws when spawned child process is missing the IPC send method', () => { - // Simulate a process without a send fn so assertHasSend throws. - mockSpawn.mockReturnValue({ process: {} }) - - expect(() => spawnNode(['script.js'])).toThrow( - /expected IPC channel on child process/, - ) - }) - }) - -}) diff --git a/packages/cli/test/unit/util/telemetry/integration.test.mts b/packages/cli/test/unit/util/telemetry/integration.test.mts deleted file mode 100644 index a67e896a7..000000000 --- a/packages/cli/test/unit/util/telemetry/integration.test.mts +++ /dev/null @@ -1,489 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/personal-path-placeholders -- "testuser" is a fixture username in test input for tilde-replacement assertions; not a real personal path. */ -/** - * Unit tests for Telemetry integration utilities. - * - * Purpose: Tests the telemetry integration helper functions. - * - * Test Coverage: - finalizeTelemetry function - finalizeTelemetrySync function - * - setupTelemetryExitHandlers function - trackSubprocessExit function - - * sanitizeArgv function (via buildContext) - trackEvent function - - * trackCliStart function - trackCliEvent function - trackCliComplete function - - * trackCliError function - trackSubprocessStart function - - * trackSubprocessComplete function - trackSubprocessError function. - * - * Related Files: - util/telemetry/integration.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock TelemetryService. -const mockFlush = vi.hoisted(() => vi.fn()) -const mockTrack = vi.hoisted(() => vi.fn()) -const mockDestroy = vi.hoisted(() => vi.fn()) -const mockGetCurrentInstance = vi.hoisted(() => - vi.fn(() => ({ - destroy: mockDestroy, - flush: mockFlush, - track: mockTrack, - })), -) -const mockGetTelemetryClient = vi.hoisted(() => - vi.fn(() => - Promise.resolve({ - destroy: mockDestroy, - flush: mockFlush, - track: mockTrack, - }), - ), -) - -vi.mock('../../../../src/util/telemetry/service.mts', () => ({ - TelemetryService: { - getCurrentInstance: mockGetCurrentInstance, - getTelemetryClient: mockGetTelemetryClient, - }, -})) - -// Mock config. -const mockGetConfigValueOrUndef = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/config.mts', () => ({ - getConfigValueOrUndef: mockGetConfigValueOrUndef, -})) - -// Mock constants - set VITEST to false to enable telemetry tracking. -vi.mock('../../../../src/constants.mts', () => ({ - CONFIG_KEY_DEFAULT_ORG: 'defaultOrg', - constants: { - ENV: { - INLINED_VERSION: '1.0.0-test', - VITEST: false, - }, - }, -})) - -// Mock homedir. -const mockHomedir = vi.hoisted(() => vi.fn(() => '/Users/testuser')) -vi.mock('node:os', () => ({ - homedir: mockHomedir, - default: { - homedir: mockHomedir, - }, -})) - -// Mock debug. -vi.mock('@socketsecurity/lib-stable/debug/output', () => ({ - debugNs: vi.fn(), -})) - -import { - finalizeTelemetry, - finalizeTelemetrySync, - setupTelemetryExitHandlers, - trackCliComplete, - trackCliError, - trackCliEvent, - trackCliStart, - trackEvent, - trackSubprocessComplete, - trackSubprocessError, - trackSubprocessExit, - trackSubprocessStart, -} from '../../../../src/util/telemetry/integration.mts' - -describe('telemetry/integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetConfigValueOrUndef.mockReturnValue('test-org') - }) - - describe('finalizeTelemetry', () => { - it('flushes telemetry when instance exists', async () => { - await finalizeTelemetry() - - expect(mockGetCurrentInstance).toHaveBeenCalled() - expect(mockFlush).toHaveBeenCalled() - }) - - it('does nothing when no instance exists', async () => { - mockGetCurrentInstance.mockReturnValueOnce(undefined) - - await finalizeTelemetry() - - expect(mockGetCurrentInstance).toHaveBeenCalled() - expect(mockFlush).not.toHaveBeenCalled() - }) - }) - - describe('finalizeTelemetrySync', () => { - it('triggers flush when instance exists', () => { - finalizeTelemetrySync() - - expect(mockGetCurrentInstance).toHaveBeenCalled() - expect(mockFlush).toHaveBeenCalled() - }) - - it('does nothing when no instance exists', () => { - mockGetCurrentInstance.mockReturnValueOnce(undefined) - - finalizeTelemetrySync() - - expect(mockGetCurrentInstance).toHaveBeenCalled() - expect(mockFlush).not.toHaveBeenCalled() - }) - }) - - describe('setupTelemetryExitHandlers', () => { - it('registers exit handlers', () => { - const processOnSpy = vi.spyOn(process, 'on') - - setupTelemetryExitHandlers() - - expect(processOnSpy).toHaveBeenCalled() - processOnSpy.mockRestore() - }) - - it('skips re-registration on duplicate calls (lines 118-119)', () => { - // First call registers (or has already registered from a prior test - // in the same module — module-level exitHandlersRegistered persists). - setupTelemetryExitHandlers() - const processOnSpy = vi.spyOn(process, 'on') - // Second call should hit the early-return branch. - setupTelemetryExitHandlers() - // No new handlers registered on the second call. - expect(processOnSpy).not.toHaveBeenCalled() - processOnSpy.mockRestore() - }) - }) - - describe('trackSubprocessExit', () => { - it('tracks error when exit code is non-zero', async () => { - await trackSubprocessExit('npm', Date.now() - 1000, 1) - - expect(mockTrack).toHaveBeenCalled() - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('subprocess_error') - }) - - it('tracks completion when exit code is zero', async () => { - await trackSubprocessExit('npm', Date.now() - 1000, 0) - - expect(mockTrack).toHaveBeenCalled() - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('subprocess_complete') - }) - - it('does not track when exit code is null', async () => { - await trackSubprocessExit('npm', Date.now() - 1000, undefined) - - // Only finalize is called, no track. - expect(mockFlush).toHaveBeenCalled() - }) - }) - - describe('trackEvent', () => { - it('tracks event with context', async () => { - await trackEvent('test_event', { - arch: 'x64', - argv: ['socket', 'scan'], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }) - - expect(mockGetTelemetryClient).toHaveBeenCalledWith('test-org') - expect(mockTrack).toHaveBeenCalled() - }) - - it('includes metadata when provided', async () => { - await trackEvent( - 'test_event', - { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }, - { custom_field: 'value' }, - ) - - const call = mockTrack.mock.calls[0][0] - expect(call.metadata).toEqual({ custom_field: 'value' }) - }) - - it('includes error when provided', async () => { - const error = new Error('Test error') - await trackEvent( - 'test_event', - { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }, - {}, - { error }, - ) - - const call = mockTrack.mock.calls[0][0] - expect(call.error).toBeDefined() - expect(call.error.message).toBe('Test error') - }) - - it('sanitizes error with undefined stack (line 304-305)', async () => { - // Error with no stack property — sanitizeErrorAttribute hits the - // falsy-input early return on line 304-305. - const error = new Error('Test error') - delete (error as unknown).stack - await trackEvent( - 'test_event', - { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }, - {}, - { error }, - ) - - const call = mockTrack.mock.calls[0][0] - expect(call.error.stack).toBeUndefined() - }) - - it('flushes when flush option is true', async () => { - await trackEvent( - 'test_event', - { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }, - {}, - { flush: true }, - ) - - expect(mockFlush).toHaveBeenCalled() - }) - - it('does not track when no org slug', async () => { - mockGetConfigValueOrUndef.mockReturnValue(undefined) - - await trackEvent('test_event', { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }) - - expect(mockGetTelemetryClient).not.toHaveBeenCalled() - expect(mockTrack).not.toHaveBeenCalled() - }) - - it('handles telemetry errors gracefully', async () => { - mockGetTelemetryClient.mockRejectedValueOnce(new Error('Service error')) - - await expect( - trackEvent('test_event', { - arch: 'x64', - argv: [], - node_version: 'v20.0.0', - platform: 'darwin', - version: '1.0.0', - }), - ).resolves.not.toThrow() - }) - }) - - describe('trackCliStart', () => { - it('returns start timestamp', async () => { - const startTime = await trackCliStart(['node', 'socket', 'scan']) - - expect(typeof startTime).toBe('number') - expect(startTime).toBeGreaterThan(0) - }) - - it('tracks cli_start event', async () => { - await trackCliStart(['node', 'socket', 'scan']) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('cli_start') - }) - }) - - describe('trackCliEvent', () => { - it('tracks custom event type', async () => { - await trackCliEvent('custom_event', ['node', 'socket', 'scan']) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('custom_event') - }) - - it('includes optional metadata', async () => { - await trackCliEvent('custom_event', ['node', 'socket', 'scan'], { - custom: 'value', - }) - - const call = mockTrack.mock.calls[0][0] - expect(call.metadata).toEqual({ custom: 'value' }) - }) - }) - - describe('trackCliComplete', () => { - it('tracks cli_complete event with duration', async () => { - const startTime = Date.now() - 1000 - await trackCliComplete(['node', 'socket', 'scan'], startTime, 0) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('cli_complete') - expect(call.metadata.duration).toBeGreaterThanOrEqual(1000) - expect(call.metadata.exit_code).toBe(0) - }) - - it('flushes after tracking', async () => { - await trackCliComplete(['node', 'socket', 'scan'], Date.now(), 0) - - expect(mockFlush).toHaveBeenCalled() - }) - }) - - describe('trackCliError', () => { - it('tracks cli_error event with error details', async () => { - const error = new Error('Test error') - await trackCliError( - ['node', 'socket', 'scan'], - Date.now() - 500, - error, - 1, - ) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('cli_error') - expect(call.error).toBeDefined() - expect(call.error.message).toBe('Test error') - }) - - it('normalizes non-Error values', async () => { - await trackCliError( - ['node', 'socket', 'scan'], - Date.now(), - 'string error', - 1, - ) - - const call = mockTrack.mock.calls[0][0] - expect(call.error.message).toBe('string error') - }) - }) - - describe('trackSubprocessStart', () => { - it('returns start timestamp', async () => { - const startTime = await trackSubprocessStart('npm') - - expect(typeof startTime).toBe('number') - expect(startTime).toBeGreaterThan(0) - }) - - it('tracks subprocess_start event', async () => { - await trackSubprocessStart('npm', { cwd: '/path' }) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('subprocess_start') - expect(call.metadata.command).toBe('npm') - expect(call.metadata.cwd).toBe('/path') - }) - }) - - describe('trackSubprocessComplete', () => { - it('tracks subprocess_complete event', async () => { - await trackSubprocessComplete('npm', Date.now() - 500, 0, { - stdout_length: 100, - }) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('subprocess_complete') - expect(call.metadata.command).toBe('npm') - expect(call.metadata.exit_code).toBe(0) - }) - }) - - describe('trackSubprocessError', () => { - it('tracks subprocess_error event', async () => { - const error = new Error('Process failed') - await trackSubprocessError('npm', Date.now() - 500, error, 1) - - const call = mockTrack.mock.calls[0][0] - expect(call.event_type).toBe('subprocess_error') - expect(call.error).toBeDefined() - }) - }) - - describe('sanitizeArgv (via buildContext)', () => { - it('sanitizes API tokens from argv', async () => { - // Use a fake token pattern that matches the sanitizer (sktsec_ prefix). - const prefix = 'sktsec' - const fakeToken = `${prefix}_testvalue123` - await trackCliStart(['node', 'socket', 'scan', '--token', fakeToken]) - - const call = mockTrack.mock.calls[0][0] - expect(call.context.argv).toContain('[REDACTED]') - expect(call.context.argv).not.toContain(fakeToken) - }) - - it('replaces home directory with tilde', async () => { - await trackCliStart(['node', 'socket', 'scan', '/Users/testuser/project']) - - const call = mockTrack.mock.calls[0][0] - expect(call.context.argv).toContain('~/project') - }) - - it('strips arguments after wrapper CLI commands', async () => { - await trackCliStart([ - 'node', - 'socket', - 'npm', - 'install', - '@my/private-package', - ]) - - const call = mockTrack.mock.calls[0][0] - // Should only have 'npm', not 'install' or package name. - expect(call.context.argv).toEqual(['npm']) - }) - - it('redacts hex tokens', async () => { - await trackCliStart([ - 'node', - 'socket', - 'scan', - 'abc123def456789012345678901234567890', - ]) - - const call = mockTrack.mock.calls[0][0] - expect(call.context.argv).toContain('[REDACTED]') - }) - - it('passes argv arg through unchanged when homedir returns empty (line 292)', async () => { - mockHomedir.mockReturnValueOnce('') - await trackCliEvent('test_cli_event', ['socket', 'scan', '/some/path']) - const call = mockTrack.mock.calls[0][0] - // /some/path stays unchanged when homedir is empty. - expect(call.context.argv).toContain('/some/path') - }) - - it('passes error message through unchanged when homedir empty (line 314)', async () => { - mockHomedir.mockReturnValueOnce('') - const error = new Error('something failed') - await trackCliError(['socket', 'scan'], Date.now() - 100, error) - // The sanitizer leaves the message intact when homedir is empty. - const call = mockTrack.mock.calls[0]?.[0] - expect(call).toBeDefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/telemetry/service.test.mts b/packages/cli/test/unit/util/telemetry/service.test.mts deleted file mode 100644 index 801594756..000000000 --- a/packages/cli/test/unit/util/telemetry/service.test.mts +++ /dev/null @@ -1,514 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for Telemetry service. - * - * Purpose: Tests the TelemetryService class for event tracking and batching. - * - * Test Coverage: - Singleton pattern - Event tracking - Event batching and - * flushing - Destroy functionality. - * - * Related Files: - util/telemetry/service.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock setupSdk. -const mockSetupSdk = vi.hoisted(() => vi.fn()) -const mockGetOrgTelemetryConfig = vi.hoisted(() => vi.fn()) -const mockPostOrgTelemetry = vi.hoisted(() => vi.fn()) - -vi.mock('../../../../src/util/socket/sdk.mts', () => ({ - setupSdk: mockSetupSdk, -})) - -import { TelemetryService } from '../../../../src/util/telemetry/service.mts' - -describe('TelemetryService', () => { - beforeEach(() => { - vi.clearAllMocks() - - // Default mock implementations. - mockGetOrgTelemetryConfig.mockResolvedValue({ - success: true, - data: { - telemetry: { - enabled: true, - }, - }, - }) - - mockPostOrgTelemetry.mockResolvedValue({ - success: true, - }) - - mockSetupSdk.mockResolvedValue({ - ok: true, - data: { - getOrgTelemetryConfig: mockGetOrgTelemetryConfig, - postOrgTelemetry: mockPostOrgTelemetry, - }, - }) - }) - - afterEach(async () => { - // Clean up singleton instance after each test. - const instance = TelemetryService.getCurrentInstance() - if (instance) { - await instance.destroy() - } - }) - - describe('getCurrentInstance', () => { - it('returns undefined when no instance exists', () => { - const instance = TelemetryService.getCurrentInstance() - expect(instance).toBeUndefined() - }) - - it('returns instance after initialization', async () => { - await TelemetryService.getTelemetryClient('test-org') - - const instance = TelemetryService.getCurrentInstance() - expect(instance).not.toBeUndefined() - }) - }) - - describe('getTelemetryClient', () => { - it('creates a new instance when none exists', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - expect(client).toBeDefined() - }) - - it('returns same instance on subsequent calls', async () => { - const client1 = await TelemetryService.getTelemetryClient('test-org') - const client2 = await TelemetryService.getTelemetryClient('test-org') - expect(client1).toBe(client2) - }) - - it('returns same instance even with different org slug', async () => { - const client1 = await TelemetryService.getTelemetryClient('org-1') - const client2 = await TelemetryService.getTelemetryClient('org-2') - expect(client1).toBe(client2) - }) - - it('uses default config when SDK setup fails', async () => { - mockSetupSdk.mockResolvedValue({ - ok: false, - message: 'SDK setup failed', - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - expect(client).toBeDefined() - }) - - it('uses default config when telemetry config fetch fails', async () => { - mockGetOrgTelemetryConfig.mockResolvedValue({ - success: false, - error: 'Config fetch failed', - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - expect(client).toBeDefined() - }) - }) - - describe('track', () => { - it('queues events when telemetry is enabled', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - // Event should be queued, not sent immediately (unless batch size reached). - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - }) - - it('ignores events when service is destroyed', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - await client.destroy() - - // Create a new client and immediately destroy it. - const client2 = await TelemetryService.getTelemetryClient('test-org') - await client2.destroy() - - // Create another client to test tracking after destroy. - const client3 = await TelemetryService.getTelemetryClient('test-org') - - // Track event - this should work since we have a fresh instance. - client3.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - // The event tracking itself succeeds. - await client3.destroy() - }) - - it('clears queue without sending when telemetry disabled mid-queue (lines 328-330)', async () => { - // Initially enabled — track an event so it lands in the queue. - const client = await TelemetryService.getTelemetryClient('test-org') - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'first_event', - context: {}, - }) - // Mutate the in-memory config to disable telemetry, then flush. - ;(client as unknown).config = { telemetry: { enabled: false } } - mockPostOrgTelemetry.mockClear() - await client.flush() - // Queue should be drained without a network call. - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - expect((client as unknown).eventQueue).toEqual([]) - }) - - it('returns early on track() after destroy on same instance (lines 284-285)', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - // Hold the same reference, destroy it, then track on the destroyed - // instance directly — exercises the early-return at lines 283-286. - await client.destroy() - // No throw expected; track returns void synchronously. - expect(() => - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'after_destroy', - context: {}, - }), - ).not.toThrow() - // postOrgTelemetry should NOT be called since the instance is destroyed. - // (Counts may be > 0 from earlier tests; reset and verify no NEW call.) - mockPostOrgTelemetry.mockClear() - // Trigger another track on the destroyed instance. - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'still_destroyed', - context: {}, - }) - // Allow microtasks to settle. - await new Promise(resolve => setTimeout(resolve, 10)) - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - }) - - it('ignores events when telemetry is disabled', async () => { - mockGetOrgTelemetryConfig.mockResolvedValue({ - success: true, - data: { - telemetry: { - enabled: false, - }, - }, - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - // Event should be ignored. - await client.flush() - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - }) - }) - - describe('flush', () => { - it('sends all queued events', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_1', - context: {}, - }) - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_2', - context: {}, - }) - - await client.flush() - - expect(mockPostOrgTelemetry).toHaveBeenCalledTimes(2) - }) - - it('does nothing when queue is empty', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - await client.flush() - - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - }) - - it('clears queue when telemetry is disabled', async () => { - mockGetOrgTelemetryConfig.mockResolvedValue({ - success: true, - data: { - telemetry: { - enabled: false, - }, - }, - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - - // Queue should be cleared without sending. - await client.flush() - expect(mockPostOrgTelemetry).not.toHaveBeenCalled() - }) - - it('handles API errors gracefully', async () => { - mockPostOrgTelemetry.mockRejectedValue(new Error('API error')) - - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - // Should not throw. - await expect(client.flush()).resolves.not.toThrow() - }) - }) - - describe('destroy', () => { - it('flushes remaining events before destroying', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - await client.destroy() - - expect(mockPostOrgTelemetry).toHaveBeenCalledTimes(1) - }) - - it('clears singleton instance', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - await client.destroy() - - expect(TelemetryService.getCurrentInstance()).toBeUndefined() - }) - - it('is idempotent', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - await client.destroy() - await client.destroy() - - expect(TelemetryService.getCurrentInstance()).toBeUndefined() - }) - - it('does not flush when service is destroyed', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - await client.destroy() - - // Now try to flush on a destroyed instance. - await client.flush() - - // Should not send anything because service is destroyed. - expect(mockPostOrgTelemetry).toHaveBeenCalledTimes(0) - }) - - it('handles SDK setup failure during flush gracefully', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - - // Make SDK setup fail during flush. - mockSetupSdk.mockResolvedValue({ - ok: false, - message: 'SDK setup failed', - }) - - // Should not throw. - await expect(client.flush()).resolves.not.toThrow() - }) - - it('handles exceptions during initialization gracefully', async () => { - mockSetupSdk.mockRejectedValue(new Error('Unexpected error')) - - // Should not throw and return a client with default config. - const client = await TelemetryService.getTelemetryClient('error-org') - expect(client).toBeDefined() - }) - }) - - describe('concurrent initialization', () => { - it('handles concurrent calls to getTelemetryClient', async () => { - // Simulate concurrent calls. - const [client1, client2, client3] = await Promise.all([ - TelemetryService.getTelemetryClient('test-org'), - TelemetryService.getTelemetryClient('test-org'), - TelemetryService.getTelemetryClient('test-org'), - ]) - - // All should return the same instance. - expect(client1).toBe(client2) - expect(client2).toBe(client3) - - // SDK setup should only be called once. - expect(mockSetupSdk).toHaveBeenCalledTimes(1) - }) - }) - - describe('sendEvents error handling', () => { - it('tracks success and failure counts correctly', async () => { - // Make some events succeed and some fail. - let callCount = 0 - mockPostOrgTelemetry.mockImplementation(async () => { - callCount++ - if (callCount % 2 === 0) { - return { success: false, error: 'Failed' } - } - return { success: true } - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_1', - context: {}, - }) - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_2', - context: {}, - }) - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_3', - context: {}, - }) - - await client.flush() - - expect(mockPostOrgTelemetry).toHaveBeenCalledTimes(3) - }) - - it('handles rejected promises during send', async () => { - let callCount = 0 - mockPostOrgTelemetry.mockImplementation(async () => { - callCount++ - if (callCount === 2) { - throw new Error('Network error') - } - return { success: true } - }) - - const client = await TelemetryService.getTelemetryClient('test-org') - - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_1', - context: {}, - }) - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_2', - context: {}, - }) - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'event_3', - context: {}, - }) - - // Should not throw despite one event failing. - await expect(client.flush()).resolves.not.toThrow() - }) - }) - - describe('auto-flush on batch size', () => { - it('automatically flushes when batch size is reached', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - - // Add 10 events (default batch size). - for (let i = 0; i < 10; i++) { - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: `event_${i}`, - context: {}, - }) - } - - // Give time for auto-flush to complete. - await new Promise(resolve => setTimeout(resolve, 100)) - - // Events should have been sent. - expect(mockPostOrgTelemetry).toHaveBeenCalled() - }) - }) - - describe('flush error/timeout branches', () => { - it('handles errors thrown by setupSdk during flush (lines 351-366)', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - // Make the second setupSdk call (the one inside sendEvents) throw. - mockSetupSdk.mockRejectedValueOnce(new Error('SDK init failed')) - // Flush swallows the error and discards events. - await expect(client.flush()).resolves.toBeUndefined() - }) - - it('handles timeout-message errors during flush (lines 356-363)', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - // Reject with an error whose message contains "timed out" — exercises - // the timeout-detection branch in the flush() catch block. - mockSetupSdk.mockRejectedValueOnce( - new Error('Telemetry flush timed out after 2000ms'), - ) - await expect(client.flush()).resolves.toBeUndefined() - }) - }) - - describe('destroy error/timeout branches', () => { - it('handles errors thrown during destroy flush (lines 459-478)', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - // The next setupSdk call (inside destroy → sendEvents) throws. - mockSetupSdk.mockRejectedValueOnce(new Error('SDK init failed')) - // destroy() should not throw even when its internal flush fails. - await expect(client.destroy()).resolves.toBeUndefined() - }) - - it('handles timeout-message errors during destroy flush (lines 463-473)', async () => { - const client = await TelemetryService.getTelemetryClient('test-org') - client.track({ - event_sender_created_at: new Date().toISOString(), - event_type: 'test_event', - context: {}, - }) - mockSetupSdk.mockRejectedValueOnce( - new Error('flush during destroy timed out after 2000ms'), - ) - await expect(client.destroy()).resolves.toBeUndefined() - }) - }) -}) diff --git a/packages/cli/test/unit/util/terminal/ascii-header.test.mts b/packages/cli/test/unit/util/terminal/ascii-header.test.mts deleted file mode 100644 index 39c5c8f85..000000000 --- a/packages/cli/test/unit/util/terminal/ascii-header.test.mts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * @file Tests for ASCII header with shimmer effects. Validates header - * rendering, theme handling, and environment detection. - */ - -import { describe, expect, it } from 'vitest' - -import { - renderLogoWithFallback, - renderShimmerFrame, - supportsFullColor, -} from '../../../../src/util/terminal/ascii-header.mts' -import type { HeaderTheme } from '../../../../src/util/terminal/ascii-header.mts' - -/** - * Strip ANSI color codes from string for shimmer testing. - */ -export function stripAnsi(str: string): string { - return str.replace(/\x1b\[[0-9;]*m/g, '') -} - -describe('ascii-header', () => { - describe('supportsFullColor', () => { - it('should detect COLORTERM=truecolor', () => { - const originalColorterm = process.env['COLORTERM'] - try { - process.env['COLORTERM'] = 'truecolor' - expect(supportsFullColor()).toBe(true) - } finally { - if (originalColorterm === undefined) { - delete process.env['COLORTERM'] - } else { - process.env['COLORTERM'] = originalColorterm - } - } - }) - - it('should detect COLORTERM=24bit', () => { - const originalColorterm = process.env['COLORTERM'] - try { - process.env['COLORTERM'] = '24bit' - expect(supportsFullColor()).toBe(true) - } finally { - if (originalColorterm === undefined) { - delete process.env['COLORTERM'] - } else { - process.env['COLORTERM'] = originalColorterm - } - } - }) - - it('should detect TERM_PROGRAM=iTerm.app', () => { - const originalTermProgram = process.env['TERM_PROGRAM'] - const originalColorterm = process.env['COLORTERM'] - try { - delete process.env['COLORTERM'] - process.env['TERM_PROGRAM'] = 'iTerm.app' - expect(supportsFullColor()).toBe(true) - } finally { - if (originalTermProgram === undefined) { - delete process.env['TERM_PROGRAM'] - } else { - process.env['TERM_PROGRAM'] = originalTermProgram - } - if (originalColorterm !== undefined) { - process.env['COLORTERM'] = originalColorterm - } - } - }) - - it('should return false for basic terminals', () => { - const originalColorterm = process.env['COLORTERM'] - const originalTerm = process.env['TERM'] - const originalTermProgram = process.env['TERM_PROGRAM'] - try { - delete process.env['COLORTERM'] - delete process.env['TERM_PROGRAM'] - process.env['TERM'] = 'xterm' - expect(supportsFullColor()).toBe(false) - } finally { - if (originalColorterm !== undefined) { - process.env['COLORTERM'] = originalColorterm - } - if (originalTerm !== undefined) { - process.env['TERM'] = originalTerm - } - if (originalTermProgram !== undefined) { - process.env['TERM_PROGRAM'] = originalTermProgram - } - } - }) - }) - - describe('renderShimmerFrame', () => { - it('should render shimmer frame with default theme', () => { - const logo = renderShimmerFrame(0) - const stripped = stripAnsi(logo) - expect(stripped).toContain('|') - expect(stripped).toContain('dev') - }) - - it('should render different frames differently', () => { - const frame0 = renderShimmerFrame(0) - const frame10 = renderShimmerFrame(10) - // The shimmer engine (`@socketsecurity/lib/effects/shimmer`) is - // pure: a different frame counter advances the wave's position - // and changes per-character truecolor codes. There's no CI - // special-case in the engine, so frames must always differ. - expect(frame0).not.toBe(frame10) - }) - - it('should render shimmer with all themes', () => { - const themes: HeaderTheme[] = [ - 'default', - 'cyberpunk', - 'forest', - 'ocean', - 'sunset', - ] - for (let i = 0, { length } = themes; i < length; i += 1) { - const theme = themes[i] - const logo = renderShimmerFrame(0, theme) - const stripped = stripAnsi(logo) - expect(stripped).toContain('|') - expect(stripped).toContain('dev') - } - }) - - it('should produce 4 lines of output', () => { - const logo = renderShimmerFrame(0) - const lines = logo.split('\n') - expect(lines).toHaveLength(4) - }) - - it('should contain ANSI bold codes', () => { - const logo = renderShimmerFrame(0) - expect(logo).toContain('\x1b[1m') - }) - - it('should apply slanted shimmer effect across lines', () => { - // Each line should have different shimmer offset creating diagonal effect - const logo = renderShimmerFrame(0) - expect(logo).toBeTruthy() - // Slant is implemented via frame offset, not directly testable via output comparison - }) - }) - - describe('renderLogoWithFallback', () => { - it('should render static logo when frame is null', () => { - const logo = renderLogoWithFallback(undefined) - expect(logo).toContain('| __|___') // ASCII art content - expect(logo).toContain('.dev') - }) - - it('should render shimmer when frame provided and full color supported', () => { - const originalColorterm = process.env['COLORTERM'] - try { - process.env['COLORTERM'] = 'truecolor' - const logo = renderLogoWithFallback(0) - const stripped = stripAnsi(logo) - expect(stripped).toContain('|') - expect(stripped).toContain('dev') - // With full color support, should use shimmer (contains bold) - if (supportsFullColor()) { - expect(logo).toContain('\x1b[1m') - } - } finally { - if (originalColorterm === undefined) { - delete process.env['COLORTERM'] - } else { - process.env['COLORTERM'] = originalColorterm - } - } - }) - - it('should render simple color logo without full color support', () => { - const originalColorterm = process.env['COLORTERM'] - const originalTerm = process.env['TERM'] - const originalTermProgram = process.env['TERM_PROGRAM'] - try { - delete process.env['COLORTERM'] - delete process.env['TERM_PROGRAM'] - process.env['TERM'] = 'xterm' - const logo = renderLogoWithFallback(0) - expect(logo).toContain('| __|___') // ASCII art content - // Without full color support, should use simple colors (no RGB codes) - if (!supportsFullColor()) { - expect(logo).not.toContain('\x1b[38;2;') - } - } finally { - if (originalColorterm !== undefined) { - process.env['COLORTERM'] = originalColorterm - } - if (originalTerm !== undefined) { - process.env['TERM'] = originalTerm - } - if (originalTermProgram !== undefined) { - process.env['TERM_PROGRAM'] = originalTermProgram - } - } - }) - - it('should support all themes with fallback', () => { - const themes: HeaderTheme[] = [ - 'default', - 'cyberpunk', - 'forest', - 'ocean', - 'sunset', - ] - for (let i = 0, { length } = themes; i < length; i += 1) { - const theme = themes[i] - const logo = renderLogoWithFallback(undefined, theme) - expect(logo).toContain('| __|___') // ASCII art content - } - }) - }) - - describe('CI and VITEST mode detection', () => { - it('should not show animations in VITEST mode', () => { - // In VITEST mode, we should use static rendering - const isVitest = process.env['VITEST'] === 'true' - if (isVitest) { - // When running under vitest, prefer static logo - const logo = renderLogoWithFallback(undefined) - expect(logo).toContain('| __|___') // ASCII art content - } - }) - - it('should handle missing environment variables gracefully', () => { - const originalColorterm = process.env['COLORTERM'] - const originalTerm = process.env['TERM'] - const originalTermProgram = process.env['TERM_PROGRAM'] - try { - delete process.env['COLORTERM'] - delete process.env['TERM'] - delete process.env['TERM_PROGRAM'] - expect(() => supportsFullColor()).not.toThrow() - expect(() => renderLogoWithFallback(undefined)).not.toThrow() - } finally { - if (originalColorterm !== undefined) { - process.env['COLORTERM'] = originalColorterm - } - if (originalTerm !== undefined) { - process.env['TERM'] = originalTerm - } - if (originalTermProgram !== undefined) { - process.env['TERM_PROGRAM'] = originalTermProgram - } - } - }) - }) - - describe('edge cases', () => { - it('should handle very large frame numbers', () => { - const logo = renderShimmerFrame(1000000) - const stripped = stripAnsi(logo) - expect(stripped).toContain('|') - expect(stripped).toContain('dev') - }) - - it('should handle negative frame numbers', () => { - const logo = renderShimmerFrame(-10) - const stripped = stripAnsi(logo) - expect(stripped).toContain('|') - expect(stripped).toContain('dev') - }) - - it('should handle frame 0 consistently', () => { - const logo1 = renderShimmerFrame(0) - const logo2 = renderShimmerFrame(0) - expect(logo1).toBe(logo2) - }) - }) - - describe('brighterRgb', () => { - it('returns the brighter of two RGB tuples by channel sum', async () => { - const { brighterRgb } = - await import('../../../../src/util/terminal/ascii-header.mts') - const dark: [number, number, number] = [10, 10, 10] - const bright: [number, number, number] = [200, 200, 200] - expect(brighterRgb(dark, bright)).toBe(bright) - expect(brighterRgb(bright, dark)).toBe(bright) - }) - - it('returns the first arg on ties (a >= b)', async () => { - const { brighterRgb } = - await import('../../../../src/util/terminal/ascii-header.mts') - const a: [number, number, number] = [50, 50, 50] - const b: [number, number, number] = [50, 50, 50] - expect(brighterRgb(a, b)).toBe(a) - }) - }) -}) diff --git a/packages/cli/test/unit/util/terminal/link.test.mts b/packages/cli/test/unit/util/terminal/link.test.mts deleted file mode 100644 index d2bf12568..000000000 --- a/packages/cli/test/unit/util/terminal/link.test.mts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Unit tests for terminal link rendering. - * - * Purpose: Tests terminal hyperlink rendering. Validates OSC 8 hyperlink - * support. - * - * Test Coverage: - Hyperlink rendering - OSC 8 support detection - Fallback to - * plain URLs - Link text formatting - Terminal capability detection. - * - * Testing Approach: Tests hyperlink utilities for modern terminals. - * - * Related Files: - util/terminal/link.mts (implementation) - */ - -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import { - fileLink, - githubRepoLink, - mailtoLink, - socketDashboardLink, - socketDevLink, - socketDocsLink, - socketPackageLink, - webLink, -} from '../../../../src/util/terminal/link.mts' - -// Mock terminal-link module. -vi.mock('terminal-link', () => ({ - default: vi.fn((text, url) => `[${text}](${url})`), -})) - -describe('terminal-link utilities', () => { - describe('fileLink', () => { - it('creates link to absolute file path', () => { - const result = fileLink('/absolute/path/to/file.txt') - expect(result).toBe( - '[/absolute/path/to/file.txt](file:///absolute/path/to/file.txt)', - ) - }) - - it('creates link to relative file path', () => { - const relativePath = 'relative/file.txt' - const absolutePath = path.resolve(relativePath) - const result = fileLink(relativePath) - expect(result).toBe(`[${relativePath}](file://${absolutePath})`) - }) - - it('uses custom text when provided', () => { - const result = fileLink('/path/to/file.txt', 'Custom Text') - expect(result).toBe('[Custom Text](file:///path/to/file.txt)') - }) - }) - - describe('mailtoLink', () => { - it('creates mailto link', () => { - const result = mailtoLink('test@example.com') - expect(result).toBe('[test@example.com](mailto:test@example.com)') - }) - - it('uses custom text when provided', () => { - const result = mailtoLink('test@example.com', 'Email Me') - expect(result).toBe('[Email Me](mailto:test@example.com)') - }) - }) - - describe('socketDashboardLink', () => { - it('creates dashboard link with leading slash', () => { - const result = socketDashboardLink('/org/YOURORG/alerts') - expect(result).toBe( - '[https://socket.dev/dashboard/org/YOURORG/alerts](https://socket.dev/dashboard/org/YOURORG/alerts)', - ) - }) - - it('creates dashboard link without leading slash', () => { - const result = socketDashboardLink('org/YOURORG/settings') - expect(result).toBe( - '[https://socket.dev/dashboard/org/YOURORG/settings](https://socket.dev/dashboard/org/YOURORG/settings)', - ) - }) - - it('uses custom text when provided', () => { - const result = socketDashboardLink('/alerts', 'View Alerts') - expect(result).toBe('[View Alerts](https://socket.dev/dashboard/alerts)') - }) - }) - - describe('socketDevLink', () => { - it('creates basic Socket.dev link', () => { - const result = socketDevLink() - expect(result).toBe('[Socket.dev](https://socket.dev)') - }) - - it('creates Socket.dev link with custom text', () => { - const result = socketDevLink('Visit Socket') - expect(result).toBe('[Visit Socket](https://socket.dev)') - }) - - it('creates Socket.dev link with path', () => { - const result = socketDevLink('Pricing', '/pricing') - expect(result).toBe('[Pricing](https://socket.dev/pricing)') - }) - - it('creates Socket.dev link with default text and path', () => { - const result = socketDevLink(undefined, '/about') - expect(result).toBe('[Socket.dev](https://socket.dev/about)') - }) - }) - - describe('socketDocsLink', () => { - it('creates docs link with leading slash', () => { - const result = socketDocsLink('/docs/api-keys') - expect(result).toBe( - '[https://docs.socket.dev/docs/api-keys](https://docs.socket.dev/docs/api-keys)', - ) - }) - - it('creates docs link without leading slash', () => { - const result = socketDocsLink('docs/cli-reference') - expect(result).toBe( - '[https://docs.socket.dev/docs/cli-reference](https://docs.socket.dev/docs/cli-reference)', - ) - }) - - it('uses custom text when provided', () => { - const result = socketDocsLink('/docs/getting-started', 'Get Started') - expect(result).toBe( - '[Get Started](https://docs.socket.dev/docs/getting-started)', - ) - }) - }) - - describe('socketPackageLink', () => { - it('creates basic package link', () => { - const result = socketPackageLink('npm', 'express') - expect(result).toBe( - '[https://socket.dev/npm/package/express](https://socket.dev/npm/package/express)', - ) - }) - - it('creates package link with version', () => { - const result = socketPackageLink('npm', 'express', '4.18.0') - expect(result).toBe( - '[https://socket.dev/npm/package/express/overview/4.18.0](https://socket.dev/npm/package/express/overview/4.18.0)', - ) - }) - - it('creates package link with path in version', () => { - const result = socketPackageLink( - 'npm', - 'express', - 'files/4.18.0/CHANGELOG.md', - ) - expect(result).toBe( - '[https://socket.dev/npm/package/express/files/4.18.0/CHANGELOG.md](https://socket.dev/npm/package/express/files/4.18.0/CHANGELOG.md)', - ) - }) - - it('uses custom text when provided', () => { - const result = socketPackageLink( - 'npm', - 'lodash', - '4.17.21', - 'View Lodash', - ) - expect(result).toBe( - '[View Lodash](https://socket.dev/npm/package/lodash/overview/4.17.21)', - ) - }) - - it('handles scoped packages', () => { - const result = socketPackageLink('npm', '@babel/core') - expect(result).toBe( - '[https://socket.dev/npm/package/@babel/core](https://socket.dev/npm/package/@babel/core)', - ) - }) - }) - - describe('githubRepoLink', () => { - it('creates basic GitHub repo link', () => { - const result = githubRepoLink('SocketDev', 'socket-cli') - expect(result).toBe( - '[SocketDev/socket-cli](https://github.com/SocketDev/socket-cli)', - ) - }) - - it('creates GitHub repo link with path', () => { - const result = githubRepoLink( - 'SocketDev', - 'socket-cli', - 'blob/main/README.md', - ) - expect(result).toBe( - '[SocketDev/socket-cli](https://github.com/SocketDev/socket-cli/blob/main/README.md)', - ) - }) - - it('creates GitHub repo link with custom text', () => { - const result = githubRepoLink('SocketDev', 'socket-cli', undefined, 'CLI') - expect(result).toBe('[CLI](https://github.com/SocketDev/socket-cli)') - }) - - it('creates GitHub repo link with path and custom text', () => { - const result = githubRepoLink( - 'SocketDev', - 'socket-cli', - 'releases', - 'View Releases', - ) - expect(result).toBe( - '[View Releases](https://github.com/SocketDev/socket-cli/releases)', - ) - }) - }) - - describe('webLink', () => { - it('creates web link', () => { - const result = webLink('https://example.com') - expect(result).toBe('[https://example.com](https://example.com)') - }) - - it('uses custom text when provided', () => { - const result = webLink('https://example.com/page', 'Example Page') - expect(result).toBe('[Example Page](https://example.com/page)') - }) - - it('handles complex URLs', () => { - const url = 'https://example.com/path?query=value&other=123#section' - const result = webLink(url) - expect(result).toBe(`[${url}](${url})`) - }) - }) -}) diff --git a/packages/cli/test/unit/util/update/checker.test.mts b/packages/cli/test/unit/util/update/checker.test.mts deleted file mode 100644 index 5785acf2d..000000000 --- a/packages/cli/test/unit/util/update/checker.test.mts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * Unit tests for Update checker utilities. - * - * Purpose: Tests the update checking functionality for Socket CLI. - * - * Test Coverage: - isUpdateAvailable function - checkForUpdates function - - * NetworkUtils.fetch function - NetworkUtils.getLatestVersion function - Error - * handling and retries. - * - * Related Files: - util/update/checker.mts (implementation) - */ - -import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock https module. -const mockRequest = vi.hoisted(() => vi.fn()) -vi.mock('node:https', () => ({ - default: { - request: mockRequest, - }, - request: mockRequest, -})) - -// Mock signal-exit. -vi.mock('@socketsecurity/lib-stable/events/exit/handler', () => ({ - onExit: vi.fn(() => () => {}), -})) - -// Mock logger. -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => ({ - log: vi.fn(), - warn: vi.fn(), - }), -})) - -import { - NetworkUtils, - checkForUpdates, - isUpdateAvailable, -} from '../../../../src/util/update/checker.mts' - -// Helper types. -interface MockResponse extends EventEmitter { - statusCode: number - statusMessage: string - headers: Record<string, string> -} - -interface MockRequest extends EventEmitter { - destroy: () => void - end: () => void -} - -// Helper to create mock request. -function createMockRequest(): MockRequest { - const req = new EventEmitter() as MockRequest - req.destroy = vi.fn() - req.end = vi.fn() - return req -} - -// Helper to create mock response. -function createMockResponse( - statusCode: number, - headers: Record<string, string> = { 'content-type': 'application/json' }, -): MockResponse { - const res = new EventEmitter() as MockResponse - res.statusCode = statusCode - res.statusMessage = statusCode === 200 ? 'OK' : 'Error' - res.headers = headers - return res -} - -describe('update/checker', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useRealTimers() - }) - - describe('isUpdateAvailable', () => { - it('returns true when latest is greater than current', () => { - expect(isUpdateAvailable('1.0.0', '2.0.0')).toBe(true) - }) - - it('returns true when latest is a minor update', () => { - expect(isUpdateAvailable('1.0.0', '1.1.0')).toBe(true) - }) - - it('returns true when latest is a patch update', () => { - expect(isUpdateAvailable('1.0.0', '1.0.1')).toBe(true) - }) - - it('returns false when versions are equal', () => { - expect(isUpdateAvailable('1.0.0', '1.0.0')).toBe(false) - }) - - it('returns false when current is greater than latest', () => { - expect(isUpdateAvailable('2.0.0', '1.0.0')).toBe(false) - }) - - it('handles versions with v prefix', () => { - expect(isUpdateAvailable('v1.0.0', 'v2.0.0')).toBe(true) - }) - - it('handles prerelease versions', () => { - expect(isUpdateAvailable('1.0.0-beta.1', '1.0.0')).toBe(true) - }) - - it('falls back to string comparison for invalid versions', () => { - expect(isUpdateAvailable('invalid', 'invalid')).toBe(false) - expect(isUpdateAvailable('invalid', 'different')).toBe(true) - }) - }) - - describe('NetworkUtils.fetch', () => { - it('throws error for empty URL', async () => { - await expect(NetworkUtils.fetch('')).rejects.toThrow( - /NetworkUtils\.fetch\(url\) requires a non-empty string/, - ) - }) - - it('fetches data successfully', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '1.2.3' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - const result = await NetworkUtils.fetch('https://registry.npmjs.org/test') - - expect(result).toEqual({ version: '1.2.3' }) - expect(mockReq.end).toHaveBeenCalled() - }) - - it('includes authorization header when authInfo provided', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '1.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await NetworkUtils.fetch('https://registry.npmjs.org/test', { - authInfo: { token: 'test-token', type: 'Bearer' }, - }) - - const callOptions = mockRequest.mock.calls[0]?.[0] - expect(callOptions.headers.Authorization).toBe('Bearer test-token') - }) - - it('rejects on HTTP error status', async () => { - const mockRes = createMockResponse(404) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ error: 'Not found' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await expect( - NetworkUtils.fetch('https://registry.npmjs.org/nonexistent'), - ).rejects.toThrow('HTTP 404') - }) - - it('rejects on invalid JSON response', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', 'not valid json') - mockRes.emit('end') - }) - }) - return mockReq - }) - - await expect( - NetworkUtils.fetch('https://registry.npmjs.org/test'), - ).rejects.toThrow('Failed to parse JSON response') - }) - - it('rejects on network error', async () => { - const mockReq = createMockRequest() - - mockRequest.mockImplementation(() => { - process.nextTick(() => - mockReq.emit('error', new Error('Connection refused')), - ) - return mockReq - }) - - await expect( - NetworkUtils.fetch('https://registry.npmjs.org/test'), - ).rejects.toThrow('Network request failed: Connection refused') - }) - - it('rejects on timeout', async () => { - const mockReq = createMockRequest() - - mockRequest.mockImplementation(() => { - process.nextTick(() => mockReq.emit('timeout')) - return mockReq - }) - - await expect( - NetworkUtils.fetch('https://registry.npmjs.org/test', {}, 1000), - ).rejects.toThrow('Request timed out after 1000ms') - - expect(mockReq.destroy).toHaveBeenCalled() - }) - - it('rejects when JSON response is not an object', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', '"just a string"') - mockRes.emit('end') - }) - }) - return mockReq - }) - - await expect( - NetworkUtils.fetch('https://registry.npmjs.org/test'), - ).rejects.toThrow('Invalid JSON response from registry') - }) - }) - - describe('NetworkUtils.getLatestVersion', () => { - it('throws error for empty package name', async () => { - await expect(NetworkUtils.getLatestVersion('')).rejects.toThrow( - /getLatestVersion\(name\) requires a non-empty string/, - ) - }) - - it('throws error for invalid registry URL', async () => { - await expect( - NetworkUtils.getLatestVersion('test', { registryUrl: 'not-a-url' }), - ).rejects.toThrow(/options\.registryUrl "not-a-url" is not a valid URL/) - }) - - it('throws when registryUrl is explicit empty string (line 222-226)', async () => { - await expect( - NetworkUtils.getLatestVersion('test', { registryUrl: '' }), - ).rejects.toThrow( - /getLatestVersion options\.registryUrl must be a non-empty string/, - ) - }) - - it('returns latest version on success', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '2.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - const result = await NetworkUtils.getLatestVersion('test-package') - - expect(result).toBe('2.0.0') - }) - - it('uses custom registry URL', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '1.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await NetworkUtils.getLatestVersion('test', { - registryUrl: 'https://custom.registry.com', - }) - - const callOptions = mockRequest.mock.calls[0]?.[0] - expect(callOptions.hostname).toBe('custom.registry.com') - }) - - it('throws error when version is missing from response', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ name: 'test' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await expect( - NetworkUtils.getLatestVersion('test-package'), - ).rejects.toThrow(/responded without a \.version string/) - }) - }) - - describe('checkForUpdates', () => { - it('throws error for empty package name', async () => { - await expect( - checkForUpdates({ name: '', version: '1.0.0' }), - ).rejects.toThrow( - /checkForUpdates options\.name requires a non-empty string/, - ) - }) - - it('throws error for empty version', async () => { - await expect( - checkForUpdates({ name: 'test', version: '' }), - ).rejects.toThrow( - /checkForUpdates options\.version requires a non-empty string/, - ) - }) - - it('returns update check result when update is available', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '2.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - const result = await checkForUpdates({ - name: 'test-package', - version: '1.0.0', - }) - - expect(result).toEqual({ - current: '1.0.0', - latest: '2.0.0', - updateAvailable: true, - }) - }) - - it('returns update check result when no update is available', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '1.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - const result = await checkForUpdates({ - name: 'test-package', - version: '1.0.0', - }) - - expect(result).toEqual({ - current: '1.0.0', - latest: '1.0.0', - updateAvailable: false, - }) - }) - - it('passes authInfo to network request', async () => { - const mockRes = createMockResponse(200) - const mockReq = createMockRequest() - - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({ version: '1.0.0' })) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await checkForUpdates({ - name: 'test-package', - version: '1.0.0', - authInfo: { token: 'test-token', type: 'Bearer' }, - }) - - const callOptions = mockRequest.mock.calls[0]?.[0] - expect(callOptions.headers.Authorization).toBe('Bearer test-token') - }) - - it('throws error when registry fetch fails', async () => { - const mockReq = createMockRequest() - - mockRequest.mockImplementation(() => { - process.nextTick(() => - mockReq.emit('error', new Error('Network error')), - ) - return mockReq - }) - - await expect( - checkForUpdates({ name: 'test-package', version: '1.0.0' }), - ).rejects.toThrow() - }) - - it('throws when registry returns no version field', async () => { - // Make getLatestVersion's parse path return JSON with version: ''. - // After 3 retry attempts the inner throw escapes. - const mockReq = createMockRequest() - mockRequest.mockImplementation((_options, callback) => { - process.nextTick(() => { - const mockRes = createMockResponse(200) - callback(mockRes) - process.nextTick(() => { - mockRes.emit('data', JSON.stringify({})) - mockRes.emit('end') - }) - }) - return mockReq - }) - - await expect( - checkForUpdates({ name: 'test-package', version: '1.0.0' }), - ).rejects.toThrow() - }) - }) -}) diff --git a/packages/cli/test/unit/util/update/manager.test.mts b/packages/cli/test/unit/util/update/manager.test.mts deleted file mode 100644 index 804f92b03..000000000 --- a/packages/cli/test/unit/util/update/manager.test.mts +++ /dev/null @@ -1,570 +0,0 @@ -/* max-file-lines: legitimate — comprehensive test suite for one command/module; splitting would fragment closely related assertions. */ -/** - * Unit tests for update manager utilities. - * - * Purpose: Tests the update manager for npm/pnpm/yarn installations. - * - * Test Coverage: - checkForUpdates function - scheduleUpdateCheck function - - * Parameter validation - Cache handling. - * - * Related Files: - src/util/update/manager.mts (implementation) - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock dlx manifest. -const mockDlxManifest = vi.hoisted(() => ({ - get: vi.fn(), - set: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/dlx/manifest', () => ({ - dlxManifest: mockDlxManifest, -})) - -// Mock checker. -const mockPerformUpdateCheck = vi.hoisted(() => - vi.fn().mockResolvedValue({ - current: '1.0.0', - latest: '2.0.0', - updateAvailable: true, - }), -) -vi.mock('../../../../src/util/update/checker.mts', () => ({ - checkForUpdates: mockPerformUpdateCheck, -})) - -// Mock notifier. -const mockShowUpdateNotification = vi.hoisted(() => vi.fn()) -const mockScheduleExitNotification = vi.hoisted(() => vi.fn()) -vi.mock('../../../../src/util/update/notifier.mts', () => ({ - showUpdateNotification: mockShowUpdateNotification, - scheduleExitNotification: mockScheduleExitNotification, -})) - -// Mock SEA detect. -const mockIsSeaBinary = vi.hoisted(() => vi.fn(() => false)) -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - isSeaBinary: mockIsSeaBinary, -})) - -import { - checkForUpdates, - scheduleUpdateCheck, -} from '../../../../src/util/update/manager.mts' - -describe('update manager', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDlxManifest.get.mockReturnValue(undefined) - mockDlxManifest.set.mockResolvedValue(undefined) - mockIsSeaBinary.mockReturnValue(false) - mockPerformUpdateCheck.mockResolvedValue({ - current: '1.0.0', - latest: '2.0.0', - updateAvailable: true, - }) - }) - - describe('checkForUpdates', () => { - describe('parameter validation', () => { - it('returns false for empty package name', async () => { - const result = await checkForUpdates({ - name: '', - version: '1.0.0', - }) - - expect(result).toBe(false) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'checkForUpdates options.name requires a non-empty string', - ), - ) - }) - - it('returns false for empty version', async () => { - const result = await checkForUpdates({ - name: 'socket', - version: '', - }) - - expect(result).toBe(false) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'checkForUpdates options.version requires a non-empty string', - ), - ) - }) - - it('returns false for negative TTL', async () => { - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - ttl: -1, - }) - - expect(result).toBe(false) - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'checkForUpdates options.ttl must be >= 0 (saw: -1)', - ), - ) - }) - - it('warns about invalid auth info but continues', async () => { - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - authInfo: { token: '', type: '' }, - }) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid auth info'), - ) - }) - - it('handles empty registry URL without warning', async () => { - // Empty string is treated as "use default", not invalid. - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - registryUrl: '', - }) - - // Should proceed without warning about registry URL. - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - }) - - describe('cache handling', () => { - it('uses fresh cache and skips fetch', async () => { - // Set up fresh cache. - mockDlxManifest.get.mockReturnValue({ - timestampFetch: Date.now() - 1000, // 1 second ago (fresh). - version: '1.0.0', - }) - - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - ttl: 60_000, // 1 minute. - }) - - expect(result).toBe(false) // Same version. - expect(mockPerformUpdateCheck).not.toHaveBeenCalled() - }) - - it('fetches when cache is stale', async () => { - // Set up stale cache. - mockDlxManifest.get.mockReturnValue({ - timestampFetch: Date.now() - 120_000, // 2 minutes ago (stale). - version: '1.0.0', - }) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - ttl: 60_000, // 1 minute. - }) - - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - - it('fetches when no cache exists', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - - it('updates cache after successful fetch', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockDlxManifest.set).toHaveBeenCalledWith( - 'socket@1.0.0', - expect.objectContaining({ - version: '2.0.0', - }), - ) - }) - }) - - describe('notifications', () => { - it('shows immediate notification when update available', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - immediate: true, - }) - - expect(mockShowUpdateNotification).toHaveBeenCalledWith({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - }) - - it('schedules exit notification when not immediate', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - immediate: false, - }) - - expect(mockScheduleExitNotification).toHaveBeenCalledWith({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - }) - - it('does not notify when no update available', async () => { - mockPerformUpdateCheck.mockResolvedValue({ - current: '1.0.0', - latest: '1.0.0', - updateAvailable: false, - }) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockShowUpdateNotification).not.toHaveBeenCalled() - expect(mockScheduleExitNotification).not.toHaveBeenCalled() - }) - }) - - describe('error handling', () => { - it('uses cached version when fetch fails', async () => { - mockDlxManifest.get.mockReturnValue({ - timestampFetch: Date.now() - 120_000, // Stale. - version: '1.5.0', - }) - mockPerformUpdateCheck.mockRejectedValue(new Error('Network error')) - - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - ttl: 60_000, - }) - - expect(result).toBe(true) // 1.0.0 !== 1.5.0. - }) - - it('returns false when fetch fails and no cache', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - mockPerformUpdateCheck.mockRejectedValue(new Error('Network error')) - - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(result).toBe(false) - expect(mockLogger.log).toHaveBeenCalledWith( - 'No version information available', - ) - }) - - it('handles cache access errors', async () => { - mockDlxManifest.get.mockImplementation(() => { - throw new Error('Cache read error') - }) - - // Should not throw. - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to access cache'), - ) - // Should still try to fetch. - expect(mockPerformUpdateCheck).toHaveBeenCalled() - expect(result).toBe(true) - }) - - it('handles cache update errors gracefully', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - mockDlxManifest.set.mockRejectedValue(new Error('Cache write error')) - - // Should not throw. - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to update cache'), - ) - expect(result).toBe(true) - }) - - it('handles notification setup errors gracefully', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - mockShowUpdateNotification.mockImplementation(() => { - throw new Error('Notification error') - }) - - // Should not throw. - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - immediate: true, - }) - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to set up notification'), - ) - expect(result).toBe(true) - }) - }) - - describe('registry URL handling', () => { - it('normalizes registry URL in cache key', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - registryUrl: 'https://registry.npmjs.org', - }) - - expect(mockDlxManifest.set).toHaveBeenCalledWith( - expect.stringContaining(':https://registry.npmjs.org/'), - expect.any(Object), - ) - }) - - it('handles invalid registry URL gracefully', async () => { - mockDlxManifest.get.mockReturnValue(undefined) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - registryUrl: 'not-a-valid-url', - }) - - // Should use the raw string when URL parsing fails. - expect(mockDlxManifest.set).toHaveBeenCalledWith( - 'socket@1.0.0:not-a-valid-url', - expect.any(Object), - ) - }) - }) - }) - - describe('invalid system time handling', () => { - it('uses cached data when timestamp is invalid and cache is fresh', async () => { - // Set up valid cache with a future timestamp for comparison. - mockDlxManifest.get.mockReturnValue({ - timestampFetch: Date.now() + 10_000, - version: '2.0.0', - }) - - // We can't actually mock Date.now() easily, but we can test the - // scenario where cache exists but system time is wrong by - // verifying the checkForUpdates function behavior. - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - // Cache is fresh (timestampFetch > current time), so no fetch. - expect(mockPerformUpdateCheck).not.toHaveBeenCalled() - // Update available because 1.0.0 !== 2.0.0. - expect(result).toBe(true) - }) - - it('handles cache with invalid timestamp data', async () => { - // Cache with no timestampFetch. - mockDlxManifest.get.mockReturnValue({ - version: '2.0.0', - }) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - // Should fetch because cache has no valid timestampFetch. - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - - it('handles cache with zero timestampFetch', async () => { - mockDlxManifest.get.mockReturnValue({ - timestampFetch: 0, - version: '2.0.0', - }) - - await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - - // Should fetch because timestampFetch is 0. - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - - it('uses cached data when system time is broken (Date.now <= 0)', async () => { - mockDlxManifest.get.mockReturnValueOnce({ - timestampFetch: 1_000_000, - version: '2.0.0', - }) - const realNow = Date.now - Date.now = () => 0 - - try { - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - immediate: true, - }) - expect(result).toBe(true) - expect(mockShowUpdateNotification).toHaveBeenCalled() - } finally { - Date.now = realNow - } - }) - - it('schedules exit notification when system time is broken and not immediate', async () => { - mockDlxManifest.get.mockReturnValueOnce({ - timestampFetch: 1_000_000, - version: '2.0.0', - }) - const realNow = Date.now - Date.now = () => 0 - - try { - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - immediate: false, - }) - expect(result).toBe(true) - expect(mockScheduleExitNotification).toHaveBeenCalled() - } finally { - Date.now = realNow - } - }) - - it('returns false when system time is broken AND cache has no version', async () => { - mockDlxManifest.get.mockReturnValueOnce({ - timestampFetch: 1_000_000, - version: '', - }) - const realNow = Date.now - Date.now = () => 0 - - try { - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - expect(result).toBe(false) - } finally { - Date.now = realNow - } - }) - - it('returns false when system time is broken AND no cache exists', async () => { - mockDlxManifest.get.mockReturnValueOnce(undefined) - const realNow = Date.now - Date.now = () => 0 - - try { - const result = await checkForUpdates({ - name: 'socket', - version: '1.0.0', - }) - expect(result).toBe(false) - } finally { - Date.now = realNow - } - }) - }) - - describe('scheduleUpdateCheck', () => { - it('skips update check for SEA binaries', async () => { - mockIsSeaBinary.mockReturnValue(true) - - await scheduleUpdateCheck({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockPerformUpdateCheck).not.toHaveBeenCalled() - }) - - it('performs update check for npm installations', async () => { - mockIsSeaBinary.mockReturnValue(false) - - await scheduleUpdateCheck({ - name: 'socket', - version: '1.0.0', - }) - - expect(mockPerformUpdateCheck).toHaveBeenCalled() - }) - - it('sets immediate to false', async () => { - mockIsSeaBinary.mockReturnValue(false) - mockDlxManifest.get.mockReturnValue(undefined) - - await scheduleUpdateCheck({ - name: 'socket', - version: '1.0.0', - immediate: true, // Should be overridden. - }) - - // Should schedule exit notification, not show immediately. - expect(mockScheduleExitNotification).toHaveBeenCalled() - expect(mockShowUpdateNotification).not.toHaveBeenCalled() - }) - - it('handles errors silently', async () => { - mockIsSeaBinary.mockReturnValue(false) - mockPerformUpdateCheck.mockRejectedValue(new Error('Fatal error')) - - // Should not throw. - await expect( - scheduleUpdateCheck({ - name: 'socket', - version: '1.0.0', - }), - ).resolves.not.toThrow() - - // When fetch fails and no cache, logs about no version info. - expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining('No version information available'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/update/notifier.test.mts b/packages/cli/test/unit/util/update/notifier.test.mts deleted file mode 100644 index d3b8e4bd3..000000000 --- a/packages/cli/test/unit/util/update/notifier.test.mts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Unit tests for update notifier utilities. - * - * Purpose: Tests the update notification formatting and display. - * - * Test Coverage: - formatUpdateMessage function - showUpdateNotification - * function - scheduleExitNotification function. - * - * Related Files: - src/util/update/notifier.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock logger. -const mockLogger = vi.hoisted(() => ({ - log: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - fail: vi.fn(), - success: vi.fn(), - info: vi.fn(), -})) -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, -})) - -// Mock signal-exit. -const mockOnExit = vi.hoisted(() => vi.fn()) -vi.mock('@socketsecurity/lib-stable/events/exit/handler', () => ({ - onExit: mockOnExit, -})) - -// Mock SEA detect. -const mockGetSeaBinaryPath = vi.hoisted(() => vi.fn(() => '')) -vi.mock('../../../../src/util/sea/detect.mts', () => ({ - getSeaBinaryPath: mockGetSeaBinaryPath, -})) - -// Mock terminal link utilities. -vi.mock('../../../../src/util/terminal/link.mts', () => ({ - githubRepoLink: ( - org: string, - repo: string, - path: string, - text: string, - ): string => `https://github.com/${org}/${repo}/${path} (${text})`, - socketPackageLink: ( - ecosystem: string, - name: string, - path: string, - text: string, - ): string => - `https://socket.dev/${ecosystem}/package/${name}/${path} (${text})`, -})) - -import { - formatUpdateMessage, - scheduleExitNotification, - showUpdateNotification, -} from '../../../../src/util/update/notifier.mts' - -describe('update notifier', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSeaBinaryPath.mockReturnValue('') - }) - - describe('formatUpdateMessage', () => { - it('formats update message for npm installation', () => { - const result = formatUpdateMessage({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(result.message).toContain('socket') - expect(result.message).toContain('1.0.0') - expect(result.message).toContain('2.0.0') - expect(result.command).toBeUndefined() - expect(result.changelog).toContain('socket.dev') - }) - - it('formats update message for SEA binary', () => { - mockGetSeaBinaryPath.mockReturnValue('/usr/local/bin/socket') - - const result = formatUpdateMessage({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(result.message).toContain('socket') - expect(result.command).toContain('/usr/local/bin/socket') - expect(result.command).toContain('self-update') - expect(result.changelog).toContain('github.com') - }) - - it('includes changelog link for npm', () => { - const result = formatUpdateMessage({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(result.changelog).toContain('CHANGELOG.md') - expect(result.changelog).toContain('2.0.0') - }) - - it('includes changelog link for SEA', () => { - mockGetSeaBinaryPath.mockReturnValue('/usr/local/bin/socket') - - const result = formatUpdateMessage({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(result.changelog).toContain('CHANGELOG.md') - expect(result.changelog).toContain('SocketDev') - expect(result.changelog).toContain('socket-cli') - }) - }) - - describe('showUpdateNotification', () => { - const originalIsTTY = process.stdout?.isTTY - - beforeEach(() => { - // Mock TTY. - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }) - }) - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }) - }) - - it('shows notification when TTY is available', () => { - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(mockLogger.log).toHaveBeenCalled() - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('socket') - }) - - it('does not show notification when not TTY', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }) - - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(mockLogger.log).not.toHaveBeenCalled() - }) - - it('shows command for SEA binary', () => { - mockGetSeaBinaryPath.mockReturnValue('/usr/local/bin/socket') - - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('self-update') - }) - - it('shows changelog link', () => { - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('CHANGELOG.md') - }) - - it('handles formatting errors gracefully with npm installation', () => { - // First call throws, subsequent calls succeed. - let callCount = 0 - mockLogger.log.mockImplementation(() => { - callCount++ - if (callCount === 1) { - throw new Error('Formatting error') - } - }) - - // Should not throw. - expect(() => - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }), - ).not.toThrow() - - // Fallback message should be shown. - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('socket') - }) - - it('handles formatting errors gracefully with SEA binary', () => { - mockGetSeaBinaryPath.mockReturnValue('/usr/local/bin/socket') - - // First call throws, subsequent calls succeed. - let callCount = 0 - mockLogger.log.mockImplementation(() => { - callCount++ - if (callCount === 1) { - throw new Error('Formatting error') - } - }) - - // Should not throw. - expect(() => - showUpdateNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }), - ).not.toThrow() - - // Fallback message with self-update command should be shown. - const calls = mockLogger.log.mock.calls.map(c => c[0]).join('\n') - expect(calls).toContain('socket') - }) - }) - - describe('scheduleExitNotification', () => { - const originalIsTTY = process.stdout?.isTTY - - beforeEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }) - }) - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }) - }) - - it('schedules exit notification when TTY', () => { - scheduleExitNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(mockOnExit).toHaveBeenCalledWith(expect.any(Function)) - }) - - it('invokes the registered notificationLogger callback', () => { - // Capture the callback registered with onExit and run it to exercise - // the inner arrow function (line 135). - let registered: (() => void) | undefined - mockOnExit.mockImplementationOnce((cb: () => void) => { - registered = cb - }) - - scheduleExitNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(registered).toBeTypeOf('function') - registered!() - expect(mockLogger.log).toHaveBeenCalled() - }) - - it('does not schedule when not TTY', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }) - - scheduleExitNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }) - - expect(mockOnExit).not.toHaveBeenCalled() - }) - - it('handles onExit errors gracefully', () => { - mockOnExit.mockImplementation(() => { - throw new Error('Failed to register') - }) - - // Should not throw. - expect(() => - scheduleExitNotification({ - name: 'socket', - current: '1.0.0', - latest: '2.0.0', - }), - ).not.toThrow() - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to schedule exit notification'), - ) - }) - }) -}) diff --git a/packages/cli/test/unit/util/validation/check-input.test.mts b/packages/cli/test/unit/util/validation/check-input.test.mts deleted file mode 100644 index ff76dfe1a..000000000 --- a/packages/cli/test/unit/util/validation/check-input.test.mts +++ /dev/null @@ -1,396 +0,0 @@ - -/** - * Unit tests for input validation. - * - * Purpose: Tests user input validation utilities. Validates input sanitization - * and type checking. - * - * Test Coverage: - Input sanitization - Type validation - Format checking - - * Length limits - Allowlist/denylist - Error messages. - * - * Testing Approach: Tests input validation for security and UX. - * - * Related Files: - util/validation/check-input.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - OUTPUT_JSON, - OUTPUT_MARKDOWN, - OUTPUT_TEXT, -} from '../../../../src/constants/cli.mts' -import { checkCommandInput } from '../../../../src/util/validation/check-input.mts' - -// Mock dependencies. -vi.mock('yoctocolors-cjs', () => ({ - default: { - bgRedBright: vi.fn(str => `bgRedBright(${str})`), - bold: vi.fn(str => `bold(${str})`), - green: vi.fn(str => `green(${str})`), - red: vi.fn(str => `red(${str})`), - }, -})) - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('@socketsecurity/lib-stable/logger/symbols', () => ({ - LOG_SYMBOLS: { - success: '✓', - fail: '✗', - }, -})) - -vi.mock('@socketsecurity/lib-stable/ansi/strip', () => ({ - stripAnsi: vi.fn(str => str), -})) - -vi.mock('../../../../src/util/error/fail-msg-with-badge.mts', () => ({ - failMsgWithBadge: vi.fn((title, msg) => `${title}: ${msg}`), -})) - -vi.mock('../../../../src/util/output/result-json.mts', () => ({ - serializeResultJson: vi.fn(result => JSON.stringify(result)), -})) - -describe('checkCommandInput', () => { - let originalExitCode: number | undefined - - beforeEach(() => { - vi.clearAllMocks() - // Save original exit code. - originalExitCode = process.exitCode - process.exitCode = undefined - }) - - afterEach(() => { - // Restore original exit code. - process.exitCode = originalExitCode - }) - - describe('when all checks pass', () => { - it('returns true and does not set exit code', () => { - const result = checkCommandInput( - OUTPUT_TEXT, - { - test: true, - fail: 'Failed', - message: 'Check 1', - }, - { - test: true, - fail: 'Failed', - message: 'Check 2', - }, - ) - - expect(result).toBe(true) - expect(process.exitCode).toBeUndefined() - }) - - it('returns true for json output kind', () => { - const result = checkCommandInput(OUTPUT_JSON, { - test: true, - fail: 'Failed', - message: 'Check 1', - }) - - expect(result).toBe(true) - expect(process.exitCode).toBeUndefined() - }) - - it('returns true for markdown output kind', () => { - const result = checkCommandInput(OUTPUT_MARKDOWN, { - test: true, - fail: 'Failed', - message: 'Check 1', - }) - - expect(result).toBe(true) - expect(process.exitCode).toBeUndefined() - }) - }) - - describe('when some checks fail', () => { - it('returns false and sets exit code to 2', async () => { - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - const result = checkCommandInput( - OUTPUT_TEXT, - { - test: false, - fail: 'Missing file', - message: 'File must exist', - }, - { - test: true, - fail: 'Failed', - message: 'Check 2', - pass: 'Passed', - }, - ) - - expect(result).toBe(false) - expect(process.exitCode).toBe(2) - expect(failMsgWithBadge).toHaveBeenCalledWith( - 'Input error', - expect.stringContaining('✗ File must exist (red(Missing file))'), - ) - expect(failMsgWithBadge).toHaveBeenCalledWith( - 'Input error', - expect.stringContaining('✓ Check 2 (green(Passed))'), - ) - expect(mockLogger.fail).toHaveBeenCalled() - }) - - it('handles json output kind', async () => { - vi.mocked(await import('@socketsecurity/lib-stable/logger')) - const { serializeResultJson } = vi.mocked( - await import('../../../../src/util/output/result-json.mts'), - ) - - const result = checkCommandInput(OUTPUT_JSON, { - test: false, - fail: 'Invalid input', - message: 'Input validation failed', - }) - - expect(result).toBe(false) - expect(process.exitCode).toBe(2) - expect(serializeResultJson).toHaveBeenCalledWith({ - ok: false, - message: 'Input error', - data: expect.stringContaining('✗ Input validation failed'), - }) - expect(mockLogger.log).toHaveBeenCalled() - }) - }) - - describe('message formatting', () => { - it('handles multi-line messages', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput(OUTPUT_TEXT, { - test: false, - fail: 'Error', - message: 'First line\nSecond line\nThird line', - }) - - expect(failMsgWithBadge).toHaveBeenCalledWith( - 'Input error', - expect.stringContaining( - '✗ First line (red(Error))\n Second line\n Third line', - ), - ) - }) - - it('handles empty messages', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput( - OUTPUT_TEXT, - { - test: false, - fail: 'Error', - message: '', - }, - { - test: false, - fail: 'Another error', - message: 'Valid message', - }, - ) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).not.toContain('✗ ') - expect(callArg).toContain('✗ Valid message') - }) - - it('handles messages without fail/pass reasons', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput( - OUTPUT_TEXT, - { - test: false, - fail: '', - message: 'Check failed', - }, - { - test: true, - pass: '', - fail: '', - message: 'Check passed', - }, - ) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).toContain('✗ Check failed') - expect(callArg).toContain('✓ Check passed') - expect(callArg).not.toContain('()') - }) - }) - - describe('nook behavior', () => { - it('skips checks where nook is true and test passes', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput( - OUTPUT_TEXT, - { - test: true, - fail: 'Should not appear', - message: 'This check is skipped', - nook: true, - }, - { - test: false, - fail: 'This appears', - message: 'This check is included', - }, - ) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).not.toContain('This check is skipped') - expect(callArg).toContain('This check is included') - }) - - it('includes checks where nook is true but test fails', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput(OUTPUT_TEXT, { - test: false, - fail: 'Should appear', - message: 'This check failed', - nook: true, - }) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).toContain('This check failed') - expect(callArg).toContain('Should appear') - }) - - it('handles nook as undefined', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput( - OUTPUT_TEXT, - { - test: true, - fail: 'Failed', - message: 'Normal check', - pass: 'Passed', - nook: undefined, - }, - { - test: false, - fail: 'Failed', - message: 'Failed check', - }, - ) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).toContain('✓ Normal check (green(Passed))') - expect(callArg).toContain('✗ Failed check (red(Failed))') - }) - }) - - describe('edge cases', () => { - it('handles empty array of checks', () => { - const result = checkCommandInput(OUTPUT_TEXT) - - expect(result).toBe(true) - expect(process.exitCode).toBeUndefined() - }) - - it('handles all passing checks with various output kinds', () => { - const checks = [ - { - test: true, - fail: 'Failed', - message: 'Check 1', - }, - ] - - expect(checkCommandInput(OUTPUT_TEXT, ...checks)).toBe(true) - expect(checkCommandInput(OUTPUT_JSON, ...checks)).toBe(true) - expect(checkCommandInput(OUTPUT_MARKDOWN, ...checks)).toBe(true) - }) - - it('strips ANSI codes for JSON output', async () => { - const { stripAnsi } = vi.mocked( - await import('@socketsecurity/lib-stable/ansi/strip'), - ) - const { serializeResultJson } = vi.mocked( - await import('../../../../src/util/output/result-json.mts'), - ) - - stripAnsi.mockReturnValue('Stripped message') - - checkCommandInput(OUTPUT_JSON, { - test: false, - fail: 'Failed', - message: 'Message with ANSI', - }) - - expect(stripAnsi).toHaveBeenCalled() - expect(serializeResultJson).toHaveBeenCalledWith( - expect.objectContaining({ - data: 'Stripped message', - }), - ) - }) - }) - - describe('mixed pass and fail checks', () => { - it('handles mixed results correctly', async () => { - const { failMsgWithBadge } = vi.mocked( - await import('../../../../src/util/error/fail-msg-with-badge.mts'), - ) - - checkCommandInput( - OUTPUT_TEXT, - { test: true, fail: 'Failed', message: 'Check 1 passes' }, - { test: false, fail: 'Failed', message: 'Check 2 fails' }, - { - test: true, - fail: 'Failed', - message: 'Check 3 passes', - pass: 'Success', - }, - ) - - const callArg = failMsgWithBadge.mock.calls[0][1] - expect(callArg).toContain('✓ Check 1 passes') - expect(callArg).toContain('✗ Check 2 fails') - expect(callArg).toContain('✓ Check 3 passes (green(Success))') - }) - }) -}) diff --git a/packages/cli/test/unit/util/yarn/paths.test.mts b/packages/cli/test/unit/util/yarn/paths.test.mts deleted file mode 100644 index 9fe406790..000000000 --- a/packages/cli/test/unit/util/yarn/paths.test.mts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Unit tests for yarn path utilities. - * - * Purpose: Tests yarn-specific path utilities. Validates yarn bin path - * resolution. - * - * Test Coverage: - yarn bin path resolution - Path caching - Error handling - * when yarn not found. - * - * Testing Approach: Tests yarn path conventions and resolution logic. - * - * Related Files: - util/yarn/paths.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import type * as PathsModule from '../../../../src/util/yarn/paths.mts' - -// Mock dependencies. -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})) - -const mockFindBinPathDetailsSync = vi.hoisted(() => vi.fn()) - -vi.mock('@socketsecurity/lib-stable/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('../../../../src/util/fs/path-resolve.mts', () => ({ - findBinPathDetailsSync: mockFindBinPathDetailsSync, -})) - -describe('yarn-paths utilities', () => { - let originalExit: typeof process.exit - let getYarnBinPath: (typeof PathsModule)['getYarnBinPath'] - let getYarnBinPathDetails: (typeof PathsModule)['getYarnBinPathDetails'] - - beforeEach(async () => { - vi.clearAllMocks() - vi.resetModules() - - // Store original process.exit. - originalExit = process.exit - // Mock process.exit to prevent actual exits. - process.exit = vi.fn((code?: number) => { - throw new Error(`process.exit(${code})`) - }) as unknown - - // Re-import functions after module reset to clear caches. - const yarnPaths = await import('../../../../src/util/yarn/paths.mts') - getYarnBinPath = yarnPaths.getYarnBinPath - getYarnBinPathDetails = yarnPaths.getYarnBinPathDetails - }) - - afterEach(() => { - // Restore original process.exit. - process.exit = originalExit - vi.resetModules() - }) - - describe('getYarnBinPath', () => { - it('returns yarn bin path when found', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/yarn', - }) - - const result = getYarnBinPath() - - expect(result).toBe('/usr/local/bin/yarn') - expect(mockFindBinPathDetailsSync).toHaveBeenCalledWith('yarn/classic') - }) - - it('exits with error when yarn not found', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - path: undefined, - }) - - expect(() => getYarnBinPath()).toThrow('process.exit(127)') - expect(mockLogger.fail).toHaveBeenCalledWith( - expect.stringContaining('Socket unable to locate yarn'), - ) - }) - - it('caches the result', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/bin/yarn', - }) - - const result1 = getYarnBinPath() - const result2 = getYarnBinPath() - - expect(result1).toBe(result2) - expect(mockFindBinPathDetailsSync).toHaveBeenCalledTimes(1) - }) - - it('handles Windows yarn.cmd path', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - path: 'C:\\Program Files\\Yarn\\bin\\yarn.cmd', - }) - - const result = getYarnBinPath() - - expect(result).toBe('C:\\Program Files\\Yarn\\bin\\yarn.cmd') - }) - - it('handles yarn installed via npm', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - path: '/usr/local/lib/node_modules/.bin/yarn', - }) - - const result = getYarnBinPath() - - expect(result).toBe('/usr/local/lib/node_modules/.bin/yarn') - }) - - it('handles yarn installed via corepack', () => { - mockFindBinPathDetailsSync.mockReturnValue({ - // oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- test fixture: corepack's own canonical install location. - path: '/home/user/.cache/corepack/yarn/1.22.0/bin/yarn', - }) - - const result = getYarnBinPath() - - // oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- test fixture: corepack's own canonical install location. - expect(result).toBe('/home/user/.cache/corepack/yarn/1.22.0/bin/yarn') - }) - }) - - describe('getYarnBinPathDetails', () => { - it('returns full details including path', () => { - const mockDetails = { - path: '/usr/local/bin/yarn', - } - mockFindBinPathDetailsSync.mockReturnValue(mockDetails) - - const result = getYarnBinPathDetails() - - expect(result).toEqual(mockDetails) - expect(mockFindBinPathDetailsSync).toHaveBeenCalledWith('yarn/classic') - }) - - it('caches the result', () => { - const mockDetails = { - path: '/usr/local/bin/yarn', - } - mockFindBinPathDetailsSync.mockReturnValue(mockDetails) - - const result1 = getYarnBinPathDetails() - const result2 = getYarnBinPathDetails() - - expect(result1).toBe(result2) - expect(mockFindBinPathDetailsSync).toHaveBeenCalledTimes(1) - }) - - it('returns details even when path is undefined', () => { - const mockDetails = { - path: undefined, - } - mockFindBinPathDetailsSync.mockReturnValue(mockDetails) - - const result = getYarnBinPathDetails() - - expect(result).toEqual(mockDetails) - }) - - it('returns same object reference when cached', () => { - const mockDetails = { - path: '/usr/local/bin/yarn', - } - mockFindBinPathDetailsSync.mockReturnValue(mockDetails) - - const result1 = getYarnBinPathDetails() - const result2 = getYarnBinPathDetails() - - expect(result1).toBe(result2) // Same reference. - expect(mockFindBinPathDetailsSync).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/cli/test/unit/util/yarn/version.test.mts b/packages/cli/test/unit/util/yarn/version.test.mts deleted file mode 100644 index 0026ce8c6..000000000 --- a/packages/cli/test/unit/util/yarn/version.test.mts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Unit tests for yarn version detection. - * - * Purpose: Tests yarn version detection to determine Classic (1.x) vs Berry - * (2+). - * - * Test Coverage: - isYarnBerry detection - Version parsing - Caching behavior - - * Error handling. - * - * Testing Approach: Mocks spawnSync and getYarnBinPath to simulate different - * yarn versions. - * - * Related Files: - util/yarn/version.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -// Mock dependencies before importing the module under test. -vi.mock('@socketsecurity/lib-stable/process/spawn/child', () => ({ - spawnSync: vi.fn(), -})) - -vi.mock('../../../../src/util/yarn/paths.mts', () => ({ - getYarnBinPath: vi.fn(() => '/usr/bin/yarn'), -})) - -describe('yarn version utilities', () => { - let spawnSyncMock: ReturnType<typeof vi.fn> - let getYarnBinPathMock: ReturnType<typeof vi.fn> - - beforeEach(async () => { - vi.resetModules() - const spawnModule = await import('@socketsecurity/lib-stable/process/spawn/child') - const pathsModule = await import('../../../../src/util/yarn/paths.mts') - spawnSyncMock = spawnModule.spawnSync as ReturnType<typeof vi.fn> - getYarnBinPathMock = pathsModule.getYarnBinPath as ReturnType<typeof vi.fn> - }) - - afterEach(() => { - vi.clearAllMocks() - vi.resetModules() - }) - - describe('isYarnBerry', () => { - it('returns true for yarn 2.x', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '2.4.3\n', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(true) - expect(getYarnBinPathMock).toHaveBeenCalled() - }) - - it('returns true for yarn 3.x', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '3.6.1\n', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(true) - }) - - it('returns true for yarn 4.x', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '4.0.0', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(true) - }) - - it('returns false for yarn 1.x (Classic)', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '1.22.19\n', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('returns false when spawn fails', async () => { - spawnSyncMock.mockReturnValue({ - status: 1, - stdout: '', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('returns false when spawn returns non-zero status', async () => { - spawnSyncMock.mockReturnValue({ - status: 127, - stdout: Buffer.from(''), - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('handles invalid version string', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: 'invalid-version\n', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('handles empty version string', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('returns false when an error is thrown', async () => { - spawnSyncMock.mockImplementation(() => { - throw new Error('spawn failed') - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - const result = isYarnBerry() - - expect(result).toBe(false) - }) - - it('caches result on subsequent calls', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: '3.0.0\n', - }) - - const { isYarnBerry } = - await import('../../../../src/util/yarn/version.mts') - - // Call multiple times. - const result1 = isYarnBerry() - const result2 = isYarnBerry() - const result3 = isYarnBerry() - - expect(result1).toBe(true) - expect(result2).toBe(true) - expect(result3).toBe(true) - - // spawnSync should only be called once due to caching. - expect(spawnSyncMock).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/cli/test/util/scrub-snapshot-data.mts b/packages/cli/test/util/scrub-snapshot-data.mts deleted file mode 100644 index 681ed6576..000000000 --- a/packages/cli/test/util/scrub-snapshot-data.mts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Comprehensive snapshot data scrubbing for consistent cross-environment - * testing. - * - * This utility ensures snapshots don't contain machine-specific, - * time-dependent, or environment-specific data that would cause test failures - * across different systems or time periods. - */ - -interface ScrubOptions { - /** - * Scrub absolute file paths (default: true). - */ - paths?: boolean | undefined - /** - * Scrub timestamps and dates (default: true). - */ - timestamps?: boolean | undefined - /** - * Scrub UUIDs and scan IDs (default: true). - */ - ids?: boolean | undefined - /** - * Scrub version numbers (default: false - usually stable in mocks). - */ - versions?: boolean | undefined - /** - * Scrub IP addresses (default: true). - */ - ipAddresses?: boolean | undefined - /** - * Scrub email addresses (default: false - usually stable in mocks). - */ - emails?: boolean | undefined - /** - * Additional custom scrubbing patterns. - */ - custom?: Array<{ pattern: RegExp; replacement: string }> | undefined -} - -/** - * Scrub snapshot data to remove environment-specific and time-dependent values. - * - * This function applies multiple scrubbing passes to ensure snapshots are - * consistent across different machines, environments, and time periods. - * - * @param output - The string to scrub. - * @param options - Scrubbing options to control what gets scrubbed. - * - * @returns The scrubbed string with environment-specific data replaced - */ -export function scrubSnapshotData( - output: string, - options: ScrubOptions = {}, -): string { - const { - custom = [], - emails = false, - ids = true, - ipAddresses = true, - paths = true, - timestamps = true, - versions = false, - } = options - - let scrubbed = output - - // Phase 1: Timestamps. - if (timestamps) { - // ISO timestamps: 2025-04-02T01:47:26.914Z. - scrubbed = scrubbed.replace( - /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z/g, - '[TIMESTAMP]', - ) - // Date-only: 2025-04-02. - scrubbed = scrubbed.replace(/\d{4}-\d{2}-\d{2}/g, '[DATE]') - // Relative time: "2 days ago", "5 minutes ago". - scrubbed = scrubbed.replace( - /\d+\s+(?:days?|hours?|minutes?|seconds?)\s+ago/g, - '[RELATIVE_TIME]', - ) - } - - // Phase 2: Absolute paths. - if (paths) { - // Project root (use process.cwd()) - must come before user home scrubbing. - const cwd = process.cwd() - scrubbed = scrubbed.replaceAll(cwd, '[PROJECT]') - - // Unix home directories. - scrubbed = scrubbed.replace(/\/Users\/[^/\s]+/g, '/[HOME]') - scrubbed = scrubbed.replace(/\/home\/[^/\s]+/g, '/[HOME]') - - // Windows home directories. - scrubbed = scrubbed.replace(/C:\\Users\\[^\\]+/gi, 'C:\\[HOME]') - - // Temp directories. - scrubbed = scrubbed.replace(/\/tmp\/[a-zA-Z0-9_-]+/g, '/[TEMP]') - scrubbed = scrubbed.replace(/\\Temp\\[a-zA-Z0-9_-]+/gi, '\\[TEMP]') - } - - // Phase 3: IDs and UUIDs. - if (ids) { - // UUIDs. - scrubbed = scrubbed.replace( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, - '[UUID]', - ) - // Scan IDs: scan-123, scan-ai-dee. - scrubbed = scrubbed.replace(/scan-[a-zA-Z0-9-]+/g, 'scan-[ID]') - // Event IDs in JSON: "event_id": "123112". - scrubbed = scrubbed.replace(/"event_id":\s*"(?:\d+)"/g, '"event_id":"[ID]"') - } - - // Phase 4: Version numbers. - if (versions) { - // Node version: v22.11.0. - scrubbed = scrubbed.replace(/v\d+\.\d+\.\d+/g, 'v[VERSION]') - // Package versions: socket@1.1.25. - scrubbed = scrubbed.replace(/socket@\d+\.\d+\.\d+/g, 'socket@[VERSION]') - } - - // Phase 5: IP addresses. - if (ipAddresses) { - // IPv4. - scrubbed = scrubbed.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '[IP]') - // IPv6. - scrubbed = scrubbed.replace(/(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}/gi, '[IP]') - } - - // Phase 6: Email addresses. - if (emails) { - scrubbed = scrubbed.replace( - /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, - '[EMAIL]', - ) - } - - // Phase 7: Custom patterns. - for (const { pattern, replacement } of custom) { - scrubbed = scrubbed.replace(pattern, replacement) - } - - return scrubbed -} - -/** - * Convenience function for scrubbing inline snapshot strings. Wraps - * scrubSnapshotData with sensible defaults. - * - * @param output - The string to scrub. - * - * @returns The scrubbed string with default scrubbing applied - */ -export function toSnapshotString(output: string): string { - return scrubSnapshotData(output, { - emails: false, - versions: false, - }) -} diff --git a/packages/cli/test/util/scrub-snapshot-data.test.mts b/packages/cli/test/util/scrub-snapshot-data.test.mts deleted file mode 100644 index e3ed6fe64..000000000 --- a/packages/cli/test/util/scrub-snapshot-data.test.mts +++ /dev/null @@ -1,247 +0,0 @@ -/* oxlint-disable-next-line socket/no-file-scope-oxlint-disable -- legitimate file-scope: domain-grouped layout or test fixture; per-call would produce many redundant disables. */ -/* oxlint-disable socket/personal-path-placeholders -- "jdalton" / "testuser" are fixture inputs exercising the snapshot scrubber's path/username detection; the rule under test SHOULD scrub these. */ -import { describe, expect, it } from 'vitest' - -import { scrubSnapshotData, toSnapshotString } from './scrub-snapshot-data.mts' - -describe('scrubSnapshotData', () => { - describe('timestamps', () => { - it('should scrub ISO timestamps', () => { - const input = - 'Created at 2025-04-02T01:47:26.914Z and updated 2025-03-31T15:19:55.299Z' - const result = scrubSnapshotData(input) - expect(result).toBe('Created at [TIMESTAMP] and updated [TIMESTAMP]') - }) - - it('should scrub ISO timestamps without milliseconds', () => { - const input = 'Time: 2024-01-01T00:00:00Z' - const result = scrubSnapshotData(input) - expect(result).toBe('Time: [TIMESTAMP]') - }) - - it('should scrub date-only formats', () => { - const input = 'Released on 2025-04-02 and patched 2025-03-31' - const result = scrubSnapshotData(input) - expect(result).toBe('Released on [DATE] and patched [DATE]') - }) - - it('should scrub relative time expressions', () => { - const input = - 'Updated 2 days ago, created 5 minutes ago, and modified 1 hour ago' - const result = scrubSnapshotData(input) - expect(result).toBe( - 'Updated [RELATIVE_TIME], created [RELATIVE_TIME], and modified [RELATIVE_TIME]', - ) - }) - - it('should preserve timestamps when disabled', () => { - const input = 'Created at 2025-04-02T01:47:26.914Z' - const result = scrubSnapshotData(input, { timestamps: false }) - expect(result).toBe('Created at 2025-04-02T01:47:26.914Z') - }) - }) - - describe('paths', () => { - it('should scrub Unix home directories', () => { - const input = - '/Users/testuser/projects/other-project and /Users/anotheruser/documents/file.txt' - const result = scrubSnapshotData(input) - expect(result).toBe( - '/[HOME]/projects/other-project and /[HOME]/documents/file.txt', - ) - }) - - it('should scrub Linux home directories', () => { - const input = '/home/user/project/src' - const result = scrubSnapshotData(input) - expect(result).toBe('/[HOME]/project/src') - }) - - it('should scrub Windows home directories', () => { - const input = - 'C:\\Users\\jdalton\\projects and C:\\Users\\TestUser\\Documents' - const result = scrubSnapshotData(input) - expect(result).toBe('C:\\[HOME]\\projects and C:\\[HOME]\\Documents') - }) - - it('should scrub project root paths', () => { - const cwd = process.cwd() - const input = `Project located at ${cwd}/src/utils` - const result = scrubSnapshotData(input) - expect(result).toBe('Project located at [PROJECT]/src/utils') - }) - - it('should scrub Unix temp directories', () => { - const input = - 'Temp files in /tmp/socket-test-12345 and /tmp/build-abc_123' - const result = scrubSnapshotData(input) - expect(result).toBe('Temp files in /[TEMP] and /[TEMP]') - }) - - it('should scrub Windows temp directories', () => { - const input = 'Temp: \\Temp\\socket-test-456 and \\TEMP\\build-xyz' - const result = scrubSnapshotData(input) - expect(result).toBe('Temp: \\[TEMP] and \\[TEMP]') - }) - - it('should preserve paths when disabled', () => { - const input = '/Users/jdalton/projects/socket-cli' - const result = scrubSnapshotData(input, { paths: false }) - expect(result).toBe('/Users/jdalton/projects/socket-cli') - }) - }) - - describe('IDs and UUIDs', () => { - it('should scrub UUIDs', () => { - const input = - 'ID: 550e8400-e29b-41d4-a716-446655440000 and 123e4567-e89b-12d3-a456-426614174000' - const result = scrubSnapshotData(input) - expect(result).toBe('ID: [UUID] and [UUID]') - }) - - it('should scrub scan IDs', () => { - const input = 'Scans: scan-123, scan-ai-dee, and scan-xyz-789' - const result = scrubSnapshotData(input) - expect(result).toBe('Scans: scan-[ID], scan-[ID], and scan-[ID]') - }) - - it('should scrub event IDs in JSON', () => { - const input = '{"event_id": "123112", "event_id":"456789"}' - const result = scrubSnapshotData(input) - expect(result).toBe('{"event_id":"[ID]", "event_id":"[ID]"}') - }) - - it('should preserve IDs when disabled', () => { - const input = 'ID: 550e8400-e29b-41d4-a716-446655440000' - const result = scrubSnapshotData(input, { ids: false }) - expect(result).toBe('ID: 550e8400-e29b-41d4-a716-446655440000') - }) - }) - - describe('versions', () => { - it('should scrub Node versions when enabled', () => { - const input = 'Node v22.11.0 and v20.10.0' - const result = scrubSnapshotData(input, { versions: true }) - expect(result).toBe('Node v[VERSION] and v[VERSION]') - }) - - it('should scrub Socket CLI versions when enabled', () => { - const input = 'socket@1.1.25 and socket@1.0.80' - const result = scrubSnapshotData(input, { versions: true }) - expect(result).toBe('socket@[VERSION] and socket@[VERSION]') - }) - - it('should preserve versions by default', () => { - const input = 'Node v22.11.0 and socket@1.1.25' - const result = scrubSnapshotData(input) - expect(result).toBe('Node v22.11.0 and socket@1.1.25') - }) - }) - - describe('IP addresses', () => { - it('should scrub IPv4 addresses', () => { - const input = 'IPs: 192.168.1.1, 10.0.0.1, and 123.123.321.213' - const result = scrubSnapshotData(input) - expect(result).toBe('IPs: [IP], [IP], and [IP]') - }) - - it('should scrub IPv6 addresses', () => { - const input = 'IPv6: 2001:0db8:85a3:0000:0000:8a2e:0370:7334' - const result = scrubSnapshotData(input) - expect(result).toBe('IPv6: [IP]') - }) - - it('should preserve IPs when disabled', () => { - const input = 'IP: 192.168.1.1' - const result = scrubSnapshotData(input, { ipAddresses: false }) - expect(result).toBe('IP: 192.168.1.1') - }) - }) - - describe('emails', () => { - it('should scrub email addresses when enabled', () => { - const input = 'Contact: person@socket.dev or admin@example.com' - const result = scrubSnapshotData(input, { emails: true }) - expect(result).toBe('Contact: [EMAIL] or [EMAIL]') - }) - - it('should preserve emails by default', () => { - const input = 'Contact: person@socket.dev' - const result = scrubSnapshotData(input) - expect(result).toBe('Contact: person@socket.dev') - }) - }) - - describe('custom patterns', () => { - it('should apply custom scrubbing patterns', () => { - const input = 'API key: abc-123-def and token: xyz-456-uvw' - const result = scrubSnapshotData(input, { - custom: [ - { pattern: /[a-z]{3}-\d{3}-[a-z]{3}/g, replacement: '[CUSTOM_KEY]' }, - ], - }) - expect(result).toBe('API key: [CUSTOM_KEY] and token: [CUSTOM_KEY]') - }) - - it('should apply multiple custom patterns', () => { - const input = 'Key: SECRET123 and Token: PRIVATE456' - const result = scrubSnapshotData(input, { - custom: [ - { pattern: /SECRET\d+/g, replacement: '[SECRET]' }, - { pattern: /PRIVATE\d+/g, replacement: '[PRIVATE]' }, - ], - }) - expect(result).toBe('Key: [SECRET] and Token: [PRIVATE]') - }) - }) - - describe('comprehensive scrubbing', () => { - it('should handle complex output with multiple types', () => { - const input = ` -Created: 2025-04-02T01:47:26.914Z -Path: /Users/testuser/projects/some-project -ID: 550e8400-e29b-41d4-a716-446655440000 -IP: 192.168.1.1 -Updated: 2 days ago - `.trim() - - const result = scrubSnapshotData(input) - - expect(result).toContain('[TIMESTAMP]') - expect(result).toContain('/[HOME]/projects/some-project') - expect(result).toContain('[UUID]') - expect(result).toContain('[IP]') - expect(result).toContain('[RELATIVE_TIME]') - }) - - it('should scrub project root path before home directory', () => { - const cwd = process.cwd() - const input = `Working in ${cwd}/src/utils` - const result = scrubSnapshotData(input) - expect(result).toBe('Working in [PROJECT]/src/utils') - }) - }) - - describe('toSnapshotString', () => { - it('should apply default scrubbing options', () => { - const input = ` -Time: 2025-04-02T01:47:26.914Z -Path: /Users/jdalton/test -ID: 550e8400-e29b-41d4-a716-446655440000 -Version: v22.11.0 -Email: test@example.com - `.trim() - - const result = toSnapshotString(input) - - // Should scrub timestamps, paths, IDs, IPs. - expect(result).toContain('[TIMESTAMP]') - expect(result).toContain('/[HOME]/test') - expect(result).toContain('[UUID]') - - // Should NOT scrub versions or emails by default. - expect(result).toContain('v22.11.0') - expect(result).toContain('test@example.com') - }) - }) -}) diff --git a/packages/cli/test/utils.mts b/packages/cli/test/utils.mts deleted file mode 100644 index c6513f0bc..000000000 --- a/packages/cli/test/utils.mts +++ /dev/null @@ -1,244 +0,0 @@ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { it } from 'vitest' - -import { createEnvProxy } from '@socketsecurity/lib-stable/env/proxy' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { stripAnsi } from '@socketsecurity/lib-stable/ansi/strip' - -import type { SpawnOptions } from '@socketsecurity/lib-stable/process/spawn/types' - -import { scrubSnapshotData } from './util/scrub-snapshot-data.mts' -import { execPath } from '../src/constants/paths.mts' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Set VITEST environment variable for test runs. -// This disables interactive help menus in spawned CLI processes. -// Must be set on process.env directly (not spread) to preserve -// Windows environment variable proxy behavior. -if (!process.env['VITEST']) { - process.env['VITEST'] = '1' -} - -// Backward compatibility object for tests. -// In VITEST mode, use a Proxy to keep env vars live and handle case-sensitivity. -const constants = { - execPath, - processEnv: process.env['VITEST'] ? createEnvProxy(process.env) : process.env, -} - -// The asciiUnsafeRegexp match characters that are: -// * Control characters in the Unicode range: -// - \u0000 to \u0007 (e.g., NUL, BEL) -// - \u0009 (Tab, but note: not \u0008 Backspace or \u000A Newline) -// - \u000B to \u001F (other non-printable control characters) -// * All non-ASCII characters: -// - \u0080 to \uFFFF (extended Unicode) - -const asciiUnsafeRegexp = /[\u0000-\u0007\u0009\u000b-\u001f\u0080-\uffff]/g - -// Note: The fixture directory is in the same directory as this utils file. -export const testPath = __dirname - -function normalizeLogSymbols(str: string): string { - return str - .replaceAll('✖', '×') - .replaceAll('ℹ', 'i') - .replaceAll('✔', '√') - .replaceAll('⚠', '‼') -} - -function normalizeNewlines(str: string): string { - return ( - str - // Replace all literal \r\n. - .replaceAll('\r\n', '\n') - // Replace all escaped \\r\\n. - .replaceAll('\\r\\n', '\\n') - ) -} - -function stripZeroWidthSpace(str: string): string { - return str.replaceAll('\u200b', '') -} - -function toAsciiSafeString(str: string): string { - return str.replace(asciiUnsafeRegexp, m => { - const code = m.charCodeAt(0) - return code < 255 - ? `\\x${code.toString(16).padStart(2, '0')}` - : `\\u${code.toString(16).padStart(4, '0')}` - }) -} - -function stripTokenErrorMessages(str: string): string { - // Remove API token error messages to avoid snapshot inconsistencies - // when local environment has/doesn't have tokens set. - return str.replace( - /^\s*[×✖]\s+This command requires a Socket API token for access.*$/gm, - '', - ) -} - -function sanitizeTokens(str: string): string { - // Sanitize Socket API tokens to prevent leaking credentials into snapshots. - // Socket tokens follow the format: sktsec_[alphanumeric+underscore characters] - - // Match Socket API tokens: sktsec_ followed by word characters - const tokenPattern = /sktsec_\w+/g - let result = str.replace(tokenPattern, 'sktsec_REDACTED_TOKEN') - - // Sanitize token values in JSON-like structures - result = result.replace( - /"apiToken"\s*:\s*"sktsec_[^"]+"/g, - '"apiToken":"sktsec_REDACTED_TOKEN"', - ) - - // Sanitize token prefixes that might be displayed (e.g., "zP416" -> "REDAC") - // Match 5-character alphanumeric strings that appear after "token:" labels - result = result.replace( - /token:\s*\[?\d+m\]?(?:[A-Za-z0-9]{5})\*{3}/gi, - 'token: REDAC***', - ) - - return result -} - -export function cleanOutput(output: string): string { - return scrubSnapshotData( - toAsciiSafeString( - normalizeLogSymbols( - normalizeNewlines( - stripZeroWidthSpace( - sanitizeTokens(stripTokenErrorMessages(stripAnsi(output.trim()))), - ), - ), - ), - ), - ) -} - -type TestCollectorOptions = Exclude<Parameters<typeof it>[1], undefined> - -/** - * This is a simple template wrapper for this pattern: `it('should do: socket - * scan', (['socket', 'scan']) => {})` - */ -export function cmdit( - cmd: string[], - title: string, - cb: (cmd: string[]) => Promise<void>, - options?: TestCollectorOptions | undefined, -) { - it( - `${title}: \`${cmd.join(' ')}\``, - { - timeout: 30_000, - ...options, - }, - cb.bind(undefined, cmd), - ) -} - -export async function spawnSocketCli( - entryPath: string, - args: string[], - options?: SpawnOptions | undefined, -): Promise<{ - code: number - error?: - | { - message: string - stack: string - } - | undefined - status: boolean - stdout: string - stderr: string -}> { - const { - cwd = process.cwd(), - env: spawnEnv, - ...restOptions - } = { - __proto__: null, - ...options, - } as SpawnOptions - - // Detect if entryPath is a standalone binary (not a JS file). - // Binaries include: yao-pkg, SEA, or any executable without JS extension. - const isJsFile = - entryPath.endsWith('.js') || - entryPath.endsWith('.mjs') || - entryPath.endsWith('.cjs') || - entryPath.endsWith('.mts') || - entryPath.endsWith('.ts') - - // For binaries, execute directly. For JS files, run through Node. - const command = isJsFile ? constants.execPath : entryPath - const commandArgs = isJsFile ? [entryPath, ...args] : args - - try { - // Create a Proxy env that handles Windows case-insensitivity issues. - // This ensures PATH, TEMP, and other Windows env vars work regardless - // of case (PATH vs Path vs path). - const env = createEnvProxy( - constants.processEnv, - spawnEnv as Record<string, string | undefined>, - ) - - const output = await spawn(command, commandArgs, { - cwd, - env, - ...restOptions, - // Close stdin to prevent tests from hanging - // when commands wait for input. Must be after restOptions - // to ensure it's not overridden. - stdio: restOptions.stdio ?? ['ignore', 'pipe', 'pipe'], - }) - return { - status: true, - code: 0, - stdout: cleanOutput( - typeof output.stdout === 'string' - ? output.stdout - : output.stdout.toString(), - ), - stderr: cleanOutput( - typeof output.stderr === 'string' - ? output.stderr - : output.stderr.toString(), - ), - } - } catch (e: unknown) { - const error = e as { - code?: number | undefined - message?: string | undefined - stack?: string | undefined - stdout?: Buffer | string | undefined - stderr?: Buffer | string | undefined - } - return { - status: false, - code: typeof error.code === 'number' ? error.code : 1, - error: { - message: error.message || '', - stack: error.stack || '', - }, - stdout: cleanOutput( - typeof error.stdout === 'string' - ? error.stdout - : error.stdout?.toString() || '', - ), - stderr: cleanOutput( - typeof error.stderr === 'string' - ? error.stderr - : error.stderr?.toString() || '', - ), - } - } -} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json deleted file mode 100644 index b36c73304..000000000 --- a/packages/cli/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "extends": "../../.config/tsconfig.base.json", - "compilerOptions": { - "declarationMap": false, - "jsx": "react-jsx", - "module": "nodenext", - "noEmit": true, - "rewriteRelativeImportExtensions": true, - "sourceMap": false - }, - "include": [ - "src/**/*.mts", - "src/**/*.d.ts", - "src/**/*.tsx", - "test/helpers/**/*.mts" - ], - "exclude": [ - ".cache/**", - ".claude/**", - "build/**", - "binaries/**", - "dist/**", - "external/**", - "node_modules/**", - "pkg-binaries/**", - "src/**/*.test.mts", - "src/commands/analytics/output-analytics.mts", - "src/commands/audit-log/output-audit-log.mts", - "src/commands/threat-feed/output-threat-feed.mts", - "test/helpers/**/*.test.mts" - ] -} diff --git a/packages/cli/vitest.config.mts b/packages/cli/vitest.config.mts deleted file mode 100644 index d58cd42e9..000000000 --- a/packages/cli/vitest.config.mts +++ /dev/null @@ -1,186 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { defineConfig } from 'vitest/config' - -// Pin TZ in the parent process before vitest spawns its workers, so -// every worker inherits TZ=UTC from spawn env. V8 caches the timezone -// at the first Date op per-worker, so it must be present before any -// test code (or vitest worker bootstrap) runs. test.env below sets it -// on the worker for additional belt-and-suspenders coverage. -if (!process.env['TZ']) { - process.env['TZ'] = 'UTC' -} - -// Inject INLINED_* env vars from bundle-tools.json before workers -// spawn. These are normally inlined at build time by esbuild's define -// step; tests run from source so we feed them in at config-eval time. -// Doing this here (instead of just in test/setup.mts) means modules -// that read INLINED_* at the top level (e.g. constants/paths.mts via -// constants/env.mts → env/coana-version.mts) get the values *before* -// they evaluate, so single-file vitest runs no longer fail with -// "process.env.INLINED_COANA_VERSION is empty at runtime". -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const bundleToolsPath = path.join(__dirname, 'bundle-tools.json') -if (existsSync(bundleToolsPath)) { - try { - const tools = JSON.parse(readFileSync(bundleToolsPath, 'utf8')) - const toolVersions: Record<string, string | undefined> = { - INLINED_CDXGEN_VERSION: tools['@cyclonedx/cdxgen']?.version, - INLINED_COANA_VERSION: tools['@coana-tech/cli']?.version, - INLINED_CYCLONEDX_CDXGEN_VERSION: tools['@cyclonedx/cdxgen']?.version, - INLINED_HOMEPAGE: 'https://github.com/SocketDev/socket-cli', - INLINED_NAME: '@socketsecurity/cli', - INLINED_OPENGREP_VERSION: tools['opengrep']?.version, - INLINED_PUBLISHED_BUILD: '', - INLINED_PYCLI_VERSION: tools['socketsecurity']?.version, - INLINED_PYTHON_BUILD_TAG: tools['python']?.tag, - INLINED_PYTHON_VERSION: tools['python']?.version, - INLINED_SENTRY_BUILD: '', - INLINED_SFW_NPM_VERSION: tools['sfw']?.npm?.version, - INLINED_SFW_VERSION: tools['sfw']?.version, - INLINED_SOCKET_PATCH_VERSION: tools['socket-patch']?.version, - INLINED_SYNP_VERSION: tools['synp']?.version, - INLINED_TRIVY_VERSION: tools['trivy']?.version, - INLINED_TRUFFLEHOG_VERSION: tools['trufflehog']?.version, - INLINED_VERSION: '0.0.0-test', - INLINED_VERSION_HASH: '0.0.0-test:abc1234:test', - } - for (const [key, value] of Object.entries(toolVersions)) { - if (!process.env[key] && value) { - process.env[key] = value - } - } - } catch { - // Ignore — fall back to test/setup.mts injection. - } -} - -const isCoverageEnabled = - process.env['npm_lifecycle_event'] === 'cover' || - process.argv.includes('--coverage') - -// Detect if running in CI. -const isCI = 'CI' in process.env - -// Detect if running in CI on macOS. -const isMacCI = isCI && process.platform === 'darwin' - -// Calculate optimal thread count based on environment. -// macOS CI runners have limited memory, so use fewer threads to prevent SIGABRT. -export function getMaxThreads(): number { - if (isCoverageEnabled) { - return 1 - } - if (isMacCI) { - // Use 50% of CPUs on macOS CI to prevent memory exhaustion. - return Math.max(2, Math.floor(os.cpus().length / 2)) - } - // Use all CPUs on other platforms. - return os.cpus().length -} - -// oxlint-disable-next-line socket/no-default-export -- vitest config file requires default export -export default defineConfig({ - resolve: { - preserveSymlinks: false, - }, - test: { - globals: false, - environment: 'node', - // Pin timezone for stable date-formatting snapshots regardless of - // how vitest is invoked. CI runners are UTC; without this, devs on - // local timezones see shifted dates (a 2025-04-19T04:50Z fixture - // renders as Apr 18 in PDT). Vitest applies `test.env` to the - // worker process before any module loads, so this is set early - // enough that V8's internal timezone cache picks it up. - env: { - TZ: 'UTC', - }, - include: ['test/**/*.test.{mts,ts}'], - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/.{idea,git,cache,output,temp}/**', - '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', - // Exclude E2E tests from regular test runs. - '**/*.e2e.test.mts', - // Exclude integration tests (run separately via scripts/integration.mts). - 'test/integration/**', - ], - reporters: ['default'], - setupFiles: ['./test/setup.mts'], - // Use threads for better performance. - pool: 'threads', - // Maximize parallel execution to offset isolate: true performance cost. - // Use CPU count for better hardware utilization. - // Reduce threads on macOS CI to prevent memory exhaustion (SIGABRT). - maxWorkers: getMaxThreads(), - // IMPORTANT: Changed to isolate: true to fix worker thread termination issues. - // - // Previous configuration (isolate: false) caused "Terminating worker thread" - // errors due to resource cleanup issues when tests completed. - // - // Tradeoff Analysis: - // - isolate: true = Full isolation, slower, but reliable cleanup - // - isolate: false = Shared worker context, faster, but cleanup issues - // - // We choose isolate: true to ensure: - // 1. Clean worker thread termination without errors - // 2. Reliable test execution in CI environments - // 3. Proper resource cleanup between tests - // 4. No "Terminating worker thread" errors - // - // Performance impact is acceptable for reliability. - isolate: true, - deps: { - interopDefault: false, - }, - testTimeout: 30_000, - hookTimeout: 30_000, - // Enable file-level parallelization for better performance. - // Large test files (like cmd-scan-reach.test.mts) will run in separate threads. - fileParallelism: true, - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov', 'clover'], - // Prevent v8 coverage segfaults by processing in smaller chunks. - processingConcurrency: 1, - // Use less memory-intensive options. - reportOnFailure: true, - reportsDirectory: './coverage', - exclude: [ - '**/*.config.*', - '**/node_modules/**', - '**/[.]**', - '**/*.d.mts', - '**/*.d.ts', - '**/virtual:*', - 'bin/**', - 'coverage/**', - 'dist/**', - 'external/**', - 'pnpmfile.*', - 'scripts/**', - 'src/**/types.mts', - 'test/**', - 'perf/**', - // Explicit root-level exclusions - '/scripts/**', - '/test/**', - ], - include: ['src/**/*.mts', 'src/**/*.ts'], - clean: true, - skipFull: false, - ignoreClassMethods: ['constructor'], - thresholds: { - lines: 0, - functions: 0, - branches: 0, - statements: 0, - }, - }, - }, -}) diff --git a/packages/cli/vitest.e2e.config.mts b/packages/cli/vitest.e2e.config.mts deleted file mode 100644 index ebe3bdb3e..000000000 --- a/packages/cli/vitest.e2e.config.mts +++ /dev/null @@ -1,36 +0,0 @@ -import os from 'node:os' - -import { defineConfig } from 'vitest/config' - -// oxlint-disable-next-line socket/no-default-export -- vitest config file requires default export -export default defineConfig({ - resolve: { - preserveSymlinks: false, - }, - test: { - globals: false, - environment: 'node', - include: ['**/*.e2e.test.{mts,ts}'], - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/.{idea,git,cache,output,temp}/**', - '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', - ], - reporters: ['default'], - setupFiles: ['./test/setup.mts'], - // Use threads for better performance. - pool: 'threads', - maxWorkers: os.cpus().length, - // E2E tests need full isolation for clean execution. - isolate: true, - deps: { - interopDefault: false, - }, - // E2E tests need longer timeouts for spawning processes. - testTimeout: 60_000, - hookTimeout: 60_000, - // Enable sharding for parallel E2E execution. - fileParallelism: true, - }, -}) diff --git a/packages/cli/vitest.integration.config.mts b/packages/cli/vitest.integration.config.mts deleted file mode 100644 index 3ddcaaf7c..000000000 --- a/packages/cli/vitest.integration.config.mts +++ /dev/null @@ -1,32 +0,0 @@ -import os from 'node:os' - -import { defineConfig } from 'vitest/config' - -// oxlint-disable-next-line socket/no-default-export -- vitest config file requires default export -export default defineConfig({ - resolve: { - preserveSymlinks: false, - }, - test: { - globals: false, - environment: 'node', - include: ['test/integration/**/*.test.{mts,ts}'], - exclude: [ - '**/node_modules/**', - '**/dist/**', - '**/.{idea,git,cache,output,temp}/**', - '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', - ], - reporters: ['default'], - setupFiles: ['./test/setup.mts'], - pool: 'threads', - maxWorkers: os.cpus().length, - isolate: true, - deps: { - interopDefault: false, - }, - testTimeout: 60_000, // Integration tests may take longer. - hookTimeout: 30_000, - fileParallelism: true, - }, -}) diff --git a/packages/package-builder/.gitignore b/packages/package-builder/.gitignore deleted file mode 100644 index ce6a8a58f..000000000 --- a/packages/package-builder/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Generated package directories -build/ diff --git a/packages/package-builder/README.md b/packages/package-builder/README.md deleted file mode 100644 index 819f302ba..000000000 --- a/packages/package-builder/README.md +++ /dev/null @@ -1,490 +0,0 @@ -# Package Builder - -Automated package generation system for Socket CLI distribution. Transforms templates into publishable npm packages for multiple distribution channels and platforms. - -## Table of Contents - -- [Architecture](#architecture) -- [Distribution Strategy](#distribution-strategy) - - [Tool Management](#tool-management) -- [Package Types](#package-types) - - [CLI Packages](#cli-packages) - - [Socket Wrapper Package](#socket-wrapper-package) - - [Socketbin Packages](#socketbin-packages) -- [Generator Scripts](#generator-scripts) - - [generate-cli-packages.mjs](#generate-cli-packagesmjs) - - [generate-socketbin-packages.mjs](#generate-socketbin-packagesmjs) -- [Build Process](#build-process) - - [CLI Package Build](#cli-package-build) -- [Template Structure](#template-structure) - - [CLI Package Template](#cli-package-template) - - [Socketbin Package Template](#socketbin-package-template) -- [Package Validation](#package-validation) -- [Build Output](#build-output) -- [Integration Points](#integration-points) - - [Dependencies on Main CLI](#dependencies-on-main-cli) - - [esbuild Configuration](#esbuild-configuration) - - [Version Synchronization](#version-synchronization) -- [Usage](#usage) - - [Generate All Packages](#generate-all-packages) - - [Generate Individual Package Type](#generate-individual-package-type) - - [Build Generated Package](#build-generated-package) - - [Verify Generated Package](#verify-generated-package) -- [Design Patterns](#design-patterns) - - [Template-Based Generation](#template-based-generation) - - [Platform Abstraction](#platform-abstraction) - - [Build Delegation](#build-delegation) - - [Optional Binary Dependencies](#optional-binary-dependencies) -- [Code Quality Observations](#code-quality-observations) - - [Strengths](#strengths) - - [Patterns](#patterns) - - [Potential Improvements](#potential-improvements) -- [Summary](#summary) - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Package Builder │ -│ │ -│ Templates + Scripts → Generated Build Artifacts │ -└─────────────────────────────────────────────────────────────────┘ - - ┌──────────────┐ - │ Templates │ - │ │ - │ • cli │ - │ • cli-sentry│ - │ • socket │ - │ • socketbin │ - └──────┬───────┘ - │ - ┌────────────────┼────────────────┐ - │ │ │ - ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ - │ Scripts │ │ Scripts │ │ Scripts │ - │ │ │ │ │ │ - │ generate- │ │ generate- │ │ generate- │ - │ cli │ │ socketbin │ │ socket │ - └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ - │ │ │ - ┌─────▼──────────────▼──────────────▼─────┐ - │ Build Directory │ - │ │ - │ Generated Packages: │ - │ • cli/ (npm package) │ - │ • cli-with-sentry/ (npm package) │ - │ • socketbin-cli-*/ (6 platforms) │ - └──────────────────────────────────────────┘ -``` - -## Distribution Strategy - -Socket CLI uses a multi-channel distribution approach with VFS-based tool bundling: - -``` -┌────────────────────────────────────────────────────────────┐ -│ Distribution Channels │ -├────────────────────────────────────────────────────────────┤ -│ │ -│ 1. socket (npm wrapper) │ -│ └─→ optionalDependencies → Installs platform binary │ -│ Binary contains: CLI + VFS with external tools │ -│ First run: Extract tools from VFS → cache │ -│ │ -│ 2. @socketsecurity/cli │ -│ └─→ Pure JavaScript (no VFS) │ -│ First run: Lazy download tools from GitHub │ -│ Tools cached in ~/.socket/vfs-tools/ │ -│ │ -│ 3. @socketsecurity/cli-with-sentry │ -│ └─→ Pure JavaScript + Sentry telemetry (no VFS) │ -│ First run: Lazy download tools from GitHub │ -│ Tools cached in ~/.socket/vfs-tools/ │ -│ │ -│ 4. socketbin-cli-{platform}-{arch} │ -│ └─→ SEA binary with embedded VFS (6 variants) │ -│ • darwin-arm64, darwin-x64 │ -│ • linux-arm64, linux-x64 │ -│ • win32-arm64, win32-x64 │ -│ Binary contains: CLI code + external tools in VFS │ -│ │ -└────────────────────────────────────────────────────────────┘ -``` - -### Tool Management - -**VFS (Virtual File System) - For Binaries:** - -- External tools embedded in binary at build time -- Tools: Python, OpenGrep, Trivy, TruffleHog, npm packages -- First run: Extract from VFS → `~/.socket/vfs-tools/` -- No network required for tool installation - -**Lazy Download - For Pure JS Packages:** - -- External tools downloaded from GitHub releases on first run -- Same tools as VFS binaries -- Cached in `~/.socket/vfs-tools/` (same location) -- Network required only on first run - -## Package Types - -### CLI Packages - -Standard Node.js implementations with all CLI functionality. - -**@socketsecurity/cli** - -- Pure JavaScript CLI package (no binaries, no VFS). -- No telemetry. -- Built from `templates/cli-package/`. -- Includes: Main CLI + npm/npx/pnpm/yarn wrappers. -- Lazy downloads external tools from GitHub on first run. -- Tools cached in `~/.socket/vfs-tools/`. - -**@socketsecurity/cli-with-sentry** - -- Pure JavaScript CLI with Sentry error reporting (no binaries, no VFS). -- Built from `templates/cli-sentry-package/`. -- Uses `cli-dispatch-with-sentry.mts` entry point. -- Includes `@sentry/node` as external dependency. -- Lazy downloads external tools from GitHub on first run. -- Tools cached in `~/.socket/vfs-tools/`. - -### Socket Wrapper Package - -Installs platform-specific binaries via optionalDependencies. - -### Socketbin Packages - -Self-contained SEA binaries with embedded VFS. - -**socketbin-cli-{platform}-{arch}** - -- 6 packages total (darwin × 2, linux × 2, win32 × 2). -- Generated from `templates/socketbin-package/`. -- Contains single executable binary with CLI + VFS. -- VFS includes: Python, OpenGrep, Trivy, TruffleHog, npm tools. -- Includes OS and CPU constraints in package.json. -- First run: Extracts tools from VFS → `~/.socket/vfs-tools/`. - -## Generator Scripts - -### generate-cli-packages.mjs - -Creates both standard CLI packages. - -``` -Input: templates/cli-package/ - templates/cli-sentry-package/ - -Output: build/cli/ - build/cli-with-sentry/ - -Action: Recursive directory copy. -``` - -### generate-socketbin-packages.mjs - -Creates all platform-specific binary packages. - -``` -Input: templates/socketbin-package/ - - package.json.template - - README.md.template - - .gitignore - -Output: build/socketbin-cli-{platform}-{arch}/ - -Action: Template variable replacement: - {{PLATFORM}}, {{ARCH}}, {{OS}}, - {{CPU}}, {{BIN_EXT}}, {{DESCRIPTION}} -``` - -**Platform Configurations:** - -``` -darwin-arm64 → macOS ARM64 (Apple Silicon) -darwin-x64 → macOS x64 (Intel) -linux-arm64 → Linux ARM64 -linux-x64 → Linux x64 -win32-arm64 → Windows ARM64 (.exe) -win32-x64 → Windows x64 (.exe) -``` - -## Build Process - -### CLI Package Build - -Located in `templates/cli-package/scripts/build.mjs`: - -``` -1. Build CLI bundle → .config/esbuild.cli.build.mjs -2. Build index loader → .config/esbuild.index.config.mjs -3. Build npm inject → .config/esbuild.inject.config.mjs -4. Copy CLI to dist → dist/cli.js -5. Copy data directory → data/ -6. Copy repo assets → LICENSE, CHANGELOG.md, logos -``` - -**Binary Outputs:** - -- `bin/cli.js` - Main CLI entry. -- `bin/npm-cli.js` - npm wrapper. -- `bin/npx-cli.js` - npx wrapper. -- `bin/pnpm-cli.js` - pnpm wrapper. -- `bin/yarn-cli.js` - yarn wrapper. - -## Template Structure - -### CLI Package Template - -``` -cli-package/ -├── .config/ -│ ├── esbuild.cli.build.mjs # Main CLI build -│ ├── esbuild.config.mjs # Base config -│ ├── esbuild.index.config.mjs # Index loader -│ └── esbuild.inject.config.mjs # Shadow npm inject -├── bin/ -│ ├── cli.js # Main entry -│ ├── npm-cli.js # npm wrapper -│ ├── npx-cli.js # npx wrapper -│ ├── pnpm-cli.js # pnpm wrapper -│ └── yarn-cli.js # yarn wrapper -├── scripts/ -│ ├── build.mjs # Build orchestration -│ └── verify-package.mjs # Package validation -├── test/ -│ └── package.test.mjs -├── package.json -└── vitest.config.mts -``` - -### Socket Package Template - -``` -socket-package/ -├── scripts/ -│ ├── build.mjs # Build orchestration -│ ├── esbuild.bootstrap.config.mjs # Bootstrap bundler -│ └── verify-package.mjs # Package validation -├── test/ -│ └── bootstrap.test.mjs -├── package.json -└── vitest.config.mts -``` - -**Key Features:** - -- Optional dependencies on all socketbin packages. -- Bootstrap loader detects platform and downloads binary. -- Falls back to Node.js implementation if binary unavailable. - -### Socketbin Package Template - -``` -socketbin-package/ -├── package.json.template # Template with variables -├── README.md.template # Template with variables -└── .gitignore # Static file -``` - -**Template Variables:** - -- `{{PLATFORM}}` - OS platform (darwin/linux/win32). -- `{{ARCH}}` - CPU architecture (arm64/x64). -- `{{OS}}` - OS constraint for package.json. -- `{{CPU}}` - CPU constraint for package.json. -- `{{BIN_EXT}}` - Binary extension (.exe for Windows, empty for Unix). -- `{{DESCRIPTION}}` - Human-readable platform description. - -## Package Validation - -Each template includes verification scripts to validate generated packages. - -**Validation Checks:** - -- package.json exists and has correct structure. -- Required files present (LICENSE, CHANGELOG.md). -- Dist files exist (index.js, cli.js). -- Data directory and files exist. -- Binary files exist (for CLI packages). -- Sentry integration present (for cli-with-sentry). - -**Run Validation:** - -```bash -node scripts/verify-package.mjs -``` - -## Build Output - -Generated packages appear in `build/` directory: - -``` -build/ -├── cli/ # @socketsecurity/cli -├── cli-with-sentry/ # @socketsecurity/cli-with-sentry -├── socketbin-cli-darwin-arm64/ -├── socketbin-cli-darwin-x64/ -├── socketbin-cli-linux-arm64/ -├── socketbin-cli-linux-x64/ -├── socketbin-cli-win32-arm64/ -└── socketbin-cli-win32-x64/ -``` - -Each directory is a complete, publishable npm package. - -## Integration Points - -### Dependencies on Main CLI - -All generated packages depend on: - -- Main CLI source (`packages/cli/`). -- Bootstrap package (`packages/bootstrap/`). -- Build infrastructure (`packages/build-infra/`). - -### esbuild Configuration - -Templates reference base configurations from main CLI: - -```javascript -import baseConfig from '../../cli/.config/esbuild.cli.build.mjs' -``` - -This ensures consistency across all distribution channels. - -### Version Synchronization - -All packages share version from monorepo: - -- Read from `.node-version` for Node.js version. -- Read from `package.json` for CLI version. -- Injected as build-time constants. - -## Usage - -### Generate All Packages - -```bash -# From package-builder directory: -node scripts/generate-cli-packages.mjs -node scripts/generate-socketbin-packages.mjs -``` - -### Generate Individual Package Type - -```bash -# CLI packages only: -node scripts/generate-cli-packages.mjs - -# Socketbin packages only: -node scripts/generate-socketbin-packages.mjs -``` - -### Build Generated Package - -```bash -# Navigate to generated package: -cd build/cli -pnpm run build - -# Or from package-builder: -pnpm --filter ./build/cli run build -``` - -### Verify Generated Package - -```bash -cd build/cli -pnpm run verify -``` - -## Design Patterns - -### Template-Based Generation - -**Pattern:** Separate templates from generated output. - -**Benefits:** - -- Templates tracked in version control. -- Generated packages excluded from git. -- Clear separation of source and artifacts. -- Easy to regenerate from scratch. - -### Platform Abstraction - -**Pattern:** Single template generates multiple platform-specific packages. - -**Benefits:** - -- Reduces duplication. -- Ensures consistency across platforms. -- Simplifies platform additions. -- Centralizes platform configurations. - -### Build Delegation - -**Pattern:** Generated packages contain their own build scripts. - -**Benefits:** - -- Packages are self-contained. -- Can be built independently. -- Supports incremental builds. -- Simplifies CI/CD integration. - -### Optional Binary Dependencies - -**Pattern:** Main package lists binaries as optional dependencies. - -**Benefits:** - -- Graceful fallback to Node.js implementation. -- Reduces download size. -- Platform-specific installation. -- Better error handling. - -## Code Quality Observations - -### Strengths - -1. **Consistent Structure:** All generators follow same pattern. -2. **Clear Separation:** Templates isolated from generated output. -3. **Error Handling:** Proper error messages and exit codes. -4. **Validation:** Built-in verification for generated packages. -5. **Logging:** Clear, informative progress messages. -6. **Documentation:** Good inline comments explaining purpose. - -### Patterns - -1. **Directory Operations:** Uses `fs.cp` for recursive copying. -2. **Template Processing:** Simple regex-based variable replacement. -3. **Async/Await:** Consistent async patterns throughout. -4. **ESM Modules:** All scripts use `.mjs` extension. -5. **Path Resolution:** Proper use of `fileURLToPath` and `path.join`. - -### Recent Improvements - -1. **Handlebars Template Engine:** Upgraded from regex-based replacement to Handlebars. - - Supports conditional logic with `{{#if}}` and loops with `{{#each}}`. - - Backward compatible with existing `{{VARIABLE}}` syntax. - - Shared `processTemplate()` utility in `scripts/utils.mjs` for reuse. - - Enables advanced template features like helpers and partials. - -## Summary - -The package-builder is a well-designed code generation system that transforms templates into multiple distribution formats. Key strengths include: - -- Clear architecture with template-based generation. -- Support for multiple distribution channels (npm, binary, wrapper). -- Platform-specific package generation (6 platforms). -- Self-contained generated packages with own build scripts. -- Comprehensive validation and verification. -- Clean, maintainable code with good error handling. - -The system effectively handles the complexity of multi-platform CLI distribution while maintaining a simple, understandable structure. diff --git a/packages/package-builder/package.json b/packages/package-builder/package.json deleted file mode 100644 index 3f856933a..000000000 --- a/packages/package-builder/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "package-builder", - "version": "1.0.0", - "private": true, - "description": "Template-based package generation system for Socket CLI distribution", - "type": "module", - "exports": { - "./scripts/*": "./scripts/*" - }, - "scripts": { - "generate:all": "node scripts/generate-all.mts", - "generate:cli": "node scripts/generate-cli-packages.mts", - "generate:cli-sentry": "node scripts/generate-cli-sentry-package.mts", - "generate:socketaddon": "node scripts/generate-socketaddon-packages.mts", - "generate:socketbin": "node scripts/generate-socketbin-packages.mts" - }, - "dependencies": { - "@socketsecurity/lib": "catalog:", - "build-infra": "workspace:*", - "handlebars": "^4.7.9" - } -} diff --git a/packages/package-builder/scripts/generate-all.mts b/packages/package-builder/scripts/generate-all.mts deleted file mode 100644 index 5e7f265a2..000000000 --- a/packages/package-builder/scripts/generate-all.mts +++ /dev/null @@ -1,67 +0,0 @@ - -/** - * @file Generate all package directories from templates. Runs all package - * generation scripts in sequence. Usage: node scripts/generate-all.mts. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const logger = getDefaultLogger() - -/** - * Run a script and report results. - */ -async function runScript(scriptName, description) { - logger.log('') - logger.log(`▶ ${description}...`) - logger.log('─'.repeat(50)) - - const result = await spawn('node', [path.join(__dirname, scriptName)], { - cwd: path.dirname(__dirname), - stdio: 'inherit', - }) - - if (result.code !== 0) { - const error = new Error( - `${scriptName} failed with exit code ${result.code}. Check output above for details.`, - ) - if (result.stderr) { - error.message += `\nStderr: ${result.stderr}` - } - throw error - } - - logger.success(`✓ ${description} complete`) -} - -/** - * Main generation logic. - */ -async function main() { - logger.log('') - logger.log('═'.repeat(50)) - logger.log('Generating all packages from templates') - logger.log('═'.repeat(50)) - - // Run all generation scripts in sequence. - await runScript('generate-cli-packages.mts', 'CLI packages') - await runScript('generate-socketaddon-packages.mts', 'Socketaddon packages') - await runScript('generate-socketbin-packages.mts', 'Socketbin packages') - - logger.log('') - logger.log('═'.repeat(50)) - logger.success('All packages generated successfully!') - logger.log('═'.repeat(50)) - logger.log('') -} - -main().catch(e => { - logger.log('') - logger.error('Package generation failed:', e.message) - process.exitCode = 1 -}) diff --git a/packages/package-builder/scripts/generate-cli-packages.mts b/packages/package-builder/scripts/generate-cli-packages.mts deleted file mode 100644 index 28a7f19f3..000000000 --- a/packages/package-builder/scripts/generate-cli-packages.mts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Generate CLI package directories from templates. Creates the standard CLI, - * CLI-with-Sentry, and socket packages. - * - * Usage: node scripts/generate-cli-packages.mts. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - CLI_SENTRY_TEMPLATE_DIR, - CLI_TEMPLATE_DIR, - SOCKET_TEMPLATE_DIR, - getPackageOutDir, -} from './paths.mts' -import { copyDirectory } from './utils.mts' - -const logger = getDefaultLogger() - -/** - * Package configurations. - */ -const PACKAGES = [ - { - name: '@socketsecurity/cli', - outputDir: 'cli', - templateDir: CLI_TEMPLATE_DIR, - }, - { - name: '@socketsecurity/cli-with-sentry', - outputDir: 'cli-with-sentry', - templateDir: CLI_SENTRY_TEMPLATE_DIR, - }, - { - // socket package is bootstrap loader for @socketbin/* SEA binaries. - name: 'socket', - outputDir: 'socket', - templateDir: SOCKET_TEMPLATE_DIR, - }, -] - -/** - * Main generation logic. - */ -async function main() { - logger.log('') - logger.log('Generating CLI packages from templates...') - logger.log('='.repeat(50)) - logger.log('') - - for (let i = 0, { length } = PACKAGES; i < length; i += 1) { - const pkg = PACKAGES[i] - const packagePath = getPackageOutDir(pkg.outputDir) - - // Copy entire template directory. - await copyDirectory(pkg.templateDir, packagePath) - - logger.success(`Generated ${pkg.name} package`) - } - - logger.log('') -} - -main().catch(e => { - logger.error('Package generation failed:', e) - process.exitCode = 1 -}) diff --git a/packages/package-builder/scripts/generate-cli-sentry-package.mts b/packages/package-builder/scripts/generate-cli-sentry-package.mts deleted file mode 100644 index 15d0a354e..000000000 --- a/packages/package-builder/scripts/generate-cli-sentry-package.mts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Generate cli-with-sentry package directory from template. Creates the - * @socketsecurity/cli-with-sentry package that will be used for publishing the - * CLI with Sentry telemetry integration. - * - * Usage: node scripts/generate-cli-sentry-package.mts. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { copyDirectory } from './utils.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const generatePath = path.join(__dirname, '..') -const logger = getDefaultLogger() - -/** - * Main generation logic. - */ -async function main() { - logger.log('') - logger.log('Generating cli-with-sentry package from template...') - logger.log('='.repeat(50)) - logger.log('') - - const templatePath = path.join(generatePath, 'templates/cli-sentry-package') - const packagePath = path.join(generatePath, 'build/cli-with-sentry') - - // Copy entire template directory. - await copyDirectory(templatePath, packagePath) - - logger.success('Generated @socketsecurity/cli-with-sentry package') - logger.log('') -} - -main().catch(e => { - logger.error('Package generation failed:', e) - process.exitCode = 1 -}) diff --git a/packages/package-builder/scripts/generate-socketaddon-packages.mts b/packages/package-builder/scripts/generate-socketaddon-packages.mts deleted file mode 100644 index 0d1abf1d5..000000000 --- a/packages/package-builder/scripts/generate-socketaddon-packages.mts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Generate socketaddon package directories from template. Creates package - * directories for each platform/arch combination that will be used for native - * addon distribution via npm. - * - * Usage: node scripts/generate-socketaddon-packages.mts. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { PLATFORM_CONFIGS } from 'build-infra/lib/platform-targets' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - SOCKETADDON_MAIN_TEMPLATE_DIR, - SOCKETADDON_TEMPLATE_DIR, - getBuildOutDir, - getSocketaddonPackageDir, -} from './paths.mts' -import { processTemplate } from './utils.mts' - -const logger = getDefaultLogger() - -/** - * Generate the main wrapper package. - */ -async function generateMainPackage() { - const packagePath = path.join(getBuildOutDir(), 'socketaddon-iocraft') - const templatePath = SOCKETADDON_MAIN_TEMPLATE_DIR - - // Create package directory. - await fs.mkdir(packagePath, { recursive: true }) - - // Copy package.json. - const packageJsonContent = await fs.readFile( - path.join(templatePath, 'package.json'), - 'utf-8', - ) - await fs.writeFile( - path.join(packagePath, 'package.json'), - packageJsonContent, - 'utf-8', - ) - - // Copy index.mjs. - const indexContent = await fs.readFile( - path.join(templatePath, 'index.mjs'), - 'utf-8', - ) - await fs.writeFile(path.join(packagePath, 'index.mjs'), indexContent, 'utf-8') - - // Copy index.d.ts. - const indexDtsContent = await fs.readFile( - path.join(templatePath, 'index.d.ts'), - 'utf-8', - ) - await fs.writeFile( - path.join(packagePath, 'index.d.ts'), - indexDtsContent, - 'utf-8', - ) - - // Copy LICENSE. - const licenseContent = await fs.readFile( - path.join(templatePath, 'LICENSE'), - 'utf-8', - ) - await fs.writeFile(path.join(packagePath, 'LICENSE'), licenseContent, 'utf-8') - - // Copy README.md. - const readmeContent = await fs.readFile( - path.join(templatePath, 'README.md'), - 'utf-8', - ) - await fs.writeFile( - path.join(packagePath, 'README.md'), - readmeContent, - 'utf-8', - ) - - logger.info('Generated socketaddon-iocraft (main wrapper)') -} - -/** - * Generate a single socketaddon package. - */ -export async function generatePackage(config) { - const { arch, cpu, description, libc, os, releasePlatform } = config - const muslSuffix = libc === 'musl' ? '-musl' : '' - const packageName = `socketaddon-iocraft-${releasePlatform}-${arch}${muslSuffix}` - const packagePath = getSocketaddonPackageDir(releasePlatform, arch, libc) - const templatePath = SOCKETADDON_TEMPLATE_DIR - - // Template context for Handlebars. - // Use releasePlatform for npm package naming (win, not win32). - const context = { - ARCH: arch, - CPU: cpu, - DESCRIPTION: description, - LIBC_SUFFIX: muslSuffix, - OS: os, - PLATFORM: releasePlatform, - } - - // Create package directory. - await fs.mkdir(packagePath, { recursive: true }) - - // Generate package.json. - const packageJsonContent = await processTemplate( - path.join(templatePath, 'package.json.template'), - context, - ) - await fs.writeFile( - path.join(packagePath, 'package.json'), - `${packageJsonContent}\n`, - 'utf-8', - ) - - // Copy LICENSE. - const licenseContent = await fs.readFile( - path.join(templatePath, 'LICENSE'), - 'utf-8', - ) - await fs.writeFile(path.join(packagePath, 'LICENSE'), licenseContent, 'utf-8') - - // Generate README.md. - const readmeContent = await processTemplate( - path.join(templatePath, 'README.md.template'), - context, - ) - await fs.writeFile( - path.join(packagePath, 'README.md'), - readmeContent, - 'utf-8', - ) - - // Copy .gitignore. - const gitignoreContent = await fs.readFile( - path.join(templatePath, '.gitignore'), - 'utf-8', - ) - await fs.writeFile( - path.join(packagePath, '.gitignore'), - gitignoreContent, - 'utf-8', - ) - - logger.info(`Generated ${packageName}`) -} - -/** - * Main generation logic. - */ -async function main() { - logger.log('') - logger.log('Generating socketaddon packages from template...') - logger.log('='.repeat(50)) - logger.log('') - - // Verify template directories exist. - if (!existsSync(SOCKETADDON_TEMPLATE_DIR)) { - logger.error(`Template directory not found: ${SOCKETADDON_TEMPLATE_DIR}`) - process.exitCode = 1 - return - } - if (!existsSync(SOCKETADDON_MAIN_TEMPLATE_DIR)) { - logger.error( - `Template directory not found: ${SOCKETADDON_MAIN_TEMPLATE_DIR}`, - ) - process.exitCode = 1 - return - } - - // Generate main wrapper package. - await generateMainPackage() - - // Generate all platform packages. - for (let i = 0, { length } = PLATFORM_CONFIGS; i < length; i += 1) { - const config = PLATFORM_CONFIGS[i] - await generatePackage(config) - } - - logger.log('') - logger.success( - `Generated 1 main + ${PLATFORM_CONFIGS.length} platform socketaddon packages`, - ) - logger.log('') -} - -main().catch(e => { - logger.error('Package generation failed:', e) - process.exitCode = 1 -}) diff --git a/packages/package-builder/scripts/generate-socketbin-packages.mts b/packages/package-builder/scripts/generate-socketbin-packages.mts deleted file mode 100644 index d2fcd45f4..000000000 --- a/packages/package-builder/scripts/generate-socketbin-packages.mts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Generate socketbin package directories from template. Creates package - * directories for each platform/arch combination that will be used for binary - * distribution via npm. - * - * Usage: node scripts/generate-socketbin-packages.mts. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { PLATFORM_CONFIGS } from 'build-infra/lib/platform-targets' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SOCKETBIN_TEMPLATE_DIR, getSocketbinPackageDir } from './paths.mts' -import { processTemplate } from './utils.mts' - -const logger = getDefaultLogger() - -/** - * Generate a single socketbin package. - */ -export async function generatePackage(config) { - const { - arch, - binExt, - cpu, - description, - libc, - os, - platform, - releasePlatform, - } = config - const muslSuffix = libc === 'musl' ? '-musl' : '' - const packageName = `socketbin-cli-${releasePlatform}-${arch}${muslSuffix}` - const packagePath = getSocketbinPackageDir(releasePlatform, arch, libc) - const templatePath = SOCKETBIN_TEMPLATE_DIR - - // Template context for Handlebars. - // Use releasePlatform for npm package naming (win, not win32). - const context = { - ARCH: arch, - BIN_EXT: binExt, - CPU: cpu, - DESCRIPTION: description, - LIBC_SUFFIX: muslSuffix, - OS: os, - PLATFORM: releasePlatform, - } - - // Create package directory. - await fs.mkdir(packagePath, { recursive: true }) - - // Generate package.json. - const packageJsonContent = await processTemplate( - path.join(templatePath, 'package.json.template'), - context, - ) - await fs.writeFile( - path.join(packagePath, 'package.json'), - `${packageJsonContent}\n`, - 'utf-8', - ) - - // Generate README.md. - const readmeContent = await processTemplate( - path.join(templatePath, 'README.md.template'), - context, - ) - await fs.writeFile( - path.join(packagePath, 'README.md'), - readmeContent, - 'utf-8', - ) - - // Copy .gitignore. - const gitignoreContent = await fs.readFile( - path.join(templatePath, '.gitignore'), - 'utf-8', - ) - await fs.writeFile( - path.join(packagePath, '.gitignore'), - gitignoreContent, - 'utf-8', - ) - - logger.info(`Generated ${packageName}`) -} - -/** - * Main generation logic. - */ -async function main() { - logger.log('') - logger.log('Generating socketbin packages from template...') - logger.log('='.repeat(50)) - logger.log('') - - // Verify template directory exists. - if (!existsSync(SOCKETBIN_TEMPLATE_DIR)) { - logger.error(`Template directory not found: ${SOCKETBIN_TEMPLATE_DIR}`) - process.exitCode = 1 - return - } - - // Generate all packages. - for (let i = 0, { length } = PLATFORM_CONFIGS; i < length; i += 1) { - const config = PLATFORM_CONFIGS[i] - await generatePackage(config) - } - - logger.log('') - logger.success(`Generated ${PLATFORM_CONFIGS.length} socketbin packages`) - logger.log('') -} - -main().catch(e => { - logger.error('Package generation failed:', e) - process.exitCode = 1 -}) diff --git a/packages/package-builder/scripts/paths.mts b/packages/package-builder/scripts/paths.mts deleted file mode 100644 index 83b1e9c1a..000000000 --- a/packages/package-builder/scripts/paths.mts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Centralized path resolution for package-builder. - * - * This is the source of truth for all build output paths. Follows ultrathink - * pattern: build/{mode}/out/{package} - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Package-builder root directory. -const PACKAGE_BUILDER_ROOT = path.join(__dirname, '..') - -// Template directories. -const TEMPLATES_DIR = path.join(PACKAGE_BUILDER_ROOT, 'templates') -export const CLI_TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'cli-package') -export const CLI_SENTRY_TEMPLATE_DIR = path.join( - TEMPLATES_DIR, - 'cli-sentry-package', -) -export const SOCKET_TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'socket-package') -export const SOCKETADDON_MAIN_TEMPLATE_DIR = path.join( - TEMPLATES_DIR, - 'socketaddon-main', -) -export const SOCKETADDON_TEMPLATE_DIR = path.join( - TEMPLATES_DIR, - 'socketaddon-package', -) -export const SOCKETBIN_TEMPLATE_DIR = path.join( - TEMPLATES_DIR, - 'socketbin-package', -) - -/** - * Get build mode (dev/prod). - * - * Priority: - * - * 1. --dev or --prod CLI args - * 2. BUILD_MODE env var - * 3. CI env var (prod in CI, dev locally) - */ -export function getBuildMode() { - // Check CLI args. - const args = process.argv.slice(2) - if (args.includes('--dev')) { - return 'dev' - } - if (args.includes('--prod')) { - return 'prod' - } - // Check env var. - if (process.env['BUILD_MODE']) { - return process.env['BUILD_MODE'] - } - // Default based on CI. - const isCI = process.env['CI'] === '1' || process.env['CI'] === 'true' - return isCI ? 'prod' : 'dev' -} - -/** - * Get the build output root for a given mode. - * - * @param {string} [mode] - Build mode (dev/prod), defaults to BUILD_MODE or CI - * detection. - * - * @returns {string} Path to build output root. - */ -export function getBuildOutDir(mode = getBuildMode()) { - return path.join(PACKAGE_BUILDER_ROOT, 'build', mode, 'out') -} - -/** - * Get the output directory for a specific package. - * - * @param {string} packageName - Package directory name (e.g., 'cli', - * 'socketbin-cli-darwin-arm64'). - * @param {string} [mode] - Build mode (dev/prod), defaults to BUILD_MODE or CI - * detection. - * - * @returns {string} Path to package output directory. - */ -export function getPackageOutDir(packageName: string, mode = getBuildMode()) { - return path.join(getBuildOutDir(mode), packageName) -} - -/** - * Get the output path for a socketaddon package. Uses release platform naming - * (win instead of win32). - * - * @param {string} platform - Platform identifier (darwin, linux, win, or - * win32). - * @param {string} arch - Architecture identifier (arm64, x64). - * @param {string} [libc] - Linux libc variant ('musl' for Alpine). - * @param {string} [mode] - Build mode (dev/prod), defaults to BUILD_MODE or CI - * detection. - * - * @returns {string} Path to socketaddon package directory. - */ -export function getSocketaddonPackageDir( - platform: string, - arch: string, - libc?: string, - mode = getBuildMode(), -) { - // Normalize win32 → win for directory naming. - const releasePlatform = platform === 'win32' ? 'win' : platform - const muslSuffix = libc === 'musl' ? '-musl' : '' - const packageName = `socketaddon-iocraft-${releasePlatform}-${arch}${muslSuffix}` - return getPackageOutDir(packageName, mode) -} - -/** - * Get the binary path within a socketbin package. - * - * @param {string} platform - Platform identifier (darwin, linux, win, or - * win32). - * @param {string} arch - Architecture identifier (arm64, x64). - * @param {string} [libc] - Linux libc variant ('musl' for Alpine). - * @param {string} [mode] - Build mode (dev/prod), defaults to BUILD_MODE or CI - * detection. - * - * @returns {string} Path to the socket binary. - */ -export function getSocketbinBinaryPath( - platform: string, - arch: string, - libc?: string, - mode = getBuildMode(), -) { - // Accept both win and win32 for Windows detection. - const binaryName = - platform === 'win' || platform === 'win32' ? 'socket.exe' : 'socket' - return path.join( - getSocketbinPackageDir(platform, arch, libc, mode), - binaryName, - ) -} - -/** - * Get the output path for a socketbin package. Uses release platform naming - * (win instead of win32). - * - * @param {string} platform - Platform identifier (darwin, linux, win, or - * win32). - * @param {string} arch - Architecture identifier (arm64, x64). - * @param {string} [libc] - Linux libc variant ('musl' for Alpine). - * @param {string} [mode] - Build mode (dev/prod), defaults to BUILD_MODE or CI - * detection. - * - * @returns {string} Path to socketbin package directory. - */ -export function getSocketbinPackageDir( - platform: string, - arch: string, - libc?: string, - mode = getBuildMode(), -) { - // Normalize win32 → win for directory naming. - const releasePlatform = platform === 'win32' ? 'win' : platform - const muslSuffix = libc === 'musl' ? '-musl' : '' - const packageName = `socketbin-cli-${releasePlatform}-${arch}${muslSuffix}` - return getPackageOutDir(packageName, mode) -} diff --git a/packages/package-builder/scripts/util/prepare-package.mts b/packages/package-builder/scripts/util/prepare-package.mts deleted file mode 100644 index a26cf68d9..000000000 --- a/packages/package-builder/scripts/util/prepare-package.mts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Shared utilities for preparing packages for publishing. - */ - -import { readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -/** - * Prepares a package.json for publishing. - Removes private field - Sets - * version if provided - Sets buildMethod if provided - Updates - * optionalDependencies versions (for lockstep publishing) - * - * @param {string} packageDir - Path to the package directory. - * @param {object} [options] - Options. - * @param {string} [options.version] - Version to set. - * @param {string} [options.buildMethod] - Build method to set (for socketbin - * packages) - * @param {boolean} [options.quiet] - Suppress success logging. - * - * @returns {{ name: string; version: string }} Package info - */ -function preparePackageForPublish(packageDir, options = {}) { - const { buildMethod, quiet, version } = options - const pkgPath = join(packageDir, 'package.json') - - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) - const originalVersion = pkg.version - - // Remove private field. - delete pkg.private - - // Set version if provided. - if (version) { - pkg.version = version - - // Update optionalDependencies to use the same version (lockstep). - if (pkg.optionalDependencies) { - for (const dep of Object.keys(pkg.optionalDependencies)) { - pkg.optionalDependencies[dep] = version - } - } - } - - // Set buildMethod if provided (for socketbin packages). - if (buildMethod) { - pkg.buildMethod = buildMethod - } - - writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) - - if (!quiet) { - if (version && version !== originalVersion) { - logger.log(`Set ${pkg.name} version to ${version}`) - } - logger.success(`Prepared ${pkg.name} for publishing`) - } - - return { name: pkg.name, version: pkg.version } -} diff --git a/packages/package-builder/scripts/utils.mts b/packages/package-builder/scripts/utils.mts deleted file mode 100644 index 8f5594612..000000000 --- a/packages/package-builder/scripts/utils.mts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared utilities for package generator scripts. - */ - -import { promises as fs } from 'node:fs' - -import Handlebars from 'handlebars' - -/** - * Copy directory recursively. - * - * @param {string} src - Source directory path. - * @param {string} dest - Destination directory path. - */ -export async function copyDirectory(src, dest) { - await fs.cp(src, dest, { recursive: true }) -} - -/** - * Process template file with Handlebars. - * - * Reads a template file and compiles it with Handlebars, replacing {{VARIABLE}} - * placeholders with values from the context object. - * - * @example - * const content = await processTemplate('template.json', { - * PLATFORM: 'darwin', - * ARCH: 'arm64', - * }) - * - * @param {string} templatePath - Path to template file. - * @param {Record<string, string>} context - Variables to inject into template. - * - * @returns {Promise<string>} Rendered template content. - */ -export async function processTemplate(templatePath, context) { - const content = await fs.readFile(templatePath, 'utf-8') - - // Compile and render template with Handlebars. - const template = Handlebars.compile(content) - return template(context) -} diff --git a/packages/package-builder/templates/cli-package/.config/rolldown.cli.mts b/packages/package-builder/templates/cli-package/.config/rolldown.cli.mts deleted file mode 100644 index a478104b5..000000000 --- a/packages/package-builder/templates/cli-package/.config/rolldown.cli.mts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Rolldown configuration for building a scaffolded Socket CLI package. Extends - * the base CLI build config from the cli package. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - getInlinedEnvVars, - runBuild, -} from '../../cli/scripts/rolldown-utils.mts' - -import baseConfig from '../../cli/.config/rolldown.cli.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -const config = { - ...baseConfig, - input: path.join(rootPath, '..', 'cli', 'src', 'cli-dispatch.mts'), - output: { - ...(baseConfig.output as object), - file: path.join(rootPath, 'build/cli.js'), - }, -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - runBuild(config, 'CLI bundle', { - envVars: getInlinedEnvVars(), - unicodeTransform: true, - }).catch(() => { - process.exitCode = 1 - }) -} - -export default config diff --git a/packages/package-builder/templates/cli-package/.config/rolldown.index.mts b/packages/package-builder/templates/cli-package/.config/rolldown.index.mts deleted file mode 100644 index 2e26fc2d5..000000000 --- a/packages/package-builder/templates/cli-package/.config/rolldown.index.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Rolldown configuration for a scaffolded Socket CLI index loader. Builds the - * index loader that executes the CLI. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - createIndexConfig, - getInlinedEnvVars, - runBuild, -} from '../../cli/scripts/rolldown-utils.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.resolve(__dirname, '..') -const cliPath = path.resolve(__dirname, '../../cli') - -const config = createIndexConfig({ - entryPoint: path.join(cliPath, 'src', 'index.mts'), - outfile: path.join(rootPath, 'dist', 'index.js'), -}) - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - runBuild(config, 'Entry point', { envVars: getInlinedEnvVars() }) -} - -export default config diff --git a/packages/package-builder/templates/cli-package/.gitignore b/packages/package-builder/templates/cli-package/.gitignore deleted file mode 100644 index 01b91f921..000000000 --- a/packages/package-builder/templates/cli-package/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/coverage -**/dist diff --git a/packages/package-builder/templates/cli-package/README.md b/packages/package-builder/templates/cli-package/README.md deleted file mode 100644 index 07b9e9027..000000000 --- a/packages/package-builder/templates/cli-package/README.md +++ /dev/null @@ -1,21 +0,0 @@ -<picture> - <source media="(prefers-color-scheme: dark)" srcset="logo-light.png"> - <source media="(prefers-color-scheme: light)" srcset="logo-dark.png"> - <img alt="Socket" src="logo-dark.png" width="200"> -</picture> - -# @socketsecurity/cli - -JavaScript implementation of the [Socket CLI](https://github.com/SocketDev/socket-cli/tree/main/packages/cli#readme). - -💡 For native binaries, install [`socket`](https://socket.dev/npm/package/socket) instead. - -## Install - -```sh -npm install -g @socketsecurity/cli -``` - -## License - -[MIT](./LICENSE) diff --git a/packages/package-builder/templates/cli-package/bin/cli.js b/packages/package-builder/templates/cli-package/bin/cli.js deleted file mode 100755 index a3bf03892..000000000 --- a/packages/package-builder/templates/cli-package/bin/cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Main CLI entry point for Socket CLI with Sentry. Loads the bundled CLI with - * Sentry integration. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-package/bin/npm-cli.js b/packages/package-builder/templates/cli-package/bin/npm-cli.js deleted file mode 100755 index 0bdab926f..000000000 --- a/packages/package-builder/templates/cli-package/bin/npm-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Npm wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in npm mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-package/bin/npx-cli.js b/packages/package-builder/templates/cli-package/bin/npx-cli.js deleted file mode 100755 index 6d76ff819..000000000 --- a/packages/package-builder/templates/cli-package/bin/npx-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Npx wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in npx mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-package/bin/pnpm-cli.js b/packages/package-builder/templates/cli-package/bin/pnpm-cli.js deleted file mode 100755 index 3fe96b3d2..000000000 --- a/packages/package-builder/templates/cli-package/bin/pnpm-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Pnpm wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in pnpm mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-package/bin/yarn-cli.js b/packages/package-builder/templates/cli-package/bin/yarn-cli.js deleted file mode 100755 index 633e5b0c5..000000000 --- a/packages/package-builder/templates/cli-package/bin/yarn-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Yarn wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in yarn mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-package/logo-dark.png b/packages/package-builder/templates/cli-package/logo-dark.png deleted file mode 100644 index 665ec2782..000000000 Binary files a/packages/package-builder/templates/cli-package/logo-dark.png and /dev/null differ diff --git a/packages/package-builder/templates/cli-package/logo-light.png b/packages/package-builder/templates/cli-package/logo-light.png deleted file mode 100644 index 7f14ce68f..000000000 Binary files a/packages/package-builder/templates/cli-package/logo-light.png and /dev/null differ diff --git a/packages/package-builder/templates/cli-package/package.json b/packages/package-builder/templates/cli-package/package.json deleted file mode 100644 index 17cbcce34..000000000 --- a/packages/package-builder/templates/cli-package/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "@socketsecurity/cli", - "version": "3.0.0-pre.0", - "private": true, - "description": "Socket CLI for Socket.dev security analysis", - "keywords": [ - "cli", - "security", - "socket", - "supply-chain", - "vulnerability" - ], - "homepage": "https://github.com/SocketDev/socket-cli", - "license": "MIT", - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git", - "directory": "packages/cli" - }, - "bin": { - "socket": "bin/cli.js", - "socket-npm": "bin/npm-cli.js", - "socket-npx": "bin/npx-cli.js", - "socket-pnpm": "bin/pnpm-cli.js", - "socket-yarn": "bin/yarn-cli.js" - }, - "files": [ - "CHANGELOG.md", - "LICENSE", - "bin/**", - "data/**", - "dist/**", - "!dist/cli.js", - "logo-dark.png", - "logo-light.png" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "scripts": { - "build": "node scripts/build.mts", - "clean:dist": "del-cli 'dist'", - "test": "vitest run", - "verify": "node scripts/verify-package.mts" - }, - "devDependencies": { - "build-infra": "workspace:*", - "rolldown": "catalog:" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/package-builder/templates/cli-package/scripts/build.mts b/packages/package-builder/templates/cli-package/scripts/build.mts deleted file mode 100644 index b28bbb6dd..000000000 --- a/packages/package-builder/templates/cli-package/scripts/build.mts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @file Build script for Socket CLI. Delegates to rolldown config for actual - * build. Copies data/ and images from packages/cli. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const repoRoot = path.join(__dirname, '../../..') - -const logger = getDefaultLogger() - -async function main() { - try { - const cliPath = path.join(rootPath, '..', 'cli') - - // Build CLI bundle. - logger.info('Building CLI bundle...') - let result = await spawn('node', ['.config/rolldown.cli.mts'], { - shell: WIN32, - stdio: 'inherit', - cwd: rootPath, - }) - - if (!result) { - throw new Error('Failed to start CLI bundle build') - } - - if (result.code !== 0) { - throw new Error(`CLI bundle build failed with exit code ${result.code}`) - } - logger.success('Built CLI bundle') - - // Build index loader. - logger.info('Building index loader...') - result = await spawn('node', ['.config/rolldown.index.mts'], { - shell: WIN32, - stdio: 'inherit', - cwd: rootPath, - }) - - if (!result) { - throw new Error('Failed to start index loader build') - } - - if (result.code !== 0) { - throw new Error(`Index loader build failed with exit code ${result.code}`) - } - logger.success('Built index loader') - - // Copy CLI to dist. - logger.info('Copying CLI to dist...') - await fs.copyFile( - path.join(rootPath, 'build', 'cli.js'), - path.join(rootPath, 'dist', 'cli.js'), - ) - logger.success('Copied CLI to dist') - - // Copy data directory from packages/cli. - logger.info('Copying data/ from packages/cli...') - await fs.cp(path.join(cliPath, 'data'), path.join(rootPath, 'data'), { - recursive: true, - }) - logger.success('Copied data/') - - // Copy files from repo root. - logger.info('Copying files from repo root...') - const filesToCopy = [ - 'CHANGELOG.md', - 'LICENSE', - 'logo-dark.png', - 'logo-light.png', - ] - for (let i = 0, { length } = filesToCopy; i < length; i += 1) { - const file = filesToCopy[i] - await fs.cp(path.join(repoRoot, file), path.join(rootPath, file)) - } - logger.success('Copied files from repo root') - } catch (e) { - logger.error(`Build failed: ${e.message}`) - process.exitCode = 1 - } -} - -main() diff --git a/packages/package-builder/templates/cli-package/scripts/verify-package.mts b/packages/package-builder/templates/cli-package/scripts/verify-package.mts deleted file mode 100644 index 7d5ecc0dd..000000000 --- a/packages/package-builder/templates/cli-package/scripts/verify-package.mts +++ /dev/null @@ -1,156 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { existsSync } from 'node:fs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const packageRoot = path.resolve(__dirname, '..') -const logger = getDefaultLogger() - -/** - * Check if a file exists. - */ -export function fileExists(filePath) { - return existsSync(filePath) -} - -/** - * Main validation function. - */ -export async function validate() { - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('CLI Package Validation')}`) - logger.log('='.repeat(60)) - logger.log('') - - const errors = [] - - // Check package.json exists. - logger.info('Checking package.json...') - const pkgPath = path.join(packageRoot, 'package.json') - if (!(await existsSync(pkgPath))) { - errors.push('package.json does not exist') - } else { - logger.success('package.json exists') - - // Validate package.json configuration. - const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) - - // Validate files array. - const requiredInFiles = [ - 'CHANGELOG.md', - 'LICENSE', - 'data/**', - 'dist/**', - 'logo-dark.png', - 'logo-light.png', - ] - for (let i = 0, { length } = requiredInFiles; i < length; i += 1) { - const required = requiredInFiles[i] - if (!pkg.files?.includes(required)) { - errors.push(`package.json files array missing: ${required}`) - } - } - if (errors.length === 0) { - logger.success('package.json files array is correct') - } - } - - // Check root files exist (LICENSE, CHANGELOG.md). - const rootFiles = ['LICENSE', 'CHANGELOG.md'] - for (let i = 0, { length } = rootFiles; i < length; i += 1) { - const file = rootFiles[i] - logger.info(`Checking ${file}...`) - const filePath = path.join(packageRoot, file) - if (!(await existsSync(filePath))) { - errors.push(`${file} does not exist`) - } else { - logger.success(`${file} exists`) - } - } - - // Check dist files exist. - const distFiles = ['index.js', 'cli.js'] - for (let i = 0, { length } = distFiles; i < length; i += 1) { - const file = distFiles[i] - logger.info(`Checking dist/${file}...`) - const filePath = path.join(packageRoot, 'dist', file) - if (!(await existsSync(filePath))) { - errors.push(`dist/${file} does not exist`) - } else { - logger.success(`dist/${file} exists`) - } - } - - // Check build/cli.js exists. - logger.info('Checking build/cli.js...') - const buildPath = path.join(packageRoot, 'build', 'cli.js') - if (!(await existsSync(buildPath))) { - errors.push('build/cli.js does not exist') - } else { - logger.success('build/cli.js exists') - } - - // Check data directory exists. - logger.info('Checking data directory...') - const dataPath = path.join(packageRoot, 'data') - if (!(await existsSync(dataPath))) { - errors.push('data directory does not exist') - } else { - logger.success('data directory exists') - - // Check data files. - const dataFiles = [ - 'alert-translations.json', - 'command-api-requirements.json', - ] - for (let i = 0, { length } = dataFiles; i < length; i += 1) { - const file = dataFiles[i] - logger.info(`Checking data/${file}...`) - const filePath = path.join(dataPath, file) - if (!(await existsSync(filePath))) { - errors.push(`data/${file} does not exist`) - } else { - logger.success(`data/${file} exists`) - } - } - } - - // Print summary. - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('Validation Summary')}`) - logger.log('='.repeat(60)) - logger.log('') - - if (errors.length > 0) { - logger.log(`${colors.red('Errors:')}`) - for (let i = 0, { length } = errors; i < length; i += 1) { - const err = errors[i] - logger.fail(` ${err}`) - } - logger.log('') - logger.fail('Package validation FAILED') - logger.log('') - process.exitCode = 1 - return - } - - logger.success('Package validation PASSED') - logger.log('') -} - -// Run validation. -validate().catch(e => { - logger.error('') - logger.fail(`Unexpected error: ${e.message}`) - logger.error('') - process.exitCode = 1 -}) diff --git a/packages/package-builder/templates/cli-package/test/package.test.mts b/packages/package-builder/templates/cli-package/test/package.test.mts deleted file mode 100644 index e51a5c63c..000000000 --- a/packages/package-builder/templates/cli-package/test/package.test.mts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * @file Tests for @socketsecurity/cli package structure and configuration. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { describe, expect, it } from 'vitest' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageDir = path.join(__dirname, '..') -const configDir = path.join(packageDir, '.config') -const binDir = path.join(packageDir, 'bin') -const scriptsDir = path.join(packageDir, 'scripts') - -describe('@socketsecurity/cli-with-sentry package', () => { - describe('package.json validation', () => { - it('should have valid package.json metadata', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.name).toBe('@socketsecurity/cli-with-sentry') - expect(pkgJson.version).toMatch(/^\d+\.\d+\.\d+$/) - expect(pkgJson.license).toBe('MIT') - expect(pkgJson.description).toContain('Sentry') - expect(pkgJson.description).toContain('telemetry') - }) - - it('should have build script', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.scripts).toBeDefined() - expect(pkgJson.scripts.build).toBe('node scripts/build.mts') - expect(pkgJson.scripts['clean:dist']).toBeDefined() - }) - - it('should have all CLI bin entries', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.bin).toBeDefined() - expect(pkgJson.bin.socket).toBe('bin/cli.js') - expect(pkgJson.bin['socket-npm']).toBe('bin/npm-cli.js') - expect(pkgJson.bin['socket-npx']).toBe('bin/npx-cli.js') - expect(pkgJson.bin['socket-pnpm']).toBe('bin/pnpm-cli.js') - expect(pkgJson.bin['socket-yarn']).toBe('bin/yarn-cli.js') - }) - - it('should depend on main CLI package', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.dependencies).toBeDefined() - expect(pkgJson.dependencies['@socketsecurity/cli']).toBe('workspace:*') - }) - - it('should have Sentry as devDependency', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.devDependencies).toBeDefined() - expect(pkgJson.devDependencies['@sentry/node']).toBeDefined() - expect(pkgJson.devDependencies.rolldown).toBeDefined() - }) - }) - - describe('bin wrappers exist', () => { - it('should have bin directory', () => { - expect(existsSync(binDir)).toBe(true) - }) - - it('should have cli.js wrapper', () => { - const cliPath = path.join(binDir, 'cli.js') - expect(existsSync(cliPath)).toBe(true) - }) - - it('should have npm-cli.js wrapper', () => { - const npmPath = path.join(binDir, 'npm-cli.js') - expect(existsSync(npmPath)).toBe(true) - }) - - it('should have npx-cli.js wrapper', () => { - const npxPath = path.join(binDir, 'npx-cli.js') - expect(existsSync(npxPath)).toBe(true) - }) - - it('should have pnpm-cli.js wrapper', () => { - const pnpmPath = path.join(binDir, 'pnpm-cli.js') - expect(existsSync(pnpmPath)).toBe(true) - }) - - it('should have yarn-cli.js wrapper', () => { - const yarnPath = path.join(binDir, 'yarn-cli.js') - expect(existsSync(yarnPath)).toBe(true) - }) - - it('bin wrappers should be executable', async () => { - const cliPath = path.join(binDir, 'cli.js') - const content = await fs.readFile(cliPath, 'utf-8') - - expect(content).toContain('#!/usr/bin/env node') - expect(content).toContain('dist/cli.js') - }) - }) - - describe('build configuration', () => { - it('should have .config directory', () => { - expect(existsSync(configDir)).toBe(true) - }) - - it('should have rolldown config', () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - expect(existsSync(rolldownPath)).toBe(true) - }) - - it('rolldown config should import base config', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain( - "import baseConfig from '../../cli/.config/rolldown.cli.mts'", - ) - }) - - it('rolldown config should enable Sentry build flag', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('INLINED_SENTRY_BUILD') - expect(content).toContain("JSON.stringify('1')") - }) - - it('rolldown config should use CLI dispatch with Sentry entry point', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('cli-dispatch-with-sentry.mts') - }) - - it('rolldown config should call build() when run', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('build(config)') - expect(content).toContain('import.meta.url') - }) - }) - - describe('build scripts exist', () => { - it('should have scripts directory', () => { - expect(existsSync(scriptsDir)).toBe(true) - }) - - it('should have build.mts script', () => { - const buildPath = path.join(scriptsDir, 'build.mts') - expect(existsSync(buildPath)).toBe(true) - }) - - it('build.mts should be valid JavaScript', async () => { - const buildPath = path.join(scriptsDir, 'build.mts') - const content = await fs.readFile(buildPath, 'utf-8') - - expect(content).toBeTruthy() - expect(content).toContain('import') - expect(content).toContain('rolldown.cli-sentry.build.mts') - }) - }) - - describe('README documentation', () => { - it('should have README.md', () => { - const readmePath = path.join(packageDir, 'README.md') - expect(existsSync(readmePath)).toBe(true) - }) - - it('README should document Sentry integration', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Sentry') - expect(readme).toContain('telemetry') - expect(readme).toContain('error') - }) - - it('README should document telemetry', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Telemetry') - expect(readme).toContain('No sensitive data') - }) - - it('README should document differences from main CLI', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Differences') - expect(readme).toContain('@socketsecurity/cli') - }) - - it('README should document privacy', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('No sensitive data') - expect(readme.toLowerCase()).toContain('api token') - }) - }) - - describe('package is publishable', () => { - it('should be marked as private', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - // cli-with-sentry is private during development but will be public on release. - expect(pkgJson.private).toBe(true) - }) - - it('should have publishConfig for npm', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.publishConfig).toBeDefined() - expect(pkgJson.publishConfig.access).toBe('public') - expect(pkgJson.publishConfig.registry).toBe('https://registry.npmjs.org/') - }) - }) - - describe('Sentry integration', () => { - it('main CLI should have CLI dispatch with Sentry entry point', () => { - const sentryEntryPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'cli-dispatch-with-sentry.mts', - ) - expect(existsSync(sentryEntryPath)).toBe(true) - }) - - it('CLI dispatch with Sentry should import instrumentation first', async () => { - const sentryEntryPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'cli-dispatch-with-sentry.mts', - ) - const content = await fs.readFile(sentryEntryPath, 'utf-8') - - expect(content).toContain("import './instrument-with-sentry.mts'") - expect(content).toContain("import './cli-dispatch.mts'") - - // Verify Sentry import comes before CLI dispatch. - const sentryImportIndex = content.indexOf('instrument-with-sentry') - const dispatchImportIndex = content.indexOf('cli-dispatch.mts') - expect(sentryImportIndex).toBeLessThan(dispatchImportIndex) - }) - - it('main CLI should have Sentry instrumentation', () => { - const sentryInstrumentPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'instrument-with-sentry.mts', - ) - expect(existsSync(sentryInstrumentPath)).toBe(true) - }) - - it('Sentry instrumentation should check build flag', async () => { - const sentryInstrumentPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'instrument-with-sentry.mts', - ) - const content = await fs.readFile(sentryInstrumentPath, 'utf-8') - - expect(content).toContain('INLINED_SENTRY_BUILD') - expect(content).toContain('@sentry/node') - expect(content).toContain('Sentry.init') - }) - }) - - // Note: Actual build execution tests are pending implementation. - // These tests validate package structure and configuration only. - describe.skip('build execution (requires implementation)', () => { - it('should build Sentry-enabled CLI', async () => { - // Test pending: verify build creates dist/cli.js with Sentry. - }) - - it('should include Sentry SDK in bundle', async () => { - // Test pending: verify @sentry/node is bundled, not externalized. - }) - - it('should set SENTRY_BUILD flag in output', async () => { - // Test pending: verify flag is set to "1" in built code. - }) - }) -}) diff --git a/packages/package-builder/templates/cli-package/vitest.config.mts b/packages/package-builder/templates/cli-package/vitest.config.mts deleted file mode 100644 index ee5be7a7f..000000000 --- a/packages/package-builder/templates/cli-package/vitest.config.mts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extends shared simple vitest config. - */ -import baseConfig from '../../vitest.config.simple.mts' - -export { baseConfig } diff --git a/packages/package-builder/templates/cli-sentry-package/.config/rolldown.cli-sentry.build.mts b/packages/package-builder/templates/cli-sentry-package/.config/rolldown.cli-sentry.build.mts deleted file mode 100644 index cced7473c..000000000 --- a/packages/package-builder/templates/cli-sentry-package/.config/rolldown.cli-sentry.build.mts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Rolldown configuration for building a scaffolded Socket CLI with Sentry - * integration. Extends the base CLI build config with Sentry-specific settings. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - getInlinedEnvVars, - runBuild, -} from '../../cli/scripts/rolldown-utils.mts' - -import baseConfig from '../../cli/.config/rolldown.cli.mts' - -import type { RolldownOptions } from 'rolldown' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const cliPath = path.join(__dirname, '..', '..', 'cli') - -const config: RolldownOptions = { - ...baseConfig, - input: path.join(cliPath, 'src/cli-dispatch-with-sentry.mts'), - external: '@sentry/node', - transform: { - ...baseConfig.transform, - define: { - ...baseConfig.transform?.define, - 'process.env.INLINED_SENTRY_BUILD': JSON.stringify('1'), - 'process.env["INLINED_SENTRY_BUILD"]': JSON.stringify('1'), - }, - }, - output: { - ...(baseConfig.output as object), - file: path.join(rootPath, 'build/cli.js'), - }, -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - runBuild(config, 'CLI bundle (Sentry)', { - envVars: getInlinedEnvVars(), - unicodeTransform: true, - }).catch(() => { - process.exitCode = 1 - }) -} - -export default config diff --git a/packages/package-builder/templates/cli-sentry-package/.config/rolldown.index.mts b/packages/package-builder/templates/cli-sentry-package/.config/rolldown.index.mts deleted file mode 100644 index 90851552a..000000000 --- a/packages/package-builder/templates/cli-sentry-package/.config/rolldown.index.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Rolldown configuration for a scaffolded Socket CLI (Sentry) index loader. - * Builds the index loader that executes the CLI. - */ - -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - createIndexConfig, - getInlinedEnvVars, - runBuild, -} from '../../cli/scripts/rolldown-utils.mts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.resolve(__dirname, '..') -const cliPath = path.resolve(__dirname, '../../cli') - -const config = createIndexConfig({ - entryPoint: path.join(cliPath, 'src', 'index.mts'), - outfile: path.join(rootPath, 'dist', 'index.js'), -}) - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - runBuild(config, 'Entry point', { envVars: getInlinedEnvVars() }).catch( - () => { - process.exitCode = 1 - }, - ) -} - -export default config diff --git a/packages/package-builder/templates/cli-sentry-package/.gitignore b/packages/package-builder/templates/cli-sentry-package/.gitignore deleted file mode 100644 index 01b91f921..000000000 --- a/packages/package-builder/templates/cli-sentry-package/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/coverage -**/dist diff --git a/packages/package-builder/templates/cli-sentry-package/README.md b/packages/package-builder/templates/cli-sentry-package/README.md deleted file mode 100644 index adaf53c62..000000000 --- a/packages/package-builder/templates/cli-sentry-package/README.md +++ /dev/null @@ -1,21 +0,0 @@ -<picture> - <source media="(prefers-color-scheme: dark)" srcset="logo-light.png"> - <source media="(prefers-color-scheme: light)" srcset="logo-dark.png"> - <img alt="Socket" src="logo-dark.png" width="200"> -</picture> - -# @socketsecurity/cli-with-sentry - -JavaScript implementation of the [Socket CLI](https://github.com/SocketDev/socket-cli/tree/main/packages/cli#readme) with Sentry error reporting. - -💡 For native binaries, install [`socket`](https://socket.dev/npm/package/socket) instead. - -## Install - -```sh -npm install -g @socketsecurity/cli-with-sentry -``` - -## License - -[MIT](./LICENSE) diff --git a/packages/package-builder/templates/cli-sentry-package/bin/cli.js b/packages/package-builder/templates/cli-sentry-package/bin/cli.js deleted file mode 100755 index a3bf03892..000000000 --- a/packages/package-builder/templates/cli-sentry-package/bin/cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Main CLI entry point for Socket CLI with Sentry. Loads the bundled CLI with - * Sentry integration. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-sentry-package/bin/npm-cli.js b/packages/package-builder/templates/cli-sentry-package/bin/npm-cli.js deleted file mode 100755 index 0bdab926f..000000000 --- a/packages/package-builder/templates/cli-sentry-package/bin/npm-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Npm wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in npm mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-sentry-package/bin/npx-cli.js b/packages/package-builder/templates/cli-sentry-package/bin/npx-cli.js deleted file mode 100755 index 6d76ff819..000000000 --- a/packages/package-builder/templates/cli-sentry-package/bin/npx-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Npx wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in npx mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-sentry-package/bin/pnpm-cli.js b/packages/package-builder/templates/cli-sentry-package/bin/pnpm-cli.js deleted file mode 100755 index 3fe96b3d2..000000000 --- a/packages/package-builder/templates/cli-sentry-package/bin/pnpm-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Pnpm wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in pnpm mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-sentry-package/bin/yarn-cli.js b/packages/package-builder/templates/cli-sentry-package/bin/yarn-cli.js deleted file mode 100755 index 633e5b0c5..000000000 --- a/packages/package-builder/templates/cli-sentry-package/bin/yarn-cli.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -/** - * Yarn wrapper entry point for Socket CLI with Sentry. Loads the bundled CLI - * with Sentry integration in yarn mode. - */ - -// Load the bundled CLI. -require('../dist/cli.js') diff --git a/packages/package-builder/templates/cli-sentry-package/logo-dark.png b/packages/package-builder/templates/cli-sentry-package/logo-dark.png deleted file mode 100644 index 665ec2782..000000000 Binary files a/packages/package-builder/templates/cli-sentry-package/logo-dark.png and /dev/null differ diff --git a/packages/package-builder/templates/cli-sentry-package/logo-light.png b/packages/package-builder/templates/cli-sentry-package/logo-light.png deleted file mode 100644 index 7f14ce68f..000000000 Binary files a/packages/package-builder/templates/cli-sentry-package/logo-light.png and /dev/null differ diff --git a/packages/package-builder/templates/cli-sentry-package/package.json b/packages/package-builder/templates/cli-sentry-package/package.json deleted file mode 100644 index bacd5daf1..000000000 --- a/packages/package-builder/templates/cli-sentry-package/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@socketsecurity/cli-with-sentry", - "version": "2.1.0", - "private": true, - "description": "Socket CLI with Sentry telemetry for enhanced error reporting", - "keywords": [ - "cli", - "security", - "sentry", - "socket", - "telemetry" - ], - "homepage": "https://github.com/SocketDev/socket-cli", - "license": "MIT", - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git", - "directory": "packages/cli-with-sentry" - }, - "bin": { - "socket": "bin/cli.js", - "socket-npm": "bin/npm-cli.js", - "socket-npx": "bin/npx-cli.js", - "socket-pnpm": "bin/pnpm-cli.js", - "socket-yarn": "bin/yarn-cli.js" - }, - "files": [ - "CHANGELOG.md", - "LICENSE", - "bin/**", - "data/**", - "dist/**", - "!dist/cli.js", - "logo-dark.png", - "logo-light.png" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "scripts": { - "build": "node scripts/build.mts", - "clean:dist": "del-cli 'dist'", - "test": "vitest run", - "verify": "node scripts/verify-package.mts" - }, - "dependencies": { - "@sentry/node": "catalog:" - }, - "devDependencies": { - "@socketsecurity/cli": "workspace:*", - "build-infra": "workspace:*", - "rolldown": "catalog:" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/package-builder/templates/cli-sentry-package/scripts/build.mts b/packages/package-builder/templates/cli-sentry-package/scripts/build.mts deleted file mode 100644 index edef881aa..000000000 --- a/packages/package-builder/templates/cli-sentry-package/scripts/build.mts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @file Build script for Socket CLI with Sentry. Delegates to rolldown config - * for actual build. Copies data/ and images from packages/cli. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') -const repoRoot = path.join(__dirname, '../../..') - -const logger = getDefaultLogger() - -async function main() { - try { - const cliPath = path.join(rootPath, '..', 'cli') - - // Build CLI bundle. - logger.info('Building CLI bundle...') - let result = await spawn('node', ['.config/rolldown.cli-sentry.build.mts'], { - shell: WIN32, - stdio: 'inherit', - cwd: rootPath, - }) - if (!result) { - throw new Error('Failed to start CLI bundle build') - } - if (result.code !== 0) { - throw new Error(`CLI bundle build failed with exit code ${result.code}`) - } - logger.success('Built CLI bundle') - - // Build index loader. - logger.info('Building index loader...') - result = await spawn('node', ['.config/rolldown.index.mts'], { - shell: WIN32, - stdio: 'inherit', - cwd: rootPath, - }) - if (!result) { - throw new Error('Failed to start index loader build') - } - if (result.code !== 0) { - throw new Error(`Index loader build failed with exit code ${result.code}`) - } - logger.success('Built index loader') - - // Copy CLI to dist. - logger.info('Copying CLI to dist...') - await fs.copyFile( - path.join(rootPath, 'build', 'cli.js'), - path.join(rootPath, 'dist', 'cli.js'), - ) - logger.success('Copied CLI to dist') - - // Copy data directory from packages/cli. - logger.info('Copying data/ from packages/cli...') - await fs.cp(path.join(cliPath, 'data'), path.join(rootPath, 'data'), { - recursive: true, - }) - logger.success('Copied data/') - - // Copy files from repo root. - logger.info('Copying files from repo root...') - const filesToCopy = [ - 'CHANGELOG.md', - 'LICENSE', - 'logo-dark.png', - 'logo-light.png', - ] - for (let i = 0, { length } = filesToCopy; i < length; i += 1) { - const file = filesToCopy[i] - await fs.cp(path.join(repoRoot, file), path.join(rootPath, file)) - } - logger.success('Copied files from repo root') - } catch (e) { - logger.error(`Build failed: ${e.message}`) - process.exitCode = 1 - } -} - -main() diff --git a/packages/package-builder/templates/cli-sentry-package/scripts/verify-package.mts b/packages/package-builder/templates/cli-sentry-package/scripts/verify-package.mts deleted file mode 100644 index e4e171cf1..000000000 --- a/packages/package-builder/templates/cli-sentry-package/scripts/verify-package.mts +++ /dev/null @@ -1,168 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import colors from 'yoctocolors-cjs' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { existsSync } from 'node:fs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const packageRoot = path.resolve(__dirname, '..') -const logger = getDefaultLogger() - -/** - * Check if a file exists. - */ -export function fileExists(filePath) { - return existsSync(filePath) -} - -/** - * Main validation function. - */ -export async function validate() { - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('CLI with Sentry Package Validation')}`) - logger.log('='.repeat(60)) - logger.log('') - - const errors = [] - - // Check package.json exists and validate Sentry configuration. - logger.info('Checking package.json...') - const pkgPath = path.join(packageRoot, 'package.json') - if (!(await existsSync(pkgPath))) { - errors.push('package.json does not exist') - } else { - logger.success('package.json exists') - - // Validate package.json configuration. - const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) - - // Check @sentry/node is in dependencies. - if (!pkg.dependencies?.['@sentry/node']) { - errors.push('package.json missing @sentry/node in dependencies') - } else { - logger.success('@sentry/node is in dependencies') - } - - // Validate files array. - const requiredInFiles = [ - 'CHANGELOG.md', - 'LICENSE', - 'data/**', - 'dist/**', - 'logo-dark.png', - 'logo-light.png', - ] - for (let i = 0, { length } = requiredInFiles; i < length; i += 1) { - const required = requiredInFiles[i] - if (!pkg.files?.includes(required)) { - errors.push(`package.json files array missing: ${required}`) - } - } - if (errors.length === 0) { - logger.success('package.json files array is correct') - } - } - - // Check root files exist (LICENSE, CHANGELOG.md). - const rootFiles = ['LICENSE', 'CHANGELOG.md'] - for (let i = 0, { length } = rootFiles; i < length; i += 1) { - const file = rootFiles[i] - logger.info(`Checking ${file}...`) - const filePath = path.join(packageRoot, file) - if (!(await existsSync(filePath))) { - errors.push(`${file} does not exist`) - } else { - logger.success(`${file} exists`) - } - } - - // Check dist files exist and validate Sentry integration. - const distFiles = ['index.js', 'cli.js'] - for (let i = 0, { length } = distFiles; i < length; i += 1) { - const file = distFiles[i] - logger.info(`Checking dist/${file}...`) - const filePath = path.join(packageRoot, 'dist', file) - if (!(await existsSync(filePath))) { - errors.push(`dist/${file} does not exist`) - } else { - logger.success(`dist/${file} exists`) - } - } - - // Verify Sentry is referenced in the build (check for @sentry/node require). - logger.info('Checking for Sentry integration in build...') - const buildPath = path.join(packageRoot, 'build', 'cli.js') - if (await existsSync(buildPath)) { - const buildContent = await fs.readFile(buildPath, 'utf-8') - if (!buildContent.includes('@sentry/node')) { - errors.push('Sentry integration not found in build/cli.js') - } else { - logger.success('Sentry integration found in build') - } - } else { - errors.push('build/cli.js does not exist (required for Sentry validation)') - } - - // Check data directory exists. - logger.info('Checking data directory...') - const dataPath = path.join(packageRoot, 'data') - if (!(await existsSync(dataPath))) { - errors.push('data directory does not exist') - } else { - logger.success('data directory exists') - - // Check data files. - const dataFiles = [ - 'alert-translations.json', - 'command-api-requirements.json', - ] - for (let i = 0, { length } = dataFiles; i < length; i += 1) { - const file = dataFiles[i] - logger.info(`Checking data/${file}...`) - const filePath = path.join(dataPath, file) - if (!(await existsSync(filePath))) { - errors.push(`data/${file} does not exist`) - } else { - logger.success(`data/${file} exists`) - } - } - } - - // Print summary. - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('Validation Summary')}`) - logger.log('='.repeat(60)) - logger.log('') - - if (errors.length > 0) { - logger.log(`${colors.red('Errors:')}`) - for (let i = 0, { length } = errors; i < length; i += 1) { - const err = errors[i] - logger.fail(` ${err}`) - } - logger.log('') - logger.fail('Package validation FAILED') - logger.log('') - process.exitCode = 1 - return - } - - logger.success('Package validation PASSED') - logger.log('') -} - -// Run validation. -validate().catch(e => { - logger.error('') - logger.fail(`Unexpected error: ${e.message}`) - logger.error('') - process.exitCode = 1 -}) diff --git a/packages/package-builder/templates/cli-sentry-package/test/package.test.mts b/packages/package-builder/templates/cli-sentry-package/test/package.test.mts deleted file mode 100644 index 2be0b9c2b..000000000 --- a/packages/package-builder/templates/cli-sentry-package/test/package.test.mts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * @file Tests for @socketsecurity/cli-with-sentry package structure and - * configuration. - */ - -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { describe, expect, it } from 'vitest' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const packageDir = path.join(__dirname, '..') -const configDir = path.join(packageDir, '.config') -const binDir = path.join(packageDir, 'bin') -const scriptsDir = path.join(packageDir, 'scripts') - -describe('@socketsecurity/cli-with-sentry package', () => { - describe('package.json validation', () => { - it('should have valid package.json metadata', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.name).toBe('@socketsecurity/cli-with-sentry') - expect(pkgJson.version).toMatch(/^\d+\.\d+\.\d+$/) - expect(pkgJson.license).toBe('MIT') - expect(pkgJson.description).toContain('Sentry') - expect(pkgJson.description).toContain('telemetry') - }) - - it('should have build script', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.scripts).toBeDefined() - expect(pkgJson.scripts.build).toBe('node scripts/build.mts') - expect(pkgJson.scripts['clean:dist']).toBeDefined() - }) - - it('should have all CLI bin entries', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.bin).toBeDefined() - expect(pkgJson.bin.socket).toBe('bin/cli.js') - expect(pkgJson.bin['socket-npm']).toBe('bin/npm-cli.js') - expect(pkgJson.bin['socket-npx']).toBe('bin/npx-cli.js') - expect(pkgJson.bin['socket-pnpm']).toBe('bin/pnpm-cli.js') - expect(pkgJson.bin['socket-yarn']).toBe('bin/yarn-cli.js') - }) - - it('should depend on main CLI package', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.dependencies).toBeDefined() - expect(pkgJson.dependencies['@socketsecurity/cli']).toBe('workspace:*') - }) - - it('should have Sentry as devDependency', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.devDependencies).toBeDefined() - expect(pkgJson.devDependencies['@sentry/node']).toBeDefined() - expect(pkgJson.devDependencies.rolldown).toBeDefined() - }) - }) - - describe('bin wrappers exist', () => { - it('should have bin directory', () => { - expect(existsSync(binDir)).toBe(true) - }) - - it('should have cli.js wrapper', () => { - const cliPath = path.join(binDir, 'cli.js') - expect(existsSync(cliPath)).toBe(true) - }) - - it('should have npm-cli.js wrapper', () => { - const npmPath = path.join(binDir, 'npm-cli.js') - expect(existsSync(npmPath)).toBe(true) - }) - - it('should have npx-cli.js wrapper', () => { - const npxPath = path.join(binDir, 'npx-cli.js') - expect(existsSync(npxPath)).toBe(true) - }) - - it('should have pnpm-cli.js wrapper', () => { - const pnpmPath = path.join(binDir, 'pnpm-cli.js') - expect(existsSync(pnpmPath)).toBe(true) - }) - - it('should have yarn-cli.js wrapper', () => { - const yarnPath = path.join(binDir, 'yarn-cli.js') - expect(existsSync(yarnPath)).toBe(true) - }) - - it('bin wrappers should be executable', async () => { - const cliPath = path.join(binDir, 'cli.js') - const content = await fs.readFile(cliPath, 'utf-8') - - expect(content).toContain('#!/usr/bin/env node') - expect(content).toContain('dist/cli.js') - }) - }) - - describe('build configuration', () => { - it('should have .config directory', () => { - expect(existsSync(configDir)).toBe(true) - }) - - it('should have rolldown config', () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - expect(existsSync(rolldownPath)).toBe(true) - }) - - it('rolldown config should import base config', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain( - "import baseConfig from '../../cli/.config/rolldown.cli.mts'", - ) - }) - - it('rolldown config should enable Sentry build flag', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('INLINED_SENTRY_BUILD') - expect(content).toContain("JSON.stringify('1')") - }) - - it('rolldown config should use CLI dispatch with Sentry entry point', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('cli-dispatch-with-sentry.mts') - }) - - it('rolldown config should call build() when run', async () => { - const rolldownPath = path.join(configDir, 'rolldown.cli-sentry.build.mts') - const content = await fs.readFile(rolldownPath, 'utf-8') - - expect(content).toContain('build(config)') - expect(content).toContain('import.meta.url') - }) - }) - - describe('build scripts exist', () => { - it('should have scripts directory', () => { - expect(existsSync(scriptsDir)).toBe(true) - }) - - it('should have build.mts script', () => { - const buildPath = path.join(scriptsDir, 'build.mts') - expect(existsSync(buildPath)).toBe(true) - }) - - it('build.mts should be valid JavaScript', async () => { - const buildPath = path.join(scriptsDir, 'build.mts') - const content = await fs.readFile(buildPath, 'utf-8') - - expect(content).toBeTruthy() - expect(content).toContain('import') - expect(content).toContain('rolldown.cli-sentry.build.mts') - }) - }) - - describe('README documentation', () => { - it('should have README.md', () => { - const readmePath = path.join(packageDir, 'README.md') - expect(existsSync(readmePath)).toBe(true) - }) - - it('README should document Sentry integration', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Sentry') - expect(readme).toContain('telemetry') - expect(readme).toContain('error') - }) - - it('README should document telemetry', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Telemetry') - expect(readme).toContain('No sensitive data') - }) - - it('README should document differences from main CLI', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('Differences') - expect(readme).toContain('@socketsecurity/cli') - }) - - it('README should document privacy', async () => { - const readmePath = path.join(packageDir, 'README.md') - const readme = await fs.readFile(readmePath, 'utf-8') - - expect(readme).toContain('No sensitive data') - expect(readme.toLowerCase()).toContain('api token') - }) - }) - - describe('package is publishable', () => { - it('should be marked as private', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - // cli-with-sentry is private during development but will be public on release. - expect(pkgJson.private).toBe(true) - }) - - it('should have publishConfig for npm', async () => { - const pkgJson = JSON.parse( - await fs.readFile(path.join(packageDir, 'package.json'), 'utf-8'), - ) - - expect(pkgJson.publishConfig).toBeDefined() - expect(pkgJson.publishConfig.access).toBe('public') - expect(pkgJson.publishConfig.registry).toBe('https://registry.npmjs.org/') - }) - }) - - describe('Sentry integration', () => { - it('main CLI should have CLI dispatch with Sentry entry point', () => { - const sentryEntryPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'cli-dispatch-with-sentry.mts', - ) - expect(existsSync(sentryEntryPath)).toBe(true) - }) - - it('CLI dispatch with Sentry should import instrumentation first', async () => { - const sentryEntryPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'cli-dispatch-with-sentry.mts', - ) - const content = await fs.readFile(sentryEntryPath, 'utf-8') - - expect(content).toContain("import './instrument-with-sentry.mts'") - expect(content).toContain("import './cli-dispatch.mts'") - - // Verify Sentry import comes before CLI dispatch. - const sentryImportIndex = content.indexOf('instrument-with-sentry') - const dispatchImportIndex = content.indexOf('cli-dispatch.mts') - expect(sentryImportIndex).toBeLessThan(dispatchImportIndex) - }) - - it('main CLI should have Sentry instrumentation', () => { - const sentryInstrumentPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'instrument-with-sentry.mts', - ) - expect(existsSync(sentryInstrumentPath)).toBe(true) - }) - - it('Sentry instrumentation should check build flag', async () => { - const sentryInstrumentPath = path.join( - packageDir, - '..', - 'cli', - 'src', - 'instrument-with-sentry.mts', - ) - const content = await fs.readFile(sentryInstrumentPath, 'utf-8') - - expect(content).toContain('INLINED_SENTRY_BUILD') - expect(content).toContain('@sentry/node') - expect(content).toContain('Sentry.init') - }) - }) - - // Note: Actual build execution tests are pending implementation. - // These tests validate package structure and configuration only. - describe.skip('build execution (requires implementation)', () => { - it('should build Sentry-enabled CLI', async () => { - // Test pending: verify build creates dist/cli.js with Sentry. - }) - - it('should include Sentry SDK in bundle', async () => { - // Test pending: verify @sentry/node is bundled, not externalized. - }) - - it('should set SENTRY_BUILD flag in output', async () => { - // Test pending: verify flag is set to "1" in built code. - }) - }) -}) diff --git a/packages/package-builder/templates/cli-sentry-package/vitest.config.mts b/packages/package-builder/templates/cli-sentry-package/vitest.config.mts deleted file mode 100644 index ee5be7a7f..000000000 --- a/packages/package-builder/templates/cli-sentry-package/vitest.config.mts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extends shared simple vitest config. - */ -import baseConfig from '../../vitest.config.simple.mts' - -export { baseConfig } diff --git a/packages/package-builder/templates/socket-package/README.md b/packages/package-builder/templates/socket-package/README.md deleted file mode 100644 index 7ad45e8df..000000000 --- a/packages/package-builder/templates/socket-package/README.md +++ /dev/null @@ -1,97 +0,0 @@ -<picture> - <source media="(prefers-color-scheme: dark)" srcset="logo-light.png"> - <source media="(prefers-color-scheme: light)" srcset="logo-dark.png"> - <img alt="Socket" src="logo-dark.png" width="200"> -</picture> - -# Socket CLI - -[![Socket Badge](https://socket.dev/api/badge/npm/package/socket)](https://socket.dev/npm/package/socket) - -CLI for [Socket.dev](https://socket.dev) security analysis - -## Quick Start - -```bash -npm install -g socket -socket --help -``` - -## Core Commands - -- `socket npm [args...]` / `socket npx [args...]` - Wrap npm/npx with security scanning -- `socket pnpm [args...]` / `socket yarn [args...]` - Wrap pnpm/yarn with security scanning -- `socket pip [args...]` - Wrap pip with security scanning -- `socket scan` - Create and manage security scans -- `socket package <name>` - Analyze package security scores -- `socket fix` - Fix CVEs in dependencies -- `socket optimize` - Optimize dependencies with [`@socketregistry`](https://github.com/SocketDev/socket-registry) overrides -- `socket manifest [command]` - Generate and manage SBOMs for multiple ecosystems - - `socket cdxgen [command]` - Alias for `socket manifest cdxgen` - Run [cdxgen](https://github.com/cdxgen/cdxgen) for SBOM generation - -## Organization & Repository Management - -- `socket organization` (alias: `org`) - Manage organization settings -- `socket repository` (alias: `repo`) - Manage repositories -- `socket dependencies` (alias: `deps`) - View organization dependencies -- `socket audit-log` (alias: `audit`) - View audit logs -- `socket analytics` - View organization analytics -- `socket threat-feed` (alias: `feed`) - View threat intelligence - -## Authentication & Configuration - -- `socket login` - Authenticate with Socket.dev -- `socket logout` - Remove authentication -- `socket whoami` - Show authenticated user -- `socket config` - Manage CLI configuration - -## Aliases - -All aliases support the flags and arguments of the commands they alias. - -- `socket ci` - Alias for `socket scan create --report` (creates report and exits with error if unhealthy) -- `socket org` - Alias for `socket organization` -- `socket repo` - Alias for `socket repository` -- `socket pkg` - Alias for `socket package` -- `socket deps` - Alias for `socket dependencies` -- `socket audit` - Alias for `socket audit-log` -- `socket feed` - Alias for `socket threat-feed` - -## Flags - -### Output flags - -These flags are available on data-retrieval commands (scan, package, organization, etc.): - -- `--json` - Output as JSON -- `--markdown` - Output as Markdown - -### Other flags - -- `--dry-run` - Run without uploading -- `--help` - Show help -- `--version` - Show version - -## Configuration files - -Socket CLI reads [`socket.yml`](https://docs.socket.dev/docs/socket-yml) configuration files. -Supports version 2 format with `projectIgnorePaths` for excluding files from reports. - -## Environment variables - -- `SOCKET_CLI_API_TOKEN` - Socket API token -- `SOCKET_CLI_ORG_SLUG` - Socket organization slug -- `SOCKET_CLI_DEBUG` - Enable debug logging (set to `1`) -- `SOCKET_CLI_CONFIG` - JSON configuration object - -For full documentation, see the [Socket CLI repository](https://github.com/SocketDev/socket-cli). - -## See also - -- [Socket API Reference](https://docs.socket.dev/reference) -- [Socket GitHub App](https://github.com/apps/socket-security) -- [`@socketsecurity/sdk`](https://github.com/SocketDev/socket-sdk-js) - -## License - -[MIT](./LICENSE) diff --git a/packages/package-builder/templates/socket-package/bin/socket.js b/packages/package-builder/templates/socket-package/bin/socket.js deleted file mode 100644 index 4a1029fc0..000000000 --- a/packages/package-builder/templates/socket-package/bin/socket.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -/** - * Socket CLI bootstrap loader. - * - * Finds and executes the appropriate @socketbin/* SEA binary based on the - * current platform and architecture. - */ - -'use strict' - -const { existsSync } = require('node:fs') -const { arch, platform } = require('node:os') -const { join } = require('node:path') -const { spawn } = require('node:child_process') - -// Get the socketbin package name for current platform. -export function getSocketbinPackageName() { - const p = platform() - const a = arch() - const musl = isMusl() ? '-musl' : '' - return `@socketbin/cli-${p}-${a}${musl}` -} - -// Get path to the socket binary. -export function getSocketbinPath() { - const packageName = getSocketbinPackageName() - const binaryName = platform() === 'win32' ? 'socket.exe' : 'socket' - - // Try to find the binary in node_modules. - const paths = [ - // Installed as dependency. - join(__dirname, '..', 'node_modules', packageName, binaryName), - // Hoisted to parent node_modules. - join(__dirname, '..', '..', packageName, binaryName), - // Workspace/monorepo layout. - join(__dirname, '..', '..', '..', 'node_modules', packageName, binaryName), - ] - - for (let i = 0, { length } = paths; i < length; i += 1) { - const p = paths[i] - if (existsSync(p)) { - return p - } - } - - return undefined -} - -// Detect musl libc on Linux. -export function isMusl() { - if (platform() !== 'linux') { - return false - } - try { - // Check if we're running on Alpine/musl by looking at the libc. - const { execSync } = require('node:child_process') - const lddOutput = execSync('ldd --version 2>&1 || true', { - encoding: 'utf8', - }) - return lddOutput.includes('musl') - } catch { - return false - } -} - -// Main entry point. -function main() { - const binaryPath = getSocketbinPath() - - if (!binaryPath) { - const packageName = getSocketbinPackageName() - logger.fail(`Socket CLI binary not found for your platform.`) - logger.fail(`Expected package: ${packageName}`) - logger.fail(``) - logger.fail(`This may happen if:`) - logger.fail(` - Your platform is not supported`) - logger.fail(` - The optional dependency failed to install`) - logger.fail(``) - logger.fail(`Try reinstalling: npm install -g socket`) - process.exit(1) - } - - // Spawn the binary with all arguments. - const child = spawn(binaryPath, process.argv.slice(2), { - stdio: 'inherit', - }) - - child.on('error', err => { - logger.fail(`Failed to start Socket CLI: ${err.message}`) - process.exit(1) - }) - - child.on('exit', (code, signal) => { - if (signal) { - process.exit(1) - } - process.exit(code ?? 0) - }) -} - -main() diff --git a/packages/package-builder/templates/socket-package/logo-dark.png b/packages/package-builder/templates/socket-package/logo-dark.png deleted file mode 100644 index 665ec2782..000000000 Binary files a/packages/package-builder/templates/socket-package/logo-dark.png and /dev/null differ diff --git a/packages/package-builder/templates/socket-package/logo-light.png b/packages/package-builder/templates/socket-package/logo-light.png deleted file mode 100644 index 7f14ce68f..000000000 Binary files a/packages/package-builder/templates/socket-package/logo-light.png and /dev/null differ diff --git a/packages/package-builder/templates/socket-package/package.json b/packages/package-builder/templates/socket-package/package.json deleted file mode 100644 index 8dabd0025..000000000 --- a/packages/package-builder/templates/socket-package/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "socket", - "version": "2.1.0", - "private": true, - "description": "Socket CLI for Socket.dev", - "homepage": "https://github.com/SocketDev/socket-cli", - "license": "MIT", - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git" - }, - "bin": { - "socket": "./bin/socket.js" - }, - "files": [ - "bin/**", - "CHANGELOG.md", - "LICENSE", - "logo-dark.png", - "logo-light.png" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "optionalDependencies": { - "@socketbin/cli-darwin-arm64": "0.0.0-replaced-by-publish", - "@socketbin/cli-darwin-x64": "0.0.0-replaced-by-publish", - "@socketbin/cli-linux-arm64": "0.0.0-replaced-by-publish", - "@socketbin/cli-linux-arm64-musl": "0.0.0-replaced-by-publish", - "@socketbin/cli-linux-x64": "0.0.0-replaced-by-publish", - "@socketbin/cli-linux-x64-musl": "0.0.0-replaced-by-publish", - "@socketbin/cli-win-arm64": "0.0.0-replaced-by-publish", - "@socketbin/cli-win-x64": "0.0.0-replaced-by-publish" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/package-builder/templates/socketaddon-main/LICENSE b/packages/package-builder/templates/socketaddon-main/LICENSE deleted file mode 100644 index 4ba88869d..000000000 --- a/packages/package-builder/templates/socketaddon-main/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Socket Inc - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/package-builder/templates/socketaddon-main/README.md b/packages/package-builder/templates/socketaddon-main/README.md deleted file mode 100644 index ed51fa873..000000000 --- a/packages/package-builder/templates/socketaddon-main/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# @socketaddon/iocraft - -Node.js bindings for [iocraft](https://github.com/ccbrown/iocraft) - a Rust-based terminal user interface (TUI) library. - -## Installation - -```bash -npm install @socketaddon/iocraft -``` - -The package will automatically install the correct native binary for your platform. - -## Supported Platforms - -- **macOS**: ARM64 (Apple Silicon), x64 (Intel) -- **Linux**: ARM64, x64 (both glibc and musl) -- **Windows**: ARM64, x64 - -## Usage - -```javascript -import iocraft from '@socketaddon/iocraft' - -// Create a text element -const textNode = iocraft.text('Hello from iocraft!') - -// Create a view with the text element -const element = iocraft.view([textNode]) - -// Print the component to stdout (camelCase or snake_case - both work!) -iocraft.printComponent(element) - -// Or render to a string -const output = iocraft.renderToString(element) -console.log(output) -``` - -## API Documentation - -This package provides Node.js bindings to the [iocraft](https://github.com/ccbrown/iocraft) Rust library. Both camelCase and snake_case naming conventions are supported: - -- **Import**: Use `import iocraft from '@socketaddon/iocraft'` instead of Rust imports -- **Functions**: Use either camelCase (`printComponent`, `renderToString`) or snake_case (`print_component`, `render_to_string`) -- **Properties**: Use either camelCase (`flexDirection`, `paddingLeft`) or snake_case (`flex_direction`, `padding_left`) - -For comprehensive API documentation, see the [official iocraft documentation](https://github.com/ccbrown/iocraft#readme). diff --git a/packages/package-builder/templates/socketaddon-main/index.d.ts b/packages/package-builder/templates/socketaddon-main/index.d.ts deleted file mode 100644 index 9414323c0..000000000 --- a/packages/package-builder/templates/socketaddon-main/index.d.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * TypeScript definitions for @socketaddon/iocraft. - * - * Node.js bindings for iocraft TUI library. - */ - -/** - * Component node in the render tree. - */ -export interface ComponentNode { - children?: ComponentNode[] - type: 'View' | 'Text' - content?: string - - // Border properties - border_style?: string - border_color?: string - - // Background - background_color?: string - - // Text properties - color?: string - weight?: string - align?: string - wrap?: string - underline?: boolean - italic?: boolean - bold?: boolean - dim_color?: boolean - strikethrough?: boolean - - // Layout properties - flex_direction?: string - justify_content?: string - align_items?: string - flex_grow?: number - flex_shrink?: number - flex_basis?: number | string - flex_wrap?: string - overflow_x?: string - overflow_y?: string - - // Dimensions - width?: number - height?: number - width_percent?: number - height_percent?: number - - // Padding - padding?: number - padding_top?: number - padding_right?: number - padding_bottom?: number - padding_left?: number - padding_x?: number - padding_y?: number - - // Margin - margin?: number - margin_top?: number - margin_right?: number - margin_bottom?: number - margin_left?: number - margin_x?: number - margin_y?: number - - // Gap - gap?: number - row_gap?: number - column_gap?: number -} - -/** - * Create a simple text component node. - */ -export function text(content: string): ComponentNode - -/** - * Create a View/Box component node with children. - */ -export function view(children: ComponentNode[]): ComponentNode - -/** - * Render a component tree to a string (no terminal interaction). - */ -export function renderToString(tree: ComponentNode): string - -/** - * Render a component tree to a string with a maximum width. - */ -export function renderToStringWithWidth( - tree: ComponentNode, - maxWidth: number, -): string - -/** - * Render a component tree and print to stdout. - */ -export function printComponent(tree: ComponentNode): void - -/** - * Render a component tree and print to stderr. - */ -export function eprintComponent(tree: ComponentNode): void - -/** - * Get the current terminal size. Returns [width, height] in characters. - */ -export function getTerminalSize(): [number, number] - -/** - * Interactive TUI renderer with state management. - */ -export class TuiRenderer { - constructor() - setTree(tree: ComponentNode): Promise<void> - isRunning(): boolean - getSize(): [number, number] - renderOnce(): Promise<string> - renderWithWidth(maxWidth: number): Promise<string> - print(): Promise<void> - eprint(): Promise<void> -} - -/** - * Initialize the iocraft module. - */ -export function init(): void - -declare const iocraft: { - text: typeof text - view: typeof view - renderToString: typeof renderToString - renderToStringWithWidth: typeof renderToStringWithWidth - printComponent: typeof printComponent - eprintComponent: typeof eprintComponent - getTerminalSize: typeof getTerminalSize - TuiRenderer: typeof TuiRenderer - init: typeof init -} - -export default iocraft diff --git a/packages/package-builder/templates/socketaddon-main/index.mjs b/packages/package-builder/templates/socketaddon-main/index.mjs deleted file mode 100644 index 292b37767..000000000 --- a/packages/package-builder/templates/socketaddon-main/index.mjs +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @socketaddon/iocraft - Node.js bindings for iocraft TUI library - * - * Platform detection and native addon loading. - * Automatically loads the correct .node binary for the current platform. - */ - -import { createRequire } from 'node:module' -import os from 'node:os' - -const require = createRequire(import.meta.url) - -/** - * Detect the current platform and architecture. - * - * @returns {string} Platform identifier (e.g., 'darwin-arm64', - * 'linux-x64-musl') - */ -export function getPlatformIdentifier() { - const platformName = platform() - const archName = arch() - - // Map Node.js platform/arch to package names. - const platformMap = { - __proto__: null, - darwin: 'darwin', - linux: 'linux', - win32: 'win', - } - - const archMap = { - __proto__: null, - arm64: 'arm64', - x64: 'x64', - } - - const mappedPlatform = platformMap[platformName] - const mappedArch = archMap[archName] - - if (!mappedPlatform || !mappedArch) { - throw new Error( - `Unsupported platform: ${platformName} ${archName}\n` + - `iocraft native bindings are only available for:\n` + - ` - macOS (darwin): arm64, x64\n` + - ` - Linux (linux): arm64, x64 (glibc and musl)\n` + - ` - Windows (win32): arm64, x64`, - ) - } - - // Detect musl on Linux. - let libcSuffix = '' - if (platformName === 'linux') { - try { - const { spawnSync } = require('node:child_process') - const lddResult = spawnSync('ldd', ['--version'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - const output = lddResult.stdout || '' - if (output.includes('musl')) { - libcSuffix = '-musl' - } - } catch { - // If ldd fails, assume glibc. - } - } - - return `${mappedPlatform}-${mappedArch}${libcSuffix}` -} - -/** - * Load the native addon for the current platform. - * - * @returns {object} The loaded iocraft native module - */ -export function loadNativeAddon() { - const platformId = getPlatformIdentifier() - const packageName = `@socketaddon/iocraft-${platformId}` - - try { - // Try to load from optionalDependencies first. - return require(packageName) - } catch (e) { - // Fallback for development: resolve based on actual package location. - try { - const { dirname, join, resolve } = require('node:path') - const { fileURLToPath } = require('node:url') - const { realpathSync, existsSync } = require('node:fs') - - // Get the real path of this module (resolves pnpm symlinks). - const __dirname = dirname(fileURLToPath(import.meta.url)) - const realDir = realpathSync(__dirname) - - // Check if we're in the build output directory structure. - // Expected: .../build/dev/out/socketaddon-iocraft - // OR pnpm virtual store: .../@socketaddon+iocraft@file+packages+package-builder+build+dev+out+socketaddon-iocraft/... - let buildOutDir - - if ( - realDir.includes('/build/') && - realDir.includes('/out/socketaddon-iocraft') - ) { - // Direct path to build output. - buildOutDir = realDir.split('/socketaddon-iocraft')[0] - } else if ( - realDir.includes( - '@socketaddon+iocraft@file+packages+package-builder+build+dev+out+socketaddon-iocraft', - ) - ) { - // pnpm virtual store - extract project root and reconstruct path. - const match = realDir.match( - /^(.+?)\/node_modules\/\.pnpm\/@socketaddon/, - ) - if (match) { - const projectRoot = match[1] - buildOutDir = join( - projectRoot, - 'packages/package-builder/build/dev/out', - ) - } - } - - if (buildOutDir) { - // First: look for a sibling-package-style layout - // `socketaddon-iocraft-<platformId>/iocraft.node` next to the - // main package. - const siblingPath = join( - buildOutDir, - `socketaddon-iocraft-${platformId}`, - 'iocraft.node', - ) - if (existsSync(siblingPath)) { - return require(siblingPath) - } - // Second: look inside the main package's bundled - // `node_modules/@socketaddon/iocraft-<platformId>/iocraft.node`. - // This is where pnpm leaves the optionalDependency when it's - // installed into the file: package's local node_modules but - // not lifted into the consumer's .pnpm store (which happens - // for `file:` deps that declare optionalDependencies). - const bundledPath = join( - buildOutDir, - 'socketaddon-iocraft', - 'node_modules', - '@socketaddon', - `iocraft-${platformId}`, - 'iocraft.node', - ) - if (existsSync(bundledPath)) { - return require(bundledPath) - } - } - - throw new Error('Not in development build structure') - } catch (fallbackError) { - if (e.code === 'MODULE_NOT_FOUND') { - throw new Error( - `Failed to load iocraft native addon for ${platformId}.\n` + - `The package ${packageName} is not installed.\n` + - `This usually means your platform is not supported or the optionalDependencies were not installed correctly.\n\n` + - `Try reinstalling with: npm install --force @socketaddon/iocraft`, - ) - } - throw e - } - } -} - -// Load and export the native addon. -const iocraft = loadNativeAddon() - -export { iocraft } diff --git a/packages/package-builder/templates/socketaddon-main/package.json b/packages/package-builder/templates/socketaddon-main/package.json deleted file mode 100644 index 8fbe2ede8..000000000 --- a/packages/package-builder/templates/socketaddon-main/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@socketaddon/iocraft", - "version": "1.0.0-pre.0", - "description": "Node.js bindings for iocraft - a Rust-based TUI library", - "homepage": "https://github.com/SocketDev/socket-cli", - "license": "MIT", - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git" - }, - "files": [ - "LICENSE", - "README.md", - "index.d.ts", - "index.mjs" - ], - "type": "module", - "main": "./index.mjs", - "types": "./index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "default": "./index.mjs" - } - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "optionalDependencies": { - "@socketaddon/iocraft-darwin-arm64": "1.0.0-pre.0", - "@socketaddon/iocraft-darwin-x64": "1.0.0-pre.0", - "@socketaddon/iocraft-linux-arm64": "1.0.0-pre.0", - "@socketaddon/iocraft-linux-arm64-musl": "1.0.0-pre.0", - "@socketaddon/iocraft-linux-x64": "1.0.0-pre.0", - "@socketaddon/iocraft-linux-x64-musl": "1.0.0-pre.0", - "@socketaddon/iocraft-win-arm64": "1.0.0-pre.0", - "@socketaddon/iocraft-win-x64": "1.0.0-pre.0" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/package-builder/templates/socketaddon-package/.gitignore b/packages/package-builder/templates/socketaddon-package/.gitignore deleted file mode 100644 index 322f5393f..000000000 --- a/packages/package-builder/templates/socketaddon-package/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Binary will be downloaded during publish -iocraft.node diff --git a/packages/package-builder/templates/socketaddon-package/LICENSE b/packages/package-builder/templates/socketaddon-package/LICENSE deleted file mode 100644 index 4ba88869d..000000000 --- a/packages/package-builder/templates/socketaddon-package/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Socket Inc - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/package-builder/templates/socketaddon-package/README.md.template b/packages/package-builder/templates/socketaddon-package/README.md.template deleted file mode 100644 index 5ff82dec0..000000000 --- a/packages/package-builder/templates/socketaddon-package/README.md.template +++ /dev/null @@ -1,3 +0,0 @@ -# @socketaddon/iocraft-{{PLATFORM}}-{{ARCH}}{{LIBC_SUFFIX}} - -Native [iocraft](https://github.com/ccbrown/iocraft) addon for **{{DESCRIPTION}}**. diff --git a/packages/package-builder/templates/socketaddon-package/package.json.template b/packages/package-builder/templates/socketaddon-package/package.json.template deleted file mode 100644 index cbe3adae8..000000000 --- a/packages/package-builder/templates/socketaddon-package/package.json.template +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@socketaddon/iocraft-{{PLATFORM}}-{{ARCH}}{{LIBC_SUFFIX}}", - "version": "1.0.0-pre.0", - "description": "iocraft native addon ({{DESCRIPTION}})", - "license": "MIT", - "files": [ - "LICENSE", - "README.md", - "iocraft.node" - ], - "os": [ - "{{OS}}" - ], - "cpu": [ - "{{CPU}}" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git" - }, - "author": { - "name": "Socket Inc", - "email": "eng@socket.dev", - "url": "https://socket.dev" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/package-builder/templates/socketbin-package/.gitignore b/packages/package-builder/templates/socketbin-package/.gitignore deleted file mode 100644 index 285bf25bb..000000000 --- a/packages/package-builder/templates/socketbin-package/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -socket -socket.exe -*.blob diff --git a/packages/package-builder/templates/socketbin-package/README.md.template b/packages/package-builder/templates/socketbin-package/README.md.template deleted file mode 100644 index b16b30283..000000000 --- a/packages/package-builder/templates/socketbin-package/README.md.template +++ /dev/null @@ -1,3 +0,0 @@ -# @socketbin/cli-{{PLATFORM}}-{{ARCH}}{{LIBC_SUFFIX}} - -Native [Socket CLI](https://socket.dev/npm/package/socket) binary for **{{DESCRIPTION}}**. diff --git a/packages/package-builder/templates/socketbin-package/package.json.template b/packages/package-builder/templates/socketbin-package/package.json.template deleted file mode 100644 index 802d1d5aa..000000000 --- a/packages/package-builder/templates/socketbin-package/package.json.template +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@socketbin/cli-{{PLATFORM}}-{{ARCH}}{{LIBC_SUFFIX}}", - "version": "0.0.0-replaced-by-prepublish-socketbin", - "description": "Socket CLI binary ({{DESCRIPTION}})", - "private": true, - "license": "MIT", - "files": [ - "socket{{BIN_EXT}}" - ], - "os": [ - "{{OS}}" - ], - "cpu": [ - "{{CPU}}" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/SocketDev/socket-cli.git" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/patches/@npmcli+arborist#9.1.1.patch b/patches/@npmcli+arborist#9.1.1.patch new file mode 100644 index 000000000..acc8b0aaa --- /dev/null +++ b/patches/@npmcli+arborist#9.1.1.patch @@ -0,0 +1,21 @@ +Index: /@npmcli/arborist/lib/node.js +=================================================================== +--- /@npmcli/arborist/lib/node.js ++++ /@npmcli/arborist/lib/node.js +@@ -1156,9 +1156,15 @@ + } + + // if they're links, they match if the targets match + if (this.isLink) { +- return node.isLink && this.target.matches(node.target) ++ if (node.isLink) { ++ if (this.target && node.target) { ++ return this.target.matches(node.target) ++ } ++ } else { ++ return false ++ } + } + + // if they're two project root nodes, they're different if the paths differ + if (this.isProjectRoot && node.isProjectRoot) { diff --git a/patches/@npmcli__run-script@10.0.4.patch b/patches/@npmcli__run-script@10.0.4.patch deleted file mode 100644 index 98e0c4f83..000000000 --- a/patches/@npmcli__run-script@10.0.4.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/lib/make-spawn-args.js b/lib/make-spawn-args.js -index 1c9f02c062f72645b75947204a53457e181384e4..1bcef1c83fc07641edd4d33430d6b7eb5a26af26 100644 ---- a/lib/make-spawn-args.js -+++ b/lib/make-spawn-args.js -@@ -26,7 +26,7 @@ const makeSpawnArgs = options => { - npm_config_node_gyp = env.npm_config_node_gyp - } else { - // default -- npm_config_node_gyp = require.resolve('node-gyp/bin/node-gyp.js') -+ npm_config_node_gyp = require.resolve('node-' + 'gyp/bin/node-gyp.js') - } - - const spawnEnv = setPATH(path, binPaths, { diff --git a/patches/@rollup+plugin-commonjs#28.0.3.patch b/patches/@rollup+plugin-commonjs#28.0.3.patch new file mode 100644 index 000000000..2808dfe28 --- /dev/null +++ b/patches/@rollup+plugin-commonjs#28.0.3.patch @@ -0,0 +1,34 @@ +Index: /@rollup/plugin-commonjs/dist/cjs/index.js +=================================================================== +--- /@rollup/plugin-commonjs/dist/cjs/index.js ++++ /@rollup/plugin-commonjs/dist/cjs/index.js +@@ -377,10 +377,11 @@ + + export function getAugmentedNamespace(n) { + if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n; + var f = n.default; ++ var a + if (typeof f == "function") { +- var a = function a () { ++ a = function a () { + if (this instanceof a) { + return Reflect.construct(f, arguments, this.constructor); + } + return f.apply(this, arguments); +Index: /@rollup/plugin-commonjs/dist/es/index.js +=================================================================== +--- /@rollup/plugin-commonjs/dist/es/index.js ++++ /@rollup/plugin-commonjs/dist/es/index.js +@@ -373,10 +373,11 @@ + + export function getAugmentedNamespace(n) { + if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n; + var f = n.default; ++ var a + if (typeof f == "function") { +- var a = function a () { ++ a = function a () { + if (this instanceof a) { + return Reflect.construct(f, arguments, this.constructor); + } + return f.apply(this, arguments); diff --git a/patches/@sigstore__sign@4.1.0.patch b/patches/@sigstore__sign@4.1.0.patch deleted file mode 100644 index 399010c8d..000000000 --- a/patches/@sigstore__sign@4.1.0.patch +++ /dev/null @@ -1,25 +0,0 @@ -diff --git a/dist/external/fetch.js b/dist/external/fetch.js -index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644 ---- a/dist/external/fetch.js -+++ b/dist/external/fetch.js -@@ -19,13 +19,18 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ --const http2_1 = require("http2"); - const make_fetch_happen_1 = __importDefault(require("make-fetch-happen")); - const proc_log_1 = require("proc-log"); - const promise_retry_1 = __importDefault(require("promise-retry")); - const util_1 = require("../util"); - const error_1 = require("./error"); --const { HTTP2_HEADER_LOCATION, HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_USER_AGENT, HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_TOO_MANY_REQUESTS, HTTP_STATUS_REQUEST_TIMEOUT, } = http2_1.constants; -+// Inline HTTP header constants (lowercase, compatible with HTTP/1.1 and HTTP/2) -+const HTTP2_HEADER_LOCATION = 'location'; -+const HTTP2_HEADER_CONTENT_TYPE = 'content-type'; -+const HTTP2_HEADER_USER_AGENT = 'user-agent'; -+const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; -+const HTTP_STATUS_TOO_MANY_REQUESTS = 429; -+const HTTP_STATUS_REQUEST_TIMEOUT = 408; - async function fetchWithRetry(url, options) { - return (0, promise_retry_1.default)(async (retry, attemptNum) => { - const method = options.method || 'POST'; diff --git a/patches/ansi-term#0.0.2.patch b/patches/ansi-term#0.0.2.patch new file mode 100644 index 000000000..84432ebb8 --- /dev/null +++ b/patches/ansi-term#0.0.2.patch @@ -0,0 +1,124 @@ +Index: /ansi-term/index.js +=================================================================== +--- /ansi-term/index.js ++++ /ansi-term/index.js +@@ -23,75 +23,75 @@ + + function getFgCode(color) { + // String Value + if(typeof color == 'string' && color != 'normal') { +- return '\033[3' + exports.colors[color] + 'm'; ++ return '\x1B[3' + exports.colors[color] + 'm'; + } + // RGB Value + else if (Array.isArray(color) && color.length == 3) + { +- return '\033[38;5;' + x256(color[0],color[1],color[2]) + 'm'; ++ return '\x1B[38;5;' + x256(color[0],color[1],color[2]) + 'm'; + } + // Number + else if (typeof color == 'number') + { +- return '\033[38;5;' + color + 'm'; ++ return '\x1B[38;5;' + color + 'm'; + } + // Default + else + { +- return '\033[39m' ++ return '\x1B[39m' + } + } + + function getBgCode(color) { + // String Value + if(typeof color == 'string' && color != 'normal') { +- return '\033[4' + exports.colors[color] + 'm'; ++ return '\x1B[4' + exports.colors[color] + 'm'; + } + // RGB Value + else if (Array.isArray(color) && color.length == 3) + { +- return '\033[48;5;' + x256(color[0],color[1],color[2]) + 'm'; ++ return '\x1B[48;5;' + x256(color[0],color[1],color[2]) + 'm'; + } + // Number + else if (typeof color == 'number') + { +- return '\033[48;5;' + color + 'm'; ++ return '\x1B[48;5;' + color + 'm'; + } + // Default + else + { +- return '\033[49m' ++ return '\x1B[49m' + } + } + + var methods = { +- set: function(coord) { ++ set: function(coord) { + var color = getBgCode(this.color); +- this.content[coord] = color + ' \033[49m'; ++ this.content[coord] = color + ' \x1B[49m'; + }, +- unset: function(coord) { ++ unset: function(coord) { + this.content[coord] = null; + }, +- toggle: function(coord) { ++ toggle: function(coord) { + this.content[coord] == this.content[coord]==null?'p':null; + } + }; + + Object.keys(methods).forEach(function(method) { + AnsiTerminal.prototype[method] = function(x, y) { + if(!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + return; +- } ++ } + var coord = this.getCoord(x, y) + methods[method].call(this, coord); + } + }); + + AnsiTerminal.prototype.getCoord = function(x, y) { + x = Math.floor(x); +- y = Math.floor(y); ++ y = Math.floor(y); + return x + this.width*y; + } + + AnsiTerminal.prototype.clear = function() { +@@ -101,24 +101,24 @@ + AnsiTerminal.prototype.measureText = function(str) { + return {width: str.length * 1} + }; + +-AnsiTerminal.prototype.writeText = function(str, x, y) { ++AnsiTerminal.prototype.writeText = function(str, x, y) { + //console.log(str + ": " + x + "," + y) + var coord = this.getCoord(x, y) +- for (var i=0; i<str.length; i++) { ++ for (var i=0; i<str.length; i++) { + this.content[coord+i]=str[i] + } + + var bg = getBgCode(this.color); + var fg = getFgCode(this.fontFg); +- ++ + this.content[coord] = fg + bg + this.content[coord] +- this.content[coord+str.length-1] += '\033[39m\033[49m' ++ this.content[coord+str.length-1] += '\x1B[39m\x1B[49m' + + } + +-AnsiTerminal.prototype.frame = function frame(delimiter) { ++AnsiTerminal.prototype.frame = function frame(delimiter) { + delimiter = delimiter || '\n'; + var result = []; + for(var i = 0, j = 0; i < this.content.length; i++, j++) { + if(j == this.width) { diff --git a/patches/blessed#0.1.81.patch b/patches/blessed#0.1.81.patch new file mode 100644 index 000000000..8abe17f41 --- /dev/null +++ b/patches/blessed#0.1.81.patch @@ -0,0 +1,841 @@ +Index: /blessed/lib/gpmclient.js +=================================================================== +--- /blessed/lib/gpmclient.js ++++ /blessed/lib/gpmclient.js +@@ -189,9 +189,9 @@ + }); + } + } + +-GpmClient.prototype.__proto__ = EventEmitter.prototype; ++Object.setPrototypeOf(GpmClient.prototype, EventEmitter.prototype); + + GpmClient.prototype.stop = function() { + if (this.gpm) { + this.gpm.end(); +Index: /blessed/lib/program.js +=================================================================== +--- /blessed/lib/program.js ++++ /blessed/lib/program.js +@@ -34,9 +34,9 @@ + Program.bind(this); + + EventEmitter.call(this); + +- if (!options || options.__proto__ !== Object.prototype) { ++ if (!options || Object.getPrototypeOf(options) !== Object.prototype) { + options = { + input: arguments[0], + output: arguments[1] + }; +@@ -150,9 +150,9 @@ + }); + }); + }; + +-Program.prototype.__proto__ = EventEmitter.prototype; ++Object.setPrototypeOf(Program.prototype, EventEmitter.prototype); + + Program.prototype.type = 'program'; + + Program.prototype.log = function() { +@@ -194,9 +194,9 @@ + function caret(data) { + return data.replace(/[\0\x80\x1b-\x1f\x7f\x01-\x1a]/g, function(ch) { + switch (ch) { + case '\0': +- case '\200': ++ case '\x80': + ch = '@'; + break; + case '\x1b': + ch = '['; +@@ -1910,9 +1910,9 @@ + + //Program.prototype.pad = + Program.prototype.nul = function() { + //if (this.has('pad')) return this.put.pad(); +- return this._write('\200'); ++ return this._write('\x80'); + }; + + Program.prototype.bel = + Program.prototype.bell = function() { +Index: /blessed/lib/tput.js +=================================================================== +--- /blessed/lib/tput.js ++++ /blessed/lib/tput.js +@@ -365,9 +365,9 @@ + l = i + h.numCount * 2; + o = 0; + for (; i < l; i += 2) { + v = Tput.numbers[o++]; +- if (data[i + 1] === 0377 && data[i] === 0377) { ++ if (data[i + 1] === 0xFF && data[i] === 0xFF) { + info.numbers[v] = -1; + } else { + info.numbers[v] = (data[i + 1] << 8) | data[i]; + } +@@ -378,9 +378,9 @@ + l = i + h.strCount * 2; + o = 0; + for (; i < l; i += 2) { + v = Tput.strings[o++]; +- if (data[i + 1] === 0377 && data[i] === 0377) { ++ if (data[i + 1] === 0xFF && data[i] === 0xFF) { + info.strings[v] = -1; + } else { + info.strings[v] = (data[i + 1] << 8) | data[i]; + } +@@ -532,9 +532,9 @@ + // Numbers Section + var _numbers = []; + l = i + h.numCount * 2; + for (; i < l; i += 2) { +- if (data[i + 1] === 0377 && data[i] === 0377) { ++ if (data[i + 1] === 0xFF && data[i] === 0xFF) { + _numbers.push(-1); + } else { + _numbers.push((data[i + 1] << 8) | data[i]); + } +@@ -543,9 +543,9 @@ + // Strings Section + var _strings = []; + l = i + h.strCount * 2; + for (; i < l; i += 2) { +- if (data[i + 1] === 0377 && data[i] === 0377) { ++ if (data[i + 1] === 0xFF && data[i] === 0xFF) { + _strings.push(-1); + } else { + _strings.push((data[i + 1] << 8) | data[i]); + } +@@ -842,9 +842,9 @@ + + // '\e' -> ^[ + // '\n' -> \n + // '\r' -> \r +- // '\0' -> \200 (special case) ++ // '\0' -> \x80 (special case) + if (read(/^\\([eEnlrtbfs\^\\,:0]|.)/, true)) { + switch (ch) { + case 'e': + case 'E': +@@ -883,9 +883,9 @@ + case ':': + ch = ':'; + break; + case '0': +- ch = '\200'; ++ ch = '\x80'; + break; + case 'a': + ch = '\x07'; + break; +@@ -1900,9 +1900,9 @@ + // case '\r': + // out += '\\r'; + // i++; + // break; +-// case '\200': ++// case '\x80': + // out += '\\0'; + // i++; + // break; + // case '\f': +@@ -2093,12 +2093,12 @@ + && info.name.indexOf('screen') === 0 + && process.env.TERMCAP + && ~process.env.TERMCAP.indexOf('screen') + && ~process.env.TERMCAP.indexOf('hhII00')) { +- if (~info.strings.enter_alt_charset_mode.indexOf('\016') +- || ~info.strings.enter_alt_charset_mode.indexOf('\017') +- || ~info.strings.set_attributes.indexOf('\016') +- || ~info.strings.set_attributes.indexOf('\017')) { ++ if (~info.strings.enter_alt_charset_mode.indexOf('\x0E') ++ || ~info.strings.enter_alt_charset_mode.indexOf('\x0F') ++ || ~info.strings.set_attributes.indexOf('\x0E') ++ || ~info.strings.set_attributes.indexOf('\x0F')) { + return true; + } + } + +@@ -2275,9 +2275,9 @@ + case 's': // string + break; + case 'c': // char + param = isFinite(param) +- ? String.fromCharCode(param || 0200) ++ ? String.fromCharCode(param || 0x80) + : ''; + break; + } + +Index: /blessed/lib/widgets/ansiimage.js +=================================================================== +--- /blessed/lib/widgets/ansiimage.js ++++ /blessed/lib/widgets/ansiimage.js +@@ -52,9 +52,9 @@ + self.stop(); + }); + } + +-ANSIImage.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(ANSIImage.prototype, Box.prototype); + + ANSIImage.prototype.type = 'ansiimage'; + + ANSIImage.curl = function(url) { +Index: /blessed/lib/widgets/bigtext.js +=================================================================== +--- /blessed/lib/widgets/bigtext.js ++++ /blessed/lib/widgets/bigtext.js +@@ -35,9 +35,9 @@ + this.font = this.fontBold; + } + } + +-BigText.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(BigText.prototype, Box.prototype); + + BigText.prototype.type = 'bigtext'; + + BigText.prototype.loadFont = function(filename) { +Index: /blessed/lib/widgets/box.js +=================================================================== +--- /blessed/lib/widgets/box.js ++++ /blessed/lib/widgets/box.js +@@ -22,9 +22,9 @@ + options = options || {}; + Element.call(this, options); + } + +-Box.prototype.__proto__ = Element.prototype; ++Object.setPrototypeOf(Box.prototype, Element.prototype); + + Box.prototype.type = 'box'; + + /** +Index: /blessed/lib/widgets/button.js +=================================================================== +--- /blessed/lib/widgets/button.js ++++ /blessed/lib/widgets/button.js +@@ -42,9 +42,9 @@ + }); + } + } + +-Button.prototype.__proto__ = Input.prototype; ++Object.setPrototypeOf(Button.prototype, Input.prototype); + + Button.prototype.type = 'button'; + + Button.prototype.press = function() { +Index: /blessed/lib/widgets/checkbox.js +=================================================================== +--- /blessed/lib/widgets/checkbox.js ++++ /blessed/lib/widgets/checkbox.js +@@ -55,9 +55,9 @@ + self.screen.program.lrestoreCursor('checkbox', true); + }); + } + +-Checkbox.prototype.__proto__ = Input.prototype; ++Object.setPrototypeOf(Checkbox.prototype, Input.prototype); + + Checkbox.prototype.type = 'checkbox'; + + Checkbox.prototype.render = function() { +Index: /blessed/lib/widgets/element.js +=================================================================== +--- /blessed/lib/widgets/element.js ++++ /blessed/lib/widgets/element.js +@@ -220,9 +220,9 @@ + this.focus(); + } + } + +-Element.prototype.__proto__ = Node.prototype; ++Object.setPrototypeOf(Element.prototype, Node.prototype); + + Element.prototype.type = 'element'; + + Element.prototype.__defineGetter__('focused', function() { +Index: /blessed/lib/widgets/filemanager.js +=================================================================== +--- /blessed/lib/widgets/filemanager.js ++++ /blessed/lib/widgets/filemanager.js +@@ -64,9 +64,9 @@ + }); + }); + } + +-FileManager.prototype.__proto__ = List.prototype; ++Object.setPrototypeOf(FileManager.prototype, List.prototype); + + FileManager.prototype.type = 'file-manager'; + + FileManager.prototype.refresh = function(cwd, callback) { +Index: /blessed/lib/widgets/form.js +=================================================================== +--- /blessed/lib/widgets/form.js ++++ /blessed/lib/widgets/form.js +@@ -64,9 +64,9 @@ + }); + } + } + +-Form.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Form.prototype, Box.prototype); + + Form.prototype.type = 'form'; + + Form.prototype._refresh = function() { +Index: /blessed/lib/widgets/image.js +=================================================================== +--- /blessed/lib/widgets/image.js ++++ /blessed/lib/widgets/image.js +@@ -49,9 +49,9 @@ + + throw new Error('`type` must either be `ansi` or `overlay`.'); + } + +-Image.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Image.prototype, Box.prototype); + + Image.prototype.type = 'image'; + + /** +Index: /blessed/lib/widgets/input.js +=================================================================== +--- /blessed/lib/widgets/input.js ++++ /blessed/lib/widgets/input.js +@@ -22,9 +22,9 @@ + options = options || {}; + Box.call(this, options); + } + +-Input.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Input.prototype, Box.prototype); + + Input.prototype.type = 'input'; + + /** +Index: /blessed/lib/widgets/layout.js +=================================================================== +--- /blessed/lib/widgets/layout.js ++++ /blessed/lib/widgets/layout.js +@@ -37,9 +37,9 @@ + this.renderer = options.renderer; + } + } + +-Layout.prototype.__proto__ = Element.prototype; ++Object.setPrototypeOf(Layout.prototype, Element.prototype); + + Layout.prototype.type = 'layout'; + + Layout.prototype.isRendered = function(el) { +Index: /blessed/lib/widgets/line.js +=================================================================== +--- /blessed/lib/widgets/line.js ++++ /blessed/lib/widgets/line.js +@@ -44,9 +44,9 @@ + + this.style.border = this.style; + } + +-Line.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Line.prototype, Box.prototype); + + Line.prototype.type = 'line'; + + /** +Index: /blessed/lib/widgets/list.js +=================================================================== +--- /blessed/lib/widgets/list.js ++++ /blessed/lib/widgets/list.js +@@ -220,9 +220,9 @@ + self.removeItem(el); + }); + } + +-List.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(List.prototype, Box.prototype); + + List.prototype.type = 'list'; + + List.prototype.createItem = function(content) { +Index: /blessed/lib/widgets/listbar.js +=================================================================== +--- /blessed/lib/widgets/listbar.js ++++ /blessed/lib/widgets/listbar.js +@@ -102,9 +102,9 @@ + self.select(self.selected); + }); + } + +-Listbar.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Listbar.prototype, Box.prototype); + + Listbar.prototype.type = 'listbar'; + + Listbar.prototype.__defineGetter__('selected', function() { +Index: /blessed/lib/widgets/listtable.js +=================================================================== +--- /blessed/lib/widgets/listtable.js ++++ /blessed/lib/widgets/listtable.js +@@ -74,9 +74,9 @@ + self.screen.render(); + }); + } + +-ListTable.prototype.__proto__ = List.prototype; ++Object.setPrototypeOf(ListTable.prototype, List.prototype); + + ListTable.prototype.type = 'list-table'; + + ListTable.prototype._calculateMaxes = Table.prototype._calculateMaxes; +Index: /blessed/lib/widgets/loading.js +=================================================================== +--- /blessed/lib/widgets/loading.js ++++ /blessed/lib/widgets/loading.js +@@ -35,9 +35,9 @@ + content: '|' + }); + } + +-Loading.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Loading.prototype, Box.prototype); + + Loading.prototype.type = 'loading'; + + Loading.prototype.load = function(text) { +Index: /blessed/lib/widgets/log.js +=================================================================== +--- /blessed/lib/widgets/log.js ++++ /blessed/lib/widgets/log.js +@@ -45,9 +45,9 @@ + } + }); + } + +-Log.prototype.__proto__ = ScrollableText.prototype; ++Object.setPrototypeOf(Log.prototype, ScrollableText.prototype); + + Log.prototype.type = 'log'; + + Log.prototype.log = +Index: /blessed/lib/widgets/message.js +=================================================================== +--- /blessed/lib/widgets/message.js ++++ /blessed/lib/widgets/message.js +@@ -25,9 +25,9 @@ + + Box.call(this, options); + } + +-Message.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Message.prototype, Box.prototype); + + Message.prototype.type = 'message'; + + Message.prototype.log = +Index: /blessed/lib/widgets/node.js +=================================================================== +--- /blessed/lib/widgets/node.js ++++ /blessed/lib/widgets/node.js +@@ -15,10 +15,8 @@ + */ + + function Node(options) { + var self = this; +- var Screen = require('./screen'); +- + if (!(this instanceof Node)) { + return new Node(options); + } + +@@ -29,8 +27,9 @@ + + this.screen = this.screen || options.screen; + + if (!this.screen) { ++ var Screen = require('./screen'); + if (this.type === 'screen') { + this.screen = this; + } else if (Screen.total === 1) { + this.screen = Screen.global; +@@ -76,9 +75,9 @@ + } + + Node.uid = 0; + +-Node.prototype.__proto__ = EventEmitter.prototype; ++Object.setPrototypeOf(Node.prototype, EventEmitter.prototype); + + Node.prototype.type = 'node'; + + Node.prototype.insert = function(element, i) { +Index: /blessed/lib/widgets/overlayimage.js +=================================================================== +--- /blessed/lib/widgets/overlayimage.js ++++ /blessed/lib/widgets/overlayimage.js +@@ -115,9 +115,9 @@ + this.setImage(this.options.file || this.options.img); + } + } + +-OverlayImage.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(OverlayImage.prototype, Box.prototype); + + OverlayImage.prototype.type = 'overlayimage'; + + OverlayImage.w3mdisplay = '/usr/lib/w3m/w3mimgdisplay'; +Index: /blessed/lib/widgets/progressbar.js +=================================================================== +--- /blessed/lib/widgets/progressbar.js ++++ /blessed/lib/widgets/progressbar.js +@@ -91,9 +91,9 @@ + }); + } + } + +-ProgressBar.prototype.__proto__ = Input.prototype; ++Object.setPrototypeOf(ProgressBar.prototype, Input.prototype); + + ProgressBar.prototype.type = 'progress-bar'; + + ProgressBar.prototype.render = function() { +Index: /blessed/lib/widgets/prompt.js +=================================================================== +--- /blessed/lib/widgets/prompt.js ++++ /blessed/lib/widgets/prompt.js +@@ -66,9 +66,9 @@ + mouse: true + }); + } + +-Prompt.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Prompt.prototype, Box.prototype); + + Prompt.prototype.type = 'prompt'; + + Prompt.prototype.input = +Index: /blessed/lib/widgets/question.js +=================================================================== +--- /blessed/lib/widgets/question.js ++++ /blessed/lib/widgets/question.js +@@ -57,9 +57,9 @@ + mouse: true + }); + } + +-Question.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Question.prototype, Box.prototype); + + Question.prototype.type = 'question'; + + Question.prototype.ask = function(text, callback) { +Index: /blessed/lib/widgets/radiobutton.js +=================================================================== +--- /blessed/lib/widgets/radiobutton.js ++++ /blessed/lib/widgets/radiobutton.js +@@ -41,9 +41,9 @@ + }); + }); + } + +-RadioButton.prototype.__proto__ = Checkbox.prototype; ++Object.setPrototypeOf(RadioButton.prototype, Checkbox.prototype); + + RadioButton.prototype.type = 'radio-button'; + + RadioButton.prototype.render = function() { +Index: /blessed/lib/widgets/radioset.js +=================================================================== +--- /blessed/lib/widgets/radioset.js ++++ /blessed/lib/widgets/radioset.js +@@ -24,9 +24,9 @@ + // options.style = this.parent.style; + Box.call(this, options); + } + +-RadioSet.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(RadioSet.prototype, Box.prototype); + + RadioSet.prototype.type = 'radio-set'; + + /** +Index: /blessed/lib/widgets/screen.js +=================================================================== +--- /blessed/lib/widgets/screen.js ++++ /blessed/lib/widgets/screen.js +@@ -17,22 +17,16 @@ + , unicode = require('../unicode'); + + var nextTick = global.setImmediate || process.nextTick.bind(process); + +-var helpers = require('../helpers'); +- + var Node = require('./node'); +-var Log = require('./log'); +-var Element = require('./element'); +-var Box = require('./box'); + + /** + * Screen + */ + + function Screen(options) { + var self = this; +- + if (!(this instanceof Node)) { + return new Screen(options); + } + +@@ -240,9 +234,9 @@ + }); + }); + }; + +-Screen.prototype.__proto__ = Node.prototype; ++Object.setPrototypeOf(Screen.prototype, Node.prototype); + + Screen.prototype.type = 'screen'; + + Screen.prototype.__defineGetter__('title', function() { +@@ -333,8 +327,9 @@ + + Screen.prototype.postEnter = function() { + var self = this; + if (this.options.debug) { ++ var Log = require('./log'); + this.debugLog = new Log({ + screen: this, + parent: this, + hidden: true, +@@ -378,8 +373,9 @@ + } + + if (this.options.warnings) { + this.on('warning', function(text) { ++ var Box = require('./box'); + var warning = new Box({ + screen: self, + parent: self, + left: 'center', +@@ -476,8 +472,9 @@ + this.program.on('mouse', function(data) { + if (self.lockKeys) return; + + if (self._needsClickableSort) { ++ var helpers = require('../helpers'); + self.clickable = helpers.hsort(self.clickable); + self._needsClickableSort = false; + } + +@@ -619,9 +616,9 @@ + + if (this._hoverText) { + return; + } +- ++ var Box = require('./box'); + this._hoverText = new Box({ + screen: this, + left: 0, + top: 0, +@@ -2071,8 +2068,9 @@ + attr &= ~(0x1ff << 9); + attr |= 7 << 9; + attr |= 8 << 18; + } else if (typeof cursor.shape === 'object' && cursor.shape) { ++ var Element = require('./element'); + cattr = Element.prototype.sattr.call(cursor, cursor.shape); + + if (cursor.shape.bold || cursor.shape.underline + || cursor.shape.blink || cursor.shape.inverse +Index: /blessed/lib/widgets/scrollablebox.js +=================================================================== +--- /blessed/lib/widgets/scrollablebox.js ++++ /blessed/lib/widgets/scrollablebox.js +@@ -167,9 +167,9 @@ + + self._recalculateIndex(); + } + +-ScrollableBox.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(ScrollableBox.prototype, Box.prototype); + + ScrollableBox.prototype.type = 'scrollable-box'; + + // XXX Potentially use this in place of scrollable checks elsewhere. +Index: /blessed/lib/widgets/scrollabletext.js +=================================================================== +--- /blessed/lib/widgets/scrollabletext.js ++++ /blessed/lib/widgets/scrollabletext.js +@@ -23,9 +23,9 @@ + options.alwaysScroll = true; + ScrollableBox.call(this, options); + } + +-ScrollableText.prototype.__proto__ = ScrollableBox.prototype; ++Object.setPrototypeOf(ScrollableText.prototype, ScrollableBox.prototype); + + ScrollableText.prototype.type = 'scrollable-text'; + + /** +Index: /blessed/lib/widgets/table.js +=================================================================== +--- /blessed/lib/widgets/table.js ++++ /blessed/lib/widgets/table.js +@@ -53,9 +53,9 @@ + self.screen.render(); + }); + } + +-Table.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Table.prototype, Box.prototype); + + Table.prototype.type = 'table'; + + Table.prototype._calculateMaxes = function() { +Index: /blessed/lib/widgets/terminal.js +=================================================================== +--- /blessed/lib/widgets/terminal.js ++++ /blessed/lib/widgets/terminal.js +@@ -51,9 +51,9 @@ + + this.bootstrap(); + } + +-Terminal.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Terminal.prototype, Box.prototype); + + Terminal.prototype.type = 'terminal'; + + Terminal.prototype.bootstrap = function() { +Index: /blessed/lib/widgets/text.js +=================================================================== +--- /blessed/lib/widgets/text.js ++++ /blessed/lib/widgets/text.js +@@ -23,9 +23,9 @@ + options.shrink = true; + Element.call(this, options); + } + +-Text.prototype.__proto__ = Element.prototype; ++Object.setPrototypeOf(Text.prototype, Element.prototype); + + Text.prototype.type = 'text'; + + /** +Index: /blessed/lib/widgets/textarea.js +=================================================================== +--- /blessed/lib/widgets/textarea.js ++++ /blessed/lib/widgets/textarea.js +@@ -64,9 +64,9 @@ + }); + } + } + +-Textarea.prototype.__proto__ = Input.prototype; ++Object.setPrototypeOf(Textarea.prototype, Input.prototype); + + Textarea.prototype.type = 'textarea'; + + Textarea.prototype._updateCursor = function(get) { +Index: /blessed/lib/widgets/textbox.js +=================================================================== +--- /blessed/lib/widgets/textbox.js ++++ /blessed/lib/widgets/textbox.js +@@ -29,9 +29,9 @@ + this.secret = options.secret; + this.censor = options.censor; + } + +-Textbox.prototype.__proto__ = Textarea.prototype; ++Object.setPrototypeOf(Textbox.prototype, Textarea.prototype); + + Textbox.prototype.type = 'textbox'; + + Textbox.prototype.__olistener = Textbox.prototype._listener; +Index: /blessed/lib/widgets/video.js +=================================================================== +--- /blessed/lib/widgets/video.js ++++ /blessed/lib/widgets/video.js +@@ -104,9 +104,9 @@ + self.screen.render(); + }); + } + +-Video.prototype.__proto__ = Box.prototype; ++Object.setPrototypeOf(Video.prototype, Box.prototype); + + Video.prototype.type = 'video'; + + Video.prototype.exists = function(program) { +Index: /blessed/vendor/tng.js +=================================================================== +--- /blessed/vendor/tng.js ++++ /blessed/vendor/tng.js +@@ -47,13 +47,9 @@ + : buf.readUInt16BE(0) === 0xffd8 ? 'jpg' + : path.extname(this.file).slice(1).toLowerCase() || 'png'; + + if (this.format !== 'png') { +- try { +- return this.toPNG(buf); +- } catch (e) { +- throw e; +- } ++ return this.toPNG(buf); + } + + chunks = this.parseRaw(buf); + idat = this.parseChunks(chunks); +@@ -67,9 +63,8 @@ + PNG.prototype.parseRaw = function(buf) { + var chunks = [] + , index = 0 + , i = 0 +- , buf + , len + , type + , name + , data +@@ -480,9 +475,8 @@ + , filter_type + , scanline + , flat + , offset +- , k + , end_offset + , skip + , j + , k +@@ -591,11 +585,11 @@ + return bmp; + }; + + PNG.prototype.createCellmap = function(bmp, options) { +- var bmp = bmp || this.bmp +- , options = options || this.options +- , cellmap = [] ++ bmp = bmp || this.bmp ++ options = options || this.options ++ var cellmap = [] + , scale = options.scale || 0.20 + , height = bmp.length + , width = bmp[0].length + , cmwidth = options.width +@@ -604,9 +598,8 @@ + , x + , y + , xx + , yy +- , scale + , xs + , ys; + + if (cmwidth) { diff --git a/patches/blessed-contrib#4.11.0.patch b/patches/blessed-contrib#4.11.0.patch new file mode 100644 index 000000000..c2ad1f438 --- /dev/null +++ b/patches/blessed-contrib#4.11.0.patch @@ -0,0 +1,307 @@ +Index: /blessed-contrib/lib/server-utils.js +=================================================================== +--- /blessed-contrib/lib/server-utils.js ++++ /blessed-contrib/lib/server-utils.js +@@ -1,8 +1,5 @@ + 'use strict'; +-var url = require('url') +- , contrib = require('../index') +- , blessed = require('blessed'); + + function OutputBuffer(options) { + this.isTTY = true; + this.columns = options.cols; +@@ -40,8 +37,9 @@ + } + + + function createScreen(req, res) { ++ var url = require('url'); + var query = url.parse(req.url, true).query; + + var cols = query.cols || 250; + var rows = query.rows || 50; +@@ -52,17 +50,20 @@ + } + + res.writeHead(200, {'Content-Type': 'text/plain'}); + ++ var contrib = require('../index') + var output = new contrib.OutputBuffer({res: res, cols: cols, rows: rows}); + var input = new contrib.InputBuffer(); //required to run under forever since it replaces stdin to non-tty +- var program = blessed.program({output: output, input: input}); ++ var Program = require('blessed/lib/program') ++ var program = new Program({output: output, input: input}); + + if (query.terminal) program.terminal = query.terminal; + if (query.isOSX) program.isOSXTerm = query.isOSX; + if (query.isiTerm2) program.isiTerm2 = query.isiTerm2; + +- var screen = blessed.screen({program: program}); ++ var ScreenWidget = require('blessed/lib/widgets/screen') ++ var screen = new ScreenWidget({program: program}); + return screen; + } + + +Index: /blessed-contrib/lib/widget/canvas.js +=================================================================== +--- /blessed-contrib/lib/widget/canvas.js ++++ /blessed-contrib/lib/widget/canvas.js +@@ -1,9 +1,8 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , Box = blessed.Box +- , InnerCanvas = require('drawille-canvas-blessed-contrib').Canvas; ++var Box = require('blessed/lib/widgets/box') ++ , InnerCanvas = require('drawille-canvas-blessed-contrib').Canvas ++ , Node = require('blessed/lib/widgets/node'); + + function Canvas(options, canvasType) { + + var self = this; +Index: /blessed-contrib/lib/widget/charts/bar.js +=================================================================== +--- /blessed-contrib/lib/widget/charts/bar.js ++++ /blessed-contrib/lib/widget/charts/bar.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Node = require('blessed/lib/widgets/node') + , Canvas = require('../canvas'); + + function Bar(options) { + if (!(this instanceof Node)) { +Index: /blessed-contrib/lib/widget/charts/line.js +=================================================================== +--- /blessed-contrib/lib/widget/charts/line.js ++++ /blessed-contrib/lib/widget/charts/line.js +@@ -1,7 +1,7 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Box = require('blessed/lib/widgets/box') ++ , Node = require('blessed/lib/widgets/node') + , Canvas = require('../canvas') + , utils = require('../../utils.js') + , _ = require('lodash'); + +@@ -53,9 +53,9 @@ + function addLegend() { + if (!self.options.showLegend) return; + if (self.legend) self.remove(self.legend); + var legendWidth = self.options.legend.width || 15; +- self.legend = blessed.box({ ++ self.legend = new Box({ + height: data.length+2, + top: 1, + width: legendWidth, + left: self.width-legendWidth-3, +Index: /blessed-contrib/lib/widget/charts/stacked-bar.js +=================================================================== +--- /blessed-contrib/lib/widget/charts/stacked-bar.js ++++ /blessed-contrib/lib/widget/charts/stacked-bar.js +@@ -1,7 +1,7 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Box = require('blessed/lib/widgets/box') ++ , Node = require('blessed/lib/widgets/node') + , Canvas = require('../canvas') + , utils = require('../../utils.js'); + + function StackedBar(options) { +@@ -183,9 +183,9 @@ + var self = this; + if (!self.options.showLegend) return; + if (self.legend) self.remove(self.legend); + var legendWidth = self.options.legend.width || 15; +- self.legend = blessed.box({ ++ self.legend = new Box({ + height: bars.stackedCategory.length+2, + top: 1, + width: legendWidth, + left: x, +Index: /blessed-contrib/lib/widget/donut.js +=================================================================== +--- /blessed-contrib/lib/widget/donut.js ++++ /blessed-contrib/lib/widget/donut.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Node = require('blessed/lib/widgets/node') + , Canvas = require('./canvas'); + + function Donut(options) { + if (!(this instanceof Node)) { +Index: /blessed-contrib/lib/widget/gauge-list.js +=================================================================== +--- /blessed-contrib/lib/widget/gauge-list.js ++++ /blessed-contrib/lib/widget/gauge-list.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Node = require('blessed/lib/widgets/node') + , Canvas = require('./canvas'); + + function GaugeList(options) { + if (!(this instanceof Node)) { +Index: /blessed-contrib/lib/widget/gauge.js +=================================================================== +--- /blessed-contrib/lib/widget/gauge.js ++++ /blessed-contrib/lib/widget/gauge.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Node = require('blessed/lib/widgets/node') + , Canvas = require('./canvas'); + + function Gauge(options) { + if (!(this instanceof Node)) { +Index: /blessed-contrib/lib/widget/lcd.js +=================================================================== +--- /blessed-contrib/lib/widget/lcd.js ++++ /blessed-contrib/lib/widget/lcd.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node ++var Node = require('blessed/lib/widgets/node') + , Canvas = require('./canvas'); + + function LCD(options) { + if (!(this instanceof Node)) { +Index: /blessed-contrib/lib/widget/log.js +=================================================================== +--- /blessed-contrib/lib/widget/log.js ++++ /blessed-contrib/lib/widget/log.js +@@ -1,8 +1,7 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , List = blessed.List; ++var List = require('blessed/lib/widgets/list') ++ , Node = require('blessed/lib/widgets/node'); + + function Log(options) { + if (!(this instanceof Node)) { + return new Log(options); +Index: /blessed-contrib/lib/widget/map.js +=================================================================== +--- /blessed-contrib/lib/widget/map.js ++++ /blessed-contrib/lib/widget/map.js +@@ -1,9 +1,8 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , Canvas = require('./canvas') +- , InnerMap = require('map-canvas'); ++var InnerMap = require('map-canvas') ++ , Node = require('blessed/lib/widgets/node') ++ , Canvas = require('./canvas'); + + function Map(options) { + var self = this; + +Index: /blessed-contrib/lib/widget/markdown.js +=================================================================== +--- /blessed-contrib/lib/widget/markdown.js ++++ /blessed-contrib/lib/widget/markdown.js +@@ -1,7 +1,6 @@ + 'use strict'; +-var blessed = require('blessed') +- , Box = blessed.Box ++var Box = require('blessed/lib/widgets/box') + , marked = require('marked') + , TerminalRenderer = require('marked-terminal') + , chalk = require('chalk'); + +Index: /blessed-contrib/lib/widget/picture.js +=================================================================== +--- /blessed-contrib/lib/widget/picture.js ++++ /blessed-contrib/lib/widget/picture.js +@@ -1,8 +1,7 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , Box = blessed.Box ++var Box = require('blessed/lib/widgets/box') ++ , Node = require('blessed/lib/widgets/node') + , pictureTube = require('picture-tuber') + , fs = require('fs') + , streams = require('memory-streams') + , MemoryStream = require('memorystream'); +Index: /blessed-contrib/lib/widget/sparkline.js +=================================================================== +--- /blessed-contrib/lib/widget/sparkline.js ++++ /blessed-contrib/lib/widget/sparkline.js +@@ -1,8 +1,7 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , Box = blessed.Box ++var Box = require('blessed/lib/widgets/box') ++ , Node = require('blessed/lib/widgets/node') + , sparkline = require('sparkline'); + + function Sparkline(options) { + +Index: /blessed-contrib/lib/widget/table.js +=================================================================== +--- /blessed-contrib/lib/widget/table.js ++++ /blessed-contrib/lib/widget/table.js +@@ -1,8 +1,8 @@ + 'use strict'; +-var blessed = require('blessed') +- , Node = blessed.Node +- , Box = blessed.Box ++var Box = require('blessed/lib/widgets/box') ++ , List = require('blessed/lib/widgets/list') ++ , Node = require('blessed/lib/widgets/node') + , stripAnsi = require('strip-ansi'); + + function Table(options) { + +@@ -32,9 +32,9 @@ + options.interactive = (typeof options.interactive === 'undefined') ? true : options.interactive; + this.options = options; + Box.call(this, options); + +- this.rows = blessed.list({ ++ this.rows = new List({ + //height: 0, + top: 2, + width: 0, + left: 1, +Index: /blessed-contrib/lib/widget/tree.js +=================================================================== +--- /blessed-contrib/lib/widget/tree.js ++++ /blessed-contrib/lib/widget/tree.js +@@ -1,8 +1,8 @@ + 'use strict'; +-var blessed = require('blessed'), +- Node = blessed.Node, +- Box = blessed.Box; ++var Box = require('blessed/lib/widgets/box') ++ , List = require('blessed/lib/widgets/list') ++ , Node = require('blessed/lib/widgets/node'); + + function Tree(options) { + if (!(this instanceof Node)) { + return new Tree(options); +@@ -25,9 +25,9 @@ + options.template.retract = options.template.retract || ' [-]'; + options.template.lines = options.template.lines || false; + + // Do not set height, since this create a bug where the first line is not always displayed +- this.rows = blessed.list({ ++ this.rows = new List({ + top: 1, + width: 0, + left: 1, + style: options.style, diff --git a/patches/brace-expansion#2.0.1.patch b/patches/brace-expansion#2.0.1.patch new file mode 100644 index 000000000..18863c205 --- /dev/null +++ b/patches/brace-expansion#2.0.1.patch @@ -0,0 +1,15 @@ +Index: /brace-expansion/index.js +=================================================================== +--- /brace-expansion/index.js ++++ /brace-expansion/index.js +@@ -103,9 +103,9 @@ + var post = m.post.length + ? expand(m.post, false) + : ['']; + +- if (/\$$/.test(m.pre)) { ++ if (m.pre.endsWith('\u0024' /*'$'*/)) { + for (var k = 0; k < post.length; k++) { + var expansion = pre+ '{' + m.body + '}' + post[k]; + expansions.push(expansion); + } diff --git a/patches/bresenham#0.0.3.patch b/patches/bresenham#0.0.3.patch new file mode 100644 index 000000000..e9b0c416d --- /dev/null +++ b/patches/bresenham#0.0.3.patch @@ -0,0 +1,13 @@ +Index: /bresenham/index.js +=================================================================== +--- /bresenham/index.js ++++ /bresenham/index.js +@@ -1,7 +1,7 @@ + module.exports = function(x0, y0, x1, y1, fn) { ++ var arr = []; + if(!fn) { +- var arr = []; + fn = function(x, y) { arr.push({ x: x, y: y }); }; + } + var dx = x1 - x0; + var dy = y1 - y0; diff --git a/patches/drawille-blessed-contrib#1.0.0.patch b/patches/drawille-blessed-contrib#1.0.0.patch new file mode 100644 index 000000000..83e03957b --- /dev/null +++ b/patches/drawille-blessed-contrib#1.0.0.patch @@ -0,0 +1,88 @@ +Index: /drawille-blessed-contrib/index.js +=================================================================== +--- /drawille-blessed-contrib/index.js ++++ /drawille-blessed-contrib/index.js +@@ -20,9 +20,9 @@ + this.content.fill(0); + + this.fontFg='normal' + this.fontBg='normal' +- this.color = 'normal' ++ this.color = 'normal' + } + + exports.colors = { + black: 0 +@@ -38,15 +38,15 @@ + + var methods = { + set: function(coord, mask) { + this.content[coord] |= mask; +- this.colors[coord] = exports.colors[this.color]; ++ this.colors[coord] = exports.colors[this.color]; + this.chars[coord] = null + }, + unset: function(coord, mask) { + this.content[coord] &= ~mask; + this.colors[coord] = null +- this.chars[coord] = null ++ this.chars[coord] = null + }, + toggle: function(coord, mask) { + this.content[coord] ^= mask; + this.colors[coord] = null +@@ -58,9 +58,9 @@ + Canvas.prototype[method] = function(x, y) { + if(!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + return; + } +- ++ + var coord = this.getCoord(x, y) + var mask = map[y%4][x%2]; + methods[method].call(this, coord, mask); + } +@@ -79,22 +79,22 @@ + this.content.fill(0); + }; + + Canvas.prototype.measureText = function(str) { +- return {width: str.length * 2 + 2} ++ return {width: str.length * 2 + 2} + }; + +-Canvas.prototype.writeText = function(str, x, y) { ++Canvas.prototype.writeText = function(str, x, y) { + var coord = this.getCoord(x, y) +- for (var i=0; i<str.length; i++) { ++ for (var i=0; i<str.length; i++) { + this.chars[coord+i]=str[i] + } + + var bg = exports.colors[this.fontBg] + var fg = exports.colors[this.fontFg] +- +- this.chars[coord] = '\033[3' + fg + 'm' + '\033[4' + bg + 'm' + this.chars[coord] +- this.chars[coord+str.length-1] += '\033[39m\033[49m' ++ ++ this.chars[coord] = '\x1B[3' + fg + 'm' + '\x1B[4' + bg + 'm' + this.chars[coord] ++ this.chars[coord+str.length-1] += '\x1B[39m\x1B[49m' + } + + Canvas.prototype.frame = function frame(delimiter) { + delimiter = delimiter || '\n'; +@@ -109,11 +109,11 @@ + result.push(this.chars[i]) + } + else if(this.content[i] == 0) { + result.push(' '); +- } else { +- result.push('\033[3' + this.colors[i] + 'm'+String.fromCharCode(0x2800 + this.content[i]) + '\033[39m') +- //result.push(String.fromCharCode(0x2800 + this.content[i])) ++ } else { ++ result.push('\x1B[3' + this.colors[i] + 'm'+String.fromCharCode(0x2800 + this.content[i]) + '\x1B[39m') ++ //result.push(String.fromCharCode(0x2800 + this.content[i])) + } + } + result.push(delimiter); + return result.join(''); diff --git a/patches/drawille-canvas-blessed-contrib#0.1.3.patch b/patches/drawille-canvas-blessed-contrib#0.1.3.patch new file mode 100644 index 000000000..de32e4c45 --- /dev/null +++ b/patches/drawille-canvas-blessed-contrib#0.1.3.patch @@ -0,0 +1,169 @@ +Index: /drawille-canvas-blessed-contrib/index.js +=================================================================== +--- /drawille-canvas-blessed-contrib/index.js ++++ /drawille-canvas-blessed-contrib/index.js +@@ -5,12 +5,12 @@ + var mat2d = glMatrix.mat2d; + var vec2 = glMatrix.vec2; + + +-function Context(width, height, canvasClass) { +- var canvasClass = canvasClass || Canvas; +- this._canvas = new canvasClass(width, height); +- this.canvas = this._canvas; //compatability ++function Context(width, height, canvasClass) { ++ canvasClass = canvasClass || Canvas; ++ this._canvas = new canvasClass(width, height); ++ this.canvas = this._canvas; //compatability + this._matrix = mat2d.create(); + this._stack = []; + this._currentPath = []; + } +@@ -34,46 +34,46 @@ + + function getFgCode(color) { + // String Value + if(typeof color == 'string' && color != 'normal') { +- return '\033[3' + exports.colors[color] + 'm'; ++ return '\x1B[3' + exports.colors[color] + 'm'; + } + // RGB Value + else if (Array.isArray(color) && color.length == 3) + { +- return '\033[38;5;' + x256(color[0],color[1],color[2]) + 'm'; ++ return '\x1B[38;5;' + x256(color[0],color[1],color[2]) + 'm'; + } + // Number + else if (typeof color == 'number') + { +- return '\033[38;5;' + color + 'm'; ++ return '\x1B[38;5;' + color + 'm'; + } + // Default + else + { +- return '\033[39m' ++ return '\x1B[39m' + } + } + + function getBgCode(color) { + // String Value + if(typeof color == 'string' && color != 'normal') { +- return '\033[4' + exports.colors[color] + 'm'; ++ return '\x1B[4' + exports.colors[color] + 'm'; + } + // RGB Value + else if (Array.isArray(color) && color.length == 3) + { +- return '\033[48;5;' + x256(color[0],color[1],color[2]) + 'm'; ++ return '\x1B[48;5;' + x256(color[0],color[1],color[2]) + 'm'; + } + // Number + else if (typeof color == 'number') + { +- return '\033[48;5;' + color + 'm'; ++ return '\x1B[48;5;' + color + 'm'; + } + // Default + else + { +- return '\033[49m' ++ return '\x1B[49m' + } + } + + function br(p1, p2) { +@@ -126,9 +126,9 @@ + //this._canvas.fontBg = val + }); + + Context.prototype.clearRect = function(x, y, w, h) { +- quad(this._matrix, x, y, w, h, this._canvas.unset.bind(this._canvas)); ++ quad(this._matrix, x, y, w, h, this._canvas.unset.bind(this._canvas)); + }; + + Context.prototype.fillRect = function(x, y, w, h) { + quad(this._matrix, x, y, w, h, this._canvas.set.bind(this._canvas)); +@@ -143,9 +143,9 @@ + if(!top) return; + this._matrix = top; + }; + +-Context.prototype.translate = function translate(x, y) { ++Context.prototype.translate = function translate(x, y) { + mat2d.translate(this._matrix, this._matrix, vec2.fromValues(x, y)); + }; + + Context.prototype.rotate = function rotate(a) { +@@ -168,9 +168,9 @@ + });*/ + }; + + Context.prototype.stroke = function stroke() { +- ++ + if (this.lineWidth==0) return; + + var set = this._canvas.set.bind(this._canvas); + for(var i = 0; i < this._currentPath.length - 1; i++) { +@@ -206,19 +206,19 @@ + Context.prototype.measureText = function measureText(str) { + return this._canvas.measureText(str) + }; + +-Canvas.prototype.writeText = function(str, x, y) { ++Canvas.prototype.writeText = function(str, x, y) { + var coord = this.getCoord(x, y) +- for (var i=0; i<str.length; i++) { ++ for (var i=0; i<str.length; i++) { + this.chars[coord+i]=str[i] + } + + var bg = getBgCode(this.fontBg); + var fg = getFgCode(this.fontFg); + + this.chars[coord] = fg + bg + this.chars[coord] +- this.chars[coord+str.length-1] += '\033[39m\033[49m' ++ this.chars[coord+str.length-1] += '\x1B[39m\x1B[49m' + } + + var map = [ + [0x1, 0x8], +@@ -230,9 +230,9 @@ + Canvas.prototype.set = function(x,y) { + if(!(x >= 0 && x < this.width && y >= 0 && y < this.height)) { + return; + } +- ++ + var coord = this.getCoord(x, y) + var mask = map[y%4][x%2]; + + this.content[coord] |= mask; +@@ -254,21 +254,21 @@ + result.push(this.chars[i]) + } + else if(this.content[i] == 0) { + result.push(' '); +- } else { ++ } else { + var colorCode = this.colors[i]; +- result.push(colorCode+String.fromCharCode(0x2800 + this.content[i]) + '\033[39m') +- //result.push(String.fromCharCode(0x2800 + this.content[i])) ++ result.push(colorCode+String.fromCharCode(0x2800 + this.content[i]) + '\x1B[39m') ++ //result.push(String.fromCharCode(0x2800 + this.content[i])) + } + } + result.push(delimiter); + return result.join(''); + }; + + module.exports = Context; + module.exports.Canvas = function(width, height, canvasClass) { +- ++ + var ctx; + + this.getContext = function() { + return ctx = ctx || new Context(width, height, canvasClass) diff --git a/patches/execa@2.1.0.patch b/patches/execa@2.1.0.patch deleted file mode 100644 index 0eb02177b..000000000 --- a/patches/execa@2.1.0.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/lib/kill.js b/lib/kill.js -index dc1c77c38407a9157cfc847c78deaac17b8898bb..998d34578b1d3a360c2a0c7a2dc0acb6d5c15ea0 100644 ---- a/lib/kill.js -+++ b/lib/kill.js -@@ -1,6 +1,6 @@ - 'use strict'; - const os = require('os'); --const onExit = require('signal-exit'); -+const { onExit } = require('signal-exit'); - const pFinally = require('p-finally'); - - const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5; diff --git a/patches/execa@5.1.1.patch b/patches/execa@5.1.1.patch deleted file mode 100644 index 39b69df95..000000000 --- a/patches/execa@5.1.1.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/lib/kill.js b/lib/kill.js -index 287a14238ea9fa99b3a9c0bdaabd13df72230d78..998d34578b1d3a360c2a0c7a2dc0acb6d5c15ea0 100644 ---- a/lib/kill.js -+++ b/lib/kill.js -@@ -1,6 +1,6 @@ - 'use strict'; - const os = require('os'); --const onExit = require('signal-exit'); -+const { onExit } = require('signal-exit'); - - const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5; - diff --git a/patches/graceful-fs#4.2.11.patch b/patches/graceful-fs#4.2.11.patch new file mode 100644 index 000000000..1dda693b1 --- /dev/null +++ b/patches/graceful-fs#4.2.11.patch @@ -0,0 +1,20 @@ +Index: /graceful-fs/clone.js +=================================================================== +--- /graceful-fs/clone.js ++++ /graceful-fs/clone.js +@@ -9,12 +9,11 @@ + function clone (obj) { + if (obj === null || typeof obj !== 'object') + return obj + +- if (obj instanceof Object) +- var copy = { __proto__: getPrototypeOf(obj) } +- else +- var copy = Object.create(null) ++ var copy = obj instanceof Object ++ ? { __proto__: getPrototypeOf(obj) } ++ : Object.create(null) + + Object.getOwnPropertyNames(obj).forEach(function (key) { + Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(obj, key)) + }) diff --git a/patches/lodash#4.17.21.patch b/patches/lodash#4.17.21.patch new file mode 100644 index 000000000..7981f3fd6 --- /dev/null +++ b/patches/lodash#4.17.21.patch @@ -0,0 +1,98 @@ +Index: /lodash/_baseExtremum.js +=================================================================== +--- /lodash/_baseExtremum.js ++++ /lodash/_baseExtremum.js +@@ -12,19 +12,20 @@ + */ + function baseExtremum(array, iteratee, comparator) { + var index = -1, + length = array.length; +- ++ var computed; ++ var result; + while (++index < length) { + var value = array[index], + current = iteratee(value); + + if (current != null && (computed === undefined + ? (current === current && !isSymbol(current)) + : comparator(current, computed) + )) { +- var computed = current, +- result = value; ++ computed = current; ++ result = value; + } + } + return result; + } +Index: /lodash/_getRawTag.js +=================================================================== +--- /lodash/_getRawTag.js ++++ /lodash/_getRawTag.js +@@ -26,11 +26,12 @@ + function getRawTag(value) { + var isOwn = hasOwnProperty.call(value, symToStringTag), + tag = value[symToStringTag]; + ++ var unmasked + try { + value[symToStringTag] = undefined; +- var unmasked = true; ++ unmasked = true; + } catch (e) {} + + var result = nativeObjectToString.call(value); + if (unmasked) { +Index: /lodash/lodash.js +=================================================================== +--- /lodash/lodash.js ++++ /lodash/lodash.js +@@ -8,9 +8,8 @@ + */ + ;(function() { + + /** Used as a safe reference for `undefined` in pre-ES5 environments. */ +- var undefined; + + /** Used as the semantic version number. */ + var VERSION = '4.17.21'; + +@@ -2898,19 +2897,20 @@ + */ + function baseExtremum(array, iteratee, comparator) { + var index = -1, + length = array.length; +- ++ var computed; ++ var result; + while (++index < length) { + var value = array[index], + current = iteratee(value); + + if (current != null && (computed === undefined + ? (current === current && !isSymbol(current)) + : comparator(current, computed) + )) { +- var computed = current, +- result = value; ++ computed = current; ++ result = value; + } + } + return result; + } +@@ -6048,11 +6048,12 @@ + function getRawTag(value) { + var isOwn = hasOwnProperty.call(value, symToStringTag), + tag = value[symToStringTag]; + ++ var unmasked + try { + value[symToStringTag] = undefined; +- var unmasked = true; ++ unmasked = true; + } catch (e) {} + + var result = nativeObjectToString.call(value); + if (unmasked) { diff --git a/patches/node-gyp@12.2.0.patch b/patches/node-gyp@12.2.0.patch deleted file mode 100644 index b12719eed..000000000 --- a/patches/node-gyp@12.2.0.patch +++ /dev/null @@ -1,3 +0,0 @@ -diff --git a/lib/Find-VisualStudio.cs b/lib/Find-VisualStudio.cs -deleted file mode 100644 -index d2e45a76275f5315ff3809c14dd10a9cb0aa40e5..0000000000000000000000000000000000000000 diff --git a/patches/string_decoder#0.10.31.patch b/patches/string_decoder#0.10.31.patch new file mode 100644 index 000000000..8cae566fe --- /dev/null +++ b/patches/string_decoder#0.10.31.patch @@ -0,0 +1,15 @@ +Index: /string_decoder/index.js +=================================================================== +--- /string_decoder/index.js ++++ /string_decoder/index.js +@@ -138,9 +138,9 @@ + } + + charStr += buffer.toString(this.encoding, 0, end); + +- var end = charStr.length - 1; ++ end = charStr.length - 1; + var charCode = charStr.charCodeAt(end); + // CESU-8: lead surrogate (D800-DBFF) is also the incomplete character + if (charCode >= 0xD800 && charCode <= 0xDBFF) { + var size = this.surrogateSize; diff --git a/patches/tinyglobby#0.2.14.patch b/patches/tinyglobby#0.2.14.patch new file mode 100644 index 000000000..818230a95 --- /dev/null +++ b/patches/tinyglobby#0.2.14.patch @@ -0,0 +1,27 @@ +Index: /tinyglobby/package.json +=================================================================== +--- /tinyglobby/package.json ++++ /tinyglobby/package.json +@@ -2,14 +2,9 @@ + "name": "tinyglobby", + "version": "0.2.14", + "description": "A fast and minimal alternative to globby and fast-glob", + "main": "dist/index.js", +- "module": "dist/index.mjs", + "types": "dist/index.d.ts", +- "exports": { +- "import": "./dist/index.mjs", +- "require": "./dist/index.js" +- }, + "sideEffects": false, + "files": [ + "dist" + ], +@@ -61,5 +56,5 @@ + "test:coverage": "node --experimental-transform-types --test --experimental-test-coverage", + "test:only": "node --experimental-transform-types --test --test-only", + "typecheck": "tsc --noEmit" + } +-} +\ No newline at end of file ++} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 64bacd2cb..000000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,8686 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -catalogs: - default: - '@anthropic-ai/claude-code': - specifier: 2.1.98 - version: 2.1.98 - '@babel/core': - specifier: 7.28.4 - version: 7.28.4 - '@babel/generator': - specifier: 7.28.5 - version: 7.28.5 - '@babel/parser': - specifier: 7.28.4 - version: 7.28.4 - '@babel/plugin-proposal-export-default-from': - specifier: 7.27.1 - version: 7.27.1 - '@babel/plugin-transform-export-namespace-from': - specifier: 7.27.1 - version: 7.27.1 - '@babel/plugin-transform-runtime': - specifier: 7.28.3 - version: 7.28.3 - '@babel/preset-react': - specifier: 7.27.1 - version: 7.27.1 - '@babel/preset-typescript': - specifier: 7.27.1 - version: 7.27.1 - '@babel/runtime': - specifier: 7.28.4 - version: 7.28.4 - '@babel/traverse': - specifier: 7.28.4 - version: 7.28.4 - '@babel/types': - specifier: 7.28.5 - version: 7.28.5 - '@gitbeaker/rest': - specifier: 43.7.0 - version: 43.7.0 - '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0 - '@npmcli/arborist': - specifier: 9.4.2 - version: 9.4.2 - '@npmcli/config': - specifier: 10.4.0 - version: 10.4.0 - '@octokit/openapi-types': - specifier: 25.1.0 - version: 25.1.0 - '@octokit/rest': - specifier: 22.0.0 - version: 22.0.0 - '@octokit/types': - specifier: 14.1.0 - version: 14.1.0 - '@pnpm/dependency-path': - specifier: 1001.1.0 - version: 1001.1.0 - '@pnpm/lockfile.detect-dep-types': - specifier: 1001.0.13 - version: 1001.0.13 - '@pnpm/lockfile.fs': - specifier: 1001.1.17 - version: 1001.1.17 - '@pnpm/logger': - specifier: 1001.0.0 - version: 1001.0.0 - '@sinclair/typebox': - specifier: 0.34.49 - version: 0.34.49 - '@socketregistry/hyrious__bun.lockb': - specifier: 1.0.19 - version: 1.0.19 - '@socketregistry/indent-string': - specifier: 1.0.14 - version: 1.0.14 - '@socketregistry/is-interactive': - specifier: 1.0.6 - version: 1.0.6 - '@socketregistry/yocto-spinner': - specifier: 1.0.25 - version: 1.0.25 - '@types/adm-zip': - specifier: 0.5.7 - version: 0.5.7 - '@types/cmd-shim': - specifier: 5.0.2 - version: 5.0.2 - '@types/js-yaml': - specifier: 4.0.9 - version: 4.0.9 - '@types/micromatch': - specifier: 4.0.9 - version: 4.0.9 - '@types/mock-fs': - specifier: 4.13.4 - version: 4.13.4 - '@types/node': - specifier: 24.9.2 - version: 24.9.2 - '@types/npm-package-arg': - specifier: 6.1.4 - version: 6.1.4 - '@types/npmcli__arborist': - specifier: 6.3.1 - version: 6.3.1 - '@types/npmcli__config': - specifier: 6.0.3 - version: 6.0.3 - '@types/proc-log': - specifier: 3.0.4 - version: 3.0.4 - '@types/semver': - specifier: 7.7.1 - version: 7.7.1 - '@types/which': - specifier: 3.0.4 - version: 3.0.4 - '@types/yargs-parser': - specifier: 21.0.3 - version: 21.0.3 - '@vitest/coverage-v8': - specifier: 4.0.3 - version: 4.0.3 - '@yao-pkg/pkg': - specifier: 6.8.0 - version: 6.8.0 - adm-zip: - specifier: 0.5.16 - version: 0.5.16 - ajv-dist: - specifier: 8.17.1 - version: 8.17.1 - browserslist: - specifier: 4.25.4 - version: 4.25.4 - chalk-table: - specifier: 1.0.2 - version: 1.0.2 - cmd-shim: - specifier: 7.0.0 - version: 7.0.0 - compromise: - specifier: 14.14.4 - version: 14.14.4 - del-cli: - specifier: 6.0.0 - version: 6.0.0 - dev-null-cli: - specifier: 2.0.0 - version: 2.0.0 - ecc-agentshield: - specifier: 1.4.0 - version: 1.4.0 - fast-glob: - specifier: 3.3.3 - version: 3.3.3 - hpagent: - specifier: 1.2.0 - version: 1.2.0 - ignore: - specifier: 7.0.5 - version: 7.0.5 - js-yaml: - specifier: npm:@zkochan/js-yaml@0.0.10 - version: 0.0.10 - lint-staged: - specifier: 16.1.6 - version: 16.1.6 - magic-string: - specifier: 0.30.19 - version: 0.30.19 - micromatch: - specifier: 4.0.8 - version: 4.0.8 - mock-fs: - specifier: 5.5.0 - version: 5.5.0 - nanotar: - specifier: 0.2.1 - version: 0.2.1 - nock: - specifier: 14.0.10 - version: 14.0.10 - npm-run-all2: - specifier: 9.0.0 - version: 9.0.0 - open: - specifier: 10.2.0 - version: 10.2.0 - postject: - specifier: 1.0.0-alpha.6 - version: 1.0.0-alpha.6 - registry-auth-token: - specifier: 5.1.0 - version: 5.1.0 - registry-url: - specifier: 7.2.0 - version: 7.2.0 - ssri: - specifier: 12.0.0 - version: 12.0.0 - tar-stream: - specifier: 3.1.7 - version: 3.1.7 - terminal-link: - specifier: 2.1.1 - version: 2.1.1 - trash: - specifier: 10.0.0 - version: 10.0.0 - type-coverage: - specifier: 2.29.7 - version: 2.29.7 - typescript: - specifier: 5.9.3 - version: 5.9.3 - unplugin-purge-polyfills: - specifier: 0.1.0 - version: 0.1.0 - vitest: - specifier: 4.0.3 - version: 4.0.3 - yoctocolors-cjs: - specifier: 2.1.3 - version: 2.1.3 - zod: - specifier: 4.1.8 - version: 4.1.8 - -overrides: - '@octokit/graphql': 9.0.1 - '@octokit/request-error': 7.0.0 - '@sigstore/sign': 4.1.0 - '@socketregistry/packageurl-js': npm:@socketregistry/packageurl-js@1.4.2 - '@socketregistry/packageurl-js-stable': npm:@socketregistry/packageurl-js@1.4.2 - '@socketsecurity/lib': 6.0.5 - '@socketsecurity/lib-stable': npm:@socketsecurity/lib@6.0.5 - '@socketsecurity/registry': npm:@socketsecurity/registry@2.0.2 - '@socketsecurity/registry-stable': npm:@socketsecurity/registry@2.0.2 - '@socketsecurity/sdk': npm:@socketsecurity/sdk@4.0.1 - '@socketsecurity/sdk-stable': npm:@socketsecurity/sdk@4.0.1 - aggregate-error: npm:@socketregistry/aggregate-error@^1.0.15 - ansi-regex: 6.2.2 - brace-expansion: 5.0.5 - defu: '>=6.1.7' - emoji-regex: 10.6.0 - es-define-property: npm:@socketregistry/es-define-property@^1.0.7 - es-set-tostringtag: npm:@socketregistry/es-set-tostringtag@^1.0.10 - fast-uri: '>=3.1.2' - function-bind: npm:@socketregistry/function-bind@^1.0.7 - glob: '>=13.0.6' - globalthis: npm:@socketregistry/globalthis@^1.0.8 - gopd: npm:@socketregistry/gopd@^1.0.7 - graceful-fs: 4.2.11 - has-property-descriptors: npm:@socketregistry/has-property-descriptors@^1.0.7 - has-proto: npm:@socketregistry/has-proto@^1.0.7 - has-symbols: npm:@socketregistry/has-symbols@^1.0.7 - has-tostringtag: npm:@socketregistry/has-tostringtag@^1.0.7 - hasown: npm:@socketregistry/hasown@^1.0.7 - hono: '>=4.12.18' - https-proxy-agent: 7.0.6 - indent-string: npm:@socketregistry/indent-string@^1.0.14 - ip-address: '>=10.2.0' - is-core-module: npm:@socketregistry/is-core-module@^1.0.11 - isarray: npm:@socketregistry/isarray@^1.0.8 - lodash: 4.17.21 - npm-package-arg: 13.0.0 - packageurl-js: npm:@socketregistry/packageurl-js@^1.4.2 - path-parse: npm:@socketregistry/path-parse@^1.0.8 - postcss: '>=8.5.14' - qs: '>=6.15.1' - rolldown: 1.0.3 - safe-buffer: npm:@socketregistry/safe-buffer@^1.0.9 - safer-buffer: npm:@socketregistry/safer-buffer@^1.0.10 - semver: 7.7.2 - set-function-length: npm:@socketregistry/set-function-length@^1.0.10 - shell-quote: 1.8.3 - side-channel: npm:@socketregistry/side-channel@^1.0.10 - signal-exit: 4.1.0 - string-width: 8.1.0 - string_decoder: 0.10.31 - strip-ansi: 7.1.2 - tiny-colors: 2.1.3 - typedarray: npm:@socketregistry/typedarray@^1.0.8 - undici: 6.21.3 - vite: 8.0.14 - wrap-ansi: 9.0.2 - xml2js: 0.6.2 - yaml: 2.8.1 - yargs-parser: 21.1.1 - -patchedDependencies: - '@npmcli/run-script@10.0.4': 65d59a7c4dd7b00f1c218cbcf97d78fe2f462f2e048de4a22b41bd70dbdefcdc - '@sigstore/sign@4.1.0': cdf99454490d44e78fde33563611c0bf50da7f256a239c94d3eb7af6c7d205fa - execa@2.1.0: e06dd2da266f9d3e4ac91468988bdc140c6ec1e5722f321960e1f61c83acb9fd - execa@5.1.1: ee0e2217eadd7986ec585d2e684030a05ad958593a9b11affa002a14a5d46f77 - node-gyp@12.2.0: 140ba43d43d74f7d3577feb3f8a6efad544dbb0059784102b144a0e2daa437f9 - -importers: - - .: - devDependencies: - '@anthropic-ai/claude-code': - specifier: 'catalog:' - version: 2.1.98 - '@babel/core': - specifier: 'catalog:' - version: 7.28.4 - '@babel/parser': - specifier: 'catalog:' - version: 7.28.4 - '@babel/plugin-proposal-export-default-from': - specifier: 'catalog:' - version: 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-export-namespace-from': - specifier: 'catalog:' - version: 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-runtime': - specifier: 'catalog:' - version: 7.28.3(@babel/core@7.28.4) - '@babel/preset-react': - specifier: 'catalog:' - version: 7.27.1(@babel/core@7.28.4) - '@babel/preset-typescript': - specifier: 'catalog:' - version: 7.27.1(@babel/core@7.28.4) - '@babel/runtime': - specifier: 'catalog:' - version: 7.28.4 - '@babel/traverse': - specifier: 'catalog:' - version: 7.28.4 - '@npmcli/arborist': - specifier: 'catalog:' - version: 9.4.2 - '@npmcli/config': - specifier: 'catalog:' - version: 10.4.0 - '@octokit/graphql': - specifier: 9.0.1 - version: 9.0.1 - '@octokit/openapi-types': - specifier: 'catalog:' - version: 25.1.0 - '@octokit/request-error': - specifier: 7.0.0 - version: 7.0.0 - '@octokit/rest': - specifier: 'catalog:' - version: 22.0.0 - '@octokit/types': - specifier: 'catalog:' - version: 14.1.0 - '@pnpm/dependency-path': - specifier: 'catalog:' - version: 1001.1.0 - '@pnpm/lockfile.detect-dep-types': - specifier: 'catalog:' - version: 1001.0.13 - '@pnpm/lockfile.fs': - specifier: 'catalog:' - version: 1001.1.17(@pnpm/logger@1001.0.0) - '@pnpm/logger': - specifier: 'catalog:' - version: 1001.0.0 - '@sinclair/typebox': - specifier: 'catalog:' - version: 0.34.49 - '@socketregistry/hyrious__bun.lockb': - specifier: 'catalog:' - version: 1.0.19 - '@socketregistry/indent-string': - specifier: 'catalog:' - version: 1.0.14 - '@socketregistry/is-interactive': - specifier: 'catalog:' - version: 1.0.6 - '@socketregistry/packageurl-js': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: 1.4.2 - '@socketregistry/packageurl-js-stable': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: '@socketregistry/packageurl-js@1.4.2' - '@socketregistry/yocto-spinner': - specifier: 'catalog:' - version: 1.0.25 - '@socketsecurity/lib': - specifier: 6.0.5 - version: 6.0.5(typescript@5.9.3) - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@socketsecurity/registry': - specifier: npm:@socketsecurity/registry@2.0.2 - version: 2.0.2(typescript@5.9.3) - '@socketsecurity/registry-stable': - specifier: npm:@socketsecurity/registry@2.0.2 - version: '@socketsecurity/registry@2.0.2(typescript@5.9.3)' - '@socketsecurity/sdk': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: 4.0.1 - '@socketsecurity/sdk-stable': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: '@socketsecurity/sdk@4.0.1' - '@types/cmd-shim': - specifier: 'catalog:' - version: 5.0.2 - '@types/js-yaml': - specifier: 'catalog:' - version: 4.0.9 - '@types/micromatch': - specifier: 'catalog:' - version: 4.0.9 - '@types/mock-fs': - specifier: 'catalog:' - version: 4.13.4 - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - '@types/npm-package-arg': - specifier: 'catalog:' - version: 6.1.4 - '@types/npmcli__arborist': - specifier: 'catalog:' - version: 6.3.1 - '@types/npmcli__config': - specifier: 'catalog:' - version: 6.0.3 - '@types/proc-log': - specifier: 'catalog:' - version: 3.0.4 - '@types/semver': - specifier: 'catalog:' - version: 7.7.1 - '@types/which': - specifier: 'catalog:' - version: 3.0.4 - '@types/yargs-parser': - specifier: 'catalog:' - version: 21.0.3 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260511.1 - version: 7.0.0-dev.20260511.1 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 4.0.3(vitest@4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1)) - '@yao-pkg/pkg': - specifier: 'catalog:' - version: 6.8.0 - browserslist: - specifier: 'catalog:' - version: 4.25.4 - chalk-table: - specifier: 'catalog:' - version: 1.0.2 - cmd-shim: - specifier: 'catalog:' - version: 7.0.0 - del-cli: - specifier: 'catalog:' - version: 6.0.0 - dev-null-cli: - specifier: 'catalog:' - version: 2.0.0 - ecc-agentshield: - specifier: 'catalog:' - version: 1.4.0 - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - hpagent: - specifier: 'catalog:' - version: 1.2.0 - ignore: - specifier: 'catalog:' - version: 7.0.5 - js-yaml: - specifier: 'catalog:' - version: '@zkochan/js-yaml@0.0.10' - lint-staged: - specifier: 'catalog:' - version: 16.1.6 - magic-string: - specifier: 'catalog:' - version: 0.30.19 - micromatch: - specifier: 'catalog:' - version: 4.0.8 - mock-fs: - specifier: 'catalog:' - version: 5.5.0 - nanotar: - specifier: 'catalog:' - version: 0.2.1 - nock: - specifier: 'catalog:' - version: 14.0.10 - npm-package-arg: - specifier: 13.0.0 - version: 13.0.0 - npm-run-all2: - specifier: 'catalog:' - version: 9.0.0 - open: - specifier: 'catalog:' - version: 10.2.0 - oxfmt: - specifier: 0.48.0 - version: 0.48.0 - oxlint: - specifier: 1.63.0 - version: 1.63.0 - package-builder: - specifier: workspace:* - version: link:packages/package-builder - postject: - specifier: 'catalog:' - version: 1.0.0-alpha.6 - registry-auth-token: - specifier: 'catalog:' - version: 5.1.0 - registry-url: - specifier: 'catalog:' - version: 7.2.0 - semver: - specifier: 7.7.2 - version: 7.7.2 - ssri: - specifier: 'catalog:' - version: 12.0.0 - taze: - specifier: 19.11.0 - version: 19.11.0 - terminal-link: - specifier: 'catalog:' - version: 2.1.1 - trash: - specifier: 'catalog:' - version: 10.0.0 - type-coverage: - specifier: 'catalog:' - version: 2.29.7(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - unplugin-purge-polyfills: - specifier: 'catalog:' - version: 0.1.0 - vitest: - specifier: 'catalog:' - version: 4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1) - yaml: - specifier: 2.8.1 - version: 2.8.1 - yargs-parser: - specifier: 21.1.1 - version: 21.1.1 - yoctocolors-cjs: - specifier: 'catalog:' - version: 2.1.3 - zod: - specifier: 'catalog:' - version: 4.1.8 - - .claude/hooks/actionlint-on-workflow-edit: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/ask-suppression-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/auth-rotation-reminder: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/check-new-deps: - dependencies: - '@socketregistry/packageurl-js-stable': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: '@socketregistry/packageurl-js@1.4.2' - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@socketsecurity/sdk-stable': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: '@socketsecurity/sdk@4.0.1' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/claude-md-section-size-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/claude-md-size-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/codex-no-write-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/comment-tone-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-author-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-message-format-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/commit-pr-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/compound-lessons-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/concurrent-cargo-build-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/consumer-grep-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/cross-repo-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/default-branch-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dirty-worktree-on-stop-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dont-blame-user-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/dont-stop-mid-queue-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/drift-check-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/enterprise-push-property-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/error-message-quality-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/excuse-detector: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/extension-build-current-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/file-size-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/follow-direct-imperative-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/gh-token-hygiene-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/gitmodules-comment-guard: {} - - .claude/hooks/identifying-users-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/immutable-release-pattern-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/inline-script-defer-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/judgment-reminder: - dependencies: - compromise: - specifier: 14.15.0 - version: 14.15.0 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/lock-step-ref-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/logger-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/markdown-filename-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/marketplace-comment-guard: {} - - .claude/hooks/minify-mcp-output: {} - - .claude/hooks/minimum-release-age-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/new-hook-claude-md-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-blind-keychain-read-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-disable-lint-rule-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-empty-commit-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-experimental-strip-types-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-external-issue-ref-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-file-scope-oxlint-disable-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-fleet-fork-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-meta-comments-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-non-fleet-push-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-orphaned-staging: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-package-json-pnpm-overrides-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-revert-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-structured-clone-prefer-json-guard: {} - - .claude/hooks/no-token-in-dotenv-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/no-underscore-identifier-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/node-modules-staging-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/overeager-staging-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/path-guard: {} - - .claude/hooks/path-regex-normalize-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/paths-mts-inherit-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/perfectionist-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plan-location-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plan-review-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/plugin-patch-format-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/pointer-comment-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/pr-vs-push-default-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/prefer-rebase-over-revert-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/private-name-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/public-surface-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/pull-request-target-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/readme-fleet-shape-guard: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/release-workflow-guard: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/scan-label-in-commit-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/setup-basics-tools: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/setup-claude-scanners: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/setup-firewall: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/setup-misc-tools: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/setup-security-tools: - dependencies: - '@sinclair/typebox': - specifier: 'catalog:' - version: 0.34.49 - '@socketregistry/packageurl-js-stable': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: '@socketregistry/packageurl-js@1.4.2' - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - - .claude/hooks/setup-signing: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/soak-exclude-date-annotation-guard: {} - - .claude/hooks/socket-token-minifier-start: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - - .claude/hooks/squash-history-reminder: - dependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/stale-process-sweeper: {} - - .claude/hooks/sweep-ds-store: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/token-guard: {} - - .claude/hooks/token-hygiene: - devDependencies: - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@types/node': - specifier: 24.9.2 - version: 24.9.2 - - .claude/hooks/variant-analysis-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/verify-rendered-output-before-commit-reminder: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/version-bump-order-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/vitest-include-vs-node-test-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - .claude/hooks/workflow-uses-comment-guard: {} - - .claude/hooks/workflow-yaml-multiline-body-guard: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 24.9.2 - - packages/build-infra: - dependencies: - '@babel/parser': - specifier: 'catalog:' - version: 7.28.4 - '@babel/traverse': - specifier: 'catalog:' - version: 7.28.4 - '@sinclair/typebox': - specifier: 'catalog:' - version: 0.34.49 - '@socketsecurity/lib': - specifier: 6.0.5 - version: 6.0.5(typescript@5.9.3) - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - magic-string: - specifier: 'catalog:' - version: 0.30.19 - - packages/cli: - devDependencies: - '@babel/generator': - specifier: 'catalog:' - version: 7.28.5 - '@babel/parser': - specifier: 'catalog:' - version: 7.28.4 - '@babel/traverse': - specifier: 'catalog:' - version: 7.28.4 - '@babel/types': - specifier: 'catalog:' - version: 7.28.5 - '@gitbeaker/rest': - specifier: 'catalog:' - version: 43.7.0 - '@modelcontextprotocol/sdk': - specifier: 'catalog:' - version: 1.29.0(zod@4.1.8) - '@npmcli/arborist': - specifier: 'catalog:' - version: 9.4.2 - '@octokit/graphql': - specifier: 9.0.1 - version: 9.0.1 - '@octokit/request-error': - specifier: 7.0.0 - version: 7.0.0 - '@octokit/rest': - specifier: 'catalog:' - version: 22.0.0 - '@socketregistry/hyrious__bun.lockb': - specifier: 'catalog:' - version: 1.0.19 - '@socketregistry/indent-string': - specifier: 'catalog:' - version: 1.0.14 - '@socketregistry/is-interactive': - specifier: 'catalog:' - version: 1.0.6 - '@socketregistry/packageurl-js': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: 1.4.2 - '@socketregistry/packageurl-js-stable': - specifier: npm:@socketregistry/packageurl-js@1.4.2 - version: '@socketregistry/packageurl-js@1.4.2' - '@socketregistry/yocto-spinner': - specifier: 'catalog:' - version: 1.0.25 - '@socketsecurity/lib': - specifier: 6.0.5 - version: 6.0.5(typescript@5.9.3) - '@socketsecurity/lib-stable': - specifier: npm:@socketsecurity/lib@6.0.5 - version: '@socketsecurity/lib@6.0.5(typescript@5.9.3)' - '@socketsecurity/registry': - specifier: npm:@socketsecurity/registry@2.0.2 - version: 2.0.2(typescript@5.9.3) - '@socketsecurity/registry-stable': - specifier: npm:@socketsecurity/registry@2.0.2 - version: '@socketsecurity/registry@2.0.2(typescript@5.9.3)' - '@socketsecurity/sdk': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: 4.0.1 - '@socketsecurity/sdk-stable': - specifier: npm:@socketsecurity/sdk@4.0.1 - version: '@socketsecurity/sdk@4.0.1' - '@types/adm-zip': - specifier: 'catalog:' - version: 0.5.7 - adm-zip: - specifier: 'catalog:' - version: 0.5.16 - ajv-dist: - specifier: 'catalog:' - version: 8.17.1 - ansi-regex: - specifier: 6.2.2 - version: 6.2.2 - brace-expansion: - specifier: 5.0.5 - version: 5.0.5 - browserslist: - specifier: 'catalog:' - version: 4.25.4 - build-infra: - specifier: workspace:* - version: link:../build-infra - chalk-table: - specifier: 'catalog:' - version: 1.0.2 - cmd-shim: - specifier: 'catalog:' - version: 7.0.0 - compromise: - specifier: 'catalog:' - version: 14.14.4 - cross-env: - specifier: 10.1.0 - version: 10.1.0 - del-cli: - specifier: 'catalog:' - version: 6.0.0 - emoji-regex: - specifier: 10.6.0 - version: 10.6.0 - fast-glob: - specifier: 'catalog:' - version: 3.3.3 - graceful-fs: - specifier: 4.2.11 - version: 4.2.11 - hpagent: - specifier: 'catalog:' - version: 1.2.0 - https-proxy-agent: - specifier: 7.0.6 - version: 7.0.6 - ignore: - specifier: 'catalog:' - version: 7.0.5 - lru-cache: - specifier: 11.2.6 - version: 11.2.6 - micromatch: - specifier: 'catalog:' - version: 4.0.8 - nanotar: - specifier: 'catalog:' - version: 0.2.1 - npm-package-arg: - specifier: 13.0.0 - version: 13.0.0 - open: - specifier: 'catalog:' - version: 10.2.0 - package-builder: - specifier: workspace:* - version: link:../package-builder - registry-auth-token: - specifier: 'catalog:' - version: 5.1.0 - registry-url: - specifier: 'catalog:' - version: 7.2.0 - rolldown: - specifier: 1.0.3 - version: 1.0.3 - semver: - specifier: 7.7.2 - version: 7.7.2 - ssri: - specifier: 'catalog:' - version: 12.0.0 - string-width: - specifier: 8.1.0 - version: 8.1.0 - tar-stream: - specifier: 'catalog:' - version: 3.1.7 - terminal-link: - specifier: 'catalog:' - version: 2.1.1 - yaml: - specifier: 2.8.1 - version: 2.8.1 - yargs-parser: - specifier: 21.1.1 - version: 21.1.1 - yoctocolors-cjs: - specifier: 'catalog:' - version: 2.1.3 - zod: - specifier: 'catalog:' - version: 4.1.8 - - packages/package-builder: - dependencies: - '@socketsecurity/lib': - specifier: 6.0.5 - version: 6.0.5(typescript@5.9.3) - build-infra: - specifier: workspace:* - version: link:../build-infra - handlebars: - specifier: ^4.7.9 - version: 4.7.9 - -packages: - - '@antfu/ni@30.1.0': - resolution: {integrity: sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw==} - engines: {node: '>=20.19.0'} - hasBin: true - - '@anthropic-ai/claude-code@2.1.98': - resolution: {integrity: sha512-qecREauMWXHplkpjqsuDuUv4ww+NprMl71k9sMuLkZU7qwjLMkTPxRBjuKvZWWMrAPvZWdGZE9LljUTfCQ1lWQ==} - engines: {node: '>=18.0.0'} - hasBin: true - - '@anthropic-ai/sdk@0.39.0': - resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.4': - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-define-polyfill-provider@0.6.8': - resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-proposal-export-default-from@7.27.1': - resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-export-namespace-from@7.27.1': - resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-display-name@7.28.0': - resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-development@7.27.1': - resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx@7.28.6': - resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-pure-annotations@7.27.1': - resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-runtime@7.28.3': - resolution: {integrity: sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-react@7.27.1': - resolution: {integrity: sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.27.1': - resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.4': - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@epic-web/invariant@1.0.0': - resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - - '@gar/promise-retry@1.0.3': - resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@gitbeaker/core@43.8.0': - resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==} - engines: {node: '>=18.20.0'} - - '@gitbeaker/requester-utils@43.8.0': - resolution: {integrity: sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==} - engines: {node: '>=18.20.0'} - - '@gitbeaker/rest@43.7.0': - resolution: {integrity: sha512-CqTP1uRcz1K5KRm95mo3H0Q/KaJfm85LPXbH72sA2ZFoYpdH87hWudurgOa726a80ePKGupKtB4em7l+4NCcaQ==} - engines: {node: '>=18.20.0'} - - '@henrygd/queue@1.2.0': - resolution: {integrity: sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA==} - - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: '>=4.12.18' - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - - '@isaacs/string-locale-compare@1.1.0': - resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@mswjs/interceptors@0.39.8': - resolution: {integrity: sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==} - engines: {node: '>=18'} - - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@npm/types@1.0.2': - resolution: {integrity: sha512-KXZccTDEnWqNrrx6JjpJKU/wJvNeg9BDgjS0XhmlZab7br921HtyVbsYzJr4L+xIvjdJ20Wh9dgxgCI2a5CEQw==} - - '@npmcli/agent@4.0.0': - resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/arborist@9.4.2': - resolution: {integrity: sha512-omJgPyzt11cEGrxzgrECoOyxAunmPMgBFTcAB/FbaB+9iOYhGmRdsQqySV8o0LWQ/l2kTeASUIMR4xJufVwmtw==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - '@npmcli/config@10.4.0': - resolution: {integrity: sha512-0l6f/q/qfB726SWOGIEooh7u6aB1SOgRxGLu7DeJ6Z9Vvq1gG1s3x+Mq+qv9wt0Q0t53mVHIEBokfJZpeaWDyA==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/fs@5.0.0': - resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/git@6.0.3': - resolution: {integrity: sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/git@7.0.2': - resolution: {integrity: sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/installed-package-contents@4.0.0': - resolution: {integrity: sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - '@npmcli/map-workspaces@4.0.2': - resolution: {integrity: sha512-mnuMuibEbkaBTYj9HQ3dMe6L0ylYW+s/gfz7tBDMFY/la0w9Kf44P9aLn4/+/t3aTR3YUHKoT6XQL9rlicIe3Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/map-workspaces@5.0.3': - resolution: {integrity: sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/metavuln-calculator@9.0.3': - resolution: {integrity: sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/name-from-folder@3.0.0': - resolution: {integrity: sha512-61cDL8LUc9y80fXn+lir+iVt8IS0xHqEKwPu/5jCjxQTVoSCmkXvw4vbMrzAMtmghz3/AkiBjhHkDKUH+kf7kA==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/name-from-folder@4.0.0': - resolution: {integrity: sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/node-gyp@5.0.0': - resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/package-json@6.2.0': - resolution: {integrity: sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/package-json@7.0.5': - resolution: {integrity: sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/promise-spawn@8.0.3': - resolution: {integrity: sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/promise-spawn@9.0.1': - resolution: {integrity: sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/query@5.0.0': - resolution: {integrity: sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/redact@4.0.0': - resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@npmcli/run-script@10.0.4': - resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@octokit/auth-token@6.0.0': - resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} - engines: {node: '>= 20'} - - '@octokit/core@7.0.6': - resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} - engines: {node: '>= 20'} - - '@octokit/endpoint@11.0.3': - resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} - engines: {node: '>= 20'} - - '@octokit/graphql@9.0.1': - resolution: {integrity: sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==} - engines: {node: '>= 20'} - - '@octokit/openapi-types@25.1.0': - resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} - - '@octokit/openapi-types@26.0.0': - resolution: {integrity: sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==} - - '@octokit/openapi-types@27.0.0': - resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} - - '@octokit/plugin-paginate-rest@13.2.1': - resolution: {integrity: sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-request-log@6.0.0': - resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-rest-endpoint-methods@16.1.1': - resolution: {integrity: sha512-VztDkhM0ketQYSh5Im3IcKWFZl7VIrrsCaHbDINkdYeiiAsJzjhS2xRFCSJgfN6VOcsoW4laMtsmf3HcNqIimg==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/request-error@7.0.0': - resolution: {integrity: sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==} - engines: {node: '>= 20'} - - '@octokit/request@10.0.8': - resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} - engines: {node: '>= 20'} - - '@octokit/rest@22.0.0': - resolution: {integrity: sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==} - engines: {node: '>= 20'} - - '@octokit/types@14.1.0': - resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - - '@octokit/types@15.0.2': - resolution: {integrity: sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q==} - - '@octokit/types@16.0.0': - resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - - '@open-draft/deferred-promise@2.2.0': - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - '@open-draft/logger@0.3.0': - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - - '@open-draft/until@2.1.0': - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - - '@oxfmt/binding-android-arm-eabi@0.48.0': - resolution: {integrity: sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm64@0.48.0': - resolution: {integrity: sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-darwin-arm64@0.48.0': - resolution: {integrity: sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.48.0': - resolution: {integrity: sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.48.0': - resolution: {integrity: sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': - resolution: {integrity: sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.48.0': - resolution: {integrity: sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm64-gnu@0.48.0': - resolution: {integrity: sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-musl@0.48.0': - resolution: {integrity: sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-ppc64-gnu@0.48.0': - resolution: {integrity: sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.48.0': - resolution: {integrity: sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-musl@0.48.0': - resolution: {integrity: sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-s390x-gnu@0.48.0': - resolution: {integrity: sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.48.0': - resolution: {integrity: sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-musl@0.48.0': - resolution: {integrity: sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-openharmony-arm64@0.48.0': - resolution: {integrity: sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-win32-arm64-msvc@0.48.0': - resolution: {integrity: sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.48.0': - resolution: {integrity: sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.48.0': - resolution: {integrity: sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint/binding-android-arm-eabi@1.63.0': - resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.63.0': - resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.63.0': - resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.63.0': - resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.63.0': - resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.63.0': - resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-musl@1.63.0': - resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.63.0': - resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.63.0': - resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.63.0': - resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.63.0': - resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.63.0': - resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.63.0': - resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.63.0': - resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.63.0': - resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@pnpm/config.env-replace@1.1.0': - resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} - engines: {node: '>=12.22.0'} - - '@pnpm/constants@1001.3.0': - resolution: {integrity: sha512-ZFRekNHbDlu//67Byg+mG8zmtmCsfBhNsg1wKBLRtF7VjH+Q5TDGMX0+8aJYSikQDuzM2FOhvQcDwyjILKshJQ==} - engines: {node: '>=18.12'} - - '@pnpm/crypto.hash@1000.2.0': - resolution: {integrity: sha512-L22sQHDC4VM9cPSbOFi0e+C7JSt3isl/biV1jShz8MG9QjemiwTUMog4h0k0C5HoB1ycUjGkXTqAE4RJu3jLQA==} - engines: {node: '>=18.12'} - - '@pnpm/crypto.polyfill@1000.1.0': - resolution: {integrity: sha512-tNe7a6U4rCpxLMBaR0SIYTdjxGdL0Vwb3G1zY8++sPtHSvy7qd54u8CIB0Z+Y6t5tc9pNYMYCMwhE/wdSY7ltg==} - engines: {node: '>=18.12'} - - '@pnpm/dependency-path@1001.1.0': - resolution: {integrity: sha512-hOVNtEu25HTNOdi0PkvDd27AQHXBke18njbGSYJ02J4GbyoufazqP8+YDiC/wQ+28rKOpgUylT7pVlZoTmdUsg==} - engines: {node: '>=18.12'} - - '@pnpm/error@1000.0.4': - resolution: {integrity: sha512-22mG/Mq4u2r7gr2+XY5j4GlN7J4Mg4WiCfT9flvsUc1uZecShocv6WkyoA20qs14M64f6I+aaWB6b6xsDiITlg==} - engines: {node: '>=18.12'} - - '@pnpm/git-utils@1000.0.0': - resolution: {integrity: sha512-W6isNTNgB26n6dZUgwCw6wly+uHQ2Zh5QiRKY1HHMbLAlsnZOxsSNGnuS9euKWHxDftvPfU7uR8XB5x95T5zPQ==} - engines: {node: '>=18.12'} - - '@pnpm/graceful-fs@1000.0.0': - resolution: {integrity: sha512-RvMEliAmcfd/4UoaYQ93DLQcFeqit78jhYmeJJVPxqFGmj0jEcb9Tu0eAOXr7tGP3eJHpgvPbTU4o6pZ1bJhxg==} - engines: {node: '>=18.12'} - - '@pnpm/lockfile.detect-dep-types@1001.0.13': - resolution: {integrity: sha512-CYVsUdxFkfj+V9W/6d/I4GZ/JqlJKIkiZMax+JnEtzPMWl0lPVZsbsVKt3bcVXy8IA5E9S45DQAoAi5X/NY5SQ==} - engines: {node: '>=18.12'} - - '@pnpm/lockfile.fs@1001.1.17': - resolution: {integrity: sha512-OUBdQjO7fls2AZGkZINpIc/n7DdxHeJu7LOgayiLT3eO47VtFIguXdKguTR3na4Yue7u3SbcFSgC1cDdQTpdxQ==} - engines: {node: '>=18.12'} - peerDependencies: - '@pnpm/logger': '>=1001.0.0 <1002.0.0' - - '@pnpm/lockfile.merger@1001.0.10': - resolution: {integrity: sha512-SHaBzhigjoVIVJ2ho5nAIqmlyfDitaPfSCu+hyWSmUMSB2OEOd5lIiWvRoN4Yii7ZQqgJEeN2lRIr4+SoBTG2Q==} - engines: {node: '>=18.12'} - - '@pnpm/lockfile.types@1002.0.0': - resolution: {integrity: sha512-Y1UZAFKviKGmftMF3hk2jxRTWymctSG+x+5XjOAuNAV6mwtdPdrjUVM8zTbLW+om7GoYhaSszyicO7Qn9InRfg==} - engines: {node: '>=18.12'} - - '@pnpm/lockfile.utils@1003.0.0': - resolution: {integrity: sha512-yVXnBWNtgNsMMlxDaswgAUeqTMULocw7OdYWywkP8t/7MMw8G1BUZZ+K+mxZUv/a0xcZkd7gCKgfPzmnqs37kw==} - engines: {node: '>=18.12'} - - '@pnpm/logger@1001.0.0': - resolution: {integrity: sha512-nj80XtTHHt7T+b5stLWszzd166MbGx4eTOu9+6h6RdelKMlSWhrb7KUb0j90tYk+yoGx8TeMVdJCaoBnkLp8xw==} - engines: {node: '>=18.12'} - - '@pnpm/network.ca-file@1.0.2': - resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} - engines: {node: '>=12.22.0'} - - '@pnpm/npm-conf@2.3.1': - resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} - engines: {node: '>=12'} - - '@pnpm/object.key-sorting@1000.0.1': - resolution: {integrity: sha512-YTJCXyUGOrJuj4QqhSKqZa1vlVAm82h1/uw00ZmD/kL2OViggtyUwWyIe62kpwWVPwEYixfGjfvaFKVJy2mjzA==} - engines: {node: '>=18.12'} - - '@pnpm/patching.types@1000.1.0': - resolution: {integrity: sha512-Zib2ysLctRnWM4KXXlljR44qSKwyEqYmLk+8VPBDBEK3l5Gp5mT3N4ix9E4qjYynvFqahumsxzOfxOYQhUGMGw==} - engines: {node: '>=18.12'} - - '@pnpm/pick-fetcher@1001.0.0': - resolution: {integrity: sha512-Zl8npMjFSS1gSGM27KkbmfmeOuwU2MCxRFIofAUo/PkqOE2IzzXr0yzB1XYJM8Ml1nUXt9BHfwAlUQKC5MdBLA==} - engines: {node: '>=18.12'} - - '@pnpm/ramda@0.28.1': - resolution: {integrity: sha512-zcAG+lvU0fMziNeGXpPyCyCJYp5ZVrPElEE4t14jAmViaihohocZ+dDkcRIyAomox8pQsuZnv1EyHR+pOhmUWw==} - - '@pnpm/resolver-base@1005.0.0': - resolution: {integrity: sha512-EGrQzH913uCHtkjIIR06JOUog0x0VlXS4dAD4unTrX6kPpRSPdISKn+LWRujoEJc8i0JBW6KIfUXcNmI0W5q+Q==} - engines: {node: '>=18.12'} - - '@pnpm/types@1000.7.0': - resolution: {integrity: sha512-1s7FvDqmOEIeFGLUj/VO8sF5lGFxeE/1WALrBpfZhDnMXY/x8FbmuygTTE5joWifebcZ8Ww8Kw2CgBoStsIevQ==} - engines: {node: '>=18.12'} - - '@pnpm/util.lex-comparator@3.0.2': - resolution: {integrity: sha512-blFO4Ws97tWv/SNE6N39ZdGmZBrocXnBOfVp0ln4kELmns4pGPZizqyRtR8EjfOLMLstbmNCTReBoDvLz1isVg==} - engines: {node: '>=18.12'} - - '@quansync/fs@1.0.0': - resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@sigstore/bundle@4.0.0': - resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@sigstore/core@3.2.0': - resolution: {integrity: sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@sigstore/protobuf-specs@0.5.1': - resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@sigstore/sign@4.1.0': - resolution: {integrity: sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@sigstore/tuf@4.0.2': - resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@sigstore/verify@3.1.0': - resolution: {integrity: sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@sinclair/typebox@0.34.49': - resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} - - '@sindresorhus/chunkify@2.0.0': - resolution: {integrity: sha512-srajPSoMTC98FETCJIeXJhJqB77IRPJSu8g907jLuuioLORHZJ3YAOY2DsP5ebrZrjOrAwjqf+Cgkg/I8TGPpw==} - engines: {node: '>=18'} - deprecated: 'Renamed to chunkify: https://www.npmjs.com/package/chunkify' - - '@sindresorhus/df@1.0.1': - resolution: {integrity: sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==} - engines: {node: '>=0.10.0'} - - '@sindresorhus/df@3.1.1': - resolution: {integrity: sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==} - engines: {node: '>=8'} - - '@sindresorhus/merge-streams@2.3.0': - resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} - engines: {node: '>=18'} - - '@socketregistry/es-set-tostringtag@1.0.10': - resolution: {integrity: sha512-btXmvw1JpA8WtSoXx9mTapo9NAyIDKRRzK84i48d8zc0X09M6ORfobVnHbgwhXf7CFhkRzhYrHG9dqbI9vpELQ==} - engines: {node: '>=18'} - - '@socketregistry/hasown@1.0.7': - resolution: {integrity: sha512-MZ5dyXOtiEc7q3801T+2EmKkxrd55BOSQnG8z/8/IkIJzDxqBxGGBKVyixqFm3W657TyUEBfIT9iWgSB6ipFsA==} - engines: {node: '>=18'} - - '@socketregistry/hyrious__bun.lockb@1.0.19': - resolution: {integrity: sha512-Tqgd8FHrJBRaF/6GMTaJbQZQHnwoPVzowzlrZXSimujxVMIDSUlyx0TGmcz33gBWZLXaimZ9labhn44VG6CFkg==} - engines: {node: '>=18'} - hasBin: true - - '@socketregistry/indent-string@1.0.14': - resolution: {integrity: sha512-SCb2h+KkZppDEDyzZheazziUpJQVeCpEMQxSiTn4VMbVkGgvpNVAWQyx3IniSzwiSJpASmwTRTlhiYk6AR19bw==} - engines: {node: '>=18'} - - '@socketregistry/is-core-module@1.0.11': - resolution: {integrity: sha512-obrSzvIfJXKQthA3u1RmkjLHuA1QDtLm0SbXJxGs2CQfXZY9Eql5/pBGSV1hIUWKcpdcNphFgnJMC5BITcTXsQ==} - engines: {node: '>=18'} - - '@socketregistry/is-interactive@1.0.6': - resolution: {integrity: sha512-KbKE6j98nf+cZum6lAO5ubP/Sid5tbbl3S7XYb8VFu3RaHy9I1uIZ/dcM932xYk3+TQuoXgV3pzqAM2ekqA1tA==} - engines: {node: '>=18'} - - '@socketregistry/isarray@1.0.8': - resolution: {integrity: sha512-DM81ydAjO2GJKkNf2Vn17InJ37sEYLK1YyhxpDX16OdbOpYlsDIw8QyeFEUZtc7GqsQXbcPKJmz3j/2qS+BhKQ==} - engines: {node: '>=18'} - - '@socketregistry/packageurl-js@1.4.2': - resolution: {integrity: sha512-yt9UfUzD02wZ7kwb67oe4jxG2D9JtgPqjrK/ans2BovFyeie0w8hvRR0MuOWM4mUt2371oFPp7NB6O5ZjYJmlw==} - engines: {node: '>=18.20.8', pnpm: '>=11.0.0-rc.0'} - - '@socketregistry/path-parse@1.0.8': - resolution: {integrity: sha512-9dcT4Vj4TY6BsU7hd3sEemoaA8OEGUutK2ufNdP+qKOljcH0xy/5+WnbEZ1RLEJcSKDnpZ3T47mVdq/ZWiGNxw==} - engines: {node: '>=18'} - - '@socketregistry/safe-buffer@1.0.9': - resolution: {integrity: sha512-eV4uYchI1+vQeKpFG+aBlhVQ/AaaPTTXaan+ReiNn/izy8U9hfT4WC8l4g8o8BC3zaeNnsNVxec14hJH/y2y3g==} - engines: {node: '>=18'} - - '@socketregistry/safer-buffer@1.0.10': - resolution: {integrity: sha512-jbEY37bJn51W9pP1pXxIoGcQbmbi9EQDtnXfWBjGLNvKC1iEyNLOaGm8ee7dN7Z+KgJdQbrrDjjD3HbGeOFC4A==} - engines: {node: '>=18'} - - '@socketregistry/side-channel@1.0.10': - resolution: {integrity: sha512-nqm2QgbXHldY6DgIBap3i1MlQms+eP7zIC0vPuyy9FmxF62ITa80hjj/3w6zH7DCxV4nQBcJsz3CaGNulQAP7g==} - engines: {node: '>=18'} - - '@socketregistry/yocto-spinner@1.0.25': - resolution: {integrity: sha512-f8AqJMH1+BL15G6bHDzb1jyY+wW4gOYQs5JumSxmnE/H/+KgqbIZgaPwDdRwoeciDGojoSVrRHiTZjbe7n7dJA==} - engines: {node: '>=18'} - - '@socketsecurity/lib@6.0.5': - resolution: {integrity: sha512-Ka2k1xdm+tj0ttq/MmMKdcItI5AmNeJOxvibXAzt5NCq7WlnoqD7UFc/asduICekx2vC2V0ojG1wtMrhbH/bJA==} - engines: {node: '>=22', npm: '>=11.16.0', pnpm: '>=11.4.0'} - hasBin: true - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@socketsecurity/registry@2.0.2': - resolution: {integrity: sha512-hGfteZxSnPN2gmOc9A5cJmyTZBumgMWmg2MVOMRmQjFwxVssk/Bs5dgETGGSOfWBmo/g1K5rBfPs1vE0n/SXMQ==} - engines: {node: '>=18'} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - - '@socketsecurity/sdk@4.0.1': - resolution: {integrity: sha512-fe3DQp2dFwhc0G6Za36GIMSV+QaPAP5L96K3ZOtywt9nhbwxc9IQwqzdOVztdn5Rbez3t9EHU9Esj24/hWdP0g==} - engines: {node: '>=18.20.8', pnpm: '>=11.0.0-rc.0'} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@stroncium/procfs@1.2.1': - resolution: {integrity: sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==} - engines: {node: '>=8'} - - '@tufjs/canonical-json@2.0.0': - resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} - engines: {node: ^16.14.0 || >=18.0.0} - - '@tufjs/models@4.1.0': - resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} - engines: {node: ^20.17.0 || >=22.9.0} - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/adm-zip@0.5.7': - resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} - - '@types/braces@3.0.5': - resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - - '@types/cacache@20.0.1': - resolution: {integrity: sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/cmd-shim@5.0.2': - resolution: {integrity: sha512-Pnee6lEDnxqVmV0SBKGmAFKCmdZtI7sIYI3qCo5iNIZ1SYNspDFwWVJll8F3zvl0Ap/a/XllHiaV8sA9UTjdeA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - - '@types/micromatch@4.0.9': - resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} - - '@types/minimist@1.2.5': - resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - - '@types/mock-fs@4.13.4': - resolution: {integrity: sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg==} - - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - - '@types/node@24.9.2': - resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} - - '@types/normalize-package-data@2.4.4': - resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - - '@types/npm-package-arg@6.1.4': - resolution: {integrity: sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==} - - '@types/npm-registry-fetch@8.0.9': - resolution: {integrity: sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g==} - - '@types/npmcli__arborist@6.3.1': - resolution: {integrity: sha512-CUADRvIKRFwVuiroLQ0wWzOpeOcL8OacCbODtZZxMOA+PBg1au/D8ry/zBnQWdEH+i0IXKeNL2Nt0er30bYWng==} - - '@types/npmcli__config@6.0.3': - resolution: {integrity: sha512-JasDNjgkmtYWGJxMmhmfc8gRrRgcONd4DRaUTD/jWGhwIJSkUMSGHPatTVfUmD7QopQh93TzDH14FZL5tB2tEA==} - - '@types/npmcli__package-json@4.0.4': - resolution: {integrity: sha512-6QjlFUSHBmZJWuC08bz1ZCx6tm4t+7+OJXAdvM6tL2pI7n6Bh5SIp/YxQvnOLFf8MzCXs2ijyFgrzaiu1UFBGA==} - - '@types/npmlog@7.0.0': - resolution: {integrity: sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==} - - '@types/pacote@11.1.8': - resolution: {integrity: sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q==} - - '@types/proc-log@3.0.4': - resolution: {integrity: sha512-E1DsqzHqsKRkFoY6VFjnU15gOGwyDrCgtcH32X1Uq79E50V4CiMJWF7PRakcdwgGfHJfcGfq+hO8Sk2u1ZFVXw==} - - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - - '@types/ssri@7.1.5': - resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} - - '@types/which@3.0.4': - resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-zIe31OYgBvkgTIQEwJtKim6SYyuVTkr+9fK/87hVwKN15X3Ikjeh0C0g2W/Vl4rXeMvy95wBGDN1jpW11DIvgg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-YbmCQXGYkDChGFG7hXJzIgmRjtU1kE5VK/+k322nGnbq4ePqSjS3dS0+ehPATmvfO1XjCDfh3ekED+AtmWk6aQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-02b45lpPmYf125PvcnK67WW93N55qwKmtInwfVefV997S17Ib3h6hlCW4e24BDhNsGRCSLhPA4Lu7ZvTq5pLkw==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-e+TweaVJFaM96tV1UM1kRfk2y8QBkZtz7+0wcxrDGmyJz3IIRUlg1btocaBkhsmVtQPXMr37RutBBMgpl3vgUg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-zgkoGiCpOrly5h8ghcuu6ZNSfrnRqtHoCq584Q92+s4D/j1MU3oKkGPvmkezp5Mj2v7ffR9AjU+lWRDkrfm6eA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-SUm7iVYzKaflol+QwH0Ny5jZtco6PJduI+h/TEg0sgBJzVBa+9RN4I9+Xu9v+EJ1bci3XI7835IRdSP36lCgCw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - - '@typescript/native-preview@7.0.0-dev.20260511.1': - resolution: {integrity: sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA==} - engines: {node: '>=16.20.0'} - hasBin: true - - '@vitest/coverage-v8@4.0.3': - resolution: {integrity: sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==} - peerDependencies: - '@vitest/browser': 4.0.3 - vitest: 4.0.3 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@4.0.3': - resolution: {integrity: sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==} - - '@vitest/mocker@4.0.3': - resolution: {integrity: sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==} - peerDependencies: - msw: ^2.4.9 - vite: 8.0.14 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.0.3': - resolution: {integrity: sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==} - - '@vitest/runner@4.0.3': - resolution: {integrity: sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==} - - '@vitest/snapshot@4.0.3': - resolution: {integrity: sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==} - - '@vitest/spy@4.0.3': - resolution: {integrity: sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==} - - '@vitest/utils@4.0.3': - resolution: {integrity: sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==} - - '@yao-pkg/pkg-fetch@3.5.28': - resolution: {integrity: sha512-0dTu0yFgAuOp3OJBiwSZVkTMuGmvExwmG9mHKQhHkaNate5BWh6rBTzfQ0WId9DHXmg7GiT/kIyejEV0G3EHUQ==} - hasBin: true - - '@yao-pkg/pkg@6.8.0': - resolution: {integrity: sha512-QQcMbQHlaw7dFy3Nk7XQ7Gm8DlczTBcgSQB1V068L5/2rSZ3TO4CMc7rU7DeIOOR0Pm+mb5TDR3yH4jT6nfkpw==} - engines: {node: '>=18.0.0'} - hasBin: true - - '@zkochan/js-yaml@0.0.10': - resolution: {integrity: sha512-pSVOuIjRa7PjIaCmL54Qaz68C3zvwdSxp0qMI5twIt1aw2c/PUVb2M46xnnLWsd2AEgsTbGNcOgHXsM9cENhjA==} - hasBin: true - - '@zkochan/js-yaml@0.0.9': - resolution: {integrity: sha512-SsdK25Upg5wLeGK2Wm8y5bDloMMxN/qE5H6aNOiPRh07a9/fQPYVhlLZz2zRFg9il9XOlpFdrnQnPKsU7FJIpQ==} - hasBin: true - - '@zkochan/rimraf@3.0.2': - resolution: {integrity: sha512-GBf4ua7ogWTr7fATnzk/JLowZDBnBJMm8RkMaC/KcvxZ9gxbMWix0/jImd815LmqKyIHZ7h7lADRddGMdGBuCA==} - engines: {node: '>=18.12'} - - '@zkochan/which@2.0.3': - resolution: {integrity: sha512-C1ReN7vt2/2O0fyTsx5xnbQuxBrmG5NMSbcIkPKCCfCTJgpZBsuRYzFXHj3nVq8vTfK7vxHUmzfCpSHgO7j4rg==} - engines: {node: '>= 8'} - hasBin: true - - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} - - abbrev@4.0.0: - resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} - engines: {node: ^20.17.0 || >=22.9.0} - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - adm-zip@0.5.16: - resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} - engines: {node: '>=12.0'} - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - - ajv-dist@8.17.1: - resolution: {integrity: sha512-KzJwANMzTTR/RERGnkx+bHzmxIfMTPMMv7+cH1d6Lx9UQ7BZyhiieq4hnO5lRuBWOtYTUL8hyWs7RJYI/45Rtg==} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - arrify@1.0.1: - resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} - engines: {node: '>=0.10.0'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - b4a@1.8.0: - resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - - babel-plugin-polyfill-corejs2@0.4.17: - resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-corejs3@0.13.0: - resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-regenerator@0.6.8: - resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.7.0: - resolution: {integrity: sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.8.7: - resolution: {integrity: sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - - bare-stream@2.13.0: - resolution: {integrity: sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==} - peerDependencies: - bare-abort-controller: '*' - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.4.0: - resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - baseline-browser-mapping@2.10.19: - resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} - engines: {node: '>=6.0.0'} - hasBin: true - - before-after-hook@4.0.0: - resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - - bin-links@6.0.0: - resolution: {integrity: sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==} - engines: {node: ^20.17.0 || >=22.9.0} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - bole@5.0.28: - resolution: {integrity: sha512-l+yybyZLV7zTD6EuGxoXsilpER1ctMCpdOqjSYNigJJma39ha85fzCtYccPx06oR1u7uCQLOcUAFFzvfXVBmuQ==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.25.4: - resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} - engines: {node: '>=18'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - cac@7.0.0: - resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} - engines: {node: '>=20.19.0'} - - cacache@20.0.4: - resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} - engines: {node: ^20.17.0 || >=22.9.0} - - camelcase-keys@7.0.2: - resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} - engines: {node: '>=12'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chalk-table@1.0.2: - resolution: {integrity: sha512-lmtmQtr/GCtbiJiiuXPE5lj0arIXJir5hSjIhye/4Uyr7oTQlP+ufPnHzUS3Bre0xS/VWbz9NfeuPnvse9BXoQ==} - - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - - cmd-shim@7.0.0: - resolution: {integrity: sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==} - engines: {node: ^18.17.0 || >=20.5.0} - - cmd-shim@8.0.0: - resolution: {integrity: sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==} - engines: {node: ^20.17.0 || >=22.9.0} - - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - - common-ancestor-path@2.0.0: - resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} - engines: {node: '>= 18'} - - compromise@14.14.4: - resolution: {integrity: sha512-QdbJwronwxeqb7a5KFK/+Y5YieZ4PE1f7ai0vU58Pp4jih+soDCBMuKVbhDEPQ+6+vI3vSiG4UAAjTAXLJw1Qw==} - engines: {node: '>=12.0.0'} - - compromise@14.15.0: - resolution: {integrity: sha512-YEMv5JGWyqRJw5hdZqDVQF3MMlHA6TRiXreR8IYffk6xB7GA5p/8DeDzvg0Jy2tHNGpD+qJGl0+oJwA+5R/sVA==} - engines: {node: '>=12.0.0'} - - comver-to-semver@1.0.0: - resolution: {integrity: sha512-gcGtbRxjwROQOdXLUWH1fQAXqThUVRZ219aAwgtX3KfYw429/Zv6EIJRf5TBSzWdAGwePmqH7w70WTaX4MDqag==} - engines: {node: '>=12.17'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - core-js-compat@3.49.0: - resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-env@10.1.0: - resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} - engines: {node: '>=20'} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize-keys@1.1.1: - resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} - engines: {node: '>=0.10.0'} - - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - - decamelize@5.0.1: - resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} - engines: {node: '>=10'} - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - default-browser-id@5.0.1: - resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} - engines: {node: '>=18'} - - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} - engines: {node: '>=18'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - del-cli@6.0.0: - resolution: {integrity: sha512-9nitGV2W6KLFyya4qYt4+9AKQFL+c0Ehj5K7V7IwlxTc6RMCfQUGY9E9pLG6e8TQjtwXpuiWIGGZb3mfVxyZkw==} - engines: {node: '>=18'} - hasBin: true - - del@8.0.1: - resolution: {integrity: sha512-gPqh0mKTPvaUZGAuHbrBUYKZWBNAeHG7TU3QH5EhVwPMyKvmfJaNXhcD2jTcXsJRRcffuho4vaYweu80dRrMGA==} - engines: {node: '>=18'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dev-null-cli@2.0.0: - resolution: {integrity: sha512-7wwzBy6Yo0UqCI+mNRtltZxAuqhmDWE4UPA0yiANku4ya6j6ABt1Uf+jpF8kheObKYWLH/r9Q/3gHsHADdduqA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - - ecc-agentshield@1.4.0: - resolution: {integrity: sha512-R98OO1Ujyk2lezDLb+iQmMhF6FwTJCHajy3G4FCB6x7wkSTqR9f8+eAelC5KDzYDsGSbc0sOZvjXOOPRBtMpDg==} - engines: {node: '>=18'} - hasBin: true - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - efrt@2.7.0: - resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} - engines: {node: '>=12.0.0'} - - electron-to-chromium@1.5.336: - resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - - eventsource-parser@3.0.8: - resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - execa@2.1.0: - resolution: {integrity: sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==} - engines: {node: ^8.12.0 || >=9.7.0} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - exponential-backoff@3.1.3: - resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - - express-rate-limit@8.4.1: - resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-content-type-parse@3.0.0: - resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - find-up-simple@1.0.1: - resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} - engines: {node: '>=18'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - from2@2.3.0: - resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fzf@0.5.2: - resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} - - get-npm-tarball-url@2.1.0: - resolution: {integrity: sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==} - engines: {node: '>=12.17'} - - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - globby@14.1.0: - resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} - engines: {node: '>=18'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - grad-school@0.0.5: - resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} - engines: {node: '>=8.0.0'} - - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - hard-rejection@2.1.0: - resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} - engines: {node: '>=6'} - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hono@4.12.18: - resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} - engines: {node: '>=16.9.0'} - - hosted-git-info@4.1.0: - resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} - engines: {node: '>=10'} - - hosted-git-info@8.1.0: - resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} - engines: {node: ^18.17.0 || >=20.5.0} - - hosted-git-info@9.0.2: - resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} - engines: {node: ^20.17.0 || >=22.9.0} - - hpagent@1.2.0: - resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} - engines: {node: '>=14'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore-walk@8.0.0: - resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==} - engines: {node: ^20.17.0 || >=22.9.0} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - individual@3.0.0: - resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ini@5.0.0: - resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} - engines: {node: ^18.17.0 || >=20.5.0} - - ini@6.0.0: - resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - into-stream@6.0.0: - resolution: {integrity: sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==} - engines: {node: '>=10'} - - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-path-cwd@3.0.0: - resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - is-path-inside@4.0.0: - resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} - engines: {node: '>=12'} - - is-plain-obj@1.1.0: - resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} - engines: {node: '>=0.10.0'} - - is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - - isexe@4.0.0: - resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} - engines: {node: '>=20'} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-parse-even-better-errors@4.0.0: - resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} - engines: {node: ^18.17.0 || >=20.5.0} - - json-parse-even-better-errors@5.0.0: - resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - json-parse-even-better-errors@6.0.0: - resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - json-stringify-nice@1.1.4: - resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - json-with-bigint@3.5.8: - resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsonparse@1.3.1: - resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} - engines: {'0': node >= 0.2.0} - - just-diff-apply@5.5.0: - resolution: {integrity: sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==} - - just-diff@6.0.2: - resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} - - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - lint-staged@16.1.6: - resolution: {integrity: sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==} - engines: {node: '>=20.17'} - hasBin: true - - listr2@9.0.5: - resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} - engines: {node: '>=20.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - make-fetch-happen@15.0.5: - resolution: {integrity: sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==} - engines: {node: ^20.17.0 || >=22.9.0} - - map-obj@1.0.1: - resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} - engines: {node: '>=0.10.0'} - - map-obj@4.3.0: - resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} - engines: {node: '>=8'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - - meow@10.1.5: - resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - meow@13.2.0: - resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} - engines: {node: '>=18'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist-options@4.1.0: - resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} - engines: {node: '>= 6'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@5.0.2: - resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - minipass-flush@1.0.7: - resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@2.0.0: - resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - - mock-fs@5.5.0: - resolution: {integrity: sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==} - engines: {node: '>=12.0.0'} - - mount-point@3.0.0: - resolution: {integrity: sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==} - engines: {node: '>=0.10.0'} - - move-file@3.1.0: - resolution: {integrity: sha512-4aE3U7CCBWgrQlQDMq8da4woBWDGHioJFiOZ8Ie6Yq2uwYQ9V2kGhTz4x3u6Wc+OU17nw0yc3rJ/lQ4jIiPe3A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - multistream@4.1.0: - resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} - - nano-spawn@1.0.3: - resolution: {integrity: sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==} - engines: {node: '>=20.17'} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanotar@0.2.1: - resolution: {integrity: sha512-MUrzzDUcIOPbv7ubhDV/L4CIfVTATd9XhDE2ixFeCrM5yp9AlzUpn91JrnN0HD6hksdxvz9IW9aKANz0Bta0GA==} - - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - ndjson@2.0.0: - resolution: {integrity: sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==} - engines: {node: '>=10'} - hasBin: true - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - nock@14.0.10: - resolution: {integrity: sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==} - engines: {node: '>=18.20.0 <20 || >=20.12.1'} - - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} - engines: {node: '>=10'} - - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-gyp@12.2.0: - resolution: {integrity: sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - - noop-stream@1.0.0: - resolution: {integrity: sha512-EHpIatM09Pg7dZOsowDwqqdacYpogTBb1BNSMIy8g/J+MGpaxy0k+qmrbYrjLNRPXtW3fqf+Q3b2Q0yFRnQdIw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - - nopt@9.0.0: - resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - normalize-package-data@3.0.3: - resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} - engines: {node: '>=10'} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-bundled@5.0.0: - resolution: {integrity: sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-install-checks@7.1.2: - resolution: {integrity: sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - npm-install-checks@8.0.0: - resolution: {integrity: sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-normalize-package-bin@4.0.0: - resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} - engines: {node: ^18.17.0 || >=20.5.0} - - npm-normalize-package-bin@5.0.0: - resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-normalize-package-bin@6.0.0: - resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-package-arg@13.0.0: - resolution: {integrity: sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-packlist@10.0.4: - resolution: {integrity: sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-pick-manifest@10.0.0: - resolution: {integrity: sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - npm-pick-manifest@11.0.3: - resolution: {integrity: sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-registry-fetch@19.1.1: - resolution: {integrity: sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==} - engines: {node: ^20.17.0 || >=22.9.0} - - npm-run-all2@9.0.0: - resolution: {integrity: sha512-NMHaiMWl+kotdoAzVtwElvEh4PLdjAGsdmCJXOGv0rdM4d19FGIa0z0ISFuMklmYgVgQzS4h+jNlowz+q1aojw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} - hasBin: true - - npm-run-path@3.1.0: - resolution: {integrity: sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==} - engines: {node: '>=8'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - ofetch@1.5.1: - resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - open@10.2.0: - resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} - engines: {node: '>=18'} - - os-homedir@1.0.2: - resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} - engines: {node: '>=0.10.0'} - - outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - - oxfmt@0.48.0: - resolution: {integrity: sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - oxlint@1.63.0: - resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.22.1' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - - p-finally@2.0.1: - resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} - engines: {node: '>=8'} - - p-is-promise@3.0.0: - resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==} - engines: {node: '>=8'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - - pacote@21.5.0: - resolution: {integrity: sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - parse-conflict-json@5.0.1: - resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-exists@5.0.0: - resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-name@1.0.0: - resolution: {integrity: sha512-/dcAb5vMXH0f51yvMuSUqFpxUcA8JelbRmE5mW/p4CUJxrNgK24IkstnV7ENtg2IDGBOu6izKTG6eilbnbNKWQ==} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - - path-type@6.0.0: - resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} - engines: {node: '>=18'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch-browser@2.2.6: - resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} - engines: {node: '>=8.6'} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pinkie-promise@2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} - - pinkie@2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pnpm-workspace-yaml@1.6.0: - resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} - - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - - postject@1.0.0-alpha.6: - resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} - engines: {node: '>=14.0.0'} - hasBin: true - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - presentable-error@0.0.1: - resolution: {integrity: sha512-E6rsNU1QNJgB3sjj7OANinGncFKuK+164sLXw1/CqBjj/EkXSoSdHCtWQGBNlREIGLnL7IEUEGa08YFVUbrhVg==} - engines: {node: '>=16'} - - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - proc-log@6.1.0: - resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - proggy@4.0.0: - resolution: {integrity: sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - - promise-all-reject-late@1.0.1: - resolution: {integrity: sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==} - - promise-call-limit@3.0.2: - resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} - - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - - propagate@2.0.1: - resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} - engines: {node: '>= 8'} - - proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} - engines: {node: '>=0.6'} - - quansync@1.0.0: - resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - rate-limiter-flexible@8.3.0: - resolution: {integrity: sha512-mzwlfipDLlRinPgELqVDJetke6Snq26nL565m8nLWXIcWgosYSeNRgqwh7ZrZ4MfYs8CNfmLvR5SBVz3rISQsQ==} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - read-cmd-shim@6.0.0: - resolution: {integrity: sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==} - engines: {node: ^20.17.0 || >=22.9.0} - - read-package-json-fast@6.0.0: - resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - read-pkg-up@8.0.0: - resolution: {integrity: sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==} - engines: {node: '>=12'} - - read-pkg@6.0.0: - resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==} - engines: {node: '>=12'} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - redent@4.0.0: - resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} - engines: {node: '>=12'} - - registry-auth-token@5.1.0: - resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} - engines: {node: '>=14'} - - registry-url@7.2.0: - resolution: {integrity: sha512-I5UEBQ+09LWKInA1fPswOMZps0cs2Z+IQXb5Z5EkTJiUmIN52Vm/FD3ji5X82c5jIXL3nWEWOrYK0RkON6Oqyg==} - engines: {node: '>=18'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - run-applescript@7.1.0: - resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} - engines: {node: '>=18'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-execa@0.1.2: - resolution: {integrity: sha512-vdTshSQ2JsRCgT8eKZWNJIL26C6bVqy1SOmuCMlKHegVeo8KYRobRrefOdUq9OozSPUUiSxrylteeRmLOMFfWg==} - engines: {node: '>=12'} - - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - sigstore@4.1.0: - resolution: {integrity: sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==} - engines: {node: ^20.17.0 || >=22.9.0} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - - sort-keys@4.2.0: - resolution: {integrity: sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==} - engines: {node: '>=8'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-expression-parse@4.0.0: - resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} - - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - - split2@3.2.2: - resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} - - ssri@10.0.5: - resolution: {integrity: sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - ssri@13.0.1: - resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - stream-meter@1.0.4: - resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} - - streamx@2.25.0: - resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} - - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - - string-width@8.1.0: - resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} - engines: {node: '>=20'} - - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-indent@4.1.1: - resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} - engines: {node: '>=12'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - suffix-thumb@5.0.2: - resolution: {integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-hyperlinks@2.3.0: - resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-fs@3.1.2: - resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - - tar-stream@3.1.8: - resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} - - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} - engines: {node: '>=18'} - - taze@19.11.0: - resolution: {integrity: sha512-BlfH8Z6JdoIsrUptnz4P4YuEqdYsa/bSNNDOMhTlsHZ7Bbg1/0NyYh6uPkoRREjrt/kVovV+HYdi1ilHxvChfw==} - hasBin: true - - teex@1.0.1: - resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - - terminal-link@2.1.1: - resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} - engines: {node: '>=8'} - - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - - through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - trash@10.0.0: - resolution: {integrity: sha512-nyHQPJ7F4dYCfj1xN95DAkLkf9qlyRLDpT9yYwcR5SH16q+f7VA1L5VwsdEqWFUuGNpKwgLnbOS1QBvXMYnLfA==} - engines: {node: '>=20'} - - treeverse@3.0.0: - resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - trim-newlines@4.1.1: - resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} - engines: {node: '>=12'} - - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - - tuf-js@4.1.0: - resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} - engines: {node: ^20.17.0 || >=22.9.0} - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-coverage-core@2.29.7: - resolution: {integrity: sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==} - peerDependencies: - typescript: 2 || 3 || 4 || 5 - - type-coverage@2.29.7: - resolution: {integrity: sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==} - hasBin: true - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - unconfig-core@7.5.0: - resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} - - unconfig@7.5.0: - resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} - - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - unplugin-purge-polyfills@0.1.0: - resolution: {integrity: sha512-dHahgAhuzaHZHU65oY7BU24vqH/AtcXppdH1B1SmrBeglyX7NOBtkryjp2F8mOD4tL2RVxfAc41JRqRKTAeAkA==} - - unplugin@2.3.11: - resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} - engines: {node: '>=18.12.0'} - - unzipper@0.12.3: - resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - user-home@2.0.0: - resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} - engines: {node: '>=0.10.0'} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - validate-npm-package-name@6.0.2: - resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite@8.0.14: - resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: 2.8.1 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.0.3: - resolution: {integrity: sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.3 - '@vitest/browser-preview': 4.0.3 - '@vitest/browser-webdriverio': 4.0.3 - '@vitest/ui': 4.0.3 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/debug': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - walk-up-path@4.0.0: - resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} - engines: {node: 20 || >=22} - - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@5.0.0: - resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - - which@6.0.1: - resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - - which@7.0.0: - resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - write-file-atomic@7.0.1: - resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} - engines: {node: ^20.17.0 || >=22.9.0} - - wsl-utils@0.1.0: - resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} - engines: {node: '>=18'} - - xcase@2.0.1: - resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} - - xdg-basedir@4.0.0: - resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} - engines: {node: '>=8'} - - xdg-trashdir@3.1.0: - resolution: {integrity: sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==} - engines: {node: '>=10'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} - engines: {node: '>= 14.6'} - hasBin: true - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zod@4.1.8: - resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} - -snapshots: - - '@antfu/ni@30.1.0': - dependencies: - fzf: 0.5.2 - package-manager-detector: 1.6.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - - '@anthropic-ai/claude-code@2.1.98': - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - '@anthropic-ai/sdk@0.39.0': - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.28.4': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.4) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.28.4 - '@babel/template': 7.28.6 - '@babel/traverse': 7.28.4 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.4 - lru-cache: 5.1.1 - semver: 7.7.2 - - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.4) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.28.4': - dependencies: - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.4) - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-runtime@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.28.4) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.4) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.28.4) - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/preset-react@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.4) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.4) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.28.4': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.28.4': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@1.0.2': {} - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@epic-web/invariant@1.0.0': {} - - '@gar/promise-retry@1.0.3': {} - - '@gitbeaker/core@43.8.0': - dependencies: - '@gitbeaker/requester-utils': 43.8.0 - qs: 6.15.1 - xcase: 2.0.1 - - '@gitbeaker/requester-utils@43.8.0': - dependencies: - picomatch-browser: 2.2.6 - qs: 6.15.1 - rate-limiter-flexible: 8.3.0 - xcase: 2.0.1 - - '@gitbeaker/rest@43.7.0': - dependencies: - '@gitbeaker/core': 43.8.0 - '@gitbeaker/requester-utils': 43.8.0 - - '@henrygd/queue@1.2.0': {} - - '@hono/node-server@1.19.14(hono@4.12.18)': - dependencies: - hono: 4.12.18 - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - - '@isaacs/string-locale-compare@1.1.0': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@modelcontextprotocol/sdk@1.29.0(zod@4.1.8)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.18) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.8 - express: 5.2.1 - express-rate-limit: 8.4.1(express@5.2.1) - hono: 4.12.18 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.1.8 - zod-to-json-schema: 3.25.2(zod@4.1.8) - transitivePeerDependencies: - - supports-color - - '@mswjs/interceptors@0.39.8': - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@npm/types@1.0.2': {} - - '@npmcli/agent@4.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 11.2.6 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/arborist@9.4.2': - dependencies: - '@gar/promise-retry': 1.0.3 - '@isaacs/string-locale-compare': 1.1.0 - '@npmcli/fs': 5.0.0 - '@npmcli/installed-package-contents': 4.0.0 - '@npmcli/map-workspaces': 5.0.3 - '@npmcli/metavuln-calculator': 9.0.3 - '@npmcli/name-from-folder': 4.0.0 - '@npmcli/node-gyp': 5.0.0 - '@npmcli/package-json': 7.0.5 - '@npmcli/query': 5.0.0 - '@npmcli/redact': 4.0.0 - '@npmcli/run-script': 10.0.4(patch_hash=65d59a7c4dd7b00f1c218cbcf97d78fe2f462f2e048de4a22b41bd70dbdefcdc) - bin-links: 6.0.0 - cacache: 20.0.4 - common-ancestor-path: 2.0.0 - hosted-git-info: 9.0.2 - json-stringify-nice: 1.1.4 - lru-cache: 11.2.6 - minimatch: 10.2.5 - nopt: 9.0.0 - npm-install-checks: 8.0.0 - npm-package-arg: 13.0.0 - npm-pick-manifest: 11.0.3 - npm-registry-fetch: 19.1.1 - pacote: 21.5.0 - parse-conflict-json: 5.0.1 - proc-log: 6.1.0 - proggy: 4.0.0 - promise-all-reject-late: 1.0.1 - promise-call-limit: 3.0.2 - semver: 7.7.2 - ssri: 13.0.1 - treeverse: 3.0.0 - walk-up-path: 4.0.0 - transitivePeerDependencies: - - supports-color - - '@npmcli/config@10.4.0': - dependencies: - '@npmcli/map-workspaces': 4.0.2 - '@npmcli/package-json': 6.2.0 - ci-info: 4.4.0 - ini: 5.0.0 - nopt: 8.1.0 - proc-log: 5.0.0 - semver: 7.7.2 - walk-up-path: 4.0.0 - - '@npmcli/fs@5.0.0': - dependencies: - semver: 7.7.2 - - '@npmcli/git@6.0.3': - dependencies: - '@npmcli/promise-spawn': 8.0.3 - ini: 5.0.0 - lru-cache: 10.4.3 - npm-pick-manifest: 10.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - semver: 7.7.2 - which: 5.0.0 - - '@npmcli/git@7.0.2': - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/promise-spawn': 9.0.1 - ini: 6.0.0 - lru-cache: 11.2.6 - npm-pick-manifest: 11.0.3 - proc-log: 6.1.0 - semver: 7.7.2 - which: 6.0.1 - - '@npmcli/installed-package-contents@4.0.0': - dependencies: - npm-bundled: 5.0.0 - npm-normalize-package-bin: 5.0.0 - - '@npmcli/map-workspaces@4.0.2': - dependencies: - '@npmcli/name-from-folder': 3.0.0 - '@npmcli/package-json': 6.2.0 - glob: 13.0.6 - minimatch: 9.0.9 - - '@npmcli/map-workspaces@5.0.3': - dependencies: - '@npmcli/name-from-folder': 4.0.0 - '@npmcli/package-json': 7.0.5 - glob: 13.0.6 - minimatch: 10.2.5 - - '@npmcli/metavuln-calculator@9.0.3': - dependencies: - cacache: 20.0.4 - json-parse-even-better-errors: 5.0.0 - pacote: 21.5.0 - proc-log: 6.1.0 - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - '@npmcli/name-from-folder@3.0.0': {} - - '@npmcli/name-from-folder@4.0.0': {} - - '@npmcli/node-gyp@5.0.0': {} - - '@npmcli/package-json@6.2.0': - dependencies: - '@npmcli/git': 6.0.3 - glob: 13.0.6 - hosted-git-info: 8.1.0 - json-parse-even-better-errors: 4.0.0 - proc-log: 5.0.0 - semver: 7.7.2 - validate-npm-package-license: 3.0.4 - - '@npmcli/package-json@7.0.5': - dependencies: - '@npmcli/git': 7.0.2 - glob: 13.0.6 - hosted-git-info: 9.0.2 - json-parse-even-better-errors: 5.0.0 - proc-log: 6.1.0 - semver: 7.7.2 - spdx-expression-parse: 4.0.0 - - '@npmcli/promise-spawn@8.0.3': - dependencies: - which: 5.0.0 - - '@npmcli/promise-spawn@9.0.1': - dependencies: - which: 6.0.1 - - '@npmcli/query@5.0.0': - dependencies: - postcss-selector-parser: 7.1.1 - - '@npmcli/redact@4.0.0': {} - - '@npmcli/run-script@10.0.4(patch_hash=65d59a7c4dd7b00f1c218cbcf97d78fe2f462f2e048de4a22b41bd70dbdefcdc)': - dependencies: - '@npmcli/node-gyp': 5.0.0 - '@npmcli/package-json': 7.0.5 - '@npmcli/promise-spawn': 9.0.1 - node-gyp: 12.2.0(patch_hash=140ba43d43d74f7d3577feb3f8a6efad544dbb0059784102b144a0e2daa437f9) - proc-log: 6.1.0 - transitivePeerDependencies: - - supports-color - - '@octokit/auth-token@6.0.0': {} - - '@octokit/core@7.0.6': - dependencies: - '@octokit/auth-token': 6.0.0 - '@octokit/graphql': 9.0.1 - '@octokit/request': 10.0.8 - '@octokit/request-error': 7.0.0 - '@octokit/types': 16.0.0 - before-after-hook: 4.0.0 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@11.0.3': - dependencies: - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@9.0.1': - dependencies: - '@octokit/request': 10.0.8 - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@25.1.0': {} - - '@octokit/openapi-types@26.0.0': {} - - '@octokit/openapi-types@27.0.0': {} - - '@octokit/plugin-paginate-rest@13.2.1(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 15.0.2 - - '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - - '@octokit/plugin-rest-endpoint-methods@16.1.1(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 15.0.2 - - '@octokit/request-error@7.0.0': - dependencies: - '@octokit/types': 14.1.0 - - '@octokit/request@10.0.8': - dependencies: - '@octokit/endpoint': 11.0.3 - '@octokit/request-error': 7.0.0 - '@octokit/types': 16.0.0 - fast-content-type-parse: 3.0.0 - json-with-bigint: 3.5.8 - universal-user-agent: 7.0.3 - - '@octokit/rest@22.0.0': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/plugin-paginate-rest': 13.2.1(@octokit/core@7.0.6) - '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) - '@octokit/plugin-rest-endpoint-methods': 16.1.1(@octokit/core@7.0.6) - - '@octokit/types@14.1.0': - dependencies: - '@octokit/openapi-types': 25.1.0 - - '@octokit/types@15.0.2': - dependencies: - '@octokit/openapi-types': 26.0.0 - - '@octokit/types@16.0.0': - dependencies: - '@octokit/openapi-types': 27.0.0 - - '@open-draft/deferred-promise@2.2.0': {} - - '@open-draft/logger@0.3.0': - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - - '@open-draft/until@2.1.0': {} - - '@oxc-project/types@0.133.0': {} - - '@oxfmt/binding-android-arm-eabi@0.48.0': - optional: true - - '@oxfmt/binding-android-arm64@0.48.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.48.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.48.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.48.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.48.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.48.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.48.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.48.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.48.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.48.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.48.0': - optional: true - - '@oxlint/binding-android-arm-eabi@1.63.0': - optional: true - - '@oxlint/binding-android-arm64@1.63.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.63.0': - optional: true - - '@oxlint/binding-darwin-x64@1.63.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.63.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.63.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.63.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.63.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.63.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.63.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.63.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.63.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.63.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.63.0': - optional: true - - '@pnpm/config.env-replace@1.1.0': {} - - '@pnpm/constants@1001.3.0': {} - - '@pnpm/crypto.hash@1000.2.0': - dependencies: - '@pnpm/crypto.polyfill': 1000.1.0 - '@pnpm/graceful-fs': 1000.0.0 - ssri: 10.0.5 - - '@pnpm/crypto.polyfill@1000.1.0': {} - - '@pnpm/dependency-path@1001.1.0': - dependencies: - '@pnpm/crypto.hash': 1000.2.0 - '@pnpm/types': 1000.7.0 - semver: 7.7.2 - - '@pnpm/error@1000.0.4': - dependencies: - '@pnpm/constants': 1001.3.0 - - '@pnpm/git-utils@1000.0.0': - dependencies: - execa: safe-execa@0.1.2 - - '@pnpm/graceful-fs@1000.0.0': - dependencies: - graceful-fs: 4.2.11 - - '@pnpm/lockfile.detect-dep-types@1001.0.13': - dependencies: - '@pnpm/dependency-path': 1001.1.0 - '@pnpm/lockfile.types': 1002.0.0 - '@pnpm/types': 1000.7.0 - - '@pnpm/lockfile.fs@1001.1.17(@pnpm/logger@1001.0.0)': - dependencies: - '@pnpm/constants': 1001.3.0 - '@pnpm/dependency-path': 1001.1.0 - '@pnpm/error': 1000.0.4 - '@pnpm/git-utils': 1000.0.0 - '@pnpm/lockfile.merger': 1001.0.10 - '@pnpm/lockfile.types': 1002.0.0 - '@pnpm/lockfile.utils': 1003.0.0 - '@pnpm/logger': 1001.0.0 - '@pnpm/object.key-sorting': 1000.0.1 - '@pnpm/types': 1000.7.0 - '@zkochan/rimraf': 3.0.2 - comver-to-semver: 1.0.0 - js-yaml: '@zkochan/js-yaml@0.0.9' - normalize-path: 3.0.0 - ramda: '@pnpm/ramda@0.28.1' - semver: 7.7.2 - strip-bom: 4.0.0 - write-file-atomic: 5.0.1 - - '@pnpm/lockfile.merger@1001.0.10': - dependencies: - '@pnpm/lockfile.types': 1002.0.0 - '@pnpm/types': 1000.7.0 - comver-to-semver: 1.0.0 - ramda: '@pnpm/ramda@0.28.1' - semver: 7.7.2 - - '@pnpm/lockfile.types@1002.0.0': - dependencies: - '@pnpm/patching.types': 1000.1.0 - '@pnpm/resolver-base': 1005.0.0 - '@pnpm/types': 1000.7.0 - - '@pnpm/lockfile.utils@1003.0.0': - dependencies: - '@pnpm/dependency-path': 1001.1.0 - '@pnpm/lockfile.types': 1002.0.0 - '@pnpm/pick-fetcher': 1001.0.0 - '@pnpm/resolver-base': 1005.0.0 - '@pnpm/types': 1000.7.0 - get-npm-tarball-url: 2.1.0 - ramda: '@pnpm/ramda@0.28.1' - - '@pnpm/logger@1001.0.0': - dependencies: - bole: 5.0.28 - ndjson: 2.0.0 - - '@pnpm/network.ca-file@1.0.2': - dependencies: - graceful-fs: 4.2.11 - - '@pnpm/npm-conf@2.3.1': - dependencies: - '@pnpm/config.env-replace': 1.1.0 - '@pnpm/network.ca-file': 1.0.2 - config-chain: 1.1.13 - - '@pnpm/object.key-sorting@1000.0.1': - dependencies: - '@pnpm/util.lex-comparator': 3.0.2 - sort-keys: 4.2.0 - - '@pnpm/patching.types@1000.1.0': {} - - '@pnpm/pick-fetcher@1001.0.0': {} - - '@pnpm/ramda@0.28.1': {} - - '@pnpm/resolver-base@1005.0.0': - dependencies: - '@pnpm/types': 1000.7.0 - - '@pnpm/types@1000.7.0': {} - - '@pnpm/util.lex-comparator@3.0.2': {} - - '@quansync/fs@1.0.0': - dependencies: - quansync: 1.0.0 - - '@rolldown/binding-android-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-x64@1.0.3': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.3': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.3': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.3': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.3': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.3': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.3': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@sigstore/bundle@4.0.0': - dependencies: - '@sigstore/protobuf-specs': 0.5.1 - - '@sigstore/core@3.2.0': {} - - '@sigstore/protobuf-specs@0.5.1': {} - - '@sigstore/sign@4.1.0(patch_hash=cdf99454490d44e78fde33563611c0bf50da7f256a239c94d3eb7af6c7d205fa)': - dependencies: - '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.2.0 - '@sigstore/protobuf-specs': 0.5.1 - make-fetch-happen: 15.0.5 - proc-log: 6.1.0 - promise-retry: 2.0.1 - transitivePeerDependencies: - - supports-color - - '@sigstore/tuf@4.0.2': - dependencies: - '@sigstore/protobuf-specs': 0.5.1 - tuf-js: 4.1.0 - transitivePeerDependencies: - - supports-color - - '@sigstore/verify@3.1.0': - dependencies: - '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.2.0 - '@sigstore/protobuf-specs': 0.5.1 - - '@sinclair/typebox@0.34.49': {} - - '@sindresorhus/chunkify@2.0.0': {} - - '@sindresorhus/df@1.0.1': {} - - '@sindresorhus/df@3.1.1': - dependencies: - execa: 2.1.0(patch_hash=e06dd2da266f9d3e4ac91468988bdc140c6ec1e5722f321960e1f61c83acb9fd) - - '@sindresorhus/merge-streams@2.3.0': {} - - '@socketregistry/es-set-tostringtag@1.0.10': {} - - '@socketregistry/hasown@1.0.7': {} - - '@socketregistry/hyrious__bun.lockb@1.0.19': {} - - '@socketregistry/indent-string@1.0.14': {} - - '@socketregistry/is-core-module@1.0.11': {} - - '@socketregistry/is-interactive@1.0.6': {} - - '@socketregistry/isarray@1.0.8': {} - - '@socketregistry/packageurl-js@1.4.2': {} - - '@socketregistry/path-parse@1.0.8': {} - - '@socketregistry/safe-buffer@1.0.9': {} - - '@socketregistry/safer-buffer@1.0.10': {} - - '@socketregistry/side-channel@1.0.10': {} - - '@socketregistry/yocto-spinner@1.0.25': - dependencies: - yoctocolors-cjs: 2.1.3 - - '@socketsecurity/lib@6.0.5(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - - '@socketsecurity/registry@2.0.2(typescript@5.9.3)': - optionalDependencies: - typescript: 5.9.3 - - '@socketsecurity/sdk@4.0.1': {} - - '@standard-schema/spec@1.1.0': {} - - '@stroncium/procfs@1.2.1': {} - - '@tufjs/canonical-json@2.0.0': {} - - '@tufjs/models@4.1.0': - dependencies: - '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.5 - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/adm-zip@0.5.7': - dependencies: - '@types/node': 24.9.2 - - '@types/braces@3.0.5': {} - - '@types/cacache@20.0.1': - dependencies: - '@types/node': 24.9.2 - minipass: 7.1.3 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/cmd-shim@5.0.2': {} - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/js-yaml@4.0.9': {} - - '@types/micromatch@4.0.9': - dependencies: - '@types/braces': 3.0.5 - - '@types/minimist@1.2.5': {} - - '@types/mock-fs@4.13.4': - dependencies: - '@types/node': 24.9.2 - - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 24.9.2 - form-data: 4.0.5 - - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - - '@types/node@24.9.2': - dependencies: - undici-types: 7.16.0 - - '@types/normalize-package-data@2.4.4': {} - - '@types/npm-package-arg@6.1.4': {} - - '@types/npm-registry-fetch@8.0.9': - dependencies: - '@types/node': 24.9.2 - '@types/node-fetch': 2.6.13 - '@types/npm-package-arg': 6.1.4 - '@types/npmlog': 7.0.0 - '@types/ssri': 7.1.5 - - '@types/npmcli__arborist@6.3.1': - dependencies: - '@npm/types': 1.0.2 - '@types/cacache': 20.0.1 - '@types/node': 24.9.2 - '@types/npmcli__package-json': 4.0.4 - '@types/pacote': 11.1.8 - - '@types/npmcli__config@6.0.3': - dependencies: - '@types/node': 24.9.2 - '@types/semver': 7.7.1 - - '@types/npmcli__package-json@4.0.4': {} - - '@types/npmlog@7.0.0': - dependencies: - '@types/node': 24.9.2 - - '@types/pacote@11.1.8': - dependencies: - '@types/node': 24.9.2 - '@types/npm-registry-fetch': 8.0.9 - '@types/npmlog': 7.0.0 - '@types/ssri': 7.1.5 - - '@types/proc-log@3.0.4': {} - - '@types/semver@7.7.1': {} - - '@types/ssri@7.1.5': - dependencies: - '@types/node': 24.9.2 - - '@types/which@3.0.4': {} - - '@types/yargs-parser@21.0.3': {} - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260511.1': - optional: true - - '@typescript/native-preview@7.0.0-dev.20260511.1': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260511.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260511.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260511.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260511.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260511.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260511.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260511.1 - - '@vitest/coverage-v8@4.0.3(vitest@4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.3 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.3.5 - std-env: 3.10.0 - tinyrainbow: 3.1.0 - vitest: 4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@4.0.3': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.0.3 - '@vitest/utils': 4.0.3 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.0.3(vite@8.0.14(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 4.0.3 - estree-walker: 3.0.3 - magic-string: 0.30.19 - optionalDependencies: - vite: 8.0.14(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1) - - '@vitest/pretty-format@4.0.3': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.0.3': - dependencies: - '@vitest/utils': 4.0.3 - pathe: 2.0.3 - - '@vitest/snapshot@4.0.3': - dependencies: - '@vitest/pretty-format': 4.0.3 - magic-string: 0.30.19 - pathe: 2.0.3 - - '@vitest/spy@4.0.3': {} - - '@vitest/utils@4.0.3': - dependencies: - '@vitest/pretty-format': 4.0.3 - tinyrainbow: 3.1.0 - - '@yao-pkg/pkg-fetch@3.5.28': - dependencies: - https-proxy-agent: 7.0.6 - node-fetch: 2.7.0 - picocolors: 1.1.1 - progress: 2.0.3 - semver: 7.7.2 - tar-fs: 3.1.2 - yargs: 16.2.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - encoding - - react-native-b4a - - supports-color - - '@yao-pkg/pkg@6.8.0': - dependencies: - '@babel/generator': 7.29.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.29.0 - '@yao-pkg/pkg-fetch': 3.5.28 - into-stream: 6.0.0 - minimist: 1.2.8 - multistream: 4.1.0 - picocolors: 1.1.1 - picomatch: 4.0.4 - prebuild-install: 7.1.3 - resolve: 1.22.12 - stream-meter: 1.0.4 - tar: 7.5.13 - tinyglobby: 0.2.16 - unzipper: 0.12.3 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - encoding - - react-native-b4a - - supports-color - - '@zkochan/js-yaml@0.0.10': - dependencies: - argparse: 2.0.1 - - '@zkochan/js-yaml@0.0.9': - dependencies: - argparse: 2.0.1 - - '@zkochan/rimraf@3.0.2': {} - - '@zkochan/which@2.0.3': - dependencies: - isexe: 2.0.0 - - abbrev@3.0.1: {} - - abbrev@4.0.0: {} - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn@8.16.0: {} - - adm-zip@0.5.16: {} - - agent-base@7.1.4: {} - - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - - ajv-dist@8.17.1: {} - - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - - ansi-regex@6.2.2: {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - argparse@2.0.1: {} - - arrify@1.0.1: {} - - assertion-error@2.0.1: {} - - ast-v8-to-istanbul@0.3.12: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - - asynckit@0.4.0: {} - - b4a@1.8.0: {} - - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.28.4): - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.28.4) - semver: 7.7.2 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.28.4) - core-js-compat: 3.49.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - balanced-match@4.0.4: {} - - bare-events@2.8.2: {} - - bare-fs@4.7.0: - dependencies: - bare-events: 2.8.2 - bare-path: 3.0.0 - bare-stream: 2.13.0(bare-events@2.8.2) - bare-url: 2.4.0 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-os@3.8.7: {} - - bare-path@3.0.0: - dependencies: - bare-os: 3.8.7 - - bare-stream@2.13.0(bare-events@2.8.2): - dependencies: - streamx: 2.25.0 - teex: 1.0.1 - optionalDependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - react-native-b4a - - bare-url@2.4.0: - dependencies: - bare-path: 3.0.0 - - base64-js@1.5.1: {} - - baseline-browser-mapping@2.10.19: {} - - before-after-hook@4.0.0: {} - - bin-links@6.0.0: - dependencies: - cmd-shim: 8.0.0 - npm-normalize-package-bin: 5.0.0 - proc-log: 6.1.0 - read-cmd-shim: 6.0.0 - write-file-atomic: 7.0.1 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - bluebird@3.7.2: {} - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.1 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - bole@5.0.28: - dependencies: - fast-safe-stringify: 2.1.1 - individual: 3.0.0 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.25.4: - dependencies: - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.336 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.25.4) - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.19 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.336 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bundle-name@4.1.0: - dependencies: - run-applescript: 7.1.0 - - bytes@3.1.2: {} - - cac@7.0.0: {} - - cacache@20.0.4: - dependencies: - '@npmcli/fs': 5.0.0 - fs-minipass: 3.0.3 - glob: 13.0.6 - lru-cache: 11.2.6 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 13.0.1 - - camelcase-keys@7.0.2: - dependencies: - camelcase: 6.3.0 - map-obj: 4.3.0 - quick-lru: 5.1.1 - type-fest: 1.4.0 - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001788: {} - - chai@6.2.2: {} - - chalk-table@1.0.2: - dependencies: - chalk: 2.4.2 - strip-ansi: 7.1.2 - - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} - - chownr@1.1.4: {} - - chownr@3.0.0: {} - - ci-info@4.4.0: {} - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.1.0 - - cliui@7.0.4: - dependencies: - string-width: 8.1.0 - strip-ansi: 7.1.2 - wrap-ansi: 9.0.2 - - cmd-shim@7.0.0: {} - - cmd-shim@8.0.0: {} - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.3: {} - - color-name@1.1.4: {} - - colorette@2.0.20: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@13.1.0: {} - - commander@14.0.3: {} - - commander@9.5.0: {} - - common-ancestor-path@2.0.0: {} - - compromise@14.14.4: - dependencies: - efrt: 2.7.0 - grad-school: 0.0.5 - suffix-thumb: 5.0.2 - - compromise@14.15.0: - dependencies: - efrt: 2.7.0 - grad-school: 0.0.5 - suffix-thumb: 5.0.2 - - comver-to-semver@1.0.0: {} - - confbox@0.1.8: {} - - config-chain@1.1.13: - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 - - content-disposition@1.1.0: {} - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - core-js-compat@3.49.0: - dependencies: - browserslist: 4.28.2 - - core-util-is@1.0.3: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-env@10.1.0: - dependencies: - '@epic-web/invariant': 1.0.0 - cross-spawn: 7.0.6 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - cssesc@3.0.0: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decamelize-keys@1.1.1: - dependencies: - decamelize: 1.2.0 - map-obj: 1.0.1 - - decamelize@1.2.0: {} - - decamelize@5.0.1: {} - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-extend@0.6.0: {} - - default-browser-id@5.0.1: {} - - default-browser@5.5.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - - define-lazy-prop@3.0.0: {} - - defu@6.1.7: {} - - del-cli@6.0.0: - dependencies: - del: 8.0.1 - meow: 13.2.0 - - del@8.0.1: - dependencies: - globby: 14.1.0 - is-glob: 4.0.3 - is-path-cwd: 3.0.0 - is-path-inside: 4.0.0 - p-map: 7.0.4 - presentable-error: 0.0.1 - slash: 5.1.0 - - delayed-stream@1.0.0: {} - - depd@2.0.0: {} - - destr@2.0.5: {} - - detect-libc@2.1.2: {} - - dev-null-cli@2.0.0: - dependencies: - meow: 10.1.5 - noop-stream: 1.0.0 - - duplexer2@0.1.4: - dependencies: - readable-stream: 2.3.8 - - ecc-agentshield@1.4.0: - dependencies: - '@anthropic-ai/sdk': 0.39.0 - chalk: 5.6.2 - commander: 13.1.0 - glob: 13.0.6 - yaml: 2.8.1 - zod: 3.25.76 - transitivePeerDependencies: - - encoding - - ee-first@1.1.1: {} - - efrt@2.7.0: {} - - electron-to-chromium@1.5.336: {} - - emoji-regex@10.6.0: {} - - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - env-paths@2.2.1: {} - - environment@1.1.0: {} - - err-code@2.0.3: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@1.0.5: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - etag@1.8.1: {} - - event-target-shim@5.0.1: {} - - eventemitter3@5.0.4: {} - - events-universal@1.0.1: - dependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - - eventsource-parser@3.0.8: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.8 - - execa@2.1.0(patch_hash=e06dd2da266f9d3e4ac91468988bdc140c6ec1e5722f321960e1f61c83acb9fd): - dependencies: - cross-spawn: 7.0.6 - get-stream: 5.2.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 3.1.0 - onetime: 5.1.2 - p-finally: 2.0.1 - signal-exit: 4.1.0 - strip-final-newline: 2.0.0 - - execa@5.1.1(patch_hash=ee0e2217eadd7986ec585d2e684030a05ad958593a9b11affa002a14a5d46f77): - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 4.1.0 - strip-final-newline: 2.0.0 - - expand-template@2.0.3: {} - - expect-type@1.3.0: {} - - exponential-backoff@3.1.3: {} - - express-rate-limit@8.4.1(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.2.0 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.1.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.1 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-content-type-parse@3.0.0: {} - - fast-deep-equal@3.1.3: {} - - fast-fifo@1.3.2: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.2: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - find-up-simple@1.0.1: {} - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - form-data-encoder@1.7.2: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: '@socketregistry/es-set-tostringtag@1.0.10' - hasown: '@socketregistry/hasown@1.0.7' - mime-types: 2.1.35 - - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - from2@2.3.0: - dependencies: - inherits: 2.0.4 - readable-stream: 2.3.8 - - fs-constants@1.0.0: {} - - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - - fsevents@2.3.3: - optional: true - - fzf@0.5.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-east-asian-width@1.5.0: {} - - get-npm-tarball-url@2.1.0: {} - - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - - get-stream@6.0.1: {} - - github-from-package@0.0.0: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - globby@14.1.0: - dependencies: - '@sindresorhus/merge-streams': 2.3.0 - fast-glob: 3.3.3 - ignore: 7.0.5 - path-type: 6.0.0 - slash: 5.1.0 - unicorn-magic: 0.3.0 - - graceful-fs@4.2.11: {} - - grad-school@0.0.5: {} - - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - hard-rejection@2.1.0: {} - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - hono@4.12.18: {} - - hosted-git-info@4.1.0: - dependencies: - lru-cache: 6.0.0 - - hosted-git-info@8.1.0: - dependencies: - lru-cache: 10.4.3 - - hosted-git-info@9.0.2: - dependencies: - lru-cache: 11.2.6 - - hpagent@1.2.0: {} - - html-escaper@2.0.2: {} - - http-cache-semantics@4.2.0: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - human-signals@2.1.0: {} - - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - - iconv-lite@0.7.2: - dependencies: - safer-buffer: '@socketregistry/safer-buffer@1.0.10' - - ieee754@1.2.1: {} - - ignore-walk@8.0.0: - dependencies: - minimatch: 10.2.5 - - ignore@7.0.5: {} - - imurmurhash@0.1.4: {} - - individual@3.0.0: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - ini@5.0.0: {} - - ini@6.0.0: {} - - into-stream@6.0.0: - dependencies: - from2: 2.3.0 - p-is-promise: 3.0.0 - - ip-address@10.2.0: {} - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-docker@3.0.0: {} - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.5.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - - is-node-process@1.2.0: {} - - is-number@7.0.0: {} - - is-path-cwd@3.0.0: {} - - is-path-inside@4.0.0: {} - - is-plain-obj@1.1.0: {} - - is-plain-obj@2.1.0: {} - - is-promise@4.0.0: {} - - is-stream@2.0.1: {} - - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - - isexe@2.0.0: {} - - isexe@3.1.5: {} - - isexe@4.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jiti@2.7.0: {} - - jose@6.2.3: {} - - js-tokens@10.0.0: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json-parse-even-better-errors@4.0.0: {} - - json-parse-even-better-errors@5.0.0: {} - - json-parse-even-better-errors@6.0.0: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - json-stringify-nice@1.1.4: {} - - json-stringify-safe@5.0.1: {} - - json-with-bigint@3.5.8: {} - - json5@2.2.3: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsonparse@1.3.1: {} - - just-diff-apply@5.5.0: {} - - just-diff@6.0.2: {} - - kind-of@6.0.3: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - lint-staged@16.1.6: - dependencies: - chalk: 5.6.2 - commander: 14.0.3 - debug: 4.4.3 - lilconfig: 3.1.3 - listr2: 9.0.5 - micromatch: 4.0.8 - nano-spawn: 1.0.3 - pidtree: 0.6.0 - string-argv: 0.3.2 - yaml: 2.8.1 - transitivePeerDependencies: - - supports-color - - listr2@9.0.5: - dependencies: - cli-truncate: 5.2.0 - colorette: 2.0.20 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.debounce@4.0.8: {} - - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.1.2 - wrap-ansi: 9.0.2 - - lru-cache@10.4.3: {} - - lru-cache@11.2.6: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.3.5: - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.2 - - make-fetch-happen@15.0.5: - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/agent': 4.0.0 - '@npmcli/redact': 4.0.0 - cacache: 20.0.4 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 5.0.2 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 6.1.0 - ssri: 13.0.1 - transitivePeerDependencies: - - supports-color - - map-obj@1.0.1: {} - - map-obj@4.3.0: {} - - media-typer@1.1.0: {} - - memorystream@0.3.1: {} - - meow@10.1.5: - dependencies: - '@types/minimist': 1.2.5 - camelcase-keys: 7.0.2 - decamelize: 5.0.1 - decamelize-keys: 1.1.1 - hard-rejection: 2.1.0 - minimist-options: 4.1.0 - normalize-package-data: 3.0.3 - read-pkg-up: 8.0.0 - redent: 4.0.0 - trim-newlines: 4.1.1 - type-fest: 1.4.0 - yargs-parser: 21.1.1 - - meow@13.2.0: {} - - merge-descriptors@2.0.0: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.52.0: {} - - mime-db@1.54.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-fn@2.1.0: {} - - mimic-function@5.0.1: {} - - mimic-response@3.1.0: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimatch@9.0.9: - dependencies: - brace-expansion: 5.0.5 - - minimist-options@4.1.0: - dependencies: - arrify: 1.0.1 - is-plain-obj: 1.1.0 - kind-of: 6.0.3 - - minimist@1.2.8: {} - - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@5.0.2: - dependencies: - minipass: 7.1.3 - minipass-sized: 2.0.0 - minizlib: 3.1.0 - optionalDependencies: - iconv-lite: 0.7.2 - - minipass-flush@1.0.7: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@2.0.0: - dependencies: - minipass: 7.1.3 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - - mkdirp-classic@0.5.3: {} - - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - mock-fs@5.5.0: {} - - mount-point@3.0.0: - dependencies: - '@sindresorhus/df': 1.0.1 - pify: 2.3.0 - pinkie-promise: 2.0.1 - - move-file@3.1.0: - dependencies: - path-exists: 5.0.0 - - ms@2.1.3: {} - - multistream@4.1.0: - dependencies: - once: 1.4.0 - readable-stream: 3.6.2 - - nano-spawn@1.0.3: {} - - nanoid@3.3.11: {} - - nanotar@0.2.1: {} - - napi-build-utils@2.0.0: {} - - ndjson@2.0.0: - dependencies: - json-stringify-safe: 5.0.1 - minimist: 1.2.8 - readable-stream: 3.6.2 - split2: 3.2.2 - through2: 4.0.2 - - negotiator@1.0.0: {} - - neo-async@2.6.2: {} - - nock@14.0.10: - dependencies: - '@mswjs/interceptors': 0.39.8 - json-stringify-safe: 5.0.1 - propagate: 2.0.1 - - node-abi@3.89.0: - dependencies: - semver: 7.7.2 - - node-domexception@1.0.0: {} - - node-fetch-native@1.6.7: {} - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - node-gyp@12.2.0(patch_hash=140ba43d43d74f7d3577feb3f8a6efad544dbb0059784102b144a0e2daa437f9): - dependencies: - env-paths: 2.2.1 - exponential-backoff: 3.1.3 - graceful-fs: 4.2.11 - make-fetch-happen: 15.0.5 - nopt: 9.0.0 - proc-log: 6.1.0 - semver: 7.7.2 - tar: 7.5.13 - tinyglobby: 0.2.16 - which: 6.0.1 - transitivePeerDependencies: - - supports-color - - node-int64@0.4.0: {} - - node-releases@2.0.37: {} - - noop-stream@1.0.0: {} - - nopt@8.1.0: - dependencies: - abbrev: 3.0.1 - - nopt@9.0.0: - dependencies: - abbrev: 4.0.0 - - normalize-package-data@3.0.3: - dependencies: - hosted-git-info: 4.1.0 - is-core-module: '@socketregistry/is-core-module@1.0.11' - semver: 7.7.2 - validate-npm-package-license: 3.0.4 - - normalize-path@3.0.0: {} - - npm-bundled@5.0.0: - dependencies: - npm-normalize-package-bin: 5.0.0 - - npm-install-checks@7.1.2: - dependencies: - semver: 7.7.2 - - npm-install-checks@8.0.0: - dependencies: - semver: 7.7.2 - - npm-normalize-package-bin@4.0.0: {} - - npm-normalize-package-bin@5.0.0: {} - - npm-normalize-package-bin@6.0.0: {} - - npm-package-arg@13.0.0: - dependencies: - hosted-git-info: 9.0.2 - proc-log: 5.0.0 - semver: 7.7.2 - validate-npm-package-name: 6.0.2 - - npm-packlist@10.0.4: - dependencies: - ignore-walk: 8.0.0 - proc-log: 6.1.0 - - npm-pick-manifest@10.0.0: - dependencies: - npm-install-checks: 7.1.2 - npm-normalize-package-bin: 4.0.0 - npm-package-arg: 13.0.0 - semver: 7.7.2 - - npm-pick-manifest@11.0.3: - dependencies: - npm-install-checks: 8.0.0 - npm-normalize-package-bin: 5.0.0 - npm-package-arg: 13.0.0 - semver: 7.7.2 - - npm-registry-fetch@19.1.1: - dependencies: - '@npmcli/redact': 4.0.0 - jsonparse: 1.3.1 - make-fetch-happen: 15.0.5 - minipass: 7.1.3 - minipass-fetch: 5.0.2 - minizlib: 3.1.0 - npm-package-arg: 13.0.0 - proc-log: 6.1.0 - transitivePeerDependencies: - - supports-color - - npm-run-all2@9.0.0: - dependencies: - ansi-styles: 6.2.3 - cross-spawn: 7.0.6 - memorystream: 0.3.1 - picomatch: 4.0.4 - pidtree: 0.6.0 - read-package-json-fast: 6.0.0 - shell-quote: 1.8.3 - which: 7.0.0 - - npm-run-path@3.1.0: - dependencies: - path-key: 3.1.1 - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-assign@4.1.1: {} - - ofetch@1.5.1: - dependencies: - destr: 2.0.5 - node-fetch-native: 1.6.7 - ufo: 1.6.4 - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - - open@10.2.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - wsl-utils: 0.1.0 - - os-homedir@1.0.2: {} - - outvariant@1.4.3: {} - - oxfmt@0.48.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.48.0 - '@oxfmt/binding-android-arm64': 0.48.0 - '@oxfmt/binding-darwin-arm64': 0.48.0 - '@oxfmt/binding-darwin-x64': 0.48.0 - '@oxfmt/binding-freebsd-x64': 0.48.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.48.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.48.0 - '@oxfmt/binding-linux-arm64-gnu': 0.48.0 - '@oxfmt/binding-linux-arm64-musl': 0.48.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.48.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.48.0 - '@oxfmt/binding-linux-riscv64-musl': 0.48.0 - '@oxfmt/binding-linux-s390x-gnu': 0.48.0 - '@oxfmt/binding-linux-x64-gnu': 0.48.0 - '@oxfmt/binding-linux-x64-musl': 0.48.0 - '@oxfmt/binding-openharmony-arm64': 0.48.0 - '@oxfmt/binding-win32-arm64-msvc': 0.48.0 - '@oxfmt/binding-win32-ia32-msvc': 0.48.0 - '@oxfmt/binding-win32-x64-msvc': 0.48.0 - - oxlint@1.63.0: - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.63.0 - '@oxlint/binding-android-arm64': 1.63.0 - '@oxlint/binding-darwin-arm64': 1.63.0 - '@oxlint/binding-darwin-x64': 1.63.0 - '@oxlint/binding-freebsd-x64': 1.63.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 - '@oxlint/binding-linux-arm-musleabihf': 1.63.0 - '@oxlint/binding-linux-arm64-gnu': 1.63.0 - '@oxlint/binding-linux-arm64-musl': 1.63.0 - '@oxlint/binding-linux-ppc64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-gnu': 1.63.0 - '@oxlint/binding-linux-riscv64-musl': 1.63.0 - '@oxlint/binding-linux-s390x-gnu': 1.63.0 - '@oxlint/binding-linux-x64-gnu': 1.63.0 - '@oxlint/binding-linux-x64-musl': 1.63.0 - '@oxlint/binding-openharmony-arm64': 1.63.0 - '@oxlint/binding-win32-arm64-msvc': 1.63.0 - '@oxlint/binding-win32-ia32-msvc': 1.63.0 - '@oxlint/binding-win32-x64-msvc': 1.63.0 - - p-finally@2.0.1: {} - - p-is-promise@3.0.0: {} - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-map@7.0.4: {} - - package-manager-detector@1.6.0: {} - - pacote@21.5.0: - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/git': 7.0.2 - '@npmcli/installed-package-contents': 4.0.0 - '@npmcli/package-json': 7.0.5 - '@npmcli/promise-spawn': 9.0.1 - '@npmcli/run-script': 10.0.4(patch_hash=65d59a7c4dd7b00f1c218cbcf97d78fe2f462f2e048de4a22b41bd70dbdefcdc) - cacache: 20.0.4 - fs-minipass: 3.0.3 - minipass: 7.1.3 - npm-package-arg: 13.0.0 - npm-packlist: 10.0.4 - npm-pick-manifest: 11.0.3 - npm-registry-fetch: 19.1.1 - proc-log: 6.1.0 - sigstore: 4.1.0 - ssri: 13.0.1 - tar: 7.5.13 - transitivePeerDependencies: - - supports-color - - parse-conflict-json@5.0.1: - dependencies: - json-parse-even-better-errors: 5.0.0 - just-diff: 6.0.2 - just-diff-apply: 5.5.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-exists@5.0.0: {} - - path-key@3.1.1: {} - - path-name@1.0.0: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.3 - - path-to-regexp@8.4.2: {} - - path-type@6.0.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch-browser@2.2.6: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pidtree@0.6.0: {} - - pify@2.3.0: {} - - pinkie-promise@2.0.1: - dependencies: - pinkie: 2.0.4 - - pinkie@2.0.4: {} - - pkce-challenge@5.0.1: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - pnpm-workspace-yaml@1.6.0: - dependencies: - yaml: 2.8.1 - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.5.14: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postject@1.0.0-alpha.6: - dependencies: - commander: 9.5.0 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.89.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - - presentable-error@0.0.1: {} - - proc-log@5.0.0: {} - - proc-log@6.1.0: {} - - process-nextick-args@2.0.1: {} - - proggy@4.0.0: {} - - progress@2.0.3: {} - - promise-all-reject-late@1.0.1: {} - - promise-call-limit@3.0.2: {} - - promise-retry@2.0.1: - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - - propagate@2.0.1: {} - - proto-list@1.2.4: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - qs@6.15.1: - dependencies: - side-channel: '@socketregistry/side-channel@1.0.10' - - quansync@1.0.0: {} - - queue-microtask@1.2.3: {} - - quick-lru@5.1.1: {} - - range-parser@1.2.1: {} - - rate-limiter-flexible@8.3.0: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - read-cmd-shim@6.0.0: {} - - read-package-json-fast@6.0.0: - dependencies: - json-parse-even-better-errors: 6.0.0 - npm-normalize-package-bin: 6.0.0 - - read-pkg-up@8.0.0: - dependencies: - find-up: 5.0.0 - read-pkg: 6.0.0 - type-fest: 1.4.0 - - read-pkg@6.0.0: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 3.0.3 - parse-json: 5.2.0 - type-fest: 1.4.0 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: '@socketregistry/isarray@1.0.8' - process-nextick-args: 2.0.1 - safe-buffer: '@socketregistry/safe-buffer@1.0.9' - string_decoder: 0.10.31 - util-deprecate: 1.0.2 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 0.10.31 - util-deprecate: 1.0.2 - - redent@4.0.0: - dependencies: - indent-string: '@socketregistry/indent-string@1.0.14' - strip-indent: 4.1.1 - - registry-auth-token@5.1.0: - dependencies: - '@pnpm/npm-conf': 2.3.1 - - registry-url@7.2.0: - dependencies: - find-up-simple: 1.0.1 - ini: 5.0.0 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: '@socketregistry/is-core-module@1.0.11' - path-parse: '@socketregistry/path-parse@1.0.8' - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - retry@0.12.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - - rolldown@1.0.3: - dependencies: - '@oxc-project/types': 0.133.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - run-applescript@7.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-execa@0.1.2: - dependencies: - '@zkochan/which': 2.0.3 - execa: 5.1.1(patch_hash=ee0e2217eadd7986ec585d2e684030a05ad958593a9b11affa002a14a5d46f77) - path-name: 1.0.0 - - semver@7.7.2: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - shell-quote@1.8.3: {} - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - sigstore@4.1.0: - dependencies: - '@sigstore/bundle': 4.0.0 - '@sigstore/core': 3.2.0 - '@sigstore/protobuf-specs': 0.5.1 - '@sigstore/sign': 4.1.0(patch_hash=cdf99454490d44e78fde33563611c0bf50da7f256a239c94d3eb7af6c7d205fa) - '@sigstore/tuf': 4.0.2 - '@sigstore/verify': 3.1.0 - transitivePeerDependencies: - - supports-color - - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - slash@5.1.0: {} - - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - smart-buffer@4.2.0: {} - - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - - sort-keys@4.2.0: - dependencies: - is-plain-obj: 2.1.0 - - source-map-js@1.2.1: {} - - source-map@0.6.1: {} - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.23 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-expression-parse@4.0.0: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-license-ids@3.0.23: {} - - split2@3.2.2: - dependencies: - readable-stream: 3.6.2 - - ssri@10.0.5: - dependencies: - minipass: 7.1.3 - - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - - ssri@13.0.1: - dependencies: - minipass: 7.1.3 - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@3.10.0: {} - - stream-meter@1.0.4: - dependencies: - readable-stream: 2.3.8 - - streamx@2.25.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - strict-event-emitter@0.5.1: {} - - string-argv@0.3.2: {} - - string-width@8.1.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.1.2 - - string_decoder@0.10.31: {} - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-indent@4.1.1: {} - - strip-json-comments@2.0.1: {} - - suffix-thumb@5.0.2: {} - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-hyperlinks@2.3.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-fs@3.1.2: - dependencies: - pump: 3.0.4 - tar-stream: 3.1.8 - optionalDependencies: - bare-fs: 4.7.0 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar-stream@3.1.7: - dependencies: - b4a: 1.8.0 - fast-fifo: 1.3.2 - streamx: 2.25.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - tar-stream@3.1.8: - dependencies: - b4a: 1.8.0 - bare-fs: 4.7.0 - fast-fifo: 1.3.2 - streamx: 2.25.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar@7.5.13: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - - taze@19.11.0: - dependencies: - '@antfu/ni': 30.1.0 - '@henrygd/queue': 1.2.0 - cac: 7.0.0 - find-up-simple: 1.0.1 - ofetch: 1.5.1 - package-manager-detector: 1.6.0 - pathe: 2.0.3 - pnpm-workspace-yaml: 1.6.0 - restore-cursor: 5.1.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - unconfig: 7.5.0 - yaml: 2.8.1 - - teex@1.0.1: - dependencies: - streamx: 2.25.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - terminal-link@2.1.1: - dependencies: - ansi-escapes: 4.3.2 - supports-hyperlinks: 2.3.0 - - text-decoder@1.2.7: - dependencies: - b4a: 1.8.0 - transitivePeerDependencies: - - react-native-b4a - - through2@4.0.2: - dependencies: - readable-stream: 3.6.2 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyexec@1.1.2: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@2.1.0: {} - - tinyrainbow@3.1.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - tr46@0.0.3: {} - - trash@10.0.0: - dependencies: - '@sindresorhus/chunkify': 2.0.0 - '@stroncium/procfs': 1.2.1 - globby: 14.1.0 - is-path-inside: 4.0.0 - move-file: 3.1.0 - p-map: 7.0.4 - xdg-trashdir: 3.1.0 - - treeverse@3.0.0: {} - - trim-newlines@4.1.1: {} - - tslib@1.14.1: {} - - tslib@2.8.1: {} - - tsutils@3.21.0(typescript@5.9.3): - dependencies: - tslib: 1.14.1 - typescript: 5.9.3 - - tuf-js@4.1.0: - dependencies: - '@tufjs/models': 4.1.0 - debug: 4.4.3 - make-fetch-happen: 15.0.5 - transitivePeerDependencies: - - supports-color - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: '@socketregistry/safe-buffer@1.0.9' - - type-coverage-core@2.29.7(typescript@5.9.3): - dependencies: - fast-glob: 3.3.3 - minimatch: 10.2.5 - normalize-path: 3.0.0 - tslib: 2.8.1 - tsutils: 3.21.0(typescript@5.9.3) - typescript: 5.9.3 - - type-coverage@2.29.7(typescript@5.9.3): - dependencies: - chalk: 4.1.2 - minimist: 1.2.8 - type-coverage-core: 2.29.7(typescript@5.9.3) - transitivePeerDependencies: - - typescript - - type-fest@0.21.3: {} - - type-fest@1.4.0: {} - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - ufo@1.6.4: {} - - uglify-js@3.19.3: - optional: true - - unconfig-core@7.5.0: - dependencies: - '@quansync/fs': 1.0.0 - quansync: 1.0.0 - - unconfig@7.5.0: - dependencies: - '@quansync/fs': 1.0.0 - defu: 6.1.7 - jiti: 2.7.0 - quansync: 1.0.0 - unconfig-core: 7.5.0 - - undici-types@5.26.5: {} - - undici-types@7.16.0: {} - - unicorn-magic@0.3.0: {} - - universal-user-agent@7.0.3: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - unplugin-purge-polyfills@0.1.0: - dependencies: - defu: 6.1.7 - magic-string: 0.30.19 - mlly: 1.8.2 - unplugin: 2.3.11 - - unplugin@2.3.11: - dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.16.0 - picomatch: 4.0.4 - webpack-virtual-modules: 0.6.2 - - unzipper@0.12.3: - dependencies: - bluebird: 3.7.2 - duplexer2: 0.1.4 - fs-extra: 11.3.4 - graceful-fs: 4.2.11 - node-int64: 0.4.0 - - update-browserslist-db@1.2.3(browserslist@4.25.4): - dependencies: - browserslist: 4.25.4 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - user-home@2.0.0: - dependencies: - os-homedir: 1.0.2 - - util-deprecate@1.0.2: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - validate-npm-package-name@6.0.2: {} - - vary@1.1.2: {} - - vite@8.0.14(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.14 - rolldown: 1.0.3 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 24.9.2 - fsevents: 2.3.3 - jiti: 2.7.0 - yaml: 2.8.1 - - vitest@4.0.3(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1): - dependencies: - '@vitest/expect': 4.0.3 - '@vitest/mocker': 4.0.3(vite@8.0.14(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.3 - '@vitest/runner': 4.0.3 - '@vitest/snapshot': 4.0.3 - '@vitest/spy': 4.0.3 - '@vitest/utils': 4.0.3 - debug: 4.4.3 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 - magic-string: 0.30.19 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@24.9.2)(jiti@2.7.0)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.2 - transitivePeerDependencies: - - '@vitejs/devtools' - - esbuild - - jiti - - less - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - walk-up-path@4.0.0: {} - - web-streams-polyfill@4.0.0-beta.3: {} - - webidl-conversions@3.0.1: {} - - webpack-virtual-modules@0.6.2: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@5.0.0: - dependencies: - isexe: 3.1.5 - - which@6.0.1: - dependencies: - isexe: 4.0.0 - - which@7.0.0: - dependencies: - isexe: 4.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wordwrap@1.0.0: {} - - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.1.0 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - - write-file-atomic@7.0.1: - dependencies: - signal-exit: 4.1.0 - - wsl-utils@0.1.0: - dependencies: - is-wsl: 3.1.1 - - xcase@2.0.1: {} - - xdg-basedir@4.0.0: {} - - xdg-trashdir@3.1.0: - dependencies: - '@sindresorhus/df': 3.1.1 - mount-point: 3.0.0 - user-home: 2.0.0 - xdg-basedir: 4.0.0 - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yallist@4.0.0: {} - - yallist@5.0.0: {} - - yaml@2.8.1: {} - - yargs-parser@21.1.1: {} - - yargs@16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 8.1.0 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} - - yoctocolors-cjs@2.1.3: {} - - zod-to-json-schema@3.25.2(zod@4.1.8): - dependencies: - zod: 4.1.8 - - zod@3.25.76: {} - - zod@4.1.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 0866b4b51..000000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,347 +0,0 @@ -packages: - - packages/* - - '!packages/package-builder/build' - - .claude/hooks/* - -# Packages allowed to run build scripts (pnpm v11 strictDepBuilds default). -allowBuilds: - rolldown: true - postject: false - -# Refuse to run if the pnpm version on PATH differs from the packageManager -# field in package.json. Our setup action pins pnpm via external-tools.json; -# any drift should fail fast, not silently auto-download via @pnpm/exe -# (which in rc.5 leaves a placeholder launcher that errors at runtime). -pmOnFail: error - -catalog: - '@anthropic-ai/claude-code': 2.1.98 - '@babel/core': 7.28.4 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.4 - '@babel/plugin-proposal-export-default-from': 7.27.1 - '@babel/plugin-transform-export-namespace-from': 7.27.1 - '@babel/plugin-transform-runtime': 7.28.3 - '@babel/preset-react': 7.27.1 - '@babel/preset-typescript': 7.27.1 - '@babel/runtime': 7.28.4 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.5 - '@gitbeaker/rest': 43.7.0 - '@iarna/toml': 2.2.5 - '@modelcontextprotocol/sdk': 1.29.0 - '@npmcli/arborist': 9.4.2 - '@npmcli/config': 10.4.0 - '@octokit/graphql': 9.0.1 - '@octokit/openapi-types': 25.1.0 - '@octokit/request-error': 7.0.0 - '@octokit/rest': 22.0.0 - '@octokit/types': 14.1.0 - '@pnpm/dependency-path': 1001.1.0 - '@pnpm/lockfile.detect-dep-types': 1001.0.13 - '@pnpm/lockfile.fs': 1001.1.17 - '@pnpm/logger': 1001.0.0 - '@sentry/node': 8.0.0 - '@sinclair/typebox': 0.34.49 - '@socketregistry/hyrious__bun.lockb': 1.0.19 - '@socketregistry/indent-string': 1.0.14 - '@socketregistry/is-interactive': 1.0.6 - '@socketregistry/packageurl-js': 'npm:@socketregistry/packageurl-js@1.4.2' - '@socketsecurity/lib': 6.0.5 - # -stable aliases: pnpm `overrides:` can't redirect a package's own - # name from inside the same package — Node ESM resolves it as a - # self-reference. The 4 Socket-published packages each need a - # SECOND name (`<canonical>-stable`) so build/script/hook/config - # code resolves to the published catalog version regardless of where - # the importing file lives. See socket-wheelhouse@92cd3e3 for the - # full rationale. - '@socketregistry/packageurl-js-stable': 'npm:@socketregistry/packageurl-js@1.4.2' - '@socketregistry/yocto-spinner': 1.0.25 - '@socketsecurity/lib-stable': 'npm:@socketsecurity/lib@6.0.5' - '@socketsecurity/registry': 'npm:@socketsecurity/registry@2.0.2' - '@socketsecurity/registry-stable': 'npm:@socketsecurity/registry@2.0.2' - '@socketsecurity/sdk': 'npm:@socketsecurity/sdk@4.0.1' - '@socketsecurity/sdk-stable': 'npm:@socketsecurity/sdk@4.0.1' - '@types/adm-zip': 0.5.7 - '@types/cmd-shim': 5.0.2 - '@types/js-yaml': 4.0.9 - '@types/mdast': 4.0.4 - '@types/micromatch': 4.0.9 - '@types/mock-fs': 4.13.4 - '@types/node': 24.9.2 - '@types/npm-package-arg': 6.1.4 - '@types/npmcli__arborist': 6.3.1 - '@types/npmcli__config': 6.0.3 - '@types/proc-log': 3.0.4 - '@types/react': 19.2.2 - '@types/semver': 7.7.1 - '@types/which': 3.0.4 - '@types/yargs-parser': 21.0.3 - '@typescript/native-preview': 7.0.0-dev.20260510.1 - '@vitest/coverage-v8': 4.0.3 - '@vitest/ui': 4.1.6 - '@yao-pkg/pkg': 6.8.0 - '@yarnpkg/parsers': 3.0.3 - adm-zip: 0.5.16 - aggregate-error: npm:@socketregistry/aggregate-error@^1.0.15 - ajv-dist: 8.17.1 - ansi-regex: 6.2.2 - brace-expansion: 5.0.5 - browserslist: 4.25.4 - chalk-table: 1.0.2 - cmd-shim: 7.0.0 - compromise: 14.14.4 - del-cli: 6.0.0 - dev-null-cli: 2.0.0 - ecc-agentshield: 1.4.0 - emoji-regex: 10.6.0 - es-define-property: npm:@socketregistry/es-define-property@^1.0.7 - es-set-tostringtag: npm:@socketregistry/es-set-tostringtag@^1.0.10 - fast-glob: 3.3.3 - fast-xml-parser: 5.5.1 - function-bind: npm:@socketregistry/function-bind@^1.0.7 - globalthis: npm:@socketregistry/globalthis@^1.0.8 - gopd: npm:@socketregistry/gopd@^1.0.7 - graceful-fs: 4.2.11 - has-property-descriptors: npm:@socketregistry/has-property-descriptors@^1.0.7 - has-proto: npm:@socketregistry/has-proto@^1.0.7 - has-symbols: npm:@socketregistry/has-symbols@^1.0.7 - has-tostringtag: npm:@socketregistry/has-tostringtag@^1.0.7 - hasown: npm:@socketregistry/hasown@^1.0.7 - hpagent: 1.2.0 - https-proxy-agent: 7.0.6 - husky: 9.1.7 - ignore: 7.0.5 - indent-string: npm:@socketregistry/indent-string@^1.0.14 - is-core-module: npm:@socketregistry/is-core-module@^1.0.11 - isarray: npm:@socketregistry/isarray@^1.0.8 - js-yaml: npm:@zkochan/js-yaml@0.0.10 - lint-staged: 16.1.6 - lodash: 4.17.21 - magic-string: 0.30.19 - 'mdast-util-from-markdown': 2.0.3 - 'micromark': 4.0.2 - micromatch: 4.0.8 - mock-fs: 5.5.0 - nanotar: 0.2.1 - nock: 14.0.10 - npm-package-arg: 13.0.0 - npm-run-all2: 9.0.0 - octokit: 5.0.5 - onnxruntime-web: 1.23.0 - open: 10.2.0 - oxfmt: 0.48.0 - oxlint: 1.63.0 - packageurl-js: npm:@socketregistry/packageurl-js@^1.4.2 - path-parse: npm:@socketregistry/path-parse@^1.0.8 - postject: 1.0.0-alpha.6 - react: 19.2.0 - react-reconciler: 0.33.0 - registry-auth-token: 5.1.0 - registry-url: 7.2.0 - # Fleet bundler (replaces esbuild) for the CLI bundle. Fleet-latest, - # single-sourced; the override forces vite's bundled rolldown to match. - rolldown: 1.0.3 - safe-buffer: npm:@socketregistry/safe-buffer@^1.0.9 - safer-buffer: npm:@socketregistry/safer-buffer@^1.0.10 - semver: 7.7.2 - set-function-length: npm:@socketregistry/set-function-length@^1.0.10 - shell-quote: 1.8.3 - side-channel: npm:@socketregistry/side-channel@^1.0.10 - ssri: 12.0.0 - string-width: 8.1.0 - string_decoder: 0.10.31 - strip-ansi: 7.1.2 - tar-stream: 3.1.7 - taze: 19.11.0 - terminal-link: 2.1.1 - tiny-colors: 2.1.3 - trash: 10.0.0 - type-coverage: 2.29.7 - typedarray: npm:@socketregistry/typedarray@^1.0.8 - typescript: 5.9.3 - undici: 6.21.3 - unplugin-purge-polyfills: 0.1.0 - # vite 8.0.14 swaps esbuild → rolldown natively; the override forces its - # bundled rolldown up to the catalog's 1.0.3. - vite: 8.0.14 - vitest: 4.0.3 - wrap-ansi: 9.0.2 - xml2js: 0.6.2 - yaml: 2.8.1 - yargs-parser: 21.1.1 - yoctocolors-cjs: 2.1.3 - zod: 4.1.8 -# pnpm v11 reads settings from this file; only auth/registry go in .npmrc. -ignoreScripts: true -linkWorkspacePackages: false - -# Wait 7 days (10080 minutes) before installing newly published packages. -minimumReleaseAge: 10080 -minimumReleaseAgeExclude: - - '@anthropic-ai/claude-code@2.1.98' - - '@socketaddon/*' - - '@socketbin/*' - - '@socketregistry/*' - - '@socketsecurity/*' - # Network-mocking lib used in fleet test suites. v15 betas pre-date - # npm's `time` field for the major; allow pinned beta until v15 GA. - - 'nock@15.0.0-beta.11' - # Security fix for CVE-class CSS injection in JSX SSR + JWT - # NumericDate validation + cache-leak. Dependabot advisories #140, - # #141, #142 (all hono <4.12.18). Override forces the patched version - # via pnpm-workspace.yaml `overrides`; pulling it through the - # minimum-release-age gate as an explicit security exception. - - 'hono@4.12.18' - - 'npm-run-all2@9.0.0' - # vite 8.0.14 ships rolldown 1.0.2 natively; the override forces 1.0.3. - # Scoped to the exact tag so a future vite 8.0.15 still soaks. - # published: 2026-05-21 | removable: 2026-05-28 - - 'vite@8.0.14' - # rolldown 1.0.3 is the fleet bundler for the CLI bundle; the per-platform - # bindings share its pin. `@rolldown/pluginutils` versions independently — - # rolldown depends on `^1.0.0` → resolves to 1.0.1. Listed explicitly per - # the fleet "no scope globs in soak excludes" rule. - # published: 2026-05-27 | removable: 2026-06-03 - - 'rolldown@1.0.3' - # published: 2026-05-13 | removable: 2026-05-20 - - '@rolldown/pluginutils@1.0.1' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-darwin-arm64@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-darwin-x64@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-arm64-gnu@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-arm64-musl@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-x64-gnu@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-x64-musl@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-win32-x64-msvc@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-win32-arm64-msvc@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-android-arm64@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-freebsd-x64@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-ppc64-gnu@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-linux-s390x-gnu@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-openharmony-arm64@1.0.3' - # published: 2026-05-27 | removable: 2026-06-03 - - '@rolldown/binding-wasm32-wasi@1.0.3' - # rolldown's AST/types layer. rolldown 1.0.3 pins `=0.133.0`. - # published: 2026-05-26 | removable: 2026-06-02 - - '@oxc-project/types@0.133.0' - # @npmcli/read-package-json-fast 6.0.0 (2026-05-15) is npm-run-all2 9's - # ESM-only major transitive. Bypassed alongside the parent bump so the - # lockfile resolves cleanly today rather than waiting 4 days for the - # natural soak clearance on 2026-05-22. - - 'read-package-json-fast@6.0.0' - -# Refuse transitive dependencies declared via git/tarball/local-tarball -# specs — an npm package shouldn't be allowed to drag in a git URL we -# don't control (bypasses npm registry validation, no provenance, no -# soak window). Direct git deps are still allowed (the test suite at -# pnpm/pkg-manager/core/test/install/blockExoticSubdeps.ts confirms -# this). pnpm's current default is `false`; declared explicitly so a -# future flip can't silently change install behavior. -blockExoticSubdeps: true - -# Dependency overrides (migrated from package.json pnpm.overrides). -# Force every consumer of Socket's own packages to resolve through the -# catalog-pinned published versions. The `catalog:` form rewrites -# `workspace:*`, `^x.y.z`, and bare-version specs alike to the version -# in the default `catalog:` block above. This defeats accidental -# local-checkout resolution when a sibling repo is on disk. -overrides: - '@octokit/graphql': 'catalog:' - '@octokit/request-error': 'catalog:' - '@sigstore/sign': '4.1.0' - '@socketregistry/packageurl-js': 'catalog:' - '@socketregistry/packageurl-js-stable': 'catalog:' - '@socketsecurity/lib': 'catalog:' - '@socketsecurity/lib-stable': 'catalog:' - '@socketsecurity/registry': 'catalog:' - '@socketsecurity/registry-stable': 'catalog:' - '@socketsecurity/sdk': 'catalog:' - '@socketsecurity/sdk-stable': 'catalog:' - 'aggregate-error': 'catalog:' - 'ansi-regex': 'catalog:' - 'brace-expansion': 'catalog:' - 'defu': '>=6.1.7' - 'emoji-regex': 'catalog:' - 'es-define-property': 'catalog:' - 'es-set-tostringtag': 'catalog:' - 'fast-uri': '>=3.1.2' - 'function-bind': 'catalog:' - 'glob': '>=13.0.6' - 'globalthis': 'catalog:' - 'gopd': 'catalog:' - 'graceful-fs': 'catalog:' - 'has-property-descriptors': 'catalog:' - 'has-proto': 'catalog:' - 'has-symbols': 'catalog:' - 'has-tostringtag': 'catalog:' - 'hasown': 'catalog:' - 'hono': '>=4.12.18' - 'https-proxy-agent': 'catalog:' - 'indent-string': 'catalog:' - 'ip-address': '>=10.2.0' - 'is-core-module': 'catalog:' - 'isarray': 'catalog:' - 'lodash': 'catalog:' - 'npm-package-arg': 'catalog:' - 'packageurl-js': 'catalog:' - 'path-parse': 'catalog:' - 'postcss': '>=8.5.14' - 'qs': '>=6.15.1' - # Force vite's bundled rolldown (1.0.2) up to the catalog's 1.0.3. - 'rolldown': 'catalog:' - 'safe-buffer': 'catalog:' - 'safer-buffer': 'catalog:' - 'semver': 'catalog:' - 'set-function-length': 'catalog:' - 'shell-quote': 'catalog:' - 'side-channel': 'catalog:' - 'signal-exit': '4.1.0' - 'string-width': 'catalog:' - 'string_decoder': 'catalog:' - 'strip-ansi': 'catalog:' - 'tiny-colors': 'catalog:' - 'typedarray': 'catalog:' - 'undici': 'catalog:' - 'vite': 'catalog:' - 'wrap-ansi': 'catalog:' - 'xml2js': 'catalog:' - 'yaml': 'catalog:' - 'yargs-parser': 'catalog:' - -# Auto-install missing peer deps (pnpm default). Declared explicitly -# so a future default flip can't silently change install behavior. -autoInstallPeers: true - -# Run pre/post lifecycle scripts on the workspace root (e.g. -# prepare -> husky). This is the pnpm default; declared explicitly -# so a future default flip can't silently disable husky setup. -enablePrePostScripts: true - -# Patched dependencies (migrated from package.json pnpm.patchedDependencies). -patchedDependencies: - '@npmcli/run-script@10.0.4': patches/@npmcli__run-script@10.0.4.patch - '@sigstore/sign@4.1.0': patches/@sigstore__sign@4.1.0.patch - execa@2.1.0: patches/execa@2.1.0.patch - execa@5.1.1: patches/execa@5.1.1.patch - node-gyp@12.2.0: patches/node-gyp@12.2.0.patch -saveExact: true -strictPeerDependencies: true -trustPolicy: no-downgrade -trustPolicyExclude: - - undici@6.21.3 - - 'compromise@14.15.0' diff --git a/scripts/ai-lint-fix.mts b/scripts/ai-lint-fix.mts deleted file mode 100644 index 0d8c4aeb5..000000000 --- a/scripts/ai-lint-fix.mts +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node -/** - * @file Thin entry shim — real CLI lives in ai-lint-fix/cli.mts. Rule data - * (AI_HANDLED_RULES + RULE_GUIDANCE) lives in ai-lint-fix/rule-guidance.mts - * so the prompt corpus can be reviewed / extended without touching the - * orchestrator. - */ - -import './ai-lint-fix/cli.mts' diff --git a/scripts/ai-lint-fix/cli.mts b/scripts/ai-lint-fix/cli.mts deleted file mode 100644 index 86f36d09f..000000000 --- a/scripts/ai-lint-fix/cli.mts +++ /dev/null @@ -1,431 +0,0 @@ -/** - * @file AI-assisted lint fix step. Runs after `pnpm run lint --fix` (oxlint + - * oxfmt deterministic autofix) to handle the lint findings that aren't safely - * mechanically fixable. The CLAUDE.md "Lint rules" guidance is to autofix - * when the rewrite is unambiguous; what's left after the deterministic pass - * is by definition the judgment-call set. Pipeline: - * - * 1. Run `pnpm run lint --json` to capture remaining violations. - * 2. If there are any findings the AI step is allowed to handle, build a - * per-file batch and spawn a headless `claude --print` with Sonnet, the - * four lockdown flags, and a tight tool list (Read, Edit, Grep, Glob). - * Each spawn handles one file's worth of findings to keep the context - * window predictable. - * 3. After all spawns finish, re-run `pnpm run lint` (without --fix) to verify - * nothing got worse. If the count went up, log a warning and exit - * non-zero. Skipped silently: - * - * - When the `claude` CLI isn't on PATH. - * - When `SKIP_AI_FIX=1` is set (CI sets this; AI-fix runs locally). - * - When `--no-ai` is passed. The four lockdown flags per CLAUDE.md - * "Programmatic Claude calls": - * - tools / allowedTools / disallowedTools / permissionMode. Cost / safety: - * - Sonnet 4.6, not Opus — judgment work but not architecturally deep; - * cost-tier-appropriate. - * - Per-file batches with a 5-minute timeout — bounds runaway loops. - * - Tools restricted to Read/Edit/Grep/Glob — no Bash, no Write of new files. - * The AI can only edit files that already exist. - * - permissionMode `acceptEdits` so Edit calls don't deadlock on the missing - * AskUserQuestion surface. Rule data (which rules the AI handles + per-rule - * guidance prompts) lives in `./rule-guidance.mts` so the prompt corpus can - * be reviewed / extended without touching the orchestrator logic. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { discoverAiAgents } from '@socketsecurity/lib-stable/ai/discover' -import { AI_PROFILE } from '@socketsecurity/lib-stable/ai/profiles' -import { spawnAiAgent } from '@socketsecurity/lib-stable/ai/spawn' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { isSpawnError } from '@socketsecurity/lib-stable/process/spawn/errors' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { AI_HANDLED_RULES, RULE_GUIDANCE } from './rule-guidance.mts' - -const logger = getDefaultLogger() - -interface OxlintMessage { - ruleId?: string | undefined - message: string - severity: number - line: number - column: number - endLine?: number | undefined - endColumn?: number | undefined -} - -interface OxlintFile { - filePath: string - messages: OxlintMessage[] -} - -/** - * Raw shape of a diagnostic in oxlint's `--format=json` output. The wrapper - * object is `{ "diagnostics": [Diagnostic, ...] }`. Each diagnostic carries - * `code` (e.g. `"socket(rule-id)"`), `filename`, and a `labels[]` array whose - * first entry has the source span. - */ -interface OxlintDiagnostic { - code: string - filename: string - message: string - severity: string - labels: Array<{ - span: { - offset: number - length: number - line: number - column: number - } - }> -} - -interface OxlintJsonOutput { - diagnostics: OxlintDiagnostic[] -} - -/** - * Normalize oxlint's `{diagnostics:[...]}` payload into the ESLint-style - * `OxlintFile[]` shape the rest of this CLI expects. Strip the `socket(...)` - * wrapper around the rule code so AI_HANDLED_RULES (which stores bare rule - * names) matches. - */ -function normalizeOxlintJson(payload: OxlintJsonOutput): OxlintFile[] { - const byFile = new Map<string, OxlintMessage[]>() - for (const d of payload.diagnostics) { - const label = d.labels[0] - if (!label) { - continue - } - // `code` looks like "socket(prefer-async-spawn)" or - // "eslint(no-unused-vars)"; strip the plugin wrapper. - const ruleId = - typeof d.code === 'string' && d.code.includes('(') - ? d.code.replace(/^[^(]+\(([^)]+)\).*$/, '$1') - : d.code - const msg: OxlintMessage = { - ruleId, - message: d.message, - severity: d.severity === 'error' ? 2 : 1, - line: label.span.line, - column: label.span.column, - } - const existing = byFile.get(d.filename) - if (existing) { - existing.push(msg) - } else { - byFile.set(d.filename, [msg]) - } - } - return Array.from(byFile, ([filePath, messages]) => ({ filePath, messages })) -} - -interface CliArgs { - noAi: boolean - staged: boolean - all: boolean - passthrough: string[] -} - -function parseArgs(argv: readonly string[]): CliArgs { - const passthrough: string[] = [] - let noAi = false - let staged = false - let all = false - for (let i = 0, { length } = argv; i < length; i += 1) { - const arg = argv[i]! - if (arg === '--no-ai') { - noAi = true - continue - } - if (arg === '--staged') { - staged = true - passthrough.push(arg) - continue - } - if (arg === '--all') { - all = true - passthrough.push(arg) - continue - } - passthrough.push(arg) - } - return { all, noAi, passthrough, staged } -} - -async function runLintJson( - passthrough: readonly string[], -): Promise<OxlintFile[]> { - // Run oxlint directly with --format=json. Bypass `pnpm run lint` - // because that wrapper formats for humans. - const args = [ - 'exec', - 'oxlint', - '--format=json', - '--config=.config/oxlintrc.json', - ...passthrough.filter(a => a !== '--all'), - ] - if (!passthrough.includes('--all') && !passthrough.includes('--staged')) { - args.push('.') - } - let stdout = '' - try { - const result = await spawn('pnpm', args, { - shell: process.platform === 'win32', - stdio: 'pipe', - stdioString: true, - }) - stdout = String(result.stdout ?? '') - } catch (e) { - if (isSpawnError(e)) { - // oxlint exits non-zero when there are violations — that's - // expected. Read stdout regardless. - stdout = String(e.stdout ?? '') - } else { - throw e - } - } - if (!stdout.trim()) { - return [] - } - try { - const parsed = JSON.parse(stdout) as OxlintJsonOutput - if (!parsed || !Array.isArray(parsed.diagnostics)) { - return [] - } - return normalizeOxlintJson(parsed) - } catch { - logger.warn('oxlint JSON parse failed; skipping AI-fix') - return [] - } -} - -function bucketFindings(files: OxlintFile[]): Map<string, OxlintMessage[]> { - const byFile = new Map<string, OxlintMessage[]>() - for (let i = 0, { length } = files; i < length; i += 1) { - const f = files[i]! - const handled = f.messages.filter( - m => m.ruleId && AI_HANDLED_RULES.has(m.ruleId), - ) - if (handled.length === 0) { - continue - } - byFile.set(f.filePath, handled) - } - return byFile -} - -function renderFindings(findings: OxlintMessage[], _rel: string): string { - return findings - .map( - f => - `<finding rule="${f.ruleId}" line="${f.line}" column="${f.column}">${f.message - .replace(/[<>&]/g, ch => - ch === '<' ? '<' : ch === '>' ? '>' : '&', - ) - .replace(/\n/g, ' ')}</finding>`, - ) - .map(line => ` ${line}`) - .join('\n') -} - -function renderRuleGuidance(findings: OxlintMessage[]): string { - const seen = new Set<string>() - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - if (f.ruleId) { - seen.add(f.ruleId) - } - } - const entries = [...seen] - .toSorted() - .map(id => { - const guidance = RULE_GUIDANCE[id] - if (!guidance) { - return '' - } - return ` <rule id="${id}">${guidance}</rule>` - }) - .filter(s => s.length > 0) - if (entries.length === 0) { - return '' - } - return `<rules>\n${entries.join('\n')}\n</rules>` -} - -/** - * Build the per-file prompt. Structure follows Anthropic's prompt- engineering - * best practices for headless tool-use: - * - * - <role>: senior engineer doing a careful refactor — sets the bar above "quick - * autofix" so the model treats edge cases. - * - <task>: one-sentence framing. - * - <file>: the target path. Edits must stay scoped to it. - * - <findings>: machine-readable list of violations. - * - <rules>: per-rule canonical rewrite + good/bad examples (low freedom). - * - <process>: numbered steps that force a Read → reason → Edit → self-verify - * loop. Self-verify is the highest-leverage step — it catches the - * import/callsite mismatch class that produced past breakage. - * - <constraints>: hard rules — no Bash, no Write, single-file scope, no orphan - * imports. - * - <reminders>: instructions repeated at the END for the long- context regime - * per Anthropic guidance. - * - <output>: response format expectation, prefilled to suppress markdown / - * preamble. - * - * The prompt is intentionally short but the structure is explicit. Adding - * boilerplate dilutes instructions; omitting the verify step is how this prompt - * has historically produced orphan imports. - */ -function buildPrompt(filePath: string, findings: OxlintMessage[]): string { - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- relative path for prompt display; user invokes `pnpm run fix` from their cwd and expects paths relative to where they ran. - const rel = path.relative(process.cwd(), filePath) - const findingsBlock = renderFindings(findings, rel) - const rulesBlock = renderRuleGuidance(findings) - return `<role> -You are a principal TypeScript engineer with a perfectionist mindset applying a careful, minimal-diff refactor in response to lint findings. You hold yourself to a higher standard than the rule strictly requires: you read the whole file before touching it, you trace every reference you're about to rename, and you re-read the file after editing to confirm the result is internally consistent. - -Opt for doing things correctly over cutting corners. If the right fix touches multiple parts of the file, do all of them. If the right fix requires understanding how a function is called within this file, read those callsites before editing. Never apply a partial fix that satisfies the lint message but leaves the file in a broken state. "Works on the happy path" is not done. "Builds, type-checks, and survives my own self-verification" is done. - -A fix that introduces a runtime crash (e.g. renaming an imported binding without updating call sites) is worse than leaving the finding alone — when in doubt, skip the finding and report why. -</role> - -<task>Fix the lint findings in a single source file. Do not edit other files.</task> - -<file>${rel}</file> - -<findings> -${findingsBlock} -</findings> - -${rulesBlock} - -<process> - <step n="1">Use the Read tool to view ${rel} in full. Do not edit before reading.</step> - <step n="2">For each finding, identify the canonical rewrite from the matching <rule> entry above. If multiple rewrites are possible, choose the one with the smallest diff.</step> - <step n="3">Apply the rewrites with the Edit tool. Each Edit must preserve unrelated code, comments, blank lines, and formatting exactly.</step> - <step n="4">SELF-VERIFY: use the Read tool to view ${rel} again. Walk through every import you changed and confirm every reference to the old name in the same file is either (a) covered by the new import, or (b) also rewritten in the same Edit pass. A file that imports X but uses Y, or imports Y but uses X, is broken — fix it before you stop.</step> - <step n="5">Reply with ONE short sentence summarizing what changed and (if applicable) which findings you skipped and why.</step> -</process> - -<constraints> - <constraint>Edit only ${rel}. Do not create new files. Do not run Bash commands.</constraint> - <constraint>NEVER end an edit with an imported binding that's not used, or a used identifier that's not imported. Self-verify (step 4) is required, not optional.</constraint> - <constraint>If a finding requires changes you cannot safely make (e.g. splitting a 1000-line file, implementing a placeholder, a rewrite that ripples into other files), skip it and state why. Do not delete the marker, do not produce a partial fix, do not invent a workaround.</constraint> - <constraint>If you cannot determine the right rewrite for a finding, skip it. A skipped finding will be re-evaluated on the next lint run; a wrong fix breaks the build.</constraint> - <constraint>Apply the minimum diff needed. No drive-by cleanups, no reformatting, no "while I'm here" changes.</constraint> -</constraints> - -<reminders> -The single most important step is step 4 (self-verify). Past failures: import binding renamed (\`spawnSync\` → \`spawn\`) but every call site still says \`spawnSync\` — module load crashes with ReferenceError. Local const injected when an \`export const\` of the same name already exists — module load crashes with redeclaration error. Both are caught by step 4. Run step 4 every time, no exceptions. -</reminders> - -<output>One short sentence. No markdown, no code blocks, no preamble. Format: "Fixed N findings: <summary>." or "Fixed N findings, skipped M: <summary>; <skip reasons>." If you applied no edits, lead with "Skipped all findings: <reason>".</output>` -} - -async function runClaudeFix( - _filePath: string, - prompt: string, - cwd: string, -): Promise<{ exitCode: number; stdout: string; stderr: string }> { - // AI_PROFILE.edit = in-place edits only (Edit on existing files, no - // Write/MultiEdit) — exactly the lint-fix contract: the prompt forbids - // creating files. spawnAiAgent owns the --no-session-persistence / - // --add-dir / 529-retry the hand-rolled version used to duplicate. - const { exitCode, stderr, stdout } = await spawnAiAgent({ - ...AI_PROFILE.edit, - cwd, - model: 'claude-sonnet-4-6', - prompt, - timeoutMs: 5 * 60 * 1000, - }) - return { exitCode, stderr, stdout } -} - -async function hasClaudeCli(cwd: string): Promise<boolean> { - // discoverAiAgents resolves each known agent CLI via `which`; claude - // is present iff it's a key in the returned map. - const discovered = await discoverAiAgents({ repoRoot: cwd }) - return 'claude' in discovered -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - if (args.noAi) { - return - } - if (process.env['SKIP_AI_FIX'] === '1') { - return - } - if (!existsSync('.config/oxlintrc.json')) { - return - } - - const files = await runLintJson(args.passthrough) - const byFile = bucketFindings(files) - if (byFile.size === 0) { - return - } - - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- relative path for log output; user invokes `pnpm run fix` from their cwd and expects paths relative to where they ran. - const cwd = process.cwd() - - if (!(await hasClaudeCli(cwd))) { - const total = [...byFile.values()].reduce((n, m) => n + m.length, 0) - logger.warn( - `${total} AI-handled lint findings remain in ${byFile.size} files; skipping AI-fix step (claude CLI not on PATH).`, - ) - return - } - - let totalEdits = 0 - let totalErrors = 0 - - for (const [filePath, findings] of byFile) { - const rel = path.relative(cwd, filePath) - logger.log(`AI-fix ${rel} (${findings.length} findings)…`) - const prompt = buildPrompt(filePath, findings) - const { exitCode, stderr } = await runClaudeFix(filePath, prompt, cwd) - if (exitCode === 0) { - totalEdits += findings.length - continue - } - totalErrors++ - logger.warn(`AI-fix exited ${exitCode} for ${rel}: ${stderr.slice(0, 200)}`) - } - - // Verification — re-run lint and count remaining AI-handled - // findings. Per CLAUDE.md / Anthropic best practices, "give Claude - // a way to verify its work" is the highest-leverage thing; we do - // it at the script level since the AI subprocesses don't have Bash. - const beforeCount = [...byFile.values()].reduce((n, m) => n + m.length, 0) - const afterFiles = await runLintJson(args.passthrough) - const afterByFile = bucketFindings(afterFiles) - const afterCount = [...afterByFile.values()].reduce((n, m) => n + m.length, 0) - - if (totalErrors > 0) { - logger.warn( - `AI-fix finished with ${totalErrors} subprocess errors. ${afterCount}/${beforeCount} findings remain. Re-run \`pnpm run lint\` to see what survived.`, - ) - process.exitCode = 1 - return - } - if (afterCount > beforeCount) { - logger.warn( - `AI-fix introduced regressions: ${beforeCount} → ${afterCount} findings. Inspect the changes.`, - ) - process.exitCode = 1 - return - } - logger.log( - `AI-fix attempted ${totalEdits} findings across ${byFile.size} files (${beforeCount} → ${afterCount} remaining).`, - ) -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(`ai-lint-fix: ${msg}`) - process.exitCode = 1 -}) diff --git a/scripts/ai-lint-fix/rule-guidance.mts b/scripts/ai-lint-fix/rule-guidance.mts deleted file mode 100644 index f4f043290..000000000 --- a/scripts/ai-lint-fix/rule-guidance.mts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file Rule allowlist + per-rule prompt guidance for the AI-fix orchestrator. - * Kept separate from `cli.mts` because: - * - * 1. The data is large (~200 LOC of prompt text) and changes independently from - * the orchestrator logic. - * 2. Editing a prompt is a content review, not a code review — having it in its - * own file makes that distinction visible. - * 3. Adding / removing a rule is a one-file edit here; the orchestrator just - * imports `AI_HANDLED_RULES` + `RULE_GUIDANCE` and works with whatever's - * defined. Invariant: every entry in `AI_HANDLED_RULES` must have a - * matching key in `RULE_GUIDANCE`. The orchestrator iterates findings, - * filters to AI-handled rules, then looks up the guidance text per rule - * id. A missing guidance entry would render an empty `<rule>` block — the - * lint runner's `validate-template.mts` could enforce this if drift ever - * becomes a concern. - */ - -// Rules below need an AI-driven fix because the right rewrite -// depends on surrounding code structure that a regex / AST pass can't -// safely infer. Each one IS fixable — the AI step does the work. -// The deterministic linter already handled the unambiguous shapes; -// what remains is the structural-rewrite set. -export const AI_HANDLED_RULES: ReadonlySet<string> = new Set([ - 'socket/inclusive-language', - 'socket/max-file-lines', - 'socket/no-fetch-prefer-http-request', - 'socket/no-placeholders', - 'socket/personal-path-placeholders', - 'socket/prefer-async-spawn', - 'socket/prefer-exists-sync', - 'socket/prefer-node-builtin-imports', - 'socket/prefer-undefined-over-null', -]) - -/** - * Per-rule guidance — concise, low-freedom (one canonical rewrite per rule). - * Built per Anthropic's prompt-engineering best practices: direct instructions, - * XML structure, examples per rule. - * - * Each entry is rendered into the prompt as `<rule id="...">…</rule>` inside a - * `<rules>` block. Claude sees only the rules that fired in the current file, - * so noise stays low. - */ -export const RULE_GUIDANCE: Readonly<Record<string, string>> = { - // oxlint-disable-next-line socket/prefer-undefined-over-null -- null-prototype object literal. - __proto__: null, - // oxlint-disable-next-line socket/inclusive-language -- rule guidance string documents the legacy terms it scans for. - 'socket/inclusive-language': - 'Replace `master`/`slave` with the contextually correct term: `main` (branch), `primary`/`controller` (process), `replica`/`worker`/`secondary`/`follower` (subordinate). Read the surrounding code to pick the right one. Do not autofix when an external API field name forces the legacy term — leave a `// inclusive-language: external-api` comment instead.', - 'socket/personal-path-placeholders': - "Two scenarios. (1) Source code / docs / tests: replace literal usernames in user-home paths with the canonical placeholder — `<user>` for /Users/ and /home/, `<USERNAME>` for C:\\Users\\. Env-var forms (`$HOME`, `${USER}`, `%USERNAME%`) are also acceptable. (2) WASM / generated bundles / minified output: a literal username inside compiled output means the bundler is leaking the developer's path. Trace back to the build config (esbuild / rolldown / webpack `sourcemap`, `sourceRoot`, `__dirname` baking, fs.realpath calls in plugins) and fix THAT — do not chase the string in the artifact.", - 'socket/prefer-exists-sync': - 'Rewrite `fs.access` / `fs.stat` existence-checks to `existsSync(p)` from `node:fs`. Common shapes: `try { await fs.access(p); return true } catch { return false }` → `return existsSync(p)`. `await fs.access(p).then(() => true).catch(() => false)` → `existsSync(p)`. `if (await fs.stat(p))` → `if (existsSync(p))`. When the stat result is destructured for metadata (`s.size`, `s.mtime`, `s.isDirectory()`), KEEP the stat call and add a one-line comment stating intent — that is not an existence check. Trace back through callers: if the caller awaited a Promise<boolean>, the rewrite collapses to a sync boolean and the await becomes a no-op (safe).', - 'socket/prefer-node-builtin-imports': - "Rewrite `import fs from 'node:fs'` / `import * as fs from 'node:fs'` to `import { … } from 'node:fs'` with the names actually used in the file. Change every `fs.X` reference to bare `X`. If `fs` is passed as a value (e.g. `someApi(fs)`), keep the namespace import and add a `// prefer-node-builtin-imports: passed-as-value` comment.", - 'socket/prefer-async-spawn': `Replace \`node:child_process\` spawn calls with their \`@socketsecurity/lib-stable/process/spawn/child\` equivalents. The lib re-exports BOTH names so a sync caller keeps using \`spawnSync\` and only the import source changes; only convert sync → async when the enclosing function is already async (or can be safely made async) AND every caller of that function is async-ready. - -<process> - 1. List every spawn-family callsite in the file: \`spawnSync(\`, \`spawn(\`, \`child_process.spawnSync(\`, \`cp.spawnSync(\`. Note which names are actually used. - 2. For each callsite, decide: (a) keep sync semantics — use \`spawnSync\` from the lib (drop-in, same args, same return shape \`{ status, stdout, stderr }\`); or (b) convert to async — use \`spawn\` from the lib (returns a Promise of \`{ code, stdout, stderr }\`, requires \`await\`, requires async enclosing context, return shape uses \`.code\` not \`.status\`). Default to (a) unless you can verify (b) is safe — sync → async is a contract change. - 3. Update the import line. If every callsite stays sync: \`import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'\`. If every callsite becomes async: \`import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'\`. If mixed: \`import { spawn, spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'\`. - 4. Self-verify before stopping: re-read the file. Confirm EVERY \`spawnSync(\` callsite is satisfied by the new import (either the name is in the import list OR you converted that callsite to \`await spawn(\`). A file with \`import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'\` and a body containing \`spawnSync(\` is broken — fix it before you declare done. -</process> - -<good-fix description="Sync caller; safest path is keeping sync semantics by importing spawnSync from the lib."> -- import { spawnSync } from 'node:child_process' -+ import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - - function run(cmd) { - const r = spawnSync(cmd, [], { encoding: 'utf8' }) - return r.status === 0 - } -</good-fix> - -<bad-fix description="What you must NOT do: rename the import without updating callsites."> -- import { spawnSync } from 'node:child_process' -+ import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - - function run(cmd) { - const r = spawnSync(cmd, [], { encoding: 'utf8' }) // ❌ spawnSync is no longer imported — runtime ReferenceError - return r.status === 0 - } -</bad-fix> - -<good-fix description="Async caller; can switch to lib's async spawn AND update return-shape access."> -- import { spawnSync } from 'node:child_process' -+ import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - - async function run(cmd) { - const r = await spawn(cmd, [], { stdio: 'pipe' }) - return r.code === 0 // .code, not .status - } -</good-fix>`, - 'socket/prefer-undefined-over-null': - 'In the target file, flip BOTH the value and the surrounding type annotation in lockstep: `let x: string | null = null` → `let x: string | undefined = undefined`. Apply to function-parameter annotations, return-type annotations, generic-parameter constraints, interface / type-alias members. For tight-equality checks in the same file: `x === null` → `x === undefined` (loose `x == null` already covers both — leave loose-equality alone). DO NOT edit other files; if a caller in another file depends on the type, the lint rule will fire there on the next run and a separate AI-fix subprocess will pick it up. Skip the finding if the type is a third-party API contract you cannot change (e.g. a return type from a library).', - 'socket/max-file-lines': - 'Split the file along its natural seams: one tool/domain/phase per file. Name the new files descriptively (`spawn-cdxgen.mts`, `parse-arguments.mts`). Update import paths in callers. Do not introduce a barrel just to hide the split. If the file is a single legitimate parser/state-machine/table, add a leading `// max-file-lines: legitimate parser` comment instead of splitting.', - 'socket/no-placeholders': - 'Implement the placeholder. If the work is too large, do NOT delete the marker — leave the file unchanged and explain in your final reply.', - 'socket/no-fetch-prefer-http-request': - 'Replace `fetch(url, opts)` with the right helper from `@socketsecurity/lib-stable/http-request`: `httpJson` when the caller calls `.json()` on the response, `httpText` when it calls `.text()`, `httpRequest` for raw access. Add the named import.', -} as unknown as Readonly<Record<string, string>> diff --git a/scripts/audit-transcript.mts b/scripts/audit-transcript.mts deleted file mode 100644 index 7bf1af52b..000000000 --- a/scripts/audit-transcript.mts +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env node -/** - * @file Read-only forensic scan of a Claude Code transcript. Flags tool-use - * patterns that touched security-sensitive surfaces — gh auth flows, keychain - * reads, signing-key reads, dscl authenticate calls, sudo with non-trivial - * commands, security-tool installs. Never blocks anything; the point is - * post-hoc visibility into what an agent session actually did with privileged - * tooling. Usage: node scripts/audit-transcript.mts <transcript-path> node - * scripts/audit-transcript.mts --json <transcript-path> node - * scripts/audit-transcript.mts --recent # auto-pick most recent Output: - * human-readable report grouped by category. With --json, emits {findings: - * [...]} for programmatic consumption. The transcript JSONL lives at - * ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl on macOS / Linux. - * --recent auto-picks the most-recently-modified transcript for the cwd the - * script is invoked from. - */ - -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -interface Finding { - // Severity tier. critical = direct credential exfil risk; warn = - // unusual but explainable; info = forensic record only. - severity: 'critical' | 'warn' | 'info' - // Short category label. - category: string - // Verbatim or summarized command/input that triggered the finding. - evidence: string - // 1-based index in the JSONL of the line that produced this. - line: number -} - -interface ToolUseEvent { - name: string - input: Record<string, unknown> - line: number -} - -function readToolUses(transcriptPath: string): ToolUseEvent[] { - if (!existsSync(transcriptPath)) { - throw new Error(`transcript not found: ${transcriptPath}`) - } - const raw = readFileSync(transcriptPath, 'utf8') - const lines = raw.split('\n').filter(Boolean) - const out: ToolUseEvent[] = [] - for (let i = 0; i < lines.length; i += 1) { - let evt: unknown - try { - evt = JSON.parse(lines[i]!) - } catch { - continue - } - // Tool uses appear under message.content[] for assistant turns. - const msg = ( - evt as { message?: { content?: unknown | undefined } | undefined } - ).message - const content = msg?.content - if (!Array.isArray(content)) { - continue - } - for (const block of content) { - if (!block || typeof block !== 'object') { - continue - } - const b = block as Record<string, unknown> - if (b['type'] !== 'tool_use') { - continue - } - const name = typeof b['name'] === 'string' ? b['name'] : undefined - const input = b['input'] - if (!name || !input || typeof input !== 'object') { - continue - } - out.push({ - name, - input: input as Record<string, unknown>, - line: i + 1, - }) - } - } - return out -} - -const PATTERNS: ReadonlyArray<{ - severity: Finding['severity'] - category: string - // Predicate: does this Bash command match this pattern? - matches: (command: string) => boolean - // Optional input shape filter (tool_name). - tool?: string | undefined -}> = [ - // CRITICAL — direct credential exposure paths. - { - severity: 'critical', - category: 'gh auth login (re-auth — verify expected)', - tool: 'Bash', - matches: c => /\bgh\s+auth\s+(?:login|logout)\b/.test(c), - }, - { - severity: 'critical', - category: 'gh auth refresh -s workflow (workflow scope grant)', - tool: 'Bash', - matches: c => - /\bgh\s+auth\s+refresh\b/.test(c) && - /(?:^|\s)(?:--scopes|-s)\b[^|;&]*\bworkflow\b/.test(c), - }, - { - severity: 'critical', - category: 'gh workflow dispatch (release/publish surface)', - tool: 'Bash', - matches: c => - /\bgh\s+workflow\s+(?:dispatch|run)\b/.test(c) || - (/\bgh\s+api\b/.test(c) && - /\/actions\/workflows\/[^/\s]+\/dispatches\b/.test(c)), - }, - { - severity: 'critical', - category: 'keychain READ via platform CLI', - tool: 'Bash', - matches: c => - /\bsecurity\s+find-(?:generic|internet)-password\b/.test(c) || - /\bsecret-tool\s+lookup\b/.test(c) || - /\bkeyring\s+get\b/.test(c) || - /\bGet-StoredCredential\b/.test(c), - }, - { - severity: 'critical', - category: 'dscl authentication probe', - tool: 'Bash', - matches: c => /\bdscl\b[^|;&]*-authonly\b/.test(c), - }, - { - severity: 'critical', - category: 'sudo invocation (non-cached)', - tool: 'Bash', - matches: c => - /(?:^|\s|;|&&|\|\|)sudo\s+/.test(c) && !/\bsudo\s+-k\b/.test(c), - }, - // WARN — unusual surfaces that should be checked. - { - severity: 'warn', - category: 'gh auth status (token introspection)', - tool: 'Bash', - matches: c => /\bgh\s+auth\s+status\b/.test(c), - }, - { - severity: 'warn', - category: 'security add-/delete-generic-password (keychain write)', - tool: 'Bash', - matches: c => - /\bsecurity\s+(?:add|delete)-(?:generic|internet)-password\b/.test(c) || - /\bsecret-tool\s+(?:clear|store)\b/.test(c), - }, - { - severity: 'warn', - category: 'private-key file access (~/.ssh, .pem)', - tool: 'Bash', - matches: c => - /~\/\.ssh\/[^\s|;&]+/.test(c) || - /\bopenssl\s+(?:pkcs8|pkey|rsa)\b/.test(c) || - /\bssh-keygen\b/.test(c) || - /\.pem\b/.test(c), - }, - // INFO — forensic record. - { - severity: 'info', - category: 'git push (artifact emission)', - tool: 'Bash', - matches: c => /\bgit\s+push\b/.test(c), - }, - { - severity: 'info', - category: 'workflow YAML edit', - matches: c => /\.github\/workflows\/[^/\s]+\.ya?ml/.test(c), - }, -] - -function scanToolUse(evt: ToolUseEvent): Finding[] { - const findings: Finding[] = [] - // Most patterns target Bash commands; some target file paths (Edit/Write). - const command = - evt.name === 'Bash' - ? String((evt.input as { command?: unknown | undefined }).command ?? '') - : '' - const filePath = - evt.name === 'Edit' || evt.name === 'Write' - ? String( - (evt.input as { file_path?: unknown | undefined }).file_path ?? '', - ) - : '' - const haystack = command || filePath - if (!haystack) { - return findings - } - for (let i = 0, { length } = PATTERNS; i < length; i += 1) { - const p = PATTERNS[i]! - if (p.tool && p.tool !== evt.name) { - continue - } - if (!p.matches(haystack)) { - continue - } - findings.push({ - severity: p.severity, - category: p.category, - evidence: - haystack.length > 200 ? haystack.slice(0, 197) + '...' : haystack, - line: evt.line, - }) - } - return findings -} - -function findRecentTranscript(): string | undefined { - // ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl - // encoded-cwd is the cwd with every `/` replaced by `-`. The leading - // `/` becomes the leading `-` automatically since the replace - // operates on the whole path. (So `/Users/foo` → `-Users-foo`, not - // `--Users-foo`.) - const encoded = process.cwd().replace(/\//g, '-') - const dir = path.join(os.homedir(), '.claude', 'projects', encoded) - if (!existsSync(dir)) { - return undefined - } - // TOCTOU: another Claude session may rotate/delete a .jsonl between - // readdir and stat. Tolerate missing entries instead of crashing. - const entries = readdirSync(dir) - .filter(f => f.endsWith('.jsonl')) - .map(f => { - const full = path.join(dir, f) - try { - return { full, mtime: statSync(full).mtimeMs } - } catch { - return undefined - } - }) - .filter((x): x is { full: string; mtime: number } => x !== undefined) - .toSorted((a, b) => b.mtime - a.mtime) - return entries[0]?.full -} - -interface Args { - json: boolean - transcript: string | undefined - recent: boolean -} - -function parseArgs(argv: readonly string[]): Args { - let json = false - let recent = false - let transcript: string | undefined - for (let i = 0; i < argv.length; i += 1) { - const a = argv[i] - if (a === '--json') { - json = true - } else if (a === '--recent') { - recent = true - } else if (a === '--help' || a === '-h') { - printHelp() - process.exit(0) - } else if (a && !a.startsWith('--')) { - transcript = a - } - } - return { json, recent, transcript } -} - -function printHelp(): void { - logger.log( - 'audit-transcript — read-only forensic scan of a Claude Code transcript', - ) - logger.log('') - logger.log('Usage:') - logger.log(' node scripts/audit-transcript.mts <transcript-path>') - logger.log( - ' node scripts/audit-transcript.mts --recent # auto-pick most recent', - ) - logger.log( - ' node scripts/audit-transcript.mts --json <path> # JSON output for tooling', - ) -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - let target = args.transcript - if (!target && args.recent) { - target = findRecentTranscript() - if (!target) { - logger.error('No transcript found for this cwd.') - process.exit(1) - } - } - if (!target) { - printHelp() - process.exit(1) - } - - const toolUses = readToolUses(target) - const findings: Finding[] = [] - for (const evt of toolUses) { - findings.push(...scanToolUse(evt)) - } - - if (args.json) { - process.stdout.write( - JSON.stringify({ transcript: target, findings }, null, 2), - ) - process.stdout.write('\n') - return - } - - logger.log(`Transcript: ${target}`) - logger.log(`Tool uses scanned: ${toolUses.length}`) - logger.log(`Findings: ${findings.length}`) - logger.log('') - - if (findings.length === 0) { - logger.success('No security-relevant tool-use patterns detected.') - return - } - - const byCategory = new Map<string, Finding[]>() - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - const list = byCategory.get(f.category) ?? [] - list.push(f) - byCategory.set(f.category, list) - } - - for (const severity of ['critical', 'warn', 'info'] as const) { - const entries = [...byCategory.entries()].filter( - ([, fs]) => fs[0]!.severity === severity, - ) - if (entries.length === 0) { - continue - } - logger.log(`── ${severity.toUpperCase()} ──`) - for (const [category, fs] of entries) { - logger.log(` ${category} (${fs.length})`) - for (const f of fs.slice(0, 5)) { - logger.log(` line ${f.line}: ${f.evidence}`) - } - if (fs.length > 5) { - logger.log(` ... and ${fs.length - 5} more`) - } - } - logger.log('') - } -} - -main().catch(err => { - logger.error(String((err as Error)?.message ?? err)) - process.exit(1) -}) diff --git a/scripts/babel/babel-plugin-inline-const-enum.mts b/scripts/babel/babel-plugin-inline-const-enum.mts deleted file mode 100644 index 601f78a86..000000000 --- a/scripts/babel/babel-plugin-inline-const-enum.mts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * @file Babel plugin to inline TypeScript const enum member access. Replaces - * enum member access with literal values when enum definition is available. - * Note: TypeScript normally handles this during compilation. This plugin is - * useful for post-processing compiled code or handling external modules. - */ - -/** - * Get the value from a literal node. - */ -function getLiteralValue(t, node) { - if (t.isNullLiteral(node)) { - return undefined - } - return node.value -} - -/** - * Check if a node is a literal value. - */ -function isLiteralValue(t, node) { - return ( - t.isNumericLiteral(node) || - t.isStringLiteral(node) || - t.isBooleanLiteral(node) || - t.isNullLiteral(node) - ) -} - -/** - * Convert a value to a Babel AST literal node. - */ -export function valueToLiteral(t, value) { - if (value === null) { - return t.nullLiteral() - } - if (value === undefined) { - return t.identifier('undefined') - } - if (typeof value === 'string') { - return t.stringLiteral(value) - } - if (typeof value === 'number') { - return t.numericLiteral(value) - } - if (typeof value === 'boolean') { - return t.booleanLiteral(value) - } - throw new Error(`Unsupported enum value type: ${typeof value}`) -} - -/** - * Babel plugin to inline const enum values. - * - * Transforms: MyEnum.Value → 42 (if MyEnum.Value = 42 in the enum definition) - * - * Usage: Pass enum definitions via options.enums: { enums: { MyEnum: { Value1: - * 0, Value2: 1 } } } - * - * Or let the plugin scan the code for enum declarations (limited support). - * - * @param {object} babel - Babel API object. - * @param {object} options - Plugin options. - * @param {Record<string, Record<string, any>>} [options.enums] - Enum - * definitions. - * @param {boolean} [options.scanDeclarations=false] - Auto-detect enum - * declarations. - * - * @returns {object} Babel plugin object - */ -export default function inlineConstEnum(babel, options = {}) { - const { types: t } = babel - const { enums = {}, scanDeclarations = false } = options - - // Map of enum name to member values. - const enumMap = new Map(Object.entries(enums)) - - return { - name: 'inline-const-enum', - - visitor: { - // Scan for enum declarations if enabled. - // Note: This has limited support and may not catch all cases. - VariableDeclaration(path) { - if (!scanDeclarations) { - return - } - - // Look for: const MyEnum = { Value: 0, ... } - const { declarations } = path.node - - for (let i = 0, { length } = declarations; i < length; i += 1) { - const decl = declarations[i] - if ( - !t.isVariableDeclarator(decl) || - !t.isIdentifier(decl.id) || - !t.isObjectExpression(decl.init) - ) { - continue - } - - const enumName = decl.id.name - const enumValues = {} - - // Extract property values. - for (const prop of decl.init.properties) { - if ( - !t.isObjectProperty(prop) || - !t.isIdentifier(prop.key) || - !isLiteralValue(t, prop.value) - ) { - continue - } - - enumValues[prop.key.name] = getLiteralValue(t, prop.value) - } - - if (Object.keys(enumValues).length > 0) { - enumMap.set(enumName, enumValues) - } - } - }, - - // Inline enum member access: MyEnum.Value - MemberExpression(path) { - const { object, property } = path.node - - // Match: EnumName.MemberName - if (!t.isIdentifier(object) || !t.isIdentifier(property)) { - return - } - - const enumName = object.name - const memberName = property.name - - // Check if we have this enum. - const enumDef = enumMap.get(enumName) - if (!enumDef || !(memberName in enumDef)) { - return - } - - const value = enumDef[memberName] - - // Replace with literal value. - const replacement = valueToLiteral(t, value) - path.replaceWith(replacement) - }, - }, - } -} diff --git a/scripts/babel/babel-plugin-inline-process-env.mts b/scripts/babel/babel-plugin-inline-process-env.mts deleted file mode 100644 index 3e25fb5d9..000000000 --- a/scripts/babel/babel-plugin-inline-process-env.mts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @file Babel plugin to inline process.env values. Replaces process.env.X with - * literal values, enabling dead code elimination. After this plugin runs, - * Rollup's tree-shaking can eliminate unreachable branches: if - * (process.env.NODE_ENV === 'production') { prodCode() } else { devCode() } - * Becomes: if ('production' === 'production') { prodCode() } else { devCode() - * } Then Rollup removes the dead else branch, leaving just: prodCode() - */ - -/** - * Babel plugin to inline process.env. - * - * Replaces process.env.VAR_NAME with the actual value from process.env. Use - * options.env to provide custom environment values. - * - * @example - * // With options: { env: { NODE_ENV: 'production' } } - * process.env.NODE_ENV // → 'production' - * process.env.DEBUG // → unchanged (not in env) - * - * @param {object} babel - Babel API object. - * @param {object} options - Plugin options. - * @param {Record<string, string>} [options.env] - Environment variables to - * inline. - * @param {string[]} [options.include] - Only inline these env vars (allowlist) - * @param {string[]} [options.exclude] - Never inline these env vars (denylist) - * - * @returns {object} Babel plugin object - */ -export default function inlineProcessEnv(babel, options = {}) { - const { types: t } = babel - const { env = process.env, exclude = [], include = [] } = options - - const excludeSet = new Set(exclude) - const includeSet = new Set(include) - - return { - name: 'inline-process-env', - - visitor: { - MemberExpression(path) { - const { object, property } = path.node - - // Match: process.env.VAR_NAME - if ( - !t.isMemberExpression(object) || - !t.isIdentifier(object.object, { name: 'process' }) || - !t.isIdentifier(object.property, { name: 'env' }) || - !t.isIdentifier(property) - ) { - return - } - - const envKey = property.name - - // Check allowlist/denylist. - if (includeSet.size > 0 && !includeSet.has(envKey)) { - return - } - if (excludeSet.has(envKey)) { - return - } - - // Get the value from env. - const value = env[envKey] - - // Only inline if value exists. - if (value === undefined) { - return - } - - // Replace with literal value. - const replacement = valueToLiteral(t, value) - path.replaceWith(replacement) - }, - }, - } -} - -/** - * Convert a value to a Babel AST literal node. - */ -export function valueToLiteral(t, value) { - // Handle common types. - if (value === null) { - return t.nullLiteral() - } - if (value === undefined) { - return t.identifier('undefined') - } - if (value === 'true') { - return t.booleanLiteral(true) - } - if (value === 'false') { - return t.booleanLiteral(false) - } - - // Check if it's a number. - const num = Number(value) - if (!Number.isNaN(num) && String(num) === value) { - return t.numericLiteral(num) - } - - // Default to string. - return t.stringLiteral(value) -} diff --git a/scripts/babel/babel-plugin-inline-require-calls.js b/scripts/babel/babel-plugin-inline-require-calls.js deleted file mode 100644 index 1c486e8e4..000000000 --- a/scripts/babel/babel-plugin-inline-require-calls.js +++ /dev/null @@ -1,161 +0,0 @@ -const { createRequire } = require('node:module') -const path = require('node:path') - -/** - * Babel plugin to inline require calls. - * - * @param {object} babel - Babel API object. - * - * @returns {object} Babel plugin object - */ -module.exports = function inlineRequireCalls(babel) { - const { types: t } = babel - - return { - name: 'inline-require-calls', - - visitor: { - CallExpression(nodePath, state) { - const { node } = nodePath - - // Check if this is a require() call. - if ( - !t.isIdentifier(node.callee, { name: 'require' }) || - node.arguments.length !== 1 - ) { - return - } - - // Check if the first argument is a string literal. - const arg = node.arguments[0] - if (!t.isStringLiteral(arg)) { - return - } - - // Check for /*@__INLINE__*/ comment in leading comments. - const leadingComments = node.leadingComments || [] - const hasInlineDirective = leadingComments.some(comment => { - return ( - comment.type === 'CommentBlock' && - comment.value.trim() === '@__INLINE__' - ) - }) - - if (!hasInlineDirective) { - return - } - - // Resolve the require path relative to the current file. - const currentFilePath = state.filename || state.file.opts.filename - if (!currentFilePath) { - throw nodePath.buildCodeFrameError( - 'Cannot inline require: unable to determine current file path', - ) - } - - const requirePath = arg.value - const currentDir = path.dirname(currentFilePath) - let absolutePath - - try { - // Resolve the path relative to the current file. - // Try both with and without .ts extension for TypeScript files. - absolutePath = path.resolve(currentDir, requirePath) - const possiblePaths = [ - absolutePath, - `${absolutePath}.ts`, - `${absolutePath}.js`, - `${absolutePath}/index.ts`, - `${absolutePath}/index.js`, - ] - - // Find the first path that exists. - const fs = require('node:fs') - let resolvedPath = absolutePath - for (let i = 0, { length } = possiblePaths; i < length; i += 1) { - const testPath = possiblePaths[i] - try { - if (fs.existsSync(testPath)) { - resolvedPath = testPath - break - } - } catch { - // Ignore errors, continue checking. - } - } - - // Create a require function relative to the current file. - const requireFunc = createRequire(currentFilePath) - - // Load the module. - const module = requireFunc(resolvedPath) - - // Get the default export (supports both ESM default and CJS module.exports). - const value = module.default ?? module - - // Verify the value is serializable (primitive or simple object). - if (!isSerializable(value)) { - throw new Error( - 'Cannot inline require: value is not serializable (got ' + - typeof value + - ')', - ) - } - - // Replace the require call with the actual value. - const replacement = valueToASTNode(t, value) - - // Add a comment to indicate what was inlined. - t.addComment( - replacement, - 'trailing', - ` was: require('${requirePath}') `, - false, - ) - - nodePath.replaceWith(replacement) - } catch (e) { - throw nodePath.buildCodeFrameError( - `Cannot inline require('${requirePath}'): ${e.message}`, - ) - } - }, - }, - } -} - -/** - * Check if a value can be serialized to an AST node. - */ -export function isSerializable(value) { - const type = typeof value - return ( - value === null || - value === undefined || - type === 'string' || - type === 'number' || - type === 'boolean' - ) -} - -/** - * Convert a JavaScript value to a Babel AST node. - */ -export function valueToASTNode(t, value) { - if (value === null) { - return t.nullLiteral() - } - if (value === undefined) { - return t.identifier('undefined') - } - if (typeof value === 'string') { - return t.stringLiteral(value) - } - if (typeof value === 'number') { - return t.numericLiteral(value) - } - if (typeof value === 'boolean') { - return t.booleanLiteral(value) - } - throw new Error(`Unsupported value type: ${typeof value}`) -} diff --git a/scripts/babel/babel-plugin-strict-mode.mts b/scripts/babel/babel-plugin-strict-mode.mts deleted file mode 100644 index 5e1908549..000000000 --- a/scripts/babel/babel-plugin-strict-mode.mts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * @file Babel plugin to transform loose-mode code into strict-mode compatible - * code This plugin ensures code runs correctly in strict mode by transforming - * problematic patterns: - * - * 1. Octal numeric literals (0123) → Modern octal (0o123) - * 2. Octal escape sequences in strings (\012) → Proper escapes - * 3. With statements → Error (cannot be transformed safely) - * 4. Future reserved words as identifiers → Safe alternatives - * 5. Adds 'use strict' directive if missing - * - * @example - * // Before: - * var x = 0123 // Octal literal - * var str = '\012' // Octal escape - * - * // After: - * ;('use strict') - * var x = 83 // Decimal equivalent - * var str = '\n' // Proper escape - */ - -export default function babelPluginStrictMode({ types: t }) { - const stats = { - octalLiterals: 0, - octalEscapes: 0, - withStatements: 0, - strictDirectives: 0, - } - - return { - name: 'babel-plugin-strict-mode', - - visitor: { - /** - * Add 'use strict' directive to programs that don't have it. - */ - Program: { - enter(path) { - const { body, directives } = path.node - - // Check if already has 'use strict' - const hasUseStrict = - directives?.some(d => d.value.value === 'use strict') || - body.some( - n => - t.isExpressionStatement(n) && - t.isStringLiteral(n.expression) && - n.expression.value === 'use strict', - ) - - if (!hasUseStrict) { - // Add 'use strict' directive at the beginning - path.unshiftContainer( - 'directives', - t.directive(t.directiveLiteral('use strict')), - ) - stats.strictDirectives++ - } - }, - - exit(path) { - const totalTransforms = Object.values(stats).reduce( - (a, b) => a + b, - 0, - ) - - if (totalTransforms > 0) { - const statsComment = ` -Strict Mode Transformation Stats: - - Octal literals converted: ${stats.octalLiterals} - - Octal escapes transformed: ${stats.octalEscapes} - - With statements found: ${stats.withStatements} - - Strict directives added: ${stats.strictDirectives} - Total transformations: ${totalTransforms} - `.trim() - - path.addComment('trailing', statsComment) - } - }, - }, - - /** - * Transform legacy octal numeric literals. - * - * @example - * // Input: var x = 0123 - * // Output: var x = 83 - */ - NumericLiteral(path) { - const { node } = path - const { extra } = node - - // Check if this is a legacy octal literal (starts with 0) - if (extra?.raw) { - const decimal = convertOctalLiteral(extra.raw) - if (decimal !== null) { - // Replace with decimal equivalent - path.replaceWith(t.numericLiteral(decimal)) - stats.octalLiterals++ - - path.addComment( - 'leading', - ` Strict-mode: Transformed octal ${extra.raw} → ${decimal}`, - ) - } - } - }, - - /** - * Transform octal escape sequences in string literals. - * - * @example - * // Input: var str = "Hello\012World" - * // Output: var str = "Hello\nWorld" - */ - StringLiteral(path) { - const { node } = path - const { extra } = node - - if (extra?.raw) { - // Check if string contains octal escapes - if (/\\[0-7]/.test(extra.raw)) { - const originalValue = node.value - const transformed = transformOctalEscapes(originalValue) - - if (transformed !== originalValue) { - path.replaceWith(t.stringLiteral(transformed)) - stats.octalEscapes++ - - path.addComment( - 'leading', - ' Strict-mode: Transformed octal escapes', - ) - } - } - } - }, - - /** - * Detect and error on 'with' statements (cannot be safely transformed) - * - * @example - * // Input: with (obj) { x = 1 } - * // Output: Error thrown - */ - WithStatement(path) { - stats.withStatements++ - - // Add a warning comment - path.addComment( - 'leading', - ' ERROR: "with" statement is not allowed in strict mode!', - ) - - // Throw an error to prevent compilation - throw path.buildCodeFrameError( - 'WithStatement is not allowed in strict mode and cannot be safely transformed. ' + - 'Please refactor your code to avoid using "with" statements.', - ) - }, - - /** - * Transform template literals with octal escapes. - */ - TemplateLiteral(path) { - const { node } = path - let transformed = false - - for (let i = 0, { length } = node.quasis; i < length; i += 1) { - const quasi = node.quasis[i] - const { value } = quasi - if (value.raw && /\\[0-7]/.test(value.raw)) { - const transformedCooked = transformOctalEscapes( - value.cooked || value.raw, - ) - if (transformedCooked !== value.cooked) { - quasi.value.cooked = transformedCooked - quasi.value.raw = transformedCooked - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - transformed = true - } - } - } - - if (transformed) { - stats.octalEscapes++ - path.addComment( - 'leading', - ' Strict-mode: Transformed octal escapes in template', - ) - } - }, - }, - } -} - -/** - * Convert legacy octal literal (0123) to decimal number. - * - * @param {string} value - The numeric literal string. - * - * @returns {number | undefined} Decimal value or undefined if not octal - */ -function convertOctalLiteral(value) { - // Match legacy octal: starts with 0, followed by octal digits (0-7) - const octalMatch = /^0([0-7]+)$/.exec(value) - if (!octalMatch) { - return undefined - } - - const octalDigits = octalMatch[1] - return Number.parseInt(octalDigits, 8) -} - -/** - * Transform octal escape sequences in strings to proper escapes. - * - * @param {string} str - String literal value. - * - * @returns {string} Transformed string - */ -function transformOctalEscapes(str) { - // Common octal escapes and their replacements - const commonOctals = { - // Null (allowed in strict mode if not followed by digit) - '\\0': '\\0', - // Start of Heading - '\\1': '\\x01', - // Start of Text - '\\2': '\\x02', - // End of Text - '\\3': '\\x03', - // End of Transmission - '\\4': '\\x04', - // Enquiry - '\\5': '\\x05', - // Acknowledge - '\\6': '\\x06', - // Bell - '\\7': '\\x07', - // Backspace - '\\10': '\\b', - // Tab - '\\11': '\\t', - // Line Feed - '\\12': '\\n', - // Vertical Tab - '\\13': '\\v', - // Form Feed - '\\14': '\\f', - // Carriage Return - '\\15': '\\r', - } - - let result = str - - // Replace common named escapes first - for (const [octal, replacement] of Object.entries(commonOctals)) { - // Use word boundary to avoid matching longer sequences - result = result.replace( - new RegExp(`${octal.replace(/\\/g, '\\\\')}(?![0-7])`, 'g'), - replacement, - ) - } - - // Replace any remaining octal escapes (\16-\377) with hex escapes - result = result.replace(/\\([0-7]{1,3})/g, (_match, octalDigits) => { - const codePoint = Number.parseInt(octalDigits, 8) - if (codePoint <= 0xff) { - return `\\x${codePoint.toString(16).padStart(2, '0')}` - } - return `\\u${codePoint.toString(16).padStart(4, '0')}` - }) - - return result -} diff --git a/scripts/babel/babel-plugin-strip-debug.mts b/scripts/babel/babel-plugin-strip-debug.mts deleted file mode 100644 index 3211ead5b..000000000 --- a/scripts/babel/babel-plugin-strip-debug.mts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @file Babel plugin to strip debug code blocks. Removes code wrapped in DEBUG - * checks: if (DEBUG) { ... } - */ - -/** - * Check if a node is a DEBUG identifier. - */ -function isDebugIdentifier(t, node, debugIds) { - return t.isIdentifier(node) && debugIds.has(node.name) -} - -/** - * Check if test expression is a debug check. - */ -function isDebugTest(t, test, debugIds) { - // Simple: if (DEBUG) - if (isDebugIdentifier(t, test, debugIds)) { - return true - } - - // Logical: if (DEBUG && x) or if (x && DEBUG) - if (t.isLogicalExpression(test, { operator: '&&' })) { - return ( - isDebugIdentifier(t, test.left, debugIds) || - isDebugIdentifier(t, test.right, debugIds) - ) - } - - return false -} - -/** - * Babel plugin to strip debug code. - * - * Removes: - if (DEBUG) { ... } - if (DEBUG && condition) { ... } - DEBUG && - * expression - DEBUG ? trueExpr : falseExpr (keeps falseExpr) - * - * Usage in code: if (DEBUG) { getDefaultLogger().log('debug info') } // In - * production build: entire block removed. - * - * @param {object} babel - Babel API object. - * @param {object} options - Plugin options. - * @param {string[]} [options.identifiers=['DEBUG']] - Debug identifiers to - * strip. - * - * @returns {object} Babel plugin object - */ -export default function stripDebug(babel, options = {}) { - const { types: t } = babel - const { identifiers = ['DEBUG'] } = options - const debugIds = new Set(identifiers) - - return { - name: 'strip-debug', - - visitor: { - // Remove: if (DEBUG) { ... } - IfStatement(path) { - const { test } = path.node - - // Check if test is DEBUG identifier or logical expression containing DEBUG. - if (isDebugTest(t, test, debugIds)) { - path.remove() - return - } - - // Handle: if (DEBUG && condition) { ... } - if ( - t.isLogicalExpression(test, { operator: '&&' }) && - isDebugIdentifier(t, test.left, debugIds) - ) { - path.remove() - return - } - }, - - // Remove: DEBUG && expression - LogicalExpression(path) { - const { left, operator } = path.node - - if (operator === '&&' && isDebugIdentifier(t, left, debugIds)) { - // Remove entire expression. - if (path.parentPath.isExpressionStatement()) { - path.parentPath.remove() - } else { - // Replace with undefined in other contexts. - path.replaceWith(t.identifier('undefined')) - } - } - }, - - // Handle: DEBUG ? trueExpr : falseExpr → falseExpr - ConditionalExpression(path) { - const { alternate, test } = path.node - - if (isDebugIdentifier(t, test, debugIds)) { - // Replace with alternate (false branch). - path.replaceWith(alternate) - } - }, - }, - } -} diff --git a/scripts/babel/babel-plugin-with-intl-none.mts b/scripts/babel/babel-plugin-with-intl-none.mts deleted file mode 100644 index cf57c6df8..000000000 --- a/scripts/babel/babel-plugin-with-intl-none.mts +++ /dev/null @@ -1,473 +0,0 @@ -/** - * @file Babel plugin for --with-intl=none compatibility This plugin transforms - * ICU-dependent JavaScript features into ICU-free alternatives, enabling - * Node.js builds with --with-intl=none to save ~6-8MB. Note: --without-intl - * is deprecated, use --with-intl=none instead. Transformations: - * - * 1. `.toLocaleString()` → Simple formatting with commas/basic date strings - * 2. `Intl.*` APIs → Polyfills or basic implementations - * 3. Unicode regex `\p{...}` → Character class alternatives (shared transform) - * 4. Unicode regex `/v` flag → Downgrade to `/u` or remove - * 5. `.localeCompare()` → Basic string comparison - * - * @example - * // Before: - * const formatted = count.toLocaleString() - * const date = new Date().toLocaleDateString() - * const regex = /[\p{Letter}\p{Number}]+/v - * - * // After: - * const formatted = count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') - * const date = new Date().toISOString().split('T')[0] - * const regex = /[a-zA-Z0-9]+/ - */ - -import { unicodePropertyMap } from 'build-infra/lib/unicode-property-escape-transform' - -/** - * Helper Functions (injected at runtime via Babel template.ast): - * - * __formatNumber(num) - Format with comma thousands separators - * __formatDate(date) - Format in YYYY-MM-DD __formatDateTime(date) - Format - * with time __simpleCompare(a, b) - Basic string comparison. - */ - -export default function babelPluginWithIntlNone({ template, types: t }) { - const stats = { - toLocaleString: 0, - toLocaleDateString: 0, - toLocaleTimeString: 0, - localeCompare: 0, - intlAPIs: 0, - unicodeRegex: 0, - } - - // Helper to create runtime helper import - const helperImports = new WeakMap() - function ensureHelper(path, helperName) { - const program = path.findParent(p => p.isProgram()) - if (!program) { - return - } - - if (!helperImports.has(program.node)) { - helperImports.set(program.node, new Set()) - } - - const imports = helperImports.get(program.node) - if (imports.has(helperName)) { - return - } - - imports.add(helperName) - - // Add helper function at the top of the file using template.ast - const helpers = { - __formatNumber: () => - template.ast(` - function __formatNumber(num) { - return num.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); - } - `), - __formatDate: () => - template.ast(` - function __formatDate(date) { - return date.toISOString().split('T')[0]; - } - `), - __formatDateTime: () => - template.ast(` - function __formatDateTime(date) { - return date.toISOString().replace('T', ' ').replace(/\\.\\d+Z$/, ' UTC'); - } - `), - __simpleCompare: () => - template.ast(` - function __simpleCompare(a, b) { - return a < b ? -1 : a > b ? 1 : 0; - } - `), - } - - program.node.body.unshift(helpers[helperName]()) - } - - return { - name: 'babel-plugin-with-intl-none', - - visitor: { - /** - * Transform number.toLocaleString() calls. - * - * @example - * // Input: - * count.toLocaleString()(1234567).toLocaleString() - * num.toLocaleString('en-US', { minimumFractionDigits: 2 }) - * - * // Output: - * __formatNumber(count) - * __formatNumber(1234567) - * __formatNumber(num) - */ - CallExpression(path) { - const { node } = path - - // Handle toLocaleString() on numbers - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.property, { name: 'toLocaleString' }) - ) { - const objectType = path.get('callee.object') - - // Check if it's likely a number (numeric literal or number-type identifier) - const isNumber = - t.isNumericLiteral(objectType.node) || - (t.isIdentifier(objectType.node) && - [ - 'count', - 'size', - 'length', - 'total', - 'num', - 'number', - 'amount', - 'bytes', - ].some(n => objectType.node.name.toLowerCase().includes(n))) - - if (isNumber) { - ensureHelper(path, '__formatNumber') - path.replaceWith( - t.callExpression(t.identifier('__formatNumber'), [ - node.callee.object, - ]), - ) - stats.toLocaleString++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed toLocaleString() → __formatNumber()', - ) - return - } - - // Handle Date.prototype.toLocaleString() - if ( - t.isNewExpression(objectType.node) && - t.isIdentifier(objectType.node.callee, { name: 'Date' }) - ) { - ensureHelper(path, '__formatDateTime') - path.replaceWith( - t.callExpression(t.identifier('__formatDateTime'), [ - node.callee.object, - ]), - ) - stats.toLocaleString++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed Date.toLocaleString() → __formatDateTime()', - ) - return - } - } - - // Handle toLocaleDateString() - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.property, { name: 'toLocaleDateString' }) - ) { - ensureHelper(path, '__formatDate') - path.replaceWith( - t.callExpression(t.identifier('__formatDate'), [ - node.callee.object, - ]), - ) - stats.toLocaleDateString++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed toLocaleDateString() → __formatDate()', - ) - return - } - - // Handle toLocaleTimeString() - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.property, { name: 'toLocaleTimeString' }) - ) { - // Convert to ISO time format - path.replaceWith( - t.callExpression( - t.memberExpression( - t.callExpression( - t.memberExpression( - node.callee.object, - t.identifier('toISOString'), - ), - [], - ), - t.identifier('split'), - ), - [t.stringLiteral('T')], - ), - ) - path.replaceWith( - t.memberExpression(path.node, t.numericLiteral(1), true), - ) - stats.toLocaleTimeString++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed toLocaleTimeString() → ISO time', - ) - return - } - - // Handle localeCompare() - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.property, { name: 'localeCompare' }) - ) { - ensureHelper(path, '__simpleCompare') - path.replaceWith( - t.callExpression(t.identifier('__simpleCompare'), [ - node.callee.object, - node.arguments[0], - ]), - ) - stats.localeCompare++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed localeCompare() → __simpleCompare()', - ) - return - } - - // Handle Intl.* API usage - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.object, { name: 'Intl' }) - ) { - const apiName = node.callee.property.name - - // Intl.DateTimeFormat - if (apiName === 'DateTimeFormat') { - path.addComment( - 'leading', - ' WARNING: Intl.DateTimeFormat removed - using basic date formatting', - ) - - // Replace with a simple wrapper that returns { format: (date) => formatDate(date) } - path.replaceWith( - t.objectExpression([ - t.objectMethod( - 'method', - t.identifier('format'), - [t.identifier('date')], - t.blockStatement([ - t.returnStatement( - t.callExpression( - t.memberExpression( - t.identifier('date'), - t.identifier('toISOString'), - ), - [], - ), - ), - ]), - ), - ]), - ) - stats.intlAPIs++ - return - } - - // Intl.NumberFormat - if (apiName === 'NumberFormat') { - ensureHelper(path, '__formatNumber') - path.addComment( - 'leading', - ' WARNING: Intl.NumberFormat removed - using basic number formatting', - ) - - // Replace with a simple wrapper - path.replaceWith( - t.objectExpression([ - t.objectMethod( - 'method', - t.identifier('format'), - [t.identifier('num')], - t.blockStatement([ - t.returnStatement( - t.callExpression(t.identifier('__formatNumber'), [ - t.identifier('num'), - ]), - ), - ]), - ), - ]), - ) - stats.intlAPIs++ - return - } - - // Other Intl.* APIs - just warn - path.addComment( - 'leading', - ` WARNING: Intl.${apiName} is not available without ICU - this may break!`, - ) - stats.intlAPIs++ - } - }, - - /** - * Transform Unicode property escapes in regex and handle /v flag. - * - * @example - * // Input: - * /\p{Letter}/u - * /[\p{Alpha}0-9_]/u - * /[\p{Letter}\p{Number}]+/v - * - * // Output: - * /[a-zA-Z]/ - * /[a-zA-Z0-9_]/ - * /[a-zA-Z0-9]+/ - */ - RegExpLiteral(path) { - const { node } = path - - // Handle /v flag (unicodeSets) - ES2024 feature requiring Unicode support. - // The /v flag provides enhanced Unicode character class features. - // Downgrade to /u flag (basic Unicode) or remove if transforming. - if (node.flags.includes('v')) { - const pattern = node.pattern - const newFlags = node.flags.replace('v', 'u') - let transformed = false - - // Check if pattern uses \p{...} that we can transform. - if (pattern.includes('\\p{')) { - // Continue to transformation below. - transformed = true - } else { - // No \p{...} - just downgrade v→u flag. - path.replaceWith(t.regExpLiteral(pattern, newFlags)) - stats.unicodeRegex++ - path.addComment( - 'leading', - ' --with-intl=none: Downgraded /v flag → /u flag', - ) - return - } - } - - // Check for unicode property escapes (\p{...}). - if ( - (node.flags.includes('u') || node.flags.includes('v')) && - node.pattern.includes('\\p{') - ) { - let pattern = node.pattern - let transformed = false - - // Transform \p{...} inside character classes [...] - // Match character classes that contain \p{...} - pattern = pattern.replace( - /\[([^\]]*\\p\{[^}]+\}[^\]]*)\]/g, - (_match, content) => { - let newContent = content - // Inside character class, replace with just the character range - newContent = newContent.replace(/\\p\{Letter\}/g, 'a-zA-Z') - newContent = newContent.replace(/\\p\{L\}/g, 'a-zA-Z') - newContent = newContent.replace(/\\p\{Alpha\}/g, 'a-zA-Z') - newContent = newContent.replace(/\\p\{Alphabetic\}/g, 'a-zA-Z') - newContent = newContent.replace(/\\p\{Number\}/g, '0-9') - newContent = newContent.replace(/\\p\{N\}/g, '0-9') - newContent = newContent.replace(/\\p\{Digit\}/g, '0-9') - newContent = newContent.replace(/\\p\{Nd\}/g, '0-9') - newContent = newContent.replace(/\\p\{Space\}/g, '\\s') - newContent = newContent.replace(/\\p\{White_Space\}/g, '\\s') - - if (newContent !== content) { - transformed = true - } - return `[${newContent}]` - }, - ) - - // Transform standalone \p{...} (not inside character class) - const standaloneTransforms = { - '\\p{Letter}': '[a-zA-Z]', - '\\p{L}': '[a-zA-Z]', - '\\p{Alpha}': '[a-zA-Z]', - '\\p{Alphabetic}': '[a-zA-Z]', - '\\p{Number}': '[0-9]', - '\\p{N}': '[0-9]', - '\\p{Digit}': '[0-9]', - '\\p{Nd}': '[0-9]', - '\\p{Space}': '\\s', - '\\p{White_Space}': '\\s', - '\\p{ASCII}': '[\\x00-\\x7F]', - } - - for (const [unicode, basic] of Object.entries(standaloneTransforms)) { - if (pattern.includes(unicode)) { - pattern = pattern.replace( - new RegExp(unicode.replace(/[\\{}]/g, '\\$&'), 'g'), - basic, - ) - transformed = true - } - } - - if (transformed) { - // Remove 'u' and 'v' flags since we're no longer using unicode escapes. - const newFlags = node.flags.replace('u', '').replace('v', '') - path.replaceWith(t.regExpLiteral(pattern, newFlags)) - stats.unicodeRegex++ - - path.addComment( - 'leading', - ' --with-intl=none: Transformed unicode regex → character class', - ) - } else if (pattern.includes('\\p{')) { - // Can't transform - add warning - path.addComment( - 'leading', - ' WARNING: Complex unicode regex may not work without ICU!', - ) - stats.unicodeRegex++ - } - } - }, - - /** - * Add stats comment at the end of the file. - */ - Program: { - exit(path) { - const totalTransforms = Object.values(stats).reduce( - (a, b) => a + b, - 0, - ) - - if (totalTransforms > 0) { - const statsComment = ` -ICU Removal Stats: - - toLocaleString() calls: ${stats.toLocaleString} - - toLocaleDateString() calls: ${stats.toLocaleDateString} - - toLocaleTimeString() calls: ${stats.toLocaleTimeString} - - localeCompare() calls: ${stats.localeCompare} - - Intl.* API usage: ${stats.intlAPIs} - - Unicode regex patterns: ${stats.unicodeRegex} - Total transformations: ${totalTransforms} - `.trim() - - path.addComment('trailing', statsComment) - } - }, - }, - }, - } -} diff --git a/scripts/babel/transform-set-proto-plugin.js b/scripts/babel/transform-set-proto-plugin.js new file mode 100644 index 000000000..f446bdd55 --- /dev/null +++ b/scripts/babel/transform-set-proto-plugin.js @@ -0,0 +1,47 @@ +'use strict' + +// Helper to check if something is a .__proto__ access. +function isProtoAccess(node, t) { + return ( + t.isMemberExpression(node) && + t.isIdentifier(node.property, { name: '__proto__' }) + ) +} + +// Unwraps A.__proto__ or A.prototype.__proto__. +function unwrapProto(node, t) { + const { object } = node + return { + object, + isPrototype: + t.isMemberExpression(object) && + t.isIdentifier(object.property, { name: 'prototype' }), + } +} + +module.exports = function ({ types: t }) { + return { + name: 'transform-set-proto', + visitor: { + ExpressionStatement(path) { + const { expression: expr } = path.node + // Handle: Xyz.prototype.__proto__ = foo + if (t.isAssignmentExpression(expr) && isProtoAccess(expr.left, t)) { + const { object } = unwrapProto(expr.left, t) + const { right } = expr + path.replaceWith( + t.expressionStatement( + t.callExpression( + t.memberExpression( + t.identifier('Object'), + t.identifier('setPrototypeOf'), + ), + [object, right], + ), + ), + ) + } + }, + }, + } +} diff --git a/scripts/babel/transform-set-proto-plugin.mts b/scripts/babel/transform-set-proto-plugin.mts deleted file mode 100644 index 22da550d3..000000000 --- a/scripts/babel/transform-set-proto-plugin.mts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @file Babel plugin to transform **proto** assignments into - * Object.setPrototypeOf calls. - */ - -/** - * Check if node is a **proto** property access. - */ -function isProtoAccess(node, t) { - return ( - t.isMemberExpression(node) && - t.isIdentifier(node.property, { name: '__proto__' }) - ) -} - -// Unwraps A.__proto__ or A.prototype.__proto__. -function unwrapProto(node, t) { - const { object } = node - return { - object, - isPrototype: - t.isMemberExpression(object) && - t.isIdentifier(object.property, { name: 'prototype' }), - } -} - -export default function ({ types: t }) { - return { - name: 'transform-set-proto', - visitor: { - ExpressionStatement(path) { - const { expression: expr } = path.node - // Handle: Xyz.prototype.__proto__ = foo - if (t.isAssignmentExpression(expr) && isProtoAccess(expr.left, t)) { - const { object } = unwrapProto(expr.left, t) - const { right } = expr - path.replaceWith( - t.expressionStatement( - t.callExpression( - t.memberExpression( - t.identifier('Object'), - t.identifier('setPrototypeOf'), - ), - [object, right], - ), - ), - ) - } - }, - }, - } -} diff --git a/scripts/babel/transform-url-parse-plugin.js b/scripts/babel/transform-url-parse-plugin.js new file mode 100644 index 000000000..9662038f4 --- /dev/null +++ b/scripts/babel/transform-url-parse-plugin.js @@ -0,0 +1,41 @@ +'use strict' + +module.exports = function ({ types: t }) { + return { + name: 'transform-url-parse', + visitor: { + CallExpression(path) { + const { node } = path + // Match `url.parse(...)` calls with exactly one argument. + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.name === 'url' && + node.callee.property.name === 'parse' && + node.arguments.length === 1 + ) { + const { parent } = path + // Create an AST node for `new URL(<arg>)`. + const newUrl = t.newExpression(t.identifier('URL'), [ + node.arguments[0], + ]) + // Check if the result of `url.parse()` is immediately accessed, e.g. + // `url.parse(x).protocol`. + if (parent.type === 'MemberExpression' && parent.object === node) { + // Replace the full `url.parse(x).protocol` with `(new URL(x)).protocol`. + path.parentPath.replaceWith( + t.memberExpression( + newUrl, + parent.property, + // Handle dynamic props like `['protocol']`. + parent.computed, + ), + ) + } else { + // Otherwise, replace `url.parse(x)` with `new URL(x)`. + path.replaceWith(newUrl) + } + } + }, + }, + } +} diff --git a/scripts/babel/transform-url-parse-plugin.mts b/scripts/babel/transform-url-parse-plugin.mts deleted file mode 100644 index 6cc56f149..000000000 --- a/scripts/babel/transform-url-parse-plugin.mts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @file Babel plugin to transform url.parse() calls into new URL() constructor. - */ - -export default function ({ types: t }) { - return { - name: 'transform-url-parse', - visitor: { - CallExpression(path) { - const { node } = path - // Match `url.parse(...)` calls with exactly one argument. - if ( - node.callee.type === 'MemberExpression' && - node.callee.object.name === 'url' && - node.callee.property.name === 'parse' && - node.arguments.length === 1 - ) { - const { parent } = path - // Create an AST node for `new URL(<arg>)`. - const newUrl = t.newExpression(t.identifier('URL'), [ - node.arguments[0], - ]) - // Check if the result of `url.parse()` is immediately accessed, e.g. - // `url.parse(x).protocol`. - if (parent.type === 'MemberExpression' && parent.object === node) { - // Replace the full `url.parse(x).protocol` with `(new URL(x)).protocol`. - path.parentPath.replaceWith( - t.memberExpression( - newUrl, - parent.property, - // Handle dynamic props like `['protocol']`. - parent.computed, - ), - ) - } else { - // Otherwise, replace `url.parse(x)` with `new URL(x)`. - path.replaceWith(newUrl) - } - } - }, - }, - } -} diff --git a/scripts/bootstrap-firewall-deps.mts b/scripts/bootstrap-firewall-deps.mts deleted file mode 100644 index d7d998a59..000000000 --- a/scripts/bootstrap-firewall-deps.mts +++ /dev/null @@ -1,304 +0,0 @@ - -/** - * @file Bootstrap zero-dep Socket packages into node_modules/ before `pnpm - * install` runs, with Socket Firewall verification on each pinned tarball - * before extraction. Why: setup.mts (and downstream tooling) imports - * `@socketsecurity/lib-stable` and other zero-dep Socket helpers at - * module-load time. On a fresh clone, `pnpm install` itself runs scripts that - * import these — but pnpm install hasn't completed yet, so the imports fail - * with `ERR_MODULE_NOT_FOUND`. Bootstrap solves this by fetching the pinned - * tarball from the npm registry, running it through Socket Firewall - * (refuse-on-alert), and extracting the verified tarball into - * node_modules/<scope>/<name>/. Subsequent pnpm install will see the - * directory and either keep it (if version matches) or replace it with the - * workspace-resolved version. Pinned versions come from - * `pnpm-workspace.yaml`'s `catalog:` — single source of truth. --- - * Repo-convention exceptions --- This script intentionally CANNOT depend on - * `@socketsecurity/lib-stable` because it is the script that bootstraps that - * very package. The usual repo conventions therefore do not apply here: - * - * - `fetch()` is used directly instead of `httpJson` from - * `@socketsecurity/lib-stable/http-request`. - * - `rmSync` is used directly instead of `safeDelete` from - * `@socketsecurity/lib-stable/fs`. - * - Caught errors use the inline `e instanceof Error ? e.message : String(e)` - * pattern instead of `errorMessage()` from - * `@socketsecurity/lib-stable/errors`. These exceptions are intentional, - * narrow, and self-contained. Do not add other repo-convention violations - * here without documenting the reason in this header. Once - * `@socketsecurity/lib-stable` is on disk (post-bootstrap), other scripts - * must use the helpers as normal. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' - -import os from 'node:os' - -import path from 'node:path' -import process from 'node:process' - -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const REPO_ROOT = path.resolve(__dirname, '..') - -// Packages to bootstrap. Each entry must: -// 1. Be zero-dependency (or only depend on already-bootstrapped -// packages) so we don't have to recurse into their dep graph. -// 2. Be imported by setup.mts or another script that runs BEFORE -// pnpm install completes — otherwise normal install handles it. -const BOOTSTRAP_PACKAGES = [ - '@sinclair/typebox', - '@socketregistry/packageurl-js-stable', - '@socketsecurity/lib-stable', -] - -// Socket Firewall API — verifies a package isn't malware before we -// fetch its tarball directly from the npm registry. Mirrors the -// helper in socket-registry's setup action. Any alert at all means -// malware (the API doesn't return informational alerts), so block -// unconditionally on a populated `alerts` array. Network failures -// are non-fatal so a network blip doesn't break a fresh clone. -const FIREWALL_API_URL = 'https://firewall-api.socket.dev/purl' -const FIREWALL_TIMEOUT_MS = 10_000 - -interface FirewallAlert { - severity?: string | undefined - type?: string | undefined - key?: string | undefined -} - -const checkFirewall = async ( - pkgName: string, - version: string, -): Promise<boolean> => { - const purl = `pkg:npm/${pkgName}@${version}` - const url = `${FIREWALL_API_URL}/${encodeURIComponent(purl)}` - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), FIREWALL_TIMEOUT_MS) - timer.unref?.() - try { - // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- bootstrap script runs before deps installed; uses AbortController.signal for hard timeout and reads res.ok + res.status for non-fatal proceed-anyway behavior. - const res = await fetch(url, { - headers: { - 'User-Agent': 'socket-bootstrap-firewall-deps/1.0', - Accept: 'application/json', - }, - signal: controller.signal, - }) - clearTimeout(timer) - if (!res.ok) { - err( - `firewall-api: HTTP ${res.status} for ${purl} — proceeding anyway (non-fatal)`, - ) - return true - } - const data = (await res.json()) as { alerts?: FirewallAlert[] | undefined } - const alerts = data.alerts ?? [] - if (alerts.length > 0) { - err( - `\n✗ Socket Firewall flagged ${pkgName}@${version} as malware (${alerts.length} alert(s)):`, - ) - for (const a of alerts.slice(0, 10)) { - err( - ` ${a.type ?? a.key ?? 'malware'}${a.severity ? ` (${a.severity})` : ''}`, - ) - } - err( - '\nFix: bump the pinned version in pnpm-workspace.yaml or package.json to a known-good release.', - ) - return false - } - log(`✓ ${pkgName}@${version} cleared by Socket Firewall`) - return true - } catch (e) { - clearTimeout(timer) - err( - `firewall-api: ${e instanceof Error ? e.message : String(e)} — proceeding anyway (non-fatal)`, - ) - return true - } -} - -const log = (msg: string): void => { - process.stdout.write(`[bootstrap] ${msg}\n`) -} - -const err = (msg: string): void => { - process.stderr.write(`[bootstrap] ${msg}\n`) -} - -/** - * Read the pinned version of a package, checking (in order): - * - * 1. `pnpm-workspace.yaml` `catalog:` entries - * 2. Root `package.json` `dependencies` / `devDependencies` (skip "catalog:" / - * "workspace:" / "*" / "" — those need (1)). - * - * Avoids a dep on a YAML parser by hand-parsing the catalog block — this script - * must itself be zero-dep so it can run before `pnpm install` brings any - * tooling in. - */ - -// Strip range prefixes (^, ~, >=, <=, etc.) so the registry tarball -// URL gets an exact semver. Applied to BOTH the catalog and the -// package.json paths so they can never disagree. -const stripRange = (v: string): string => v.replace(/^[\^~>=<]+/, '').trim() - -const readPinnedVersion = (pkgName: string): string => { - // (1) pnpm-workspace.yaml catalog - const wsPath = path.join(REPO_ROOT, 'pnpm-workspace.yaml') - if (existsSync(wsPath)) { - const content = readFileSync(wsPath, 'utf8') - const lines = content.split('\n') - let inCatalog = false - for (let i = 0, { length } = lines; i < length; i += 1) { - const rawLine = lines[i] - const line = rawLine.replace(/\r$/, '') - if (/^catalog:\s*$/.test(line)) { - inCatalog = true - continue - } - if (inCatalog) { - // Leave the catalog block on the next top-level key (no - // leading whitespace, ends with ':'). - if (/^\S.*:\s*$/.test(line)) { - inCatalog = false - continue - } - const m = line.match( - /^\s+['"]?([@A-Za-z0-9_/-]+)['"]?\s*:\s*['"]?([^'"\s]+)['"]?\s*$/, - ) - if (m && m[1] === pkgName) { - return stripRange(m[2]!) - } - } - } - } - - // (2) Root package.json dependencies / devDependencies - const pkgJsonPath = path.join(REPO_ROOT, 'package.json') - if (existsSync(pkgJsonPath)) { - const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) - for (const field of ['dependencies', 'devDependencies'] as const) { - const deps = pkg[field] - if (deps && typeof deps[pkgName] === 'string') { - const v: string = deps[pkgName] - if ( - v !== '' && - v !== '*' && - !v.startsWith('catalog:') && - !v.startsWith('workspace:') - ) { - return stripRange(v) - } - } - } - } - - throw new Error( - `Pinned version not found for ${pkgName}. Add it to pnpm-workspace.yaml \`catalog:\` or root package.json dependencies.`, - ) -} - -/** - * Download a npm registry tarball for `<pkg>@<version>` and extract it into - * `node_modules/<pkg>/`. Skips if the destination already has a package.json - * with the matching version. Firewall-checks the version against - * firewall-api.socket.dev before downloading; refuses to install if the - * firewall returned any alerts. - */ -const bootstrapPackage = async (pkgName: string): Promise<void> => { - const version = readPinnedVersion(pkgName) - const dest = path.join(REPO_ROOT, 'node_modules', pkgName) - const destPkgJson = path.join(dest, 'package.json') - - if (existsSync(destPkgJson)) { - try { - const installed = JSON.parse(readFileSync(destPkgJson, 'utf8')) - if (installed.version === version) { - log(`${pkgName}@${version} already present, skipping`) - return - } - log( - `${pkgName} present at ${installed.version}, replacing with ${version}`, - ) - } catch { - // Malformed package.json — overwrite. - } - } - - // Firewall check — refuses install if the package is flagged as - // malware. Network errors are non-fatal so a network blip doesn't - // block a fresh clone. - const cleared = await checkFirewall(pkgName, version) - if (!cleared) { - throw new Error( - `Socket Firewall blocked ${pkgName}@${version}; refusing to install.`, - ) - } - - // Build the registry tarball URL. The npm registry redirects - // /<pkg>/-/<basename>-<version>.tgz, but for scoped packages the - // basename is the unscoped portion. - const unscoped = pkgName.startsWith('@') ? pkgName.split('/')[1]! : pkgName - const tarballUrl = `https://registry.npmjs.org/${pkgName}/-/${unscoped}-${version}.tgz` - - log(`Fetching ${tarballUrl}`) - const tarballPath = path.join( - tmpdir(), - `socket-bootstrap-${unscoped}-${version}.tgz`, - ) - - // Use curl — it's universally available and avoids a dep on a - // node http client. Follow redirects with -L, fail loudly with -f. - const curl = spawnSync('curl', ['-fsSL', tarballUrl, '-o', tarballPath], { - stdio: 'inherit', - }) - if (curl.status !== 0) { - throw new Error( - `Failed to download ${pkgName}@${version} from ${tarballUrl}.\nVerify the version exists on the npm registry, or check network access.`, - ) - } - - // Ensure dest exists and is empty for clean extraction. - if (existsSync(dest)) { - rmSync(dest, { recursive: true, force: true }) - } - mkdirSync(dest, { recursive: true }) - - // Extract: tarball top-level dir is `package/`, strip it. - const tar = spawnSync( - 'tar', - ['-xzf', tarballPath, '--strip-components=1', '-C', dest], - { stdio: 'inherit' }, - ) - if (tar.status !== 0) { - throw new Error(`Failed to extract ${tarballPath} into ${dest}.`) - } - - rmSync(tarballPath, { force: true }) - log(`${pkgName}@${version} → node_modules/${pkgName}`) -} - -const main = async (): Promise<number> => { - log( - `Bootstrapping ${BOOTSTRAP_PACKAGES.length} package(s) from npm registry...`, - ) - for (let i = 0, { length } = BOOTSTRAP_PACKAGES; i < length; i += 1) { - const pkg = BOOTSTRAP_PACKAGES[i] - try { - await bootstrapPackage(pkg) - } catch (e) { - err( - `Failed to bootstrap ${pkg}: ${e instanceof Error ? e.message : String(e)}`, - ) - return 1 - } - } - log('Bootstrap complete.') - return 0 -} - -main().then(code => process.exit(code)) diff --git a/scripts/build.mts b/scripts/build.mts deleted file mode 100644 index a88784b03..000000000 --- a/scripts/build.mts +++ /dev/null @@ -1,846 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ - -/** - * Comprehensive build script with intelligent caching. - * - * Builds packages in the correct order: - * - * 1. CLI package (TypeScript compilation and bundling) - * 2. SEA binary for current platform (only with --force) - * - * Note: Yoga WASM and node-smol binaries are downloaded from socket-btm during - * CLI build. - * - * Usage: pnpm run build # Smart build (skips unchanged) pnpm run build --force - * # Force rebuild all + SEA for current platform pnpm run build:sea # Build SEA - * binaries for all platforms pnpm run build --target <name> # Build specific - * target pnpm run build --targets <t1,t2,...> # Build multiple targets pnpm run - * build --platforms # Build all platform binaries pnpm run build --platforms - * --parallel # Build platforms in parallel pnpm run build --help # Show this - * help. - */ - -import crypto from 'node:crypto' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import fg from 'fast-glob' - -import colors from 'yoctocolors-cjs' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { CHECKPOINTS } from '../packages/build-infra/lib/constants.mts' -import { - PLATFORM_TARGETS, - formatPlatformTarget, - parsePlatformTarget, -} from '../packages/build-infra/lib/platform-targets.mts' -import { runPipelineCli } from '../packages/build-infra/lib/build-pipeline.mts' - -const logger = getDefaultLogger() -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const rootDir = path.resolve(__dirname, '..') - -const TARGET_PACKAGES: Record<string, string> = { - __proto__: undefined as unknown as string, - all: './packages/**', - cli: '@socketsecurity/cli', - 'cli-sentry': '@socketsecurity/cli-with-sentry', - node: '@socketbin/node-smol-builder-builder', - sea: '@socketbin/node-sea-builder-builder', - socket: 'socket', -} - -interface BuildPackageConfig { - name: string - filter: string - outputCheck: string - /** - * Glob patterns (repo-relative) whose file contents contribute to the build - * signature. A change to any matching file invalidates the cache and forces a - * rebuild. - */ - inputs: string[] -} - -interface BuildResult { - success: boolean - skipped: boolean -} - -interface BuildTargetResult { - duration: string - success: boolean - target: string -} - -interface ParsedArgs { - arch: string | undefined - buildArgs: string[] - force: boolean - help: boolean - parallel: boolean - platform: string | undefined - platforms: boolean - target: string | undefined - targets: string[] -} - -/** - * Build configuration for each package in the default build order. - */ -const BUILD_PACKAGES: BuildPackageConfig[] = [ - { - name: 'CLI Package', - filter: '@socketsecurity/cli', - outputCheck: 'packages/cli/dist/index.js', - inputs: [ - 'packages/cli/.config/**/*.{mts,ts,json}', - 'packages/cli/scripts/**/*.{mts,ts}', - 'packages/cli/src/**/*.{mts,ts,cts,json}', - 'packages/cli/package.json', - 'packages/cli/tsconfig.json', - 'packages/build-infra/lib/**/*.{mts,ts}', - 'packages/build-infra/package.json', - 'pnpm-lock.yaml', - '.node-version', - ], - }, -] - -/** - * Build SEA binary for current platform. - */ -async function buildCurrentPlatformSea(): Promise<{ success: boolean }> { - const { arch, platform } = await import('node:os') - const currentPlatform = platform() - const currentArch = arch() - - logger.log('') - logger.log( - `${colors.cyan('→')} Building SEA binary for ${currentPlatform}-${currentArch}...`, - ) - - const startTime = Date.now() - const result = await spawn( - 'pnpm', - [ - '--filter', - '@socketsecurity/cli', - 'run', - 'build:sea', - `--platform=${currentPlatform}`, - `--arch=${currentArch}`, - ], - { - shell: WIN32, - stdio: 'inherit', - }, - ) - const duration = ((Date.now() - startTime) / 1000).toFixed(1) - - if (result.code !== 0) { - logger.log(`${colors.red('✗')} SEA build failed (${duration}s)`) - return { success: false } - } - - logger.log(`${colors.green('✓')} SEA binary built (${duration}s)`) - return { success: true } -} - -/** - * Build a single package. - */ -async function buildPackage( - pkg: BuildPackageConfig, - force: boolean, -): Promise<BuildResult> { - const skip = !needsBuild(pkg, force) - - if (skip) { - logger.log( - `${colors.cyan('→')} ${pkg.name}: ${colors.gray('skipped (up to date)')}`, - ) - return { success: true, skipped: true } - } - - logger.log(`${colors.cyan('→')} ${pkg.name}: ${colors.blue('building...')}`) - - const buildScript = force ? 'build:force' : 'build' - const args = ['--filter', pkg.filter, 'run', buildScript] - - const startTime = Date.now() - const result = await spawn('pnpm', args, { - shell: WIN32, - stdio: 'inherit', - }) - const duration = ((Date.now() - startTime) / 1000).toFixed(1) - - if (result.code !== 0) { - logger.log( - `${colors.red('✗')} ${pkg.name}: ${colors.red('failed')} (${duration}s)`, - ) - return { success: false, skipped: false } - } - - logger.log( - `${colors.green('✓')} ${pkg.name}: ${colors.green('built')} (${duration}s)`, - ) - - try { - writeSignature(pkg, computeBuildSignature(pkg)) - } catch (e) { - logger.warn( - `Could not write build signature for ${pkg.name}: ${e instanceof Error ? e.message : String(e)}`, - ) - } - - return { success: true, skipped: false } -} - -/** - * Build SEA binary for a specific platform. - */ -async function buildPlatformSea( - platform: string, - arch: string, - libc: string | undefined, -): Promise<{ success: boolean }> { - const targetName = formatPlatformTarget(platform, arch, libc) - - logger.log('') - logger.log(`${colors.cyan('→')} Building SEA binary for ${targetName}...`) - - const seaArgs = [ - '--filter', - '@socketsecurity/cli', - 'run', - 'build:sea', - `--platform=${platform}`, - `--arch=${arch}`, - ] - - if (libc) { - seaArgs.push(`--libc=${libc}`) - } - - const startTime = Date.now() - const result = await spawn('pnpm', seaArgs, { - shell: WIN32, - stdio: 'inherit', - }) - const duration = ((Date.now() - startTime) / 1000).toFixed(1) - - if (result.code !== 0) { - logger.log( - `${colors.red('✗')} SEA build for ${targetName} failed (${duration}s)`, - ) - return { success: false } - } - - logger.log( - `${colors.green('✓')} SEA binary for ${targetName} built (${duration}s)`, - ) - return { success: true } -} - -/** - * Build a single target (for parallel/sequential builds). - */ -export async function buildTarget( - target: string, - buildArgs: string[], -): Promise<BuildTargetResult> { - const startTime = Date.now() - logger.log(`${colors.cyan('→')} [${target}] Starting build...`) - - // Check if this is a platform target (e.g., darwin-arm64). - const platformInfo = parsePlatformTarget(target) - if (platformInfo) { - const seaArgs = [ - '--filter', - '@socketsecurity/cli', - 'run', - 'build:sea', - `--platform=${platformInfo.platform}`, - `--arch=${platformInfo.arch}`, - ] - - if (platformInfo.libc) { - seaArgs.push(`--libc=${platformInfo.libc}`) - } - - const result = await spawn('pnpm', seaArgs, { - shell: WIN32, - stdio: 'pipe', - }) - - const duration = ((Date.now() - startTime) / 1000).toFixed(1) - - if (result.code === 0) { - logger.log( - `${colors.green('✓')} [${target}] Build succeeded (${duration}s)`, - ) - return { duration, success: true, target } - } - - logger.error(`${colors.red('✗')} [${target}] Build failed (${duration}s)`) - if (result.stderr) { - logger.error(`${colors.red('✗')} [${target}] Error output:`) - logger.error(result.stderr) - } - return { duration, success: false, target } - } - - // Not a platform target, use pnpm filter for package builds. - const packageFilter = TARGET_PACKAGES[target] - if (!packageFilter) { - throw new Error(`Unknown build target: ${target}`) - } - - const pnpmArgs = ['--filter', packageFilter, 'run', 'build', ...buildArgs] - - const result = await spawn('pnpm', pnpmArgs, { - shell: WIN32, - stdio: 'pipe', - }) - - const duration = ((Date.now() - startTime) / 1000).toFixed(1) - - if (result.code === 0) { - logger.log( - `${colors.green('✓')} [${target}] Build succeeded (${duration}s)`, - ) - return { duration, success: true, target } - } - - logger.error(`${colors.red('✗')} [${target}] Build failed (${duration}s)`) - if (result.stderr) { - logger.error(`${colors.red('✗')} [${target}] Error output:`) - logger.error(result.stderr) - } - return { duration, success: false, target } -} - -/** - * Compute a SHA-256 signature over the contents of files matched by the - * package's input globs. Files are sorted to keep the hash deterministic. - */ -function computeBuildSignature(pkg: BuildPackageConfig): string { - const files = fg.sync(pkg.inputs, { - cwd: rootDir, - onlyFiles: true, - dot: true, - absolute: true, - }) - files.sort() - - const hash = createHash('sha256') - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - const relative = path.relative(rootDir, file) - hash.update(relative) - hash.update('\0') - hash.update(readFileSync(file)) - hash.update('\0') - } - return hash.digest('hex') -} - -/** - * Check if a package needs to be built. Returns true if build is needed, false - * if can skip. - * - * Rebuild triggers: 1. --force 2. Missing build output 3. Missing signature - * sidecar 4. Current input signature differs from the recorded one. - */ -function needsBuild(pkg: BuildPackageConfig, force: boolean): boolean { - if (force) { - return true - } - - const outputPath = path.join(rootDir, pkg.outputCheck) - if (!existsSync(outputPath)) { - return true - } - - const stored = readSignature(pkg) - if (!stored) { - return true - } - - return computeBuildSignature(pkg) !== stored -} - -/** - * Parse command line arguments. - */ -export function parseArgs(): ParsedArgs { - const args = process.argv.slice(2) - let target: string | undefined - let targets: string[] = [] - let platforms = false - let parallel = false - let force = false - let help = false - let platform: string | undefined - let arch: string | undefined - const buildArgs: string[] = [] - - for (let i = 0; i < args.length; i++) { - const arg = args[i]! - if (arg === '--target' && i + 1 < args.length) { - target = args[++i] - } else if (arg === '--targets' && i + 1 < args.length) { - targets = args[++i]!.split(',').map(t => t.trim()) - } else if (arg === '--platform' && i + 1 < args.length) { - platform = args[++i] - } else if (arg.startsWith('--platform=')) { - platform = arg.split('=')[1] - } else if (arg === '--arch' && i + 1 < args.length) { - arch = args[++i] - } else if (arg.startsWith('--arch=')) { - arch = arg.split('=')[1] - } else if (arg === '--platforms') { - platforms = true - } else if (arg === '--parallel') { - parallel = true - } else if (arg === '--force') { - force = true - } else if (arg === '--help' || arg === '-h') { - help = true - } else { - buildArgs.push(arg) - } - } - - // If --platform and --arch are provided, combine them into target. - if (platform && arch) { - target = `${platform}-${arch}` - } - - return { - arch, - buildArgs, - force, - help, - parallel, - platform, - platforms, - target, - targets, - } -} - -function readSignature(pkg: BuildPackageConfig): string | null { - const file = signaturePath(pkg) - if (!existsSync(file)) { - return undefined - } - return readFileSync(file, 'utf8').trim() -} - -/** - * Run multiple targeted builds in parallel. - */ -async function runParallelBuilds( - targetsToBuild: string[], - buildArgs: string[], -): Promise<void> { - logger.log('') - logger.log('='.repeat(60)) - logger.log( - `${colors.blue('Building ' + targetsToBuild.length + ' targets in parallel')}`, - ) - logger.log('='.repeat(60)) - logger.log('') - logger.log(`Targets: ${targetsToBuild.join(', ')}`) - logger.log('') - - // Check if any targets are platform targets that need CLI built first. - const hasPlatformTargets = targetsToBuild.some( - t => parsePlatformTarget(t) !== null, - ) - if (hasPlatformTargets) { - const cliOutputPath = path.join(rootDir, 'packages/cli/dist/index.js') - if (!existsSync(cliOutputPath)) { - logger.log(`${colors.cyan('→')} Building CLI first...`) - const cliResult = await buildPackage(BUILD_PACKAGES[0], false) - if (!cliResult.success) { - process.exitCode = 1 - return - } - logger.log('') - } - } - - const startTime = Date.now() - const results = await Promise.allSettled( - targetsToBuild.map(target => buildTarget(target, buildArgs)), - ) - - const totalDuration = ((Date.now() - startTime) / 1000).toFixed(1) - - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('Build Summary')}`) - logger.log('='.repeat(60)) - logger.log('') - - const successful = results.filter( - r => r.status === 'fulfilled' && r.value.success, - ).length - const failed = results.filter( - r => - r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.success), - ).length - - logger.log(`${colors.green('Succeeded:')} ${successful}`) - logger.log(`${colors.red('Failed:')} ${failed}`) - logger.log(`${colors.blue('Total:')} ${totalDuration}s`) - logger.log('') - - if (failed > 0) { - logger.log(`${colors.red('✗')} One or more builds failed`) - logger.log('') - process.exitCode = 1 - return - } - - logger.log(`${colors.green('✓')} All builds completed successfully`) - logger.log('') -} - -/** - * Run multiple targeted builds sequentially. - */ -async function runSequentialBuilds( - targetsToBuild: string[], - buildArgs: string[], -): Promise<void> { - logger.log('') - logger.log('='.repeat(60)) - logger.log( - `${colors.blue('Building ' + targetsToBuild.length + ' targets sequentially')}`, - ) - logger.log('='.repeat(60)) - logger.log('') - logger.log(`Targets: ${targetsToBuild.join(', ')}`) - logger.log('') - - // Check if any targets are platform targets that need CLI built first. - const hasPlatformTargets = targetsToBuild.some( - t => parsePlatformTarget(t) !== null, - ) - if (hasPlatformTargets) { - const cliOutputPath = path.join(rootDir, 'packages/cli/dist/index.js') - if (!existsSync(cliOutputPath)) { - logger.log(`${colors.cyan('→')} Building CLI first...`) - const cliResult = await buildPackage(BUILD_PACKAGES[0], false) - if (!cliResult.success) { - process.exitCode = 1 - return - } - logger.log('') - } - } - - const startTime = Date.now() - const results = [] - - for (let i = 0, { length } = targetsToBuild; i < length; i += 1) { - const target = targetsToBuild[i] - const result = await buildTarget(target, buildArgs) - results.push(result) - - if (!result.success) { - break - } - } - - const totalDuration = ((Date.now() - startTime) / 1000).toFixed(1) - - logger.log('') - logger.log('='.repeat(60)) - logger.log(`${colors.blue('Build Summary')}`) - logger.log('='.repeat(60)) - logger.log('') - - const successful = results.filter(r => r.success).length - const failed = results.filter(r => !r.success).length - - logger.log(`${colors.green('Succeeded:')} ${successful}`) - logger.log(`${colors.red('Failed:')} ${failed}`) - logger.log(`${colors.blue('Total:')} ${totalDuration}s`) - logger.log('') - - if (failed > 0) { - logger.log( - `${colors.red('✗')} Build failed at target: ${results.find(r => !r.success)?.target}`, - ) - logger.log('') - process.exitCode = 1 - return - } - - logger.log(`${colors.green('✓')} All builds completed successfully`) - logger.log('') -} - -/** - * Run the default smart build — now orchestrated via the shared build-pipeline - * (same system socket-btm/ultrathink/socket-tui/sdxgen use). - * - * Stages: CLI build @socketsecurity/cli via pnpm --filter (the existing - * buildPackage helper handles signature check + skip-on-cached inside the - * workspace; the orchestrator's shouldRun layer complements with a - * content-hashed cache key) SEA build SEA binary for current platform (only - * when --force/--prod) FINALIZED verify expected outputs exist. - * - * Inherits CLI flags from runPipelineCli: --force, --clean, --clean-stage, - * --from-stage, --cache-key, --prod, --dev. - */ -async function runSmartBuild(force: boolean): Promise<void> { - // The orchestrator reads --force/--clean/... off process.argv itself; we - // only pass `force` here so the SEA stage knows whether to run. - const cliPkg = BUILD_PACKAGES[0]! - const cliOutputPath = path.join(rootDir, cliPkg.outputCheck) - - await runPipelineCli({ - packageRoot: rootDir, - packageName: 'cli', - resolvePlatformArch: async () => 'universal', - getBuildPaths: (mode: string) => ({ - buildDir: path.join(rootDir, 'build', mode), - }), - getOutputFiles: () => [cliOutputPath], - stages: [ - { - name: CHECKPOINTS.CLI, - sourcePaths: fg.sync(cliPkg.inputs, { - cwd: rootDir, - onlyFiles: true, - dot: true, - absolute: true, - }), - run: async () => { - const result = await buildPackage(cliPkg, force) - if (!result.success) { - throw new Error(`${cliPkg.name} build failed`) - } - return { - smokeTest: async () => { - if (!existsSync(cliOutputPath)) { - throw new Error(`CLI output missing: ${cliOutputPath}`) - } - }, - } - }, - }, - // SEA stage only runs when --force / --prod is set (matches the - // historical behavior of runSmartBuild — plain `pnpm build` stops - // after CLI, `pnpm build --force` also builds SEA for the current - // platform). The skip predicate runs before shouldRun(), so the - // stage is transparently absent on a normal dev build. - { - name: CHECKPOINTS.SEA, - skip: ctx => !force && ctx.buildMode !== 'prod', - // Hash the CLI output into this stage's cache key. Without it, - // shouldRun() only sees external-tools.json + package.json, so a - // CLI rebuild that leaves those files untouched would skip SEA - // and leave a stale binary built against the previous dist/index.js. - sourcePaths: [cliOutputPath], - run: async () => { - const seaResult = await buildCurrentPlatformSea() - if (!seaResult.success) { - throw new Error('SEA binary build failed') - } - return {} - }, - }, - { - name: CHECKPOINTS.FINALIZED, - run: async () => ({ - smokeTest: async () => { - if (!existsSync(cliOutputPath)) { - throw new Error(`CLI output missing: ${cliOutputPath}`) - } - }, - }), - }, - ], - }) -} - -/** - * Run a targeted build for a specific package or platform. - */ -async function runTargetedBuild( - target: string, - buildArgs: string[], -): Promise<void> { - // Check if this is a platform target (e.g., darwin-arm64). - const platformInfo = parsePlatformTarget(target) - if (platformInfo) { - // Ensure CLI is built first. - const cliOutputPath = path.join(rootDir, 'packages/cli/dist/index.js') - if (!existsSync(cliOutputPath)) { - logger.log(`${colors.cyan('→')} Building CLI first...`) - const cliResult = await buildPackage(BUILD_PACKAGES[0], false) - if (!cliResult.success) { - process.exitCode = 1 - return - } - } - - // Build SEA for the specified platform. - const result = await buildPlatformSea( - platformInfo.platform, - platformInfo.arch, - platformInfo.libc, - ) - process.exitCode = result.success ? 0 : 1 - return - } - - // Not a platform target, use pnpm filter for package builds. - const packageFilter = TARGET_PACKAGES[target] - if (!packageFilter) { - logger.error(`Unknown build target: ${target}`) - logger.error( - `Available targets: ${Object.keys(TARGET_PACKAGES).join(', ')}`, - ) - process.exitCode = 1 - return - } - - const pnpmArgs = ['--filter', packageFilter, 'run', 'build', ...buildArgs] - - const result = await spawn('pnpm', pnpmArgs, { - shell: WIN32, - stdio: 'inherit', - }) - - process.exitCode = result.code ?? 1 -} - -/** - * Display help message. - */ -export function showHelp(): void { - logger.log('') - logger.log(`${colors.blue('Socket CLI Build System')}`) - logger.log('') - logger.log('Usage:') - logger.log( - ' pnpm run build # Smart build (skips unchanged)', - ) - logger.log( - ' pnpm run build --force # Force rebuild all + SEA for current platform', - ) - logger.log( - ' pnpm run build:sea # Build SEA binaries for all platforms', - ) - logger.log( - ' pnpm run build --target <name> # Build specific target', - ) - logger.log( - ' pnpm run build --platform <p> --arch <a> # Build specific platform/arch', - ) - logger.log( - ' pnpm run build --targets <t1,t2,...> # Build multiple targets', - ) - logger.log( - ' pnpm run build --platforms # Build all platform binaries', - ) - logger.log( - ' pnpm run build --platforms --parallel # Build platforms in parallel', - ) - logger.log(' pnpm run build --help # Show this help') - logger.log('') - logger.log('Default Build Order:') - logger.log(' 1. CLI Package (TypeScript compilation + bundling)') - logger.log(' 2. SEA Binary for current platform (only with --force)') - logger.log('') - logger.log( - 'Note: Yoga WASM and node-smol binaries are downloaded from socket-btm', - ) - logger.log(' All pre-built binaries are cached in ~/.socket/ directory') - logger.log('') - logger.log('Platform Targets:') - for (let i = 0, { length } = PLATFORM_TARGETS; i < length; i += 1) { - const target = PLATFORM_TARGETS[i] - logger.log(` ${target}`) - } - logger.log('') - logger.log('Other Available Targets:') - for (const target of Object.keys(TARGET_PACKAGES).sort()) { - if (!PLATFORM_TARGETS.includes(target)) { - logger.log(` ${target}`) - } - } - logger.log('') -} - -/** - * Path to the sidecar signature file written alongside the build output. - */ -function signaturePath(pkg: BuildPackageConfig): string { - return path.join(rootDir, `${pkg.outputCheck}.build-signature`) -} - -function writeSignature( - pkg: BuildPackageConfig, - signature: string, -): void { - writeFileSync(signaturePath(pkg), `${signature}\n`, 'utf8') -} - -/** - * Main build function. - */ -async function main(): Promise<void> { - const opts = parseArgs() - - if (opts.help) { - showHelp() - return - } - - // Handle platforms build. - if (opts.platforms) { - const buildFn = opts.parallel ? runParallelBuilds : runSequentialBuilds - await buildFn(PLATFORM_TARGETS, opts.buildArgs) - return - } - - // Handle multiple targets. - if (opts.targets.length > 0) { - const buildFn = opts.parallel ? runParallelBuilds : runSequentialBuilds - await buildFn(opts.targets, opts.buildArgs) - return - } - - // Handle single target. - if (opts.target) { - await runTargetedBuild(opts.target, opts.buildArgs) - return - } - - // Otherwise, run the smart build with caching. - await runSmartBuild(opts.force) -} - -main().catch((e: unknown) => { - const message = e instanceof Error ? e.message : String(e) - logger.error('') - logger.error(`${colors.red('✗')} Unexpected error: ${message}`) - logger.error('') - process.exitCode = 1 -}) diff --git a/scripts/check-lock-step-header.mts b/scripts/check-lock-step-header.mts deleted file mode 100644 index c56f545b2..000000000 --- a/scripts/check-lock-step-header.mts +++ /dev/null @@ -1,368 +0,0 @@ -#!/usr/bin/env node -/** - * @file Lock-step header byte-equality gate. Mantra: the four impls of a - * quadruplet agree about WHAT THE FILE IS FOR. The `BEGIN LOCK-STEP HEADER` / - * `END LOCK-STEP HEADER` block names that contract; every member of the - * quadruplet carries the same block, byte-for-byte (after stripping the `// ` - * comment prefix). Drift on the contract is a different failure mode from a - * stale path reference (which `check-lock-step-refs.mts` catches) — this gate - * is the _intent_ tripwire. Opt-in per repo: uses the same - * `.config/lock-step-refs.json` as the path gate. Without the config, the - * gate is a no-op. With the config, the gate walks every scanned source file, - * looks for a `BEGIN LOCK-STEP HEADER` marker on the canonical side (a file - * whose header contains one or more `Lock-step with <Lang>: <path>` refs), - * extracts the header content, then opens each named peer and demands its - * header block be byte-identical. "Canonical side" is determined by the - * header content itself: - * - * - A file with `Lock-step with <Lang>: <path>` is canonical for that peer. - * (The peer should reciprocate with `Lock-step from <Lang>: <my-path>`, but - * the gate doesn't rely on that — symmetry is a §5 rule, not a §7 rule.) - * - A file with only `Lock-step from <Lang>: <path>` is a port and is checked - * against its canonical source. Header format (single-line `// ` across - * every language): // BEGIN LOCK-STEP HEADER // Class Parsing - * (Declarations, Expressions, Elements, Methods) // // Lock-step with Go: - * src/parser/class.go // Lock-step with C++: src/parser/class.cpp // END - * LOCK-STEP HEADER Comparison strips the `// ` prefix from each line; an - * empty comment line (`//`) is preserved as an empty content line. The - * content between BEGIN and END is the contract. Usage: node - * scripts/check-lock-step-header.mts # report + fail node - * scripts/check-lock-step-header.mts --json # machine-readable node - * scripts/check-lock-step-header.mts --quiet # silent on clean Exit codes: - * 0 — clean (no quadruplets diverged, or config absent) 1 — at least one - * quadruplet has a header diff 2 — gate itself crashed - */ - -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { parseArgs } from 'node:util' - -const CONFIG_PATH = '.config/lock-step-refs.json' -const SKIP_DIRS = new Set([ - '.git', - '.next', - 'build', - 'dist', - 'node_modules', - 'out', - 'pkg-node', - 'pkg-node-dev', - 'target', - 'vendor', -]) - -const BEGIN_MARKER = 'BEGIN LOCK-STEP HEADER' -const END_MARKER = 'END LOCK-STEP HEADER' - -type Config = { - readonly roots: Readonly<Record<string, readonly string[]>> - readonly scan: readonly string[] - readonly extensions: readonly string[] -} - -type HeaderBlock = { - readonly file: string - readonly bodyLines: readonly string[] - readonly withRefs: ReadonlyArray<{ lang: string; refPath: string }> -} - -type Diff = { - readonly canonical: string - readonly peer: string - readonly lang: string - readonly canonicalBody: readonly string[] - readonly peerBody: readonly string[] - readonly reason: 'peer-missing-header' | 'body-mismatch' | 'peer-not-found' -} - -function loadConfig(repoRoot: string): Config | undefined { - const configFile = path.join(repoRoot, CONFIG_PATH) - if (!existsSync(configFile)) { - return undefined - } - const raw = readFileSync(configFile, 'utf8') - const parsed = JSON.parse(raw) as Config - return parsed -} - -function walk(dir: string, exts: readonly string[]): string[] { - const out: string[] = [] - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - return out - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - if (SKIP_DIRS.has(entry)) { - continue - } - const full = path.join(dir, entry) - let st - try { - st = statSync(full) - } catch { - continue - } - if (st.isDirectory()) { - out.push(...walk(full, exts)) - } else if (st.isFile() && exts.includes(path.extname(entry))) { - out.push(full) - } - } - return out -} - -// Extract a HeaderBlock from file content, or undefined if no -// `BEGIN LOCK-STEP HEADER` marker is present. The block is the lines -// between BEGIN and END, with the `// ` prefix stripped from each. -// Each line in the returned `bodyLines` is the comment content WITHOUT -// the `// ` prefix; an empty comment line (`//` alone) becomes `''`. -function extractHeader(file: string): HeaderBlock | undefined { - let content: string - try { - content = readFileSync(file, 'utf8') - } catch { - return undefined - } - const lines = content.split('\n') - let beginIdx = -1 - let endIdx = -1 - for (let i = 0, { length } = lines; i < length; i += 1) { - if (lines[i]!.includes(BEGIN_MARKER)) { - beginIdx = i - break - } - } - if (beginIdx === -1) { - return undefined - } - for (let i = beginIdx + 1, { length } = lines; i < length; i += 1) { - if (lines[i]!.includes(END_MARKER)) { - endIdx = i - break - } - } - if (endIdx === -1) { - return undefined - } - const bodyLines: string[] = [] - for (let i = beginIdx + 1; i < endIdx; i += 1) { - const raw = lines[i]! - const stripped = stripCommentPrefix(raw) - bodyLines.push(stripped) - } - const withRe = - /Lock-step with ([A-Za-z][A-Za-z0-9+#-]*): ([^\s:,]*[./][^\s:,]*)/g - const withRefs: Array<{ lang: string; refPath: string }> = [] - for (let i = 0, { length } = bodyLines; i < length; i += 1) { - const line = bodyLines[i]! - withRe.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = withRe.exec(line)) !== null) { - withRefs.push({ lang: m[1]!, refPath: m[2]! }) - } - } - return { file, bodyLines, withRefs } -} - -// Strip the `// ` prefix (or `//` for empty content lines) from a -// comment line. Returns the content. Non-comment lines come back as -// empty string — they shouldn't appear inside a BEGIN/END block, but -// we tolerate them silently rather than failing on whitespace. -function stripCommentPrefix(line: string): string { - const trimmed = line.replace(/^\s*/, '') - if (trimmed === '//') { - return '' - } - if (trimmed.startsWith('// ')) { - return trimmed.slice(3) - } - if (trimmed.startsWith('//')) { - return trimmed.slice(2) - } - return '' -} - -function resolveRefPath( - config: Config, - repoRoot: string, - lang: string, - refPath: string, -): string | undefined { - const roots = config.roots[lang] - if (!roots) { - return undefined - } - const candidates = [ - path.join(repoRoot, refPath), - ...roots.map(r => path.join(repoRoot, r, refPath)), - ] - for (let i = 0, { length } = candidates; i < length; i += 1) { - const c = candidates[i]! - if (existsSync(c)) { - return c - } - } - return undefined -} - -function bodyEqual(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) { - return false - } - for (let i = 0, { length } = a; i < length; i += 1) { - if (a[i] !== b[i]) { - return false - } - } - return true -} - -function formatDiff(d: Diff, repoRoot: string): string { - const out: string[] = [] - const rel = (p: string) => path.relative(repoRoot, p) - out.push( - `\n${rel(d.canonical)} (canonical) ↔ ${rel(d.peer)} (${d.lang} peer):`, - ) - if (d.reason === 'peer-not-found') { - out.push(` peer path doesn't exist on disk: ${rel(d.peer)}`) - return out.join('\n') - } - if (d.reason === 'peer-missing-header') { - out.push(` peer is missing its BEGIN LOCK-STEP HEADER block`) - return out.join('\n') - } - // body-mismatch — show the diff. - out.push(' canonical header body:') - for (const line of d.canonicalBody) { - out.push(` | ${line}`) - } - out.push(' peer header body:') - for (const line of d.peerBody) { - out.push(` | ${line}`) - } - return out.join('\n') -} - -function main(): void { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - json: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - }, - allowPositionals: false, - }) - const repoRoot = process.cwd() - let config: Config | undefined - try { - config = loadConfig(repoRoot) - } catch (e) { - process.stderr.write(`check-lock-step-header: ${(e as Error).message}\n`) - process.exitCode = 2 - return - } - if (!config) { - if (!values.quiet) { - process.stdout.write( - `check-lock-step-header: ${CONFIG_PATH} not present — opt-in gate disabled, exiting clean\n`, - ) - } - return - } - const allFiles: string[] = [] - for (const scanDir of config.scan) { - const full = path.join(repoRoot, scanDir) - if (!existsSync(full)) { - continue - } - allFiles.push(...walk(full, config.extensions)) - } - // Build a map of canonical files (with at least one `Lock-step with` - // ref) and check each peer they name. - const diffs: Diff[] = [] - let canonicalCount = 0 - for (let i = 0, { length } = allFiles; i < length; i += 1) { - const file = allFiles[i]! - const header = extractHeader(file) - if (!header || header.withRefs.length === 0) { - continue - } - canonicalCount += 1 - for (const ref of header.withRefs) { - const peerPath = resolveRefPath(config, repoRoot, ref.lang, ref.refPath) - if (!peerPath) { - diffs.push({ - canonical: file, - peer: path.join(repoRoot, ref.refPath), - lang: ref.lang, - canonicalBody: header.bodyLines, - peerBody: [], - reason: 'peer-not-found', - }) - continue - } - const peerHeader = extractHeader(peerPath) - if (!peerHeader) { - diffs.push({ - canonical: file, - peer: peerPath, - lang: ref.lang, - canonicalBody: header.bodyLines, - peerBody: [], - reason: 'peer-missing-header', - }) - continue - } - if (!bodyEqual(header.bodyLines, peerHeader.bodyLines)) { - diffs.push({ - canonical: file, - peer: peerPath, - lang: ref.lang, - canonicalBody: header.bodyLines, - peerBody: peerHeader.bodyLines, - reason: 'body-mismatch', - }) - } - } - } - if (values.json) { - process.stdout.write( - JSON.stringify( - diffs.map(d => ({ - canonical: path.relative(repoRoot, d.canonical), - peer: path.relative(repoRoot, d.peer), - lang: d.lang, - reason: d.reason, - canonicalBody: d.canonicalBody, - peerBody: d.peerBody, - })), - null, - 2, - ) + '\n', - ) - } else if (diffs.length === 0) { - if (!values.quiet) { - process.stdout.write( - `check-lock-step-header: validated ${canonicalCount} canonical header(s) — clean\n`, - ) - } - } else { - process.stderr.write( - `check-lock-step-header: ${diffs.length} quadruplet diff(s) across ${canonicalCount} canonical header(s)`, - ) - for (let i = 0, { length } = diffs; i < length; i += 1) { - const d = diffs[i]! - process.stderr.write(formatDiff(d, repoRoot)) - } - process.stderr.write('\n') - } - if (diffs.length > 0) { - process.exitCode = 1 - } -} - -main() diff --git a/scripts/check-lock-step-refs.mts b/scripts/check-lock-step-refs.mts deleted file mode 100644 index 7bd8b32d6..000000000 --- a/scripts/check-lock-step-refs.mts +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env node -/** - * @file Lock-step reference hygiene gate. Mantra: comments that name a path are - * claims about file layout; stale claims rot silently. This gate greps every - * `Lock-step with <Lang>:` / `Lock-step from <Lang>:` / inline `// Lock-step - * with <Lang>: <path>:<lines>` comment in tracked source files, resolves each - * path against the per-lang impl root declared in - * `.config/lock-step-refs.json`, and fails CI when the path no longer exists. - * Line ranges are advisory and can drift; path existence is enforceable and - * that is what we enforce. The gate is opt-in per repo: if - * `.config/lock-step-refs.json` is absent, it exits 0 immediately. Repos that - * don't ship cross-language ports pay nothing. Config shape - * (`.config/lock-step-refs.json`): { "roots": { "Rust": - * ["packages/acorn/lang/rust/crates"], "Go": ["packages/acorn/lang/go/src"], - * "C++": ["packages/acorn/lang/cpp/src"], "TS": - * ["packages/acorn/lang/typescript/src"] }, "scan": ["packages/acorn/lang"], - * "extensions": [".rs", ".go", ".cpp", ".hpp", ".ts", ".py", ".zig"] } - * `roots` maps the `<Lang>` token in the comment to one or more directories - * the path is resolved against. The first root that contains the file wins. - * `scan` lists directories the gate walks looking for comments. `extensions` - * filters which files are inspected. Comment shapes recognized (all four are - * documented in `docs/claude.md/fleet/parser-comments.md` §5): //! Lock-step - * with Go: src/parser/class.go //! Lock-step from Rust: - * crates/parser/src/class.rs // Lock-step with Go: parser.go:6450-6457 // - * Lock-step note: <freeform — not validated, by design> Only forms that carry - * a `<path>` are validated; `Lock-step note:` is a rationale shape and - * intentionally has no enforced target. Usage: node - * scripts/check-lock-step-refs.mts # report + fail on rot node - * scripts/check-lock-step-refs.mts --json # machine-readable node - * scripts/check-lock-step-refs.mts --quiet # silent on clean Exit codes: 0 — - * clean, or repo has no `.config/lock-step-refs.json` (opt-in absent) 1 — at - * least one stale reference found 2 — gate itself crashed (malformed config, - * walker failure) - */ - -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { parseArgs } from 'node:util' - -const CONFIG_PATH = '.config/lock-step-refs.json' -const SKIP_DIRS = new Set([ - '.git', - '.next', - 'build', - 'dist', - 'node_modules', - 'out', - 'pkg-node', - 'pkg-node-dev', - 'target', - 'vendor', -]) - -type Config = { - readonly roots: Readonly<Record<string, readonly string[]>> - readonly scan: readonly string[] - readonly extensions: readonly string[] -} - -type Finding = { - readonly file: string - readonly line: number - readonly lang: string - readonly refPath: string - readonly reason: 'unknown-lang' | 'path-not-found' -} - -// Capture-group layout: -// 1: form keyword — "with" or "from" -// 2: lang token (letters, digits, +, #, hyphen — covers Rust/Go/C++/TS/Py/Zig) -// 3: path (no whitespace, no colon; must contain `.` or `/` to avoid -// matching prose like "Lock-step with Go: JSON parser") -// 4: optional `:start[-end]` line range (discarded for path resolution) -const LOCK_STEP_RE = - /Lock-step (?:from|with) (?:[A-Za-z][A-Za-z0-9+#-]*): (?:[^\s:,]*[./][^\s:,]*)(?::(?:\d+(?:-\d+)?))?/g - -function loadConfig(repoRoot: string): Config | undefined { - const configFile = path.join(repoRoot, CONFIG_PATH) - if (!existsSync(configFile)) { - return undefined - } - let raw: string - try { - raw = readFileSync(configFile, 'utf8') - } catch (e) { - throw new Error(`failed to read ${CONFIG_PATH}: ${(e as Error).message}`) - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (e) { - throw new Error(`${CONFIG_PATH} is not valid JSON: ${(e as Error).message}`) - } - if (!parsed || typeof parsed !== 'object') { - throw new Error(`${CONFIG_PATH} must be a JSON object`) - } - const obj = parsed as Record<string, unknown> - if (!obj['roots'] || typeof obj['roots'] !== 'object') { - throw new Error(`${CONFIG_PATH} missing required "roots" object`) - } - if (!Array.isArray(obj['scan'])) { - throw new Error(`${CONFIG_PATH} missing required "scan" array`) - } - if (!Array.isArray(obj['extensions'])) { - throw new Error(`${CONFIG_PATH} missing required "extensions" array`) - } - return obj as unknown as Config -} - -function walk(dir: string, exts: readonly string[]): string[] { - const out: string[] = [] - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - return out - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - if (SKIP_DIRS.has(entry)) { - continue - } - const full = path.join(dir, entry) - let st - try { - st = statSync(full) - } catch { - continue - } - if (st.isDirectory()) { - out.push(...walk(full, exts)) - } else if (st.isFile() && exts.includes(path.extname(entry))) { - out.push(full) - } - } - return out -} - -function resolveRef( - config: Config, - repoRoot: string, - lang: string, - refPath: string, -): { found: boolean; knownLang: boolean } { - const roots = config.roots[lang] - if (!roots || !roots.length) { - return { found: false, knownLang: false } - } - // Absolute-style refs (start with `packages/`, `crates/`, `src/`, etc.) - // are tried as repo-root relative AND against each lang root. The first - // hit wins. This tolerates the variety we see in practice: Rust files - // reference `parser.go:6450` (root-relative) while Go files reference - // `crates/parser/src/class.rs` (lang-relative). - const repoRelative = path.join(repoRoot, refPath) - if (existsSync(repoRelative)) { - return { found: true, knownLang: true } - } - for (let i = 0, { length } = roots; i < length; i += 1) { - const root = roots[i]! - const candidate = path.join(repoRoot, root, refPath) - if (existsSync(candidate)) { - return { found: true, knownLang: true } - } - } - return { found: false, knownLang: true } -} - -function scanFile( - filePath: string, - config: Config, - repoRoot: string, -): Finding[] { - const findings: Finding[] = [] - let content: string - try { - content = readFileSync(filePath, 'utf8') - } catch { - return findings - } - const lines = content.split('\n') - for (let i = 0, len = lines.length; i < len; i += 1) { - const line = lines[i]! - LOCK_STEP_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = LOCK_STEP_RE.exec(line)) !== null) { - const [, , lang, refPath] = match - const { found, knownLang } = resolveRef(config, repoRoot, lang!, refPath!) - if (!knownLang) { - findings.push({ - file: filePath, - line: i + 1, - lang: lang!, - refPath: refPath!, - reason: 'unknown-lang', - }) - } else if (!found) { - findings.push({ - file: filePath, - line: i + 1, - lang: lang!, - refPath: refPath!, - reason: 'path-not-found', - }) - } - } - } - return findings -} - -function formatFindings( - findings: readonly Finding[], - repoRoot: string, -): string { - const grouped = new Map<string, Finding[]>() - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - const key = f.file - let arr = grouped.get(key) - if (!arr) { - arr = [] - grouped.set(key, arr) - } - arr.push(f) - } - const lines: string[] = [] - for (const [file, fileFindings] of grouped) { - const rel = path.relative(repoRoot, file) - lines.push(`\n${rel}:`) - for (let i = 0, { length } = fileFindings; i < length; i += 1) { - const f = fileFindings[i]! - const tag = - f.reason === 'unknown-lang' - ? `unknown <Lang> token "${f.lang}" (add to .config/lock-step-refs.json roots)` - : `path not found: ${f.refPath}` - lines.push(` L${f.line}: Lock-step ${f.lang} — ${tag}`) - } - } - return lines.join('\n') -} - -function main(): void { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - json: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - }, - allowPositionals: false, - }) - const repoRoot = process.cwd() - let config: Config | undefined - try { - config = loadConfig(repoRoot) - } catch (e) { - process.stderr.write(`check-lock-step-refs: ${(e as Error).message}\n`) - process.exitCode = 2 - return - } - if (!config) { - if (!values.quiet) { - process.stdout.write( - `check-lock-step-refs: ${CONFIG_PATH} not present — opt-in gate disabled, exiting clean\n`, - ) - } - return - } - const allFiles: string[] = [] - for (const scanDir of config.scan) { - const full = path.join(repoRoot, scanDir) - if (!existsSync(full)) { - continue - } - allFiles.push(...walk(full, config.extensions)) - } - const findings: Finding[] = [] - for (let i = 0, { length } = allFiles; i < length; i += 1) { - const file = allFiles[i]! - findings.push(...scanFile(file, config, repoRoot)) - } - if (values.json) { - process.stdout.write( - JSON.stringify( - findings.map(f => ({ - file: path.relative(repoRoot, f.file), - line: f.line, - lang: f.lang, - refPath: f.refPath, - reason: f.reason, - })), - null, - 2, - ) + '\n', - ) - } else if (findings.length === 0) { - if (!values.quiet) { - process.stdout.write( - `check-lock-step-refs: scanned ${allFiles.length} files — clean\n`, - ) - } - } else { - process.stderr.write( - `check-lock-step-refs: ${findings.length} stale reference(s) across ${allFiles.length} scanned files`, - ) - process.stderr.write(formatFindings(findings, repoRoot)) - process.stderr.write('\n') - } - if (findings.length > 0) { - process.exitCode = 1 - } -} - -main() diff --git a/scripts/check-paths.mts b/scripts/check-paths.mts deleted file mode 100644 index 5189f578f..000000000 --- a/scripts/check-paths.mts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -/** - * @file Thin entry shim — real CLI lives in check-paths/cli.mts. - */ - -import './check-paths/cli.mts' diff --git a/scripts/check-paths/allowlist.mts b/scripts/check-paths/allowlist.mts deleted file mode 100644 index 3f9dc3131..000000000 --- a/scripts/check-paths/allowlist.mts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * @file Allowlist parsing + matching for the path-hygiene gate. Loads - * `.github/paths-allowlist.yml` with a tiny purpose-built YAML subset parser - * (entries with scalar fields plus YAML 1.2 `|` / `>` block scalars for - * multi-line `reason` text) so the gate stays self-contained — usable inside - * socket-lib itself, where adding a `yaml` dep would create a circular - * dependency. `snippetHash` produces a whitespace-insensitive, 12-hex-char - * SHA-256 prefix used as a drift-tolerant key in allowlist entries. - * `isAllowlisted` matches a finding against any combination of `rule` / - * `file` / `pattern` / `line` / `snippet_hash` filters; the line/hash check - * is OR'd so reformatting that shifts the line still matches via the hash. - */ - -import crypto from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import type { AllowlistEntry, Finding } from './types.mts' - -/** - * Read `pathsAllowlist` from `.config/socket-wheelhouse.json` (the fleet's - * canonical config file — JSON, not YAML, per the "JSON not YAML for our own - * configs" rule). Returns `undefined` when the config is absent / has no - * pathsAllowlist key — caller falls back to the legacy - * `.github/paths-allowlist.yml`. Returns `[]` when the key is present but - * empty. - * - * Each entry mirrors the YAML schema (rule/file/pattern/line/ - * snippet_hash/reason). `reason` is required; structural validation is light — - * bad shapes get dropped with a stderr note rather than blowing up the whole - * gate. - */ -const loadAllowlistFromJson = ( - repoRoot: string, -): AllowlistEntry[] | undefined => { - // Two accepted locations match the rest of the fleet's - // socket-wheelhouse.json resolution: primary under .config/ and - // legacy root-level dotfile. - const candidates = [ - path.join(repoRoot, '.config', 'socket-wheelhouse.json'), - path.join(repoRoot, '.socket-wheelhouse.json'), - ] - let configPath: string | undefined - for (let i = 0, { length } = candidates; i < length; i += 1) { - const c = candidates[i]! - if (existsSync(c)) { - configPath = c - break - } - } - if (!configPath) { - return undefined - } - let raw: string - try { - raw = readFileSync(configPath, 'utf8') - } catch { - return undefined - } - let cfg: { pathsAllowlist?: unknown | undefined } - try { - cfg = JSON.parse(raw) - } catch { - return undefined - } - const arr = cfg.pathsAllowlist - if (arr === undefined) { - return undefined - } - if (!Array.isArray(arr)) { - process.stderr.write( - `[check-paths] pathsAllowlist in ${configPath} must be an array; ignoring.\n`, - ) - return [] - } - const out: AllowlistEntry[] = [] - for (let i = 0; i < arr.length; i += 1) { - const e = arr[i]! - if (typeof e !== 'object' || e === null) { - process.stderr.write( - `[check-paths] pathsAllowlist[${i}] in ${configPath} is not an object; skipping.\n`, - ) - continue - } - const obj = e as Record<string, unknown> - if (typeof obj['reason'] !== 'string' || obj['reason'].length === 0) { - process.stderr.write( - `[check-paths] pathsAllowlist[${i}] in ${configPath} missing required \`reason\`; skipping.\n`, - ) - continue - } - const entry: AllowlistEntry = { reason: obj['reason'] } - if (typeof obj['file'] === 'string') { - entry.file = obj['file'] - } - if (typeof obj['pattern'] === 'string') { - entry.pattern = obj['pattern'] - } - if (typeof obj['rule'] === 'string') { - entry.rule = obj['rule'] - } - if (typeof obj['line'] === 'number') { - entry.line = obj['line'] - } - if (typeof obj['snippet_hash'] === 'string') { - entry.snippet_hash = obj['snippet_hash'] - } - out.push(entry) - } - return out -} - -export const unquote = (s: string): string => { - const t = s.trim() - if ( - (t.startsWith('"') && t.endsWith('"')) || - (t.startsWith("'") && t.endsWith("'")) - ) { - return t.slice(1, -1) - } - return t -} - -export const loadAllowlist = (repoRoot: string): AllowlistEntry[] => { - // Primary source: `.config/socket-wheelhouse.json` → `pathsAllowlist` - // array. Fleet convention is "JSON not YAML for our own configs" - // (pnpm-mandated configs stay in pnpm-workspace.yaml; everything - // else lives in socket-wheelhouse.json). Falls back to the legacy - // `.github/paths-allowlist.yml` while repos migrate. - const jsonEntries = loadAllowlistFromJson(repoRoot) - if (jsonEntries !== undefined) { - return jsonEntries - } - const allowlistPath = path.join(repoRoot, '.github', 'paths-allowlist.yml') - if (!existsSync(allowlistPath)) { - return [] - } - const text = readFileSync(allowlistPath, 'utf8') - // Tiny YAML parser — only the shape we need: list of entries with - // `file`, `pattern`, `rule`, `line`, `reason` scalar fields, plus - // YAML 1.2 block-scalar indicators `|` (literal) and `>` (folded) - // for multi-line reasons. Avoids a yaml dep for a gate that has to - // be self-contained. - const entries: AllowlistEntry[] = [] - let current: Partial<AllowlistEntry> | undefined = undefined - // When set, subsequent more-indented lines fold into this key as a - // block scalar (literal '|' keeps newlines, folded '>' joins with - // spaces). - let blockKey: string | undefined = undefined - let blockKind: '|' | '>' | undefined = undefined - let blockIndent = 0 - let blockLines: string[] = [] - const flushBlock = () => { - if (current && blockKey) { - const value = - blockKind === '>' - ? blockLines.join(' ').replace(/\s+/g, ' ').trim() - : blockLines.join('\n').replace(/\n+$/, '') - ;(current as any)[blockKey] = value - } - blockKey = undefined - blockKind = undefined - blockLines = [] - } - const indentOf = (line: string): number => { - let i = 0 - while (i < line.length && line[i] === ' ') { - i += 1 - } - return i - } - const lines = text.split('\n') - for (let i = 0; i < lines.length; i++) { - const raw = lines[i]! - const line = raw.replace(/\r$/, '') - // Block-scalar accumulation takes precedence over normal parsing. - if (blockKey !== null) { - if (line.trim() === '') { - // Preserve blank lines inside a literal block; folded blocks - // turn them into paragraph breaks (kept as separate joins). - blockLines.push('') - continue - } - const indent = indentOf(line) - if (indent >= blockIndent) { - blockLines.push(line.slice(blockIndent)) - continue - } - flushBlock() - // Fall through and re-process the dedented line as normal. - } - if (!line.trim() || line.trim().startsWith('#')) { - continue - } - const tryAssign = (key: string, value: string) => { - const trimmed = value.trim() - if (current === null) { - return - } - if (trimmed === '>' || trimmed === '|') { - blockKey = key - blockKind = trimmed as '|' | '>' - blockIndent = indentOf(lines[i + 1] ?? '') || indentOf(line) + 2 - blockLines = [] - return - } - ;(current as any)[key] = - key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) - } - if (line.startsWith('- ')) { - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - current = {} - const rest = line.slice(2).trim() - if (rest) { - const m = rest.match(/^([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } else if (current) { - const m = line.match(/^\s+([\w-]+):\s*(.*)$/) - if (m) { - tryAssign(m[1]!, m[2]!) - } - } - } - if (blockKey !== null) { - flushBlock() - } - if (current && current.reason) { - entries.push(current as AllowlistEntry) - } - return entries -} - -/** - * Stable, normalized snippet hash. Whitespace-insensitive so trivial - * reformatting (indent change, trailing comma, line wrap) doesn't invalidate an - * allowlist entry, but content-changing edits do. The hash exposes only the - * first 12 hex chars (~48 bits) which is plenty for collision-resistance within - * a single repo's finding set and keeps the YAML readable. - */ -export const snippetHash = (snippet: string): string => { - const normalized = snippet.replace(/\s+/g, ' ').trim() - return crypto - .createHash('sha256') - .update(normalized) - .digest('hex') - .slice(0, 12) -} - -/** - * Allowlist matching trades off two failure modes: - * - * - Drift via reformatting (a line shift breaks an entry, the finding - * re-surfaces, devs paper over with a new entry). - * - Stealth allowlisting (an entry pinned to "anywhere in this file" silently - * exempts unrelated future violations). - * - * Strategy: exact line match OR `snippet_hash` match (whitespace- normalized - * SHA-256, first 12 hex). Either is sufficient. Lines stay exact (was ±2; the - * slack let reformatting silently slide), and `snippet_hash` provides - * reformatting-tolerant matching that's still tied to the literal text — - * paste-and-edit cheating would change the hash. If neither `line` nor - * `snippet_hash` is provided, the entry matches purely by `rule` + `file` + - * `pattern` (file-level exempt; use sparingly and always pair with a precise - * `pattern`). - */ -export const isAllowlisted = ( - finding: Finding, - allowlist: readonly AllowlistEntry[], -): boolean => - allowlist.some(entry => { - if (entry.rule && entry.rule !== finding.rule) { - return false - } - if (entry.file && !finding.file.includes(entry.file)) { - return false - } - if (entry.pattern && !finding.snippet.includes(entry.pattern)) { - return false - } - const lineProvided = entry.line !== undefined - const hashProvided = - typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 - if (lineProvided || hashProvided) { - const lineMatches = lineProvided && entry.line === finding.line - const hashMatches = - hashProvided && entry.snippet_hash === snippetHash(finding.snippet) - if (!(lineMatches || hashMatches)) { - return false - } - } - return true - }) diff --git a/scripts/check-paths/cli.mts b/scripts/check-paths/cli.mts deleted file mode 100644 index ed4831981..000000000 --- a/scripts/check-paths/cli.mts +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env node -/** - * @file Path-hygiene gate CLI entry. Mantra: 1 path, 1 reference. A path is - * constructed exactly once; everywhere else references the constructed value. - * Whole-repo scan complementing the per-edit `.claude/hooks/path-guard` hook. - * The hook stops new violations from landing; this gate finds the existing - * ones and blocks merges that introduce more. Helper modules: - * - * - exempt.mts — file-path patterns the gate skips - * - walk.mts — recursive file walker with SKIP_DIRS - * - allowlist.mts — paths-allowlist.yml parser + matcher - * - scan-code.mts — Rule A + B (.mts / .cts) - * - scan-workflow.mts — Rule C + D (.github/workflows/*.yml) - * - scan-script.mts — Rule G (Makefile / Dockerfile / shell) - * - rules.mts — Rule F (cross-file shape repetition) - * - state.mts — shared findings array + push/get helpers - * - types.mts — Finding + AllowlistEntry interfaces Rules enforced (full prose - * lives in each scanner module): A — Multi-stage path constructed inline. B - * — Cross-package path traversal into a sibling's build output. C — - * Hand-built workflow path outside a "Compute paths" step. D — - * Comment-encoded fully-qualified path. F — Same path shape constructed in - * 2+ files. G — Hand-built paths in Makefiles, Dockerfiles, shell scripts. - * Allowlist: `.github/paths-allowlist.yml`. Each entry needs a `reason` so - * the list stays audit-able. Patterns are deliberately narrow — entries - * should be specific, not blanket. Usage: node scripts/check-paths.mts # - * default: report + fail node scripts/check-paths.mts --explain # long-form - * explanation node scripts/check-paths.mts --json # machine-readable node - * scripts/check-paths.mts --quiet # silent on clean Exit codes: 0 — clean - * (no findings, or every finding is allowlisted) 1 — findings present 2 — - * gate itself crashed - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { parseArgs } from 'node:util' - -import { isAllowlisted, loadAllowlist, snippetHash } from './allowlist.mts' -import { isExempt } from './exempt.mts' -import { checkRuleF } from './rules.mts' -import { scanCodeFile } from './scan-code.mts' -import { scanScriptFile } from './scan-script.mts' -import { scanWorkflowFile } from './scan-workflow.mts' -import { getFindings } from './state.mts' -import { walk } from './walk.mts' - -// Plain stderr/stdout output — no @socketsecurity/lib-stable dependency so -// the gate is self-contained and works in socket-lib itself (which -// would otherwise import itself). -const logger = { - log: (msg: string) => process.stdout.write(msg + '\n'), - error: (msg: string) => process.stderr.write(msg + '\n'), - step: (msg: string) => process.stdout.write(`→ ${msg}\n`), - // oxlint-disable-next-line socket/no-status-emoji -- local logger replica; can't import lib's logger because this gate runs in socket-lib itself. - success: (msg: string) => process.stdout.write(`✔ ${msg}\n`), - substep: (msg: string) => process.stdout.write(` ${msg}\n`), -} - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -// `cli.mts` lives one level deeper than the original `check-paths.mts`, -// so REPO_ROOT walks up two parents instead of one. -const REPO_ROOT = path.resolve(__dirname, '..', '..') - -const args = parseArgs({ - options: { - explain: { type: 'boolean', default: false }, - json: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - 'show-hashes': { type: 'boolean', default: false }, - }, - strict: false, -}) - -const ALLOWLIST = loadAllowlist(REPO_ROOT) - -const main = (): number => { - // Scan code files (Rule A + B). - for (const rel of walk( - REPO_ROOT, - REPO_ROOT, - p => p.endsWith('.mts') || p.endsWith('.cts'), - )) { - if (isExempt(rel)) { - continue - } - scanCodeFile(REPO_ROOT, rel) - } - // Scan workflows (Rule C + D). - const workflowDir = path.join(REPO_ROOT, '.github', 'workflows') - if (existsSync(workflowDir)) { - for (const rel of walk(REPO_ROOT, workflowDir, p => p.endsWith('.yml'))) { - if (isExempt(rel)) { - continue - } - scanWorkflowFile(REPO_ROOT, rel) - } - } - // Scan scripts/Makefiles/Dockerfiles (Rule G). - for (const rel of walk(REPO_ROOT, REPO_ROOT, p => { - const base = path.basename(p) - return ( - base === 'Makefile' || - base.endsWith('.mk') || - base.endsWith('.Dockerfile') || - base === 'Dockerfile' || - base.endsWith('.glibc') || - base.endsWith('.musl') || - (base.endsWith('.sh') && !p.includes('test/')) - ) - })) { - if (isExempt(rel)) { - continue - } - scanScriptFile(REPO_ROOT, rel) - } - // Promote cross-file Rule-A repeats to Rule F. - checkRuleF() - - const findings = getFindings() - // Filter against allowlist. - const blocking = findings.filter(f => !isAllowlisted(f, ALLOWLIST)) - - if (args.values.json) { - process.stdout.write( - JSON.stringify( - { findings: blocking, allowlisted: findings.length - blocking.length }, - null, - 2, - ) + '\n', - ) - return blocking.length === 0 ? 0 : 1 - } - - if (blocking.length === 0) { - if (!args.values.quiet) { - logger.success('Path-hygiene check passed (1 path, 1 reference)') - if (findings.length > 0) { - logger.substep(`${findings.length} finding(s) allowlisted`) - } - } - return 0 - } - - logger.error(`Path-hygiene check FAILED — ${blocking.length} finding(s)`) - logger.log('') - logger.log('Mantra: 1 path, 1 reference') - logger.log('') - for (let i = 0, { length } = blocking; i < length; i += 1) { - const f = blocking[i]! - logger.log(` [${f.rule}] ${f.file}:${f.line}`) - logger.log(` ${f.snippet}`) - logger.log(` → ${f.message}`) - if (args.values['show-hashes']) { - logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) - } - if (args.values.explain) { - logger.log(` Fix: ${f.fix}`) - } - logger.log('') - } - if (!args.values.explain) { - logger.log('Run with --explain to see fix suggestions per finding.') - logger.log( - 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', - ) - logger.log( - 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', - ) - } - return 1 -} - -try { - process.exitCode = main() -} catch (e) { - logger.error(`Path-hygiene gate crashed: ${e}`) - process.exitCode = 2 -} diff --git a/scripts/check-paths/exempt.mts b/scripts/check-paths/exempt.mts deleted file mode 100644 index a7f7031fc..000000000 --- a/scripts/check-paths/exempt.mts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @file Exempt-file patterns for the path-hygiene gate. Lists the files that - * legitimately enumerate path segments — the canonical constructors - * (`paths.mts`), build-infra helpers, and the scanners / hooks that READ the - * segment vocabulary in order to flag everyone else. Pure data + predicate; - * no I/O. - */ - -// File-path patterns that legitimately enumerate path segments. -export const EXEMPT_FILE_PATTERNS: RegExp[] = [ - // Any paths.mts is the canonical constructor. - /(?:^|\/)paths\.(?:cts|js|mts)$/, - // Build-infra owns shared helpers that enumerate stages. - /packages\/build-infra\/lib\/paths\.mts$/, - /packages\/build-infra\/lib\/constants\.mts$/, - // Path-scanning gates that intentionally enumerate. - /scripts\/check-paths\.mts$/, - /scripts\/check-paths\//, - /scripts\/check-consistency\.mts$/, - /\.claude\/hooks\/path-guard\//, - // Allowlist + config files. - /\.github\/paths-allowlist\.yml$/, -] - -export const isExempt = (filePath: string): boolean => - EXEMPT_FILE_PATTERNS.some(re => re.test(filePath)) diff --git a/scripts/check-paths/rules.mts b/scripts/check-paths/rules.mts deleted file mode 100644 index cd299fe20..000000000 --- a/scripts/check-paths/rules.mts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @file Cross-file rule promotions for the path-hygiene gate. Rule F — same - * path shape constructed in 2+ DISTINCT files. Runs after every scanner has - * populated `state.findings`. Walks the Rule-A findings (the only ones that - * produce comparable snippets), groups by the literal-segment shape of each - * snippet, and when a shape appears in two or more distinct files, promotes - * those findings to Rule F with a sharper message. Two hand-builds in a - * single file stay Rule A; the violation is cross-FILE duplication of the - * construction. - */ - -import { findings } from './state.mts' - -import type { Finding } from './types.mts' - -export const checkRuleF = (): void => { - // A path is "constructed" each time we see a new path.join with a - // matching shape. Group findings of Rule A by their snippet shape; - // when the same shape appears in 2+ files, demote them to Rule F so - // the message is more accurate. - const byShape = new Map<string, Finding[]>() - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - if (f.rule !== 'A') { - continue - } - // Normalize: strip whitespace, identifiers, surrounding context; - // keep just the literal path-segment shape. - const literalsRe = /'[^']*'|"[^"]*"/g - const literals = (f.snippet.match(literalsRe) ?? []).join(',') - if (!literals) { - continue - } - const list = byShape.get(literals) ?? [] - list.push(f) - byShape.set(literals, list) - } - for (const [shape, list] of byShape) { - if (list.length < 2) { - continue - } - // Rule F is "same path shape appears in two or more *files*" — two - // hand-builds in a single file are still a Rule-A pattern, not a - // cross-file duplication. Promote only when at least two distinct - // files share the shape. - const distinctFiles = new Set(list.map(f => f.file)) - if (distinctFiles.size < 2) { - continue - } - for (let i = 0, { length } = list; i < length; i += 1) { - const f = list[i]! - f.rule = 'F' - f.message = `Same path shape constructed in ${distinctFiles.size} files (${list.length} places): ${shape.slice(0, 100)}` - f.fix = - 'Construct this path ONCE in a paths.mts (or build-infra helper) and import the computed value. References of the computed variable are unlimited; re-constructing the same shape twice is the violation.' - } - } -} diff --git a/scripts/check-paths/scan-code.mts b/scripts/check-paths/scan-code.mts deleted file mode 100644 index aea6d1309..000000000 --- a/scripts/check-paths/scan-code.mts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * @file Rule A + B scanner for .mts / .cts source files. Rule A — multi-stage - * path constructed inline (a `path.join(...)` / `path.resolve(...)` call OR a - * template literal that stitches stage tokens together). Rule B — - * cross-package traversal: `path.join(*, '..', '<sibling>', 'build', ...)` - * reaching into a sibling package's build output without going through its - * `exports`. Argument extraction uses a paren-balancing scanner (not just - * regex) so nested calls like `path.join(getDir(child(x)), 'build', 'Final')` - * are captured fully. Template literals get their `${...}` placeholders - * stripped to a sentinel so a placeholder-only segment can't accidentally - * match a stage token. - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' - -import { - BUILD_ROOT_SEGMENTS, - KNOWN_SIBLING_PACKAGES, - MODE_SEGMENTS, - STAGE_SEGMENTS, -} from '../../.claude/hooks/path-guard/segments.mts' -import { pushFinding } from './state.mts' - -// Locate `path.join(` or `path.resolve(` call sites; argument-list -// extraction uses a paren-balancing scanner below to handle arbitrary -// nesting depth (the previous regex-only approach silently missed any -// argument containing 2+ levels of nested function calls). -export const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g -export const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g - -// Template literal scanner. Captures backtick-delimited strings -// (including those with `${...}` placeholders) so Rule A also catches -// path construction via template literals — backtick variants of the -// same stitch-stages-inline pattern path.join() guards against. -export const TEMPLATE_LITERAL_RE = - /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g - -/** - * Convert a template-literal body into a synthetic forward-slash path by - * replacing `${...}` placeholders with a sentinel and normalizing separators. - * Returns the sequence of path segments split on `/`. The sentinel doesn't - * match any STAGE/BUILD_ROOT/MODE token, so a placeholder-only segment - * (`${binaryName}`) won't match those sets. - */ -export const templateLiteralSegments = (body: string): string[] => { - // Strip placeholders so they don't introduce noise in segments. - // Empty result for a placeholder is fine; downstream filters by set - // membership and skips empties. - const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') - return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') -} - -/** - * Extract every `path.join(...)` and `path.resolve(...)` call from the source - * text, returning each call's literal start offset and argument substring. Uses - * paren-balancing so deeply-nested arguments like `path.join(getDir(child(x)), - * 'build', 'Final')` are captured fully. - */ -export const extractPathCalls = ( - source: string, -): Array<{ offset: number; args: string }> => { - const calls: Array<{ offset: number; args: string }> = [] - PATH_CALL_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = PATH_CALL_RE.exec(source)) !== null) { - const callStart = match.index - const argsStart = PATH_CALL_RE.lastIndex - let depth = 1 - let i = argsStart - let inString: '"' | "'" | '`' | undefined = undefined - while (i < source.length && depth > 0) { - const ch = source[i]! - if (inString) { - if (ch === '\\') { - i += 2 - continue - } - if (ch === inString) { - inString = undefined - } - } else { - if (ch === '"' || ch === "'" || ch === '`') { - inString = ch - } else if (ch === '(') { - depth += 1 - } else if (ch === ')') { - depth -= 1 - if (depth === 0) { - break - } - } - } - i += 1 - } - if (depth === 0) { - calls.push({ offset: callStart, args: source.slice(argsStart, i) }) - PATH_CALL_RE.lastIndex = i + 1 - } - } - return calls -} - -export const extractStringLiterals = (args: string): string[] => { - const literals: string[] = [] - let match: RegExpExecArray | null - STRING_LITERAL_RE.lastIndex = 0 - while ((match = STRING_LITERAL_RE.exec(args)) !== null) { - if (match[2] !== undefined) { - literals.push(match[2]) - } - } - return literals -} - -export const scanCodeFile = (repoRoot: string, relPath: string): void => { - const full = path.join(repoRoot, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - // Build a line-offset map so we can map regex offsets back to line - // numbers cheaply. - const lineOffsets: number[] = [0] - for (let i = 0; i < content.length; i++) { - if (content[i] === '\n') { - lineOffsets.push(i + 1) - } - } - const offsetToLine = (offset: number): number => { - let lo = 0 - let hi = lineOffsets.length - 1 - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1 - if (lineOffsets[mid]! <= offset) { - lo = mid - } else { - hi = mid - 1 - } - } - return lo + 1 - } - - for (const call of extractPathCalls(content)) { - const literals = extractStringLiterals(call.args) - const stages = literals.filter(l => STAGE_SEGMENTS.has(l)) - const buildRoots = literals.filter(l => BUILD_ROOT_SEGMENTS.has(l)) - const modes = literals.filter(l => MODE_SEGMENTS.has(l)) - - // Rule A: 2+ stages OR (1 stage + 1 build-root + 1 mode). - const triggersA = - stages.length >= 2 || - (stages.length >= 1 && buildRoots.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - pushFinding({ - rule: 'A', - file: relPath, - line, - snippet, - message: 'Multi-stage path constructed inline (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - - // Rule B: each '..' opens a window; the window stays open only - // until the next non-'..' literal. A sibling-package literal - // *immediately after* a '..' (no path segment between them) - // triggers, AND there must be build context elsewhere in the - // call. Resetting per-segment prevents false positives where '..' - // appears earlier and sibling-name appears much later in an - // unrelated position. - const hasBuildContext = literals.some( - l => BUILD_ROOT_SEGMENTS.has(l) || STAGE_SEGMENTS.has(l), - ) - if (hasBuildContext) { - for (let i = 0; i < literals.length - 1; i++) { - if ( - literals[i] === '..' && - KNOWN_SIBLING_PACKAGES.has(literals[i + 1]!) - ) { - const sibling = literals[i + 1]! - const line = offsetToLine(call.offset) - const snippet = (lines[line - 1] ?? '').trim() - pushFinding({ - rule: 'B', - file: relPath, - line, - snippet, - message: `Cross-package traversal into '${sibling}' build output.`, - fix: `Add '${sibling}: workspace:*' as a dep, declare an exports entry on '${sibling}' (e.g. './scripts/paths' → './scripts/paths.mts'), and import the path from there.`, - }) - break - } - } - } - } - - // Rule A (template literal variant). Backtick strings that stitch - // stage tokens inline construct paths the same way `path.join(...)` - // does — flag the same shapes. TEMPLATE_LITERAL_RE matches any - // backtick string and we rely on segment composition to decide if - // it's a path. - TEMPLATE_LITERAL_RE.lastIndex = 0 - let tmpl: RegExpExecArray | null - while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { - const body = tmpl[1] ?? '' - if (!body.includes('/')) { - continue - } - const segments = templateLiteralSegments(body) - const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) - const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) - const modes = segments.filter(s => MODE_SEGMENTS.has(s)) - // Template literal trigger is tighter than path.join() because - // backtick strings often appear in patch fixtures, error messages, - // and other multi-line content that incidentally contains stage - // tokens. Require the canonical build-output shape: build + out + - // stage, or two stages + out, or build + stage + a literal mode. - const hasBuildAndOut = - buildRoots.includes('build') && buildRoots.includes('out') - const hasOut = buildRoots.includes('out') - const hasBuild = buildRoots.includes('build') - const triggersA = - (hasBuildAndOut && stages.length >= 1) || - (stages.length >= 2 && hasOut) || - (hasBuild && stages.length >= 1 && modes.length >= 1) - if (triggersA) { - const line = offsetToLine(tmpl.index) - const snippet = (lines[line - 1] ?? '').trim() - pushFinding({ - rule: 'A', - file: relPath, - line, - snippet, - message: - 'Multi-stage path constructed inline via template literal (outside paths.mts).', - fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', - }) - } - } -} diff --git a/scripts/check-paths/scan-script.mts b/scripts/check-paths/scan-script.mts deleted file mode 100644 index 0c80db23e..000000000 --- a/scripts/check-paths/scan-script.mts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file Rule G scanner for Makefile / Dockerfile / shell. Same shape as Rule A - * (multi-stage path constructed inline), applied to executable artifacts that - * can't `import` a TS `paths.mts`. Each canonical construction in a script - * must reference the source-of- truth TS module by comment so the script - * can't drift from TS without a flagged change. Dockerfile-aware: each `FROM - * ... AS ...` opens a new stage scope in which earlier `ENV` / `ARG` - * declarations don't propagate, so the 2+-times check is scoped per stage. - * Non-Dockerfile scripts share one global scope (stage 0). - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' - -import { pushFinding } from './state.mts' - -export const SCRIPT_HAND_BUILT_RE = - /build\/\$?\{?(?:BUILD_MODE|MODE|dev|prod)\}?\/[\w${}.-]*\/out\/(?:Compressed|Final|Optimized|Release|Stripped|Synced)/g - -export const scanScriptFile = (repoRoot: string, relPath: string): void => { - const full = path.join(repoRoot, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - const isDockerfile = - /Dockerfile/i.test(relPath) || /\.glibc$|\.musl$/.test(relPath) - - // First pass: collect every multi-stage path occurrence in this file, - // scoped per Dockerfile stage (each `FROM ... AS ...` starts a new - // scope where ENV/ARG don't propagate). - type Hit = { line: number; text: string; pathStr: string; stage: number } - const hits: Hit[] = [] - let stage = 0 - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comments — documentation, not construction. - continue - } - if (isDockerfile && /^FROM\s+/i.test(line)) { - stage += 1 - continue - } - SCRIPT_HAND_BUILT_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = SCRIPT_HAND_BUILT_RE.exec(line)) !== null) { - hits.push({ - line: i + 1, - text: line.trim(), - pathStr: m[0], - stage, - }) - } - } - - // Group by (stage, pathStr) — only flag when a path is built 2+ - // times within the SAME Dockerfile stage (or anywhere in non- - // Dockerfile scripts, where stages don't apply). - const grouped = new Map<string, Hit[]>() - for (let i = 0, { length } = hits; i < length; i += 1) { - const h = hits[i]! - const key = `${h.stage}::${h.pathStr}` - const list = grouped.get(key) ?? [] - list.push(h) - grouped.set(key, list) - } - for (const [, list] of grouped) { - if (list.length < 2) { - continue - } - for (let i = 0, { length } = list; i < length; i += 1) { - const hit = list[i]! - pushFinding({ - rule: 'G', - file: relPath, - line: hit.line, - snippet: hit.text, - message: `Hand-built multi-stage path constructed ${list.length} times in this file: ${hit.pathStr}`, - fix: 'Assign to a variable / ENV once near the top of the script / Dockerfile stage, with a comment naming the canonical paths.mts. Reference the variable everywhere downstream. References of a single construction are unlimited; reconstructing the same path is the violation.', - }) - } - } -} diff --git a/scripts/check-paths/scan-workflow.mts b/scripts/check-paths/scan-workflow.mts deleted file mode 100644 index 1da5abfff..000000000 --- a/scripts/check-paths/scan-workflow.mts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @file Rule C + D scanner for `.github/workflows/*.yml`. Rule C — workflow - * constructs the same multi-stage path 2+ times outside a canonical "Compute - * paths" step. The fix is to add one `id: paths` step early in the job that - * computes the path and exposes it via `$GITHUB_OUTPUT`; later steps - * reference it. Rule D — comments encode a fully-qualified multi-stage path - * string. Comments may describe path _structure_ with placeholders but - * shouldn't carry a tool-parsable path — the canonical construction IS the - * documentation. `isInsideComputePathsBlock` walks backwards from the current - * line to find the enclosing step header; if that step is named `Compute … - * paths` or has `id: paths`, the line is exempt from Rule C (the canonical - * place to construct a path). - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' - -import { pushFinding } from './state.mts' - -export const WORKFLOW_PATH_RE = - /build\/\$\{[^}]+\}\/[^"'`\s]*\/out\/(?:Compressed|Final|Optimized|Release|Stripped|Synced)/g -export const WORKFLOW_GH_EXPR_PATH_RE = - /build\/\$\{\{\s*[^}]+\}\}\/[^"'`\s]*\/out\/(?:Compressed|Final|Optimized|Release|Stripped|Synced)/g - -export const isInsideComputePathsBlock = ( - lines: string[], - lineIdx: number, -): boolean => { - // Walk backwards up to 60 lines looking for the start of the - // current step. If that step is a "Compute paths" step, the line - // is exempt. - for (let i = lineIdx; i >= Math.max(0, lineIdx - 60); i--) { - const l = lines[i] ?? '' - if (/^\s*-\s*name:/i.test(l)) { - // Step boundary — check if THIS step is a Compute paths step. - // The step body may include `id: paths` even if the name is - // something else (e.g. `id: stub-paths`), so look at the next - // ~20 lines for either marker. - for (let j = i; j < Math.min(lines.length, i + 20); j++) { - const m = lines[j] ?? '' - if ( - /^\s*-\s*name:\s*Compute\s+[\w-]+\s+paths/i.test(m) || - /^\s*id:\s*[\w-]*paths\s*$/i.test(m) - ) { - return true - } - if (j > i && /^\s*-\s*name:/i.test(m)) { - // Hit the next step — current step is NOT Compute paths. - return false - } - } - return false - } - } - return false -} - -export const scanWorkflowFile = (repoRoot: string, relPath: string): void => { - const full = path.join(repoRoot, relPath) - let content: string - try { - content = readFileSync(full, 'utf8') - } catch { - return - } - const lines = content.split('\n') - - // First pass: collect every hand-built path occurrence outside a - // "Compute paths" step. Per the mantra, a single reference is fine - // — what's banned is reconstructing the same path 2+ times. - type PathHit = { - line: number - snippet: string - pathStr: string - } - const occurrences = new Map<string, PathHit[]>() - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (/^\s*#/.test(line)) { - // Skip comment lines from C scan; they're under D below. - continue - } - if (isInsideComputePathsBlock(lines, i)) { - // Inside the canonical construction step — exempt. - continue - } - WORKFLOW_PATH_RE.lastIndex = 0 - WORKFLOW_GH_EXPR_PATH_RE.lastIndex = 0 - const matches: string[] = [] - let m: RegExpExecArray | null - while ((m = WORKFLOW_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - while ((m = WORKFLOW_GH_EXPR_PATH_RE.exec(line)) !== null) { - matches.push(m[0]) - } - for (let i = 0, { length } = matches; i < length; i += 1) { - const pathStr = matches[i]! - const list = occurrences.get(pathStr) ?? [] - list.push({ line: i + 1, snippet: line.trim(), pathStr }) - occurrences.set(pathStr, list) - } - } - - // Flag every occurrence of a shape that appears 2+ times. - for (const [pathStr, hits] of occurrences) { - if (hits.length < 2) { - continue - } - for (let i = 0, { length } = hits; i < length; i += 1) { - const hit = hits[i]! - pushFinding({ - rule: 'C', - file: relPath, - line: hit.line, - snippet: hit.snippet, - message: `Workflow constructs the same path ${hits.length} times: ${pathStr}`, - fix: 'Add a "Compute <pkg> paths" step (id: paths) early in the job that computes this path ONCE and exposes it via $GITHUB_OUTPUT. Reference as ${{ steps.paths.outputs.<name> }} in subsequent steps. References of the constructed value are unlimited; reconstructing is the violation.', - }) - } - } - - // Rule D: comments encoding a fully-qualified multi-stage path - // (separate scan since it has different semantics). - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - if (!/^\s*#/.test(line)) { - continue - } - const literalShape = - /build\/(?:dev|prod|shared)\/[a-z0-9-]+\/(?:wasm\/)?out\/(?:Compressed|Final|Optimized|Release|Stripped|Synced)/i - if (literalShape.test(line)) { - pushFinding({ - rule: 'D', - file: relPath, - line: i + 1, - snippet: line.trim(), - message: 'Comment encodes a fully-qualified path string.', - fix: 'Cite the canonical paths.mts (e.g. "see packages/<pkg>/scripts/paths.mts:getBuildPaths()") instead of duplicating the path string. Comments may describe structure with placeholders ("<mode>/<arch>") but should not be a parsable path.', - }) - } - } -} diff --git a/scripts/check-paths/state.mts b/scripts/check-paths/state.mts deleted file mode 100644 index 3811e44c7..000000000 --- a/scripts/check-paths/state.mts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file Shared findings state for the path-hygiene gate. Replaces the - * module-level `findings: Finding[]` array that lived at file scope in the - * pre-split monolith. Every scanner imports `pushFinding` (write) and the CLI - * entry reads via `getFindings()` so the array stays a single source of truth - * across the helper modules. `clearFindings` exists for test harnesses that - * exercise multiple runs in one process; the production CLI never resets - * mid-run. - */ - -import type { Finding } from './types.mts' - -export const findings: Finding[] = [] - -export function pushFinding(f: Finding): void { - findings.push(f) -} - -export function getFindings(): readonly Finding[] { - return findings -} - -export function clearFindings(): void { - findings.length = 0 -} diff --git a/scripts/check-paths/types.mts b/scripts/check-paths/types.mts deleted file mode 100644 index cf1a68661..000000000 --- a/scripts/check-paths/types.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @file Shared types for the path-hygiene gate. `Finding` is the canonical - * finding shape every scanner produces; `AllowlistEntry` mirrors the YAML row - * shape in `.github/paths-allowlist.yml`. Pure types — no runtime; importing - * this file has zero side effects. - */ - -export type Finding = { - rule: 'A' | 'B' | 'C' | 'D' | 'F' | 'G' - file: string - line: number - snippet: string - message: string - fix: string -} - -export type AllowlistEntry = { - file?: string | undefined - pattern?: string | undefined - rule?: string | undefined - line?: number | undefined - snippet_hash?: string | undefined - reason: string -} diff --git a/scripts/check-paths/walk.mts b/scripts/check-paths/walk.mts deleted file mode 100644 index 721c72d7e..000000000 --- a/scripts/check-paths/walk.mts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file Repo file-walker for the path-hygiene gate. Recursively yields files - * under `dir` whose repo-relative path passes `filter`. The skip set covers - * everything we never want to scan: `node_modules`, generated outputs - * (`build`/`dist`/`out`/`target`), VCS metadata, caches, and `upstream/` - * vendor trees. The generator shape lets callers stop scanning early without - * buffering the whole tree. - */ - -import { readdirSync } from 'node:fs' -import path from 'node:path' - -export const SKIP_DIRS = new Set([ - '.cache', - '.git', - 'build', - 'dist', - 'node_modules', - 'out', - 'target', - 'upstream', -]) - -export const walk = function* ( - repoRoot: string, - dir: string, - filter: (relPath: string) => boolean, -): Generator<string> { - let entries - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - return - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const e = entries[i]! - if (SKIP_DIRS.has(e.name)) { - continue - } - const full = path.join(dir, e.name) - const rel = path.relative(repoRoot, full) - if (e.isDirectory()) { - yield* walk(repoRoot, full, filter) - } else if (e.isFile() && filter(rel)) { - yield rel - } - } -} diff --git a/scripts/check-prompt-less-setup.mts b/scripts/check-prompt-less-setup.mts deleted file mode 100644 index adf1a6cf9..000000000 --- a/scripts/check-prompt-less-setup.mts +++ /dev/null @@ -1,427 +0,0 @@ -#!/usr/bin/env node -/** - * @file Audit the dev machine for prompt-less secret / signing setup. Each - * check has a `fix` suggestion the operator can copy-paste. Exit code 0 = all - * good. Exit code 1 = at least one check failed. Use `--fix` to attempt - * automatic remediation (writes ~/.gnupg/ gpg-agent.conf + ~/.zshenv). - * Read-only by default. Checks (macOS, Linux, Windows where applicable): - * - * 1. gpg-agent cache TTL ≥ 8 hours (otherwise pinentry re-prompts every ~10 - * minutes, which is the default). - * 2. GPG_TTY exported in the user's shell rc so pinentry can find the - * controlling terminal in non-interactive shells. - * 3. commit.gpgsign config consistency — if signing is enabled, the signing key - * must exist and gpg-agent must cache it. - * 4. macOS: pinentry-program points at pinentry-mac (offers "Save in Keychain" - * so subsequent signs don't even hit gpg). - * 5. SOCKET_API_KEY present in env OR wired via shell-rc-bridge block (so hooks - * read env instead of hitting the keychain). - * 6. macOS: keychain has the Socket token entry with ACL set to "any app" (-T - * '') so subsequent reads don't trigger the "this app wants to access your - * keychain" dialog. Invocation: node - * template/scripts/check-prompt-less-setup.mts node - * template/scripts/check-prompt-less-setup.mts --fix Wired into `pnpm run - * doctor:auth` in template/package.json — that's the canonical entry - * point. Run it after `pnpm run setup` and whenever a fresh - * signing/keychain prompt surprises you. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, readFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import process from 'node:process' - -const logger = console - -interface CheckResult { - readonly name: string - readonly ok: boolean - readonly detail: string - readonly fix?: string | undefined -} - -const CACHE_TTL_THRESHOLD_SECONDS = 28800 - -function isMac(): boolean { - return os.platform() === 'darwin' -} - -function readGpgAgentConf(): string | undefined { - const confPath = path.join(os.homedir(), '.gnupg', 'gpg-agent.conf') - if (!existsSync(confPath)) { - return undefined - } - try { - return readFileSync(confPath, 'utf8') - } catch { - return undefined - } -} - -function parseTtl(content: string, directive: string): number | undefined { - // gpg-agent.conf supports comments via `#`; directives are - // `directive value` on a line. Take the LAST occurrence (gpg-agent - // semantics: later wins on duplicates). - const lines = content.split('\n') - let match: number | undefined - for (let i = 0, { length } = lines; i < length; i += 1) { - const ln = lines[i]!.trim() - if (ln.startsWith('#') || !ln) { - continue - } - const re = new RegExp(`^${directive}\\s+(\\d+)\\s*(?:#.*)?$`) - const m = re.exec(ln) - if (m && m[1]) { - match = Number(m[1]) - } - } - return match -} - -function checkGpgAgentCacheTtl(): CheckResult { - const content = readGpgAgentConf() - if (!content) { - return { - name: 'gpg-agent cache TTL', - ok: false, - detail: - '~/.gnupg/gpg-agent.conf missing — defaults are 600s (10 min) which forces a fresh pinentry every ~10 minutes of work.', - fix: - 'mkdir -p ~/.gnupg && cat >> ~/.gnupg/gpg-agent.conf <<EOF\n' + - 'default-cache-ttl 28800\n' + - 'max-cache-ttl 28800\n' + - 'default-cache-ttl-ssh 28800\n' + - 'max-cache-ttl-ssh 28800\n' + - 'EOF\n' + - 'gpg-connect-agent reloadagent /bye', - } - } - const defaultTtl = parseTtl(content, 'default-cache-ttl') - const maxTtl = parseTtl(content, 'max-cache-ttl') - if (defaultTtl === undefined || maxTtl === undefined) { - return { - name: 'gpg-agent cache TTL', - ok: false, - detail: `gpg-agent.conf exists but is missing ${[ - defaultTtl === undefined ? 'default-cache-ttl' : '', - maxTtl === undefined ? 'max-cache-ttl' : '', - ] - .filter(Boolean) - .join(' + ')}; gpg-agent falls back to 600s defaults.`, - fix: - 'Add the missing directives to ~/.gnupg/gpg-agent.conf:\n' + - 'default-cache-ttl 28800\nmax-cache-ttl 28800\n' + - 'Then: gpg-connect-agent reloadagent /bye', - } - } - if ( - defaultTtl < CACHE_TTL_THRESHOLD_SECONDS || - maxTtl < CACHE_TTL_THRESHOLD_SECONDS - ) { - return { - name: 'gpg-agent cache TTL', - ok: false, - detail: `default-cache-ttl=${defaultTtl}s, max-cache-ttl=${maxTtl}s. Threshold is ${CACHE_TTL_THRESHOLD_SECONDS}s (8h). Lower TTLs make pinentry re-prompt mid-session.`, - fix: `Edit ~/.gnupg/gpg-agent.conf to set both default-cache-ttl and max-cache-ttl to ${CACHE_TTL_THRESHOLD_SECONDS} (8h). Then: gpg-connect-agent reloadagent /bye`, - } - } - return { - name: 'gpg-agent cache TTL', - ok: true, - detail: `default=${defaultTtl}s, max=${maxTtl}s (both ≥ ${CACHE_TTL_THRESHOLD_SECONDS}s threshold).`, - } -} - -function checkGpgTtyExported(): CheckResult { - // Two places to look: ~/.zshenv (preferred — runs for every zsh) and - // ~/.bashrc / ~/.bash_profile (bash). The check just needs to see - // `GPG_TTY` exported somewhere reachable. - const candidates = [ - path.join(os.homedir(), '.zshenv'), - path.join(os.homedir(), '.zshrc'), - path.join(os.homedir(), '.bashrc'), - path.join(os.homedir(), '.bash_profile'), - path.join(os.homedir(), '.profile'), - ] - for (let i = 0, { length } = candidates; i < length; i += 1) { - const f = candidates[i]! - if (!existsSync(f)) { - continue - } - try { - const content = readFileSync(f, 'utf8') - if (/^\s*export\s+GPG_TTY\s*=/m.test(content)) { - return { - name: 'GPG_TTY exported in shell rc', - ok: true, - detail: `found 'export GPG_TTY=...' in ${path.relative(os.homedir(), f).replace(/^/, '~/')}.`, - } - } - } catch { - // Skip unreadable files. - } - } - return { - name: 'GPG_TTY exported in shell rc', - ok: false, - detail: - 'No `export GPG_TTY=$(tty)` found in ~/.zshenv / ~/.zshrc / ~/.bashrc / ~/.bash_profile / ~/.profile. pinentry needs GPG_TTY to find the controlling terminal in non-interactive shells (Claude Code, IDE integrations).', - fix: "echo 'export GPG_TTY=$(tty)' >> ~/.zshenv (or ~/.bashrc for bash)", - } -} - -function checkPinentryProgram(): CheckResult { - if (!isMac()) { - return { - name: 'pinentry-program', - ok: true, - detail: 'skipped (non-macOS).', - } - } - const content = readGpgAgentConf() ?? '' - const m = /^\s*pinentry-program\s+(\S+)/m.exec(content) - if (!m) { - return { - name: 'pinentry-program', - ok: false, - detail: - 'No `pinentry-program` set in ~/.gnupg/gpg-agent.conf. pinentry-mac integrates with macOS Keychain ("Save in Keychain" checkbox); without it, gpg may use a less-friendly fallback.', - fix: 'brew install pinentry-mac && echo "pinentry-program $(brew --prefix)/bin/pinentry-mac" >> ~/.gnupg/gpg-agent.conf && gpg-connect-agent reloadagent /bye', - } - } - const program = m[1]! - if (!program.includes('pinentry-mac')) { - return { - name: 'pinentry-program', - ok: false, - detail: `pinentry-program is ${program} — not pinentry-mac. pinentry-mac is the recommended choice on macOS (Keychain integration).`, - fix: 'brew install pinentry-mac && sed -i "" "s|^pinentry-program .*|pinentry-program $(brew --prefix)/bin/pinentry-mac|" ~/.gnupg/gpg-agent.conf && gpg-connect-agent reloadagent /bye', - } - } - if (!existsSync(program)) { - return { - name: 'pinentry-program', - ok: false, - detail: `pinentry-program points at ${program} but that file doesn't exist.`, - fix: 'brew install pinentry-mac # restores the binary at the expected path', - } - } - return { - name: 'pinentry-program', - ok: true, - detail: `${program} (pinentry-mac, Keychain-integrated).`, - } -} - -function checkCommitGpgsign(): CheckResult { - const r = spawnSync( - 'git', - ['config', '--global', '--get', 'commit.gpgsign'], - { - stdio: ['ignore', 'pipe', 'pipe'], - }, - ) - const value = typeof r.stdout === 'string' ? r.stdout.trim() : '' - if (r.status !== 0 || !value) { - return { - name: 'commit.gpgsign', - ok: true, - detail: 'unset (no signing → no prompts; nothing to optimize).', - } - } - if (value !== 'true') { - return { - name: 'commit.gpgsign', - ok: true, - detail: `${value} (signing disabled; nothing to optimize).`, - } - } - // Signing IS on globally. Check the key exists. - const keyR = spawnSync( - 'git', - ['config', '--global', '--get', 'user.signingkey'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - const key = typeof keyR.stdout === 'string' ? keyR.stdout.trim() : '' - if (!key) { - return { - name: 'commit.gpgsign', - ok: false, - detail: - 'commit.gpgsign=true but user.signingkey is unset. Commits will fail or prompt for key selection on every sign.', - fix: - 'gpg --list-secret-keys --keyid-format LONG # find your key id\n' + - 'git config --global user.signingkey <KEYID>', - } - } - // Confirm gpg can find the key without prompting. - const checkR = spawnSync('gpg', ['--list-secret-keys', key], { - stdio: ['ignore', 'pipe', 'pipe'], - }) - if (checkR.status !== 0) { - return { - name: 'commit.gpgsign', - ok: false, - detail: `signing key ${key} is configured but gpg can't find it. Every sign will fail.`, - fix: - `gpg --list-secret-keys --keyid-format LONG # confirm or pick another key\n` + - `git config --global user.signingkey <KEYID>`, - } - } - return { - name: 'commit.gpgsign', - ok: true, - detail: `enabled, key ${key} found.`, - } -} - -function checkSocketTokenInEnv(): CheckResult { - // This audit reports whether the raw env slots are wired up; the - // keychain-fallback getter would defeat the check. - const env = - // socket-api-token-getter: allow direct-env - // oxlint-disable-next-line socket/socket-api-token-env -- audit script: must check the primary slot because that's literally what's being audited (whether the install hook's primary export is wired up). - process.env['SOCKET_API_KEY'] || process.env['SOCKET_API_TOKEN'] - if (env) { - // socket-api-token-getter: allow direct-env -- audit reports which raw env name is set. - const source = process.env['SOCKET_API_TOKEN'] - ? // oxlint-disable-next-line socket/socket-api-token-env -- audit script: reports which name was found, including the primary slot. - 'SOCKET_API_KEY' - : 'SOCKET_API_TOKEN' - return { - name: 'Socket API token in env', - ok: true, - detail: `${source} set (length ${env.length}). Hooks read env first; no keychain prompts.`, - } - } - // Token not in env — check if the shell-rc-bridge block is wired up. - const rcFiles = [ - path.join(os.homedir(), '.zshenv'), - path.join(os.homedir(), '.zshrc'), - path.join(os.homedir(), '.bashrc'), - path.join(os.homedir(), '.bash_profile'), - ] - for (let i = 0, { length } = rcFiles; i < length; i += 1) { - const f = rcFiles[i]! - if (!existsSync(f)) { - continue - } - try { - const content = readFileSync(f, 'utf8') - if (content.includes('# BEGIN socket-cli env')) { - return { - name: 'Socket API token in env', - ok: true, - detail: `not set in current shell, but shell-rc-bridge block exists in ${path.relative(os.homedir(), f).replace(/^/, '~/')} — fresh shells will export it.`, - } - } - } catch { - // Skip unreadable files. - } - } - return { - name: 'Socket API token in env', - ok: false, - detail: - 'SOCKET_API_KEY is not in the current env AND no shell-rc-bridge block is wired up. Hooks fall through to the keychain, which prompts on first access.', - fix: - 'node .claude/hooks/setup-security-tools/install.mts\n' + - ' # installs the shell-rc-bridge block; exports the token in every fresh shell', - } -} - -function checkKeychainTokenAcl(): CheckResult { - if (!isMac()) { - return { - name: 'macOS Keychain token ACL', - ok: true, - detail: 'skipped (non-macOS).', - } - } - // `security find-generic-password -s socket-cli -a SOCKET_API_KEY -g` - // would print the entry. We don't want to trigger a Keychain unlock - // dialog by reading the password — instead, just check whether the - // entry exists via the non-password-fetching form. - const r = spawnSync( - 'security', - ['find-generic-password', '-s', 'socket-cli', '-a', 'SOCKET_API_TOKEN'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) - if (r.status !== 0) { - return { - name: 'macOS Keychain token ACL', - ok: false, - detail: - 'No socket-cli/SOCKET_API_KEY entry in the Keychain. Tools that fall back to keychain (when env is empty) will prompt for input on first use.', - fix: - 'node .claude/hooks/setup-security-tools/install.mts\n' + - ' # prompts for the token interactively and persists it to the Keychain with -T "" (any app can read).', - } - } - // Entry exists. We can't programmatically inspect the ACL without - // triggering an unlock prompt; trust that setup-security-tools wrote - // it with `-T ''`. Report as OK with a note. - return { - name: 'macOS Keychain token ACL', - ok: true, - detail: - 'socket-cli/SOCKET_API_KEY entry present. Assumes ACL=any app (-T "") from setup-security-tools — if you still get Keychain prompts, open Keychain Access → search "socket-cli" → click "Always Allow" once for /usr/bin/security.', - } -} - -interface CheckSummary { - total: number - ok: number - failed: number - results: CheckResult[] -} - -function runAllChecks(): CheckSummary { - const results: CheckResult[] = [ - checkGpgAgentCacheTtl(), - checkGpgTtyExported(), - checkPinentryProgram(), - checkCommitGpgsign(), - checkSocketTokenInEnv(), - checkKeychainTokenAcl(), - ] - const ok = results.filter(r => r.ok).length - return { - total: results.length, - ok, - failed: results.length - ok, - results, - } -} - -function printReport(summary: CheckSummary): void { - logger.error('') - logger.error( - `=== prompt-less auth setup audit (${summary.ok}/${summary.total} ok) ===`, - ) - for (let i = 0, { length } = summary.results; i < length; i += 1) { - const r = summary.results[i]! - const status = r.ok ? '[ok] ' : '[FAIL]' - logger.error('') - logger.error(`${status} ${r.name}`) - logger.error(` ${r.detail}`) - if (!r.ok && r.fix) { - logger.error('') - logger.error(' fix:') - const fixLines = r.fix.split('\n') - for (let j = 0, l = fixLines.length; j < l; j += 1) { - logger.error(` ${fixLines[j]!}`) - } - } - } - logger.error('') -} - -function main(): void { - const summary = runAllChecks() - printReport(summary) - process.exit(summary.failed > 0 ? 1 : 0) -} - -main() diff --git a/scripts/check-soak-exclude-dates.mts b/scripts/check-soak-exclude-dates.mts deleted file mode 100644 index 2738fc3bb..000000000 --- a/scripts/check-soak-exclude-dates.mts +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -/** - * @file Whole-file commit-time gate that mirrors the edit-time - * `.claude/hooks/soak-exclude-date-annotation-guard/`. Scans the repo's - * `pnpm-workspace.yaml` `minimumReleaseAgeExclude:` block and reports any - * per-package exact-pin entry missing the canonical `# published: YYYY-MM-DD - * | removable: YYYY-MM-DD` annotation. Why the second surface (hook + - * script): defense in depth. The hook blocks Edit/Write in-session; this - * script catches anything that lands via a non-Claude path (manual `git - * checkout`, external editor, etc.). Reports stale entries too — any line - * whose `removable:` date is in the past is a cleanup candidate. Reporting is - * informational only (exit 0 on stale entries; exit 1 only on missing - * annotation). Exit codes: - * - * - 0 — clean (no missing annotations; stale entries logged as warnings) - * - 1 — at least one missing annotation - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -const SECTION_HEADER = /^minimumReleaseAgeExclude:\s*$/ -const ANY_TOP_LEVEL_KEY = /^[A-Za-z_][\w-]*:\s*(\S.*)?$/ -const ENTRY_RE = - /^\s*-\s*['"]?((?:@[^@/'"\s]+\/)?[^@'"\s]+)@([^'"\s]+)['"]?\s*$/ -const GLOB_ENTRY_RE = /^\s*-\s*['"]?[^'"\s]*\*[^'"\s]*['"]?\s*$/ -const BARE_NAME_ENTRY_RE = /^\s*-\s*['"]?[^@'"\s]+['"]?\s*$/ -const ANNOTATION_RE = - /^\s*#\s+published:\s+(\d{4}-\d{2}-\d{2})\s+\|\s+removable:\s+(\d{4}-\d{2}-\d{2})\s*$/ -const ALLOW_MARKER = '# socket-hook: allow soak-exclude-no-date-annotation' - -interface Finding { - kind: 'missing' | 'stale' - line: number - name: string - version: string - removable?: string | undefined -} - -function scan(text: string, todayISO: string): Finding[] { - const lines = text.split('\n') - const findings: Finding[] = [] - let inBlock = false - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] ?? '' - if (SECTION_HEADER.test(line)) { - inBlock = true - continue - } - if (!inBlock) { - continue - } - if (ANY_TOP_LEVEL_KEY.test(line) && !line.startsWith(' ')) { - inBlock = false - continue - } - const m = ENTRY_RE.exec(line) - if (!m) { - continue - } - if (line.includes(ALLOW_MARKER)) { - continue - } - if (GLOB_ENTRY_RE.test(line) || BARE_NAME_ENTRY_RE.test(line)) { - continue - } - const name = m[1] ?? '<unknown>' - const version = m[2] ?? '<unknown>' - const prev = i > 0 ? (lines[i - 1] ?? '') : '' - const annotationMatch = ANNOTATION_RE.exec(prev) - if (!annotationMatch) { - findings.push({ kind: 'missing', line: i + 1, name, version }) - continue - } - const removable = annotationMatch[2]! - if (removable < todayISO) { - findings.push({ - kind: 'stale', - line: i + 1, - name, - version, - removable, - }) - } - } - return findings -} - -function main(): void { - // Anchor on this script's location and walk up to the repo root - // (the dir containing pnpm-workspace.yaml). process.cwd() is unstable - // because the script may be invoked from any working directory. - const here = path.dirname(fileURLToPath(import.meta.url)) - const repoRoot = path.resolve(here, '..') - const yamlPath = path.join(repoRoot, 'pnpm-workspace.yaml') - let content: string - try { - content = readFileSync(yamlPath, 'utf8') - } catch { - // No pnpm-workspace.yaml — not a workspace repo, nothing to check. - process.exit(0) - } - const todayISO = new Date().toISOString().slice(0, 10) - const findings = scan(content, todayISO) - const missing = findings.filter(f => f.kind === 'missing') - const stale = findings.filter(f => f.kind === 'stale') - - if (stale.length > 0) { - process.stderr.write( - `[check-soak-exclude-dates] ${stale.length} stale soak-bypass ` + - `entr${stale.length === 1 ? 'y' : 'ies'} ` + - `(removable: date in the past) — candidates for cleanup:\n`, - ) - for (let i = 0, { length } = stale; i < length; i += 1) { - const f = stale[i]! - process.stderr.write( - ` line ${f.line}: ${f.name}@${f.version} (removable ${f.removable})\n`, - ) - } - process.stderr.write( - `\nRun \`pnpm install\` after removing — the soak has cleared naturally.\n\n`, - ) - } - - if (missing.length > 0) { - process.stderr.write( - `[check-soak-exclude-dates] ${missing.length} missing soak-bypass ` + - `annotation${missing.length === 1 ? '' : 's'}:\n`, - ) - for (let i = 0, { length } = missing; i < length; i += 1) { - const f = missing[i]! - process.stderr.write(` line ${f.line}: ${f.name}@${f.version}\n`) - } - process.stderr.write( - `\nEach per-package soak-bypass needs the canonical annotation directly above the bullet:\n` + - ` # published: <YYYY-MM-DD> | removable: <YYYY-MM-DD>\n` + - ` - 'pkg@1.2.3'\n` + - `\nReference: docs/claude.md/fleet/tooling.md "Soak time".\n`, - ) - process.exit(1) - } - - process.exit(0) -} - -main() diff --git a/scripts/check.mts b/scripts/check.mts deleted file mode 100644 index d431de2ca..000000000 --- a/scripts/check.mts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file Unified check runner — delegates to lint + type + path-hygiene. - * Forwards CLI scope flags to the lint script so `pnpm run check --all` - * actually runs a full-scope lint (not the default modified-only scope). - * `pnpm type` doesn't accept our scope flags, so it's always a full check. - * Usage: pnpm run check # lint in modified scope + full type check + - * path-hygiene pnpm run check --staged # lint staged + full type + paths pnpm - * run check --all # full lint + full type + paths (CI) Byte-identical across - * every fleet repo. Sync-scaffolding flags drift. - */ - -// prefer-async-spawn: sync-required — top-level CLI runner; entire -// flow is sequential gate-running with exit-code aggregation. -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import process from 'node:process' - -const args = process.argv.slice(2) -const forwardedArgs = args.filter( - a => a === '--all' || a === '--fix' || a === '--quiet' || a === '--staged', -) - -// spawnSync with array args — no shell interpolation, matches the -// socket/prefer-spawn-over-execsync rule. -function run(cmd: string, cmdArgs: string[]): boolean { - const r = spawnSync(cmd, cmdArgs, { stdio: 'inherit' }) - return r.status === 0 -} - -const steps: Array<() => boolean> = [ - // Lint scope is forwarded; everything else is full-scope. - () => run('node', ['scripts/lint.mts', ...forwardedArgs]), - () => run('pnpm', ['exec', 'tsgo', '--noEmit', '-p', 'tsconfig.check.json']), - // Path-hygiene check (1 path, 1 reference). Mantra-driven gate; - // see .claude/skills/path-guard/ + .claude/hooks/path-guard/. - () => run('node', ['scripts/check-paths.mts', '--quiet']), - // Lock-step reference hygiene. Opt-in gate that exits clean when - // .config/lock-step-refs.json is absent; for repos that ship - // cross-language ports (acorn quadruplet, socket-btm mcp/*.cpp), - // it validates every `Lock-step with <Lang>: <path>` comment resolves - // to an existing file. Forms documented in - // docs/claude.md/fleet/parser-comments.md §5–6. - () => run('node', ['scripts/check-lock-step-refs.mts', '--quiet']), - // Lock-step header byte-equality. Same opt-in. Where the path-refs - // gate above catches stale REFERENCES, this one catches drift in the - // top-of-file `BEGIN LOCK-STEP HEADER` / `END LOCK-STEP HEADER` block - // — the intent tripwire across the quadruplet. Spec: - // docs/claude.md/fleet/parser-comments.md §7. - () => run('node', ['scripts/check-lock-step-header.mts', '--quiet']), - // Soak-exclude date-annotation gate — pairs with - // .claude/hooks/soak-exclude-date-annotation-guard/. Catches - // pnpm-workspace.yaml `minimumReleaseAgeExclude` entries that landed - // via non-Claude paths without the canonical - // `# published: YYYY-MM-DD | removable: YYYY-MM-DD` annotation. - () => run('node', ['scripts/check-soak-exclude-dates.mts']), -] - -for (let i = 0, { length } = steps; i < length; i += 1) { - if (!steps[i]!()) { - process.exitCode = 1 - break - } -} diff --git a/scripts/clean-cache.mts b/scripts/clean-cache.mts deleted file mode 100644 index 5155dc8ea..000000000 --- a/scripts/clean-cache.mts +++ /dev/null @@ -1,252 +0,0 @@ - -/** - * Clean stale caches across all packages. - * - * Usage: pnpm run clean:cache # Clean all stale caches pnpm run clean:cache - * --all # Clean ALL caches (nuclear option) pnpm run clean:cache --dry-run # - * Show what would be deleted. - */ - -import { readdirSync, statSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { parseArgs } from 'node:util' - -import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { getGlobalCacheDirs } from '../packages/cli/scripts/constants/paths.mts' - -const logger = getDefaultLogger() - -const __dirname = fileURLToPath(new URL('.', import.meta.url)) -const ROOT_DIR = join(__dirname, '..') - -const { values } = parseArgs({ - options: { - all: { type: 'boolean' }, - 'dry-run': { type: 'boolean' }, - }, - strict: false, -}) - -const dryRun = values['dry-run'] -const cleanAll = values.all - -interface CacheDirInfo { - package: string - path: string -} - -interface CacheEntry { - name: string - path: string - size: number - mtime: Date - ageD: number -} - -/** - * Analyze cache directory and determine what to clean. - */ -function analyzeCacheDir(cacheDir: string): CacheEntry[] { - const entries: CacheEntry[] = [] - - try { - const items = readdirSync(cacheDir) - for (let i = 0, { length } = items; i < length; i += 1) { - const item = items[i] - const itemPath = join(cacheDir, item) - const stats = statSync(itemPath) - - if (stats.isDirectory()) { - entries.push({ - name: item, - path: itemPath, - size: getDirSize(itemPath), - mtime: stats.mtime, - ageD: Math.floor( - (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60 * 24), - ), - }) - } - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Error analyzing ${cacheDir}: ${message}`) - } - - return entries.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) -} - -/** - * Find all .cache directories in packages. - */ -function findCacheDirs(): CacheDirInfo[] { - const cacheDirs: CacheDirInfo[] = [] - const packagesDir = join(ROOT_DIR, 'packages') - - try { - const packages = readdirSync(packagesDir) - for (let i = 0, { length } = packages; i < length; i += 1) { - const pkg = packages[i] - const cacheDir = join(packagesDir, pkg, '.cache') - try { - statSync(cacheDir) - cacheDirs.push({ package: pkg, path: cacheDir }) - } catch { - // No cache dir, skip. - } - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Error scanning packages: ${message}`) - } - - return cacheDirs -} - -/** - * Format bytes to human readable. - */ -function formatSize(bytes: number): string { - if (bytes < 1024) { - return `${bytes} B` - } - if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(1)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -/** - * Get directory size recursively. - */ -function getDirSize(dir: string): number { - let size = 0 - try { - const items = readdirSync(dir) - for (let i = 0, { length } = items; i < length; i += 1) { - const item = items[i] - const itemPath = join(dir, item) - const stats = statSync(itemPath) - if (stats.isDirectory()) { - size += getDirSize(itemPath) - } else { - size += stats.size - } - } - } catch { - // Ignore errors. - } - return size -} - -async function main(): Promise<void> { - const cacheDirs = findCacheDirs() - - if (!cacheDirs.length) { - logger.log('No cache directories found.') - return - } - - logger.log( - `Found ${cacheDirs.length} cache director${cacheDirs.length === 1 ? 'y' : 'ies'}:`, - ) - logger.log('') - - let totalDeleted = 0 - let totalSize = 0 - - for (const { package: pkg, path: cacheDir } of cacheDirs) { - const entries = analyzeCacheDir(cacheDir) - - if (!entries.length) { - logger.log(`📦 ${pkg}: Empty cache`) - continue - } - - logger.log(`📦 ${pkg}:`) - - if (cleanAll) { - // Delete everything. - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i] - logger.log( - ` ${dryRun ? '[DRY RUN]' : '✗'} ${entry.name} (${formatSize(entry.size)}, ${entry.ageD}d old)`, - ) - if (!dryRun) { - await safeDelete(entry.path) - } - totalDeleted++ - totalSize += entry.size - } - } else { - // Keep most recent, delete older ones. - const [latest, ...older] = entries - - logger.log( - ` ✓ ${latest.name} (${formatSize(latest.size)}, ${latest.ageD}d old) - KEEP`, - ) - - for (let i = 0, { length } = older; i < length; i += 1) { - const entry = older[i] - logger.log( - ` ${dryRun ? '[DRY RUN]' : '✗'} ${entry.name} (${formatSize(entry.size)}, ${entry.ageD}d old)`, - ) - if (!dryRun) { - await safeDelete(entry.path) - } - totalDeleted++ - totalSize += entry.size - } - } - - logger.log('') - } - - if (totalDeleted > 0) { - logger.log( - `${dryRun ? 'Would delete' : 'Deleted'} ${totalDeleted} cache entr${totalDeleted === 1 ? 'y' : 'ies'} (${formatSize(totalSize)})`, - ) - } else { - logger.success('All caches are current - nothing to delete') - } - - // Clean global caches if --all flag is used. - if (cleanAll) { - logger.log('') - logger.log('🌍 Cleaning global caches:') - - const globalCaches = getGlobalCacheDirs() - - for (const { name, path } of globalCaches) { - try { - const stats = statSync(path) - const size = stats.isDirectory() ? getDirSize(path) : stats.size - logger.log( - ` ${dryRun ? '[DRY RUN]' : '✗'} ${name} (${formatSize(size)})`, - ) - if (!dryRun) { - await safeDelete(path) - } - } catch { - // Cache doesn't exist, skip. - } - } - } - - if (dryRun) { - logger.log('') - logger.log('Run without --dry-run to actually delete.') - } -} - -main().catch((e: unknown) => { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Error: ${message}`) - process.exitCode = 1 -}) diff --git a/scripts/constants.js b/scripts/constants.js new file mode 100644 index 000000000..576f61e0a --- /dev/null +++ b/scripts/constants.js @@ -0,0 +1,179 @@ +'use strict' + +const path = require('node:path') + +const registryConstants = require('@socketsecurity/registry/lib/constants') + +const { + kInternalsSymbol, + [kInternalsSymbol]: { + attributes: registryConstantsAttribs, + createConstantsObject, + }, +} = registryConstants + +const CONSTANTS = 'constants' +const INLINED_CYCLONEDX_CDXGEN_VERSION = 'INLINED_CYCLONEDX_CDXGEN_VERSION' +const INLINED_SOCKET_CLI_HOMEPAGE = 'INLINED_SOCKET_CLI_HOMEPAGE' +const INLINED_SOCKET_CLI_LEGACY_BUILD = 'INLINED_SOCKET_CLI_LEGACY_BUILD' +const INLINED_SOCKET_CLI_NAME = 'INLINED_SOCKET_CLI_NAME' +const INLINED_SOCKET_CLI_PUBLISHED_BUILD = 'INLINED_SOCKET_CLI_PUBLISHED_BUILD' +const INLINED_SOCKET_CLI_SENTRY_BUILD = 'INLINED_SOCKET_CLI_SENTRY_BUILD' +const INLINED_SOCKET_CLI_VERSION = 'INLINED_SOCKET_CLI_VERSION' +const INLINED_SOCKET_CLI_VERSION_HASH = 'INLINED_SOCKET_CLI_VERSION_HASH' +const INLINED_SYNP_VERSION = 'INLINED_SYNP_VERSION' +const INSTRUMENT_WITH_SENTRY = 'instrument-with-sentry' +const ROLLUP_EXTERNAL_SUFFIX = '?commonjs-external' +const SHADOW_NPM_BIN = 'shadow-npm-bin' +const SHADOW_NPM_INJECT = 'shadow-npm-inject' +const SLASH_NODE_MODULES_SLASH = '/node_modules/' +const SOCKET = 'socket' +const SOCKET_CLI_BIN_NAME = 'socket' +const SOCKET_CLI_BIN_NAME_ALIAS = 'cli' +const SOCKET_CLI_SENTRY_BIN_NAME_ALIAS = 'cli-with-sentry' +const SOCKET_CLI_LEGACY_PACKAGE_NAME = '@socketsecurity/cli' +const SOCKET_CLI_NPM_BIN_NAME = 'socket-npm' +const SOCKET_CLI_NPX_BIN_NAME = 'socket-npx' +const SOCKET_CLI_PACKAGE_NAME = 'socket' +const SOCKET_CLI_SENTRY_BIN_NAME = 'socket-with-sentry' +const SOCKET_CLI_SENTRY_NPM_BIN_NAME = 'socket-npm-with-sentry' +const SOCKET_CLI_SENTRY_NPX_BIN_NAME = 'socket-npx-with-sentry' +const SOCKET_CLI_SENTRY_PACKAGE_NAME = '@socketsecurity/cli-with-sentry' +const UTILS = 'utils' +const VENDOR = 'vendor' +const WITH_SENTRY = 'with-sentry' + +const LAZY_ENV = () => { + const { envAsBoolean } = require('@socketsecurity/registry/lib/env') + const { env } = process + return Object.freeze({ + // Lazily access registryConstants.ENV. + ...registryConstants.ENV, + // Flag set to determine if this is the Legacy build. + [INLINED_SOCKET_CLI_LEGACY_BUILD]: envAsBoolean( + env[INLINED_SOCKET_CLI_LEGACY_BUILD], + ), + // Flag set to determine if this is a published build. + [INLINED_SOCKET_CLI_PUBLISHED_BUILD]: envAsBoolean( + env[INLINED_SOCKET_CLI_PUBLISHED_BUILD], + ), + // Flag set to determine if this is the Sentry build. + [INLINED_SOCKET_CLI_SENTRY_BUILD]: envAsBoolean( + env[INLINED_SOCKET_CLI_SENTRY_BUILD], + ), + }) +} + +const lazyBlessedContribPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, 'blessed-contrib') + +const lazyBlessedPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, 'blessed') + +const lazyCoanaBinPath = () => + // Lazily access constants.coanaPath. + path.join(constants.coanaPath, 'cli.mjs') + +const lazyCoanaPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, '@coana-tech/cli') + +const lazyConfigPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, '.config') + +const lazyDistPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'dist') + +const lazyExternalPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'external') + +const lazyRootPackageJsonPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'package.json') + +const lazyRootPackageLockPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'package-lock.json') + +const lazyRootPath = () => path.resolve(__dirname, '..') + +const lazySocketRegistryPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, '@socketsecurity/registry') + +const lazySrcPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'src') + +const constants = createConstantsObject( + { + ...registryConstantsAttribs.props, + CONSTANTS, + ENV: undefined, + INLINED_CYCLONEDX_CDXGEN_VERSION, + INLINED_SOCKET_CLI_HOMEPAGE, + INLINED_SOCKET_CLI_LEGACY_BUILD, + INLINED_SOCKET_CLI_NAME, + INLINED_SOCKET_CLI_PUBLISHED_BUILD, + INLINED_SOCKET_CLI_SENTRY_BUILD, + INLINED_SOCKET_CLI_VERSION, + INLINED_SOCKET_CLI_VERSION_HASH, + INLINED_SYNP_VERSION, + INSTRUMENT_WITH_SENTRY, + ROLLUP_EXTERNAL_SUFFIX, + SHADOW_NPM_BIN, + SHADOW_NPM_INJECT, + SLASH_NODE_MODULES_SLASH, + SOCKET, + SOCKET_CLI_BIN_NAME, + SOCKET_CLI_BIN_NAME_ALIAS, + SOCKET_CLI_LEGACY_PACKAGE_NAME, + SOCKET_CLI_NPM_BIN_NAME, + SOCKET_CLI_NPX_BIN_NAME, + SOCKET_CLI_PACKAGE_NAME, + SOCKET_CLI_SENTRY_BIN_NAME, + SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, + SOCKET_CLI_SENTRY_NPM_BIN_NAME, + SOCKET_CLI_SENTRY_NPX_BIN_NAME, + SOCKET_CLI_SENTRY_PACKAGE_NAME, + UTILS, + VENDOR, + WITH_SENTRY, + blessedContribPath: undefined, + blessedOptions: undefined, + blessedPath: undefined, + coanaBinPath: undefined, + coanaPath: undefined, + configPath: undefined, + distPath: undefined, + externalPath: undefined, + rootPackageJsonPath: undefined, + rootPath: undefined, + socketRegistryPath: undefined, + srcPath: undefined, + }, + { + getters: { + ...registryConstantsAttribs.getters, + ENV: LAZY_ENV, + blessedContribPath: lazyBlessedContribPath, + blessedPath: lazyBlessedPath, + coanaBinPath: lazyCoanaBinPath, + coanaPath: lazyCoanaPath, + configPath: lazyConfigPath, + distPath: lazyDistPath, + externalPath: lazyExternalPath, + rootPackageJsonPath: lazyRootPackageJsonPath, + rootPackageLockPath: lazyRootPackageLockPath, + rootPath: lazyRootPath, + socketRegistryPath: lazySocketRegistryPath, + srcPath: lazySrcPath, + }, + }, +) +module.exports = constants diff --git a/scripts/fix.mts b/scripts/fix.mts deleted file mode 100644 index d48d22fe6..000000000 --- a/scripts/fix.mts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * @file Auto-fix script — runs linters with --fix, then security tools (zizmor, - * agentshield) if available, then an AI-assisted pass for the lint findings - * the deterministic fixer can't safely handle. Steps: - * - * 1. pnpm run lint --fix — oxlint + oxfmt (forwards extra argv like --all) - * 2. zizmor --fix .github/ — GitHub Actions workflow fixes (skipped if .github/ - * doesn't exist) - * 3. agentshield scan --fix — Claude config fixes (skipped if .claude/ or - * agentshield isn't installed) - * 4. AI-assisted lint fix — headless claude (Sonnet) with a restricted toolset - * for judgment-call rules. Skipped silently when the claude CLI isn't - * installed, when SKIP_AI_FIX=1, or when --no-ai is passed. See - * scripts/ai-lint-fix.mts. Forwards `process.argv.slice(2)` to the lint - * step, so `pnpm run fix --all` runs `pnpm run lint --fix --all` - * (full-tree fix), and `pnpm run fix --staged` does the staged-only flow. - */ - -import { existsSync } from 'node:fs' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const WIN32 = process.platform === 'win32' -const logger = getDefaultLogger() - -async function run( - cmd: string, - args: string[], - { - label, - required = true, - }: { label?: string | undefined; required?: boolean | undefined } = {}, -): Promise<number> { - try { - const result = await spawn(cmd, args, { - shell: WIN32, - stdio: 'inherit', - }) - if (result.code !== 0 && required) { - logger.error(`${label || cmd} failed (exit ${result.code})`) - return result.code - } - if (result.code !== 0) { - // Non-blocking: log warning and continue. - logger.warn(`${label || cmd}: exited ${result.code} (non-blocking)`) - } - return 0 - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - if (!required) { - logger.warn(`${label || cmd}: ${msg} (non-blocking)`) - return 0 - } - throw e - } -} - -async function main(): Promise<void> { - // Step 1: Lint fix — delegates to scripts/lint.mts which runs both - // oxfmt and oxlint. Forward extra argv so `--all` / `--staged` / - // explicit file paths reach the lint runner unchanged. - const lintExit = await run( - 'pnpm', - ['run', 'lint', '--fix', ...process.argv.slice(2)], - { label: 'lint --fix' }, - ) - if (lintExit) { - process.exitCode = lintExit - } - - // Step 2: zizmor — fixes GitHub Actions workflow security issues. - // Only runs if .github/ directory exists (some repos don't have workflows). - if (existsSync('.github')) { - await run('zizmor', ['--fix', '.github/'], { - label: 'zizmor --fix', - required: false, - }) - } - - // Step 3: AgentShield — fixes Claude config security findings. - // Only runs if .claude/ exists and agentshield binary is installed. - if (existsSync('.claude') && existsSync('node_modules/.bin/agentshield')) { - await run('pnpm', ['exec', 'agentshield', 'scan', '--fix'], { - label: 'agentshield --fix', - required: false, - }) - } - - // Step 4: AI-assisted lint fix. Most lint rules ship a - // deterministic autofix and Step 1 handled them. What remains is - // the judgment-call set — rule violations whose right rewrite - // depends on surrounding context that a regex / AST rewrite can't - // safely infer. This step shells out to a headless Claude (Sonnet, - // four-flag lockdown per CLAUDE.md "Programmatic Claude calls", - // restricted toolset) to handle just those rules. - // - // Skipped silently when the claude CLI isn't on PATH, when - // SKIP_AI_FIX=1, or when --no-ai is passed. CI sets SKIP_AI_FIX=1 - // because the fleet rule is "no AI in CI for code changes." - await run('node', ['scripts/ai-lint-fix.mts', ...process.argv.slice(2)], { - label: 'ai-lint-fix', - required: false, - }) -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e) - logger.error(msg) - process.exitCode = 1 -}) diff --git a/scripts/get-platform-matrix.mts b/scripts/get-platform-matrix.mts deleted file mode 100644 index 52fe454e4..000000000 --- a/scripts/get-platform-matrix.mts +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -/** - * Output platform matrix JSON for GitHub Actions. Used by publish workflow to - * generate dynamic matrix. - * - * Usage: node scripts/get-platform-matrix.mts. - * - * # Outputs: {"include":[...]} - */ - -import { PLATFORM_CONFIGS } from '../packages/build-infra/lib/platform-targets.mts' - -interface MatrixEntry { - arch: string - libc: string | null - platform: string - releasePlatform: string - runner: string -} - -const matrix: { include: MatrixEntry[] } = { - include: PLATFORM_CONFIGS.map( - (c): MatrixEntry => ({ - arch: c.arch, - libc: c.libc ?? undefined, - platform: c.platform, // Node.js platform (win32 for Windows) - releasePlatform: c.releasePlatform, // Release naming (win for Windows) - runner: c.runner, - }), - ), -} - -logger.log(JSON.stringify(matrix)) diff --git a/scripts/get-platform-targets.mts b/scripts/get-platform-targets.mts deleted file mode 100644 index 1577b4aca..000000000 --- a/scripts/get-platform-targets.mts +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node -/** - * Output platform targets for shell scripts. Used by publish workflow to - * iterate over platforms. - * - * Usage: node scripts/get-platform-targets.mts. - * - * # Outputs space-separated: linux-x64 linux-arm64 ... - */ - -import { PLATFORM_TARGETS } from '../packages/build-infra/lib/platform-targets.mts' - -logger.log(PLATFORM_TARGETS.join(' ')) diff --git a/scripts/git-partial-submodule.mts b/scripts/git-partial-submodule.mts deleted file mode 100644 index 5e69f3338..000000000 --- a/scripts/git-partial-submodule.mts +++ /dev/null @@ -1,649 +0,0 @@ -#!/usr/bin/env node -// max-file-lines: legitimate -- single-purpose CLI port; argparse + 4 subcommands; splitting fractures the flow - -/** - * @file Add / clone / save-sparse / restore-sparse partial submodules. Ported - * from Reedbeta/git-partial-submodule (Apache-2.0): - * https://github.com/Reedbeta/git-partial-submodule Lets the fleet declare a - * `sparse-checkout` field in `.gitmodules` and have partial clones - * (`--filter=blob:none --sparse`) honor it on init/clone. Vanilla `git - * submodule update` ignores the field; this script reads it. Usage: node - * scripts/git-partial-submodule.mts add [--branch B] [--name N] [--sparse] - * <url> <path> node scripts/git-partial-submodule.mts clone [path...] node - * scripts/git-partial-submodule.mts save-sparse [path...] node - * scripts/git-partial-submodule.mts restore-sparse [path...] Requires git >= - * 2.27 (--filter + --sparse on git clone). - */ - -import { existsSync, mkdirSync, promises as fs, readdirSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -type CommonOpts = { - dryRun: boolean - verbose: boolean -} - -type AddOpts = CommonOpts & { - branch: string | undefined - name: string | undefined - path: string - repository: string - sparse: boolean -} - -type CloneOpts = CommonOpts & { - paths: string[] -} - -type SaveOrRestoreOpts = CommonOpts & { - paths: string[] -} - -type Submodule = { - branch?: string | undefined - name: string - path?: string | undefined - 'sparse-checkout'?: string | undefined - url?: string | undefined -} - -type Gitmodules = { - byName: Map<string, Submodule> - byPath: Map<string, Submodule> -} - -const USAGE = `git-partial-submodule — add / clone / save-sparse / restore-sparse partial submodules - -Usage: - git-partial-submodule [-n|--dry-run] [-v|--verbose] <command> [args] - -Commands: - add [--branch B] [--name N] [--sparse] <url> <path> - Add a new partial submodule. - clone [path...] - Clone partial submodules from .gitmodules (all if no paths given). - save-sparse [path...] - Save sparse-checkout patterns to .gitmodules. - restore-sparse [path...] - Restore sparse-checkout patterns from .gitmodules. -` - -/** - * Run git, exit non-zero on failure unless code is in `okReturnCodes`. Returns - * the spawn result, or undefined on dry-run. - */ -async function runGit( - opts: CommonOpts, - gitArgs: string[], - options: { okReturnCodes?: number[] | undefined } = {}, -): Promise<{ code: number | null } | undefined> { - const okReturnCodes = options.okReturnCodes ?? [0] - if (opts.verbose || opts.dryRun) { - logger.log(`git ${gitArgs.join(' ')}`) - } - if (opts.dryRun) { - return undefined - } - const result = await spawn('git', gitArgs, { stdio: 'inherit' }) - const code = result.code ?? 0 - if (!okReturnCodes.includes(code)) { - logger.error(`Git command failed: git ${gitArgs.join(' ')}`) - process.exit(1) - } - return { code } -} - -/** - * Run git, capture stdout. Ignores verbose / dry-run (query-only). Returns - * trimmed stdout, or exits on non-OK return code. - */ -async function readGitOutput( - gitArgs: string[], - options: { okReturnCodes?: number[] | undefined } = {}, -): Promise<string> { - const okReturnCodes = options.okReturnCodes ?? [0] - const result = await spawn('git', gitArgs, { - stdio: ['inherit', 'pipe', 'inherit'], - }) - const code = result.code ?? 0 - if (!okReturnCodes.includes(code)) { - logger.error(`Git command failed: git ${gitArgs.join(' ')}`) - process.exit(1) - } - return String(result.stdout ?? '') -} - -async function checkGitVersion(min: [number, number, number]): Promise<void> { - const out = await readGitOutput(['--version']) - const match = out.match(/git version (\d+)\.(\d+)\.(\d+)/) - if (!match) { - logger.error(`Couldn't parse git version from: ${out.trim()}`) - process.exit(1) - } - const have: [number, number, number] = [ - Number.parseInt(match[1]!, 10), - Number.parseInt(match[2]!, 10), - Number.parseInt(match[3]!, 10), - ] - if ( - have[0] < min[0] || - (have[0] === min[0] && have[1] < min[1]) || - (have[0] === min[0] && have[1] === min[1] && have[2] < min[2]) - ) { - logger.error( - `Git version is too old. You need at least ${min.join('.')}, and you have ${have.join('.')}.`, - ) - process.exit(1) - } -} - -/** - * Parse the .gitmodules file at <worktreeRoot>/.gitmodules. - * - * Format reminder: [submodule "<name>"] path = <path> url = <url> branch = - * <branch> (optional) sparse-checkout = a b c (our extension; space-separated) - */ -async function readGitmodules( - opts: CommonOpts, - worktreeRoot: string, -): Promise<Gitmodules> { - const gitmodulesPath = path.join(worktreeRoot, '.gitmodules') - if (!existsSync(gitmodulesPath)) { - logger.error("Couldn't parse .gitmodules!") - process.exit(1) - } - const raw = await fs.readFile(gitmodulesPath, 'utf8') - const lines = raw.split(/\r?\n/) - const byName = new Map<string, Submodule>() - const byPath = new Map<string, Submodule>() - let current: Submodule | undefined - for (const rawLine of lines) { - // Strip inline comments (# or ;) — but not inside quoted strings; - // .gitmodules section headers are `[submodule "<name>"]` so we strip - // comments per-line after the section parse. - const line = rawLine.split(/[#;]/)[0]!.trim() - if (!line) { - continue - } - const sectionMatch = line.match(/^\[submodule "(.+)"\]$/) - if (sectionMatch) { - const name = sectionMatch[1]! - current = { name } - byName.set(name, current) - continue - } - if (!current) { - continue - } - const kvMatch = line.match(/^([\w-]+)\s*=\s*(.*)$/) - if (kvMatch) { - const key = kvMatch[1]! - const value = kvMatch[2]! - ;(current as Record<string, unknown>)[key] = value - if (key === 'path') { - byPath.set(value, current) - } - } - } - if (opts.verbose) { - logger.log(`parsed ${byName.size} submodules from .gitmodules`) - } - return { byName, byPath } -} - -/** - * Resolve a user-supplied subpath into a worktree-relative posix path. Git - * always uses forward slashes in submodule paths. - */ -function toWorktreeRelative(worktreeRoot: string, input: string): string { - const abs = path.resolve(input) - return path.relative(worktreeRoot, abs).replaceAll(path.sep, '/') -} - -async function getRoots(): Promise<{ repoRoot: string; worktreeRoot: string }> { - const worktreeRoot = path.resolve( - (await readGitOutput(['rev-parse', '--show-toplevel'])).trim(), - ) - const repoRoot = path.resolve( - (await readGitOutput(['rev-parse', '--git-dir'])).trim(), - ) - return { repoRoot, worktreeRoot } -} - -/** - * Apply sparse-checkout patterns within a submodule worktree. Patterns are - * split on whitespace (TODO: support quoted paths). - */ -async function applySparsePatterns( - opts: CommonOpts, - submoduleWorktreeRoot: string, - patterns: string, -): Promise<void> { - await runGit(opts, ['-C', submoduleWorktreeRoot, 'sparse-checkout', 'init']) - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'sparse-checkout', - 'set', - ...patterns.split(/\s+/).filter(Boolean), - ]) -} - -async function cmdAdd(opts: AddOpts): Promise<void> { - const { repoRoot, worktreeRoot } = await getRoots() - if (opts.verbose) { - logger.log(`worktree root: ${worktreeRoot}`) - logger.log(`repo root: ${repoRoot}`) - } - const submoduleRelPath = toWorktreeRelative(worktreeRoot, opts.path) - const submoduleName = opts.name ?? submoduleRelPath - const submoduleRepoRoot = path.join(repoRoot, 'modules', submoduleName) - if (existsSync(submoduleRepoRoot)) { - logger.error(`submodule ${submoduleName} repo already exists!`) - process.exit(1) - } - const submoduleWorktreeRoot = path.join(worktreeRoot, submoduleRelPath) - if ( - existsSync(submoduleWorktreeRoot) && - readdirSync(submoduleWorktreeRoot).length > 0 - ) { - logger.error(`${opts.path} submodule worktree is nonempty!`) - process.exit(1) - } - const indexCheck = ( - await readGitOutput([ - '-C', - worktreeRoot, - 'ls-files', - '--cached', - submoduleRelPath, - ]) - ).trim() - if (indexCheck) { - logger.error( - `${opts.path} submodule worktree is nonempty in the index!\n` + - `You might need to \`git rm\` that directory first.`, - ) - process.exit(1) - } - if (!opts.dryRun) { - mkdirSync(path.dirname(submoduleRepoRoot), { recursive: true }) - mkdirSync(submoduleWorktreeRoot, { recursive: true }) - } - await runGit(opts, [ - 'clone', - '--filter=blob:none', - '--no-checkout', - '--separate-git-dir', - submoduleRepoRoot, - ...(opts.branch ? ['--branch', opts.branch] : []), - ...(opts.sparse ? ['--sparse'] : []), - opts.repository, - submoduleWorktreeRoot, - ]) - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'checkout', - ...(opts.branch ? [opts.branch] : []), - ]) - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'config', - 'core.worktree', - submoduleWorktreeRoot.replaceAll(path.sep, '/'), - ]) - await runGit(opts, [ - '-C', - worktreeRoot, - 'submodule', - 'add', - ...(opts.branch ? ['-b', opts.branch] : []), - ...(opts.name ? ['--name', opts.name] : []), - opts.repository, - submoduleRelPath, - ]) -} - -async function cmdClone(opts: CloneOpts): Promise<void> { - const { repoRoot, worktreeRoot } = await getRoots() - if (opts.verbose) { - logger.log(`worktree root: ${worktreeRoot}`) - logger.log(`repo root: ${repoRoot}`) - } - const gitmodules = await readGitmodules(opts, worktreeRoot) - await runGit(opts, ['submodule', 'init', ...opts.paths]) - const relPaths: string[] = opts.paths.length - ? opts.paths.map(p => toWorktreeRelative(worktreeRoot, p)) - : [...gitmodules.byPath.keys()] - let skipped = 0 - let processed = 0 - for (let i = 0, { length } = relPaths; i < length; i += 1) { - const submoduleRelPath = relPaths[i]! - const submodule = gitmodules.byPath.get(submoduleRelPath) - if (!submodule) { - logger.error( - `Couldn't find ${submoduleRelPath} in .gitmodules! Skipping.`, - ) - skipped += 1 - continue - } - const submoduleRepoRoot = path.join(repoRoot, 'modules', submodule.name) - if ( - existsSync(submoduleRepoRoot) && - readdirSync(submoduleRepoRoot).length > 0 - ) { - if (opts.verbose) { - logger.log(`submodule ${submodule.name} repo already exists; skipping`) - } - skipped += 1 - continue - } - const submoduleWorktreeRoot = path.join(worktreeRoot, submoduleRelPath) - if ( - existsSync(submoduleWorktreeRoot) && - readdirSync(submoduleWorktreeRoot).length > 0 - ) { - logger.error( - `${submoduleRelPath} submodule worktree is nonempty! Skipping.`, - ) - skipped += 1 - continue - } - if (!opts.dryRun) { - mkdirSync(path.dirname(submoduleRepoRoot), { recursive: true }) - mkdirSync(submoduleWorktreeRoot, { recursive: true }) - } - const url = submodule.url - if (!url) { - logger.error(`Submodule ${submodule.name} missing url; skipping`) - skipped += 1 - continue - } - await runGit(opts, [ - 'clone', - '--filter=blob:none', - '--no-checkout', - '--separate-git-dir', - submoduleRepoRoot, - ...(submodule.branch ? ['--branch', submodule.branch] : []), - url, - submoduleWorktreeRoot, - ]) - const sparsePatterns = submodule['sparse-checkout'] - if (sparsePatterns) { - await applySparsePatterns(opts, submoduleWorktreeRoot, sparsePatterns) - logger.log(`Applied sparse-checkout patterns: ${sparsePatterns}`) - } - // Resolve the recorded gitlink sha to detach-checkout at. - const treeInfo = ( - await readGitOutput([ - '-C', - worktreeRoot, - 'ls-tree', - 'HEAD', - submoduleRelPath, - ]) - ) - .trim() - .split(/\s+/) - if (treeInfo.length !== 4) { - logger.error('git ls-tree produced unexpected output:') - logger.error(treeInfo.join(' ')) - process.exit(1) - } - const submoduleCommit = treeInfo[2]! - if (opts.verbose) { - logger.log(`${submodule.name} submodule sha1 is ${submoduleCommit}`) - } - let checkoutArgs: string[] = ['--detach', submoduleCommit] - if (submodule.branch && !opts.dryRun) { - const branchHeadCommit = ( - await readGitOutput([ - '-C', - submoduleWorktreeRoot, - 'rev-parse', - submodule.branch, - ]) - ).trim() - if (opts.verbose) { - logger.log( - `${submoduleRelPath} branch ${submodule.branch} is at sha1 ${branchHeadCommit}`, - ) - } - if (branchHeadCommit === submoduleCommit) { - checkoutArgs = [submodule.branch] - } - } - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'checkout', - ...checkoutArgs, - ]) - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'config', - 'core.worktree', - submoduleWorktreeRoot.replaceAll(path.sep, '/'), - ]) - processed += 1 - } - logger.log(`Cloned ${processed} submodules and skipped ${skipped}.`) -} - -async function cmdSaveSparse(opts: SaveOrRestoreOpts): Promise<void> { - const { worktreeRoot } = await getRoots() - const gitmodules = await readGitmodules(opts, worktreeRoot) - const relPaths: string[] = opts.paths.length - ? opts.paths.map(p => toWorktreeRelative(worktreeRoot, p)) - : [...gitmodules.byPath.keys()] - for (let i = 0, { length } = relPaths; i < length; i += 1) { - const submoduleRelPath = relPaths[i]! - const submodule = gitmodules.byPath.get(submoduleRelPath) - if (!submodule) { - logger.error( - `Couldn't find ${submoduleRelPath} in .gitmodules! Skipping.`, - ) - continue - } - const submoduleWorktreeRoot = path.join(worktreeRoot, submoduleRelPath) - if ( - !existsSync(submoduleWorktreeRoot) || - readdirSync(submoduleWorktreeRoot).length === 0 - ) { - logger.error(`${submoduleRelPath} submodule worktree is empty! Skipping.`) - continue - } - const sparseEnabled = ( - await readGitOutput( - ['-C', submoduleWorktreeRoot, 'config', 'core.sparseCheckout'], - { okReturnCodes: [0, 1] }, - ) - ).trim() - if (sparseEnabled === 'true') { - const sparsePatterns = ( - await readGitOutput([ - '-C', - submoduleWorktreeRoot, - 'sparse-checkout', - 'list', - ]) - ).trim() - await runGit(opts, [ - '-C', - worktreeRoot, - 'config', - '-f', - '.gitmodules', - `submodule.${submodule.name}.sparse-checkout`, - sparsePatterns.replaceAll('\n', ' '), - ]) - logger.log(`Saved sparse-checkout patterns for ${submodule.name}.`) - } else { - await runGit( - opts, - [ - '-C', - worktreeRoot, - 'config', - '-f', - '.gitmodules', - '--unset', - `submodule.${submodule.name}.sparse-checkout`, - ], - { okReturnCodes: [0, 5] }, - ) - logger.log(`Sparse checkout not enabled for ${submodule.name}.`) - } - } -} - -async function cmdRestoreSparse(opts: SaveOrRestoreOpts): Promise<void> { - const { worktreeRoot } = await getRoots() - const gitmodules = await readGitmodules(opts, worktreeRoot) - const relPaths: string[] = opts.paths.length - ? opts.paths.map(p => toWorktreeRelative(worktreeRoot, p)) - : [...gitmodules.byPath.keys()] - for (let i = 0, { length } = relPaths; i < length; i += 1) { - const submoduleRelPath = relPaths[i]! - const submodule = gitmodules.byPath.get(submoduleRelPath) - if (!submodule) { - logger.error( - `Couldn't find ${submoduleRelPath} in .gitmodules! Skipping.`, - ) - continue - } - const submoduleWorktreeRoot = path.join(worktreeRoot, submoduleRelPath) - if ( - !existsSync(submoduleWorktreeRoot) || - readdirSync(submoduleWorktreeRoot).length === 0 - ) { - logger.error(`${submoduleRelPath} submodule worktree is empty! Skipping.`) - continue - } - const sparsePatterns = submodule['sparse-checkout'] - if (sparsePatterns) { - await applySparsePatterns(opts, submoduleWorktreeRoot, sparsePatterns) - logger.log(`Applied sparse-checkout patterns for ${submodule.name}.`) - } else { - await runGit(opts, [ - '-C', - submoduleWorktreeRoot, - 'sparse-checkout', - 'disable', - ]) - logger.log(`Sparse checkout disabled for ${submodule.name}.`) - } - } -} - -function parseArgs(argv: string[]): { - command: 'add' | 'clone' | 'help' | 'restore-sparse' | 'save-sparse' - rest: string[] - opts: CommonOpts -} { - const opts: CommonOpts = { dryRun: false, verbose: false } - const remaining: string[] = [] - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]! - if (arg === '--dry-run' || arg === '-n') { - opts.dryRun = true - } else if (arg === '--verbose' || arg === '-v') { - opts.verbose = true - } else if (arg === '--help' || arg === '-h') { - return { command: 'help', opts, rest: [] } - } else { - remaining.push(arg) - } - } - if (remaining.length === 0) { - return { command: 'help', opts, rest: [] } - } - const command = remaining.shift()! - if ( - command !== 'add' && - command !== 'clone' && - command !== 'restore-sparse' && - command !== 'save-sparse' - ) { - logger.error(`Unknown command: ${command}`) - return { command: 'help', opts, rest: [] } - } - return { command, opts, rest: remaining } -} - -function parseAddArgs(common: CommonOpts, rest: string[]): AddOpts { - let branch: string | undefined - let name: string | undefined - let sparse = false - const positional: string[] = [] - for (let i = 0; i < rest.length; i += 1) { - const arg = rest[i]! - if (arg === '--branch' || arg === '-b') { - branch = rest[++i] - } else if (arg === '--name') { - name = rest[++i] - } else if (arg === '--sparse') { - sparse = true - } else { - positional.push(arg) - } - } - if (positional.length !== 2) { - logger.error( - `add requires <repository> <path>; got ${positional.length} positional args`, - ) - process.exit(1) - } - return { - ...common, - branch, - name, - path: positional[1]!, - repository: positional[0]!, - sparse, - } -} - -async function main(): Promise<void> { - // git >= 2.27 is required for `--filter` + `--sparse` on `git clone`. - await checkGitVersion([2, 27, 0]) - - const { command, opts, rest } = parseArgs(process.argv.slice(2)) - if (command === 'help') { - logger.log(USAGE) - return - } - if (opts.dryRun) { - logger.log('DRY RUN:') - } - switch (command) { - case 'add': - await cmdAdd(parseAddArgs(opts, rest)) - return - case 'clone': - await cmdClone({ ...opts, paths: rest }) - return - case 'save-sparse': - await cmdSaveSparse({ ...opts, paths: rest }) - return - case 'restore-sparse': - await cmdRestoreSparse({ ...opts, paths: rest }) - return - } -} - -main().catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err) - logger.error(`git-partial-submodule: ${msg}`) - process.exitCode = 1 -}) diff --git a/scripts/install-claude-plugins.mts b/scripts/install-claude-plugins.mts deleted file mode 100644 index efda9e852..000000000 --- a/scripts/install-claude-plugins.mts +++ /dev/null @@ -1,670 +0,0 @@ -#!/usr/bin/env node -/** - * @file Reconcile the local machine's Claude Code plugin state to the - * wheelhouse-canonical SHA-pinned set. What the reconciler does: - * - * 1. Ensures the `socket-wheelhouse` marketplace is added to Claude Code - * (`~/.claude/plugins/known_marketplaces.json`). - * 2. For each plugin in the wheelhouse marketplace's - * `.claude-plugin/marketplace.json`: - * - * - If installed under a _different_ marketplace (foreign source) — uninstalls - * it, then installs ours. Wheelhouse is the pin authority; foreign installs - * are silently overriding our pin. - * - If installed under our marketplace at the right SHA — no-op. - * - If installed under our marketplace at a stale SHA — uninstalls - * - reinstalls to bump. - * - If not installed at all — installs. - * - * 3. Warns (does NOT auto-remove) about marketplaces that exist locally + only - * serve plugins we now serve canonically. The user might intentionally - * keep a dev-source override; let them remove it explicitly. Idempotent — - * running twice in a row is a no-op. Designed for `pnpm setup` wiring in - * every fleet repo. Pin discipline is enforced by - * `.claude/hooks/marketplace-comment-guard/`: every `plugins[].source.sha` - * in `marketplace.json` must have a row in `.claude-plugin/README.md` with - * matching version + sha + ISO date. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -// Wheelhouse-owned patches reapplied to plugin caches after (re)install. -// Some upstream plugins ship bugs we've fixed but can't land upstream yet; -// the cache is overwritten on every install, so the fix has to be reapplied -// from a checked-in diff. Lives in scripts/plugin-patches/ (a plainly-ours -// dir, not Claude Code's `.claude-plugin/` convention dir). File naming: -// <plugin>-<version>-<slug>.patch — the `<plugin>` + `<version>` prefix maps -// to the cache dir ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/. -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) -const PLUGIN_PATCHES_DIR = path.join(SCRIPT_DIR, 'plugin-patches') -// <plugin>-<version>-<slug>.patch — version is dotted (e.g. 1.0.1); slug is -// freeform after it. Capture plugin + version to locate the cache dir. -const PATCH_FILE_NAME = /^([a-z0-9-]+)-(\d+\.\d+\.\d+)-[a-z0-9-]+\.patch$/ - -/** - * Parse a plugin-patch filename of the form `<plugin>-<version>-<slug>.patch` - * into its `{ plugin, version }`. The plugin + version map to the cache dir - * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`. Returns - * `undefined` for any name that doesn't match the shape (dotted semver version - * sandwiched between a plugin name and a freeform slug). Greedy `<plugin>` is - * disambiguated by the `\d+\.\d+\.\d+` version anchor, so a hyphenated plugin - * name (`socket-foo`) still parses. - */ -export function parsePatchFileName( - fileName: string, -): { plugin: string; version: string } | undefined { - const m = PATCH_FILE_NAME.exec(fileName) - if (!m) { - return undefined - } - return { plugin: m[1]!, version: m[2]! } -} - -// Canonical marketplace identity. The repo URL is what `claude plugin -// marketplace add` resolves; the name is what Claude Code records in -// `known_marketplaces.json` and what plugins reference via `@<name>`. -const MARKETPLACE_NAME = 'socket-wheelhouse' -const MARKETPLACE_URL = 'https://github.com/SocketDev/socket-wheelhouse' - -// Claude Code stores SHA-pinned plugin installs at a cache directory -// whose name is `<sha-12-chars>-<content-hash-8-chars>`. We parse the -// first segment to extract the pinned SHA for drift comparison. -const SHA_PINNED_DIR_NAME = /^([0-9a-f]{12})-[0-9a-f]{8,}$/ - -/** - * The single owner of the `~/.claude/plugins/` base path — Claude Code's - * plugin home, which holds both `installed_plugins.json` (the state file) and - * `cache/<marketplace>/<plugin>/<version>/` (the per-plugin caches). Every - * other reference derives from this one construction (1 path, 1 reference). - * Returns `undefined` if HOME / USERPROFILE is unresolvable. - */ -function getPluginsDir(): string | undefined { - const home = process.env['HOME'] ?? process.env['USERPROFILE'] - if (!home || !path.isAbsolute(home)) { - return undefined - } - return path.join(home, '.claude', 'plugins') -} - -export interface MarketplaceListEntry { - name: string - source: string - installLocation?: string | undefined -} - -export interface PluginListEntry { - id: string - version?: string | undefined - scope?: string | undefined - enabled?: boolean | undefined - installPath?: string | undefined -} - -export interface MarketplacePluginSource { - source: string - url?: string | undefined - path?: string | undefined - ref?: string | undefined - sha?: string | undefined - commit?: string | undefined -} - -export interface MarketplacePlugin { - name: string - source: MarketplacePluginSource -} - -export interface MarketplaceManifest { - name?: string | undefined - plugins?: MarketplacePlugin[] | undefined -} - -/** - * Parse the plugin's `installPath` to extract the SHA prefix it was pinned to - * (12 chars). Returns `null` for directory installs, version-tagged installs, - * or any path shape we don't recognize as SHA-pinned. Claude Code uses this - * dir-name shape for ref-less pins; version-tagged pins use a dir name like - * `1.0.1` instead — see `lookupInstalledSha` for the authoritative source. - */ -export function extractInstalledSha( - installPath: string | undefined, -): string | undefined { - if (!installPath) { - return undefined - } - const dirName = path.basename(installPath) - const m = SHA_PINNED_DIR_NAME.exec(dirName) - return m ? (m[1] ?? undefined) : undefined -} - -/** - * Look up the installed `gitCommitSha` for a plugin from Claude Code's own - * state file `~/.claude/plugins/installed_plugins.json`. This is the - * authoritative record of which commit a plugin was installed from, regardless - * of whether the cache dir is SHA-prefixed (`9cb4fe40-deadbeef/`) or - * version-tagged (`1.0.1/`). - * - * Returns the full 40-char SHA, or `null` if the file/entry is missing or the - * `gitCommitSha` field is absent (some plugin sources don't carry it — - * directory installs, for example). - */ -export function lookupInstalledSha( - installedPluginsJson: unknown, - installId: string, -): string | undefined { - if (!installedPluginsJson || typeof installedPluginsJson !== 'object') { - return undefined - } - const plugins = (installedPluginsJson as { plugins?: unknown | undefined }) - .plugins - if (!plugins || typeof plugins !== 'object') { - return undefined - } - const entries = (plugins as Record<string, unknown>)[installId] - if (!Array.isArray(entries)) { - return undefined - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - if (!entry || typeof entry !== 'object') { - continue - } - const sha = (entry as { gitCommitSha?: unknown | undefined }).gitCommitSha - if (typeof sha === 'string' && /^[0-9a-f]{40}$/.test(sha)) { - return sha - } - } - return undefined -} - -/** - * Find an existing install of `pluginName` that came from a marketplace _other - * than_ ours. Plugin ids have the shape `<name>@<marketplace>`. Returns the - * foreign install entry, or `undefined` if none. - */ -export function findForeignInstall( - pluginName: string, - plugins: PluginListEntry[], - ourMarketplace: string, -): PluginListEntry | undefined { - const ourId = `${pluginName}@${ourMarketplace}` - for (let i = 0, { length } = plugins; i < length; i += 1) { - const p = plugins[i]! - if (!p.id.startsWith(`${pluginName}@`)) { - continue - } - if (p.id === ourId) { - continue - } - return p - } - return undefined -} - -/** - * Identify marketplaces that look orphaned — exist locally, aren't ours, and - * only serve plugins our marketplace now serves canonically. Returns the - * marketplace names; we warn the user rather than auto-remove (a dev-source - * override is a legitimate deliberate state). - */ -export function findOrphanMarketplaces( - marketplaces: MarketplaceListEntry[], - ourMarketplace: string, - ourPluginNames: Set<string>, - plugins: PluginListEntry[], -): string[] { - const orphans: string[] = [] - for (let i = 0, { length } = marketplaces; i < length; i += 1) { - const mkt = marketplaces[i]! - if (mkt.name === ourMarketplace) { - continue - } - // Find every plugin installed from this marketplace. - const installedFromHere = plugins - .filter(p => p.id.endsWith(`@${mkt.name}`)) - .map(p => p.id.slice(0, -`@${mkt.name}`.length)) - if (installedFromHere.length === 0) { - // No installs from this marketplace — leave it alone. The user - // added it for a reason we can't see. - continue - } - if (installedFromHere.every(name => ourPluginNames.has(name))) { - orphans.push(mkt.name) - } - } - return orphans -} - -/** - * Run `claude` CLI synchronously; return stdout + exit code. Stderr goes - * through to our own stderr so the user sees CLI errors in real time. Fails - * loudly on non-zero exit codes — the install flow has no graceful fallback if - * the CLI itself is broken. - */ -function runClaudeCli(args: string[]): string { - const result = spawnSync('claude', args, { - stdio: ['ignore', 'pipe', 'inherit'], - }) - if (result.error) { - throw new Error( - `failed to spawn claude CLI: ${errorMessage(result.error)}. ` + - 'Is the Claude Code CLI installed and on PATH?', - ) - } - if (result.status !== 0) { - throw new Error( - `claude ${args.join(' ')} exited with status ${result.status}`, - ) - } - return String(result.stdout) -} - -function listMarketplaces(): MarketplaceListEntry[] { - const stdout = runClaudeCli(['plugin', 'marketplace', 'list', '--json']) - try { - return JSON.parse(stdout) as MarketplaceListEntry[] - } catch { - return [] - } -} - -function listPlugins(): PluginListEntry[] { - const stdout = runClaudeCli(['plugin', 'list', '--json']) - try { - return JSON.parse(stdout) as PluginListEntry[] - } catch { - return [] - } -} - -function ensureMarketplace(): MarketplaceListEntry { - const existing = listMarketplaces().find(m => m.name === MARKETPLACE_NAME) - if (existing) { - // Marketplace already added — but the local snapshot may be stale - // relative to upstream. Pull a fresh copy so we read today's pinned - // set, not whatever was committed when this machine first added the - // marketplace. Cheap (Claude Code downloads a tarball snapshot, no - // git clone) and idempotent. - logger.log( - `Marketplace "${MARKETPLACE_NAME}" already added; refreshing snapshot…`, - ) - runClaudeCli(['plugin', 'marketplace', 'update', MARKETPLACE_NAME]) - return existing - } - logger.log( - `Adding marketplace "${MARKETPLACE_NAME}" from ${MARKETPLACE_URL}…`, - ) - runClaudeCli([ - 'plugin', - 'marketplace', - 'add', - MARKETPLACE_URL, - '--scope', - 'user', - ]) - const added = listMarketplaces().find(m => m.name === MARKETPLACE_NAME) - if (!added) { - throw new Error( - `marketplace "${MARKETPLACE_NAME}" did not appear in plugin ` + - 'marketplace list after add — check the CLI output above.', - ) - } - return added -} - -/** - * Load `~/.claude/plugins/installed_plugins.json` — Claude Code's authoritative - * state file for which commit each installed plugin came from. Returns `null` - * if the file is absent or unparseable; the reconciler falls back to - * path-prefix parsing in that case. - */ -function loadInstalledPluginsState(): unknown { - const pluginsDir = getPluginsDir() - if (!pluginsDir) { - return undefined - } - const stateFile = path.join(pluginsDir, 'installed_plugins.json') - if (!existsSync(stateFile)) { - return undefined - } - try { - return JSON.parse(readFileSync(stateFile, 'utf8')) - } catch { - return undefined - } -} - -function loadMarketplaceManifest( - marketplace: MarketplaceListEntry, -): MarketplaceManifest { - if (!marketplace.installLocation) { - throw new Error( - `marketplace "${marketplace.name}" has no installLocation; ` + - 'cannot read its marketplace.json.', - ) - } - const manifestPath = path.join( - marketplace.installLocation, - '.claude-plugin', - 'marketplace.json', - ) - if (!existsSync(manifestPath)) { - throw new Error( - `marketplace.json not found at ${manifestPath} ` + - '— the marketplace install may be stale; try ' + - `\`claude plugin marketplace update ${marketplace.name}\`.`, - ) - } - const raw = readFileSync(manifestPath, 'utf8') - return JSON.parse(raw) as MarketplaceManifest -} - -function uninstallPlugin(installId: string): void { - logger.log(`Uninstalling ${installId}…`) - runClaudeCli(['plugin', 'uninstall', installId, '--scope', 'user']) -} - -function installPlugin(installId: string, pinDescription: string): void { - logger.log(`Installing ${installId} pinned to ${pinDescription}…`) - runClaudeCli(['plugin', 'install', installId, '--scope', 'user']) -} - -/** - * Resolve the installed SHA for a plugin. Prefer the authoritative - * `gitCommitSha` field from `~/.claude/plugins/installed_plugins.json`; fall - * back to parsing the cache dir name for ref-less SHA-prefix installs. Returns - * the full 40-char SHA (or 12-char prefix from the fallback path), or `null` if - * neither source resolves. - */ -function resolveInstalledSha( - ours: PluginListEntry, - state: unknown, -): string | undefined { - const fromState = lookupInstalledSha(state, ours.id) - if (fromState) { - return fromState - } - return extractInstalledSha(ours.installPath) -} - -/** - * Reconcile a single plugin to the wheelhouse pin. Handles four cases: foreign - * install (uninstall + install), missing (install), stale SHA (uninstall + - * reinstall), and correct (no-op). - */ -function reconcilePlugin(plugin: MarketplacePlugin): void { - const ourInstallId = `${plugin.name}@${MARKETPLACE_NAME}` - const expectedSha = plugin.source.sha ?? undefined - const pinDescription = plugin.source.sha ?? plugin.source.ref ?? '<no ref>' - - let plugins = listPlugins() - - // (1) Foreign install: same plugin name, different marketplace. Wheelhouse - // is the pin authority; uninstall the foreign install so our pin can - // take effect. The user's enabledPlugins entry under the foreign id - // disappears as a side effect of the CLI uninstall. - const foreign = findForeignInstall(plugin.name, plugins, MARKETPLACE_NAME) - if (foreign) { - logger.log( - `Found foreign install ${foreign.id} (path: ${foreign.installPath ?? '<unknown>'}); rewiring to ${ourInstallId}.`, - ) - uninstallPlugin(foreign.id) - plugins = listPlugins() - } - - // (2) Our install present? Check SHA against installed_plugins.json's - // gitCommitSha field (authoritative) with cache-dir-name parsing as - // fallback. Both SHA forms can compare: the authoritative one is full - // 40-char, the fallback is 12-char prefix, so compare on a shared - // 12-char prefix. - const ours = plugins.find(p => p.id === ourInstallId) - if (ours) { - if (!expectedSha) { - // Manifest pin has no SHA — we can't drift-compare. Trust the - // existing install. - logger.log( - `Plugin ${ourInstallId} already installed (manifest has no SHA to compare).`, - ) - return - } - const state = loadInstalledPluginsState() - const installedSha = resolveInstalledSha(ours, state) - const expectedPrefix = expectedSha.slice(0, 12) - const installedPrefix = installedSha?.slice(0, 12) ?? undefined - if (installedPrefix === expectedPrefix) { - logger.log( - `Plugin ${ourInstallId} already installed at pinned SHA ${expectedPrefix}.`, - ) - return - } - // Drift: our install is at a different SHA. Reinstall. - logger.log( - `Plugin ${ourInstallId} drift: installed at ${installedPrefix ?? '<unknown>'}, manifest pins ${expectedPrefix}. Reinstalling.`, - ) - uninstallPlugin(ourInstallId) - installPlugin(ourInstallId, pinDescription) - return - } - - // (3) Not installed at all (or we just uninstalled a foreign copy). - installPlugin(ourInstallId, pinDescription) - const after = listPlugins().find(p => p.id === ourInstallId) - if (!after) { - throw new Error( - `plugin ${ourInstallId} did not appear in plugin list after install ` + - '— check the CLI output above.', - ) - } -} - -function warnOrphanMarketplaces( - marketplaces: MarketplaceListEntry[], - ourPluginNames: Set<string>, - plugins: PluginListEntry[], -): void { - const orphans = findOrphanMarketplaces( - marketplaces, - MARKETPLACE_NAME, - ourPluginNames, - plugins, - ) - for (let i = 0, { length } = orphans; i < length; i += 1) { - const name = orphans[i]! - logger.warn( - `Marketplace "${name}" appears to only serve plugins we now pin via ` + - `"${MARKETPLACE_NAME}". Consider \`claude plugin marketplace remove ${name}\` ` + - `to keep your config tidy. (Not auto-removed — a deliberate dev-source ` + - `override is a legitimate state we won't silently undo.)`, - ) - } -} - -/** - * Resolve the on-disk cache dir for a plugin pinned in our marketplace. Claude - * Code lays caches out at - * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`. Returns the - * absolute path, or `undefined` if HOME is unresolvable or the dir is absent. - */ -function resolvePluginCacheDir( - pluginName: string, - version: string, -): string | undefined { - const pluginsDir = getPluginsDir() - if (!pluginsDir) { - return undefined - } - const dir = path.join( - pluginsDir, - 'cache', - MARKETPLACE_NAME, - pluginName, - version, - ) - return existsSync(dir) ? dir : undefined -} - -/** - * Strip the leading `# @key: value` / `#` comment header from a fleet-style - * patch, returning just the unified-diff body (everything from the first - * `--- ` line onward). Mirrors socket-btm's node-smol patch convention, where - * the header carries provenance metadata and the apply step feeds only the - * diff to `patch`. Returns an empty string if the file has no `--- ` line. - */ -export function stripPatchHeader(patchText: string): string { - const idx = patchText.search(/^--- /m) - return idx === -1 ? '' : patchText.slice(idx) -} - -/** - * Derive the sidecar dir for a patch file. A patch named `<x>.patch` may ship a - * companion `<x>.files/` directory whose tree mirrors the plugin cache root - * (e.g. `<x>.files/scripts/lib/read-stdin-sync.mjs` → `<cache>/scripts/lib/…`). - * The fleet "smallest patch footprint" rule prefers moving substantial logic - * into such a sidecar module so the diff itself stays an import + call-site - * swap, rather than inlining a 30-line function body. Returns the dir path - * (whether or not it exists — caller checks). - */ -export function patchSidecarDir(patchPath: string): string { - return patchPath.replace(/\.patch$/, '.files') -} - -/** - * Copy a patch's sidecar `.files/` tree into the plugin cache, overwriting. - * No-op when the patch ships no sidecar. Runs before the diff is applied so the - * thin diff's `import` of a sidecar module resolves. Idempotent (plain - * overwrite copy). - */ -function copyPatchSidecar(patchPath: string, cacheDir: string): void { - const sidecar = patchSidecarDir(patchPath) - if (!existsSync(sidecar)) { - return - } - cpSync(sidecar, cacheDir, { recursive: true }) -} - -/** - * Reapply wheelhouse-owned patches to plugin caches. The cache is regenerated - * on every (re)install, so an upstream-bug fix we can't land upstream yet has - * to be replayed from a checked-in diff. - * - * Patches use the fleet (socket-btm) convention: a `# @key: value` provenance - * header above a plain `diff -u` body (NOT a `git diff` — no `index`/`mode` - * markers), applied with `patch -p1`, the same tool the node-smol build chain - * uses. The header is stripped before feeding the diff to `patch`. - * - * Idempotent: a forward `--dry-run` that fails while a reverse `--dry-run` - * succeeds means the fix is already present, so it's skipped. A patch that - * applies neither way (e.g. the plugin bumped and the patch went stale) is - * reported, not fatal — a stale patch shouldn't wedge the whole reconcile. - */ -function reapplyPluginPatches(): void { - if (!existsSync(PLUGIN_PATCHES_DIR)) { - return - } - const patchFiles = readdirSync(PLUGIN_PATCHES_DIR) - .filter(f => f.endsWith('.patch')) - .toSorted() - for (let i = 0, { length } = patchFiles; i < length; i += 1) { - const file = patchFiles[i]! - const parsed = parsePatchFileName(file) - if (!parsed) { - logger.warn( - `Skipping patch "${file}": name must match <plugin>-<version>-<slug>.patch.`, - ) - continue - } - const { plugin: pluginName, version } = parsed - const patchPath = path.join(PLUGIN_PATCHES_DIR, file) - const diff = stripPatchHeader(readFileSync(patchPath, 'utf8')) - if (!diff) { - logger.warn(`Skipping patch "${file}": no \`--- \` diff body found.`) - continue - } - const cacheDir = resolvePluginCacheDir(pluginName, version) - if (!cacheDir) { - logger.log( - `Patch "${file}": no cache for ${pluginName}@${version}; skipping (plugin not installed).`, - ) - continue - } - // Copy any sidecar modules into the cache first, so the thin diff's - // import of them resolves (and so the already-applied reverse-check sees - // the same tree the forward apply produced). - copyPatchSidecar(patchPath, cacheDir) - // patch reads the diff from stdin. -p1 strips the leading a/ b/ segment; - // --forward refuses to re-apply an already-applied hunk (so the forward - // dry-run cleanly fails when the fix is present). - const runPatch = (extraArgs: readonly string[]) => - spawnSync('patch', ['-p1', '--forward', '--silent', ...extraArgs], { - cwd: cacheDir, - input: diff, - stdio: ['pipe', 'ignore', 'ignore'], - }) - if (runPatch(['--dry-run']).status !== 0) { - // Forward dry-run failed. Either already applied or genuinely stale — - // a reverse dry-run that succeeds means the fix is already present. - if (runPatch(['--reverse', '--dry-run']).status === 0) { - logger.log( - `Patch "${file}" already applied to ${pluginName}@${version}.`, - ) - } else { - logger.warn( - `Patch "${file}" did not apply to ${pluginName}@${version} ` + - '(neither forward nor already-applied). The plugin may have ' + - 'changed upstream — regenerate via the regenerating-plugin-patches skill.', - ) - } - continue - } - if (runPatch([]).status === 0) { - logger.success(`Applied patch "${file}" to ${pluginName}@${version}.`) - } else { - logger.warn(`Patch "${file}" dry-run passed but apply failed; skipped.`) - } - } -} - -function main(): void { - logger.log(`Reconciling Claude Code plugins to ${MARKETPLACE_NAME}…`) - const marketplace = ensureMarketplace() - const manifest = loadMarketplaceManifest(marketplace) - const plugins = manifest.plugins ?? [] - if (plugins.length === 0) { - logger.log( - `marketplace "${MARKETPLACE_NAME}" has no plugins listed — nothing to install.`, - ) - } - for (let i = 0, { length } = plugins; i < length; i += 1) { - const plugin = plugins[i]! - reconcilePlugin(plugin) - } - - // Post-pass: warn about marketplaces that now look redundant. - const ourPluginNames = new Set(plugins.map(p => p.name)) - warnOrphanMarketplaces(listMarketplaces(), ourPluginNames, listPlugins()) - - // Post-pass: reapply wheelhouse-owned patches over the (re)installed caches. - reapplyPluginPatches() - - logger.log('Done.') -} - -// Skip execution when imported (for tests). The CLI entry is direct -// `node scripts/install-claude-plugins.mts` invocation. -if (import.meta.url === `file://${process.argv[1]}`) { - try { - main() - } catch (e) { - logger.fail(errorMessage(e)) - process.exit(1) - } -} diff --git a/scripts/install-git-hooks.mts b/scripts/install-git-hooks.mts deleted file mode 100644 index 5ecc80aa1..000000000 --- a/scripts/install-git-hooks.mts +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env node -/** - * @file Configure git to use .git-hooks/ as the local hooks dir. Replaces husky - * — same end-state (committed hook source + auto-install on `pnpm install`), - * one fewer dependency. Idempotent: re-running is a no-op when core.hooksPath - * already points at .git-hooks. Safe to invoke from `prepare`. Skipped when: - * - * - Not inside a git repo (e.g. running in a tarball install). - * - .git-hooks/ doesn't exist (e.g. the template scaffold hasn't been cascaded - * into this repo yet). - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -const HOOKS_DIR = '.git-hooks' - -// Anchor on the script's own location instead of process.cwd(). The -// `prepare` hook normally runs from the package root, but some -// invocations (e.g. `pnpm --filter <pkg> install` from a parent -// dir, or workspace `prepare` chains) execute with a cwd that -// differs from the script's repo root. `scripts/install-git-hooks.mts` -// is always at `<repo-root>/scripts/install-git-hooks.mts`, so the -// parent of __dirname is the repo root. -const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') - -function main(): void { - if (!existsSync(path.join(REPO_ROOT, '.git'))) { - return - } - if (!existsSync(path.join(REPO_ROOT, HOOKS_DIR))) { - return - } - - const current = spawnSync( - 'git', - ['config', '--local', '--get', 'core.hooksPath'], - { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ) - if (current.status === 0 && String(current.stdout).trim() === HOOKS_DIR) { - return - } - - const set = spawnSync( - 'git', - ['config', '--local', 'core.hooksPath', HOOKS_DIR], - { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ) - if (set.status !== 0) { - process.stderr.write( - `[install-git-hooks] failed to set core.hooksPath: ${String(set.stderr).trim()}\n`, - ) - process.exitCode = 1 - } -} - -main() diff --git a/scripts/install-sfw.mts b/scripts/install-sfw.mts deleted file mode 100644 index 1a305f41a..000000000 --- a/scripts/install-sfw.mts +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install Socket Firewall (sfw) into the Socket _dlx cache via - * - * @socketsecurity/lib-stable's downloadBinary helper. Matches the CI install - * path: same version source, same binary integrity check (SHA-256 inline), - * same on-disk layout (~/.socket/_dlx/<hash>/sfw). The dev-only piece is a - * stable shim symlink at ~/.socket/_wheelhouse/bin/sfw → _dlx-hashed path so - * existing shims in ~/.socket/_wheelhouse/shims/ continue to resolve. - * - * Detects + migrates a pre-existing ~/.socket/sfw/ install in place on first - * run (rename to ~/.socket/_wheelhouse/). The `_` prefix matches the npm / - * lib-stable convention for "managed internal cache" (compare to _dlx, - * _cacache, etc.) — `sfw/` was the lone non-prefixed sibling, now - * regularized. - * - * Reads version + per-platform sha256 from the repo's root - * `external-tools.json` under `tools.sfw-free` / `tools.sfw-enterprise`. - * That file is the single fleet source of truth — every consumer of - * external tooling reads the same entries. Usage: pnpm run install:sfw # - * free flavor pnpm run install:sfw -- --enterprise # requires - * SOCKET_API_KEY (or SOCKET_API_TOKEN) pnpm run install:sfw -- --force # - * ignore cache, redownload pnpm run install:sfw -- --quiet. - */ - -import { - existsSync, - promises as fsPromises, - readFileSync, - renameSync, -} from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { parseArgs } from 'node:util' - -import { WIN32, getArch } from '@socketsecurity/lib-stable/constants/platform' -import { downloadBinary } from '@socketsecurity/lib-stable/dlx/binary' -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeDelete, safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { - getSocketAppDir, - getUserHomeDir, -} from '@socketsecurity/lib-stable/paths/socket' - -const logger = getDefaultLogger() - -// Resolve the repo-root external-tools.json. Scripts live at -// <repo-root>/scripts/install-sfw.mts, so go one dir up. -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const REPO_ROOT = path.join(__dirname, '..') -const EXTERNAL_TOOLS_PATH = path.join(REPO_ROOT, 'external-tools.json') - -// Resolve the user-home wheelhouse umbrella via the canonical lib-stable -// helper (getSocketAppDir('wheelhouse') → ~/.socket/_wheelhouse/). Cross- -// platform via getUserHomeDir() which handles HOME / USERPROFILE / fallback. -const WHEELHOUSE_DIR = getSocketAppDir('wheelhouse') -const WHEELHOUSE_BIN_DIR = path.join(WHEELHOUSE_DIR, 'bin') -// One-time migration: if a pre-rename ~/.socket/sfw/ install exists AND the -// new ~/.socket/_wheelhouse/ doesn't, rename the directory in place. Keeps -// existing shims valid (each will be regenerated on next setup pass to point -// at the new path). Idempotent: skips when either condition fails. Older -// fleet machines won't break across the rename. -const LEGACY_SFW_DIR = path.join(getUserHomeDir(), '.socket', 'sfw') -if (existsSync(LEGACY_SFW_DIR) && !existsSync(WHEELHOUSE_DIR)) { - logger.log(`Migrating legacy ${LEGACY_SFW_DIR} → ${WHEELHOUSE_DIR}…`) - renameSync(LEGACY_SFW_DIR, WHEELHOUSE_DIR) -} -// Ensure the expected subdir layout exists. safeMkdirSync is recursive + -// EEXIST-safe by default. -safeMkdirSync(WHEELHOUSE_BIN_DIR) - -const SFW_BIN_DIR = WHEELHOUSE_BIN_DIR - -interface ToolEntry { - version: string - repository?: string | undefined - release?: string | undefined - checksums?: Record<string, { asset: string; sha256: string }> | undefined -} - -interface ExternalToolsFile { - tools: Record<string, ToolEntry> -} - -export function detectPlatform(): string { - const arch = getArch() - if (process.platform === 'darwin') { - return `darwin-${arch}` - } - if (process.platform === 'win32') { - return `win-${arch}` - } - if (process.platform === 'linux') { - // Detect musl vs glibc via the loader presence — same heuristic - // the CI install-tool.mjs uses. - const isMusl = - existsSync('/lib/ld-musl-x86_64.so.1') || - existsSync('/lib/ld-musl-aarch64.so.1') - return `linux-${arch}${isMusl ? '-musl' : ''}` - } - throw new Error(`Unsupported platform: ${process.platform}`) -} - -async function main(): Promise<void> { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - enterprise: { type: 'boolean', default: false }, - force: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - }, - strict: false, - }) - - // Install bootstrap reads both the local keychain slot (SOCKET_API_KEY) and - // the canonical CI/docs name (SOCKET_API_TOKEN); this is the one place both - // legacy + canonical names legitimately appear, and it runs before the - // keychain helper's deps are guaranteed present, so it gates on raw env. - // socket-api-token-env: bootstrap - // socket-api-token-getter: allow direct-env - const apiKeyInEnv = process.env['SOCKET_API_KEY'] - // socket-api-token-env: bootstrap - // socket-api-token-getter: allow direct-env - const apiTokenInEnv = process.env['SOCKET_API_TOKEN'] - if (values['enterprise'] && !apiKeyInEnv && !apiTokenInEnv) { - logger.fail( - '--enterprise requires SOCKET_API_KEY (or SOCKET_API_TOKEN) in env', - ) - process.exit(1) - return - } - - if (!values['quiet']) { - logger.info(`Reading version table from ${EXTERNAL_TOOLS_PATH}`) - } - - if (!existsSync(EXTERNAL_TOOLS_PATH)) { - logger.fail( - `external-tools.json not found at ${EXTERNAL_TOOLS_PATH}\n` + - ' Every fleet repo ships this file at its root via the wheelhouse cascade.', - ) - process.exit(1) - return - } - const tools = JSON.parse( - readFileSync(EXTERNAL_TOOLS_PATH, 'utf8'), - ) as ExternalToolsFile - const toolKey = values['enterprise'] ? 'sfw-enterprise' : 'sfw-free' - const entry = tools.tools?.[toolKey] - if (!entry) { - logger.fail( - `external-tools.json has no \`tools.${toolKey}\` entry at ${EXTERNAL_TOOLS_PATH}`, - ) - process.exit(1) - return - } - if (!entry.repository) { - logger.fail(`tools.${toolKey} is missing the required \`repository\` field`) - process.exit(1) - return - } - - const platform = detectPlatform() - const platformMeta = entry.checksums?.[platform] - if (!platformMeta) { - const supported = Object.keys(entry.checksums ?? {}).join(', ') - logger.fail( - `${toolKey} v${entry.version} is not published for ${platform}.\n` + - ` Supported: ${supported || '(none)'}`, - ) - process.exit(1) - return - } - - const repoSlug = entry.repository.replace(/^github:/, '') - const url = `https://github.com/${repoSlug}/releases/download/v${entry.version}/${platformMeta.asset}` - const binaryName = WIN32 ? 'sfw.exe' : 'sfw' - const sha256 = platformMeta.sha256 - - if (!values['quiet']) { - logger.info(`Installing ${toolKey} v${entry.version} (${platform})`) - logger.log(` from: ${url}`) - } - - const { binaryPath, downloaded } = await downloadBinary({ - force: Boolean(values['force']), - name: binaryName, - sha256, - url, - }) - - if (!values['quiet']) { - logger.log(` ${downloaded ? 'downloaded' : 'cached'}: ${binaryPath}`) - } - - // Stable shim entry point: ~/.socket/_wheelhouse/bin/sfw → _dlx-hashed path. - // The shims in ~/.socket/_wheelhouse/shims/ exec this symlink so the - // _dlx hash is invisible to PATH-prepending consumers. Refresh on every - // install so a version bump updates the link target. - await fsPromises.mkdir(SFW_BIN_DIR, { recursive: true }) - const linkPath = path.join(SFW_BIN_DIR, binaryName) - // oxlint-disable-next-line socket/prefer-exists-sync -- need lstat (not existsSync) to detect broken symlinks; existsSync follows the link and returns false if the target is gone, leaving the stale link in place. - const linkExists = await fsPromises - .lstat(linkPath) - .then(() => true) - .catch(() => false) - if (linkExists) { - await safeDelete(linkPath) - } - await fsPromises.symlink(binaryPath, linkPath) - - if (!values['quiet']) { - logger.success(`sfw v${entry.version} ready at ${linkPath}`) - logger.log(` → ${binaryPath}`) - } -} - -main().catch((e: unknown) => { - logger.fail(errorMessage(e)) - process.exitCode = 1 -}) diff --git a/scripts/install-token-minifier.mts b/scripts/install-token-minifier.mts deleted file mode 100644 index 9ec1606db..000000000 --- a/scripts/install-token-minifier.mts +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env node -/** - * @file Install socket-token-minifier as a self-contained CLI at - * ~/.socket/_wheelhouse/socket-token-minifier/ with its own node_modules/. - * Writes a thin bin shim at ~/.socket/_wheelhouse/bin/socket-token-minifier - * that execs the installed entry-point. **Install model (post-rev)**: the - * source files (`.mts`) are COPIED to the install dest as top-level files — - * NOT installed under `node_modules/@socketsecurity/token-minifier/`. Reason: - * Node 22+ refuses to strip TS types from files under `node_modules/` - * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). The fleet convention is - * `.mts` source everywhere, so the install model adapts: source lives at the - * dest root, only `dependencies/` end up under `node_modules/`. The proxy - * resolves its deps via the colocated `node_modules` — same module-resolution - * semantics as the wheelhouse repo itself. The install dir is a one-package - * pnpm workspace so the `@socketsecurity/lib-stable` alias resolves the same - * way it does inside the fleet (catalog maps `lib-stable` → - * `npm:@socketsecurity/lib@<v>`). Without the workspace yaml at the install - * dest, the alias name wouldn't resolve from outside the originating - * workspace. Source of the package: packages/socket-token-minifier/ in the - * wheelhouse checkout this script runs from. The script copies `bin/`, - * `src/`, and `package.json` into the dest, writes a minimal - * `pnpm-workspace.yaml` carrying the catalog aliases, then `pnpm install`s at - * the dest to materialize deps. Idempotent: re-running upgrades the install - * when the package version in package.json differs from the version recorded - * in the dest's package.json. Usage: pnpm run install-token-minifier pnpm run - * install-token-minifier -- --force # ignore cached install pnpm run - * install-token-minifier -- --quiet. - */ - -import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { parseArgs } from 'node:util' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getSocketAppDir } from '@socketsecurity/lib-stable/paths/socket' -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Scripts live at <wheelhouse-root>/scripts/install-token-minifier.mts -// OR <wheelhouse-root>/template/scripts/install-token-minifier.mts. -// Walk up to find packages/socket-token-minifier — same logic either way. -const WHEELHOUSE_ROOT = (() => { - let cur = path.dirname(__dirname) - const root = path.parse(cur).root - while (cur && cur !== root) { - if ( - existsSync( - path.join(cur, 'packages', 'socket-token-minifier', 'package.json'), - ) - ) { - return cur - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - throw new Error( - 'Could not locate packages/socket-token-minifier/ — script must run ' + - 'from inside the wheelhouse checkout.', - ) -})() - -const PKG_SOURCE_DIR = path.join( - WHEELHOUSE_ROOT, - 'packages', - 'socket-token-minifier', -) -const WHEELHOUSE_INSTALL_DIR = getSocketAppDir('wheelhouse') -const INSTALL_DIR = path.join(WHEELHOUSE_INSTALL_DIR, 'socket-token-minifier') -const BIN_DIR = path.join(WHEELHOUSE_INSTALL_DIR, 'bin') -const SHIM_PATH = path.join(BIN_DIR, 'socket-token-minifier') - -interface CatalogYamlMap { - readonly [key: string]: string -} - -/** - * Read the wheelhouse pnpm-workspace.yaml and extract just the catalog entries - * the proxy package depends on. We need to mirror these into the install dest's - * workspace yaml so the alias names (e.g. lib-stable) resolve correctly when - * pnpm installs at the custom prefix. - * - * Parsed by hand instead of pulling in a yaml dep — the catalog block is - * line-shaped (key: value) and we only need the @socketsecurity/* entries the - * proxy actually references. - */ -export function readNeededCatalogEntries(): CatalogYamlMap { - const yamlPath = path.join(WHEELHOUSE_ROOT, 'pnpm-workspace.yaml') - const text = readFileSync(yamlPath, 'utf8') - const lines = text.split('\n') - let inCatalog = false - const out: Record<string, string> = {} - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i]! - // Match the `catalog:` top-level key. Sub-catalogs (`catalogs.default:`) - // are uncommon in the fleet — wheelhouse uses the top-level form. - if (/^catalog:\s*$/.test(line)) { - inCatalog = true - continue - } - if (inCatalog) { - // Catalog block ends when we hit a non-indented line. - if (/^\S/.test(line)) { - inCatalog = false - continue - } - // Match ` '@socketsecurity/...': '...'` or unquoted variants. - // Split on the first `:` after the key so the value is captured - // raw — then trim surrounding quotes + whitespace ourselves - // instead of trying to balance them in the regex. - const m = /^\s+'?(@socketsecurity\/[^':]+)'?:\s*(.+?)\s*$/.exec(line) - if (m) { - let value = m[2] as string - // Strip wrapping single or double quotes. - if ( - (value.startsWith("'") && value.endsWith("'")) || - (value.startsWith('"') && value.endsWith('"')) - ) { - value = value.slice(1, -1) - } - out[m[1] as string] = value - } - } - } - return out -} - -/** - * Emit a minimal pnpm-workspace.yaml at the install dest that mirrors the - * catalog aliases the package source declares. Keeps imports of - * `@socketsecurity/lib-stable/...` resolvable from inside the install. - */ -export function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { - const lines = ['catalog:'] - for (const [k, v] of Object.entries(catalog)) { - // Quote values that aren't bare versions (e.g. `npm:foo@1.0.0`). - const needsQuotes = /^[^\d]/.test(v) || v.includes(':') || v.includes('@') - lines.push(` '${k}': ${needsQuotes ? `'${v}'` : v}`) - } - writeFileSync( - path.join(INSTALL_DIR, 'pnpm-workspace.yaml'), - lines.join('\n') + '\n', - 'utf8', - ) -} - -/** - * Copy the source package's `package.json` into the install dest, preserving - * its `dependencies` block (which pnpm will materialize on install). Adds an - * `x-source-version` field that mirrors `version` for idempotency tracking. - * Stripping `bin`/`exports` keeps pnpm from trying to wire global binaries at - * install time — we drop our own shim explicitly. - */ -export function writeInstallPackageJson(sourceVersion: string): void { - const sourcePkg = JSON.parse( - readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), - ) - const pkg = { - name: sourcePkg.name ?? '@socketsecurity/token-minifier', - version: sourcePkg.version ?? sourceVersion, - private: true, - type: sourcePkg.type ?? 'module', - dependencies: sourcePkg.dependencies ?? {}, - 'x-source-version': sourceVersion, - } - writeFileSync( - path.join(INSTALL_DIR, 'package.json'), - JSON.stringify(pkg, null, 2) + '\n', - 'utf8', - ) -} - -/** - * Mirror the source `bin/` and `src/` directories into the install dest. Keeps - * file extensions intact (`.mts` source stays `.mts`) so Node 22+'s built-in - * type-stripping handles them at runtime. Crucial: the source files land at the - * dest's TOP LEVEL, NOT under `node_modules/` — Node refuses to strip types - * under `node_modules/`. - * - * `fs.cp` with recursive + force is the cross-platform equivalent of `cp -r`. - * Force overwrites stale files on reinstall. - */ -export function copySource(): void { - // Use sync fs API for consistency with the rest of the script — this - // is a one-shot install, not a hot path. `cpSync` exists since - // Node 20; the recursive option is required for directories. - for (const subdir of ['bin', 'src']) { - cpSync(path.join(PKG_SOURCE_DIR, subdir), path.join(INSTALL_DIR, subdir), { - recursive: true, - force: true, - }) - } -} - -/** - * Read the source package.json version to drive idempotency. We re- install - * when the recorded x-source-version in the dest's package.json differs from - * the source. - */ -export function readSourceVersion(): string { - const pkg = JSON.parse( - readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), - ) - return pkg.version ?? '0.0.0' -} - -export function readInstalledVersion(): string | undefined { - const installedPkgPath = path.join(INSTALL_DIR, 'package.json') - if (!existsSync(installedPkgPath)) { - return undefined - } - try { - const pkg = JSON.parse(readFileSync(installedPkgPath, 'utf8')) - return pkg['x-source-version'] - } catch { - return undefined - } -} - -export function pnpmInstallAtDest(quiet: boolean): void { - const result = spawnSync( - 'pnpm', - [ - 'install', - // No frozen lockfile — we generate fresh per install. - '--no-frozen-lockfile', - // Don't run lifecycle scripts of dependents — the proxy has none - // and we're a leaf install. - '--ignore-scripts', - ], - { - cwd: INSTALL_DIR, - stdio: quiet ? 'ignore' : 'inherit', - }, - ) - if (result.status !== 0) { - throw new Error('pnpm install at install dir failed; see output above') - } -} - -export function writeBinShim(): void { - // Shim execs the proxy's top-level bin/ entry. Source lives at - // INSTALL_DIR/bin/, NOT under node_modules/ — so Node 22+ can strip - // types from the .mts file at runtime. `node` is on PATH on every - // dev + CI machine the fleet runs on. - const targetEntry = path.join(INSTALL_DIR, 'bin', 'socket-token-minifier.mts') - const shim = [ - '#!/bin/bash', - '# socket-token-minifier shim — auto-generated by install-token-minifier.mts.', - '# Do not hand-edit; the contents are regenerated on every install.', - `exec node ${JSON.stringify(targetEntry)} "$@"`, - '', - ].join('\n') - safeMkdirSync(BIN_DIR) - writeFileSync(SHIM_PATH, shim, { mode: 0o755 }) -} - -async function main(): Promise<void> { - const { values } = parseArgs({ - options: { - force: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - }, - strict: false, - }) - const quiet = Boolean(values['quiet']) - const force = Boolean(values['force']) - - const sourceVersion = readSourceVersion() - const installedVersion = readInstalledVersion() - if (!force && installedVersion === sourceVersion && existsSync(SHIM_PATH)) { - if (!quiet) { - logger.log( - `socket-token-minifier already installed at v${sourceVersion} ` + - `(${INSTALL_DIR}). Use --force to reinstall.`, - ) - } - return - } - - if (!quiet) { - logger.log( - `Installing socket-token-minifier v${sourceVersion} to ${INSTALL_DIR}…`, - ) - } - - // Set up the install dir. - safeMkdirSync(INSTALL_DIR) - - // Copy source files (.mts and friends) to the install dest. - // Top-level (NOT under node_modules) — Node 22+ won't strip TS types - // from files inside node_modules. - copySource() - - // Write the install-dir workspace yaml (carries catalog aliases). - const catalog = readNeededCatalogEntries() - writeInstallWorkspaceYaml(catalog) - - // Write the install-dir package.json (copy source's deps + version). - writeInstallPackageJson(sourceVersion) - - // Materialize the deps in a colocated node_modules. - pnpmInstallAtDest(quiet) - - // Drop the bin shim. - writeBinShim() - - if (!quiet) { - logger.log(`Installed. Shim: ${SHIM_PATH}`) - logger.log( - 'Start it manually: socket-token-minifier (with ' + - `${BIN_DIR} on PATH), or wire the auto-start hook in your fleet repo.`, - ) - } -} - -try { - await main() -} catch (e) { - logger.fail(errorMessage(e)) - process.exit(1) -} diff --git a/scripts/janus.mts b/scripts/janus.mts deleted file mode 100644 index 370bfc21b..000000000 --- a/scripts/janus.mts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @file Canonical fleet janus launcher. Forwards argv to the janus binary - * installed by `.claude/hooks/setup-security-tools/` under the shared - * wheelhouse dir - * (`~/.socket/_wheelhouse/janus/<version>/<platform-arch>/janus`) so every - * fleet member's `pnpm run janus -- <args>` resolves to the same SHA-verified - * binary. Version + platform support come from the hook's - * `external-tools.json` so this script never drifts from the installer. janus - * is not a security tool — it's a single-binary utility that some Socket - * workflows opt into. If the binary is missing (or the current platform isn't - * supported by upstream), we print a hint to run `pnpm run - * setup-security-tools` and exit non-zero rather than masking the absence. - * Platform/path construction goes through `getSocketHomePath()` from - * `@socketsecurity/lib-stable/paths/socket` so darwin / linux / win32 all - * resolve correctly. Cross-platform spawn lifecycle via `spawn` from - * `@socketsecurity/lib-stable/spawn` with `shell: WIN32` for Windows - * .exe/.cmd resolution. Wired in via `package.json`: "janus": "node - * scripts/janus.mts". Byte-identical across every fleet repo. - * Sync-scaffolding flags drift. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { getSocketHomePath } from '@socketsecurity/lib-stable/paths/socket' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -type ToolEntry = { - version?: string | undefined - checksums?: Record<string, unknown> | undefined -} - -function readJanusEntry(): ToolEntry { - // The hook's external-tools.json is the single source of truth for - // version + supported-platform list. Read it directly rather than - // pinning a version here — drift between the installer and this - // launcher would silently point at a missing dir. - const configPath = path.join( - __dirname, - '..', - '.claude', - 'hooks', - 'setup-security-tools', - 'external-tools.json', - ) - const raw = JSON.parse(readFileSync(configPath, 'utf8')) as { - tools?: Record<string, ToolEntry> | undefined - } - const entry = raw.tools?.['janus'] - if (!entry) { - throw new Error( - `janus entry missing from ${configPath}; run \`pnpm run setup-security-tools\` to repair the hook`, - ) - } - return entry -} - -function getPlatformKey(): string { - return `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` -} - -async function main(): Promise<void> { - const entry = readJanusEntry() - const platformKey = getPlatformKey() - - if (!entry.checksums?.[platformKey]) { - logger.info( - `janus has no upstream build for ${platformKey} (currently darwin-arm64 only); skipping`, - ) - return - } - - const binaryName = process.platform === 'win32' ? 'janus.exe' : 'janus' - const binaryPath = path.join( - getSocketHomePath(), - '_wheelhouse', - 'janus', - entry.version!, - platformKey, - binaryName, - ) - - if (!existsSync(binaryPath)) { - logger.info( - `janus not installed at ${binaryPath}; run "pnpm run setup-security-tools" to install`, - ) - process.exitCode = 1 - return - } - - // process.argv: [node, scripts/janus.mts, ...forwarded]. - const forwardedArgs = process.argv.slice(2) - try { - const result = await spawn(binaryPath, forwardedArgs, { - stdio: 'inherit', - shell: WIN32, - }) - process.exitCode = result.code ?? 1 - } catch (e) { - if (e && typeof e === 'object' && 'code' in e) { - const code = (e as { code: unknown }).code - process.exitCode = typeof code === 'number' ? code : 1 - return - } - throw e - } -} - -main().catch((e: unknown) => { - logger.error(e) - process.exitCode = 1 -}) diff --git a/scripts/lib/build-exec.mts b/scripts/lib/build-exec.mts deleted file mode 100644 index cf14872ac..000000000 --- a/scripts/lib/build-exec.mts +++ /dev/null @@ -1,148 +0,0 @@ - -/** - * @file Build execution utilities Centralized command execution for build - * script. - */ - -import type { SpawnStdioResult } from '@socketsecurity/lib-stable/process/spawn/types' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -import { saveBuildLog } from './build-helpers.mts' - -const logger = getDefaultLogger() - -interface ExecOptions { - buildDir?: string | undefined - cwd?: string | undefined - env?: NodeJS.ProcessEnv | undefined -} - -interface ExecSilentResult { - code: number - stdout: string - stderr: string -} - -interface DownloadOptions { - buildDir?: string | undefined - maxRetries?: number | undefined - verifyIntegrity?: boolean | undefined -} - -/** - * Download file with retry and verification. - */ -async function downloadWithRetry( - url: string, - outputPath: string, - options: DownloadOptions = {}, -): Promise<boolean> { - const { buildDir, maxRetries = 3, verifyIntegrity = true } = options - - // Import verifyFileIntegrity dynamically to avoid circular dependency. - const { verifyFileIntegrity } = await import('./build-helpers.mts') - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - if (attempt > 1) { - logger.log(` Retry attempt ${attempt}/${maxRetries}...`) - } - - await exec('curl', ['-sL', url, '-o', outputPath], { buildDir }) - - if (verifyIntegrity) { - const integrity = await verifyFileIntegrity(outputPath) - if (!integrity.valid) { - throw new Error(`File integrity check failed: ${integrity.reason}`) - } - } - - return true - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - if (attempt === maxRetries) { - throw new Error( - `Download failed after ${maxRetries} attempts: ${message}`, - ) - } - - logger.warn(` ⚠️ Download attempt ${attempt} failed: ${message}`) - - // Delete corrupted file if it exists. - try { - const { unlink } = await import('node:fs/promises') - await unlink(outputPath) - } catch { - // Ignore errors. - } - - // Wait before retry (exponential backoff). - const waitTime = Math.min(1000 * 2 ** (attempt - 1), 5000) - logger.log(` ⏱️ Waiting ${waitTime}ms before retry...`) - await new Promise<void>(resolve => setTimeout(resolve, waitTime)) - } - } - - return false -} - -/** - * Execute a command and stream output. - */ -export async function exec( - command: string, - args: string[] = [], - options: ExecOptions = {}, -): Promise<SpawnStdioResult> { - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- helper accepts cwd; the process.cwd() default is for ad-hoc invocations, not a bypass of the anchor-on-script-location rule. - const { buildDir, cwd = process.cwd(), env = process.env } = options - - const cmdStr = `$ ${command} ${args.join(' ')}` - logger.log(cmdStr) - - if (buildDir) { - await saveBuildLog(buildDir, cmdStr) - } - - const result = await spawn(command, args, { - cwd, - env, - stdio: 'inherit', - shell: false, - }) - - if (result.code !== 0) { - throw new Error( - `Command failed with exit code ${result.code}: ${command} ${args.join(' ')}`, - ) - } - - return result -} - -/** - * Execute a command silently (no output). - */ -async function execSilent( - command: string, - args: string[] = [], - options: ExecOptions = {}, -): Promise<ExecSilentResult> { - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- helper accepts cwd; the process.cwd() default is for ad-hoc invocations, not a bypass of the anchor-on-script-location rule. - const { cwd = process.cwd(), env = process.env } = options - - const result = await spawn(command, args, { - cwd, - env, - stdio: 'pipe', - shell: false, - }) - - return { - code: result.code, - stdout: result.stdout ? String(result.stdout).trim() : '', - stderr: result.stderr ? String(result.stderr).trim() : '', - } -} diff --git a/scripts/lib/build-helpers.mts b/scripts/lib/build-helpers.mts deleted file mode 100644 index f6a200779..000000000 --- a/scripts/lib/build-helpers.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file Helper functions for build script. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -/** - * Format file size in human-readable format. - */ -export function formatBytes(bytes: number): string { - if (bytes === 0) { - return '0 B' - } - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}` -} - -/** - * Get build log path. - */ -function getBuildLogPath(buildDir: string): string { - return join(buildDir, 'build.log') -} - -/** - * Save build output to log file. - */ -export async function saveBuildLog( - buildDir: string, - content: string, -): Promise<void> { - const logPath = getBuildLogPath(buildDir) - try { - await fs.appendFile(logPath, `${content}\n`) - } catch { - // Don't fail build if logging fails. - } -} diff --git a/scripts/lib/build-output.mts b/scripts/lib/build-output.mts deleted file mode 100644 index c309f0f58..000000000 --- a/scripts/lib/build-output.mts +++ /dev/null @@ -1,90 +0,0 @@ - -/** - * @file Build output formatting utilities Centralized output formatting for - * build script. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() -/** - * Print error with instructions. - */ -export function printError( - title: string, - message: string, - instructions: string[] = [], -): void { - logger.error('') - logger.error('❌', title) - logger.error('') - logger.error(message) - if (instructions.length > 0) { - logger.error('') - logger.error('What to do:') - for (let i = 0, { length } = instructions; i < length; i += 1) { - const instruction = instructions[i] - logger.error(` • ${instruction}`) - } - } - logger.error('') -} - -/** - * Print section header. - */ -export function printHeader(title: string): void { - logger.log('') - logger.log('━'.repeat(60)) - logger.log(` ${title}`) - logger.log('━'.repeat(60)) - logger.log('') -} - -/** - * Print info message. - */ -function printInfo(message: string): void { - logger.log(`ℹ️ ${message}`) -} - -/** - * Print step with description. - */ -function printStep( - step: number, - total: number, - description: string, -): void { - logger.log(`[${step}/${total}] ${description}`) -} - -/** - * Print success message. - */ -export function printSuccess(message: string): void { - logger.log(`✅ ${message}`) -} - -/** - * Print warning with suggestions. - */ -function printWarning( - title: string, - message: string, - suggestions: string[] = [], -): void { - logger.warn('') - logger.warn('⚠️ ', title) - logger.warn('') - logger.warn(message) - if (suggestions.length > 0) { - logger.warn('') - logger.warn('Suggestions:') - for (let i = 0, { length } = suggestions; i < length; i += 1) { - const suggestion = suggestions[i] - logger.warn(` • ${suggestion}`) - } - } - logger.warn('') -} diff --git a/scripts/lib/patch-validator.mts b/scripts/lib/patch-validator.mts deleted file mode 100644 index ef0e5937e..000000000 --- a/scripts/lib/patch-validator.mts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * @file Patch validation and compatibility checking Validates patches before - * applying to prevent build failures. - */ -import { promises as fs } from 'node:fs' - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -interface PatchMetadata { - description: string | null - nodeVersions: string[] - requires: string[] - conflicts: string[] -} - -interface CompatibilityResult { - compatible: boolean - reason: string | null -} - -interface ValidationResult { - valid: boolean - reason?: string | null | undefined - metadata: PatchMetadata | null -} - -interface PatchAnalysis { - modifiesV8Includes: boolean - modifiesSEA: boolean - modifiesFiles: string[] -} - -interface PatchInfo { - name: string - analysis: PatchAnalysis -} - -interface PatchConflict { - type: string - file?: string | undefined - patches: string[] - message: string - severity?: string | undefined -} - -interface PatchApplicationResult { - canApply: boolean - reason: string | null - stderr?: string | Buffer | undefined -} - -/** - * Compare Node.js versions. - */ -export function compareVersions(v1: string, v2: string): number { - const parts1 = v1.replace('v', '').split('.').map(Number) - const parts2 = v2.replace('v', '').split('.').map(Number) - - for (let i = 0; i < 3; i++) { - if ((parts1[i] ?? 0) > (parts2[i] ?? 0)) { - return 1 - } - if ((parts1[i] ?? 0) < (parts2[i] ?? 0)) { - return -1 - } - } - return 0 -} - -/** - * Analyze what a patch modifies. - */ -function analyzePatchContent(patchContent: string): PatchAnalysis { - const analysis: PatchAnalysis = { - modifiesV8Includes: false, - modifiesSEA: false, - modifiesFiles: [], - } - - const lines = patchContent.split('\n') - let currentFile: string | undefined - - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i] - // Track which files are modified. - if (line.startsWith('---') || line.startsWith('+++')) { - const match = line.match(/[+-]{3}\s+(?:a\/|b\/)?(.+)/) - if (match) { - currentFile = match[1] - if ( - currentFile !== '/dev/null' && - !analysis.modifiesFiles.includes(currentFile) - ) { - analysis.modifiesFiles.push(currentFile) - } - } - } - - // Check for V8 include modifications. - if ( - line.includes('#include') && - line.includes('base/') && - currentFile?.includes('deps/v8') - ) { - analysis.modifiesV8Includes = true - } - - // Check for SEA modifications. - if (line.includes('isSea') && currentFile?.includes('lib/sea.js')) { - analysis.modifiesSEA = true - } - } - - return analysis -} - -/** - * Check for patch conflicts. - */ -function checkPatchConflicts( - patches: PatchInfo[], - nodeVersion: string, -): PatchConflict[] { - const conflicts: PatchConflict[] = [] - - // Check for multiple patches modifying same files. - const fileModifications = new Map<string, string[]>() - - for (let i = 0, { length } = patches; i < length; i += 1) { - const patch = patches[i] - for (const file of patch.analysis.modifiesFiles) { - if (!fileModifications.has(file)) { - fileModifications.set(file, []) - } - fileModifications.get(file)!.push(patch.name) - } - } - - for (const [file, patchNames] of fileModifications) { - if (patchNames.length > 1) { - conflicts.push({ - type: 'file', - file, - patches: patchNames, - message: `Multiple patches modify ${file}: ${patchNames.join(', ')}`, - }) - } - } - - // Check for V8 include modifications on v24.10.0+. - if (compareVersions(nodeVersion, 'v24.10.0') >= 0) { - const v8Patches = patches.filter(p => p.analysis.modifiesV8Includes) - if (v8Patches.length > 0) { - conflicts.push({ - type: 'version', - patches: v8Patches.map(p => p.name), - message: `Patches modify V8 includes but ${nodeVersion} doesn't need this fix`, - severity: 'error', - }) - } - } - - return conflicts -} - -/** - * Check if patch is compatible with Node version. - */ -function isPatchCompatible( - metadata: PatchMetadata, - nodeVersion: string, -): CompatibilityResult { - if (metadata.nodeVersions.length === 0) { - // No version restriction = compatible with all. - return { compatible: true, reason: undefined } - } - - // Check version ranges. - for (const versionSpec of metadata.nodeVersions) { - if (versionSpec.includes('+')) { - // v24.10.0+ means v24.10.0 and later. - const baseVersion = versionSpec.replace('+', '') - if (compareVersions(nodeVersion, baseVersion) >= 0) { - return { compatible: true, reason: undefined } - } - } else if (versionSpec.includes('-')) { - // v24.9.0-v24.9.5 means range. - const [min, max] = versionSpec.split('-') - if ( - compareVersions(nodeVersion, min) >= 0 && - compareVersions(nodeVersion, max) <= 0 - ) { - return { compatible: true, reason: undefined } - } - } else { - // Exact version. - if (nodeVersion === versionSpec) { - return { compatible: true, reason: undefined } - } - } - } - - return { - compatible: false, - reason: `Patch supports ${metadata.nodeVersions.join(', ')} but you're using ${nodeVersion}`, - } -} - -/** - * Parse patch metadata from header comments. - */ -function parsePatchMetadata(patchContent: string): PatchMetadata { - const lines = patchContent.split('\n') - const metadata: PatchMetadata = { - description: undefined, - nodeVersions: [], - requires: [], - conflicts: [], - } - - for (let i = 0, { length } = lines; i < length; i += 1) { - const line = lines[i] - // Stop at first non-comment - if (!line.startsWith('#')) { - break - } - - // Parse metadata directives. - if (line.includes('@node-versions:')) { - const versions = line - .split(':')[1] - .trim() - .split(/[,\s]+/) - metadata.nodeVersions.push(...versions) - } - if (line.includes('@requires:')) { - const required = line.split(':')[1].trim() - metadata.requires.push(required) - } - if (line.includes('@conflicts:')) { - const conflicted = line.split(':')[1].trim() - metadata.conflicts.push(conflicted) - } - if (line.includes('@description:')) { - metadata.description = line.split(':')[1].trim() - } - } - - return metadata -} - -/** - * Test if a patch will apply cleanly (dry-run). - */ -async function testPatchApplication( - patchPath: string, - targetDir: string, - stripLevel: number = 1, -): Promise<PatchApplicationResult> { - try { - // Use /bin/sh wrapper to ensure patch command is found in PATH. - // This matches the pattern used in the build script for applying patches. - const patchCommand = `patch -p${stripLevel} --dry-run --batch --forward < "${patchPath}"` - const result = await spawn('/bin/sh', ['-c', patchCommand], { - cwd: targetDir, - stdio: 'pipe', - }) - - if (result.code === 0) { - return { - canApply: true, - reason: undefined, - } - } - - return { - canApply: false, - reason: `Patch dry-run failed with exit code ${result.code}`, - stderr: result.stderr, - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - return { - canApply: false, - reason: `Patch dry-run error: ${message}`, - } - } -} - -/** - * Validate patch file before applying. - */ -async function validatePatch( - patchPath: string, - nodeVersion: string, -): Promise<ValidationResult> { - try { - const content = await fs.readFile(patchPath, 'utf8') - - // Parse metadata. - const metadata = parsePatchMetadata(content) - - // Check version compatibility. - const compatibility = isPatchCompatible(metadata, nodeVersion) - if (!compatibility.compatible) { - return { - valid: false, - reason: compatibility.reason, - metadata, - } - } - - // Check patch is not empty. - if (!content.includes('diff ') && !content.includes('---')) { - return { - valid: false, - reason: 'Patch file contains no diff content', - metadata, - } - } - - // Check for suspicious patterns. - const suspiciousPatterns = [ - { - pattern: /<html>/i, - reason: 'Patch contains HTML (probably download error)', - }, - { pattern: /404 not found/i, reason: 'Patch contains 404 error' }, - { - pattern: /access denied/i, - reason: 'Patch contains access denied error', - }, - ] - - for (const { pattern, reason } of suspiciousPatterns) { - if (pattern.test(content)) { - return { valid: false, reason, metadata } - } - } - - return { - valid: true, - metadata, - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - return { - valid: false, - reason: `Cannot read patch: ${message}`, - metadata: undefined, - } - } -} diff --git a/scripts/lint-github-settings.mts b/scripts/lint-github-settings.mts deleted file mode 100644 index 37fcbbbd6..000000000 --- a/scripts/lint-github-settings.mts +++ /dev/null @@ -1,1062 +0,0 @@ -/** - * @file Fleet lint: validate (and optionally fix) the GitHub repository - * settings against the canonical fleet config. Why this exists: a half-dozen - * repo settings determine whether the fleet enforces signed commits, - * restricts PRs to collaborators, disables wikis/discussions/projects/forks, - * and forces squash-only merges. GitHub doesn't make these flags discoverable - * to the maintainer, and the only signal a repo is misconfigured is when - * something breaks in production. This script audits them and prints the - * exact URL to fix each, or PATCHes them itself with `--fix`. Run cadence: - * weekly, locally. The first successful run writes - * `.cache/socket-wheelhouse-github-settings.json` with a timestamp; - * subsequent runs within 7 days are no-ops (use `--force` to override). CI - * behavior: if `CI=true` is in the env (GitHub Actions, etc.), the script - * skips entirely. Settings audits aren't a CI gate — the local cache write is - * the gate. CI failing on a missing/stale cache would burn API quota on every - * job and serialize maintainers behind it. Auth: requires `gh` CLI - * authenticated, OR `GITHUB_TOKEN` / `GH_TOKEN` in env. Read-only audit needs - * `repo:read`; `--fix` needs `repo:admin` (PATCH /repos/{owner}/{repo}). - * Usage: node scripts/lint-github-settings.mts # audit (uses cache) node - * scripts/lint-github-settings.mts --force # audit (skip cache) node - * scripts/lint-github-settings.mts --fix # audit + apply fixes node - * scripts/lint-github-settings.mts --json # machine-readable. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { REPO_ROOT } from './paths.mts' - -// Inline path + config-loader equivalents of the wheelhouse template's -// paths.mts helpers. `lint-github-settings.mts` cascades into fleet -// repos whose per-package `paths.mts` is intentionally minimal -// (`socket-cli`, `ultrathink`, etc. only export REPO_ROOT + -// package-specific build paths). Importing `NODE_MODULES_CACHE_DIR` / -// `loadSocketWheelhouseConfig` from `./paths.mts` would force every -// consumer to widen their paths.mts surface — wrong direction. Keep -// the per-package paths.mts narrow; carry the standalone helpers here. -const NODE_MODULES_CACHE_DIR = path.join(REPO_ROOT, 'node_modules', '.cache') - -const SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL = '.config/socket-wheelhouse.json' -const SOCKET_WHEELHOUSE_CONFIG_LEGACY_REL = '.socket-wheelhouse.json' - -interface LoadedSocketWheelhouseConfig { - readonly value: Record<string, unknown> -} - -function loadSocketWheelhouseConfig( - repoRoot: string, -): LoadedSocketWheelhouseConfig | undefined { - const primary = path.join(repoRoot, SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL) - const legacy = path.join(repoRoot, SOCKET_WHEELHOUSE_CONFIG_LEGACY_REL) - const target = existsSync(primary) - ? primary - : existsSync(legacy) - ? legacy - : undefined - if (!target) { - return undefined - } - let raw: string - try { - raw = readFileSync(target, 'utf8') - } catch { - return undefined - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return undefined - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return undefined - } - return { value: parsed as Record<string, unknown> } -} - -interface RepoApiPayload { - default_branch?: string | undefined - has_wiki?: boolean | undefined - has_discussions?: boolean | undefined - has_projects?: boolean | undefined - allow_forking?: boolean | undefined - allow_squash_merge?: boolean | undefined - allow_merge_commit?: boolean | undefined - allow_rebase_merge?: boolean | undefined - allow_auto_merge?: boolean | undefined - allow_update_branch?: boolean | undefined - delete_branch_on_merge?: boolean | undefined - pull_request_creation_policy?: string | undefined - full_name?: string | undefined - fork?: boolean | undefined -} - -interface BranchProtectionPayload { - required_signatures?: { enabled?: boolean | undefined } | undefined - required_pull_request_reviews?: - | { - required_approving_review_count?: number | undefined - require_code_owner_reviews?: boolean | undefined - dismiss_stale_reviews?: boolean | undefined - } - | undefined - allow_force_pushes?: { enabled?: boolean | undefined } | undefined - allow_deletions?: { enabled?: boolean | undefined } | undefined - enforce_admins?: { enabled?: boolean | undefined } | undefined -} - -/** - * GitHub custom-property values for the repo, shaped as the API returns: an - * array of `{ property_name, value }` pairs. We normalize to `Record<string, - * string | null>` at read time. - * - * Recognized fleet properties: - * - * - `disable-github-actions-security` ('true' | 'false') When 'true', the fleet's - * branch-protection-must-require-signed- commits rule downgrades from error → - * warn. Rationale: the shared socket-registry setup/install action IS the - * security gate; per-repo branch protection is belt-and-suspenders. - * - `doesnt-touch-customers` ('true' | 'false') Public repos default 'false' - * (they DO touch customers; full fleet rules apply). Private repos not - * published to npm can set 'true' to opt out of customer-facing rules. - * - `temporarily-doesnt-touch-customers` ('true' | 'false') Escape hatch for - * repos mid-remediation. Always downgrades customer-facing rules to warn. - * Should be removed once the remediation lands. - */ -interface CustomPropertyValue { - property_name?: string | undefined - value?: string | null | undefined -} - -type Severity = 'error' | 'warn' - -interface Finding { - rule: string - severity: Severity - current: unknown - expected: unknown - fixUrl: string - fixable: boolean - /** - * PATCH-shaped patch payload to apply when --fix is given. - */ - fixPatch?: Record<string, unknown> | undefined - /** - * Required permission for the PATCH; informational. - */ - fixRequires?: string | undefined -} - -interface CacheEntry { - verifiedAt: string - repo: string - pass: boolean - ttl: number - findings: Finding[] -} - -// Cache lives at `node_modules/.cache/` — fleet convention for -// build-tool state (vitest, etc.) and the only `.cache/` flavor -// that's auto-ignored everywhere (via pnpm/npm's gitignore + the -// fleet's `**/.cache/` rule). Path constructed once. -// Cache file name mirrors the script name (`lint-github-settings`) -// + the `socket-wheelhouse-` fleet prefix so it doesn't collide with -// any other tool's cache file under node_modules/.cache/. -const CACHE_FILE = path.join( - NODE_MODULES_CACHE_DIR, - 'socket-wheelhouse-lint-github-settings.json', -) -// 7 days in ms. Mirrors the fleet's npm catalog soak time -// (minimumReleaseAge: 10080 minutes), which is the same governing -// timeframe for "things we don't need to re-verify constantly." -const TTL_MS = 7 * 24 * 60 * 60 * 1000 - -interface CliFlags { - fix: boolean - force: boolean - json: boolean -} - -function parseFlags(): CliFlags { - const argv = process.argv.slice(2) - return { - fix: argv.includes('--fix'), - force: argv.includes('--force'), - json: argv.includes('--json'), - } -} - -/** - * Read a fresh cache entry, or undefined if absent/stale/malformed. Stale is - * decided by `verifiedAt + ttl < now`. Malformed entries (parse error, missing - * fields, wrong repo) are treated as absent — the next run will rewrite them. - */ -function readCache(repo: string): CacheEntry | undefined { - if (!existsSync(CACHE_FILE)) { - return undefined - } - let raw: string - try { - raw = readFileSync(CACHE_FILE, 'utf8') - } catch { - return undefined - } - let entry: CacheEntry - try { - entry = JSON.parse(raw) as CacheEntry - } catch { - return undefined - } - if (entry.repo !== repo) { - return undefined - } - const verifiedAt = Date.parse(entry.verifiedAt) - if (!Number.isFinite(verifiedAt)) { - return undefined - } - if (Date.now() - verifiedAt > (entry.ttl ?? TTL_MS)) { - return undefined - } - return entry -} - -function writeCache(entry: CacheEntry): void { - if (!existsSync(NODE_MODULES_CACHE_DIR)) { - mkdirSync(NODE_MODULES_CACHE_DIR, { recursive: true }) - } - writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2) + '\n') -} - -/** - * Resolve `<owner>/<repo>` by parsing the `origin` git remote. We deliberately - * use `origin` instead of `gh repo view` because in a fork checkout (e.g. - * socket-packageurl-js, a fork of package-url/packageurl-js), `gh repo view` - * returns the UPSTREAM parent, not the SocketDev fork. The audit needs to - * inspect the SocketDev fork's settings, not upstream's. The git remote is the - * source of truth for "which repo does this checkout push to." - */ -function resolveRepo(): string | undefined { - const remote = spawnSync('git', ['config', '--get', 'remote.origin.url'], { - cwd: REPO_ROOT, - }) - if (remote.status !== 0) { - return undefined - } - const url = String(remote.stdout).trim() - // Match `git@github.com:owner/repo[.git]` or - // `https://github.com/owner/repo[.git]`. - const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) - if (!m) { - return undefined - } - return `${m[1]}/${m[2]}` -} - -/** - * Thin wrapper around `gh api`. Returns JSON-parsed body on success or - * undefined on any error. The caller decides whether undefined is an - * audit-failing condition or a soft skip. - */ -function ghApi<T>( - endpoint: string, - method: 'GET' | 'PATCH' = 'GET', - body?: Record<string, unknown>, -): T | undefined { - const args = ['api', endpoint] - if (method !== 'GET') { - args.push('-X', method) - } - if (body) { - for (const [k, v] of Object.entries(body)) { - // gh api uses -F for raw JSON values (bool/null), -f for strings. - const isRaw = - typeof v === 'boolean' || - typeof v === 'number' || - v === null || - Array.isArray(v) || - typeof v === 'object' - const flag = isRaw ? '-F' : '-f' - args.push(flag, `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) - } - } - const r = spawnSync('gh', args, {}) - if (r.status !== 0) { - if (process.env['DEBUG']) { - process.stderr.write(`gh ${args.join(' ')} failed: ${r.stderr}\n`) - } - return undefined - } - if (!String(r.stdout).trim()) { - return undefined as unknown as T - } - try { - return JSON.parse(String(r.stdout)) as T - } catch { - return undefined - } -} - -/** - * Required GitHub Apps. We can't list installations directly without - * `admin:org` scope, so we infer presence from recent check-run activity on - * main HEAD. An app that's installed but inactive on main may false-negative; - * for the fleet's hot repos this is rare. - * - * Alphabetical order. - */ -const REQUIRED_APP_SLUGS = [ - 'cursor', - 'socket-security', - 'socket-trufflehog', -] as const - -interface CheckSuitesPayload { - check_suites?: - | Array<{ - app?: { slug?: string | undefined } | undefined - }> - | undefined -} - -/** - * Probe app presence by listing check-SUITES (not check-runs) on recent - * commits. Why suites and not runs: - Check-runs are only created when an app - * posts a finding. Apps like socket-trufflehog that only report on - * secrets-found don't post check-runs on clean commits — listing check-runs - * would false-negative. - Check-suites are created whenever an app receives the - * commit webhook, regardless of whether it ultimately posted a run. This is the - * broader signal — "did this app see the event." - * - * Walks the most recent 10 commits on the repo's default branch (resolved at - * call time so forks with `main` work the same as `master`-only legacy repos). - * Returns the union of app slugs observed. - */ -/** - * Load the repo's custom-property values. Returns `{ <name>: <value or null> - * }`. Empty object when the API isn't available or the call fails — equivalent - * to "no opt-outs." - */ -function loadCustomProperties(repo: string): Record<string, string | null> { - const props = ghApi<CustomPropertyValue[]>(`repos/${repo}/properties/values`) - if (!Array.isArray(props)) { - return {} - } - const out: Record<string, string | null> = {} - for (let i = 0, { length } = props; i < length; i += 1) { - const p = props[i]! - if (typeof p.property_name === 'string') { - if (p.value === null || typeof p.value === 'string') { - out[p.property_name] = p.value - } - } - } - return out -} - -/** - * Read the declared GitHub apps from this checkout's - * `.config/socket-wheelhouse.json` (the fleet-config canon — sibling of - * `claude`, `workspace`, `hooks` blocks). Schema: - * - * { "github": { "apps": ["cursor", "socket-security", "socket-trufflehog"] } } - * - * Used for apps whose installation can't be reliably inferred from check-suites - * — socket-trufflehog being the canonical example (it only posts a check-suite - * when a secret is found, so a clean repo with the app installed would - * false-negative under check-suites detection alone). - * - * Audit treats apps listed here as installed (trust the manifest). The - * maintainer's signed statement IS the install record — trust + - * verify-once-via-eyeballs > unreliable automation. - */ -function readDeclaredApps(): Set<string> { - const declared = new Set<string>() - const loaded = loadSocketWheelhouseConfig(REPO_ROOT) - if (!loaded) { - return declared - } - const github = loaded.value['github'] - if (typeof github !== 'object' || github === null) { - return declared - } - const apps = (github as Record<string, unknown>)['apps'] - if (Array.isArray(apps)) { - for (let i = 0, { length } = apps; i < length; i += 1) { - const a = apps[i]! - if (typeof a === 'string') { - declared.add(a) - } - } - } - return declared -} - -function detectInstalledApps(repo: string, defaultBranch: string): Set<string> { - const seen = new Set<string>() - // List of commits, not a single commit — `/commits` (plural) with - // `sha` query for the branch ref. The singular `/commits/{ref}` - // endpoint returns ONE commit, which is the bug shape this fixes. - const commits = ghApi<Array<{ sha?: string | undefined }>>( - `repos/${repo}/commits?sha=${encodeURIComponent(defaultBranch)}&per_page=10`, - ) - for (const c of commits ?? []) { - if (!c.sha) { - continue - } - const suites = ghApi<CheckSuitesPayload>( - `repos/${repo}/commits/${c.sha}/check-suites?per_page=100`, - ) - for (const s of suites?.check_suites ?? []) { - if (s.app?.slug) { - seen.add(s.app.slug) - } - } - if (seen.size >= REQUIRED_APP_SLUGS.length) { - break - } - } - return seen -} - -interface WorkflowsPayload { - workflows?: - | Array<{ - name?: string | undefined - path?: string | undefined - state?: string | undefined - }> - | undefined -} - -/** - * Names of canonical shared workflows hosted in socket-registry. When a fleet - * repo has a local workflow file whose path basename matches one of these AND - * the workflow body doesn't `uses:` the shared variant AND doesn't carry the - * explicit opt-out marker, that's drift. - * - * Two exemption shapes: - * - * 1. `_local-not-for-reuse-*` filename prefix — the socket-registry convention for - * local triggers that consume a shared workflow. The file IS the right - * shape. - * 2. `# socket-wheelhouse-shadow-allow: <reason>` header line — maintainer's - * explicit, audit-able commitment that the local workflow inlines logic by - * design (e.g. socket-cli's provenance.yml does CLI-specific multi-package - * release orchestration that doesn't fit the generic shared shape). The - * comment text serves as the documented reason. - */ -const SHARED_WORKFLOW_BASENAMES = [ - 'build.yml', - 'install.yml', - 'lint.yml', - 'provenance.yml', - 'release.yml', - 'setup.yml', - 'test.yml', -] as const - -function detectLocalShadows( - repo: string, -): Array<{ basename: string; localPath: string }> { - const out: Array<{ basename: string; localPath: string }> = [] - const wf = ghApi<WorkflowsPayload>( - `repos/${repo}/actions/workflows?per_page=100`, - ) - if (!wf?.workflows) { - return out - } - for (const w of wf.workflows) { - if (!w.path || !w.path.startsWith('.github/workflows/')) { - continue - } - const basename = w.path.slice('.github/workflows/'.length) - if (basename.startsWith('_local-not-for-reuse-')) { - continue - } - if ( - !SHARED_WORKFLOW_BASENAMES.includes( - basename as (typeof SHARED_WORKFLOW_BASENAMES)[number], - ) - ) { - continue - } - const r = spawnSync('gh', ['api', `repos/${repo}/contents/${w.path}`], { - cwd: REPO_ROOT, - }) - if (r.status !== 0) { - continue - } - let bodyRaw: string - try { - const obj = JSON.parse(String(r.stdout)) as { - content?: string | undefined - encoding?: string | undefined - } - if (obj.encoding !== 'base64' || !obj.content) { - continue - } - bodyRaw = Buffer.from(obj.content, 'base64').toString('utf8') - } catch { - continue - } - // Exemption 1: delegates to the shared workflow via `uses:`. - if ( - /uses:\s*SocketDev\/socket-registry\/\.github\/workflows\//.test(bodyRaw) - ) { - continue - } - // Exemption 2: explicit opt-out comment. Single unified fleet - // marker `socket-bypass: <name>` (one prefix for hooks, custom - // lints, audits — fewer prefixes to remember). - // # socket-bypass: workflow-shadow -- <reason> - // Free-text reason after `--` is encouraged but not parsed; - // maintainer accountability via git blame. - if (/^#\s*socket-bypass:\s*workflow-shadow\b/m.test(bodyRaw)) { - continue - } - out.push({ basename, localPath: w.path }) - } - return out -} - -/** - * Canonical fleet config. Each rule names the API field, expected value, and - * the fix URL. `fixPatch` is the body to send to PATCH /repos/{owner}/{repo} - * when --fix is given (undefined = manual fix required, no API endpoint yet). - */ -/** - * Custom-property opt-out knobs that downgrade specific rules from 'error' to - * 'warn'. Reading the property values is one API call per audit (see - * `loadCustomProperties`). - * - * Why warn-not-skip: a maintainer marking a repo - * `temporarily-doesnt-touch-customers: true` should still see a reminder of - * what's deferred — silencing the finding entirely would mean the eventual lift - * forgets the reminder existed. Warn = visible-but-not-CI-blocking. - */ -function severityOverride( - ruleKey: string, - props: Record<string, string | null>, -): Severity { - const disableGhAS = props['disable-github-actions-security'] === 'true' - const doesntTouchCustomers = props['doesnt-touch-customers'] === 'true' - const tempDoesntTouchCustomers = - props['temporarily-doesnt-touch-customers'] === 'true' - - // The shared socket-registry setup/install IS the security gate; - // per-repo branch protection is belt-and-suspenders. When the - // maintainer has explicitly opted out of redundant GH Actions - // security, downgrade branch-protection findings to warn. - if ( - disableGhAS && - (ruleKey === 'branch-protection-allow-deletions' || - ruleKey === 'branch-protection-allow-force-pushes' || - ruleKey === 'branch-protection-dismiss-stale-reviews' || - ruleKey === 'branch-protection-enforce-admins' || - ruleKey === 'branch-protection-exists' || - ruleKey === 'branch-protection-required-pr-reviews' || - ruleKey === 'branch-protection-required-signatures') - ) { - return 'warn' - } - - // Customer-facing rules: only enforce on repos that DO touch - // customers. Private/unpublished or in-remediation repos get - // warnings instead of errors so the maintainer sees the reminder - // without CI red. - const customerFacingRules = new Set([ - 'has_discussions must be false', - 'has_projects must be false', - 'has_wiki must be false', - 'pull_request_creation_policy must be collaborators_only', - ]) - if ( - (doesntTouchCustomers || tempDoesntTouchCustomers) && - customerFacingRules.has(ruleKey) - ) { - return 'warn' - } - - return 'error' -} - -function evaluate( - repo: string, - apiRepo: RepoApiPayload, - apiProtection: BranchProtectionPayload | undefined, - installedApps: Set<string>, - localShadows: ReadonlyArray<{ basename: string; localPath: string }>, - customProps: Record<string, string | null>, -): Finding[] { - const findings: Finding[] = [] - const settingsUrl = `https://github.com/${repo}/settings` - const branchesUrl = `https://github.com/${repo}/settings/branches` - - const check = ( - rule: string, - current: unknown, - expected: unknown, - fixUrl: string, - fixPatch: Record<string, unknown> | undefined, - ): void => { - if (current === expected) { - return - } - findings.push({ - rule, - severity: severityOverride(rule, customProps), - current, - expected, - fixUrl, - fixable: fixPatch !== undefined, - ...(fixPatch !== undefined - ? { fixPatch, fixRequires: 'repo:admin' } - : {}), - }) - } - - check( - 'default_branch must be main', - apiRepo.default_branch, - 'main', - branchesUrl, - // No PATCH for default_branch via /repos/{owner}/{repo} — need to - // rename the branch first via /repos/{owner}/{repo}/rename-branch - // and then set it. Manual. - undefined, - ) - check( - 'has_wiki must be false', - apiRepo.has_wiki, - false, - `${settingsUrl}#features`, - { has_wiki: false }, - ) - check( - 'has_discussions must be false', - apiRepo.has_discussions, - false, - `${settingsUrl}#features`, - { has_discussions: false }, - ) - check( - 'has_projects must be false', - apiRepo.has_projects, - false, - `${settingsUrl}#features`, - { has_projects: false }, - ) - // Note: `allow_forking` is intentionally NOT checked. The actual - // "no outside-contributor PRs" gate is `pull_request_creation_ - // policy: collaborators_only` (checked below). Letting people fork - // for read access / personal-use is the open-source default and - // doesn't bypass PR review. - check( - 'allow_squash_merge must be true', - apiRepo.allow_squash_merge, - true, - `${settingsUrl}#pull-requests`, - { allow_squash_merge: true }, - ) - check( - 'allow_merge_commit must be false', - apiRepo.allow_merge_commit, - false, - `${settingsUrl}#pull-requests`, - { allow_merge_commit: false }, - ) - check( - 'allow_rebase_merge must be false', - apiRepo.allow_rebase_merge, - false, - `${settingsUrl}#pull-requests`, - { allow_rebase_merge: false }, - ) - check( - 'allow_auto_merge must be true', - apiRepo.allow_auto_merge, - true, - `${settingsUrl}#pull-requests`, - { allow_auto_merge: true }, - ) - check( - 'allow_update_branch must be true', - apiRepo.allow_update_branch, - true, - `${settingsUrl}#pull-requests`, - { allow_update_branch: true }, - ) - check( - 'delete_branch_on_merge must be true', - apiRepo.delete_branch_on_merge, - true, - `${settingsUrl}#pull-requests`, - { delete_branch_on_merge: true }, - ) - check( - 'pull_request_creation_policy must be collaborators_only', - apiRepo.pull_request_creation_policy, - 'collaborators_only', - `${settingsUrl}#pull-requests`, - { pull_request_creation_policy: 'collaborators_only' }, - ) - - // Branch protection on main — signed commits. - if (!apiProtection) { - findings.push({ - rule: 'main branch protection must exist', - severity: severityOverride('branch-protection-exists', customProps), - current: undefined, - expected: '{ required_signatures: { enabled: true } }', - fixUrl: branchesUrl, - fixable: false, - }) - } else { - // Required signatures. - if (apiProtection.required_signatures?.enabled !== true) { - findings.push({ - rule: 'main branch protection: required_signatures must be enabled', - severity: severityOverride( - 'branch-protection-required-signatures', - customProps, - ), - current: apiProtection.required_signatures?.enabled ?? false, - expected: true, - fixUrl: branchesUrl, - // PATCH /repos/{owner}/{repo}/branches/{branch}/protection/required_signatures - // is the endpoint; this script's --fix doesn't auto-apply it - // because rewriting branch protection rules can clobber custom - // status-check requirements set by the maintainer. Manual. - fixable: false, - }) - } - // Required PR reviews. Direct pushes to main are forbidden under - // the fleet's standard policy. At least 1 approving review, - // dismiss stale reviews on new pushes. Code-owner enforcement - // is opt-in per repo (some repos don't have a CODEOWNERS file). - const prReviews = apiProtection.required_pull_request_reviews - if (!prReviews) { - findings.push({ - rule: 'main branch protection: required_pull_request_reviews must be enabled', - severity: severityOverride( - 'branch-protection-required-pr-reviews', - customProps, - ), - current: undefined, - expected: - '{ required_approving_review_count: 1, dismiss_stale_reviews: true }', - fixUrl: branchesUrl, - fixable: false, - }) - } else { - if ((prReviews.required_approving_review_count ?? 0) < 1) { - findings.push({ - rule: 'main branch protection: required_approving_review_count must be ≥ 1', - severity: severityOverride( - 'branch-protection-required-pr-reviews', - customProps, - ), - current: prReviews.required_approving_review_count ?? 0, - expected: '≥ 1', - fixUrl: branchesUrl, - fixable: false, - }) - } - if (prReviews.dismiss_stale_reviews !== true) { - findings.push({ - rule: 'main branch protection: dismiss_stale_reviews must be enabled', - severity: severityOverride( - 'branch-protection-dismiss-stale-reviews', - customProps, - ), - current: prReviews.dismiss_stale_reviews ?? false, - expected: true, - fixUrl: branchesUrl, - fixable: false, - }) - } - } - // Force pushes — must be disabled. A force push to main is the - // recovery-from-bad-state pattern that also enables stolen-token - // attacks (rewrite history, push back). - if (apiProtection.allow_force_pushes?.enabled === true) { - findings.push({ - rule: 'main branch protection: allow_force_pushes must be disabled', - severity: severityOverride( - 'branch-protection-allow-force-pushes', - customProps, - ), - current: true, - expected: false, - fixUrl: branchesUrl, - fixable: false, - }) - } - // Branch deletion — must be disabled. The default branch shouldn't - // be deletable via the API (separate concern from regular - // branch cleanup). - if (apiProtection.allow_deletions?.enabled === true) { - findings.push({ - rule: 'main branch protection: allow_deletions must be disabled', - severity: severityOverride( - 'branch-protection-allow-deletions', - customProps, - ), - current: true, - expected: false, - fixUrl: branchesUrl, - fixable: false, - }) - } - // Enforce admins — must be enabled. Without this, repo admins - // can bypass every other branch-protection rule. The whole - // point of branch protection is to apply uniformly; admin - // bypass undermines it. - if (apiProtection.enforce_admins?.enabled !== true) { - findings.push({ - rule: 'main branch protection: enforce_admins must be enabled', - severity: severityOverride( - 'branch-protection-enforce-admins', - customProps, - ), - current: apiProtection.enforce_admins?.enabled ?? false, - expected: true, - fixUrl: branchesUrl, - fixable: false, - }) - } - } - - // Required apps. Each missing app gets one finding with the install URL. - for (let i = 0, { length } = REQUIRED_APP_SLUGS; i < length; i += 1) { - const slug = REQUIRED_APP_SLUGS[i]! - if (!installedApps.has(slug)) { - findings.push({ - rule: `GitHub App must be installed: ${slug}`, - // App findings stay 'error' regardless of custom properties — - // app installation is universal. (Could be made overridable - // per-property if a use case emerges.) - severity: 'error', - current: - 'not detected on recent check-suites or declared in .github/required-apps.yml', - expected: 'installed + declared', - fixUrl: `https://github.com/apps/${slug}`, - fixable: false, - }) - } - } - - // Local shadows of shared workflows. Either delete the local file - // (and `uses:` the shared one), or add the explicit opt-out header - // `# socket-wheelhouse-shadow-allow: <reason>` documenting why the - // local version is intentional. - for (let i = 0, { length } = localShadows; i < length; i += 1) { - const shadow = localShadows[i]! - findings.push({ - rule: `Local workflow shadows a shared one: ${shadow.basename}`, - severity: 'error', - current: shadow.localPath, - expected: - `uses: SocketDev/socket-registry/.github/workflows/${shadow.basename}@<sha> ` + - `OR add a header comment '# socket-bypass: workflow-shadow -- <reason>' ` + - `to document why this local workflow is intentional`, - fixUrl: `https://github.com/${repo}/blob/${apiRepo.default_branch ?? 'main'}/${shadow.localPath}`, - fixable: false, - }) - } - - return findings -} - -function applyFixes(repo: string, findings: readonly Finding[]): number { - const patchable = findings.filter(f => f.fixable && f.fixPatch) - if (patchable.length === 0) { - return 0 - } - // Merge all PATCH bodies into one call — /repos/{owner}/{repo} - // accepts arbitrary subsets of settings. - const patch: Record<string, unknown> = {} - for (let i = 0, { length } = patchable; i < length; i += 1) { - const f = patchable[i]! - Object.assign(patch, f.fixPatch) - } - process.stdout.write( - `\n🔧 Applying ${patchable.length} fixes via PATCH /repos/${repo}:\n`, - ) - for (const [k, v] of Object.entries(patch)) { - process.stdout.write(` ${k} = ${JSON.stringify(v)}\n`) - } - const result = ghApi(`repos/${repo}`, 'PATCH', patch) - if (!result) { - process.stderr.write( - '::error::PATCH failed. Token may lack `repo:admin` permission.\n', - ) - return 0 - } - return patchable.length -} - -function printReport( - findings: readonly Finding[], - repo: string, - json: boolean, -): void { - if (json) { - process.stdout.write(JSON.stringify({ repo, findings }, null, 2) + '\n') - return - } - if (findings.length === 0) { - process.stdout.write(`✓ GitHub settings audit passed for ${repo}.\n`) - return - } - const errors = findings.filter(f => f.severity === 'error') - const warns = findings.filter(f => f.severity === 'warn') - process.stdout.write( - `\n${repo}: ${errors.length} error(s), ${warns.length} warning(s)\n\n`, - ) - // Errors first, then warnings — operator should fix errors before - // worrying about warnings. - for (const f of [...errors, ...warns]) { - const marker = f.severity === 'error' ? '✗' : '⚠' - process.stdout.write(` ${marker} [${f.severity}] ${f.rule}\n`) - process.stdout.write(` current: ${JSON.stringify(f.current)}\n`) - process.stdout.write(` expected: ${JSON.stringify(f.expected)}\n`) - process.stdout.write(` fix: ${f.fixUrl}\n`) - if (f.fixable) { - process.stdout.write(` auto-fix: --fix (requires repo:admin)\n`) - } - process.stdout.write('\n') - } - // Manual-verify items — always print. - const settingsUrl = `https://github.com/${repo}/settings` - process.stdout.write('Manual-verify (no REST API; check via UI):\n') - process.stdout.write( - ` • Commit comments must be disabled: ${settingsUrl} → General → Commits\n`, - ) - process.stdout.write( - ` • Release immutability enabled: ${settingsUrl} → General → Releases\n`, - ) - process.stdout.write( - ` • Sponsorships button off: ${settingsUrl} → General → Features\n`, - ) - process.stdout.write( - ` • Auto-close issues with merged linked PRs ON: ${settingsUrl} → General → Pull Requests\n`, - ) - process.stdout.write( - ` • Single-push branch+tag update limit = 5: ${settingsUrl} → General → Pushes\n`, - ) - process.stdout.write( - ` • Required Actions secrets present (ANTHROPIC_API_KEY, SOCKET_API_TOKEN): ${settingsUrl}/secrets/actions\n`, - ) -} - -function main(): number { - // CI bypass — settings audits are local-run only. See header comment. - if (process.env['CI'] === 'true') { - process.stdout.write( - 'CI=true detected; skipping GitHub settings audit (local-run only).\n', - ) - return 0 - } - - const flags = parseFlags() - const repo = resolveRepo() - if (!repo) { - process.stderr.write( - '::error::Could not resolve <owner>/<repo>. Run from inside a git checkout with a github.com remote.\n', - ) - return 1 - } - - // Cache hit shortcut (unless --force or --fix). - if (!flags.force && !flags.fix) { - const cached = readCache(repo) - if (cached?.pass) { - const ageHours = Math.round( - (Date.now() - Date.parse(cached.verifiedAt)) / 3600_000, - ) - process.stdout.write( - `✓ Cache fresh (${ageHours}h old, < 7d TTL). Use --force to re-check.\n`, - ) - return 0 - } - } - - const apiRepo = ghApi<RepoApiPayload>(`repos/${repo}`) - if (!apiRepo) { - process.stderr.write( - `::error::Could not fetch repos/${repo}. Check gh auth status / token permissions.\n`, - ) - return 1 - } - - // Branch protection lookup must use the repo's actual default - // branch — a fork on a legacy `master` default would never have - // protection on `main`. Default to 'main' when the API doesn't - // expose it (rare). - const defaultBranch = apiRepo.default_branch ?? 'main' - const apiProtection = ghApi<BranchProtectionPayload>( - `repos/${repo}/branches/${defaultBranch}/protection`, - ) - // Union of apps actually-observed via check-suites + apps - // declared in .github/required-apps.yml. Declared-apps are how - // socket-trufflehog (which only posts on findings) gets credit. - const installedApps = new Set<string>([ - ...detectInstalledApps(repo, defaultBranch), - ...readDeclaredApps(), - ]) - const localShadows = detectLocalShadows(repo) - const customProps = loadCustomProperties(repo) - - let findings = evaluate( - repo, - apiRepo, - apiProtection, - installedApps, - localShadows, - customProps, - ) - - if (flags.fix && findings.length > 0) { - const fixedCount = applyFixes(repo, findings) - if (fixedCount > 0) { - // Re-fetch + re-evaluate so the report + cache reflect post-fix - // state. Cheap (one extra GET). - const apiRepoAfter = ghApi<RepoApiPayload>(`repos/${repo}`) - if (apiRepoAfter) { - findings = evaluate( - repo, - apiRepoAfter, - apiProtection, - installedApps, - localShadows, - customProps, - ) - } - } - } - - printReport(findings, repo, flags.json) - - // Exit-status policy: only error-severity findings fail the run. - // Warnings (custom-property downgrades, mid-remediation flags) are - // informational — they show in the report but don't block CI or - // the maintainer's local `pnpm run` chain. Cache the result either - // way so the 7-day TTL is honored; the next run will re-check. - const errors = findings.filter(f => f.severity === 'error') - const pass = errors.length === 0 - writeCache({ - verifiedAt: new Date().toISOString(), - repo, - pass, - ttl: TTL_MS, - findings, - }) - - return pass ? 0 : 1 -} - -process.exit(main()) diff --git a/scripts/lint.mts b/scripts/lint.mts deleted file mode 100644 index e11ab05c4..000000000 --- a/scripts/lint.mts +++ /dev/null @@ -1,333 +0,0 @@ -/* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */ -/** - * @file Canonical minimal lint runner for socket-* repos. Scope modes: - * (default) Lint files modified in the working tree vs HEAD. --staged Lint - * files in the git index (used by .git-hooks/pre-commit). --all Lint the - * entire workspace. Flags: --fix Auto-fix issues. --quiet Suppress progress - * output. If the chosen scope has no lintable files, the script is a no-op. - * Config or infrastructure changes (.config/oxlintrc.json, - * .config/oxfmtrc.json, tsconfig*.json, pnpm-lock.yaml, .config/**, - * scripts/**, package.json) escalate to `--all` automatically, since they can - * affect everything. This is the minimal zero-dependency reference - * implementation. Larger repos (socket-lib, socket-registry, socket-sdk-js, - * etc.) use a richer version based on @socketsecurity/lib-stable helpers; - * this one keeps the same CLI contract so pre-commit hooks and CI work - * identically across repos. - */ - -// prefer-async-spawn: sync-required — top-level CLI runner; entire -// flow is sync (sequential gates, exit-code aggregation). -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import type { SpawnSyncOptions } from 'node:child_process' -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const args = process.argv.slice(2) -const mode: 'staged' | 'all' | 'modified' = args.includes('--all') - ? 'all' - : args.includes('--staged') - ? 'staged' - : 'modified' -const fix = args.includes('--fix') -const quiet = args.includes('--quiet') || args.includes('--silent') -const stdio: SpawnSyncOptions['stdio'] = quiet ? 'pipe' : 'inherit' -// On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly -// via spawnSync (CVE-2024-27980 hardening). The shell wrapper resolves -// the shim; on POSIX we keep direct invocation so no shell-quoting -// surface is introduced. -const useShell = process.platform === 'win32' - -const LINTABLE_EXTS = new Set(['.cjs', '.cts', '.js', '.mjs', '.mts', '.ts']) - -// Paths that, when touched, force a full-workspace lint. -const ESCALATION_PATTERNS = [ - /^\.config\//, - /^scripts\//, - /^pnpm-lock\.yaml$/, - /^tsconfig.*\.json$/, - /^package\.json$/, - /^lockstep\.schema\.json$/, -] - -function log(msg: string): void { - if (!quiet) { - logger.log(msg) - } -} - -function gitFiles(args: string[]): string[] { - // spawnSync with array args — no shell interpolation, no injection - // surface even if a future caller passes data into args. - const r = spawnSync('git', args, { - stdio: ['ignore', 'pipe', 'pipe'], - stdioString: true, - }) - if (r.status !== 0 || typeof r.stdout !== 'string') { - return [] - } - return r.stdout - .split('\n') - .map(s => s.trim()) - .filter(s => s.length > 0) -} - -function getStagedFiles(): string[] { - return gitFiles(['diff', '--cached', '--name-only', '--diff-filter=ACMR']) -} - -function getModifiedFiles(): string[] { - return gitFiles(['diff', '--name-only', '--diff-filter=ACMR', 'HEAD']) -} - -function shouldEscalate(files: string[]): boolean { - for (let i = 0, { length } = files; i < length; i += 1) { - const f = files[i]! - for (let i = 0, { length } = ESCALATION_PATTERNS; i < length; i += 1) { - const pattern = ESCALATION_PATTERNS[i]! - if (pattern.test(f)) { - return true - } - } - } - return false -} - -function filterLintable(files: string[]): string[] { - return files.filter(f => LINTABLE_EXTS.has(path.extname(f)) && existsSync(f)) -} - -// Wheelhouse-self dogfood paths. These dirs are in the canonical -// .config/oxlint{,rc}.json ignorePatterns because downstream fleet -// repos consume them as opaque tooling — but the wheelhouse itself -// authors the code and must lint it. Pass the paths explicitly so -// oxlint walks them, with the same config + plugin rule set. -// -// `template/**` ships byte-identical to every fleet repo via the -// sync-scaffolding cascade — including `template/.claude/hooks/` -// (the actual fleet hook code) and `template/.config/oxlint-plugin/` -// (the canonical rule definitions). The wheelhouse must lint these -// here, before they propagate, because downstream repos can't -// independently fix drift in fleet-canonical files. -// -// NOTE: The wheelhouse's OWN `<root>/.claude/` is excluded. That's -// local-dev tooling (the wheelhouse's machine-local hook setup), not -// fleet-canonical. It's a copy of `template/.claude/` plus per-machine -// overrides; linting it would double-flag every issue once in -// `template/` and once in `.claude/`. -const DOGFOOD_LINT_PATHS = ['.config/oxlint-plugin', 'template'] - -// Markdown lint pass — gated behind LINT_MARKDOWN=1 so existing fleet -// repos with pre-existing markdown hygiene findings aren't blocked -// until they've cleaned up. Operates over the markdownlint-cli2 config -// at .config/.markdownlint-cli2.jsonc, which scopes globs + ignores -// and registers the three fleet custom rules -// (socket-no-private-wheelhouse-leak, socket-no-relative-sibling- -// script, socket-readme-required-sections). When the env var is unset -// the function is a no-op and returns 0. -// -// Scope choice: markdown lint always runs over the whole tree (the -// canonical config's globs/ignores decide the scope, not the script). -// Per-file invocation would require pre-filtering for the same globs + -// is slower for the small overall file count typical in fleet repos. -function runMarkdown(): number { - if (process.env['LINT_MARKDOWN'] !== '1') { - return 0 - } - if (!existsSync('.config/.markdownlint-cli2.jsonc')) { - log('Skipping markdownlint: .config/.markdownlint-cli2.jsonc absent.') - return 0 - } - log('Running markdownlint-cli2…') - const mdArgs = [ - 'exec', - 'markdownlint-cli2', - '--config', - '.config/.markdownlint-cli2.jsonc', - ] - if (fix) { - mdArgs.push('--fix') - } - const mdRes = spawnSync('pnpm', mdArgs, { shell: useShell, stdio }) - if (mdRes.status !== 0) { - return 1 - } - return 0 -} - -function runAll(): number { - log('Formatting all files…') - // spawnSync with array args, no shell interpolation. Matches the - // socket/prefer-spawn-over-execsync rule: shell-string execSync is - // banned because every interpolated value is a potential injection - // vector; the array form structurally can't shell-expand its args. - const oxfmtArgs = [ - 'exec', - 'oxfmt', - '-c', - '.config/oxfmtrc.json', - '--ignore-path', - '.config/.prettierignore', - fix ? '--write' : '--check', - '.', - ] - const fmtRes = spawnSync('pnpm', oxfmtArgs, { shell: useShell, stdio }) - if (fmtRes.status !== 0) { - return 1 - } - log('Running oxlint on all files…') - const oxlintArgs = ['exec', 'oxlint', '-c', '.config/oxlintrc.json'] - if (fix) { - oxlintArgs.push('--fix') - } - const lintRes = spawnSync('pnpm', oxlintArgs, { shell: useShell, stdio }) - if (lintRes.status !== 0) { - return 1 - } - // Wheelhouse-self dogfood: lint the .config/oxlint-plugin/ + template/ - // trees too. The canonical .config/oxlintrc.json ignores those paths so - // downstream fleet repos don't waste cycles linting opaque tooling, but - // the wheelhouse is the author — every change here lands in every - // fleet repo, so the rules must hold here first. .config/oxlintrc.dogfood.json - // extends the base config with a narrower ignore list. - // - // The dogfood lint surface has known structural exemptions (e.g. rule - // modules MUST `export default` per the oxlint plugin contract, so - // `no-default-export` is exempt for them). Those exemptions live in - // .config/oxlintrc.dogfood.json's `overrides`. Today this lint pass - // is gated behind LINT_DOGFOOD=1 so it doesn't break the default - // workflow while the exemption list is being curated. Set the env var - // to opt in. - if (process.env['LINT_DOGFOOD'] === '1') { - if (!quiet) { - logger.log('Running oxlint on wheelhouse-self dogfood paths…') - } - for (let i = 0, { length } = DOGFOOD_LINT_PATHS; i < length; i += 1) { - const dogfoodPath = DOGFOOD_LINT_PATHS[i]! - if (!existsSync(dogfoodPath)) { - continue - } - // spawnSync (not execSync) — array args, no shell interpolation. - // Avoids any chance of command injection via dogfoodPath. - // spawnSync returns a status object rather than throwing on - // non-zero exit, so we branch on status. - const args = ['exec', 'oxlint', '-c', '.config/oxlintrc.dogfood.json'] - if (fix) { - args.push('--fix') - } - args.push(dogfoodPath) - const r = spawnSync('pnpm', args, { shell: useShell, stdio }) - if (r.status !== 0) { - return 1 - } - } - } - const mdStatus = runMarkdown() - if (mdStatus !== 0) { - return mdStatus - } - return 0 -} - -function runFiles(files: string[]): number { - if (files.length === 0) { - log('No lintable files; skipping.') - return 0 - } - log(`Formatting ${files.length} file(s)...`) - const oxfmtArgs = [ - 'exec', - 'oxfmt', - '-c', - '.config/oxfmtrc.json', - '--ignore-path', - '.config/.prettierignore', - fix ? '--write' : '--check', - '--no-error-on-unmatched-pattern', - ...files, - ] - const fmtRes = spawnSync('pnpm', oxfmtArgs, { shell: useShell, stdio }) - if (fmtRes.status !== 0) { - return 1 - } - log(`Running oxlint on ${files.length} file(s)...`) - // --no-error-on-unmatched-pattern keeps the command exit-0 when - // every listed file falls inside the config's ignorePatterns (e.g. - // touching just .claude/ files, which the canonical config excludes). - // Without it oxlint exits 1 with "No files found" — the user sees a - // lint failure for files they were never going to lint. - const oxlintArgs = [ - 'exec', - 'oxlint', - '-c', - '.config/oxlintrc.json', - '--no-error-on-unmatched-pattern', - ] - if (fix) { - oxlintArgs.push('--fix') - } - oxlintArgs.push(...files) - const lintRes = spawnSync('pnpm', oxlintArgs, { shell: useShell, stdio }) - if (lintRes.status !== 0) { - return 1 - } - // Markdown lint when any of the changed files is .md / .mdx. The - // markdownlint-cli2 config picks its own scope from globs; we just - // gate on whether to invoke at all so unrelated edits don't pay the - // markdownlint startup cost. - const touchedMarkdown = files.some(f => /\.(?:md|mdx)$/i.test(f)) - if (touchedMarkdown) { - const mdStatus = runMarkdown() - if (mdStatus !== 0) { - return mdStatus - } - } - return 0 -} - -function main(): void { - if (mode === 'all') { - log('Lint scope: all') - process.exitCode = runAll() - if (process.exitCode === 0) { - log('Lint passed') - } else { - log('Lint failed') - } - return - } - - const files = mode === 'staged' ? getStagedFiles() : getModifiedFiles() - - if (files.length === 0) { - log(`No ${mode} files; skipping lint.`) - return - } - - if (shouldEscalate(files)) { - log(`Config files changed; escalating to full lint.`) - process.exitCode = runAll() - if (process.exitCode === 0) { - log('Lint passed') - } else { - log('Lint failed') - } - return - } - - const lintable = filterLintable(files) - log( - `Lint scope: ${mode} (${lintable.length} of ${files.length} files lintable)`, - ) - process.exitCode = runFiles(lintable) - if (process.exitCode === 0) { - log('Lint passed') - } else { - log('Lint failed') - } -} - -main() diff --git a/scripts/lockstep-emit-schema.mts b/scripts/lockstep-emit-schema.mts deleted file mode 100644 index e018e5942..000000000 --- a/scripts/lockstep-emit-schema.mts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -/** - * @file Thin entry shim — real script lives in lockstep/emit-schema.mts. - */ - -import './lockstep/emit-schema.mts' diff --git a/scripts/lockstep-schema.mts b/scripts/lockstep-schema.mts deleted file mode 100644 index 53479e0e3..000000000 --- a/scripts/lockstep-schema.mts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @file Re-export shim — real schema lives in lockstep/schema.mts. Kept for - * backward compatibility with imports from `./lockstep-schema.mts`. - */ - -export * from './lockstep/schema.mts' diff --git a/scripts/lockstep.mts b/scripts/lockstep.mts deleted file mode 100644 index ad5ea3a99..000000000 --- a/scripts/lockstep.mts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node -/** - * @file Thin entry shim — real CLI lives in lockstep/cli.mts. - */ - -import './lockstep/cli.mts' diff --git a/scripts/lockstep/checks.mts b/scripts/lockstep/checks.mts deleted file mode 100644 index d20bb0a6c..000000000 --- a/scripts/lockstep/checks.mts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * @file Per-kind checkers for the lockstep harness. One `check<Kind>` function - * per row kind, each producing the matching `<Kind>Report`. The dispatcher in - * `cli.mts` switches on `row.kind` and routes to the right checker; each - * checker is independent and pure-ish (reads files / submodules but mutates - * only the report it returns). `checkCrossRowConsistency` is the - * manifest-wide layer on top: schema validation catches per-row shape, this - * catches referential integrity (duplicate ids within an area, dangling - * `upstream` aliases, ports pointing at sites that don't exist). `rootDir` is - * supplied by the CLI so all path resolution is relative to one canonical - * anchor (the repo root computed in `cli.mts` from `import.meta.url`). - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import type { - FeatureParityRow, - FileForkRow, - LangParityRow, - Row, - SpecConformanceRow, - VersionPinRow, -} from './schema.mts' -import type { - FeatureParityReport, - FileForkReport, - LangParityReport, - Manifest, - SpecConformanceReport, - VersionPinReport, -} from './types.mts' - -import { - driftCommitsSince, - gitIn, - resolveUpstream, - shaIsReachable, - splitLines, -} from './git.mts' -import { countPatternHits, walkDirFiles } from './scan.mts' - -export function checkFileFork( - row: FileForkRow, - manifest: Manifest, - area: string, - rootDir: string, -): FileForkReport { - const messages: string[] = [] - const upstream = resolveUpstream(manifest, row.upstream, messages) - const base: FileForkReport = { - kind: 'file-fork', - area, - id: row.id, - severity: 'ok', - messages, - local: row.local, - upstream: row.upstream, - upstream_path: row.upstream_path, - forked_at_sha: row.forked_at_sha, - drift: [], - } - if (!upstream) { - base.severity = 'error' - return base - } - const submoduleDir = path.join(rootDir, upstream.submodule) - const localPath = path.join(rootDir, row.local) - const upstreamFilePath = path.join(submoduleDir, row.upstream_path) - - if (!existsSync(localPath)) { - base.severity = 'error' - messages.push(`local file missing: ${row.local}`) - } - if (!existsSync(upstreamFilePath)) { - base.severity = 'error' - messages.push( - `upstream file missing — submodule out of date, or upstream_path stale`, - ) - } - if (!shaIsReachable(submoduleDir, row.forked_at_sha)) { - base.severity = 'error' - messages.push( - `forked_at_sha unreachable in submodule — submodule too shallow, or SHA typo`, - ) - } - if (base.severity === 'error') { - return base - } - const drift = driftCommitsSince( - submoduleDir, - row.forked_at_sha, - row.upstream_path, - ) - base.drift = drift - if (drift.length > 0) { - base.severity = 'drift' - messages.push( - `${drift.length} upstream commit(s) since fork — review for bugfixes/features`, - ) - } - return base -} - -export function checkVersionPin( - row: VersionPinRow, - manifest: Manifest, - area: string, - rootDir: string, -): VersionPinReport { - const messages: string[] = [] - const upstream = resolveUpstream(manifest, row.upstream, messages) - const base: VersionPinReport = { - kind: 'version-pin', - area, - id: row.id, - severity: 'ok', - messages, - upstream: row.upstream, - pinned_sha: row.pinned_sha, - pinned_tag: row.pinned_tag ?? undefined, - upgrade_policy: row.upgrade_policy, - head_sha: undefined, - drift_count: 0, - } - if (!upstream) { - base.severity = 'error' - return base - } - const submoduleDir = path.join(rootDir, upstream.submodule) - if (!existsSync(submoduleDir)) { - base.severity = 'error' - messages.push( - `submodule not checked out at ${upstream.submodule} — run \`git submodule update --init\``, - ) - return base - } - if (!shaIsReachable(submoduleDir, row.pinned_sha)) { - base.severity = 'error' - messages.push(`pinned_sha unreachable — submodule too shallow, or SHA typo`) - return base - } - let head = '' - try { - head = gitIn(submoduleDir, ['rev-parse', 'HEAD']).trim() - } catch { - base.severity = 'error' - messages.push(`could not read submodule HEAD`) - return base - } - base.head_sha = head - - if (head !== row.pinned_sha) { - base.severity = 'error' - messages.push( - `submodule HEAD (${head.slice(0, 12)}) does not match pinned_sha (${row.pinned_sha.slice(0, 12)}) — run \`git submodule update\``, - ) - return base - } - - // Count commits on the upstream default branch since pinned SHA. - let driftRef = '' - try { - const remoteRefs = gitIn(submoduleDir, [ - 'for-each-ref', - '--format=%(refname)', - 'refs/remotes/origin/', - ]) - const lines = splitLines(remoteRefs).filter(s => s.trim()) - const pref = [ - 'refs/remotes/origin/HEAD', - 'refs/remotes/origin/main', - // inclusive-language: external-api — git's historical default branch. - 'refs/remotes/origin/master', - ] - for (let i = 0, { length } = pref; i < length; i += 1) { - const p = pref[i]! - if (lines.includes(p)) { - driftRef = p - break - } - } - } catch { - // no remotes available — drift can't be computed; report OK with a note. - } - if (!driftRef) { - messages.push(`no origin remote ref found; cannot compute upstream drift`) - return base - } - try { - const count = gitIn(submoduleDir, [ - 'rev-list', - '--count', - `${row.pinned_sha}..${driftRef}`, - ]).trim() - const n = parseInt(count, 10) - if (!Number.isNaN(n) && n > 0) { - base.drift_count = n - base.severity = 'drift' - const tagSuffix = row.pinned_tag ? ` (from ${row.pinned_tag})` : '' - messages.push( - `${n} upstream commit(s) since pin${tagSuffix} on ${driftRef.replace('refs/remotes/', '')}`, - ) - } - } catch { - // silent — drift ref not fetched. - } - return base -} - -export function checkFeatureParity( - row: FeatureParityRow, - _manifest: Manifest, - area: string, - rootDir: string, -): FeatureParityReport { - const messages: string[] = [] - const base: FeatureParityReport = { - kind: 'feature-parity', - area, - id: row.id, - severity: 'ok', - messages, - upstream: row.upstream, - local_area: row.local_area, - criticality: row.criticality, - code_score: 0, - test_score: 0, - fixture_score: 0, - total_score: 0, - } - const localAreaPath = path.join(rootDir, row.local_area) - if (!existsSync(localAreaPath)) { - base.severity = 'error' - messages.push(`local_area path missing: ${row.local_area}`) - return base - } - - const codePatterns = row.code_patterns ?? [] - const testPatterns = row.test_patterns ?? [] - const codeFiles = walkDirFiles(localAreaPath, /\.(?:m?[jt]sx?|json)$/).filter( - f => !/[/\\](?:__tests__|test|tests)[/\\]|\.test\.|\.spec\./.test(f), - ) - - const codeScore = - codePatterns.length === 0 - ? 1 - : countPatternHits(codeFiles, codePatterns) / codePatterns.length - - // Test files: by default search local_area; if test_area is set, search - // that directory instead (sdxgen-style where tests live outside the - // parser directory). - const testAreaPath = path.join(rootDir, row.test_area ?? row.local_area) - const testAreaFiles = walkDirFiles(testAreaPath, /\.(?:m?[jt]sx?|json)$/) - const testFiles = row.test_area - ? testAreaFiles - : testAreaFiles.filter(f => - /[/\\](?:__tests__|test|tests)[/\\]|\.test\.|\.spec\./.test(f), - ) - const testScore = - testPatterns.length === 0 - ? 1 - : countPatternHits(testFiles, testPatterns) / testPatterns.length - - let fixtureScore = 1 - if (row.fixture_check) { - const fixturePath = path.join(rootDir, row.fixture_check.fixture_path) - if (!existsSync(fixturePath)) { - fixtureScore = 0 - messages.push(`fixture not found: ${row.fixture_check.fixture_path}`) - } else if (row.fixture_check.snapshot_path) { - const snapPath = path.join(rootDir, row.fixture_check.snapshot_path) - if (!existsSync(snapPath)) { - fixtureScore = 0 - messages.push( - `snapshot not found: ${row.fixture_check.snapshot_path} — run test suite to generate`, - ) - } - } - } - - base.code_score = Math.round(codeScore * 100) / 100 - base.test_score = Math.round(testScore * 100) / 100 - base.fixture_score = Math.round(fixtureScore * 100) / 100 - const total = 0.3 * codeScore + 0.3 * testScore + 0.4 * fixtureScore - base.total_score = Math.round(total * 100) / 100 - - // Floor: higher criticality = stricter. Cap at 0.85 so 10/10 criticality - // doesn't demand perfect pattern coverage (code is prose, patterns miss). - const floor = Math.min(0.85, row.criticality / 10) - if (total < floor) { - base.severity = 'drift' - messages.push( - `parity score ${base.total_score} below floor ${Math.round(floor * 100) / 100} (criticality ${row.criticality})`, - ) - } - return base -} - -export function checkSpecConformance( - row: SpecConformanceRow, - manifest: Manifest, - area: string, - rootDir: string, -): SpecConformanceReport { - const messages: string[] = [] - const upstream = resolveUpstream(manifest, row.upstream, messages) - const base: SpecConformanceReport = { - kind: 'spec-conformance', - area, - id: row.id, - severity: 'ok', - messages, - upstream: row.upstream, - local_impl: row.local_impl, - spec_version: row.spec_version, - spec_path: row.spec_path ?? undefined, - } - if (!upstream) { - base.severity = 'error' - return base - } - const localImplPath = path.join(rootDir, row.local_impl) - if (!existsSync(localImplPath)) { - base.severity = 'error' - messages.push(`local_impl missing: ${row.local_impl}`) - return base - } - if (row.spec_path) { - const specPath = path.join(rootDir, upstream.submodule, row.spec_path) - if (!existsSync(specPath)) { - base.severity = 'error' - messages.push(`spec_path missing in upstream submodule: ${row.spec_path}`) - return base - } - } - return base -} - -export function checkLangParity( - row: LangParityRow, - manifest: Manifest, - area: string, -): LangParityReport { - const messages: string[] = [] - const base: LangParityReport = { - kind: 'lang-parity', - area, - id: row.id, - severity: 'ok', - messages, - category: row.category, - ports: row.ports, - } - - const declaredSites = Object.keys(manifest.sites ?? {}) - if (declaredSites.length === 0) { - base.severity = 'error' - messages.push(`manifest has lang-parity rows but no top-level 'sites' map`) - return base - } - - for (let i = 0, { length } = declaredSites; i < length; i += 1) { - const site = declaredSites[i]! - if (!(site in row.ports)) { - base.severity = 'error' - messages.push(`port '${site}' missing (declared in sites)`) - } - } - for (const port of Object.keys(row.ports)) { - if (!declaredSites.includes(port)) { - base.severity = 'error' - messages.push(`port '${port}' not in sites map`) - } - const state = row.ports[port]! - if (state.status === 'opt-out' && (!state.reason || !state.reason.trim())) { - base.severity = 'error' - messages.push(`port '${port}' is opt-out without a reason`) - } - } - - if (row.category === 'rejected') { - for (const port of Object.keys(row.ports)) { - const state = row.ports[port]! - if (state.status !== 'opt-out') { - base.severity = 'drift' - messages.push( - `REJECTED anti-pattern reintroduced: port '${port}' is '${state.status}' (must be 'opt-out' for category=rejected)`, - ) - } - } - } - - return base -} - -// --------------------------------------------------------------------------- -// Cross-row consistency checks (beyond zod's per-row validation). -// --------------------------------------------------------------------------- - -/** - * Cross-row checks that zod validation can't express: unique ids, upstream refs - * resolve to the `upstreams` map, port keys resolve to the `sites` map. Zod's - * `LockstepManifestSchema.parse()` (called from `loadManifestTree`) already - * covers per-row shape, enum values, id pattern, and required fields — this is - * the referential-integrity layer on top. - */ -export function checkCrossRowConsistency( - rowsWithArea: Array<{ row: Row; area: string }>, - merged: Manifest, -): string[] { - const errors: string[] = [] - // Ids are unique per area, not globally. Same concept can legitimately - // appear in multiple areas (e.g. ultrathink has `transport-stdio` in both - // lsp and mcp). Scope the seen-set per area. - const seenIdsPerArea = new Map<string, Set<string>>() - const upstreamAliases = new Set(Object.keys(merged.upstreams ?? {})) - const siteKeys = new Set(Object.keys(merged.sites ?? {})) - - for (const { row, area } of rowsWithArea) { - const loc = `[${area}/${row.id}]` - - let areaIds = seenIdsPerArea.get(area) - if (!areaIds) { - areaIds = new Set() - seenIdsPerArea.set(area, areaIds) - } - if (areaIds.has(row.id)) { - errors.push(`${loc} duplicate id within area`) - } - areaIds.add(row.id) - - if ( - row.kind === 'feature-parity' || - row.kind === 'file-fork' || - row.kind === 'spec-conformance' || - row.kind === 'version-pin' - ) { - if (!upstreamAliases.has(row.upstream)) { - errors.push( - `${loc} upstream '${row.upstream}' not in upstreams map (known: ${[...upstreamAliases].join(', ') || '(none)'})`, - ) - } - } - - if (row.kind === 'lang-parity') { - for (const port of Object.keys(row.ports)) { - if (!siteKeys.has(port)) { - errors.push( - `${loc} port '${port}' not in sites map (known: ${[...siteKeys].join(', ') || '(none)'})`, - ) - } - } - } - } - - return errors -} diff --git a/scripts/lockstep/cli.mts b/scripts/lockstep/cli.mts deleted file mode 100644 index 5d2b6a7fb..000000000 --- a/scripts/lockstep/cli.mts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @file Lockstep harness CLI entry — dispatcher + `main()`. Reads - * `lockstep.json` (+ any `includes[]` sub-manifests) and validates each row - * against its upstream or sibling ports. Every supported `kind` has a - * checker; a repo populates its manifest only with the kinds it needs. Kinds: - * file-fork vendored upstream file with local deviations; drift = upstream - * moved since our fork SHA. version-pin submodule pinned to a specific - * SHA/tag; drift = upstream cut a new release (on default ref). - * feature-parity local impl should match an upstream behavior; three-pillar - * score: code + test + fixture snapshot. spec-conformance local impl of an - * external spec at a known version. lang-parity N sibling language ports of - * one spec; drift = port diverged, or rejected anti-pattern reintroduced on - * any port. Exit codes: 0 — manifest valid, no drift. 1 — schema violation, - * missing file, unreachable baseline, unknown kind. 2 — drift (upstream - * moved, parity below floor, rejected anti-pattern). Output: Default — - * human-readable, compact per-area summary + detailed rows. `--format=json` - * or `--json` — single JSON object for CI tooling. Sources and learnings: - * - * - file-fork and version-pin semantics: stuie (this repo). - * - feature-parity three-pillar scoring: sdxgen lock-step-features.json - * (snapshots replace the 20% tolerance). - * - lang-parity ports, rejected anti-pattern, per-area summaries, exit code 2 - * semantics: ultrathink/acorn/scripts/xlang-harness.mts. - */ - -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - checkCrossRowConsistency, - checkFeatureParity, - checkFileFork, - checkLangParity, - checkSpecConformance, - checkVersionPin, -} from './checks.mts' -import { loadManifestTree } from './manifest.mts' -import { emitHuman, summarize } from './report.mts' - -import type { Row } from './schema.mts' -import type { Manifest, Report } from './types.mts' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// scripts/lockstep/cli.mts → ../../ is the repo root. -const rootDir = path.resolve(__dirname, '..', '..') - -// --------------------------------------------------------------------------- -// Dispatcher. -// --------------------------------------------------------------------------- - -function evaluate( - rowsWithArea: Array<{ row: Row; area: string }>, - merged: Manifest, -): Report[] { - const reports: Report[] = [] - for (const { row, area } of rowsWithArea) { - switch (row.kind) { - case 'file-fork': - reports.push(checkFileFork(row, merged, area, rootDir)) - break - case 'version-pin': - reports.push(checkVersionPin(row, merged, area, rootDir)) - break - case 'feature-parity': - reports.push(checkFeatureParity(row, merged, area, rootDir)) - break - case 'spec-conformance': - reports.push(checkSpecConformance(row, merged, area, rootDir)) - break - case 'lang-parity': - reports.push(checkLangParity(row, merged, area)) - break - default: { - const anyRow = row as { kind: string; id: string } - reports.push({ - kind: 'file-fork', - area, - id: anyRow.id, - severity: 'error', - messages: [`no checker registered for kind '${anyRow.kind}'`], - local: '', - upstream: '', - upstream_path: '', - forked_at_sha: '', - drift: [], - }) - process.exitCode = 1 - } - } - } - return reports -} - -function main(): void { - const rootManifestPath = path.join(rootDir, 'lockstep.json') - const { areas, merged } = loadManifestTree(rootManifestPath) - - const rowsWithArea: Array<{ row: Row; area: string }> = [] - for (const { area, manifest } of areas) { - for (const row of manifest.rows) { - rowsWithArea.push({ row, area }) - } - } - - const crossRowErrors = checkCrossRowConsistency(rowsWithArea, merged) - if (crossRowErrors.length > 0) { - for (let i = 0, { length } = crossRowErrors; i < length; i += 1) { - const err = crossRowErrors[i]! - logger.fail(err) - } - logger.error( - `lockstep: ${crossRowErrors.length} cross-row error(s) — fix before running drift checks`, - ) - process.exit(1) - } - - const reports = evaluate(rowsWithArea, merged) - const summaries = summarize(reports) - - const jsonMode = - process.argv.includes('--json') || process.argv.includes('--format=json') - - if (jsonMode) { - process.stdout.write(JSON.stringify({ reports, summaries }, null, 2) + '\n') - const anyError = reports.some(r => r.severity === 'error') - const anyDrift = reports.some(r => r.severity === 'drift') - if (anyError) { - process.exitCode = 1 - } else if (anyDrift) { - process.exitCode = 2 - } - return - } - - const code = emitHuman(reports, summaries) - if (code !== 0) { - process.exitCode = code - } -} - -main() diff --git a/scripts/lockstep/emit-schema.mts b/scripts/lockstep/emit-schema.mts deleted file mode 100644 index f2b8134e6..000000000 --- a/scripts/lockstep/emit-schema.mts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file Emit `lockstep.schema.json` from the TypeBox schema. The TypeBox schema - * in `scripts/lockstep/schema.mts` is the source of truth. TypeBox schemas - * are JSON Schema natively — no conversion library needed, just serialize the - * schema object and add the draft-2020-12 meta headers. Run via `pnpm run - * lockstep:emit-schema` when the schema changes. - */ - -import { writeFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { LockstepManifestSchema } from './schema.mts' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// scripts/lockstep/emit-schema.mts → ../../ is the repo root. -const rootDir = path.resolve(__dirname, '..', '..') -const outPath = path.join(rootDir, 'lockstep.schema.json') - -// TypeBox schemas carry JSON Schema shape directly, plus a Symbol-keyed -// [Kind] marker that JSON.stringify drops. Spreading the schema first -// then layering the canonical $schema / $id / title on top gives a clean -// draft-2020-12 document with the Socket-specific headers. -const enriched = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://github.com/SocketDev/lockstep.schema.json', - title: 'lockstep manifest', - ...LockstepManifestSchema, -} - -writeFileSync(outPath, JSON.stringify(enriched, null, 2) + '\n', 'utf8') - -// Run oxfmt on the output so the file matches what oxfmt would -// produce. Without this, `pnpm run check --all` (which runs oxfmt -// over the tree) would flag the emitted schema as drifted on every -// repo that re-emits it. The schema is in IDENTICAL_FILES, so the -// formatted form is the byte-canonical form fleet-wide. -await spawn('pnpm', ['exec', 'oxfmt', '-c', '.config/oxfmtrc.json', outPath], { - cwd: rootDir, - stdio: 'inherit', -}) - -logger.success(`wrote ${path.relative(rootDir, outPath)}`) diff --git a/scripts/lockstep/git.mts b/scripts/lockstep/git.mts deleted file mode 100644 index 5ff5be75c..000000000 --- a/scripts/lockstep/git.mts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @file Git helpers for the lockstep harness. Thin wrappers over `git -C <dir> - * <cmd>` that the kind checkers (file-fork, version-pin) use to peek at - * submodule state without dragging in a full libgit binding. The harness is - * read-only — these helpers never mutate. `splitLines` is the CRLF-tolerant - * counterpart to `.split('\n')`; bare splits leave a trailing `\r` on each - * line when git is invoked on Windows / msys, which throws off downstream - * `includes`/match checks. `resolveUpstream` is a lookup helper that lives - * here because it's coupled to the same per-row-message accumulator the other - * helpers write to. - */ - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' - -import type { Upstream } from './schema.mts' -import type { DriftCommit, Manifest } from './types.mts' - -/** - * Split text on LF after CRLF normalization. Git on Windows / msys may emit - * CRLF-terminated output; bare `.split('\n')` leaves a trailing `\r` on every - * line that throws off downstream `includes`/match checks. - */ -export function splitLines(text: string): string[] { - return text.replace(/\r\n/g, '\n').split('\n') -} - -export function gitIn(submoduleDir: string, args: string[]): string { - const result = spawnSync('git', ['-C', submoduleDir, ...args], { - stdio: ['ignore', 'pipe', 'pipe'], - stdioString: true, - }) - if (result.error) { - throw result.error - } - if (result.status !== 0) { - throw new Error( - `git ${args.join(' ')} failed (status ${result.status}): ${String(result.stderr).trim()}`, - ) - } - return String(result.stdout) -} - -export function shaIsReachable(submoduleDir: string, sha: string): boolean { - try { - gitIn(submoduleDir, ['cat-file', '-e', sha]) - return true - } catch { - return false - } -} - -export function driftCommitsSince( - submoduleDir: string, - sha: string, - pathInRepo: string, -): DriftCommit[] { - try { - const out = gitIn(submoduleDir, [ - 'log', - '--pretty=format:%H%x09%s', - `${sha}..HEAD`, - '--', - pathInRepo, - ]) - const trimmed = out.trim() - if (!trimmed) { - return [] - } - return splitLines(trimmed).map(line => { - // Preserve any embedded tabs in the commit subject (rare but - // possible) — `.split` destructuring would truncate at the - // first tab inside the summary. - const [commitSha, ...summaryParts] = line.split('\t') - return { - sha: commitSha ?? '', - summary: summaryParts.join('\t') ?? '', - } - }) - } catch { - return [] - } -} - -export function resolveUpstream( - manifest: Manifest, - alias: string, - messages: string[], -): Upstream | undefined { - const upstream = manifest.upstreams?.[alias] - if (!upstream) { - const known = Object.keys(manifest.upstreams ?? {}).join(', ') || '(none)' - messages.push(`unknown upstream alias '${alias}' (known: ${known})`) - return undefined - } - return upstream -} diff --git a/scripts/lockstep/manifest.mts b/scripts/lockstep/manifest.mts deleted file mode 100644 index 55bcb9a80..000000000 --- a/scripts/lockstep/manifest.mts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file Manifest loading + sub-manifest tree resolution. `readManifest` parses - * one `lockstep.json` (or sub-manifest) and runs it through the TypeBox - * schema; schema failures terminate the process with exit 1 and a per-issue - * error trail (deeper than a single throw). `loadManifestTree` walks the - * top-level manifest's `includes[]` array, reads each sub-manifest, and - * produces a flattened view: per-area manifest list (preserving file - * boundaries for per-area reports) plus a merged view (upstreams + sites - * union, rows concatenated). The merge uses null-prototype maps to keep - * attacker-controlled manifest keys out of the prototype chain. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { validateSchema } from '@socketsecurity/lib-stable/schema/validate' - -import { LockstepManifestSchema } from './schema.mts' -import type { Row, Site, Upstream } from './schema.mts' - -import type { Manifest } from './types.mts' - -const logger = getDefaultLogger() - -export function readManifest(manifestPath: string): Manifest { - if (!existsSync(manifestPath)) { - logger.error(`lockstep: manifest not found at ${manifestPath}`) - process.exit(1) - } - let raw: unknown - try { - raw = JSON.parse(readFileSync(manifestPath, 'utf8')) - } catch (e) { - logger.error(`lockstep: could not parse ${manifestPath}`) - logger.fail(` ${errorMessage(e)}`) - process.exit(1) - } - const result = validateSchema(LockstepManifestSchema, raw) - if (result.ok) { - return result.value - } - logger.error(`lockstep: schema validation failed for ${manifestPath}`) - for (const issue of result.errors) { - const loc = issue.path.length ? issue.path.join('.') : '<root>' - logger.fail(` ${loc}: ${issue.message}`) - } - process.exit(1) -} - -/** - * Resolve a manifest + all its `includes[]` sub-manifests into a single - * flattened view. Each sub-manifest contributes its rows; the top-level - * upstreams/sites maps are merged (top-level wins on conflict). - */ -export function loadManifestTree(rootManifestPath: string): { - areas: Array<{ area: string; manifest: Manifest }> - merged: Manifest -} { - const rootManifest = readManifest(rootManifestPath) - const rootArea = rootManifest.area ?? 'root' - const areas: Array<{ area: string; manifest: Manifest }> = [ - { area: rootArea, manifest: rootManifest }, - ] - - const includes = rootManifest.includes ?? [] - const baseDir = path.dirname(rootManifestPath) - for (let i = 0, { length } = includes; i < length; i += 1) { - const rel = includes[i]! - const subPath = path.resolve(baseDir, rel) - const sub = readManifest(subPath) - const area = - sub.area ?? path.basename(rel, '.json').replace(/^lockstep-/, '') - areas.push({ area, manifest: sub }) - } - - // Null-prototype maps guard against prototype pollution via untrusted - // manifest keys. Double-cast through `unknown` so the - // `exactOptionalPropertyTypes + noUncheckedIndexedAccess` strict - // tsconfig in some repos accepts the `__proto__` sigil. - const mergedUpstreams: Record<string, Upstream> = { - __proto__: null, - } as unknown as Record<string, Upstream> - const mergedSites: Record<string, Site> = { - __proto__: null, - } as unknown as Record<string, Site> - - const mergedRows: Row[] = [] - // Include order, root last so it wins on duplicate keys. - for (const { manifest } of [...areas.slice(1), ...areas.slice(0, 1)]) { - for (const [k, v] of Object.entries(manifest.upstreams ?? {})) { - mergedUpstreams[k] = v - } - for (const [k, v] of Object.entries(manifest.sites ?? {})) { - mergedSites[k] = v - } - } - for (const { manifest } of areas) { - mergedRows.push(...manifest.rows) - } - return { - areas, - merged: { - upstreams: mergedUpstreams, - sites: mergedSites, - rows: mergedRows, - }, - } -} diff --git a/scripts/lockstep/report.mts b/scripts/lockstep/report.mts deleted file mode 100644 index a76ba657e..000000000 --- a/scripts/lockstep/report.mts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @file Human-readable rendering for lockstep reports. `summarize` produces the - * per-area roll-up (total / ok / drift / error counts, sorted by area name) - * consumed at the top of the human output and embedded in the `--json` - * payload. `emitHuman` is the default formatter; it writes the per-area - * summary table and then each row's detail block (banner, kind-specific - * facts, accumulated messages, file-fork drift commits). The return value is - * the exit code: 0 = clean, 1 = error in any row, 2 = drift in any row (per - * the harness contract documented at the top of `cli.mts`). Learned from - * ultrathink xlang-harness. - */ - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import type { Report } from './types.mts' - -const logger = getDefaultLogger() - -export interface AreaSummary { - area: string - total: number - ok: number - drift: number - error: number -} - -export function summarize(reports: Report[]): AreaSummary[] { - const byArea = new Map<string, AreaSummary>() - for (let i = 0, { length } = reports; i < length; i += 1) { - const r = reports[i]! - let s = byArea.get(r.area) - if (!s) { - s = { area: r.area, total: 0, ok: 0, drift: 0, error: 0 } - byArea.set(r.area, s) - } - s.total += 1 - s[r.severity] += 1 - } - return [...byArea.values()].toSorted((a, b) => a.area.localeCompare(b.area)) -} - -export function emitHuman(reports: Report[], summaries: AreaSummary[]): number { - logger.info( - `lockstep — ${reports.length} row(s) across ${summaries.length} area(s)`, - ) - logger.info('') - for (let i = 0, { length } = summaries; i < length; i += 1) { - const s = summaries[i]! - const label = s.area.padEnd(24) - const parts = `total=${String(s.total).padStart(3)} ok=${String(s.ok).padStart(3)} drift=${String(s.drift).padStart(3)} error=${String(s.error).padStart(3)}` - logger.info(` ${label}${parts}`) - } - logger.info('') - - let hadError = false - let hadDrift = false - for (let i = 0, { length } = reports; i < length; i += 1) { - const r = reports[i]! - const banner = `[${r.area}/${r.id}] (${r.kind})` - if (r.kind === 'file-fork') { - logger.info(banner) - logger.info(` local: ${r.local}`) - logger.info( - ` upstream: ${r.upstream}:${r.upstream_path} @ ${r.forked_at_sha.slice(0, 12)}`, - ) - } else if (r.kind === 'version-pin') { - logger.info(banner) - const tag = r.pinned_tag ? ` (${r.pinned_tag})` : '' - logger.info( - ` upstream: ${r.upstream} @ ${r.pinned_sha.slice(0, 12)}${tag}, policy=${r.upgrade_policy}`, - ) - } else if (r.kind === 'feature-parity') { - logger.info(banner) - logger.info( - ` upstream: ${r.upstream}, local_area: ${r.local_area}, criticality: ${r.criticality}`, - ) - logger.info( - ` scores: code=${r.code_score} test=${r.test_score} fixture=${r.fixture_score} total=${r.total_score}`, - ) - } else if (r.kind === 'spec-conformance') { - logger.info(banner) - logger.info( - ` upstream: ${r.upstream}, local_impl: ${r.local_impl}, spec_version: ${r.spec_version}`, - ) - } else if (r.kind === 'lang-parity') { - logger.info(banner) - logger.info(` category: ${r.category}`) - for (const [port, state] of Object.entries(r.ports)) { - const suffix = - state.status === 'opt-out' ? ` (${state.reason ?? ''})` : '' - logger.info(` ${port}: ${state.status}${suffix}`) - } - } - - for (const msg of r.messages) { - if (r.severity === 'error') { - logger.fail(` ${msg}`) - } else if (r.severity === 'drift') { - logger.warn(` ${msg}`) - } else { - logger.info(` ${msg}`) - } - } - - if (r.kind === 'file-fork') { - for (const c of r.drift) { - logger.info(` ${c.sha.slice(0, 12)} ${c.summary}`) - } - } - - if (r.severity === 'ok') { - logger.success(` ok`) - } else if (r.severity === 'error') { - hadError = true - } else if (r.severity === 'drift') { - hadDrift = true - } - logger.info('') - } - - if (hadError) { - return 1 - } - if (hadDrift) { - return 2 - } - return 0 -} diff --git a/scripts/lockstep/scan.mts b/scripts/lockstep/scan.mts deleted file mode 100644 index eaa0e7018..000000000 --- a/scripts/lockstep/scan.mts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file File-tree walker + regex matcher for the feature-parity scorer. - * `walkDirFiles` is a depth-first walker that ignores the usual noise - * directories (`node_modules`, `.git`, `dist`). `countPatternHits` is the - * regex-scoring loop the feature-parity check uses to compute the code and - * test pillars. Invalid manifest regexes log a warning instead of throwing, - * so one bad pattern doesn't sink an otherwise-clean lockstep run. - */ - -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' -import path from 'node:path' - -import { errorMessage } from '@socketsecurity/lib-stable/errors' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -export function walkDirFiles(dir: string, extRe: RegExp): string[] { - const files: string[] = [] - if (!existsSync(dir)) { - return files - } - const stack: string[] = [dir] - while (stack.length > 0) { - const current = stack.pop()! - let entries: string[] = [] - try { - entries = readdirSync(current) - } catch { - continue - } - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - if (entry === '.git' || entry === 'dist' || entry === 'node_modules') { - continue - } - const full = path.join(current, entry) - let stat - try { - stat = statSync(full) - } catch { - continue - } - if (stat.isDirectory()) { - stack.push(full) - } else if (stat.isFile() && extRe.test(entry)) { - files.push(full) - } - } - } - return files -} - -export function countPatternHits(files: string[], patterns: string[]): number { - if (patterns.length === 0) { - return 0 - } - // Manifest authors occasionally land a bad regex; surface the bad - // pattern and keep going rather than throwing a SyntaxError that - // kills the whole run. - const compiled: RegExp[] = [] - for (let i = 0, { length } = patterns; i < length; i += 1) { - const p = patterns[i]! - try { - compiled.push(new RegExp(p)) - } catch (e) { - logger.warn( - `lockstep: skipping invalid regex ${JSON.stringify(p)}: ${errorMessage(e)}`, - ) - } - } - let hits = 0 - for (let i = 0, { length } = compiled; i < length; i += 1) { - const pat = compiled[i]! - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i]! - let content: string - try { - content = readFileSync(file, 'utf8') - } catch { - continue - } - if (pat.test(content)) { - hits += 1 - break - } - } - } - return hits -} diff --git a/scripts/lockstep/schema.mts b/scripts/lockstep/schema.mts deleted file mode 100644 index dc51817c0..000000000 --- a/scripts/lockstep/schema.mts +++ /dev/null @@ -1,459 +0,0 @@ -/** - * @file TypeBox schema for lockstep.json — single source of truth. Everything - * else is derived: - * - * - TypeScript types in scripts/lockstep/cli.mts via `Static<typeof ...>` - * - lockstep.schema.json (draft 2020-12) via direct JSON.stringify of the - * TypeBox schema, emitted by scripts/lockstep/emit-schema.mts - * - Runtime validation at harness startup via - * `validateSchema(LockstepManifestSchema, ...)` from - * `@socketsecurity/lib-stable/validation/validate-schema` Byte-identical - * across sdxgen / socket-btm / socket-registry / socket-wheelhouse / stuie - * / ultrathink via sync-scaffolding.mts. - */ - -import { Type } from '@sinclair/typebox' -import type { Static } from '@sinclair/typebox' - -// --------------------------------------------------------------------------- -// Shared primitives. -// --------------------------------------------------------------------------- - -// Full git commit SHA. Used by file-fork.forked_at_sha and -// version-pin.pinned_sha. Centralized so adding a new SHA-bearing -// field can't accidentally accept short SHAs. -const FULL_SHA_PATTERN = '^[0-9a-f]{40}$' - -const IdSchema = Type.String({ - // Kebab-case enforced. The earlier "camelCase segments allowed for - // API-mirror ids" relaxation produced inconsistent ids across - // manifests. When an id needs to mirror an API name, namespace it: - // `api/findNodeAt` instead of `export-findNodeAt`. The slash carves - // out the camelCase segment without polluting top-level ids. - pattern: '^[a-z0-9][a-z0-9-]*(/[A-Za-z0-9_-]+)?$', - description: - 'Stable identifier, unique within the manifest. Kebab-case (lowercase letters / digits / hyphens). For ids that mirror an external API name, use a namespace prefix: `api/findNodeAt`, `node/parseURL`. The slash separates the kebab namespace from the free-form leaf.', -}) - -const CriticalitySchema = Type.Integer({ - minimum: 1, - maximum: 10, - description: - 'Stay-in-step importance. Anchors: 1 = cosmetic / nice-to-have; 5 = behavioral parity expected; 10 = security-sensitive. The harness surfaces high-criticality drift louder and gates feature-parity rows on the criticality/10 floor.', -}) - -const UpstreamRefSchema = Type.String({ - description: - 'Key into the top-level `upstreams` map. The harness errors if no matching upstream entry exists.', -}) - -const ConformanceTestSchema = Type.String({ - description: - 'Path (relative to repo root) of a test that enforces behavior parity (modulo documented deviations). Strongly recommended — static checks catch syntactic drift, not behavioral. A row without a conformance test relies entirely on code-pattern / fixture-snapshot checks.', -}) - -const NotesSchema = Type.String({ - description: - 'Free-form context: why this row exists, gotchas, links to related issues / PRs / upstream discussions. Read by humans, not by the harness.', -}) - -const PortStatusSchema = Type.Object( - { - status: Type.Union([Type.Literal('implemented'), Type.Literal('opt-out')], { - description: - "`implemented` = port meets the row's assertions; `opt-out` = port consciously skips this row (requires `reason`).", - }), - reason: Type.Optional( - Type.String({ - description: - 'Why this port opts out. SCHEMA-CONDITIONAL: required when status is `opt-out`. The TypeBox type cannot express the conditional, but the harness rejects opt-out rows with empty / missing reason.', - }), - ), - path: Type.Optional( - Type.String({ - description: - "Optional path to this port's implementation of the row. Useful for module-inventory rows where each language points at a different directory; redundant when the port's overall layout already encodes the path.", - }), - ), - note: Type.Optional( - Type.String({ - description: - "Optional free-form note attached to this specific port's status. For multi-port context, prefer the row-level `notes` field.", - }), - ), - }, - { - additionalProperties: false, - description: - 'Per-port status for a lang-parity row. The `ports` map on a row pairs each top-level `sites` key with one of these.', - }, -) - -const UpstreamSchema = Type.Object( - { - submodule: Type.String({ - description: - 'Submodule path, relative to repo root. Must match an entry in `.gitmodules`.', - }), - repo: Type.String({ - // Tightened from `^https?://` to require a host. Empty hosts - // (`http://`) silently match the loose pattern but break every - // git operation downstream. - pattern: '^https?://[^/\\s]+', - description: - 'Upstream repository URL (http:// or https:// + host). Anchored at the host so empty URLs fail validation rather than failing at git-fetch time.', - }), - }, - { - additionalProperties: false, - description: - 'A submodule + its upstream repo URL. Referenced by file-fork / version-pin / feature-parity / spec-conformance rows via `upstream`.', - }, -) - -const SiteSchema = Type.Object( - { - path: Type.String({ - description: - "Path to the port's root directory, relative to repo root. The harness reads files under this path when checking the port's assertions.", - }), - language: Type.Optional( - Type.String({ - description: - "Language label for human reports (e.g. `cpp`, `go`, `rust`, `typescript`). The harness does no language-specific processing — it's purely informational.", - }), - ), - }, - { - additionalProperties: false, - description: - 'A sibling port (typically per-language). Referenced by lang-parity rows via `ports.<site-key>`.', - }, -) - -const FixtureCheckSchema = Type.Object( - { - fixture_path: Type.String({ - description: - 'Path (relative to repo root) of the input fixture the local implementation runs against.', - }), - snapshot_path: Type.Optional( - Type.String({ - description: - "Path (relative to repo root) of the snapshot file the implementation's output is diffed against. When absent, the harness only checks that the fixture is processed without error — no output comparison.", - }), - ), - diff_tolerance: Type.Optional( - Type.Union( - [ - Type.Literal('exact'), - Type.Literal('line-by-line'), - Type.Literal('semantic'), - ], - { - description: - 'How the snapshot diff is computed. `exact` = byte-identical; the strictest check. `line-by-line` = per-line diff after normalizing line endings (CRLF / LF); tolerates trailing-newline drift. `semantic` = harness-defined deeper comparison (typically AST or normalized JSON for output that has equivalent representations); each row kind documents what `semantic` means in its context.', - }, - ), - ), - }, - { - additionalProperties: false, - description: - "Golden-input verification. Snapshot-based diffs replace the brittle hardcoded-count checks the harness used historically (sdxgen's lock-step-features lesson).", - }, -) - -// --------------------------------------------------------------------------- -// Row kinds. -// -// Five kinds, each tracking a different "stay in sync with X" relation: -// -// file-fork — vendored file derived from upstream -// version-pin — submodule pinned to upstream release -// feature-parity — local impl mirrors upstream behavior -// spec-conformance — local impl of an external spec -// lang-parity — N sibling language ports of one spec -// -// The `kind` literal on each row is the harness's dispatch key. Adding -// a new kind = (1) new row schema here, (2) new case in lockstep.mts' -// dispatcher, (3) new report-row type. The schema keeps row kinds -// closed (no Type.Union with `any`); harness errors on unknown kinds -// rather than silently skipping. -// --------------------------------------------------------------------------- - -const FileForkRowSchema = Type.Object( - { - kind: Type.Literal('file-fork'), - id: IdSchema, - upstream: UpstreamRefSchema, - criticality: Type.Optional(CriticalitySchema), - conformance_test: Type.Optional(ConformanceTestSchema), - notes: Type.Optional(NotesSchema), - local: Type.String({ - description: - 'Path (relative to repo root) of our ported copy of the upstream file.', - }), - upstream_path: Type.String({ - description: - 'Path within the upstream submodule (relative to the submodule root) of the source file we forked from.', - }), - forked_at_sha: Type.String({ - pattern: FULL_SHA_PATTERN, - description: - 'Full 40-char SHA of the upstream commit we forked from. The harness runs `git log <sha>..HEAD -- <upstream_path>` inside the submodule to surface drift.', - }), - deviations: Type.Array(Type.String(), { - minItems: 1, - description: - 'Human-readable list of intentional differences from upstream. Zero deviations = the file should not be forked; consume upstream directly. Each entry is one short sentence (e.g. `swap require() for import` or `remove Node 14 fallback`).', - }), - }, - { - additionalProperties: false, - description: - 'A local file derived from an upstream file with intentional modifications. Drift = upstream moved forward on this path; we may need to cherry-pick or update our deviations.', - }, -) - -const VersionPinRowSchema = Type.Object( - { - kind: Type.Literal('version-pin'), - id: IdSchema, - upstream: UpstreamRefSchema, - criticality: Type.Optional(CriticalitySchema), - conformance_test: Type.Optional(ConformanceTestSchema), - notes: Type.Optional(NotesSchema), - pinned_sha: Type.String({ - pattern: FULL_SHA_PATTERN, - description: - 'Full 40-char SHA the submodule is pinned to. Authoritative — the harness compares this against the submodule HEAD, not against `pinned_tag`.', - }), - pinned_tag: Type.Optional( - Type.String({ - description: - 'Human-readable release tag for reports / PR titles (e.g. `v3.2.1`). Informational only — `pinned_sha` is the source of truth. Useful when an upstream cuts a release without changing semver but moves the SHA.', - }), - ), - upgrade_policy: Type.Union( - [ - Type.Literal('track-latest'), - Type.Literal('major-gate'), - Type.Literal('locked'), - ], - { - description: - '`track-latest` = any new release is actionable; updating-lockstep auto-bumps. `major-gate` = patch / minor auto-bump; major bumps surfaced as advisory. `locked` = explicit decision per upgrade; the harness reports drift but never auto-bumps. Pick `locked` when bumping is gated on a coordinated change in another repo (e.g. Node vendoring temporal-rs).', - }, - ), - }, - { - additionalProperties: false, - description: - "A submodule pinned to an upstream release. Drift = upstream cut a new release we haven't adopted.", - }, -) - -const FeatureParityRowSchema = Type.Object( - { - kind: Type.Literal('feature-parity'), - id: IdSchema, - upstream: UpstreamRefSchema, - criticality: CriticalitySchema, - conformance_test: Type.Optional(ConformanceTestSchema), - notes: Type.Optional(NotesSchema), - local_area: Type.String({ - description: - 'Path (relative to repo root) of the local module / directory implementing the feature. The code-pattern scan targets this directory recursively, excluding test files (matched by `*.test.{ts,mts,js,mjs}` and `*.spec.*`).', - }), - test_area: Type.Optional( - Type.String({ - description: - 'Path (relative to repo root) of the directory where tests for this feature live. When absent, the harness searches for tests inside `local_area`. Useful when tests live in a sibling directory (e.g. `local_area=src/auth`, `test_area=test/auth`).', - }), - ), - code_patterns: Type.Optional( - Type.Array(Type.String(), { - description: - 'Regex patterns the local implementation must contain. Prefer anchored patterns (function signatures, exported symbols) over loose keywords to avoid matching comments. Each pattern is searched independently across `local_area`; missing patterns lower the code score.', - }), - ), - test_patterns: Type.Optional( - Type.Array(Type.String(), { - description: - 'Regex patterns the test suite must contain. Same scoring as `code_patterns` but searched across `test_area` (or `local_area` when `test_area` is absent).', - }), - ), - fixture_check: Type.Optional(FixtureCheckSchema), - }, - { - additionalProperties: false, - description: - 'A behavioral feature reimplemented locally to match upstream behavior. Three-pillar validation: code patterns + test patterns + fixture snapshot. The total score is averaged across present pillars; rows below the criticality / 10 floor surface as drift.', - }, -) - -const SpecConformanceRowSchema = Type.Object( - { - kind: Type.Literal('spec-conformance'), - id: IdSchema, - upstream: UpstreamRefSchema, - criticality: Type.Optional(CriticalitySchema), - conformance_test: Type.Optional(ConformanceTestSchema), - notes: Type.Optional(NotesSchema), - local_impl: Type.String({ - description: - 'Path (relative to repo root) of our reimplementation of the spec. Either a file or a directory.', - }), - spec_version: Type.String({ - description: - 'Version label of the spec we conform to (e.g. `ECMAScript-2024`, `RFC-9110`, commit SHA, or upstream tag). Free-form — the harness only checks for drift via the upstream submodule, not the version string itself.', - }), - spec_path: Type.Optional( - Type.String({ - description: - 'Path within the upstream submodule to the spec document. Used to scope drift detection to the spec file (rather than every change in the upstream repo).', - }), - ), - }, - { - additionalProperties: false, - description: - 'A local reimplementation of an external specification. Drift = the spec was revised; we may need to update our impl, the spec_version, or both.', - }, -) - -// Open-ended assertion shape — each lang-parity row attaches whatever -// shape its harness needs. -// -// Each assertion is a `{ kind: string, ... }` object: the harness reads -// `kind` and dispatches to a per-kind checker. Known kinds (subject to -// per-repo extension): -// -// `presence` — `{kind: 'presence', symbol: string}` -// `signature` — `{kind: 'signature', signature: string, where?: string}` -// `not-present` — `{kind: 'not-present', anti_pattern: string, where?: string}` -// -// Repos add new kinds in their own harness extensions. Unknown kinds -// are skipped with a log line — schema-level enumeration would couple -// the manifest to one harness's dispatch table. Historical precedent: -// ultrathink/acorn/scripts/xlang-harness.mts. -const AssertionSchema = Type.Record(Type.String(), Type.Unknown(), { - description: - 'A typed assertion the lang-parity row asserts on each port. Shape: `{kind: string, ...kind-specific fields}`. The lockstep harness dispatches on `kind`; per-kind contracts are documented in the harness, not here.', -}) - -const LangParityRowSchema = Type.Object( - { - kind: Type.Literal('lang-parity'), - id: IdSchema, - name: Type.String({ - description: - 'Short human-readable label for this row (e.g. `Range parsing`, `Async iterators`). Used in report headers; not parsed.', - }), - description: Type.String({ - description: - 'One-paragraph description of what behavior this row asserts on each port. Read by humans; not parsed.', - }), - category: Type.String({ - description: - "Grouping tag for report aggregation (e.g. `parser`, `runtime`, `api`). The single magic value is `rejected` — RESERVED for anti-patterns: every port MUST be `opt-out`, and any port flipping to `implemented` exits 2 ('rejected anti-pattern reintroduced'). Use freely otherwise.", - }), - criticality: Type.Optional(CriticalitySchema), - conformance_test: Type.Optional(ConformanceTestSchema), - notes: Type.Optional(NotesSchema), - assertions: Type.Optional( - Type.Array(AssertionSchema, { - description: - 'Assertions checked against each port. Each entry is `{kind: string, ...}`; the harness dispatches on `kind`. See AssertionSchema description for known kinds; unknown kinds skip with a log line. Mutually compatible with `matrix_files` (a row can have both, neither, or one).', - }), - ), - matrix_files: Type.Optional( - Type.Array(Type.String(), { - description: - 'Paths (relative to this manifest) of `lockstep-lang-*.json` sub-manifests this row indexes. For inventory-style rows that group many smaller checks under one parent. The harness loads each and merges its rows.', - }), - ), - ports: Type.Record(Type.String(), PortStatusSchema, { - description: - "Per-port status map. Keys MUST match top-level `sites` keys exactly — the harness errors on stray ports / missing sites. Each value is `{status: 'implemented' | 'opt-out', ...}` per PortStatusSchema.", - }), - }, - { - additionalProperties: false, - description: - 'N sibling language ports of one spec within a single project. Drift = a port diverged from its siblings (one implemented, others opt-out without reason / or vice versa), or a `rejected` anti-pattern was reintroduced.', - }, -) - -export const RowSchema = Type.Union([ - FileForkRowSchema, - VersionPinRowSchema, - FeatureParityRowSchema, - SpecConformanceRowSchema, - LangParityRowSchema, -]) - -// --------------------------------------------------------------------------- -// Top-level manifest. -// --------------------------------------------------------------------------- - -export const LockstepManifestSchema = Type.Object( - { - $schema: Type.Optional( - Type.String({ - description: - 'JSON Schema reference for editor autocompletion. Conventionally `./lockstep.schema.json` — both the manifest and its schema live side-by-side at repo root.', - }), - ), - description: Type.Optional( - Type.String({ - description: - 'Human-readable description of what this manifest tracks. Read by humans, not parsed. One short paragraph.', - }), - ), - area: Type.Optional( - Type.String({ - description: - "Optional label for this manifest file. Used as a grouping key in harness output (per-area summaries). Defaults to 'root' for the top-level file and to the filename stem (with the `lockstep-` prefix stripped) for included files.", - }), - ), - includes: Type.Optional( - Type.Array(Type.String(), { - description: - 'Relative paths to sub-manifests. The harness loads each and merges its rows into a single flattened view. Top-level `upstreams` and `sites` maps override any same-keyed entries from included manifests (top wins on conflict).', - }), - ), - upstreams: Type.Optional( - Type.Record(Type.String(), UpstreamSchema, { - description: - 'Named upstream submodules. Each entry pairs a submodule path with its repo URL. Referenced by rows[].upstream on file-fork / version-pin / feature-parity / spec-conformance rows. Omit when the manifest only has lang-parity rows.', - }), - ), - sites: Type.Optional( - Type.Record(Type.String(), SiteSchema, { - description: - 'Named sibling ports (typically per-language: `cpp`, `go`, `rust`, `typescript`). Referenced by rows[].ports.<site> on lang-parity rows. Omit when the manifest has no lang-parity rows.', - }), - ), - rows: Type.Array(RowSchema, { - description: - "The actual checks the harness runs. Empty array is valid (and expected for repos that have no upstream relationships — e.g. socket-cli's empty rows).", - }), - }, - { - description: - 'Unified lock-step manifest shared across Socket repos. One schema, all cases — the `kind` discriminator on each row selects which flavor of lock-step applies. Single-file manifests work for repos with one cohesive concern; the `includes[]` field carves a manifest into per-area files (e.g. lockstep-acorn.json + lockstep-build.json) when one repo tracks multiple independent concerns.', - }, -) - -export type Row = Static<typeof RowSchema> -export type LockstepManifest = Static<typeof LockstepManifestSchema> -export type Upstream = Static<typeof UpstreamSchema> -export type Site = Static<typeof SiteSchema> -export type PortStatus = Static<typeof PortStatusSchema> -export type FileForkRow = Static<typeof FileForkRowSchema> -export type VersionPinRow = Static<typeof VersionPinRowSchema> -export type FeatureParityRow = Static<typeof FeatureParityRowSchema> -export type SpecConformanceRow = Static<typeof SpecConformanceRowSchema> -export type LangParityRow = Static<typeof LangParityRowSchema> diff --git a/scripts/lockstep/types.mts b/scripts/lockstep/types.mts deleted file mode 100644 index 3bd7ae0df..000000000 --- a/scripts/lockstep/types.mts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file Report types and shared aliases for the lockstep harness. Each row kind - * in the manifest produces a typed report row — the dispatcher in `cli.mts` - * is exhaustively typed on the `Report` union below so the formatter can read - * each kind's payload without `any` casts. `Severity` is the tri-state every - * report carries: `ok` (no drift), `drift` (consumer needs to look), `error` - * (manifest is broken). Exit codes map 0 / 2 / 1 respectively. - */ - -import type { LockstepManifest, PortStatus } from './schema.mts' - -export type Manifest = LockstepManifest - -// --------------------------------------------------------------------------- -// Report types — one per kind so dispatcher output is typed precisely. -// --------------------------------------------------------------------------- - -export type Severity = 'ok' | 'drift' | 'error' - -export interface ReportBase { - area: string - id: string - severity: Severity - messages: string[] -} - -export interface DriftCommit { - sha: string - summary: string -} - -export interface FileForkReport extends ReportBase { - kind: 'file-fork' - local: string - upstream: string - upstream_path: string - forked_at_sha: string - drift: DriftCommit[] -} - -export interface VersionPinReport extends ReportBase { - kind: 'version-pin' - upstream: string - pinned_sha: string - pinned_tag: string | undefined - upgrade_policy: string - head_sha: string | undefined - drift_count: number -} - -export interface FeatureParityReport extends ReportBase { - kind: 'feature-parity' - upstream: string - local_area: string - criticality: number - code_score: number - test_score: number - fixture_score: number - total_score: number -} - -export interface SpecConformanceReport extends ReportBase { - kind: 'spec-conformance' - upstream: string - local_impl: string - spec_version: string - spec_path: string | undefined -} - -export interface LangParityReport extends ReportBase { - kind: 'lang-parity' - category: string - ports: Record<string, PortStatus> -} - -export type Report = - | FileForkReport - | VersionPinReport - | FeatureParityReport - | SpecConformanceReport - | LangParityReport diff --git a/scripts/paths.mts b/scripts/paths.mts deleted file mode 100644 index 51052bdce..000000000 --- a/scripts/paths.mts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * @file Canonical path constants + resolvers for this package. Mantra: 1 path, - * 1 reference. Every path the scripts in this directory need — config files, - * lockfiles, build outputs, cache dirs, manifest files — gets constructed - * exactly once here. Every consumer imports the constructed value. A future - * rename or relocation is a one-file edit; consumers don't have to be - * re-audited. Per-package, like package.json: every package that has its own - * `scripts/` directory has its own `paths.mts`. A sub-package can inherit - * from a parent's paths.mts by re-exporting: // packages/foo/bar/paths.mts - * export * from '../../../scripts/paths.mts' // Add sub-package-specific - * overrides below the export line. export const FOO_BAR_DIST = - * path.join(REPO_ROOT, 'packages', 'foo', 'bar', 'dist') Consumers resolve - * `paths.mts` the same way Node resolves `package.json` — relative to the - * importing file's location, with `..`-walks finding the nearest one. Two - * flavors of path live in this file: - * - * 1. STATIC CONSTANTS — paths that don't depend on runtime input. Example: - * `REPO_ROOT`, `CONFIG_DIR`, `NODE_MODULES_CACHE_DIR`. Importable as-is. - * 2. RESOLVER FUNCTIONS — paths that need a search (multiple accepted locations) - * or runtime input (a target directory, a package name). Example: - * `findSocketWheelhouseConfig(repoRoot)` returns the first of - * `.config/socket-wheelhouse.json` or `.socket-wheelhouse.json` that - * exists. Resolution from script call sites: every script anchors on its - * own location via `fileURLToPath(import.meta.url)`, then walks up to the - * package.json-bearing ancestor. `process.cwd()` is forbidden in scripts/ - * per fleet rule (the user / Claude Code may invoke from any subdir). - * - * @see The fleet rule: CLAUDE.md "1 path, 1 reference" and the - * `socket/no-process-cwd-in-scripts-hooks` oxlint rule. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -// --------------------------------------------------------------------------- -// REPO-ROOT resolver — used to anchor every other path. -// --------------------------------------------------------------------------- - -/** - * Walk up from this module's own location to find the repo root — the nearest - * ancestor that has a `package.json`. Cached per-process since the answer - * doesn't change at runtime. - * - * @throws If no package.json ancestor exists (= we're not in a repo). - */ -export function resolveRepoRoot(): string { - let cur = path.dirname(fileURLToPath(import.meta.url)) - const root = path.parse(cur).root - while (cur && cur !== root) { - if (existsSync(path.join(cur, 'package.json'))) { - return cur - } - const parent = path.dirname(cur) - if (parent === cur) { - break - } - cur = parent - } - throw new Error( - `Could not resolve repo root from ${fileURLToPath(import.meta.url)} ` + - '(no ancestor has package.json).', - ) -} - -/** - * Absolute path to the repo root (nearest `package.json` ancestor). - */ -export const REPO_ROOT = resolveRepoRoot() - -// --------------------------------------------------------------------------- -// Static directory + file constants. -// --------------------------------------------------------------------------- - -/** - * Absolute path to the repo's `.config/` directory. - */ -export const CONFIG_DIR = path.join(REPO_ROOT, '.config') - -/** - * Absolute path to the repo's `node_modules/` directory. - */ -export const NODE_MODULES_DIR = path.join(REPO_ROOT, 'node_modules') - -/** - * Absolute path to the repo's tool-cache directory. Fleet convention: every - * per-repo tool cache lives here (vitest, taze, our own audit caches, etc.). - * Auto-gitignored via the fleet's `**∕.cache/` rule. Build tools also write - * here (oxlint, etc.). - */ -// oxlint-disable-next-line socket/prefer-node-modules-dot-cache -- NODE_MODULES_DIR is the canonical node_modules root; the rule's per-arg check can't see through identifiers. -export const NODE_MODULES_CACHE_DIR = path.join(NODE_MODULES_DIR, '.cache') - -/** - * Absolute path to the repo's `pnpm-workspace.yaml`. - */ -export const PNPM_WORKSPACE_YAML = path.join(REPO_ROOT, 'pnpm-workspace.yaml') - -/** - * Absolute path to the repo's `package.json`. - */ -export const PACKAGE_JSON = path.join(REPO_ROOT, 'package.json') - -/** - * Absolute path to the repo's `pnpm-lock.yaml`. - */ -export const PNPM_LOCK = path.join(REPO_ROOT, 'pnpm-lock.yaml') - -// --------------------------------------------------------------------------- -// socket-wheelhouse.json resolver. -// -// Two locations are accepted (matches the rest of the fleet's -// resolution shape — see `scripts/socket-wheelhouse-schema.mts` for -// the TypeBox schema, and `scripts/sync-scaffolding/socket-wheelhouse- -// config.mts` for the wheelhouse-side validator): -// -// 1. `.config/socket-wheelhouse.json` (primary; lives next to other -// tooling configs) -// 2. `.socket-wheelhouse.json` at repo root (legacy; useful for -// repos that prefer root-level dotfile discovery) -// -// The primary path wins when both exist; the loader emits a stderr -// note so a stray duplicate is visible. Neither is deprecated. -// -// This module deliberately does NOT validate the schema beyond -// "valid JSON object" — schema validation lives in the wheelhouse- -// side helper. Downstream consumers typically just need to read a -// single field (e.g. `github.apps`) and don't want the cost of a -// full TypeBox validate-pass on every audit. -// --------------------------------------------------------------------------- - -const SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL = '.config/socket-wheelhouse.json' -const SOCKET_WHEELHOUSE_CONFIG_LEGACY_REL = '.socket-wheelhouse.json' - -export interface SocketWheelhouseConfigLocation { - /** - * Absolute path to the file that was actually read. - */ - readonly path: string - /** - * Which of the two accepted locations was used. - */ - readonly kind: 'primary' | 'legacy' -} - -export interface LoadedSocketWheelhouseConfig { - readonly location: SocketWheelhouseConfigLocation - /** - * Parsed JSON root. Always an object; non-object payloads cause `undefined`. - */ - readonly value: Record<string, unknown> -} - -/** - * Find the socket-wheelhouse.json under `repoRoot` (defaults to the current - * repo's root). Returns the first matching location, or `undefined` if neither - * file exists. When both exist, emits a stderr warning + returns the primary - * location. - */ -export function findSocketWheelhouseConfig( - repoRoot: string = REPO_ROOT, -): SocketWheelhouseConfigLocation | undefined { - const primary = path.join(repoRoot, SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL) - const legacy = path.join(repoRoot, SOCKET_WHEELHOUSE_CONFIG_LEGACY_REL) - const primaryExists = existsSync(primary) - const legacyExists = existsSync(legacy) - if (primaryExists && legacyExists) { - process.stderr.write( - `[socket-wheelhouse] both ${SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL} ` + - `and ${SOCKET_WHEELHOUSE_CONFIG_LEGACY_REL} exist in ${repoRoot}; ` + - `using ${SOCKET_WHEELHOUSE_CONFIG_PRIMARY_REL}. Delete one to ` + - `silence this note.\n`, - ) - } - if (primaryExists) { - return { path: primary, kind: 'primary' } - } - if (legacyExists) { - return { path: legacy, kind: 'legacy' } - } - return undefined -} - -/** - * Load + parse the socket-wheelhouse.json under `repoRoot` (defaults to the - * current repo's root). Returns `undefined` on absent / unreadable / - * unparseable / non-object root — every failure shape collapses to "no config" - * since downstream audits should fail-open when the config is unavailable. - */ -export function loadSocketWheelhouseConfig( - repoRoot: string = REPO_ROOT, -): LoadedSocketWheelhouseConfig | undefined { - const location = findSocketWheelhouseConfig(repoRoot) - if (!location) { - return undefined - } - let raw: string - try { - raw = readFileSync(location.path, 'utf8') - } catch { - return undefined - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return undefined - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return undefined - } - return { - location, - value: parsed as Record<string, unknown>, - } -} diff --git a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs b/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs deleted file mode 100644 index b04b24391..000000000 --- a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.files/scripts/lib/read-stdin-sync.mjs +++ /dev/null @@ -1,39 +0,0 @@ -// Sidecar shipped by codex-1.0.1-stdin-eagain.patch (socket-wheelhouse). -// -// Robust synchronous stdin read for Claude Code hooks. The plugin's hooks -// originally did `fs.readFileSync(0, "utf8")`, which throws EAGAIN the instant -// Claude Code hands the hook a non-blocking stdin pipe (O_NONBLOCK set) with no -// bytes buffered yet. This reads in a loop instead, sleeping ~2ms on EAGAIN -// (Atomics.wait blocks the thread without a busy spin) until EOF. -// -// Kept as a standalone module — not inlined into the patch — so the patch's -// diff footprint stays tiny (an import + two call-site swaps). The reapply step -// in install-claude-plugins.mts copies this file into the cache before applying -// the diff. Provenance + lifecycle: docs/claude.md/fleet/plugin-cache-patches.md. - -import fs from "node:fs"; - -export function readStdinSync() { - const chunks = []; - const buf = Buffer.alloc(65536); - for (;;) { - let bytesRead; - try { - bytesRead = fs.readSync(0, buf, 0, buf.length, null); - } catch (e) { - if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); - continue; - } - if (e && e.code === "EOF") { - break; - } - throw e; - } - if (bytesRead === 0) { - break; - } - chunks.push(Buffer.from(buf.subarray(0, bytesRead))); - } - return Buffer.concat(chunks).toString("utf8"); -} diff --git a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch b/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch deleted file mode 100644 index cbc674402..000000000 --- a/scripts/plugin-patches/codex-1.0.1-stdin-eagain.patch +++ /dev/null @@ -1,79 +0,0 @@ -# @plugin: codex -# @plugin-version: 1.0.1 -# @sha: 9cb4fe4099195b2587c402117a3efce6ab5aac78 -# @upstream: https://github.com/openai/codex-plugin-cc -# @description: Fix EAGAIN crash when hooks read stdin from a non-blocking pipe -# -# Three hook entry points read their JSON payload with a bare -# fs.readFileSync(0, "utf8"). When Claude Code hands the hook a stdin pipe with -# O_NONBLOCK set, that synchronous read throws "EAGAIN: resource temporarily -# unavailable" the instant no bytes are buffered yet, killing the hook before it -# parses its input. The fix routes all three through readStdinSync(), a loop -# over fs.readSync(0) that sleeps ~2ms on EAGAIN (Atomics.wait, no busy spin) -# until EOF. -# -# Smallest-footprint form: the readStdinSync() body ships as a sidecar module -# (scripts/lib/read-stdin-sync.mjs, in this patch's companion .files/ dir, copied -# into the cache by install-claude-plugins.mts before the diff is applied), so -# this diff is just an import + a call-site swap per file rather than 30 inlined -# lines. Stopgap until fixed upstream; on a fixing release, bump the marketplace -# SHA pin and delete this patch + sidecar + manifest entry. Regenerate against a -# new pin via the regenerating-plugin-patches skill. Spec: -# docs/claude.md/fleet/plugin-cache-patches.md. -# ---- a/scripts/lib/fs.mjs -+++ b/scripts/lib/fs.mjs -@@ -1,6 +1,8 @@ - import fs from "node:fs"; - import os from "node:os"; - import path from "node:path"; -+ -+import { readStdinSync } from "./read-stdin-sync.mjs"; - - export function ensureAbsolutePath(cwd, maybePath) { - return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath); -@@ -36,5 +38,5 @@ - if (process.stdin.isTTY) { - return ""; - } -- return fs.readFileSync(0, "utf8"); -+ return readStdinSync(); - } ---- a/scripts/stop-review-gate-hook.mjs -+++ b/scripts/stop-review-gate-hook.mjs -@@ -7,6 +7,7 @@ - import { fileURLToPath } from "node:url"; - - import { getCodexLoginStatus } from "./lib/codex.mjs"; -+import { readStdinSync } from "./lib/read-stdin-sync.mjs"; - import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; - import { getConfig, listJobs } from "./lib/state.mjs"; - import { sortJobsNewestFirst } from "./lib/job-control.mjs"; -@@ -19,7 +20,7 @@ - const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; - - function readHookInput() { -- const raw = fs.readFileSync(0, "utf8").trim(); -+ const raw = readStdinSync().trim(); - if (!raw) { - return {}; - } ---- a/scripts/session-lifecycle-hook.mjs -+++ b/scripts/session-lifecycle-hook.mjs -@@ -4,6 +4,7 @@ - import process from "node:process"; - - import { terminateProcessTree } from "./lib/process.mjs"; -+import { readStdinSync } from "./lib/read-stdin-sync.mjs"; - import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs"; - import { - clearBrokerSession, -@@ -20,7 +21,7 @@ - const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; - - function readHookInput() { -- const raw = fs.readFileSync(0, "utf8").trim(); -+ const raw = readStdinSync().trim(); - if (!raw) { - return {}; - } diff --git a/scripts/power-state.mts b/scripts/power-state.mts deleted file mode 100644 index 79ed5817b..000000000 --- a/scripts/power-state.mts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @file Detect whether the host is currently on AC power (vs battery). Used by - * long-running build/test scripts to size timeouts adaptively — laptops on - * battery throttle CPU hard (especially macOS), and a static timeout that - * fits AC will kill an otherwise-healthy run on battery. Two paths, in - * priority order: - * - * 1. `node:smol-power` — when running inside a node-smol binary that ships the - * smol_power native binding (socket-btm's custom Node distribution). Pure - * C++ syscalls, sub-millisecond. - * 2. Shellout fallback — system Node doesn't have node:smol-power. Each platform - * has a different mechanism: - * - * - macOS: `pmset -g batt` parses "AC Power" / "Battery Power" - * - Linux: reads /sys/class/power_supply/<entry>/online (no shellout, just - * open/read syscalls) - * - Windows: PowerShell `Get-CimInstance Win32_Battery` On detection failure we - * conservatively assume AC — the downstream timeout becomes the shorter / - * more aggressive value, which is appropriate for build servers and - * headless CI (those environments are expected to run at full speed). - * Returns a Promise so callers don't block the event loop on shellout - * paths. Byte-identical across the fleet via socket-wheelhouse's - * sync-scaffolding (IDENTICAL_FILES). - */ - -import { Buffer } from 'node:buffer' -import { existsSync, promises as fs } from 'node:fs' -import { isBuiltin } from 'node:module' -import path from 'node:path' -import process from 'node:process' - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -// Probe for node:smol-power. Lives in socket-btm's node-smol binary -// — `isBuiltin()` returns true on those builds and false on system -// Node, so we only attempt the dynamic import when the module is -// actually available. -interface SmolPower { - isOnAcPower: () => boolean -} -let cachedSmolPower: SmolPower | undefined -let smolPowerProbed = false -async function getSmolPower(): Promise<SmolPower | undefined> { - if (smolPowerProbed) { - return cachedSmolPower - } - smolPowerProbed = true - if (!isBuiltin('node:smol-power')) { - return undefined - } - // Cast through `unknown` because system Node's typings don't - // declare the module — only node-smol's lib.d.ts does. - cachedSmolPower = (await import( - 'node:smol-power' as string - )) as unknown as SmolPower - return cachedSmolPower -} - -// Coerce spawn's stdout (string | Buffer | undefined) to a string. -function stdoutString(value: unknown): string { - if (typeof value === 'string') { - return value - } - if (value instanceof Uint8Array) { - return Buffer.from(value).toString('utf8') - } - return '' -} - -async function detectMacOs(): Promise<boolean> { - try { - // `pmset -g batt` on macOS prints lines like - // Now drawing from 'AC Power' - // Now drawing from 'Battery Power' - // Match the AC variant; everything else (battery, unknown) is - // treated as not-AC. - const result = await spawn('pmset', ['-g', 'batt'], { - stdio: ['ignore', 'pipe', 'ignore'], - }) - return /AC Power/.test(stdoutString(result.stdout)) - } catch { - return true - } -} - -async function detectLinux(): Promise<boolean> { - // Linux exposes power state under /sys/class/power_supply. Each - // AC adapter is its own dir (`AC`, `ADP1`, `AC0`, `ACAD`, …) - // with an `online` file holding "1" when power is connected. - // Containers and headless servers often have no power_supply - // tree at all — treat that as AC since those environments are - // expected to run at full speed. - const psDir = '/sys/class/power_supply' - if (!existsSync(psDir)) { - return true - } - try { - const entries = await fs.readdir(psDir) - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const onlineFile = path.join(psDir, entry, 'online') - if (!existsSync(onlineFile)) { - continue - } - try { - const value = await fs.readFile(onlineFile, 'utf8') - if (value.trim() === '1') { - return true - } - } catch { - // Unreadable entry — skip; another entry may report. - } - } - } catch { - // Directory enumeration failed — fall through to AC. - return true - } - return false -} - -async function detectWindows(): Promise<boolean> { - try { - // Windows: query the battery status via PowerShell + CIM. - // `Win32_Battery.BatteryStatus`: - // 1 = Discharging (battery) - // 2 = On AC, not charging or fully charged - // 3..5 = Various battery states - // 6 = AC + charging - // Desktops with no battery return an empty result; treat as AC. - const result = await spawn( - 'powershell.exe', - [ - '-NoProfile', - '-Command', - '(Get-CimInstance -ClassName Win32_Battery).BatteryStatus', - ], - { stdio: ['ignore', 'pipe', 'ignore'] }, - ) - const trimmed = stdoutString(result.stdout).trim() - if (trimmed === '') { - return true - } - const status = Number.parseInt(trimmed, 10) - if (Number.isNaN(status)) { - return true - } - return status === 2 || status === 6 - } catch { - return true - } -} - -/** - * Returns `true` if the host is on AC power. Conservative on detection failure - * (returns `true`) — callers using this for timeout sizing prefer a longer - * timeout to a too-short one. - * - * Prefers the native binding (`node:smol-power`) when running inside a - * node-smol binary; falls back to a per-platform path (shellout on macOS / - * Windows, direct sysfs reads on Linux) on system Node. - */ -export async function isOnAcPower(): Promise<boolean> { - const native = await getSmolPower() - if (native) { - return native.isOnAcPower() - } - if (process.platform === 'darwin') { - return await detectMacOs() - } - if (process.platform === 'linux') { - return await detectLinux() - } - if (process.platform === 'win32') { - return await detectWindows() - } - // Unsupported platform; conservative default. - return true -} diff --git a/scripts/prepare-package-for-publish.mts b/scripts/prepare-package-for-publish.mts deleted file mode 100644 index 4af02468f..000000000 --- a/scripts/prepare-package-for-publish.mts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @file Helper script to prepare package.json for publishing. Handles removing - * private field and optionally setting version. - */ - -import path from 'node:path' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { preparePackageForPublish } from 'package-builder/scripts/util/prepare-package.mts' - -const logger = getDefaultLogger() - -const args = process.argv.slice(2) -const packagePath: string | undefined = args[0] -const version: string | undefined = args[1] - -if (!packagePath) { - logger.error( - 'Usage: prepare-package-for-publish.mts <package-path> [version]', - ) - process.exitCode = 1 -} else { - try { - preparePackageForPublish(resolve(packagePath), { version }) - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Error preparing package: ${message}`) - process.exitCode = 1 - } -} diff --git a/scripts/prepublish-socketbin.mts b/scripts/prepublish-socketbin.mts deleted file mode 100644 index 6c995a4a0..000000000 --- a/scripts/prepublish-socketbin.mts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @file Prepares @socketbin/* binary packages for publishing. Updates - * package.json with version and buildMethod, removes private field. Binary is - * already in place from SEA build (following biome convention). - */ - -import { existsSync } from 'node:fs' - -import { parseArgs } from '@socketsecurity/lib-stable/argv/parse' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { - getSocketbinBinaryPath, - getSocketbinPackageDir, -} from 'package-builder/scripts/paths.mts' -import { preparePackageForPublish } from 'package-builder/scripts/util/prepare-package.mts' - -const logger = getDefaultLogger() - -const { values } = parseArgs({ - options: { - arch: { type: 'string' }, - dev: { type: 'boolean' }, - libc: { type: 'string' }, - method: { default: 'sea', type: 'string' }, - platform: { type: 'string' }, - prod: { type: 'boolean' }, - version: { type: 'string' }, - }, -}) - -const { - arch, - libc, - method: buildMethod, - platform, - version: providedVersion, -} = values - -if (!platform || !arch) { - logger.error( - 'Usage: prepublish-socketbin.mts --platform=darwin --arch=arm64 --version=2.1.0 [--method=sea]', - ) - process.exitCode = 1 -} else if (!providedVersion) { - logger.error('--version is required') - process.exitCode = 1 -} else { - // Get package directory from centralized paths. - const packageDir = getSocketbinPackageDir(platform, arch, libc) - - // Verify binary exists (should be built by SEA build). - const binaryPath = getSocketbinBinaryPath(platform, arch, libc) - if (!existsSync(binaryPath)) { - logger.error(`Binary not found at ${binaryPath}`) - logger.error('Run SEA build first: pnpm run build:sea') - process.exitCode = 1 - } else { - const version = providedVersion.replace(/^v/, '') - - // Prepare package for publishing. - const { name } = preparePackageForPublish(packageDir, { - buildMethod, - version, - }) - - logger.log(` Version: ${version}`) - logger.log(` Build method: ${buildMethod}`) - logger.log(` Binary: ${binaryPath}`) - logger.log('') - logger.log(`Package ready for publishing at: ${packageDir}`) - logger.log('') - logger.log('To publish:') - logger.log(` cd ${packageDir}`) - logger.log(' npm publish --provenance --access public') - } -} diff --git a/scripts/rollup/socket-modify-plugin.js b/scripts/rollup/socket-modify-plugin.js new file mode 100644 index 000000000..17e247d45 --- /dev/null +++ b/scripts/rollup/socket-modify-plugin.js @@ -0,0 +1,45 @@ +'use strict' + +const { createFilter } = require('@rollup/pluginutils') +const MagicString = require('magic-string') + +function socketModifyPlugin({ + exclude, + find, + include, + replace, + sourcemap = true, +}) { + const filter = createFilter(include, exclude) + return { + name: 'socket-modify', + renderChunk(code, { fileName }) { + if (!filter(fileName)) { + return null + } + const s = new MagicString(code) + const { global } = find + find.lastIndex = 0 + let match + while ((match = find.exec(code)) !== null) { + s.overwrite( + match.index, + match.index + match[0].length, + typeof replace === 'function' + ? Reflect.apply(replace, match, match) + : String(replace), + ) + // Exit early if not a global regexp. + if (!global) { + break + } + } + return { + code: s.toString(), + map: sourcemap ? s.generateMap() : null, + } + }, + } +} + +module.exports = socketModifyPlugin diff --git a/scripts/security.mts b/scripts/security.mts deleted file mode 100644 index 9430d4430..000000000 --- a/scripts/security.mts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file Canonical fleet scanning-security runner. Runs the two static-analysis - * tools the fleet uses for local security checks before push: - * - * 1. AgentShield — scans `.claude/` config for prompt-injection, leaked secrets, - * and overly-permissive tool permissions. - * 2. zizmor — static analysis for `.github/workflows/*.yml` (unpinned actions, - * secret exposure, template injection, permission issues). Either tool - * missing prints a "run pnpm run setup-security-tools" hint (which - * downloads + verifies the pinned binary via the setup-security-tools hook - * + prompts for a Socket API token if none is stored) and skips that scan - * rather than failing the entire run. Cross-platform: uses `which` from - * `@socketsecurity/lib-stable/bin` for binary discovery (handles Windows - * .exe/.cmd resolution; returns null rather than throwing on miss) and - * `spawn` from `@socketsecurity/lib-stable/spawn` for proper async - * lifecycle. Wired in via `package.json`: "security": "node - * scripts/security.mts" Byte-identical across every fleet repo. - * Sync-scaffolding flags drift. - */ - -import process from 'node:process' - -import { which } from '@socketsecurity/lib-stable/bin/which' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -async function hasExecutable(name: string): Promise<boolean> { - // socket-lib's `which` returns null when the binary isn't on PATH - // (no throw), so a simple truthy check suffices. - return Boolean(await which(name)) -} - -async function runTool(command: string, args: string[]): Promise<number> { - try { - const result = await spawn(command, args, { - stdio: 'inherit', - shell: WIN32, - }) - return result.code ?? 1 - } catch (e) { - if (e && typeof e === 'object' && 'code' in e) { - const code = (e as { code: unknown }).code - return typeof code === 'number' ? code : 1 - } - throw e - } -} - -async function main(): Promise<void> { - if (!(await hasExecutable('agentshield'))) { - logger.info( - 'agentshield not installed; run "pnpm run setup-security-tools" to install', - ) - } else { - const agentshieldCode = await runTool('agentshield', ['scan']) - if (agentshieldCode !== 0) { - process.exitCode = agentshieldCode - return - } - } - - if (!(await hasExecutable('zizmor'))) { - logger.info( - 'zizmor not installed; run "pnpm run setup-security-tools" to install', - ) - return - } - - const zizmorCode = await runTool('zizmor', ['.github/']) - if (zizmorCode !== 0) { - process.exitCode = zizmorCode - } -} - -main().catch((e: unknown) => { - logger.error(e) - process.exitCode = 1 -}) diff --git a/scripts/setup.mts b/scripts/setup.mts deleted file mode 100644 index 6849525b6..000000000 --- a/scripts/setup.mts +++ /dev/null @@ -1,619 +0,0 @@ -/* max-file-lines: legitimate — tracks one cohesive module domain; splitting would scatter tightly coupled helpers. */ -import process from 'node:process' - -/** - * @file Developer setup script - checks prerequisites and prepares environment. - * Checks and optionally installs: - * - * - Node.js version (>=18.0.0) - * - pnpm version (>=10.21.0) - * - gh CLI (optional, for cache restoration) - * - Homebrew (if needed for installations) Actions: - * - Checks for required tools (Node.js, pnpm) and fails if missing - * - Auto-installs optional tools (gh CLI, brew/choco) if --install flag - * provided - * - Verifies installed tools are actually available in PATH before proceeding - * - Attempts to restore build cache from CI (only if gh CLI available) - * - Reports missing tools with installation instructions Usage: pnpm run setup - * # Check prerequisites and restore GitHub cache pnpm run setup --install # - * Check and auto-install optional tools, then restore cache pnpm run setup - * --skip-prereqs # Only restore GitHub cache (skip prerequisite checks) - * pnpm run setup --skip-gh-cache # Check prerequisites but skip GitHub - * cache restoration pnpm run setup --quiet # Minimal output (for - * postinstall) Flags: --install Auto-install missing optional tools (gh - * CLI) --skip-prereqs Skip prerequisite checks (for CI use; still attempts - * cache restoration) --skip-gh-cache Skip GitHub cache restoration (useful - * when cache is corrupt) --quiet Minimal output Note: Setup helpers are - * also exported in build-infra/lib/setup-helpers for reuse in other build - * scripts. - */ - -import { existsSync } from 'node:fs' -import { mkdir } from 'node:fs/promises' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -const autoInstall = process.argv.includes('--install') -const quiet = process.argv.includes('--quiet') -const skipPrereqs = process.argv.includes('--skip-prereqs') -const skipGhCache = process.argv.includes('--skip-gh-cache') - -// Handle --help flag. -const showHelp = process.argv.includes('--help') || process.argv.includes('-h') -if (showHelp) { - logger.log('') - logger.log('Socket CLI Developer Setup') - logger.log('') - logger.log('Usage:') - logger.log(' pnpm run setup [options]') - logger.log('') - logger.log('Options:') - logger.log(' --install Auto-install missing optional tools (gh CLI)') - logger.log(' --skip-prereqs Skip prerequisite checks (for CI use)') - logger.log( - ' --skip-gh-cache Skip GitHub cache restoration (useful when cache is corrupt)', - ) - logger.log(' --quiet Minimal output') - logger.log(' --help, -h Show this help message') - logger.log('') - logger.log('Examples:') - logger.log( - ' pnpm run setup # Check prerequisites and restore cache', - ) - logger.log(' pnpm run setup --install # Auto-install optional tools') - logger.log( - ' pnpm run setup --skip-gh-cache # Skip cache (useful if cache is corrupt)', - ) - logger.log(' pnpm run setup --skip-prereqs # Skip checks, only restore cache') - logger.log('') - process.exitCode = 0 -} - -interface VersionInfo { - major: number - minor: number - patch: number -} - -interface PrerequisiteOptions { - command: string - minVersion?: VersionInfo | undefined - name: string - required?: boolean | undefined -} - -/** - * Check prerequisite. - */ -async function checkPrerequisite({ - command, - minVersion, - name, - required = true, -}: PrerequisiteOptions): Promise<boolean> { - const version = await getVersion(command) - - if (!version) { - logger.error(`${name} not found`) - return false - } - - if (minVersion) { - const current = parseVersion(version) - if (!current) { - logger.warn(`Could not parse ${name} version: ${version}`) - return !required - } - - if (compareVersions(current, minVersion) < 0) { - const minVersionStr = `${minVersion.major}.${minVersion.minor}.${minVersion.patch}` - logger.error(`${name} ${version} found, but >=${minVersionStr} required`) - return false - } - } - - logger.log(`${name} ${version}`) - return true -} - -/** - * Compare two version objects. Returns: -1 if a < b, 0 if a === b, 1 if a > b. - */ -export function compareVersions(a: VersionInfo, b: VersionInfo): number { - if (a.major !== b.major) { - return a.major < b.major ? -1 : 1 - } - if (a.minor !== b.minor) { - return a.minor < b.minor ? -1 : 1 - } - if (a.patch !== b.patch) { - return a.patch < b.patch ? -1 : 1 - } - return 0 -} - -/** - * Check and optionally install gh CLI. - */ -async function ensureGhCli(): Promise<boolean> { - if (await hasCommand('gh')) { - const version = await getVersion('gh') - logger.log(`gh CLI ${version} (optional)`) - return true - } - - if (!autoInstall) { - logger.info('gh CLI not found (optional - enables cache restoration)') - logger.info('Install from: https://cli.github.com/') - logger.info('Or run: pnpm run setup --install') - return false - } - - // Auto-install mode. - if (WIN32) { - // Windows: Try Chocolatey. - if (!(await hasCommand('choco'))) { - logger.info('Chocolatey not found (needed for auto-install on Windows)') - logger.log('Attempting to install Chocolatey...') - const installed = await installChocolatey() - if (!installed) { - logger.warn('Could not install Chocolatey') - logger.info('Install gh CLI manually from: https://cli.github.com/') - logger.info( - 'Or install Chocolatey from: https://chocolatey.org/install', - ) - return false - } - } - - // Install gh CLI with Chocolatey. - logger.log('Installing gh CLI with Chocolatey...') - const installed = await installWithChocolatey('gh') - if (installed) { - // Verify gh is actually available after installation. - if (await hasCommand('gh')) { - const version = await getVersion('gh') - logger.log(`gh CLI ${version} installed!`) - return true - } - logger.warn('gh CLI installed but not available in PATH') - logger.info('You may need to restart your shell or run: pnpm run setup') - return false - } - - logger.warn('Could not install gh CLI') - logger.info('Install manually from: https://cli.github.com/') - return false - } - - // macOS/Linux: Try Homebrew. - if (!(await hasCommand('brew'))) { - logger.info('Homebrew not found (needed for auto-install)') - logger.log('Attempting to install Homebrew...') - const installed = await installHomebrew() - if (!installed) { - logger.warn('Could not install Homebrew') - logger.info('Install gh CLI manually from: https://cli.github.com/') - return false - } - } - - // Install gh CLI with Homebrew. - logger.log('Installing gh CLI with Homebrew...') - const installed = await installWithHomebrew('gh') - if (installed) { - // Verify gh is actually available after installation. - if (await hasCommand('gh')) { - const version = await getVersion('gh') - logger.log(`gh CLI ${version} installed!`) - return true - } - logger.warn('gh CLI installed but not available in PATH') - logger.info('You may need to restart your shell or run: pnpm run setup') - return false - } - - logger.warn('Could not install gh CLI') - logger.info('Install manually from: https://cli.github.com/') - return false -} - -/** - * Generate cli-with-sentry package from template. - */ -async function generateCliSentryPackage(): Promise<boolean> { - if (!quiet) { - logger.log('Generating cli-with-sentry package from template...') - } - - const scriptPath = new URL( - '../packages/package-builder/scripts/generate-cli-sentry-package.mts', - import.meta.url, - ) - const result = await spawn('node', [scriptPath.pathname], { - stdio: quiet ? 'pipe' : 'inherit', - }) - - if (result.code === 0) { - if (!quiet) { - logger.log('cli-with-sentry package generated!') - } - return true - } - - logger.warn('Failed to generate cli-with-sentry package') - return false -} - -/** - * Generate socketbin packages from template. - */ -async function generateSocketbinPackages(): Promise<boolean> { - if (!quiet) { - logger.log('Generating socketbin packages from template...') - } - - const scriptPath = new URL( - '../packages/package-builder/scripts/generate-socketbin-packages.mts', - import.meta.url, - ) - const result = await spawn('node', [scriptPath.pathname], { - stdio: quiet ? 'pipe' : 'inherit', - }) - - if (result.code === 0) { - if (!quiet) { - logger.log('Socketbin packages generated!') - } - return true - } - - logger.warn('Failed to generate socketbin packages') - return false -} - -/** - * Get version of a command. - */ -async function getVersion( - command: string, - args: string[] = ['--version'], -): Promise<string | undefined> { - try { - const result = await spawn(command, args, { - stdio: 'pipe', - }) - if (result.code === 0) { - return String(result.stdout).trim() - } - } catch { - // Ignore. - } - return undefined -} - -/** - * Check if a command is available. - */ -async function hasCommand(command: string): Promise<boolean> { - try { - const result = await spawn(command, ['--version'], { - stdio: 'pipe', - }) - return result.code === 0 - } catch { - return false - } -} - -/** - * Install Chocolatey (Windows). - */ -async function installChocolatey(): Promise<boolean> { - if (!WIN32) { - logger.warn('Chocolatey is only available on Windows') - return false - } - - logger.step('Installing Chocolatey...') - logger.info('This requires admin access and may take a few minutes') - - const installScript = - "Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))" - - const result = await spawn('powershell', ['-Command', installScript], { - stdio: 'inherit', - }) - - if (result.code === 0) { - logger.success('Chocolatey installed successfully!') - return true - } - - logger.error('Failed to install Chocolatey') - logger.info('You may need to run as Administrator') - return false -} - -/** - * Install Homebrew (macOS/Linux). - */ -async function installHomebrew(): Promise<boolean> { - if (WIN32) { - logger.warn('Homebrew is not available on Windows') - return false - } - - logger.step('Installing Homebrew...') - logger.info('This requires sudo access and may take a few minutes') - - const installScript = - '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' - - const result = await spawn('bash', ['-c', installScript], { - stdio: 'inherit', - }) - - if (result.code === 0) { - logger.success('Homebrew installed successfully!') - return true - } - - logger.error('Failed to install Homebrew') - return false -} - -/** - * Install a package using Chocolatey (Windows). - */ -async function installWithChocolatey( - packageName: string, -): Promise<boolean> { - if (!(await hasCommand('choco'))) { - logger.error('Chocolatey not available') - return false - } - - logger.step(`Installing ${packageName} with Chocolatey...`) - - const result = await spawn('choco', ['install', packageName, '-y'], { - stdio: 'inherit', - }) - - if (result.code === 0) { - logger.success(`${packageName} installed successfully!`) - return true - } - - logger.error(`Failed to install ${packageName}`) - logger.info('You may need to run as Administrator') - return false -} - -/** - * Install a package using Homebrew (macOS/Linux). - */ -async function installWithHomebrew( - packageName: string, -): Promise<boolean> { - if (!(await hasCommand('brew'))) { - logger.error('Homebrew not available') - return false - } - - logger.step(`Installing ${packageName} with Homebrew...`) - - const result = await spawn('brew', ['install', packageName], { - stdio: 'inherit', - }) - - if (result.code === 0) { - logger.success(`${packageName} installed successfully!`) - return true - } - - logger.error(`Failed to install ${packageName}`) - return false -} - -/** - * Parse version string to compare. - */ -function parseVersion(versionString: string): VersionInfo | undefined { - const match = versionString.match(/(\d+)\.(\d+)\.(\d+)/) - if (!match) { - return undefined - } - return { - major: Number.parseInt(match[1], 10), - minor: Number.parseInt(match[2], 10), - patch: Number.parseInt(match[3], 10), - } -} - -/** - * Restore build cache if possible. - */ -export async function restoreCache(hasGh: boolean): Promise<boolean> { - // Skip entirely if gh CLI not available. - if (!hasGh) { - logger.info('Skipping cache restoration (gh CLI not available)') - return false - } - - // Check if already built. - if (existsSync('packages/cli/build') && existsSync('packages/cli/dist')) { - logger.info('Build artifacts already exist, skipping cache restoration') - return true - } - - // Ensure directories exist. - logger.log('Ensuring build directories exist...') - await mkdir('packages/cli/build', { recursive: true }) - await mkdir('packages/cli/dist', { recursive: true }) - - logger.log('Attempting to restore build cache from CI...') - - const result = await spawn( - 'pnpm', - ['--filter', '@socketsecurity/cli', 'run', 'restore-cache', '--quiet'], - { - stdio: 'inherit', - }, - ) - - if (result.code === 0) { - logger.log('Build cache restored!') - return true - } - - logger.info('Cache not available for this commit (will build from scratch)') - return false -} - -/** - * Main entry point. - */ -async function main(): Promise<number> { - // Handle --skip-prereqs: skip prerequisite checks, proceed to cache restoration. - if (skipPrereqs) { - if (!quiet) { - logger.log('') - logger.log('Socket CLI Cache Restoration') - logger.log('============================') - logger.log('') - logger.info('Skipping prerequisite checks (--skip-prereqs)') - logger.log('') - } - - // Cache restoration respects --skip-gh-cache flag. - if (!skipGhCache) { - const hasGh = await hasCommand('gh') - if (!hasGh) { - logger.error('gh CLI not found (required for cache restoration)') - logger.info('Install from: https://cli.github.com/') - return 1 - } - await restoreCache(hasGh) - } else if (!quiet) { - logger.info('Skipping GitHub cache restoration (--skip-gh-cache)') - } - - if (!quiet) { - logger.log('') - logger.log('Setup complete!') - logger.log('') - } - return 0 - } - - // Normal setup flow: check prerequisites and restore cache. - if (!quiet) { - logger.log('') - logger.log('Socket CLI Developer Setup') - logger.log('==========================') - logger.log('') - - if (autoInstall) { - logger.info('Auto-install mode enabled (--install)') - logger.log('') - } - } - - logger.log('Checking prerequisites...') - if (!quiet) { - logger.log('') - } - - // Check Node.js. - const nodeOk = await checkPrerequisite({ - command: 'node', - minVersion: { major: 18, minor: 0, patch: 0 }, - name: 'Node.js', - required: true, - }) - - // Check pnpm. - const pnpmOk = await checkPrerequisite({ - command: 'pnpm', - minVersion: { major: 10, minor: 21, patch: 0 }, - name: 'pnpm', - required: true, - }) - - // Check gh CLI (optional, with auto-install). - const ghOk = await ensureGhCli() - - if (!quiet) { - logger.log('') - } - - if (!nodeOk || !pnpmOk) { - logger.error( - 'Required prerequisites missing. Please install and try again.', - ) - if (!quiet) { - logger.log('') - } - if (!nodeOk) { - logger.info('Node.js: https://nodejs.org/') - } - if (!pnpmOk) { - logger.info('pnpm: npm install -g pnpm') - } - return 1 - } - - logger.log('All required prerequisites met!') - if (!quiet) { - logger.log('') - } - - // Generate packages from templates. - await generateCliSentryPackage() - if (!quiet) { - logger.log('') - } - - await generateSocketbinPackages() - - if (!quiet) { - logger.log('') - } - - // Always restore cache after prerequisite checks (unless --skip-gh-cache). - if (!skipGhCache) { - await restoreCache(ghOk) - } else if (!quiet) { - logger.info('Skipping GitHub cache restoration (--skip-gh-cache)') - } - - if (!quiet) { - logger.log('') - logger.log('Setup complete!') - logger.log('') - logger.log('Next steps:') - logger.log(' pnpm run build # Build the CLI') - logger.log(' pnpm test # Run tests') - logger.log(' pnpm exec socket # Run the CLI') - logger.log('') - } - - return 0 -} - -if (!showHelp) { - main() - .then((code: number) => { - process.exitCode = code - }) - .catch((e: unknown) => { - const message = e instanceof Error ? e.message : String(e) - logger.error(message) - process.exitCode = 1 - }) -} diff --git a/scripts/socket-wheelhouse-emit-schema.mts b/scripts/socket-wheelhouse-emit-schema.mts deleted file mode 100644 index dec16d0ec..000000000 --- a/scripts/socket-wheelhouse-emit-schema.mts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @file Emit `socket-wheelhouse-schema.json` from the TypeBox source. Run via - * `pnpm run socket-wheelhouse:emit-schema` from a fleet repo (the worktree - * where TypeBox is installed). Mirrors the lockstep emit pattern. - */ - -import { writeFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { SocketWheelhouseConfigSchema } from './socket-wheelhouse-schema.mts' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootDir = path.resolve(__dirname, '..') -// Schema lives in `.config/` next to the per-repo -// `.config/socket-wheelhouse.json` it describes — the marker's -// `$schema` ref is `./socket-wheelhouse-schema.json`. -const outPath = path.join(rootDir, '.config', 'socket-wheelhouse-schema.json') - -const enriched = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://github.com/SocketDev/socket-wheelhouse-schema.json', - title: 'socket-wheelhouse per-repo config', - ...SocketWheelhouseConfigSchema, -} - -writeFileSync(outPath, JSON.stringify(enriched, null, 2) + '\n', 'utf8') - -// Run oxfmt on the output so the file matches what oxfmt would -// produce. Without this, `pnpm run check --all` (which runs oxfmt -// over the tree) would flag the emitted schema as drifted on every -// repo that re-emits it. The schema is in IDENTICAL_FILES, so the -// formatted form is the byte-canonical form fleet-wide. -await spawn('pnpm', ['exec', 'oxfmt', '-c', '.config/oxfmtrc.json', outPath], { - cwd: rootDir, - stdio: 'inherit', -}) - -logger.success(`wrote ${path.relative(rootDir, outPath)}`) diff --git a/scripts/socket-wheelhouse-schema.mts b/scripts/socket-wheelhouse-schema.mts deleted file mode 100644 index 4f006a315..000000000 --- a/scripts/socket-wheelhouse-schema.mts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * @file TypeBox schema for the per-fleet-repo socket-wheelhouse config consumed - * by `sync-scaffolding`. Two valid locations: - * `.config/socket-wheelhouse.json` (primary) or `.socket-wheelhouse.json` at - * the repo root (alternative). Both are first-class — pick the location that - * fits your repo's convention. Each fleet repo (socket-lib, socket-cli, - * ultrathink, …) ships this config declaring its `layout` + `native` axes - * plus any per-repo opt-ins. The runner reads it to decide which optional - * files the repo is expected to ship and which it must not ship. - * Source-of-truth flow: - * - * - This TypeBox source → `Static<typeof SocketWheelhouseConfigSchema>` for - * typed reads in the runner. - * - `socket-wheelhouse-emit-schema.mts` writes - * `.config/socket-wheelhouse-schema.json` (draft 2020-12) next to the - * per-repo config. - * - The per-repo config references the JSON Schema via its `$schema` field for - * IDE autocompletion. Byte-identical across the fleet via - * sync-scaffolding's IDENTICAL_FILES. - */ - -import { Type } from '@sinclair/typebox' -import type { Static } from '@sinclair/typebox' - -// --------------------------------------------------------------------------- -// Two orthogonal axes describe a fleet repo: -// -// layout — package shape: single-package vs monorepo. -// native — native-binary supply-chain role: none / consumer / -// producer / both. -// -// Per-language ports (e.g. ultrathink's cpp/go/rust/typescript ports -// of one spec) live in `lockstep.json` `lang-parity` rows, not here — -// the manifest is the source of truth for parity tracking. -// --------------------------------------------------------------------------- - -const LayoutSchema = Type.Union( - [Type.Literal('single-package'), Type.Literal('monorepo')], - { - description: - 'Package layout. `single-package` = one `package.json` at root, no `packages/`. `monorepo` = pnpm workspaces under `packages/`.', - }, -) - -const NativeSchema = Type.Union( - [ - Type.Literal('none'), - Type.Literal('consumer'), - Type.Literal('producer'), - Type.Literal('both'), - ], - { - description: - 'Native-binary supply-chain role. `none` = pure-npm publish path. `consumer` = pulls prebuilt binaries from a sibling producer. `producer` = ships native artifacts via GH releases. `both` = consumes one set, produces another. (Per-language ports live in `lockstep.json` `lang-parity` rows, not here.)', - }, -) - -// --------------------------------------------------------------------------- -// Hooks block — git hook variant selection. -// --------------------------------------------------------------------------- - -const HooksSchema = Type.Object( - { - enablePrePush: Type.Optional( - Type.Boolean({ - description: - 'Wire `.git-hooks/pre-push` (shell shim) → `.git-hooks/pre-push.mts`. Mandatory security gate; default true.', - }), - ), - enableCommitMsg: Type.Optional( - Type.Boolean({ - description: - 'Wire `.git-hooks/commit-msg` (shell shim) → `.git-hooks/commit-msg.mts`. Strips AI attribution; default true.', - }), - ), - enablePreCommit: Type.Optional( - Type.Boolean({ - description: - 'Wire `.git-hooks/pre-commit` (shell shim) → `.git-hooks/pre-commit.mts`. Lint + secret scan on staged files; default true.', - }), - ), - preCommitVariant: Type.Optional( - Type.Union([Type.Literal('lint-only'), Type.Literal('lint-test')], { - description: - '`lint-only` runs format + secret scan; `lint-test` adds vitest on touched packages. Default `lint-test`.', - }), - ), - }, - { description: 'Git-hook opt-ins.' }, -) - -// --------------------------------------------------------------------------- -// Scripts block — package.json script declarations. -// --------------------------------------------------------------------------- - -const ScriptsSchema = Type.Object( - { - required: Type.Optional( - Type.Array(Type.String(), { - description: - 'Override REQUIRED_SCRIPTS from manifest.mts. Usually omitted — the fleet default applies.', - }), - ), - optional: Type.Optional( - Type.Record(Type.String(), Type.Boolean(), { - description: - 'Per-script opt-in map keyed by script name. `true` = repo ships this RECOMMENDED script; `false` = explicit opt-out.', - }), - ), - bodyExempt: Type.Optional( - Type.Array(Type.String(), { - description: - 'Script names whose body is allowed to drift from the canonical form (e.g. socket-lib runs a richer test runner than the standard `node scripts/test.mts`). Each entry is the script name only.', - }), - ), - }, - { description: 'package.json script tracking overrides.' }, -) - -// --------------------------------------------------------------------------- -// Lint block — oxlint profile selection. -// --------------------------------------------------------------------------- - -const LintSchema = Type.Object( - { - profile: Type.Optional( - Type.Union([Type.Literal('standard'), Type.Literal('rich')], { - description: - '`standard` requires the fleet plugin set (import + typescript + unicorn). `rich` opts into a wider set; check the runner for the exact basenames currently exempted.', - }), - ), - }, - { description: 'oxlint profile.' }, -) - -// --------------------------------------------------------------------------- -// Workflows block — GitHub Actions opt-ins. -// --------------------------------------------------------------------------- - -const WorkflowsSchema = Type.Object( - { - ci: Type.Optional( - Type.Boolean({ description: 'Ship `.github/workflows/ci.yml`.' }), - ), - weeklyUpdate: Type.Optional( - Type.Boolean({ - description: 'Ship `.github/workflows/weekly-update.yml`.', - }), - ), - provenance: Type.Optional( - Type.Boolean({ - description: - 'Repo publishes with npm provenance (OIDC). Hint for setup helpers; not enforced by the checker today.', - }), - ), - requirePinnedFullSha: Type.Optional( - Type.Boolean({ - description: - 'Enforce 40-char SHA pins on every `uses:` ref. Defaults to true; an opt-out is reserved for special cases (e.g. workflow-dispatch test rigs) and currently has no consumer.', - }), - ), - }, - { description: 'CI workflow opt-ins.' }, -) - -// --------------------------------------------------------------------------- -// Claude block — opt-in agents/skills/commands. -// --------------------------------------------------------------------------- - -const ClaudeSchema = Type.Object( - { - includeSecurityScanSkill: Type.Optional( - Type.Boolean({ - description: 'Ship `.claude/skills/scanning-security/SKILL.md`.', - }), - ), - includeSharedSkills: Type.Optional( - Type.Boolean({ - description: - 'Ship `.claude/skills/_shared/*` — env-check, path-guard-rule, report-format, security-tools, verify-build.', - }), - ), - includeUpdatingSkill: Type.Optional( - Type.Boolean({ - description: - 'Ship the dependency-update skill. Reserved — no consumer wired today.', - }), - ), - }, - { description: 'Claude Code opt-ins.' }, -) - -// --------------------------------------------------------------------------- -// Workspace block — pnpm-workspace.yaml derived settings. -// --------------------------------------------------------------------------- - -const WorkspaceSchema = Type.Object( - { - allowBuilds: Type.Optional( - Type.Record(Type.String(), Type.Boolean(), { - description: - 'pnpm `onlyBuiltDependencies` allowlist. Map a package name to true/false to grant/deny build scripts.', - }), - ), - blockExoticSubdeps: Type.Optional( - Type.Boolean({ - description: - 'Refuse transitive git/tarball subdeps (direct git deps still allowed). Required true; the field exists so a repo can document the intent locally.', - }), - ), - minimumReleaseAge: Type.Optional( - Type.Integer({ - minimum: 0, - description: - 'Soak time in minutes before installing freshly-published packages. Fleet default 10080 (= 7 days).', - }), - ), - minimumReleaseAgeExclude: Type.Optional( - Type.Array(Type.String(), { - description: - 'Scopes / package patterns exempt from the soak time. Socket-owned scopes typically listed here.', - }), - ), - resolutionMode: Type.Optional( - Type.Union([Type.Literal('highest'), Type.Literal('lowest-direct')], { - description: 'pnpm `resolutionMode`. Fleet default `highest`.', - }), - ), - trustPolicy: Type.Optional( - Type.Union([Type.Literal('no-downgrade'), Type.Literal('match-spec')], { - description: 'pnpm `trustPolicy`. Fleet default `no-downgrade`.', - }), - ), - }, - { - description: - 'pnpm-workspace.yaml setting hints. The runner reads from the YAML; this block exists for repos that prefer to declare intent in JSON.', - }, -) - -// --------------------------------------------------------------------------- -// GitHub-related config. Lives in our own JSON file (not .github/*.yml) -// because the fleet rule is "JSON not YAML for configs we own." -// --------------------------------------------------------------------------- - -const GithubSchema = Type.Object( - { - apps: Type.Optional( - Type.Array(Type.String(), { - description: - 'GitHub App slugs that must be installed on the repo (e.g. `cursor`, `socket-security`, `socket-trufflehog`). Audited by `scripts/lint-github-settings.mts` — apps whose installation cannot be reliably detected via check-suites are trusted via this manifest.', - }), - ), - }, - { - description: 'GitHub-related fleet config.', - }, -) - -// --------------------------------------------------------------------------- -// pathsAllowlist — exemptions for the path-hygiene gate -// (scripts/check-paths.mts). Migrated from `.github/paths-allowlist.yml` -// per the "JSON not YAML for our own configs" rule. -// --------------------------------------------------------------------------- - -const PathsAllowlistEntrySchema = Type.Object( - { - rule: Type.Optional( - Type.String({ - description: 'Rule letter (A, B, C, D, F, G). Omit to match any rule.', - }), - ), - file: Type.Optional( - Type.String({ - description: 'Substring match against the relative file path.', - }), - ), - pattern: Type.Optional( - Type.String({ - description: 'Substring match against the offending snippet.', - }), - ), - line: Type.Optional( - Type.Number({ - description: 'Exact line number. Strict — no fuzz tolerance.', - }), - ), - snippet_hash: Type.Optional( - Type.String({ - description: - "12-char SHA-256 prefix of the normalized snippet (whitespace collapsed). Drift-resistant: keeps matching after reformatting that doesn't change the offending construction. Get via `node scripts/check-paths.mts --show-hashes`.", - }), - ), - reason: Type.String({ - description: 'Why this site is genuinely exempt. Required.', - }), - }, - { - description: 'One exemption for the path-hygiene gate.', - }, -) - -// --------------------------------------------------------------------------- -// Top-level config. -// --------------------------------------------------------------------------- - -export const SocketWheelhouseConfigSchema = Type.Object( - { - $schema: Type.Optional( - Type.String({ - description: - 'JSON Schema reference for editor autocompletion. Conventionally `./socket-wheelhouse-schema.json` — both the config and its schema live side-by-side in `.config/`.', - }), - ), - schemaVersion: Type.Literal(1, { - description: - 'Schema version. Bump on breaking changes; readers gate on it.', - }), - repoName: Type.String({ - pattern: '^[a-z0-9][a-z0-9-]*$', - description: - 'Canonical repo basename (e.g. `socket-lib`, `ultrathink`). Used for layout / native-independent exemptions like the oxlint `socket-lib` carve-out.', - }), - layout: LayoutSchema, - native: NativeSchema, - hooks: Type.Optional(HooksSchema), - scripts: Type.Optional(ScriptsSchema), - lint: Type.Optional(LintSchema), - workflows: Type.Optional(WorkflowsSchema), - claude: Type.Optional(ClaudeSchema), - workspace: Type.Optional(WorkspaceSchema), - github: Type.Optional(GithubSchema), - pathsAllowlist: Type.Optional( - Type.Array(PathsAllowlistEntrySchema, { - description: - 'Exemptions for the path-hygiene gate (scripts/check-paths.mts). Migrated from `.github/paths-allowlist.yml`. Each entry needs a `reason`; prefer narrow entries (rule + file + snippet_hash + pattern) over blanket file-level exempts.', - }), - ), - }, - { - description: - "Per-repo socket-wheelhouse config. Two valid locations: `.config/socket-wheelhouse.json` (primary) or `.socket-wheelhouse.json` at the repo root (alternative). Both are first-class — pick the location that fits your repo's convention.", - }, -) - -export type SocketWheelhouseConfig = Static<typeof SocketWheelhouseConfigSchema> -export type Layout = Static<typeof LayoutSchema> -export type Native = Static<typeof NativeSchema> diff --git a/scripts/test.mts b/scripts/test.mts deleted file mode 100644 index 690ebb18e..000000000 --- a/scripts/test.mts +++ /dev/null @@ -1,175 +0,0 @@ -/* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */ -/** - * @file Canonical minimal test runner for socket-* repos. Delegates the - * scope-to-tests mapping to vitest itself rather than rolling a basename- - * based mapper that would inevitably drift from the actual module graph. - * - * Scope modes: - * - * - `(default)` — local-dev scope. Runs `vitest --changed`, vitest's - * compare-vs-HEAD-with-uncommitted mode. Walks the actual import graph - * so a change to a util shared by many tests runs every affected test - * file, not the union of two guesses. - * - `--staged` — pre-commit hook scope. Hands `git diff --cached` filenames - * to `vitest related <files…> --run`. Same module-graph walk, but rooted - * at the staged delta. The `--run` flag is mandatory: `vitest related` - * defaults to watch mode just like the bare `vitest` invocation, which - * would hang the pre-commit hook. - * - `--all` — run the full suite (`vitest run`). Used in CI and on explicit - * opt-in. - * - * Flags: `--quiet` / `--silent` suppress progress output. - * - * Config / infrastructure changes (`vitest.config*`, `tsconfig*`, - * `.oxlintrc.json`, `.oxfmtrc.json`, `pnpm-lock.yaml`, `package.json`, - * anything under `.config/` or `scripts/`) still escalate to `all` — - * module-graph traversal doesn't capture config-derived discovery + alias - * changes. See https://vitest.dev/guide/cli.html#vitest-related. - */ - -// prefer-async-spawn: sync-required — top-level CLI runner; entire -// flow is sync (test runner invocation + exit-code aggregation). -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import type { SpawnSyncOptions } from 'node:child_process' -import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const args = process.argv.slice(2) -const mode: 'staged' | 'all' | 'modified' = args.includes('--all') - ? 'all' - : args.includes('--staged') - ? 'staged' - : 'modified' -const quiet = args.includes('--quiet') || args.includes('--silent') -const stdio: SpawnSyncOptions['stdio'] = quiet ? 'pipe' : 'inherit' -// On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly via -// spawnSync (CVE-2024-27980 hardening). Wrap through the shell on Windows -// only; POSIX keeps direct invocation. -const useShell = process.platform === 'win32' - -// Paths that, when changed, force the full suite to run. -const ESCALATION_PATTERNS = [ - /^\.config\//, - /^scripts\//, - /^pnpm-lock\.yaml$/, - /^tsconfig.*\.json$/, - /^\.oxlintrc\.json$/, - /^\.oxfmtrc\.json$/, - /^vitest\.config\.(js|mjs|mts|ts)$/, - /^package\.json$/, - /^lockstep\.schema\.json$/, -] - -function log(msg: string): void { - if (!quiet) { - logger.log(msg) - } -} - -function gitFiles(args: string[]): string[] { - // spawnSync with array args — no shell interpolation. Matches the - // socket/prefer-spawn-over-execsync rule contract. - const r = spawnSync('git', args, { - stdio: ['ignore', 'pipe', 'pipe'], - stdioString: true, - }) - if (r.status !== 0 || typeof r.stdout !== 'string') { - return [] - } - return r.stdout - .split('\n') - .map(s => s.trim()) - .filter(s => s.length > 0) -} - -function getStagedFiles(): string[] { - return gitFiles(['diff', '--cached', '--name-only', '--diff-filter=ACMR']) -} - -function getModifiedFiles(): string[] { - return gitFiles(['diff', '--name-only', '--diff-filter=ACMR', 'HEAD']) -} - -function shouldEscalate(files: string[]): boolean { - for (let i = 0, { length } = files; i < length; i += 1) { - const f = files[i]! - for (let i = 0, { length } = ESCALATION_PATTERNS; i < length; i += 1) { - const pattern = ESCALATION_PATTERNS[i]! - if (pattern.test(f)) { - return true - } - } - } - return false -} - -function runVitest(vitestArgs: string[], label: string): number { - log(`Test scope: ${label}`) - const r = spawnSync( - 'pnpm', - ['exec', 'vitest', ...vitestArgs, '--config', '.config/vitest.config.mts'], - // Windows shell-shim rationale: see useShell at file top. - { shell: useShell, stdio }, - ) - if (r.status !== 0) { - log('Tests failed') - return 1 - } - log('All tests passed') - return 0 -} - -function runAll(): number { - return runVitest(['run'], 'all') -} - -// --passWithNoTests: a scoped run where the changed files don't resolve -// to any test file should succeed rather than error with "No test files -// found". Keeps pre-commit hooks passing when an edit touches only -// non-testable code. -function runChanged(): number { - return runVitest(['run', '--changed', '--passWithNoTests'], 'changed') -} - -function runRelated(files: string[]): number { - // `vitest related <files…>` defaults to watch mode; `--run` forces a - // single non-watch execution. Pass the staged file list as positionals; - // vitest walks the module graph from each. - return runVitest( - ['related', ...files, '--run', '--passWithNoTests'], - `staged (${files.length} file(s))`, - ) -} - -function main(): void { - if (mode === 'all') { - process.exitCode = runAll() - return - } - - const files = mode === 'staged' ? getStagedFiles() : getModifiedFiles() - - if (files.length === 0) { - log(`No ${mode} files; skipping tests.`) - return - } - - if (shouldEscalate(files)) { - log('Config files changed; escalating to full test suite.') - process.exitCode = runAll() - return - } - - if (mode === 'staged') { - process.exitCode = runRelated(files) - return - } - - // Working-tree changed → vitest's native --changed (it re-detects the - // file list via git itself, including uncommitted edits). - process.exitCode = runChanged() -} - -main() diff --git a/scripts/test/check-lock-step-header.test.mts b/scripts/test/check-lock-step-header.test.mts deleted file mode 100644 index 5c5f98a06..000000000 --- a/scripts/test/check-lock-step-header.test.mts +++ /dev/null @@ -1,254 +0,0 @@ -// node --test specs for scripts/check-lock-step-header.mts. -// -// The header gate is the §7 companion to §5–6 path-refs gate. Where -// check-lock-step-refs.mts validates that named paths resolve, this -// gate validates that the `BEGIN LOCK-STEP HEADER` / `END LOCK-STEP -// HEADER` block is byte-identical across every member of a quadruplet. -// -// Test strategy: build a tmpdir repo with a canonical file (Rust) -// whose header lists peers + the peer files themselves, vary the -// peers' headers, and inspect exit code + stderr. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import test from 'node:test' -import assert from 'node:assert/strict' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const SCRIPT_PATH = path.join(here, '..', 'check-lock-step-header.mts') - -interface RepoSpec { - readonly configContent?: string | undefined - readonly files: Readonly<Record<string, string>> -} - -function makeRepo(spec: RepoSpec): string { - const root = mkdtempSync(path.join(os.tmpdir(), 'clsh-')) - if (spec.configContent !== undefined) { - mkdirSync(path.join(root, '.config'), { recursive: true }) - writeFileSync( - path.join(root, '.config', 'lock-step-refs.json'), - spec.configContent, - ) - } - for (const [rel, content] of Object.entries(spec.files)) { - const full = path.join(root, rel) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, content) - } - return root -} - -function runGate( - cwd: string, - args: readonly string[] = [], -): { stdout: string; stderr: string; exitCode: number } { - const result = spawnSync('node', [SCRIPT_PATH, ...args], { - cwd, - }) - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - exitCode: result.status ?? -1, - } -} - -const CANONICAL_HEADER = [ - '// BEGIN LOCK-STEP HEADER', - '// Class Parsing', - '//', - '// Lock-step with Go: src/class.go', - '// END LOCK-STEP HEADER', - '', - 'fn parse_class() {}', -].join('\n') - -const MATCHING_PEER_HEADER = [ - '// BEGIN LOCK-STEP HEADER', - '// Class Parsing', - '//', - '// Lock-step with Go: src/class.go', - '// END LOCK-STEP HEADER', - '', - 'package parser', -].join('\n') - -const DRIFTED_PEER_HEADER = [ - '// BEGIN LOCK-STEP HEADER', - '// Class Parsing (with extra prose)', // ← divergence - '//', - '// Lock-step with Go: src/class.go', - '// END LOCK-STEP HEADER', - '', - 'package parser', -].join('\n') - -const STD_CONFIG = JSON.stringify({ - roots: { Rust: ['crates'], Go: ['src'] }, - scan: ['crates', 'src'], - extensions: ['.rs', '.go'], -}) - -test('exits 0 when config is absent', () => { - const repo = makeRepo({ files: {} }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - assert.match(stdout, /opt-in gate disabled/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 0 when no files carry a BEGIN LOCK-STEP HEADER block', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': 'fn x() {}', - 'src/class.go': 'package parser', - }, - }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - assert.match(stdout, /validated 0 canonical header/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 0 when canonical + peer headers are byte-identical', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': MATCHING_PEER_HEADER, - }, - }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - // Both files carry `Lock-step with Go:` — same shared canonical header — - // so both are counted as canonical. Each validates the other; both clean. - assert.match(stdout, /validated \d+ canonical header.*clean/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 1 when peer header drifts from canonical', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': DRIFTED_PEER_HEADER, - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - assert.match(stderr, /1 quadruplet diff/) - assert.match(stderr, /with extra prose/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 1 when peer is missing its LOCK-STEP HEADER block', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': 'package parser', // no header at all - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - assert.match(stderr, /peer is missing its BEGIN LOCK-STEP HEADER block/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 1 when peer file does not exist', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - // src/class.go intentionally missing - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - assert.match(stderr, /peer path doesn't exist/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('--json emits machine-readable diffs', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': DRIFTED_PEER_HEADER, - }, - }) - const { exitCode, stdout } = runGate(repo, ['--json']) - assert.equal(exitCode, 1) - const parsed = JSON.parse(stdout) as Array<Record<string, unknown>> - assert.equal(parsed.length, 1) - assert.equal(parsed[0]!['lang'], 'Go') - assert.equal(parsed[0]!['reason'], 'body-mismatch') - rmSync(repo, { recursive: true, force: true }) -}) - -test('--quiet suppresses clean-run stdout', () => { - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': MATCHING_PEER_HEADER, - }, - }) - const { exitCode, stdout } = runGate(repo, ['--quiet']) - assert.equal(exitCode, 0) - assert.equal(stdout, '') - rmSync(repo, { recursive: true, force: true }) -}) - -test('header body comparison ignores leading whitespace after // prefix', () => { - // Both files use `// content` — same prefix stripping. The content - // bytes must match exactly. - const repo = makeRepo({ - configContent: STD_CONFIG, - files: { - 'crates/parser/src/class.rs': CANONICAL_HEADER, - 'src/class.go': [ - '// BEGIN LOCK-STEP HEADER', - '// Class Parsing', - '//', - '// Lock-step with Go: src/class.go', - '// END LOCK-STEP HEADER', - '', - 'package parser', - ].join('\n'), - }, - }) - const { exitCode } = runGate(repo) - assert.equal(exitCode, 0) - rmSync(repo, { recursive: true, force: true }) -}) - -test('handles multi-peer canonical file', () => { - const multiPeerHeader = [ - '// BEGIN LOCK-STEP HEADER', - '// Class Parsing', - '//', - '// Lock-step with Go: src/class.go', - '// Lock-step with C++: cpp/class.cpp', - '// END LOCK-STEP HEADER', - ].join('\n') - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'], Go: ['src'], 'C++': ['cpp'] }, - scan: ['crates', 'src', 'cpp'], - extensions: ['.rs', '.go', '.cpp'], - }), - files: { - 'crates/parser/src/class.rs': multiPeerHeader + '\nfn x() {}', - 'src/class.go': multiPeerHeader + '\npackage parser', - 'cpp/class.cpp': multiPeerHeader + '\nvoid x() {}', - }, - }) - const { exitCode } = runGate(repo) - assert.equal(exitCode, 0) - rmSync(repo, { recursive: true, force: true }) -}) diff --git a/scripts/test/check-lock-step-refs.test.mts b/scripts/test/check-lock-step-refs.test.mts deleted file mode 100644 index 241c6c913..000000000 --- a/scripts/test/check-lock-step-refs.test.mts +++ /dev/null @@ -1,285 +0,0 @@ -// node --test specs for scripts/check-lock-step-refs.mts. -// -// The script is the CI-gate side of the Lock-step convention. It walks -// the scan dirs declared in .config/lock-step-refs.json, greps every -// canonical `Lock-step (with|from) <Lang>: <path>` comment, and fails -// when the path doesn't resolve. Companion edit-time hook is -// .claude/hooks/lock-step-ref-guard/. -// -// Test strategy: build a tmpdir repo with a known set of source files + -// a config + (optionally) the target files the refs claim. Spawn the -// script from that cwd and inspect exit code + stderr/stdout. Each test -// owns its own tmpdir to avoid cross-pollution. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import test from 'node:test' -import assert from 'node:assert/strict' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const SCRIPT_PATH = path.join(here, '..', 'check-lock-step-refs.mts') - -interface RepoSpec { - readonly configContent?: string | undefined - readonly files: Readonly<Record<string, string>> -} - -function makeRepo(spec: RepoSpec): string { - const root = mkdtempSync(path.join(os.tmpdir(), 'clsr-')) - if (spec.configContent !== undefined) { - mkdirSync(path.join(root, '.config'), { recursive: true }) - writeFileSync( - path.join(root, '.config', 'lock-step-refs.json'), - spec.configContent, - ) - } - for (const [rel, content] of Object.entries(spec.files)) { - const full = path.join(root, rel) - mkdirSync(path.dirname(full), { recursive: true }) - writeFileSync(full, content) - } - return root -} - -function runGate( - cwd: string, - args: readonly string[] = [], -): { stdout: string; stderr: string; exitCode: number } { - const result = spawnSync('node', [SCRIPT_PATH, ...args], { - cwd, - }) - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - exitCode: result.status ?? -1, - } -} - -test('exits 0 cleanly when .config/lock-step-refs.json is absent', () => { - const repo = makeRepo({ files: {} }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - assert.match(stdout, /opt-in gate disabled/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 2 when config is malformed JSON', () => { - const repo = makeRepo({ - configContent: '{ not valid json', - files: {}, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 2) - assert.match(stderr, /not valid JSON/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 2 when config is missing "roots"', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ scan: [], extensions: [] }), - files: {}, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 2) - assert.match(stderr, /missing required "roots"/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 0 when all refs resolve', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'crates/parser/src/class.rs': '', - 'src/parser/class.go': - '//! Lock-step from Rust: parser/src/class.rs\npackage parser', - }, - }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - assert.match(stdout, /scanned \d+ files — clean/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 1 when a ref points at a missing path', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'src/parser/class.go': - '//! Lock-step from Rust: parser-stmt/src/class.rs\npackage parser', - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - assert.match(stderr, /stale reference/) - assert.match(stderr, /parser-stmt\/src\/class\.rs/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('exits 1 when <Lang> is not in roots config', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'src/parser/class.go': - '//! Lock-step from Bash: scripts/run.sh\npackage parser', - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - assert.match(stderr, /unknown <Lang>/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('does NOT match prose "Lock-step with Go: JSON parser"', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Go: ['src'] }, - scan: ['src'], - extensions: ['.rs'], - }), - files: { - 'src/foo.rs': - '// Lock-step with Go: JSON parser semantics are subtle.\nfn x() {}', - }, - }) - const { exitCode, stdout } = runGate(repo) - assert.equal(exitCode, 0) - assert.match(stdout, /clean/) - rmSync(repo, { recursive: true, force: true }) -}) - -test('accepts inline ref with line range', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Go: ['src'] }, - scan: ['src'], - extensions: ['.rs'], - }), - files: { - 'src/parser.go': '', - 'src/foo.rs': '// Lock-step with Go: src/parser.go:6450-6457\nfn x() {}', - }, - }) - const { exitCode } = runGate(repo) - assert.equal(exitCode, 0) - rmSync(repo, { recursive: true, force: true }) -}) - -test('--json emits machine-readable findings', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'src/foo.go': - '//! Lock-step from Rust: parser-stmt/src/x.rs\npackage foo', - }, - }) - const { exitCode, stdout } = runGate(repo, ['--json']) - assert.equal(exitCode, 1) - const parsed = JSON.parse(stdout) as Array<Record<string, unknown>> - assert.ok(Array.isArray(parsed)) - assert.equal(parsed.length, 1) - assert.equal(parsed[0]!['lang'], 'Rust') - assert.equal(parsed[0]!['reason'], 'path-not-found') - rmSync(repo, { recursive: true, force: true }) -}) - -test('--quiet suppresses clean-run stdout', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'crates/parser/src/class.rs': '', - 'src/parser/class.go': - '//! Lock-step from Rust: parser/src/class.rs\npackage parser', - }, - }) - const { exitCode, stdout } = runGate(repo, ['--quiet']) - assert.equal(exitCode, 0) - assert.equal(stdout, '') - rmSync(repo, { recursive: true, force: true }) -}) - -test('skips SKIP_DIRS (node_modules, dist, target)', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - // These should be IGNORED — stale ref inside node_modules/ shouldn't fail the gate. - 'src/node_modules/junk/file.go': - '//! Lock-step from Rust: doesnotexist.rs\npackage x', - 'src/dist/x.go': '//! Lock-step from Rust: doesnotexist.rs\npackage x', - 'src/target/x.go': '//! Lock-step from Rust: doesnotexist.rs\npackage x', - }, - }) - const { exitCode } = runGate(repo) - assert.equal(exitCode, 0) - rmSync(repo, { recursive: true, force: true }) -}) - -test('resolves path against repo-root before per-lang roots', () => { - // A Rust file in ultrathink references `parser.go` — root-relative form - // (the Go impl tree puts parser.go where it does without lang-prefix). - // Should resolve when EITHER repo-root OR <lang>-root contains it. - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Go: ['langs/go/src'] }, - scan: ['langs/rust'], - extensions: ['.rs'], - }), - files: { - // Found via root-relative path resolution. - 'parser.go': '', - 'langs/rust/foo.rs': '// Lock-step with Go: parser.go:42\nfn x() {}', - }, - }) - const { exitCode } = runGate(repo) - assert.equal(exitCode, 0) - rmSync(repo, { recursive: true, force: true }) -}) - -test('reports findings grouped by file', () => { - const repo = makeRepo({ - configContent: JSON.stringify({ - roots: { Rust: ['crates'] }, - scan: ['src'], - extensions: ['.go'], - }), - files: { - 'src/a.go': - '//! Lock-step from Rust: stale-a.rs\n// Lock-step with Rust: stale-b.rs\npackage a', - 'src/b.go': '//! Lock-step from Rust: stale-c.rs\npackage b', - }, - }) - const { exitCode, stderr } = runGate(repo) - assert.equal(exitCode, 1) - // Three findings across two files. - assert.match(stderr, /3 stale reference/) - // File-grouped: each file appears once in the output even with multiple hits. - assert.match(stderr, /src\/a\.go/) - assert.match(stderr, /src\/b\.go/) - rmSync(repo, { recursive: true, force: true }) -}) diff --git a/scripts/test/install-claude-plugins.test.mts b/scripts/test/install-claude-plugins.test.mts deleted file mode 100644 index a68ed1ab4..000000000 --- a/scripts/test/install-claude-plugins.test.mts +++ /dev/null @@ -1,331 +0,0 @@ -// node --test specs for scripts/install-claude-plugins.mts. -// -// We test the pure helpers (extractInstalledSha, findForeignInstall, -// findOrphanMarketplaces). The Claude CLI shell-outs are integration -// surface — they mutate ~/.claude/ and aren't covered here. The pure -// helpers carry the actual reconciliation logic; if they're correct, -// the orchestration in reconcilePlugin / main is straightforward to -// audit by reading. - -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - extractInstalledSha, - findForeignInstall, - findOrphanMarketplaces, - lookupInstalledSha, - parsePatchFileName, - patchSidecarDir, - stripPatchHeader, -} from '../install-claude-plugins.mts' -import type { - MarketplaceListEntry, - PluginListEntry, -} from '../install-claude-plugins.mts' - -const OUR = 'socket-wheelhouse' - -test('extractInstalledSha returns 12-char prefix for SHA-pinned cache path', () => { - const got = extractInstalledSha( - '/Users/x/.claude/plugins/cache/socket-wheelhouse/codex/9cb4fe409919-deadbeef', - ) - assert.strictEqual(got, '9cb4fe409919') -}) - -test('extractInstalledSha handles content-hash of various lengths', () => { - const got = extractInstalledSha('/x/cache/m/p/abcdef012345-fedcba98') - assert.strictEqual(got, 'abcdef012345') -}) - -test('extractInstalledSha returns undefined for directory-source install (version-tagged)', () => { - const got = extractInstalledSha('/Users/x/projects/codex-plugin-cc') - assert.strictEqual(got, undefined) -}) - -test('extractInstalledSha returns undefined for version-tagged install', () => { - const got = extractInstalledSha( - '/Users/x/.claude/plugins/cache/openai-codex/codex/1.0.1', - ) - assert.strictEqual(got, undefined) -}) - -test('extractInstalledSha returns undefined for undefined input', () => { - assert.strictEqual(extractInstalledSha(undefined), undefined) -}) - -test('extractInstalledSha returns undefined for empty string', () => { - assert.strictEqual(extractInstalledSha(''), undefined) -}) - -test('extractInstalledSha rejects shapes that almost-match but are not 12 + 8+', () => { - // 11 chars instead of 12. - assert.strictEqual(extractInstalledSha('/x/cache/m/p/9cb4fe40991-deadbeef'), undefined) - // No content-hash suffix. - assert.strictEqual(extractInstalledSha('/x/cache/m/p/9cb4fe409919'), undefined) - // Non-hex chars. - assert.strictEqual(extractInstalledSha('/x/cache/m/p/zzzzzzzzzzzz-deadbeef'), undefined) -}) - -const fakePlugin = (id: string, installPath?: string): PluginListEntry => ({ - id, - scope: 'user', - enabled: true, - ...(installPath !== undefined ? { installPath } : {}), -}) - -test('findForeignInstall finds plugin under non-canonical marketplace', () => { - const plugins = [ - fakePlugin('codex@openai-codex', '/Users/x/projects/codex-plugin-cc'), - fakePlugin('clangd-lsp@claude-plugins-official'), - ] - const got = findForeignInstall('codex', plugins, OUR) - assert.ok(got) - assert.strictEqual(got.id, 'codex@openai-codex') -}) - -test('findForeignInstall returns undefined when plugin is under our marketplace', () => { - const plugins = [ - fakePlugin( - 'codex@socket-wheelhouse', - '/x/cache/socket-wheelhouse/codex/9cb4fe409919-aa', - ), - ] - const got = findForeignInstall('codex', plugins, OUR) - assert.strictEqual(got, undefined) -}) - -test('findForeignInstall returns undefined when plugin is not installed at all', () => { - const plugins = [fakePlugin('clangd-lsp@claude-plugins-official')] - const got = findForeignInstall('codex', plugins, OUR) - assert.strictEqual(got, undefined) -}) - -test('findForeignInstall ignores other plugins with similar prefixes', () => { - // "codex-helper" should not match "codex" — we match on the exact - // name before the @ separator. - const plugins = [fakePlugin('codex-helper@some-mkt')] - const got = findForeignInstall('codex', plugins, OUR) - assert.strictEqual(got, undefined) -}) - -test('findOrphanMarketplaces flags marketplace serving only-our plugins', () => { - const marketplaces: MarketplaceListEntry[] = [ - { name: OUR, source: 'github' }, - { name: 'openai-codex', source: 'directory' }, - ] - const plugins = [ - fakePlugin('codex@openai-codex'), - fakePlugin('codex@socket-wheelhouse'), - ] - const got = findOrphanMarketplaces( - marketplaces, - OUR, - new Set(['codex']), - plugins, - ) - assert.deepStrictEqual(got, ['openai-codex']) -}) - -test('findOrphanMarketplaces does NOT flag empty marketplace (no installs from it)', () => { - // User added a marketplace but installed nothing from it. Leave alone. - const marketplaces: MarketplaceListEntry[] = [ - { name: OUR, source: 'github' }, - { name: 'experimental', source: 'directory' }, - ] - const plugins = [fakePlugin('codex@socket-wheelhouse')] - const got = findOrphanMarketplaces( - marketplaces, - OUR, - new Set(['codex']), - plugins, - ) - assert.deepStrictEqual(got, []) -}) - -test('findOrphanMarketplaces does NOT flag marketplace serving non-overlapping plugins', () => { - // openai-codex serves codex (ours) AND some-other-plugin (NOT ours). - // We shouldn't suggest removing it — user might want some-other-plugin. - const marketplaces: MarketplaceListEntry[] = [ - { name: OUR, source: 'github' }, - { name: 'openai-codex', source: 'directory' }, - ] - const plugins = [ - fakePlugin('codex@openai-codex'), - fakePlugin('some-other-plugin@openai-codex'), - ] - const got = findOrphanMarketplaces( - marketplaces, - OUR, - new Set(['codex']), - plugins, - ) - assert.deepStrictEqual(got, []) -}) - -test('findOrphanMarketplaces never flags our own marketplace', () => { - const marketplaces: MarketplaceListEntry[] = [{ name: OUR, source: 'github' }] - const plugins = [fakePlugin('codex@socket-wheelhouse')] - const got = findOrphanMarketplaces( - marketplaces, - OUR, - new Set(['codex']), - plugins, - ) - assert.deepStrictEqual(got, []) -}) - -const FULL_SHA = '9cb4fe4099195b2587c402117a3efce6ab5aac78' - -test('lookupInstalledSha extracts gitCommitSha from installed_plugins.json shape', () => { - const state = { - version: 2, - plugins: { - 'codex@socket-wheelhouse': [ - { - scope: 'user', - installPath: '/x/y/z', - version: '1.0.1', - gitCommitSha: FULL_SHA, - }, - ], - }, - } - assert.strictEqual(lookupInstalledSha(state, 'codex@socket-wheelhouse'), FULL_SHA) -}) - -test('lookupInstalledSha returns undefined when plugin id is absent', () => { - const state = { version: 2, plugins: {} } - assert.strictEqual(lookupInstalledSha(state, 'codex@socket-wheelhouse'), undefined) -}) - -test('lookupInstalledSha returns undefined when entry has no gitCommitSha', () => { - const state = { - version: 2, - plugins: { - 'codex@socket-wheelhouse': [ - { scope: 'user', installPath: '/x/y/z', version: '1.0.1' }, - ], - }, - } - assert.strictEqual(lookupInstalledSha(state, 'codex@socket-wheelhouse'), undefined) -}) - -test('lookupInstalledSha rejects malformed gitCommitSha values', () => { - const state = { - version: 2, - plugins: { - 'codex@socket-wheelhouse': [{ gitCommitSha: 'not-a-sha' }], - }, - } - assert.strictEqual(lookupInstalledSha(state, 'codex@socket-wheelhouse'), undefined) -}) - -test('lookupInstalledSha handles null / non-object input', () => { - assert.strictEqual(lookupInstalledSha(undefined, 'codex@socket-wheelhouse'), undefined) - assert.strictEqual( - lookupInstalledSha('not-an-object', 'codex@socket-wheelhouse'), - undefined, - ) - assert.strictEqual(lookupInstalledSha({}, 'codex@socket-wheelhouse'), undefined) - assert.strictEqual( - lookupInstalledSha({ plugins: undefined }, 'codex@socket-wheelhouse'), - undefined, - ) -}) - -test('lookupInstalledSha walks multiple scope entries to find a valid SHA', () => { - // installed_plugins.json arrays can have multiple entries (one per - // scope). Take the first valid gitCommitSha. - const state = { - plugins: { - 'codex@socket-wheelhouse': [ - { scope: 'local' /* no sha */ }, - { scope: 'user', gitCommitSha: FULL_SHA }, - ], - }, - } - assert.strictEqual(lookupInstalledSha(state, 'codex@socket-wheelhouse'), FULL_SHA) -}) - -test('parsePatchFileName parses <plugin>-<version>-<slug>.patch', () => { - assert.deepStrictEqual(parsePatchFileName('codex-1.0.1-stdin-eagain.patch'), { - plugin: 'codex', - version: '1.0.1', - }) -}) - -test('parsePatchFileName keeps a hyphenated plugin name (version anchor disambiguates)', () => { - // The greedy plugin capture stops at the dotted-semver anchor, so a - // hyphenated plugin name survives. - assert.deepStrictEqual(parsePatchFileName('socket-foo-2.3.4-fix-crash.patch'), { - plugin: 'socket-foo', - version: '2.3.4', - }) -}) - -test('parsePatchFileName returns undefined without a dotted-semver version', () => { - assert.strictEqual(parsePatchFileName('codex-latest-fix.patch'), undefined) - assert.strictEqual(parsePatchFileName('codex-1.0-fix.patch'), undefined) -}) - -test('parsePatchFileName returns undefined without a slug after the version', () => { - assert.strictEqual(parsePatchFileName('codex-1.0.1.patch'), undefined) -}) - -test('parsePatchFileName returns undefined for a non-.patch file', () => { - assert.strictEqual(parsePatchFileName('codex-1.0.1-fix.diff'), undefined) - assert.strictEqual(parsePatchFileName('README.md'), undefined) -}) - -test('parsePatchFileName rejects uppercase (file naming is lowercase-kebab)', () => { - assert.strictEqual(parsePatchFileName('Codex-1.0.1-Fix.patch'), undefined) -}) - -test('stripPatchHeader drops the # provenance header, keeps the diff body', () => { - const patch = [ - '# @plugin: codex', - '# @description: fix something', - '#', - '--- a/scripts/lib/fs.mjs', - '+++ b/scripts/lib/fs.mjs', - '@@ -1,1 +1,1 @@', - '-old', - '+new', - '', - ].join('\n') - const body = stripPatchHeader(patch) - assert.ok(body.startsWith('--- a/scripts/lib/fs.mjs')) - assert.ok(!body.includes('@plugin')) -}) - -test('stripPatchHeader returns the whole body when there is no header', () => { - const body = '--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n' - assert.strictEqual(stripPatchHeader(body), body) -}) - -test('stripPatchHeader returns empty string when no diff body is present', () => { - assert.strictEqual(stripPatchHeader('# @plugin: codex\n# just a comment\n'), '') -}) - -test('stripPatchHeader only matches --- at line start (not mid-line)', () => { - // A `---` inside a comment line must not be mistaken for the diff start. - const patch = '# note: see --- somewhere\n--- a/real\n+++ b/real\n@@ -1 +1 @@\n-x\n+y\n' - const body = stripPatchHeader(patch) - assert.ok(body.startsWith('--- a/real')) -}) - -test('patchSidecarDir maps <x>.patch → <x>.files', () => { - assert.strictEqual( - patchSidecarDir('/a/b/codex-1.0.1-stdin-eagain.patch'), - '/a/b/codex-1.0.1-stdin-eagain.files', - ) -}) - -test('patchSidecarDir only rewrites a trailing .patch extension', () => { - // A `.patch` mid-path must not be rewritten — only the final extension. - assert.strictEqual( - patchSidecarDir('/a/.patch-stuff/codex-1.0.1-x.patch'), - '/a/.patch-stuff/codex-1.0.1-x.files', - ) -}) diff --git a/scripts/test/install-git-hooks.test.mts b/scripts/test/install-git-hooks.test.mts deleted file mode 100644 index 4da2b04da..000000000 --- a/scripts/test/install-git-hooks.test.mts +++ /dev/null @@ -1,174 +0,0 @@ -// node --test specs for scripts/install-git-hooks.mts. -// -// The installer is invoked from `prepare` at `pnpm install` time. Its -// job: set `core.hooksPath = .git-hooks` in the local git config when -// run inside a git checkout that has a `.git-hooks/` dir. Replaces -// husky's auto-install side effect with a 60-LOC dependency-free -// script. -// -// Each test spawns the installer in a tmpdir with a controlled -// .git/ + .git-hooks/ layout, then inspects the resulting -// core.hooksPath value via `git config`. Idempotency is verified by -// running the installer twice and confirming the second run is silent. -// -// The installer anchors REPO_ROOT on its own `import.meta.url` (not -// `process.cwd()`), so each test must COPY install-git-hooks.mts into -// `<tmpdir>/scripts/install-git-hooks.mts` before spawning it. Running -// the original script in the wheelhouse/fleet repo would still -// resolve REPO_ROOT to the real repo and write to the real git config -// instead of the tmpdir, which is what we want to verify. - -import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' -import { - copyFileSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import test from 'node:test' -import assert from 'node:assert/strict' -import { fileURLToPath } from 'node:url' - -const here = path.dirname(fileURLToPath(import.meta.url)) -const SOURCE_SCRIPT = path.join(here, '..', 'install-git-hooks.mts') - -interface TmpRepo { - /** - * Absolute path to the tmpdir; serves as the repo root the installer sees. - */ - readonly dir: string - /** - * Copy of install-git-hooks.mts under <dir>/scripts/ — what each test spawns. - */ - readonly installerPath: string - /** - * Where the installer expects to find / will write `core.hooksPath` -> here. - */ - readonly hooksDir: string - readonly cleanup: () => void -} - -function makeTmpRepo(): TmpRepo { - const dir = mkdtempSync(path.join(os.tmpdir(), 'install-git-hooks-test-')) - // Mirror the real on-disk layout: <repo-root>/scripts/install-git-hooks.mts. - // The installer derives REPO_ROOT as `path.dirname(import.meta.url)/..`, - // so placing the copy under `<dir>/scripts/` makes REPO_ROOT === dir. - const scriptsDir = path.join(dir, 'scripts') - mkdirSync(scriptsDir, { recursive: true }) - const installerPath = path.join(scriptsDir, 'install-git-hooks.mts') - copyFileSync(SOURCE_SCRIPT, installerPath) - // Construct once; tests reference `repo.hooksDir` everywhere they need it. - const hooksDir = path.join(dir, '.git-hooks') - return { - dir, - installerPath, - hooksDir, - cleanup: () => { - rmSync(dir, { force: true, recursive: true }) - }, - } -} - -// Initialize an empty git repo at dir. Uses `git init` so the .git -// directory has the same shape git itself expects (objects/, refs/, -// HEAD, …). Inheriting the user's git config could pollute the local -// `core.hooksPath` we're trying to inspect, so the test config sets a -// minimal identity and disables `core.hooksPath` inheritance via -// --local writes only. -function gitInit(dir: string): void { - const r = spawnSync('git', ['init', '--quiet', dir], {}) - assert.strictEqual(r.status, 0, `git init failed: ${r.stderr}`) -} - -function readLocalConfig(dir: string, key: string): string | undefined { - const r = spawnSync('git', ['-C', dir, 'config', '--local', '--get', key], {}) - return r.status === 0 ? String(r.stdout).trim() : undefined -} - -function runInstaller( - installerPath: string, - cwd: string, -): { code: number; stderr: string } { - const r = spawnSync(process.execPath, [installerPath], { - cwd, - }) - return { code: r.status ?? 0, stderr: r.stderr ? String(r.stderr) : '' } -} - -test('install-git-hooks: sets core.hooksPath when .git + .git-hooks both present', () => { - const repo = makeTmpRepo() - try { - gitInit(repo.dir) - mkdirSync(repo.hooksDir, { recursive: true }) - writeFileSync(path.join(repo.hooksDir, 'pre-commit'), '#!/bin/sh\nexit 0\n') - - const result = runInstaller(repo.installerPath, repo.dir) - assert.strictEqual(result.code, 0, `installer stderr: ${result.stderr}`) - assert.strictEqual( - readLocalConfig(repo.dir, 'core.hooksPath'), - '.git-hooks', - ) - } finally { - repo.cleanup() - } -}) - -test('install-git-hooks: idempotent — second run is a silent no-op', () => { - const repo = makeTmpRepo() - try { - gitInit(repo.dir) - mkdirSync(repo.hooksDir, { recursive: true }) - - const first = runInstaller(repo.installerPath, repo.dir) - assert.strictEqual(first.code, 0) - assert.strictEqual( - readLocalConfig(repo.dir, 'core.hooksPath'), - '.git-hooks', - ) - - const second = runInstaller(repo.installerPath, repo.dir) - assert.strictEqual(second.code, 0) - // Still set, still pointing at .git-hooks. - assert.strictEqual( - readLocalConfig(repo.dir, 'core.hooksPath'), - '.git-hooks', - ) - // Second run produced no stderr (truly silent on the no-op path). - assert.strictEqual(second.stderr.trim(), '') - } finally { - repo.cleanup() - } -}) - -test('install-git-hooks: skips when .git dir is absent (e.g. tarball install)', () => { - const repo = makeTmpRepo() - try { - // No `git init` — just create .git-hooks/ alone. - mkdirSync(repo.hooksDir, { recursive: true }) - - const result = runInstaller(repo.installerPath, repo.dir) - assert.strictEqual(result.code, 0) - // No config to inspect — the dir isn't a git repo. - assert.strictEqual(readLocalConfig(repo.dir, 'core.hooksPath'), undefined) - } finally { - repo.cleanup() - } -}) - -test('install-git-hooks: skips when .git-hooks dir is absent (pre-cascade state)', () => { - const repo = makeTmpRepo() - try { - gitInit(repo.dir) - // No .git-hooks dir. - - const result = runInstaller(repo.installerPath, repo.dir) - assert.strictEqual(result.code, 0) - // Installer bowed out before writing config. - assert.strictEqual(readLocalConfig(repo.dir, 'core.hooksPath'), undefined) - } finally { - repo.cleanup() - } -}) diff --git a/scripts/type.mts b/scripts/type.mts deleted file mode 100644 index 88dfa44c6..000000000 --- a/scripts/type.mts +++ /dev/null @@ -1,158 +0,0 @@ - -/** - * @file Monorepo-aware TypeScript type checker. Runs type checking across - * packages with pretty UI. - */ - -import type { PackageInfo } from './util/monorepo-helper.mts' - -import colors from 'yoctocolors-cjs' - -import { isQuiet } from '@socketsecurity/lib-stable/argv/flag-predicates' -import { parseArgs } from '@socketsecurity/lib-stable/argv/parse' -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' -import { printFooter } from '@socketsecurity/lib-stable/stdio/footer' -import { printHeader } from '@socketsecurity/lib-stable/stdio/header' - -import { getPackagesWithScript } from './util/monorepo-helper.mts' - -const logger = getDefaultLogger() -/** - * Run type check on a specific package with pretty output. - */ -async function runPackageTypeCheck( - pkg: PackageInfo, - quiet: boolean = false, -): Promise<number> { - const displayName = pkg.displayName || pkg.name - - if (!quiet) { - logger.progress(`${displayName}: checking types`) - } - - const result = await spawn('pnpm', ['--filter', pkg.name, 'run', 'type'], { - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- script runs under pnpm workspace; pnpm sets cwd to the package root so process.cwd() resolves correctly. - cwd: process.cwd(), - shell: WIN32, - stdio: 'pipe', - stdioString: true, - }) - - if (result.code !== 0) { - if (!quiet) { - logger.clearLine() - logger.log(`${colors.red('✗')} ${displayName}`) - } - if (result.stdout) { - logger.log(result.stdout) - } - if (result.stderr) { - logger.error(result.stderr) - } - return result.code - } - - if (!quiet) { - logger.clearLine() - logger.log(`${colors.green('✓')} ${displayName}`) - } - - return 0 -} - -async function main(): Promise<void> { - try { - // Parse arguments. - const { values } = parseArgs({ - options: { - help: { type: 'boolean', default: false }, - quiet: { type: 'boolean', default: false }, - silent: { type: 'boolean', default: false }, - }, - allowPositionals: false, - strict: false, - }) - - // Show help if requested. - if (values.help) { - logger.log('Monorepo Type Checker') - logger.log('') - logger.log('Usage: pnpm type [options]') - logger.log('') - logger.log('Options:') - logger.log(' --help Show this help message') - logger.log(' --quiet, --silent Suppress progress messages') - logger.log('') - logger.log('Examples:') - logger.log(' pnpm type # Type check all packages') - logger.log('') - logger.log('Note: Type checking always runs on all packages due to') - logger.log(' cross-package TypeScript dependencies.') - process.exitCode = 0 - return - } - - const quiet = isQuiet(values) - - if (!quiet) { - printHeader('Monorepo Type Checker') - logger.log('') - } - - // Get all packages with type script. - const packages = getPackagesWithScript('type') - - if (!packages.length) { - if (!quiet) { - logger.step('No packages with type checking found') - } - process.exitCode = 0 - return - } - - // Display what we're checking. - if (!quiet) { - logger.step( - `Type checking ${packages.length} package${packages.length > 1 ? 's' : ''}`, - ) - // Blank line. - logger.error('') - } - - // Run type check across all packages. - let exitCode = 0 - for (let i = 0, { length } = packages; i < length; i += 1) { - const pkg = packages[i] - const result = await runPackageTypeCheck(pkg, quiet) - if (result !== 0) { - exitCode = result - break - } - } - - if (exitCode !== 0) { - if (!quiet) { - logger.error('') - logger.log('Type checking failed') - } - process.exitCode = exitCode - } else { - if (!quiet) { - logger.error('') - logger.success('All type checks passed!') - printFooter() - } - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.error(`Type checker failed: ${message}`) - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - logger.error(e) - process.exitCode = 1 -}) diff --git a/scripts/update.mts b/scripts/update.mts deleted file mode 100644 index 2731c6fe2..000000000 --- a/scripts/update.mts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Update: two-pass taze to apply the fleet's maturity policy correctly. - * - * Pass 1: default config (.config/taze.config.mts) — non-Socket deps respect - * maturityPeriod: 7. - * - * Pass 2: CLI-flag override — Socket-owned scopes only, maturityPeriod: 0. - * taze's config auto-discovery is path-based and doesn't support a --config - * override, so the second pass uses `--include <scopes> --maturity- period 0` - * flags instead of a second config file. - * - * Pass 3: pnpm install to refresh the lockfile against the updated - * package.json. - * - * SOCKET_SCOPES below MUST match the `exclude` list in .config/taze.config.mts - * — drift causes double-bumps or misses. - * - * This is a reference script. Consuming repos can drop it into their own - * scripts/ dir and wire it in via a `"update": "node scripts/update.mts"` - * package.json entry. - */ -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -async function run(cmd: string, args: string[]): Promise<boolean> { - try { - await spawn(cmd, args, { stdio: 'inherit' }) - return true - } catch (e) { - process.exitCode = (e as { code?: number | undefined }).code ?? 1 - return false - } -} - -/* Socket-owned scopes — keep in lockstep with the exclude list - * in .config/taze.config.mts. */ -const SOCKET_SCOPES = [ - '@socketregistry/*', - '@socketsecurity/*', - '@socketdev/*', - 'socket-*', - 'ecc-agentshield', - 'sfw', -] - -const steps: Array<[string, string[]]> = [ - /* Pass 1 — third-party deps, respects the 7-day cooldown. - * - * `--maturity-period 7` MUST be passed on the CLI even though - * the config file (.config/taze.config.mts) sets the same - * value. Taze's CLI default for this flag is 0, and CLI - * defaults override config — without this flag, the cooldown - * is silently disabled. */ - ['pnpm', ['exec', 'taze', '--maturity-period', '7', '--write']], - /* Pass 2 — Socket deps, no cooldown. --include is comma-separated. */ - [ - 'pnpm', - [ - 'exec', - 'taze', - '--include', - SOCKET_SCOPES.join(','), - '--maturity-period', - '0', - '--write', - ], - ], - /* Pass 3 — resync lockfile against the updated package.json. */ - ['pnpm', ['install']], -] - -for (const [cmd, args] of steps) { - if (!(await run(cmd, args))) { - break - } -} diff --git a/scripts/util/changed-test-mapper.mts b/scripts/util/changed-test-mapper.mts deleted file mode 100644 index 7b32385e4..000000000 --- a/scripts/util/changed-test-mapper.mts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @file Maps changed source files to test files for affected test running. Uses - * git utilities from socket-registry to detect changes. - */ - -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { getChangedFilesSync } from '@socketsecurity/lib-stable/git/changed' -import { getStagedFilesSync } from '@socketsecurity/lib-stable/git/staged' -import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize' - -// oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- module-level constant: script runs under pnpm test which always invokes from the package root. -const rootPath = path.resolve(process.cwd()) - -/** - * Core files that require running all tests when changed. - */ -const CORE_FILES: string[] = [ - 'packages/cli/src/constants/', - 'packages/cli/src/bootstrap/', - 'packages/cli/src/polyfills/', - 'vitest.config.mts', - '.config/vitest.config.mts', - 'tsconfig.json', - '.config/tsconfig', -] - -interface GetTestsOptions { - staged?: boolean | undefined - all?: boolean | undefined -} - -interface GetTestsResult { - tests: string[] | 'all' | null - reason?: string | undefined - mode?: string | undefined -} - -/** - * Get affected test files to run based on changed files. - */ -export function getTestsToRun(options: GetTestsOptions = {}): GetTestsResult { - const { all = false, staged = false } = options - - // All mode runs all tests - if (all || process.env['FORCE_TEST'] === '1') { - return { tests: 'all', reason: 'explicit --all flag', mode: 'all' } - } - - // CI always runs all tests - if (process.env['CI'] === 'true') { - return { tests: 'all', reason: 'CI environment', mode: 'all' } - } - - // Get changed files - const changedFiles = staged ? getStagedFilesSync() : getChangedFilesSync() - const mode = staged ? 'staged' : 'changed' - - if (changedFiles.length === 0) { - // No changes, skip tests - return { tests: undefined, mode } - } - - const testFiles = new Set<string>() - let runAllTests = false - let runAllReason = '' - - for (let i = 0, { length } = changedFiles; i < length; i += 1) { - const file = changedFiles[i] - const normalized = normalizePath(file) - - // Test files always run themselves - if (normalized.includes('.test.')) { - // Skip deleted files. - if (existsSync(path.join(rootPath, file))) { - testFiles.add(file) - } - continue - } - - // Source files map to test files - const tests = mapSourceToTests(normalized) - if (tests.includes('all')) { - runAllTests = true - runAllReason = 'core file changes' - break - } - - for (let i = 0, { length } = tests; i < length; i += 1) { - const test = tests[i] - // Handle directory patterns - if (test.endsWith('/')) { - runAllTests = true - runAllReason = 'integration test directory' - break - } - - // Skip deleted files. - if (existsSync(path.join(rootPath, test))) { - testFiles.add(test) - } - } - - if (runAllTests) { - break - } - } - - if (runAllTests) { - return { tests: 'all', reason: runAllReason, mode: 'all' } - } - - if (testFiles.size === 0) { - return { tests: undefined, mode } - } - - return { tests: Array.from(testFiles), mode } -} - -/** - * Map source files to their corresponding test files. - */ -export function mapSourceToTests(filepath: string): string[] { - const normalized = normalizePath(filepath) - - // Skip non-code files - const ext = path.extname(normalized) - const codeExtensions = ['.js', '.mjs', '.cjs', '.ts', '.cts', '.mts', '.json'] - if (!codeExtensions.includes(ext)) { - return [] - } - - // Core utilities affect all tests - if (CORE_FILES.some(f => normalized.includes(f))) { - return ['all'] - } - - // Test files that live alongside source - if (normalized.includes('.test.')) { - return [normalized] - } - - // Map packages/cli/src files to their test counterparts - if (normalized.startsWith('packages/cli/src/')) { - // For files in packages/cli/src root, check for co-located test - const basename = path.basename(normalized, path.extname(normalized)) - const dirname = path.dirname(normalized) - - // Check for test file in same directory - const colocatedTest = path.join(dirname, `${basename}.test.mts`) - if (existsSync(path.join(rootPath, colocatedTest))) { - return [colocatedTest] - } - - // Check for test file in packages/cli/test - const testInTestDir = `packages/cli/test/${basename}.test.mts` - if (existsSync(path.join(rootPath, testInTestDir))) { - return [testInTestDir] - } - - // Special mappings for subdirectories - if (normalized.includes('packages/cli/src/commands/')) { - // Commands might have integration tests - return ['packages/cli/test/integration/'] - } - - // Check helpers and utils - if (normalized.includes('packages/cli/src/helpers/')) { - const helperName = basename - const helperTest = `packages/cli/test/helpers/${helperName}.test.mts` - if (existsSync(path.join(rootPath, helperTest))) { - return [helperTest] - } - } - - if (normalized.includes('packages/cli/src/util/')) { - const utilName = basename - const utilTest = `packages/cli/test/util/${utilName}.test.mts` - if (existsSync(path.join(rootPath, utilTest))) { - return [utilTest] - } - } - } - - // Scripts changes run all tests - if (normalized.startsWith('scripts/')) { - return ['all'] - } - - // External or fixtures changes - if ( - normalized.startsWith('external/') || - normalized.startsWith('fixtures/') - ) { - return ['packages/cli/test/integration/'] - } - - // If no specific mapping, run all tests to be safe - return ['all'] -} diff --git a/scripts/util/monorepo-helper.mts b/scripts/util/monorepo-helper.mts deleted file mode 100644 index 23abaf305..000000000 --- a/scripts/util/monorepo-helper.mts +++ /dev/null @@ -1,199 +0,0 @@ - -/** - * @file Monorepo helper utilities for running commands across packages. - * Provides package detection, file-to-package mapping, and pretty UI for - * multi-package operations. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' - -import colors from 'yoctocolors-cjs' - -import { WIN32 } from '@socketsecurity/lib-stable/constants/platform' -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' - -const logger = getDefaultLogger() - -export interface PackageInfo { - name: string - path: string - displayName: string -} - -/** - * Determine which packages are affected by changed files. - */ -export function getAffectedPackages(changedFiles: string[]): PackageInfo[] { - const affectedPkgs = new Set<PackageInfo>() - const packages = getPackagesWithScript('lint') - - for (let i = 0, { length } = changedFiles; i < length; i += 1) { - const file = changedFiles[i] - // Root level files affect all packages. - if ( - !file.startsWith('packages/') && - (file.includes('pnpm-lock.yaml') || - file.includes('tsconfig') || - file.includes('.oxfmtrc.json') || - file.includes('.oxlintrc.json')) - ) { - return packages - } - - // Map packages/* files to specific packages. - if (file.startsWith('packages/')) { - const parts = file.split('/') - if (parts.length > 1) { - const pkgDir = parts[1] - const pkg = packages.find(p => p.displayName === pkgDir) - if (pkg) { - affectedPkgs.add(pkg) - } - } - } - - // Scripts changes affect all packages. - if (file.startsWith('scripts/')) { - return packages - } - } - - return Array.from(affectedPkgs) -} - -/** - * Get all packages in the monorepo with specific scripts. - */ -export function getPackagesWithScript(scriptName: string): PackageInfo[] { - const packages: PackageInfo[] = [] - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- script runs under pnpm workspace; pnpm sets cwd to the package root. - const packagesDir = path.join(process.cwd(), 'packages') - - // Main CLI package always has all scripts. - const cliPackagePath = path.join(packagesDir, 'cli', 'package.json') - if (existsSync(cliPackagePath)) { - const pkgJson = JSON.parse(readFileSync(cliPackagePath, 'utf8')) - if (pkgJson.scripts?.[scriptName]) { - packages.push({ - displayName: 'cli', - name: pkgJson.name, - path: path.join(packagesDir, 'cli'), - }) - } - } - - // Check other packages that might have the script. - const otherPackages = [ - 'cli-with-sentry', - 'socket', - 'sbom-generator', - 'node-sea-builder', - 'node-smol-builder', - ] - - for (let i = 0, { length } = otherPackages; i < length; i += 1) { - const pkgDir = otherPackages[i] - const pkgPath = path.join(packagesDir, pkgDir, 'package.json') - if (existsSync(pkgPath)) { - const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (pkgJson.scripts?.[scriptName]) { - packages.push({ - displayName: pkgDir, - name: pkgJson.name, - path: path.join(packagesDir, pkgDir), - }) - } - } - } - - return packages -} - -/** - * Run a script across multiple packages. - */ -export async function runAcrossPackages( - packages: PackageInfo[], - scriptName: string, - args: string[] = [], - quiet: boolean = false, - sectionTitle: string = '', -): Promise<number> { - if (!packages.length) { - if (!quiet) { - logger.substep('No packages to process') - } - return 0 - } - - for (let i = 0, { length } = packages; i < length; i += 1) { - const pkg = packages[i] - const progressMessage = - sectionTitle || `${pkg.displayName || pkg.name}: running ${scriptName}` - const exitCode = await runPackageScript( - pkg, - scriptName, - args, - quiet, - progressMessage, - ) - if (exitCode !== 0) { - return exitCode - } - } - - return 0 -} - -/** - * Run a script on a specific package with pretty output. - */ -export async function runPackageScript( - pkg: PackageInfo, - scriptName: string, - args: string[] = [], - quiet: boolean = false, - progressMessage: string = '', -): Promise<number> { - const displayName = pkg.displayName || pkg.name - - if (!quiet) { - const message = progressMessage || `${displayName}: running ${scriptName}` - logger.progress(message) - } - - const result = await spawn( - 'pnpm', - ['--filter', pkg.name, 'run', scriptName, ...args], - { - // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- script runs under pnpm workspace; pnpm sets cwd to the package root so process.cwd() resolves correctly. - cwd: process.cwd(), - shell: WIN32, - stdio: 'pipe', - stdioString: true, - }, - ) - - if (result.code !== 0) { - if (!quiet) { - logger.clearLine() - logger.log(`${colors.red('✗')} ${displayName}`) - } - if (result.stdout) { - logger.log(result.stdout) - } - if (result.stderr) { - logger.error(result.stderr) - } - return result.code - } - - if (!quiet) { - logger.clearLine() - logger.success(`${displayName}: ${scriptName} passed`) - } - - return 0 -} diff --git a/scripts/utils/fs.js b/scripts/utils/fs.js new file mode 100644 index 000000000..aaf8d380a --- /dev/null +++ b/scripts/utils/fs.js @@ -0,0 +1,27 @@ +'use strict' + +const { statSync } = require('node:fs') +const path = require('node:path') + +function findUpSync(name, { cwd = process.cwd() }) { + let dir = path.resolve(cwd) + const { root } = path.parse(dir) + const names = [name].flat() + while (dir && dir !== root) { + for (const name of names) { + const filePath = path.join(dir, name) + try { + const stats = statSync(filePath) + if (stats.isFile()) { + return filePath + } + } catch {} + } + dir = path.dirname(dir) + } + return undefined +} + +module.exports = { + findUpSync, +} diff --git a/scripts/utils/packages.js b/scripts/utils/packages.js new file mode 100644 index 000000000..4139649b8 --- /dev/null +++ b/scripts/utils/packages.js @@ -0,0 +1,140 @@ +'use strict' + +const fs = require('node:fs') +const Module = require('node:module') +const path = require('node:path') +const vm = require('node:vm') + +const { isValidPackageName } = require('@socketsecurity/registry/lib/packages') +const { + isRelative, + normalizePath, +} = require('@socketsecurity/registry/lib/path') + +const { findUpSync } = require('./fs') + +const { createRequire, isBuiltin } = Module + +// eslint-disable-next-line no-control-regex +const cjsPluginPrefixRegExp = /^\x00/ +const cjsPluginSuffixRegExp = + /\?commonjs-(?:entry|es-import|exports|external|module|proxy|wrapped)$/ + +function getPackageName(string, start = 0) { + const end = getPackageNameEnd(string, start) + const name = string.slice(start, end) + return isValidPackageName(name) ? name : '' +} + +function getPackageNameEnd(string, start = 0) { + if (isRelative(string)) { + return 0 + } + const firstSlashIndex = string.indexOf('/', start) + if (firstSlashIndex === -1) { + return string.length + } + if (string.charCodeAt(start) !== 64 /*'@'*/) { + return firstSlashIndex + } + const secondSlashIndex = string.indexOf('/', firstSlashIndex + 1) + return secondSlashIndex === -1 ? string.length : secondSlashIndex +} + +function resolveId(id_, req = require) { + const id = normalizeId(id_) + let resolvedId + if (typeof req === 'string') { + try { + req = createRequire(req) + } catch {} + } + if (req !== require) { + try { + resolvedId = normalizePath(req.resolve(id)) + } catch {} + } + if (resolvedId === undefined) { + try { + resolvedId = normalizePath(require.resolve(id)) + } catch {} + } + if (resolvedId === undefined) { + resolvedId = id + } + if (isValidPackageName(id)) { + return resolvedId + } + const mtsId = `${resolvedId}.mts` + return fs.existsSync(mtsId) ? mtsId : resolvedId +} + +function isEsmId(id_, parentId_) { + if (isBuiltin(id_)) { + return false + } + const parentId = parentId_ ? resolveId(parentId_) : undefined + const resolvedId = resolveId(id_, parentId) + if (resolvedId.endsWith('.mjs') || resolvedId.endsWith('.mts')) { + return true + } + if ( + resolvedId.endsWith('.cjs') || + resolvedId.endsWith('.json') || + resolvedId.endsWith('.ts') + ) { + return false + } + let filepath + if (path.isAbsolute(resolvedId)) { + filepath = resolvedId + } else if (parentId && isRelative(resolvedId)) { + filepath = path.join(path.dirname(parentId), resolvedId) + } + if (!filepath) { + return false + } + const pkgJsonPath = findUpSync('package.json', { + cwd: path.dirname(resolvedId), + }) + if (pkgJsonPath) { + const pkgJson = require(pkgJsonPath) + const { exports: entryExports } = pkgJson + if ( + pkgJson.type === 'module' && + !entryExports?.require && + !entryExports?.node?.require && + !entryExports?.node?.default?.endsWith?.('.cjs') && + !entryExports?.['.']?.require && + !entryExports?.['.']?.node?.require && + !entryExports?.['.']?.node?.default?.endsWith?.('.cjs') && + !entryExports?.['.']?.node?.default?.default?.endsWith?.('.cjs') + ) { + return true + } + } + try { + // eslint-disable-next-line no-new + new vm.Script(fs.readFileSync(resolvedId, 'utf8')) + } catch (e) { + if (e instanceof SyntaxError) { + return true + } + } + return false +} + +function normalizeId(id) { + return normalizePath(id) + .replace(cjsPluginPrefixRegExp, '') + .replace(cjsPluginSuffixRegExp, '') +} + +module.exports = { + isBuiltin, + isEsmId, + getPackageName, + getPackageNameEnd, + normalizeId, + resolveId, +} diff --git a/scripts/validate-bundle-deps.mts b/scripts/validate-bundle-deps.mts deleted file mode 100644 index c487e7de1..000000000 --- a/scripts/validate-bundle-deps.mts +++ /dev/null @@ -1,474 +0,0 @@ -/* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */ -/** - * @file Validates that bundled vs external dependencies are correctly declared - * in package.json. Rules: - * - * - Bundled packages (code copied into dist/) should be in devDependencies - * - External packages (require() calls in dist/) should be in dependencies or - * peerDependencies - * - Packages used only for building should be in devDependencies This ensures - * consumers install only what they need. - */ - -import { promises as fs } from 'node:fs' -import { builtinModules } from 'node:module' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -// Node.js builtins to ignore (including node: prefix variants). -// node:smol-* are Socket SEA-bundled optional builtins (smol-util, smol-primordial); -// they appear in dist behind `mod.isBuiltin('node:smol-util')` guards and are only -// resolvable in SEA binaries, so they should never be expected in dependencies. -const SOCKET_SEA_BUILTINS = ['node:smol-util', 'node:smol-primordial'] -const BUILTIN_MODULES = new Set([ - ...builtinModules, - ...builtinModules.map(m => `node:${m}`), - ...SOCKET_SEA_BUILTINS, -]) - -/** - * Find all JavaScript files in dist directory. - */ -async function findDistFiles(distPath: string): Promise<string[]> { - const files: string[] = [] - - try { - const entries = await fs.readdir(distPath, { withFileTypes: true }) - - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const fullPath = path.join(distPath, entry.name) - - if (entry.isDirectory()) { - files.push(...(await findDistFiles(fullPath))) - } else if ( - entry.name.endsWith('.js') || - entry.name.endsWith('.mjs') || - entry.name.endsWith('.cjs') - ) { - files.push(fullPath) - } - } - } catch { - // Directory doesn't exist or can't be read - return [] - } - - return files -} - -/** - * Check if a string is a valid package specifier. - */ -function isValidPackageSpecifier(specifier: string): boolean { - // Relative imports - if (specifier.startsWith('.') || specifier.startsWith('/')) { - return false - } - - // Subpath imports (Node.js internal imports starting with #) - if (specifier.startsWith('#')) { - return false - } - - // Filter out invalid patterns - if ( - specifier.includes('${') || - specifier.includes('"}') || - specifier.includes('`') || - specifier === 'true' || - specifier === 'false' || - specifier === 'null' || - specifier === 'undefined' || - specifier === 'name' || - specifier === 'dependencies' || - specifier === 'devDependencies' || - specifier === 'peerDependencies' || - specifier === 'version' || - specifier === 'description' || - specifier.length === 0 || - // Filter out strings that look like code fragments - specifier.includes('\n') || - specifier.includes(';') || - specifier.includes('function') || - specifier.includes('const ') || - specifier.includes('let ') || - specifier.includes('var ') - ) { - return false - } - - return true -} - -/** - * Extract external package names from require() and import statements in built - * files. - */ -async function extractExternalPackages(filePath: string): Promise<Set<string>> { - const content = await fs.readFile(filePath, 'utf8') - const externals = new Set<string>() - - // Match require('package') or require("package") - const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g - // Match import from 'package' or import from "package" - const importPattern = /(?:from|import)\s+['"]([^'"]+)['"]/g - // Match dynamic import() calls - const dynamicImportPattern = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g - - let match: RegExpExecArray | null - - // Extract from require() - while ((match = requirePattern.exec(content)) !== null) { - const specifier = match[1] - if (!specifier) { - continue - } - // Skip internal src/external/ wrapper paths (used by socket-lib pattern) - if (specifier.includes('/external/')) { - continue - } - if (isValidPackageSpecifier(specifier)) { - externals.add(specifier) - } - } - - // Extract from import statements - while ((match = importPattern.exec(content)) !== null) { - const specifier = match[1] - if (!specifier) { - continue - } - // Skip internal src/external/ wrapper paths (used by socket-lib pattern) - if (specifier.includes('/external/')) { - continue - } - if (isValidPackageSpecifier(specifier)) { - externals.add(specifier) - } - } - - // Extract from dynamic import() - while ((match = dynamicImportPattern.exec(content)) !== null) { - const specifier = match[1] - if (!specifier) { - continue - } - // Skip internal src/external/ wrapper paths (used by socket-lib pattern) - if (specifier.includes('/external/')) { - continue - } - if (isValidPackageSpecifier(specifier)) { - externals.add(specifier) - } - } - - return externals -} - -/** - * Extract bundled package names from node_modules paths in comments and code. - */ -async function extractBundledPackages(filePath: string): Promise<Set<string>> { - const content = await fs.readFile(filePath, 'utf8') - const bundled = new Set<string>() - - // Match node_modules paths in comments: node_modules/.pnpm/@scope+package@version/... - // or node_modules/@scope/package/... - // or node_modules/package/... - const nodeModulesPattern = - /node_modules\/(?:\.pnpm\/)?(@[^/]+\+[^@/]+|@[^/]+\/[^/]+|[^/@]+)/g - - let match: RegExpExecArray | null - while ((match = nodeModulesPattern.exec(content)) !== null) { - let packageName = match[1] - if (!packageName) { - continue - } - - // Handle pnpm path format: @scope+package -> @scope/package - if (packageName.includes('+')) { - packageName = packageName.replace('+', '/') - } - - // Filter out invalid package names (contains special chars, code fragments, etc.) - if ( - packageName.includes('"') || - packageName.includes("'") || - packageName.includes('`') || - packageName.includes('${') || - packageName.includes('\\') || - packageName.includes(';') || - packageName.includes('\n') || - packageName.includes('function') || - packageName.includes('const') || - packageName.includes('let') || - packageName.includes('var') || - packageName.includes('=') || - packageName.includes('{') || - packageName.includes('}') || - packageName.includes('[') || - packageName.includes(']') || - packageName.includes('(') || - packageName.includes(')') || - // Filter out common false positives (strings that appear in code but aren't packages) - packageName === 'bin' || - packageName === '.bin' || - packageName === 'npm' || - packageName === 'node' || - packageName === 'pnpm' || - packageName === 'yarn' || - packageName.length === 0 || - // npm package name max length - packageName.length > 214 - ) { - continue - } - - bundled.add(packageName) - } - - return bundled -} - -/** - * Get package name from a module specifier (strip subpaths). - */ -function getPackageName(specifier: string): string | undefined { - // Relative imports are not packages - if (specifier.startsWith('.') || specifier.startsWith('/')) { - return undefined - } - - // Subpath imports (Node.js internal imports starting with #) - if (specifier.startsWith('#')) { - return undefined - } - - // Filter out template strings, boolean strings, and other non-package patterns - if ( - specifier.includes('${') || - specifier.includes('"}') || - specifier.includes('`') || - specifier === 'true' || - specifier === 'false' || - specifier === 'null' || - specifier === 'undefined' || - specifier.length === 0 || - // Filter out strings that look like code fragments - specifier.includes('\n') || - specifier.includes(';') || - specifier.includes('function') || - specifier.includes('const ') || - specifier.includes('let ') || - specifier.includes('var ') || - // Filter out common non-package strings - specifier.includes('"') || - specifier.includes("'") || - specifier.includes('\\') - ) { - return undefined - } - - // Scoped package: @scope/package or @scope/package/subpath - if (specifier.startsWith('@')) { - const parts = specifier.split('/') - if (parts.length >= 2) { - return `${parts[0]}/${parts[1]}` - } - return undefined - } - - // Regular package: package or package/subpath - const parts = specifier.split('/') - return parts[0] || undefined -} - -interface PackageJson { - name?: string | undefined - version?: string | undefined - main?: string | undefined - types?: string | undefined - dependencies?: Record<string, string> | undefined - devDependencies?: Record<string, string> | undefined - peerDependencies?: Record<string, string> | undefined - optionalDependencies?: Record<string, string> | undefined - exports?: Record<string, string | Record<string, string>> | undefined -} - -/** - * Read and parse package.json. - */ -async function readPackageJson(): Promise<PackageJson> { - const packageJsonPath = path.join(rootPath, 'package.json') - const content = await fs.readFile(packageJsonPath, 'utf8') - try { - return JSON.parse(content) - } catch (e) { - throw new Error( - `Failed to parse ${packageJsonPath}: ${e instanceof Error ? e.message : 'Unknown error'}`, - { cause: e }, - ) - } -} - -interface Violation { - type: string - package: string - message: string - fix: string -} - -interface Warning { - type: string - package: string - message: string - fix: string -} - -interface ValidationResult { - violations: Violation[] - warnings: Warning[] -} - -/** - * Validate bundle dependencies. - */ -async function validateBundleDeps(): Promise<ValidationResult> { - const distPath = path.join(rootPath, 'dist') - const pkg = await readPackageJson() - - const dependencies = new Set(Object.keys(pkg.dependencies || {})) - const devDependencies = new Set(Object.keys(pkg.devDependencies || {})) - const peerDependencies = new Set(Object.keys(pkg.peerDependencies || {})) - - // Find all dist files - const distFiles = await findDistFiles(distPath) - - if (distFiles.length === 0) { - logger.info('No dist files found - run build first') - return { violations: [], warnings: [] } - } - - // Collect all external and bundled packages - const allExternals = new Set<string>() - const allBundled = new Set<string>() - - for (let i = 0, { length } = distFiles; i < length; i += 1) { - const file = distFiles[i]! - const externals = await extractExternalPackages(file) - const bundled = await extractBundledPackages(file) - - // externals + bundled are Set<string> — use for...of, the - // canonical fix for set / map / iterable iteration. - for (const ext of externals) { - const packageName = getPackageName(ext) - if (packageName && !BUILTIN_MODULES.has(packageName)) { - allExternals.add(packageName) - } - } - - for (const bun of bundled) { - allBundled.add(bun) - } - } - - const violations: Violation[] = [] - const warnings: Warning[] = [] - - // Validate external packages are in dependencies or peerDependencies. - // allExternals / allBundled are Sets — use for...of. - for (const packageName of allExternals) { - if (!dependencies.has(packageName) && !peerDependencies.has(packageName)) { - violations.push({ - type: 'external-not-in-deps', - package: packageName, - message: `External package "${packageName}" is marked external but not in dependencies`, - fix: devDependencies.has(packageName) - ? `RECOMMENDED: Remove "${packageName}" from esbuild's "external" array to bundle it (keep in devDependencies)\n OR: Move "${packageName}" to dependencies if it must stay external` - : `RECOMMENDED: Remove "${packageName}" from esbuild's "external" array to bundle it\n OR: Add "${packageName}" to dependencies if it must stay external`, - }) - } - } - - // Validate bundled packages are in devDependencies (not dependencies) - for (const packageName of allBundled) { - if (dependencies.has(packageName)) { - violations.push({ - type: 'bundled-in-deps', - package: packageName, - message: `Bundled package "${packageName}" should be in devDependencies, not dependencies`, - fix: `Move "${packageName}" from dependencies to devDependencies (code is bundled into dist/)`, - }) - } - - if (!devDependencies.has(packageName) && !dependencies.has(packageName)) { - warnings.push({ - type: 'bundled-not-declared', - package: packageName, - message: `Bundled package "${packageName}" is not declared in devDependencies`, - fix: `Add "${packageName}" to devDependencies`, - }) - } - } - - return { violations, warnings } -} - -async function main(): Promise<void> { - try { - const { violations, warnings } = await validateBundleDeps() - - if (violations.length === 0 && warnings.length === 0) { - logger.success('Bundle dependencies validation passed') - process.exitCode = 0 - return - } - - if (violations.length > 0) { - logger.fail('Bundle dependencies validation failed') - logger.error('') - - for (let i = 0, { length } = violations; i < length; i += 1) { - const violation = violations[i]! - logger.error(` ${violation.message}`) - logger.error(` ${violation.fix}`) - logger.error('') - } - } - - if (warnings.length > 0) { - logger.warn('Warnings:') - logger.error('') - - for (let i = 0, { length } = warnings; i < length; i += 1) { - const warning = warnings[i]! - logger.log(` ${warning.message}`) - logger.log(` ${warning.fix}`) - logger.log('') - } - } - - // Only fail on violations, not warnings - process.exitCode = violations.length > 0 ? 1 : 0 - } catch (e) { - logger.error( - 'Validation failed:', - e instanceof Error ? e.message : String(e), - ) - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - logger.error('Unhandled error in main():', e) - process.exitCode = 1 -}) diff --git a/scripts/validate-checksums.mts b/scripts/validate-checksums.mts deleted file mode 100644 index 2f3e7790c..000000000 --- a/scripts/validate-checksums.mts +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node - -/** - * @file Build-time validation for SHA-256 checksums. Ensures all required - * platform-specific tool assets have checksums defined in bundle-tools.json - * before building SEA binaries. This script is a security requirement - - * builds MUST NOT proceed if any checksums are missing for downloadable - * binaries. Exit codes: - * - * - 0: All required checksums are present. - * - 1: One or more checksums are missing. - */ - -import { readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -import { PLATFORM_MAP_TOOLS } from '../packages/cli/scripts/constants/external-tools-platforms.mts' - -const logger = getDefaultLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -// Load external tools configuration. -const externalToolsPath = path.join(rootPath, 'packages/cli/bundle-tools.json') -const externalTools = JSON.parse(readFileSync(externalToolsPath, 'utf8')) - -/** - * Validate that all required checksums exist for external tools. - */ -export function validateChecksums(): boolean { - const errors: string[] = [] - const warnings: string[] = [] - - logger.info('Validating SHA-256 checksums for external tools...') - logger.error('') - - // Track all unique assets that need checksums. - const requiredAssets = new Map<string, Set<string>>() - - // Collect all assets needed across all platforms. - for (const [platform, tools] of Object.entries(PLATFORM_MAP_TOOLS)) { - if (!tools) { - continue - } - - for (const [toolName, assetName] of Object.entries(tools)) { - if (!assetName) { - continue - } - - if (!requiredAssets.has(toolName)) { - requiredAssets.set(toolName, new Set()) - } - requiredAssets.get(toolName).add(assetName) - } - } - - // Validate each tool's checksums. - for (const [toolName, assets] of requiredAssets) { - const toolConfig = externalTools[toolName] - - if (!toolConfig) { - errors.push(`Tool "${toolName}" not found in bundle-tools.json`) - continue - } - - // Only GitHub release tools need checksums. - if (toolConfig.type !== 'github-release') { - continue - } - - const checksums = toolConfig.checksums || {} - const missingAssets: string[] = [] - - for (let i = 0, { length } = assets; i < length; i += 1) { - const assetName = assets[i] - if (!checksums[assetName]) { - missingAssets.push(assetName) - } - } - - if (missingAssets.length > 0) { - errors.push( - `Missing checksums for ${toolName}:\n` + - missingAssets.map(a => ` - ${a}`).join('\n'), - ) - } else { - logger.success(`${toolName}: ${assets.size} asset checksum(s) verified`) - } - } - - // Check for extra checksums that aren't used (informational). - for (const [toolName, toolConfig] of Object.entries(externalTools)) { - if (toolConfig.type !== 'github-release' || !toolConfig.checksums) { - continue - } - - const usedAssets = requiredAssets.get(toolName) || new Set() - const extraAssets = Object.keys(toolConfig.checksums).filter( - asset => !usedAssets.has(asset), - ) - - if (extraAssets.length > 0) { - warnings.push( - `${toolName} has ${extraAssets.length} unused checksum(s) (may be for unsupported platforms)`, - ) - } - } - - // Print summary. - logger.log('') - if (warnings.length > 0) { - logger.warn('Warnings:') - for (let i = 0, { length } = warnings; i < length; i += 1) { - const warning = warnings[i] - logger.warn(` ${warning}`) - } - logger.log('') - } - - if (errors.length > 0) { - logger.error('CHECKSUM VALIDATION FAILED') - logger.log('') - for (let i = 0, { length } = errors; i < length; i += 1) { - const error = errors[i] - logger.error(error) - } - logger.log('') - logger.error( - 'All external tool assets MUST have SHA-256 checksums defined in bundle-tools.json.', - ) - logger.error( - 'This is a security requirement to prevent supply chain attacks.', - ) - return false - } - - logger.error('') - logger.success('All required checksums are present.') - return true -} - -// Run validation. -const valid = validateChecksums() -process.exit(valid ? 0 : 1) diff --git a/scripts/validate-config-paths.mts b/scripts/validate-config-paths.mts deleted file mode 100644 index 939faf9df..000000000 --- a/scripts/validate-config-paths.mts +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node -/** - * @file Repo gate: every tooling config that _can_ live in `.config/` _does_ - * live there, and there is no stale duplicate at the repo root. Per - * CLAUDE.md's "Config files in `.config/`" rule, the root keeps only what - * _must_ be there: - * - * - package manifests + lockfile (package.json, pnpm-lock.yaml, - * pnpm-workspace.yaml) - * - linter / formatter dotfiles whose tools require root placement - * (.oxlintrc.json, .oxfmtrc.json, .npmrc, .gitignore, .gitattributes, - * .node-version) - * - tsconfig.json (TypeScript's project root anchor — extends from - * .config/tsconfig.base.json) Everything else (taze.config.mts, - * vitest.config_.mts, tsconfig.base.json, esbuild.config.mts, - * lockstep.json, socket-wheelhouse.json, etc.) lives in `.config/`. A copy - * at root is drift — usually a half-finished move that left a stale file - * behind. `tsconfig.base.json` is the abstract compiler-options layer - * (fleet-canonical, byte-identical across the fleet) and stays in - * `.config/`. *Concrete_ tsconfigs (`tsconfig.json`, `tsconfig.check.json`, - * `tsconfig.dts.json`, etc. — anything with `include`/`exclude`/`files`) - * live at the package root: at repo root for single-package repos, at each - * `packages/<pkg>/` for monorepos. tsc discovers `tsconfig.json` at cwd - * natively; keeping the concrete elsewhere breaks IDE language-server - * discovery and forces every caller to pass `-p <path>` explicitly. Exit - * codes: 0 — clean 1 — duplicate(s) found - */ -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.resolve(__dirname, '..') -const configPath = path.join(rootPath, '.config') - -// Filename patterns that must live in .config/ when they exist at all, -// and must NOT have a root duplicate. Listed by basename — the gate -// checks both root and .config/ for each. -const CONFIG_BASENAMES: readonly string[] = [ - 'esbuild.config.mts', - 'isolated-tests.json', - 'lockstep.json', - 'lockstep.schema.json', - 'oxfmtrc.json', - 'oxlintrc.json', - 'socket-wheelhouse-schema.json', - 'socket-wheelhouse.json', - 'taze.config.mts', - 'tsconfig.base.json', - 'vitest.config.isolated.mts', - 'vitest.config.mts', - 'vitest.coverage.config.mts', -] - -// Root dotfile aliases for files that ALSO appear without the dot in -// .config/. e.g. `.oxlintrc.json` is the root-required form (tool -// looks for it at cwd); `oxlintrc.json` is the .config/ form. Both -// are legitimate; the gate verifies only one of each pair is present. -const ROOT_DOT_PAIRS: ReadonlyArray<readonly [string, string]> = [ - ['.oxlintrc.json', 'oxlintrc.json'], - ['.oxfmtrc.json', 'oxfmtrc.json'], -] - -// Concrete tsconfig basenames — these must NOT live in `.config/`. -// They have `include`/`exclude` and belong at the package root so tsc -// + IDE can discover them natively. The abstract layer -// (`tsconfig.base.json`) stays in `.config/` and is in -// `CONFIG_BASENAMES` above. -const CONCRETE_TSCONFIG_BASENAMES: readonly string[] = [ - 'tsconfig.json', - 'tsconfig.check.json', - 'tsconfig.check.local.json', - 'tsconfig.dts.json', - 'tsconfig.test.json', - 'tsconfig.build.json', - 'tsconfig.declaration.json', -] - -function main(): void { - const findings: string[] = [] - - // Direct duplicates: same basename at root AND in .config/. - for (let i = 0, { length } = CONFIG_BASENAMES; i < length; i += 1) { - const basename = CONFIG_BASENAMES[i]! - const rootCopy = path.join(rootPath, basename) - const configCopy = path.join(configPath, basename) - if (existsSync(rootCopy) && existsSync(configCopy)) { - findings.push( - `Duplicate config: ${basename} exists at both repo root and .config/. Delete the root copy; .config/ is canonical.`, - ) - } else if (existsSync(rootCopy)) { - findings.push( - `Stale root config: ${basename} should live in .config/, not at the repo root. Move it.`, - ) - } - } - - // Dotfile aliases: only ONE of the pair should exist. - for (const [dotName, plainName] of ROOT_DOT_PAIRS) { - const dotPath = path.join(rootPath, dotName) - const plainPath = path.join(configPath, plainName) - if (existsSync(dotPath) && existsSync(plainPath)) { - findings.push( - `Duplicate config: ${dotName} (root) and .config/${plainName} both exist. Keep the .config/ copy; tools accept -c .config/${plainName} explicitly.`, - ) - } - } - - // Concrete tsconfigs must NOT live in `.config/`. They belong at the - // repo root (single-package) or each `packages/<pkg>/` (monorepo). - // tsc + IDE discover them natively at cwd; burying them in `.config/` - // breaks language-server lookups and forces explicit `-p <path>`. - for ( - let i = 0, { length } = CONCRETE_TSCONFIG_BASENAMES; - i < length; - i += 1 - ) { - const basename = CONCRETE_TSCONFIG_BASENAMES[i]! - const configCopy = path.join(configPath, basename) - if (existsSync(configCopy)) { - findings.push( - `Concrete tsconfig in .config/: .config/${basename} should live at the package root, not in .config/. Move it (single-package: repo root; monorepo: packages/<pkg>/).`, - ) - } - } - - if (findings.length === 0) { - const total = CONFIG_BASENAMES.length + CONCRETE_TSCONFIG_BASENAMES.length - logger.success( - `Config-path hygiene OK — ${total} basenames checked, no drift.`, - ) - return - } - - logger.error(`Config-path hygiene violations (${findings.length}):`) - for (let i = 0, { length } = findings; i < length; i += 1) { - const f = findings[i]! - logger.error(` ${f}`) - } - process.exitCode = 1 -} - -main() diff --git a/scripts/validate-file-size.mts b/scripts/validate-file-size.mts deleted file mode 100644 index 675336370..000000000 --- a/scripts/validate-file-size.mts +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node -/** - * @file Validates that no individual files exceed size threshold. Rules: - * - * - No single file should exceed 2MB (2,097,152 bytes) - * - Helps prevent accidental commits of large binaries, data files, or - * artifacts - * - Excludes: node_modules, .git, dist, build, coverage directories - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -// Maximum file size: 2MB (2,097,152 bytes) -const MAX_FILE_SIZE = 2 * 1024 * 1024 - -// Allowlisted large files: fleet-canonical assets whose size is bounded by -// the upstream they ship, not by repo authoring. acorn.wasm is the AST -// parser shared by AST-based oxlint plugin rules + hooks; its ~3MB is the -// upstream build artifact. Two paths because socket-lib vendors its own -// copy at vendor/acorn-wasm/ (so the lib package's own AST helpers can -// load without a node_modules round-trip). Adding a path here is -// intentional — it should only happen for files the fleet jointly owns, -// not per-repo binary leaks. -const ALLOWED_LARGE_FILES = new Set<string>([ - '.claude/hooks/_shared/acorn/acorn.wasm', - 'vendor/acorn-wasm/acorn.wasm', -]) - -// Directories to skip -const SKIP_DIRS = new Set([ - '.cache', - '.git', - '.next', - '.nuxt', - '.output', - '.turbo', - '.vercel', - '.vscode', - 'build', - 'coverage', - 'dist', - 'external', - 'node_modules', - 'tmp', -]) - -/** - * Format bytes to human-readable size. - */ -function formatBytes(bytes: number): string { - if (bytes === 0) { - return '0 B' - } - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.min( - Math.floor(Math.log(bytes) / Math.log(k)), - sizes.length - 1, - ) - return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}` -} - -interface FileSizeViolation { - file: string - size: number - formattedSize: string - maxSize: string -} - -/** - * Recursively scan directory for files exceeding size limit. - */ -async function scanDirectory( - dir: string, - violations: FileSizeViolation[] = [], -): Promise<FileSizeViolation[]> { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i]! - const fullPath = path.join(dir, entry.name) - - if (entry.isDirectory()) { - // Skip excluded directories and hidden directories (except .claude, .config, .github) - if ( - !SKIP_DIRS.has(entry.name) && - (!entry.name.startsWith('.') || - entry.name === '.claude' || - entry.name === '.config' || - entry.name === '.github') - ) { - await scanDirectory(fullPath, violations) - } - } else if (entry.isFile()) { - try { - // oxlint-disable-next-line socket/prefer-exists-sync -- need stats.size for the size threshold check; this IS the file-size validator. - const stats = await fs.stat(fullPath) - if (stats.size > MAX_FILE_SIZE) { - const relativePath = path.relative(rootPath, fullPath) - if (ALLOWED_LARGE_FILES.has(relativePath)) { - continue - } - violations.push({ - file: relativePath, - size: stats.size, - formattedSize: formatBytes(stats.size), - maxSize: formatBytes(MAX_FILE_SIZE), - }) - } - } catch { - // Skip files we can't stat - } - } - } - } catch { - // Skip directories we can't read - } - - return violations -} - -/** - * Validate file sizes in repository. - */ -async function validateFileSizes(): Promise<FileSizeViolation[]> { - const violations = await scanDirectory(rootPath) - - // Sort by size descending (largest first) - violations.sort((a, b) => b.size - a.size) - - return violations -} - -async function main(): Promise<void> { - try { - const violations = await validateFileSizes() - - if (violations.length === 0) { - logger.success('All files are within size limits') - process.exitCode = 0 - return - } - - logger.fail('File size violations found') - logger.log('') - logger.log(`Maximum allowed file size: ${formatBytes(MAX_FILE_SIZE)}`) - logger.log('') - logger.log('Files exceeding limit:') - logger.log('') - - for (let i = 0, { length } = violations; i < length; i += 1) { - const violation = violations[i]! - logger.log(` ${violation.file}`) - logger.log(` Size: ${violation.formattedSize}`) - logger.log( - ` Exceeds limit by: ${formatBytes(violation.size - MAX_FILE_SIZE)}`, - ) - logger.log('') - } - - logger.log( - 'Reduce file sizes, move large files to external storage, or exclude from repository.', - ) - logger.log('') - - process.exitCode = 1 - } catch (e) { - logger.fail( - `Validation failed: ${e instanceof Error ? e.message : String(e)}`, - ) - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - logger.fail(`Validation failed: ${e}`) - process.exitCode = 1 -}) diff --git a/scripts/validate-no-cdn-refs.mts b/scripts/validate-no-cdn-refs.mts deleted file mode 100644 index 2ea126992..000000000 --- a/scripts/validate-no-cdn-refs.mts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * @file Validates that there are no CDN references in the codebase. This is a - * preventative check to ensure no hardcoded CDN URLs are introduced. The - * project deliberately avoids CDN dependencies for security and reliability. - * Blocked CDN domains: - * - * - unpkg.com - * - cdn.jsdelivr.net - * - esm.sh - * - cdn.skypack.dev - * - ga.jspm.io - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -// CDN domains to block -const CDN_PATTERNS = [ - /unpkg\.com/i, - /cdn\.jsdelivr\.net/i, - /esm\.sh/i, - /cdn\.skypack\.dev/i, - /ga\.jspm\.io/i, -] - -// Directories to skip -const SKIP_DIRS = new Set([ - '.cache', - '.git', - '.next', - '.nuxt', - '.output', - '.turbo', - '.type-coverage', - '.yarn', - 'build', - 'coverage', - 'dist', - 'node_modules', -]) - -// File extensions to check -const TEXT_EXTENSIONS = new Set([ - '.bash', - '.cjs', - '.css', - '.cts', - '.htm', - '.html', - '.js', - '.json', - '.jsx', - '.md', - '.mjs', - '.mts', - '.sh', - '.svg', - '.ts', - '.tsx', - '.txt', - '.xml', - '.yaml', - '.yml', -]) - -interface CdnViolation { - file: string - line: number - content: string - cdnDomain: string -} - -/** - * Check file contents for CDN references. - */ -async function checkFileForCdnRefs( - filePath: string, -): Promise<CdnViolation[]> { - // Skip this validator script itself (it mentions CDN domains by necessity) - if (filePath.endsWith('validate-no-cdn-refs.mts')) { - return [] - } - - try { - const content = await fs.readFile(filePath, 'utf8') - const lines = content.split('\n') - const violations: CdnViolation[] = [] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - const lineNumber = i + 1 - - for (let i = 0, { length } = CDN_PATTERNS; i < length; i += 1) { - const pattern = CDN_PATTERNS[i] - if (pattern.test(line)) { - const match = line.match(pattern) - violations.push({ - file: path.relative(rootPath, filePath), - line: lineNumber, - content: line.trim(), - cdnDomain: match[0], - }) - } - } - } - - return violations - } catch (e) { - // Skip files we can't read (likely binary despite extension) - const error = e as NodeJS.ErrnoException - if (error.code === 'EISDIR' || error.message?.includes('ENOENT')) { - return [] - } - // For other errors, try to continue - return [] - } -} - -/** - * Recursively find all text files to scan. - */ -async function findTextFiles( - dir: string, - files: string[] = [], -): Promise<string[]> { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i] - const fullPath = path.join(dir, entry.name) - - if (entry.isDirectory()) { - // Skip certain directories and hidden directories (except .github) - if ( - !SKIP_DIRS.has(entry.name) && - (!entry.name.startsWith('.') || entry.name === '.github') - ) { - await findTextFiles(fullPath, files) - } - } else if (entry.isFile() && shouldScanFile(entry.name)) { - files.push(fullPath) - } - } - } catch { - // Skip directories we can't read - } - - return files -} - -/** - * Check if file should be scanned. - */ -function shouldScanFile(filename: string): boolean { - const ext = path.extname(filename).toLowerCase() - return TEXT_EXTENSIONS.has(ext) -} - -/** - * Validate all files for CDN references. - */ -async function validateNoCdnRefs(): Promise<CdnViolation[]> { - const files = await findTextFiles(rootPath) - const allViolations = [] - - for (let i = 0, { length } = files; i < length; i += 1) { - const file = files[i] - const violations = await checkFileForCdnRefs(file) - allViolations.push(...violations) - } - - return allViolations -} - -async function main(): Promise<void> { - try { - const violations = await validateNoCdnRefs() - - if (violations.length === 0) { - logger.success('No CDN references found') - process.exitCode = 0 - return - } - - logger.fail(`Found ${violations.length} CDN reference(s)`) - logger.log('') - logger.log('CDN URLs are not allowed in this codebase for security and') - logger.log('reliability reasons. Please use npm packages instead.') - logger.log('') - logger.log('Blocked CDN domains:') - logger.log(' - unpkg.com') - logger.log(' - cdn.jsdelivr.net') - logger.log(' - esm.sh') - logger.log(' - cdn.skypack.dev') - logger.log(' - ga.jspm.io') - logger.log('') - logger.log('Violations:') - logger.log('') - - for (let i = 0, { length } = violations; i < length; i += 1) { - const violation = violations[i] - logger.log(` ${violation.file}:${violation.line}`) - logger.log(` Domain: ${violation.cdnDomain}`) - logger.log(` Content: ${violation.content}`) - logger.log('') - } - - logger.log('Remove CDN references and use npm dependencies instead.') - logger.log('') - - process.exitCode = 1 - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - logger.fail(`Validation failed: ${message}`) - process.exitCode = 1 - } -} - -main().catch((e: unknown) => { - const message = e instanceof Error ? e.message : String(e) - logger.fail(`Unexpected error: ${message}`) - process.exitCode = 1 -}) diff --git a/scripts/validate-no-link-deps.mts b/scripts/validate-no-link-deps.mts deleted file mode 100644 index ded59d48e..000000000 --- a/scripts/validate-no-link-deps.mts +++ /dev/null @@ -1,180 +0,0 @@ - -/** - * @file Validates that no package.json files contain link: dependencies. Link - * dependencies are prohibited - use workspace: or catalog: instead. - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(__dirname, '..') - -interface LinkViolation { - file: string - field: string - package: string - value: string -} - -/** - * Check if a package.json contains link: dependencies. - */ -async function checkPackageJson(filePath: string): Promise<LinkViolation[]> { - const content = await fs.readFile(filePath, 'utf8') - const pkg = JSON.parse(content) - - const violations: LinkViolation[] = [] - - // Check dependencies. - if (pkg.dependencies) { - for (const [name, version] of Object.entries(pkg.dependencies)) { - if (typeof version === 'string' && version.startsWith('link:')) { - violations.push({ - file: filePath, - field: 'dependencies', - package: name, - value: version, - }) - } - } - } - - // Check devDependencies. - if (pkg.devDependencies) { - for (const [name, version] of Object.entries(pkg.devDependencies)) { - if (typeof version === 'string' && version.startsWith('link:')) { - violations.push({ - file: filePath, - field: 'devDependencies', - package: name, - value: version, - }) - } - } - } - - // Check peerDependencies. - if (pkg.peerDependencies) { - for (const [name, version] of Object.entries(pkg.peerDependencies)) { - if (typeof version === 'string' && version.startsWith('link:')) { - violations.push({ - file: filePath, - field: 'peerDependencies', - package: name, - value: version, - }) - } - } - } - - // Check optionalDependencies. - if (pkg.optionalDependencies) { - for (const [name, version] of Object.entries(pkg.optionalDependencies)) { - if (typeof version === 'string' && version.startsWith('link:')) { - violations.push({ - file: filePath, - field: 'optionalDependencies', - package: name, - value: version, - }) - } - } - } - - // Check pnpm.overrides. - if (pkg.pnpm?.overrides) { - for (const [name, version] of Object.entries(pkg.pnpm.overrides)) { - if (typeof version === 'string' && version.startsWith('link:')) { - violations.push({ - file: filePath, - field: 'pnpm.overrides', - package: name, - value: version, - }) - } - } - } - - return violations -} - -/** - * Find all package.json files in the repository. - */ -async function findPackageJsonFiles(dir: string): Promise<string[]> { - const files = [] - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (let i = 0, { length } = entries; i < length; i += 1) { - const entry = entries[i] - const fullPath = path.join(dir, entry.name) - - // Skip node_modules, .git, and build directories. - if ( - entry.name === '.git' || - entry.name === 'build' || - entry.name === 'dist' || - entry.name === 'node_modules' - ) { - continue - } - - if (entry.isDirectory()) { - files.push(...(await findPackageJsonFiles(fullPath))) - } else if (entry.name === 'package.json') { - files.push(fullPath) - } - } - - return files -} - -async function main(): Promise<void> { - const packageJsonFiles = await findPackageJsonFiles(rootPath) - const allViolations: LinkViolation[] = [] - - for (let i = 0, { length } = packageJsonFiles; i < length; i += 1) { - const file = packageJsonFiles[i] - const violations = await checkPackageJson(file) - allViolations.push(...violations) - } - - if (allViolations.length > 0) { - logger.error('❌ Found link: dependencies (prohibited)') - logger.log('') - logger.error( - 'Use workspace: protocol for monorepo packages or catalog: for centralized versions.', - ) - logger.log('') - - for (let i = 0, { length } = allViolations; i < length; i += 1) { - const violation = allViolations[i] - const relativePath = path.relative(rootPath, violation.file) - logger.error(` ${relativePath}`) - logger.error( - ` ${violation.field}.${violation.package}: "${violation.value}"`, - ) - } - - logger.log('') - logger.error('Replace link: with:') - logger.error(' - workspace: for monorepo packages') - logger.error(' - catalog: for centralized version management') - logger.log('') - - process.exitCode = 1 - } else { - logger.success('No link: dependencies found') - } -} - -main().catch((e: unknown) => { - logger.error('Validation failed:', e) - process.exitCode = 1 -}) diff --git a/scripts/validate-rolldown-minify.mts b/scripts/validate-rolldown-minify.mts deleted file mode 100644 index 9e9968312..000000000 --- a/scripts/validate-rolldown-minify.mts +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env node -/** - * @file Validates that every rolldown build config keeps `output.minify` false - * by default. Minification breaks ESM/CJS interop and makes debugging harder, - * so the default (non-publish) build must emit readable output. Repos may - * still opt into minification for a publish artifact behind an env gate (e.g. - * `MINIFY=1` on a `*:prepublish` script); this validator only asserts the - * default, un-gated build stays unminified — it loads each config with the - * minify env var explicitly cleared. Config discovery (first match wins, in - * order): - * - * 1. `.config/rolldown-validate.json` — an optional `{ "configs": [...] }` array - * of repo-root-relative config paths. Repos whose configs are nested - * (monorepo packages) or non-standard-named list them here. Each listed - * path is validated. - * 2. `.config/rolldown.config.mts`, then root `rolldown.config.mts` — the - * single-config fallback for simple single-package repos. If neither - * resolves the repo has no rolldown build and the check is a no-op pass. - * Export shapes tolerated per config: a `default` export (single options - * object or array), named `buildConfig` / `configs` exports (object or - * array), and a named `getRolldownConfig(entry, out)` factory (probed with - * placeholder args). All discovered `output.minify` flags must be false or - * unset. - */ - -import { existsSync, readFileSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' - -const logger = getDefaultLogger() - -const here = path.dirname(fileURLToPath(import.meta.url)) -const rootPath = path.join(here, '..') - -interface MinifyViolation { - config: string - value: unknown - message: string - location: string -} - -// Read every `output.minify` reachable off a loaded config module, across the -// export shapes the fleet uses: `default` / named `buildConfig` / named -// `configs` (each a single options object or an array of them, each with a -// single `output` or array of outputs), plus a named -// `getRolldownConfig(entry, out)` factory probed with placeholder args. -export function collectMinifyFlags( - imported: Record<string, unknown>, -): unknown[] { - const flags: unknown[] = [] - const pushOutputs = (cfg: unknown): void => { - const output = (cfg as { output?: unknown | undefined } | undefined)?.output - const outputs = Array.isArray(output) ? output : [output] - for (const out of outputs) { - flags.push((out as { minify?: unknown | undefined } | undefined)?.minify) - } - } - const pushConfigs = (value: unknown): void => { - if (value === undefined) { - return - } - for (const cfg of Array.isArray(value) ? value : [value]) { - pushOutputs(cfg) - } - } - - pushConfigs(imported['default']) - pushConfigs(imported['buildConfig']) - pushConfigs(imported['configs']) - - const factory = imported['getRolldownConfig'] - if (typeof factory === 'function') { - pushOutputs( - (factory as (a: string, b: string) => unknown)('entry.js', 'out.js'), - ) - } - - return flags -} - -// All rolldown config paths to validate, absolute. The manifest list wins; -// otherwise the single canonical `.config/` path, then a root config. -export function findRolldownConfigs(): string[] { - const manifest = readConfigManifest() - if (manifest) { - return manifest - .map(rel => path.resolve(rootPath, rel)) - .filter(p => existsSync(p)) - } - const candidates = [ - path.join(rootPath, '.config', 'rolldown.config.mts'), - path.join(rootPath, 'rolldown.config.mts'), - ] - for (let i = 0, { length } = candidates; i < length; i += 1) { - const candidate = candidates[i]! - if (existsSync(candidate)) { - return [candidate] - } - } - return [] -} - -// Repo-root-relative config paths declared in `.config/rolldown-validate.json`, -// or undefined when the file is absent / malformed (caller falls back to the -// single-config auto-discovery below). -export function readConfigManifest(): string[] | undefined { - const manifestPath = path.join(rootPath, '.config', 'rolldown-validate.json') - if (!existsSync(manifestPath)) { - return undefined - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) - } catch (e) { - logger.error( - `Failed to parse .config/rolldown-validate.json: ${e instanceof Error ? e.message : String(e)}`, - ) - process.exitCode = 1 - return undefined - } - const configs = (parsed as { configs?: unknown | undefined } | undefined) - ?.configs - if (!Array.isArray(configs) || configs.some(c => typeof c !== 'string')) { - logger.error( - '.config/rolldown-validate.json must have a "configs" array of string paths', - ) - process.exitCode = 1 - return undefined - } - return configs as string[] -} - -/** - * Validate every discovered rolldown config's default (MINIFY-unset) build has - * minify false. Clears the `MINIFY` env gate before importing so a publish-only - * minify path doesn't trip the check. - */ -export async function validateRolldownMinify(): Promise<MinifyViolation[]> { - const configPaths = findRolldownConfigs() - if (configPaths.length === 0) { - // No rolldown build in this repo — nothing to validate. - return [] - } - - // Clear the publish-time gate so we evaluate the default build path. Configs - // read `process.env.MINIFY` at module-evaluation time, so this MUST happen - // before the imports below — hence the dynamic import (a static import would - // capture MINIFY at load time, defeating the clear). - delete process.env['MINIFY'] - - const violations: MinifyViolation[] = [] - for (const configPath of configPaths) { - try { - // oxlint-disable-next-line socket/no-dynamic-import-outside-bundle -- the config must load AFTER the MINIFY env gate is cleared (see above); a static top-level import would evaluate it too early. - const imported = (await import(configPath)) as Record<string, unknown> - const flags = collectMinifyFlags(imported) - for (let i = 0, { length } = flags; i < length; i += 1) { - const value = flags[i] - if (value !== false && value !== undefined) { - violations.push({ - config: `output[${i}]`, - value, - message: 'output.minify must be false (or unset) by default', - location: configPath, - }) - } - } - } catch (e) { - logger.error( - `Failed to load rolldown config ${configPath}: ${e instanceof Error ? e.message : String(e)}`, - ) - process.exitCode = 1 - return [] - } - } - return violations -} - -async function main(): Promise<void> { - const violations = await validateRolldownMinify() - - if (violations.length === 0) { - logger.success('rolldown minify validation passed') - process.exitCode = 0 - return - } - - logger.fail('rolldown minify validation failed') - logger.error('') - - for (let i = 0, { length } = violations; i < length; i += 1) { - const violation = violations[i]! - logger.error(` ${violation.message}`) - logger.error(` Found: minify: ${violation.value}`) - logger.error(' Expected: minify: false') - logger.error(` Location: ${violation.location}`) - logger.error('') - } - - logger.error( - 'Minification breaks ESM/CJS interop and makes debugging harder.', - ) - logger.error('') - - process.exitCode = 1 -} - -main().catch((e: unknown) => { - logger.error('Validation failed:', e) - process.exitCode = 1 -}) diff --git a/sd b/sd new file mode 100755 index 000000000..39db86e15 --- /dev/null +++ b/sd @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +# node 20 does not support strip types (yet) so we have go the slow path +# Note that we don't rebuild here... this will be annoying later but I +# think this is only for dev where we don't need to run node 20. +# Should we emit a warning anyways? Maybe. +if [ "$(node -v | cut -d'v' -f2 | cut -d'.' -f1)" -lt 22 ]; then + npm run s -- "$@" +else + node --experimental-strip-types --no-warnings src/cli.mts "$@" +fi diff --git a/shadow-bin/npm b/shadow-bin/npm new file mode 100755 index 000000000..1f638cd9a --- /dev/null +++ b/shadow-bin/npm @@ -0,0 +1,10 @@ +#!/usr/bin/env node +'use strict' + +const Module = require('node:module') +const path = require('node:path') +const rootPath = path.join(__dirname, '..') +Module.enableCompileCache?.(path.join(rootPath, '.cache')) + +const shadowBin = require(path.join(rootPath, 'dist/shadow-bin.js')) +shadowBin('npm') diff --git a/shadow-bin/npx b/shadow-bin/npx new file mode 100755 index 000000000..89613a03f --- /dev/null +++ b/shadow-bin/npx @@ -0,0 +1,10 @@ +#!/usr/bin/env node +'use strict' + +const Module = require('node:module') +const path = require('node:path') +const rootPath = path.join(__dirname, '..') +Module.enableCompileCache?.(path.join(rootPath, '.cache')) + +const shadowBin = require(path.join(rootPath, 'dist/shadow-bin.js')) +shadowBin('npx') diff --git a/socket.yml b/socket.yml deleted file mode 100644 index 51dfefe47..000000000 --- a/socket.yml +++ /dev/null @@ -1,4 +0,0 @@ -version: 2 - -projectIgnorePaths: - - 'packages/cli/test/fixtures/commands/fix' diff --git a/src/cli.mts b/src/cli.mts new file mode 100755 index 000000000..a8a0d4af1 --- /dev/null +++ b/src/cli.mts @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import { fileURLToPath, pathToFileURL } from 'node:url' + +import meow from 'meow' +import { messageWithCauses, stackWithCauses } from 'pony-cause' +import updateNotifier from 'tiny-updater' + +import { debugFn, debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { cmdAnalytics } from './commands/analytics/cmd-analytics.mts' +import { cmdAuditLog } from './commands/audit-log/cmd-audit-log.mts' +import { cmdCI } from './commands/ci/cmd-ci.mts' +import { cmdConfig } from './commands/config/cmd-config.mts' +import { cmdFix } from './commands/fix/cmd-fix.mts' +import { cmdInstall } from './commands/install/cmd-install.mts' +import { cmdJson } from './commands/json/cmd-json.mts' +import { cmdLogin } from './commands/login/cmd-login.mts' +import { cmdLogout } from './commands/logout/cmd-logout.mts' +import { cmdManifestCdxgen } from './commands/manifest/cmd-manifest-cdxgen.mts' +import { cmdManifest } from './commands/manifest/cmd-manifest.mts' +import { cmdNpm } from './commands/npm/cmd-npm.mts' +import { cmdNpx } from './commands/npx/cmd-npx.mts' +import { cmdOops } from './commands/oops/cmd-oops.mts' +import { cmdOptimize } from './commands/optimize/cmd-optimize.mts' +import { cmdOrganizationDependencies } from './commands/organization/cmd-organization-dependencies.mts' +import { cmdOrganizationPolicyLicense } from './commands/organization/cmd-organization-policy-license.mts' +import { cmdOrganizationPolicySecurity } from './commands/organization/cmd-organization-policy-security.mts' +import { cmdOrganization } from './commands/organization/cmd-organization.mts' +import { cmdPackage } from './commands/package/cmd-package.mts' +import { cmdRawNpm } from './commands/raw-npm/cmd-raw-npm.mts' +import { cmdRawNpx } from './commands/raw-npx/cmd-raw-npx.mts' +import { cmdRepository } from './commands/repository/cmd-repository.mts' +import { cmdScan } from './commands/scan/cmd-scan.mts' +import { cmdThreatFeed } from './commands/threat-feed/cmd-threat-feed.mts' +import { cmdUninstall } from './commands/uninstall/cmd-uninstall.mts' +import { cmdWrapper } from './commands/wrapper/cmd-wrapper.mts' +import constants from './constants.mts' +import { AuthError, InputError, captureException } from './utils/errors.mts' +import { failMsgWithBadge } from './utils/fail-msg-with-badge.mts' +import { meowWithSubcommands } from './utils/meow-with-subcommands.mts' +import { serializeResultJson } from './utils/serialize-result-json.mts' + +const __filename = fileURLToPath(import.meta.url) + +const { SOCKET_CLI_BIN_NAME } = constants + +// TODO: Add autocompletion using https://socket.dev/npm/package/omelette +void (async () => { + await updateNotifier({ + name: SOCKET_CLI_BIN_NAME, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION. + version: constants.ENV.INLINED_SOCKET_CLI_VERSION, + ttl: 86_400_000 /* 24 hours in milliseconds */, + }) + + try { + await meowWithSubcommands( + { + ci: cmdCI, + config: cmdConfig, + fix: cmdFix, + install: cmdInstall, + json: cmdJson, + login: cmdLogin, + logout: cmdLogout, + npm: cmdNpm, + npx: cmdNpx, + oops: cmdOops, + optimize: cmdOptimize, + organization: cmdOrganization, + package: cmdPackage, + 'raw-npm': cmdRawNpm, + 'raw-npx': cmdRawNpx, + wrapper: cmdWrapper, + scan: cmdScan, + 'audit-log': cmdAuditLog, + repos: cmdRepository, + analytics: cmdAnalytics, + 'threat-feed': cmdThreatFeed, + manifest: cmdManifest, + uninstall: cmdUninstall, + }, + { + aliases: { + audit: { + description: cmdAuditLog.description, + hidden: true, + argv: ['audit-log'], + }, + auditLog: { + description: cmdAuditLog.description, + hidden: true, + argv: ['audit-log'], + }, + auditLogs: { + description: cmdAuditLog.description, + hidden: true, + argv: ['audit-log'], + }, + ['audit-logs']: { + description: cmdAuditLog.description, + hidden: true, + argv: ['audit-log'], + }, + cdxgen: { + description: cmdManifestCdxgen.description, + hidden: true, + argv: ['manifest', 'cdxgen'], + }, + deps: { + description: cmdOrganizationDependencies.description, + hidden: true, + argv: ['dependencies'], + }, + feed: { + description: cmdThreatFeed.description, + hidden: true, + argv: ['threat-feed'], + }, + license: { + description: cmdOrganizationPolicyLicense.description, + hidden: true, + argv: ['organization', 'policy', 'license'], + }, + org: { + description: cmdOrganization.description, + hidden: true, + argv: ['organization'], + }, + orgs: { + description: cmdOrganization.description, + hidden: true, + argv: ['organization'], + }, + organizations: { + description: cmdOrganization.description, + hidden: true, + argv: ['organization'], + }, + organisation: { + description: cmdOrganization.description, + hidden: true, + argv: ['organization'], + }, + organisations: { + description: cmdOrganization.description, + hidden: true, + argv: ['organization'], + }, + pkg: { + description: cmdPackage.description, + hidden: true, + argv: ['package'], + }, + repo: { + description: cmdRepository.description, + hidden: true, + argv: ['repos'], + }, + repository: { + description: cmdRepository.description, + hidden: true, + argv: ['repos'], + }, + repositories: { + description: cmdRepository.description, + hidden: true, + argv: ['repos'], + }, + security: { + description: cmdOrganizationPolicySecurity.description, + hidden: true, + argv: ['organization', 'policy', 'security'], + }, + }, + argv: process.argv.slice(2), + name: SOCKET_CLI_BIN_NAME, + importMeta: { url: `${pathToFileURL(__filename)}` } as ImportMeta, + }, + ) + } catch (e) { + process.exitCode = 1 + debugFn('Uncaught error (BAD!):') + debugFn(e) + + // Try to parse the flags, find out if --json or --markdown is set + let isJson = false + try { + const cli = meow(``, { + argv: process.argv.slice(2), + importMeta: { url: `${pathToFileURL(__filename)}` } as ImportMeta, + flags: {}, + // Do not strictly check for flags here. + allowUnknownFlags: true, + autoHelp: false, + }) + isJson = !!cli.flags['json'] + } catch {} + + let errorBody: string | undefined + let errorTitle: string + let errorMessage = '' + if (e instanceof AuthError) { + errorTitle = 'Authentication error' + errorMessage = e.message + } else if (e instanceof InputError) { + errorTitle = 'Invalid input' + errorMessage = e.message + errorBody = e.body + } else if (e instanceof Error) { + errorTitle = 'Unexpected error' + errorMessage = messageWithCauses(e) + errorBody = stackWithCauses(e) + } else { + errorTitle = 'Unexpected error with no details' + } + + if (isJson) { + logger.log( + serializeResultJson({ + ok: false, + message: errorTitle, + cause: errorMessage, + }), + ) + } else { + logger.error('\n') // Any-spinner-newline + logger.fail(failMsgWithBadge(errorTitle, errorMessage)) + if (errorBody) { + // Explicitly use debugLog here. + debugLog(errorBody) + } + } + + await captureException(e) + } +})() diff --git a/packages/cli/src/commands/analytics/analytics-fixture.json b/src/commands/analytics/analytics-fixture.json similarity index 100% rename from packages/cli/src/commands/analytics/analytics-fixture.json rename to src/commands/analytics/analytics-fixture.json diff --git a/src/commands/analytics/cmd-analytics.mts b/src/commands/analytics/cmd-analytics.mts new file mode 100644 index 000000000..f73bcf19c --- /dev/null +++ b/src/commands/analytics/cmd-analytics.mts @@ -0,0 +1,178 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleAnalytics } from './handle-analytics.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'analytics', + description: `Look up analytics data`, + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + file: { + type: 'string', + description: 'Path to store result, only valid with --json/--markdown', + }, + }, + help: (command, { flags }) => + ` + Usage + $ ${command} [options] [ "org" | "repo" <reponame>] [TIME] + + API Token Requirements + - Quota: 1 unit + - Permissions: report:write + + The scope is either org or repo level, defaults to org. + + When scope is repo, a repo slug must be given as well. + + The TIME argument must be number 7, 30, or 90 and defaults to 30. + + Options + ${getFlagListOutput(flags, 6)} + + Examples + $ ${command} org 7 + $ ${command} repo test-repo 30 + $ ${command} 90 + `, +} + +export const cmdAnalytics = { + description: config.description, + hidden: config.hidden, + run: run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { file, json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + // Supported inputs: + // - [] (no args) + // - ['org'] + // - ['org', '30'] + // - ['repo', 'name'] + // - ['repo', 'name', '30'] + // - ['30'] + // Validate final values in the next step + let scope = 'org' + let time = '30' + let repoName = '' + if (cli.input[0] === 'org') { + if (cli.input[1]) { + time = cli.input[1] + } + } else if (cli.input[0] === 'repo') { + scope = 'repo' + if (cli.input[1]) { + repoName = cli.input[1] + } + if (cli.input[2]) { + time = cli.input[2] + } + } else if (cli.input[0]) { + time = cli.input[0] + } + + const hasApiToken = hasDefaultToken() + + const noLegacy = + !cli.flags['scope'] && !cli.flags['repo'] && !cli.flags['time'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: scope === 'org' || !!repoName, + message: 'When scope=repo, repo name should be the second argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: + scope === 'org' || + (repoName !== '7' && repoName !== '30' && repoName !== '90'), + message: 'When scope is repo, the second arg should be repo, not time', + pass: 'ok', + fail: 'missing', + }, + { + test: time === '7' || time === '30' || time === '90', + message: 'The time filter must either be 7, 30 or 90', + pass: 'ok', + fail: 'invalid range set, see --help for command arg details.', + }, + { + nook: true, + test: !file || !!json || !!markdown, + message: + 'The `--file` flag is only valid when using `--json` or `--markdown`', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + return await handleAnalytics({ + scope, + time: time === '90' ? 90 : time === '30' ? 30 : 7, + repo: repoName, + outputKind, + filePath: String(file || ''), + }) +} diff --git a/src/commands/analytics/cmd-analytics.test.mts b/src/commands/analytics/cmd-analytics.test.mts new file mode 100644 index 000000000..4bd6ae210 --- /dev/null +++ b/src/commands/analytics/cmd-analytics.test.mts @@ -0,0 +1,347 @@ +import semver from 'semver' +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket analytics', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['analytics', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up analytics data + + Usage + $ socket analytics [options] [ "org" | "repo" <reponame>] [TIME] + + API Token Requirements + - Quota: 1 unit + - Permissions: report:write + + The scope is either org or repo level, defaults to org. + + When scope is repo, a repo slug must be given as well. + + The TIME argument must be number 7, 30, or 90 and defaults to 30. + + Options + --file Path to store result, only valid with --json/--markdown + --json Output result as json + --markdown Output result as markdown + + Examples + $ socket analytics org 7 + $ socket analytics repo test-repo 30 + $ socket analytics 90" + `, + ) + // Node 24 on Windows currently fails this test with added stderr: + // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76 + const skipOnWin32Node24 = + constants.WIN32 && semver.parse(constants.NODE_VERSION)!.major >= 24 + if (!skipOnWin32Node24) { + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + expect(code, 'explicit help should exit with code 0').toBe(0) + } + + expect(stderr, 'banner includes base command').toContain( + '`socket analytics`', + ) + }, + ) + + cmdit( + ['analytics', '--dry-run', '--config', '{}'], + 'should report missing token with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - The time filter must either be 7, 30 or 90 (\\x1b[32mok\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'analytics', + '--scope', + 'org', + '--repo', + 'bar', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should reject legacy flags', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Legacy flags are no longer supported. See v1 migration guide. (\\x1b[31mreceived legacy flags\\x1b[39m) + + - The time filter must either be 7, 30 or 90 (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should reject legacy flags with code 2').toBe(2) + }, + ) + + cmdit( + ['analytics', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should run to dryrun without args', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['analytics', 'org', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should accept org arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['analytics', 'repo', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should ask for repo name with repo arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - When scope=repo, repo name should be the second argument (\\x1b[31mmissing\\x1b[39m) + + - The time filter must either be 7, 30 or 90 (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'analytics', + 'repo', + 'daname', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept repo with arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['analytics', '7', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should accept time 7 arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['analytics', '30', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should accept time 30 arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['analytics', '90', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should accept time 90 arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'analytics', + 'org', + '--time', + '7', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should report legacy flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Legacy flags are no longer supported. See v1 migration guide. (\\x1b[31mreceived legacy flags\\x1b[39m) + + - The time filter must either be 7, 30 or 90 (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'analytics', + 'org', + '7', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept org and time arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'analytics', + 'repo', + 'slowpo', + '30', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept repo and time arg', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket analytics\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/analytics/fetch-org-analytics.mts b/src/commands/analytics/fetch-org-analytics.mts new file mode 100644 index 000000000..e3bef5e55 --- /dev/null +++ b/src/commands/analytics/fetch-org-analytics.mts @@ -0,0 +1,20 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchOrgAnalyticsData( + time: number, +): Promise<CResult<SocketSdkReturnType<'getOrgAnalytics'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgAnalytics(time.toString()), + 'analytics data', + ) +} diff --git a/src/commands/analytics/fetch-repo-analytics.mts b/src/commands/analytics/fetch-repo-analytics.mts new file mode 100644 index 000000000..97081fe90 --- /dev/null +++ b/src/commands/analytics/fetch-repo-analytics.mts @@ -0,0 +1,21 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchRepoAnalyticsData( + repo: string, + time: number, +): Promise<CResult<SocketSdkReturnType<'getRepoAnalytics'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getRepoAnalytics(repo, time.toString()), + 'analytics data', + ) +} diff --git a/src/commands/analytics/handle-analytics.mts b/src/commands/analytics/handle-analytics.mts new file mode 100644 index 000000000..990629579 --- /dev/null +++ b/src/commands/analytics/handle-analytics.mts @@ -0,0 +1,50 @@ +import { fetchOrgAnalyticsData } from './fetch-org-analytics.mts' +import { fetchRepoAnalyticsData } from './fetch-repo-analytics.mts' +import { outputAnalytics } from './output-analytics.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function handleAnalytics({ + filePath, + outputKind, + repo, + scope, + time, +}: { + scope: string + time: number + repo: string + outputKind: OutputKind + filePath: string +}) { + let result: CResult< + | SocketSdkReturnType<'getOrgAnalytics'>['data'] + | SocketSdkReturnType<'getRepoAnalytics'>['data'] + > + if (scope === 'org') { + result = await fetchOrgAnalyticsData(time) + } else if (repo) { + result = await fetchRepoAnalyticsData(repo, time) + } else { + result = { + ok: false, + message: 'Missing repository name in command', + } + } + if (result.ok && !result.data.length) { + result = { + ok: true, + message: `The analytics data for this ${scope === 'org' ? 'organization' : 'repository'} is not yet available.`, + data: [], + } + } + + await outputAnalytics(result, { + filePath, + outputKind, + repo, + scope, + time, + }) +} diff --git a/src/commands/analytics/output-analytics.mts b/src/commands/analytics/output-analytics.mts new file mode 100644 index 000000000..18c8cd10e --- /dev/null +++ b/src/commands/analytics/output-analytics.mts @@ -0,0 +1,413 @@ +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTableStringNumber } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' +import type { Widgets } from 'blessed' // Note: Widgets does not seem to actually work as code :'( +import type { grid as ContribGrid } from 'blessed-contrib' + +const require = createRequire(import.meta.url) + +const METRICS = [ + 'total_critical_alerts', + 'total_high_alerts', + 'total_medium_alerts', + 'total_low_alerts', + 'total_critical_added', + 'total_medium_added', + 'total_low_added', + 'total_high_added', + 'total_critical_prevented', + 'total_high_prevented', + 'total_medium_prevented', + 'total_low_prevented', +] as const + +// Note: This maps `new Date(date).getMonth()` to English three letters +const Months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const + +export async function outputAnalytics( + result: CResult< + | SocketSdkReturnType<'getOrgAnalytics'>['data'] + | SocketSdkReturnType<'getRepoAnalytics'>['data'] + >, + { + filePath, + outputKind, + repo, + scope, + time, + }: { + scope: string + time: number + repo: string + outputKind: OutputKind + filePath: string + }, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'json') { + const serialized = serializeResultJson(result) + + if (filePath) { + try { + await fs.writeFile(filePath, serialized, 'utf8') + logger.success(`Data successfully written to ${filePath}`) + } catch (e) { + process.exitCode = 1 + logger.log( + serializeResultJson({ + ok: false, + message: 'File Write Failure', + cause: 'There was an error trying to write the json to disk', + }), + ) + } + } else { + logger.log(serialized) + } + + return + } + + const fdata = + scope === 'org' ? formatDataOrg(result.data) : formatDataRepo(result.data) + + if (outputKind === 'markdown') { + const serialized = renderMarkdown(fdata, time, repo) + + // TODO: do we want to write to file even if there was an error...? + if (filePath) { + try { + await fs.writeFile(filePath, serialized, 'utf8') + logger.success(`Data successfully written to ${filePath}`) + } catch (e) { + logger.error(e) + } + } else { + logger.log(serialized) + } + } else { + displayAnalyticsScreen(fdata) + } +} + +export interface FormattedData { + top_five_alert_types: Record<string, number> + total_critical_alerts: Record<string, number> + total_high_alerts: Record<string, number> + total_medium_alerts: Record<string, number> + total_low_alerts: Record<string, number> + total_critical_added: Record<string, number> + total_medium_added: Record<string, number> + total_low_added: Record<string, number> + total_high_added: Record<string, number> + total_critical_prevented: Record<string, number> + total_high_prevented: Record<string, number> + total_medium_prevented: Record<string, number> + total_low_prevented: Record<string, number> +} + +export function renderMarkdown( + data: FormattedData, + days: number, + repoSlug: string, +): string { + return ( + ` +# Socket Alert Analytics + +These are the Socket.dev analytics for the ${repoSlug ? `${repoSlug} repo` : 'org'} of the past ${days} days + +${[ + [ + 'Total critical alerts', + mdTableStringNumber('Date', 'Counts', data['total_critical_alerts']), + ], + [ + 'Total high alerts', + mdTableStringNumber('Date', 'Counts', data['total_high_alerts']), + ], + [ + 'Total critical alerts added to the main branch', + mdTableStringNumber('Date', 'Counts', data['total_critical_added']), + ], + [ + 'Total high alerts added to the main branch', + mdTableStringNumber('Date', 'Counts', data['total_high_added']), + ], + [ + 'Total critical alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_critical_prevented']), + ], + [ + 'Total high alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_high_prevented']), + ], + [ + 'Total medium alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_medium_prevented']), + ], + [ + 'Total low alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_low_prevented']), + ], +] + .map(([title, table]) => + ` +## ${title} + +${table} +`.trim(), + ) + .join('\n\n')} + +## Top 5 alert types + +${mdTableStringNumber('Name', 'Counts', data['top_five_alert_types'])} +`.trim() + '\n' + ) +} + +function displayAnalyticsScreen(data: FormattedData): void { + const ScreenWidget = require('blessed/lib/widgets/screen.js') + // Lazily access constants.blessedOptions. + const screen: Widgets.Screen = new ScreenWidget({ + ...constants.blessedOptions, + }) + const GridLayout = require('blessed-contrib/lib/layout/grid.js') + const grid = new GridLayout({ rows: 5, cols: 4, screen }) + + renderLineCharts( + grid, + screen, + 'Total critical alerts', + [0, 0, 1, 2], + data['total_critical_alerts'], + ) + renderLineCharts( + grid, + screen, + 'Total high alerts', + [0, 2, 1, 2], + data['total_high_alerts'], + ) + renderLineCharts( + grid, + screen, + 'Total critical alerts added to the main branch', + [1, 0, 1, 2], + data['total_critical_added'], + ) + renderLineCharts( + grid, + screen, + 'Total high alerts added to the main branch', + [1, 2, 1, 2], + data['total_high_added'], + ) + renderLineCharts( + grid, + screen, + 'Total critical alerts prevented from the main branch', + [2, 0, 1, 2], + data['total_critical_prevented'], + ) + renderLineCharts( + grid, + screen, + 'Total high alerts prevented from the main branch', + [2, 2, 1, 2], + data['total_high_prevented'], + ) + renderLineCharts( + grid, + screen, + 'Total medium alerts prevented from the main branch', + [3, 0, 1, 2], + data['total_medium_prevented'], + ) + renderLineCharts( + grid, + screen, + 'Total low alerts prevented from the main branch', + [3, 2, 1, 2], + data['total_low_prevented'], + ) + + const BarChart = require('blessed-contrib/lib/widget/charts/bar.js') + const bar = grid.set(4, 0, 1, 2, BarChart, { + label: 'Top 5 alert types', + barWidth: 10, + barSpacing: 17, + xOffset: 0, + maxHeight: 9, + barBgColor: 'magenta', + }) + + screen.append(bar) //must append before setting data + + bar.setData({ + titles: Object.keys(data.top_five_alert_types), + data: Object.values(data.top_five_alert_types), + }) + + screen.render() + // eslint-disable-next-line n/no-process-exit + screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) +} + +export function formatDataRepo( + data: SocketSdkReturnType<'getRepoAnalytics'>['data'], +): FormattedData { + const sortedTopFiveAlerts: Record<string, number> = {} + const totalTopAlerts: Record<string, number> = {} + + const formattedData = {} as Omit<FormattedData, 'top_five_alert_types'> + for (const metric of METRICS) { + formattedData[metric] = {} + } + + for (const entry of data) { + const topFiveAlertTypes = entry['top_five_alert_types'] + for (const type of Object.keys(topFiveAlertTypes)) { + const count = topFiveAlertTypes[type] ?? 0 + if (!totalTopAlerts[type]) { + totalTopAlerts[type] = count + } else if (count > (totalTopAlerts[type] ?? 0)) { + totalTopAlerts[type] = count + } + } + } + for (const entry of data) { + for (const metric of METRICS) { + formattedData[metric]![formatDate(entry['created_at'])] = entry[metric] + } + } + + const topFiveAlertEntries = Object.entries(totalTopAlerts) + .sort(([_keya, a], [_keyb, b]) => b - a) + .slice(0, 5) + for (const [key, value] of topFiveAlertEntries) { + sortedTopFiveAlerts[key] = value + } + + return { + ...formattedData, + top_five_alert_types: sortedTopFiveAlerts, + } +} + +export function formatDataOrg( + data: SocketSdkReturnType<'getOrgAnalytics'>['data'], +): FormattedData { + const sortedTopFiveAlerts: Record<string, number> = {} + const totalTopAlerts: Record<string, number> = {} + + const formattedData = {} as Omit<FormattedData, 'top_five_alert_types'> + for (const metric of METRICS) { + formattedData[metric] = {} + } + + for (const entry of data) { + const topFiveAlertTypes = entry['top_five_alert_types'] + for (const type of Object.keys(topFiveAlertTypes)) { + const count = topFiveAlertTypes[type] ?? 0 + if (!totalTopAlerts[type]) { + totalTopAlerts[type] = count + } else { + totalTopAlerts[type] += count + } + } + } + + for (const metric of METRICS) { + const formatted = formattedData[metric] + for (const entry of data) { + const date = formatDate(entry['created_at']) + if (!formatted[date]) { + formatted[date] = entry[metric]! + } else { + formatted[date] += entry[metric]! + } + } + } + + const topFiveAlertEntries = Object.entries(totalTopAlerts) + .sort(([_keya, a], [_keyb, b]) => b - a) + .slice(0, 5) + for (const [key, value] of topFiveAlertEntries) { + sortedTopFiveAlerts[key] = value + } + + return { + ...formattedData, + top_five_alert_types: sortedTopFiveAlerts, + } +} + +function formatDate(date: string): string { + return `${Months[new Date(date).getMonth()]} ${new Date(date).getDate()}` +} + +function renderLineCharts( + grid: ContribGrid, + screen: Widgets.Screen, + title: string, + coords: number[], + data: Record<string, number>, +): void { + const LineChart = require('blessed-contrib/lib/widget/charts/line.js') + const line = grid.set(...coords, LineChart, { + style: { line: 'cyan', text: 'cyan', baseline: 'black' }, + xLabelPadding: 0, + xPadding: 0, + xOffset: 0, + wholeNumbersOnly: true, + legend: { + width: 1, + }, + label: title, + }) + + screen.append(line) + + const lineData = { + x: Object.keys(data), + y: Object.values(data), + } + + line.setData([lineData]) +} diff --git a/src/commands/analytics/output-analytics.test.mts b/src/commands/analytics/output-analytics.test.mts new file mode 100644 index 000000000..00af7e0c1 --- /dev/null +++ b/src/commands/analytics/output-analytics.test.mts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest' + +import FIXTURE from './analytics-fixture.json' with { type: 'json' } +import { + formatDataOrg, + formatDataRepo, + renderMarkdown, +} from './output-analytics.mts' + +describe('output-analytics', () => { + describe('format data', () => { + it.skip('should formatDataRepo', () => { + const str = formatDataRepo(JSON.parse(JSON.stringify(FIXTURE))) + + expect(str).toMatchInlineSnapshot(` + { + "top_five_alert_types": { + "dynamicRequire": 71, + "envVars": 636, + "filesystemAccess": 129, + "networkAccess": 109, + "unmaintained": 133, + }, + "total_critical_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_critical_alerts": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_critical_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_high_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_high_alerts": { + "Apr 18": 13, + "Apr 19": 13, + "Apr 20": 13, + "Apr 21": 10, + }, + "total_high_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_low_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_low_alerts": { + "Apr 18": 1054, + "Apr 19": 1060, + "Apr 20": 1066, + "Apr 21": 1059, + }, + "total_low_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_medium_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_medium_alerts": { + "Apr 18": 206, + "Apr 19": 207, + "Apr 20": 209, + "Apr 21": 206, + }, + "total_medium_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + } + `) + }) + + it.skip('should formatDataOrg', () => { + const str = formatDataOrg(JSON.parse(JSON.stringify(FIXTURE))) + + expect(str).toMatchInlineSnapshot(` + { + "top_five_alert_types": { + "dynamicRequire": 274, + "envVars": 2533, + "filesystemAccess": 514, + "networkAccess": 434, + "unmaintained": 532, + }, + "total_critical_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_critical_alerts": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_critical_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_high_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_high_alerts": { + "Apr 18": 13, + "Apr 19": 13, + "Apr 20": 13, + "Apr 21": 10, + }, + "total_high_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_low_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_low_alerts": { + "Apr 18": 1054, + "Apr 19": 1060, + "Apr 20": 1066, + "Apr 21": 1059, + }, + "total_low_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_medium_added": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + "total_medium_alerts": { + "Apr 18": 206, + "Apr 19": 207, + "Apr 20": 209, + "Apr 21": 206, + }, + "total_medium_prevented": { + "Apr 18": 0, + "Apr 19": 0, + "Apr 20": 0, + "Apr 21": 0, + }, + } + `) + }) + }) + + describe('format markdown', () => { + it.skip('should renderMarkdown for repo', () => { + const fdata = formatDataRepo(JSON.parse(JSON.stringify(FIXTURE))) + const serialized = renderMarkdown(fdata, 7, 'fake_repo') + + expect(serialized).toMatchInlineSnapshot(` + "# Socket Alert Analytics + + These are the Socket.dev analytics for the fake_repo repo of the past 7 days + + ## Total critical alerts + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total high alerts + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 13 | + | Apr 20 | 13 | + | Apr 19 | 13 | + | Apr 21 | 10 | + | ------ | ------ | + + ## Total critical alerts added to the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total high alerts added to the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total critical alerts prevented from the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total high alerts prevented from the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total medium alerts prevented from the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Total low alerts prevented from the main branch + + | Date | Counts | + | ------ | ------ | + | Apr 18 | 0 | + | Apr 20 | 0 | + | Apr 19 | 0 | + | Apr 21 | 0 | + | ------ | ------ | + + ## Top 5 alert types + + | Name | Counts | + | ---------------- | ------ | + | envVars | 636 | + | unmaintained | 133 | + | filesystemAccess | 129 | + | networkAccess | 109 | + | dynamicRequire | 71 | + | ---------------- | ------ | + " + `) + }) + }) +}) diff --git a/packages/cli/src/commands/audit-log/audit-fixture.json b/src/commands/audit-log/audit-fixture.json similarity index 100% rename from packages/cli/src/commands/audit-log/audit-fixture.json rename to src/commands/audit-log/audit-fixture.json diff --git a/src/commands/audit-log/cmd-audit-log.mts b/src/commands/audit-log/cmd-audit-log.mts new file mode 100644 index 000000000..5ba35a835 --- /dev/null +++ b/src/commands/audit-log/cmd-audit-log.mts @@ -0,0 +1,174 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleAuditLog } from './handle-audit-log.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW, SOCKET_WEBSITE_URL } = constants + +const config: CliCommandConfig = { + commandName: 'audit-log', + description: 'Look up the audit log for an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + page: { + type: 'number', + description: 'Result page to fetch', + }, + perPage: { + type: 'number', + default: 30, + description: 'Results per page - default is 30', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [FILTER] + + API Token Requirements + - Quota: 1 unit + - Permissions: audit-log:list + + This feature requires an Enterprise Plan. To learn more about getting access + to this feature and many more, please visit ${SOCKET_WEBSITE_URL}/pricing + + The type FILTER arg is an enum. Defaults to any. It should be one of these: + associateLabel, cancelInvitation, changeMemberRole, changePlanSubscriptionSeats, + createApiToken, createLabel, deleteLabel, deleteLabelSetting, deleteReport, + deleteRepository, disassociateLabel, joinOrganization, removeMember, + resetInvitationLink, resetOrganizationSettingToDefault, rotateApiToken, + sendInvitation, setLabelSettingToDefault, syncOrganization, transferOwnership, + updateAlertTriage, updateApiTokenCommitter, updateApiTokenMaxQuota, + updateApiTokenName', updateApiTokenScopes, updateApiTokenVisibility, + updateLabelSetting, updateOrganizationSetting, upgradeOrganizationPlan + + The page arg should be a positive integer, offset 1. Defaults to 1. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} deleteReport --page 2 --perPage 10 + `, +} + +export const cmdAuditLog = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + interactive, + json, + markdown, + org: orgFlag, + page, + perPage, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + let [typeFilter = ''] = cli.input + typeFilter = String(typeFilter) + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['type'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: /^[a-zA-Z]*$/.test(typeFilter), + message: 'The filter must be an a-zA-Z string, it is an enum', + pass: 'ok', + fail: 'it was given but not a-zA-Z', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleAuditLog({ + orgSlug, + outputKind, + page: Number(page || 0), + perPage: Number(perPage || 0), + logType: typeFilter.charAt(0).toUpperCase() + typeFilter.slice(1), + }) +} diff --git a/src/commands/audit-log/cmd-audit-log.test.mts b/src/commands/audit-log/cmd-audit-log.test.mts new file mode 100644 index 000000000..2d8117c3e --- /dev/null +++ b/src/commands/audit-log/cmd-audit-log.test.mts @@ -0,0 +1,173 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket audit-log', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['audit-log', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up the audit log for an organization + + Usage + $ socket audit-log [options] [FILTER] + + API Token Requirements + - Quota: 1 unit + - Permissions: audit-log:list + + This feature requires an Enterprise Plan. To learn more about getting access + to this feature and many more, please visit https://socket.dev/pricing + + The type FILTER arg is an enum. Defaults to any. It should be one of these: + associateLabel, cancelInvitation, changeMemberRole, changePlanSubscriptionSeats, + createApiToken, createLabel, deleteLabel, deleteLabelSetting, deleteReport, + deleteRepository, disassociateLabel, joinOrganization, removeMember, + resetInvitationLink, resetOrganizationSettingToDefault, rotateApiToken, + sendInvitation, setLabelSettingToDefault, syncOrganization, transferOwnership, + updateAlertTriage, updateApiTokenCommitter, updateApiTokenMaxQuota, + updateApiTokenName', updateApiTokenScopes, updateApiTokenVisibility, + updateLabelSetting, updateOrganizationSetting, upgradeOrganizationPlan + + The page arg should be a positive integer, offset 1. Defaults to 1. + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --page Result page to fetch + --perPage Results per page - default is 30 + + Examples + $ socket audit-log + $ socket audit-log deleteReport --page 2 --perPage 10" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket audit-log`', + ) + }, + ) + + cmdit( + ['audit-log', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'audit-log', + '--type', + 'xyz', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should report legacy flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Legacy flags are no longer supported. See v1 migration guide. (\\x1b[31mreceived legacy flags\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'audit-log', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should accept default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'audit-log', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept --org flag in v1', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket audit-log\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/audit-log/fetch-audit-log.mts b/src/commands/audit-log/fetch-audit-log.mts new file mode 100644 index 000000000..0e06fee36 --- /dev/null +++ b/src/commands/audit-log/fetch-audit-log.mts @@ -0,0 +1,39 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchAuditLog({ + logType, + orgSlug, + outputKind, + page, + perPage, +}: { + outputKind: OutputKind + orgSlug: string + page: number + perPage: number + logType: string +}): Promise<CResult<SocketSdkReturnType<'getAuditLogEvents'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getAuditLogEvents(orgSlug, { + // I'm not sure this is used at all. + outputJson: String(outputKind === 'json'), + // I'm not sure this is used at all. + outputMarkdown: String(outputKind === 'markdown'), + orgSlug, + type: logType, + page: String(page), + per_page: String(perPage), + }), + `audit log for ${orgSlug}`, + ) +} diff --git a/src/commands/audit-log/handle-audit-log.mts b/src/commands/audit-log/handle-audit-log.mts new file mode 100644 index 000000000..a0a3cd8fc --- /dev/null +++ b/src/commands/audit-log/handle-audit-log.mts @@ -0,0 +1,34 @@ +import { fetchAuditLog } from './fetch-audit-log.mts' +import { outputAuditLog } from './output-audit-log.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleAuditLog({ + logType, + orgSlug, + outputKind, + page, + perPage, +}: { + outputKind: OutputKind + orgSlug: string + page: number + perPage: number + logType: string +}): Promise<void> { + const auditLogs = await fetchAuditLog({ + orgSlug, + outputKind, + page, + perPage, + logType, + }) + + await outputAuditLog(auditLogs, { + logType, + orgSlug, + outputKind, + page, + perPage, + }) +} diff --git a/src/commands/audit-log/output-audit-log.mts b/src/commands/audit-log/output-audit-log.mts new file mode 100644 index 000000000..6e6497d09 --- /dev/null +++ b/src/commands/audit-log/output-audit-log.mts @@ -0,0 +1,315 @@ +import { createRequire } from 'node:module' + +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTable } from '../../utils/markdown.mts' +import { msAtHome } from '../../utils/ms-at-home.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' +import type { Widgets } from 'blessed' + +const require = createRequire(import.meta.url) + +const { REDACTED } = constants + +export async function outputAuditLog( + result: CResult<SocketSdkReturnType<'getAuditLogEvents'>['data']>, + { + logType, + orgSlug, + outputKind, + page, + perPage, + }: { + outputKind: OutputKind + orgSlug: string + page: number + perPage: number + logType: string + }, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log( + await outputAsJson(result, { + logType, + orgSlug, + page, + perPage, + }), + ) + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log( + await outputAsMarkdown(result.data, { + logType, + orgSlug, + page, + perPage, + }), + ) + return + } + + await outputWithBlessed(result.data, orgSlug) +} + +function formatResult( + selectedRow?: SocketSdkReturnType<'getAuditLogEvents'>['data']['results'][number], + keepQuotes = false, +): string { + if (!selectedRow) { + return '(none)' + } + // Format the object with spacing but keep the payload compact because + // that can contain just about anything and spread many lines. + const obj = { ...selectedRow, payload: 'REPLACEME' } + const json = JSON.stringify(obj, null, 2).replace( + /"payload": "REPLACEME"/, + `"payload": ${JSON.stringify(selectedRow.payload ?? {})}`, + ) + if (keepQuotes) { + return json + } + return json.replace(/^\s*"([^"]+)?"/gm, ' $1') +} + +export async function outputAsJson( + auditLogs: CResult<SocketSdkReturnType<'getAuditLogEvents'>['data']>, + { + logType, + orgSlug, + page, + perPage, + }: { + orgSlug: string + page: number + perPage: number + logType: string + }, +): Promise<string> { + if (!auditLogs.ok) { + return serializeResultJson(auditLogs) + } + + return serializeResultJson({ + ok: true, + data: { + desc: 'Audit logs for given query', + // Lazily access constants.ENV.VITEST. + generated: constants.ENV.VITEST ? REDACTED : new Date().toISOString(), + org: orgSlug, + logType, + page, + nextPage: auditLogs.data.nextPage, + perPage, + logs: auditLogs.data.results.map(log => { + // Note: The subset is pretty arbitrary + const { + created_at, + event_id, + ip_address, + type, + user_agent, + user_email, + } = log + return { + event_id, + created_at, + ip_address, + type, + user_agent, + user_email, + } + }), + }, + }) +} + +export async function outputAsMarkdown( + auditLogs: SocketSdkReturnType<'getAuditLogEvents'>['data'], + { + logType, + orgSlug, + page, + perPage, + }: { + orgSlug: string + page: number + perPage: number + logType: string + }, +): Promise<string> { + try { + const table = mdTable<any>(auditLogs.results, [ + 'event_id', + 'created_at', + 'type', + 'user_email', + 'ip_address', + 'user_agent', + ]) + + return ` +# Socket Audit Logs + +These are the Socket.dev audit logs as per requested query. +- org: ${orgSlug} +- type filter: ${logType || '(none)'} +- page: ${page} +- next page: ${auditLogs.nextPage} +- per page: ${perPage} +- generated: ${constants.ENV.VITEST ? REDACTED : new Date().toISOString()} + +${table} +` + } catch (e) { + process.exitCode = 1 + logger.fail( + 'There was a problem converting the logs to Markdown, please try the `--json` flag', + ) + debugFn('catch: unexpected\n', e) + return 'Failed to generate the markdown report' + } +} + +async function outputWithBlessed( + data: SocketSdkReturnType<'getAuditLogEvents'>['data'], + orgSlug: string, +) { + const filteredLogs = data.results + const formattedOutput = filteredLogs.map(logs => [ + logs.event_id ?? '', + msAtHome(logs.created_at ?? ''), + logs.type ?? '', + logs.user_email ?? '', + logs.ip_address ?? '', + logs.user_agent ?? '', + ]) + const headers = [ + ' Event id', + ' Created at', + ' Event type', + ' User email', + ' IP address', + ' User agent', + ] + + // Note: this temporarily takes over the terminal (just like `man` does). + const ScreenWidget = require('blessed/lib/widgets/screen.js') + // Lazily access constants.blessedOptions. + const screen: Widgets.Screen = new ScreenWidget({ + ...constants.blessedOptions, + }) + // Register these keys first so you can always exit, even when it gets stuck + // If we don't do this and the code crashes, the user must hard-kill the + // node process just to exit it. That's very bad UX. + // eslint-disable-next-line n/no-process-exit + screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) + + const TableWidget = require('blessed-contrib/lib/widget/table.js') + const tipsBoxHeight = 1 // 1 row for tips box + const detailsBoxHeight = 20 // bottom N rows for details box. 20 gives 4 lines for condensed payload before it scrolls out of view + + const maxWidths = headers.map(s => s.length + 1) + formattedOutput.forEach(row => { + row.forEach((str, i) => { + maxWidths[i] = Math.max(str.length, maxWidths[i] ?? str.length) + }) + }) + + const table: any = new TableWidget({ + keys: 'true', + fg: 'white', + selectedFg: 'white', + selectedBg: 'magenta', + interactive: 'true', + label: `Audit Logs for ${orgSlug}`, + width: '100%', + top: 0, + bottom: detailsBoxHeight + tipsBoxHeight, + border: { + type: 'line', + fg: 'cyan', + }, + columnWidth: maxWidths, //[10, 30, 40, 25, 15, 200], + // Note: spacing works as long as you don't reserve more than total width + columnSpacing: 4, + truncate: '_', + }) + + const BoxWidget = require('blessed/lib/widgets/box.js') + const tipsBox: Widgets.BoxElement = new BoxWidget({ + bottom: detailsBoxHeight, // sits just above the details box + height: tipsBoxHeight, + width: '100%', + style: { + fg: 'yellow', + bg: 'black', + }, + tags: true, + content: `↑/↓: Move Enter: Select q/ESC: Quit`, + }) + const detailsBox: Widgets.BoxElement = new BoxWidget({ + bottom: 0, + height: detailsBoxHeight, + width: '100%', + border: { + type: 'line', + fg: 'cyan', + }, + label: 'Details', + content: formatResult(filteredLogs[0], true), + style: { + fg: 'white', + }, + }) + + table.setData({ + headers: headers, + data: formattedOutput, + }) + + // allow control the table with the keyboard + table.focus() + + // Stacking order: table (top), tipsBox (middle), detailsBox (bottom) + screen.append(table) + screen.append(tipsBox) + screen.append(detailsBox) + + // Update details box when selection changes + table.rows.on('select item', () => { + const selectedIndex = table.rows.selected + if (selectedIndex !== undefined && selectedIndex >= 0) { + const selectedRow = filteredLogs[selectedIndex] + detailsBox.setContent(formatResult(selectedRow)) + screen.render() + } + }) + + screen.render() + + screen.key(['return'], () => { + const selectedIndex = table.rows.selected + screen.destroy() + const selectedRow = formattedOutput[selectedIndex] + ? formatResult(filteredLogs[selectedIndex], true) + : '(none)' + logger.log(`Last selection:\n${selectedRow.trim()}`) + }) +} diff --git a/packages/cli/test/unit/commands/audit-log/output-audit-log.test.mts b/src/commands/audit-log/output-audit-log.test.mts similarity index 75% rename from packages/cli/test/unit/commands/audit-log/output-audit-log.test.mts rename to src/commands/audit-log/output-audit-log.test.mts index f4e6c2edc..dcdd4ff91 100644 --- a/packages/cli/test/unit/commands/audit-log/output-audit-log.test.mts +++ b/src/commands/audit-log/output-audit-log.test.mts @@ -1,54 +1,17 @@ -/** - * Unit tests for audit log output formatting functions. - * - * Tests the data transformation and output formatting for audit logs. These - * tests use fixture data and snapshot testing for both JSON and markdown - * output. - * - * Test Coverage: - * - * - JSON output formatting with complete audit log data - * - Markdown output with table rendering - * - Error handling with empty/invalid data (returns empty object or error report) - * - Audit log metadata (org, type filter, page, perPage, next page) - * - Event fields (event_id, created_at, type, user_email, ip_address, user_agent) - * - Pagination information in output - * - Generated timestamp redaction in snapshots - * - * Testing Approach: - * - * - Load audit-fixture.json for realistic test data - * - Use inline snapshots to verify formatting output - * - Test both successful results and error cases - * - Verify markdown table structure with proper headers and separators - * - Test JSON stringification of audit log structures - * - * Related Files: - * - * - Src/commands/audit-log/output-audit-log.mts - Implementation - * - Src/commands/audit-log/audit-fixture.json - Test fixture data - * - Src/commands/audit-log/handle-audit-log.mts - Handler that uses output - * functions - */ - import { describe, expect, it } from 'vitest' -import FIXTURE from '../../../../src/commands/audit-log/audit-fixture.json' with { type: 'json' } -import { - outputAsJson, - outputAsMarkdown, -} from '../../../../src/commands/audit-log/output-audit-log.mts' -import { createSuccessResult } from '../../../helpers/mocks.mts' +import FIXTURE from './audit-fixture.json' with { type: 'json' } +import { outputAsJson, outputAsMarkdown } from './output-audit-log.mts' -import type { SocketSdkSuccessResult } from '@socketsecurity/sdk-stable' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' -type AuditLogs = SocketSdkSuccessResult<'getAuditLogEvents'>['data']['results'] +type AuditLogs = SocketSdkReturnType<'getAuditLogEvents'>['data']['results'] describe('output-audit-log', () => { describe('json', () => { it('should return formatted json string', async () => { const r = await outputAsJson( - createSuccessResult(JSON.parse(JSON.stringify(FIXTURE))), + { ok: true, data: JSON.parse(JSON.stringify(FIXTURE)) }, { logType: '', orgSlug: 'noorgslug', @@ -56,17 +19,16 @@ describe('output-audit-log', () => { perPage: 10, }, ) - expect(r).toMatchInlineSnapshot( - ` + expect(r).toMatchInlineSnapshot(` "{ "ok": true, "data": { "desc": "Audit logs for given query", "generated": "<redacted>", - "logType": "", - "nextPage": "2", "org": "noorgslug", + "logType": "", "page": 1, + "nextPage": "2", "perPage": 10, "logs": [ { @@ -129,8 +91,7 @@ describe('output-audit-log', () => { } } " - `, - ) + `) }) it('should return empty object string on error', async () => { diff --git a/src/commands/ci/cmd-ci.mts b/src/commands/ci/cmd-ci.mts new file mode 100644 index 000000000..e3799838d --- /dev/null +++ b/src/commands/ci/cmd-ci.mts @@ -0,0 +1,75 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleCI } from './handle-ci.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'ci', + description: + 'Create a new scan and report whether it passes your security policy', + hidden: true, + flags: { + ...commonFlags, + autoManifest: { + type: 'boolean', + default: false, // dev tools is not likely to be set up so this is safer + description: + 'Auto generate manifest files where detected? See autoManifest flag in `socket scan create`', + }, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + Options + ${getFlagListOutput(config.flags, 6)} + + This command is intended to use in CI runs to allow automated systems to + accept or reject a current build. When the scan does not pass your security + policy, the exit code will be non-zero. + + It will use the default org for the set API token. + + The --autoManifest flag does the same as the one from \`socket scan create\` + but is not enabled by default since the CI is less likely to be set up with + all the necessary dev tooling. Enable it if you want the scan to include + locally generated manifests like for gradle and sbt. + + Examples + $ ${command} + $ ${command} --autoManifest + `, +} + +export const cmdCI = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleCI(Boolean(cli.flags['autoManifest'])) +} diff --git a/src/commands/ci/cmd-ci.test.mts b/src/commands/ci/cmd-ci.test.mts new file mode 100644 index 000000000..8e23c8953 --- /dev/null +++ b/src/commands/ci/cmd-ci.test.mts @@ -0,0 +1,71 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket ci', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['ci', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Create a new scan and report whether it passes your security policy + + Usage + $ socket ci [options] + + Options + --autoManifest Auto generate manifest files where detected? See autoManifest flag in \`socket scan create\` + + This command is intended to use in CI runs to allow automated systems to + accept or reject a current build. When the scan does not pass your security + policy, the exit code will be non-zero. + + It will use the default org for the set API token. + + The --autoManifest flag does the same as the one from \`socket scan create\` + but is not enabled by default since the CI is less likely to be set up with + all the necessary dev tooling. Enable it if you want the scan to include + locally generated manifests like for gradle and sbt. + + Examples + $ socket ci + $ socket ci --autoManifest" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket ci\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket ci`') + }, + ) + + cmdit( + ['ci', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket ci\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/ci/fetch-default-org-slug.mts b/src/commands/ci/fetch-default-org-slug.mts new file mode 100644 index 000000000..d0a096d2a --- /dev/null +++ b/src/commands/ci/fetch-default-org-slug.mts @@ -0,0 +1,61 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import { handleApiCall } from '../../utils/api.mts' +import { getConfigValueOrUndef } from '../../utils/config.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' + +// Use the config defaultOrg when set, otherwise discover from remote +export async function getDefaultOrgSlug(): Promise<CResult<string>> { + const defaultOrgResult = getConfigValueOrUndef('defaultOrg') + + if (defaultOrgResult) { + debugFn('use: default org', defaultOrgResult) + return { ok: true, data: defaultOrgResult } + } + + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + const result = await handleApiCall( + sockSdk.getOrganizations(), + 'list of organizations', + ) + + if (!result.ok) { + return result + } + + const orgs = result.data.organizations + const keys = Object.keys(orgs) + + if (!keys[0]) { + return { + ok: false, + message: 'Failed to establish identity', + data: `API did not return any organization associated with the current API token. Unable to continue.`, + } + } + + const slug = (keys[0] in orgs && orgs?.[keys[0]]?.name) ?? undefined + + if (!slug) { + return { + ok: false, + message: 'Failed to establish identity', + data: `Was unable to determine the default organization for the current API token. Unable to continue.`, + } + } + + debugFn('resolve: org', slug) + + return { + ok: true, + message: 'Retrieved default org from server', + data: slug, + } +} diff --git a/src/commands/ci/handle-ci.mts b/src/commands/ci/handle-ci.mts new file mode 100644 index 000000000..6f09e15a2 --- /dev/null +++ b/src/commands/ci/handle-ci.mts @@ -0,0 +1,41 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { getDefaultOrgSlug } from './fetch-default-org-slug.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' +import { handleCreateNewScan } from '../scan/handle-create-new-scan.mts' + +export async function handleCI(autoManifest: boolean): Promise<void> { + // ci: { + // description: 'Alias for "report create --view --strict"', + // argv: ['report', 'create', '--view', '--strict'] + // } + const result = await getDefaultOrgSlug() + if (!result.ok) { + process.exitCode = result.code ?? 1 + // Always assume json mode + logger.log(serializeResultJson(result)) + return + } + + // TODO: does it make sense to discover the commit details from local git? + // TODO: does it makes sense to use custom branch/repo names here? probably socket.yml, right + await handleCreateNewScan({ + autoManifest, + branchName: 'socket-default-branch', + commitMessage: '', + commitHash: '', + committers: '', + cwd: process.cwd(), + defaultBranch: false, + interactive: false, + orgSlug: result.data, + outputKind: 'json', + pendingHead: true, // when true, requires branch name set, tmp false + pullRequest: 0, + repoName: 'socket-default-repository', + readOnly: false, + report: true, + targets: ['.'], + tmp: false, // don't set when pendingHead is true + }) +} diff --git a/src/commands/cli.test.mts b/src/commands/cli.test.mts new file mode 100755 index 000000000..3db5e999d --- /dev/null +++ b/src/commands/cli.test.mts @@ -0,0 +1,101 @@ +import { describe, expect } from 'vitest' + +import { cmdit, invokeNpm } from '../../test/utils.mts' +import constants from '../constants.mts' + +describe('socket root command', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit(['--help', '--config', '{}'], 'should support --help', async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "CLI for Socket.dev + + Usage + $ socket <command> + + All commands have their own --help page + + Main commands + + socket login Setup the CLI with an API Token and defaults + socket scan create Create a new Scan and report + socket npm/eslint@1.0.0 Request the security score of a particular package + socket ci Shorthand for CI; socket scan create --report --no-interactive + + Socket API + + analytics Look up analytics data + audit-log Look up the audit log for an organization + organization Manage organization account details + package Look up published package details + repository Manage registered repositories + scan Manage Socket scans + threat-feed [beta] View the threat feed + + Local tools + + fix Update dependencies with "fixable" Socket alerts + manifest Generate a dependency manifest for certain languages + npm npm wrapper functionality + npx npx wrapper functionality + optimize Optimize dependencies with @socketregistry overrides + raw-npm Temporarily disable the Socket npm wrapper + raw-npx Temporarily disable the Socket npx wrapper + + CLI configuration + + config Manage the CLI configuration directly + install Manually install CLI tab completion on your system + login Socket API login and CLI setup + logout Socket API logout + uninstall Remove the CLI tab completion from your system + wrapper Enable or disable the Socket npm/npx wrapper + + Options (Note: all CLI commands have these flags even when not displayed in their help) + + --config Allows you to temp overrides the internal CLI config + --dryRun Do input validation for a sub-command and then exit + --help Give you detailed help information about any sub-command + --version Show version of CLI + + Examples + $ socket --help + $ socket scan create --json + $ socket package score npm left-pad --markdown" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket`') + }) + + cmdit( + ['mootools', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/config/cmd-config-auto.mts b/src/commands/config/cmd-config-auto.mts new file mode 100644 index 000000000..7693ebe19 --- /dev/null +++ b/src/commands/config/cmd-config-auto.mts @@ -0,0 +1,107 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleConfigAuto } from './handle-config-auto.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { supportedConfigKeys } from '../../utils/config.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { LocalConfig } from '../../utils/config.mts' +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'auto', + description: 'Automatically discover and set the correct value config item', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] KEY + + Options + ${getFlagListOutput(config.flags, 6)} + + Attempt to automatically discover the correct value for given config KEY. + + Keys: + +${Array.from(supportedConfigKeys.entries()) + .map(([key, desc]) => ` - ${key} -- ${desc}`) + .join('\n')} + + For certain keys it will request the value from server, for others it will + reset the value to the default. For some keys this has no effect. + + Keys: + +${Array.from(supportedConfigKeys.entries()) + .map(([key, desc]) => ` - ${key} -- ${desc}`) + .join('\n')} + + Examples + $ ${command} defaultOrg + `, +} + +export const cmdConfigAuto = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [key = ''] = cli.input + + const wasValidInput = checkCommandInput( + outputKind, + { + test: supportedConfigKeys.has(key as keyof LocalConfig) && key !== 'test', + message: 'Config key should be the first arg', + pass: 'ok', + fail: key ? 'invalid config key' : 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleConfigAuto({ + key: key as keyof LocalConfig, + outputKind, + }) +} diff --git a/src/commands/config/cmd-config-auto.test.mts b/src/commands/config/cmd-config-auto.test.mts new file mode 100644 index 000000000..177d1a828 --- /dev/null +++ b/src/commands/config/cmd-config-auto.test.mts @@ -0,0 +1,94 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config auto', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', 'auto', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Automatically discover and set the correct value config item + + Usage + $ socket config auto [options] KEY + + Options + --json Output result as json + --markdown Output result as markdown + + Attempt to automatically discover the correct value for given config KEY. + + Keys: + + - apiBaseUrl -- Base URL of the API endpoint + - apiProxy -- A proxy through which to access the API + - apiToken -- The API token required to access most API endpoints + - defaultOrg -- The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value. + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + - skipAskToPersistDefaultOrg -- This flag prevents the CLI from asking you to persist the org slug when you selected one interactively + - org -- Alias for defaultOrg + + For certain keys it will request the value from server, for others it will + reset the value to the default. For some keys this has no effect. + + Keys: + + - apiBaseUrl -- Base URL of the API endpoint + - apiProxy -- A proxy through which to access the API + - apiToken -- The API token required to access most API endpoints + - defaultOrg -- The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value. + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + - skipAskToPersistDefaultOrg -- This flag prevents the CLI from asking you to persist the org slug when you selected one interactively + - org -- Alias for defaultOrg + + Examples + $ socket config auto defaultOrg" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config auto\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket config auto`', + ) + }, + ) + + cmdit( + [ + 'config', + 'auto', + 'defaultOrg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config auto\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/config/cmd-config-get.mts b/src/commands/config/cmd-config-get.mts new file mode 100644 index 000000000..8565d99bd --- /dev/null +++ b/src/commands/config/cmd-config-get.mts @@ -0,0 +1,99 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleConfigGet } from './handle-config-get.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { supportedConfigKeys } from '../../utils/config.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { LocalConfig } from '../../utils/config.mts' +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'get', + description: 'Get the value of a local CLI config item', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] KEY + + Retrieve the value for given KEY at this time. If you have overridden the + config then the value will come from that override. + + Options + ${getFlagListOutput(config.flags, 6)} + + KEY is an enum. Valid keys: + +${Array.from(supportedConfigKeys.entries()) + .map(([key, desc]) => ` - ${key} -- ${desc}`) + .join('\n')} + + Examples + $ ${command} defaultOrg + `, +} + +export const cmdConfigGet = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [key = ''] = cli.input + + const wasValidInput = checkCommandInput( + outputKind, + { + test: supportedConfigKeys.has(key as keyof LocalConfig) || key === 'test', + message: 'Config key should be the first arg', + pass: 'ok', + fail: key ? 'invalid config key' : 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleConfigGet({ + key: key as keyof LocalConfig, + outputKind, + }) +} diff --git a/src/commands/config/cmd-config-get.test.mts b/src/commands/config/cmd-config-get.test.mts new file mode 100644 index 000000000..2deae0169 --- /dev/null +++ b/src/commands/config/cmd-config-get.test.mts @@ -0,0 +1,334 @@ +import semver from 'semver' +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config get', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', 'get', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Get the value of a local CLI config item + + Usage + $ socket config get [options] KEY + + Retrieve the value for given KEY at this time. If you have overridden the + config then the value will come from that override. + + Options + --json Output result as json + --markdown Output result as markdown + + KEY is an enum. Valid keys: + + - apiBaseUrl -- Base URL of the API endpoint + - apiProxy -- A proxy through which to access the API + - apiToken -- The API token required to access most API endpoints + - defaultOrg -- The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value. + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + - skipAskToPersistDefaultOrg -- This flag prevents the CLI from asking you to persist the org slug when you selected one interactively + - org -- Alias for defaultOrg + + Examples + $ socket config get defaultOrg" + `, + ) + // Node 24 on Windows currently fails this test with added stderr: + // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76 + const skipOnWin32Node24 = + constants.WIN32 && semver.parse(constants.NODE_VERSION)!.major >= 24 + if (!skipOnWin32Node24) { + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + expect(code, 'explicit help should exit with code 0').toBe(0) + } + + expect(stderr, 'banner includes base command').toContain( + '`socket config get`', + ) + }, + ) + + cmdit( + ['config', 'get', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Config key should be the first arg (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'config', + 'test', + 'test', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + describe('env vars', () => { + describe('token', () => { + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should return undefined when token not set in config', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: null + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: null')).toBe(true) + }, + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should return the env var token when set', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, { + SOCKET_CLI_API_TOKEN: 'abc', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + }, + ) + + // Migrate this away...? + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should backwards compat support api key as well env var', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, { + SOCKET_SECURITY_API_KEY: 'abc', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + }, + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should be nice and support cli prefixed env var for token as well', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, { + SOCKET_CLI_API_TOKEN: 'abc', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + }, + ) + + // Migrate this away...? + cmdit( + ['config', 'get', 'apiToken', '--config', '{"apiToken":null}'], + 'should be very nice and support cli prefixed env var for key as well since it is an easy mistake to make', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, { + SOCKET_CLI_API_KEY: 'abc', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + }, + ) + + cmdit( + [ + 'config', + 'get', + 'apiToken', + '--config', + '{"apiToken":"ignoremebecausetheenvvarshouldbemoreimportant"}', + ], + 'should use the env var token when the config override also has a token set', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, { + SOCKET_CLI_API_KEY: 'abc', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: abc + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: abc')).toBe(true) + }, + ) + + cmdit( + [ + 'config', + 'get', + 'apiToken', + '--config', + '{"apiToken":"pickmepickme"}', + ], + 'should use the config override when there is no env var', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: pickmepickme + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: pickmepickme')).toBe(true) + }, + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{}'], + 'should yield no token when override has none', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd, {}) + expect(stdout).toMatchInlineSnapshot( + ` + "apiToken: undefined + + Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config get\`, cwd: <redacted>" + `) + + expect(stdout.includes('apiToken: undefined')).toBe(true) + }, + ) + }) + }) +}) diff --git a/src/commands/config/cmd-config-list.mts b/src/commands/config/cmd-config-list.mts new file mode 100644 index 000000000..bffd17d4f --- /dev/null +++ b/src/commands/config/cmd-config-list.mts @@ -0,0 +1,82 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { outputConfigList } from './output-config-list.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'list', + description: 'Show all local CLI config items and their values', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + full: { + type: 'boolean', + default: false, + description: 'Show full tokens in plaintext (unsafe)', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + `, +} + +export const cmdConfigList = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { full, json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await outputConfigList({ + full: !!full, + outputKind, + }) +} diff --git a/src/commands/config/cmd-config-list.test.mts b/src/commands/config/cmd-config-list.test.mts new file mode 100644 index 000000000..cdb4a37cb --- /dev/null +++ b/src/commands/config/cmd-config-list.test.mts @@ -0,0 +1,65 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config get', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', 'list', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Show all local CLI config items and their values + + Usage + $ socket config list [options] + + Options + --full Show full tokens in plaintext (unsafe) + --json Output result as json + --markdown Output result as markdown + + Examples + $ socket config list" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config list\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket config list`', + ) + }, + ) + + cmdit( + ['config', 'list', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/config/cmd-config-set.mts b/src/commands/config/cmd-config-set.mts new file mode 100644 index 000000000..10f253dca --- /dev/null +++ b/src/commands/config/cmd-config-set.mts @@ -0,0 +1,114 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleConfigSet } from './handle-config-set.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { supportedConfigKeys } from '../../utils/config.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { LocalConfig } from '../../utils/config.mts' +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'set', + description: 'Update the value of a local CLI config item', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <KEY> <VALUE> + + Options + ${getFlagListOutput(config.flags, 6)} + + This is a crude way of updating the local configuration for this CLI tool. + + Note that updating a value here is nothing more than updating a key/value + store entry. No validation is happening. The server may reject your values + in some cases. Use at your own risk. + + Note: use \`socket config unset\` to restore to defaults. Setting a key + to \`undefined\` will not allow default values to be set on it. + + Keys: + +${Array.from(supportedConfigKeys.entries()) + .map(([key, desc]) => ` - ${key} -- ${desc}`) + .join('\n')} + + Examples + $ ${command} apiProxy https://example.com + `, +} + +export const cmdConfigSet = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [key = '', ...rest] = cli.input + const value = rest.join(' ') + + const wasValidInput = checkCommandInput( + outputKind, + { + test: key === 'test' || supportedConfigKeys.has(key as keyof LocalConfig), + message: 'Config key should be the first arg', + pass: 'ok', + fail: key ? 'invalid config key' : 'missing', + }, + { + test: !!value, // This is a string, empty string is not ok + message: + 'Key value should be the remaining args (use `unset` to unset a value)', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleConfigSet({ + key: key as keyof LocalConfig, + outputKind, + value, + }) +} diff --git a/src/commands/config/cmd-config-set.test.mts b/src/commands/config/cmd-config-set.test.mts new file mode 100644 index 000000000..12a01aa6f --- /dev/null +++ b/src/commands/config/cmd-config-set.test.mts @@ -0,0 +1,116 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config get', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', 'set', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Update the value of a local CLI config item + + Usage + $ socket config set [options] <KEY> <VALUE> + + Options + --json Output result as json + --markdown Output result as markdown + + This is a crude way of updating the local configuration for this CLI tool. + + Note that updating a value here is nothing more than updating a key/value + store entry. No validation is happening. The server may reject your values + in some cases. Use at your own risk. + + Note: use \`socket config unset\` to restore to defaults. Setting a key + to \`undefined\` will not allow default values to be set on it. + + Keys: + + - apiBaseUrl -- Base URL of the API endpoint + - apiProxy -- A proxy through which to access the API + - apiToken -- The API token required to access most API endpoints + - defaultOrg -- The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value. + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + - skipAskToPersistDefaultOrg -- This flag prevents the CLI from asking you to persist the org slug when you selected one interactively + - org -- Alias for defaultOrg + + Examples + $ socket config set apiProxy https://example.com" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket config set`', + ) + }, + ) + + cmdit( + ['config', 'set', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Config key should be the first arg (\\x1b[31mmissing\\x1b[39m) + + - Key value should be the remaining args (use \`unset\` to unset a value) (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'config', + 'set', + 'test', + 'xyz', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config set\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/config/cmd-config-unset.mts b/src/commands/config/cmd-config-unset.mts new file mode 100644 index 000000000..e7505cac7 --- /dev/null +++ b/src/commands/config/cmd-config-unset.mts @@ -0,0 +1,99 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleConfigUnset } from './handle-config-unset.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { supportedConfigKeys } from '../../utils/config.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { LocalConfig } from '../../utils/config.mts' +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'unset', + description: 'Clear the value of a local CLI config item', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <KEY> <VALUE> + + Options + ${getFlagListOutput(config.flags, 6)} + + Removes a value from a config key, allowing the default value to be used + for it instead. + + Keys: + +${Array.from(supportedConfigKeys.entries()) + .map(([key, desc]) => ` - ${key} -- ${desc}`) + .join('\n')} + + Examples + $ ${command} defaultOrg + `, +} + +export const cmdConfigUnset = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [key = ''] = cli.input + + const wasValidInput = checkCommandInput( + outputKind, + { + test: key === 'test' || supportedConfigKeys.has(key as keyof LocalConfig), + message: 'Config key should be the first arg', + pass: 'ok', + fail: key ? 'invalid config key' : 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleConfigUnset({ + key: key as keyof LocalConfig, + outputKind, + }) +} diff --git a/src/commands/config/cmd-config-unset.test.mts b/src/commands/config/cmd-config-unset.test.mts new file mode 100644 index 000000000..69f35537c --- /dev/null +++ b/src/commands/config/cmd-config-unset.test.mts @@ -0,0 +1,107 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config unset', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', 'unset', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Clear the value of a local CLI config item + + Usage + $ socket config unset [options] <KEY> <VALUE> + + Options + --json Output result as json + --markdown Output result as markdown + + Removes a value from a config key, allowing the default value to be used + for it instead. + + Keys: + + - apiBaseUrl -- Base URL of the API endpoint + - apiProxy -- A proxy through which to access the API + - apiToken -- The API token required to access most API endpoints + - defaultOrg -- The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value. + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + - skipAskToPersistDefaultOrg -- This flag prevents the CLI from asking you to persist the org slug when you selected one interactively + - org -- Alias for defaultOrg + + Examples + $ socket config unset defaultOrg" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket config unset`', + ) + }, + ) + + cmdit( + ['config', 'unset', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Config key should be the first arg (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'config', + 'unset', + 'test', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config unset\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/config/cmd-config.mts b/src/commands/config/cmd-config.mts new file mode 100644 index 000000000..98133e3d3 --- /dev/null +++ b/src/commands/config/cmd-config.mts @@ -0,0 +1,32 @@ +import { cmdConfigAuto } from './cmd-config-auto.mts' +import { cmdConfigGet } from './cmd-config-get.mts' +import { cmdConfigList } from './cmd-config-list.mts' +import { cmdConfigSet } from './cmd-config-set.mts' +import { cmdConfigUnset } from './cmd-config-unset.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Commands related to the local CLI configuration' + +export const cmdConfig: CliSubcommand = { + description, + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + auto: cmdConfigAuto, + get: cmdConfigGet, + list: cmdConfigList, + set: cmdConfigSet, + unset: cmdConfigUnset, + }, + { + argv, + description, + importMeta, + name: `${parentName} config`, + }, + ) + }, +} diff --git a/src/commands/config/cmd-config.test.mts b/src/commands/config/cmd-config.test.mts new file mode 100644 index 000000000..a15a668f2 --- /dev/null +++ b/src/commands/config/cmd-config.test.mts @@ -0,0 +1,117 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket config', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['config', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Commands related to the local CLI configuration + + Usage + $ socket config <command> + + Commands + auto Automatically discover and set the correct value config item + get Get the value of a local CLI config item + list Show all local CLI config items and their values + set Update the value of a local CLI config item + unset Clear the value of a local CLI config item + + Options + (none) + + Examples + $ socket config --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket config`', + ) + }, + ) + + cmdit( + ['config', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket config\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + describe('config override', () => { + cmdit( + ['config', 'get', 'apiToken'], + 'should print nice error when env config override cannot be parsed', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd, { + // This will be parsed first. If it fails it should fallback to flag or empty. + SOCKET_CLI_CONFIG: '{apiToken:invalidjson}', + }) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m Could not parse Config as JSON" + `) + + expect(stderr.includes('Could not parse Config as JSON')).toBe(true) + expect(code, 'bad config input should exit with code 2 ').toBe(2) + }, + ) + + cmdit( + ['config', 'get', 'apiToken', '--config', '{apiToken:invalidjson}'], + 'should print nice error when flag config override cannot be parsed', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m Could not parse Config as JSON" + `) + + expect(stderr.includes('Could not parse Config as JSON')).toBe(true) + expect(code, 'bad config input should exit with code 2 ').toBe(2) + }, + ) + }) +}) diff --git a/src/commands/config/discover-config-value.mts b/src/commands/config/discover-config-value.mts new file mode 100644 index 000000000..7bb20dd5a --- /dev/null +++ b/src/commands/config/discover-config-value.mts @@ -0,0 +1,184 @@ +import { handleApiCall } from '../../utils/api.mts' +import { supportedConfigKeys } from '../../utils/config.mts' +import { hasDefaultToken, setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function discoverConfigValue( + key: string, +): Promise<CResult<unknown>> { + // This will have to be a specific implementation per key because certain + // keys should request information from particular API endpoints while + // others should simply return their default value, like endpoint URL. + + if (!supportedConfigKeys.has(key as keyof LocalConfig)) { + return { + ok: false, + message: 'Auto discover failed', + cause: 'Requested key is not a valid config key.', + } + } + + if (key === 'apiBaseUrl') { + // Return the default value + return { + ok: false, + message: 'Auto discover failed', + cause: + "If you're unsure about the base endpoint URL then simply unset it.", + } + } + + if (key === 'apiProxy') { + // I don't think we can auto-discover this with any order of reliability..? + return { + ok: false, + message: 'Auto discover failed', + cause: + 'When uncertain, unset this key. Otherwise ask your network administrator', + } + } + + if (key === 'apiToken') { + return { + ok: false, + message: 'Auto discover failed', + cause: + 'You can find/create your API token in your Socket dashboard > settings > API tokens.\nYou should then use `socket login` to login instead of this command.', + } + } + + if (key === 'defaultOrg') { + const hasApiToken = hasDefaultToken() + if (!hasApiToken) { + return { + ok: false, + message: 'Auto discover failed', + cause: + 'No API token set, must have a token to resolve its default org.', + } + } + + const org = await getDefaultOrgFromToken() + if (!org?.length) { + return { + ok: false, + message: 'Auto discover failed', + cause: 'Was unable to determine default org for the current API token.', + } + } + + if (Array.isArray(org)) { + return { + ok: true, + data: org, + message: 'These are the orgs that the current API token can access.', + } + } + + return { + ok: true, + data: org, + message: 'This is the org that belongs to the current API token.', + } + } + + if (key === 'enforcedOrgs') { + const hasApiToken = hasDefaultToken() + if (!hasApiToken) { + return { + ok: false, + message: 'Auto discover failed', + cause: + 'No API token set, must have a token to resolve orgs to enforce.', + } + } + + const orgs = await getEnforceableOrgsFromToken() + if (!orgs?.length) { + return { + ok: false, + message: 'Auto discover failed', + cause: + 'Was unable to determine any orgs to enforce for the current API token.', + } + } + + return { + ok: true, + data: orgs, + message: 'These are the orgs whose security policy you can enforce.', + } + } + + if (key === 'test') { + return { + ok: false, + message: 'Auto discover failed', + cause: 'congrats, you found the test key', + } + } + + // Mostly to please TS, because we're not telling it `key` is keyof LocalConfig + return { + ok: false, + message: 'Auto discover failed', + cause: 'unreachable?', + } +} + +async function getDefaultOrgFromToken(): Promise< + string[] | string | undefined +> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return undefined + } + const sockSdk = sockSdkResult.data + + const result = await handleApiCall( + sockSdk.getOrganizations(), + 'list of organizations', + ) + + if (result.ok) { + const arr = Array.from(Object.values(result.data.organizations)).map( + ({ slug }) => slug, + ) + if (arr.length === 0) { + return undefined + } + if (arr.length === 1) { + return arr[0] + } + return arr + } + + return undefined +} + +async function getEnforceableOrgsFromToken(): Promise<string[] | undefined> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return undefined + } + const sockSdk = sockSdkResult.data + + const result = await handleApiCall( + sockSdk.getOrganizations(), + 'list of organizations', + ) + + if (result.ok) { + const arr = Array.from(Object.values(result.data.organizations)).map( + ({ slug }) => slug, + ) + if (arr.length === 0) { + return undefined + } + return arr + } + + return undefined +} diff --git a/packages/cli/src/commands/config/handle-config-auto.mts b/src/commands/config/handle-config-auto.mts similarity index 87% rename from packages/cli/src/commands/config/handle-config-auto.mts rename to src/commands/config/handle-config-auto.mts index 161c7ea09..ec3a1f8b0 100644 --- a/packages/cli/src/commands/config/handle-config-auto.mts +++ b/src/commands/config/handle-config-auto.mts @@ -2,7 +2,7 @@ import { discoverConfigValue } from './discover-config-value.mts' import { outputConfigAuto } from './output-config-auto.mts' import type { OutputKind } from '../../types.mts' -import type { LocalConfig } from '../../util/config.mts' +import type { LocalConfig } from '../../utils/config.mts' export async function handleConfigAuto({ key, diff --git a/src/commands/config/handle-config-get.mts b/src/commands/config/handle-config-get.mts new file mode 100644 index 000000000..3ef21f348 --- /dev/null +++ b/src/commands/config/handle-config-get.mts @@ -0,0 +1,17 @@ +import { outputConfigGet } from './output-config-get.mts' +import { getConfigValue } from '../../utils/config.mts' + +import type { OutputKind } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function handleConfigGet({ + key, + outputKind, +}: { + key: keyof LocalConfig + outputKind: OutputKind +}) { + const result = getConfigValue(key) + + await outputConfigGet(key, result, outputKind) +} diff --git a/src/commands/config/handle-config-set.mts b/src/commands/config/handle-config-set.mts new file mode 100644 index 000000000..88766a09c --- /dev/null +++ b/src/commands/config/handle-config-set.mts @@ -0,0 +1,19 @@ +import { outputConfigSet } from './output-config-set.mts' +import { updateConfigValue } from '../../utils/config.mts' + +import type { OutputKind } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function handleConfigSet({ + key, + outputKind, + value, +}: { + key: keyof LocalConfig + outputKind: OutputKind + value: string +}) { + const result = updateConfigValue(key, value) + + await outputConfigSet(result, outputKind) +} diff --git a/src/commands/config/handle-config-unset.mts b/src/commands/config/handle-config-unset.mts new file mode 100644 index 000000000..7746bab15 --- /dev/null +++ b/src/commands/config/handle-config-unset.mts @@ -0,0 +1,17 @@ +import { outputConfigUnset } from './output-config-unset.mts' +import { updateConfigValue } from '../../utils/config.mts' + +import type { OutputKind } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function handleConfigUnset({ + key, + outputKind, +}: { + key: keyof LocalConfig + outputKind: OutputKind +}) { + const updateResult = updateConfigValue(key, undefined) + + await outputConfigUnset(updateResult, outputKind) +} diff --git a/src/commands/config/output-config-auto.mts b/src/commands/config/output-config-auto.mts new file mode 100644 index 000000000..fa0744ffb --- /dev/null +++ b/src/commands/config/output-config-auto.mts @@ -0,0 +1,114 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { isReadOnlyConfig, updateConfigValue } from '../../utils/config.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function outputConfigAuto( + key: keyof LocalConfig, + result: CResult<unknown>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log(`# Auto discover config value`) + logger.log('') + logger.log( + `Attempted to automatically discover the value for config key: "${key}"`, + ) + logger.log('') + if (result.ok) { + logger.log(`The discovered value is: "${result.data}"`) + if (result.message) { + logger.log('') + logger.log(result.message) + } + } + logger.log('') + } else { + if (result.message) { + logger.log(result.message) + logger.log('') + } + logger.log(`- ${key}: ${result.data}`) + logger.log('') + + if (isReadOnlyConfig()) { + logger.log( + '(Unable to persist this value because the config is in read-only mode, meaning it was overridden through env or flag.)', + ) + } else if (key === 'defaultOrg') { + const proceed = await select<string>({ + message: + 'Would you like to update the default org in local config to this value?', + choices: (Array.isArray(result.data) ? result.data : [result.data]) + .map(slug => ({ + name: 'Yes [' + slug + ']', + value: slug, + description: `Use "${slug}" as the default organization`, + })) + .concat({ + name: 'No', + value: '', + description: 'Do not use any of these organizations', + }), + }) + if (proceed) { + logger.log(`Setting defaultOrg to "${proceed}"...`) + const updateResult = updateConfigValue('defaultOrg', proceed) + if (updateResult.ok) { + logger.log( + `OK. Updated defaultOrg to "${proceed}".\nYou should no longer need to add the org to commands that normally require it.`, + ) + } else { + logger.log(failMsgWithBadge(updateResult.message, updateResult.cause)) + } + } else { + logger.log('OK. No changes made.') + } + } else if (key === 'enforcedOrgs') { + const proceed = await select<string>({ + message: + 'Would you like to update the enforced orgs in local config to this value?', + choices: (Array.isArray(result.data) ? result.data : [result.data]) + .map(slug => ({ + name: 'Yes [' + slug + ']', + value: slug, + description: `Enforce the security policy of "${slug}" on this machine`, + })) + .concat({ + name: 'No', + value: '', + description: 'Do not use any of these organizations', + }), + }) + if (proceed) { + logger.log(`Setting enforcedOrgs key to "${proceed}"...`) + const updateResult = updateConfigValue('defaultOrg', proceed) + if (updateResult.ok) { + logger.log(`OK. Updated enforcedOrgs to "${proceed}".`) + } else { + logger.log(failMsgWithBadge(updateResult.message, updateResult.cause)) + } + } else { + logger.log('OK. No changes made.') + } + } + } +} diff --git a/src/commands/config/output-config-get.mts b/src/commands/config/output-config-get.mts new file mode 100644 index 000000000..50db9d4b2 --- /dev/null +++ b/src/commands/config/output-config-get.mts @@ -0,0 +1,49 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { isReadOnlyConfig } from '../../utils/config.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { LocalConfig } from '../../utils/config.mts' + +export async function outputConfigGet( + key: keyof LocalConfig, + result: CResult<LocalConfig[keyof LocalConfig]>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const readOnly = isReadOnlyConfig() + + if (outputKind === 'markdown') { + logger.log(`# Config Value`) + logger.log('') + logger.log(`Config key '${key}' has value '${result.data}`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.', + ) + } + } else { + logger.log(`${key}: ${result.data}`) + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an env var or command flag.', + ) + } + } +} diff --git a/src/commands/config/output-config-list.mts b/src/commands/config/output-config-list.mts new file mode 100644 index 000000000..0ad651657 --- /dev/null +++ b/src/commands/config/output-config-list.mts @@ -0,0 +1,95 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { + getConfigValue, + isReadOnlyConfig, + sensitiveConfigKeys, + supportedConfigKeys, +} from '../../utils/config.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { OutputKind } from '../../types.mts' + +export async function outputConfigList({ + full, + outputKind, +}: { + full: boolean + outputKind: OutputKind +}) { + const readOnly = isReadOnlyConfig() + if (outputKind === 'json') { + let failed = false + const obj: Record<string, unknown> = {} + for (const key of supportedConfigKeys.keys()) { + const result = getConfigValue(key) + let value = result.data + if (!result.ok) { + value = `Failed to retrieve: ${result.message}` + failed = true + } else if (!full && sensitiveConfigKeys.has(key)) { + value = '********' + } + if (full || value !== undefined) { + obj[key as any] = value ?? '<none>' + } + } + if (failed) { + process.exitCode = 1 + } + logger.log( + serializeResultJson( + failed + ? { + ok: false, + message: 'At least one config key failed to be fetched...', + data: JSON.stringify({ + full, + config: obj, + readOnly, + }), + } + : { + ok: true, + data: { + full, + config: obj, + readOnly, + }, + }, + ), + ) + } else { + const maxWidth = Array.from(supportedConfigKeys.keys()).reduce( + (a, b) => Math.max(a, b.length), + 0, + ) + + logger.log('# Local CLI Config') + logger.log('') + logger.log(`This is the local CLI config (full=${!!full}):`) + logger.log('') + for (const key of supportedConfigKeys.keys()) { + const result = getConfigValue(key) + if (!result.ok) { + logger.log(`- ${key}: failed to read: ${result.message}`) + } else { + let value = result.data + if (!full && sensitiveConfigKeys.has(key)) { + value = '********' + } + if (full || value !== undefined) { + logger.log( + `- ${key}:${' '.repeat(Math.max(0, maxWidth - key.length + 3))} ${Array.isArray(value) ? value.join(', ') || '<none>' : (value ?? '<none>')}`, + ) + } + } + } + if (readOnly) { + logger.log('') + logger.log( + 'Note: the config is in read-only mode, meaning at least one key was temporarily\n overridden from an env var or command flag.', + ) + } + } +} diff --git a/src/commands/config/output-config-set.mts b/src/commands/config/output-config-set.mts new file mode 100644 index 000000000..a264b79c3 --- /dev/null +++ b/src/commands/config/output-config-set.mts @@ -0,0 +1,41 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputConfigSet( + result: CResult<undefined | string>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log(`# Update config`) + logger.log('') + logger.log(result.message) + if (result.data) { + logger.log('') + logger.log(result.data) + } + } else { + logger.log(`OK`) + logger.log(result.message) + if (result.data) { + logger.log('') + logger.log(result.data) + } + } +} diff --git a/src/commands/config/output-config-unset.mts b/src/commands/config/output-config-unset.mts new file mode 100644 index 000000000..ab8a4069c --- /dev/null +++ b/src/commands/config/output-config-unset.mts @@ -0,0 +1,41 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputConfigUnset( + updateResult: CResult<undefined | string>, + outputKind: OutputKind, +) { + if (!updateResult.ok) { + process.exitCode = updateResult.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(updateResult)) + return + } + if (!updateResult.ok) { + logger.fail(failMsgWithBadge(updateResult.message, updateResult.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log(`# Update config`) + logger.log('') + logger.log(updateResult.message) + if (updateResult.data) { + logger.log('') + logger.log(updateResult.data) + } + } else { + logger.log(`OK`) + logger.log(updateResult.message) + if (updateResult.data) { + logger.log('') + logger.log(updateResult.data) + } + } +} diff --git a/src/commands/fix/agent-fix.mts b/src/commands/fix/agent-fix.mts new file mode 100644 index 000000000..8e525e509 --- /dev/null +++ b/src/commands/fix/agent-fix.mts @@ -0,0 +1,629 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import semver from 'semver' + +import { getManifestData } from '@socketsecurity/registry' +import { arrayUnique } from '@socketsecurity/registry/lib/arrays' +import { debugFn, isDebug } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { runScript } from '@socketsecurity/registry/lib/npm' +import { + fetchPackagePackument, + readPackageJson, + resolvePackageName, +} from '@socketsecurity/registry/lib/packages' +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import { getActiveBranchesForPackage } from './fix-branch-helpers.mts' +import { getActualTree } from './get-actual-tree.mts' +import { + getSocketBranchName, + getSocketBranchWorkspaceComponent, + getSocketCommitMessage, + gitCreateAndPushBranch, + gitRemoteBranchExists, + gitResetAndClean, + gitUnstagedModifiedFiles, +} from './git.mts' +import { + cleanupOpenPrs, + enablePrAutoMerge, + openPr, + prExistForBranch, + setGitRemoteGithubRepoUrl, +} from './open-pr.mts' +import constants from '../../constants.mts' +import { + findBestPatchVersion, + findPackageNode, + findPackageNodes, + updatePackageJsonFromNode, +} from '../../shadow/npm/arborist-helpers.mts' +import { removeNodeModules } from '../../utils/fs.mts' +import { globWorkspace } from '../../utils/glob.mts' +import { readLockfile } from '../../utils/lockfile.mts' +import { getPurlObject } from '../../utils/purl.mts' +import { applyRange } from '../../utils/semver.mts' +import { getCveInfoFromAlertsMap } from '../../utils/socket-package-alert.mts' +import { idToPurl } from '../../utils/spec.mts' +import { getOverridesData } from '../optimize/get-overrides-by-agent.mts' + +import type { CiEnv } from './fix-env-helpers.mts' +import type { PrMatch } from './open-pr.mts' +import type { NodeClass } from '../../shadow/npm/arborist/types.mts' +import type { CResult } from '../../types.mts' +import type { EnvDetails } from '../../utils/package-environment.mts' +import type { RangeStyle } from '../../utils/semver.mts' +import type { AlertsByPurl } from '../../utils/socket-package-alert.mts' +import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +export type FixOptions = { + autoMerge: boolean + cwd: string + limit: number + purls: string[] + rangeStyle: RangeStyle + spinner?: Spinner | undefined + test: boolean + testScript: string +} + +export type InstallOptions = { + args?: string[] | undefined + cwd?: string | undefined + spinner?: Spinner | undefined +} + +export type InstallPhaseHandler = ( + editablePkgJson: EditablePackageJson, + name: string, + oldVersion: string, + newVersion: string, + vulnerableVersionRange: string, + options: FixOptions, +) => Promise<void> + +export type Installer = ( + pkgEnvDetails: EnvDetails, + options: InstallOptions, +) => Promise<NodeClass | null> + +const noopHandler = (() => {}) as unknown as InstallPhaseHandler + +export async function agentFix( + pkgEnvDetails: EnvDetails, + actualTree: NodeClass | undefined, + alertsMap: AlertsByPurl, + installer: Installer, + { + beforeInstall = noopHandler, + // eslint-disable-next-line sort-destructure-keys/sort-destructure-keys + afterInstall = noopHandler, + revertInstall = noopHandler, + }: { + beforeInstall?: InstallPhaseHandler | undefined + afterInstall?: InstallPhaseHandler | undefined + revertInstall?: InstallPhaseHandler | undefined + }, + ciEnv: CiEnv | null, + openPrs: PrMatch[], + options: FixOptions, +): Promise<CResult<{ fixed: boolean }>> { + const { autoMerge, cwd, limit, rangeStyle, test, testScript } = options + const { spinner } = constants + const { pkgPath: rootPath } = pkgEnvDetails + + let count = 0 + + const infoByPartialPurl = getCveInfoFromAlertsMap(alertsMap, { + limit: Math.max(limit, openPrs.length), + }) + if (!infoByPartialPurl) { + spinner?.stop() + logger.info('No fixable vulns found.') + return { ok: true, data: { fixed: false } } + } + + if (isDebug()) { + debugFn('found: cves for', Array.from(infoByPartialPurl.keys())) + } + + // Lazily access constants.packumentCache. + const { packumentCache } = constants + + const workspacePkgJsonPaths = await globWorkspace( + pkgEnvDetails.agent, + rootPath, + ) + const pkgJsonPaths = [ + ...workspacePkgJsonPaths, + // Process the workspace root last since it will add an override to package.json. + pkgEnvDetails.editablePkgJson.filename!, + ] + const sortedInfoEntries = Array.from(infoByPartialPurl.entries()).sort( + (a, b) => naturalCompare(a[0], b[0]), + ) + + const cleanupInfoEntriesLoop = () => { + logger.dedent() + spinner?.dedent() + packumentCache.clear() + } + + const handleInstallFail = (): CResult<{ fixed: boolean }> => { + cleanupInfoEntriesLoop() + return { + ok: false, + message: 'Install failed', + cause: `Unexpected condition: ${pkgEnvDetails.agent} install failed`, + } + } + + spinner?.stop() + + infoEntriesLoop: for ( + let i = 0, { length } = sortedInfoEntries; + i < length; + i += 1 + ) { + const isLastInfoEntry = i === length - 1 + const infoEntry = sortedInfoEntries[i]! + const partialPurlObj = getPurlObject(infoEntry[0]) + const name = resolvePackageName(partialPurlObj) + + const infos = Array.from(infoEntry[1].values()) + if (!infos.length) { + continue infoEntriesLoop + } + + logger.log(`Processing vulns for ${name}:`) + logger.indent() + spinner?.indent() + + if (getManifestData(partialPurlObj.type, name)) { + debugFn(`found: Socket Optimize variant for ${name}`) + } + // eslint-disable-next-line no-await-in-loop + const packument = await fetchPackagePackument(name) + if (!packument) { + logger.warn(`Unexpected condition: No packument found for ${name}.\n`) + cleanupInfoEntriesLoop() + continue infoEntriesLoop + } + + const activeBranches = getActiveBranchesForPackage( + ciEnv, + infoEntry[0], + openPrs, + ) + const availableVersions = Object.keys(packument.versions) + const warningsForAfter = new Set<string>() + + // eslint-disable-next-line no-unused-labels + pkgJsonPathsLoop: for ( + let j = 0, { length: length_j } = pkgJsonPaths; + j < length_j; + j += 1 + ) { + const isLastPkgJsonPath = j === length_j - 1 + const pkgJsonPath = pkgJsonPaths[j]! + const pkgPath = path.dirname(pkgJsonPath) + const isWorkspaceRoot = + pkgJsonPath === pkgEnvDetails.editablePkgJson.filename + const workspace = isWorkspaceRoot + ? 'root' + : path.relative(rootPath, pkgPath) + const branchWorkspace = ciEnv + ? getSocketBranchWorkspaceComponent(workspace) + : '' + + // actualTree may not be defined on the first iteration of pkgJsonPathsLoop. + if (!actualTree) { + if (!ciEnv) { + // eslint-disable-next-line no-await-in-loop + await removeNodeModules(cwd) + } + const maybeActualTree = + ciEnv && existsSync(path.join(rootPath, 'node_modules')) + ? // eslint-disable-next-line no-await-in-loop + await getActualTree(cwd) + : // eslint-disable-next-line no-await-in-loop + await installer(pkgEnvDetails, { cwd, spinner }) + const maybeLockSrc = maybeActualTree + ? // eslint-disable-next-line no-await-in-loop + await readLockfile(pkgEnvDetails.lockPath) + : null + if (maybeActualTree && maybeLockSrc) { + actualTree = maybeActualTree + } + } + if (!actualTree) { + // Exit early if install fails. + return handleInstallFail() + } + + const oldVersions = arrayUnique( + findPackageNodes(actualTree, name) + .map(n => n.version) + .filter(Boolean), + ) + + if (!oldVersions.length) { + debugFn(`skip: ${name} not found\n`) + // Skip to next package. + cleanupInfoEntriesLoop() + continue infoEntriesLoop + } + + // Always re-read the editable package.json to avoid stale mutations + // across iterations. + // eslint-disable-next-line no-await-in-loop + const editablePkgJson = await readPackageJson(pkgJsonPath, { + editable: true, + }) + const seenVersions = new Set<string>() + + let hasAnnouncedWorkspace = false + let workspaceLogCallCount = logger.logCallCount + if (isDebug()) { + debugFn(`check: workspace ${workspace}`) + hasAnnouncedWorkspace = true + workspaceLogCallCount = logger.logCallCount + } + + oldVersionsLoop: for (const oldVersion of oldVersions) { + const oldId = `${name}@${oldVersion}` + const oldPurl = idToPurl(oldId, partialPurlObj.type) + + const node = findPackageNode(actualTree, name, oldVersion) + if (!node) { + debugFn(`skip: ${oldId} not found`) + continue oldVersionsLoop + } + infosLoop: for (const { + firstPatchedVersionIdentifier, + vulnerableVersionRange, + } of infos) { + const newVersion = findBestPatchVersion( + node, + availableVersions, + vulnerableVersionRange, + ) + const newVersionPackument = newVersion + ? packument.versions[newVersion] + : undefined + + if (!(newVersion && newVersionPackument)) { + warningsForAfter.add( + `${oldId} not updated: requires >=${firstPatchedVersionIdentifier}`, + ) + continue infosLoop + } + if (seenVersions.has(newVersion)) { + continue infosLoop + } + if (semver.gte(oldVersion, newVersion)) { + debugFn(`skip: ${oldId} is >= ${newVersion}`) + continue infosLoop + } + if ( + activeBranches.find( + b => + b.workspace === branchWorkspace && b.newVersion === newVersion, + ) + ) { + debugFn(`skip: open PR found for ${name}@${newVersion}`) + if (++count >= limit) { + cleanupInfoEntriesLoop() + break infoEntriesLoop + } + continue infosLoop + } + + const { overrides: oldOverrides } = getOverridesData( + pkgEnvDetails, + editablePkgJson.content, + ) + let refRange = oldOverrides?.[`${name}@${vulnerableVersionRange}`] + if (!isNonEmptyString(refRange)) { + refRange = oldOverrides?.[name] + } + if (!isNonEmptyString(refRange)) { + refRange = oldVersion + } + + // eslint-disable-next-line no-await-in-loop + await beforeInstall( + editablePkgJson, + name, + oldVersion, + newVersion, + vulnerableVersionRange, + options, + ) + updatePackageJsonFromNode( + editablePkgJson, + actualTree, + node, + newVersion, + rangeStyle, + ) + // eslint-disable-next-line no-await-in-loop + if (!(await editablePkgJson.save({ ignoreWhitespace: true }))) { + debugFn(`skip: ${workspace}/package.json unchanged`) + // Reset things just in case. + if (ciEnv) { + // eslint-disable-next-line no-await-in-loop + await gitResetAndClean(ciEnv.baseBranch, cwd) + } + continue infosLoop + } + + if (!hasAnnouncedWorkspace) { + hasAnnouncedWorkspace = true + workspaceLogCallCount = logger.logCallCount + } + + const newId = `${name}@${applyRange(refRange, newVersion, rangeStyle)}` + + spinner?.start() + spinner?.info(`Installing ${newId} in ${workspace}.`) + + let error + let errored = false + try { + // eslint-disable-next-line no-await-in-loop + const maybeActualTree = await installer(pkgEnvDetails, { + cwd, + spinner, + }) + const maybeLockSrc = maybeActualTree + ? // eslint-disable-next-line no-await-in-loop + await readLockfile(pkgEnvDetails.lockPath) + : null + if (maybeActualTree && maybeLockSrc) { + actualTree = maybeActualTree + // eslint-disable-next-line no-await-in-loop + await afterInstall( + editablePkgJson, + name, + oldVersion, + newVersion, + vulnerableVersionRange, + options, + ) + if (test) { + spinner?.info(`Testing ${newId} in ${workspace}.`) + // eslint-disable-next-line no-await-in-loop + await runScript(testScript, [], { spinner, stdio: 'ignore' }) + } + spinner?.success(`Fixed ${name} in ${workspace}.`) + seenVersions.add(newVersion) + } else { + errored = true + } + } catch (e) { + error = e + errored = true + } + + spinner?.stop() + + // Check repoInfo to make TypeScript happy. + if (!errored && ciEnv?.repoInfo) { + try { + // eslint-disable-next-line no-await-in-loop + const result = await gitUnstagedModifiedFiles(cwd) + if (!result.ok) { + logger.warn( + 'Unexpected condition: Nothing to commit, skipping PR creation.', + ) + continue + } + const moddedFilepaths = result.data.filter(filepath => { + const basename = path.basename(filepath) + return ( + basename === 'package.json' || + basename === pkgEnvDetails.lockName + ) + }) + if (!moddedFilepaths.length) { + logger.warn( + 'Unexpected condition: Nothing to commit, skipping PR creation.', + ) + continue infosLoop + } + + const branch = getSocketBranchName(oldPurl, newVersion, workspace) + let skipPr = false + if ( + // eslint-disable-next-line no-await-in-loop + await prExistForBranch( + ciEnv.repoInfo.owner, + ciEnv.repoInfo.repo, + branch, + ) + ) { + skipPr = true + debugFn(`skip: branch "${branch}" exists`) + } + // eslint-disable-next-line no-await-in-loop + else if (await gitRemoteBranchExists(branch, cwd)) { + skipPr = true + debugFn(`skip: remote branch "${branch}" exists`) + } else if ( + // eslint-disable-next-line no-await-in-loop + !(await gitCreateAndPushBranch( + branch, + getSocketCommitMessage(oldPurl, newVersion, workspace), + moddedFilepaths, + { + cwd, + email: ciEnv.gitEmail, + user: ciEnv.gitUser, + }, + )) + ) { + skipPr = true + logger.warn( + 'Unexpected condition: Push failed, skipping PR creation.', + ) + } + if (skipPr) { + // eslint-disable-next-line no-await-in-loop + await gitResetAndClean(ciEnv.baseBranch, cwd) + // eslint-disable-next-line no-await-in-loop + const maybeActualTree = await installer(pkgEnvDetails, { + cwd, + spinner, + }) + const maybeLockSrc = maybeActualTree + ? // eslint-disable-next-line no-await-in-loop + await readLockfile(pkgEnvDetails.lockPath) + : null + if (maybeActualTree && maybeLockSrc) { + actualTree = maybeActualTree + continue infosLoop + } + // Exit early if install fails. + return handleInstallFail() + } + + // eslint-disable-next-line no-await-in-loop + await Promise.allSettled([ + setGitRemoteGithubRepoUrl( + ciEnv.repoInfo.owner, + ciEnv.repoInfo.repo, + ciEnv.githubToken!, + cwd, + ), + cleanupOpenPrs(ciEnv.repoInfo.owner, ciEnv.repoInfo.repo, { + newVersion, + purl: oldPurl, + workspace, + }), + ]) + // eslint-disable-next-line no-await-in-loop + const prResponse = await openPr( + ciEnv.repoInfo.owner, + ciEnv.repoInfo.repo, + branch, + oldPurl, + newVersion, + { + baseBranch: ciEnv.baseBranch, + cwd, + workspace, + }, + ) + if (prResponse) { + const { data } = prResponse + const prRef = `PR #${data.number}` + logger.success(`Opened ${prRef}.`) + if (autoMerge) { + logger.indent() + spinner?.indent() + // eslint-disable-next-line no-await-in-loop + const { details, enabled } = await enablePrAutoMerge(data) + if (enabled) { + logger.info(`Auto-merge enabled for ${prRef}.`) + } else { + const message = `Failed to enable auto-merge for ${prRef}${ + details + ? `:\n${details.map(d => ` - ${d}`).join('\n')}` + : '.' + }` + logger.error(message) + } + logger.dedent() + spinner?.dedent() + } + } + } catch (e) { + error = e + errored = true + } + } + + if (ciEnv) { + spinner?.start() + // eslint-disable-next-line no-await-in-loop + await gitResetAndClean(ciEnv.baseBranch, cwd) + // eslint-disable-next-line no-await-in-loop + const maybeActualTree = await installer(pkgEnvDetails, { + cwd, + spinner, + }) + spinner?.stop() + if (maybeActualTree) { + actualTree = maybeActualTree + } else { + errored = true + } + } + if (errored) { + if (!ciEnv) { + spinner?.start() + // eslint-disable-next-line no-await-in-loop + await revertInstall( + editablePkgJson, + name, + oldVersion, + newVersion, + vulnerableVersionRange, + options, + ) + // eslint-disable-next-line no-await-in-loop + await Promise.all([ + removeNodeModules(cwd), + editablePkgJson.save({ ignoreWhitespace: true }), + ]) + // eslint-disable-next-line no-await-in-loop + const maybeActualTree = await installer(pkgEnvDetails, { + cwd, + spinner, + }) + spinner?.stop() + if (maybeActualTree) { + actualTree = maybeActualTree + } else { + // Exit early if install fails. + return handleInstallFail() + } + } + return { + ok: false, + message: 'Update failed', + cause: `Update failed for ${oldId} in ${workspace}${error ? '; ' + error : ''}`, + } + } + debugFn('name:', name) + debugFn('increment: count', count + 1) + if (++count >= limit) { + cleanupInfoEntriesLoop() + break infoEntriesLoop + } + } + } + if (!isLastPkgJsonPath && logger.logCallCount > workspaceLogCallCount) { + logger.logNewline() + } + } + + for (const warningText of warningsForAfter) { + logger.warn(warningText) + } + if (!isLastInfoEntry) { + logger.logNewline() + } + cleanupInfoEntriesLoop() + } + + spinner?.stop() + + // Or, did we change anything? + return { ok: true, data: { fixed: true } } +} diff --git a/src/commands/fix/cmd-fix.mts b/src/commands/fix/cmd-fix.mts new file mode 100644 index 000000000..635706fd9 --- /dev/null +++ b/src/commands/fix/cmd-fix.mts @@ -0,0 +1,182 @@ +import path from 'node:path' + +import terminalLink from 'terminal-link' + +import { joinOr } from '@socketsecurity/registry/lib/arrays' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleFix } from './handle-fix.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { RangeStyles } from '../../utils/semver.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' +import type { RangeStyle } from '../../utils/semver.mts' + +const { DRY_RUN_NOT_SAVING } = constants + +const config: CliCommandConfig = { + commandName: 'fix', + description: 'Update dependencies with "fixable" Socket alerts', + hidden: false, + flags: { + ...commonFlags, + autoMerge: { + type: 'boolean', + default: false, + description: `Enable auto-merge for pull requests that Socket opens.\n See ${terminalLink( + 'GitHub documentation', + 'https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository', + )} for managing auto-merge for pull requests in your repository.`, + }, + autopilot: { + type: 'boolean', + default: false, + description: `Shorthand for --autoMerge --test`, + }, + ghsa: { + type: 'string', + default: [], + description: `Provide a list of ${terminalLink( + 'GHSA IDs', + 'https://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories-from-the-github-advisory-database/about-the-github-advisory-database#about-ghsa-ids', + )} to compute fixes for, as either a comma separated value or as multiple flags.\n Use '--ghsa auto' to automatically lookup GHSA IDs and compute fixes for them.`, + isMultiple: true, + }, + limit: { + type: 'number', + default: Infinity, + description: 'The number of fixes to attempt at a time', + }, + purl: { + type: 'string', + default: [], + description: `Provide a list of ${terminalLink( + 'PURLs', + 'https://github.com/package-url/purl-spec?tab=readme-ov-file#purl', + )} to compute fixes for, as either a comma separated value or as multiple flags,\n instead of querying the Socket API`, + isMultiple: true, + shortFlag: 'p', + }, + rangeStyle: { + type: 'string', + default: 'preserve', + description: ` + Define how updated dependency versions should be written in package.json. + Available styles: + * caret - Use ^ range for compatible updates (e.g. ^1.2.3) + * gt - Use > to allow any newer version (e.g. >1.2.3) + * gte - Use >= to allow any newer version (e.g. >=1.2.3) + * lt - Use < to allow only lower versions (e.g. <1.2.3) + * lte - Use <= to allow only lower versions (e.g. <=1.2.3) + * pin - Use the exact version (e.g. 1.2.3) + * preserve - Retain the existing version range style as-is + * tilde - Use ~ range for patch/minor updates (e.g. ~1.2.3) + `.trim(), + }, + test: { + type: 'boolean', + default: false, + description: 'Verify the fix by running unit tests', + }, + testScript: { + type: 'string', + default: 'test', + description: 'The test script to run for each fix attempt', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} ./proj/tree --autoMerge + `, +} + +export const cmdFix = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { autopilot, json, markdown } = cli.flags as { + autopilot: boolean + json: boolean + markdown: boolean + } + const outputKind = getOutputKind(json, markdown) + + let rangeStyle = cli.flags['rangeStyle'] as RangeStyle + if (!rangeStyle) { + rangeStyle = 'preserve' + } + + const wasValidInput = checkCommandInput(outputKind, { + test: RangeStyles.includes(rangeStyle), + message: `Expecting range style of ${joinOr(RangeStyles)}`, + pass: 'ok', + fail: 'invalid', + }) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_NOT_SAVING) + return + } + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + let autoMerge = Boolean(cli.flags['autoMerge']) + let test = Boolean(cli.flags['test']) + if (autopilot) { + autoMerge = true + test = true + } + + const ghsas = cmdFlagValueToArray(cli.flags['ghsa']) + const limit = + (cli.flags['limit'] + ? parseInt(String(cli.flags['limit'] || ''), 10) + : Infinity) || Infinity + const purls = cmdFlagValueToArray(cli.flags['purl']) + const testScript = String(cli.flags['testScript'] || 'test') + + await handleFix(argv, { + autoMerge, + cwd, + ghsas, + limit, + outputKind, + purls, + rangeStyle, + test, + testScript, + }) +} diff --git a/src/commands/fix/cmd-fix.test.mts b/src/commands/fix/cmd-fix.test.mts new file mode 100644 index 000000000..19b2ec666 --- /dev/null +++ b/src/commands/fix/cmd-fix.test.mts @@ -0,0 +1,78 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket fix', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['fix', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Update dependencies with "fixable" Socket alerts + + Usage + $ socket fix [options] [CWD=.] + + Options + --autoMerge Enable auto-merge for pull requests that Socket opens. + See GitHub documentation (\\u200bhttps://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository\\u200b) for managing auto-merge for pull requests in your repository. + --autopilot Shorthand for --autoMerge --test + --ghsa Provide a list of GHSA IDs (\\u200bhttps://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories-from-the-github-advisory-database/about-the-github-advisory-database#about-ghsa-ids\\u200b) to compute fixes for, as either a comma separated value or as multiple flags. + Use '--ghsa auto' to automatically lookup GHSA IDs and compute fixes for them. + --limit The number of fixes to attempt at a time + --purl Provide a list of PURLs (\\u200bhttps://github.com/package-url/purl-spec?tab=readme-ov-file#purl\\u200b) to compute fixes for, as either a comma separated value or as multiple flags, + instead of querying the Socket API + --rangeStyle Define how updated dependency versions should be written in package.json. + Available styles: + * caret - Use ^ range for compatible updates (e.g. ^1.2.3) + * gt - Use > to allow any newer version (e.g. >1.2.3) + * gte - Use >= to allow any newer version (e.g. >=1.2.3) + * lt - Use < to allow only lower versions (e.g. <1.2.3) + * lte - Use <= to allow only lower versions (e.g. <=1.2.3) + * pin - Use the exact version (e.g. 1.2.3) + * preserve - Retain the existing version range style as-is + * tilde - Use ~ range for patch/minor updates (e.g. ~1.2.3) + --test Verify the fix by running unit tests + --testScript The test script to run for each fix attempt + + Examples + $ socket fix + $ socket fix ./proj/tree --autoMerge" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket fix\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket fix`') + }, + ) + + cmdit( + ['fix', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket fix\`, cwd: <redacted>" + `) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/fix/fix-branch-helpers.mts b/src/commands/fix/fix-branch-helpers.mts new file mode 100644 index 000000000..2179136d5 --- /dev/null +++ b/src/commands/fix/fix-branch-helpers.mts @@ -0,0 +1,51 @@ +import { debugFn, isDebug } from '@socketsecurity/registry/lib/debug' +import { resolvePackageName } from '@socketsecurity/registry/lib/packages' + +import { + getSocketBranchFullNameComponent, + getSocketBranchPurlTypeComponent, +} from './git.mts' +import { getPurlObject } from '../../utils/purl.mts' + +import type { CiEnv } from './fix-env-helpers.mts' +import type { SocketBranchParseResult } from './git.mts' +import type { PrMatch } from './open-pr.mts' + +export function getActiveBranchesForPackage( + ciEnv: CiEnv | null | undefined, + partialPurl: string, + openPrs: PrMatch[], +): SocketBranchParseResult[] { + if (!ciEnv) { + return [] + } + + const activeBranches: SocketBranchParseResult[] = [] + const partialPurlObj = getPurlObject(partialPurl) + const branchFullName = getSocketBranchFullNameComponent(partialPurlObj) + const branchPurlType = getSocketBranchPurlTypeComponent(partialPurlObj) + + for (const pr of openPrs) { + const parsedBranch = ciEnv.branchParser(pr.headRefName) + if ( + branchPurlType === parsedBranch?.type && + branchFullName === parsedBranch?.fullName + ) { + activeBranches.push(parsedBranch) + } + } + + if (isDebug()) { + const fullName = resolvePackageName(partialPurlObj) + if (activeBranches.length) { + debugFn( + `found: ${activeBranches.length} active branches for ${fullName}\n`, + activeBranches, + ) + } else if (openPrs.length) { + debugFn(`miss: 0 active branches found for ${fullName}`) + } + } + + return activeBranches +} diff --git a/src/commands/fix/fix-env-helpers.mts b/src/commands/fix/fix-env-helpers.mts new file mode 100644 index 000000000..ca712b637 --- /dev/null +++ b/src/commands/fix/fix-env-helpers.mts @@ -0,0 +1,76 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import { + createSocketBranchParser, + getBaseGitBranch, + gitRepoInfo, +} from './git.mts' +import { getOpenSocketPrs } from './open-pr.mts' +import constants from '../../constants.mts' + +import type { RepoInfo, SocketBranchParser } from './git.mts' +import type { PrMatch } from './open-pr.mts' + +async function getEnvRepoInfo( + cwd?: string | undefined, +): Promise<RepoInfo | null> { + // Lazily access constants.ENV.GITHUB_REPOSITORY. + const { GITHUB_REPOSITORY } = constants.ENV + if (!GITHUB_REPOSITORY) { + debugFn('miss: GITHUB_REPOSITORY env var') + } + const ownerSlashRepo = GITHUB_REPOSITORY + const slashIndex = ownerSlashRepo.indexOf('/') + if (slashIndex !== -1) { + return { + owner: ownerSlashRepo.slice(0, slashIndex), + repo: ownerSlashRepo.slice(slashIndex + 1), + } + } + return await gitRepoInfo(cwd) +} + +export interface CiEnv { + gitEmail: string + gitUser: string + githubToken: string + repoInfo: RepoInfo + baseBranch: string + branchParser: SocketBranchParser +} + +export async function getCiEnv(): Promise<CiEnv | null> { + const gitEmail = constants.ENV.SOCKET_CLI_GIT_USER_EMAIL + const gitUser = constants.ENV.SOCKET_CLI_GIT_USER_NAME + const githubToken = constants.ENV.SOCKET_CLI_GITHUB_TOKEN + const isCi = !!(constants.ENV.CI && gitEmail && gitUser && githubToken) + if (!isCi) { + return null + } + const baseBranch = await getBaseGitBranch() + if (!baseBranch) { + return null + } + const repoInfo = await getEnvRepoInfo() + if (!repoInfo) { + return null + } + return { + gitEmail, + gitUser, + githubToken, + repoInfo, + baseBranch, + branchParser: createSocketBranchParser(), + } +} + +export async function getOpenPrsForEnvironment( + env: CiEnv | null | undefined, +): Promise<PrMatch[]> { + return env + ? await getOpenSocketPrs(env.repoInfo.owner, env.repoInfo.repo, { + author: env.gitUser, + }) + : [] +} diff --git a/src/commands/fix/get-actual-tree.mts b/src/commands/fix/get-actual-tree.mts new file mode 100644 index 000000000..ee7d3ebd4 --- /dev/null +++ b/src/commands/fix/get-actual-tree.mts @@ -0,0 +1,20 @@ +import { + Arborist, + SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, +} from '../../shadow/npm/arborist/index.mts' + +import type { NodeClass } from '../../shadow/npm/arborist/types.mts' + +export async function getActualTree( + cwd: string = process.cwd(), +): Promise<NodeClass> { + // @npmcli/arborist DOES have partial support for pnpm structured node_modules + // folders. However, support is iffy resulting in unhappy path errors and hangs. + // So, to avoid the unhappy path, we restrict our usage to --dry-run loading + // of the node_modules folder. + const arb = new Arborist({ + path: cwd, + ...SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, + }) + return await arb.loadActual() +} diff --git a/src/commands/fix/git.mts b/src/commands/fix/git.mts new file mode 100644 index 000000000..277afbf91 --- /dev/null +++ b/src/commands/fix/git.mts @@ -0,0 +1,381 @@ +import semver from 'semver' + +import { PackageURL } from '@socketregistry/packageurl-js' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { normalizePath } from '@socketsecurity/registry/lib/path' +import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { getPurlObject } from '../../utils/purl.mts' +import { + getPkgFullNameFromPurl, + getSocketDevPackageOverviewUrlFromPurl, +} from '../../utils/socket-url.mts' + +import type { CResult } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' +import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' + +export type GitCreateAndPushBranchOptions = { + cwd?: string | undefined + email?: string | undefined + user?: string | undefined +} + +function formatBranchName(name: string): string { + return name.replace(/[^-a-zA-Z0-9/._-]+/g, '+') +} + +export type SocketBranchParser = ( + branch: string, +) => SocketBranchParseResult | null + +export type SocketBranchParseResult = { + fullName: string + newVersion: string + type: string + workspace: string + version: string +} + +export type SocketBranchPatternOptions = { + newVersion?: string | undefined + purl?: string | undefined + workspace?: string | undefined +} + +export function createSocketBranchParser( + options?: SocketBranchPatternOptions | undefined, +): SocketBranchParser { + const pattern = getSocketBranchPattern(options) + return function parse(branch: string): SocketBranchParseResult | null { + const match = pattern.exec(branch) as + | [string, string, string, string, string, string] + | null + if (!match) { + return null + } + const { + 1: type, + 2: workspace, + 3: fullName, + 4: version, + 5: newVersion, + } = match + return { + fullName, + newVersion: semver.coerce(newVersion.replaceAll('+', '.'))?.version, + type, + workspace, + version: semver.coerce(version.replaceAll('+', '.'))?.version, + } as SocketBranchParseResult + } +} + +export async function getBaseGitBranch(cwd = process.cwd()): Promise<string> { + // Lazily access constants.ENV properties. + const { GITHUB_BASE_REF, GITHUB_REF_NAME, GITHUB_REF_TYPE } = constants.ENV + // 1. In a pull request, this is always the base branch. + if (GITHUB_BASE_REF) { + return GITHUB_BASE_REF + } + // 2. If it's a branch (not a tag), GITHUB_REF_TYPE should be 'branch'. + if (GITHUB_REF_TYPE === 'branch' && GITHUB_REF_NAME) { + return GITHUB_REF_NAME + } + // 3. Try to resolve the default remote branch using 'git remote show origin'. + // This handles detached HEADs or workflows triggered by tags/releases. + try { + const stdout = ( + await spawn('git', ['remote', 'show', 'origin'], { cwd }) + ).stdout.trim() + const match = /(?<=HEAD branch: ).+/.exec(stdout) + if (match?.[0]) { + return match[0].trim() + } + } catch {} + // GitHub defaults to branch name "main" + // https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches#about-the-default-branch + return 'main' +} + +export function getSocketBranchFullNameComponent( + pkgName: string | PackageURL | SocketArtifact, +): string { + const purlObj = getPurlObject( + typeof pkgName === 'string' && !pkgName.startsWith('pkg:') + ? PackageURL.fromString(`pkg:unknown/${pkgName}`) + : pkgName, + ) + const fmtMaybeNamespace = purlObj.namespace + ? `${formatBranchName(purlObj.namespace)}--` + : '' + return `${fmtMaybeNamespace}${formatBranchName(purlObj.name)}` +} + +export function getSocketBranchName( + purl: string | PackageURL | SocketArtifact, + newVersion: string, + workspace?: string | undefined, +): string { + const purlObj = getPurlObject(purl) + const fmtType = getSocketBranchPurlTypeComponent(purlObj) + const fmtWorkspace = getSocketBranchWorkspaceComponent(workspace) + const fmtFullName = getSocketBranchFullNameComponent(purlObj) + const fmtVersion = getSocketBranchPackageVersionComponent(purlObj.version!) + const fmtNewVersion = formatBranchName(newVersion) + return `socket/${fmtType}/${fmtWorkspace}/${fmtFullName}_${fmtVersion}_${fmtNewVersion}` +} + +export function getSocketBranchPackageVersionComponent( + version: string | PackageURL | SocketArtifact, +): string { + const purlObj = getPurlObject( + typeof version === 'string' && !version.startsWith('pkg:') + ? PackageURL.fromString(`pkg:unknown/unknown@${version}`) + : version, + ) + return formatBranchName(purlObj.version!) +} + +export function getSocketBranchPattern( + options?: SocketBranchPatternOptions | undefined, +): RegExp { + const { newVersion, purl, workspace } = { + __proto__: null, + ...options, + } as SocketBranchPatternOptions + const purlObj = purl ? getPurlObject(purl) : null + const escType = purlObj ? escapeRegExp(purlObj.type) : '[^/]+' + const escWorkspace = workspace + ? `${escapeRegExp(formatBranchName(workspace))}` + : '.+' + const escMaybeNamespace = purlObj?.namespace + ? `${escapeRegExp(formatBranchName(purlObj.namespace))}--` + : '' + const escFullName = purlObj + ? `${escMaybeNamespace}${escapeRegExp(formatBranchName(purlObj.name))}` + : '[^/_]+' + const escVersion = purlObj + ? escapeRegExp(formatBranchName(purlObj.version!)) + : '[^_]+' + const escNewVersion = newVersion + ? escapeRegExp(formatBranchName(newVersion)) + : '[^_]+' + return new RegExp( + `^socket/(${escType})/(${escWorkspace})/(${escFullName})_(${escVersion})_(${escNewVersion})$`, + ) +} + +export function getSocketBranchPurlTypeComponent( + purl: string | PackageURL | SocketArtifact, +): string { + const purlObj = getPurlObject(purl) + return formatBranchName(purlObj.type) +} + +export function getSocketBranchWorkspaceComponent( + workspace: string | undefined, +): string { + return workspace ? formatBranchName(workspace) : 'root' +} + +export function getSocketCommitMessage( + purl: string | PackageURL | SocketArtifact, + newVersion: string, + workspace?: string | undefined, +): string { + const purlObj = getPurlObject(purl) + const fullName = getPkgFullNameFromPurl(purlObj) + return `socket: Bump ${fullName} from ${purlObj.version} to ${newVersion}${workspace ? ` in ${workspace}` : ''}` +} + +export function getSocketPullRequestBody( + purl: string | PackageURL | SocketArtifact, + newVersion: string, + workspace?: string | undefined, +): string { + const purlObj = getPurlObject(purl) + const fullName = getPkgFullNameFromPurl(purlObj) + const pkgOverviewUrl = getSocketDevPackageOverviewUrlFromPurl(purlObj) + return `Bump [${fullName}](${pkgOverviewUrl}) from ${purlObj.version} to ${newVersion}${workspace ? ` in ${workspace}` : ''}.` +} + +export function getSocketPullRequestTitle( + purl: string | PackageURL | SocketArtifact, + newVersion: string, + workspace?: string | undefined, +): string { + const purlObj = getPurlObject(purl) + const fullName = getPkgFullNameFromPurl(purlObj) + return `Bump ${fullName} from ${purlObj.version} to ${newVersion}${workspace ? ` in ${workspace}` : ''}` +} + +export async function gitCleanFdx(cwd = process.cwd()): Promise<void> { + const stdioIgnoreOptions: SpawnOptions = { cwd, stdio: 'ignore' } + // TODO: propagate CResult? + await spawn('git', ['clean', '-fdx'], stdioIgnoreOptions) +} + +export async function gitCreateAndPushBranch( + branch: string, + commitMsg: string, + filepaths: string[], + options?: GitCreateAndPushBranchOptions | undefined, +): Promise<boolean> { + const { + cwd = process.cwd(), + // Lazily access constants.ENV.SOCKET_CLI_GIT_USER_EMAIL. + email = constants.ENV.SOCKET_CLI_GIT_USER_EMAIL, + // Lazily access constants.ENV.SOCKET_CLI_GIT_USER_NAME. + user = constants.ENV.SOCKET_CLI_GIT_USER_NAME, + } = { __proto__: null, ...options } as GitCreateAndPushBranchOptions + const stdioIgnoreOptions: SpawnOptions = { cwd, stdio: 'ignore' } + try { + await gitEnsureIdentity(user, email, cwd) + await spawn('git', ['checkout', '-b', branch], stdioIgnoreOptions) + await spawn('git', ['add', ...filepaths], stdioIgnoreOptions) + await spawn('git', ['commit', '-m', commitMsg], stdioIgnoreOptions) + await spawn( + 'git', + ['push', '--force', '--set-upstream', 'origin', branch], + stdioIgnoreOptions, + ) + return true + } catch (e) { + debugFn( + `catch: git push --force --set-upstream origin ${branch} failed\n`, + e, + ) + } + try { + // Will throw with exit code 1 if branch does not exist. + await spawn('git', ['branch', '-D', branch], stdioIgnoreOptions) + } catch {} + return false +} + +export type RepoInfo = { + owner: string + repo: string +} + +export async function gitRepoInfo( + cwd = process.cwd(), +): Promise<RepoInfo | null> { + try { + const remoteUrl = ( + await spawn('git', ['remote', 'get-url', 'origin'], { cwd }) + ).stdout.trim() + // 1. Handle SSH-style, e.g. git@github.com:owner/repo.git + const sshMatch = /^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/.exec(remoteUrl) + if (sshMatch) { + return { owner: sshMatch[1]!, repo: sshMatch[2]! } + } + // 2. Handle HTTPS/URL-style, e.g. https://github.com/owner/repo.git + try { + const parsed = new URL(remoteUrl) + const segments = parsed.pathname.split('/') + const owner = segments.at(-2) + const repo = segments.at(-1)?.replace(/\.git$/, '') + if (owner && repo) { + return { owner, repo } + } + } catch {} + debugFn('git: unmatched git remote URL format', remoteUrl) + } catch (e) { + debugFn('catch: git remote get-url origin failed\n', e) + } + return null +} + +export async function gitEnsureIdentity( + name: string, + email: string, + cwd = process.cwd(), +): Promise<void> { + const stdioIgnoreOptions: SpawnOptions = { cwd, stdio: 'ignore' } + const stdioPipeOptions: SpawnOptions = { cwd } + const identEntries: Array<[string, string]> = [ + ['user.email', name], + ['user.name', email], + ] + await Promise.all( + identEntries.map(async ({ 0: prop, 1: value }) => { + let configValue + try { + // Will throw with exit code 1 if the config property is not set. + configValue = ( + await spawn('git', ['config', '--get', prop], stdioPipeOptions) + ).stdout.trim() + } catch {} + if (configValue !== value) { + try { + await spawn('git', ['config', prop, value], stdioIgnoreOptions) + } catch (e) { + debugFn(`catch: git config ${prop} ${value} failed\n`, e) + } + } + }), + ) +} + +export async function gitRemoteBranchExists( + branch: string, + cwd = process.cwd(), +): Promise<boolean> { + const stdioPipeOptions: SpawnOptions = { cwd } + try { + return ( + ( + await spawn( + 'git', + ['ls-remote', '--heads', 'origin', branch], + stdioPipeOptions, + ) + ).stdout.trim().length > 0 + ) + } catch { + return false + } +} + +export async function gitResetAndClean( + branch = 'HEAD', + cwd = process.cwd(), +): Promise<void> { + // Discards tracked changes. + await gitResetHard(branch, cwd) + // Deletes all untracked files and directories. + await gitCleanFdx(cwd) +} + +export async function gitResetHard( + branch = 'HEAD', + cwd = process.cwd(), +): Promise<void> { + const stdioIgnoreOptions: SpawnOptions = { cwd, stdio: 'ignore' } + await spawn('git', ['reset', '--hard', branch], stdioIgnoreOptions) +} + +export async function gitUnstagedModifiedFiles( + cwd = process.cwd(), +): Promise<CResult<string[]>> { + try { + const stdioPipeOptions: SpawnOptions = { cwd } + const stdout = ( + await spawn('git', ['diff', '--name-only'], stdioPipeOptions) + ).stdout.trim() + const rawFiles = stdout.split('\n') ?? [] + return { ok: true, data: rawFiles.map(relPath => normalizePath(relPath)) } + } catch (e) { + debugFn('catch: git diff --name-only failed\n', e) + + return { + ok: false, + message: 'Git Error', + cause: 'Unexpected error while trying to ask git whether repo is dirty', + } + } +} diff --git a/src/commands/fix/handle-fix.mts b/src/commands/fix/handle-fix.mts new file mode 100644 index 000000000..2dba70a1e --- /dev/null +++ b/src/commands/fix/handle-fix.mts @@ -0,0 +1,158 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import { npmFix } from './npm-fix.mts' +import { outputFixResult } from './output-fix-result.mts' +import { pnpmFix } from './pnpm-fix.mts' +import { CMD_NAME } from './shared.mts' +import constants from '../../constants.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { spawnCoana } from '../../utils/coana.mts' +import { detectAndValidatePackageEnvironment } from '../../utils/package-environment.mts' + +import type { FixOptions } from './agent-fix.mts' +import type { OutputKind } from '../../types.mts' +import type { Remap } from '@socketsecurity/registry/lib/objects' + +const { NPM, PNPM } = constants + +export type HandleFixOptions = Remap< + FixOptions & { + ghsas: string[] + outputKind: OutputKind + } +> + +export async function handleFix( + argv: string[] | readonly string[], + { + autoMerge, + cwd, + ghsas, + limit, + outputKind, + purls, + rangeStyle, + test, + testScript, + }: HandleFixOptions, +) { + let { length: ghsasCount } = ghsas + if (ghsasCount) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Fetching GHSA IDs...') + + if (ghsasCount === 1 && ghsas[0] === 'auto') { + const autoCResult = await spawnCoana( + ['compute-fixes-and-upgrade-purls', cwd], + { cwd, spinner }, + ) + if (autoCResult.ok) { + ghsas = cmdFlagValueToArray( + /(?<=Vulnerabilities found: )[^\n]+/.exec( + autoCResult.data as string, + )?.[0], + ) + ghsasCount = ghsas.length + } else { + debugFn('coana fail:', { + message: autoCResult.message, + cause: autoCResult.cause, + }) + ghsas = [] + ghsasCount = 0 + } + } + + if (ghsasCount) { + spinner.info(`Found ${ghsasCount} GHSA ${pluralize('ID', ghsasCount)}.`) + + await outputFixResult( + await spawnCoana( + [ + 'compute-fixes-and-upgrade-purls', + cwd, + '--apply-fixes-to', + ...ghsas, + ...argv, + ], + { cwd, spinner }, + ), + outputKind, + ) + spinner.stop() + return + } + + spinner.infoAndStop('No GHSA IDs found.') + + await outputFixResult( + { + ok: true, + data: '', + }, + outputKind, + ) + return + } + + const pkgEnvCResult = await detectAndValidatePackageEnvironment(cwd, { + cmdName: CMD_NAME, + logger, + }) + if (!pkgEnvCResult.ok) { + await outputFixResult(pkgEnvCResult, outputKind) + return + } + + const { data: pkgEnvDetails } = pkgEnvCResult + if (!pkgEnvDetails) { + await outputFixResult( + { + ok: false, + message: 'No package found.', + cause: `No valid package environment found for project path: ${cwd}`, + }, + outputKind, + ) + return + } + + logger.info( + `Fixing packages for ${pkgEnvDetails.agent} v${pkgEnvDetails.agentVersion}.\n`, + ) + + const { agent } = pkgEnvDetails + if (agent !== NPM && agent !== PNPM) { + await outputFixResult( + { + ok: false, + message: 'Not supported.', + cause: `${agent} is not supported by this command at the moment.`, + }, + outputKind, + ) + return + } + + // Lazily access spinner. + const { spinner } = constants + const fixer = agent === NPM ? npmFix : pnpmFix + + await outputFixResult( + await fixer(pkgEnvDetails, { + autoMerge, + cwd, + limit, + purls, + rangeStyle, + spinner, + test, + testScript, + }), + outputKind, + ) +} diff --git a/src/commands/fix/npm-fix.mts b/src/commands/fix/npm-fix.mts new file mode 100644 index 000000000..a9b40967c --- /dev/null +++ b/src/commands/fix/npm-fix.mts @@ -0,0 +1,155 @@ +import { realpathSync } from 'node:fs' +import path from 'node:path' + +import NpmConfig from '@npmcli/config' +import { + definitions as npmConfigDefinitions, + flatten as npmConfigFlatten, + shorthands as npmConfigShorthands, + // @ts-ignore +} from '@npmcli/config/lib/definitions' + +import { debugFn, isDebug } from '@socketsecurity/registry/lib/debug' + +import { agentFix } from './agent-fix.mts' +import { getCiEnv, getOpenPrsForEnvironment } from './fix-env-helpers.mts' +import { getActualTree } from './get-actual-tree.mts' +import { getAlertsMapOptions } from './shared.mts' +import constants from '../../constants.mts' +import { + Arborist, + SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, +} from '../../shadow/npm/arborist/index.mts' +import { getAlertsMapFromArborist } from '../../shadow/npm/arborist-helpers.mts' +import { runAgentInstall } from '../../utils/agent.mts' +import { getAlertsMapFromPurls } from '../../utils/alerts-map.mts' + +import type { FixOptions, InstallOptions } from './agent-fix.mts' +import type { + ArboristOptions, + NodeClass, +} from '../../shadow/npm/arborist/types.mts' +import type { CResult } from '../../types.mts' +import type { EnvDetails } from '../../utils/package-environment.mts' +import type { PackageJson } from '@socketsecurity/registry/lib/packages' + +async function install( + pkgEnvDetails: EnvDetails, + options: InstallOptions, +): Promise<NodeClass | null> { + const { args, cwd, spinner } = { + __proto__: null, + ...options, + } as InstallOptions + try { + await runAgentInstall(pkgEnvDetails, { + args, + spinner, + stdio: isDebug() ? 'inherit' : 'ignore', + }) + return await getActualTree(cwd) + } catch {} + return null +} + +export async function npmFix( + pkgEnvDetails: EnvDetails, + options: FixOptions, +): Promise<CResult<{ fixed: boolean }>> { + const { limit, purls, spinner } = options + + spinner?.start() + + const ciEnv = await getCiEnv() + const openPrs = ciEnv ? await getOpenPrsForEnvironment(ciEnv) : [] + + let actualTree: NodeClass | undefined + let alertsMap + try { + if (purls.length) { + alertsMap = await getAlertsMapFromPurls( + purls, + getAlertsMapOptions({ limit: Math.max(limit, openPrs.length) }), + ) + } else { + const npmPath = path.resolve( + realpathSync(pkgEnvDetails.agentExecPath), + '../..', + ) + const config = new NpmConfig({ + argv: [], + cwd: process.cwd(), + definitions: npmConfigDefinitions, + // Lazily access constants.execPath. + execPath: constants.execPath, + env: process.env, + flatten: npmConfigFlatten, + npmPath, + platform: process.platform, + shorthands: npmConfigShorthands, + }) + await config.load() + + const flatConfig = { __proto__: null, ...config.flat } as ArboristOptions + flatConfig.nodeVersion = constants.NODE_VERSION + flatConfig.npmVersion = pkgEnvDetails.agentVersion.toString() + flatConfig.npmCommand = 'install' + debugFn('npm config:', flatConfig) + + const arb = new Arborist({ + path: pkgEnvDetails.pkgPath, + ...flatConfig, + ...SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, + }) + actualTree = await arb.reify() + // Calling arb.reify() creates the arb.diff object, nulls-out arb.idealTree, + // and populates arb.actualTree. + alertsMap = await getAlertsMapFromArborist( + arb, + getAlertsMapOptions({ limit: Math.max(limit, openPrs.length) }), + ) + } + } catch (e) { + spinner?.stop() + debugFn('catch: PURL API\n', e) + return { + ok: false, + message: 'API Error', + cause: (e as Error)?.message || 'Unknown Socket batch PURL API error.', + } + } + + let revertData: PackageJson | undefined + + return await agentFix( + pkgEnvDetails, + actualTree, + alertsMap, + install, + { + async beforeInstall(editablePkgJson) { + revertData = { + ...(editablePkgJson.content.dependencies && { + dependencies: { ...editablePkgJson.content.dependencies }, + }), + ...(editablePkgJson.content.optionalDependencies && { + optionalDependencies: { + ...editablePkgJson.content.optionalDependencies, + }, + }), + ...(editablePkgJson.content.peerDependencies && { + peerDependencies: { ...editablePkgJson.content.peerDependencies }, + }), + } as PackageJson + }, + async revertInstall(editablePkgJson) { + if (revertData) { + editablePkgJson.update(revertData) + } + }, + }, + ciEnv, + openPrs, + options, + ) +} diff --git a/src/commands/fix/open-pr.mts b/src/commands/fix/open-pr.mts new file mode 100644 index 000000000..4fc4d3a05 --- /dev/null +++ b/src/commands/fix/open-pr.mts @@ -0,0 +1,516 @@ +import { existsSync, promises as fs, statSync } from 'node:fs' +import path from 'node:path' + +import { + GraphqlResponseError, + graphql as OctokitGraphql, +} from '@octokit/graphql' +import { RequestError } from '@octokit/request-error' +import { Octokit } from '@octokit/rest' +import semver from 'semver' + +import { PackageURL } from '@socketregistry/packageurl-js' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { readJson, writeJson } from '@socketsecurity/registry/lib/fs' +import { spawn } from '@socketsecurity/registry/lib/spawn' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import { + createSocketBranchParser, + getSocketBranchPattern, + getSocketPullRequestBody, + getSocketPullRequestTitle, +} from './git.mts' +import constants from '../../constants.mts' +import { getPurlObject } from '../../utils/purl.mts' + +import type { SocketArtifact } from '../../utils/alert/artifact.mts' +import type { components } from '@octokit/openapi-types' +import type { OctokitResponse } from '@octokit/types' +import type { JsonContent } from '@socketsecurity/registry/lib/fs' +import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' + +let _octokit: Octokit | undefined +function getOctokit() { + if (_octokit === undefined) { + // Lazily access constants.ENV.SOCKET_CLI_GITHUB_TOKEN. + const { SOCKET_CLI_GITHUB_TOKEN } = constants.ENV + if (!SOCKET_CLI_GITHUB_TOKEN) { + debugFn('miss: SOCKET_CLI_GITHUB_TOKEN env var') + } + _octokit = new Octokit({ + auth: SOCKET_CLI_GITHUB_TOKEN, + }) + } + return _octokit +} + +let _octokitGraphql: typeof OctokitGraphql | undefined +export function getOctokitGraphql(): typeof OctokitGraphql { + if (!_octokitGraphql) { + // Lazily access constants.ENV.SOCKET_CLI_GITHUB_TOKEN. + const { SOCKET_CLI_GITHUB_TOKEN } = constants.ENV + if (!SOCKET_CLI_GITHUB_TOKEN) { + debugFn('miss: SOCKET_CLI_GITHUB_TOKEN env var') + } + _octokitGraphql = OctokitGraphql.defaults({ + headers: { + authorization: `token ${SOCKET_CLI_GITHUB_TOKEN}`, + }, + }) + } + return _octokitGraphql +} + +export async function cacheFetch<T>( + key: string, + fetcher: () => Promise<T>, + ttlMs?: number | undefined, +): Promise<T> { + // Optionally disable cache. + // Lazily access constants.ENV.DISABLE_GITHUB_CACHE. + if (constants.ENV.DISABLE_GITHUB_CACHE) { + return await fetcher() + } + let data = (await readCache(key, ttlMs)) as T + if (!data) { + data = await fetcher() + await writeCache(key, data as JsonContent) + } + return data +} + +async function readCache( + key: string, + // 5 minute in milliseconds time to live (TTL). + ttlMs = 5 * 60 * 1000, +): Promise<JsonContent | null> { + // Lazily access constants.githubCachePath. + const cacheJsonPath = path.join(constants.githubCachePath, `${key}.json`) + try { + const stat = statSync(cacheJsonPath) + const isExpired = Date.now() - stat.mtimeMs > ttlMs + if (!isExpired) { + return await readJson(cacheJsonPath) + } + } catch {} + return null +} + +async function writeCache(key: string, data: JsonContent): Promise<void> { + // Lazily access constants.githubCachePath. + const { githubCachePath } = constants + const cacheJsonPath = path.join(githubCachePath, `${key}.json`) + if (!existsSync(githubCachePath)) { + await fs.mkdir(githubCachePath, { recursive: true }) + } + await writeJson(cacheJsonPath, data as JsonContent) +} + +export type Pr = components['schemas']['pull-request'] + +export type MERGE_STATE_STATUS = + | 'BEHIND' + | 'BLOCKED' + | 'CLEAN' + | 'DIRTY' + | 'DRAFT' + | 'HAS_HOOKS' + | 'UNKNOWN' + | 'UNSTABLE' + +export type PrMatch = { + author: string + baseRefName: string + headRefName: string + mergeStateStatus: MERGE_STATE_STATUS + number: number + title: string +} + +export type CleanupPrsOptions = { + newVersion?: string | undefined + purl?: string | undefined + workspace?: string | undefined +} + +export async function cleanupOpenPrs( + owner: string, + repo: string, + options?: CleanupPrsOptions | undefined, +): Promise<PrMatch[]> { + const contextualMatches = await getOpenSocketPrsWithContext( + owner, + repo, + options, + ) + + if (!contextualMatches.length) { + return [] + } + + const cachesToSave = new Map<string, JsonContent>() + const { newVersion } = { __proto__: null, ...options } as CleanupPrsOptions + const branchParser = createSocketBranchParser(options) + const octokit = getOctokit() + + const settledMatches = await Promise.allSettled( + contextualMatches.map(async ({ context, match }) => { + const { number: prNum } = match + const prRef = `PR #${prNum}` + const parsedBranch = branchParser(match.headRefName) + const prToVersion = parsedBranch?.newVersion + + // Close older PRs. + if (prToVersion && newVersion && semver.lt(prToVersion, newVersion)) { + try { + await octokit.pulls.update({ + owner, + repo, + pull_number: prNum, + state: 'closed', + }) + debugFn(`close: ${prRef} for ${prToVersion}`) + // Remove entry from parent object. + context.parent.splice(context.index, 1) + // Mark cache to be saved. + cachesToSave.set(context.cacheKey, context.data) + return null + } catch (e) { + debugFn( + `fail: close ${prRef} for ${prToVersion}\n`, + (e as Error)?.message || 'unknown error', + ) + } + } + // Update stale PRs. + // https://docs.github.com/en/graphql/reference/enums#mergestatestatus + if (match.mergeStateStatus === 'BEHIND') { + try { + await octokit.repos.merge({ + owner, + repo, + base: match.headRefName, + head: match.baseRefName, + }) + debugFn('update: stale', prRef) + // Update entry entry. + if (context.apiType === 'graphql') { + context.entry.mergeStateStatus = 'CLEAN' + } else if (context.apiType === 'rest') { + context.entry.mergeable_state = 'clean' + } + // Mark cache to be saved. + cachesToSave.set(context.cacheKey, context.data) + } catch (e) { + const message = (e as Error)?.message || 'Unknown error' + debugFn(`fail: update ${prRef} - ${message}`) + } + } + return match + }), + ) + + if (cachesToSave.size) { + await Promise.allSettled( + Array.from(cachesToSave).map(({ 0: key, 1: data }) => + writeCache(key, data), + ), + ) + } + + const fulfilledMatches = settledMatches.filter( + r => r.status === 'fulfilled' && r.value, + ) as unknown as Array<PromiseFulfilledResult<ContextualPrMatch>> + + return fulfilledMatches.map(r => r.value.match) +} + +export type PrAutoMergeState = { + enabled: boolean + details?: string[] +} + +export async function enablePrAutoMerge({ + node_id: prId, +}: Pr): Promise<PrAutoMergeState> { + const octokitGraphql = getOctokitGraphql() + let error: unknown + try { + const response = await octokitGraphql( + ` + mutation EnableAutoMerge($pullRequestId: ID!) { + enablePullRequestAutoMerge(input: { + pullRequestId: $pullRequestId, + mergeMethod: SQUASH + }) { + pullRequest { + number + } + } + }`, + { pullRequestId: prId }, + ) + const respPrNumber = (response as any)?.enablePullRequestAutoMerge + ?.pullRequest?.number + if (respPrNumber) { + return { enabled: true } + } + } catch (e) { + error = e + } + if ( + error instanceof GraphqlResponseError && + Array.isArray(error.errors) && + error.errors.length + ) { + const details = error.errors.map(({ message }) => message.trim()) + return { enabled: false, details } + } + return { enabled: false } +} + +export type GetOpenSocketPrsOptions = { + author?: string | undefined + newVersion?: string | undefined + purl?: string | undefined + workspace?: string | undefined +} + +export async function getOpenSocketPrs( + owner: string, + repo: string, + options?: GetOpenSocketPrsOptions | undefined, +): Promise<PrMatch[]> { + return (await getOpenSocketPrsWithContext(owner, repo, options)).map( + d => d.match, + ) +} + +type ContextualPrMatch = { + context: { + apiType: 'graphql' | 'rest' + cacheKey: string + data: any + entry: any + index: number + parent: any[] + } + match: PrMatch +} + +async function getOpenSocketPrsWithContext( + owner: string, + repo: string, + options_?: GetOpenSocketPrsOptions | undefined, +): Promise<ContextualPrMatch[]> { + const options = { __proto__: null, ...options_ } as GetOpenSocketPrsOptions + const { author } = options + const checkAuthor = isNonEmptyString(author) + const octokit = getOctokit() + const octokitGraphql = getOctokitGraphql() + const branchPattern = getSocketBranchPattern(options) + + const contextualMatches: ContextualPrMatch[] = [] + try { + // Optimistically fetch only the first 50 open PRs using GraphQL to minimize + // API quota usage. Fallback to REST if no matching PRs are found. + const gqlCacheKey = `${repo}-pr-graphql-snapshot` + const gqlResp = await cacheFetch(gqlCacheKey, () => + octokitGraphql( + ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 50, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + author { + login + } + baseRefName + headRefName + mergeStateStatus + number + title + } + } + } + } + `, + { owner, repo }, + ), + ) + + type GqlPrNode = { + author?: { + login: string + } + baseRefName: string + headRefName: string + mergeStateStatus: MERGE_STATE_STATUS + number: number + title: string + } + const nodes: GqlPrNode[] = + (gqlResp as any)?.repository?.pullRequests?.nodes ?? [] + for (let i = 0, { length } = nodes; i < length; i += 1) { + const node = nodes[i]! + const login = node.author?.login + const matchesAuthor = checkAuthor ? login === author : true + const matchesBranch = branchPattern.test(node.headRefName) + if (matchesAuthor && matchesBranch) { + contextualMatches.push({ + context: { + apiType: 'graphql', + cacheKey: gqlCacheKey, + data: gqlResp, + entry: node, + index: i, + parent: nodes, + }, + match: { + ...node, + author: login ?? '<unknown>', + }, + }) + } + } + } catch {} + + if (contextualMatches.length) { + return contextualMatches + } + + // Fallback to REST if GraphQL found no matching PRs. + let allOpenPrs: Pr[] | undefined + const cacheKey = `${repo}-open-prs` + try { + allOpenPrs = await cacheFetch( + cacheKey, + async () => + (await octokit.paginate(octokit.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + })) as Pr[], + ) + } catch {} + + if (!allOpenPrs) { + return contextualMatches + } + + for (let i = 0, { length } = allOpenPrs; i < length; i += 1) { + const pr = allOpenPrs[i]! + const login = pr.user?.login + const matchesAuthor = checkAuthor ? login === author : true + const matchesBranch = branchPattern.test(pr.head.ref) + if (matchesAuthor && matchesBranch) { + contextualMatches.push({ + context: { + apiType: 'rest', + cacheKey, + data: allOpenPrs, + entry: pr, + index: i, + parent: allOpenPrs, + }, + match: { + author: login ?? '<unknown>', + baseRefName: pr.base.ref, + headRefName: pr.head.ref, + // Upper cased mergeable_state is equivalent to mergeStateStatus. + // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request + mergeStateStatus: (pr.mergeable_state?.toUpperCase?.() ?? + 'UNKNOWN') as MERGE_STATE_STATUS, + number: pr.number, + title: pr.title, + }, + }) + } + } + return contextualMatches +} + +export type OpenPrOptions = { + baseBranch?: string | undefined + cwd?: string | undefined + workspace?: string | undefined +} + +export async function openPr( + owner: string, + repo: string, + branch: string, + purl: string | PackageURL | SocketArtifact, + newVersion: string, + options?: OpenPrOptions | undefined, +): Promise<OctokitResponse<Pr> | null> { + const { baseBranch = 'main', workspace } = { + __proto__: null, + ...options, + } as OpenPrOptions + const purlObj = getPurlObject(purl) + const octokit = getOctokit() + try { + return await octokit.pulls.create({ + owner, + repo, + title: getSocketPullRequestTitle(purlObj, newVersion, workspace), + head: branch, + base: baseBranch, + body: getSocketPullRequestBody(purlObj, newVersion, workspace), + }) + } catch (e) { + let message = `Failed to open pull request` + const errors = + e instanceof RequestError + ? (e.response?.data as any)?.['errors'] + : undefined + if (Array.isArray(errors) && errors.length) { + const details = errors + .map( + d => + `- ${d.message?.trim() ?? `${d.resource}.${d.field} (${d.code})`}`, + ) + .join('\n') + message += `:\n${details}` + } + debugFn(message) + } + return null +} + +export async function prExistForBranch( + owner: string, + repo: string, + branch: string, +): Promise<boolean> { + const octokit = getOctokit() + try { + const { data: prs } = await octokit.pulls.list({ + owner, + repo, + head: `${owner}:${branch}`, + state: 'open', + per_page: 1, + }) + return prs.length > 0 + } catch {} + return false +} + +export async function setGitRemoteGithubRepoUrl( + owner: string, + repo: string, + token: string, + cwd = process.cwd(), +): Promise<void> { + const stdioIgnoreOptions: SpawnOptions = { cwd, stdio: 'ignore' } + const url = `https://x-access-token:${token}@github.com/${owner}/${repo}` + try { + await spawn('git', ['remote', 'set-url', 'origin', url], stdioIgnoreOptions) + } catch (e) { + debugFn('catch: unexpected\n', e) + } +} diff --git a/src/commands/fix/output-fix-result.mts b/src/commands/fix/output-fix-result.mts new file mode 100644 index 000000000..d2f6b2aac --- /dev/null +++ b/src/commands/fix/output-fix-result.mts @@ -0,0 +1,27 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputFixResult( + result: CResult<unknown>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log('') + logger.success('Finished!') +} diff --git a/src/commands/fix/pnpm-fix.mts b/src/commands/fix/pnpm-fix.mts new file mode 100644 index 000000000..89f25b243 --- /dev/null +++ b/src/commands/fix/pnpm-fix.mts @@ -0,0 +1,234 @@ +import { promises as fs } from 'node:fs' + +import { debugFn, isDebug } from '@socketsecurity/registry/lib/debug' +import { hasKeys } from '@socketsecurity/registry/lib/objects' + +import { agentFix } from './agent-fix.mts' +import { getCiEnv, getOpenPrsForEnvironment } from './fix-env-helpers.mts' +import { getActualTree } from './get-actual-tree.mts' +import { getAlertsMapOptions } from './shared.mts' +import constants from '../../constants.mts' +import { runAgentInstall } from '../../utils/agent.mts' +import { + getAlertsMapFromPnpmLockfile, + getAlertsMapFromPurls, +} from '../../utils/alerts-map.mts' +import { readLockfile } from '../../utils/lockfile.mts' +import { + extractOverridesFromPnpmLockSrc, + parsePnpmLockfile, + parsePnpmLockfileVersion, +} from '../../utils/pnpm.mts' +import { applyRange } from '../../utils/semver.mts' +import { getOverridesDataPnpm } from '../optimize/get-overrides-by-agent.mts' + +import type { FixOptions, InstallOptions } from './agent-fix.mts' +import type { NodeClass } from '../../shadow/npm/arborist/types.mts' +import type { CResult, StringKeyValueObject } from '../../types.mts' +import type { EnvDetails } from '../../utils/package-environment.mts' +import type { PackageJson } from '@socketsecurity/registry/lib/packages' + +const { OVERRIDES, PNPM } = constants + +async function install( + pkgEnvDetails: EnvDetails, + options: InstallOptions, +): Promise<NodeClass | null> { + const { args, cwd, spinner } = { + __proto__: null, + ...options, + } as InstallOptions + try { + await runAgentInstall(pkgEnvDetails, { + args: [ + ...(args ?? []), + // Enable pnpm updates to pnpm-lock.yaml in CI environments. + // https://pnpm.io/cli/install#--frozen-lockfile + '--no-frozen-lockfile', + // Enable a non-interactive pnpm install + // https://github.com/pnpm/pnpm/issues/6778 + '--config.confirmModulesPurge=false', + ], + spinner, + stdio: isDebug() ? 'inherit' : 'ignore', + }) + return await getActualTree(cwd) + } catch {} + return null +} + +export async function pnpmFix( + pkgEnvDetails: EnvDetails, + options: FixOptions, +): Promise<CResult<{ fixed: boolean }>> { + const { cwd, limit, purls, spinner } = options + + spinner?.start() + + let actualTree: NodeClass | undefined + let { lockSrc } = pkgEnvDetails + let lockfile = parsePnpmLockfile(lockSrc) + // Update pnpm-lock.yaml if its version is older than what the installed pnpm + // produces. + if ( + pkgEnvDetails.agentVersion.major >= 10 && + (parsePnpmLockfileVersion(lockfile?.lockfileVersion)?.major ?? 0) <= 6 + ) { + const maybeActualTree = await install(pkgEnvDetails, { + args: ['--lockfile-only'], + cwd, + spinner, + }) + const maybeLockSrc = maybeActualTree + ? await readLockfile(pkgEnvDetails.lockPath) + : null + if (maybeActualTree && maybeLockSrc) { + actualTree = maybeActualTree + lockSrc = maybeLockSrc + lockfile = parsePnpmLockfile(lockSrc) + } else { + lockfile = null + } + } + + // Exit early if pnpm-lock.yaml is not found or usable. + // Check !lockSrc to make TypeScript happy. + if (!lockfile || !lockSrc) { + spinner?.stop() + return { + ok: false, + message: 'Missing lockfile', + cause: 'Required pnpm-lock.yaml not found or usable', + } + } + + const ciEnv = await getCiEnv() + const openPrs = ciEnv ? await getOpenPrsForEnvironment(ciEnv) : [] + + let alertsMap + try { + alertsMap = purls.length + ? await getAlertsMapFromPurls( + purls, + getAlertsMapOptions({ limit: Math.max(limit, openPrs.length) }), + ) + : await getAlertsMapFromPnpmLockfile( + lockfile, + getAlertsMapOptions({ limit: Math.max(limit, openPrs.length) }), + ) + } catch (e) { + spinner?.stop() + debugFn('catch: PURL API\n', e) + return { + ok: false, + message: 'API Error', + cause: (e as Error)?.message || 'Unknown Socket batch PURL API error.', + } + } + + let revertData: PackageJson | undefined + let revertOverrides: PackageJson | undefined + let revertOverridesSrc: string | undefined + + return await agentFix( + pkgEnvDetails, + actualTree, + alertsMap, + install, + { + async beforeInstall( + editablePkgJson, + name, + oldVersion, + newVersion, + vulnerableVersionRange, + options, + ) { + const isWorkspaceRoot = + editablePkgJson.path === pkgEnvDetails.editablePkgJson.filename + // Get current overrides for revert logic. + const { overrides: oldOverrides } = getOverridesDataPnpm( + pkgEnvDetails, + editablePkgJson.content, + ) + const oldPnpmSection = editablePkgJson.content[PNPM] as + | StringKeyValueObject + | undefined + const overrideKey = `${name}@${vulnerableVersionRange}` + + revertOverrides = undefined + revertOverridesSrc = extractOverridesFromPnpmLockSrc(lockSrc) + + if (isWorkspaceRoot) { + revertOverrides = { + [PNPM]: oldPnpmSection + ? { + ...oldPnpmSection, + [OVERRIDES]: hasKeys(oldOverrides) + ? { + ...oldOverrides, + [overrideKey]: undefined, + } + : undefined, + } + : undefined, + } as PackageJson + // Update overrides in the root package.json so that when `pnpm install` + // generates pnpm-lock.yaml it updates transitive dependencies too. + editablePkgJson.update({ + [PNPM]: { + ...oldPnpmSection, + [OVERRIDES]: { + ...oldOverrides, + [overrideKey]: applyRange( + oldOverrides?.[overrideKey] ?? oldVersion, + newVersion, + options.rangeStyle, + ), + }, + }, + }) + } + + revertData = { + ...revertOverrides, + ...(editablePkgJson.content.dependencies && { + dependencies: { ...editablePkgJson.content.dependencies }, + }), + ...(editablePkgJson.content.optionalDependencies && { + optionalDependencies: { + ...editablePkgJson.content.optionalDependencies, + }, + }), + ...(editablePkgJson.content.peerDependencies && { + peerDependencies: { ...editablePkgJson.content.peerDependencies }, + }), + } as PackageJson + }, + async afterInstall(editablePkgJson) { + if (revertOverrides) { + // Revert overrides metadata in package.json now that pnpm-lock.yaml + // has been updated. + editablePkgJson.update(revertOverrides) + } + await editablePkgJson.save({ ignoreWhitespace: true }) + const updatedOverridesContent = extractOverridesFromPnpmLockSrc(lockSrc) + if (updatedOverridesContent && revertOverridesSrc) { + lockSrc = lockSrc!.replace( + updatedOverridesContent, + revertOverridesSrc, + ) + await fs.writeFile(pkgEnvDetails.lockPath, lockSrc, 'utf8') + } + }, + async revertInstall(editablePkgJson) { + if (revertData) { + editablePkgJson.update(revertData) + } + }, + }, + ciEnv, + openPrs, + options, + ) +} diff --git a/src/commands/fix/shared.mts b/src/commands/fix/shared.mts new file mode 100644 index 000000000..b6c70351e --- /dev/null +++ b/src/commands/fix/shared.mts @@ -0,0 +1,26 @@ +import type { GetAlertsMapFromPurlsOptions } from '../../utils/alerts-map.mts' +import type { Remap } from '@socketsecurity/registry/lib/objects' + +export const CMD_NAME = 'socket fix' + +export function getAlertsMapOptions( + options: GetAlertsMapFromPurlsOptions = {}, +) { + return { + __proto__: null, + consolidate: true, + nothrow: true, + ...options, + include: { + __proto__: null, + existing: true, + unfixable: false, + upgradable: false, + ...options?.include, + }, + } as Remap< + Omit<GetAlertsMapFromPurlsOptions, 'include' | 'overrides' | 'spinner'> & { + include: Exclude<GetAlertsMapFromPurlsOptions['include'], undefined> + } + > +} diff --git a/src/commands/install/cmd-install-completion.mts b/src/commands/install/cmd-install-completion.mts new file mode 100644 index 000000000..aa5064e0a --- /dev/null +++ b/src/commands/install/cmd-install-completion.mts @@ -0,0 +1,76 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleInstallCompletion } from './handle-install-completion.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'completion', + description: 'Install bash completion for Socket CLI', + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [NAME=socket] + + Installs bash completion for the Socket CLI. This will: + 1. Source the completion script in your current shell + 2. Add the source command to your ~/.bashrc if it's not already there + + This command will only setup tab completion, nothing else. + + Afterwards you should be able to type \`socket \` and then press tab to + have bash auto-complete/suggest the sub/command or flags. + + Currently only supports bash. + + The optional name argument allows you to enable tab completion on a command + name other than "socket". Mostly for debugging but also useful if you use a + different alias for socket on your system. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + + $ ${command} + $ ${command} sd + $ ${command} ./sd + `, +} + +export const cmdInstallCompletion = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const targetName = cli.input[0] || 'socket' + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleInstallCompletion(String(targetName)) +} diff --git a/src/commands/install/cmd-install-completion.test.mts b/src/commands/install/cmd-install-completion.test.mts new file mode 100644 index 000000000..a321acbbc --- /dev/null +++ b/src/commands/install/cmd-install-completion.test.mts @@ -0,0 +1,85 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket install completion', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['install', 'completion', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Install bash completion for Socket CLI + + Usage + $ socket install completion [options] [NAME=socket] + + Installs bash completion for the Socket CLI. This will: + 1. Source the completion script in your current shell + 2. Add the source command to your ~/.bashrc if it's not already there + + This command will only setup tab completion, nothing else. + + Afterwards you should be able to type \`socket \` and then press tab to + have bash auto-complete/suggest the sub/command or flags. + + Currently only supports bash. + + The optional name argument allows you to enable tab completion on a command + name other than "socket". Mostly for debugging but also useful if you use a + different alias for socket on your system. + + Options + (none) + + Examples + + $ socket install completion + $ socket install completion sd + $ socket install completion ./sd" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket install completion`', + ) + }, + ) + + cmdit( + [ + 'install', + 'completion', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/install/cmd-install.mts b/src/commands/install/cmd-install.mts new file mode 100644 index 000000000..47986e4e1 --- /dev/null +++ b/src/commands/install/cmd-install.mts @@ -0,0 +1,24 @@ +import { cmdInstallCompletion } from './cmd-install-completion.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Setup the Socket CLI command in your environment' + +export const cmdInstall: CliSubcommand = { + description, + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + completion: cmdInstallCompletion, + }, + { + argv, + description, + importMeta, + name: `${parentName} install`, + }, + ) + }, +} diff --git a/src/commands/install/cmd-install.test.mts b/src/commands/install/cmd-install.test.mts new file mode 100644 index 000000000..dd51f9f0c --- /dev/null +++ b/src/commands/install/cmd-install.test.mts @@ -0,0 +1,66 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket install', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['install', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Setup the Socket CLI command in your environment + + Usage + $ socket install <command> + + Commands + completion Install bash completion for Socket CLI + + Options + (none) + + Examples + $ socket install --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket install`', + ) + }, + ) + + cmdit( + ['install', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/packages/cli/src/commands/install/handle-install-completion.mts b/src/commands/install/handle-install-completion.mts similarity index 100% rename from packages/cli/src/commands/install/handle-install-completion.mts rename to src/commands/install/handle-install-completion.mts diff --git a/packages/cli/src/commands/install/output-install-completion.mts b/src/commands/install/output-install-completion.mts similarity index 79% rename from packages/cli/src/commands/install/output-install-completion.mts rename to src/commands/install/output-install-completion.mts index c192be8d8..03e9c2bc2 100644 --- a/packages/cli/src/commands/install/output-install-completion.mts +++ b/src/commands/install/output-install-completion.mts @@ -1,9 +1,8 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { logger } from '@socketsecurity/registry/lib/logger' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() export async function outputInstallCompletion( result: CResult<{ @@ -30,10 +29,9 @@ export async function outputInstallCompletion( ) logger.log('') - for (let i = 0, { length } = result.data.actions; i < length; i += 1) { - const action = result.data.actions[i] + result.data.actions.forEach(action => { logger.log(` - ${action}`) - } + }) logger.log('') logger.log('Socket tab completion works automatically in new terminals.') logger.log('') @@ -44,7 +42,7 @@ export async function outputInstallCompletion( logger.log('') logger.log('1. Reload your .bashrc script (best):') logger.log('') - logger.log(' source ~/.bashrc') + logger.log(` source ~/.bashrc`) logger.log('') logger.log('2. Run these commands to load the completion script:') logger.log('') diff --git a/src/commands/install/setup-tab-completion.mts b/src/commands/install/setup-tab-completion.mts new file mode 100644 index 000000000..114c3d7b9 --- /dev/null +++ b/src/commands/install/setup-tab-completion.mts @@ -0,0 +1,118 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import constants from '../../constants.mts' +import { getBashrcDetails } from '../../utils/completion.mts' + +import type { CResult } from '../../types.mts' + +export async function setupTabCompletion(targetName: string): Promise< + CResult<{ + actions: string[] + bashrcPath: string + bashrcUpdated: boolean + completionCommand: string + foundBashrc: boolean + sourcingCommand: string + targetName: string + targetPath: string + }> +> { + const result = getBashrcDetails(targetName) + if (!result.ok) { + return result + } + + const { completionCommand, sourcingCommand, targetPath, toAddToBashrc } = + result.data + + // Target dir is something like ~/.local/share/socket/settings/completion (linux) + const targetDir = path.dirname(targetPath) + debugFn('target: path + dir', targetPath, targetDir) + + if (!fs.existsSync(targetDir)) { + debugFn('create: target dir') + fs.mkdirSync(targetDir, { recursive: true }) + } + + updateInstalledTabCompletionScript(targetPath) + + let bashrcUpdated = false + + // Add to ~/.bashrc if not already there + // Lazily access constants.homePath + const bashrcPath = constants.homePath + ? path.join(constants.homePath, '.bashrc') + : '' + + const foundBashrc = Boolean(bashrcPath && fs.existsSync(bashrcPath)) + + if (foundBashrc) { + const content = fs.readFileSync(bashrcPath, 'utf8') + if (!content.includes(sourcingCommand)) { + fs.appendFileSync(bashrcPath, toAddToBashrc) + bashrcUpdated = true + } + } + + return { + ok: true, + data: { + actions: [ + `Installed the tab completion script in ${targetPath}`, + bashrcUpdated + ? 'Added tab completion loader to ~/.bashrc' + : foundBashrc + ? 'Tab completion already found in ~/.bashrc' + : 'No ~/.bashrc found so tab completion was not completely installed', + ], + bashrcPath, + bashrcUpdated, + completionCommand, + foundBashrc, + sourcingCommand, + targetName, + targetPath, + }, + } +} + +function getTabCompletionScriptRaw(): CResult<string> { + const sourceDir = path.dirname(fileURLToPath(import.meta.url)) + const sourcePath = path.join(sourceDir, 'socket-completion.bash') + + if (!fs.existsSync(sourcePath)) { + return { + ok: false, + message: 'Source not found', + cause: `Unable to find the source tab completion bash script that Socket should ship. Expected to find it in \`${sourcePath}\` but it was not there.`, + } + } + + return { ok: true, data: fs.readFileSync(sourcePath, 'utf8') } +} + +export function updateInstalledTabCompletionScript( + targetPath: string, +): CResult<undefined> { + const content = getTabCompletionScriptRaw() + if (!content.ok) { + return content + } + + // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH. + const CLI_VERSION = constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH + + // When installing set the current package.json version. + // Later, we can call _socket_completion_version to get the installed version. + fs.writeFileSync( + targetPath, + content.data.replaceAll('SOCKET_VERSION_TOKEN', CLI_VERSION), + 'utf8', + ) + + return { ok: true, data: undefined } +} diff --git a/packages/cli/data/socket-completion.bash b/src/commands/install/socket-completion.bash similarity index 78% rename from packages/cli/data/socket-completion.bash rename to src/commands/install/socket-completion.bash index 5a486e6ef..10d46e13d 100755 --- a/packages/cli/data/socket-completion.bash +++ b/src/commands/install/socket-completion.bash @@ -20,6 +20,7 @@ COMMANDS=( [diff-scan]="get" [diff-scan get]="" [fix]="" + [info]="" [install]="completion" [install completion]="" [login]="" @@ -38,10 +39,10 @@ COMMANDS=( [optimize]="" [organization]="list quota policy" [organization list]="" + [organization quota]="" [organization policy]="license security" [organization policy license]="" [organization policy security]="" - [organization quota]="" [package]="score shallow" [package score]="" [package shallow]="" @@ -52,17 +53,16 @@ COMMANDS=( [report view]="" [repos]="create view list del update" [repos create]="" - [repos del]="" + [repos view]="" [repos list]="" + [repos del]="" [repos update]="" - [repos view]="" [scan]="create list del diff metadata report view" [scan create]="" + [scan list]="" [scan del]="" [scan diff]="" - [scan list]="" [scan metadata]="" - [scan reach]="" [scan report]="" [scan view]="" [threat-feed]="" @@ -73,74 +73,60 @@ COMMANDS=( # Define flags FLAGS=( - [common]="--config --dry-run --help --version" + [common]="--config --dryRun --help --version" [analytics]="--file --json --markdown --repo --scope --time" - [audit-log]="--interactive --json --markdown --org --page --per-page --type" + [audit-log]="--interactive --org --page --perPage --type" [cdxgen]="--api-key --author --auto-compositions --deep --evidence --exclude --exclude-type --fail-on-error --filter --generate-key-and-sign --include-crypto --include-formulation --install-deps --json-pretty --min-confidence --no-babel --only --output --parent-project-id --print --profile --project-group --project-name --project-id --project-version --recurse --required-only --resolve-class --server --server-host --server-port --server-url --skip-dt-tls-check --spec-version --standard --technique --type --validate" - [ci]="--auto-manifest" - [config]="" + [ci]="--autoManifest" [config auto]="--json --markdown" [config get]="--json --markdown" [config list]="--full --json --markdown" [config set]="--json --markdown" [config unset]="--json --markdown" [dependencies]="--json --limit --markdown --offset" - [diff-scan]="" [diff-scan get]="--after --before --depth --file --json" - [fix]="--auto-merge --id --limit --range-style" - [install]="" + [fix]="--autoMerge --autopilot --limit --purl --rangeStyle --test --testScript" + [info]="--all --strict" [install completion]="" - [login]="--api-base-url --api-proxy" - [logout]="" - [manifest]="" + [login]="--apiBaseUrl --apiProxy" [manifest auto]="--cwd --verbose" - [manifest conda]="--file --stdin --out --stdout --verbose" [manifest cdxgen]="--api-key --author --auto-compositions --deep --evidence --exclude --exclude-type --fail-on-error --filter --generate-key-and-sign --include-crypto --include-formulation --install-deps --json-pretty --min-confidence --no-babel --only --output --parent-project-id --print --profile --project-group --project-name --project-id --project-version --recurse --required-only --resolve-class --server --server-host --server-port --server-url --skip-dt-tls-check --spec-version --standard --technique --type --validate" - [manifest gradle]="--bin --gradle-opts --verbose" - [manifest kotlin]="--bin --gradle-opts --verbose" - [manifest scala]="--bin --out --sbt-opts --stdout --verbose" - [manifest setup]="--cwd --default-on-read-error" + [manifest conda]="--file --stdin --out --stdout --verbose" + [manifest gradle]="--bin --gradleOpts --verbose" + [manifest kotlin]="--bin --gradleOpts --verbose" + [manifest scala]="--bin --out --sbtOpts --stdout --verbose" + [manifest setup]="--cwd --defaultOnReadError" [npm]="" [npx]="" [oops]="" - [optimize]="--json --markdown --pin --prod" - [organization]="" + [optimize]="--pin --prod" [organization list]="" - [organization policy]="" [organization policy license]="--interactive --org" [organization policy security]="--interactive --org" [organization quota]="" - [package]="" [package score]="--json --markdown" [package shallow]="--json --markdown" [raw-npm]="" [raw-npx]="" - [report]="" - [report create]="" - [report view]="" - [repos]="" - [repos create]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility" + [repos create]="--defaultBranch --homepage --interactive --org --repoDescription --repoName --visibility" [repos del]="--interactive --org" - [repos list]="--all --direction --interactive --json --markdown --org --page --per-page --sort" - [repos update]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility" - [repos view]="--interactive --org --repo-name" - [scan]="" - [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --exclude-paths --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" + [repos list]="--all --direction --interactive --org --page --perPage --sort" + [repos update]="--defaultBranch --homepage --interactive --org --repoDescription --repoName --visibility" + [repos view]="--interactive --org --repoName" + [scan create]="--autoManifest --branch --commitHash --commitMessage --committers --cwd --defaultBranch --interactive --org --pullRequest --readOnly --repo --report --setAsAlertsPage --tmp" [scan del]="--interactive --org" [scan diff]="--depth --file --interactive --org" - [scan list]="--branch --direction --from-time --interactive --json --markdown --org --page --per-page --repo --sort --until-time" + [scan list]="--branch --direction --fromTime --interactive --org --page --perPage --repo --sort --untilTime" [scan metadata]="--interactive --org" - [scan reach]="--exclude-paths --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" - [scan report]="--fold --interactive --license --org --report-level --short" + [scan report]="--fold --interactive --license --org --reportLevel --short" [scan view]="--interactive --org --stream" - [threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --per-page" - [uninstall]="" + [threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --perPage" [uninstall completion]="" [wrapper]="--disable --enable" ) _socket_completion_version() { - echo "%SOCKET_VERSION_TOKEN%" # replaced when installing + echo "SOCKET_VERSION_TOKEN" # replaced when installing } _socket_completion() { diff --git a/src/commands/json/cmd-json.mts b/src/commands/json/cmd-json.mts new file mode 100644 index 000000000..dd5413327 --- /dev/null +++ b/src/commands/json/cmd-json.mts @@ -0,0 +1,53 @@ +import path from 'node:path' + +import { handleCmdJson } from './handle-cmd-json.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'json', + description: + 'Display the `socket.json` that would be applied for target folder', + hidden: true, + flags: { + ...commonFlags, + }, + help: command => ` + Usage + $ ${command} [options] [CWD=.] + + Display the \`socket.json\` file that would apply when running relevant commands + in the target directory. + + Examples + $ ${command} + `, +} + +export const cmdJson = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + await handleCmdJson(cwd) +} diff --git a/src/commands/json/cmd-json.test.mts b/src/commands/json/cmd-json.test.mts new file mode 100644 index 000000000..c7456cf57 --- /dev/null +++ b/src/commands/json/cmd-json.test.mts @@ -0,0 +1,150 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket json', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['json', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Display the \`socket.json\` that would be applied for target folder + + Usage + $ socket json [options] [CWD=.] + + Display the \`socket.json\` file that would apply when running relevant commands + in the target directory. + + Examples + $ socket json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket json`') + }, + ) + + cmdit( + ['json', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> + + \\x1b[34mi\\x1b[39m Target cwd: <redacted> + \\x1b[31m\\xd7\\x1b[39m Not found: <redacted>" + `) + + expect(code, 'not found is failure').toBe(1) + }, + ) + + cmdit( + ['json', '.', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should print error when file does not exist in folder', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> + + \\x1b[34mi\\x1b[39m Target cwd: <redacted> + \\x1b[31m\\xd7\\x1b[39m Not found: <redacted>" + `) + + expect(code, 'not found is failure').toBe(1) + }, + ) + + cmdit( + [ + 'json', + './doesnotexist', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should print an error when the path to file does not exist', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> + + \\x1b[34mi\\x1b[39m Target cwd: <redacted> + \\x1b[31m\\xd7\\x1b[39m Not found: <redacted>" + `) + + expect(code, 'not found is failure').toBe(1) + }, + ) + + cmdit( + ['json', './sjtest', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should print a socket.json when found', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + // (Remove carriage returns on Windows) + expect(stdout.replace(/(?:\\r|\\x0d)/g, '')).toMatchInlineSnapshot(` + "{ + " _____ _ _ ": "Local config file for Socket CLI tool ( https://npmjs.org/socket ), to work with https://socket.dev", + "| __|___ ___| |_ ___| |_ ": " The config in this file is used to set as defaults for flags or cmmand args when using the CLI", + "|__ | . | _| '_| -_| _| ": " in this dir, often a repo root. You can choose commit or .ignore this file, both works.", + "|_____|___|___|_,_|___|_|.dev": "Warning: This file may be overwritten without warning by \`socket manifest setup\` or other commands", + "version": 1, + "defaults": { + "manifest": { + "sbt": { + "bin": "/bin/sbt", + "outfile": "sbt.pom.xml", + "stdout": false, + "verbose": true + } + } + } + }" + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket json\`, cwd: <redacted> + + \\x1b[34mi\\x1b[39m Target cwd: <redacted> + \\x1b[32m\\u221a\\x1b[39m This is the contents of <redacted>:" + `) + + expect(code, 'found is ok').toBe(0) + }, + ) +}) diff --git a/packages/cli/src/commands/json/handle-cmd-json.mts b/src/commands/json/handle-cmd-json.mts similarity index 100% rename from packages/cli/src/commands/json/handle-cmd-json.mts rename to src/commands/json/handle-cmd-json.mts diff --git a/src/commands/json/output-cmd-json.mts b/src/commands/json/output-cmd-json.mts new file mode 100644 index 000000000..05f5694c6 --- /dev/null +++ b/src/commands/json/output-cmd-json.mts @@ -0,0 +1,34 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { tildify } from '../../utils/tildify.mts' + +export async function outputCmdJson(cwd: string) { + logger.info('Target cwd:', constants.ENV.VITEST ? '<redacted>' : tildify(cwd)) + + const sjpath = path.join(cwd, 'socket.json') + const tildeSjpath = constants.ENV.VITEST ? '<redacted>' : tildify(sjpath) + + if (!fs.existsSync(sjpath)) { + logger.fail(`Not found: ${tildeSjpath}`) + process.exitCode = 1 + return + } + + if (!fs.lstatSync(sjpath).isFile()) { + logger.fail( + `This is not a regular file (maybe a directory?): ${tildeSjpath}`, + ) + process.exitCode = 1 + return + } + + const data = fs.readFileSync(sjpath, 'utf8') + + logger.success(`This is the contents of ${tildeSjpath}:`) + logger.error('') + logger.log(data) +} diff --git a/src/commands/login/apply-login.mts b/src/commands/login/apply-login.mts new file mode 100644 index 000000000..e9c38c9d9 --- /dev/null +++ b/src/commands/login/apply-login.mts @@ -0,0 +1,13 @@ +import { updateConfigValue } from '../../utils/config.mts' + +export function applyLogin( + apiToken: string, + enforcedOrgs: string[], + apiBaseUrl: string | undefined, + apiProxy: string | undefined, +) { + updateConfigValue('enforcedOrgs', enforcedOrgs) + updateConfigValue('apiToken', apiToken) + updateConfigValue('apiBaseUrl', apiBaseUrl) + updateConfigValue('apiProxy', apiProxy) +} diff --git a/src/commands/login/attempt-login.mts b/src/commands/login/attempt-login.mts new file mode 100644 index 000000000..8a06157de --- /dev/null +++ b/src/commands/login/attempt-login.mts @@ -0,0 +1,163 @@ +import terminalLink from 'terminal-link' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { confirm, password, select } from '@socketsecurity/registry/lib/prompts' + +import { applyLogin } from './apply-login.mts' +import constants from '../../constants.mts' +import { handleApiCall } from '../../utils/api.mts' +import { + getConfigValueOrUndef, + isReadOnlyConfig, + updateConfigValue, +} from '../../utils/config.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { setupSdk } from '../../utils/sdk.mts' +import { setupTabCompletion } from '../install/setup-tab-completion.mts' + +import type { Choice, Separator } from '@socketsecurity/registry/lib/prompts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +type OrgChoice = Choice<string> +type OrgChoices = Array<Separator | OrgChoice> +const { SOCKET_PUBLIC_API_TOKEN } = constants + +export async function attemptLogin( + apiBaseUrl: string | undefined, + apiProxy: string | undefined, +) { + apiBaseUrl ??= getConfigValueOrUndef('apiBaseUrl') ?? undefined + apiProxy ??= getConfigValueOrUndef('apiProxy') ?? undefined + const apiTokenInput = await password({ + message: `Enter your ${terminalLink( + 'Socket.dev API key', + 'https://docs.socket.dev/docs/api-keys', + )} (leave blank for a public key)`, + }) + + if (apiTokenInput === undefined) { + logger.fail('Canceled by user') + return { ok: false, message: 'Canceled', cause: 'Canceled by user' } + } + + const apiToken = apiTokenInput || SOCKET_PUBLIC_API_TOKEN + + const sdk = await setupSdk(apiToken, apiBaseUrl, apiProxy) + if (!sdk.ok) { + process.exitCode = 1 + logger.fail(failMsgWithBadge(sdk.message, sdk.cause)) + return + } + + const result = await handleApiCall( + sdk.data.getOrganizations(), + 'token verification', + ) + + if (!result.ok) { + process.exitCode = 1 + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const orgs: SocketSdkReturnType<'getOrganizations'>['data'] = result.data + const orgSlugs = Object.values(orgs.organizations).map(obj => obj.slug) + + logger.success(`API key verified: ${orgSlugs}`) + + const enforcedChoices: OrgChoices = Object.values(orgs.organizations) + .filter(org => org?.plan === 'enterprise') + .map(org => ({ + name: org.name ?? 'undefined', + value: org.id, + })) + + let enforcedOrgs: string[] = [] + if (enforcedChoices.length > 1) { + const id = await select({ + message: + "Which organization's policies should Socket enforce system-wide?", + choices: enforcedChoices.concat({ + name: 'None', + value: '', + description: 'Pick "None" if this is a personal device', + }), + }) + if (id === undefined) { + logger.fail('Canceled by user') + return { ok: false, message: 'Canceled', cause: 'Canceled by user' } + } + if (id) { + enforcedOrgs = [id] + } + } else if (enforcedChoices.length) { + const shouldEnforce = await confirm({ + message: `Should Socket enforce ${(enforcedChoices[0] as OrgChoice)?.name}'s security policies system-wide?`, + default: true, + }) + if (shouldEnforce === undefined) { + logger.fail('Canceled by user') + return { ok: false, message: 'Canceled', cause: 'Canceled by user' } + } + if (shouldEnforce) { + const existing = enforcedChoices[0] as OrgChoice + if (existing) { + enforcedOrgs = [existing.value] + } + } + } + + const wantToComplete = await select({ + message: 'Would you like to install bash tab completion?', + choices: [ + { + name: 'Yes', + value: true, + description: + 'Sets up tab completion for "socket" in your bash env. If you\'re unsure, this is probably what you want.', + }, + { + name: 'No', + value: false, + description: + 'Will skip tab completion setup. Does not change how Socket works.', + }, + ], + }) + if (wantToComplete === undefined) { + logger.fail('Canceled by user') + return { ok: false, message: 'Canceled', cause: 'Canceled by user' } + } + if (wantToComplete) { + logger.log('Setting up tab completion...') + const result = await setupTabCompletion('socket') + if (result.ok) { + logger.success( + 'Tab completion will be enabled after restarting your terminal', + ) + } else { + logger.fail( + 'Failed to install tab completion script. Try `socket install completion` later.', + ) + } + } + + updateConfigValue('defaultOrg', orgSlugs[0]) + + const previousPersistedToken = getConfigValueOrUndef('apiToken') + try { + applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy) + logger.success( + `API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`, + ) + if (isReadOnlyConfig()) { + logger.log('') + logger.warn( + 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the login was not persisted!', + ) + } + } catch { + process.exitCode = 1 + logger.fail(`API login failed`) + } +} diff --git a/src/commands/login/cmd-login.mts b/src/commands/login/cmd-login.mts new file mode 100644 index 000000000..c533e436d --- /dev/null +++ b/src/commands/login/cmd-login.mts @@ -0,0 +1,81 @@ +import isInteractive from '@socketregistry/is-interactive/index.cjs' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { attemptLogin } from './attempt-login.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { InputError } from '../../utils/errors.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'login', + description: 'Socket API login', + hidden: false, + flags: { + ...commonFlags, + apiBaseUrl: { + type: 'string', + description: 'API server to connect to for login', + }, + apiProxy: { + type: 'string', + description: 'Proxy to use when making connection to API server', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] + + API Token Requirements + - Quota: 1 unit + + Logs into the Socket API by prompting for an API key + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} --api-proxy=http://localhost:1234 + `, +} + +export const cmdLogin = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const apiBaseUrl = cli.flags['apiBaseUrl'] as string | undefined + const apiProxy = cli.flags['apiProxy'] as string | undefined + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + if (!isInteractive()) { + throw new InputError( + 'Cannot prompt for credentials in a non-interactive shell', + ) + } + + await attemptLogin(apiBaseUrl, apiProxy) +} diff --git a/src/commands/login/cmd-login.test.mts b/src/commands/login/cmd-login.test.mts new file mode 100644 index 000000000..5437a2231 --- /dev/null +++ b/src/commands/login/cmd-login.test.mts @@ -0,0 +1,66 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket login', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['login', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Socket API login + + Usage + $ socket login [options] + + API Token Requirements + - Quota: 1 unit + + Logs into the Socket API by prompting for an API key + + Options + --apiBaseUrl API server to connect to for login + --apiProxy Proxy to use when making connection to API server + + Examples + $ socket login + $ socket login --api-proxy=http://localhost:1234" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket login\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket login`') + }, + ) + + cmdit( + ['login', 'mootools', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket login\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/logout/apply-logout.mts b/src/commands/logout/apply-logout.mts new file mode 100644 index 000000000..e2fa3579e --- /dev/null +++ b/src/commands/logout/apply-logout.mts @@ -0,0 +1,8 @@ +import { updateConfigValue } from '../../utils/config.mts' + +export function applyLogout() { + updateConfigValue('apiToken', null) + updateConfigValue('apiBaseUrl', null) + updateConfigValue('apiProxy', null) + updateConfigValue('enforcedOrgs', null) +} diff --git a/src/commands/logout/attempt-logout.mts b/src/commands/logout/attempt-logout.mts new file mode 100644 index 000000000..fe3db7961 --- /dev/null +++ b/src/commands/logout/attempt-logout.mts @@ -0,0 +1,19 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { applyLogout } from './apply-logout.mts' +import { isReadOnlyConfig } from '../../utils/config.mts' + +export function attemptLogout() { + try { + applyLogout() + logger.success('Successfully logged out') + if (isReadOnlyConfig()) { + logger.log('') + logger.warn( + 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!', + ) + } + } catch { + logger.fail('Failed to complete logout steps') + } +} diff --git a/src/commands/logout/cmd-logout.mts b/src/commands/logout/cmd-logout.mts new file mode 100644 index 000000000..1f37f01d0 --- /dev/null +++ b/src/commands/logout/cmd-logout.mts @@ -0,0 +1,54 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { attemptLogout } from './attempt-logout.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'logout', + description: 'Socket API logout', + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + Logs out of the Socket API and clears all Socket credentials from disk + + Examples + $ ${command} + `, +} + +export const cmdLogout = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + attemptLogout() +} diff --git a/src/commands/logout/cmd-logout.test.mts b/src/commands/logout/cmd-logout.test.mts new file mode 100644 index 000000000..509bc9768 --- /dev/null +++ b/src/commands/logout/cmd-logout.test.mts @@ -0,0 +1,60 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket logout', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['logout', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Socket API logout + + Usage + $ socket logout [options] + + Logs out of the Socket API and clears all Socket credentials from disk + + Examples + $ socket logout" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket logout\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket logout`', + ) + }, + ) + + cmdit( + ['logout', 'mootools', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket logout\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/README.md b/src/commands/manifest/README.md new file mode 100644 index 000000000..5243f9e91 --- /dev/null +++ b/src/commands/manifest/README.md @@ -0,0 +1,35 @@ +# Manifest + +(At the time of writing...) + +## Dev + +Run it like these examples: + +``` +# Scala: +npm run bs manifest scala -- --bin ~/apps/sbt/bin/sbt ~/socket/repos/scala/akka +# Gradle/Kotlin +npm run bs manifest yolo -- --cwd ~/socket/repos/kotlin/kotlinx.coroutines +``` + +And upload with this: + +``` +npm exec socket scan create -- --repo=depscantmp --branch=mastertmp --tmp --cwd ~/socket/repos/scala/akka socketdev . +npm exec socket scan create -- --repo=depscantmp --branch=mastertmp --tmp --cwd ~/socket/repos/kotlin/kotlinx.coroutines . +``` + +(The `cwd` option for `create` is necessary because we can't go to the dir and run `npm exec`). + +## Prod + +User flow look something like this: + +``` +socket manifest scala . +socket manifest kotlin . +socket manifest yolo + +socket scan create --repo=depscantmp --branch=mastertmp --tmp socketdev . +``` diff --git a/src/commands/manifest/cmd-manifest-auto.mts b/src/commands/manifest/cmd-manifest-auto.mts new file mode 100644 index 000000000..3fe6bb603 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-auto.mts @@ -0,0 +1,123 @@ +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { detectManifestActions } from './detect-manifest-actions.mts' +import { generateAutoManifest } from './generate_auto_manifest.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'auto', + description: 'Auto-detect build and attempt to generate manifest file', + hidden: false, + flags: { + ...commonFlags, + verbose: { + type: 'boolean', + default: false, + description: + 'Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Tries to figure out what language your target repo uses. If it finds a + supported case then it will try to generate the manifest file for that + language with the default or detected settings. + + Note: you can exclude languages from being auto-generated if you don't want + them to. Run \`socket manifest setup\` in the same dir to disable it. + + Examples + + $ ${command} + $ ${command} ./project/foo + `, +} + +export const cmdManifestAuto = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + const { json, markdown, verbose: verboseFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) // TODO: impl json/md further + const verbose = !!verboseFlag + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.log('- cwd:', cwd) + logger.groupEnd() + } + + const socketJson = await readOrDefaultSocketJson(cwd) + + const detected = await detectManifestActions(socketJson, cwd) + debugLog('[DEBUG]', detected) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + if (!detected.count) { + logger.fail( + 'Was unable to discover any targets for which we can generate manifest files...', + ) + logger.log('') + logger.log( + '- Make sure this script would work with your target build (see `socket manifest --help` for your target).', + ) + logger.log( + '- Make sure to run it from the correct dir (use --cwd to target another dir)', + ) + logger.log('- Make sure the necessary build tools are available (`PATH`)') + process.exitCode = 1 + return + } + + await generateAutoManifest({ + detected, + cwd, + outputKind, + verbose, + }) + + logger.success( + `Finished. Should have attempted to generate manifest files for ${detected.count} targets.`, + ) +} diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts new file mode 100644 index 000000000..997ba5ca4 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -0,0 +1,70 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest auto', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'auto', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Auto-detect build and attempt to generate manifest file + + Usage + $ socket manifest auto [options] [CWD=.] + + Options + --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors + + Tries to figure out what language your target repo uses. If it finds a + supported case then it will try to generate the manifest file for that + language with the default or detected settings. + + Note: you can exclude languages from being auto-generated if you don't want + them to. Run \`socket manifest setup\` in the same dir to disable it. + + Examples + + $ socket manifest auto + $ socket manifest auto ./project/foo" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest auto`', + ) + }, + ) + + cmdit( + ['manifest', 'auto', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest auto\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest-cdxgen.mts b/src/commands/manifest/cmd-manifest-cdxgen.mts new file mode 100644 index 000000000..4c37da3f3 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-cdxgen.mts @@ -0,0 +1,293 @@ +import terminalLink from 'terminal-link' +import yargsParse from 'yargs-parser' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import { runCdxgen } from './run-cdxgen.mts' +import constants from '../../constants.mts' +import { isHelpFlag } from '../../utils/cmd.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +// TODO: Convert yargs to meow. +const toLower = (arg: string) => arg.toLowerCase() +const arrayToLower = (arg: string[]) => arg.map(toLower) + +// npx @cyclonedx/cdxgen@11.2.7 --help +// +// Options: +// -o, --output Output file. Default bom.json [default: "bom.json"] +// -t, --type Project type. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TYPES for supp +// orted languages/platforms. [array] +// --exclude-type Project types to exclude. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TY +// PES for supported languages/platforms. +// -r, --recurse Recurse mode suitable for mono-repos. Defaults to true. Pass --no-recurse to disable. +// [boolean] [default: true] +// -p, --print Print the SBOM as a table with tree. [boolean] +// -c, --resolve-class Resolve class names for packages. jars only for now. [boolean] +// --deep Perform deep searches for components. Useful while scanning C/C++ apps, live OS and oci i +// mages. [boolean] +// --server-url Dependency track url. Eg: https://deptrack.cyclonedx.io +// --skip-dt-tls-check Skip TLS certificate check when calling Dependency-Track. [boolean] [default: false] +// --api-key Dependency track api key +// --project-group Dependency track project group +// --project-name Dependency track project name. Default use the directory name +// --project-version Dependency track project version [string] [default: ""] +// --project-id Dependency track project id. Either provide the id or the project name and version togeth +// er [string] +// --parent-project-id Dependency track parent project id [string] +// --required-only Include only the packages with required scope on the SBOM. Would set compositions.aggrega +// te to incomplete unless --no-auto-compositions is passed. [boolean] +// --fail-on-error Fail if any dependency extractor fails. [boolean] +// --no-babel Do not use babel to perform usage analysis for JavaScript/TypeScript projects. [boolean] +// --generate-key-and-sign Generate an RSA public/private key pair and then sign the generated SBOM using JSON Web S +// ignatures. [boolean] +// --server Run cdxgen as a server [boolean] +// --server-host Listen address [default: "127.0.0.1"] +// --server-port Listen port [default: "9090"] +// --install-deps Install dependencies automatically for some projects. Defaults to true but disabled for c +// ontainers and oci scans. Use --no-install-deps to disable this feature. +// [boolean] [default: true] +// --validate Validate the generated SBOM using json schema. Defaults to true. Pass --no-validate to di +// sable. [boolean] [default: true] +// --evidence Generate SBOM with evidence for supported languages. [boolean] [default: false] +// --spec-version CycloneDX Specification version to use. Defaults to 1.6 +// [number] [choices: 1.4, 1.5, 1.6, 1.7] [default: 1.6] +// --filter Filter components containing this word in purl or component.properties.value. Multiple va +// lues allowed. [array] +// --only Include components only containing this word in purl. Useful to generate BOM with first p +// arty components alone. Multiple values allowed. [array] +// --author The person(s) who created the BOM. Set this value if you're intending the modify the BOM +// and claim authorship. [array] [default: "OWASP Foundation"] +// --profile BOM profile to use for generation. Default generic. +// [choices: "appsec", "research", "operational", "threat-modeling", "license-compliance", "generic", "machine-learning", +// "ml", "deep-learning", "ml-deep", "ml-tiny"] [default: "generic"] +// --exclude Additional glob pattern(s) to ignore [array] +// --include-formulation Generate formulation section with git metadata and build tools. Defaults to false. +// [boolean] [default: false] +// --include-crypto Include crypto libraries as components. [boolean] [default: false] +// --standard The list of standards which may consist of regulations, industry or organizational-specif +// ic standards, maturity models, best practices, or any other requirements which can be eva +// luated against or attested to. +// [array] [choices: "asvs-5.0", "asvs-4.0.3", "bsimm-v13", "masvs-2.0.0", "nist_ssdf-1.1", "pcissc-secure-slc-1.1", "scv +// s-1.0.0", "ssaf-DRAFT-2023-11"] +// --json-pretty Pretty-print the generated BOM json. [boolean] [default: false] +// --min-confidence Minimum confidence needed for the identity of a component from 0 - 1, where 1 is 100% con +// fidence. [number] [default: 0] +// --technique Analysis technique to use +// [array] [choices: "auto", "source-code-analysis", "binary-analysis", "manifest-analysis", "hash-comparison", "instrume +// ntation", "filename"] +// --auto-compositions Automatically set compositions when the BOM was filtered. Defaults to true +// [boolean] [default: true] +// -h, --help Show help [boolean] +// -v, --version Show version number [boolean] + +// isSecureMode defined at: +// https://github.com/CycloneDX/cdxgen/blob/v11.2.7/lib/helpers/utils.js#L66 +// const isSecureMode = +// ['true', '1'].includes(process.env?.CDXGEN_SECURE_MODE) || +// process.env?.NODE_OPTIONS?.includes('--permission') + +// Yargs CDXGEN configuration defined at: +// https://github.com/CycloneDX/cdxgen/blob/v11.2.7/bin/cdxgen.js#L64 +const yargsConfig = { + configuration: { + 'camel-case-expansion': false, + 'greedy-arrays': false, + 'parse-numbers': false, + 'populate--': true, + 'short-option-groups': false, + 'strip-aliased': true, + 'unknown-options-as-args': true, + }, + coerce: { + 'exclude-type': arrayToLower, + 'feature-flags': arrayToLower, + filter: arrayToLower, + only: arrayToLower, + profile: toLower, + standard: arrayToLower, + technique: arrayToLower, + type: arrayToLower, + }, + default: { + //author: ['OWASP Foundation'], + //'auto-compositions': true, + //babel: true, + //banner: false, // hidden + //'deps-slices-file': 'deps.slices.json', // hidden + //evidence: false, + //'exclude-type': [], + //'export-proto': true, // hidden + //'fail-on-error': isSecureMode, + //'feature-flags': [], // hidden + //'include-crypto': false, + //'include-formulation': false, + //'install-deps': !isSecureMode + //lifecycle: 'build', // hidden + //'min-confidence': '0', + //output: 'bom.json', + //profile: 'generic', + //'project-version': '', + //'proto-bin-file': 'bom.cdx', // hidden + //recurse: true, + //'skip-dt-tls-check': false, + //'semantics-slices-file': 'semantics.slices.json', + //'server-host': '127.0.0.1', + //'server-port': '9090', + //'spec-version': '1.6', + type: ['js'], + //validate: true, + }, + alias: { + help: ['h'], + output: ['o'], + print: ['p'], + recurse: ['r'], + 'resolve-class': ['c'], + type: ['t'], + version: ['v'], + yes: ['y'], + }, + array: [ + { key: 'author', type: 'string' }, + { key: 'exclude', type: 'string' }, + { key: 'exclude-type', type: 'string' }, + { key: 'feature-flags', type: 'string' }, // hidden + { key: 'filter', type: 'string' }, + { key: 'only', type: 'string' }, + { key: 'standard', type: 'string' }, + { key: 'technique', type: 'string' }, + { key: 'type', type: 'string' }, + ], + boolean: [ + 'auto-compositions', + 'babel', + 'banner', // hidden + 'deep', + 'evidence', + 'export-proto', // hidden + 'fail-on-error', + 'generate-key-and-sign', + 'help', + 'include-crypto', + 'include-formulation', + 'install-deps', + 'json-pretty', + 'print', + 'recurse', + 'required-only', + 'resolve-class', + 'skip-dt-tls-check', + 'server', + 'validate', + 'version', + // The --yes flag and -y alias map to the corresponding flag and alias of npx. + // https://docs.npmjs.com/cli/v7/commands/npx#compatibility-with-older-npx-versions + 'yes', + ], + string: [ + 'api-key', + 'data-flow-slices-file', // hidden + 'deps-slices-file', // hidden + 'evinse-output', // hidden + 'lifecycle', + 'min-confidence', // number + 'openapi-spec-file', // hidden + 'output', + 'parent-project-id', + 'profile', + 'project-group', + 'project-name', + 'project-version', + 'project-id', + 'proto-bin-file', // hidden + 'reachables-slices-file', // hidden + 'semantics-slices-file', // hidden + 'server-host', + 'server-port', + 'server-url', + 'spec-version', // number + 'usages-slices-file', // hidden + ], +} + +const config: CliCommandConfig = { + commandName: 'cdxgen', + description: 'Create an SBOM with CycloneDX generator (cdxgen)', + hidden: false, + // Stub out flags and help. + // TODO: Convert yargs to meow. + flags: {}, + help: () => '', +} + +export const cmdManifestCdxgen = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + allowUnknownFlags: true, + // Don't let meow take over --help. + argv: argv.filter(a => !isHelpFlag(a)), + config, + importMeta, + parentName, + }) + + // TODO: Convert yargs to meow. + const yargv = { + ...yargsParse(argv as string[], yargsConfig), + } as any + + const unknown: string[] = yargv._ + const { length: unknownLength } = unknown + if (unknownLength) { + // Use exit status of 2 to indicate incorrect usage, generally invalid + // options or missing arguments. + // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html + process.exitCode = 2 + logger.fail( + `Unknown ${pluralize('argument', unknownLength)}: ${yargv._.join(', ')}`, + ) + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + // Change defaults when not passing the --help flag. + if (!yargv.help) { + // Make 'lifecycle' default to 'pre-build', which also sets 'install-deps' to `false`, + // to avoid arbitrary code execution on the cdxgen scan. + // https://github.com/CycloneDX/cdxgen/issues/1328 + if (yargv.lifecycle === undefined) { + yargv.lifecycle = 'pre-build' + yargv['install-deps'] = false + logger.info( + `Setting cdxgen --lifecycle to "${yargv.lifecycle}" to avoid arbitrary code execution on this scan.\n Pass "--lifecycle build" to generate a BOM consisting of information obtained during the build process.\n See cdxgen ${terminalLink( + 'BOM lifecycles documentation', + 'https://cyclonedx.github.io/cdxgen/#/ADVANCED?id=bom-lifecycles', + )} for more details.\n`, + ) + } + if (yargv.output === undefined) { + yargv.output = 'socket-cdx.json' + } + } + + await runCdxgen(yargv) +} diff --git a/src/commands/manifest/cmd-manifest-cdxgen.test.mts b/src/commands/manifest/cmd-manifest-cdxgen.test.mts new file mode 100644 index 000000000..17fb30b98 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-cdxgen.test.mts @@ -0,0 +1,113 @@ +import { describe, expect } from 'vitest' + +import { cmdit, invokeNpm } from '../../../test/utils.mts' +import constants from '../../constants.mts' + +describe('socket manifest cdxgen', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'cdxgen', '--help'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd, { + // Need to pass it on as env because --config will break cdxgen + SOCKET_CLI_CONFIG: '{}', + }) + expect(stdout).toMatchInlineSnapshot( + ` + "cdxgen [command] + + Commands: + cdxgen completion Generate bash/zsh completion + + Options: + -o, --output Output file. Default bom.json [default: "bom.json"] + -t, --type Project type. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TYPES for supported languages/platforms. [array] + --exclude-type Project types to exclude. Please refer to https://cyclonedx.github.io/cdxgen/#/PROJECT_TYPES for supported languages/platforms. + -r, --recurse Recurse mode suitable for mono-repos. Defaults to true. Pass --no-recurse to disable. [boolean] [default: true] + -p, --print Print the SBOM as a table with tree. [boolean] + -c, --resolve-class Resolve class names for packages. jars only for now. [boolean] + --deep Perform deep searches for components. Useful while scanning C/C++ apps, live OS and oci images. [boolean] + --server-url Dependency track url. Eg: https://deptrack.cyclonedx.io + --skip-dt-tls-check Skip TLS certificate check when calling Dependency-Track. [boolean] [default: false] + --api-key Dependency track api key + --project-group Dependency track project group + --project-name Dependency track project name. Default use the directory name + --project-version Dependency track project version [string] [default: ""] + --project-id Dependency track project id. Either provide the id or the project name and version together [string] + --parent-project-id Dependency track parent project id [string] + --required-only Include only the packages with required scope on the SBOM. Would set compositions.aggregate to incomplete unless --no-auto-compositions is passed. [boolean] + --fail-on-error Fail if any dependency extractor fails. [boolean] + --no-babel Do not use babel to perform usage analysis for JavaScript/TypeScript projects. [boolean] + --generate-key-and-sign Generate an RSA public/private key pair and then sign the generated SBOM using JSON Web Signatures. [boolean] + --server Run cdxgen as a server [boolean] + --server-host Listen address [default: "127.0.0.1"] + --server-port Listen port [default: "9090"] + --install-deps Install dependencies automatically for some projects. Defaults to true but disabled for containers and oci scans. Use --no-install-deps to disable this feature. [boolean] [default: true] + --validate Validate the generated SBOM using json schema. Defaults to true. Pass --no-validate to disable. [boolean] [default: true] + --evidence Generate SBOM with evidence for supported languages. [boolean] [default: false] + --spec-version CycloneDX Specification version to use. Defaults to 1.6 [number] [choices: 1.4, 1.5, 1.6, 1.7] [default: 1.6] + --filter Filter components containing this word in purl or component.properties.value. Multiple values allowed. [array] + --only Include components only containing this word in purl. Useful to generate BOM with first party components alone. Multiple values allowed. [array] + --author The person(s) who created the BOM. Set this value if you're intending the modify the BOM and claim authorship. [array] [default: "OWASP Foundation"] + --profile BOM profile to use for generation. Default generic. [choices: "appsec", "research", "operational", "threat-modeling", "license-compliance", "generic", "machine-learning", "ml", "deep-learning", "ml-deep", "ml-tiny"] [default: "generic"] + --exclude Additional glob pattern(s) to ignore [array] + --include-formulation Generate formulation section with git metadata and build tools. Defaults to false. [boolean] [default: false] + --include-crypto Include crypto libraries as components. [boolean] [default: false] + --standard The list of standards which may consist of regulations, industry or organizational-specific standards, maturity models, best practices, or any other requirements which can be evaluated against or attested to. [array] [choices: "asvs-5.0", "asvs-4.0.3", "bsimm-v13", "masvs-2.0.0", "nist_ssdf-1.1", "pcissc-secure-slc-1.1", "scvs-1.0.0", "ssaf-DRAFT-2023-11"] + --json-pretty Pretty-print the generated BOM json. [boolean] [default: false] + --min-confidence Minimum confidence needed for the identity of a component from 0 - 1, where 1 is 100% confidence. [number] [default: 0] + --technique Analysis technique to use [array] [choices: "auto", "source-code-analysis", "binary-analysis", "manifest-analysis", "hash-comparison", "instrumentation", "filename"] + --auto-compositions Automatically set compositions when the BOM was filtered. Defaults to true [boolean] [default: true] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + Examples: + cdxgen -t java . Generate a Java SBOM for the current directory + cdxgen -t java -t js . Generate a SBOM for Java and JavaScript in the current directory + cdxgen -t java --profile ml . Generate a Java SBOM for machine learning purposes. + cdxgen -t python --profile research . Generate a Python SBOM for appsec research. + cdxgen --server Run cdxgen as a server + + for documentation, visit https://cyclonedx.github.io/cdxgen" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest cdxgen\`, cwd: <redacted>" + `) + + // expect(code, 'explicit help should exit with code 0').toBe(0) + expect(code, 'help should exit with code 2').toBe(0) // cdxgen special case + expect(stderr, 'banner includes base command').toContain( + '`socket manifest cdxgen`', + ) + }, + ) + + // cdxgen does not support --dry-run + // cmdit( + // ['cdxgen', '--help', '--config', '{"apiToken":"anything"}'], + // 'should require args with just dry-run', + // async cmd => { + // const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + // expect(stdout).toMatchInlineSnapshot(`""`) + // expect(`\n ${stderr}`).toMatchInlineSnapshot(` + // " + // _____ _ _ /--------------- + // | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + // |__ | . | _| '_| -_| _| | Node: <redacted>, API token set: <redacted> + // |_____|___|___|_,_|___|_|.dev | Command: \`socket cdxgen\`, cwd: <redacted> + // + // \\x1b[31m\\xd7\\x1b[39m Unknown argument: --dry-run" + // `) + // + // expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + // } + // ) +}) diff --git a/src/commands/manifest/cmd-manifest-conda.mts b/src/commands/manifest/cmd-manifest-conda.mts new file mode 100644 index 000000000..b2aa15450 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-conda.mts @@ -0,0 +1,192 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleManifestConda } from './handle-manifest-conda.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'conda', + description: + '[beta] Convert a Conda environment.yml file to a python requirements.txt', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + file: { + type: 'string', + description: + 'Input file name (by default for Conda this is "environment.yml"), relative to cwd', + }, + stdin: { + type: 'boolean', + description: 'Read the input from stdin (supersedes --file)', + }, + out: { + type: 'string', + description: 'Output path (relative to cwd)', + }, + stdout: { + type: 'boolean', + description: + 'Print resulting requirements.txt to stdout (supersedes --out)', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Warning: While we don't support Conda necessarily, this tool extracts the pip + block from an environment.yml and outputs it as a requirements.txt + which you can scan as if it were a pypi package. + + USE AT YOUR OWN RISK + + Note: FILE can be a dash (-) to indicate stdin. This way you can pipe the + contents of a file to have it processed. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + + $ ${command} + $ ${command} ./project/foo --file environment.yaml + `, +} + +export const cmdManifestConda = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json = false, markdown = false } = cli.flags + let { file: filename, out, stdin, stdout, verbose } = cli.flags + const outputKind = getOutputKind(json, markdown) + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const socketJson = await readOrDefaultSocketJson(cwd) + + // Set defaults for any flag/arg that is not given. Check socket.json first. + if ( + stdin === undefined && + socketJson.defaults?.manifest?.conda?.stdin !== undefined + ) { + stdin = socketJson.defaults?.manifest?.conda?.stdin + logger.info('Using default --stdin from socket.json:', stdin) + } + if (stdin) { + filename = '-' + } else if (!filename) { + if (socketJson.defaults?.manifest?.conda?.infile) { + filename = socketJson.defaults?.manifest?.conda?.infile + logger.info('Using default --file from socket.json:', filename) + } else { + filename = 'environment.yml' + } + } + if ( + stdout === undefined && + socketJson.defaults?.manifest?.conda?.stdout !== undefined + ) { + stdout = socketJson.defaults?.manifest?.conda?.stdout + logger.info('Using default --stdout from socket.json:', stdout) + } + if (stdout) { + out = '-' + } else if (!out) { + if (socketJson.defaults?.manifest?.conda?.outfile) { + out = socketJson.defaults?.manifest?.conda?.outfile + logger.info('Using default --out from socket.json:', out) + } else { + out = 'requirements.txt' + } + } + if ( + verbose === undefined && + socketJson.defaults?.manifest?.conda?.verbose !== undefined + ) { + verbose = socketJson.defaults?.manifest?.conda?.verbose + logger.info('Using default --verbose from socket.json:', verbose) + } else if (verbose === undefined) { + verbose = false + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- target:', cwd) + logger.log('- output:', out) + logger.groupEnd() + } + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + pass: 'ok', + fail: 'received ' + cli.input.length, + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + logger.warn( + 'Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk.', + ) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleManifestConda({ + cwd, + filename: String(filename), + out: String(out || ''), + outputKind, + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest-conda.test.mts b/src/commands/manifest/cmd-manifest-conda.test.mts new file mode 100644 index 000000000..244a8bb0b --- /dev/null +++ b/src/commands/manifest/cmd-manifest-conda.test.mts @@ -0,0 +1,169 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest conda', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'conda', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "[beta] Convert a Conda environment.yml file to a python requirements.txt + + Usage + $ socket manifest conda [options] [CWD=.] + + Warning: While we don't support Conda necessarily, this tool extracts the pip + block from an environment.yml and outputs it as a requirements.txt + which you can scan as if it were a pypi package. + + USE AT YOUR OWN RISK + + Note: FILE can be a dash (-) to indicate stdin. This way you can pipe the + contents of a file to have it processed. + + Options + --file Input file name (by default for Conda this is "environment.yml"), relative to cwd + --json Output result as json + --markdown Output result as markdown + --out Output path (relative to cwd) + --stdin Read the input from stdin (supersedes --file) + --stdout Print resulting requirements.txt to stdout (supersedes --out) + --verbose Print debug messages + + Examples + + $ socket manifest conda + $ socket manifest conda ./project/foo --file environment.yaml" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest conda`', + ) + }, + ) + + cmdit( + ['manifest', 'conda', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Warning: This will approximate your Conda dependencies using PyPI. We do not yet officially support Conda. Use at your own risk." + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + describe('output flags', () => { + cmdit( + [ + 'manifest', + 'conda', + 'two', + 'three', // this triggers the error + '--config', + '{}', + ], + 'should print raw text without flags', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Can only accept one DIR (make sure to escape spaces!) (\\x1b[31mreceived 2\\x1b[39m) + \\x1b[22m" + `) + }, + ) + + cmdit( + [ + 'manifest', + 'conda', + 'two', + 'three', // this triggers the error + '--json', + '--config', + '{}', + ], + 'should print a json blurb with --json flag', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(` + "{ + "ok": false, + "message": "Input error", + "data": "Please review the input requirements and try again\\n\\n - Can only accept one DIR (make sure to escape spaces!) (\\u001b[31mreceived 2\\u001b[39m)\\n" + }" + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted>" + `) + }, + ) + + cmdit( + [ + 'manifest', + 'conda', + 'two', + 'three', // this triggers the error + '--markdown', + '--config', + '{}', + ], + 'should print a markdown blurb with --markdown flag', + async cmd => { + const { stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest conda\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Can only accept one DIR (make sure to escape spaces!) (\\x1b[31mreceived 2\\x1b[39m) + \\x1b[22m" + `) + }, + ) + }) +}) diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts new file mode 100644 index 000000000..0dea8f882 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -0,0 +1,177 @@ +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'gradle', + description: + '[beta] Use Gradle to generate a manifest file (`pom.xml`) for a Gradle/Java/Kotlin/etc project', + hidden: false, + flags: { + ...commonFlags, + bin: { + type: 'string', + description: 'Location of gradlew binary to use, default: CWD/gradlew', + }, + gradleOpts: { + type: 'string', + description: + 'Additional options to pass on to ./gradlew, see `./gradlew --help`', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Uses gradle, preferably through your local project \`gradlew\`, to generate a + \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the + global \`gradle\` binary but that may not work (hard to predict). + + The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or + or requirements.txt for PyPi), but specifically for Maven, which is Java's + dependency repository. Languages like Kotlin and Scala piggy back on it too. + + There are some caveats with the gradle to \`pom.xml\` conversion: + + - each task will generate its own xml file and by default it generates one xml + for every task. (This may be a good thing!) + + - it's possible certain features don't translate well into the xml. If you + think something is missing that could be supported please reach out. + + - it works with your \`gradlew\` from your repo and local settings and config + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ ${command} . + $ ${command} --bin=../gradlew . + `, +} + +export const cmdManifestGradle = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json = false, markdown = false } = cli.flags + let { bin, gradleOpts, verbose } = cli.flags + const outputKind = getOutputKind(json, markdown) // TODO: impl json/md further + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const socketJson = await readOrDefaultSocketJson(cwd) + + debugLog( + '[DEBUG] socket.json gradle override:', + socketJson?.defaults?.manifest?.gradle, + ) + + // Set defaults for any flag/arg that is not given. Check socket.json first. + if (!bin) { + if (socketJson.defaults?.manifest?.gradle?.bin) { + bin = socketJson.defaults?.manifest?.gradle?.bin + logger.info('Using default --bin from socket.json:', bin) + } else { + bin = path.join(cwd, 'gradlew') + } + } + if (!gradleOpts) { + if (socketJson.defaults?.manifest?.gradle?.gradleOpts) { + gradleOpts = socketJson.defaults?.manifest?.gradle?.gradleOpts + logger.info('Using default --gradleOpts from socket.json:', gradleOpts) + } else { + gradleOpts = '' + } + } + if (verbose === undefined) { + if (socketJson.defaults?.manifest?.gradle?.verbose !== undefined) { + verbose = socketJson.defaults?.manifest?.gradle?.verbose + logger.info('Using default --verbose from socket.json:', verbose) + } else { + verbose = false + } + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.groupEnd() + } + + // TODO: I'm not sure it's feasible to parse source file from stdin. We could + // try, store contents in a file in some folder, target that folder... what + // would the file name be? + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + pass: 'ok', + fail: 'received ' + cli.input.length, + }) + if (!wasValidInput) { + return + } + + if (verbose) { + logger.group() + logger.info('- cwd:', cwd) + logger.info('- gradle bin:', bin) + logger.groupEnd() + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await convertGradleToMaven({ + bin: String(bin), + cwd, + gradleOpts: String(gradleOpts || '') + .split(' ') + .map(s => s.trim()) + .filter(Boolean), + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts new file mode 100644 index 000000000..732f931a4 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -0,0 +1,85 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest gradle', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'gradle', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "[beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project + + Usage + $ socket manifest gradle [options] [CWD=.] + + Options + --bin Location of gradlew binary to use, default: CWD/gradlew + --gradleOpts Additional options to pass on to ./gradlew, see \`./gradlew --help\` + --verbose Print debug messages + + Uses gradle, preferably through your local project \`gradlew\`, to generate a + \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the + global \`gradle\` binary but that may not work (hard to predict). + + The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or + or requirements.txt for PyPi), but specifically for Maven, which is Java's + dependency repository. Languages like Kotlin and Scala piggy back on it too. + + There are some caveats with the gradle to \`pom.xml\` conversion: + + - each task will generate its own xml file and by default it generates one xml + for every task. (This may be a good thing!) + + - it's possible certain features don't translate well into the xml. If you + think something is missing that could be supported please reach out. + + - it works with your \`gradlew\` from your repo and local settings and config + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ socket manifest gradle . + $ socket manifest gradle --bin=../gradlew ." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest gradle`', + ) + }, + ) + + cmdit( + ['manifest', 'gradle', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest gradle\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts new file mode 100644 index 000000000..a54c00bf7 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -0,0 +1,182 @@ +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +// TODO: we may want to dedupe some pieces for all gradle languages. I think it +// makes sense to have separate commands for them and I think it makes +// sense for the help panels to note the requested language, rather than +// `socket manifest kotlin` to print help screens with `gradle` as the +// command. Room for improvement. +const config: CliCommandConfig = { + commandName: 'kotlin', + description: + '[beta] Use Gradle to generate a manifest file (`pom.xml`) for a Kotlin project', + hidden: false, + flags: { + ...commonFlags, + bin: { + type: 'string', + description: 'Location of gradlew binary to use, default: CWD/gradlew', + }, + gradleOpts: { + type: 'string', + description: + 'Additional options to pass on to ./gradlew, see `./gradlew --help`', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Uses gradle, preferably through your local project \`gradlew\`, to generate a + \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the + global \`gradle\` binary but that may not work (hard to predict). + + The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or + or requirements.txt for PyPi), but specifically for Maven, which is Java's + dependency repository. Languages like Kotlin and Scala piggy back on it too. + + There are some caveats with the gradle to \`pom.xml\` conversion: + + - each task will generate its own xml file and by default it generates one xml + for every task. (This may be a good thing!) + + - it's possible certain features don't translate well into the xml. If you + think something is missing that could be supported please reach out. + + - it works with your \`gradlew\` from your repo and local settings and config + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ ${command} . + $ ${command} --bin=../gradlew . + `, +} + +export const cmdManifestKotlin = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json = false, markdown = false } = cli.flags + let { bin, gradleOpts, verbose } = cli.flags + const outputKind = getOutputKind(json, markdown) // TODO: impl json/md further + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const socketJson = await readOrDefaultSocketJson(cwd) + + debugLog( + '[DEBUG] socket.json gradle override:', + socketJson?.defaults?.manifest?.gradle, + ) + + // Set defaults for any flag/arg that is not given. Check socket.json first. + if (!bin) { + if (socketJson.defaults?.manifest?.gradle?.bin) { + bin = socketJson.defaults?.manifest?.gradle?.bin + logger.info('Using default --bin from socket.json:', bin) + } else { + bin = path.join(cwd, 'gradlew') + } + } + if (!gradleOpts) { + if (socketJson.defaults?.manifest?.gradle?.gradleOpts) { + gradleOpts = socketJson.defaults?.manifest?.gradle?.gradleOpts + logger.info('Using default --gradleOpts from socket.json:', gradleOpts) + } else { + gradleOpts = '' + } + } + if (verbose === undefined) { + if (socketJson.defaults?.manifest?.gradle?.verbose !== undefined) { + verbose = socketJson.defaults?.manifest?.gradle?.verbose + logger.info('Using default --verbose from socket.json:', verbose) + } else { + verbose = false + } + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.groupEnd() + } + + // TODO: I'm not sure it's feasible to parse source file from stdin. We could + // try, store contents in a file in some folder, target that folder... what + // would the file name be? + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + pass: 'ok', + fail: 'received ' + cli.input.length, + }) + if (!wasValidInput) { + return + } + + if (verbose) { + logger.group() + logger.info('- cwd:', cwd) + logger.info('- gradle bin:', bin) + logger.groupEnd() + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await convertGradleToMaven({ + bin: String(bin), + cwd, + gradleOpts: String(gradleOpts || '') + .split(' ') + .map(s => s.trim()) + .filter(Boolean), + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts new file mode 100644 index 000000000..891e06739 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -0,0 +1,85 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest kotlin', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'kotlin', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "[beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project + + Usage + $ socket manifest kotlin [options] [CWD=.] + + Options + --bin Location of gradlew binary to use, default: CWD/gradlew + --gradleOpts Additional options to pass on to ./gradlew, see \`./gradlew --help\` + --verbose Print debug messages + + Uses gradle, preferably through your local project \`gradlew\`, to generate a + \`pom.xml\` file for each task. If you have no \`gradlew\` you can try the + global \`gradle\` binary but that may not work (hard to predict). + + The \`pom.xml\` is a manifest file similar to \`package.json\` for npm or + or requirements.txt for PyPi), but specifically for Maven, which is Java's + dependency repository. Languages like Kotlin and Scala piggy back on it too. + + There are some caveats with the gradle to \`pom.xml\` conversion: + + - each task will generate its own xml file and by default it generates one xml + for every task. (This may be a good thing!) + + - it's possible certain features don't translate well into the xml. If you + think something is missing that could be supported please reach out. + + - it works with your \`gradlew\` from your repo and local settings and config + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ socket manifest kotlin . + $ socket manifest kotlin --bin=../gradlew ." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest kotlin`', + ) + }, + ) + + cmdit( + ['manifest', 'kotlin', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest kotlin\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts new file mode 100644 index 000000000..56949d844 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -0,0 +1,210 @@ +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { convertSbtToMaven } from './convert_sbt_to_maven.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'scala', + description: + "[beta] Generate a manifest file (`pom.xml`) from Scala's `build.sbt` file", + hidden: false, + flags: { + ...commonFlags, + bin: { + type: 'string', + description: 'Location of sbt binary to use', + }, + out: { + type: 'string', + description: + 'Path of output file; where to store the resulting manifest, see also --stdout', + }, + stdout: { + type: 'boolean', + description: 'Print resulting pom.xml to stdout (supersedes --out)', + }, + sbtOpts: { + type: 'string', + description: 'Additional options to pass on to sbt, as per `sbt --help`', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file. + This xml file is the dependency manifest (like a package.json + for Node.js or requirements.txt for PyPi), but specifically for Scala. + + There are some caveats with \`build.sbt\` to \`pom.xml\` conversion: + + - the xml is exported as socket.pom.xml as to not confuse existing build tools + but it will first hit your /target/sbt<version> folder (as a different name) + + - the pom.xml format (standard by Scala) does not support certain sbt features + - \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\` + - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html + + - it uses your sbt settings and local configuration verbatim + + - it can only export one target per run, so if you have multiple targets like + development and production, you must run them separately. + + You can specify --bin to override the path to the \`sbt\` binary to invoke. + + Support is beta. Please report issues or give us feedback on what's missing. + + This is only for SBT. If your Scala setup uses gradle, please see the help + sections for \`socket manifest gradle\` or \`socket cdxgen\`. + + Examples + + $ ${command} + $ ${command} ./proj --bin=/usr/bin/sbt --file=boot.sbt + `, +} + +export const cmdManifestScala = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json = false, markdown = false } = cli.flags + let { bin, out, sbtOpts, stdout, verbose } = cli.flags + const outputKind = getOutputKind(json, markdown) // TODO: impl json/md further + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const socketJson = await readOrDefaultSocketJson(cwd) + + debugLog( + '[DEBUG] socket.json sbt override:', + socketJson?.defaults?.manifest?.sbt, + ) + + // Set defaults for any flag/arg that is not given. Check socket.json first. + if (!bin) { + if (socketJson.defaults?.manifest?.sbt?.bin) { + bin = socketJson.defaults?.manifest?.sbt?.bin + logger.info('Using default --bin from socket.json:', bin) + } else { + bin = 'sbt' + } + } + if ( + stdout === undefined && + socketJson.defaults?.manifest?.sbt?.stdout !== undefined + ) { + stdout = socketJson.defaults?.manifest?.sbt?.stdout + logger.info('Using default --stdout from socket.json:', stdout) + } + if (stdout) { + out = '-' + } else if (!out) { + if (socketJson.defaults?.manifest?.sbt?.outfile) { + out = socketJson.defaults?.manifest?.sbt?.outfile + logger.info('Using default --out from socket.json:', out) + } else { + out = './socket.pom.xml' + } + } + if (!sbtOpts) { + if (socketJson.defaults?.manifest?.sbt?.sbtOpts) { + sbtOpts = socketJson.defaults?.manifest?.sbt?.sbtOpts + logger.info('Using default --sbtOpts from socket.json:', sbtOpts) + } else { + sbtOpts = '' + } + } + if ( + verbose === undefined && + socketJson.defaults?.manifest?.sbt?.verbose !== undefined + ) { + verbose = socketJson.defaults?.manifest?.sbt?.verbose + logger.info('Using default --verbose from socket.json:', verbose) + } else if (verbose === undefined) { + verbose = false + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.groupEnd() + } + + // TODO: I'm not sure it's feasible to parse source file from stdin. We could + // try, store contents in a file in some folder, target that folder... what + // would the file name be? + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + pass: 'ok', + fail: 'received ' + cli.input.length, + }) + if (!wasValidInput) { + return + } + + if (verbose) { + logger.group() + logger.log('- target:', cwd) + logger.log('- sbt bin:', bin) + logger.log('- out:', out) + logger.groupEnd() + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await convertSbtToMaven({ + bin: String(bin), + cwd: cwd, + out: String(out), + sbtOpts: String(sbtOpts) + .split(' ') + .map(s => s.trim()) + .filter(Boolean), + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts new file mode 100644 index 000000000..b2eed0ba0 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -0,0 +1,92 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest scala', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'scala', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "[beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file + + Usage + $ socket manifest scala [options] [CWD=.] + + Options + --bin Location of sbt binary to use + --out Path of output file; where to store the resulting manifest, see also --stdout + --sbtOpts Additional options to pass on to sbt, as per \`sbt --help\` + --stdout Print resulting pom.xml to stdout (supersedes --out) + --verbose Print debug messages + + Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file. + This xml file is the dependency manifest (like a package.json + for Node.js or requirements.txt for PyPi), but specifically for Scala. + + There are some caveats with \`build.sbt\` to \`pom.xml\` conversion: + + - the xml is exported as socket.pom.xml as to not confuse existing build tools + but it will first hit your /target/sbt<version> folder (as a different name) + + - the pom.xml format (standard by Scala) does not support certain sbt features + - \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\` + - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html + + - it uses your sbt settings and local configuration verbatim + + - it can only export one target per run, so if you have multiple targets like + development and production, you must run them separately. + + You can specify --bin to override the path to the \`sbt\` binary to invoke. + + Support is beta. Please report issues or give us feedback on what's missing. + + This is only for SBT. If your Scala setup uses gradle, please see the help + sections for \`socket manifest gradle\` or \`socket cdxgen\`. + + Examples + + $ socket manifest scala + $ socket manifest scala ./proj --bin=/usr/bin/sbt --file=boot.sbt" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest scala`', + ) + }, + ) + + cmdit( + ['manifest', 'scala', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest scala\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts new file mode 100644 index 000000000..9d7ddb696 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -0,0 +1,89 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleManifestSetup } from './handle-manifest-setup.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'setup', + description: + 'Start interactive configurator to customize default flag values for `socket manifest` in this dir', + hidden: false, + flags: { + ...commonFlags, + defaultOnReadError: { + type: 'boolean', + description: + 'If reading the socket.json fails, just use a default config? Warning: This might override the existing json file!', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + This command will try to detect all supported ecosystems in given CWD. Then + it starts a configurator where you can setup default values for certain flags + when creating manifest files in that dir. These configuration details are + then stored in a local \`socket.json\` file (which you may or may not commit + to the repo). Next time you run \`socket manifest ...\` it will load this + json file and any flags which are not explicitly set in the command but which + have been registered in the json file will get the default value set to that + value you stored rather than the hardcoded defaults. + + This helps with for example when your build binary is in a particular path + or when your build tool needs specific opts and you don't want to specify + them when running the command every time. + + You can also disable manifest generation for certain ecosystems. + + This generated configuration file will only be used locally by the CLI. You + can commit it to the repo (useful for collaboration) or choose to add it to + your .gitignore all the same. Only this CLI will use it. + + Examples + $ ${command} + $ ${command} ./proj + `, +} + +export const cmdManifestSetup = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + const { defaultOnReadError = false } = cli.flags + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleManifestSetup(cwd, Boolean(defaultOnReadError)) +} diff --git a/src/commands/manifest/cmd-manifest-setup.test.mts b/src/commands/manifest/cmd-manifest-setup.test.mts new file mode 100644 index 000000000..a08992f98 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-setup.test.mts @@ -0,0 +1,81 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest setup', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', 'setup', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Start interactive configurator to customize default flag values for \`socket manifest\` in this dir + + Usage + $ socket manifest setup [CWD=.] + + Options + --defaultOnReadErrorIf reading the socket.json fails, just use a default config? Warning: This might override the existing json file! + + This command will try to detect all supported ecosystems in given CWD. Then + it starts a configurator where you can setup default values for certain flags + when creating manifest files in that dir. These configuration details are + then stored in a local \`socket.json\` file (which you may or may not commit + to the repo). Next time you run \`socket manifest ...\` it will load this + json file and any flags which are not explicitly set in the command but which + have been registered in the json file will get the default value set to that + value you stored rather than the hardcoded defaults. + + This helps with for example when your build binary is in a particular path + or when your build tool needs specific opts and you don't want to specify + them when running the command every time. + + You can also disable manifest generation for certain ecosystems. + + This generated configuration file will only be used locally by the CLI. You + can commit it to the repo (useful for collaboration) or choose to add it to + your .gitignore all the same. Only this CLI will use it. + + Examples + $ socket manifest setup + $ socket manifest setup ./proj" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest setup`', + ) + }, + ) + + cmdit( + ['manifest', 'setup', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest setup\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest.mts b/src/commands/manifest/cmd-manifest.mts new file mode 100644 index 000000000..b15b8f038 --- /dev/null +++ b/src/commands/manifest/cmd-manifest.mts @@ -0,0 +1,85 @@ +import { cmdManifestAuto } from './cmd-manifest-auto.mts' +import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts' +import { cmdManifestConda } from './cmd-manifest-conda.mts' +import { cmdManifestGradle } from './cmd-manifest-gradle.mts' +import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' +import { cmdManifestScala } from './cmd-manifest-scala.mts' +import { cmdManifestSetup } from './cmd-manifest-setup.mts' +import { commonFlags } from '../../flags.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'manifest', + description: 'Generate a dependency manifest for given file or dir', + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <LANGUAGE> <TARGET> + + Options + ${getFlagListOutput(config.flags, 6)} + + Generates a declarative dependency manifest (like a package.json for Node.JS + or requirements.txt for PyPi), but for certain supported ecosystems + where it's common to use a dynamic manifest, like Scala's sbt. + + Only certain languages are supported and there may be language specific + configurations available. See \`manifest <language> --help\` for usage details + per language. + + Currently supported language: scala [beta], gradle [beta], kotlin (through + gradle) [beta]. + + Examples + + $ ${command} scala . + + To have it auto-detect and attempt to run: + + $ ${command} auto + `, +} + +export const cmdManifest = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + await meowWithSubcommands( + { + auto: cmdManifestAuto, + cdxgen: cmdManifestCdxgen, + conda: cmdManifestConda, + gradle: cmdManifestGradle, + kotlin: cmdManifestKotlin, + scala: cmdManifestScala, + setup: cmdManifestSetup, + }, + { + argv, + aliases: { + yolo: { + description: config.description, + hidden: true, + argv: ['auto'], + }, + }, + description: config.description, + importMeta, + flags: config.flags, + name: `${parentName} ${config.commandName}`, + }, + ) +} diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts new file mode 100644 index 000000000..13d1e832d --- /dev/null +++ b/src/commands/manifest/cmd-manifest.test.mts @@ -0,0 +1,78 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket manifest', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['manifest', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Generate a dependency manifest for given file or dir + + Usage + $ socket manifest <command> + + Commands + auto Auto-detect build and attempt to generate manifest file + cdxgen Create an SBOM with CycloneDX generator (cdxgen) + conda [beta] Convert a Conda environment.yml file to a python requirements.txt + gradle [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Gradle/Java/Kotlin/etc project + kotlin [beta] Use Gradle to generate a manifest file (\`pom.xml\`) for a Kotlin project + scala [beta] Generate a manifest file (\`pom.xml\`) from Scala's \`build.sbt\` file + setup Start interactive configurator to customize default flag values for \`socket manifest\` in this dir + + Options + (none) + + Examples + $ socket manifest --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest`', + ) + }, + ) + + cmdit( + [ + 'manifest', + 'mootools', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/convert-conda-to-requirements.mts b/src/commands/manifest/convert-conda-to-requirements.mts new file mode 100644 index 000000000..7e9f2bf9d --- /dev/null +++ b/src/commands/manifest/convert-conda-to-requirements.mts @@ -0,0 +1,142 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import type { CResult } from '../../types.mts' + +export async function convertCondaToRequirements( + filename: string, + cwd: string, + verbose: boolean, +): Promise<CResult<{ contents: string; pip: string }>> { + let contents: string + if (filename === '-') { + if (verbose) { + logger.info(`[VERBOSE] reading input from stdin`) + } + + const buf: string[] = [] + contents = await new Promise((resolve, reject) => { + process.stdin.on('data', chunk => { + const input = chunk.toString() + buf.push(input) + }) + process.stdin.on('end', () => { + resolve(buf.join('')) + }) + process.stdin.on('error', e => { + if (verbose) { + logger.error('Unexpected error while reading from stdin:', e) + } + reject(e) + }) + process.stdin.on('close', () => { + if (buf.length === 0) { + if (verbose) { + logger.error('stdin closed explicitly without data received') + } + reject(new Error('No data received from stdin')) + } else { + if (verbose) { + logger.error( + 'warning: stdin closed explicitly with some data received', + ) + } + resolve(buf.join('')) + } + }) + }) + + if (!contents) { + return { + ok: false, + message: 'Manifest Generation Failed', + cause: 'No data received from stdin', + } + } + } else { + const filepath = path.join(cwd, filename) + + if (verbose) { + logger.info(`[VERBOSE] target: ${filepath}`) + } + + if (!fs.existsSync(filepath)) { + return { + ok: false, + message: 'Manifest Generation Failed', + cause: `The file was not found at ${filepath}`, + } + } + + contents = fs.readFileSync(filepath, 'utf8') + + if (!contents) { + return { + ok: false, + message: 'Manifest Generation Failed', + cause: `File at ${filepath} is empty`, + } + } + } + + return { + ok: true, + data: { + contents, + pip: convertCondaToRequirementsFromInput(contents), + }, + } +} + +// Just extract the first pip block, if one exists at all. +export function convertCondaToRequirementsFromInput(input: string): string { + const keeping: string[] = [] + let collecting = false + let delim = '-' + let indent = '' + input.split('\n').some(line => { + if (!line) { + // Ignore empty lines + return + } + if (collecting) { + if (line.startsWith('#')) { + // Ignore comment lines (keep?) + return + } + if (line.startsWith(delim)) { + // In this case we have a line with the same indentation as the + // `- pip:` line, so we have reached the end of the pip block. + return true // the end + } else { + if (!indent) { + // Store the indentation of the block + if (line.trim().startsWith('-')) { + indent = line.split('-')[0] + '-' + if (indent.length <= delim.length) { + // The first line after the `pip:` line does not indent further + // than that so the block is empty? + return true + } + } + } + if (line.startsWith(indent)) { + keeping.push(line.slice(indent.length).trim()) + } else { + // Unexpected input. bail. + return true + } + } + } else { + // Note: the line may end with a line comment so don't === it. + if (line.trim().startsWith('- pip:')) { + delim = line.split('-')[0] + '-' + collecting = true + } + } + }) + + return keeping.join('\n') +} diff --git a/src/commands/manifest/convert-conda-to-requirements.test.mts b/src/commands/manifest/convert-conda-to-requirements.test.mts new file mode 100644 index 000000000..d5faa1924 --- /dev/null +++ b/src/commands/manifest/convert-conda-to-requirements.test.mts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' + +import { convertCondaToRequirementsFromInput } from './convert-conda-to-requirements.mts' + +describe('convert-conda-to-requirements', () => { + it('should convert a simple example', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should support arbitrary indent block', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should support single space indented block', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should support comment and empty lines inside pip block', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should support block closing on further indent than start', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 + - the end +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should support block closing on closer indent than start', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv +channels: + - defaults +dependencies: + - python=3.8 + - pip + - pip: + - pandas + - numpy==1.21.0 + - requests>=2.26.0 +- the end +`) + + expect(output).toMatchInlineSnapshot(` + "pandas + numpy==1.21.0 + requests>=2.26.0" + `) + }) + + it('should convert an example with stuff after the pip block', () => { + const output = convertCondaToRequirementsFromInput(` +channels: +- defaults +- conda-forge +- conda +- pytorch +- nvidia +- anaconda +- https://repo.continuum.io/pkgs/main +- conda-forge +- Gurobi +dependencies: +- python=3.9 +- gurobi>=12.0.0 +- ordered-set +- pygraphviz=1.9 +- pydot=1.4.2 +- pympler +- dill +- pytest +- pip: + - aiohttp==3.8.4 + - requests==2.30.0 + - networkx==3.1 + - numpy==1.24.3 + - scipy==1.10.1 + - pandas==2.0.1 + - dotwiz==0.4.0 + - pydantic==2.7.1 + - pyyaml==6.0.1 + - psutil==5.9.0 + - memray==1.14.0 + - optuna>=4.1.0 +name: py-optim + `) + + expect(output).toMatchInlineSnapshot(` + "aiohttp==3.8.4 + requests==2.30.0 + networkx==3.1 + numpy==1.24.3 + scipy==1.10.1 + pandas==2.0.1 + dotwiz==0.4.0 + pydantic==2.7.1 + pyyaml==6.0.1 + psutil==5.9.0 + memray==1.14.0 + optuna>=4.1.0" + `) + }) + + it('should convert an more complex example', () => { + const output = convertCondaToRequirementsFromInput(` +name: myenv # Environment name (optional but recommended) + +channels: # Package sources/repositories + - conda-forge # Higher priority channel + - defaults # Lower priority channel + +dependencies: # List of packages to install + # Conda packages (direct dependencies) + - python=3.9 # Major.Minor version + - pandas>=1.3.0 # Greater than or equal to version + - numpy~=1.21.0 # Compatible release (same as >=1.21.0,<1.22.0) + - scipy==1.7.0 # Exact version + - matplotlib<3.5.0 # Less than version + + # Optional: specify build number + - package=1.0.0=h123456_0 # package=version=build_string + + # Pip packages (installed via pip) + - pip # Include pip itself + - pip: # Packages to be installed via pip + - tensorflow>=2.0.0 + - torch==1.9.0 + - transformers + - -r requirements.txt # Can include requirements.txt file + - git+https://github.com/user/repo.git # Install from git + + # Platform-specific dependencies + - cudatoolkit=11.0 # Only for systems with NVIDIA GPU +`) + + expect(output).toMatchInlineSnapshot(` + "tensorflow>=2.0.0 + torch==1.9.0 + transformers + -r requirements.txt # Can include requirements.txt file + git+https://github.com/user/repo.git # Install from git" + `) + }) +}) diff --git a/src/commands/manifest/convert_gradle_to_maven.mts b/src/commands/manifest/convert_gradle_to_maven.mts new file mode 100644 index 000000000..9e4920713 --- /dev/null +++ b/src/commands/manifest/convert_gradle_to_maven.mts @@ -0,0 +1,136 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' + +export async function convertGradleToMaven({ + bin, + cwd, + gradleOpts, + verbose, +}: { + bin: string + cwd: string + verbose: boolean + gradleOpts: string[] +}) { + // TODO: impl json/md + + // Note: use resolve because the bin could be an absolute path, away from cwd + // TODO: what about $PATH resolved commands? (`gradlew` without dir prefix) + const rBin = path.resolve(cwd, bin) + const binExists = fs.existsSync(rBin) + const cwdExists = fs.existsSync(cwd) + + logger.group('gradle2maven:') + logger.info(`- executing: \`${rBin}\``) + if (!binExists) { + logger.warn( + `Warning: It appears the executable could not be found. An error might be printed later because of that.`, + ) + } + logger.info(`- src dir: \`${cwd}\``) + if (!cwdExists) { + logger.warn( + `Warning: It appears the src dir could not be found. An error might be printed later because of that.`, + ) + } + logger.groupEnd() + + try { + // Run gradlew with the init script we provide which should yield zero or more + // pom files. We have to figure out where to store those pom files such that + // we can upload them and predict them through the GitHub API. We could do a + // .socket folder. We could do a socket.pom.gz with all the poms, although + // I'd prefer something plain-text if it is to be committed. + // Note: init.gradle will be exported by .config/rollup.dist.config.mjs + const initLocation = path.join(constants.distPath, 'init.gradle') + const commandArgs = ['--init-script', initLocation, ...gradleOpts, 'pom'] + if (verbose) { + logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) + } + logger.log(`Converting gradle to maven from \`${bin}\` on \`${cwd}\` ...`) + const output = await execGradleWithSpinner(rBin, commandArgs, cwd) + if (verbose) { + logger.group('[VERBOSE] gradle stdout:') + logger.log(output) + logger.groupEnd() + } + if (output.code !== 0) { + process.exitCode = 1 + logger.fail(`Gradle exited with exit code ${output.code}`) + // (In verbose mode, stderr was printed above, no need to repeat it) + if (!verbose) { + logger.group('stderr:') + logger.error(output.stderr) + logger.groupEnd() + } + return + } + logger.success('Executed gradle successfully') + logger.log('Reported exports:') + output.stdout.replace( + /^POM file copied to: (.*)/gm, + (_all: string, fn: string) => { + logger.log('- ', fn) + return fn + }, + ) + logger.log('') + logger.log( + 'Next step is to generate a Scan by running the `socket scan create` command on the same directory', + ) + } catch (e) { + process.exitCode = 1 + logger.fail( + 'There was an unexpected error while generating manifests' + + (verbose ? '' : ' (use --verbose for details)'), + ) + if (verbose) { + logger.group('[VERBOSE] error:') + logger.log(e) + logger.groupEnd() + } + } +} + +async function execGradleWithSpinner( + bin: string, + commandArgs: string[], + cwd: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + // Lazily access constants.spinner. + const { spinner } = constants + + let pass = false + try { + logger.info( + '(Running gradle can take a while, it depends on how long gradlew has to run)', + ) + logger.info( + '(It will show no output, you can use --verbose to see its output)', + ) + spinner.start(`Running gradlew`) + + const output = await spawn(bin, commandArgs, { + // We can pipe the output through to have the user see the result + // of running gradlew, but then we can't (easily) gather the output + // to discover the generated files... probably a flag we should allow? + // stdio: isDebug() ? 'inherit' : undefined, + cwd, + }) + + pass = true + const { code, stderr, stdout } = output + return { code, stdout, stderr } + } finally { + if (pass) { + spinner.successAndStop('Gracefully completed gradlew execution.') + } else { + spinner.failAndStop('There was an error while trying to run gradlew.') + } + } +} diff --git a/src/commands/manifest/convert_sbt_to_maven.mts b/src/commands/manifest/convert_sbt_to_maven.mts new file mode 100644 index 000000000..029a5df56 --- /dev/null +++ b/src/commands/manifest/convert_sbt_to_maven.mts @@ -0,0 +1,122 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { safeReadFile } from '../../utils/fs.mts' + +export async function convertSbtToMaven({ + bin, + cwd, + out, + sbtOpts, + verbose, +}: { + bin: string + cwd: string + out: string + sbtOpts: string[] + verbose: boolean +}) { + // TODO: impl json/md + + // Lazily access constants.spinner. + const { spinner } = constants + + logger.group('sbt2maven:') + logger.info(`- executing: \`${bin}\``) + logger.info(`- src dir: \`${cwd}\``) + logger.groupEnd() + + try { + spinner.start(`Converting sbt to maven from \`${bin}\` on \`${cwd}\`...`) + + // Run sbt with the init script we provide which should yield zero or more + // pom files. We have to figure out where to store those pom files such that + // we can upload them and predict them through the GitHub API. We could do a + // .socket folder. We could do a socket.pom.gz with all the poms, although + // I'd prefer something plain-text if it is to be committed. + const output = await spawn(bin, ['makePom', ...sbtOpts], { cwd }) + + spinner.stop() + + if (verbose) { + logger.group('[VERBOSE] sbt stdout:') + logger.log(output) + logger.groupEnd() + } + if (output.stderr) { + process.exitCode = 1 + logger.fail('There were errors while running sbt') + // (In verbose mode, stderr was printed above, no need to repeat it) + if (!verbose) { + logger.group('[VERBOSE] stderr:') + logger.error(output.stderr) + logger.groupEnd() + } + return + } + const poms: string[] = [] + output.stdout.replace(/Wrote (.*?.pom)\n/g, (_all: string, fn: string) => { + poms.push(fn) + return fn + }) + if (!poms.length) { + process.exitCode = 1 + logger.fail( + 'There were no errors from sbt but it seems to not have generated any poms either', + ) + return + } + // Move the pom file to ...? initial cwd? loc will be an absolute path, or dump to stdout + // TODO: what to do with multiple output files? Do we want to dump them to stdout? Raw or with separators or ? + // TODO: maybe we can add an option to target a specific file to dump to stdout + if (out === '-' && poms.length === 1) { + logger.log('Result:\n```') + logger.log(await safeReadFile(poms[0]!)) + logger.log('```') + logger.success(`OK`) + } else if (out === '-') { + process.exitCode = 1 + logger.error('') + logger.fail( + 'Requested output target was stdout but there are multiple generated files', + ) + logger.error('') + poms.forEach(fn => logger.info('-', fn)) + if (poms.length > 10) { + logger.error('') + logger.fail( + 'Requested output target was stdout but there are multiple generated files', + ) + } + logger.error('') + logger.info('Exiting now...') + return + } else { + // if (verbose) { + // logger.log( + // `Moving manifest file from \`${loc.replace(/^\/home\/[^/]*?\//, '~/')}\` to \`${out}\`` + // ) + // } else { + // logger.log('Moving output pom file') + // } + // TODO: do we prefer fs-extra? renaming can be gnarly on windows and fs-extra's version is better + // await renamep(loc, out) + logger.success(`Generated ${poms.length} pom files`) + poms.forEach(fn => logger.log('-', fn)) + logger.success(`OK`) + } + } catch (e) { + process.exitCode = 1 + spinner.stop() + logger.fail( + 'There was an unexpected error while running this' + + (verbose ? '' : ' (use --verbose for details)'), + ) + if (verbose) { + logger.group('[VERBOSE] error:') + logger.log(e) + logger.groupEnd() + } + } +} diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts new file mode 100644 index 000000000..55641b176 --- /dev/null +++ b/src/commands/manifest/detect-manifest-actions.mts @@ -0,0 +1,65 @@ +// The point here is to attempt to detect the various supported manifest files +// the CLI can generate. This would be environments that we can't do server side + +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' + +import type { SocketJson } from '../../utils/socketjson.mts' + +export interface GeneratableManifests { + cdxgen: boolean + count: number + conda: boolean + gradle: boolean + sbt: boolean +} + +export async function detectManifestActions( + // Passing in null means we attempt detection for every supported language + // regardless of local socket.json status. Sometimes we want that. + socketJson: SocketJson | null, + cwd = process.cwd(), +): Promise<GeneratableManifests> { + const output = { + cdxgen: false, // TODO + count: 0, + conda: false, + gradle: false, + sbt: false, + } + + if (socketJson?.defaults?.manifest?.sbt?.disabled) { + debugLog('[DEBUG] - sbt auto-detection is disabled in socket.json') + } else if (existsSync(path.join(cwd, 'build.sbt'))) { + debugLog('[DEBUG] - Detected a Scala sbt build file') + + output.sbt = true + output.count += 1 + } + + if (socketJson?.defaults?.manifest?.gradle?.disabled) { + debugLog('[DEBUG] - gradle auto-detection is disabled in socket.json') + } else if (existsSync(path.join(cwd, 'gradlew'))) { + debugLog('[DEBUG] - Detected a gradle build file') + output.gradle = true + output.count += 1 + } + + if (socketJson?.defaults?.manifest?.conda?.disabled) { + debugLog('[DEBUG] - conda auto-detection is disabled in socket.json') + } else { + const envyml = path.join(cwd, 'environment.yml') + const hasEnvyml = existsSync(envyml) + const envyaml = path.join(cwd, 'environment.yaml') + const hasEnvyaml = !hasEnvyml && existsSync(envyaml) + if (hasEnvyml || hasEnvyaml) { + debugLog('[DEBUG] - Detected an environment.yml Conda file') + output.conda = true + output.count += 1 + } + } + + return output +} diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts new file mode 100644 index 000000000..0f2853747 --- /dev/null +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -0,0 +1,81 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import { convertSbtToMaven } from './convert_sbt_to_maven.mts' +import { handleManifestConda } from './handle-manifest-conda.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { GeneratableManifests } from './detect-manifest-actions.mts' +import type { OutputKind } from '../../types.mts' + +export async function generateAutoManifest({ + cwd, + detected, + outputKind, + verbose, +}: { + detected: GeneratableManifests + cwd: string + outputKind: OutputKind + verbose: boolean +}) { + const socketJson = await readOrDefaultSocketJson(cwd) + + if (verbose) { + logger.info('Using this socket.json for defaults:', socketJson) + } + + if (!socketJson?.defaults?.manifest?.sbt?.disabled && detected.sbt) { + logger.log('Detected a Scala sbt build, generating pom files with sbt...') + await convertSbtToMaven({ + // Note: `sbt` is more likely to be resolved against PATH env + bin: socketJson.defaults?.manifest?.sbt?.bin ?? 'sbt', + cwd, + out: + socketJson.defaults?.manifest?.sbt?.outfile ?? './socket.sbt.pom.xml', + sbtOpts: + socketJson.defaults?.manifest?.sbt?.sbtOpts + ?.split(' ') + .map(s => s.trim()) + .filter(Boolean) ?? [], + verbose: Boolean(socketJson.defaults?.manifest?.sbt?.verbose), + }) + } + + if (!socketJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) { + logger.log( + 'Detected a gradle build (Gradle, Kotlin, Scala), running default gradle generator...', + ) + await convertGradleToMaven({ + // Note: `gradlew` is more likely to be resolved against cwd + // Note: .resolve() wont butcher an absolute path + // TODO: `gradlew` (or anything else given) may want to resolve against PATH + bin: socketJson.defaults?.manifest?.gradle?.bin + ? path.resolve(cwd, socketJson.defaults.manifest.gradle.bin) + : path.join(cwd, 'gradlew'), + cwd, + verbose: Boolean(socketJson.defaults?.manifest?.gradle?.verbose), + gradleOpts: + socketJson.defaults?.manifest?.gradle?.gradleOpts + ?.split(' ') + .map(s => s.trim()) + .filter(Boolean) ?? [], + }) + } + + if (!socketJson?.defaults?.manifest?.conda?.disabled && detected.conda) { + logger.log( + 'Detected an environment.yml file, running default Conda generator...', + ) + await handleManifestConda({ + cwd, + filename: + socketJson.defaults?.manifest?.conda?.infile ?? 'environment.yml', + outputKind, + out: socketJson.defaults?.manifest?.conda?.outfile ?? 'requirements.txt', + verbose: Boolean(socketJson.defaults?.manifest?.conda?.verbose), + }) + } +} diff --git a/packages/cli/src/commands/manifest/handle-manifest-conda.mts b/src/commands/manifest/handle-manifest-conda.mts similarity index 100% rename from packages/cli/src/commands/manifest/handle-manifest-conda.mts rename to src/commands/manifest/handle-manifest-conda.mts diff --git a/packages/cli/src/commands/manifest/handle-manifest-setup.mts b/src/commands/manifest/handle-manifest-setup.mts similarity index 100% rename from packages/cli/src/commands/manifest/handle-manifest-setup.mts rename to src/commands/manifest/handle-manifest-setup.mts diff --git a/packages/cli/src/commands/manifest/init.gradle b/src/commands/manifest/init.gradle similarity index 96% rename from packages/cli/src/commands/manifest/init.gradle rename to src/commands/manifest/init.gradle index fc4f1f7ec..a64e5d93c 100644 --- a/packages/cli/src/commands/manifest/init.gradle +++ b/src/commands/manifest/init.gradle @@ -10,16 +10,15 @@ initscript { repositories { - // Note: These repositories are declared for potential plugin resolution, - // but currently unused since we only rely on built-in plugins. - // Kept for compatibility with projects that may need them. + // We need these repositories for Gradle's plugin resolution system + // TODO: it's not clear if we actually need them. gradlePluginPortal() mavenCentral() google() } dependencies { - // No external dependencies needed as we only use Gradle's built-in maven-publish plugin. + // No external dependencies needed as we only use Gradle's built-in maven-publish plugin } } @@ -94,9 +93,9 @@ gradle.allprojects { project -> // Store all dependencies we find here def projectDependencies = [] - // Find all relevant dependency configurations. - // We target production dependencies (implementation, api, compile, runtime). - // Test configurations are intentionally excluded via the filter below. + // Find all relevant dependency configurations + // We care about implementation, api, compile, and runtime configurations + // TODO: anything we're missing here? tests maybe? def relevantConfigs = p.configurations.findAll { config -> !config.name.toLowerCase().contains('test') && (config.name.endsWith('Implementation') || diff --git a/src/commands/manifest/output-manifest-setup.mts b/src/commands/manifest/output-manifest-setup.mts new file mode 100644 index 000000000..dff7b75c6 --- /dev/null +++ b/src/commands/manifest/output-manifest-setup.mts @@ -0,0 +1,18 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' + +import type { CResult } from '../../types.mts' + +export async function outputManifestSetup(result: CResult<unknown>) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.success('Setup complete') +} diff --git a/src/commands/manifest/output-requirements.mts b/src/commands/manifest/output-requirements.mts new file mode 100644 index 000000000..7883ba37f --- /dev/null +++ b/src/commands/manifest/output-requirements.mts @@ -0,0 +1,68 @@ +import fs from 'node:fs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputRequirements( + result: CResult<{ contents: string; pip: string }>, + outputKind: OutputKind, + out: string, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'json') { + const json = serializeResultJson(result) + + if (out === '-') { + logger.log(json) + } else { + fs.writeFileSync(out, json, 'utf8') + } + + return + } + + if (outputKind === 'markdown') { + const arr = [] + arr.push('# Converted Conda file') + arr.push('') + arr.push( + 'This is the Conda `environment.yml` file converted to python `requirements.txt`:', + ) + arr.push('') + arr.push('```file=requirements.txt') + arr.push(result.data.pip) + arr.push('```') + arr.push('') + const md = arr.join('\n') + + if (out === '-') { + logger.log(md) + } else { + fs.writeFileSync(out, md, 'utf8') + } + return + } + + if (out === '-') { + logger.log(result.data.pip) + logger.log('') + } else { + fs.writeFileSync(out, result.data.pip, 'utf8') + } +} diff --git a/src/commands/manifest/run-cdxgen.mts b/src/commands/manifest/run-cdxgen.mts new file mode 100644 index 000000000..84895565e --- /dev/null +++ b/src/commands/manifest/run-cdxgen.mts @@ -0,0 +1,95 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' + +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import shadowBin from '../../shadow/npm/bin.mts' + +const { NPM, NPX, PACKAGE_LOCK_JSON, PNPM, YARN, YARN_LOCK } = constants + +const nodejsPlatformTypes = new Set([ + 'javascript', + 'js', + 'nodejs', + NPM, + PNPM, + 'ts', + 'tsx', + 'typescript', +]) + +function argvToArray(argv: { + [key: string]: boolean | null | number | string | Array<string | number> +}): string[] { + if (argv['help']) { + return ['--help'] + } + const result = [] + for (const { 0: key, 1: value } of Object.entries(argv)) { + if (key === '_' || key === '--') { + continue + } + if (key === 'babel' || key === 'install-deps' || key === 'validate') { + // cdxgen documents no-babel, no-install-deps, and no-validate flags so + // use them when relevant. + result.push(`--${value ? key : `no-${key}`}`) + } else if (value === true) { + result.push(`--${key}`) + } else if (typeof value === 'string') { + result.push(`--${key}`, String(value)) + } else if (Array.isArray(value)) { + result.push(`--${key}`, ...value.map(String)) + } + } + if (argv['--']) { + result.push('--', ...(argv as any)['--']) + } + return result +} + +export async function runCdxgen(yargvWithYes: any) { + let cleanupPackageLock = false + const { yes, ...yargv } = { __proto__: null, ...yargvWithYes } as any + const yesArgs = yes ? ['--yes'] : [] + if ( + yargv.type !== YARN && + nodejsPlatformTypes.has(yargv.type) && + existsSync(`./${YARN_LOCK}`) + ) { + if (existsSync(`./${PACKAGE_LOCK_JSON}`)) { + yargv.type = NPM + } else { + // Use synp to create a package-lock.json from the yarn.lock, + // based on the node_modules folder, for a more accurate SBOM. + try { + await shadowBin(NPX, [ + ...yesArgs, + // Lazily access constants.ENV.INLINED_SYNP_VERSION. + `synp@${constants.ENV.INLINED_SYNP_VERSION}`, + '--source-file', + `./${YARN_LOCK}`, + ]) + yargv.type = NPM + cleanupPackageLock = true + } catch {} + } + } + await shadowBin(NPX, [ + ...yesArgs, + // Lazily access constants.ENV.INLINED_CYCLONEDX_CDXGEN_VERSION. + `@cyclonedx/cdxgen@${constants.ENV.INLINED_CYCLONEDX_CDXGEN_VERSION}`, + ...argvToArray(yargv), + ]) + if (cleanupPackageLock) { + try { + await fs.rm(`./${PACKAGE_LOCK_JSON}`) + } catch {} + } + const fullOutputPath = path.join(process.cwd(), yargv.output) + if (existsSync(fullOutputPath)) { + logger.log(colors.cyanBright(`${yargv.output} created!`)) + } +} diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts new file mode 100644 index 000000000..6330dcef9 --- /dev/null +++ b/src/commands/manifest/setup-manifest-config.mts @@ -0,0 +1,486 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { input, select } from '@socketsecurity/registry/lib/prompts' + +import { detectManifestActions } from './detect-manifest-actions.mts' +import { readSocketJson, writeSocketJson } from '../../utils/socketjson.mts' + +import type { CResult } from '../../types.mts' +import type { SocketJson } from '../../utils/socketjson.mts' + +export async function setupManifestConfig( + cwd: string, + defaultOnReadError = false, +): Promise<CResult<unknown>> { + const detected = await detectManifestActions(null, cwd) + debugLog('[DEBUG]', detected) + + // - repeat + // - give the user an option to configure one of the supported targets + // - run through an interactive prompt for selected target + // - each target will have its own specific options + // - record them to the socket.yml (or socket-cli.yml ? or just socket.json ?) + + const jsonPath = path.join(cwd, `socket.json`) + if (fs.existsSync(jsonPath)) { + logger.info(`Found socket.json at ${jsonPath}`) + } else { + logger.info(`No socket.json found at ${cwd}, will generate a new one`) + } + + logger.log('') + logger.log( + 'Note: This tool will set up flag and argument defaults for certain', + ) + logger.log(' CLI commands. You can still override them by explicitly') + logger.log(' setting the flag. It is meant to be a convenience tool.') + logger.log('') + logger.log( + 'This command will generate a `socket.json` file in the target cwd.', + ) + logger.log('You can choose to add this file to your repo (handy for collab)') + logger.log('or to add it to the ignored files, or neither. This file is only') + logger.log('used in CLI workflows.') + logger.log('') + + const choices = [ + { + name: 'Conda'.padEnd(30, ' '), + value: 'conda', + description: 'Generate requirements.txt from a Conda environment.yml', + }, + { + name: 'Gradle'.padEnd(30, ' '), + value: 'gradle', + description: 'Generate pom.xml files through gradle', + }, + { + name: 'Kotlin (gradle)'.padEnd(30, ' '), + value: 'gradle', + description: 'Generate pom.xml files (for Kotlin) through gradle', + }, + { + name: 'Scala (gradle)'.padEnd(30, ' '), + value: 'gradle', + description: 'Generate pom.xml files (for Scala) through gradle', + }, + { + name: 'Scala (sbt)'.padEnd(30, ' '), + value: 'sbt', + description: 'Generate pom.xml files through sbt', + }, + ] + + choices.forEach(obj => { + if (detected[obj.value as keyof typeof detected]) { + obj.name += ' [detected]' + } + }) + + // Surface detected language first, then by alphabet + choices.sort((a, b) => { + if ( + detected[a.value as keyof typeof detected] && + !detected[b.value as keyof typeof detected] + ) { + return -1 + } + if ( + !detected[a.value as keyof typeof detected] && + detected[b.value as keyof typeof detected] + ) { + return 1 + } + return a.value < b.value ? -1 : a.value > b.value ? 1 : 0 + }) + + // Make exit the last entry... + choices.push({ + name: 'None, exit configurator', + value: '', + description: 'Exit setup', + }) + + // TODO: use detected to list those first + const targetEco = (await select({ + message: 'Select eco system manifest generator to configure', + choices, + })) as string | null + + const socketJsonResult = await readSocketJson(cwd, defaultOnReadError) + if (!socketJsonResult.ok) { + return socketJsonResult + } + const socketJson = socketJsonResult.data + + if (!socketJson.defaults) { + socketJson.defaults = {} + } + if (!socketJson.defaults.manifest) { + socketJson.defaults.manifest = {} + } + + let result: CResult<{ canceled: boolean }> + switch (targetEco) { + case 'conda': { + if (!socketJson.defaults.manifest.conda) { + socketJson.defaults.manifest.conda = {} + } + result = await setupConda(socketJson.defaults.manifest.conda) + break + } + case 'gradle': { + if (!socketJson.defaults.manifest.gradle) { + socketJson.defaults.manifest.gradle = {} + } + result = await setupGradle(socketJson.defaults.manifest.gradle) + break + } + case 'sbt': { + if (!socketJson.defaults.manifest.sbt) { + socketJson.defaults.manifest.sbt = {} + } + result = await setupSbt(socketJson.defaults.manifest.sbt) + break + } + default: { + result = canceledByUser() + } + } + + if (!result.ok || result.data.canceled) { + return result + } + + logger.log('') + logger.log('Setup complete. Writing socket.json') + logger.log('') + + if ( + await select({ + message: `Do you want to write the new config to ${jsonPath} ?`, + choices: [ + { + name: 'yes', + value: true, + description: 'Update config', + }, + { + name: 'no', + value: false, + description: 'Do not update the config', + }, + ], + }) + ) { + return await writeSocketJson(cwd, socketJson) + } + + return canceledByUser() +} + +async function setupConda( + config: NonNullable< + NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['conda'] + >, +): Promise<CResult<{ canceled: boolean }>> { + const on = await askForEnabled(!config.disabled) + if (on === undefined) { + return canceledByUser() + } else if (on) { + delete config.disabled + } else { + config.disabled = true + } + + const inf = await askForInputFile(config.infile || 'environment.yml') + if (inf === undefined) { + return canceledByUser() + } else if (inf.trim() === '-') { + config.stdin = true + } else { + delete config.stdin + if (inf.trim()) { + config.infile = inf.trim() + } else { + delete config.infile + } + } + + const stdout = await askForStdout(config.stdout) + if (stdout === undefined) { + return canceledByUser() + } else if (stdout === 'yes') { + config.stdout = true + } else if (stdout === 'no') { + config.stdout = false + } else { + delete config.stdout + } + + if (!config.stdout) { + const out = await askForOutputFile(config.outfile || 'requirements.txt') + if (out === undefined) { + return canceledByUser() + } else if (out === '-') { + config.stdout = true + } else { + delete config.stdout + if (out?.trim()) { + config.outfile = out.trim() + } else { + delete config.outfile + } + } + } + + const verbose = await askForVerboseFlag(config.verbose) + if (verbose === undefined) { + return canceledByUser() + } else if (verbose === 'yes' || verbose === 'no') { + config.verbose = verbose === 'yes' + } else { + delete config.verbose + } + + return notCanceled() +} + +async function setupGradle( + config: NonNullable< + NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['gradle'] + >, +): Promise<CResult<{ canceled: boolean }>> { + const bin = await askForBin(config.bin || './gradlew') + if (bin === undefined) { + return canceledByUser() + } else if (bin.trim()) { + config.bin = bin.trim() + } else { + delete config.bin + } + + const opts = await input({ + message: '(--gradleOpts) Enter gradle options to pass through', + default: config.gradleOpts || '', + required: false, + // validate: async string => bool + }) + if (opts === undefined) { + return canceledByUser() + } else if (opts.trim()) { + config.gradleOpts = opts.trim() + } else { + delete config.gradleOpts + } + + const verbose = await askForVerboseFlag(config.verbose) + if (verbose === undefined) { + return canceledByUser() + } else if (verbose === 'yes' || verbose === 'no') { + config.verbose = verbose === 'yes' + } else { + delete config.verbose + } + + return notCanceled() +} + +async function setupSbt( + config: NonNullable< + NonNullable<NonNullable<SocketJson['defaults']>['manifest']>['sbt'] + >, +): Promise<CResult<{ canceled: boolean }>> { + const bin = await askForBin(config.bin || 'sbt') + if (bin === undefined) { + return canceledByUser() + } else if (bin.trim()) { + config.bin = bin.trim() + } else { + delete config.bin + } + + const opts = await input({ + message: '(--sbtOpts) Enter sbt options to pass through', + default: config.sbtOpts || '', + required: false, + // validate: async string => bool + }) + if (opts === undefined) { + return canceledByUser() + } else if (opts.trim()) { + config.sbtOpts = opts.trim() + } else { + delete config.sbtOpts + } + + const stdout = await askForStdout(config.stdout) + if (stdout === undefined) { + return canceledByUser() + } else if (stdout === 'yes') { + config.stdout = true + } else if (stdout === 'no') { + config.stdout = false + } else { + delete config.stdout + } + + if (config.stdout !== true) { + const out = await askForOutputFile(config.outfile || 'sbt.pom.xml') + if (out === undefined) { + return canceledByUser() + } else if (out === '-') { + config.stdout = true + } else { + delete config.stdout + if (out?.trim()) { + config.outfile = out.trim() + } else { + delete config.outfile + } + } + } + + const verbose = await askForVerboseFlag(config.verbose) + if (verbose === undefined) { + return canceledByUser() + } else if (verbose === 'yes' || verbose === 'no') { + config.verbose = verbose === 'yes' + } else { + delete config.verbose + } + + return notCanceled() +} + +async function askForStdout( + defaultValue: boolean | undefined, +): Promise<string | undefined> { + return await select({ + message: '(--stdout) Print the resulting pom.xml to stdout?', + choices: [ + { + name: 'no', + value: 'no', + description: 'Write output to a file, not stdout', + }, + { + name: 'yes', + value: 'yes', + description: 'Print in stdout (this will supersede --out)', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: defaultValue === true ? 'yes' : defaultValue === false ? 'no' : '', + }) +} + +async function askForEnabled( + defaultValue: boolean | undefined, +): Promise<boolean | undefined> { + return await select({ + message: + 'Do you want to enable or disable auto generating manifest files for this language in this dir?', + choices: [ + { + name: 'Enable', + value: true, + description: 'Generate manifest files for this language when detected', + }, + { + name: 'Disable', + value: false, + description: + 'Do not generate manifest files for this language when detected, unless explicitly asking for it', + }, + { + name: 'Cancel', + value: undefined, + description: 'Exit configurator', + }, + ], + default: + defaultValue === true + ? 'enable' + : defaultValue === false + ? 'disable' + : '', + }) +} + +async function askForInputFile(defaultName = ''): Promise<string | undefined> { + return await input({ + message: + '(--file) What should be the default file name to read? Should be an absolute path or relative to the cwd. Use `-` to read from stdin instead.' + + (defaultName ? ' (Backspace to leave default)' : ''), + default: defaultName, + required: false, + // validate: async string => bool + }) +} + +async function askForOutputFile(defaultName = ''): Promise<string | undefined> { + return await input({ + message: + '(--out) What should be the default output file? Should be absolute path or relative to cwd.' + + (defaultName ? ' (Backspace to leave default)' : ''), + default: defaultName, + required: false, + // validate: async string => bool + }) +} + +async function askForBin(defaultName = ''): Promise<string | undefined> { + return await input({ + message: + '(--bin) What should be the command to execute? Usually your build binary.' + + (defaultName ? ' (Backspace to leave default)' : ''), + default: defaultName, + required: false, + // validate: async string => bool + }) +} + +async function askForVerboseFlag( + current: boolean | undefined, +): Promise<string | undefined> { + return await select({ + message: '(--verbose) Should this run in verbose mode by default?', + choices: [ + { + name: 'no', + value: 'no', + description: 'Do not run this manifest in verbose mode', + }, + { + name: 'yes', + value: 'yes', + description: 'Run this manifest in verbose mode', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: current === true ? 'yes' : current === false ? 'no' : '', + }) +} + +function canceledByUser(): CResult<{ canceled: boolean }> { + logger.log('') + logger.info('User canceled') + logger.log('') + return { ok: true, data: { canceled: true } } +} + +function notCanceled(): CResult<{ canceled: boolean }> { + return { ok: true, data: { canceled: false } } +} diff --git a/src/commands/npm/cmd-npm.mts b/src/commands/npm/cmd-npm.mts new file mode 100644 index 000000000..f6e7b14ba --- /dev/null +++ b/src/commands/npm/cmd-npm.mts @@ -0,0 +1,68 @@ +import { createRequire } from 'node:module' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const require = createRequire(import.meta.url) + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'npm', + description: `npm wrapper functionality`, + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, _config) => ` + Usage + $ ${command} ... + + This runs npm but checks packages through Socket before installing anything. + See docs for more details. + + Note: Everything after "npm" is sent straight to the npm command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Use \`socket wrapper on\` to automatically enable this such that you don't + have to write \`socket npm\` for that purpose. + + Examples + $ ${command} + $ ${command} install -g socket + `, +} + +export const cmdNpm = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + allowUnknownFlags: true, + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + // Lazily access constants.shadowNpmBinPath. + const shadowBin = require(constants.shadowNpmBinPath) + await shadowBin('npm', argv) +} diff --git a/src/commands/npm/cmd-npm.test.mts b/src/commands/npm/cmd-npm.test.mts new file mode 100644 index 000000000..4f080f472 --- /dev/null +++ b/src/commands/npm/cmd-npm.test.mts @@ -0,0 +1,66 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket npm', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['npm', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "npm wrapper functionality + + Usage + $ socket npm ... + + This runs npm but checks packages through Socket before installing anything. + See docs for more details. + + Note: Everything after "npm" is sent straight to the npm command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Use \`socket wrapper on\` to automatically enable this such that you don't + have to write \`socket npm\` for that purpose. + + Examples + $ socket npm + $ socket npm install -g socket" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket npm\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket npm`') + }, + ) + + cmdit( + ['npm', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket npm\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/npx/cmd-npx.mts b/src/commands/npx/cmd-npx.mts new file mode 100644 index 000000000..1a5481ff6 --- /dev/null +++ b/src/commands/npx/cmd-npx.mts @@ -0,0 +1,68 @@ +import { createRequire } from 'node:module' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const require = createRequire(import.meta.url) + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'npx', + description: `npx wrapper functionality`, + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, _config) => ` + Usage + $ ${command} ... + + This runs npx but checks packages through Socket before running them. + See docs for more details. + + Note: Everything after "npx" is sent straight to the npx command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Use \`socket wrapper on\` to automatically enable this such that you don't + have to write \`socket npx\` for that purpose. + + Examples + $ ${command} + $ ${command} prettier + `, +} + +export const cmdNpx = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + allowUnknownFlags: true, + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + // Lazily access constants.shadowNpmBinPath. + const shadowBin = require(constants.shadowNpmBinPath) + await shadowBin('npx', argv) +} diff --git a/src/commands/npx/cmd-npx.test.mts b/src/commands/npx/cmd-npx.test.mts new file mode 100644 index 000000000..115b5a772 --- /dev/null +++ b/src/commands/npx/cmd-npx.test.mts @@ -0,0 +1,66 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket npx', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['npx', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "npx wrapper functionality + + Usage + $ socket npx ... + + This runs npx but checks packages through Socket before running them. + See docs for more details. + + Note: Everything after "npx" is sent straight to the npx command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Use \`socket wrapper on\` to automatically enable this such that you don't + have to write \`socket npx\` for that purpose. + + Examples + $ socket npx + $ socket npx prettier" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket npx\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket npx`') + }, + ) + + cmdit( + ['npx', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket npx\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/oops/cmd-oops.mts b/src/commands/oops/cmd-oops.mts new file mode 100644 index 000000000..2d7b34366 --- /dev/null +++ b/src/commands/oops/cmd-oops.mts @@ -0,0 +1,80 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'oops', + description: 'Trigger an intentional error (for development)', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags, + throw: { + type: 'boolean', + default: false, + description: + 'Throw an explicit error even if --json or --markdown are set', + }, + }, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Don't run me. + `, +} + +export const cmdOops = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown, throw: justThrow } = cli.flags + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + if (json && !justThrow) { + process.exitCode = 1 + logger.log( + serializeResultJson({ + ok: false, + message: 'Oops', + cause: 'This error was intentionally left blank', + }), + ) + } + + if (markdown && !justThrow) { + process.exitCode = 1 + logger.fail( + failMsgWithBadge('Oops', 'This error was intentionally left blank'), + ) + return + } + + throw new Error('This error was intentionally left blank') +} diff --git a/src/commands/oops/cmd-oops.test.mts b/src/commands/oops/cmd-oops.test.mts new file mode 100644 index 000000000..b817e9ac5 --- /dev/null +++ b/src/commands/oops/cmd-oops.test.mts @@ -0,0 +1,55 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket oops', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['oops', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Trigger an intentional error (for development) + + Usage + $ socket oops oops + + Don't run me." + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket oops`') + }, + ) + + cmdit( + ['oops', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket oops\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/optimize/add-overrides.mts b/src/commands/optimize/add-overrides.mts new file mode 100644 index 000000000..a6343f654 --- /dev/null +++ b/src/commands/optimize/add-overrides.mts @@ -0,0 +1,286 @@ +import path from 'node:path' + +import semver from 'semver' + +import { getManifestData } from '@socketsecurity/registry' +import { hasOwn, toSortedObject } from '@socketsecurity/registry/lib/objects' +import { fetchPackageManifest } from '@socketsecurity/registry/lib/packages' +import { pEach } from '@socketsecurity/registry/lib/promises' +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import { depsIncludesByAgent } from './deps-includes-by-agent.mts' +import { getDependencyEntries } from './get-dependency-entries.mts' +import { + getOverridesData, + getOverridesDataNpm, + getOverridesDataYarnClassic, +} from './get-overrides-by-agent.mts' +import { lockfileIncludesByAgent } from './lockfile-includes-by-agent.mts' +import { lsByAgent } from './ls-by-agent.mts' +import { CMD_NAME } from './shared.mts' +import { updateManifestByAgent } from './update-manifest-by-agent.mts' +import constants from '../../constants.mts' +import { cmdPrefixMessage } from '../../utils/cmd.mts' +import { globWorkspace } from '../../utils/glob.mts' +import { npa } from '../../utils/npm-package-arg.mts' +import { getMajor } from '../../utils/semver.mts' + +import type { GetOverridesResult } from './get-overrides-by-agent.mts' +import type { AgentLockIncludesFn } from './lockfile-includes-by-agent.mts' +import type { AliasResult } from '../../utils/npm-package-arg.mts' +import type { EnvDetails } from '../../utils/package-environment.mts' +import type { Logger } from '@socketsecurity/registry/lib/logger' +import type { PackageJson } from '@socketsecurity/registry/lib/packages' + +type AddOverridesOptions = { + logger?: Logger | undefined + pin?: boolean | undefined + prod?: boolean | undefined + spinner?: Spinner | undefined + state?: AddOverridesState | undefined +} +type AddOverridesState = { + added: Set<string> + addedInWorkspaces: Set<string> + updated: Set<string> + updatedInWorkspaces: Set<string> + warnedPnpmWorkspaceRequiresNpm: boolean +} + +const { NPM, PNPM } = constants + +const manifestNpmOverrides = getManifestData(NPM) + +export async function addOverrides( + pkgEnvDetails: EnvDetails, + pkgPath: string, + options?: AddOverridesOptions | undefined, +): Promise<AddOverridesState> { + const { + agent, + lockName, + lockSrc, + npmExecPath, + pkgPath: rootPath, + } = pkgEnvDetails + const { + logger, + pin, + prod, + spinner, + state = { + added: new Set(), + addedInWorkspaces: new Set(), + updated: new Set(), + updatedInWorkspaces: new Set(), + warnedPnpmWorkspaceRequiresNpm: false, + }, + } = { __proto__: null, ...options } as AddOverridesOptions + const workspacePkgJsonPaths = await globWorkspace(agent, pkgPath) + const isWorkspace = workspacePkgJsonPaths.length > 0 + const isWorkspaceRoot = pkgPath === rootPath + const isLockScanned = isWorkspaceRoot && !prod + const workspace = isWorkspaceRoot ? 'root' : path.relative(rootPath, pkgPath) + if ( + isWorkspace && + agent === PNPM && + // npmExecPath will === the agent name IF it CANNOT be resolved. + npmExecPath === NPM && + !state.warnedPnpmWorkspaceRequiresNpm + ) { + state.warnedPnpmWorkspaceRequiresNpm = true + logger?.warn( + cmdPrefixMessage( + CMD_NAME, + `${agent} workspace support requires \`npm ls\`, falling back to \`${agent} list\``, + ), + ) + } + + const overridesDataObjects = [] as GetOverridesResult[] + if (isWorkspace || pkgEnvDetails.editablePkgJson.content['private']) { + overridesDataObjects.push(getOverridesData(pkgEnvDetails)) + } else { + overridesDataObjects.push( + getOverridesDataNpm(pkgEnvDetails), + getOverridesDataYarnClassic(pkgEnvDetails), + ) + } + + spinner?.setText(`Adding overrides to ${workspace}...`) + + const depAliasMap = new Map<string, string>() + const depEntries = getDependencyEntries(pkgEnvDetails) + + const manifestEntries = manifestNpmOverrides.filter(({ 1: data }) => + semver.satisfies( + // Roughly check Node range as semver.coerce will strip leading + // v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~). + semver.coerce(data.engines.node)!, + pkgEnvDetails.pkgRequirements.node, + ), + ) + + // Chunk package names to process them in parallel 3 at a time. + await pEach(manifestEntries, 3, async ({ 1: data }) => { + const { name: sockRegPkgName, package: origPkgName, version } = data + const major = getMajor(version)! + const sockOverridePrefix = `${NPM}:${sockRegPkgName}@` + const sockOverrideSpec = `${sockOverridePrefix}${pin ? version : `^${major}`}` + for (const { 1: depObj } of depEntries) { + const sockSpec = hasOwn(depObj, sockRegPkgName) + ? depObj[sockRegPkgName] + : undefined + if (sockSpec) { + depAliasMap.set(sockRegPkgName, sockSpec) + } + const origSpec = hasOwn(depObj, origPkgName) + ? depObj[origPkgName] + : undefined + if (origSpec) { + let thisSpec = origSpec + // Add package aliases for direct dependencies to avoid npm EOVERRIDE + // errors... + // https://docs.npmjs.com/cli/v8/using-npm/package-spec#aliases + if ( + // ...if the spec doesn't start with a valid Socket override. + !( + thisSpec.startsWith(sockOverridePrefix) && + // Check the validity of the spec by passing it through npa and + // seeing if it will coerce to a version. + semver.coerce((npa(thisSpec) as AliasResult).subSpec.rawSpec) + ?.version + ) + ) { + thisSpec = sockOverrideSpec + depObj[origPkgName] = thisSpec + state.added.add(sockRegPkgName) + if (!isWorkspaceRoot) { + state.addedInWorkspaces.add(workspace) + } + } + depAliasMap.set(origPkgName, thisSpec) + } + } + if (isWorkspaceRoot) { + // The AgentDepsIncludesFn and AgentLockIncludesFn types overlap in their + // first two parameters. AgentLockIncludesFn accepts an optional third + // parameter which AgentDepsIncludesFn will ignore so we cast thingScanner + // as an AgentLockIncludesFn type. + const thingScanner = ( + isLockScanned + ? lockfileIncludesByAgent.get(agent) + : depsIncludesByAgent.get(agent) + ) as AgentLockIncludesFn + const thingToScan = isLockScanned + ? lockSrc + : await lsByAgent.get(agent)!(pkgEnvDetails, pkgPath, { npmExecPath }) + // Chunk package names to process them in parallel 3 at a time. + await pEach(overridesDataObjects, 3, async ({ overrides, type }) => { + const overrideExists = hasOwn(overrides, origPkgName) + if ( + overrideExists || + thingScanner(thingToScan, origPkgName, lockName) + ) { + const oldSpec = overrideExists ? overrides[origPkgName]! : undefined + const origDepAlias = depAliasMap.get(origPkgName) + const sockRegDepAlias = depAliasMap.get(sockRegPkgName) + const depAlias = sockRegDepAlias ?? origDepAlias + let newSpec = sockOverrideSpec + if (type === NPM && depAlias) { + // With npm one may not set an override for a package that one directly + // depends on unless both the dependency and the override itself share + // the exact same spec. To make this limitation easier to deal with, + // overrides may also be defined as a reference to a spec for a direct + // dependency by prefixing the name of the package to match the version + // of with a $. + // https://docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides + newSpec = `$${sockRegDepAlias ? sockRegPkgName : origPkgName}` + } else if (typeof oldSpec === 'string') { + const thisSpec = oldSpec.startsWith('$') + ? depAlias || newSpec + : oldSpec || newSpec + if (thisSpec.startsWith(sockOverridePrefix)) { + if ( + pin && + getMajor( + // Check the validity of the spec by passing it through npa + // and seeing if it will coerce to a version. semver.coerce + // will strip leading v's, carets (^), comparators (<,<=,>,>=,=), + // and tildes (~). If not coerced to a valid version then + // default to the manifest entry version. + semver.coerce((npa(thisSpec) as AliasResult).subSpec.rawSpec) + ?.version ?? version, + ) !== major + ) { + const otherVersion = (await fetchPackageManifest(thisSpec)) + ?.version + if (otherVersion && otherVersion !== version) { + newSpec = `${sockOverridePrefix}${pin ? otherVersion : `^${getMajor(otherVersion)!}`}` + } + } + } else { + newSpec = oldSpec + } + } + if (newSpec !== oldSpec) { + overrides[origPkgName] = newSpec + const addedOrUpdated = overrideExists ? 'updated' : 'added' + state[addedOrUpdated].add(sockRegPkgName) + } + } + }) + } + }) + + if (isWorkspace) { + // Chunk package names to process them in parallel 3 at a time. + await pEach(workspacePkgJsonPaths, 3, async workspacePkgJsonPath => { + const otherState = await addOverrides( + pkgEnvDetails, + path.dirname(workspacePkgJsonPath), + { + logger, + pin, + prod, + spinner, + }, + ) + for (const key of [ + 'added', + 'addedInWorkspaces', + 'updated', + 'updatedInWorkspaces', + ] satisfies + // Here we're just telling TS that we're looping over key names + // of the type and that they're all Set<string> props. + Array< + keyof Pick< + AddOverridesState, + 'added' | 'addedInWorkspaces' | 'updated' | 'updatedInWorkspaces' + > + >) { + for (const value of otherState[key]) { + state[key].add(value) + } + } + }) + } + + if (state.added.size > 0 || state.updated.size > 0) { + pkgEnvDetails.editablePkgJson.update( + Object.fromEntries(depEntries) as PackageJson, + ) + if (isWorkspaceRoot) { + for (const { overrides, type } of overridesDataObjects) { + updateManifestByAgent.get(type)!( + pkgEnvDetails, + toSortedObject(overrides), + ) + } + } + await pkgEnvDetails.editablePkgJson.save() + } + + return state +} diff --git a/src/commands/optimize/apply-optimization.mts b/src/commands/optimize/apply-optimization.mts new file mode 100644 index 000000000..b5d8f1af2 --- /dev/null +++ b/src/commands/optimize/apply-optimization.mts @@ -0,0 +1,88 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { addOverrides } from './add-overrides.mts' +import { CMD_NAME } from './shared.mts' +import { updateLockfile } from './update-lockfile.mts' +import constants from '../../constants.mts' +import { cmdPrefixMessage } from '../../utils/cmd.mts' +import { detectAndValidatePackageEnvironment } from '../../utils/package-environment.mts' + +import type { CResult } from '../../types.mts' + +const { VLT } = constants + +export async function applyOptimization( + cwd: string, + pin: boolean, + prod: boolean, +): Promise< + CResult<{ + addedCount: number + updatedCount: number + pkgJsonChanged: boolean + updatedInWorkspaces: number + addedInWorkspaces: number + }> +> { + const result = await detectAndValidatePackageEnvironment(cwd, { + cmdName: CMD_NAME, + logger, + prod, + }) + + if (!result.ok) { + return result + } + const pkgEnvDetails = result.data + + if (pkgEnvDetails.agent === VLT) { + return { + ok: false, + message: 'Unsupported', + cause: cmdPrefixMessage( + CMD_NAME, + `${VLT} does not support overrides. Soon, though ⚡`, + ), + } + } + + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Socket optimizing...') + + const state = await addOverrides(pkgEnvDetails, pkgEnvDetails.pkgPath, { + logger, + pin, + prod, + spinner, + }) + + const addedCount = state.added.size + const updatedCount = state.updated.size + const pkgJsonChanged = addedCount > 0 || updatedCount > 0 + + if (pkgJsonChanged || pkgEnvDetails.features.npmBuggyOverrides) { + const result = await updateLockfile(pkgEnvDetails, { + cmdName: CMD_NAME, + logger, + spinner, + }) + if (!result.ok) { + return result + } + } + + spinner.stop() + + return { + ok: true, + data: { + addedCount, + updatedCount, + pkgJsonChanged, + updatedInWorkspaces: state.updatedInWorkspaces.size, + addedInWorkspaces: state.addedInWorkspaces.size, + }, + } +} diff --git a/src/commands/optimize/cmd-optimize.mts b/src/commands/optimize/cmd-optimize.mts new file mode 100644 index 000000000..808461ffe --- /dev/null +++ b/src/commands/optimize/cmd-optimize.mts @@ -0,0 +1,83 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleOptimize } from './handle-optimize.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'optimize', + description: 'Optimize dependencies with @socketregistry overrides', + hidden: false, + flags: { + ...commonFlags, + pin: { + type: 'boolean', + default: false, + description: 'Pin overrides to their latest version', + }, + prod: { + type: 'boolean', + default: false, + description: 'Only add overrides for production dependencies', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} ./proj/tree --pin + `, +} + +export const cmdOptimize = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const { pin, prod } = cli.flags + const outputKind = getOutputKind(json, markdown) + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleOptimize({ + cwd, + pin: Boolean(pin), + outputKind, + prod: Boolean(prod), + }) +} diff --git a/src/commands/optimize/cmd-optimize.test.mts b/src/commands/optimize/cmd-optimize.test.mts new file mode 100644 index 000000000..d7e876336 --- /dev/null +++ b/src/commands/optimize/cmd-optimize.test.mts @@ -0,0 +1,63 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket optimize', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['optimize', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Optimize dependencies with @socketregistry overrides + + Usage + $ socket optimize [options] [CWD=.] + + Options + --pin Pin overrides to their latest version + --prod Only add overrides for production dependencies + + Examples + $ socket optimize + $ socket optimize ./proj/tree --pin" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket optimize`', + ) + }, + ) + + cmdit( + ['optimize', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket optimize\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/optimize/deps-includes-by-agent.mts b/src/commands/optimize/deps-includes-by-agent.mts new file mode 100644 index 000000000..1a9850999 --- /dev/null +++ b/src/commands/optimize/deps-includes-by-agent.mts @@ -0,0 +1,24 @@ +import constants from '../../constants.mts' + +import type { Agent } from '../../utils/package-environment.mts' + +type AgentDepsIncludesFn = (stdout: string, name: string) => boolean + +const { BUN, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function matchLsCmdViewHumanStdout(stdout: string, name: string) { + return stdout.includes(` ${name}@`) +} + +function matchQueryCmdStdout(stdout: string, name: string) { + return stdout.includes(`"${name}"`) +} + +export const depsIncludesByAgent = new Map<Agent, AgentDepsIncludesFn>([ + [BUN, matchLsCmdViewHumanStdout], + [NPM, matchQueryCmdStdout], + [PNPM, matchQueryCmdStdout], + [VLT, matchQueryCmdStdout], + [YARN_BERRY, matchLsCmdViewHumanStdout], + [YARN_CLASSIC, matchLsCmdViewHumanStdout], +]) diff --git a/packages/cli/src/commands/optimize/get-dependency-entries.mts b/src/commands/optimize/get-dependency-entries.mts similarity index 91% rename from packages/cli/src/commands/optimize/get-dependency-entries.mts rename to src/commands/optimize/get-dependency-entries.mts index e0d6aeea7..1ad63714f 100644 --- a/packages/cli/src/commands/optimize/get-dependency-entries.mts +++ b/src/commands/optimize/get-dependency-entries.mts @@ -1,4 +1,4 @@ -import type { EnvDetails } from '../../util/ecosystem/environment.mjs' +import type { EnvDetails } from '../../utils/package-environment.mts' export function getDependencyEntries(pkgEnvDetails: EnvDetails) { const { diff --git a/packages/cli/src/commands/optimize/get-overrides-by-agent.mts b/src/commands/optimize/get-overrides-by-agent.mts similarity index 78% rename from packages/cli/src/commands/optimize/get-overrides-by-agent.mts rename to src/commands/optimize/get-overrides-by-agent.mts index b559b8860..8a1457506 100644 --- a/packages/cli/src/commands/optimize/get-overrides-by-agent.mts +++ b/src/commands/optimize/get-overrides-by-agent.mts @@ -1,4 +1,10 @@ -import { +import constants from '../../constants.mts' + +import type { NpmOverrides, Overrides, PnpmOrYarnOverrides } from './types.mts' +import type { Agent, EnvDetails } from '../../utils/package-environment.mts' +import type { PackageJson } from '@socketsecurity/registry/lib/packages' + +const { BUN, NPM, OVERRIDES, @@ -7,38 +13,12 @@ import { VLT, YARN_BERRY, YARN_CLASSIC, -} from '@socketsecurity/lib-stable/constants/agents' - -import type { NpmOverrides, Overrides, PnpmOrYarnOverrides } from './types.mts' -import type { Agent, EnvDetails } from '../../util/ecosystem/environment.mjs' -import type { PackageJson } from '@socketsecurity/lib-stable/packages/operations' - -export type GetOverridesResult = { type: Agent; overrides: Overrides } - -export function getOverridesData( - pkgEnvDetails: EnvDetails, - pkgJson?: PackageJson | undefined, -): GetOverridesResult { - switch (pkgEnvDetails.agent) { - case BUN: - return getOverridesDataBun(pkgEnvDetails, pkgJson) - case PNPM: - return getOverridesDataPnpm(pkgEnvDetails, pkgJson) - case VLT: - return getOverridesDataVlt(pkgEnvDetails, pkgJson) - case YARN_BERRY: - return getOverridesDataYarn(pkgEnvDetails, pkgJson) - case YARN_CLASSIC: - return getOverridesDataYarnClassic(pkgEnvDetails, pkgJson) - default: - return getOverridesDataNpm(pkgEnvDetails, pkgJson) - } -} +} = constants export function getOverridesDataBun( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: PnpmOrYarnOverrides } { +) { const overrides = (pkgJson?.[RESOLUTIONS] ?? {}) as PnpmOrYarnOverrides return { type: YARN_BERRY, overrides } } @@ -48,7 +28,7 @@ export function getOverridesDataBun( export function getOverridesDataNpm( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: NpmOverrides } { +) { const overrides = (pkgJson?.[OVERRIDES] ?? {}) as NpmOverrides return { type: NPM, overrides } } @@ -58,17 +38,16 @@ export function getOverridesDataNpm( export function getOverridesDataPnpm( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: PnpmOrYarnOverrides } { - const overrides = (( - pkgJson as Record<string, Record<string, unknown> | undefined> | undefined - )?.[PNPM]?.[OVERRIDES] ?? {}) as PnpmOrYarnOverrides +) { + const overrides = ((pkgJson as any)?.[PNPM]?.[OVERRIDES] ?? + {}) as PnpmOrYarnOverrides return { type: PNPM, overrides } } export function getOverridesDataVlt( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: NpmOverrides } { +) { const overrides = (pkgJson?.[OVERRIDES] ?? {}) as NpmOverrides return { type: VLT, overrides } } @@ -78,7 +57,7 @@ export function getOverridesDataVlt( export function getOverridesDataYarn( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: PnpmOrYarnOverrides } { +) { const overrides = (pkgJson?.[RESOLUTIONS] ?? {}) as PnpmOrYarnOverrides return { type: YARN_BERRY, overrides } } @@ -88,7 +67,35 @@ export function getOverridesDataYarn( export function getOverridesDataYarnClassic( pkgEnvDetails: EnvDetails, pkgJson = pkgEnvDetails.editablePkgJson.content, -): { type: Agent; overrides: PnpmOrYarnOverrides } { +) { const overrides = (pkgJson?.[RESOLUTIONS] ?? {}) as PnpmOrYarnOverrides return { type: YARN_CLASSIC, overrides } } + +export type GetOverrides = ( + pkgEnvDetails: EnvDetails, + pkgJson?: PackageJson | undefined, +) => GetOverridesResult + +export type GetOverridesResult = { type: Agent; overrides: Overrides } + +export function getOverridesData( + pkgEnvDetails: EnvDetails, + pkgJson?: PackageJson | undefined, +): GetOverridesResult { + switch (pkgEnvDetails.agent) { + case BUN: + return getOverridesDataBun(pkgEnvDetails, pkgJson) + case PNPM: + return getOverridesDataPnpm(pkgEnvDetails, pkgJson) + case VLT: + return getOverridesDataVlt(pkgEnvDetails, pkgJson) + case YARN_BERRY: + return getOverridesDataYarn(pkgEnvDetails, pkgJson) + case YARN_CLASSIC: + return getOverridesDataYarnClassic(pkgEnvDetails, pkgJson) + case NPM: + default: + return getOverridesDataNpm(pkgEnvDetails, pkgJson) + } +} diff --git a/src/commands/optimize/handle-optimize.mts b/src/commands/optimize/handle-optimize.mts new file mode 100644 index 000000000..2d06a5bd0 --- /dev/null +++ b/src/commands/optimize/handle-optimize.mts @@ -0,0 +1,20 @@ +import { applyOptimization } from './apply-optimization.mts' +import { outputOptimizeResult } from './output-optimize-result.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleOptimize({ + cwd, + outputKind, + pin, + prod, +}: { + cwd: string + outputKind: OutputKind + pin: boolean + prod: boolean +}) { + const result = await applyOptimization(cwd, pin, prod) + + await outputOptimizeResult(result, outputKind) +} diff --git a/src/commands/optimize/lockfile-includes-by-agent.mts b/src/commands/optimize/lockfile-includes-by-agent.mts new file mode 100644 index 000000000..5aac9bb0f --- /dev/null +++ b/src/commands/optimize/lockfile-includes-by-agent.mts @@ -0,0 +1,73 @@ +import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' + +import constants from '../../constants.mts' + +import type { Agent } from '../../utils/package-environment.mts' + +export type AgentLockIncludesFn = ( + lockSrc: string, + name: string, + ext?: string | undefined, +) => boolean + +const { BUN, LOCK_EXT, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function includesNpm(lockSrc: string, name: string) { + // Detects the package name in the following cases: + // "name": + return lockSrc.includes(`"${name}":`) +} + +function includesBun(lockSrc: string, name: string, lockName?: string) { + // This is a bit counterintuitive. When lockName ends with a .lockb + // we treat it as a yarn.lock. When lockName ends with a .lock we + // treat it as a package-lock.json. The bun.lock format is not identical + // package-lock.json, however it close enough for npmLockIncludes to work. + const lockfileScanner = lockName?.endsWith(LOCK_EXT) + ? includesNpm + : includesYarn + return lockfileScanner(lockSrc, name) +} + +function includesPnpm(lockSrc: string, name: string) { + const escapedName = escapeRegExp(name) + return new RegExp( + // Detects the package name. + // v9.0 and v6.0 lockfile patterns: + // 'name' + // name: + // name@ + // v6.0 lockfile patterns: + // /name@ + `(?<=^\\s*)(?:'${escapedName}'|/?${escapedName}(?=[:@]))`, + 'm', + ).test(lockSrc) +} + +function includesVlt(lockSrc: string, name: string) { + // Detects the package name in the following cases: + // "name" + return lockSrc.includes(`"${name}"`) +} + +function includesYarn(lockSrc: string, name: string) { + const escapedName = escapeRegExp(name) + return new RegExp( + // Detects the package name in the following cases: + // "name@ + // , "name@ + // name@ + // , name@ + `(?<=(?:^\\s*|,\\s*)"?)${escapedName}(?=@)`, + 'm', + ).test(lockSrc) +} + +export const lockfileIncludesByAgent = new Map<Agent, AgentLockIncludesFn>([ + [BUN, includesBun], + [NPM, includesNpm], + [PNPM, includesPnpm], + [VLT, includesVlt], + [YARN_BERRY, includesYarn], + [YARN_CLASSIC, includesYarn], +]) diff --git a/src/commands/optimize/ls-by-agent.mts b/src/commands/optimize/ls-by-agent.mts new file mode 100644 index 000000000..a7c1a5cc1 --- /dev/null +++ b/src/commands/optimize/ls-by-agent.mts @@ -0,0 +1,189 @@ +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' + +import type { Agent, EnvDetails } from '../../utils/package-environment.mts' + +const { BUN, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function cleanupQueryStdout(stdout: string): string { + if (stdout === '') { + return '' + } + let pkgs + try { + pkgs = JSON.parse(stdout) + } catch {} + if (!Array.isArray(pkgs) || !pkgs.length) { + return '' + } + const names = new Set<string>() + for (const { _id, name, pkgid } of pkgs) { + // `npm query` results may not have a "name" property, in which case we + // fallback to "_id" and then "pkgid". + // `vlt ls --view json` results always have a "name" property. + const fallback = _id ?? pkgid ?? '' + const resolvedName = name ?? fallback.slice(0, fallback.indexOf('@', 1)) + // Add package names, except for those under the `@types` scope as those + // are known to only be dev dependencies. + if (resolvedName && !resolvedName.startsWith('@types/')) { + names.add(resolvedName) + } + } + return JSON.stringify(Array.from(names), null, 2) +} + +function parsableToQueryStdout(stdout: string) { + if (stdout === '') { + return '' + } + // Convert the parsable stdout into a json array of unique names. + // The matchAll regexp looks for a forward (posix) or backward (win32) slash + // and matches one or more non-slashes until the newline. + const names = new Set(stdout.matchAll(/(?<=[/\\])[^/\\]+(?=\n)/g)) + return JSON.stringify(Array.from(names), null, 2) +} + +async function npmQuery(npmExecPath: string, cwd: string): Promise<string> { + let stdout = '' + try { + stdout = ( + await spawn(npmExecPath, ['query', ':not(.dev)'], { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }) + ).stdout.trim() + } catch {} + return cleanupQueryStdout(stdout) +} + +async function lsBun(pkgEnvDetails: EnvDetails, cwd: string): Promise<string> { + try { + // Bun does not support filtering by production packages yet. + // https://github.com/oven-sh/bun/issues/8283 + return ( + await spawn(pkgEnvDetails.agentExecPath, ['pm', 'ls', '--all'], { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }) + ).stdout.trim() + } catch {} + return '' +} + +async function lsNpm(pkgEnvDetails: EnvDetails, cwd: string): Promise<string> { + return await npmQuery(pkgEnvDetails.agentExecPath, cwd) +} + +async function lsPnpm( + pkgEnvDetails: EnvDetails, + cwd: string, + options?: AgentListDepsOptions | undefined, +): Promise<string> { + const npmExecPath = options?.npmExecPath + if (npmExecPath && npmExecPath !== NPM) { + const result = await npmQuery(npmExecPath, cwd) + if (result) { + return result + } + } + let stdout = '' + try { + stdout = ( + await spawn( + pkgEnvDetails.agentExecPath, + // Pnpm uses the alternative spelling of parsable. + // https://en.wiktionary.org/wiki/parsable + ['ls', '--parseable', '--prod', '--depth', 'Infinity'], + { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }, + ) + ).stdout.trim() + } catch {} + return parsableToQueryStdout(stdout) +} + +async function lsVlt(pkgEnvDetails: EnvDetails, cwd: string): Promise<string> { + let stdout = '' + try { + // See https://docs.vlt.sh/cli/commands/list#options. + stdout = ( + await spawn( + pkgEnvDetails.agentExecPath, + ['ls', '--view', 'human', ':not(.dev)'], + { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }, + ) + ).stdout.trim() + } catch {} + return cleanupQueryStdout(stdout) +} + +async function lsYarnBerry( + pkgEnvDetails: EnvDetails, + cwd: string, +): Promise<string> { + try { + return ( + // Yarn Berry does not support filtering by production packages yet. + // https://github.com/yarnpkg/berry/issues/5117 + ( + await spawn( + pkgEnvDetails.agentExecPath, + ['info', '--recursive', '--name-only'], + { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }, + ) + ).stdout.trim() + ) + } catch {} + return '' +} + +async function lsYarnClassic( + pkgEnvDetails: EnvDetails, + cwd: string, +): Promise<string> { + try { + // However, Yarn Classic does support it. + // https://github.com/yarnpkg/yarn/releases/tag/v1.0.0 + // > Fix: Excludes dev dependencies from the yarn list output when the + // environment is production + return ( + await spawn(pkgEnvDetails.agentExecPath, ['list', '--prod'], { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }) + ).stdout.trim() + } catch {} + return '' +} + +export type AgentListDepsOptions = { npmExecPath?: string } + +export type AgentListDepsFn = ( + pkgEnvDetails: EnvDetails, + cwd: string, + options?: AgentListDepsOptions | undefined, +) => Promise<string> + +export const lsByAgent = new Map<Agent, AgentListDepsFn>([ + [BUN, lsBun], + [NPM, lsNpm], + [PNPM, lsPnpm], + [VLT, lsVlt], + [YARN_BERRY, lsYarnBerry], + [YARN_CLASSIC, lsYarnClassic], +]) diff --git a/src/commands/optimize/output-optimize-result.mts b/src/commands/optimize/output-optimize-result.mts new file mode 100644 index 000000000..3b77998a2 --- /dev/null +++ b/src/commands/optimize/output-optimize-result.mts @@ -0,0 +1,59 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputOptimizeResult( + result: CResult<{ + addedCount: number + updatedCount: number + pkgJsonChanged: boolean + updatedInWorkspaces: number + addedInWorkspaces: number + }>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const data = result.data + + if (data.updatedCount > 0) { + logger?.log( + `${createActionMessage('Updated', data.updatedCount, data.updatedInWorkspaces)}${data.addedCount ? '.' : '🚀'}`, + ) + } + if (data.addedCount > 0) { + logger?.log( + `${createActionMessage('Added', data.addedCount, data.addedInWorkspaces)} 🚀`, + ) + } + if (!data.pkgJsonChanged) { + logger?.log('Scan complete. No Socket.dev optimized overrides applied.') + } + + logger.log('') + logger.success('Finished!') + logger.log('') +} + +function createActionMessage( + verb: string, + overrideCount: number, + workspaceCount: number, +): string { + return `${verb} ${overrideCount} Socket.dev optimized ${pluralize('override', overrideCount)}${workspaceCount ? ` in ${workspaceCount} ${pluralize('workspace', workspaceCount)}` : ''}` +} diff --git a/packages/cli/src/commands/optimize/shared.mts b/src/commands/optimize/shared.mts similarity index 100% rename from packages/cli/src/commands/optimize/shared.mts rename to src/commands/optimize/shared.mts diff --git a/packages/cli/src/commands/optimize/types.mts b/src/commands/optimize/types.mts similarity index 100% rename from packages/cli/src/commands/optimize/types.mts rename to src/commands/optimize/types.mts diff --git a/src/commands/optimize/update-lockfile.mts b/src/commands/optimize/update-lockfile.mts new file mode 100644 index 000000000..62a069525 --- /dev/null +++ b/src/commands/optimize/update-lockfile.mts @@ -0,0 +1,66 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import constants from '../../constants.mts' +import { runAgentInstall } from '../../utils/agent.mts' +import { cmdPrefixMessage } from '../../utils/cmd.mts' + +import type { CResult } from '../../types.mts' +import type { EnvDetails } from '../../utils/package-environment.mts' +import type { Logger } from '@socketsecurity/registry/lib/logger' + +const { NPM_BUGGY_OVERRIDES_PATCHED_VERSION } = constants + +export type UpdateLockfileOptions = { + cmdName?: string | undefined + logger?: Logger | undefined + spinner?: Spinner | undefined +} + +export async function updateLockfile( + pkgEnvDetails: EnvDetails, + options: UpdateLockfileOptions, +): Promise<CResult<unknown>> { + const { + cmdName = '', + logger, + spinner, + } = { + __proto__: null, + ...options, + } as UpdateLockfileOptions + const isSpinning = !!spinner?.['isSpinning'] + if (!isSpinning) { + spinner?.start() + } + spinner?.setText(`Updating ${pkgEnvDetails.lockName}...`) + try { + await runAgentInstall(pkgEnvDetails, { spinner }) + if (pkgEnvDetails.features.npmBuggyOverrides) { + spinner?.stop() + logger?.log( + `💡 Re-run ${cmdName ? `${cmdName} ` : ''}whenever ${pkgEnvDetails.lockName} changes.\n This can be skipped for ${pkgEnvDetails.agent} >=${NPM_BUGGY_OVERRIDES_PATCHED_VERSION}.`, + ) + } + } catch (e) { + spinner?.stop() + + debugFn('fail: update\n', e) + + return { + ok: false, + message: 'Update failed', + cause: cmdPrefixMessage( + cmdName, + `${pkgEnvDetails.agent} install failed to update ${pkgEnvDetails.lockName}`, + ), + } + } + if (isSpinning) { + spinner?.start() + } else { + spinner?.stop() + } + + return { ok: true, data: undefined } +} diff --git a/src/commands/optimize/update-manifest-by-agent.mts b/src/commands/optimize/update-manifest-by-agent.mts new file mode 100644 index 000000000..27f84a15f --- /dev/null +++ b/src/commands/optimize/update-manifest-by-agent.mts @@ -0,0 +1,172 @@ +import { hasKeys, isObject } from '@socketsecurity/registry/lib/objects' + +import constants from '../../constants.mts' + +import type { Overrides } from './types.mts' +import type { Agent, EnvDetails } from '../../utils/package-environment.mts' +import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' + +const { + BUN, + NPM, + OVERRIDES, + PNPM, + RESOLUTIONS, + VLT, + YARN_BERRY, + YARN_CLASSIC, +} = constants + +const depFields = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'optionalDependencies', + 'bundleDependencies', +] + +function getEntryIndexes( + entries: Array<[string | symbol, any]>, + keys: Array<string | symbol>, +): number[] { + return keys + .map(n => entries.findIndex(p => p[0] === n)) + .filter(n => n !== -1) + .sort((a, b) => a - b) +} + +function getLowestEntryIndex( + entries: Array<[string | symbol, any]>, + keys: Array<string | symbol>, +) { + return getEntryIndexes(entries, keys)?.[0] ?? -1 +} + +function getHighestEntryIndex( + entries: Array<[string | symbol, any]>, + keys: Array<string | symbol>, +) { + return getEntryIndexes(entries, keys).at(-1) ?? -1 +} + +function updatePkgJsonField( + editablePkgJson: EditablePackageJson, + field: string, + value: any, +) { + const oldValue = editablePkgJson.content[field] + if (oldValue) { + // The field already exists so we simply update the field value. + if (field === PNPM) { + const isPnpmObj = isObject(oldValue) + if (hasKeys(value)) { + editablePkgJson.update({ + [field]: { + ...(isPnpmObj ? oldValue : {}), + overrides: { + ...(isPnpmObj ? (oldValue as any)[OVERRIDES] : {}), + ...value, + }, + }, + }) + } else { + // Properties with undefined values are omitted when saved as JSON. + editablePkgJson.update( + (hasKeys(oldValue) + ? { + [field]: { + ...(isPnpmObj ? oldValue : {}), + overrides: undefined, + }, + } + : { [field]: undefined }) as typeof editablePkgJson.content, + ) + } + } else if (field === OVERRIDES || field === RESOLUTIONS) { + // Properties with undefined values are omitted when saved as JSON. + editablePkgJson.update({ + [field]: hasKeys(value) ? value : undefined, + } as typeof editablePkgJson.content) + } else { + editablePkgJson.update({ [field]: value }) + } + return + } + if ( + (field === OVERRIDES || field === PNPM || field === RESOLUTIONS) && + !hasKeys(value) + ) { + return + } + // Since the field doesn't exist we want to insert it into the package.json + // in a place that makes sense, e.g. close to the "dependencies" field. If + // we can't find a place to insert the field we'll add it to the bottom. + const entries = Object.entries(editablePkgJson.content) + let insertIndex = -1 + let isPlacingHigher = false + if (field === OVERRIDES) { + insertIndex = getLowestEntryIndex(entries, [RESOLUTIONS]) + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, [...depFields, PNPM]) + } + } else if (field === RESOLUTIONS) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, [...depFields, OVERRIDES, PNPM]) + } else if (field === PNPM) { + insertIndex = getLowestEntryIndex(entries, [OVERRIDES, RESOLUTIONS]) + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, depFields) + } + } + if (insertIndex === -1) { + insertIndex = getLowestEntryIndex(entries, ['engines', 'files']) + } + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, ['exports', 'imports', 'main']) + } + if (insertIndex === -1) { + insertIndex = entries.length + } else if (isPlacingHigher) { + insertIndex += 1 + } + entries.splice(insertIndex, 0, [ + field, + field === PNPM ? { [OVERRIDES]: value } : value, + ]) + editablePkgJson.fromJSON( + `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n`, + ) +} + +function updateOverridesField(pkgEnvDetails: EnvDetails, overrides: Overrides) { + updatePkgJsonField(pkgEnvDetails.editablePkgJson, OVERRIDES, overrides) +} + +function updateResolutionsField( + pkgEnvDetails: EnvDetails, + overrides: Overrides, +) { + updatePkgJsonField(pkgEnvDetails.editablePkgJson, RESOLUTIONS, overrides) +} + +function updatePnpmField(pkgEnvDetails: EnvDetails, overrides: Overrides) { + updatePkgJsonField(pkgEnvDetails.editablePkgJson, PNPM, overrides) +} + +export type AgentModifyManifestFn = ( + pkgEnvDetails: EnvDetails, + overrides: Overrides, +) => void + +export const updateManifestByAgent = new Map<Agent, AgentModifyManifestFn>([ + [BUN, updateResolutionsField], + [NPM, updateOverridesField], + [PNPM, updatePnpmField], + [VLT, updateOverridesField], + [YARN_BERRY, updateResolutionsField], + [YARN_CLASSIC, updateResolutionsField], +]) diff --git a/src/commands/organization/cmd-organization-dependencies.mts b/src/commands/organization/cmd-organization-dependencies.mts new file mode 100644 index 000000000..d50e0828b --- /dev/null +++ b/src/commands/organization/cmd-organization-dependencies.mts @@ -0,0 +1,108 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleDependencies } from './handle-dependencies.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'dependencies', + description: + 'Search for any dependency that is being used in your organization', + hidden: false, + flags: { + ...commonFlags, + limit: { + type: 'number', + default: 50, + description: 'Maximum number of dependencies returned', + }, + offset: { + type: 'number', + default: 0, + description: 'Page number', + }, + ...outputFlags, + }, + help: (command, config) => ` + Usage + ${command} [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: none (does need token with access to target org) + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + ${command} + ${command} --limit 20 --offset 10 + `, +} + +export const cmdOrganizationDependencies = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, limit, markdown, offset } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleDependencies({ + limit: Number(limit || 0) || 0, + offset: Number(offset || 0) || 0, + outputKind, + }) +} diff --git a/src/commands/organization/cmd-organization-dependencies.test.mts b/src/commands/organization/cmd-organization-dependencies.test.mts new file mode 100644 index 000000000..01dda1414 --- /dev/null +++ b/src/commands/organization/cmd-organization-dependencies.test.mts @@ -0,0 +1,75 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization dependencies', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'dependencies', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Search for any dependency that is being used in your organization + + Usage + socket organization dependencies [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: none (does need token with access to target org) + + Options + --json Output result as json + --limit Maximum number of dependencies returned + --markdown Output result as markdown + --offset Page number + + Examples + socket organization dependencies + socket organization dependencies --limit 20 --offset 10" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization dependencies\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization dependencies`', + ) + }, + ) + + cmdit( + [ + 'organization', + 'dependencies', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization dependencies\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization-list.mts b/src/commands/organization/cmd-organization-list.mts new file mode 100644 index 000000000..744fa3ea0 --- /dev/null +++ b/src/commands/organization/cmd-organization-list.mts @@ -0,0 +1,93 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleOrganizationList } from './handle-organization-list.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'list', + description: 'List organizations associated with the API key used', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: none (does need a token) + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} --json + `, +} + +export const cmdOrganizationList = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleOrganizationList(outputKind) +} diff --git a/src/commands/organization/cmd-organization-list.test.mts b/src/commands/organization/cmd-organization-list.test.mts new file mode 100644 index 000000000..92fca6650 --- /dev/null +++ b/src/commands/organization/cmd-organization-list.test.mts @@ -0,0 +1,75 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization list', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'list', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List organizations associated with the API key used + + Usage + $ socket organization list [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: none (does need a token) + + Options + --json Output result as json + --markdown Output result as markdown + + Examples + $ socket organization list + $ socket organization list --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization list\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization list`', + ) + }, + ) + + cmdit( + [ + 'organization', + 'list', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization-policy-license.mts b/src/commands/organization/cmd-organization-policy-license.mts new file mode 100644 index 000000000..5b4f87821 --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-license.mts @@ -0,0 +1,113 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleLicensePolicy } from './handle-license-policy.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'license', + description: 'Retrieve the license policy of an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: license-policy:read + + Options + ${getFlagListOutput(config.flags, 6)} + + Your API token will need the \`license-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ ${command} + $ ${command} --json + `, +} + +export const cmdOrganizationPolicyLicense = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleLicensePolicy(orgSlug, outputKind) +} diff --git a/src/commands/organization/cmd-organization-policy-license.test.mts b/src/commands/organization/cmd-organization-policy-license.test.mts new file mode 100644 index 000000000..51561badf --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-license.test.mts @@ -0,0 +1,166 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization policy license', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'policy', 'license', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Retrieve the license policy of an organization + + Usage + $ socket organization policy license [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: license-policy:read + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Your API token will need the \`license-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ socket organization policy license + $ socket organization policy license --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization policy license`', + ) + }, + ) + + cmdit( + ['organization', 'policy', 'license', '--dry-run', '--config', '{}'], + 'should reject dry run without proper args', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if input bad').toBe(2) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + 'license', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + 'license', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should accept default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + 'license', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy license\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization-policy-security.mts b/src/commands/organization/cmd-organization-policy-security.mts new file mode 100644 index 000000000..4f5645d8c --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-security.mts @@ -0,0 +1,114 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleSecurityPolicy } from './handle-security-policy.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +// TODO: secret toplevel alias `socket security policy`? +const config: CliCommandConfig = { + commandName: 'security', + description: 'Retrieve the security policy of an organization', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: security-policy:read + + Options + ${getFlagListOutput(config.flags, 6)} + + Your API token will need the \`security-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ ${command} + $ ${command} --json + `, +} + +export const cmdOrganizationPolicySecurity = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleSecurityPolicy(orgSlug, outputKind) +} diff --git a/src/commands/organization/cmd-organization-policy-security.test.mts b/src/commands/organization/cmd-organization-policy-security.test.mts new file mode 100644 index 000000000..dddcf6731 --- /dev/null +++ b/src/commands/organization/cmd-organization-policy-security.test.mts @@ -0,0 +1,135 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization policy security', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'policy', 'security', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Retrieve the security policy of an organization + + Usage + $ socket organization policy security [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: security-policy:read + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Your API token will need the \`security-policy:read\` permission otherwise + the request will fail with an authentication error. + + Examples + $ socket organization policy security + $ socket organization policy security --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization policy security`', + ) + }, + ) + + cmdit( + ['organization', 'policy', 'security', '--dry-run', '--config', '{}'], + 'should reject dry run without proper args', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if input bad').toBe(2) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + 'security', + '--dry-run', + '--config', + '{"isTestingV1": true, "apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should accept default org in v1', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + 'security', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"isTestingV1": true, "apiToken":"anything"}', + ], + 'should accept --org flag in v1', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy security\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization-policy.mts b/src/commands/organization/cmd-organization-policy.mts new file mode 100644 index 000000000..da4660b86 --- /dev/null +++ b/src/commands/organization/cmd-organization-policy.mts @@ -0,0 +1,31 @@ +import { cmdOrganizationPolicyLicense } from './cmd-organization-policy-license.mts' +import { cmdOrganizationPolicySecurity } from './cmd-organization-policy-security.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Organization policy details' + +export const cmdOrganizationPolicy: CliSubcommand = { + description, + // Hidden because it was broken all this time (nobody could be using it) + // and we're not sure if it's useful to anyone in its current state. + // Until we do, we'll hide this to keep the help tidier. + // And later, we may simply move this under `scan`, anyways. + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + security: cmdOrganizationPolicySecurity, + license: cmdOrganizationPolicyLicense, + }, + { + argv, + description, + defaultSub: 'list', // Backwards compat + importMeta, + name: parentName + ' policy', + }, + ) + }, +} diff --git a/src/commands/organization/cmd-organization-policy.test.mts b/src/commands/organization/cmd-organization-policy.test.mts new file mode 100644 index 000000000..6b615d604 --- /dev/null +++ b/src/commands/organization/cmd-organization-policy.test.mts @@ -0,0 +1,74 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization list', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'policy', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Organization policy details + + Usage + $ socket organization policy <command> + + Commands + license Retrieve the license policy of an organization + + Options + (none) + + Examples + $ socket organization policy --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization policy`', + ) + }, + ) + + cmdit( + [ + 'organization', + 'policy', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should support --dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization policy\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization-quota.mts b/src/commands/organization/cmd-organization-quota.mts new file mode 100644 index 000000000..238a0815a --- /dev/null +++ b/src/commands/organization/cmd-organization-quota.mts @@ -0,0 +1,89 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleQuota } from './handle-quota.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'quota', + description: 'List organizations associated with the API key used', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, _config) => ` + Usage + $ ${command} [options] + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} --json + `, +} + +export const cmdOrganizationQuota = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const json = Boolean(cli.flags['json']) + const markdown = Boolean(cli.flags['markdown']) + const outputKind = getOutputKind(json, markdown) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleQuota(outputKind) +} diff --git a/src/commands/organization/cmd-organization-quota.test.mts b/src/commands/organization/cmd-organization-quota.test.mts new file mode 100644 index 000000000..a6124c304 --- /dev/null +++ b/src/commands/organization/cmd-organization-quota.test.mts @@ -0,0 +1,71 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization quota', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', 'quota', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List organizations associated with the API key used + + Usage + $ socket organization quota [options] + + Options + --json Output result as json + --markdown Output result as markdown + + Examples + $ socket organization quota + $ socket organization quota --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization quota\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization quota`', + ) + }, + ) + + cmdit( + [ + 'organization', + 'quota', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization quota\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/cmd-organization.mts b/src/commands/organization/cmd-organization.mts new file mode 100644 index 000000000..724a1ad69 --- /dev/null +++ b/src/commands/organization/cmd-organization.mts @@ -0,0 +1,49 @@ +import { cmdOrganizationDependencies } from './cmd-organization-dependencies.mts' +import { cmdOrganizationList } from './cmd-organization-list.mts' +import { cmdOrganizationPolicyLicense } from './cmd-organization-policy-license.mts' +import { cmdOrganizationPolicySecurity } from './cmd-organization-policy-security.mts' +import { cmdOrganizationPolicy } from './cmd-organization-policy.mts' +import { cmdOrganizationQuota } from './cmd-organization-quota.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Account details' + +export const cmdOrganization: CliSubcommand = { + description, + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + dependencies: cmdOrganizationDependencies, + list: cmdOrganizationList, + quota: cmdOrganizationQuota, + policy: cmdOrganizationPolicy, + }, + { + aliases: { + deps: { + description: cmdOrganizationDependencies.description, + hidden: true, + argv: ['dependencies'], + }, + license: { + description: cmdOrganizationPolicyLicense.description, + hidden: true, + argv: ['policy', 'license'], + }, + security: { + description: cmdOrganizationPolicySecurity.description, + hidden: true, + argv: ['policy', 'security'], + }, + }, + argv, + description, + importMeta, + name: parentName + ' organization', + }, + ) + }, +} diff --git a/src/commands/organization/cmd-organization.test.mts b/src/commands/organization/cmd-organization.test.mts new file mode 100644 index 000000000..ea397ce8b --- /dev/null +++ b/src/commands/organization/cmd-organization.test.mts @@ -0,0 +1,70 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket organization', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['organization', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Account details + + Usage + $ socket organization <command> + + Commands + dependencies Search for any dependency that is being used in your organization + list List organizations associated with the API key used + policy Organization policy details + + Options + (none) + + Examples + $ socket organization --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket organization`', + ) + }, + ) + + cmdit( + ['organization', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket organization\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/organization/fetch-dependencies.mts b/src/commands/organization/fetch-dependencies.mts new file mode 100644 index 000000000..8020a3bb5 --- /dev/null +++ b/src/commands/organization/fetch-dependencies.mts @@ -0,0 +1,24 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchDependencies({ + limit, + offset, +}: { + limit: number + offset: number +}): Promise<CResult<SocketSdkReturnType<'searchDependencies'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.searchDependencies({ limit, offset }), + 'organization dependencies', + ) +} diff --git a/src/commands/organization/fetch-license-policy.mts b/src/commands/organization/fetch-license-policy.mts new file mode 100644 index 000000000..c21a3f350 --- /dev/null +++ b/src/commands/organization/fetch-license-policy.mts @@ -0,0 +1,20 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchLicensePolicy( + orgSlug: string, +): Promise<CResult<SocketSdkReturnType<'getOrgLicensePolicy'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgLicensePolicy(orgSlug), + 'organization license policy', + ) +} diff --git a/src/commands/organization/fetch-organization-list.mts b/src/commands/organization/fetch-organization-list.mts new file mode 100644 index 000000000..086931169 --- /dev/null +++ b/src/commands/organization/fetch-organization-list.mts @@ -0,0 +1,17 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchOrganization(): Promise< + CResult<SocketSdkReturnType<'getOrganizations'>['data']> +> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall(sockSdk.getOrganizations(), 'organization list') +} diff --git a/src/commands/organization/fetch-quota.mts b/src/commands/organization/fetch-quota.mts new file mode 100644 index 000000000..39924a003 --- /dev/null +++ b/src/commands/organization/fetch-quota.mts @@ -0,0 +1,17 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchQuota(): Promise< + CResult<SocketSdkReturnType<'getQuota'>['data']> +> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall(sockSdk.getQuota(), 'token quota') +} diff --git a/src/commands/organization/fetch-security-policy.mts b/src/commands/organization/fetch-security-policy.mts new file mode 100644 index 000000000..100de196b --- /dev/null +++ b/src/commands/organization/fetch-security-policy.mts @@ -0,0 +1,20 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchSecurityPolicy( + orgSlug: string, +): Promise<CResult<SocketSdkReturnType<'getOrgSecurityPolicy'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgSecurityPolicy(orgSlug), + 'organization security policy', + ) +} diff --git a/src/commands/organization/handle-dependencies.mts b/src/commands/organization/handle-dependencies.mts new file mode 100644 index 000000000..c661509ca --- /dev/null +++ b/src/commands/organization/handle-dependencies.mts @@ -0,0 +1,18 @@ +import { fetchDependencies } from './fetch-dependencies.mts' +import { outputDependencies } from './output-dependencies.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleDependencies({ + limit, + offset, + outputKind, +}: { + limit: number + offset: number + outputKind: OutputKind +}): Promise<void> { + const result = await fetchDependencies({ limit, offset }) + + await outputDependencies(result, { limit, offset, outputKind }) +} diff --git a/packages/cli/src/commands/organization/handle-license-policy.mts b/src/commands/organization/handle-license-policy.mts similarity index 75% rename from packages/cli/src/commands/organization/handle-license-policy.mts rename to src/commands/organization/handle-license-policy.mts index b8ffccb2f..d908a352f 100644 --- a/packages/cli/src/commands/organization/handle-license-policy.mts +++ b/src/commands/organization/handle-license-policy.mts @@ -7,9 +7,7 @@ export async function handleLicensePolicy( orgSlug: string, outputKind: OutputKind, ): Promise<void> { - const data = await fetchLicensePolicy(orgSlug, { - commandPath: 'socket organization policy license', - }) + const data = await fetchLicensePolicy(orgSlug) await outputLicensePolicy(data, outputKind) } diff --git a/src/commands/organization/handle-organization-list.mts b/src/commands/organization/handle-organization-list.mts new file mode 100644 index 000000000..f4f62dc99 --- /dev/null +++ b/src/commands/organization/handle-organization-list.mts @@ -0,0 +1,12 @@ +import { fetchOrganization } from './fetch-organization-list.mts' +import { outputOrganizationList } from './output-organization-list.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleOrganizationList( + outputKind: OutputKind = 'text', +): Promise<void> { + const data = await fetchOrganization() + + await outputOrganizationList(data, outputKind) +} diff --git a/packages/cli/src/commands/organization/handle-quota.mts b/src/commands/organization/handle-quota.mts similarity index 100% rename from packages/cli/src/commands/organization/handle-quota.mts rename to src/commands/organization/handle-quota.mts diff --git a/packages/cli/src/commands/organization/handle-security-policy.mts b/src/commands/organization/handle-security-policy.mts similarity index 75% rename from packages/cli/src/commands/organization/handle-security-policy.mts rename to src/commands/organization/handle-security-policy.mts index 001cf8f75..c37a98dbc 100644 --- a/packages/cli/src/commands/organization/handle-security-policy.mts +++ b/src/commands/organization/handle-security-policy.mts @@ -7,9 +7,7 @@ export async function handleSecurityPolicy( orgSlug: string, outputKind: OutputKind, ): Promise<void> { - const data = await fetchSecurityPolicy(orgSlug, { - commandPath: 'socket organization policy security', - }) + const data = await fetchSecurityPolicy(orgSlug) await outputSecurityPolicy(data, outputKind) } diff --git a/src/commands/organization/output-dependencies.mts b/src/commands/organization/output-dependencies.mts new file mode 100644 index 000000000..441f67b27 --- /dev/null +++ b/src/commands/organization/output-dependencies.mts @@ -0,0 +1,72 @@ +// @ts-ignore +import chalkTable from 'chalk-table' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputDependencies( + result: CResult<SocketSdkReturnType<'searchDependencies'>['data']>, + { + limit, + offset, + outputKind, + }: { + limit: number + offset: number + outputKind: OutputKind + }, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + outputMarkdown(result.data, { limit, offset }) +} + +function outputMarkdown( + result: SocketSdkReturnType<'searchDependencies'>['data'], + { + limit, + offset, + }: { + limit: number + offset: number + }, +) { + logger.log('# Organization dependencies') + logger.log('') + logger.log('Request details:') + logger.log('- Offset:', offset) + logger.log('- Limit:', limit) + logger.log('- Is there more data after this?', result.end ? 'no' : 'yes') + logger.log('') + + const options = { + columns: [ + { field: 'type', name: colors.cyan('Ecosystem') }, + { field: 'namespace', name: colors.cyan('Namespace') }, + { field: 'name', name: colors.cyan('Name') }, + { field: 'version', name: colors.cyan('Version') }, + { field: 'repository', name: colors.cyan('Repository') }, + { field: 'branch', name: colors.cyan('Branch') }, + { field: 'direct', name: colors.cyan('Direct') }, + ], + } + + logger.log(chalkTable(options, result.rows)) +} diff --git a/src/commands/organization/output-license-policy.mts b/src/commands/organization/output-license-policy.mts new file mode 100644 index 000000000..e62c33174 --- /dev/null +++ b/src/commands/organization/output-license-policy.mts @@ -0,0 +1,41 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTableOfPairs } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputLicensePolicy( + result: CResult<SocketSdkReturnType<'getOrgLicensePolicy'>['data']>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.info('Use --json to get the full result') + logger.log('# License policy') + logger.log('') + logger.log('This is the license policy for your organization:') + logger.log('') + const rules = result.data['license_policy']! + const entries = rules ? Object.entries(rules) : [] + const mapped: Array<[string, string]> = entries.map( + ([key, value]) => + [key, (value as any)?.['allowed'] ? ' yes' : ' no'] as const, + ) + mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + logger.log(mdTableOfPairs(mapped, ['License Name', 'Allowed'])) + logger.log('') +} diff --git a/src/commands/organization/output-organization-list.mts b/src/commands/organization/output-organization-list.mts new file mode 100644 index 000000000..287e21e39 --- /dev/null +++ b/src/commands/organization/output-organization-list.mts @@ -0,0 +1,78 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { getVisibleTokenPrefix } from '../../utils/sdk.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputOrganizationList( + result: CResult<SocketSdkReturnType<'getOrganizations'>['data']>, + outputKind: OutputKind = 'text', +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const organizations = Object.values(result.data.organizations) + const visibleTokenPrefix = getVisibleTokenPrefix() + + switch (outputKind) { + case 'markdown': { + // | Syntax | Description | + // | ----------- | ----------- | + // | Header | Title | + // | Paragraph | Text | + let mw1 = 4 + let mw2 = 2 + let mw3 = 4 + for (const o of organizations) { + mw1 = Math.max(mw1, o.name?.length ?? 0) + mw2 = Math.max(mw2, o.id.length) + mw3 = Math.max(mw3, o.plan.length) + } + logger.log('# Organizations\n') + logger.log( + `List of organizations associated with your API key, starting with: ${colors.italic(visibleTokenPrefix)}\n`, + ) + logger.log( + `| Name${' '.repeat(mw1 - 4)} | ID${' '.repeat(mw2 - 2)} | Plan${' '.repeat(mw3 - 4)} |`, + ) + logger.log( + `| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} | ${'-'.repeat(mw3)} |`, + ) + for (const o of organizations) { + logger.log( + `| ${(o.name || '').padEnd(mw1, ' ')} | ${(o.id || '').padEnd(mw2, ' ')} | ${(o.plan || '').padEnd(mw3, ' ')} |`, + ) + } + logger.log( + `| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} | ${'-'.repeat(mw3)} |`, + ) + return + } + default: { + logger.log( + `List of organizations associated with your API key, starting with: ${colors.italic(visibleTokenPrefix)}\n`, + ) + // Just dump + for (const o of organizations) { + logger.log( + `- Name: ${colors.bold(o.name ?? 'undefined')}, ID: ${colors.bold(o.id)}, Plan: ${colors.bold(o.plan)}`, + ) + } + } + } +} diff --git a/src/commands/organization/output-quota.mts b/src/commands/organization/output-quota.mts new file mode 100644 index 000000000..b43f74aa6 --- /dev/null +++ b/src/commands/organization/output-quota.mts @@ -0,0 +1,36 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputQuota( + result: CResult<SocketSdkReturnType<'getQuota'>['data']>, + outputKind: OutputKind = 'text', +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log('# Quota') + logger.log('') + logger.log(`Quota left on the current API token: ${result.data.quota}`) + logger.log('') + return + } + + logger.log(`Quota left on the current API token: ${result.data.quota}`) + logger.log('') +} diff --git a/src/commands/organization/output-security-policy.mts b/src/commands/organization/output-security-policy.mts new file mode 100644 index 000000000..aa1524c11 --- /dev/null +++ b/src/commands/organization/output-security-policy.mts @@ -0,0 +1,48 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTableOfPairs } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputSecurityPolicy( + result: CResult<SocketSdkReturnType<'getOrgSecurityPolicy'>['data']>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log('# Security policy') + logger.log('') + logger.log( + `The default security policy setting is: "${result.data.securityPolicyDefault}"`, + ) + logger.log('') + logger.log( + 'These are the security policies per setting for your organization:', + ) + logger.log('') + const rules = result.data.securityPolicyRules + const entries: Array< + [string, { action: 'defer' | 'error' | 'warn' | 'monitor' | 'ignore' }] + > = rules ? Object.entries(rules) : [] + const mapped: Array<[string, string]> = entries.map(([key, value]) => [ + key, + value.action, + ]) + mapped.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + logger.log(mdTableOfPairs(mapped, ['name', 'action'])) + logger.log('') +} diff --git a/src/commands/package/cmd-package-score.mts b/src/commands/package/cmd-package-score.mts new file mode 100644 index 000000000..047fa97fe --- /dev/null +++ b/src/commands/package/cmd-package-score.mts @@ -0,0 +1,131 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handlePurlDeepScore } from './handle-purl-deep-score.mts' +import { parsePackageSpecifiers } from './parse-package-specifiers.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'score', + description: + 'Look up score for one package which reflects all of its transitive dependencies as well', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <<ECOSYSTEM> <NAME> | <PURL>> + + API Token Requirements + - Quota: 100 units + - Permissions: packages:list + + Options + ${getFlagListOutput(config.flags, 6)} + + Show deep scoring details for one package. The score will reflect the package + itself, any of its dependencies, and any of its transitive dependencies. + + When you want to know whether to trust a package, this is the command to run. + + See also the \`socket package shallow\` command, which returns the shallow + score for any number of packages. That will not reflect the dependency scores. + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + The version is optional but when given should be a direct match. The \`pkg:\` + prefix is optional. + + Note: if a package cannot be found it may be too old or perhaps was removed + before we had the opportunity to process it. + + Examples + $ ${command} npm babel-cli + $ ${command} npm eslint@1.0.0 --json + $ ${command} pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60 + $ ${command} nuget/needpluscommonlibrary@1.0.0 --markdown + `, +} + +export const cmdPackageScore = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [ecosystem = '', purl] = cli.input + + const hasApiToken = hasDefaultToken() + + const { purls, valid } = parsePackageSpecifiers(ecosystem, purl ? [purl] : []) + + const wasValidInput = checkCommandInput( + outputKind, + { + test: valid, + message: 'First parameter must be an ecosystem or the whole purl', + pass: 'ok', + fail: 'bad', + }, + { + test: purls.length === 1, + message: 'Expecting at least one package', + pass: 'ok', + fail: purls.length === 0 ? 'missing' : 'too many', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handlePurlDeepScore(purls[0] || '', outputKind) +} diff --git a/src/commands/package/cmd-package-score.test.mts b/src/commands/package/cmd-package-score.test.mts new file mode 100644 index 000000000..14d7e584a --- /dev/null +++ b/src/commands/package/cmd-package-score.test.mts @@ -0,0 +1,124 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket package score', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['package', 'score', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up score for one package which reflects all of its transitive dependencies as well + + Usage + $ socket package score [options] <<ECOSYSTEM> <NAME> | <PURL>> + + API Token Requirements + - Quota: 100 units + - Permissions: packages:list + + Options + --json Output result as json + --markdown Output result as markdown + + Show deep scoring details for one package. The score will reflect the package + itself, any of its dependencies, and any of its transitive dependencies. + + When you want to know whether to trust a package, this is the command to run. + + See also the \`socket package shallow\` command, which returns the shallow + score for any number of packages. That will not reflect the dependency scores. + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + The version is optional but when given should be a direct match. The \`pkg:\` + prefix is optional. + + Note: if a package cannot be found it may be too old or perhaps was removed + before we had the opportunity to process it. + + Examples + $ socket package score npm babel-cli + $ socket package score npm eslint@1.0.0 --json + $ socket package score pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60 + $ socket package score nuget/needpluscommonlibrary@1.0.0 --markdown" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect( + stderr, + 'header should include command (without params)', + ).toContain('`socket package score`') + }, + ) + + cmdit( + ['package', 'score', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - First parameter must be an ecosystem or the whole purl (\\x1b[31mbad\\x1b[39m) + + - Expecting at least one package (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'package', + 'score', + 'npm', + 'babel', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package score\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/package/cmd-package-shallow.mts b/src/commands/package/cmd-package-shallow.mts new file mode 100644 index 000000000..796b5945f --- /dev/null +++ b/src/commands/package/cmd-package-shallow.mts @@ -0,0 +1,130 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handlePurlsShallowScore } from './handle-purls-shallow-score.mts' +import { parsePackageSpecifiers } from './parse-package-specifiers.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'shallow', + description: + 'Look up info regarding one or more packages but not their transitives', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <<ECOSYSTEM> <PKGNAME> [<PKGNAME> ...] | <PURL> [<PURL> ...]> + + API Token Requirements + - Quota: 100 units + - Permissions: packages:list + + Options + ${getFlagListOutput(config.flags, 6)} + + Show scoring details for one or more packages purely based on their own package. + This means that any dependency scores are not reflected by the score. You can + use the \`socket package score <pkg>\` command to get its full transitive score. + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped to that ecosystem. The \`pkg:\` prefix is optional. + + Note: if a package cannot be found, it may be too old or perhaps was removed + before we had the opportunity to process it. + + Examples + $ ${command} npm webtorrent + $ ${command} npm webtorrent@1.9.1 + $ ${command} npm/webtorrent@1.9.1 + $ ${command} pkg:npm/webtorrent@1.9.1 + $ ${command} maven webtorrent babel + $ ${command} npm/webtorrent golang/babel + $ ${command} npm npm/webtorrent@1.0.1 babel + `, +} + +export const cmdPackageShallow = { + description: config.description, + hidden: config.hidden, + alias: { + shallowScore: { + description: config.description, + hidden: true, + argv: [], + }, + }, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [ecosystem = '', ...pkgs] = cli.input + + const { purls, valid } = parsePackageSpecifiers(ecosystem, pkgs) + + const wasValidInput = checkCommandInput( + outputKind, + { + test: valid, + message: + 'First parameter should be an ecosystem or all args must be purls', + pass: 'ok', + fail: 'bad', + }, + { + test: purls.length > 0, + message: 'Expecting at least one package', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handlePurlsShallowScore({ + outputKind, + purls, + }) +} diff --git a/src/commands/package/cmd-package-shallow.test.mts b/src/commands/package/cmd-package-shallow.test.mts new file mode 100644 index 000000000..ddf58fa09 --- /dev/null +++ b/src/commands/package/cmd-package-shallow.test.mts @@ -0,0 +1,120 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket package shallow', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['package', 'shallow', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Look up info regarding one or more packages but not their transitives + + Usage + $ socket package shallow [options] <<ECOSYSTEM> <PKGNAME> [<PKGNAME> ...] | <PURL> [<PURL> ...]> + + API Token Requirements + - Quota: 100 units + - Permissions: packages:list + + Options + --json Output result as json + --markdown Output result as markdown + + Show scoring details for one or more packages purely based on their own package. + This means that any dependency scores are not reflected by the score. You can + use the \`socket package score <pkg>\` command to get its full transitive score. + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + A "purl" is a standard package name formatting: \`pkg:eco/name@version\` + This command will automatically prepend "pkg:" when not present. + + If the first arg is an ecosystem, remaining args that are not a purl are + assumed to be scoped to that ecosystem. The \`pkg:\` prefix is optional. + + Note: if a package cannot be found, it may be too old or perhaps was removed + before we had the opportunity to process it. + + Examples + $ socket package shallow npm webtorrent + $ socket package shallow npm webtorrent@1.9.1 + $ socket package shallow npm/webtorrent@1.9.1 + $ socket package shallow pkg:npm/webtorrent@1.9.1 + $ socket package shallow maven webtorrent babel + $ socket package shallow npm/webtorrent golang/babel + $ socket package shallow npm npm/webtorrent@1.0.1 babel" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket package shallow`', + ) + }, + ) + + cmdit( + ['package', 'shallow', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - First parameter should be an ecosystem or all args must be purls (\\x1b[31mbad\\x1b[39m) + + - Expecting at least one package (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'package', + 'shallow', + 'npm', + 'babel', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package shallow\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/package/cmd-package.mts b/src/commands/package/cmd-package.mts new file mode 100644 index 000000000..bc64e0836 --- /dev/null +++ b/src/commands/package/cmd-package.mts @@ -0,0 +1,33 @@ +import { cmdPackageScore } from './cmd-package-score.mts' +import { cmdPackageShallow } from './cmd-package-shallow.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Commands relating to looking up published packages' + +export const cmdPackage: CliSubcommand = { + description, + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + score: cmdPackageScore, + shallow: cmdPackageShallow, + }, + { + aliases: { + deep: { + description, + hidden: true, + argv: ['score'], + }, + }, + argv, + description, + importMeta, + name: parentName + ' package', + }, + ) + }, +} diff --git a/src/commands/package/cmd-package.test.mts b/src/commands/package/cmd-package.test.mts new file mode 100644 index 000000000..63217e271 --- /dev/null +++ b/src/commands/package/cmd-package.test.mts @@ -0,0 +1,67 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket package', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['package', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Commands relating to looking up published packages + + Usage + $ socket package <command> + + Commands + score Look up score for one package which reflects all of its transitive dependencies as well + shallow Look up info regarding one or more packages but not their transitives + + Options + (none) + + Examples + $ socket package --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket package`', + ) + }, + ) + + cmdit( + ['package', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket package\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/packages/cli/src/commands/package/fetch-purl-deep-score.mts b/src/commands/package/fetch-purl-deep-score.mts similarity index 88% rename from packages/cli/src/commands/package/fetch-purl-deep-score.mts rename to src/commands/package/fetch-purl-deep-score.mts index c86bdc95d..8ecb4dc2b 100644 --- a/packages/cli/src/commands/package/fetch-purl-deep-score.mts +++ b/src/commands/package/fetch-purl-deep-score.mts @@ -1,11 +1,9 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { logger } from '@socketsecurity/registry/lib/logger' -import { queryApiSafeJson } from '../../util/socket/api.mjs' +import { queryApiSafeJson } from '../../utils/api.mts' import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() - export interface PurlDataResponse { purl: string self: { diff --git a/src/commands/package/fetch-purls-shallow-score.mts b/src/commands/package/fetch-purls-shallow-score.mts new file mode 100644 index 000000000..cffb3754b --- /dev/null +++ b/src/commands/package/fetch-purls-shallow-score.mts @@ -0,0 +1,41 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchPurlsShallowScore( + purls: string[], +): Promise<CResult<SocketSdkReturnType<'batchPackageFetch'>>> { + logger.info( + `Requesting shallow score data for ${purls.length} package urls (purl): ${purls.join(', ')}`, + ) + + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + const result = await handleApiCall( + sockSdk.batchPackageFetch( + { + alerts: 'true', + }, + { components: purls.map(purl => ({ purl })) }, + ), + 'looking up package', + ) + + if (!result.ok) { + return result + } + + // TODO: seems like there's a bug in the typing since we absolutely have to return the .data here + return { + ok: true, + data: result.data as SocketSdkReturnType<'batchPackageFetch'>, + } +} diff --git a/packages/cli/src/commands/package/fixtures/go_deep.json b/src/commands/package/fixtures/go_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/go_deep.json rename to src/commands/package/fixtures/go_deep.json diff --git a/packages/cli/src/commands/package/fixtures/go_shallow.json b/src/commands/package/fixtures/go_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/go_shallow.json rename to src/commands/package/fixtures/go_shallow.json diff --git a/packages/cli/src/commands/package/fixtures/maven_deep.json b/src/commands/package/fixtures/maven_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/maven_deep.json rename to src/commands/package/fixtures/maven_deep.json diff --git a/packages/cli/src/commands/package/fixtures/maven_shallow.json b/src/commands/package/fixtures/maven_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/maven_shallow.json rename to src/commands/package/fixtures/maven_shallow.json diff --git a/packages/cli/src/commands/package/fixtures/npm_deep.json b/src/commands/package/fixtures/npm_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/npm_deep.json rename to src/commands/package/fixtures/npm_deep.json diff --git a/packages/cli/src/commands/package/fixtures/npm_shallow.json b/src/commands/package/fixtures/npm_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/npm_shallow.json rename to src/commands/package/fixtures/npm_shallow.json diff --git a/packages/cli/src/commands/package/fixtures/nuget_deep.json b/src/commands/package/fixtures/nuget_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/nuget_deep.json rename to src/commands/package/fixtures/nuget_deep.json diff --git a/packages/cli/src/commands/package/fixtures/nuget_shallow.json b/src/commands/package/fixtures/nuget_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/nuget_shallow.json rename to src/commands/package/fixtures/nuget_shallow.json diff --git a/packages/cli/src/commands/package/fixtures/python_deep.json b/src/commands/package/fixtures/python_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/python_deep.json rename to src/commands/package/fixtures/python_deep.json diff --git a/packages/cli/src/commands/package/fixtures/python_dupes.json b/src/commands/package/fixtures/python_dupes.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/python_dupes.json rename to src/commands/package/fixtures/python_dupes.json diff --git a/packages/cli/src/commands/package/fixtures/python_shallow.json b/src/commands/package/fixtures/python_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/python_shallow.json rename to src/commands/package/fixtures/python_shallow.json diff --git a/packages/cli/src/commands/package/fixtures/ruby_deep.json b/src/commands/package/fixtures/ruby_deep.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/ruby_deep.json rename to src/commands/package/fixtures/ruby_deep.json diff --git a/packages/cli/src/commands/package/fixtures/ruby_shallow.json b/src/commands/package/fixtures/ruby_shallow.json similarity index 100% rename from packages/cli/src/commands/package/fixtures/ruby_shallow.json rename to src/commands/package/fixtures/ruby_shallow.json diff --git a/src/commands/package/handle-purl-deep-score.mts b/src/commands/package/handle-purl-deep-score.mts new file mode 100644 index 000000000..8742959b8 --- /dev/null +++ b/src/commands/package/handle-purl-deep-score.mts @@ -0,0 +1,13 @@ +import { fetchPurlDeepScore } from './fetch-purl-deep-score.mts' +import { outputPurlsDeepScore } from './output-purls-deep-score.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handlePurlDeepScore( + purl: string, + outputKind: OutputKind, +) { + const result = await fetchPurlDeepScore(purl) + + await outputPurlsDeepScore(purl, result, outputKind) +} diff --git a/src/commands/package/handle-purls-shallow-score.mts b/src/commands/package/handle-purls-shallow-score.mts new file mode 100644 index 000000000..1109e37c4 --- /dev/null +++ b/src/commands/package/handle-purls-shallow-score.mts @@ -0,0 +1,21 @@ +import { fetchPurlsShallowScore } from './fetch-purls-shallow-score.mts' +import { outputPurlsShallowScore } from './output-purls-shallow-score.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' + +export async function handlePurlsShallowScore({ + outputKind, + purls, +}: { + outputKind: OutputKind + purls: string[] +}) { + const packageData = await fetchPurlsShallowScore(purls) + + outputPurlsShallowScore( + purls, + packageData as CResult<SocketArtifact[]>, + outputKind, + ) +} diff --git a/src/commands/package/output-purls-deep-score.mts b/src/commands/package/output-purls-deep-score.mts new file mode 100644 index 000000000..39d097da6 --- /dev/null +++ b/src/commands/package/output-purls-deep-score.mts @@ -0,0 +1,218 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTable } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { PurlDataResponse } from './fetch-purl-deep-score.mts' +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputPurlsDeepScore( + purl: string, + result: CResult<PurlDataResponse>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + const md = createMarkdownReport(result.data) + logger.success(`Score report for "${result.data.purl}" ("${purl}"):\n`) + logger.log(md) + return + } + + logger.log( + `Score report for "${purl}" (use --json for raw and --markdown for formatted reports):`, + ) + logger.log(result.data) + logger.log('') +} + +export function createMarkdownReport(data: PurlDataResponse) { + const { + self: { + alerts: selfAlerts, + capabilities: selfCaps, + purl, + score: selfScore, + }, + transitively: { + alerts, + capabilities, + dependencyCount, + func, + lowest, + score, + }, + } = data + + const arr: string[] = [] + + arr.push('# Complete Package Score') + arr.push('') + if (dependencyCount) { + arr.push( + `This is a Socket report for the package *"${purl}"* and its *${dependencyCount}* direct/transitive dependencies.`, + ) + } else { + arr.push( + `This is a Socket report for the package *"${purl}"*. It has *no dependencies*.`, + ) + } + arr.push('') + if (dependencyCount) { + arr.push( + `It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it.`, + ) + } else { + arr.push( + `It will show you the shallow score for the package itself, which capabilities were found, and its top alerts.`, + ) + arr.push('') + arr.push( + 'Since it has no dependencies, the shallow score is also the deep score.', + ) + } + arr.push('') + if (dependencyCount) { + // This doesn't make much sense if there are no dependencies. Better to omit it. + arr.push( + 'The report should give you a good insight into the status of this package.', + ) + arr.push('') + arr.push('## Package itself') + arr.push('') + arr.push( + 'Here are results for the package itself (excluding data from dependencies).', + ) + } else { + arr.push('## Report') + arr.push('') + arr.push( + 'The report should give you a good insight into the status of this package.', + ) + } + arr.push('') + arr.push('### Shallow Score') + arr.push('') + arr.push('This score is just for the package itself:') + arr.push('') + arr.push('- Overall: ' + selfScore.overall) + arr.push('- Maintenance: ' + selfScore.maintenance) + arr.push('- Quality: ' + selfScore.quality) + arr.push('- Supply Chain: ' + selfScore.supplyChain) + arr.push('- Vulnerability: ' + selfScore.vulnerability) + arr.push('- License: ' + selfScore.license) + arr.push('') + arr.push('### Capabilities') + arr.push('') + if (selfCaps.length) { + arr.push('These are the capabilities detected in the package itself:') + arr.push('') + selfCaps.forEach(cap => { + arr.push(`- ${cap}`) + }) + } else { + arr.push('No capabilities were found in the package.') + } + arr.push('') + arr.push('### Alerts for this package') + arr.push('') + if (selfAlerts.length) { + if (dependencyCount) { + arr.push('These are the alerts found for the package itself:') + } else { + arr.push('These are the alerts found for this package:') + } + arr.push('') + arr.push( + mdTable(selfAlerts, ['severity', 'name'], ['Severity', 'Alert Name']), + ) + } else { + arr.push('There are currently no alerts for this package.') + } + arr.push('') + if (dependencyCount) { + arr.push('## Transitive Package Results') + arr.push('') + arr.push( + 'Here are results for the package and its direct/transitive dependencies.', + ) + arr.push('') + arr.push('### Deep Score') + arr.push('') + arr.push( + 'This score represents the package and and its direct/transitive dependencies:', + ) + arr.push( + `The function used to calculate the values in aggregate is: *"${func}"*`, + ) + arr.push('') + arr.push('- Overall: ' + score.overall) + arr.push('- Maintenance: ' + score.maintenance) + arr.push('- Quality: ' + score.quality) + arr.push('- Supply Chain: ' + score.supplyChain) + arr.push('- Vulnerability: ' + score.vulnerability) + arr.push('- License: ' + score.license) + arr.push('') + arr.push('### Capabilities') + arr.push('') + arr.push( + 'These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores.', + ) + arr.push('') + arr.push('- Overall: ' + lowest.overall) + arr.push('- Maintenance: ' + lowest.maintenance) + arr.push('- Quality: ' + lowest.quality) + arr.push('- Supply Chain: ' + lowest.supplyChain) + arr.push('- Vulnerability: ' + lowest.vulnerability) + arr.push('- License: ' + lowest.license) + arr.push('') + arr.push('### Capabilities') + arr.push('') + if (capabilities.length) { + arr.push('These are the capabilities detected in at least one package:') + arr.push('') + capabilities.forEach(cap => { + arr.push(`- ${cap}`) + }) + } else { + arr.push( + 'This package had no capabilities and neither did any of its direct/transitive dependencies.', + ) + } + arr.push('') + arr.push('### Alerts') + arr.push('') + if (alerts.length) { + arr.push('These are the alerts found:') + arr.push('') + + arr.push( + mdTable( + alerts, + ['severity', 'name', 'example'], + ['Severity', 'Alert Name', 'Example package reporting it'], + ), + ) + } else { + arr.push( + 'This package had no alerts and neither did any of its direct/transitive dependencies', + ) + } + arr.push('') + + return arr.join('\n') + } +} diff --git a/src/commands/package/output-purls-deep-score.test.mts b/src/commands/package/output-purls-deep-score.test.mts new file mode 100644 index 000000000..8f112f279 --- /dev/null +++ b/src/commands/package/output-purls-deep-score.test.mts @@ -0,0 +1,628 @@ +import { describe, expect, it } from 'vitest' + +import goDeep from './fixtures/go_deep.json' +import mavenDeep from './fixtures/maven_deep.json' +import npmDeep from './fixtures/npm_deep.json' +import nugetDeep from './fixtures/nuget_deep.json' +import pythonDeep from './fixtures/python_deep.json' +import rubyDeep from './fixtures/ruby_deep.json' +import { createMarkdownReport } from './output-purls-deep-score.mts' + +describe('package score output', async () => { + describe('npm', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(npmDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"npm/bowserify@10.2.1"* and its *171* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 35 + - Maintenance: 74 + - Quality: 99 + - Supply Chain: 35 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + These are the capabilities detected in the package itself: + + - fs + - net + - unsafe + - url + + ### Alerts for this package + + These are the alerts found for the package itself: + + | -------- | ---------------- | + | Severity | Alert Name | + | -------- | ---------------- | + | critical | didYouMean | + | high | troll | + | middle | networkAccess | + | middle | unpopularPackage | + | low | debugAccess | + | low | dynamicRequire | + | low | filesystemAccess | + | low | unmaintained | + | -------- | ---------------- | + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 25 + - Maintenance: 50 + - Quality: 49 + - Supply Chain: 35 + - Vulnerability: 25 + - License: 80 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: npm/shell-quote@0.0.1 + - Maintenance: npm/jsonify@0.0.1 + - Quality: npm/tty-browserify@0.0.1 + - Supply Chain: npm/bowserify@10.2.1 + - Vulnerability: npm/shell-quote@0.0.1 + - License: npm/acorn-node@1.8.2 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - env + - eval + - fs + - net + - unsafe + - url + + ### Alerts + + These are the alerts found: + + | -------- | ---------------------- | ---------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | ---------------------- | ---------------------------- | + | critical | criticalCVE | npm/shell-quote@0.0.1 | + | critical | didYouMean | npm/bowserify@10.2.1 | + | high | cve | npm/minimatch@2.0.10 | + | high | socketUpgradeAvailable | npm/safe-buffer@5.1.2 | + | high | troll | npm/bowserify@10.2.1 | + | middle | deprecated | npm/querystring@0.2.0 | + | middle | miscLicenseIssues | npm/duplexer2@0.0.2 | + | middle | missingAuthor | npm/indexof@0.0.1 | + | middle | networkAccess | npm/https-browserify@0.0.1 | + | middle | trivialPackage | npm/tty-browserify@0.0.1 | + | middle | unpopularPackage | npm/b@1.0.0 | + | middle | usesEval | npm/syntax-error@1.4.0 | + | low | debugAccess | npm/asn1.js@4.10.1 | + | low | dynamicRequire | npm/module-deps@3.9.1 | + | low | envVars | npm/readable-stream@2.3.8 | + | low | filesystemAccess | npm/browser-resolve@1.11.3 | + | low | newAuthor | npm/wrappy@1.0.2 | + | low | noLicenseFound | npm/indexof@0.0.1 | + | low | unidentifiedLicense | npm/jsonify@0.0.1 | + | low | unmaintained | npm/bowserify@10.2.1 | + | -------- | ---------------------- | ---------------------------- | + " + `) + }) + }) + + describe('go', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(goDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60"* and its *81* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 100 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 100 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + No capabilities were found in the package. + + ### Alerts for this package + + There are currently no alerts for this package. + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 70 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 70 + - Vulnerability: 84 + - License: 70 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: golang/go.uber.org/mock@v0.5.0 + - Maintenance: golang/github.com/stretchr/objx@v0.1.0 + - Quality: golang/github.com/stretchr/objx@v0.1.0 + - Supply Chain: golang/go.uber.org/mock@v0.5.0 + - Vulnerability: golang/github.com/golang-jwt/jwt/v5@v5.2.1 + - License: golang/github.com/hashicorp/go-cleanhttp@v0.5.2 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - env + - eval + - fs + - net + - shell + - unsafe + + ### Alerts + + These are the alerts found: + + | -------- | ---------------------- | ------------------------------------------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | ---------------------- | ------------------------------------------------------------- | + | high | cve | golang/github.com/golang-jwt/jwt/v5@v5.2.1 | + | middle | hasNativeCode | golang/github.com/pkg/diff@v0.0.0-20210226163009-20ebb0f2a09e | + | middle | mediumCVE | golang/golang.org/x/net@v0.35.0 | + | middle | networkAccess | golang/github.com/stretchr/objx@v0.1.0 | + | middle | potentialVulnerability | golang/github.com/onsi/ginkgo/v2@v2.22.2 | + | middle | shellAccess | golang/github.com/stretchr/testify@v1.9.0 | + | middle | usesEval | golang/gopkg.in/yaml.v3@v3.0.1 | + | low | copyleftLicense | golang/github.com/hashicorp/go-cleanhttp@v0.5.2 | + | low | envVars | golang/gopkg.in/yaml.v3@v3.0.1 | + | low | filesystemAccess | golang/github.com/stretchr/objx@v0.1.0 | + | low | gptAnomaly | golang/github.com/stretchr/objx@v0.1.0 | + | low | nonpermissiveLicense | golang/github.com/hashicorp/go-cleanhttp@v0.5.2 | + | low | unidentifiedLicense | golang/gopkg.in/yaml.v3@v3.0.1 | + | -------- | ---------------------- | ------------------------------------------------------------- | + " + `) + }) + }) + + describe('ruby', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(rubyDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"pkg:gem/plaid@14.11.0?platform=ruby"* and its *31* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 100 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 100 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + No capabilities were found in the package. + + ### Alerts for this package + + There are currently no alerts for this package. + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 72 + - Maintenance: 100 + - Quality: 92 + - Supply Chain: 84 + - Vulnerability: 72 + - License: 70 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: gem/rexml@3.2.4 + - Maintenance: gem/diff-lcs@1.4.4 + - Quality: gem/rspec@3.10.0 + - Supply Chain: gem/rubocop@0.91.1 + - Vulnerability: gem/rexml@3.2.4 + - License: gem/diff-lcs@1.4.4 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - env + - eval + - fs + - net + - shell + - unsafe + + ### Alerts + + These are the alerts found: + + | -------- | -------------------- | ---------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | -------------------- | ---------------------------- | + | high | cve | gem/rexml@3.2.4 | + | middle | mediumCVE | gem/rexml@3.2.4 | + | middle | networkAccess | gem/faraday@1.8.0 | + | middle | shellAccess | gem/diff-lcs@1.4.4 | + | middle | usesEval | gem/ruby2_keywords@0.0.5 | + | low | copyleftLicense | gem/diff-lcs@1.4.4 | + | low | envVars | gem/parser@2.7.2.0 | + | low | filesystemAccess | gem/diff-lcs@1.4.4 | + | low | noLicenseFound | gem/minitest@5.14.2 | + | low | nonpermissiveLicense | gem/diff-lcs@1.4.4 | + | -------- | -------------------- | ---------------------------- | + " + `) + }) + }) + + describe('nuget', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(nugetDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"pkg:nuget/needpluscommonlibrary@1.0.0"* and its *3* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 100 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 100 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + No capabilities were found in the package. + + ### Alerts for this package + + There are currently no alerts for this package. + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 84 + - Maintenance: 100 + - Quality: 88 + - Supply Chain: 89 + - Vulnerability: 84 + - License: 100 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: nuget/newtonsoft.json@4.5.10 + - Maintenance: nuget/dotnetzip@1.9.1.8 + - Quality: nuget/dotnetzip@1.9.1.8 + - Supply Chain: nuget/nlog@2.0.0.2000 + - Vulnerability: nuget/newtonsoft.json@4.5.10 + - License: nuget/dotnetzip@1.9.1.8 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - eval + - fs + - net + - shell + - unsafe + + ### Alerts + + These are the alerts found: + + | -------- | ------------------- | ---------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | ------------------- | ---------------------------- | + | high | cve | nuget/newtonsoft.json@4.5.10 | + | middle | mediumCVE | nuget/dotnetzip@1.9.1.8 | + | middle | networkAccess | nuget/nlog@2.0.0.2000 | + | middle | shellAccess | nuget/dotnetzip@1.9.1.8 | + | middle | usesEval | nuget/dotnetzip@1.9.1.8 | + | low | filesystemAccess | nuget/dotnetzip@1.9.1.8 | + | low | unidentifiedLicense | nuget/dotnetzip@1.9.1.8 | + | -------- | ------------------- | ---------------------------- | + " + `) + }) + }) + + describe('maven', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(mavenDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"pkg:maven/org.apache.beam/beam-runners-flink-1.15-job-server@2.58.0?classifier=tests&ext=jar"* and its *404* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 100 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 100 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + No capabilities were found in the package. + + ### Alerts for this package + + There are currently no alerts for this package. + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 6 + - Maintenance: 71 + - Quality: 88 + - Supply Chain: 6 + - Vulnerability: 25 + - License: 50 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: maven/io.trino.hadoop/hadoop-apache@3.2.0-12 + - Maintenance: maven/org.apache.beam/beam-sdks-java-extensions-arrow@2.58.0 + - Quality: maven/log4j/log4j@1.2.17 + - Supply Chain: maven/io.trino.hadoop/hadoop-apache@3.2.0-12 + - Vulnerability: maven/log4j/log4j@1.2.17 + - License: maven/com.fasterxml.jackson.datatype/jackson-datatype-joda@2.15.4 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - env + - eval + - fs + - net + - shell + - unsafe + + ### Alerts + + These are the alerts found: + + | -------- | ---------------------- | ---------------------------------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | ---------------------- | ---------------------------------------------------- | + | critical | criticalCVE | maven/log4j/log4j@1.2.17 | + | critical | didYouMean | maven/io.trino.hadoop/hadoop-apache@3.2.0-12 | + | high | cve | maven/log4j/log4j@1.2.17 | + | middle | hasNativeCode | maven/org.apache.beam/beam-vendor-grpc-1_60_1@0.2 | + | middle | mediumCVE | maven/org.apache.ant/ant@1.10.9 | + | middle | networkAccess | maven/log4j/log4j@1.2.17 | + | middle | potentialVulnerability | maven/log4j/log4j@1.2.17 | + | middle | shellAccess | maven/org.apache.beam/beam-vendor-calcite-1_28_0@0.2 | + | middle | usesEval | maven/log4j/log4j@1.2.17 | + | low | copyleftLicense | maven/javax.annotation/javax.annotation-api@1.3.2 | + | low | envVars | maven/org.apache.beam/beam-vendor-calcite-1_28_0@0.2 | + | low | filesystemAccess | maven/log4j/log4j@1.2.17 | + | low | gptAnomaly | maven/io.netty/netty-transport@4.1.100.Final | + | low | licenseException | maven/javax.annotation/javax.annotation-api@1.3.2 | + | low | mildCVE | maven/org.apache.hadoop/hadoop-common@2.10.2 | + | low | noLicenseFound | maven/com.google.guava/failureaccess@1.0.2 | + | low | nonpermissiveLicense | maven/org.apache.commons/commons-math3@3.6.1 | + | low | unidentifiedLicense | maven/log4j/log4j@1.2.17 | + | low | unmaintained | maven/log4j/log4j@1.2.17 | + | -------- | ---------------------- | ---------------------------------------------------- | + " + `) + }) + }) + + describe('python', () => { + it('should report deep as markdown', () => { + const txt = createMarkdownReport(pythonDeep.data, []) + expect(txt).toMatchInlineSnapshot(` + "# Complete Package Score + + This is a Socket report for the package *"pkg:pypi/discordpydebug@0.0.4?artifact_id=tar-gz"* and its *825* direct/transitive dependencies. + + It will show you the shallow score for just the package itself and a deep score for all the transitives combined. Additionally you can see which capabilities were found and the top alerts as well as a package that was responsible for it. + + The report should give you a good insight into the status of this package. + + ## Package itself + + Here are results for the package itself (excluding data from dependencies). + + ### Shallow Score + + This score is just for the package itself: + + - Overall: 100 + - Maintenance: 100 + - Quality: 100 + - Supply Chain: 100 + - Vulnerability: 100 + - License: 100 + + ### Capabilities + + No capabilities were found in the package. + + ### Alerts for this package + + There are currently no alerts for this package. + + ## Transitive Package Results + + Here are results for the package and its direct/transitive dependencies. + + ### Deep Score + + This score represents the package and and its direct/transitive dependencies: + The function used to calculate the values in aggregate is: *"min"* + + - Overall: 70 + - Maintenance: 99 + - Quality: 88 + - Supply Chain: 70 + - Vulnerability: 100 + - License: 70 + + ### Capabilities + + These are the packages with the lowest recorded score. If there is more than one with the lowest score, just one is shown here. This may help you figure out the source of low scores. + + - Overall: pypi/virtualenv@20.31.2 + - Maintenance: pypi/webencodings@0.5.1 + - Quality: pypi/coverage-enable-subprocess@1.0 + - Supply Chain: pypi/virtualenv@20.31.2 + - Vulnerability: pypi/chardet@5.2.0 + - License: pypi/chardet@5.2.0 + + ### Capabilities + + These are the capabilities detected in at least one package: + + - env + - eval + - fs + - net + - shell + - unsafe + - url + + ### Alerts + + These are the alerts found: + + | -------- | -------------------- | ----------------------------- | + | Severity | Alert Name | Example package reporting it | + | -------- | -------------------- | ----------------------------- | + | middle | gptDidYouMean | pypi/jinja2@3.1.6 | + | middle | hasNativeCode | pypi/pyyaml@6.0.2 | + | middle | networkAccess | pypi/webencodings@0.5.1 | + | middle | shellAccess | pypi/colorama@0.4.6 | + | middle | usesEval | pypi/stack-data@0.6.3 | + | low | ambiguousClassifier | pypi/jinja2@3.1.6 | + | low | copyleftLicense | pypi/chardet@5.2.0 | + | low | envVars | pypi/sphinxcontrib-jquery@4.1 | + | low | filesystemAccess | pypi/chardet@5.2.0 | + | low | gptAnomaly | pypi/genshi@0.7.9 | + | low | licenseException | pypi/pygments@2.19.1 | + | low | nonpermissiveLicense | pypi/chardet@5.2.0 | + | low | unidentifiedLicense | pypi/webencodings@0.5.1 | + | low | unmaintained | pypi/webencodings@0.5.1 | + | -------- | -------------------- | ----------------------------- | + " + `) + }) + }) +}) diff --git a/src/commands/package/output-purls-shallow-score.mts b/src/commands/package/output-purls-shallow-score.mts new file mode 100644 index 000000000..8d3427eeb --- /dev/null +++ b/src/commands/package/output-purls-shallow-score.mts @@ -0,0 +1,327 @@ +import colors from 'yoctocolors-cjs' + +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' + +// This is a simplified view of an artifact. Potentially merged with other artifacts. +interface DedupedArtifact { + ecosystem: string // artifact.type + namespace: string + name: string + version: string + score: { + supplyChain: number + maintenance: number + quality: number + vulnerability: number + license: number + } + alerts: Map< + string, + { + type: string + severity: string + } + > +} + +export function outputPurlsShallowScore( + purls: string[], + result: CResult<SocketArtifact[]>, + outputKind: OutputKind, +): void { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const { missing, rows } = preProcess(result.data, purls) + + if (outputKind === 'markdown') { + const md = generateMarkdownReport(rows, missing) + logger.log(md) + return + } + + const txt = generateTextReport(rows, missing) + logger.log(txt) +} + +function formatReportCard(artifact: DedupedArtifact, color: boolean): string { + const scoreResult = { + 'Supply Chain Risk': Math.floor((artifact.score?.supplyChain ?? 0) * 100), + Maintenance: Math.floor((artifact.score?.maintenance ?? 0) * 100), + Quality: Math.floor((artifact.score?.quality ?? 0) * 100), + Vulnerabilities: Math.floor((artifact.score?.vulnerability ?? 0) * 100), + License: Math.floor((artifact.score?.license ?? 0) * 100), + } + const alertString = getAlertString(artifact.alerts, !color) + if (!artifact.ecosystem) { + debugFn('miss: Artifact ecosystem', artifact) + } + const purl = `pkg:${artifact.ecosystem}/${artifact.name}${artifact.version ? '@' + artifact.version : ''}` + + return [ + 'Package: ' + (color ? colors.bold(purl) : purl), + '', + ...Object.entries(scoreResult).map( + score => + `- ${score[0]}:`.padEnd(20, ' ') + + ` ${formatScore(score[1], !color, true)}`, + ), + alertString, + ].join('\n') +} + +function formatScore(score: number, noColor = false, pad = false): string { + const padded = String(score).padStart(pad ? 3 : 0, ' ') + if (noColor) { + return padded + } + if (score >= 80) { + return colors.green(padded) + } + if (score >= 60) { + return colors.yellow(padded) + } + return colors.red(padded) +} + +function getAlertString( + alerts: DedupedArtifact['alerts'], + noColor = false, +): string { + if (!alerts.size) { + return noColor ? `- Alerts: none!` : `- Alerts: ${colors.green('none')}!` + } + + const arr = Array.from(alerts.values()) + const bad = arr + .filter(alert => alert.severity !== 'low' && alert.severity !== 'middle') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + const mid = arr + .filter(alert => alert.severity === 'middle') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + const low = arr + .filter(alert => alert.severity === 'low') + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)) + + // We need to create the no-color string regardless because the actual string + // contains a bunch of invisible ANSI chars which would screw up length checks. + const colorless = `- Alerts (${bad.length}/${mid.length.toString()}/${low.length}):` + + if (noColor) { + return ( + colorless + + ' '.repeat(Math.max(0, 20 - colorless.length)) + + ' ' + + [ + bad.map(alert => `[${alert.severity}] ` + alert.type).join(', '), + mid.map(alert => `[${alert.severity}] ` + alert.type).join(', '), + low.map(alert => `[${alert.severity}] ` + alert.type).join(', '), + ] + .filter(Boolean) + .join(', ') + ) + } + return ( + `- Alerts (${colors.red(bad.length.toString())}/${colors.yellow(mid.length.toString())}/${low.length}):` + + ' '.repeat(Math.max(0, 20 - colorless.length)) + + ' ' + + [ + bad + .map(alert => + colors.red(colors.dim(`[${alert.severity}] `) + alert.type), + ) + .join(', '), + mid + .map(alert => + colors.yellow(colors.dim(`[${alert.severity}] `) + alert.type), + ) + .join(', '), + low + .map(alert => colors.dim(`[${alert.severity}] `) + alert.type) + .join(', '), + ] + .filter(Boolean) + .join(', ') + ) +} + +export function preProcess( + artifacts: SocketArtifact[], + requestedPurls: string[], +): { rows: Map<string, DedupedArtifact>; missing: string[] } { + // Dedupe results (for example, pypi will emit one package for each system release (win/mac/cpu) even if it's + // the same package version with same results. The duplication is irrelevant and annoying to the user. + + // Make some effort to match the requested data with the response + // Dedupe and merge results when only the .release value is different + + // API does not tell us which purls were not found. + // Generate all purls to try so we can try to match search request. + const purls: Set<string> = new Set() + artifacts.forEach(data => { + purls.add( + `pkg:${data.type}/${data.namespace ? `${data.namespace}/` : ''}${data.name}@${data.version}`, + ) + purls.add(`pkg:${data.type}/${data.name}@${data.version}`) + purls.add(`pkg:${data.type}/${data.name}`) + purls.add( + `pkg:${data.type}/${data.namespace ? `${data.namespace}/` : ''}${data.name}`, + ) + }) + // Try to match the searched purls against this list + const missing = requestedPurls.filter(purl => { + if (purls.has(purl)) { + return false + } + if ( + purl.endsWith('@latest') && + purls.has(purl.slice(0, -'@latest'.length)) + ) { + return false + } + return true // not found + }) + + // Create a unique set of rows which represents each artifact that is returned + // while deduping when the artifact (main) meta data only differs due to the + // .release field (observed with python, at least). + // Merge the alerts for duped packages. Use lowest score between all of them. + const rows: Map<string, DedupedArtifact> = new Map() + artifacts.forEach(artifact => { + const purl = `pkg:${artifact.type}/${artifact.namespace ? `${artifact.namespace}/` : ''}${artifact.name}${artifact.version ? `@${artifact.version}` : ''}` + if (rows.has(purl)) { + const row = rows.get(purl) + if (!row) { + // unreachable; satisfy TS + return + } + + if ((artifact.score?.supplyChain || 100) < row.score.supplyChain) { + row.score.supplyChain = artifact.score?.supplyChain || 100 + } + if ((artifact.score?.maintenance || 100) < row.score.maintenance) { + row.score.maintenance = artifact.score?.maintenance || 100 + } + if ((artifact.score?.quality || 100) < row.score.quality) { + row.score.quality = artifact.score?.quality || 100 + } + if ((artifact.score?.vulnerability || 100) < row.score.vulnerability) { + row.score.vulnerability = artifact.score?.vulnerability || 100 + } + if ((artifact.score?.license || 100) < row.score.license) { + row.score.license = artifact.score?.license || 100 + } + + artifact.alerts?.forEach(({ severity, type }) => { + row.alerts.set(`${type}:${severity}`, { + type: (type as string) ?? 'unknown', + severity: (severity as string) ?? 'none', + }) + }) + } else { + const alerts = new Map() + artifact.alerts?.forEach(({ severity, type }) => { + alerts.set(`${type}:${severity}`, { + type: (type as string) ?? 'unknown', + severity: (severity as string) ?? 'none', + }) + }) + + rows.set(purl, { + ecosystem: artifact.type, + namespace: artifact.namespace || '', + name: artifact.name, + version: artifact.version || '', + score: { + supplyChain: artifact.score?.supplyChain || 100, + maintenance: artifact.score?.maintenance || 100, + quality: artifact.score?.quality || 100, + vulnerability: artifact.score?.vulnerability || 100, + license: artifact.score?.license || 100, + }, + alerts, + }) + } + }) + + return { rows, missing } +} + +export function generateMarkdownReport( + artifacts: Map<string, DedupedArtifact>, + missing: string[], +): string { + const blocks: string[] = [] + const dupes: Set<string> = new Set() + artifacts.forEach(artifact => { + const block = '## ' + formatReportCard(artifact, false) + if (dupes.has(block)) { + return + } + dupes.add(block) + blocks.push(block) + }) + + return ` +# Shallow Package Report + +This report contains the response for requesting data on some package url(s). + +Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + +${missing.length ? `\n## Missing response\n\nAt least one package had no response or the purl was not canonical:\n\n${missing.map(purl => '- ' + purl + '\n').join('')}` : ''} + +${blocks.join('\n\n\n')} + `.trim() +} + +export function generateTextReport( + artifacts: Map<string, DedupedArtifact>, + missing: string[], +): string { + const arr: string[] = [] + + arr.push('\n' + colors.bold('Shallow Package Score') + '\n') + arr.push( + 'Please note: The listed scores are ONLY for the package itself. It does NOT\n' + + ' reflect the scores of any dependencies, transitive or otherwise.', + ) + + if (missing.length) { + arr.push( + `\nAt least one package had no response or the purl was not canonical:\n${missing.map(purl => '\n- ' + colors.bold(purl)).join('')}`, + ) + } + + const dupes: Set<string> = new Set() // Omit dupes when output is identical + artifacts.forEach(artifact => { + const block = formatReportCard(artifact, true) + if (dupes.has(block)) { + return + } + dupes.add(block) + arr.push('\n') + arr.push(block) + }) + arr.push('') + + return arr.join('\n') +} diff --git a/src/commands/package/output-purls-shallow-score.test.mts b/src/commands/package/output-purls-shallow-score.test.mts new file mode 100644 index 000000000..a07917a55 --- /dev/null +++ b/src/commands/package/output-purls-shallow-score.test.mts @@ -0,0 +1,364 @@ +import { describe, expect, it } from 'vitest' + +import goShallow from './fixtures/go_shallow.json' +import mavenShallow from './fixtures/maven_shallow.json' +import npmShallow from './fixtures/npm_shallow.json' +import nugetShallow from './fixtures/nuget_shallow.json' +import pythonDupes from './fixtures/python_dupes.json' +import pythonShallow from './fixtures/python_shallow.json' +import rubyShallow from './fixtures/ruby_shallow.json' +import { + generateMarkdownReport, + generateTextReport, + preProcess, +} from './output-purls-shallow-score.mts' + +describe('package score output', async () => { + describe('npm', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(npmShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:npm/bowserify@10.2.1 + + - Supply Chain Risk:  36 + - Maintenance:  75 + - Quality:  99 + - Vulnerabilities: 100 + - License: 100 + - Alerts (2/2/4): [critical] didYouMean, [high] troll, [middle] networkAccess, [middle] unpopularPackage, [low] debugAccess, [low] dynamicRequire, [low] filesystemAccess, [low] unmaintained + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(npmShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:npm/bowserify@10.2.1 + + - Supply Chain Risk: 36 + - Maintenance: 75 + - Quality: 99 + - Vulnerabilities: 100 + - License: 100 + - Alerts (2/2/4): [critical] didYouMean, [high] troll, [middle] networkAccess, [middle] unpopularPackage, [low] debugAccess, [low] dynamicRequire, [low] filesystemAccess, [low] unmaintained" + `) + }) + }) + + describe('go', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(goShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:golang/tlsproxy@v0.0.0-20250304082521-29051ed19c60 + + - Supply Chain Risk:  39 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, [low] filesystemAccess + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(goShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:golang/tlsproxy@v0.0.0-20250304082521-29051ed19c60 + + - Supply Chain Risk: 39 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, [low] filesystemAccess" + `) + }) + }) + + describe('ruby', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(rubyShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:gem/plaid@14.11.0 + + - Supply Chain Risk:  86 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (2/3/2): [high] gptMalware, [high] obfuscatedFile, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, [low] filesystemAccess + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(rubyShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:gem/plaid@14.11.0 + + - Supply Chain Risk: 86 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (2/3/2): [high] gptMalware, [high] obfuscatedFile, [middle] networkAccess, [middle] shellAccess, [middle] usesEval, [low] envVars, [low] filesystemAccess" + `) + }) + }) + + describe('nuget', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(nugetShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:nuget/needpluscommonlibrary@1.0.0 + + - Supply Chain Risk:  91 + - Maintenance: 100 + - Quality:  86 + - Vulnerabilities: 100 + - License: 100 + - Alerts (0/4/2): [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [middle] usesEval, [low] filesystemAccess, [low] unidentifiedLicense + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(nugetShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:nuget/needpluscommonlibrary@1.0.0 + + - Supply Chain Risk: 91 + - Maintenance: 100 + - Quality: 86 + - Vulnerabilities: 100 + - License: 100 + - Alerts (0/4/2): [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [middle] usesEval, [low] filesystemAccess, [low] unidentifiedLicense" + `) + }) + }) + + describe('maven', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(mavenShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:maven/beam-runners-flink-1.15-job-server@2.58.0 + + - Supply Chain Risk:  67 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License:  60 + - Alerts (0/3/0): [middle] hasNativeCode, [middle] networkAccess, [middle] usesEval + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(mavenShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:maven/beam-runners-flink-1.15-job-server@2.58.0 + + - Supply Chain Risk: 67 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 60 + - Alerts (0/3/0): [middle] hasNativeCode, [middle] networkAccess, [middle] usesEval" + `) + }) + }) + + describe('python', () => { + it('should report shallow as text', () => { + const { missing, rows } = preProcess(pythonShallow.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:pypi/discordpydebug@0.0.4 + + - Supply Chain Risk:  22 + - Maintenance: 100 + - Quality:  99 + - Vulnerabilities: 100 + - License: 100 + - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [low] filesystemAccess, [low] unidentifiedLicense + " + `) + }) + + it('should report shallow as markdown', () => { + const { missing, rows } = preProcess(pythonShallow.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:pypi/discordpydebug@0.0.4 + + - Supply Chain Risk: 22 + - Maintenance: 100 + - Quality: 99 + - Vulnerabilities: 100 + - License: 100 + - Alerts (1/3/2): [critical] malware, [middle] networkAccess, [middle] shellAccess, [middle] unpopularPackage, [low] filesystemAccess, [low] unidentifiedLicense" + `) + }) + + describe('python duplication', () => { + it('should dedupe the python dupes and create a colored plain text report with three score blocks', () => { + const { missing, rows } = preProcess(pythonDupes.data, []) + const txt = generateTextReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + " + Shallow Package Score + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + Package: pkg:pypi/charset-normalizer@3.4.0 + + - Supply Chain Risk:  99 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (0/2/1): [middle] hasNativeCode, [middle] usesEval, [low] filesystemAccess + " + `) + + expect(txt.split('Supply Chain Risk:').length).toBe(2) // Should find it once so when you split that you get 2 parts + }) + + it('should dedupe the python dupes and create a markdown report with three score blocks', () => { + const { missing, rows } = preProcess(pythonDupes.data, []) + const txt = generateMarkdownReport(rows, missing) + expect(txt).toMatchInlineSnapshot(` + "# Shallow Package Report + + This report contains the response for requesting data on some package url(s). + + Please note: The listed scores are ONLY for the package itself. It does NOT + reflect the scores of any dependencies, transitive or otherwise. + + + + ## Package: pkg:pypi/charset-normalizer@3.4.0 + + - Supply Chain Risk: 99 + - Maintenance: 100 + - Quality: 100 + - Vulnerabilities: 100 + - License: 100 + - Alerts (0/2/1): [middle] hasNativeCode, [middle] usesEval, [low] filesystemAccess" + `) + + expect(txt.split('Supply Chain Risk:').length).toBe(2) // Should find it once so when you split that you get 2 parts + expect(txt).toContain('pkg:pypi/charset-normalizer@3.4.0') + }) + }) + }) +}) diff --git a/packages/cli/src/commands/package/parse-package-specifiers.mts b/src/commands/package/parse-package-specifiers.mts similarity index 84% rename from packages/cli/src/commands/package/parse-package-specifiers.mts rename to src/commands/package/parse-package-specifiers.mts index a0d50999c..337a4db03 100644 --- a/packages/cli/src/commands/package/parse-package-specifiers.mts +++ b/src/commands/package/parse-package-specifiers.mts @@ -16,32 +16,30 @@ export function parsePackageSpecifiers( if (!pkg) { valid = false break - } - if (pkg.startsWith('pkg:')) { + } else if (pkg.startsWith('pkg:')) { // keep purls.push(pkg) } else { - purls.push(`pkg:${ecosystem}/${pkg}`) + purls.push('pkg:' + ecosystem + '/' + pkg) } } if (!purls.length) { valid = false } } else { - // Assume ecosystem is a purl, too. + // Assume ecosystem is a purl, too pkgs.unshift(ecosystem) for (let i = 0; i < pkgs.length; ++i) { const pkg = pkgs[i] ?? '' if (!/^(?:pkg:)?[a-zA-Z]+\/./.test(pkg)) { - // At least one purl did not start with `pkg:eco/x` or `eco/x`. + // At least one purl did not start with `pkg:eco/x` or `eco/x` valid = false break - } - if (pkg.startsWith('pkg:')) { + } else if (pkg.startsWith('pkg:')) { purls.push(pkg) } else { - purls.push(`pkg:${pkg}`) + purls.push('pkg:' + pkg) } } diff --git a/src/commands/package/parse-package-specifiers.test.mts b/src/commands/package/parse-package-specifiers.test.mts new file mode 100644 index 000000000..59e8ea816 --- /dev/null +++ b/src/commands/package/parse-package-specifiers.test.mts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' + +import { parsePackageSpecifiers } from './parse-package-specifiers.mts' + +describe('parse-package-specifiers', async () => { + it('should parse a simple `npm babel`', () => { + const { purls, valid } = parsePackageSpecifiers('npm', ['babel']) + expect(valid).toBe(true) + expect(purls).toStrictEqual(['pkg:npm/babel']) + }) + + it('should parse a simple purl with prefix', () => { + expect(parsePackageSpecifiers('pkg:npm/babel', [])).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + ], + "valid": true, + } + `) + }) + + it('should support npm scoped packages', () => { + expect( + parsePackageSpecifiers('npm', ['@babel/core']), + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/@babel/core", + ], + "valid": true, + } + `) + }) + + it('should parse a simple purl without prefix', () => { + expect(parsePackageSpecifiers('npm/babel', [])).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + ], + "valid": true, + } + `) + }) + + it('should parse a multiple purls', () => { + expect( + parsePackageSpecifiers('npm/babel', ['golang/foo']), + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/babel", + "pkg:golang/foo", + ], + "valid": true, + } + `) + }) + + it('should parse a mixed names and purls', () => { + expect( + parsePackageSpecifiers('npm', ['golang/foo', 'babel', 'pkg:npm/tenko']), + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:npm/golang/foo", + "pkg:npm/babel", + "pkg:npm/tenko", + ], + "valid": true, + } + `) + }) + + it('should complain when seeing an unscoped package without namespace', () => { + expect( + parsePackageSpecifiers('golang/foo', ['babel', 'pkg:npm/tenko']), + ).toMatchInlineSnapshot(` + { + "purls": [ + "pkg:golang/foo", + ], + "valid": false, + } + `) + }) + + it('should complain when only getting a namespace', () => { + expect(parsePackageSpecifiers('npm', [])).toMatchInlineSnapshot(` + { + "purls": [], + "valid": false, + } + `) + }) + + it('should complain when getting an empty namespace', () => { + expect(parsePackageSpecifiers('', [])).toMatchInlineSnapshot(` + { + "purls": [], + "valid": false, + } + `) + }) +}) diff --git a/src/commands/raw-npm/cmd-raw-npm.mts b/src/commands/raw-npm/cmd-raw-npm.mts new file mode 100644 index 000000000..39a45d1af --- /dev/null +++ b/src/commands/raw-npm/cmd-raw-npm.mts @@ -0,0 +1,57 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { runRawNpm } from './run-raw-npm.mts' +import constants from '../../constants.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW, NPM } = constants + +const config: CliCommandConfig = { + commandName: 'raw-npm', + description: `Temporarily disable the Socket ${NPM} wrapper`, + hidden: false, + flags: {}, + help: command => ` + Usage + $ ${command} ... + + This does the opposite of \`socket npm\`: it will execute the real \`npm\` + command without Socket. This can be useful when you have the wrapper on + and want to install a certain package anyways. Use at your own risk. + + Note: Everything after "raw-npm" is sent straight to the npm command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Examples + $ ${command} install -g socket + `, +} + +export const cmdRawNpm = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + allowUnknownFlags: true, + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await runRawNpm(argv) +} diff --git a/src/commands/raw-npm/cmd-raw-npm.test.mts b/src/commands/raw-npm/cmd-raw-npm.test.mts new file mode 100644 index 000000000..ee44b4119 --- /dev/null +++ b/src/commands/raw-npm/cmd-raw-npm.test.mts @@ -0,0 +1,65 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket raw-npm', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['raw-npm', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Temporarily disable the Socket npm wrapper + + Usage + $ socket raw-npm ... + + This does the opposite of \`socket npm\`: it will execute the real \`npm\` + command without Socket. This can be useful when you have the wrapper on + and want to install a certain package anyways. Use at your own risk. + + Note: Everything after "raw-npm" is sent straight to the npm command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Examples + $ socket raw-npm install -g socket" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npm\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket raw-npm`', + ) + }, + ) + + cmdit( + ['raw-npm', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npm\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/raw-npm/run-raw-npm.mts b/src/commands/raw-npm/run-raw-npm.mts new file mode 100644 index 000000000..cfc6e23ad --- /dev/null +++ b/src/commands/raw-npm/run-raw-npm.mts @@ -0,0 +1,24 @@ +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { getNpmBinPath } from '../../utils/npm-paths.mts' + +export async function runRawNpm( + argv: string[] | readonly string[], +): Promise<void> { + const spawnPromise = spawn(getNpmBinPath(), argv as string[], { + // Lazily access constants.WIN32. + shell: constants.WIN32, + stdio: 'inherit', + }) + // See https://nodejs.org/api/child_process.html#event-exit. + spawnPromise.process.on('exit', (code, signalName) => { + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + // eslint-disable-next-line n/no-process-exit + process.exit(code) + } + }) + await spawnPromise +} diff --git a/src/commands/raw-npx/cmd-raw-npx.mts b/src/commands/raw-npx/cmd-raw-npx.mts new file mode 100644 index 000000000..0e8d7a1d0 --- /dev/null +++ b/src/commands/raw-npx/cmd-raw-npx.mts @@ -0,0 +1,57 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { runRawNpx } from './run-raw-npx.mts' +import constants from '../../constants.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW, NPX } = constants + +const config: CliCommandConfig = { + commandName: 'raw-npx', + description: `Temporarily disable the Socket ${NPX} wrapper`, + hidden: false, + flags: {}, + help: command => ` + Usage + $ ${command} ... + + This does the opposite of \`socket npx\`: it will execute the real \`npx\` + command without Socket. This can be useful when you have the wrapper on + and want to run a certain package anyways. Use at your own risk. + + Note: Everything after "raw-npx" is sent straight to the npx command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Examples + $ ${command} prettier + `, +} + +export const cmdRawNpx = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + allowUnknownFlags: true, + argv, + config, + importMeta, + parentName, + }) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await runRawNpx(argv) +} diff --git a/src/commands/raw-npx/cmd-raw-npx.test.mts b/src/commands/raw-npx/cmd-raw-npx.test.mts new file mode 100644 index 000000000..bfd7ef2c9 --- /dev/null +++ b/src/commands/raw-npx/cmd-raw-npx.test.mts @@ -0,0 +1,65 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket raw-npx', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['raw-npx', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Temporarily disable the Socket npx wrapper + + Usage + $ socket raw-npx ... + + This does the opposite of \`socket npx\`: it will execute the real \`npx\` + command without Socket. This can be useful when you have the wrapper on + and want to run a certain package anyways. Use at your own risk. + + Note: Everything after "raw-npx" is sent straight to the npx command. + Only the \`--dryRun\` and \`--help\` flags are caught here. + + Examples + $ socket raw-npx prettier" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npx\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket raw-npx`', + ) + }, + ) + + cmdit( + ['raw-npx', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket raw-npx\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/raw-npx/run-raw-npx.mts b/src/commands/raw-npx/run-raw-npx.mts new file mode 100644 index 000000000..6a868cf9a --- /dev/null +++ b/src/commands/raw-npx/run-raw-npx.mts @@ -0,0 +1,24 @@ +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { getNpxBinPath } from '../../utils/npm-paths.mts' + +export async function runRawNpx( + argv: string[] | readonly string[], +): Promise<void> { + const spawnPromise = spawn(getNpxBinPath(), argv as string[], { + // Lazily access constants.WIN32. + shell: constants.WIN32, + stdio: 'inherit', + }) + // See https://nodejs.org/api/child_process.html#event-exit. + spawnPromise.process.on('exit', (code, signalName) => { + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + // eslint-disable-next-line n/no-process-exit + process.exit(code) + } + }) + await spawnPromise +} diff --git a/src/commands/repository/cmd-repository-create.mts b/src/commands/repository/cmd-repository-create.mts new file mode 100644 index 000000000..b46b8fc1b --- /dev/null +++ b/src/commands/repository/cmd-repository-create.mts @@ -0,0 +1,158 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleCreateRepo } from './handle-create-repo.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'create', + description: 'Create a repository in an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + defaultBranch: { + type: 'string', + default: 'main', + description: 'Repository default branch. Defaults to "main"', + }, + homepage: { + type: 'string', + default: '', + description: 'Repository url', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + repoDescription: { + type: 'string', + default: '', + description: 'Repository description', + }, + visibility: { + type: 'string', + default: 'private', + description: 'Repository visibility (Default Private)', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:create + + The REPO name should be a "slug". Follows the same naming convention as GitHub. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} test-repo + $ ${command} our-repo --homepage=socket.dev --default-branch=trunk + `, +} + +export const cmdRepositoryCreate = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [repoName = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['repoName'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + test: !!repoName, + message: 'Repository name as first argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleCreateRepo( + { + orgSlug, + repoName: String(repoName), + description: String(cli.flags['repoDescription'] || ''), + homepage: String(cli.flags['homepage'] || ''), + default_branch: String(cli.flags['defaultBranch'] || ''), + visibility: String(cli.flags['visibility'] || 'private'), + }, + outputKind, + ) +} diff --git a/src/commands/repository/cmd-repository-create.test.mts b/src/commands/repository/cmd-repository-create.test.mts new file mode 100644 index 000000000..68a80d337 --- /dev/null +++ b/src/commands/repository/cmd-repository-create.test.mts @@ -0,0 +1,237 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository create', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', 'create', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Create a repository in an organization + + Usage + $ socket repository create [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:create + + The REPO name should be a "slug". Follows the same naming convention as GitHub. + + Options + --defaultBranch Repository default branch. Defaults to "main" + --homepage Repository url + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --repoDescription Repository description + --visibility Repository visibility (Default Private) + + Examples + $ socket repository create test-repo + $ socket repository create our-repo --homepage=socket.dev --default-branch=trunk" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository create`', + ) + }, + ) + + cmdit( + ['repository', 'create', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'create', + 'a', + 'b', + '--org', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'repository', + 'create', + 'reponame', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'create', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should only report missing repo name with default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'create', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should only report missing repo name with --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'create', + 'fakerepo', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should run to dryrun', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository create\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/cmd-repository-del.mts b/src/commands/repository/cmd-repository-del.mts new file mode 100644 index 000000000..353aeea5b --- /dev/null +++ b/src/commands/repository/cmd-repository-del.mts @@ -0,0 +1,125 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleDeleteRepo } from './handle-delete-repo.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'del', + description: 'Delete a repository in an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:delete + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} test-repo + `, +} + +export const cmdRepositoryDel = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [repoName = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['repoName'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + test: !!repoName, + message: 'Repository name as first argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleDeleteRepo(orgSlug, repoName, outputKind) +} diff --git a/src/commands/repository/cmd-repository-del.test.mts b/src/commands/repository/cmd-repository-del.test.mts new file mode 100644 index 000000000..8bb959335 --- /dev/null +++ b/src/commands/repository/cmd-repository-del.test.mts @@ -0,0 +1,230 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository del', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', 'del', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Delete a repository in an organization + + Usage + $ socket repository del [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:delete + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Examples + $ socket repository del test-repo" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository del`', + ) + }, + ) + + cmdit( + ['repository', 'del', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'del', + 'a', + 'b', + '--org', + 'xyz', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: xyz + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'repository', + 'del', + 'reponame', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'del', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should only report missing repo name with default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'del', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should only report missing repo name with --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'del', + 'fakerepo', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should run to dryrun', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository del\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/cmd-repository-list.mts b/src/commands/repository/cmd-repository-list.mts new file mode 100644 index 000000000..c1ef97c4b --- /dev/null +++ b/src/commands/repository/cmd-repository-list.mts @@ -0,0 +1,170 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleListRepos } from './handle-list-repos.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'list', + description: 'List repositories in an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + all: { + type: 'boolean', + default: false, + description: + 'By default view shows the last n repos. This flag allows you to fetch the entire list. Will ignore --page and --perPage.', + }, + direction: { + type: 'string', + default: 'desc', + description: 'Direction option', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + perPage: { + type: 'number', + shortFlag: 'pp', + default: 30, + description: 'Number of results per page', + }, + page: { + type: 'number', + shortFlag: 'p', + default: 1, + description: 'Page number', + }, + sort: { + type: 'string', + shortFlag: 's', + default: 'created_at', + description: 'Sorting option', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:list + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} --json + `, +} + +export const cmdRepositoryList = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + all, + direction = 'desc', + dryRun, + interactive, + json, + markdown, + org: orgFlag, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + { + nook: true, + test: direction === 'asc' || direction === 'desc', + message: 'The --direction value must be "asc" or "desc"', + pass: 'ok', + fail: 'unexpected value', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleListRepos({ + all: Boolean(all), + direction: direction === 'asc' ? 'asc' : 'desc', + orgSlug, + outputKind, + page: Number(cli.flags['page']) || 1, + per_page: Number(cli.flags['perPage']) || 30, + sort: String(cli.flags['sort'] || 'created_at'), + }) +} diff --git a/src/commands/repository/cmd-repository-list.test.mts b/src/commands/repository/cmd-repository-list.test.mts new file mode 100644 index 000000000..5249d8acb --- /dev/null +++ b/src/commands/repository/cmd-repository-list.test.mts @@ -0,0 +1,191 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository list', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', 'list', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List repositories in an organization + + Usage + $ socket repository list [options] + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:list + + Options + --all By default view shows the last n repos. This flag allows you to fetch the entire list. Will ignore --page and --perPage. + --direction Direction option + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --page Page number + --perPage Number of results per page + --sort Sorting option + + Examples + $ socket repository list + $ socket repository list --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository list`', + ) + }, + ) + + cmdit( + ['repository', 'list', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'list', + 'a', + '--org', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['repository', 'list', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'list', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should accept default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'repository', + 'list', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/cmd-repository-update.mts b/src/commands/repository/cmd-repository-update.mts new file mode 100644 index 000000000..63c030cc4 --- /dev/null +++ b/src/commands/repository/cmd-repository-update.mts @@ -0,0 +1,160 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleUpdateRepo } from './handle-update-repo.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'update', + description: 'Update a repository in an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + defaultBranch: { + type: 'string', + shortFlag: 'b', + default: 'main', + description: 'Repository default branch', + }, + homepage: { + type: 'string', + shortFlag: 'h', + default: '', + description: 'Repository url', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + repoDescription: { + type: 'string', + shortFlag: 'd', + default: '', + description: 'Repository description', + }, + visibility: { + type: 'string', + shortFlag: 'v', + default: 'private', + description: 'Repository visibility (Default Private)', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:update + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} test-repo + $ ${command} test-repo --homepage https://example.com + `, +} + +export const cmdRepositoryUpdate = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [repoName = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['repoName'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + test: !!repoName, + message: 'Repository name as first argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleUpdateRepo( + { + orgSlug, + repoName: String(repoName), + description: String(cli.flags['repoDescription'] || ''), + homepage: String(cli.flags['homepage'] || ''), + default_branch: String(cli.flags['defaultBranch'] || ''), + visibility: String(cli.flags['visibility'] || 'private'), + }, + outputKind, + ) +} diff --git a/src/commands/repository/cmd-repository-update.test.mts b/src/commands/repository/cmd-repository-update.test.mts new file mode 100644 index 000000000..5076fd523 --- /dev/null +++ b/src/commands/repository/cmd-repository-update.test.mts @@ -0,0 +1,207 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository update', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', 'update', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Update a repository in an organization + + Usage + $ socket repository update [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:update + + Options + --defaultBranch Repository default branch + --homepage Repository url + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --repoDescription Repository description + --visibility Repository visibility (Default Private) + + Examples + $ socket repository update test-repo + $ socket repository update test-repo --homepage https://example.com" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository update`', + ) + }, + ) + + cmdit( + ['repository', 'update', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'update', + 'reponame', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'update', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should only report missing repo name with default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'update', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should only report missing repo name with --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'update', + 'fakerepo', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should run to dryrun', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository update\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/cmd-repository-view.mts b/src/commands/repository/cmd-repository-view.mts new file mode 100644 index 000000000..d3711adbf --- /dev/null +++ b/src/commands/repository/cmd-repository-view.mts @@ -0,0 +1,134 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleViewRepo } from './handle-view-repo.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'view', + description: 'View repositories in an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:list + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} test-repo + $ ${command} test-repo --json + `, +} + +export const cmdRepositoryView = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [repoName = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['repoName'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + test: !!repoName, + message: 'Repository name as first argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleViewRepo(orgSlug, String(repoName), outputKind) +} diff --git a/src/commands/repository/cmd-repository-view.test.mts b/src/commands/repository/cmd-repository-view.test.mts new file mode 100644 index 000000000..322c1e0bb --- /dev/null +++ b/src/commands/repository/cmd-repository-view.test.mts @@ -0,0 +1,231 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository view', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', 'view', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "View repositories in an organization + + Usage + $ socket repository view [options] <REPO> + + API Token Requirements + - Quota: 1 unit + - Permissions: repo:list + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Examples + $ socket repository view test-repo + $ socket repository view test-repo --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository view`', + ) + }, + ) + + cmdit( + ['repository', 'view', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'view', + 'a', + 'b', + '--org', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'repository', + 'view', + 'reponame', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Repository name as first argument (\\x1b[32mok\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'view', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should only report missing repo name with default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'view', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should only report missing repo name with --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Repository name as first argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'repository', + 'view', + 'fakerepo', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should run to dryrun', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository view\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/cmd-repository.mts b/src/commands/repository/cmd-repository.mts new file mode 100644 index 000000000..510e13dd4 --- /dev/null +++ b/src/commands/repository/cmd-repository.mts @@ -0,0 +1,31 @@ +import { cmdRepositoryCreate } from './cmd-repository-create.mts' +import { cmdRepositoryDel } from './cmd-repository-del.mts' +import { cmdRepositoryList } from './cmd-repository-list.mts' +import { cmdRepositoryUpdate } from './cmd-repository-update.mts' +import { cmdRepositoryView } from './cmd-repository-view.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Repository related commands' + +export const cmdRepository: CliSubcommand = { + description, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + create: cmdRepositoryCreate, + view: cmdRepositoryView, + list: cmdRepositoryList, + del: cmdRepositoryDel, + update: cmdRepositoryUpdate, + }, + { + argv, + description, + importMeta, + name: `${parentName} repository`, + }, + ) + }, +} diff --git a/src/commands/repository/cmd-repository.test.mts b/src/commands/repository/cmd-repository.test.mts new file mode 100644 index 000000000..9fd9ae810 --- /dev/null +++ b/src/commands/repository/cmd-repository.test.mts @@ -0,0 +1,70 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket repository', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['repository', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Repository related commands + + Usage + $ socket repository <command> + + Commands + create Create a repository in an organization + del Delete a repository in an organization + list List repositories in an organization + update Update a repository in an organization + view View repositories in an organization + + Options + (none) + + Examples + $ socket repository --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket repository`', + ) + }, + ) + + cmdit( + ['repository', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket repository\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/repository/fetch-create-repo.mts b/src/commands/repository/fetch-create-repo.mts new file mode 100644 index 000000000..55b0f6108 --- /dev/null +++ b/src/commands/repository/fetch-create-repo.mts @@ -0,0 +1,38 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchCreateRepo({ + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, +}: { + orgSlug: string + repoName: string + description: string + homepage: string + default_branch: string + visibility: string +}): Promise<CResult<SocketSdkReturnType<'createOrgRepo'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.createOrgRepo(orgSlug, { + name: repoName, + description, + homepage, + default_branch, + visibility, + }), + 'to create a repository', + ) +} diff --git a/src/commands/repository/fetch-delete-repo.mts b/src/commands/repository/fetch-delete-repo.mts new file mode 100644 index 000000000..eabffa527 --- /dev/null +++ b/src/commands/repository/fetch-delete-repo.mts @@ -0,0 +1,21 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchDeleteRepo( + orgSlug: string, + repoName: string, +): Promise<CResult<SocketSdkReturnType<'deleteOrgRepo'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.deleteOrgRepo(orgSlug, repoName), + 'to delete a repository', + ) +} diff --git a/src/commands/repository/fetch-list-all-repos.mts b/src/commands/repository/fetch-list-all-repos.mts new file mode 100644 index 000000000..f04fb44f4 --- /dev/null +++ b/src/commands/repository/fetch-list-all-repos.mts @@ -0,0 +1,61 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchListAllRepos({ + direction, + orgSlug, + sort, +}: { + direction: string + orgSlug: string + sort: string +}): Promise<CResult<SocketSdkReturnType<'getOrgRepoList'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + const rows: SocketSdkReturnType<'getOrgRepoList'>['data']['results'] = [] + let protection = 0 + let nextPage = 0 + while (nextPage >= 0) { + if (++protection > 100) { + return { + ok: false, + message: 'Infinite loop detected', + cause: `Either there are over 100 pages of results or the fetch has run into an infinite loop. Breaking it off now. nextPage=${nextPage}`, + } + } + // eslint-disable-next-line no-await-in-loop + const result = await handleApiCall( + sockSdk.getOrgRepoList(orgSlug, { + sort, + direction, + per_page: String(100), // max + page: String(nextPage), + }), + 'list of repositories', + ) + if (!result.ok) { + debugFn('fail: fetch repo\n', result) + return result + } + + result.data.results.forEach(row => rows.push(row)) + nextPage = result.data.nextPage ?? -1 + } + + return { + ok: true, + data: { + results: rows, + nextPage: null, + }, + } +} diff --git a/src/commands/repository/fetch-list-repos.mts b/src/commands/repository/fetch-list-repos.mts new file mode 100644 index 000000000..a306b6be8 --- /dev/null +++ b/src/commands/repository/fetch-list-repos.mts @@ -0,0 +1,35 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchListRepos({ + direction, + orgSlug, + page, + per_page, + sort, +}: { + direction: string + orgSlug: string + page: number + per_page: number + sort: string +}): Promise<CResult<SocketSdkReturnType<'getOrgRepoList'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgRepoList(orgSlug, { + sort, + direction, + per_page: String(per_page), + page: String(page), + }), + 'list of repositories', + ) +} diff --git a/src/commands/repository/fetch-update-repo.mts b/src/commands/repository/fetch-update-repo.mts new file mode 100644 index 000000000..6687526b4 --- /dev/null +++ b/src/commands/repository/fetch-update-repo.mts @@ -0,0 +1,39 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchUpdateRepo({ + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, +}: { + orgSlug: string + repoName: string + description: string + homepage: string + default_branch: string + visibility: string +}): Promise<CResult<SocketSdkReturnType<'updateOrgRepo'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.updateOrgRepo(orgSlug, repoName, { + orgSlug, + name: repoName, + description, + homepage, + default_branch, + visibility, + }), + 'to update a repository', + ) +} diff --git a/src/commands/repository/fetch-view-repo.mts b/src/commands/repository/fetch-view-repo.mts new file mode 100644 index 000000000..e4d67fd11 --- /dev/null +++ b/src/commands/repository/fetch-view-repo.mts @@ -0,0 +1,21 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchViewRepo( + orgSlug: string, + repoName: string, +): Promise<CResult<SocketSdkReturnType<'getOrgRepo'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgRepo(orgSlug, repoName), + 'repository data', + ) +} diff --git a/src/commands/repository/handle-create-repo.mts b/src/commands/repository/handle-create-repo.mts new file mode 100644 index 000000000..e9c9f35b4 --- /dev/null +++ b/src/commands/repository/handle-create-repo.mts @@ -0,0 +1,33 @@ +import { fetchCreateRepo } from './fetch-create-repo.mts' +import { outputCreateRepo } from './output-create-repo.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleCreateRepo( + { + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, + }: { + orgSlug: string + repoName: string + description: string + homepage: string + default_branch: string + visibility: string + }, + outputKind: OutputKind, +): Promise<void> { + const data = await fetchCreateRepo({ + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, + }) + outputCreateRepo(data, repoName, outputKind) +} diff --git a/packages/cli/src/commands/repository/handle-delete-repo.mts b/src/commands/repository/handle-delete-repo.mts similarity index 76% rename from packages/cli/src/commands/repository/handle-delete-repo.mts rename to src/commands/repository/handle-delete-repo.mts index 31f5ea692..384cc2bb2 100644 --- a/packages/cli/src/commands/repository/handle-delete-repo.mts +++ b/src/commands/repository/handle-delete-repo.mts @@ -8,9 +8,7 @@ export async function handleDeleteRepo( repoName: string, outputKind: OutputKind, ) { - const data = await fetchDeleteRepo(orgSlug, repoName, { - commandPath: 'socket repository del', - }) + const data = await fetchDeleteRepo(orgSlug, repoName) await outputDeleteRepo(data, repoName, outputKind) } diff --git a/src/commands/repository/handle-list-repos.mts b/src/commands/repository/handle-list-repos.mts new file mode 100644 index 000000000..20a337af6 --- /dev/null +++ b/src/commands/repository/handle-list-repos.mts @@ -0,0 +1,52 @@ +import { fetchListAllRepos } from './fetch-list-all-repos.mts' +import { fetchListRepos } from './fetch-list-repos.mts' +import { outputListRepos } from './output-list-repos.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleListRepos({ + all, + direction, + orgSlug, + outputKind, + page, + per_page, + sort, +}: { + all: boolean + direction: 'asc' | 'desc' + orgSlug: string + outputKind: OutputKind + page: number + per_page: number + sort: string +}): Promise<void> { + if (all) { + const data = await fetchListAllRepos({ direction, orgSlug, sort }) + + await outputListRepos(data, outputKind, 0, 0, sort, Infinity, direction) + } else { + const data = await fetchListRepos({ + direction, + orgSlug, + page, + per_page, + sort, + }) + + if (!data.ok) { + await outputListRepos(data, outputKind, 0, 0, '', 0, direction) + } else { + // Note: nextPage defaults to 0, is null when there's no next page + await outputListRepos( + data, + outputKind, + page, + data.data.nextPage, + sort, + per_page, + direction, + ) + } + } +} diff --git a/src/commands/repository/handle-update-repo.mts b/src/commands/repository/handle-update-repo.mts new file mode 100644 index 000000000..c34369418 --- /dev/null +++ b/src/commands/repository/handle-update-repo.mts @@ -0,0 +1,34 @@ +import { fetchUpdateRepo } from './fetch-update-repo.mts' +import { outputUpdateRepo } from './output-update-repo.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleUpdateRepo( + { + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, + }: { + orgSlug: string + repoName: string + description: string + homepage: string + default_branch: string + visibility: string + }, + outputKind: OutputKind, +): Promise<void> { + const data = await fetchUpdateRepo({ + default_branch, + description, + homepage, + orgSlug, + repoName, + visibility, + }) + + await outputUpdateRepo(data, repoName, outputKind) +} diff --git a/packages/cli/src/commands/repository/handle-view-repo.mts b/src/commands/repository/handle-view-repo.mts similarity index 75% rename from packages/cli/src/commands/repository/handle-view-repo.mts rename to src/commands/repository/handle-view-repo.mts index d92c5d263..459555649 100644 --- a/packages/cli/src/commands/repository/handle-view-repo.mts +++ b/src/commands/repository/handle-view-repo.mts @@ -8,9 +8,7 @@ export async function handleViewRepo( repoName: string, outputKind: OutputKind, ): Promise<void> { - const data = await fetchViewRepo(orgSlug, repoName, { - commandPath: 'socket repository view', - }) + const data = await fetchViewRepo(orgSlug, repoName) await outputViewRepo(data, outputKind) } diff --git a/src/commands/repository/output-create-repo.mts b/src/commands/repository/output-create-repo.mts new file mode 100644 index 000000000..b5494f1c7 --- /dev/null +++ b/src/commands/repository/output-create-repo.mts @@ -0,0 +1,29 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export function outputCreateRepo( + result: CResult<SocketSdkReturnType<'createOrgRepo'>['data']>, + requestedName: string, + outputKind: OutputKind, +): void { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + const { slug } = result.data + logger.success( + `OK. Repository created successfully, slug: \`${slug}\`${slug !== requestedName ? ' (Warning: slug is not the same as name that was requested!)' : ''}`, + ) +} diff --git a/src/commands/repository/output-delete-repo.mts b/src/commands/repository/output-delete-repo.mts new file mode 100644 index 000000000..d20d79ddb --- /dev/null +++ b/src/commands/repository/output-delete-repo.mts @@ -0,0 +1,28 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputDeleteRepo( + result: CResult<SocketSdkReturnType<'deleteOrgRepo'>['data']>, + repoName: string, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.success(`OK. Repository \`${repoName}\` deleted successfully`) +} diff --git a/src/commands/repository/output-list-repos.mts b/src/commands/repository/output-list-repos.mts new file mode 100644 index 000000000..0eb631746 --- /dev/null +++ b/src/commands/repository/output-list-repos.mts @@ -0,0 +1,80 @@ +// @ts-ignore +import chalkTable from 'chalk-table' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputListRepos( + result: CResult<SocketSdkReturnType<'getOrgRepoList'>['data']>, + outputKind: OutputKind, + page: number, + nextPage: number | null, + sort: string, + perPage: number, + direction: 'asc' | 'desc', +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + if (result.ok) { + logger.log( + serializeResultJson({ + ok: true, + data: { + data: result.data, + direction, + nextPage: nextPage ?? 0, + page, + perPage, + sort, + }, + }), + ) + } else { + logger.log(serializeResultJson(result)) + } + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log( + `Result page: ${page}, results per page: ${perPage === Infinity ? 'all' : perPage}, sorted by: ${sort}, direction: ${direction}`, + ) + + const options = { + columns: [ + { field: 'id', name: colors.magenta('ID') }, + { field: 'name', name: colors.magenta('Name') }, + { field: 'visibility', name: colors.magenta('Visibility') }, + { field: 'default_branch', name: colors.magenta('Default branch') }, + { field: 'archived', name: colors.magenta('Archived') }, + ], + } + + logger.log(chalkTable(options, result.data.results)) + if (nextPage) { + logger.info( + `This is page ${page}. Server indicated there are more results available on page ${nextPage}...`, + ) + logger.info( + `(Hint: you can use \`socket repository list --page ${nextPage}\`)`, + ) + } else if (perPage === Infinity) { + logger.info(`This should be the entire list available on the server.`) + } else { + logger.info( + `This is page ${page}. Server indicated this is the last page with results.`, + ) + } +} diff --git a/src/commands/repository/output-update-repo.mts b/src/commands/repository/output-update-repo.mts new file mode 100644 index 000000000..c47ca4383 --- /dev/null +++ b/src/commands/repository/output-update-repo.mts @@ -0,0 +1,28 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputUpdateRepo( + result: CResult<SocketSdkReturnType<'updateOrgRepo'>['data']>, + repoName: string, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.success(`Repository \`${repoName}\` updated successfully`) +} diff --git a/src/commands/repository/output-view-repo.mts b/src/commands/repository/output-view-repo.mts new file mode 100644 index 000000000..75e319498 --- /dev/null +++ b/src/commands/repository/output-view-repo.mts @@ -0,0 +1,43 @@ +// @ts-ignore +import chalkTable from 'chalk-table' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputViewRepo( + result: CResult<SocketSdkReturnType<'createOrgRepo'>['data']>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const options = { + columns: [ + { field: 'id', name: colors.magenta('ID') }, + { field: 'name', name: colors.magenta('Name') }, + { field: 'visibility', name: colors.magenta('Visibility') }, + { field: 'default_branch', name: colors.magenta('Default branch') }, + { field: 'homepage', name: colors.magenta('Homepage') }, + { field: 'archived', name: colors.magenta('Archived') }, + { field: 'created_at', name: colors.magenta('Created at') }, + ], + } + + logger.log(chalkTable(options, [result.data])) +} diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts new file mode 100644 index 000000000..783b0f737 --- /dev/null +++ b/src/commands/scan/cmd-scan-create.mts @@ -0,0 +1,418 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleCreateNewScan } from './handle-create-new-scan.mts' +import { outputCreateNewScan } from './output-create-new-scan.mts' +import { suggestOrgSlug } from './suggest-org-slug.mts' +import { suggestTarget } from './suggest_target.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' +import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'create', + description: 'Create a scan', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + autoManifest: { + type: 'boolean', + description: + 'Run `socket manifest auto` before collecting manifest files? This would be necessary for languages like Scala, Gradle, and Kotlin, See `socket manifest auto --help`.', + }, + branch: { + type: 'string', + shortFlag: 'b', + description: 'Branch name', + }, + commitMessage: { + type: 'string', + shortFlag: 'm', + default: '', + description: 'Commit message', + }, + commitHash: { + type: 'string', + shortFlag: 'ch', + default: '', + description: 'Commit hash', + }, + committers: { + type: 'string', + shortFlag: 'c', + default: '', + description: 'Committers', + }, + cwd: { + type: 'string', + description: 'working directory, defaults to process.cwd()', + }, + defaultBranch: { + type: 'boolean', + default: false, + description: + 'Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch.', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + pullRequest: { + type: 'number', + shortFlag: 'pr', + description: 'Commit hash', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + readOnly: { + type: 'boolean', + default: false, + description: + 'Similar to --dry-run except it can read from remote, stops before it would create an actual report', + }, + repo: { + type: 'string', + shortFlag: 'r', + description: 'Repository name', + }, + report: { + type: 'boolean', + description: + 'Wait for the scan creation to complete, then basically run `socket scan report` on it', + }, + setAsAlertsPage: { + type: 'boolean', + default: true, + aliases: ['pendingHead'], + description: + 'When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true.', + }, + tmp: { + type: 'boolean', + shortFlag: 't', + default: false, + description: + 'Set the visibility (true/false) of the scan in your dashboard.', + }, + }, + // TODO: your project's "socket.yml" file's "projectIgnorePaths" + help: (command, config) => ` + Usage + $ ${command} [options] [TARGET...] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:create + + Options + ${getFlagListOutput(config.flags, 6)} + + Uploads the specified dependency manifest files for Go, Gradle, JavaScript, + Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". + If any folder is specified, the ones found in there recursively are uploaded. + + Details on TARGET: + + - Defaults to the current dir (cwd) if none given + - Multiple targets can be specified + - If a target is a file, only that file is checked + - If it is a dir, the dir is scanned for any supported manifest files + - Dirs MUST be within the current dir (cwd), you can use --cwd to change it + - Supports globbing such as "**/package.json", "**/requirements.txt", etc. + - Ignores any file specified in your project's ".gitignore" + - Also a sensible set of default ignores from the "ignore-by-default" module + + The --repo and --branch flags tell Socket to associate this Scan with that + repo/branch. The names will show up on your dashboard on the Socket website. + + Note: for a first run you probably want to set --defaultBranch to indicate + the default branch name, like "main" or "master". + + The "alerts page" (https://socket.dev/dashboard/org/YOURORG/alerts) will show + the results from the last scan designated as the "pending head" on the branch + configured on Socket to be the "default branch". When creating a scan the + --setAsAlertsPage flag will default to true to update this. You can prevent + this by using --no-setAsAlertsPage. This flag is ignored for any branch that + is not designated as the "default branch". It is disabled when using --tmp. + + You can use \`socket scan setup\` to configure certain repo flag defaults. + + Examples + $ ${command} + $ ${command} ./proj --json + $ ${command} --repo=test-repo --branch=main ./package.json + `, +} + +export const cmdScanCreate = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + commitHash, + commitMessage, + committers, + cwd: cwdOverride, + defaultBranch, + dryRun = false, + interactive = true, + json, + markdown, + org: orgFlag, + pullRequest, + readOnly, + setAsAlertsPage: pendingHeadFlag, + tmp, + } = cli.flags as { + cwd: string + commitHash: string + commitMessage: string + committers: string + defaultBranch: boolean + dryRun: boolean + interactive: boolean + json: boolean + markdown: boolean + org: string + pullRequest: number + readOnly: boolean + setAsAlertsPage: boolean + tmp: boolean + } + let { + autoManifest, + branch: branchName, + repo: repoName, + report, + } = cli.flags as { + autoManifest?: boolean + branch: string + repo: string + report?: boolean + } + const outputKind = getOutputKind(json, markdown) + + const pendingHead = tmp ? false : pendingHeadFlag + + let [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + interactive, + dryRun, + ) + + // Accept zero or more paths. Default to cwd() if none given. + let targets = cli.input || [process.cwd()] + + const cwd = + cwdOverride && cwdOverride !== 'process.cwd()' + ? path.resolve(process.cwd(), String(cwdOverride)) + : process.cwd() + + const socketJson = await readOrDefaultSocketJson(cwd) + + // Note: This needs meow booleanDefault=undefined + if (typeof autoManifest !== 'boolean') { + if (socketJson.defaults?.scan?.create?.autoManifest !== undefined) { + autoManifest = socketJson.defaults.scan.create.autoManifest + logger.info( + 'Using default --autoManifest from socket.json:', + autoManifest, + ) + } else { + autoManifest = false + } + } + if (!branchName) { + if (socketJson.defaults?.scan?.create?.branch) { + branchName = socketJson.defaults.scan.create.branch + logger.info('Using default --branch from socket.json:', branchName) + } else { + branchName = 'socket-default-branch' + } + } + if (!repoName) { + if (socketJson.defaults?.scan?.create?.repo) { + repoName = socketJson.defaults.scan.create.repo + logger.info('Using default --repo from socket.json:', repoName) + } else { + repoName = 'socket-default-repository' + } + } + if (typeof report !== 'boolean') { + if (socketJson.defaults?.scan?.create?.report !== undefined) { + report = socketJson.defaults.scan.create.report + logger.info('Using default --report from socket.json:', report) + } else { + report = false + } + } + + // We're going to need an api token to suggest data because those suggestions + // must come from data we already know. Don't error on missing api token yet. + // If the api-token is not set, ignore it for the sake of suggestions. + const hasApiToken = hasDefaultToken() + + // If we updated any inputs then we should print the command line to repeat + // the command without requiring user input, as a suggestion. + let updatedInput = false + + if (!targets.length && !dryRun && interactive) { + const received = await suggestTarget() + targets = received ?? [] + updatedInput = true + } + + // If the current cwd is unknown and is used as a repo slug anyways, we will + // first need to register the slug before we can use it. + // Only do suggestions with an apiToken and when not in dryRun mode + if (hasApiToken && !dryRun && interactive) { + if (!orgSlug) { + const suggestion = await suggestOrgSlug() + if (suggestion === undefined) { + await outputCreateNewScan( + { + ok: false, + message: 'Canceled by user', + cause: 'Org selector was canceled by user', + }, + outputKind, + false, + ) + return + } + if (suggestion) { + orgSlug = suggestion + } + updatedInput = true + } + } + + const detected = await detectManifestActions(socketJson, cwd) + if (detected.count > 0 && !autoManifest) { + logger.info( + `Detected ${detected.count} manifest targets we could try to generate. Please set the --autoManifest flag if you want to include languages covered by \`socket manifest auto\` in the Scan.`, + ) + } + + if (updatedInput && orgSlug && targets?.length) { + logger.info( + 'Note: You can invoke this command next time to skip the interactive questions:', + ) + logger.info('```') + logger.info( + ` socket scan create [other flags...] ${orgSlug} ${targets.join(' ')}`, + ) + logger.info('```') + logger.error('') + logger.info( + 'You can also run `socket scan setup` to persist these flag defaults to a socket.json file.', + ) + logger.error('') + } + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + test: !!targets.length, + message: 'At least one TARGET (e.g. `.` or `./package.json`)', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: 'This command requires an API token for access', + pass: 'ok', + fail: 'missing (try `socket login`)', + }, + { + nook: true, + test: !pendingHead || !!branchName, + message: 'When --pendingHead is set, --branch is mandatory', + pass: 'ok', + fail: 'missing branch name', + }, + { + nook: true, + test: !defaultBranch || !!branchName, + message: 'When --defaultBranch is set, --branch is mandatory', + pass: 'ok', + fail: 'missing branch name', + }, + ) + if (!wasValidInput) { + return + } + + // Note exiting earlier to skirt a hidden auth requirement + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleCreateNewScan({ + autoManifest: Boolean(autoManifest), + branchName: branchName as string, + commitHash: (commitHash && String(commitHash)) || '', + commitMessage: (commitMessage && String(commitMessage)) || '', + committers: (committers && String(committers)) || '', + cwd, + defaultBranch: Boolean(defaultBranch), + interactive: Boolean(interactive), + orgSlug, + outputKind, + pendingHead: Boolean(pendingHead), + pullRequest: Number(pullRequest), + readOnly: Boolean(readOnly), + repoName: repoName, + report, + targets, + tmp: Boolean(tmp), + }) +} diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts new file mode 100644 index 000000000..efebf81a5 --- /dev/null +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -0,0 +1,124 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan create', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'create', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(` + "Create a scan + + Usage + $ socket scan create [options] [TARGET...] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:create + + Options + --autoManifest Run \`socket manifest auto\` before collecting manifest files? This would be necessary for languages like Scala, Gradle, and Kotlin, See \`socket manifest auto --help\`. + --branch Branch name + --commitHash Commit hash + --commitMessage Commit message + --committers Committers + --cwd working directory, defaults to process.cwd() + --defaultBranch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --pullRequest Commit hash + --readOnly Similar to --dry-run except it can read from remote, stops before it would create an actual report + --repo Repository name + --report Wait for the scan creation to complete, then basically run \`socket scan report\` on it + --setAsAlertsPage When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. + --tmp Set the visibility (true/false) of the scan in your dashboard. + + Uploads the specified dependency manifest files for Go, Gradle, JavaScript, + Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". + If any folder is specified, the ones found in there recursively are uploaded. + + Details on TARGET: + + - Defaults to the current dir (cwd) if none given + - Multiple targets can be specified + - If a target is a file, only that file is checked + - If it is a dir, the dir is scanned for any supported manifest files + - Dirs MUST be within the current dir (cwd), you can use --cwd to change it + - Supports globbing such as "**/package.json", "**/requirements.txt", etc. + - Ignores any file specified in your project's ".gitignore" + - Also a sensible set of default ignores from the "ignore-by-default" module + + The --repo and --branch flags tell Socket to associate this Scan with that + repo/branch. The names will show up on your dashboard on the Socket website. + + Note: for a first run you probably want to set --defaultBranch to indicate + the default branch name, like "main" or "master". + + The "alerts page" (https://socket.dev/dashboard/org/YOURORG/alerts) will show + the results from the last scan designated as the "pending head" on the branch + configured on Socket to be the "default branch". When creating a scan the + --setAsAlertsPage flag will default to true to update this. You can prevent + this by using --no-setAsAlertsPage. This flag is ignored for any branch that + is not designated as the "default branch". It is disabled when using --tmp. + + You can use \`socket scan setup\` to configure certain repo flag defaults. + + Examples + $ socket scan create + $ socket scan create ./proj --json + $ socket scan create --repo=test-repo --branch=main ./package.json" + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan create`', + ) + }, + ) + + cmdit( + [ + 'scan', + 'create', + '--org', + 'fakeorg', + 'target', + '--dry-run', + '--repo', + 'xyz', + '--branch', + 'abc', + '--config', + '{"apiToken": "abc"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan create\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-del.mts b/src/commands/scan/cmd-scan-del.mts new file mode 100644 index 000000000..94be9ff13 --- /dev/null +++ b/src/commands/scan/cmd-scan-del.mts @@ -0,0 +1,117 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleDeleteScan } from './handle-delete-scan.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'del', + description: 'Delete a scan', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <SCAN_ID> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:delete + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json + `, +} + +export const cmdScanDel = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [scanId = ''] = cli.input + + const [orgSlug, defaultOrgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: !!defaultOrgSlug, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + test: !!scanId, + message: 'Scan ID to delete', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleDeleteScan(orgSlug, scanId, outputKind) +} diff --git a/src/commands/scan/cmd-scan-del.test.mts b/src/commands/scan/cmd-scan-del.test.mts new file mode 100644 index 000000000..e66133004 --- /dev/null +++ b/src/commands/scan/cmd-scan-del.test.mts @@ -0,0 +1,109 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan del', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'del', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Delete a scan + + Usage + $ socket scan del [options] <SCAN_ID> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:delete + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Examples + $ socket scan del 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ socket scan del 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan del`', + ) + }, + ) + + cmdit( + ['scan', 'del', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Scan ID to delete (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'del', + '--org', + 'fakeorg', + 'scanidee', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan del\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-diff.mts b/src/commands/scan/cmd-scan-diff.mts new file mode 100644 index 000000000..adda1b85f --- /dev/null +++ b/src/commands/scan/cmd-scan-diff.mts @@ -0,0 +1,178 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleDiffScan } from './handle-diff-scan.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW, SOCKET_WEBSITE_URL } = constants + +const SOCKET_SBOM_URL_PREFIX = `${SOCKET_WEBSITE_URL}/dashboard/org/SocketDev/sbom/` + +const { length: SOCKET_SBOM_URL_PREFIX_LENGTH } = SOCKET_SBOM_URL_PREFIX + +const config: CliCommandConfig = { + commandName: 'diff', + description: 'See what changed between two Scans', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + depth: { + type: 'number', + default: 2, + description: + 'Max depth of JSON to display before truncating, use zero for no limit (without --json/--file)', + }, + file: { + type: 'string', + shortFlag: 'f', + default: '', + description: + 'Path to a local file where the output should be saved. Use `-` to force stdout.', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <SCAN_ID1> <SCAN_ID2> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + This command displays the package changes between two scans. The full output + can be pretty large depending on the size of your repo and time range. It is + best stored to disk (with --json) to be further analyzed by other tools. + + Note: While it will work in any order, the first Scan ID is assumed to be the + older ID, even if it is a newer Scan. This is only relevant for the + added/removed list (similar to diffing two files with git). + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 + $ ${command} aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 --json + `, +} + +export const cmdScanDiff = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + depth, + dryRun, + file, + interactive, + json, + markdown, + org: orgFlag, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + let [id1 = '', id2 = ''] = cli.input + // Support dropping in full socket urls to an sbom + if (id1.startsWith(SOCKET_SBOM_URL_PREFIX)) { + id1 = id1.slice(SOCKET_SBOM_URL_PREFIX_LENGTH) + } + if (id2.startsWith(SOCKET_SBOM_URL_PREFIX)) { + id2 = id2.slice(SOCKET_SBOM_URL_PREFIX_LENGTH) + } + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + test: !!(id1 && id2), + message: + 'Specify two Scan IDs.\nA Scan ID looks like `aaa0aa0a-aaaa-0000-0a0a-0000000a00a0`.', + pass: 'ok', + fail: + !id1 && !id2 + ? 'missing both Scan IDs' + : !id2 + ? 'missing second Scan ID' + : 'missing first Scan ID', // Not sure how this can happen but ok. + }, + { + test: !!orgSlug, + nook: true, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleDiffScan({ + id1: String(id1 || ''), + id2: String(id2 || ''), + depth: Number(depth), + orgSlug, + outputKind, + file: String(file || ''), + }) +} diff --git a/src/commands/scan/cmd-scan-diff.test.mts b/src/commands/scan/cmd-scan-diff.test.mts new file mode 100644 index 000000000..2fa2a901c --- /dev/null +++ b/src/commands/scan/cmd-scan-diff.test.mts @@ -0,0 +1,121 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan diff', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'diff', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "See what changed between two Scans + + Usage + $ socket scan diff [options] <SCAN_ID1> <SCAN_ID2> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + This command displays the package changes between two scans. The full output + can be pretty large depending on the size of your repo and time range. It is + best stored to disk (with --json) to be further analyzed by other tools. + + Note: While it will work in any order, the first Scan ID is assumed to be the + older ID, even if it is a newer Scan. This is only relevant for the + added/removed list (similar to diffing two files with git). + + Options + --depth Max depth of JSON to display before truncating, use zero for no limit (without --json/--file) + --file Path to a local file where the output should be saved. Use \`-\` to force stdout. + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Examples + $ socket scan diff aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 + $ socket scan diff aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan diff`', + ) + }, + ) + + cmdit( + ['scan', 'diff', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Specify two Scan IDs. (\\x1b[31mmissing both Scan IDs\\x1b[39m) + A Scan ID looks like \`aaa0aa0a-aaaa-0000-0a0a-0000000a00a0\`. + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'diff', + '--org', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + 'x', + 'y', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan diff\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-github.mts b/src/commands/scan/cmd-scan-github.mts new file mode 100644 index 000000000..3b9e602c3 --- /dev/null +++ b/src/commands/scan/cmd-scan-github.mts @@ -0,0 +1,257 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleCreateGithubScan } from './handle-create-github-scan.mts' +import { outputScanGithub } from './output-scan-github.mts' +import { suggestOrgSlug } from './suggest-org-slug.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'github', + description: 'Create a scan for given GitHub repo', + hidden: true, // wip + flags: { + ...commonFlags, + ...outputFlags, + all: { + type: 'boolean', + description: + 'Apply for all known repos reported by the Socket API. Supersedes `repos`.', + }, + githubToken: { + type: 'string', + description: + '(required) GitHub token for authentication (or set GITHUB_TOKEN as an environment variable)', + }, + githubApiUrl: { + type: 'string', + description: + 'Base URL of the GitHub API (default: https://api.github.com)', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + orgGithub: { + type: 'string', + description: + 'Alternate GitHub Org if the name is different than the Socket Org', + }, + repos: { + type: 'string', + description: + 'List of repos to target in a comma-separated format (e.g., repo1,repo2). If not specified, the script will pull the list from Socket and ask you to pick one. Use --all to use them all.', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:create + + This is similar to the \`socket scan create\` command except it pulls the files + from GitHub. See the help for that command for more details. + + A GitHub Personal Access Token (PAT) will at least need read access to the repo + ("contents", read-only) for this command to work. + + Note: This command cannot run the \`socket manifest auto\` things because that + requires local access to the repo while this command runs entirely through the + GitHub for file access. + + You can use \`socket scan setup\` to configure certain repo flag defaults. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} ./proj + `, +} + +export const cmdScanGithub = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun = false, + // Lazily access constants.ENV.SOCKET_CLI_GITHUB_TOKEN. + githubToken = constants.ENV.SOCKET_CLI_GITHUB_TOKEN, + interactive = true, + json, + markdown, + org: orgFlag, + } = cli.flags as { + dryRun: boolean + githubToken: string + interactive: boolean + json: boolean + markdown: boolean + org: string + orgGithub: string + } + let { all, githubApiUrl, orgGithub, repos } = cli.flags as { + all: boolean + githubApiUrl: string + orgGithub: string + repos: string + } + const outputKind = getOutputKind(json, markdown) + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + let [orgSlug, defaultOrgSlug] = await determineOrgSlug( + String(orgFlag || ''), + interactive, + dryRun, + ) + if (!defaultOrgSlug) { + // Tmp. just for TS. will drop this later. + defaultOrgSlug = '' + } + + const socketJson = await readOrDefaultSocketJson(cwd) + + if (all === undefined) { + if (socketJson.defaults?.scan?.github?.all !== undefined) { + all = socketJson.defaults?.scan?.github?.all + } else { + all = false + } + } + if (!githubApiUrl) { + if (socketJson.defaults?.scan?.github?.githubApiUrl !== undefined) { + githubApiUrl = socketJson.defaults.scan.github.githubApiUrl + } else { + githubApiUrl = 'https://api.github.com' + } + } + if (!orgGithub) { + if (socketJson.defaults?.scan?.github?.orgGithub !== undefined) { + orgGithub = socketJson.defaults.scan.github.orgGithub + } else { + // Default to Socket org slug. Often that's fine. Vanity and all that. + orgGithub = orgSlug + } + } + if (!all && !repos) { + if (socketJson.defaults?.scan?.github?.repos !== undefined) { + repos = socketJson.defaults.scan.github.repos + } else { + repos = '' + } + } + + // We're going to need an api token to suggest data because those suggestions + // must come from data we already know. Don't error on missing api token yet. + // If the api-token is not set, ignore it for the sake of suggestions. + const hasSocketApiToken = hasDefaultToken() + // We will also be needing that GitHub token. + const hasGithubApiToken = !!githubToken + + // If the current cwd is unknown and is used as a repo slug anyways, we will + // first need to register the slug before we can use it. + // Only do suggestions with an apiToken and when not in dryRun mode + if (hasSocketApiToken && !dryRun && interactive) { + if (!orgSlug) { + const suggestion = await suggestOrgSlug() + if (suggestion === undefined) { + await outputScanGithub( + { + ok: false, + message: 'Canceled by user', + cause: 'Org selector was canceled by user', + }, + outputKind, + ) + return + } + if (suggestion) { + orgSlug = suggestion + } + } + } + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasSocketApiToken, + message: 'This command requires an API token for access', + pass: 'ok', + fail: 'missing (try `socket login`)', + }, + { + test: hasGithubApiToken, + message: 'This command requires a GitHub API token for access', + pass: 'ok', + fail: 'missing', + }, + ) + if (!wasValidInput) { + return + } + + // Note exiting earlier to skirt a hidden auth requirement + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleCreateGithubScan({ + all: Boolean(all), + githubApiUrl, + githubToken, + interactive: Boolean(interactive), + orgSlug, + orgGithub, + outputKind, + repos, + }) +} diff --git a/src/commands/scan/cmd-scan-github.test.mts b/src/commands/scan/cmd-scan-github.test.mts new file mode 100644 index 000000000..b43e0e656 --- /dev/null +++ b/src/commands/scan/cmd-scan-github.test.mts @@ -0,0 +1,131 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan github', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'github', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Create a scan for given GitHub repo + + Usage + $ socket scan github [options] [CWD=.] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:create + + This is similar to the \`socket scan create\` command except it pulls the files + from GitHub. See the help for that command for more details. + + A GitHub Personal Access Token (PAT) will at least need read access to the repo + ("contents", read-only) for this command to work. + + Note: This command cannot run the \`socket manifest auto\` things because that + requires local access to the repo while this command runs entirely through the + GitHub for file access. + + You can use \`socket scan setup\` to configure certain repo flag defaults. + + Options + --all Apply for all known repos reported by the Socket API. Supersedes \`repos\`. + --githubApiUrl Base URL of the GitHub API (default: https://api.github.com) + --githubToken (required) GitHub token for authentication (or set GITHUB_TOKEN as an environment variable) + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --orgGithub Alternate GitHub Org if the name is different than the Socket Org + --repos List of repos to target in a comma-separated format (e.g., repo1,repo2). If not specified, the script will pull the list from Socket and ask you to pick one. Use --all to use them all. + + Examples + $ socket scan github + $ socket scan github ./proj" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan github`', + ) + }, + ) + + cmdit( + ['scan', 'github', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - This command requires an API token for access (\\x1b[31mmissing (try \`socket login\`)\\x1b[39m) + + - This command requires a GitHub API token for access (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'github', + 'fakeorg', + '--dry-run', + '--github-token', + 'fake', + '--config', + '{"apiToken":"anything"}', + 'x', + 'y', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan github\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-list.mts b/src/commands/scan/cmd-scan-list.mts new file mode 100644 index 000000000..cd2ca19b6 --- /dev/null +++ b/src/commands/scan/cmd-scan-list.mts @@ -0,0 +1,201 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleListScans } from './handle-list-scans.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { + CliCommandConfig, + CliSubcommand, +} from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'list', + description: 'List the scans for an organization', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + branch: { + type: 'string', + description: 'Filter to show only scans with this branch name', + }, + direction: { + type: 'string', + shortFlag: 'd', + default: 'desc', + description: 'Direction option (`desc` or `asc`) - Default is `desc`', + }, + fromTime: { + type: 'string', + shortFlag: 'f', + default: '', + description: 'From time - as a unix timestamp', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + page: { + type: 'number', + shortFlag: 'p', + default: 1, + description: 'Page number - Default is 1', + }, + perPage: { + type: 'number', + shortFlag: 'pp', + default: 30, + description: 'Results per page - Default is 30', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + sort: { + type: 'string', + shortFlag: 's', + default: 'created_at', + description: + 'Sorting option (`name` or `created_at`) - default is `created_at`', + }, + untilTime: { + type: 'string', + shortFlag: 'u', + default: '', + description: 'Until time - as a unix timestamp', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [REPO [BRANCH]] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + Optionally filter by REPO. If you specify a repo, you can also specify a + branch to filter by. (Note: If you don't specify a repo then you must use + \`--branch\` to filter by branch across all repos). + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} webtools badbranch --markdown + `, +} + +export const cmdScanList: CliSubcommand = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +) { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + branch: branchFlag, + dryRun, + interactive, + json, + markdown, + org: orgFlag, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [repo = '', branchArg = ''] = cli.input + const branch = String(branchFlag || branchArg || '') + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const noLegacy = !cli.flags['repo'] + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: noLegacy, + message: 'Legacy flags are no longer supported. See v1 migration guide.', + pass: 'ok', + fail: `received legacy flags`, + }, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'dot is an invalid org, most likely you forgot the org name here?', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + { + nook: true, + test: !branchFlag || !branchArg, + message: + 'You should not set --branch and also give a second arg for branch name', + pass: 'ok', + fail: 'received flag and second arg', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleListScans({ + branch: branch ? String(branch) : '', + direction: String(cli.flags['direction'] || ''), + from_time: String(cli.flags['fromTime'] || ''), + orgSlug, + outputKind, + page: Number(cli.flags['page'] || 1), + per_page: Number(cli.flags['perPage'] || 30), + repo: repo ? String(repo) : '', + sort: String(cli.flags['sort'] || ''), + }) +} diff --git a/src/commands/scan/cmd-scan-list.test.mts b/src/commands/scan/cmd-scan-list.test.mts new file mode 100644 index 000000000..8b7fa16f2 --- /dev/null +++ b/src/commands/scan/cmd-scan-list.test.mts @@ -0,0 +1,117 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan list', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'list', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "List the scans for an organization + + Usage + $ socket scan list [options] [REPO [BRANCH]] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + Optionally filter by REPO. If you specify a repo, you can also specify a + branch to filter by. (Note: If you don't specify a repo then you must use + \`--branch\` to filter by branch across all repos). + + Options + --branch Filter to show only scans with this branch name + --direction Direction option (\`desc\` or \`asc\`) - Default is \`desc\` + --fromTime From time - as a unix timestamp + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --page Page number - Default is 1 + --perPage Results per page - Default is 30 + --sort Sorting option (\`name\` or \`created_at\`) - default is \`created_at\` + --untilTime Until time - as a unix timestamp + + Examples + $ socket scan list + $ socket scan list webtools badbranch --markdown" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan list`', + ) + }, + ) + + cmdit( + ['scan', 'list', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mdot is an invalid org, most likely you forgot the org name here?\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'list', + '--org', + 'fakeorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan list\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-metadata.mts b/src/commands/scan/cmd-scan-metadata.mts new file mode 100644 index 000000000..67a8518b0 --- /dev/null +++ b/src/commands/scan/cmd-scan-metadata.mts @@ -0,0 +1,130 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleOrgScanMetadata } from './handle-scan-metadata.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { + CliCommandConfig, + CliSubcommand, +} from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'metadata', + description: "Get a scan's metadata", + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <SCAN_ID> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json + `, +} + +export const cmdScanMetadata: CliSubcommand = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, interactive, json, markdown, org: orgFlag } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [scanId = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: + orgSlug === '.' + ? 'dot is an invalid org, most likely you forgot the org name here?' + : 'missing', + }, + { + test: !!scanId, + message: 'Scan ID to inspect as argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleOrgScanMetadata(orgSlug, scanId, outputKind) +} diff --git a/src/commands/scan/cmd-scan-metadata.test.mts b/src/commands/scan/cmd-scan-metadata.test.mts new file mode 100644 index 000000000..322f947cb --- /dev/null +++ b/src/commands/scan/cmd-scan-metadata.test.mts @@ -0,0 +1,109 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan metadata', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'metadata', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Get a scan's metadata + + Usage + $ socket scan metadata [options] <SCAN_ID> + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + + Examples + $ socket scan metadata 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ socket scan metadata 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan metadata`', + ) + }, + ) + + cmdit( + ['scan', 'metadata', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - Scan ID to inspect as argument (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'metadata', + '--org', + 'fakeorg', + 'scanidee', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan metadata\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-reach.mts b/src/commands/scan/cmd-scan-reach.mts new file mode 100644 index 000000000..ba6c52849 --- /dev/null +++ b/src/commands/scan/cmd-scan-reach.mts @@ -0,0 +1,74 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleScanReach } from './handle-reach-scan.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'reach', + description: 'Compute tier 1 reachability', + hidden: true, + flags: { + ...commonFlags, + ...outputFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} + $ ${command} ./proj + `, +} + +export const cmdScanReach = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { dryRun, json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const wasValidInput = checkCommandInput(outputKind) + if (!wasValidInput) { + return + } + + if (dryRun) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleScanReach(argv, cwd, outputKind) +} diff --git a/src/commands/scan/cmd-scan-reach.test.mts b/src/commands/scan/cmd-scan-reach.test.mts new file mode 100644 index 000000000..2c28a1c86 --- /dev/null +++ b/src/commands/scan/cmd-scan-reach.test.mts @@ -0,0 +1,63 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan reach', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'reach', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Compute tier 1 reachability + + Usage + $ socket scan reach [options] [CWD=.] + + Options + --json Output result as json + --markdown Output result as markdown + + Examples + $ socket scan reach + $ socket scan reach ./proj" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan reach`', + ) + }, + ) + + cmdit( + ['scan', 'reach', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan reach\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-report.mts b/src/commands/scan/cmd-scan-report.mts new file mode 100644 index 000000000..a06504e01 --- /dev/null +++ b/src/commands/scan/cmd-scan-report.mts @@ -0,0 +1,196 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleScanReport } from './handle-scan-report.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { + CliCommandConfig, + CliSubcommand, +} from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'report', + description: + 'Check whether a scan result passes the organizational policies (security, license)', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + fold: { + type: 'string', + default: 'none', + description: 'Fold reported alerts to some degree', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + reportLevel: { + type: 'string', + default: 'warn', + description: 'Which policy level alerts should be reported', + }, + short: { + type: 'boolean', + default: false, + description: 'Report only the healthy status', + }, + license: { + type: 'boolean', + default: false, + description: 'Also report the license policy status. Default: false', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <SCAN_ID> [OUTPUT_PATH] + + API Token Requirements + - Quota: 2 units + - Permissions: full-scans:list security-policy:read + + Options + ${getFlagListOutput(config.flags, 6)} + + When no output path is given the contents is sent to stdout. + + By default the result is a nested object that looks like this: + \`{ + [ecosystem]: { + [pkgName]: { + [version]: { + [file]: { + [line:col]: alert + }}}}\` + So one alert for each occurrence in every file, version, etc, a huge response. + + You can --fold these up to given level: 'pkg', 'version', 'file', and 'none'. + For example: \`socket scan report --fold=version\` will dedupe alerts to only + show one alert of a particular kind, no matter how often it was foud in a + file or in how many files it was found. At most one per version that has it. + + By default only the warn and error policy level alerts are reported. You can + override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') + + Short responses look like this: + --json: \`{healthy:bool}\` + --markdown: \`healthy = bool\` + neither: \`OK/ERR\` + + Examples + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 --license --markdown --short + `, +} + +export const cmdScanReport: CliSubcommand = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + fold = 'none', + interactive, + json, + license, + markdown, + org: orgFlag, + reportLevel = 'warn', + } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [scanId = '', file = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'dot is an invalid org, most likely you forgot the org name here?', + }, + { + test: !!scanId, + message: 'Scan ID to report on', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleScanReport({ + orgSlug, + scanId, + includeLicensePolicy: !!license, + outputKind, + filePath: file, + fold: fold as 'none' | 'file' | 'pkg' | 'version', + short: !!cli.flags['short'], + reportLevel: reportLevel as + | 'warn' + | 'error' + | 'defer' + | 'ignore' + | 'monitor', + }) +} diff --git a/src/commands/scan/cmd-scan-report.test.mts b/src/commands/scan/cmd-scan-report.test.mts new file mode 100644 index 000000000..d880a74fc --- /dev/null +++ b/src/commands/scan/cmd-scan-report.test.mts @@ -0,0 +1,139 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan report', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'report', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Check whether a scan result passes the organizational policies (security, license) + + Usage + $ socket scan report [options] <SCAN_ID> [OUTPUT_PATH] + + API Token Requirements + - Quota: 2 units + - Permissions: full-scans:list security-policy:read + + Options + --fold Fold reported alerts to some degree + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --license Also report the license policy status. Default: false + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --reportLevel Which policy level alerts should be reported + --short Report only the healthy status + + When no output path is given the contents is sent to stdout. + + By default the result is a nested object that looks like this: + \`{ + [ecosystem]: { + [pkgName]: { + [version]: { + [file]: { + [line:col]: alert + }}}}\` + So one alert for each occurrence in every file, version, etc, a huge response. + + You can --fold these up to given level: 'pkg', 'version', 'file', and 'none'. + For example: \`socket scan report --fold=version\` will dedupe alerts to only + show one alert of a particular kind, no matter how often it was foud in a + file or in how many files it was found. At most one per version that has it. + + By default only the warn and error policy level alerts are reported. You can + override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') + + Short responses look like this: + --json: \`{healthy:bool}\` + --markdown: \`healthy = bool\` + neither: \`OK/ERR\` + + Examples + $ socket scan report 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version + $ socket scan report 000aaaa1-0000-0a0a-00a0-00a0000000a0 --license --markdown --short" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan report`', + ) + }, + ) + + cmdit( + ['scan', 'report', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mdot is an invalid org, most likely you forgot the org name here?\\x1b[39m) + + - Scan ID to report on (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'report', + 'org', + 'report-id', + '--dry-run', + '--org', + 'foorg', + '--config', + '{"apiToken":"anything"}', + ], + 'should be ok with org name and id', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: foorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan report\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-setup.mts b/src/commands/scan/cmd-scan-setup.mts new file mode 100644 index 000000000..cdf5d628b --- /dev/null +++ b/src/commands/scan/cmd-scan-setup.mts @@ -0,0 +1,82 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleScanConfig } from './handle-scan-config.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'setup', + description: + 'Start interactive configurator to customize default flag values for `socket scan` in this dir', + hidden: false, + flags: { + ...commonFlags, + defaultOnReadError: { + type: 'boolean', + description: + 'If reading the socket.json fails, just use a default config? Warning: This might override the existing json file!', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags, 6)} + + Interactive configurator to create a local json file in the target directory + that helps to set flag defaults for \`socket scan create\`. + + This helps to configure the (Socket reported) repo and branch names, as well + as which branch name is the "default branch" (main, master, etc). This way + you don't have to specify these flags when creating a scan in this dir. + + This generated configuration file will only be used locally by the CLI. You + can commit it to the repo (useful for collaboration) or choose to add it to + your .gitignore all the same. Only this CLI will use it. + + Examples + + $ ${command} + $ ${command} ./proj + `, +} + +export const cmdScanSetup = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + const { defaultOnReadError = false } = cli.flags + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleScanConfig(cwd, Boolean(defaultOnReadError)) +} diff --git a/src/commands/scan/cmd-scan-setup.test.mts b/src/commands/scan/cmd-scan-setup.test.mts new file mode 100644 index 000000000..2c9d01f5f --- /dev/null +++ b/src/commands/scan/cmd-scan-setup.test.mts @@ -0,0 +1,82 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan setup', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'setup', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Start interactive configurator to customize default flag values for \`socket scan\` in this dir + + Usage + $ socket scan setup [options] [CWD=.] + + Options + --defaultOnReadErrorIf reading the socket.json fails, just use a default config? Warning: This might override the existing json file! + + Interactive configurator to create a local json file in the target directory + that helps to set flag defaults for \`socket scan create\`. + + This helps to configure the (Socket reported) repo and branch names, as well + as which branch name is the "default branch" (main, master, etc). This way + you don't have to specify these flags when creating a scan in this dir. + + This generated configuration file will only be used locally by the CLI. You + can commit it to the repo (useful for collaboration) or choose to add it to + your .gitignore all the same. Only this CLI will use it. + + Examples + + $ socket scan setup + $ socket scan setup ./proj" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan setup\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan setup`', + ) + }, + ) + + cmdit( + [ + 'scan', + 'setup', + 'fakeorg', + 'scanidee', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan setup\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan-view.mts b/src/commands/scan/cmd-scan-view.mts new file mode 100644 index 000000000..1cb4aad1e --- /dev/null +++ b/src/commands/scan/cmd-scan-view.mts @@ -0,0 +1,155 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleScanView } from './handle-scan-view.mts' +import { streamScan } from './stream-scan.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { + CliCommandConfig, + CliSubcommand, +} from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'view', + description: 'View the raw results of a scan', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + stream: { + type: 'boolean', + default: false, + description: + 'Only valid with --json. Streams the response as "ndjson" (chunks of valid json blobs).', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] <SCAN_ID> [OUTPUT_FILE] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + When no output path is given the contents is sent to stdout. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ ${command} 000aaaa1-0000-0a0a-00a0-00a0000000a0 ./stream.txt + `, +} + +export const cmdScanView: CliSubcommand = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + interactive, + json, + markdown, + org: orgFlag, + stream, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + const [scanId = '', file = ''] = cli.input + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'dot is an invalid org, most likely you forgot the org name here?', + }, + { + test: !!scanId, + message: 'Scan ID to view', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: + 'The `--json` and `--markdown` flags can not be used at the same time', + pass: 'ok', + fail: 'bad', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + { + nook: true, + test: !stream || !!json, + message: 'You can only use --stream when using --json', + pass: 'ok', + fail: 'Either remove --stream or add --json', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + if (json && stream) { + await streamScan(orgSlug, scanId, file) + } else { + await handleScanView(orgSlug, scanId, file, outputKind) + } +} diff --git a/src/commands/scan/cmd-scan-view.test.mts b/src/commands/scan/cmd-scan-view.test.mts new file mode 100644 index 000000000..b9e439f34 --- /dev/null +++ b/src/commands/scan/cmd-scan-view.test.mts @@ -0,0 +1,112 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan view', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', 'view', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "View the raw results of a scan + + Usage + $ socket scan view [options] <SCAN_ID> [OUTPUT_FILE] + + API Token Requirements + - Quota: 1 unit + - Permissions: full-scans:list + + When no output path is given the contents is sent to stdout. + + Options + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --stream Only valid with --json. Streams the response as "ndjson" (chunks of valid json blobs). + + Examples + $ socket scan view 000aaaa1-0000-0a0a-00a0-00a0000000a0 + $ socket scan view 000aaaa1-0000-0a0a-00a0-00a0000000a0 ./stream.txt" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket scan view`', + ) + }, + ) + + cmdit( + ['scan', 'view', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mdot is an invalid org, most likely you forgot the org name here?\\x1b[39m) + + - Scan ID to view (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'scan', + 'view', + '--org', + 'fakeorg', + 'scanidee', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: fakeorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan view\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/cmd-scan.mts b/src/commands/scan/cmd-scan.mts new file mode 100644 index 000000000..585c7ec40 --- /dev/null +++ b/src/commands/scan/cmd-scan.mts @@ -0,0 +1,53 @@ +import { cmdScanCreate } from './cmd-scan-create.mts' +import { cmdScanDel } from './cmd-scan-del.mts' +import { cmdScanDiff } from './cmd-scan-diff.mts' +import { cmdScanGithub } from './cmd-scan-github.mts' +import { cmdScanList } from './cmd-scan-list.mts' +import { cmdScanMetadata } from './cmd-scan-metadata.mts' +import { cmdScanReach } from './cmd-scan-reach.mts' +import { cmdScanReport } from './cmd-scan-report.mts' +import { cmdScanSetup } from './cmd-scan-setup.mts' +import { cmdScanView } from './cmd-scan-view.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Scan related commands' + +export const cmdScan: CliSubcommand = { + description, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + create: cmdScanCreate, + del: cmdScanDel, + diff: cmdScanDiff, + github: cmdScanGithub, + list: cmdScanList, + metadata: cmdScanMetadata, + reach: cmdScanReach, + report: cmdScanReport, + setup: cmdScanSetup, + view: cmdScanView, + }, + { + aliases: { + meta: { + description: cmdScanMetadata.description, + hidden: true, + argv: ['metadata'], + }, + reachability: { + description: cmdScanReach.description, + hidden: true, + argv: ['reach'], + }, + }, + argv, + description, + importMeta, + name: parentName + ' scan', + }, + ) + }, +} diff --git a/src/commands/scan/cmd-scan.test.mts b/src/commands/scan/cmd-scan.test.mts new file mode 100644 index 000000000..e9d141447 --- /dev/null +++ b/src/commands/scan/cmd-scan.test.mts @@ -0,0 +1,73 @@ +import path from 'node:path' + +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket scan', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['scan', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Scan related commands + + Usage + $ socket scan <command> + + Commands + create Create a scan + del Delete a scan + diff See what changed between two Scans + list List the scans for an organization + metadata Get a scan's metadata + report Check whether a scan result passes the organizational policies (security, license) + setup Start interactive configurator to customize default flag values for \`socket scan\` in this dir + view View the raw results of a scan + + Options + (none) + + Examples + $ socket scan --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain('`socket scan`') + }, + ) + + cmdit( + ['scan', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket scan\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/scan/create-scan-from-github.mts b/src/commands/scan/create-scan-from-github.mts new file mode 100644 index 000000000..fc1ddae77 --- /dev/null +++ b/src/commands/scan/create-scan-from-github.mts @@ -0,0 +1,761 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pipeline } from 'node:stream/promises' + +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { confirm, select } from '@socketsecurity/registry/lib/prompts' + +import { handleCreateNewScan } from './handle-create-new-scan.mts' +import { fetchListAllRepos } from '../repository/fetch-list-all-repos.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +// Supported manifest file name patterns +// Keep in mind that we have to request these files through the GitHub API; that cost is much heavier than local disk searches +// TODO: get this list from API instead? Is that too much? Has to fetch through gh api... +const SUPPORTED_FILE_PATTERNS = [ + /.*[-.]spdx\.json/, + /bom\.json/, + /.*[-.]cyclonedx\.json/, + /.*[-.]cyclonedx\.xml/, + /package\.json/, + /package-lock\.json/, + /npm-shrinkwrap\.json/, + /yarn\.lock/, + /pnpm-lock\.yaml/, + /pnpm-lock\.yml/, + /pnpm-workspace\.yaml/, + /pnpm-workspace\.yml/, + /pipfile/, + /pyproject\.toml/, + /poetry\.lock/, + /requirements[\\/].*\.txt/, + /requirements-.*\.txt/, + /requirements_.*\.txt/, + /requirements\.frozen/, + /setup\.py/, + /pipfile\.lock/, + /go\.mod/, + /go\.sum/, + /pom\.xml/, + /.*\..*proj/, + /.*\.props/, + /.*\.targets/, + /.*\.nuspec/, + /nuget\.config/, + /packages\.config/, + /packages\.lock\.json/, +] + +export async function createScanFromGithub({ + all, + githubApiUrl, + githubToken, + interactive, + orgGithub, + orgSlug, + outputKind, + repos, +}: { + all: boolean + githubApiUrl: string + githubToken: string + interactive: boolean + orgSlug: string + orgGithub: string + outputKind: OutputKind + repos: string +}): Promise<CResult<undefined>> { + let targetRepos: string[] = repos + .trim() + .split(',') + .map(repo => repo.trim()) + .filter(Boolean) + if (all || targetRepos.length === 0) { + // Fetch from Socket API + const result = await fetchListAllRepos({ + direction: 'asc', + orgSlug, + sort: 'name', + }) + if (!result.ok) { + return result + } + targetRepos = result.data.results.map(obj => obj.slug || '') + } + + targetRepos = targetRepos.map(slug => slug.trim()).filter(Boolean) + + logger.info(`Have ${targetRepos.length} repo names to Scan!`) + logger.log('') + + if (!targetRepos.filter(Boolean).length) { + return { + ok: false, + message: 'No repo found', + cause: + 'You did not set the --repos value and/or the server responded with zero repos when asked for some. Unable to proceed.', + } + } + + // Non-interactive or explicitly requested; just do it. + if (interactive && targetRepos.length > 1 && !all && !repos) { + const which = await selectFocus(targetRepos) + if (!which.ok) { + return which + } + targetRepos = which.data + } + + // 10 is an arbitrary number. Maybe confirm whenever count>1 ? + // Do not ask to confirm when the list was given explicit. + if (interactive && (all || !repos) && targetRepos.length > 10) { + const sure = await makeSure(targetRepos.length) + if (!sure.ok) { + return sure + } + } + + let scansCreated = 0 + for (const repoSlug of targetRepos) { + // eslint-disable-next-line no-await-in-loop + const result = await scanRepo(repoSlug, { + githubApiUrl, + githubToken, + orgSlug, + orgGithub, + outputKind, + repos, + }) + if (result.ok && result.data.scanCreated) { + scansCreated += 1 + } + } + + logger.success(targetRepos.length, 'GitHub repos detected') + logger.success(scansCreated, 'with supported Manifest files') + + return { + ok: true, + data: undefined, + } +} + +async function scanRepo( + repoSlug: string, + { + githubApiUrl, + githubToken, + orgGithub, + orgSlug, + outputKind, + repos, + }: { + githubApiUrl: string + githubToken: string + orgSlug: string + orgGithub: string + outputKind: OutputKind + repos: string + }, +): Promise<CResult<{ scanCreated: boolean }>> { + logger.info( + `Requesting repo details from GitHub API for: \`${orgGithub}/${repoSlug}\`...`, + ) + logger.group() + const result = await scanOneRepo(repoSlug, { + githubApiUrl, + githubToken, + orgSlug, + orgGithub, + outputKind, + repos, + }) + logger.groupEnd() + logger.log('') + return result +} + +async function scanOneRepo( + repoSlug: string, + { + githubApiUrl, + githubToken, + orgGithub, + orgSlug, + outputKind, + }: { + githubApiUrl: string + githubToken: string + orgSlug: string + orgGithub: string + outputKind: OutputKind + repos: string + }, +): Promise<CResult<{ scanCreated: boolean }>> { + const repoResult = await getRepoDetails({ + orgGithub, + repoSlug, + githubApiUrl, + githubToken, + }) + if (!repoResult.ok) { + return repoResult + } + const { defaultBranch, repoApiUrl } = repoResult.data + + logger.info(`Default branch: \`${defaultBranch}\``) + + const treeResult = await getRepoBranchTree({ + defaultBranch, + githubToken, + orgGithub, + repoSlug, + repoApiUrl, + }) + if (!treeResult.ok) { + return treeResult + } + const files = treeResult.data + + if (!files.length) { + logger.warn( + 'No files were reported for the default branch. Moving on to next repo.', + ) + return { ok: true, data: { scanCreated: false } } + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), repoSlug)) + debugFn('init: temp dir for scan root', tmpDir) + + const downloadResult = await testAndDownloadManifestFiles({ + files, + tmpDir, + repoSlug, + defaultBranch, + orgGithub, + repoApiUrl, + githubToken, + }) + if (!downloadResult.ok) { + return downloadResult + } + + const commitResult = await getLastCommitDetails({ + orgGithub, + repoSlug, + defaultBranch, + repoApiUrl, + githubToken, + }) + if (!commitResult.ok) { + return commitResult + } + + const { lastCommitMessage, lastCommitSha, lastCommitter } = commitResult.data + + // Make request for full scan + // I think we can just kick off the socket scan create command now... + + await handleCreateNewScan({ + autoManifest: false, + branchName: defaultBranch, + commitHash: lastCommitSha, + commitMessage: lastCommitMessage || '', + committers: lastCommitter || '', + cwd: tmpDir, + defaultBranch: true, + interactive: false, + orgSlug, + outputKind, + pendingHead: true, + pullRequest: 0, + readOnly: false, + repoName: repoSlug, + report: false, + targets: ['.'], + tmp: false, + }) + + return { ok: true, data: { scanCreated: true } } +} + +async function testAndDownloadManifestFiles({ + defaultBranch, + files, + githubToken, + orgGithub, + repoApiUrl, + repoSlug, + tmpDir, +}: { + files: string[] + tmpDir: string + repoSlug: string + defaultBranch: string + orgGithub: string + repoApiUrl: string + githubToken: string +}): Promise<CResult<unknown>> { + logger.info( + `File tree for ${defaultBranch} contains`, + files.length, + `entries. Searching for supported manifest files...`, + ) + logger.group() + let fileCount = 0 + let firstFailureResult + for (const file of files) { + // eslint-disable-next-line no-await-in-loop + const result = await testAndDownloadManifestFile({ + file, + tmpDir, + defaultBranch, + repoApiUrl, + githubToken, + }) + if (result.ok) { + if (result.data.isManifest) { + fileCount += 1 + } + } else if (!firstFailureResult) { + firstFailureResult = result + } + } + logger.groupEnd() + logger.info('Found and downloaded', fileCount, 'manifest files') + + if (!fileCount) { + if (firstFailureResult) { + logger.fail( + 'While no supported manifest files were downloaded, at least one error encountered trying to do so. Showing the first error.', + ) + return firstFailureResult + } + return { + ok: false, + message: 'No manifest files found', + cause: `No supported manifest files were found in the latest commit on the branch ${defaultBranch} for repo ${orgGithub}/${repoSlug}. Skipping full scan.`, + } + } + + return { ok: true, data: undefined } +} + +async function testAndDownloadManifestFile({ + defaultBranch, + file, + githubToken, + repoApiUrl, + tmpDir, +}: { + file: string + tmpDir: string + defaultBranch: string + repoApiUrl: string + githubToken: string +}): Promise<CResult<{ isManifest: boolean }>> { + debugFn('testing: file', file) + + if (!SUPPORTED_FILE_PATTERNS.some(regex => regex.test(file))) { + debugFn(' - skip: not a known pattern') + // Not an error. + return { ok: true, data: { isManifest: false } } + } + + debugFn('found: manifest file, going to attempt to download it;', file) + + const result = await downloadManifestFile({ + file, + tmpDir, + defaultBranch, + repoApiUrl, + githubToken, + }) + + return result.ok ? { ok: true, data: { isManifest: true } } : result +} + +async function downloadManifestFile({ + defaultBranch, + file, + githubToken, + repoApiUrl, + tmpDir, +}: { + file: string + tmpDir: string + defaultBranch: string + repoApiUrl: string + githubToken: string +}): Promise<CResult<undefined>> { + debugFn('request: download url from GitHub') + + const fileUrl = `${repoApiUrl}/contents/${file}?ref=${defaultBranch}` + debugFn('url: file', fileUrl) + + const downloadUrlResponse = await fetch(fileUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${githubToken}`, + }, + }) + debugFn('complete: request') + + const downloadUrlText = await downloadUrlResponse.text() + debugFn('response: raw download url', downloadUrlText) + + let downloadUrl + try { + downloadUrl = JSON.parse(downloadUrlText).download_url + } catch { + logger.fail( + `GitHub response contained invalid JSON for download url for: ${file}`, + ) + + return { + ok: false, + message: 'Invalid JSON response', + cause: `Server responded with invalid JSON for download url ${downloadUrl}`, + } + } + + const localPath = path.join(tmpDir, file) + debugFn('download: manifest file started', downloadUrl, '->', localPath) + + // Now stream the file to that file... + const result = await streamDownloadWithFetch(localPath, downloadUrl) + if (!result.ok) { + // Do we proceed? Bail? Hrm... + logger.fail( + `Failed to download manifest file, skipping to next file. File: ${file}`, + ) + return result + } + + debugFn('download: manifest file completed') + + return { ok: true, data: undefined } +} + +// Courtesy of gemini: +async function streamDownloadWithFetch( + localPath: string, + downloadUrl: string, +): Promise<CResult<string>> { + let response // Declare response here to access it in catch if needed + + try { + response = await fetch(downloadUrl) + + if (!response.ok) { + const errorMsg = `Download failed due to bad server response: ${response.status} ${response.statusText} for ${downloadUrl}` + logger.fail(errorMsg) + return { ok: false, message: 'Download Failed', cause: errorMsg } + } + + if (!response.body) { + logger.fail( + `Download failed because the server response was empty, for ${downloadUrl}`, + ) + return { + ok: false, + message: 'Download Failed', + cause: 'Response body is null or undefined.', + } + } + + // Make sure the dir exists. It may be nested and we need to construct that + // before starting the download. + const dir = path.dirname(localPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + const fileStream = fs.createWriteStream(localPath) + + // Using stream.pipeline for better error handling and cleanup + + await pipeline(response.body, fileStream) + // 'pipeline' will automatically handle closing streams and propagating errors. + // It resolves when the piping is fully complete and fileStream is closed. + return { ok: true, data: localPath } + } catch (error) { + logger.fail( + 'An error was thrown while trying to download a manifest file... url:', + downloadUrl, + ) + debugFn('Raw error:') + debugFn(error) + + // If an error occurs and fileStream was created, attempt to clean up. + if (fs.existsSync(localPath)) { + // Check if fileStream was even opened before trying to delete + // This check might be too simplistic depending on when error occurs + fs.unlink(localPath, unlinkErr => { + if (unlinkErr) { + logger.fail( + `Error deleting partial file ${localPath}: ${unlinkErr.message}`, + ) + } + }) + } + // Construct a more informative error message + let detailedError = `Error during download of ${downloadUrl}: ${(error as { message: string }).message}` + if ((error as { cause: string }).cause) { + // Include cause if available (e.g., from network errors) + detailedError += `\nCause: ${(error as { cause: string }).cause}` + } + if (response && !response.ok) { + // If error was due to bad HTTP status + detailedError += ` (HTTP Status: ${response.status} ${response.statusText})` + } + debugFn(detailedError) + return { ok: false, message: 'Download Failed', cause: detailedError } + } +} + +async function getLastCommitDetails({ + defaultBranch, + githubToken, + orgGithub, + repoApiUrl, + repoSlug, +}: { + orgGithub: string + repoSlug: string + defaultBranch: string + repoApiUrl: string + githubToken: string +}): Promise< + CResult<{ + lastCommitSha: string + lastCommitter: string | undefined + lastCommitMessage: string + }> +> { + logger.info( + `Requesting last commit for default branch ${defaultBranch} for ${orgGithub}/${repoSlug}...`, + ) + + const commitApiUrl = `${repoApiUrl}/commits?sha=${defaultBranch}&per_page=1` + debugFn('url: commit', commitApiUrl) + + const commitResponse = await fetch(commitApiUrl, { + headers: { + Authorization: `Bearer ${githubToken}`, + }, + }) + + const commitText = await commitResponse.text() + debugFn('response: commit', commitText) + + let lastCommit + try { + lastCommit = JSON.parse(commitText)?.[0] + } catch { + logger.fail(`GitHub response contained invalid JSON for last commit`) + logger.error(commitText) + return { + ok: false, + message: 'Invalid JSON response', + cause: `Server responded with invalid JSON for last commit of repo ${repoSlug}`, + } + } + + const lastCommitSha = lastCommit.sha + const lastCommitter = Array.from( + new Set([lastCommit.commit.author.name, lastCommit.commit.committer.name]), + )[0] + const lastCommitMessage = lastCommit.message + + if (!lastCommitSha) { + return { + ok: false, + message: 'Missing commit SHA', + cause: 'Unable to get last commit for repo', + } + } + + if (!lastCommitter) { + return { + ok: false, + message: 'Missing committer', + cause: 'Last commit does not have information about who made the commit', + } + } + + return { ok: true, data: { lastCommitSha, lastCommitter, lastCommitMessage } } +} + +async function selectFocus(repos: string[]): Promise<CResult<string[]>> { + const proceed = await select<string>({ + message: 'Please select the repo to process:', + choices: repos + .map(slug => ({ + name: slug, + value: slug, + description: `Create scan for the ${slug} repo through GitHub`, + })) + .concat({ + name: '(Exit)', + value: '', + description: 'Cancel this action and exit', + }), + }) + if (!proceed) { + return { + ok: false, + message: 'Canceled by user', + cause: 'User chose to cancel the action', + } + } + return { ok: true, data: [proceed] } +} + +async function makeSure(count: number): Promise<CResult<undefined>> { + if ( + !(await confirm({ + message: `Are you sure you want to run this for ${count} repos?`, + default: false, + })) + ) { + return { + ok: false, + message: 'User canceled', + cause: 'Action canceled by user', + } + } + return { ok: true, data: undefined } +} + +async function getRepoDetails({ + githubApiUrl, + githubToken, + orgGithub, + repoSlug, +}: { + orgGithub: string + repoSlug: string + githubApiUrl: string + githubToken: string +}): Promise< + CResult<{ defaultBranch: string; repoDetails: unknown; repoApiUrl: string }> +> { + const repoApiUrl = `${githubApiUrl}/repos/${orgGithub}/${repoSlug}` + debugFn('url: repo', repoApiUrl) + + const repoDetailsResponse = await fetch(repoApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${githubToken}`, + }, + }) + logger.success(`Request completed.`) + + const repoDetailsText = await repoDetailsResponse.text() + debugFn('response: repo', repoDetailsText) + + let repoDetails + try { + repoDetails = JSON.parse(repoDetailsText) + } catch { + logger.fail(`GitHub response contained invalid JSON for repo ${repoSlug}`) + logger.error(repoDetailsText) + return { + ok: false, + message: 'Invalid JSON response', + cause: `Server responded with invalid JSON for repo ${repoSlug}`, + } + } + + const defaultBranch = repoDetails.default_branch + if (!defaultBranch) { + return { + ok: false, + message: 'Default Branch Not Found', + cause: `Repo ${repoSlug} does not have a default branch set or it was not reported`, + } + } + + return { ok: true, data: { defaultBranch, repoDetails, repoApiUrl } } +} + +async function getRepoBranchTree({ + defaultBranch, + githubToken, + orgGithub, + repoApiUrl, + repoSlug, +}: { + defaultBranch: string + githubToken: string + orgGithub: string + repoApiUrl: string + repoSlug: string +}): Promise<CResult<string[]>> { + logger.info( + `Requesting default branch file tree; branch \`${defaultBranch}\`, repo \`${orgGithub}/${repoSlug}\`...`, + ) + + const treeApiUrl = `${repoApiUrl}/git/trees/${defaultBranch}?recursive=1` + debugFn('url: tree', treeApiUrl) + + const treeResponse = await fetch(treeApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${githubToken}`, + }, + }) + + const treeText = await treeResponse.text() + debugFn('response: tree', treeText) + + let treeDetails + try { + treeDetails = JSON.parse(treeText) + } catch { + logger.fail( + `GitHub response contained invalid JSON for default branch of repo ${repoSlug}`, + ) + logger.error(treeText) + return { + ok: false, + message: 'Invalid JSON response', + cause: `Server responded with invalid JSON for repo ${repoSlug}`, + } + } + + if (treeDetails.message) { + if (treeDetails.message === 'Git Repository is empty.') { + logger.warn( + `GitHub reports the default branch of repo ${repoSlug} to be empty. Moving on to next repo.`, + ) + return { ok: true, data: [] } + } + + logger.fail('Negative response from GitHub:', treeDetails.message) + return { + ok: false, + message: 'Unexpected error response', + cause: `GitHub responded with an unexpected error while asking for details on the default branch: ${treeDetails.message}`, + } + } + + if (!treeDetails.tree || !Array.isArray(treeDetails.tree)) { + debugFn('treeDetails.tree:', treeDetails.tree) + + return { + ok: false, + message: `Tree response for default branch ${defaultBranch} for ${orgGithub}/${repoSlug} was not a list`, + } + } + + const files = (treeDetails.tree as Array<{ type: string; path: string }>) + .filter(obj => obj.type === 'blob') + .map(obj => obj.path) + + return { ok: true, data: files } +} diff --git a/src/commands/scan/fetch-create-org-full-scan.mts b/src/commands/scan/fetch-create-org-full-scan.mts new file mode 100644 index 000000000..76806e1bd --- /dev/null +++ b/src/commands/scan/fetch-create-org-full-scan.mts @@ -0,0 +1,55 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchCreateOrgFullScan( + packagePaths: string[], + orgSlug: string, + defaultBranch: boolean, + pendingHead: boolean, + tmp: boolean, + cwd: string, + { + branchName, + commitHash, + commitMessage, + committers, + pullRequest, + repoName, + }: { + branchName: string + commitHash: string + commitMessage: string + committers: string + pullRequest: number + repoName: string + }, +): Promise<CResult<SocketSdkReturnType<'CreateOrgFullScan'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.createOrgFullScan( + orgSlug, + { + ...(branchName ? { branch: branchName } : {}), + ...(commitHash ? { commit_hash: commitHash } : {}), + ...(commitMessage ? { commit_message: commitMessage } : {}), + ...(committers ? { committers } : {}), + make_default_branch: String(defaultBranch), + ...(pullRequest ? { pull_request: String(pullRequest) } : {}), + repo: repoName || 'socket-default-repository', // mandatory, this is server default for repo + set_as_pending_head: String(pendingHead), + tmp: String(tmp), + }, + packagePaths, + cwd, + ), + 'to create a scan', + ) +} diff --git a/src/commands/scan/fetch-delete-org-full-scan.mts b/src/commands/scan/fetch-delete-org-full-scan.mts new file mode 100644 index 000000000..1a2116163 --- /dev/null +++ b/src/commands/scan/fetch-delete-org-full-scan.mts @@ -0,0 +1,21 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchDeleteOrgFullScan( + orgSlug: string, + scanId: string, +): Promise<CResult<SocketSdkReturnType<'deleteOrgFullScan'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.deleteOrgFullScan(orgSlug, scanId), + 'to delete a scan', + ) +} diff --git a/src/commands/scan/fetch-diff-scan.mts b/src/commands/scan/fetch-diff-scan.mts new file mode 100644 index 000000000..e098244e5 --- /dev/null +++ b/src/commands/scan/fetch-diff-scan.mts @@ -0,0 +1,25 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { queryApiSafeJson } from '../../utils/api.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchDiffScan({ + id1, + id2, + orgSlug, +}: { + id1: string + id2: string + orgSlug: string +}): Promise<CResult<SocketSdkReturnType<'GetOrgDiffScan'>['data']>> { + logger.info('Scan ID 1:', id1) + logger.info('Scan ID 2:', id2) + logger.info('Note: this request may take some time if the scans are big') + + return await queryApiSafeJson<SocketSdkReturnType<'GetOrgDiffScan'>['data']>( + `orgs/${orgSlug}/full-scans/diff?before=${encodeURIComponent(id1)}&after=${encodeURIComponent(id2)}`, + 'a scan diff', + ) +} diff --git a/src/commands/scan/fetch-list-scans.mts b/src/commands/scan/fetch-list-scans.mts new file mode 100644 index 000000000..1bbd7a210 --- /dev/null +++ b/src/commands/scan/fetch-list-scans.mts @@ -0,0 +1,44 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchListScans({ + branch, + direction, + from_time, + orgSlug, + page, + per_page, + repo, + sort, +}: { + branch: string + direction: string + from_time: string + orgSlug: string + page: number + per_page: number + repo: string + sort: string +}): Promise<CResult<SocketSdkReturnType<'getOrgFullScanList'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgFullScanList(orgSlug, { + ...(branch ? { branch } : {}), + ...(repo ? { repo } : {}), + sort, + direction, + per_page: String(per_page), + page: String(page), + from: from_time, + }), + 'list of scans', + ) +} diff --git a/src/commands/scan/fetch-report-data.mts b/src/commands/scan/fetch-report-data.mts new file mode 100644 index 000000000..7a61d2f81 --- /dev/null +++ b/src/commands/scan/fetch-report-data.mts @@ -0,0 +1,165 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { handleApiCallNoSpinner, queryApiSafeText } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +/** + * This fetches all the relevant pieces of data to generate a report, given a + * full scan ID. + */ +export async function fetchReportData( + orgSlug: string, + scanId: string, + includeLicensePolicy: boolean, +): Promise< + CResult<{ + scan: SocketArtifact[] + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'>['data'] + }> +> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + let scanStatus = 'requested..' + let policyStatus = 'requested..' + let finishedFetching = false + + // Lazily access constants.spinner. + const { spinner } = constants + + function updateScan(desc: string) { + scanStatus = desc + updateProgress() + } + + function updatePolicy(desc: string) { + policyStatus = desc + updateProgress() + } + + function updateProgress() { + if (finishedFetching) { + spinner.stop() + logger.info( + `Scan result: ${scanStatus}. Security policy: ${policyStatus}.`, + ) + } else { + spinner.start( + `Scan result: ${scanStatus}. Security policy: ${policyStatus}.`, + ) + } + } + + async function fetchScanResult(): Promise<CResult<SocketArtifact[]>> { + const result = await queryApiSafeText( + `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${includeLicensePolicy ? '?include_license_details=true' : ''}`, + ) + + updateScan(`response received`) + + if (!result.ok) { + return result + } + + const jsonsString = result.data + + // This is nd-json; each line is a json object. + const lines = jsonsString.split('\n').filter(Boolean) + let ok = true + const data = lines.map(line => { + try { + return JSON.parse(line) + } catch { + ok = false + debugFn('fail: parse NDJSON\n', line) + return + } + }) as unknown as SocketArtifact[] + + if (ok) { + updateScan(`success`) + return { ok: true, data } + } + + updateScan(`received invalid JSON response`) + + return { + ok: false, + message: 'Invalid API response', + cause: + 'The API responded with at least one line that was not valid JSON. Please report if this persists.', + } + } + + async function fetchSecurityPolicy(): Promise< + CResult<SocketSdkReturnType<'getOrgSecurityPolicy'>['data']> + > { + const result = await handleApiCallNoSpinner( + sockSdk.getOrgSecurityPolicy(orgSlug), + 'GetOrgSecurityPolicy', + ) + + updatePolicy('received policy') + + return result + } + + updateProgress() + + const [scan, securityPolicy]: [ + CResult<SocketArtifact[]>, + CResult<SocketSdkReturnType<'getOrgSecurityPolicy'>['data']>, + ] = await Promise.all([ + fetchScanResult().catch(e => { + updateScan(`failure; unknown blocking problem occurred`) + return { + ok: false as const, + message: 'Unexpected API problem', + cause: `We encountered an unexpected problem while requesting the Scan from the API: ${e?.message || '(no error message found)'}${e?.cause ? ` (cause: ${e.cause})` : ''}`, + } + }), + fetchSecurityPolicy().catch(e => { + updatePolicy(`failure; unknown blocking problem occurred`) + return { + ok: false as const, + message: 'Unexpected API problem', + cause: `We encountered an unexpected problem while requesting the policy from the API: ${e?.message || '(no error message found)'}${e?.cause ? ` (cause: ${e.cause})` : ''}`, + } + }), + ]).finally(() => { + finishedFetching = true + updateProgress() + }) + + if (!scan.ok) { + return scan + } + if (!securityPolicy.ok) { + return securityPolicy + } + + if (!Array.isArray(scan.data)) { + return { + ok: false, + message: 'Failed to fetch', + cause: 'Was unable to fetch scan result, bailing', + } + } + + return { + ok: true, + data: { + scan: scan.data satisfies SocketArtifact[], + securityPolicy: securityPolicy.data, + }, + } +} diff --git a/src/commands/scan/fetch-scan-metadata.mts b/src/commands/scan/fetch-scan-metadata.mts new file mode 100644 index 000000000..4b1e43c73 --- /dev/null +++ b/src/commands/scan/fetch-scan-metadata.mts @@ -0,0 +1,21 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchScanMetadata( + orgSlug: string, + scanId: string, +): Promise<CResult<SocketSdkReturnType<'getOrgFullScanMetadata'>['data']>> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getOrgFullScanMetadata(orgSlug, scanId), + 'meta data for a full scan', + ) +} diff --git a/src/commands/scan/fetch-scan.mts b/src/commands/scan/fetch-scan.mts new file mode 100644 index 000000000..b46036a47 --- /dev/null +++ b/src/commands/scan/fetch-scan.mts @@ -0,0 +1,46 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import { queryApiSafeText } from '../../utils/api.mts' + +import type { CResult } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' + +export async function fetchScan( + orgSlug: string, + scanId: string, +): Promise<CResult<SocketArtifact[]>> { + const result = await queryApiSafeText( + `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}`, + 'a scan', + ) + + if (!result.ok) { + return result + } + + const jsonsString = result.data + + // This is nd-json; each line is a json object + const lines = jsonsString.split('\n').filter(Boolean) + let ok = true + const data = lines.map(line => { + try { + return JSON.parse(line) + } catch { + ok = false + debugFn('fail: parse NDJSON\n', line) + return null + } + }) as unknown as SocketArtifact[] + + if (ok) { + return { ok: true, data } + } + + return { + ok: false, + message: 'Invalid API response', + cause: + 'The API responded with at least one line that was not valid JSON. Please report if this persists.', + } +} diff --git a/src/commands/scan/fetch-supported-scan-file-names.mts b/src/commands/scan/fetch-supported-scan-file-names.mts new file mode 100644 index 000000000..0d03cc974 --- /dev/null +++ b/src/commands/scan/fetch-supported-scan-file-names.mts @@ -0,0 +1,20 @@ +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +import type { CResult } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchSupportedScanFileNames(): Promise< + CResult<SocketSdkReturnType<'getReportSupportedFiles'>['data']> +> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + return await handleApiCall( + sockSdk.getReportSupportedFiles(), + 'supported scan file types', + ) +} diff --git a/src/commands/scan/generate-report.mts b/src/commands/scan/generate-report.mts new file mode 100644 index 000000000..6392d394a --- /dev/null +++ b/src/commands/scan/generate-report.mts @@ -0,0 +1,340 @@ +import { getSocketDevPackageOverviewUrlFromPurl } from '../../utils/socket-url.mts' + +import type { CResult } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +type AlertAction = 'defer' | 'ignore' | 'monitor' | 'error' | 'warn' +type AlertKey = string + +type FileMap = Map<string, ReportLeafNode | Map<AlertKey, ReportLeafNode>> +type VersionMap = Map<string, ReportLeafNode | FileMap> +type PackageMap = Map<string, ReportLeafNode | VersionMap> +type EcoMap = Map<string, ReportLeafNode | PackageMap> +export type ViolationsMap = Map<string, EcoMap> + +export interface ShortScanReport { + healthy: boolean +} +export interface ScanReport { + orgSlug: string + scanId: string + options: { fold: string; reportLevel: string } + healthy: boolean + alerts: ViolationsMap +} + +export type ReportLeafNode = { + type: string + policy: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + url: string + manifest: string[] +} + +// Note: The returned cresult will only be ok:false when the generation +// failed. It won't reflect the healthy state. +export function generateReport( + scan: SocketArtifact[], + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'>['data'], + { + fold, + orgSlug, + reportLevel, + scanId, + short, + spinner, + }: { + fold: 'pkg' | 'version' | 'file' | 'none' + orgSlug: string + reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + scanId: string + short?: boolean | undefined + spinner?: Spinner | undefined + }, +): CResult<ScanReport | { healthy: boolean }> { + const now = Date.now() + + spinner?.start('Generating report...') + + // Create an object that includes: + // healthy: boolean + // worst violation level; + // per eco + // per package + // per version + // per offending file + // reported issue -> policy action + + // In the context of a report; + // - the alert.severity is irrelevant + // - the securityPolicyDefault is irrelevant + // - the report defaults to healthy:true with no alerts + // - the appearance of an alert will trigger the policy action; + // - error: healthy will end up as false, add alerts to report + // - warn: healthy unchanged, add alerts to report + // - monitor/ignore: no action + // - defer: unknown (no action) + + // Note: the server will emit alerts for license policy violations but + // those are only included if you set the flag when requesting the scan + // data. The alerts map to a single security policy key that determines + // what to do with any violation, regardless of the concrete license. + // That rule is called "License Policy Violation". + // The license policy part is implicitly handled here. Either they are + // included and may show up, or they are not and won't show up. + + const violations = new Map() + + let healthy = true + + const securityRules = securityPolicy.securityPolicyRules + if (securityRules) { + // Note: reportLevel: error > warn > monitor > ignore > defer + scan.forEach(artifact => { + const { + alerts, + name: pkgName = '<unknown>', + type: ecosystem, + version = '<unknown>', + } = artifact + + alerts?.forEach( + (alert: NonNullable<SocketArtifact['alerts']>[number]) => { + const alertName = alert.type as keyof typeof securityRules // => policy[type] + const action = securityRules[alertName]?.action || '' + switch (action) { + case 'error': { + healthy = false + if (!short) { + addAlert( + artifact, + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action, + ) + } + break + } + case 'warn': { + if (!short && reportLevel !== 'error') { + addAlert( + artifact, + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action, + ) + } + break + } + case 'monitor': { + if (!short && reportLevel !== 'warn' && reportLevel !== 'error') { + addAlert( + artifact, + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action, + ) + } + break + } + + case 'ignore': { + if ( + !short && + reportLevel !== 'warn' && + reportLevel !== 'error' && + reportLevel !== 'monitor' + ) { + addAlert( + artifact, + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action, + ) + } + break + } + + case 'defer': { + // Not sure but ignore for now. Defer to later ;) + if (!short && reportLevel === 'defer') { + addAlert( + artifact, + violations, + fold, + ecosystem, + pkgName, + version, + alert, + action, + ) + } + break + } + + default: { + // This value was not emitted from the api at the time of writing. + } + } + }, + ) + }) + } + + spinner?.successAndStop(`Generated reported in ${Date.now() - now} ms`) + + if (short) { + return { + ok: true, + data: { healthy }, + } + } + + const report = { + healthy, + orgSlug, + scanId, + options: { fold, reportLevel }, + alerts: violations, + } + + if (!healthy) { + return { + ok: true, + message: + 'The report contains at least one alert that violates the policies set by your organization', + data: report, + } + } + + return { + ok: true, + data: report, + } +} + +function createLeaf( + art: SocketArtifact, + alert: NonNullable<SocketArtifact['alerts']>[number], + policyAction: AlertAction, +): ReportLeafNode { + const leaf: ReportLeafNode = { + type: alert.type, + policy: policyAction, + url: getSocketDevPackageOverviewUrlFromPurl(art), + manifest: art.manifestFiles?.map(obj => obj.file) ?? [], + } + return leaf +} + +function addAlert( + art: SocketArtifact, + violations: ViolationsMap, + foldSetting: 'pkg' | 'version' | 'file' | 'none', + ecosystem: string, + pkgName: string, + version: string, + alert: NonNullable<SocketArtifact['alerts']>[number], + policyAction: AlertAction, +): void { + if (!violations.has(ecosystem)) { + violations.set(ecosystem, new Map()) + } + const ecomap: EcoMap = violations.get(ecosystem)! + if (foldSetting === 'pkg') { + const existing = ecomap.get(pkgName) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + ecomap.set(pkgName, createLeaf(art, alert, policyAction)) + } + } else { + if (!ecomap.has(pkgName)) { + ecomap.set(pkgName, new Map()) + } + const pkgmap = ecomap.get(pkgName) as PackageMap + if (foldSetting === 'version') { + const existing = pkgmap.get(version) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + pkgmap.set(version, createLeaf(art, alert, policyAction)) + } + } else { + if (!pkgmap.has(version)) { + pkgmap.set(version, new Map()) + } + const file = alert.file || '<unknown>' + const vermap = pkgmap.get(version) as VersionMap + + if (foldSetting === 'file') { + const existing = vermap.get(file) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + vermap.set(file, createLeaf(art, alert, policyAction)) + } + } else { + if (!vermap.has(file)) { + vermap.set(file, new Map()) + } + const key = `${alert.type} at ${alert.start}:${alert.end}` + const filemap: FileMap = vermap.get(file) as FileMap + const existing = filemap.get(key) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + filemap.set(key, createLeaf(art, alert, policyAction)) + } + } + } + } +} + +function isStricterPolicy( + was: 'error' | 'warn' | 'monitor' | 'ignore' | 'defer', + is: 'error' | 'warn' | 'monitor' | 'ignore' | 'defer', +): boolean { + // error > warn > monitor > ignore > defer > {unknown} + if (was === 'error') { + return false + } + if (is === 'error') { + return true + } + if (was === 'warn') { + return false + } + if (is === 'warn') { + return false + } + if (was === 'monitor') { + return false + } + if (is === 'monitor') { + return false + } + if (was === 'ignore') { + return false + } + if (is === 'ignore') { + return false + } + if (was === 'defer') { + return false + } + if (is === 'defer') { + return false + } + // unreachable? + return false +} diff --git a/src/commands/scan/generate-report.test.mts b/src/commands/scan/generate-report.test.mts new file mode 100644 index 000000000..eb22ce749 --- /dev/null +++ b/src/commands/scan/generate-report.test.mts @@ -0,0 +1,1117 @@ +import { describe, expect, it } from 'vitest' + +import { generateReport } from './generate-report.mts' +import { SocketArtifact } from '../../utils/alert/artifact.mts' + +import type { ScanReport } from './generate-report.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +type SecurityPolicyData = SocketSdkReturnType<'getOrgSecurityPolicy'>['data'] + +describe('generate-report', () => { + it('should accept empty args', () => { + const result = generateReport( + [], + { securityPolicyRules: [] } as SecurityPolicyData, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + }) + + describe('report shape', () => { + describe('report-level=warn', () => { + it('should return a healthy report without alerts when there are no violations', () => { + const result = generateReport( + getSimpleCleanScan(), + { + securityPolicyRules: { + gptSecurity: { + action: 'ignore', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a sick report with alert when an alert violates at error', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + // "ok" only reports on the state of the command, not the report health + expect(result.ok).toBe(true) + // the report health itself should be false. + expect(result.ok && result.data.healthy).toBe(false) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at warn', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'warn', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report without alerts when an alert violates at monitor', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'monitor', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert violates at ignore', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'ignore', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert violates at defer', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'defer', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy value', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: {}, + }, + securityPolicyDefault: 'medium', + } as SecurityPolicyData, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy entry', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: {}, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + }) + + describe('report-level=ignore', () => { + it('should return a healthy report without alerts when there are no violations', () => { + const result = generateReport( + getSimpleCleanScan(), + { + securityPolicyRules: { + gptSecurity: { + action: 'ignore', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a sick report with alert when an alert violates at error', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": false, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(false) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at warn', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'warn', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at monitor', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'monitor', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "monitor", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "monitor", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report with alert when an alert violates at ignore', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'ignore', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "ignore", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "ignore", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(1) + }) + + it('should return a healthy report without alerts when an alert violates at defer', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'defer', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy value', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: {}, + }, + securityPolicyDefault: 'medium', + } as SecurityPolicyData, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + + it('should return a healthy report without alerts when an alert has no policy entry', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: {}, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'ignore', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "ok": true, + } + `) + expect(result.ok).toBe(true) + expect(result.ok && result.data.healthy).toBe(true) + expect((result.data as ScanReport)['alerts']?.size).toBe(0) + }) + }) + }) + + describe('fold', () => { + it('should not fold anything when fold=none', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'none', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => Map { + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + }, + "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + }) + + it('should fold the file locations when fold=file', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'file', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => Map { + "package/which.js" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + }, + "healthy": false, + "options": { + "fold": "file", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + }) + + it('should fold the files up when fold=version', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'version', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => Map { + "1.14.1" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + }, + "healthy": false, + "options": { + "fold": "version", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + }) + + it('should fold the versions up when fold=pkg', () => { + const result = generateReport( + getScanWithEnvVars(), + { + securityPolicyRules: { + envVars: { + action: 'error', + }, + }, + securityPolicyDefault: 'medium', + }, + { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + fold: 'pkg', + reportLevel: 'warn', + }, + ) + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "alerts": Map { + "npm" => Map { + "tslib" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/overview/1.14.1", + }, + }, + }, + "healthy": false, + "options": { + "fold": "pkg", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + }, + "message": "The report contains at least one alert that violates the policies set by your organization", + "ok": true, + } + `) + }) + }) +}) + +function getSimpleCleanScan(): SocketArtifact[] { + return [ + { + id: '12521', + author: ['typescript-bot'], + size: 33965, + type: 'npm', + name: 'tslib', + version: '1.14.1', + license: '0BSD', + licenseDetails: [], + score: { + license: 1, + maintenance: 0.86, + overall: 0.86, + quality: 1, + supplyChain: 1, + vulnerability: 1, + }, + alerts: [], + manifestFiles: [ + { + file: 'package-lock.json', + start: 600172, + end: 600440, + }, + ], + topLevelAncestors: ['15903631404'], + }, + ] +} + +function getScanWithEnvVars(): SocketArtifact[] { + return [ + { + id: '12521', + author: ['typescript-bot'], + size: 33965, + type: 'npm', + name: 'tslib', + version: '1.14.1', + license: '0BSD', + licenseDetails: [], + score: { + license: 1, + maintenance: 0.86, + overall: 0.86, + quality: 1, + supplyChain: 1, + vulnerability: 1, + }, + alerts: [ + { + key: 'QEW1uRmLsj4EBOTv3wb0NZ3W4ziYZVheU5uTpYPC6txs', + type: 'envVars', + severity: 'low', + category: 'supplyChainRisk', + file: 'package/which.js', + start: 54, + end: 72, + props: { + // @ts-ignore + envVars: 'XYZ', + }, + }, + { + key: 'QEW1uRmLsj4EBOTv3wb0NZ3W4ziYZVheU5uTpYPC6txy', + type: 'envVars', + severity: 'low', + category: 'supplyChainRisk', + file: 'package/which.js', + start: 200, + end: 250, + props: { + // @ts-ignore + envVars: 'ABC', + }, + }, + ], + manifestFiles: [ + { + file: 'package-lock.json', + start: 600172, + end: 600440, + }, + ], + topLevelAncestors: ['15903631404'], + }, + ] +} diff --git a/packages/cli/src/commands/scan/handle-create-github-scan.mts b/src/commands/scan/handle-create-github-scan.mts similarity index 86% rename from packages/cli/src/commands/scan/handle-create-github-scan.mts rename to src/commands/scan/handle-create-github-scan.mts index 3e40e48b7..f59e07041 100644 --- a/packages/cli/src/commands/scan/handle-create-github-scan.mts +++ b/src/commands/scan/handle-create-github-scan.mts @@ -22,7 +22,7 @@ export async function handleCreateGithubScan({ outputKind: OutputKind repos: string }) { - const ghScanCResult = await createScanFromGithub({ + const result = await createScanFromGithub({ all: Boolean(all), githubApiUrl, githubToken, @@ -33,5 +33,5 @@ export async function handleCreateGithubScan({ repos: String(repos || ''), }) - await outputScanGithub(ghScanCResult, outputKind) + await outputScanGithub(result, outputKind) } diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts new file mode 100644 index 000000000..a31f95119 --- /dev/null +++ b/src/commands/scan/handle-create-new-scan.mts @@ -0,0 +1,138 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' +import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' +import { handleScanReport } from './handle-scan-report.mts' +import { outputCreateNewScan } from './output-create-new-scan.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getPackageFilesForScan } from '../../utils/path-resolve.mts' +import { readOrDefaultSocketJson } from '../../utils/socketjson.mts' +import { detectManifestActions } from '../manifest/detect-manifest-actions.mts' +import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleCreateNewScan({ + autoManifest, + branchName, + commitHash, + commitMessage, + committers, + cwd, + defaultBranch, + interactive, + orgSlug, + outputKind, + pendingHead, + pullRequest, + readOnly, + repoName, + report, + targets, + tmp, +}: { + autoManifest: boolean + branchName: string + commitHash: string + commitMessage: string + committers: string + cwd: string + defaultBranch: boolean + interactive: boolean + orgSlug: string + pendingHead: boolean + pullRequest: number + outputKind: OutputKind + readOnly: boolean + repoName: string + report: boolean + targets: string[] + tmp: boolean +}): Promise<void> { + if (autoManifest) { + logger.info('Auto generating manifest files ...') + const socketJson = await readOrDefaultSocketJson(cwd) + const detected = await detectManifestActions(socketJson, cwd) + await generateAutoManifest({ + detected, + cwd, + outputKind, + verbose: false, + }) + logger.info('Auto generation finished. Proceeding with Scan creation.') + } + + const supportedFileNames = await fetchSupportedScanFileNames() + if (!supportedFileNames.ok) { + await outputCreateNewScan(supportedFileNames, outputKind, interactive) + return + } + + const packagePaths = await getPackageFilesForScan( + cwd, + targets, + supportedFileNames.data, + ) + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: packagePaths.length > 0, + pass: 'ok', + fail: 'found no eligible files to scan', + message: + 'TARGET (file/dir) must contain matching / supported file types for a scan', + }) + if (!wasValidInput) { + return + } + + if (readOnly) { + logger.log('[ReadOnly] Bailing now') + return + } + + const data = await fetchCreateOrgFullScan( + packagePaths, + orgSlug, + defaultBranch, + pendingHead, + tmp, + cwd, + { + commitHash, + commitMessage, + committers, + pullRequest, + repoName, + branchName, + }, + ) + + if (data.ok && report) { + if (data.data?.id) { + await handleScanReport({ + filePath: '-', + fold: 'version', + includeLicensePolicy: true, + orgSlug, + outputKind, + reportLevel: 'error', + scanId: data.data.id, + short: false, + }) + } else { + await outputCreateNewScan( + { + ok: false, + message: 'Missing Scan ID', + cause: 'Server did not respond with a scan ID', + data: data.data, + }, + outputKind, + interactive, + ) + } + } else { + await outputCreateNewScan(data, outputKind, interactive) + } +} diff --git a/packages/cli/src/commands/scan/handle-delete-scan.mts b/src/commands/scan/handle-delete-scan.mts similarity index 77% rename from packages/cli/src/commands/scan/handle-delete-scan.mts rename to src/commands/scan/handle-delete-scan.mts index 00b34414a..d4d7c6af2 100644 --- a/packages/cli/src/commands/scan/handle-delete-scan.mts +++ b/src/commands/scan/handle-delete-scan.mts @@ -8,9 +8,7 @@ export async function handleDeleteScan( scanId: string, outputKind: OutputKind, ): Promise<void> { - const data = await fetchDeleteOrgFullScan(orgSlug, scanId, { - commandPath: 'socket scan del', - }) + const data = await fetchDeleteOrgFullScan(orgSlug, scanId) await outputDeleteScan(data, outputKind) } diff --git a/packages/cli/src/commands/scan/handle-diff-scan.mts b/src/commands/scan/handle-diff-scan.mts similarity index 100% rename from packages/cli/src/commands/scan/handle-diff-scan.mts rename to src/commands/scan/handle-diff-scan.mts diff --git a/src/commands/scan/handle-list-scans.mts b/src/commands/scan/handle-list-scans.mts new file mode 100644 index 000000000..e8194f690 --- /dev/null +++ b/src/commands/scan/handle-list-scans.mts @@ -0,0 +1,39 @@ +import { fetchListScans } from './fetch-list-scans.mts' +import { outputListScans } from './output-list-scans.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleListScans({ + branch, + direction, + from_time, + orgSlug, + outputKind, + page, + per_page, + repo, + sort, +}: { + branch: string + direction: string + from_time: string + orgSlug: string + outputKind: OutputKind + page: number + per_page: number + repo: string + sort: string +}): Promise<void> { + const data = await fetchListScans({ + branch, + direction, + from_time, + orgSlug, + page, + per_page, + repo, + sort, + }) + + await outputListScans(data, outputKind) +} diff --git a/src/commands/scan/handle-reach-scan.mts b/src/commands/scan/handle-reach-scan.mts new file mode 100644 index 000000000..faeb2273c --- /dev/null +++ b/src/commands/scan/handle-reach-scan.mts @@ -0,0 +1,36 @@ +import { outputScanReach } from './output-scan-reach.mts' +import constants from '../../constants.mts' +import { spawnCoana } from '../../utils/coana.mts' + +const { DOT_SOCKET_DOT_FACTS_JSON } = constants + +import type { OutputKind } from '../../types.mts' + +export async function handleScanReach( + argv: string[] | readonly string[], + cwd: string, + outputKind: OutputKind, +) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start('Running reachability scan...') + + const result = await spawnCoana( + [ + 'run', + cwd, + '--output-dir', + cwd, + '--socket-mode', + DOT_SOCKET_DOT_FACTS_JSON, + '--disable-report-submission', + ...argv, + ], + { cwd, spinner }, + ) + + spinner.stop() + + await outputScanReach(result, outputKind) +} diff --git a/packages/cli/src/commands/scan/handle-scan-config.mts b/src/commands/scan/handle-scan-config.mts similarity index 100% rename from packages/cli/src/commands/scan/handle-scan-config.mts rename to src/commands/scan/handle-scan-config.mts diff --git a/packages/cli/src/commands/scan/handle-scan-metadata.mts b/src/commands/scan/handle-scan-metadata.mts similarity index 77% rename from packages/cli/src/commands/scan/handle-scan-metadata.mts rename to src/commands/scan/handle-scan-metadata.mts index 1bf4023f5..fa1b10b57 100644 --- a/packages/cli/src/commands/scan/handle-scan-metadata.mts +++ b/src/commands/scan/handle-scan-metadata.mts @@ -8,9 +8,7 @@ export async function handleOrgScanMetadata( scanId: string, outputKind: OutputKind, ): Promise<void> { - const data = await fetchScanMetadata(orgSlug, scanId, { - commandPath: 'socket scan metadata', - }) + const data = await fetchScanMetadata(orgSlug, scanId) await outputScanMetadata(data, scanId, outputKind) } diff --git a/src/commands/scan/handle-scan-report.mts b/src/commands/scan/handle-scan-report.mts new file mode 100644 index 000000000..64cdda8e0 --- /dev/null +++ b/src/commands/scan/handle-scan-report.mts @@ -0,0 +1,37 @@ +import { fetchReportData } from './fetch-report-data.mts' +import { outputScanReport } from './output-scan-report.mts' + +import type { OutputKind } from '../../types.mts' + +export async function handleScanReport({ + filePath, + fold, + includeLicensePolicy, + orgSlug, + outputKind, + reportLevel, + scanId, + short, +}: { + orgSlug: string + scanId: string + includeLicensePolicy: boolean + outputKind: OutputKind + filePath: string + fold: 'pkg' | 'version' | 'file' | 'none' + reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + short: boolean +}): Promise<void> { + const result = await fetchReportData(orgSlug, scanId, includeLicensePolicy) + + await outputScanReport(result, { + filePath, + fold, + scanId: scanId, + includeLicensePolicy, + orgSlug, + outputKind, + reportLevel, + short, + }) +} diff --git a/packages/cli/src/commands/scan/handle-scan-view.mts b/src/commands/scan/handle-scan-view.mts similarity index 100% rename from packages/cli/src/commands/scan/handle-scan-view.mts rename to src/commands/scan/handle-scan-view.mts diff --git a/src/commands/scan/output-create-new-scan.mts b/src/commands/scan/output-create-new-scan.mts new file mode 100644 index 000000000..4a3ab3d7b --- /dev/null +++ b/src/commands/scan/output-create-new-scan.mts @@ -0,0 +1,65 @@ +import open from 'open' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { confirm } from '@socketsecurity/registry/lib/prompts' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputCreateNewScan( + result: CResult<SocketSdkReturnType<'CreateOrgFullScan'>['data']>, + outputKind: OutputKind, + interactive: boolean, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (!result.data.id) { + logger.fail('Did not receive a scan ID from the API...') + process.exitCode = 1 + } + + if (outputKind === 'markdown') { + logger.log('# Create New Scan') + logger.log('') + if (result.data.id) { + logger.log( + `A [new Scan](${result.data.html_report_url}) was created with ID: ${result.data.id}`, + ) + logger.log('') + } else { + logger.log( + `The server did not return a Scan ID while trying to create a new Scan. This could be an indication something went wrong.`, + ) + } + logger.log('') + return + } + + const link = colors.underline(colors.cyan(`${result.data.html_report_url}`)) + logger.log(`Available at: ${link}`) + + if ( + interactive && + (await confirm({ + message: 'Would you like to open it in your browser?', + default: false, + })) + ) { + await open(`${result.data.html_report_url}`) + } +} diff --git a/src/commands/scan/output-delete-scan.mts b/src/commands/scan/output-delete-scan.mts new file mode 100644 index 000000000..517c3b37b --- /dev/null +++ b/src/commands/scan/output-delete-scan.mts @@ -0,0 +1,27 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputDeleteScan( + result: CResult<SocketSdkReturnType<'deleteOrgFullScan'>['data']>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.success('Scan deleted successfully') +} diff --git a/src/commands/scan/output-diff-scan.mts b/src/commands/scan/output-diff-scan.mts new file mode 100644 index 000000000..54c2b0bfe --- /dev/null +++ b/src/commands/scan/output-diff-scan.mts @@ -0,0 +1,204 @@ +import fs from 'node:fs' +import util from 'node:util' + +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +const { SOCKET_WEBSITE_URL } = constants + +const SOCKET_SBOM_URL_PREFIX = `${SOCKET_WEBSITE_URL}/dashboard/org/SocketDev/sbom/` + +export async function outputDiffScan( + result: CResult<SocketSdkReturnType<'GetOrgDiffScan'>['data']>, + { + depth, + file, + outputKind, + }: { + depth: number + file: string + outputKind: OutputKind + }, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const dashboardUrl = result.data.diff_report_url + const dashboardMessage = dashboardUrl + ? `\n View this diff scan in the Socket dashboard: ${colors.cyan(dashboardUrl)}` + : '' + + // When forcing json, or dumping to file, serialize to string such that it + // won't get truncated. The only way to dump the full raw JSON to stdout is + // to use `--json --file -` (the dash is a standard notation for stdout) + if (outputKind === 'json' || file) { + await handleJson(result, file, dashboardMessage) + return + } + + if (outputKind === 'markdown') { + await handleMarkdown(result.data) + return + } + + // In this case neither the --json nor the --file flag was passed + // Dump the JSON to CLI and let NodeJS deal with truncation + + logger.log('Diff scan result:') + logger.log( + util.inspect(result.data, { + showHidden: false, + depth: depth > 0 ? depth : null, + colors: true, + maxArrayLength: null, + }), + ) + logger.info( + `\n 📝 To display the detailed report in the terminal, use the --json flag. For a friendlier report, use the --markdown flag.\n`, + ) + logger.info(dashboardMessage) +} + +async function handleJson( + data: CResult<SocketSdkReturnType<'GetOrgDiffScan'>['data']>, + file: string, + dashboardMessage: string, +) { + const json = serializeResultJson(data) + + if (file && file !== '-') { + logger.log(`Writing json to \`${file}\``) + fs.writeFile(file, json, err => { + if (err) { + logger.fail(`Writing to \`${file}\` failed...`) + logger.error(err) + } else { + logger.success(`Data successfully written to \`${file}\``) + } + logger.error(dashboardMessage) + }) + } else { + // only .log goes to stdout + logger.info(`\n Diff scan result: \n`) + logger.log(json) + logger.info(dashboardMessage) + } +} + +async function handleMarkdown( + data: SocketSdkReturnType<'GetOrgDiffScan'>['data'], +) { + logger.log('# Scan diff result') + logger.log('') + logger.log('This Socket.dev report shows the changes between two scans:') + logger.log( + `- [${data.before.id}](${SOCKET_SBOM_URL_PREFIX}${data.before.id})`, + ) + logger.log(`- [${data.after.id}](${SOCKET_SBOM_URL_PREFIX}${data.after.id})`) + logger.log('') + logger.log( + `You can [view this report in your dashboard](${data.diff_report_url})`, + ) + logger.log('') + logger.log('## Changes') + logger.log('') + logger.log(`- directDependenciesChanged: ${data.directDependenciesChanged}`) + logger.log(`- Added packages: ${data.artifacts.added.length}`) + if (data.artifacts.added.length > 0) { + data.artifacts.added.slice(0, 10).forEach(artifact => { + logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) + }) + if (data.artifacts.added.length > 10) { + logger.log(` ... and ${data.artifacts.added.length - 10} more`) + } + } + logger.log(`- Removed packages: ${data.artifacts.removed.length}`) + if (data.artifacts.removed.length > 0) { + data.artifacts.removed.slice(0, 10).forEach(artifact => { + logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) + }) + if (data.artifacts.removed.length > 10) { + logger.log(` ... and ${data.artifacts.removed.length - 10} more`) + } + } + logger.log(`- Replaced packages: ${data.artifacts.replaced.length}`) + if (data.artifacts.replaced.length > 0) { + data.artifacts.replaced.slice(0, 10).forEach(artifact => { + logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) + }) + if (data.artifacts.replaced.length > 10) { + logger.log(` ... and ${data.artifacts.replaced.length - 10} more`) + } + } + logger.log(`- Updated packages: ${data.artifacts.updated.length}`) + if (data.artifacts.updated.length > 0) { + data.artifacts.updated.slice(0, 10).forEach(artifact => { + logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) + }) + if (data.artifacts.updated.length > 10) { + logger.log(` ... and ${data.artifacts.updated.length - 10} more`) + } + } + logger.log(`- Unchanged packages: ${data.artifacts.unchanged.length}`) + if (data.artifacts.unchanged.length > 0) { + data.artifacts.unchanged.slice(0, 10).forEach(artifact => { + logger.log(` - ${artifact.type} ${artifact.name}@${artifact.version}`) + }) + if (data.artifacts.unchanged.length > 10) { + logger.log(` ... and ${data.artifacts.unchanged.length - 10} more`) + } + } + logger.log('') + logger.log(`## Scan ${data.before.id}`) + logger.log('') + logger.log( + 'This Scan was considered to be the "base" / "from" / "before" Scan.', + ) + logger.log('') + for (const [key, value] of Object.entries(data.before)) { + if (key === 'pull_request' && !value) { + continue + } + if (!['id', 'organization_id', 'repository_id'].includes(key)) { + logger.group( + `- ${key === 'repository_slug' ? 'repo' : key === 'organization_slug' ? 'org' : key}: ${value}`, + ) + logger.groupEnd() + } + } + logger.log('') + logger.log(`## Scan ${data.after.id}`) + logger.log('') + logger.log('This Scan was considered to be the "head" / "to" / "after" Scan.') + logger.log('') + for (const [key, value] of Object.entries(data.after)) { + if (key === 'pull_request' && !value) { + continue + } + if (!['id', 'organization_id', 'repository_id'].includes(key)) { + logger.group( + `- ${key === 'repository_slug' ? 'repo' : key === 'organization_slug' ? 'org' : key}: ${value}`, + ) + logger.groupEnd() + } + } + logger.log('') +} diff --git a/src/commands/scan/output-list-scans.mts b/src/commands/scan/output-list-scans.mts new file mode 100644 index 000000000..fc32e27c4 --- /dev/null +++ b/src/commands/scan/output-list-scans.mts @@ -0,0 +1,57 @@ +// @ts-ignore +import chalkTable from 'chalk-table' +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputListScans( + result: CResult<SocketSdkReturnType<'getOrgFullScanList'>['data']>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const options = { + columns: [ + { field: 'id', name: colors.magenta('ID') }, + { field: 'report_url', name: colors.magenta('Scan URL') }, + { field: 'repo', name: colors.magenta('Repo') }, + { field: 'branch', name: colors.magenta('Branch') }, + { field: 'created_at', name: colors.magenta('Created at') }, + ], + } + + const formattedResults = result.data.results.map(d => { + return { + id: d.id, + report_url: colors.underline(`${d.html_report_url}`), + created_at: d.created_at + ? new Date(d.created_at).toLocaleDateString('en-us', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }) + : '', + repo: d.repo, + branch: d.branch, + } + }) + + logger.log(chalkTable(options, formattedResults)) +} diff --git a/src/commands/scan/output-scan-config-result.mts b/src/commands/scan/output-scan-config-result.mts new file mode 100644 index 000000000..c60a21586 --- /dev/null +++ b/src/commands/scan/output-scan-config-result.mts @@ -0,0 +1,20 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' + +import type { CResult } from '../../types.mts' + +export async function outputScanConfigResult(result: CResult<unknown>) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log('') + logger.log('Finished') + logger.log('') +} diff --git a/src/commands/scan/output-scan-github.mts b/src/commands/scan/output-scan-github.mts new file mode 100644 index 000000000..2a16a583e --- /dev/null +++ b/src/commands/scan/output-scan-github.mts @@ -0,0 +1,24 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputScanGithub( + result: CResult<unknown>, + outputKind: OutputKind, +) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log('') + logger.success('Finished!') +} diff --git a/src/commands/scan/output-scan-metadata.mts b/src/commands/scan/output-scan-metadata.mts new file mode 100644 index 000000000..67df25d38 --- /dev/null +++ b/src/commands/scan/output-scan-metadata.mts @@ -0,0 +1,55 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputScanMetadata( + result: CResult<SocketSdkReturnType<'getOrgFullScanMetadata'>['data']>, + scanId: string, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (outputKind === 'markdown') { + logger.log('# Scan meta data\n') + } + logger.log(`Scan ID: ${scanId}\n`) + for (const [key, value] of Object.entries(result.data)) { + if ( + [ + 'id', + 'updated_at', + 'organization_id', + 'repository_id', + 'commit_hash', + 'html_report_url', + ].includes(key) + ) { + continue + } + logger.log(`- ${key}:`, value) + } + if (outputKind === 'markdown') { + logger.log( + `\nYou can view this report at: [${result.data.html_report_url}](${result.data.html_report_url})\n`, + ) + } else { + logger.log( + `\nYou can view this report at: ${result.data.html_report_url}]\n`, + ) + } +} diff --git a/src/commands/scan/output-scan-reach.mts b/src/commands/scan/output-scan-reach.mts new file mode 100644 index 000000000..806494154 --- /dev/null +++ b/src/commands/scan/output-scan-reach.mts @@ -0,0 +1,27 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' + +export async function outputScanReach( + result: CResult<unknown>, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + logger.log('') + logger.success('Finished!') +} diff --git a/src/commands/scan/output-scan-report.mts b/src/commands/scan/output-scan-report.mts new file mode 100644 index 000000000..a21ad699e --- /dev/null +++ b/src/commands/scan/output-scan-report.mts @@ -0,0 +1,215 @@ +import fs from 'node:fs/promises' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { generateReport } from './generate-report.mts' +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mapToObject } from '../../utils/map-to-object.mts' +import { mdTable } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' +import { walkNestedMap } from '../../utils/walk-nested-map.mts' + +import type { ReportLeafNode, ScanReport } from './generate-report.mts' +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function outputScanReport( + result: CResult<{ + scan: SocketArtifact[] + securityPolicy: SocketSdkReturnType<'getOrgSecurityPolicy'>['data'] + }>, + { + filePath, + fold, + includeLicensePolicy, + orgSlug, + outputKind, + reportLevel, + scanId, + short, + }: { + orgSlug: string + scanId: string + includeLicensePolicy: boolean + outputKind: OutputKind + filePath: string + fold: 'pkg' | 'version' | 'file' | 'none' + reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + short: boolean + }, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + const scanReport = generateReport( + result.data.scan, + result.data.securityPolicy, + { + orgSlug, + scanId, + fold, + reportLevel, + short, + // Lazily access constants.spinner. + spinner: constants.spinner, + }, + ) + + if (!scanReport.ok) { + // Note: this means generation failed, it does not reflect the healthy state + process.exitCode = scanReport.code ?? 1 + + // If report generation somehow failed then .data should not be set. + if (outputKind === 'json') { + logger.log(serializeResultJson(scanReport)) + return + } + logger.fail(failMsgWithBadge(scanReport.message, scanReport.cause)) + return + } + + // I don't think we emit the default error message with banner for an unhealhty report, do we? + // if (!scanReport.data.healhty) { + // logger.fail(failMsgWithBadge(scanReport.message, scanReport.cause)) + // return + // } + + if ( + outputKind === 'json' || + (outputKind === 'text' && filePath && filePath.endsWith('.json')) + ) { + const json = short + ? serializeResultJson(scanReport) + : toJsonReport(scanReport.data as ScanReport, includeLicensePolicy) + + if (filePath && filePath !== '-') { + logger.log('Writing json report to', filePath) + return await fs.writeFile(filePath, json) + } + + logger.log(json) + return + } + + if (outputKind === 'markdown' || (filePath && filePath.endsWith('.md'))) { + const md = short + ? `healthy = ${scanReport.data.healthy}` + : toMarkdownReport( + scanReport.data as ScanReport, // not short so must be regular report + includeLicensePolicy, + ) + + if (filePath && filePath !== '-') { + logger.log('Writing markdown report to', filePath) + return await fs.writeFile(filePath, md) + } + + logger.log(md) + logger.log('') + return + } + + if (short) { + logger.log(scanReport.data.healthy ? 'OK' : 'ERR') + } else { + logger.dir(scanReport.data, { depth: null }) + } +} + +export function toJsonReport( + report: ScanReport, + includeLicensePolicy?: boolean | undefined, +): string { + const obj = mapToObject(report.alerts) + + const newReport = { + includeLicensePolicy, + ...report, + alerts: obj, + } + + return serializeResultJson({ + ok: true, + data: newReport, + }) +} + +export function toMarkdownReport( + report: ScanReport, + includeLicensePolicy?: boolean | undefined, +): string { + const flatData = Array.from(walkNestedMap(report.alerts)).map( + ({ keys, value }: { keys: string[]; value: ReportLeafNode }) => { + const { manifest, policy, type, url } = value + return { + 'Alert Type': type, + Package: keys[1] || '<unknown>', + 'Introduced by': keys[2] || '<unknown>', + url, + 'Manifest file': manifest.join(', '), + Policy: policy, + } + }, + ) + + const md = + ` +# Scan Policy Report + +This report tells you whether the results of a Socket scan results violate the +security${includeLicensePolicy ? ' or license' : ''} policy set by your organization. + +## Health status + +${ + report.healthy + ? `The scan *PASSES* all requirements set by your security${includeLicensePolicy ? ' and license' : ''} policy.` + : 'The scan *VIOLATES* one or more policies set to the "error" level.' +} + +## Settings + +Configuration used to generate this report: + +- Organization: ${report.orgSlug} +- Scan ID: ${report.scanId} +- Alert folding: ${report.options.fold === 'none' ? 'none' : `up to ${report.options.fold}`} +- Minimal policy level for alert to be included in report: ${report.options.reportLevel === 'defer' ? 'everything' : report.options.reportLevel} +- Include license alerts: ${includeLicensePolicy ? 'yes' : 'no'} + +## Alerts + +${ + report.alerts.size + ? `All the alerts from the scan with a policy set to at least "${report.options.reportLevel}".` + : `The scan contained no alerts with a policy set to at least "${report.options.reportLevel}".` +} + +${ + !report.alerts.size + ? '' + : mdTable(flatData, [ + 'Policy', + 'Alert Type', + 'Package', + 'Introduced by', + 'url', + 'Manifest file', + ]) +} + `.trim() + '\n' + + return md +} diff --git a/src/commands/scan/output-scan-report.test.mts b/src/commands/scan/output-scan-report.test.mts new file mode 100644 index 000000000..207ad4e78 --- /dev/null +++ b/src/commands/scan/output-scan-report.test.mts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' + +import { toJsonReport, toMarkdownReport } from './output-scan-report.mts' + +import type { ScanReport } from './generate-report.mts' + +describe('output-scan-report', () => { + describe('toJsonReport', () => { + it('should be able to generate a healthy json report', () => { + expect(toJsonReport(getHealthyReport())).toMatchInlineSnapshot(` + "{ + "ok": true, + "data": { + "alerts": {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn" + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee" + } + } + " + `) + }) + + it('should be able to generate an unhealthy json report', () => { + expect(toJsonReport(getUnhealthyReport())).toMatchInlineSnapshot(` + "{ + "ok": true, + "data": { + "alerts": { + "npm": { + "tslib": { + "1.14.1": { + "package/which.js": { + "envVars at 54:72": { + "manifest": [ + "package-lock.json" + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1" + }, + "envVars at 200:250": { + "manifest": [ + "package-lock.json" + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1" + } + } + } + } + } + }, + "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn" + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee" + } + } + " + `) + }) + }) + + describe('toJsonReport', () => { + it('should be able to generate a healthy md report', () => { + expect(toMarkdownReport(getHealthyReport())).toMatchInlineSnapshot(` + "# Scan Policy Report + + This report tells you whether the results of a Socket scan results violate the + security policy set by your organization. + + ## Health status + + The scan *PASSES* all requirements set by your security policy. + + ## Settings + + Configuration used to generate this report: + + - Organization: fakeorg + - Scan ID: scan-ai-dee + - Alert folding: none + - Minimal policy level for alert to be included in report: warn + - Include license alerts: no + + ## Alerts + + The scan contained no alerts with a policy set to at least "warn". + " + `) + }) + + it('should be able to generate an unhealthy md report', () => { + expect(toMarkdownReport(getUnhealthyReport())).toMatchInlineSnapshot(` + "# Scan Policy Report + + This report tells you whether the results of a Socket scan results violate the + security policy set by your organization. + + ## Health status + + The scan *VIOLATES* one or more policies set to the "error" level. + + ## Settings + + Configuration used to generate this report: + + - Organization: fakeorg + - Scan ID: scan-ai-dee + - Alert folding: none + - Minimal policy level for alert to be included in report: warn + - Include license alerts: no + + ## Alerts + + All the alerts from the scan with a policy set to at least "warn". + + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + | Policy | Alert Type | Package | Introduced by | url | Manifest file | + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + " + `) + }) + }) +}) + +function getHealthyReport(): ScanReport { + return { + alerts: new Map(), + healthy: true, + options: { + fold: 'none', + reportLevel: 'warn', + }, + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + } +} + +function getUnhealthyReport(): ScanReport { + return { + alerts: new Map([ + [ + 'npm', + new Map([ + [ + 'tslib', + new Map([ + [ + '1.14.1', + new Map([ + [ + 'package/which.js', + new Map([ + [ + 'envVars at 54:72', + { + manifest: ['package-lock.json'], + policy: 'error' as const, + type: 'envVars', + url: 'https://socket.dev/npm/package/tslib/1.14.1', + }, + ], + [ + 'envVars at 200:250', + { + manifest: ['package-lock.json'], + policy: 'error' as const, + type: 'envVars', + url: 'https://socket.dev/npm/package/tslib/1.14.1', + }, + ], + ]), + ], + ]), + ], + ]), + ], + ]), + ], + ]), + healthy: false, + options: { + fold: 'none', + reportLevel: 'warn', + }, + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', + } +} diff --git a/src/commands/scan/output-scan-view.mts b/src/commands/scan/output-scan-view.mts new file mode 100644 index 000000000..47f04fc1a --- /dev/null +++ b/src/commands/scan/output-scan-view.mts @@ -0,0 +1,111 @@ +import fs from 'node:fs/promises' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { mdTable } from '../../utils/markdown.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { CResult, OutputKind } from '../../types.mts' +import type { SocketArtifact } from '../../utils/alert/artifact.mts' + +const { SOCKET_WEBSITE_URL } = constants + +export async function outputScanView( + result: CResult<SocketArtifact[]>, + orgSlug: string, + scanId: string, + filePath: string, + outputKind: OutputKind, +): Promise<void> { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (!result.ok) { + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if ( + outputKind === 'json' || + (outputKind === 'text' && filePath && filePath.endsWith('.json')) + ) { + const json = serializeResultJson(result) + + if (filePath && filePath !== '-') { + logger.info('Writing json results to', filePath) + try { + await fs.writeFile(filePath, json, 'utf8') + logger.info(`Data successfully written to ${filePath}`) + } catch (e) { + process.exitCode = 1 + logger.fail('There was an error trying to write the markdown to disk') + logger.error(e) + logger.log( + serializeResultJson({ + ok: false, + message: 'File Write Failure', + cause: 'Failed to write json to disk', + }), + ) + } + return + } + + logger.log(json) + return + } + + const display = result.data.map(art => { + const author = Array.isArray(art.author) + ? `${art.author[0]}${art.author.length > 1 ? ' et.al.' : ''}` + : art.author + return { + type: art.type, + name: art.name, + version: art.version, + author, + score: JSON.stringify(art.score), + } + }) + + const md = mdTable<any>(display, [ + 'type', + 'version', + 'name', + 'author', + 'score', + ]) + + const report = + ` +# Scan Details + +These are the artifacts and their scores found. + +Scan ID: ${scanId} + +${md} + +View this report at: ${SOCKET_WEBSITE_URL}/dashboard/org/${orgSlug}/sbom/${scanId} + `.trim() + '\n' + + if (filePath && filePath !== '-') { + try { + await fs.writeFile(filePath, report, 'utf8') + logger.log(`Data successfully written to ${filePath}`) + } catch (e) { + process.exitCode = 1 + logger.fail('There was an error trying to write the markdown to disk') + logger.error(e) + } + } else { + logger.log(report) + } +} diff --git a/src/commands/scan/setup-scan-config.mts b/src/commands/scan/setup-scan-config.mts new file mode 100644 index 000000000..13e46d6fd --- /dev/null +++ b/src/commands/scan/setup-scan-config.mts @@ -0,0 +1,348 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { input, select } from '@socketsecurity/registry/lib/prompts' + +import { + type SocketJson, + readSocketJson, + writeSocketJson, +} from '../../utils/socketjson.mts' + +import type { CResult } from '../../types.mts' + +export async function setupScanConfig( + cwd: string, + defaultOnReadError = false, +): Promise<CResult<unknown>> { + const jsonPath = path.join(cwd, `socket.json`) + if (fs.existsSync(jsonPath)) { + logger.info(`Found socket.json at ${jsonPath}`) + } else { + logger.info(`No socket.json found at ${cwd}, will generate a new one`) + } + + logger.log('') + logger.log( + 'Note: This tool will set up flag and argument defaults for certain', + ) + logger.log(' CLI commands. You can still override them by explicitly') + logger.log(' setting the flag. It is meant to be a convenience tool.') + logger.log('') + logger.log( + 'This command will generate a `socket.json` file in the target cwd.', + ) + logger.log('You can choose to add this file to your repo (handy for collab)') + logger.log('or to add it to the ignored files, or neither. This file is only') + logger.log('used in CLI workflows.') + logger.log('') + logger.log('Note: For details on a flag you can run `socket <cmd> --help`') + logger.log('') + + const socketJsonResult = await readSocketJson(cwd, defaultOnReadError) + if (!socketJsonResult.ok) { + return socketJsonResult + } + + const socketJson = socketJsonResult.data + if (!socketJson.defaults) { + socketJson.defaults = {} + } + if (!socketJson.defaults.scan) { + socketJson.defaults.scan = {} + } + + const targetCommand = await select({ + message: 'Which scan command do you want to configure?', + choices: [ + { + name: 'socket scan create', + value: 'create', + }, + { + name: 'socket scan github', + value: 'github', + }, + { + name: '(cancel)', + value: '', + description: 'Exit configurator, make no changes', + }, + ], + }) + switch (targetCommand) { + case 'create': { + if (!socketJson.defaults.scan.create) { + socketJson.defaults.scan.create = {} + } + const result = await configureScan(socketJson.defaults.scan.create) + if (!result.ok || result.data.canceled) { + return result + } + break + } + case 'github': { + if (!socketJson.defaults.scan.github) { + socketJson.defaults.scan.github = {} + } + const result = await configureGithub(socketJson.defaults.scan.github) + if (!result.ok || result.data.canceled) { + return result + } + break + } + default: { + return canceledByUser() + } + } + + logger.log('') + logger.log('Setup complete. Writing socket.json') + logger.log('') + + if ( + await select({ + message: `Do you want to write the new config to ${jsonPath} ?`, + choices: [ + { + name: 'yes', + value: true, + description: 'Update config', + }, + { + name: 'no', + value: false, + description: 'Do not update the config', + }, + ], + }) + ) { + return await writeSocketJson(cwd, socketJson) + } + + return canceledByUser() +} + +async function configureScan( + config: NonNullable< + NonNullable<NonNullable<SocketJson['defaults']>['scan']>['create'] + >, +): Promise<CResult<{ canceled: boolean }>> { + const defaultRepoName = await input({ + message: + '(--repo) What repo name (slug) should be reported to Socket for this dir?', + default: config.repo || 'socket-default-repository', + required: false, + // validate: async string => bool + }) + if (defaultRepoName === undefined) { + return canceledByUser() + } + if (defaultRepoName.trim()) { + // Even if it's 'socket-default-repository' store it because if we change + // this default then an existing user probably would not expect the change? + config.repo = defaultRepoName.trim() + } else { + delete config.repo + } + + const defaultBranchName = await input({ + message: + '(--branch) What branch name (slug) should be reported to Socket for this dir?', + default: config.branch || 'socket-default-branch', + required: false, + // validate: async string => bool + }) + if (defaultBranchName === undefined) { + return canceledByUser() + } + if (defaultBranchName.trim()) { + // Even if it's 'socket-default-branch' store it because if we change + // this default then an existing user probably would not expect the change? + config.branch = defaultBranchName.trim() + } else { + delete config.branch + } + + const autoManifest = await select({ + message: + '(--autoManifest) Do you want to run `socket manifest auto` before creating a scan? You would need this for sbt, gradle, etc.', + choices: [ + { + name: 'no', + value: 'no', + description: 'Do not generate local manifest files', + }, + { + name: 'yes', + value: 'yes', + description: + 'Locally generate manifest files for languages like gradle, sbt, and conda (see `socket manifest auto`), before creating a scan', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: + config.autoManifest === true + ? 'yes' + : config.autoManifest === false + ? 'no' + : '', + }) + if (autoManifest === undefined) { + return canceledByUser() + } + if (autoManifest === 'yes') { + config.autoManifest = true + } else if (autoManifest === 'no') { + config.autoManifest = false + } else { + delete config.autoManifest + } + + const alwaysReport = await select({ + message: '(--report) Do you want to enable --report by default?', + choices: [ + { + name: 'no', + value: 'no', + description: 'Do not wait for Scan result and report by default', + }, + { + name: 'yes', + value: 'yes', + description: + 'After submitting a Scan request, wait for scan to complete, then show a report (like --report would)', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: + config.report === true ? 'yes' : config.report === false ? 'no' : '', + }) + if (alwaysReport === undefined) { + return canceledByUser() + } + if (alwaysReport === 'yes') { + config.report = true + } else if (alwaysReport === 'no') { + config.report = false + } else { + delete config.report + } + + return notCanceled() +} + +async function configureGithub( + config: NonNullable< + NonNullable<NonNullable<SocketJson['defaults']>['scan']>['github'] + >, +): Promise<CResult<{ canceled: boolean }>> { + // Do not store the github API token. Just leads to a security rabbit hole. + + const all = await select({ + message: + '(--all) Do you by default want to fetch all repos from the GitHub API and scan all known repos?', + choices: [ + { + name: 'no', + value: 'no', + description: 'Fetch repos if not given and ask which repo to run on', + }, + { + name: 'yes', + value: 'yes', + description: 'Run on all remote repos by default', + }, + { + name: '(leave default)', + value: '', + description: 'Do not store a setting for this', + }, + ], + default: config.all === true ? 'yes' : config.all === false ? 'no' : '', + }) + if (all === undefined) { + return canceledByUser() + } + if (all === 'yes') { + config.all = true + } else if (all === 'no') { + config.all = false + } else { + delete config.all + } + + if (!all) { + const defaultRepos = await input({ + message: + '(--repos) Please enter the default repos to run this on, leave empty (backspace) to fetch from GitHub and ask interactive', + default: config.repos, + required: false, + // validate: async string => bool + }) + if (defaultRepos === undefined) { + return canceledByUser() + } + if (defaultRepos.trim()) { + config.repos = defaultRepos.trim() + } else { + delete config.repos + } + } + + const defaultGithubApiUrl = await input({ + message: '(--githubApiUrl) Do you want to override the default github url?', + default: config.githubApiUrl || 'https://api.github.com', + required: false, + // validate: async string => bool + }) + if (defaultGithubApiUrl === undefined) { + return canceledByUser() + } + if ( + defaultGithubApiUrl.trim() && + defaultGithubApiUrl.trim() !== 'https://api.github.com' + ) { + config.githubApiUrl = defaultGithubApiUrl.trim() + } else { + delete config.githubApiUrl + } + + const defaultOrgGithub = await input({ + message: + '(--orgGithub) Do you want to change the org slug that is used when talking to the GitHub API? Defaults to your Socket org slug.', + default: config.orgGithub || '', + required: false, + // validate: async string => bool + }) + if (defaultOrgGithub === undefined) { + return canceledByUser() + } + if (defaultOrgGithub.trim()) { + config.orgGithub = defaultOrgGithub.trim() + } else { + delete config.orgGithub + } + + return notCanceled() +} + +function canceledByUser(): CResult<{ canceled: boolean }> { + logger.log('') + logger.info('User canceled') + logger.log('') + return { ok: true, data: { canceled: true } } +} + +function notCanceled(): CResult<{ canceled: boolean }> { + return { ok: true, data: { canceled: false } } +} diff --git a/src/commands/scan/stream-scan.mts b/src/commands/scan/stream-scan.mts new file mode 100644 index 000000000..741ab6426 --- /dev/null +++ b/src/commands/scan/stream-scan.mts @@ -0,0 +1,24 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +export async function streamScan( + orgSlug: string, + scanId: string, + file: string | undefined, +) { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return sockSdkResult + } + const sockSdk = sockSdkResult.data + + logger.info('Requesting data from API...') + + // Note: this will write to stdout or target file. It's not a noop + return await handleApiCall( + sockSdk.getOrgFullScan(orgSlug, scanId, file === '-' ? undefined : file), + 'a scan', + ) +} diff --git a/src/commands/scan/suggest-org-slug.mts b/src/commands/scan/suggest-org-slug.mts new file mode 100644 index 000000000..5ea850b1a --- /dev/null +++ b/src/commands/scan/suggest-org-slug.mts @@ -0,0 +1,53 @@ +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +export async function suggestOrgSlug(): Promise<string | void> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return + } + const sockSdk = sockSdkResult.data + + const result = await handleApiCall( + sockSdk.getOrganizations(), + 'list of organizations', + ) + + // Ignore a failed request here. It was not the primary goal of + // running this command and reporting it only leads to end-user confusion. + if (result.ok) { + const proceed = await select<string>({ + message: + 'Missing org name; do you want to use any of these orgs for this scan?', + choices: [ + ...Object.values(result.data.organizations).map(org => { + const name = org.name ?? org.slug + return { + name: `Yes [${name}]`, + value: name, + description: `Use "${name}" as the organization`, + } + }), + { + name: 'No', + value: '', + description: + 'Do not use any of these organizations (will end in a no-op)', + }, + ], + }) + if (proceed === undefined) { + return undefined + } + if (proceed) { + return proceed + } + } else { + logger.fail( + 'Failed to lookup organization list from API, unable to suggest', + ) + } +} diff --git a/src/commands/scan/suggest-repo-slug.mts b/src/commands/scan/suggest-repo-slug.mts new file mode 100644 index 000000000..34da4cc31 --- /dev/null +++ b/src/commands/scan/suggest-repo-slug.mts @@ -0,0 +1,113 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { handleApiCall } from '../../utils/api.mts' +import { setupSdk } from '../../utils/sdk.mts' + +export async function suggestRepoSlug(orgSlug: string): Promise<{ + slug: string + defaultBranch: string +} | void> { + const sockSdkResult = await setupSdk() + if (!sockSdkResult.ok) { + return + } + const sockSdk = sockSdkResult.data + + // If there's a repo with the same name as cwd then + // default the selection to that name. + const result = await handleApiCall( + sockSdk.getOrgRepoList(orgSlug, { + orgSlug, + sort: 'name', + direction: 'asc', + // There's no guarantee that the cwd is part of this page. If it's not + // then do an additional request and specific search for it instead. + // This way we can offer the tip of "do you want to create [cwd]?". + perPage: '10', + page: '0', + }), + 'list of repositories', + ) + + // Ignore a failed request here. It was not the primary goal of + // running this command and reporting it only leads to end-user confusion. + if (result.ok) { + const currentDirName = dirNameToSlug(path.basename(process.cwd())) + + let cwdIsKnown = + !!currentDirName && + result.data.results.some(obj => obj.slug === currentDirName) + if (!cwdIsKnown && currentDirName) { + // Do an explicit request so we can assert that the cwd exists or not + const result = await handleApiCall( + sockSdk.getOrgRepo(orgSlug, currentDirName), + 'check if current cwd is a known repo', + ) + if (result.ok) { + cwdIsKnown = true + } + } + + const proceed = await select<string>({ + message: + 'Missing repo name; do you want to use any of these known repo names for this scan?', + choices: + // Put the CWD suggestion at the top, whether it exists or not + (currentDirName + ? [ + { + name: `Yes, current dir [${cwdIsKnown ? currentDirName : `create repo for ${currentDirName}`}]`, + value: currentDirName, + description: cwdIsKnown + ? 'Register a new repo name under the given org and use it' + : 'Use current dir as repo', + }, + ] + : [] + ).concat( + result.data.results + .filter(({ slug }) => !!slug && slug !== currentDirName) + .map(({ slug }) => ({ + name: 'Yes [' + slug + ']', + value: slug || '', // Filtered above but TS is like nah. + description: `Use "${slug}" as the repo name`, + })), + { + name: 'No', + value: '', + description: 'Do not use any of these repos (will end in a no-op)', + }, + ), + }) + + if (proceed) { + const repoName = proceed + let repoDefaultBranch = '' + // Store the default branch to help with the branch name question next + for (const obj of result.data.results) { + if (obj.slug === proceed && obj.default_branch) { + repoDefaultBranch = obj.default_branch + break + } + } + return { slug: repoName, defaultBranch: repoDefaultBranch } + } + } else { + logger.fail('Failed to lookup repo list from API, unable to suggest.') + } +} + +function dirNameToSlug(name: string): string { + // Uses slug specs asserted by our servers + // Note: this can lead to collisions; eg. slug for `x--y` and `x---y` is `x-y` + return name + .toLowerCase() + .replace(/[^[a-zA-Z0-9_.-]/g, '_') + .replace(/--+/g, '-') + .replace(/__+/g, '_') + .replace(/\.\.+/g, '.') + .replace(/[._-]+$/, '') +} diff --git a/packages/cli/src/commands/scan/suggest-to-persist-orgslug.mts b/src/commands/scan/suggest-to-persist-orgslug.mts similarity index 84% rename from packages/cli/src/commands/scan/suggest-to-persist-orgslug.mts rename to src/commands/scan/suggest-to-persist-orgslug.mts index 3d015cd02..d4235cc3c 100644 --- a/packages/cli/src/commands/scan/suggest-to-persist-orgslug.mts +++ b/src/commands/scan/suggest-to-persist-orgslug.mts @@ -1,8 +1,7 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' -import { select } from '@socketsecurity/lib-stable/stdio/prompts' +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' -import { getConfigValue, updateConfigValue } from '../../util/config.mts' -const logger = getDefaultLogger() +import { getConfigValue, updateConfigValue } from '../../utils/config.mts' export async function suggestToPersistOrgSlug(orgSlug: string): Promise<void> { const skipAsk = getConfigValue('skipAskToPersistDefaultOrg') @@ -11,7 +10,7 @@ export async function suggestToPersistOrgSlug(orgSlug: string): Promise<void> { return } - const result = await select({ + const result = await select<string>({ message: `Would you like to use this org (${orgSlug}) as the default org for future calls?`, choices: [ { diff --git a/src/commands/scan/suggest_branch_slug.mts b/src/commands/scan/suggest_branch_slug.mts new file mode 100644 index 000000000..72ed1f485 --- /dev/null +++ b/src/commands/scan/suggest_branch_slug.mts @@ -0,0 +1,40 @@ +import { select } from '@socketsecurity/registry/lib/prompts' +import { spawnSync } from '@socketsecurity/registry/lib/spawn' + +export async function suggestBranchSlug( + repoDefaultBranch: string | undefined, +): Promise<string | void> { + const spawnResult = spawnSync('git', ['branch', '--show-current']) + const currentBranch = spawnResult.stdout.toString('utf8').trim() + if (currentBranch && spawnResult.status === 0) { + const proceed = await select<string>({ + message: 'Use the current git branch as target branch name?', + choices: [ + { + name: `Yes [${currentBranch}]`, + value: currentBranch, + description: 'Use the current git branch for branch name', + }, + ...(repoDefaultBranch && repoDefaultBranch !== currentBranch + ? [ + { + name: `No, use the default branch [${repoDefaultBranch}]`, + value: repoDefaultBranch, + description: + 'Use the default branch for target repo as the target branch name', + }, + ] + : []), + { + name: 'No', + value: '', + description: + 'Do not use the current git branch as name (will end in a no-op)', + }, + ].filter(Boolean), + }) + if (proceed) { + return proceed + } + } +} diff --git a/src/commands/scan/suggest_target.mts b/src/commands/scan/suggest_target.mts new file mode 100644 index 000000000..4dd431839 --- /dev/null +++ b/src/commands/scan/suggest_target.mts @@ -0,0 +1,25 @@ +import { select } from '@socketsecurity/registry/lib/prompts' + +export async function suggestTarget(): Promise<string[] | void> { + // We could prefill this with sub-dirs of the current + // dir ... but is that going to be useful? + const proceed = await select<boolean>({ + message: 'No TARGET given. Do you want to use the current directory?', + choices: [ + { + name: 'Yes', + value: true, + description: 'Target the current directory', + }, + { + name: 'No', + value: false, + description: + 'Do not use the current directory (this will end in a no-op)', + }, + ], + }) + if (proceed) { + return ['.'] + } +} diff --git a/src/commands/threat-feed/cmd-threat-feed.mts b/src/commands/threat-feed/cmd-threat-feed.mts new file mode 100644 index 000000000..143a9a3b4 --- /dev/null +++ b/src/commands/threat-feed/cmd-threat-feed.mts @@ -0,0 +1,272 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleThreatFeed } from './handle-threat-feed.mts' +import constants from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { determineOrgSlug } from '../../utils/determine-org-slug.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { hasDefaultToken } from '../../utils/sdk.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const ECOSYSTEMS = new Set(['gem', 'golang', 'maven', 'npm', 'nuget', 'pypi']) +const TYPE_FILTERS = new Set([ + 'anom', + 'c', + 'fp', + 'joke', + 'mal', + 'secret', + 'spy', + 'tp', + 'typo', + 'u', + 'vuln', +]) + +const config: CliCommandConfig = { + commandName: 'threat-feed', + description: '[beta] View the threat feed', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + direction: { + type: 'string', + default: 'desc', + description: 'Order asc or desc by the createdAt attribute', + }, + eco: { + type: 'string', + default: '', + description: 'Only show threats for a particular ecosystem', + }, + filter: { + type: 'string', + default: 'mal', + description: 'Filter what type of threats to return', + }, + interactive: { + type: 'boolean', + default: true, + description: + 'Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no.', + }, + org: { + type: 'string', + description: + 'Force override the organization slug, overrides the default org from config', + }, + page: { + type: 'string', + default: '1', + description: 'Page token', + }, + perPage: { + type: 'number', + shortFlag: 'pp', + default: 30, + description: 'Number of items per page', + }, + pkg: { + type: 'string', + default: '', + description: 'Filter by this package name', + }, + version: { + type: 'string', + default: '', + description: 'Filter by this package version', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [ECOSYSTEM] [TYPE_FILTER] + + API Token Requirements + - Quota: 1 unit + - Permissions: threat-feed:list + - Special access + + This feature requires a Threat Feed license. Please contact + sales@socket.dev if you are interested in purchasing this access. + + Options + ${getFlagListOutput(config.flags, 6)} + + Valid ecosystems: + + - gem + - golang + - maven + - npm + - nuget + - pypi + + Valid type filters: + + - anom Anomaly + - c Do not filter + - fp False Positives + - joke Joke / Fake + - mal Malware and Possible Malware [default] + - secret Secrets + - spy Telemetry + - tp False Positives and Unreviewed + - typo Typo-squat + - u Unreviewed + - vuln Vulnerability + + Note: if you filter by package name or version, it will do so for anything + unless you also filter by that ecosystem and/or package name. When in + doubt, look at the threat-feed and see the names in the name/version + column. That's what you want to search for. + + You can put filters as args instead, we'll try to match the strings with the + correct filter type but since this would not allow you to search for a package + called "mal", you can also specify the filters through flags. + + First arg that matches a typo, eco, or version enum is used as such. First arg + that matches none of them becomes the package name filter. Rest is ignored. + + Note: The version filter is a prefix search, pkg name is a substring search. + + Examples + $ ${command} + $ ${command} maven --json + $ ${command} typo + $ ${command} npm joke 1.0.0 --perPage=5 --page=2 --direction=asc + `, +} + +export const cmdThreatFeed = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + eco, + interactive, + json, + markdown, + org: orgFlag, + pkg, + type: typef, + version, + } = cli.flags + const outputKind = getOutputKind(json, markdown) + + const argSet = new Set(cli.input) + let ecoFilter = String(eco || '') + let versionFilter = String(version || '') + let typeFilter = String(typef || '') + let nameFilter = String(pkg || '') + cli.input.some(str => { + if (ECOSYSTEMS.has(str)) { + ecoFilter = str + argSet.delete(str) + return true + } + }) + cli.input.some(str => { + if (/^v?\d+\.\d+\.\d+$/.test(str)) { + versionFilter = str + argSet.delete(str) + return true + } + }) + cli.input.some(str => { + if (TYPE_FILTERS.has(str)) { + typeFilter = str + argSet.delete(str) + return true + } + }) + const haves = new Set([ecoFilter, versionFilter, typeFilter]) + cli.input.some(str => { + if (!haves.has(str)) { + nameFilter = str + argSet.delete(str) + return true + } + }) + + if (argSet.size) { + logger.info( + `Warning: ignoring these excessive args: ${Array.from(argSet).join(', ')}`, + ) + } + + const [orgSlug] = await determineOrgSlug( + String(orgFlag || ''), + !!interactive, + !!dryRun, + ) + + const hasApiToken = hasDefaultToken() + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: !!orgSlug, + message: 'Org name by default setting, --org, or auto-discovered', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: !json || !markdown, + message: 'The json and markdown flags cannot be both set, pick one', + pass: 'ok', + fail: 'omit one', + }, + { + nook: true, + test: hasApiToken, + message: + 'You need to be logged in to use this command. See `socket login`.', + pass: 'ok', + fail: 'missing API token', + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleThreatFeed({ + direction: String(cli.flags['direction'] || 'desc'), + ecosystem: ecoFilter, + filter: typeFilter, + outputKind, + orgSlug, + page: String(cli.flags['page'] || '1'), + perPage: Number(cli.flags['perPage']) || 30, + pkg: nameFilter, + version: versionFilter, + }) +} diff --git a/src/commands/threat-feed/cmd-threat-feed.test.mts b/src/commands/threat-feed/cmd-threat-feed.test.mts new file mode 100644 index 000000000..1e21ff36f --- /dev/null +++ b/src/commands/threat-feed/cmd-threat-feed.test.mts @@ -0,0 +1,230 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket threat-feed', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['threat-feed', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "[beta] View the threat feed + + Usage + $ socket threat-feed [options] [ECOSYSTEM] [TYPE_FILTER] + + API Token Requirements + - Quota: 1 unit + - Permissions: threat-feed:list + - Special access + + This feature requires a Threat Feed license. Please contact + sales@socket.dev if you are interested in purchasing this access. + + Options + --direction Order asc or desc by the createdAt attribute + --eco Only show threats for a particular ecosystem + --filter Filter what type of threats to return + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + --json Output result as json + --markdown Output result as markdown + --org Force override the organization slug, overrides the default org from config + --page Page token + --perPage Number of items per page + --pkg Filter by this package name + --version Filter by this package version + + Valid ecosystems: + + - gem + - golang + - maven + - npm + - nuget + - pypi + + Valid type filters: + + - anom Anomaly + - c Do not filter + - fp False Positives + - joke Joke / Fake + - mal Malware and Possible Malware [default] + - secret Secrets + - spy Telemetry + - tp False Positives and Unreviewed + - typo Typo-squat + - u Unreviewed + - vuln Vulnerability + + Note: if you filter by package name or version, it will do so for anything + unless you also filter by that ecosystem and/or package name. When in + doubt, look at the threat-feed and see the names in the name/version + column. That's what you want to search for. + + You can put filters as args instead, we'll try to match the strings with the + correct filter type but since this would not allow you to search for a package + called "mal", you can also specify the filters through flags. + + First arg that matches a typo, eco, or version enum is used as such. First arg + that matches none of them becomes the package name filter. Rest is ignored. + + Note: The version filter is a prefix search, pkg name is a substring search. + + Examples + $ socket threat-feed + $ socket threat-feed maven --json + $ socket threat-feed typo + $ socket threat-feed npm joke 1.0.0 --perPage=5 --page=2 --direction=asc" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket threat-feed`', + ) + }, + ) + + cmdit( + ['threat-feed', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + + - You need to be logged in to use this command. See \`socket login\`. (\\x1b[31mmissing API token\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'threat-feed', + '--org', + 'boo', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: boo + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + ['threat-feed', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should report missing org name', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted> + + \\x1b[33m\\u203c\\x1b[39m Unable to determine the target org. Trying to auto-discover it now... + \\x1b[34mi\\x1b[39m Note: you can run \`socket login\` to set a default org. You can also override it with the --org flag. + + \\x1b[31m\\xd7\\x1b[39m Skipping auto-discovery of org in dry-run mode + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Org name by default setting, --org, or auto-discovered (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + [ + 'threat-feed', + '--dry-run', + '--config', + '{"apiToken":"anything", "defaultOrg": "fakeorg"}', + ], + 'should accept default org', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) + + cmdit( + [ + 'threat-feed', + '--org', + 'forcedorg', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should accept --org flag', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, --org: forcedorg + |_____|___|___|_,_|___|_|.dev | Command: \`socket threat-feed\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/packages/cli/src/commands/threat-feed/fetch-threat-feed.mts b/src/commands/threat-feed/fetch-threat-feed.mts similarity index 93% rename from packages/cli/src/commands/threat-feed/fetch-threat-feed.mts rename to src/commands/threat-feed/fetch-threat-feed.mts index 6319e00aa..21236b0d0 100644 --- a/packages/cli/src/commands/threat-feed/fetch-threat-feed.mts +++ b/src/commands/threat-feed/fetch-threat-feed.mts @@ -1,4 +1,4 @@ -import { queryApiSafeJson } from '../../util/socket/api.mjs' +import { queryApiSafeJson } from '../../utils/api.mts' import type { ThreadFeedResponse } from './types.mts' import type { CResult } from '../../types.mts' diff --git a/packages/cli/src/commands/threat-feed/handle-threat-feed.mts b/src/commands/threat-feed/handle-threat-feed.mts similarity index 100% rename from packages/cli/src/commands/threat-feed/handle-threat-feed.mts rename to src/commands/threat-feed/handle-threat-feed.mts diff --git a/src/commands/threat-feed/output-threat-feed.mts b/src/commands/threat-feed/output-threat-feed.mts new file mode 100644 index 000000000..b729beb2b --- /dev/null +++ b/src/commands/threat-feed/output-threat-feed.mts @@ -0,0 +1,193 @@ +import { createRequire } from 'node:module' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../constants.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { msAtHome } from '../../utils/ms-at-home.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { ThreadFeedResponse, ThreatResult } from './types.mts' +import type { CResult, OutputKind } from '../../types.mts' +import type { Widgets } from 'blessed' + +const require = createRequire(import.meta.url) + +export async function outputThreatFeed( + result: CResult<ThreadFeedResponse>, + outputKind: OutputKind, +) { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + return + } + + if (!result.data?.results?.length) { + logger.warn('Did not receive any data to display...') + return + } + + const formattedOutput = formatResults(result.data.results) + const descriptions = result.data.results.map(d => d.description) + + // Note: this temporarily takes over the terminal (just like `man` does). + const ScreenWidget = require('blessed/lib/widgets/screen.js') + // Lazily access constants.blessedOptions. + const screen: Widgets.Screen = new ScreenWidget({ + ...constants.blessedOptions, + }) + // Register these keys first so you can always exit, even when it gets stuck + // If we don't do this and the code crashes, the user must hard-kill the + // node process just to exit it. That's very bad UX. + // eslint-disable-next-line n/no-process-exit + screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) + + const TableWidget = require('blessed-contrib/lib/widget/table.js') + const detailsBoxHeight = 20 // bottom N rows for details box + const tipsBoxHeight = 1 // 1 row for tips box + + const table: any = new TableWidget({ + keys: 'true', + fg: 'white', + selectedFg: 'white', + selectedBg: 'magenta', + interactive: 'true', + label: 'Threat feed', + width: '100%', + top: 0, + bottom: detailsBoxHeight + tipsBoxHeight, + border: { + type: 'line', + fg: 'cyan', + }, + columnWidth: [10, 30, 20, 18, 15, 200], + // TODO: the truncation doesn't seem to work too well yet but when we add + // `pad` alignment fails, when we extend columnSpacing alignment fails + columnSpacing: 1, + truncate: '_', + }) + + const BoxWidget = require('blessed/lib/widgets/box.js') + const tipsBox: Widgets.BoxElement = new BoxWidget({ + bottom: detailsBoxHeight, // sits just above the details box + height: tipsBoxHeight, + width: '100%', + style: { + fg: 'yellow', + bg: 'black', + }, + tags: true, + content: '↑/↓: Move Enter: Select q/ESC: Quit', + }) + const detailsBox: Widgets.BoxElement = new BoxWidget({ + bottom: 0, + height: detailsBoxHeight, + width: '100%', + border: { + type: 'line', + fg: 'cyan', + }, + label: 'Details', + content: + 'Use arrow keys to navigate. Press Enter to select a threat. Press q to exit.', + style: { + fg: 'white', + }, + }) + + table.setData({ + headers: [ + ' Ecosystem', + ' Name', + ' Version', + ' Threat type', + ' Detected at', + ' Details', + ], + data: formattedOutput, + }) + + // Initialize details box with the first selection if available + if (formattedOutput.length > 0) { + const selectedRow = formattedOutput[0] + if (selectedRow) { + detailsBox.setContent(formatDetailBox(selectedRow, descriptions, 0)) + } + } + + // allow control the table with the keyboard + table.focus() + + // Stacking order: table (top), tipsBox (middle), detailsBox (bottom) + screen.append(table) + screen.append(tipsBox) + screen.append(detailsBox) + + // Update details box when selection changes + table.rows.on('select item', () => { + const selectedIndex = table.rows.selected + if (selectedIndex !== undefined && selectedIndex >= 0) { + const selectedRow = formattedOutput[selectedIndex] + if (selectedRow) { + // Note: the spacing works around issues with the table; it refuses to pad! + detailsBox.setContent( + formatDetailBox(selectedRow, descriptions, selectedIndex), + ) + screen.render() + } + } + }) + + screen.render() + + screen.key(['return'], () => { + const selectedIndex = table.rows.selected + screen.destroy() + const selectedRow = formattedOutput[selectedIndex] + logger.log('Last selection:\n', selectedRow) + }) +} + +function formatDetailBox( + selectedRow: string[], + descriptions: string[], + selectedIndex: number, +): string { + return ( + `Ecosystem: ${selectedRow[0]?.trim()}\n` + + `Name: ${selectedRow[1]?.trim()}\n` + + `Version: ${selectedRow[2]?.trim()}\n` + + `Threat type: ${selectedRow[3]?.trim()}\n` + + `Detected at: ${selectedRow[4]?.trim()}\n` + + `Details: ${selectedRow[5]?.trim()}\n` + + `Description: ${descriptions[selectedIndex]?.trim()}` + ) +} + +function formatResults(data: ThreatResult[]) { + return data.map(d => { + const ecosystem = d.purl.split('pkg:')[1]!.split('/')[0]! + const name = d.purl.split('/')[1]!.split('@')[0]! + const version = d.purl.split('@')[1]! + + const timeDiff = msAtHome(d.createdAt) + + // Note: the spacing works around issues with the table; it refuses to pad! + return [ + ecosystem, + decodeURIComponent(name), + ` ${version}`, + ` ${d.threatType}`, + ` ${timeDiff}`, + d.locationHtmlUrl, + ] + }) +} diff --git a/packages/cli/src/commands/threat-feed/types.mts b/src/commands/threat-feed/types.mts similarity index 100% rename from packages/cli/src/commands/threat-feed/types.mts rename to src/commands/threat-feed/types.mts diff --git a/src/commands/uninstall/cmd-uninstall-completion.mts b/src/commands/uninstall/cmd-uninstall-completion.mts new file mode 100644 index 000000000..6a805f6ff --- /dev/null +++ b/src/commands/uninstall/cmd-uninstall-completion.mts @@ -0,0 +1,68 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleUninstallCompletion } from './handle-uninstall-completion.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'completion', + description: 'Uninstall bash completion for Socket CLI', + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [COMMAND_NAME=socket] + + Uninstalls bash tab completion for the Socket CLI. This will: + 1. Remove tab completion from your current shell for given command + 2. Remove the setup for given command from your ~/.bashrc + + The optional name is required if you installed tab completion for an alias + other than the default "socket". This will NOT remove the command, only the + tab completion that is registered for it in bash. + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + + $ ${command} + $ ${command} sd + `, +} + +export const cmdUninstallCompletion = { + description: config.description, + hidden: config.hidden, + run, +} + +export async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const targetName = cli.input[0] || 'socket' + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + await handleUninstallCompletion(String(targetName)) +} diff --git a/src/commands/uninstall/cmd-uninstall-completion.test.mts b/src/commands/uninstall/cmd-uninstall-completion.test.mts new file mode 100644 index 000000000..6d66056aa --- /dev/null +++ b/src/commands/uninstall/cmd-uninstall-completion.test.mts @@ -0,0 +1,77 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket uninstall completion', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['uninstall', 'completion', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Uninstall bash completion for Socket CLI + + Usage + $ socket uninstall completion [options] [COMMAND_NAME=socket] + + Uninstalls bash tab completion for the Socket CLI. This will: + 1. Remove tab completion from your current shell for given command + 2. Remove the setup for given command from your ~/.bashrc + + The optional name is required if you installed tab completion for an alias + other than the default "socket". This will NOT remove the command, only the + tab completion that is registered for it in bash. + + Options + (none) + + Examples + + $ socket uninstall completion + $ socket uninstall completion sd" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall completion\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket uninstall completion`', + ) + }, + ) + + cmdit( + [ + 'uninstall', + 'completion', + '--dry-run', + '--config', + '{"apiToken":"anything"}', + ], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall completion\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/uninstall/cmd-uninstall.mts b/src/commands/uninstall/cmd-uninstall.mts new file mode 100644 index 000000000..83dbc4173 --- /dev/null +++ b/src/commands/uninstall/cmd-uninstall.mts @@ -0,0 +1,24 @@ +import { cmdUninstallCompletion } from './cmd-uninstall-completion.mts' +import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts' + +import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts' + +const description = 'Teardown the Socket command from your environment' + +export const cmdUninstall: CliSubcommand = { + description, + hidden: false, + async run(argv, importMeta, { parentName }) { + await meowWithSubcommands( + { + completion: cmdUninstallCompletion, + }, + { + argv, + description, + importMeta, + name: `${parentName} uninstall`, + }, + ) + }, +} diff --git a/src/commands/uninstall/cmd-uninstall.test.mts b/src/commands/uninstall/cmd-uninstall.test.mts new file mode 100644 index 000000000..74f56b5fa --- /dev/null +++ b/src/commands/uninstall/cmd-uninstall.test.mts @@ -0,0 +1,66 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket uninstall', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['uninstall', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Teardown the Socket command from your environment + + Usage + $ socket uninstall <command> + + Commands + completion Uninstall bash completion for Socket CLI + + Options + (none) + + Examples + $ socket uninstall --help" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket uninstall`', + ) + }, + ) + + cmdit( + ['uninstall', '--dry-run', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + `"[DryRun]: No-op, call a sub-command; ok"`, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket uninstall\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/packages/cli/src/commands/uninstall/handle-uninstall-completion.mts b/src/commands/uninstall/handle-uninstall-completion.mts similarity index 100% rename from packages/cli/src/commands/uninstall/handle-uninstall-completion.mts rename to src/commands/uninstall/handle-uninstall-completion.mts diff --git a/packages/cli/src/commands/uninstall/output-uninstall-completion.mts b/src/commands/uninstall/output-uninstall-completion.mts similarity index 76% rename from packages/cli/src/commands/uninstall/output-uninstall-completion.mts rename to src/commands/uninstall/output-uninstall-completion.mts index 626b40dfe..2c2622711 100644 --- a/packages/cli/src/commands/uninstall/output-uninstall-completion.mts +++ b/src/commands/uninstall/output-uninstall-completion.mts @@ -1,9 +1,8 @@ -import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { logger } from '@socketsecurity/registry/lib/logger' -import { failMsgWithBadge } from '../../util/error/fail-msg-with-badge.mts' +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' import type { CResult } from '../../types.mts' -const logger = getDefaultLogger() export async function outputUninstallCompletion( result: CResult<{ action: string; left: string[] }>, @@ -36,10 +35,9 @@ export async function outputUninstallCompletion( 'Detected more Socket Alias completions left in bashrc. Run `socket uninstall <cmd>` to remove them too.', ) logger.log('') - for (let i = 0, { length } = result.data.left; i < length; i += 1) { - const str = result.data.left[i] + result.data.left.forEach(str => { logger.log(` - \`${str}\``) - } + }) logger.log('') } } diff --git a/src/commands/uninstall/teardown-tab-completion.mts b/src/commands/uninstall/teardown-tab-completion.mts new file mode 100644 index 000000000..e544b02d8 --- /dev/null +++ b/src/commands/uninstall/teardown-tab-completion.mts @@ -0,0 +1,75 @@ +import fs from 'node:fs' +import path from 'node:path' + +import constants from '../../constants.mts' +import { + COMPLETION_CMD_PREFIX, + getBashrcDetails, +} from '../../utils/completion.mts' + +import type { CResult } from '../../types.mts' + +export async function teardownTabCompletion( + targetName: string, +): Promise<CResult<{ action: string; left: string[] }>> { + const result = getBashrcDetails(targetName) + if (!result.ok) { + return result + } + + const { completionCommand, sourcingCommand, toAddToBashrc } = result.data + + // Remove from ~/.bashrc if found + // Lazily access constants.homePath + const bashrc = constants.homePath + ? path.join(constants.homePath, '.bashrc') + : '' + + if (bashrc && fs.existsSync(bashrc)) { + const content = fs.readFileSync(bashrc, 'utf8') + + if (content.includes(toAddToBashrc)) { + const newContent = content + // Try to remove the whole thing with comment first + .replaceAll(toAddToBashrc, '') + // Comment may have been edited away, try to remove the command at least + .replaceAll(sourcingCommand, '') + .replaceAll(completionCommand, '') + + fs.writeFileSync(bashrc, newContent, 'utf8') + + return { + ok: true, + data: { + action: 'removed', + left: findRemainingCompletionSetups(newContent), + }, + message: 'Removed completion from ~/.bashrc', + } + } else { + const left = findRemainingCompletionSetups(content) + return { + ok: true, + data: { + action: 'missing', + left, + }, + message: `Completion was not found in ~/.bashrc${left.length ? ' (you may need to manually edit your .bashrc to clean this up...)' : ''}`, + } + } + } else { + return { + ok: true, // Eh. I think this makes most sense. + data: { action: 'not found', left: [] }, + message: '~/.bashrc not found, skipping', + } + } +} + +function findRemainingCompletionSetups(bashrc: string): string[] { + return bashrc + .split('\n') + .map(s => s.trim()) + .filter(s => s.startsWith(COMPLETION_CMD_PREFIX)) + .map(s => s.slice(COMPLETION_CMD_PREFIX.length).trim()) +} diff --git a/src/commands/wrapper/add-socket-wrapper.mts b/src/commands/wrapper/add-socket-wrapper.mts new file mode 100644 index 000000000..8322c8792 --- /dev/null +++ b/src/commands/wrapper/add-socket-wrapper.mts @@ -0,0 +1,32 @@ +import fs from 'node:fs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +export function addSocketWrapper(file: string): void { + return fs.appendFile( + file, + 'alias npm="socket npm"\nalias npx="socket npx"\n', + err => { + if (err) { + return new Error(`There was an error setting up the alias: ${err}`) + } + logger.success( + `The alias was added to ${file}. Running 'npm install' will now be wrapped in Socket's "safe npm" 🎉`, + ) + logger.log( + ` If you want to disable it at any time, run \`socket wrapper --disable\``, + ) + logger.log('') + logger.info( + `This will only be active in new terminal sessions going forward.`, + ) + logger.log( + ` You will need to restart your terminal or run this command to activate the alias in the current session:`, + ) + logger.log('') + logger.log(` source ${file}`) + logger.log('') + logger.log(`(You only need to do this once)`) + }, + ) +} diff --git a/src/commands/wrapper/check-socket-wrapper-setup.mts b/src/commands/wrapper/check-socket-wrapper-setup.mts new file mode 100644 index 000000000..9eedcd1fc --- /dev/null +++ b/src/commands/wrapper/check-socket-wrapper-setup.mts @@ -0,0 +1,28 @@ +import fs from 'node:fs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +export function checkSocketWrapperSetup(file: string): boolean { + const fileContent = fs.readFileSync(file, 'utf8') + const linesWithSocketAlias = fileContent + .split('\n') + .filter( + l => l === 'alias npm="socket npm"' || l === 'alias npx="socket npx"', + ) + + if (linesWithSocketAlias.length) { + logger.log( + `The Socket npm/npx wrapper is set up in your bash profile (${file}).`, + ) + logger.log('') + logger.log( + `If you haven't already since enabling; Restart your terminal or run this command to activate it in the current session:`, + ) + logger.log('') + logger.log(` source ${file}`) + logger.log('') + + return true + } + return false +} diff --git a/src/commands/wrapper/cmd-wrapper.mts b/src/commands/wrapper/cmd-wrapper.mts new file mode 100644 index 000000000..579a75350 --- /dev/null +++ b/src/commands/wrapper/cmd-wrapper.mts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { addSocketWrapper } from './add-socket-wrapper.mts' +import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.mts' +import { postinstallWrapper } from './postinstall-wrapper.mts' +import { removeSocketWrapper } from './remove-socket-wrapper.mts' +import constants from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts' + +const { DRY_RUN_BAILING_NOW } = constants + +const config: CliCommandConfig = { + commandName: 'wrapper', + description: 'Enable or disable the Socket npm/npx wrapper', + hidden: false, + flags: { + ...commonFlags, + }, + help: (command, config) => ` + Usage + $ ${command} <"on" | "off"> + + Options + ${getFlagListOutput(config.flags, 6)} + + While enabled, the wrapper makes it so that when you call npm/npx on your + machine, it will automatically actually run \`socket npm\` / \`socket npx\` + instead. + + Examples + $ ${command} on + $ ${command} off + `, +} + +export const cmdWrapper = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string }, +): Promise<void> { + // I don't think meow would mess with this but ... + if (argv[0] === '--postinstall') { + await postinstallWrapper() + return + } + + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json, markdown } = cli.flags + const outputKind = getOutputKind(json, markdown) // TODO: impl json/md further + + let enable = false + let disable = false + const [arg] = cli.input + if (arg === 'on' || arg === 'enable' || arg === 'enabled') { + enable = true + disable = false + } else if (arg === 'off' || arg === 'disable' || arg === 'disabled') { + enable = false + disable = true + } + + const wasValidInput = checkCommandInput( + outputKind, + { + test: enable || disable, + message: 'Must specify "on" or "off" argument', + pass: 'ok', + fail: 'missing', + }, + { + nook: true, + test: cli.input.length <= 1, + message: 'expecting exactly one argument', + pass: 'ok', + fail: `got multiple`, + }, + ) + if (!wasValidInput) { + return + } + + if (cli.flags['dryRun']) { + logger.log(DRY_RUN_BAILING_NOW) + return + } + + // Lazily access constants.bashRcPath and constants.zshRcPath. + const { bashRcPath, zshRcPath } = constants + if (enable) { + if (existsSync(bashRcPath) && !checkSocketWrapperSetup(bashRcPath)) { + addSocketWrapper(bashRcPath) + } + if (existsSync(zshRcPath) && !checkSocketWrapperSetup(zshRcPath)) { + addSocketWrapper(zshRcPath) + } + } else { + if (existsSync(bashRcPath)) { + removeSocketWrapper(bashRcPath) + } + if (existsSync(zshRcPath)) { + removeSocketWrapper(zshRcPath) + } + } + if (!existsSync(bashRcPath) && !existsSync(zshRcPath)) { + logger.fail('There was an issue setting up the alias in your bash profile') + } +} diff --git a/src/commands/wrapper/cmd-wrapper.test.mts b/src/commands/wrapper/cmd-wrapper.test.mts new file mode 100644 index 000000000..3d1341d2b --- /dev/null +++ b/src/commands/wrapper/cmd-wrapper.test.mts @@ -0,0 +1,89 @@ +import { describe, expect } from 'vitest' + +import constants from '../../../src/constants.mts' +import { cmdit, invokeNpm } from '../../../test/utils.mts' + +describe('socket wrapper', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + cmdit( + ['wrapper', '--help', '--config', '{}'], + 'should support --help', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot( + ` + "Enable or disable the Socket npm/npx wrapper + + Usage + $ socket wrapper <"on" | "off"> + + Options + (none) + + While enabled, the wrapper makes it so that when you call npm/npx on your + machine, it will automatically actually run \`socket npm\` / \`socket npx\` + instead. + + Examples + $ socket wrapper on + $ socket wrapper off" + `, + ) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted>" + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket wrapper`', + ) + }, + ) + + cmdit( + ['wrapper', '--dry-run', '--config', '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`""`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted> + + \\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[1m\\x1b[37m Input error: \\x1b[39m\\x1b[22m\\x1b[49m \\x1b[1mPlease review the input requirements and try again + + - Must specify "on" or "off" argument (\\x1b[31mmissing\\x1b[39m) + \\x1b[22m" + `) + + expect(code, 'dry-run should exit with code 2 if missing input').toBe(2) + }, + ) + + cmdit( + ['wrapper', '--dry-run', 'on', '--config', '{"apiToken":"anything"}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await invokeNpm(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted> + |__ | * | _| '_| -_| _| | Node: <redacted>, API token: <redacted>, org: <redacted> + |_____|___|___|_,_|___|_|.dev | Command: \`socket wrapper\`, cwd: <redacted>" + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/wrapper/postinstall-wrapper.mts b/src/commands/wrapper/postinstall-wrapper.mts new file mode 100644 index 000000000..4b830beb9 --- /dev/null +++ b/src/commands/wrapper/postinstall-wrapper.mts @@ -0,0 +1,90 @@ +import fs, { existsSync } from 'node:fs' + +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { confirm } from '@socketsecurity/registry/lib/prompts' + +import { addSocketWrapper } from './add-socket-wrapper.mts' +import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.mts' +import constants from '../../constants.mts' +import { getBashrcDetails } from '../../utils/completion.mts' +import { updateInstalledTabCompletionScript } from '../install/setup-tab-completion.mts' + +export async function postinstallWrapper() { + // Lazily access constants.bashRcPath and constants.zshRcPath. + const { bashRcPath, zshRcPath } = constants + const socketWrapperEnabled = + (existsSync(bashRcPath) && checkSocketWrapperSetup(bashRcPath)) || + (existsSync(zshRcPath) && checkSocketWrapperSetup(zshRcPath)) + + if (!socketWrapperEnabled) { + await installSafeNpm( + ` +The Socket CLI is now successfully installed! 🎉 + +To better protect yourself against supply-chain attacks, our "safe npm" wrapper can warn you about malicious packages whenever you run 'npm install'. + +Do you want to install "safe npm" (this will create an alias to the socket-npm command)? + `.trim(), + ) + } + + // Attempt to update the existing tab completion + let updatedTabCompletion = false + try { + const details = getBashrcDetails('') // Note: command is not relevant, we just want the config path + if (details.ok) { + if (fs.existsSync(details.data.targetPath)) { + // Replace the file with the one from this installation + const result = updateInstalledTabCompletionScript( + details.data.targetPath, + ) + if (result.ok) { + // This will work no matter what alias(es) were registered since that + // is controlled by bashrc and they all share the same tab script. + logger.success('Updated the installed Socket tab completion script') + updatedTabCompletion = true + } + } + } + } catch (e) { + debugFn('fail: setup tab completion\n', e) + // Ignore. Skip tab completion setup. + } + if (!updatedTabCompletion) { + // Setting up tab completion requires bashrc modification. I'm not sure if + // it's cool to just do that from an npm install... + logger.log('Run `socket install completion` to setup bash tab completion') + } +} + +async function installSafeNpm(query: string): Promise<void> { + logger.log(` + _____ _ _ +| __|___ ___| |_ ___| |_ +|__ | . | _| '_| -_| _| +|_____|___|___|_,_|___|_| + +`) + if ( + await confirm({ + message: query, + default: true, + }) + ) { + // Lazily access constants.bashRcPath and constants.zshRcPath. + const { bashRcPath, zshRcPath } = constants + try { + if (existsSync(bashRcPath)) { + addSocketWrapper(bashRcPath) + } + if (existsSync(zshRcPath)) { + addSocketWrapper(zshRcPath) + } + } catch (e) { + throw new Error( + `There was an issue setting up the alias: ${(e as any)?.['message']}`, + ) + } + } +} diff --git a/src/commands/wrapper/remove-socket-wrapper.mts b/src/commands/wrapper/remove-socket-wrapper.mts new file mode 100644 index 000000000..f93b415a1 --- /dev/null +++ b/src/commands/wrapper/remove-socket-wrapper.mts @@ -0,0 +1,34 @@ +import fs from 'node:fs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +export function removeSocketWrapper(file: string): void { + return fs.readFile(file, 'utf8', function (err, data) { + if (err) { + logger.fail('There was an error removing the alias:') + logger.error(err) + return + } + const linesWithoutSocketAlias = data + .split('\n') + .filter( + l => l !== 'alias npm="socket npm"' && l !== 'alias npx="socket npx"', + ) + + const updatedFileContent = linesWithoutSocketAlias.join('\n') + + fs.writeFile(file, updatedFileContent, function (err) { + if (err) { + logger.error(err) + return + } + logger.success( + `The alias was removed from ${file}. Running 'npm install' will now run the standard npm command in new terminals going forward.`, + ) + logger.log('') + logger.info( + `Note: We cannot deactivate the alias from current terminal sessions. You have to restart existing terminal sessions to finalize this step.`, + ) + }) + }) +} diff --git a/src/constants.mts b/src/constants.mts new file mode 100644 index 000000000..f2573d4d1 --- /dev/null +++ b/src/constants.mts @@ -0,0 +1,657 @@ +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import registryConstants from '@socketsecurity/registry/lib/constants' + +import type { Agent } from './utils/package-environment.mts' +import type { Remap } from '@socketsecurity/registry/lib/objects' + +const require = createRequire(import.meta.url) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const { + kInternalsSymbol, + [kInternalsSymbol as unknown as 'Symbol(kInternalsSymbol)']: { + attributes: registryConstantsAttribs, + createConstantsObject, + getIpc, + }, +} = registryConstants + +type RegistryEnv = typeof registryConstants.ENV + +type RegistryInternals = (typeof registryConstants)['Symbol(kInternalsSymbol)'] + +type Sentry = any + +type Internals = Remap< + Omit<RegistryInternals, 'getIpc'> & + Readonly<{ + getIpc: { + (): Promise<IPC> + <K extends keyof IPC | undefined>( + key?: K | undefined, + ): Promise<K extends keyof IPC ? IPC[K] : IPC> + } + getSentry: () => Sentry + setSentry(Sentry: Sentry): boolean + }> +> + +type ENV = Remap< + RegistryEnv & + Readonly<{ + DISABLE_GITHUB_CACHE: boolean + GITHUB_BASE_REF: string + GITHUB_REF_NAME: string + GITHUB_REF_TYPE: string + GITHUB_REPOSITORY: string + GITHUB_TOKEN: string + INLINED_CYCLONEDX_CDXGEN_VERSION: string + INLINED_SOCKET_CLI_HOMEPAGE: string + INLINED_SOCKET_CLI_LEGACY_BUILD: string + INLINED_SOCKET_CLI_NAME: string + INLINED_SOCKET_CLI_PUBLISHED_BUILD: string + INLINED_SOCKET_CLI_SENTRY_BUILD: string + INLINED_SOCKET_CLI_VERSION: string + INLINED_SOCKET_CLI_VERSION_HASH: string + INLINED_SYNP_VERSION: string + LOCALAPPDATA: string + NODE_COMPILE_CACHE: string + PATH: string + SOCKET_CLI_ACCEPT_RISKS: boolean + SOCKET_CLI_API_BASE_URL: string + SOCKET_CLI_API_PROXY: string + SOCKET_CLI_API_TOKEN: string + SOCKET_CLI_CONFIG: string + SOCKET_CLI_DEBUG: boolean + SOCKET_CLI_GIT_USER_EMAIL: string + SOCKET_CLI_GIT_USER_NAME: string + SOCKET_CLI_GITHUB_TOKEN: string + SOCKET_CLI_NO_API_TOKEN: boolean + SOCKET_CLI_VIEW_ALL_RISKS: boolean + TERM: string + XDG_DATA_HOME: string + }> +> + +type IPC = Readonly<{ + SOCKET_CLI_FIX?: string | undefined + SOCKET_CLI_OPTIMIZE?: boolean | undefined + SOCKET_CLI_SAFE_BIN?: string | undefined + SOCKET_CLI_SAFE_PROGRESS?: boolean | undefined +}> + +type Constants = Remap< + Omit<typeof registryConstants, 'Symbol(kInternalsSymbol)' | 'ENV' | 'IPC'> & { + readonly 'Symbol(kInternalsSymbol)': Internals + readonly ALERT_TYPE_CRITICAL_CVE: 'criticalCVE' + readonly ALERT_TYPE_CVE: 'cve' + readonly ALERT_TYPE_MEDIUM_CVE: 'mediumCVE' + readonly ALERT_TYPE_MILD_CVE: 'mildCVE' + readonly API_V0_URL: 'https://api.socket.dev/v0/' + readonly BINARY_LOCK_EXT: '.lockb' + readonly BUN: 'bun' + readonly ENV: ENV + readonly DOT_SOCKET_DOT_FACTS_JSON: '.socket.facts.json' + readonly DRY_RUN_LABEL: '[DryRun]' + readonly DRY_RUN_BAILING_NOW: '[DryRun] Bailing now' + readonly DRY_RUN_NOT_SAVING: '[DryRun] Not saving' + readonly IPC: IPC + readonly LOCK_EXT: '.lock' + readonly NPM_BUGGY_OVERRIDES_PATCHED_VERSION: '11.2.0' + readonly NPM_REGISTRY_URL: 'https://registry.npmjs.org' + readonly PNPM: 'pnpm' + readonly REDACTED: '<redacted>' + readonly SHADOW_NPM_BIN: 'shadow-npm-bin' + readonly SHADOW_NPM_INJECT: 'shadow-npm-inject' + readonly SOCKET: 'socket' + readonly SOCKET_CLI_ACCEPT_RISKS: 'SOCKET_CLI_ACCEPT_RISKS' + readonly SOCKET_CLI_BIN_NAME: 'socket' + readonly SOCKET_CLI_BIN_NAME_ALIAS: 'cli' + readonly SOCKET_CLI_CONFIG: 'SOCKET_CLI_CONFIG' + readonly SOCKET_CLI_FIX: 'SOCKET_CLI_FIX' + readonly SOCKET_CLI_ISSUES_URL: 'https://github.com/SocketDev/socket-cli/issues' + readonly SOCKET_CLI_SENTRY_BIN_NAME_ALIAS: 'cli-with-sentry' + readonly SOCKET_CLI_LEGACY_PACKAGE_NAME: '@socketsecurity/cli' + readonly SOCKET_CLI_NPM_BIN_NAME: 'socket-npm' + readonly SOCKET_CLI_NPX_BIN_NAME: 'socket-npx' + readonly SOCKET_CLI_OPTIMIZE: 'SOCKET_CLI_OPTIMIZE' + readonly SOCKET_CLI_PACKAGE_NAME: 'socket' + readonly SOCKET_CLI_SAFE_BIN: 'SOCKET_CLI_SAFE_BIN' + readonly SOCKET_CLI_SAFE_PROGRESS: 'SOCKET_CLI_SAFE_PROGRESS' + readonly SOCKET_CLI_SENTRY_BIN_NAME: 'socket-with-sentry' + readonly SOCKET_CLI_SENTRY_NPM_BIN_NAME: 'socket-npm-with-sentry' + readonly SOCKET_CLI_SENTRY_NPX_BIN_NAME: 'socket-npx-with-sentry' + readonly SOCKET_CLI_SENTRY_PACKAGE_NAME: '@socketsecurity/cli-with-sentry' + readonly SOCKET_CLI_VIEW_ALL_RISKS: 'SOCKET_CLI_VIEW_ALL_RISKS' + readonly SOCKET_WEBSITE_URL: 'https://socket.dev' + readonly VLT: 'vlt' + readonly WITH_SENTRY: 'with-sentry' + readonly YARN: 'yarn' + readonly YARN_BERRY: 'yarn/berry' + readonly YARN_CLASSIC: 'yarn/classic' + readonly YARN_LOCK: 'yarn.lock' + readonly bashRcPath: string + readonly binCliPath: string + readonly binPath: string + readonly blessedContribPath: string + readonly blessedOptions: { + smartCSR: boolean + term: string + useBCE: boolean + } + readonly blessedPath: string + readonly coanaBinPath: string + readonly coanaPath: string + readonly distCliPath: string + readonly distPath: string + readonly externalPath: string + readonly githubCachePath: string + readonly homePath: string + readonly instrumentWithSentryPath: string + readonly minimumVersionByAgent: Map<Agent, string> + readonly nmBinPath: string + readonly nodeHardenFlags: string[] + readonly rootPath: string + readonly shadowBinPath: string + readonly shadowNpmBinPath: string + readonly shadowNpmInjectPath: string + readonly socketAppDataPath: string + readonly socketCachePath: string + readonly socketRegistryPath: string + readonly zshRcPath: string + } +> + +const ALERT_TYPE_CRITICAL_CVE = 'criticalCVE' +const ALERT_TYPE_CVE = 'cve' +const ALERT_TYPE_MEDIUM_CVE = 'mediumCVE' +const ALERT_TYPE_MILD_CVE = 'mildCVE' +const API_V0_URL = 'https://api.socket.dev/v0/' +const BINARY_LOCK_EXT = '.lockb' +const BUN = 'bun' +const DOT_SOCKET_DOT_FACTS_JSON = '.socket.facts.json' +const DRY_RUN_LABEL = '[DryRun]' +const DRY_RUN_BAILING_NOW = `${DRY_RUN_LABEL}: Bailing now` +const DRY_RUN_NOT_SAVING = `${DRY_RUN_LABEL}: Not saving` +const LOCALAPPDATA = 'LOCALAPPDATA' +const LOCK_EXT = '.lock' +const NPM_BUGGY_OVERRIDES_PATCHED_VERSION = '11.2.0' +const NPM_REGISTRY_URL = 'https://registry.npmjs.org' +const PNPM = 'pnpm' +const REDACTED = '<redacted>' +const SHADOW_NPM_BIN = 'shadow-npm-bin' +const SHADOW_NPM_INJECT = 'shadow-npm-inject' +const SOCKET = 'socket' +const SOCKET_CLI_ACCEPT_RISKS = 'SOCKET_CLI_ACCEPT_RISKS' +const SOCKET_CLI_BIN_NAME = 'socket' +const SOCKET_CLI_BIN_NAME_ALIAS = 'cli' +const SOCKET_CLI_FIX = 'SOCKET_CLI_FIX' +const SOCKET_CLI_ISSUES_URL = 'https://github.com/SocketDev/socket-cli/issues' +const SOCKET_CLI_LEGACY_PACKAGE_NAME = '@socketsecurity/cli' +const SOCKET_CLI_OPTIMIZE = 'SOCKET_CLI_OPTIMIZE' +const SOCKET_CLI_NPM_BIN_NAME = 'socket-npm' +const SOCKET_CLI_NPX_BIN_NAME = 'socket-npx' +const SOCKET_CLI_PACKAGE_NAME = 'socket' +const SOCKET_CLI_SAFE_BIN = 'SOCKET_CLI_SAFE_BIN' +const SOCKET_CLI_SAFE_PROGRESS = 'SOCKET_CLI_SAFE_PROGRESS' +const SOCKET_CLI_SENTRY_BIN_NAME = 'socket-with-sentry' +const SOCKET_CLI_SENTRY_BIN_NAME_ALIAS = 'cli-with-sentry' +const SOCKET_CLI_SENTRY_NPM_BIN_NAME = 'socket-npm-with-sentry' +const SOCKET_CLI_SENTRY_NPX_BIN_NAME = 'socket-npx-with-sentry' +const SOCKET_CLI_SENTRY_PACKAGE_NAME = '@socketsecurity/cli-with-sentry' +const SOCKET_CLI_VIEW_ALL_RISKS = 'SOCKET_CLI_VIEW_ALL_RISKS' +const SOCKET_WEBSITE_URL = 'https://socket.dev' +const VLT = 'vlt' +const WITH_SENTRY = 'with-sentry' +const YARN = 'yarn' +const YARN_BERRY = 'yarn/berry' +const YARN_CLASSIC = 'yarn/classic' +const YARN_LOCK = 'yarn.lock' + +let _Sentry: any + +const LAZY_ENV = () => { + const { + envAsBoolean, + envAsString, + } = require('@socketsecurity/registry/lib/env') + const { env } = process + const GITHUB_TOKEN = envAsString(env['GITHUB_TOKEN']) + // We inline some environment values so that they CANNOT be influenced by user + // provided environment variables. + return Object.freeze({ + __proto__: null, + // Lazily access registryConstants.ENV. + ...registryConstants.ENV, + // Flag to disable using GitHub's workflow actions/cache. + // https://github.com/actions/cache + DISABLE_GITHUB_CACHE: envAsBoolean(env['DISABLE_GITHUB_CACHE']), + // The name of the base ref or target branch of the pull request in a workflow + // run. This is only set when the event that triggers a workflow run is either + // pull_request or pull_request_target. For example, main. + // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables + GITHUB_BASE_REF: envAsString(env['GITHUB_BASE_REF']), + // The short ref name of the branch or tag that triggered the GitHub workflow + // run. This value matches the branch or tag name shown on GitHub. For example, + // feature-branch-1. For pull requests, the format is <pr_number>/merge. + // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables + GITHUB_REF_NAME: envAsString(env['GITHUB_REF_NAME']), + // The type of ref that triggered the workflow run. Valid values are branch or tag. + // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables + GITHUB_REF_TYPE: envAsString(env['GITHUB_REF_TYPE']), + // The owner and repository name. For example, octocat/Hello-World. + // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables + GITHUB_REPOSITORY: envAsString(env['GITHUB_REPOSITORY']), + // The GITHUB_TOKEN secret is a GitHub App installation access token. + // The token's permissions are limited to the repository that contains the + // workflow. + // https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#about-the-github_token-secret + GITHUB_TOKEN, + // Comp-time inlined @cyclonedx/cdxgen package version. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_CYCLONEDX_CDXGEN_VERSION']". + INLINED_CYCLONEDX_CDXGEN_VERSION: envAsString( + process.env['INLINED_CYCLONEDX_CDXGEN_VERSION'], + ), + // Comp-time inlined Socket package homepage. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_HOMEPAGE']". + INLINED_SOCKET_CLI_HOMEPAGE: envAsString( + process.env['INLINED_SOCKET_CLI_HOMEPAGE'], + ), + // Comp-time inlined flag to determine if this is the Legacy build. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_LEGACY_BUILD']". + INLINED_SOCKET_CLI_LEGACY_BUILD: envAsBoolean( + process.env['INLINED_SOCKET_CLI_LEGACY_BUILD'], + ), + // Comp-time inlined Socket package name. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_NAME']". + INLINED_SOCKET_CLI_NAME: envAsString( + process.env['INLINED_SOCKET_CLI_NAME'], + ), + // Comp-time inlined flag to determine if this is a published build. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_PUBLISHED_BUILD']". + INLINED_SOCKET_CLI_PUBLISHED_BUILD: envAsBoolean( + process.env['INLINED_SOCKET_CLI_PUBLISHED_BUILD'], + ), + // Comp-time inlined flag to determine if this is the Sentry build. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_SENTRY_BUILD']". + INLINED_SOCKET_CLI_SENTRY_BUILD: envAsBoolean( + process.env['INLINED_SOCKET_CLI_SENTRY_BUILD'], + ), + // Comp-time inlined Socket package version. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_VERSION']". + INLINED_SOCKET_CLI_VERSION: envAsString( + process.env['INLINED_SOCKET_CLI_VERSION'], + ), + // Comp-time inlined Socket package version hash. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SOCKET_CLI_VERSION_HASH']". + INLINED_SOCKET_CLI_VERSION_HASH: envAsString( + process.env['INLINED_SOCKET_CLI_VERSION_HASH'], + ), + // Comp-time inlined synp package version. + // The '@rollup/plugin-replace' will replace "process.env['INLINED_SYNP_VERSION']". + INLINED_SYNP_VERSION: envAsString(process.env['INLINED_SYNP_VERSION']), + // The location of the %localappdata% folder on Windows used to store user-specific, + // non-roaming application data, like temporary files, cached data, and program + // settings, that are specific to the current machine and user. + LOCALAPPDATA: envAsString(env[LOCALAPPDATA]), + // Flag to enable the module compile cache for the Node.js instance. + // https://nodejs.org/api/cli.html#node_compile_cachedir + NODE_COMPILE_CACHE: + // Lazily access constants.SUPPORTS_NODE_COMPILE_CACHE_ENV_VAR. + constants.SUPPORTS_NODE_COMPILE_CACHE_ENV_VAR + ? // Lazily access constants.socketCachePath. + constants.socketCachePath + : '', + // PATH is an environment variable that lists directories where executable + // programs are located. When a command is run, the system searches these + // directories to find the executable. + PATH: envAsString(env['PATH']), + // Flag to accepts risks of safe-npm and safe-npx run. + SOCKET_CLI_ACCEPT_RISKS: envAsBoolean(env[SOCKET_CLI_ACCEPT_RISKS]), + // Flag to change the base URL for all API-calls. + // https://github.com/SocketDev/socket-cli?tab=readme-ov-file#environment-variables-for-development + SOCKET_CLI_API_BASE_URL: + envAsString(env['SOCKET_CLI_API_BASE_URL']) || + envAsString(env['SOCKET_SECURITY_API_BASE_URL']), + // Flag to set the proxy all requests are routed through. + // https://github.com/SocketDev/socket-cli?tab=readme-ov-file#environment-variables-for-development + SOCKET_CLI_API_PROXY: + envAsString(env['SOCKET_CLI_API_PROXY']) || + envAsString(env['SOCKET_SECURITY_API_PROXY']), + // Flag to set the API token. + // https://github.com/SocketDev/socket-cli?tab=readme-ov-file#environment-variables + SOCKET_CLI_API_TOKEN: + envAsString(env['SOCKET_CLI_API_TOKEN']) || + envAsString(env['SOCKET_CLI_API_KEY']) || + envAsString(env['SOCKET_SECURITY_API_TOKEN']) || + envAsString(env['SOCKET_SECURITY_API_KEY']), + // Flag containing a JSON stringified Socket configuration object. + SOCKET_CLI_CONFIG: envAsString(env['SOCKET_CLI_CONFIG']), + // Flag to help debug Socket CLI. + SOCKET_CLI_DEBUG: envAsBoolean(env['SOCKET_CLI_DEBUG']), + // The git config user.email used by Socket CLI. + SOCKET_CLI_GIT_USER_EMAIL: + envAsString(env['SOCKET_CLI_GIT_USER_EMAIL']) || + 'github-actions[bot]@users.noreply.github.com', + // The git config user.name used by Socket CLI. + SOCKET_CLI_GIT_USER_NAME: + envAsString(env['SOCKET_CLI_GIT_USER_NAME']) || + envAsString(env['SOCKET_CLI_GIT_USERNAME']) || + 'github-actions[bot]', + // A classic GitHub personal access token with the "repo" scope or a + // fine-grained access token with at least read/write permissions set for + // "Contents" and "Pull Request". + // https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens + SOCKET_CLI_GITHUB_TOKEN: + envAsString(env['SOCKET_CLI_GITHUB_TOKEN']) || + envAsString(env['SOCKET_SECURITY_GITHUB_PAT']) || + GITHUB_TOKEN, + // Flag to make the default API token `undefined`. + SOCKET_CLI_NO_API_TOKEN: envAsBoolean(env['SOCKET_CLI_NO_API_TOKEN']), + // Flag to view all risks of safe-npm and safe-npx run. + SOCKET_CLI_VIEW_ALL_RISKS: envAsBoolean(env[SOCKET_CLI_VIEW_ALL_RISKS]), + // Specifies the type of terminal or terminal emulator being used by the process. + TERM: envAsString(env['TERM']), + // The location of the base directory on Linux and MacOS used to store + // user-specific data files, defaulting to $HOME/.local/share if not set or empty. + XDG_DATA_HOME: envAsString(env['XDG_DATA_HOME']), + }) +} + +const lazyBashRcPath = () => + // Lazily access constants.homePath. + path.join(constants.homePath, '.bashrc') + +const lazyBinPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'bin') + +const lazyBinCliPath = () => + // Lazily access constants.binPath. + path.join(constants.binPath, 'cli.js') + +const lazyBlessedContribPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, 'blessed-contrib') + +const lazyBlessedOptions = () => + Object.freeze({ + smartCSR: true, + // Lazily access constants.WIN32. + term: constants.WIN32 ? 'windows-ansi' : 'xterm', + useBCE: true, + }) + +const lazyBlessedPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, 'blessed') + +const lazyCoanaBinPath = () => + // Lazily access constants.coanaPath. + path.join(constants.coanaPath, 'cli.mjs') + +const lazyCoanaPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, '@coana-tech/cli') + +const lazyDistCliPath = () => + // Lazily access constants.distPath. + path.join(constants.distPath, 'cli.js') + +const lazyDistPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'dist') + +const lazyExternalPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'external') + +const lazyGithubCachePath = () => + // Lazily access constants.socketCachePath. + path.join(constants.socketCachePath, 'github') + +const lazyHomePath = () => os.homedir() + +const lazyInstrumentWithSentryPath = () => + // Lazily access constants.distPath. + path.join(constants.distPath, 'instrument-with-sentry.js') + +const lazyMinimumVersionByAgent = () => + new Map([ + // Bun >=1.1.39 supports the text-based lockfile. + // https://bun.sh/blog/bun-lock-text-lockfile + [BUN, '1.1.39'], + // The npm version bundled with Node 18. + // https://nodejs.org/en/about/previous-releases#looking-for-the-latest-release-of-a-version-branch + ['npm', '10.8.2'], + // 8.x is the earliest version to support Node 18. + // https://pnpm.io/installation#compatibility + // https://www.npmjs.com/package/pnpm?activeTab=versions + [PNPM, '8.15.7'], + // 4.x supports >= Node 18.12.0 + // https://github.com/yarnpkg/berry/blob/%40yarnpkg/core/4.1.0/CHANGELOG.md#400 + [YARN_BERRY, '4.0.0'], + // Latest 1.x. + // https://www.npmjs.com/package/yarn?activeTab=versions + [YARN_CLASSIC, '1.22.22'], + // vlt does not support overrides so we don't gate on it. + [VLT, '*'], + ]) + +const lazyNmBinPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'node_modules/.bin') + +// Redefine registryConstants.nodeHardenFlags to account for the +// INLINED_SOCKET_CLI_SENTRY_BUILD environment variable. +const lazyNodeHardenFlags = () => + Object.freeze( + // Lazily access constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD. + constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD || + // Lazily access constants.WIN32. + constants.WIN32 + ? [] + : // Harden Node security. + // https://nodejs.org/en/learn/getting-started/security-best-practices + [ + '--disable-proto', + 'throw', + // We have contributed the following patches to our dependencies to make + // Node's --frozen-intrinsics workable. + // √ https://github.com/SBoudrias/Inquirer.js/pull/1683 + // √ https://github.com/pnpm/components/pull/23 + '--frozen-intrinsics', + '--no-deprecation', + ], + ) + +const lazyRootPath = () => path.join(realpathSync.native(__dirname), '..') + +const lazyShadowBinPath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'shadow-bin') + +const lazyShadowNpmBinPath = () => + // Lazily access constants.distPath. + path.join(constants.distPath, `${SHADOW_NPM_BIN}.js`) + +const lazyShadowNpmInjectPath = () => + // Lazily access constants.distPath. + path.join(constants.distPath, `${SHADOW_NPM_INJECT}.js`) + +const lazySocketAppDataPath = (): string | undefined => { + // Get the OS app data folder: + // - Win: %LOCALAPPDATA% or fail? + // - Mac: %XDG_DATA_HOME% or fallback to "~/Library/Application Support/" + // - Linux: %XDG_DATA_HOME% or fallback to "~/.local/share/" + // Note: LOCALAPPDATA is typically: C:\Users\USERNAME\AppData + // Note: XDG stands for "X Desktop Group", nowadays "freedesktop.org" + // On most systems that path is: $HOME/.local/share + // Then append `socket/settings`, so: + // - Win: %LOCALAPPDATA%\socket\settings or return undefined + // - Mac: %XDG_DATA_HOME%/socket/settings or "~/Library/Application Support/socket/settings" + // - Linux: %XDG_DATA_HOME%/socket/settings or "~/.local/share/socket/settings" + + // Lazily access constants.WIN32. + const { WIN32 } = constants + let dataHome: string | undefined = WIN32 + ? // Lazily access constants.ENV.LOCALAPPDATA + constants.ENV.LOCALAPPDATA + : // Lazily access constants.ENV.XDG_DATA_HOME + constants.ENV.XDG_DATA_HOME + if (!dataHome) { + if (WIN32) { + const logger = require('@socketsecurity/registry/lib/logger') + logger.warn(`Missing %${LOCALAPPDATA}%`) + } else { + dataHome = path.join( + // Lazily access constants.homePath. + constants.homePath, + // Lazily access constants.DARWIN. + constants.DARWIN ? 'Library/Application Support' : '.local/share', + ) + } + } + return dataHome ? path.join(dataHome, 'socket/settings') : undefined +} + +const lazySocketCachePath = () => + // Lazily access constants.rootPath. + path.join(constants.rootPath, '.cache') + +const lazySocketRegistryPath = () => + // Lazily access constants.externalPath. + path.join(constants.externalPath, '@socketsecurity/registry') + +const lazyZshRcPath = () => + // Lazily access constants.homePath. + path.join(constants.homePath, '.zshrc') + +const constants: Constants = createConstantsObject( + { + ...registryConstantsAttribs.props, + ALERT_TYPE_CRITICAL_CVE, + ALERT_TYPE_CVE, + ALERT_TYPE_MEDIUM_CVE, + ALERT_TYPE_MILD_CVE, + API_V0_URL, + BINARY_LOCK_EXT, + BUN, + DOT_SOCKET_DOT_FACTS_JSON, + DRY_RUN_LABEL, + DRY_RUN_BAILING_NOW, + DRY_RUN_NOT_SAVING, + ENV: undefined, + LOCK_EXT, + NPM_BUGGY_OVERRIDES_PATCHED_VERSION, + NPM_REGISTRY_URL, + PNPM, + REDACTED, + SHADOW_NPM_BIN, + SHADOW_NPM_INJECT, + SOCKET, + SOCKET_CLI_ACCEPT_RISKS, + SOCKET_CLI_BIN_NAME, + SOCKET_CLI_BIN_NAME_ALIAS, + SOCKET_CLI_FIX, + SOCKET_CLI_ISSUES_URL, + SOCKET_CLI_SENTRY_BIN_NAME_ALIAS, + SOCKET_CLI_LEGACY_PACKAGE_NAME, + SOCKET_CLI_NPM_BIN_NAME, + SOCKET_CLI_NPX_BIN_NAME, + SOCKET_CLI_OPTIMIZE, + SOCKET_CLI_PACKAGE_NAME, + SOCKET_CLI_SAFE_BIN, + SOCKET_CLI_SAFE_PROGRESS, + SOCKET_CLI_SENTRY_BIN_NAME, + SOCKET_CLI_SENTRY_NPM_BIN_NAME, + SOCKET_CLI_SENTRY_NPX_BIN_NAME, + SOCKET_CLI_SENTRY_PACKAGE_NAME, + SOCKET_CLI_VIEW_ALL_RISKS, + SOCKET_WEBSITE_URL, + VLT, + WITH_SENTRY, + YARN, + YARN_BERRY, + YARN_CLASSIC, + YARN_LOCK, + bashRcPath: undefined, + binPath: undefined, + binCliPath: undefined, + blessedContribPath: undefined, + blessedOptions: undefined, + blessedPath: undefined, + coanaBinPath: undefined, + coanaPath: undefined, + distCliPath: undefined, + distPath: undefined, + externalPath: undefined, + githubCachePath: undefined, + homePath: undefined, + instrumentWithSentryPath: undefined, + minimumVersionByAgent: undefined, + nmBinPath: undefined, + nodeHardenFlags: undefined, + rootPath: undefined, + shadowBinPath: undefined, + shadowNpmInjectPath: undefined, + shadowNpmBinPath: undefined, + socketAppDataPath: undefined, + socketCachePath: undefined, + socketRegistryPath: undefined, + zshRcPath: undefined, + }, + { + getters: { + ...registryConstantsAttribs.getters, + ENV: LAZY_ENV, + bashRcPath: lazyBashRcPath, + binCliPath: lazyBinCliPath, + binPath: lazyBinPath, + blessedContribPath: lazyBlessedContribPath, + blessedOptions: lazyBlessedOptions, + blessedPath: lazyBlessedPath, + coanaBinPath: lazyCoanaBinPath, + coanaPath: lazyCoanaPath, + distCliPath: lazyDistCliPath, + distPath: lazyDistPath, + externalPath: lazyExternalPath, + githubCachePath: lazyGithubCachePath, + homePath: lazyHomePath, + instrumentWithSentryPath: lazyInstrumentWithSentryPath, + minimumVersionByAgent: lazyMinimumVersionByAgent, + nmBinPath: lazyNmBinPath, + nodeHardenFlags: lazyNodeHardenFlags, + rootPath: lazyRootPath, + shadowBinPath: lazyShadowBinPath, + shadowNpmBinPath: lazyShadowNpmBinPath, + shadowNpmInjectPath: lazyShadowNpmInjectPath, + socketAppDataPath: lazySocketAppDataPath, + socketCachePath: lazySocketCachePath, + socketRegistryPath: lazySocketRegistryPath, + zshRcPath: lazyZshRcPath, + }, + internals: { + ...registryConstantsAttribs.internals, + getIpc, + getSentry() { + return _Sentry + }, + setSentry(Sentry: Sentry): boolean { + if (_Sentry === undefined) { + _Sentry = Sentry + return true + } + return false + }, + }, + }, +) as Constants + +export default constants diff --git a/src/external/blessed-contrib/lib/layout/grid.mjs b/src/external/blessed-contrib/lib/layout/grid.mjs new file mode 100755 index 000000000..a15a4c7bc --- /dev/null +++ b/src/external/blessed-contrib/lib/layout/grid.mjs @@ -0,0 +1 @@ +export { default } from 'blessed-contrib/lib/layout/grid' diff --git a/src/external/blessed-contrib/lib/widget/charts/bar.mjs b/src/external/blessed-contrib/lib/widget/charts/bar.mjs new file mode 100755 index 000000000..b50202d1a --- /dev/null +++ b/src/external/blessed-contrib/lib/widget/charts/bar.mjs @@ -0,0 +1 @@ +export { default } from 'blessed-contrib/lib/widget/charts/bar' diff --git a/src/external/blessed-contrib/lib/widget/charts/line.mjs b/src/external/blessed-contrib/lib/widget/charts/line.mjs new file mode 100755 index 000000000..6667f0dc8 --- /dev/null +++ b/src/external/blessed-contrib/lib/widget/charts/line.mjs @@ -0,0 +1 @@ +export { default } from 'blessed-contrib/lib/widget/charts/line' diff --git a/src/external/blessed-contrib/lib/widget/table.mjs b/src/external/blessed-contrib/lib/widget/table.mjs new file mode 100755 index 000000000..758498ae4 --- /dev/null +++ b/src/external/blessed-contrib/lib/widget/table.mjs @@ -0,0 +1 @@ +export { default } from 'blessed-contrib/lib/widget/table' diff --git a/src/flags.mts b/src/flags.mts new file mode 100644 index 000000000..38d9504a4 --- /dev/null +++ b/src/flags.mts @@ -0,0 +1,77 @@ +import type { Flag } from 'meow' + +// TODO: not sure if I'm missing something but meow doesn't seem to expose this? +type StringFlag = Flag<'string', string> | Flag<'string', string[], true> +type BooleanFlag = Flag<'boolean', boolean> | Flag<'boolean', boolean[], true> +type NumberFlag = Flag<'number', number> | Flag<'number', number[], true> +type AnyFlag = StringFlag | BooleanFlag | NumberFlag + +// Note: we use this description in getFlagListOutput, meow doesn't care +export type MeowFlags = Record< + string, + AnyFlag & { description: string; hidden?: boolean } +> + +export const commonFlags: MeowFlags = { + config: { + type: 'string', + default: '', + hidden: true, + description: 'Override the local config with this JSON', + }, + dryRun: { + type: 'boolean', + default: false, + hidden: true, // Only show in root command + description: + 'Do input validation for a command and exit 0 when input is ok', + }, + help: { + type: 'boolean', + default: false, + shortFlag: 'h', + hidden: true, + description: 'Print this help', + }, + nobanner: { + // I know this would be `--no-banner` but that doesn't work with cdxgen. + // Mostly for internal usage anyways. + type: 'boolean', + default: false, + hidden: true, + description: 'Hide the Socket banner', + }, + version: { + type: 'boolean', + hidden: true, + description: 'Print the app version', + }, +} + +export const outputFlags: MeowFlags = { + json: { + type: 'boolean', + shortFlag: 'j', + default: false, + description: 'Output result as json', + }, + markdown: { + type: 'boolean', + shortFlag: 'm', + default: false, + description: 'Output result as markdown', + }, +} + +export const validationFlags: MeowFlags = { + all: { + type: 'boolean', + default: false, + description: 'Include all issues', + }, + strict: { + type: 'boolean', + default: false, + description: 'Exits with an error code if any matching issues are found', + }, +} diff --git a/src/instrument-with-sentry.mts b/src/instrument-with-sentry.mts new file mode 100644 index 000000000..c7576c8dc --- /dev/null +++ b/src/instrument-with-sentry.mts @@ -0,0 +1,58 @@ +// This should ONLY be included in the special Sentry build! +// Otherwise the Sentry dependency won't even be present in the manifest. + +import { createRequire } from 'node:module' + +import { logger } from '@socketsecurity/registry/lib/logger' + +const require = createRequire(import.meta.url) + +// Require constants with require(relConstantsPath) instead of require('./constants') +// so Rollup doesn't generate a constants2.js chunk. +const relConstantsPath = './constants' +const constants = require(relConstantsPath) + +// Lazily access constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD. +if (constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD) { + const Sentry = require('@sentry/node') + Sentry.init({ + onFatalError(error: Error) { + // Defer module loads until after Sentry.init is called. + if (constants.ENV.SOCKET_CLI_DEBUG) { + logger.fail('[DEBUG] [Sentry onFatalError]:', error) + } + }, + dsn: 'https://66736701db8e4ffac046bd09fa6aaced@o555220.ingest.us.sentry.io/4508846967619585', + enabled: true, + integrations: [], + }) + Sentry.setTag( + 'environment', + // Lazily access constants.ENV.INLINED_SOCKET_CLI_PUBLISHED_BUILD. + constants.ENV.INLINED_SOCKET_CLI_PUBLISHED_BUILD + ? 'pub' + : // Lazily access constants.ENV.NODE_ENV. + constants.ENV.NODE_ENV, + ) + Sentry.setTag( + 'version', + // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH. + constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH, + ) + // Lazily access constants.ENV.SOCKET_CLI_DEBUG. + if (constants.ENV.SOCKET_CLI_DEBUG) { + Sentry.setTag('debugging', true) + logger.info('[DEBUG] Set up Sentry.') + } else { + Sentry.setTag('debugging', false) + } + const { + kInternalsSymbol, + [kInternalsSymbol as unknown as 'Symbol(kInternalsSymbol)']: { setSentry }, + } = constants + setSentry(Sentry) +} +// Lazily access constants.ENV.SOCKET_CLI_DEBUG. +else if (constants.ENV.SOCKET_CLI_DEBUG) { + logger.info('[DEBUG] Sentry disabled explicitly.') +} diff --git a/src/shadow/npm/arborist-helpers.mts b/src/shadow/npm/arborist-helpers.mts new file mode 100644 index 000000000..1fb45cbd2 --- /dev/null +++ b/src/shadow/npm/arborist-helpers.mts @@ -0,0 +1,442 @@ +import semver from 'semver' + +import { PackageURL } from '@socketregistry/packageurl-js' +import { getManifestData } from '@socketsecurity/registry' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { hasOwn } from '@socketsecurity/registry/lib/objects' +import { fetchPackagePackument } from '@socketsecurity/registry/lib/packages' + +import constants from '../../constants.mts' +import { Edge } from './arborist/index.mts' +import { DiffAction } from './arborist/types.mts' +import { getAlertsMapFromPurls } from '../../utils/alerts-map.mts' +import { type AliasResult, npa } from '../../utils/npm-package-arg.mts' +import { applyRange, getMajor, getMinVersion } from '../../utils/semver.mts' +import { idToNpmPurl } from '../../utils/spec.mts' + +import type { + ArboristInstance, + Diff, + EdgeClass, + LinkClass, + NodeClass, +} from './arborist/types.mts' +import type { RangeStyle } from '../../utils/semver.mts' +import type { + AlertIncludeFilter, + AlertsByPurl, +} from '../../utils/socket-package-alert.mts' +import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +const { LOOP_SENTINEL, NPM, NPM_REGISTRY_URL } = constants + +function getUrlOrigin(input: string): string { + try { + // TODO: URL.parse is available in Node 22.1.0. We can use it when we drop Node 18. + // https://nodejs.org/docs/latest-v22.x/api/url.html#urlparseinput-base + // return URL.parse(input)?.origin ?? '' + return new URL(input).origin ?? '' + } catch {} + return '' +} + +export function findBestPatchVersion( + node: NodeClass, + availableVersions: string[], + vulnerableVersionRange?: string, +): string | null { + const manifestData = getManifestData(NPM, node.name) + let eligibleVersions + if (manifestData && manifestData.name === manifestData.package) { + const major = getMajor(manifestData.version) + if (typeof major !== 'number') { + return null + } + eligibleVersions = availableVersions.filter(v => getMajor(v) === major) + } else { + const major = getMajor(node.version) + if (typeof major !== 'number') { + return null + } + eligibleVersions = availableVersions.filter( + v => + // Filter for versions that are within the current major version and + // are NOT in the vulnerable range. + getMajor(v) === major && + (!vulnerableVersionRange || + !semver.satisfies(v, vulnerableVersionRange)), + ) + } + return eligibleVersions ? semver.maxSatisfying(eligibleVersions, '*') : null +} + +export function findPackageNode( + tree: NodeClass, + name: string, + version?: string | undefined, +): NodeClass | undefined { + const queue: Array<NodeClass | LinkClass> = [tree] + const visited = new Set<NodeClass>() + let sentinel = 0 + while (queue.length) { + if (sentinel++ === LOOP_SENTINEL) { + throw new Error('Detected infinite loop in findPackageNode') + } + const nodeOrLink = queue.pop()! + const node = getTargetNode(nodeOrLink) + if (visited.has(node)) { + continue + } + visited.add(node) + if ( + node.name === name && + (typeof version !== 'string' || node.version === version) + ) { + return node + } + for (const child of node.children.values()) { + queue.push(child) + } + for (const edge of node.edgesOut.values()) { + const { to } = edge + if (to) { + queue.push(to) + } + } + } + return undefined +} + +export function findPackageNodes( + tree: NodeClass, + name: string, + version?: string | undefined, +): NodeClass[] { + const matches: NodeClass[] = [] + const queue: Array<NodeClass | LinkClass> = [tree] + const visited = new Set<NodeClass>() + let sentinel = 0 + while (queue.length) { + if (sentinel++ === LOOP_SENTINEL) { + throw new Error('Detected infinite loop in findPackageNodes') + } + const nodeOrLink = queue.pop()! + const node = getTargetNode(nodeOrLink) + if (visited.has(node)) { + continue + } + visited.add(node) + + const { version: targetVersion } = node + if (!targetVersion && Array.isArray(node.errors) && node.errors.length) { + debugFn(`miss: version for ${node.name} due to errors:\n`, node.errors) + } + if ( + node.name === name && + (typeof version !== 'string' || node.version === version) + ) { + matches.push(node) + } + for (const child of node.children.values()) { + queue.push(child) + } + for (const edge of node.edgesOut.values()) { + const { to } = edge + if (to) { + queue.push(to) + } + } + } + return matches +} + +export type GetAlertsMapFromArboristOptions = { + consolidate?: boolean | undefined + include?: AlertIncludeFilter | undefined + nothrow?: boolean | undefined + spinner?: Spinner | undefined +} + +export async function getAlertsMapFromArborist( + arb: ArboristInstance, + options_?: GetAlertsMapFromArboristOptions | undefined, +): Promise<AlertsByPurl> { + const options = { + __proto__: null, + consolidate: false, + include: undefined, + limit: Infinity, + nothrow: false, + ...options_, + } as GetAlertsMapFromArboristOptions + + options.include = { + __proto__: null, + // Leave 'actions' unassigned so it can be given a default value in + // subsequent functions where `options` is passed. + // actions: undefined, + blocked: true, + critical: true, + cve: true, + existing: false, + unfixable: true, + upgradable: false, + ...options.include, + } as AlertIncludeFilter + + const needInfoOn = getDetailsFromDiff(arb.diff, { + include: { + unchanged: options.include.existing, + }, + }) + + const purls = needInfoOn.map(d => idToNpmPurl(d.node.pkgid)) + + let overrides: { [key: string]: string } | undefined + const overridesMap = ( + arb.actualTree ?? + arb.idealTree ?? + (await arb.loadActual()) + )?.overrides?.children + if (overridesMap) { + overrides = Object.fromEntries( + Array.from(overridesMap.entries()).map(([key, overrideSet]) => { + return [key, overrideSet.value!] + }), + ) + } + + return await getAlertsMapFromPurls(purls, { + overrides, + ...options, + }) +} + +export type DiffQueryIncludeFilter = { + unchanged?: boolean | undefined + unknownOrigin?: boolean | undefined +} + +export type DiffQueryOptions = { + include?: DiffQueryIncludeFilter | undefined +} + +export type PackageDetail = { + node: NodeClass + existing?: NodeClass | undefined +} + +export function getDetailsFromDiff( + diff_: Diff | null, + options?: DiffQueryOptions | undefined, +): PackageDetail[] { + const details: PackageDetail[] = [] + // `diff_` is `null` when `npm install --package-lock-only` is passed. + if (!diff_) { + return details + } + + const include = { + __proto__: null, + unchanged: false, + unknownOrigin: false, + ...({ __proto__: null, ...options } as DiffQueryOptions).include, + } as DiffQueryIncludeFilter + + const queue: Diff[] = [...diff_.children] + let pos = 0 + let { length: queueLength } = queue + while (pos < queueLength) { + if (pos === LOOP_SENTINEL) { + throw new Error('Detected infinite loop while walking Arborist diff') + } + const diff = queue[pos++]! + const { action } = diff + if (action) { + // The `pkgNode`, i.e. the `ideal` node, will be `undefined` if the diff + // action is 'REMOVE' + // The `oldNode`, i.e. the `actual` node, will be `undefined` if the diff + // action is 'ADD'. + const { actual: oldNode, ideal: pkgNode } = diff + let existing: NodeClass | undefined + let keep = false + if (action === DiffAction.change) { + if (pkgNode?.package.version !== oldNode?.package.version) { + keep = true + if ( + oldNode?.package.name && + oldNode.package.name === pkgNode?.package.name + ) { + existing = oldNode + } + } else { + // TODO: This debug log has too much information. We should narrow it down. + // debugFn('skip: meta change diff\n', diff) + } + } else { + keep = action !== DiffAction.remove + } + if (keep && pkgNode?.resolved && (!oldNode || oldNode.resolved)) { + if ( + include.unknownOrigin || + getUrlOrigin(pkgNode.resolved) === NPM_REGISTRY_URL + ) { + details.push({ + node: pkgNode, + existing, + }) + } + } + } + for (const child of diff.children) { + queue[queueLength++] = child + } + } + if (include.unchanged) { + const { unchanged } = diff_! + for (let i = 0, { length } = unchanged; i < length; i += 1) { + const pkgNode = unchanged[i]! + if ( + include.unknownOrigin || + getUrlOrigin(pkgNode.resolved!) === NPM_REGISTRY_URL + ) { + details.push({ + node: pkgNode, + existing: pkgNode, + }) + } + } + } + return details +} + +export function getTargetNode(nodeOrLink: NodeClass | LinkClass): NodeClass +export function getTargetNode<T>(nodeOrLink: T): NodeClass | null +export function getTargetNode(nodeOrLink: any): NodeClass | null { + return nodeOrLink?.isLink ? nodeOrLink.target : (nodeOrLink ?? null) +} + +export function isTopLevel(tree: NodeClass, node: NodeClass): boolean { + return getTargetNode(tree.children.get(node.name)) === node +} + +export type Packument = Exclude< + Awaited<ReturnType<typeof fetchPackagePackument>>, + null +> + +export function updateNode( + node: NodeClass, + newVersion: string, + newVersionPackument: Packument['versions'][number], +): void { + // Object.defineProperty is needed to set the version property and replace + // the old value with newVersion. + Object.defineProperty(node, 'version', { + configurable: true, + enumerable: true, + get: () => newVersion, + }) + // Update package.version associated with the node. + node.package.version = newVersion + // Update node.resolved. + const purlObj = PackageURL.fromString(idToNpmPurl(node.name)) + node.resolved = `${NPM_REGISTRY_URL}/${node.name}/-/${purlObj.name}-${newVersion}.tgz` + // Update node.integrity with the targetPackument.dist.integrity value if available + // else delete node.integrity so a new value is resolved for the target version. + const { integrity } = newVersionPackument.dist + if (integrity) { + node.integrity = integrity + } else { + delete node.integrity + } + // Update node.package.deprecated based on targetPackument.deprecated. + if (hasOwn(newVersionPackument, 'deprecated')) { + node.package['deprecated'] = newVersionPackument.deprecated as string + } else { + delete node.package['deprecated'] + } + // Update node.package.dependencies. + const newDeps = { ...newVersionPackument.dependencies } + const { dependencies: oldDeps } = node.package + node.package.dependencies = newDeps + if (oldDeps) { + for (const oldDepName of Object.keys(oldDeps)) { + if (!hasOwn(newDeps, oldDepName)) { + // Detach old edges for dependencies that don't exist on the updated + // node.package.dependencies. + node.edgesOut.get(oldDepName)?.detach() + } + } + } + for (const newDepName of Object.keys(newDeps)) { + if (!hasOwn(oldDeps, newDepName)) { + // Add new edges for dependencies that don't exist on the old + // node.package.dependencies. + node.addEdgeOut( + new Edge({ + from: node, + name: newDepName, + spec: newDeps[newDepName], + type: 'prod', + }) as unknown as EdgeClass, + ) + } + } +} + +export function updatePackageJsonFromNode( + editablePkgJson: EditablePackageJson, + tree: NodeClass, + node: NodeClass, + newVersion: string, + rangeStyle?: RangeStyle | undefined, +): boolean { + let result = false + if (!isTopLevel(tree, node)) { + return result + } + const { name } = node + for (const depField of [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { + const depObject = editablePkgJson.content[depField] as + | { [key: string]: string } + | undefined + const depValue = hasOwn(depObject, name) ? depObject[name] : undefined + if (typeof depValue !== 'string' || depValue.startsWith('catalog:')) { + continue + } + let oldRange = depValue + // Use npa if depValue looks like more than just a semver range. + if (depValue.includes(':')) { + const npaResult = npa(depValue) + if (!npaResult || (npaResult as AliasResult).subSpec) { + continue + } + oldRange = npaResult.rawSpec + } + const oldMin = getMinVersion(oldRange) + const newRange = + oldMin && + // Ensure we're on the same major version... + getMajor(newVersion) === oldMin.major && + // and not a downgrade. + semver.gte(newVersion, oldMin.version) + ? applyRange(oldRange, newVersion, rangeStyle) + : oldRange + if (oldRange !== newRange) { + result = true + editablePkgJson.update({ + [depField]: { + ...depObject, + [name]: newRange, + }, + }) + } + } + return result +} diff --git a/src/shadow/npm/arborist/index.mts b/src/shadow/npm/arborist/index.mts new file mode 100755 index 000000000..0469905d4 --- /dev/null +++ b/src/shadow/npm/arborist/index.mts @@ -0,0 +1,50 @@ +import { createRequire } from 'node:module' + +// @ts-ignore +import UntypedEdge from '@npmcli/arborist/lib/edge.js' +// @ts-ignore +import UntypedNode from '@npmcli/arborist/lib/node.js' +// @ts-ignore +import UntypedOverrideSet from '@npmcli/arborist/lib/override-set.js' + +import { + getArboristClassPath, + getArboristEdgeClassPath, + getArboristNodeClassPath, + getArboristOverrideSetClassPath, +} from '../paths.mts' +import { Arborist, SafeArborist } from './lib/arborist/index.mts' + +import type { EdgeClass, NodeClass, OverrideSetClass } from './types.mts' + +const require = createRequire(import.meta.url) + +export const SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES = { + __proto__: null, + audit: false, + dryRun: true, + fund: false, + ignoreScripts: true, + progress: false, + save: false, + saveBundle: false, + silent: true, +} + +export { Arborist, SafeArborist } + +export const Edge: EdgeClass = UntypedEdge + +export const Node: NodeClass = UntypedNode + +export const OverrideSet: OverrideSetClass = UntypedOverrideSet + +export function installSafeArborist() { + // Override '@npmcli/arborist' module exports with patched variants based on + // https://github.com/npm/cli/pull/8089. + const cache: { [key: string]: any } = require.cache + cache[getArboristClassPath()] = { exports: SafeArborist } + cache[getArboristEdgeClassPath()] = { exports: Edge } + cache[getArboristNodeClassPath()] = { exports: Node } + cache[getArboristOverrideSetClassPath()] = { exports: OverrideSet } +} diff --git a/src/shadow/npm/arborist/lib/arborist/index.mts b/src/shadow/npm/arborist/lib/arborist/index.mts new file mode 100755 index 000000000..7d25a43ea --- /dev/null +++ b/src/shadow/npm/arborist/lib/arborist/index.mts @@ -0,0 +1,164 @@ +// @ts-ignore +import UntypedArborist from '@npmcli/arborist/lib/arborist/index.js' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../../../../../constants.mts' +import { logAlertsMap } from '../../../../../utils/socket-package-alert.mts' +import { getAlertsMapFromArborist } from '../../../arborist-helpers.mts' + +import type { + ArboristClass, + ArboristReifyOptions, + NodeClass, +} from '../../types.mts' + +const { + NPM, + NPX, + SOCKET_CLI_ACCEPT_RISKS, + SOCKET_CLI_SAFE_BIN, + SOCKET_CLI_SAFE_PROGRESS, + SOCKET_CLI_VIEW_ALL_RISKS, + kInternalsSymbol, + [kInternalsSymbol as unknown as 'Symbol(kInternalsSymbol)']: { getIpc }, +} = constants + +export const SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES = { + __proto__: null, + audit: false, + dryRun: true, + fund: false, + ignoreScripts: true, + progress: false, + save: false, + saveBundle: false, + silent: true, +} + +export const kCtorArgs = Symbol('ctorArgs') + +export const kRiskyReify = Symbol('riskyReify') + +export const Arborist: ArboristClass = UntypedArborist + +// Implementation code not related to our custom behavior is based on +// https://github.com/npm/cli/blob/v11.0.0/workspaces/arborist/lib/arborist/index.js: +export class SafeArborist extends Arborist { + constructor(...ctorArgs: ConstructorParameters<ArboristClass>) { + super( + { + path: + (ctorArgs.length ? ctorArgs[0]?.path : undefined) ?? process.cwd(), + ...(ctorArgs.length ? ctorArgs[0] : undefined), + ...SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, + }, + ...ctorArgs.slice(1), + ) + ;(this as any)[kCtorArgs] = ctorArgs + } + + async [kRiskyReify]( + ...args: Parameters<InstanceType<ArboristClass>['reify']> + ): Promise<NodeClass> { + const ctorArgs = (this as any)[kCtorArgs] + const arb = new Arborist( + { + ...(ctorArgs.length ? ctorArgs[0] : undefined), + progress: false, + }, + ...ctorArgs.slice(1), + ) + const ret = await (arb.reify as (...args: any[]) => Promise<NodeClass>)( + { + ...(args.length ? args[0] : undefined), + progress: false, + }, + ...args.slice(1), + ) + Object.assign(this, arb) + return ret + } + + // @ts-ignore Incorrectly typed. + override async reify( + this: SafeArborist, + ...args: Parameters<InstanceType<ArboristClass>['reify']> + ): Promise<NodeClass> { + const options = { + __proto__: null, + ...(args.length ? args[0] : undefined), + } as ArboristReifyOptions + const ipc = await getIpc() + const binName = ipc[SOCKET_CLI_SAFE_BIN] + if (!binName) { + return await this[kRiskyReify](...args) + } + await super.reify( + { + ...options, + ...SAFE_ARBORIST_REIFY_OPTIONS_OVERRIDES, + progress: false, + }, + // @ts-ignore: TypeScript gets grumpy about rest parameters. + ...args.slice(1), + ) + // Lazily access constants.ENV.SOCKET_CLI_ACCEPT_RISKS. + const acceptRisks = constants.ENV.SOCKET_CLI_ACCEPT_RISKS + const progress = ipc[SOCKET_CLI_SAFE_PROGRESS] + const spinner = + options['silent'] || !progress + ? undefined + : // Lazily access constants.spinner. + constants.spinner + const isSafeNpm = binName === NPM + const isSafeNpx = binName === NPX + const alertsMap = await getAlertsMapFromArborist(this, { + spinner, + include: + acceptRisks || options.dryRun || options['yes'] + ? { + actions: ['error'], + blocked: true, + critical: false, + cve: false, + existing: true, + unfixable: false, + } + : { + existing: isSafeNpx, + unfixable: isSafeNpm, + }, + }) + if (alertsMap.size) { + process.exitCode = 1 + // Lazily access constants.ENV.SOCKET_CLI_VIEW_ALL_RISKS. + const viewAllRisks = constants.ENV.SOCKET_CLI_VIEW_ALL_RISKS + logAlertsMap(alertsMap, { + hideAt: viewAllRisks ? 'none' : 'middle', + output: process.stderr, + }) + throw new Error( + ` + Socket ${binName} exiting due to risks.${ + viewAllRisks + ? '' + : `\nView all risks - Rerun with environment variable ${SOCKET_CLI_VIEW_ALL_RISKS}=1.` + }${ + acceptRisks + ? '' + : `\nAccept risks - Rerun with environment variable ${SOCKET_CLI_ACCEPT_RISKS}=1.` + } + `.trim(), + ) + } else if (!options['silent']) { + logger.success( + `Socket ${binName} ${acceptRisks ? 'accepted' : 'found no'} risks`, + ) + if (binName === NPX) { + logger.log(`Running ${options.add![0]}`) + } + } + return await this[kRiskyReify](...args) + } +} diff --git a/src/shadow/npm/arborist/types.mts b/src/shadow/npm/arborist/types.mts new file mode 100755 index 000000000..363b83acc --- /dev/null +++ b/src/shadow/npm/arborist/types.mts @@ -0,0 +1,222 @@ +import { createEnum } from '../../../utils/objects.mts' + +import type { + Advisory as BaseAdvisory, + Arborist as BaseArborist, + Options as BaseArboristOptions, + AuditReport as BaseAuditReport, + Diff as BaseDiff, + Edge as BaseEdge, + Node as BaseNode, + BaseOverrideSet, + BuildIdealTreeOptions, + ReifyOptions, +} from '@npmcli/arborist' + +export type ArboristOptions = BaseArboristOptions & { + npmCommand?: string + npmVersion?: string +} + +export type ArboristClass = ArboristInstance & { + new (...args: any): ArboristInstance +} + +export type ArboristInstance = Omit< + typeof BaseArborist, + | 'actualTree' + | 'auditReport' + | 'buildIdealTree' + | 'diff' + | 'idealTree' + | 'loadActual' + | 'loadVirtual' + | 'reify' +> & { + auditReport?: AuditReportInstance | null | undefined + actualTree?: NodeClass | null | undefined + diff: Diff | null + idealTree?: NodeClass | null | undefined + buildIdealTree(options?: BuildIdealTreeOptions): Promise<NodeClass> + loadActual(options?: ArboristOptions): Promise<NodeClass> + loadVirtual(options?: ArboristOptions): Promise<NodeClass> + reify(options?: ArboristReifyOptions): Promise<NodeClass> +} + +export type ArboristReifyOptions = ReifyOptions & ArboristOptions + +export type AuditAdvisory = Omit<BaseAdvisory, 'id'> & { + id: number + cwe: string[] + cvss: { + score: number + vectorString: string + } + vulnerable_versions: string +} + +export type AuditReportInstance = Omit<BaseAuditReport, 'report'> & { + report: { [dependency: string]: AuditAdvisory[] } +} + +export const DiffAction = createEnum({ + add: 'ADD', + change: 'CHANGE', + remove: 'REMOVE', +}) + +export type Diff = Omit< + BaseDiff, + | 'actual' + | 'children' + | 'filterSet' + | 'ideal' + | 'leaves' + | 'removed' + | 'shrinkwrapInflated' + | 'unchanged' +> & { + actual: NodeClass + children: Diff[] + filterSet: Set<NodeClass> + ideal: NodeClass + leaves: NodeClass[] + parent: Diff | null + removed: NodeClass[] + shrinkwrapInflated: Set<NodeClass> + unchanged: NodeClass[] +} + +export type EdgeClass = Omit< + BaseEdge, + | 'accept' + | 'detach' + | 'optional' + | 'overrides' + | 'peer' + | 'peerConflicted' + | 'rawSpec' + | 'reload' + | 'satisfiedBy' + | 'spec' + | 'to' +> & { + optional: boolean + overrides: OverrideSetClass | undefined + peer: boolean + peerConflicted: boolean + rawSpec: string + get accept(): string | undefined + get spec(): string + get to(): NodeClass | null + new (...args: any): EdgeClass + detach(): void + reload(hard?: boolean): void + satisfiedBy(node: NodeClass): boolean +} + +export type LinkClass = Omit<NodeClass, 'isLink'> & { + readonly isLink: true +} + +export type NodeClass = Omit< + BaseNode, + | 'addEdgeIn' + | 'addEdgeOut' + | 'canDedupe' + | 'canReplace' + | 'canReplaceWith' + | 'children' + | 'deleteEdgeIn' + | 'edgesIn' + | 'edgesOut' + | 'from' + | 'hasShrinkwrap' + | 'inDepBundle' + | 'inShrinkwrap' + | 'integrity' + | 'isTop' + | 'matches' + | 'meta' + | 'name' + | 'overrides' + | 'packageName' + | 'parent' + | 'recalculateOutEdgesOverrides' + | 'resolve' + | 'resolveParent' + | 'root' + | 'target' + | 'updateOverridesEdgeInAdded' + | 'updateOverridesEdgeInRemoved' + | 'version' + | 'versions' +> & { + name: string + version: string + children: Map<string, NodeClass | LinkClass> + edgesIn: Set<EdgeClass> + edgesOut: Map<string, EdgeClass> + from: NodeClass | null + hasShrinkwrap: boolean + inShrinkwrap: boolean | undefined + integrity?: string | null + isTop: boolean | undefined + meta: BaseNode['meta'] & { + addEdge(edge: EdgeClass): void + } + overrides: OverrideSetClass | undefined + target: NodeClass + versions: string[] + get inDepBundle(): boolean + get packageName(): string | null + get parent(): NodeClass | null + set parent(value: NodeClass | null) + get resolveParent(): NodeClass | null + get root(): NodeClass | null + set root(value: NodeClass | null) + new (...args: any): NodeClass + addEdgeIn(edge: EdgeClass): void + addEdgeOut(edge: EdgeClass): void + canDedupe(preferDedupe?: boolean): boolean + canReplace(node: NodeClass, ignorePeers?: string[]): boolean + canReplaceWith(node: NodeClass, ignorePeers?: string[]): boolean + deleteEdgeIn(edge: EdgeClass): void + matches(node: NodeClass): boolean + recalculateOutEdgesOverrides(): void + resolve(name: string): NodeClass + updateOverridesEdgeInAdded( + otherOverrideSet: OverrideSetClass | undefined, + ): boolean + updateOverridesEdgeInRemoved(otherOverrideSet: OverrideSetClass): boolean +} + +export interface OverrideSetClass + extends Omit< + BaseOverrideSet, + | 'ancestry' + | 'children' + | 'getEdgeRule' + | 'getMatchingRule' + | 'getNodeRule' + | 'parent' + | 'ruleset' + > { + children: Map<string, OverrideSetClass> + key: string | undefined + keySpec: string | undefined + name: string | undefined + parent: OverrideSetClass | undefined + value: string | undefined + version: string | undefined + // eslint-disable-next-line @typescript-eslint/no-misused-new + new (...args: any[]): OverrideSetClass + get isRoot(): boolean + get ruleset(): Map<string, OverrideSetClass> + ancestry(): Generator<OverrideSetClass> + childrenAreEqual(otherOverrideSet: OverrideSetClass | undefined): boolean + getEdgeRule(edge: EdgeClass): OverrideSetClass + getMatchingRule(node: NodeClass): OverrideSetClass | null + getNodeRule(node: NodeClass): OverrideSetClass + isEqual(otherOverrideSet: OverrideSetClass | undefined): boolean +} diff --git a/src/shadow/npm/bin.mts b/src/shadow/npm/bin.mts new file mode 100755 index 000000000..cbbe45dd1 --- /dev/null +++ b/src/shadow/npm/bin.mts @@ -0,0 +1,126 @@ +import { isDebug } from '@socketsecurity/registry/lib/debug' +import { + isLoglevelFlag, + isNodeOptionsFlag, + isProgressFlag, +} from '@socketsecurity/registry/lib/npm' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { installLinks } from './link.mts' +import constants from '../../constants.mts' +import { cmdFlagsToString } from '../../utils/cmd.mts' + +import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' + +const { SOCKET_CLI_SAFE_BIN, SOCKET_CLI_SAFE_PROGRESS, SOCKET_IPC_HANDSHAKE } = + constants + +export default async function shadowBin( + binName: 'npm' | 'npx', + args = process.argv.slice(2), +) { + process.exitCode = 1 + // Lazily access constants.ENV.NODE_COMPILE_CACHE + const { NODE_COMPILE_CACHE } = constants.ENV + const terminatorPos = args.indexOf('--') + const rawBinArgs = terminatorPos === -1 ? args : args.slice(0, terminatorPos) + const binArgs = rawBinArgs.filter( + a => !isProgressFlag(a) && !isNodeOptionsFlag(a), + ) + const nodeOptionsArg = rawBinArgs.findLast(isNodeOptionsFlag) + const progressArg = rawBinArgs.findLast(isProgressFlag) !== '--no-progress' + const otherArgs = terminatorPos === -1 ? [] : args.slice(terminatorPos) + const permArgs = + binName === 'npm' && + // Lazily access constants.SUPPORTS_NODE_PERMISSION_FLAG. + constants.SUPPORTS_NODE_PERMISSION_FLAG + ? await (async () => { + const cwd = process.cwd() + const stdioPipeOptions: SpawnOptions = { cwd } + const globalPrefix = ( + await spawn('npm', ['prefix', '-g'], stdioPipeOptions) + ).stdout.trim() + const npmCachePath = ( + await spawn('npm', ['config', 'get', 'cache'], stdioPipeOptions) + ).stdout.trim() + return [ + '--permission', + '--allow-child-process', + // '--allow-addons', + // '--allow-wasi', + // Allow all reads because npm walks up directories looking for config + // and package.json files. + '--allow-fs-read=*', + `--allow-fs-write=${cwd}/*`, + `--allow-fs-write=${globalPrefix}/*`, + `--allow-fs-write=${npmCachePath}/*`, + ] + })() + : [] + const useDebug = isDebug() + const useNodeOptions = nodeOptionsArg || permArgs.length + const isSilent = !useDebug && !binArgs.some(isLoglevelFlag) + // The default value of loglevel is "notice". We default to "error" which is + // two levels quieter. + const logLevelArgs = isSilent ? ['--loglevel', 'error'] : [] + const spawnPromise = spawn( + // Lazily access constants.execPath. + constants.execPath, + [ + // Lazily access constants.nodeHardenFlags. + ...constants.nodeHardenFlags, + // Lazily access constants.nodeNoWarningsFlags. + ...constants.nodeNoWarningsFlags, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD. + ...(constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD + ? [ + '--require', + // Lazily access constants.instrumentWithSentryPath. + constants.instrumentWithSentryPath, + ] + : []), + '--require', + // Lazily access constants.shadowNpmInjectPath. + constants.shadowNpmInjectPath, + // Lazily access constants.shadowBinPath. + await installLinks(constants.shadowBinPath, binName), + ...(useDebug ? ['--trace-uncaught', '--trace-warnings'] : []), + ...(useNodeOptions + ? [ + `--node-options='${nodeOptionsArg ? nodeOptionsArg.slice(15) : ''}${cmdFlagsToString(permArgs)}'`, + ] + : []), + // Add '--no-progress' to fix input being swallowed by the npm spinner. + '--no-progress', + // Add '--loglevel=error' if a loglevel flag is not provided and the + // SOCKET_CLI_DEBUG environment variable is not truthy. + ...logLevelArgs, + ...binArgs, + ...otherArgs, + ], + { + env: { + ...process.env, + ...(NODE_COMPILE_CACHE ? { NODE_COMPILE_CACHE } : undefined), + }, + // 'inherit' + 'ipc' + stdio: [0, 1, 2, 'ipc'], + }, + ) + // See https://nodejs.org/api/child_process.html#event-exit. + spawnPromise.process.on('exit', (code, signalName) => { + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + // eslint-disable-next-line n/no-process-exit + process.exit(code) + } + }) + spawnPromise.process.send({ + [SOCKET_IPC_HANDSHAKE]: { + [SOCKET_CLI_SAFE_BIN]: binName, + [SOCKET_CLI_SAFE_PROGRESS]: progressArg, + }, + }) + await spawnPromise +} diff --git a/src/shadow/npm/inject.mts b/src/shadow/npm/inject.mts new file mode 100644 index 000000000..9e0671109 --- /dev/null +++ b/src/shadow/npm/inject.mts @@ -0,0 +1,3 @@ +import { installSafeArborist } from './arborist/index.mts' + +installSafeArborist() diff --git a/src/shadow/npm/install.mts b/src/shadow/npm/install.mts new file mode 100644 index 000000000..ce4dff023 --- /dev/null +++ b/src/shadow/npm/install.mts @@ -0,0 +1,119 @@ +import { isDebug } from '@socketsecurity/registry/lib/debug' +import { + isAuditFlag, + isFundFlag, + isLoglevelFlag, + isProgressFlag, + realExecPathSync, +} from '@socketsecurity/registry/lib/npm' +import { isObject } from '@socketsecurity/registry/lib/objects' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../../constants.mts' +import { getNpmBinPath } from '../../utils/npm-paths.mts' + +import type { SpawnResult } from '@socketsecurity/registry/lib/spawn' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +const { + NPM, + SOCKET_CLI_SAFE_BIN, + SOCKET_CLI_SAFE_PROGRESS, + SOCKET_IPC_HANDSHAKE, +} = constants + +type SpawnOption = Exclude<Parameters<typeof spawn>[2], undefined> + +export type SafeNpmInstallOptions = SpawnOption & { + agentExecPath?: string | undefined + args?: string[] | readonly string[] | undefined + ipc?: object | undefined + spinner?: Spinner | undefined +} + +export function safeNpmInstall( + options?: SafeNpmInstallOptions, +): SpawnResult<string, Record<any, any> | undefined> { + const { + agentExecPath = getNpmBinPath(), + args = [], + ipc, + spinner, + ...spawnOptions + } = { __proto__: null, ...options } as SafeNpmInstallOptions + // Lazily access constants.ENV.NODE_COMPILE_CACHE + const { NODE_COMPILE_CACHE } = constants.ENV + let stdio = spawnOptions.stdio + const useIpc = isObject(ipc) + // Include 'ipc' in the spawnOptions.stdio when an options.ipc object is provided. + // See https://github.com/nodejs/node/blob/v23.6.0/lib/child_process.js#L161-L166 + // and https://github.com/nodejs/node/blob/v23.6.0/lib/internal/child_process.js#L238. + if (typeof stdio === 'string') { + stdio = useIpc ? [stdio, stdio, stdio, 'ipc'] : [stdio, stdio, stdio] + } else if (useIpc && Array.isArray(stdio) && !stdio.includes('ipc')) { + stdio = stdio.concat('ipc') + } + const useDebug = isDebug() + const terminatorPos = args.indexOf('--') + const rawBinArgs = terminatorPos === -1 ? args : args.slice(0, terminatorPos) + const progressArg = rawBinArgs.findLast(isProgressFlag) !== '--no-progress' + const binArgs = rawBinArgs.filter( + a => !isAuditFlag(a) && !isFundFlag(a) && !isProgressFlag(a), + ) + const otherArgs = terminatorPos === -1 ? [] : args.slice(terminatorPos) + const isSilent = !useDebug && !binArgs.some(isLoglevelFlag) + const logLevelArgs = isSilent ? ['--loglevel', 'silent'] : [] + const spawnPromise = spawn( + // Lazily access constants.execPath. + constants.execPath, + [ + // Lazily access constants.nodeHardenFlags. + ...constants.nodeHardenFlags, + // Lazily access constants.nodeNoWarningsFlags. + ...constants.nodeNoWarningsFlags, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD. + ...(constants.ENV.INLINED_SOCKET_CLI_SENTRY_BUILD + ? [ + '--require', + // Lazily access constants.instrumentWithSentryPath. + constants.instrumentWithSentryPath, + ] + : []), + '--require', + // Lazily access constants.shadowNpmInjectPath. + constants.shadowNpmInjectPath, + realExecPathSync(agentExecPath), + 'install', + // Avoid code paths for 'audit' and 'fund'. + '--no-audit', + '--no-fund', + // Add '--no-progress' to fix input being swallowed by the npm spinner. + '--no-progress', + // Add '--loglevel=silent' if a loglevel flag is not provided and the + // SOCKET_CLI_DEBUG environment variable is not truthy. + ...logLevelArgs, + ...binArgs, + ...otherArgs, + ], + { + spinner, + ...spawnOptions, + stdio, + env: { + ...process.env, + ...(NODE_COMPILE_CACHE ? { NODE_COMPILE_CACHE } : undefined), + ...spawnOptions.env, + }, + }, + ) + if (useIpc) { + spawnPromise.process.send({ + [SOCKET_IPC_HANDSHAKE]: { + [SOCKET_CLI_SAFE_BIN]: NPM, + [SOCKET_CLI_SAFE_PROGRESS]: progressArg, + ...ipc, + }, + }) + } + return spawnPromise +} diff --git a/src/shadow/npm/link.mts b/src/shadow/npm/link.mts new file mode 100644 index 000000000..87b8a46db --- /dev/null +++ b/src/shadow/npm/link.mts @@ -0,0 +1,40 @@ +import path from 'node:path' + +import cmdShim from 'cmd-shim' + +import constants from '../../constants.mts' +import { + getNpmBinPath, + getNpxBinPath, + isNpmBinPathShadowed, + isNpxBinPathShadowed, +} from '../../utils/npm-paths.mts' + +export async function installLinks( + realBinPath: string, + binName: 'npm' | 'npx', +): Promise<string> { + const isNpx = binName === 'npx' + // Find package manager being shadowed by this process. + const binPath = isNpx ? getNpxBinPath() : getNpmBinPath() + // Lazily access constants.WIN32. + const { WIN32 } = constants + // TODO: Is this early exit needed? + if (WIN32 && binPath) { + return binPath + } + const shadowed = isNpx ? isNpxBinPathShadowed() : isNpmBinPathShadowed() + // Move our bin directory to front of PATH so its found first. + if (!shadowed) { + if (WIN32) { + await cmdShim( + // Lazily access constants.distPath. + path.join(constants.distPath, `${binName}-cli.js`), + path.join(realBinPath, binName), + ) + } + const { env } = process + env['PATH'] = `${realBinPath}${path.delimiter}${env['PATH']}` + } + return binPath +} diff --git a/src/shadow/npm/paths.mts b/src/shadow/npm/paths.mts new file mode 100644 index 000000000..5d4839fdd --- /dev/null +++ b/src/shadow/npm/paths.mts @@ -0,0 +1,74 @@ +import path from 'node:path' + +import { normalizePath } from '@socketsecurity/registry/lib/path' + +import constants from '../../constants.mts' +import { getNpmRequire } from '../../utils/npm-paths.mts' + +let _arboristPkgPath: string | undefined +export function getArboristPackagePath() { + if (_arboristPkgPath === undefined) { + const pkgName = '@npmcli/arborist' + const mainPathWithForwardSlashes = normalizePath( + getNpmRequire().resolve(pkgName), + ) + const arboristPkgPathWithForwardSlashes = mainPathWithForwardSlashes.slice( + 0, + mainPathWithForwardSlashes.lastIndexOf(pkgName) + pkgName.length, + ) + // Lazily access constants.WIN32. + _arboristPkgPath = constants.WIN32 + ? path.normalize(arboristPkgPathWithForwardSlashes) + : arboristPkgPathWithForwardSlashes + } + return _arboristPkgPath +} + +let _arboristClassPath: string | undefined +export function getArboristClassPath() { + if (_arboristClassPath === undefined) { + _arboristClassPath = path.join( + getArboristPackagePath(), + 'lib/arborist/index.js', + ) + } + return _arboristClassPath +} + +let _arboristDepValidPath: string | undefined +export function getArboristDepValidPath() { + if (_arboristDepValidPath === undefined) { + _arboristDepValidPath = path.join( + getArboristPackagePath(), + 'lib/dep-valid.js', + ) + } + return _arboristDepValidPath +} + +let _arboristEdgeClassPath: string | undefined +export function getArboristEdgeClassPath() { + if (_arboristEdgeClassPath === undefined) { + _arboristEdgeClassPath = path.join(getArboristPackagePath(), 'lib/edge.js') + } + return _arboristEdgeClassPath +} + +let _arboristNodeClassPath: string | undefined +export function getArboristNodeClassPath() { + if (_arboristNodeClassPath === undefined) { + _arboristNodeClassPath = path.join(getArboristPackagePath(), 'lib/node.js') + } + return _arboristNodeClassPath +} + +let _arboristOverrideSetClassPath: string | undefined +export function getArboristOverrideSetClassPath() { + if (_arboristOverrideSetClassPath === undefined) { + _arboristOverrideSetClassPath = path.join( + getArboristPackagePath(), + 'lib/override-set.js', + ) + } + return _arboristOverrideSetClassPath +} diff --git a/src/shadow/npm/proc-log/index.mts b/src/shadow/npm/proc-log/index.mts new file mode 100755 index 000000000..7ece1ae0a --- /dev/null +++ b/src/shadow/npm/proc-log/index.mts @@ -0,0 +1,63 @@ +import constants from '../../../constants.mts' +import { getNpmRequire } from '../../../utils/npm-paths.mts' + +const { UNDEFINED_TOKEN } = constants + +interface RequireKnownModules { + npmlog: typeof import('npmlog') + // The DefinitelyTyped definition of 'proc-log' does NOT have the log method. + // The return type of the log method is the same as `typeof import('proc-log')`. + 'proc-log': typeof import('proc-log') +} + +type RequireTransformer<T extends keyof RequireKnownModules> = ( + mod: RequireKnownModules[T], +) => RequireKnownModules[T] + +function tryRequire<T extends keyof RequireKnownModules>( + req: NodeJS.Require, + ...ids: Array<T | [T, RequireTransformer<T>]> +): RequireKnownModules[T] | undefined { + for (const data of ids) { + let id: string | undefined + let transformer: RequireTransformer<T> | undefined + if (Array.isArray(data)) { + id = data[0] + transformer = data[1] as RequireTransformer<T> + } else { + id = data as keyof RequireKnownModules + transformer = mod => mod + } + try { + // Check that the transformed value isn't `undefined` because older + // versions of packages like 'proc-log' may not export a `log` method. + const exported = transformer(req(id)) + if (exported !== undefined) { + return exported + } + } catch {} + } + return undefined +} + +export type Logger = + | typeof import('npmlog') + | typeof import('proc-log') + | undefined + +let _log: Logger | {} | undefined = UNDEFINED_TOKEN +export function getLogger(): Logger { + if (_log === UNDEFINED_TOKEN) { + _log = tryRequire( + getNpmRequire(), + [ + 'proc-log/lib/index.js' as 'proc-log', + // The proc-log DefinitelyTyped definition is incorrect. The type definition + // is really that of its export log. + mod => (mod as any).log as RequireKnownModules['proc-log'], + ], + 'npmlog/lib/log.js' as 'npmlog', + ) + } + return _log as Logger | undefined +} diff --git a/src/types.mts b/src/types.mts new file mode 100644 index 000000000..8822111f8 --- /dev/null +++ b/src/types.mts @@ -0,0 +1,30 @@ +export type StringKeyValueObject = { [key: string]: string } + +export type OutputKind = 'json' | 'markdown' | 'text' + +// CResult is akin to the "Result" or "Outcome" or "Either" pattern. +// Main difference might be that it's less strict about the error side of +// things, but still assumes a message is returned explaining the error. +// "CResult" is easier to grep for than "result". Short for CliJsonResult. +export type CResult<T> = + | { + ok: true + data: T + // The message prop may contain warnings that we want to convey. + message?: string + } + | { + ok: false + // This should be set to process.exitCode if this + // payload is actually displayed to the user. + // Defaults to 1 if not set. + code?: number + // Short message, for non-json this would show in + // the red banner part of an error message. + message: string + // Full explanation. Shown after the red banner of + // a non-json error message. Optional. + cause?: string + // If set, this may conform to the actual payload. + data?: unknown + } diff --git a/src/utils/agent.mts b/src/utils/agent.mts new file mode 100644 index 000000000..f516dc9ca --- /dev/null +++ b/src/utils/agent.mts @@ -0,0 +1,59 @@ +import { spawn } from '@socketsecurity/registry/lib/spawn' +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import constants from '../constants.mts' +import { cmdFlagsToString } from './cmd.mts' +import { safeNpmInstall } from '../shadow/npm/install.mts' + +import type { EnvDetails } from './package-environment.mts' + +const { NPM, PNPM } = constants + +type SpawnOption = Exclude<Parameters<typeof spawn>[2], undefined> + +export type AgentInstallOptions = SpawnOption & { + args?: string[] | readonly string[] | undefined + spinner?: Spinner | undefined +} + +export type AgentSpawnResult = ReturnType<typeof spawn> + +export function runAgentInstall( + pkgEnvDetails: EnvDetails, + options?: AgentInstallOptions | undefined, +): AgentSpawnResult { + const { agent, agentExecPath } = pkgEnvDetails + // All package managers support the "install" command. + if (agent === NPM) { + return safeNpmInstall({ + agentExecPath, + ...options, + }) + } + const { + args = [], + spinner, + ...spawnOptions + } = { __proto__: null, ...options } as AgentInstallOptions + const skipNodeHardenFlags = + agent === PNPM && pkgEnvDetails.agentVersion.major < 11 + return spawn(agentExecPath, ['install', ...args], { + // Lazily access constants.WIN32. + shell: constants.WIN32, + spinner, + stdio: 'inherit', + ...spawnOptions, + env: { + ...process.env, + NODE_OPTIONS: cmdFlagsToString([ + ...(skipNodeHardenFlags + ? [] + : // Lazily access constants.nodeHardenFlags. + constants.nodeHardenFlags), + // Lazily access constants.nodeNoWarningsFlags. + ...constants.nodeNoWarningsFlags, + ]), + ...spawnOptions.env, + }, + }) +} diff --git a/src/utils/alert/artifact.mts b/src/utils/alert/artifact.mts new file mode 100755 index 000000000..df7b7ae67 --- /dev/null +++ b/src/utils/alert/artifact.mts @@ -0,0 +1,82 @@ +import constants from '../../constants.mts' + +import type { Remap } from '@socketsecurity/registry/lib/objects' +import type { components, operations } from '@socketsecurity/sdk/types/api' + +export type ALERT_ACTION = 'error' | 'monitor' | 'warn' | 'ignore' + +export type ALERT_TYPE = keyof NonNullable< + operations['getOrgSecurityPolicy']['responses']['200']['content']['application/json']['securityPolicyRules'] +> + +export type CVE_ALERT_TYPE = 'cve' | 'mediumCVE' | 'mildCVE' | 'criticalCVE' + +export type ArtifactAlertCve = Remap< + Omit<CompactSocketArtifactAlert, 'type'> & { + type: CVE_ALERT_TYPE + } +> + +export type ArtifactAlertCveFixable = Remap< + Omit<CompactSocketArtifactAlert, 'props' | 'type'> & { + type: CVE_ALERT_TYPE + props: CveProps + } +> + +export type ArtifactAlertUpgrade = Remap< + Omit<CompactSocketArtifactAlert, 'type'> & { + type: 'socketUpgradeAvailable' + } +> + +export type CompactSocketArtifactAlert = Remap< + Omit<SocketArtifactAlert, 'category' | 'end' | 'file' | 'start'> +> + +export type CompactSocketArtifact = Remap< + Omit<SocketArtifact, 'alerts' | 'batchIndex' | 'size'> & { + alerts: CompactSocketArtifactAlert[] + } +> + +export type CveProps = { + firstPatchedVersionIdentifier?: string + vulnerableVersionRange: string + [key: string]: any +} + +export type PURL_Type = components['schemas']['SocketPURL_Type'] + +export type SocketArtifact = Remap< + Omit<components['schemas']['SocketArtifact'], 'alerts'> & { + alerts?: SocketArtifactAlert[] + } +> + +export type SocketArtifactAlert = Remap< + Omit<components['schemas']['SocketAlert'], 'action' | 'props' | 'type'> & { + type: ALERT_TYPE + action?: 'error' | 'monitor' | 'warn' | 'ignore' + props?: any | undefined + } +> + +const { + ALERT_TYPE_CRITICAL_CVE, + ALERT_TYPE_CVE, + ALERT_TYPE_MEDIUM_CVE, + ALERT_TYPE_MILD_CVE, +} = constants + +export function isArtifactAlertCve( + alert: CompactSocketArtifactAlert, +): alert is ArtifactAlertCve { + const { type } = alert + return ( + type === ALERT_TYPE_CVE || + type === ALERT_TYPE_MEDIUM_CVE || + type === ALERT_TYPE_MILD_CVE || + type === ALERT_TYPE_CRITICAL_CVE + ) +} diff --git a/src/utils/alert/fix.mts b/src/utils/alert/fix.mts new file mode 100644 index 000000000..721d434c1 --- /dev/null +++ b/src/utils/alert/fix.mts @@ -0,0 +1,7 @@ +import { createEnum } from '../objects.mts' + +export const ALERT_FIX_TYPE = createEnum({ + cve: 'cve', + remove: 'remove', + upgrade: 'upgrade', +}) diff --git a/src/utils/alert/severity.mts b/src/utils/alert/severity.mts new file mode 100644 index 000000000..8750e9097 --- /dev/null +++ b/src/utils/alert/severity.mts @@ -0,0 +1,72 @@ +import { createEnum, pick } from '../objects.mts' +import { stringJoinWithSeparateFinalSeparator } from '../strings.mts' + +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export const ALERT_SEVERITY = createEnum({ + critical: 'critical', + high: 'high', + middle: 'middle', + low: 'low', +}) + +export type SocketSdkAlertList = + SocketSdkReturnType<'getIssuesByNPMPackage'>['data'] + +export type SocketSdkAlert = SocketSdkAlertList[number]['value'] extends + | infer U + | undefined + ? U + : never + +// Ordered from most severe to least. +export const ALERT_SEVERITIES_SORTED: ReadonlyArray< + SocketSdkAlert['severity'] +> = Object.freeze(['critical', 'high', 'middle', 'low']) + +function getDesiredSeverities( + lowestToInclude: SocketSdkAlert['severity'] | undefined, +): Array<SocketSdkAlert['severity']> { + const result: Array<SocketSdkAlert['severity']> = [] + for (const severity of ALERT_SEVERITIES_SORTED) { + result.push(severity) + if (severity === lowestToInclude) { + break + } + } + return result +} + +export function formatSeverityCount( + severityCount: Record<SocketSdkAlert['severity'], number>, +): string { + const summary: string[] = [] + for (const severity of ALERT_SEVERITIES_SORTED) { + if (severityCount[severity]) { + summary.push(`${severityCount[severity]} ${severity}`) + } + } + return stringJoinWithSeparateFinalSeparator(summary) +} + +export function getSeverityCount( + issues: SocketSdkAlertList, + lowestToInclude: SocketSdkAlert['severity'] | undefined, +): Record<SocketSdkAlert['severity'], number> { + const severityCount = pick( + { low: 0, middle: 0, high: 0, critical: 0 }, + getDesiredSeverities(lowestToInclude), + ) as Record<SocketSdkAlert['severity'], number> + + for (const issue of issues) { + const { value } = issue + if (!value) { + continue + } + const { severity } = value + if (severityCount[severity] !== undefined) { + severityCount[severity] += 1 + } + } + return severityCount +} diff --git a/src/utils/alerts-map.mts b/src/utils/alerts-map.mts new file mode 100644 index 000000000..89e8b7b1a --- /dev/null +++ b/src/utils/alerts-map.mts @@ -0,0 +1,144 @@ +import { arrayUnique } from '@socketsecurity/registry/lib/arrays' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { extractPurlsFromPnpmLockfile } from './pnpm.mts' +import { getPublicToken, setupSdk } from './sdk.mts' +import { addArtifactToAlertsMap } from './socket-package-alert.mts' +import constants from '../constants.mts' + +import type { CompactSocketArtifact } from './alert/artifact.mts' +import type { + AlertIncludeFilter, + AlertsByPurl, +} from './socket-package-alert.mts' +import type { LockfileObject } from '@pnpm/lockfile.fs' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +export type GetAlertsMapFromPnpmLockfileOptions = { + consolidate?: boolean | undefined + include?: AlertIncludeFilter | undefined + overrides?: { [key: string]: string } | undefined + nothrow?: boolean | undefined + spinner?: Spinner | undefined +} + +export async function getAlertsMapFromPnpmLockfile( + lockfile: LockfileObject, + options?: GetAlertsMapFromPnpmLockfileOptions | undefined, +): Promise<AlertsByPurl> { + const purls = await extractPurlsFromPnpmLockfile(lockfile) + return await getAlertsMapFromPurls(purls, { + overrides: lockfile.overrides, + ...options, + }) +} + +export type GetAlertsMapFromPurlsOptions = { + consolidate?: boolean | undefined + include?: AlertIncludeFilter | undefined + limit?: number | undefined + overrides?: { [key: string]: string } | undefined + nothrow?: boolean | undefined + spinner?: Spinner | undefined +} + +export async function getAlertsMapFromPurls( + purls: string[] | readonly string[], + options_?: GetAlertsMapFromPurlsOptions | undefined, +): Promise<AlertsByPurl> { + const options = { + __proto__: null, + consolidate: false, + include: undefined, + limit: Infinity, + nothrow: false, + ...options_, + } as GetAlertsMapFromPurlsOptions + + options.include = { + __proto__: null, + // Leave 'actions' unassigned so it can be given a default value in + // subsequent functions where `options` is passed. + // actions: undefined, + blocked: true, + critical: true, + cve: true, + existing: false, + unfixable: true, + upgradable: false, + ...options.include, + } as AlertIncludeFilter + + const { spinner } = options + + const uniqPurls = arrayUnique(purls) + let { length: remaining } = uniqPurls + const alertsByPurl: AlertsByPurl = new Map() + if (!remaining) { + return alertsByPurl + } + const getText = () => `Looking up data for ${remaining} packages` + + spinner?.start(getText()) + + const sockSdkResult = await setupSdk(getPublicToken()) + if (!sockSdkResult.ok) { + throw new Error('Auth error: Try to run `socket login` first') + } + const sockSdk = sockSdkResult.data + + const alertsMapOptions = { + overrides: options.overrides, + consolidate: options.consolidate, + include: options.include, + spinner, + } + + for await (const batchResult of sockSdk.batchPackageStream( + { + alerts: 'true', + compact: 'true', + ...(options.include.actions + ? { actions: options.include.actions.join(',') } + : {}), + ...(options.include.unfixable ? {} : { fixable: 'true' }), + }, + { + components: uniqPurls.map(purl => ({ purl })), + }, + )) { + if (batchResult.success) { + await addArtifactToAlertsMap( + batchResult.data as CompactSocketArtifact, + alertsByPurl, + alertsMapOptions, + ) + } else if (!options.nothrow) { + const statusCode = batchResult.status ?? 'unknown' + const statusMessage = batchResult.error ?? 'No status message' + throw new Error( + `Socket API server error (${statusCode}): ${statusMessage}`, + ) + } else { + const { spinner } = constants + spinner.stop() + debugFn('Received a result=false:', batchResult) + logger.fail( + `Received a ${batchResult.status} response from Socket API which we consider a permanent failure:`, + batchResult.error, + batchResult.cause ? `( ${batchResult.cause} )` : '', + ) + break + } + remaining -= 1 + if (spinner && remaining > 0) { + spinner.start() + spinner.setText(getText()) + } + } + + spinner?.stop() + + return alertsByPurl +} diff --git a/src/utils/api.mts b/src/utils/api.mts new file mode 100644 index 000000000..81205c4cd --- /dev/null +++ b/src/utils/api.mts @@ -0,0 +1,290 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import { getConfigValueOrUndef } from './config.mts' +import { AuthError } from './errors.mts' +import constants from '../constants.mts' +import { failMsgWithBadge } from './fail-msg-with-badge.mts' +import { getDefaultToken } from './sdk.mts' + +import type { CResult } from '../types.mts' +import type { + SocketSdkErrorType, + SocketSdkOperations, + SocketSdkResultType, + SocketSdkReturnType, +} from '@socketsecurity/sdk' + +// TODO: this function is removed after v1.0.0 +export function handleUnsuccessfulApiResponse<T extends SocketSdkOperations>( + _name: T, + error: string, + cause: string, + status: number, +): never { + const message = `${error || 'No error message returned'}${cause ? ` (reason: ${cause})` : ''}` + if (status === 401 || status === 403) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.stop() + + throw new AuthError(message) + } + logger.fail(failMsgWithBadge('Socket API returned an error', message)) + // eslint-disable-next-line n/no-process-exit + process.exit(1) +} + +export async function handleApiCall<T extends SocketSdkOperations>( + value: Promise<SocketSdkResultType<T>>, + fetchingDesc: string, +): Promise<CResult<SocketSdkReturnType<T>['data']>> { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start(`Requesting ${fetchingDesc} from API...`) + + let result: SocketSdkResultType<T> + try { + result = await value + + // TODO: info, not success (looks weird when response is non-200) + spinner.successAndStop( + `Received API response (after requesting ${fetchingDesc}).`, + ) + } catch (e) { + spinner.failAndStop(`An error was thrown while requesting ${fetchingDesc}`) + + const message = `${e || 'No error message returned'}` + const reason = `${e || 'No error message returned'}` + + debugFn(`catch: ${fetchingDesc} error:\n`, e) + + return { + ok: false, + message: 'Socket API returned an error', + cause: `${message}${reason ? ` ( Reason: ${reason} )` : ''}`, + } + } finally { + spinner.stop() + } + + // Note: TS can't narrow down the type of result due to generics + if (result.success === false) { + const err = result as SocketSdkErrorType<T> + const message = `${err.error || 'No error message returned'}` + const { cause: reason } = err + + debugFn(`fail: ${fetchingDesc} bad response:\n`, err) + + return { + ok: false, + message: 'Socket API returned an error', + cause: `${message}${reason ? ` ( Reason: ${reason} )` : ''}`, + data: { + code: result.status, + }, + } + } else { + const ok = result as SocketSdkReturnType<T> + return { + ok: true, + data: ok.data, + } + } +} + +export async function handleApiCallNoSpinner<T extends SocketSdkOperations>( + value: Promise<SocketSdkResultType<T>>, + description: string, +): Promise<CResult<SocketSdkReturnType<T>['data']>> { + let result: SocketSdkResultType<T> + try { + result = await value + } catch (e) { + const message = `${e || 'No error message returned'}` + const reason = `${e || 'No error message returned'}` + + debugFn(`catch: ${description} error:\n`, e) + + return { + ok: false, + message: 'Socket API returned an error', + cause: `${message}${reason ? ` ( Reason: ${reason} )` : ''}`, + } + } + + // Note: TS can't narrow down the type of result due to generics + if (result.success === false) { + const err = result as SocketSdkErrorType<T> + const message = `${err.error || 'No error message returned'}` + + debugFn(`fail: ${description} bad response:\n`, err) + + return { + ok: false, + message: 'Socket API returned an error', + cause: `${message}${err.cause ? ` ( Reason: ${err.cause} )` : ''}`, + data: { + code: result.status, + }, + } + } else { + const ok = result as SocketSdkReturnType<T> + return { + ok: true, + data: ok.data, + } + } +} + +export async function getErrorMessageForHttpStatusCode(code: number) { + if (code === 400) { + return 'One of the options passed might be incorrect' + } + if (code === 403 || code === 401) { + return 'Your API token may not have the required permissions for this command or you might be trying to access (data from) an organization that is not linked to the API key you are logged in with' + } + if (code === 404) { + return 'The requested Socket API endpoint was not found (404) or there was no result for the requested parameters. If unexpected, this could be a temporary problem caused by an incident or a bug in the CLI. If the problem persists please let us know.' + } + if (code === 500) { + return 'There was an unknown server side problem with your request. This ought to be temporary. Please let us know if this problem persists.' + } + return `Server responded with status code ${code}` +} + +// The API server that should be used for operations. +export function getDefaultApiBaseUrl(): string | undefined { + const baseUrl = + // Lazily access constants.ENV.SOCKET_CLI_API_BASE_URL. + constants.ENV.SOCKET_CLI_API_BASE_URL || getConfigValueOrUndef('apiBaseUrl') + if (isNonEmptyString(baseUrl)) { + return baseUrl + } + // Lazily access constants.API_V0_URL. + const API_V0_URL = constants.API_V0_URL + return API_V0_URL +} + +export async function queryApi(path: string, apiToken: string) { + const baseUrl = getDefaultApiBaseUrl() || '' + if (!baseUrl) { + logger.warn( + 'API endpoint is not set and default was empty. Request is likely to fail.', + ) + } + + return await fetch(`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`, { + method: 'GET', + headers: { + Authorization: `Basic ${btoa(`${apiToken}:`)}`, + }, + }) +} + +export async function queryApiSafeText( + path: string, + fetchSpinnerDesc?: string, +): Promise<CResult<string>> { + const apiToken = getDefaultToken() + if (!apiToken) { + return { + ok: false, + message: 'Authentication Error', + cause: + 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.', + } + } + + if (fetchSpinnerDesc) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.start(`Requesting ${fetchSpinnerDesc} from API...`) + } + + let result + try { + result = await queryApi(path, apiToken) + if (fetchSpinnerDesc) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.successAndStop( + `Received API response (after requesting ${fetchSpinnerDesc}).`, + ) + } + } catch (e) { + if (fetchSpinnerDesc) { + // Lazily access constants.spinner. + const { spinner } = constants + + spinner.failAndStop( + `An error was thrown while requesting ${fetchSpinnerDesc}`, + ) + } + + const cause = (e as undefined | { message: string })?.message + + debugFn('catch: queryApi() error\n', e) + + return { + ok: false, + message: 'API Request failed to complete', + ...(cause ? { cause } : {}), + } + } + + if (!result.ok) { + const cause = await getErrorMessageForHttpStatusCode(result.status) + return { + ok: false, + message: 'Socket API returned an error', + cause: `${result.statusText}${cause ? ` (cause: ${cause})` : ''}`, + } + } + + try { + const data = await result.text() + + return { + ok: true, + data, + } + } catch (e) { + debugFn('catch: await result.text() error\n', e) + + return { + ok: false, + message: 'API Request failed to complete', + cause: 'There was an unexpected error trying to read the response text', + } + } +} + +export async function queryApiSafeJson<T>( + path: string, + fetchSpinnerDesc = '', +): Promise<CResult<T>> { + const result = await queryApiSafeText(path, fetchSpinnerDesc) + + if (!result.ok) { + return result + } + + try { + return { + ok: true, + data: JSON.parse(result.data) as T, + } + } catch (e) { + return { + ok: false, + message: 'Server returned invalid JSON', + cause: `Please report this. JSON.parse threw an error over the following response: \`${(result.data?.slice?.(0, 100) || '<empty>').trim() + (result.data?.length > 100 ? '...' : '')}\``, + } + } +} diff --git a/src/utils/check-input.mts b/src/utils/check-input.mts new file mode 100644 index 000000000..d9032cef2 --- /dev/null +++ b/src/utils/check-input.mts @@ -0,0 +1,64 @@ +import colors from 'yoctocolors-cjs' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from './fail-msg-with-badge.mts' +import { serializeResultJson } from './serialize-result-json.mts' + +import type { OutputKind } from '../types.mts' + +export function checkCommandInput( + outputKind: OutputKind, + ...checks: Array<{ + fail: string + message: string + pass: string + test: boolean + nook?: boolean | undefined + }> +): boolean { + if (checks.every(d => d.test)) { + return true + } + + const msg = ['Please review the input requirements and try again', ''] + for (const d of checks) { + // If nook, then ignore when test is ok + if (d.nook && d.test) { + continue + } + const lines = d.message.split('\n') + const { length: lineCount } = lines + if (!lineCount) { + continue + } + // If the message has newlines then format the first line with the input + // expectation and the rest indented below it. + msg.push( + ` - ${lines[0]} (${d.test ? colors.green(d.pass) : colors.red(d.fail)})`, + ) + if (lineCount > 1) { + msg.push(...lines.slice(1).map(str => ` ${str}`)) + } + msg.push('') + } + + // Use exit status of 2 to indicate incorrect usage, generally invalid + // options or missing arguments. + // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html + process.exitCode = 2 + + if (outputKind === 'json') { + logger.log( + serializeResultJson({ + ok: false, + message: 'Input error', + data: msg.join('\n'), + }), + ) + } else { + logger.fail(failMsgWithBadge('Input error', msg.join('\n'))) + } + + return false +} diff --git a/src/utils/cmd.mts b/src/utils/cmd.mts new file mode 100644 index 000000000..37cb3e8a6 --- /dev/null +++ b/src/utils/cmd.mts @@ -0,0 +1,36 @@ +const helpFlags = new Set(['--help', '-h']) + +export function cmdFlagsToString(args: string[]) { + const result = [] + for (let i = 0, { length } = args; i < length; i += 1) { + if (args[i]!.startsWith('--')) { + // Check if the next item exists and is NOT another flag. + if (i + 1 < length && !args[i + 1]!.startsWith('--')) { + result.push(`${args[i]}=${args[i + 1]}`) + i += 1 + } else { + result.push(args[i]) + } + } + } + return result.join(' ') +} + +export function cmdFlagValueToArray(flagValue: any): string[] { + if (typeof flagValue === 'string') { + return flagValue.trim().split(/, */) + } + if (Array.isArray(flagValue)) { + return flagValue.flatMap(v => v.split(/, */)) + } + return [] +} + +export function cmdPrefixMessage(cmdName: string, text: string): string { + const cmdPrefix = cmdName ? `${cmdName}: ` : '' + return `${cmdPrefix}${text}` +} + +export function isHelpFlag(cmdArg: string) { + return helpFlags.has(cmdArg) +} diff --git a/src/utils/coana.mts b/src/utils/coana.mts new file mode 100644 index 000000000..15a7e3cfc --- /dev/null +++ b/src/utils/coana.mts @@ -0,0 +1,45 @@ +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../constants.mts' +import { getDefaultToken } from './sdk.mts' + +import type { CResult } from '../types.mts' +import type { + SpawnExtra, + SpawnOptions, +} from '@socketsecurity/registry/lib/spawn' + +export async function spawnCoana( + args: string[] | readonly string[], + options?: SpawnOptions | undefined, + extra?: SpawnExtra | undefined, +): Promise<CResult<unknown>> { + const { env: optionsEnv } = { __proto__: null, ...options } as SpawnOptions + try { + const output = await spawn( + constants.execPath, + [ + // Lazily access constants.nodeNoWarningsFlags. + ...constants.nodeNoWarningsFlags, + // Lazily access constants.coanaBinPath. + constants.coanaBinPath, + ...args, + ], + { + ...options, + env: { + ...process.env, + ...optionsEnv, + SOCKET_CLI_API_BASE_URL: + constants.ENV.SOCKET_CLI_API_BASE_URL || undefined, + SOCKET_CLI_API_TOKEN: getDefaultToken(), + }, + }, + extra, + ) + return { ok: true, data: output.stdout.trim() } + } catch (e) { + const message = (e as any)?.stdout ?? (e as Error)?.message + return { ok: false, data: e, message } + } +} diff --git a/src/utils/color-or-markdown.mts b/src/utils/color-or-markdown.mts new file mode 100644 index 000000000..445404396 --- /dev/null +++ b/src/utils/color-or-markdown.mts @@ -0,0 +1,66 @@ +import terminalLink from 'terminal-link' +import colors from 'yoctocolors-cjs' + +import indentString from '@socketregistry/indent-string/index.cjs' + +export class ColorOrMarkdown { + public useMarkdown: boolean + + constructor(useMarkdown: boolean) { + this.useMarkdown = !!useMarkdown + } + + bold(text: string): string { + return this.useMarkdown ? `**${text}**` : colors.bold(`${text}`) + } + + header(text: string, level = 1): string { + return this.useMarkdown + ? `\n${''.padStart(level, '#')} ${text}\n` + : colors.underline(`\n${level === 1 ? colors.bold(text) : text}\n`) + } + + hyperlink( + text: string, + url: string | undefined, + { + fallback = true, + fallbackToUrl, + }: { + fallback?: boolean | undefined + fallbackToUrl?: boolean | undefined + } = {}, + ) { + if (url) { + return this.useMarkdown + ? `[${text}](${url})` + : terminalLink(text, url, { + fallback: fallbackToUrl ? (_text, url) => url : fallback, + }) + } + return text + } + + indent( + ...args: Parameters<typeof indentString> + ): ReturnType<typeof indentString> { + return indentString(...args) + } + + italic(text: string): string { + return this.useMarkdown ? `_${text}_` : colors.italic(`${text}`) + } + + json(value: any): string { + return this.useMarkdown + ? '```json\n' + JSON.stringify(value) + '\n```' + : JSON.stringify(value) + } + + list(items: string[]): string { + const indentedContent = items.map(item => this.indent(item).trimStart()) + return this.useMarkdown + ? `* ${indentedContent.join('\n* ')}\n` + : `${indentedContent.join('\n')}\n` + } +} diff --git a/src/utils/completion.mts b/src/utils/completion.mts new file mode 100644 index 000000000..830bf5b38 --- /dev/null +++ b/src/utils/completion.mts @@ -0,0 +1,80 @@ +import fs from 'node:fs' +import path from 'node:path' + +import constants from '../constants.mts' + +import type { CResult } from '../types.mts' + +export const COMPLETION_CMD_PREFIX = 'complete -F _socket_completion' + +export function getCompletionSourcingCommand(): CResult<string> { + // Note: this is exported to distPath in .config/rollup.dist.config.mjs + const completionScriptExportPath = path.join( + // Lazily access constants.distPath. + constants.distPath, + 'socket-completion.bash', + ) + + if (!fs.existsSync(completionScriptExportPath)) { + return { + ok: false, + message: 'Tab Completion script not found', + cause: `Expected to find completion script at \`${completionScriptExportPath}\` but it was not there`, + } + } + + return { ok: true, data: `source ${completionScriptExportPath}` } +} + +export function getBashrcDetails(targetCommandName: string): CResult<{ + completionCommand: string + sourcingCommand: string + toAddToBashrc: string + targetName: string + targetPath: string +}> { + const sourcingCommand = getCompletionSourcingCommand() + if (!sourcingCommand.ok) { + return sourcingCommand + } + + // Lazily access constants.socketAppDataPath. + const { socketAppDataPath } = constants + if (!socketAppDataPath) { + return { + ok: false, + message: 'Could not determine config directory', + cause: 'Failed to get config path', + } + } + + // _socket_completion is the function defined in our completion bash script + const completionCommand = `${COMPLETION_CMD_PREFIX} ${targetCommandName}` + + // Location of completion script in config after installing + const completionScriptPath = path.join( + path.dirname(socketAppDataPath), + 'completion', + 'socket-completion.bash', + ) + + const bashrcContent = `# Socket CLI completion for "${targetCommandName}" +if [ -f "${completionScriptPath}" ]; then + # Load the tab completion script + source "${completionScriptPath}" + # Tell bash to use this function for tab completion of this function + ${completionCommand} +fi +` + + return { + ok: true, + data: { + sourcingCommand: sourcingCommand.data, + completionCommand, + toAddToBashrc: bashrcContent, + targetName: targetCommandName, + targetPath: completionScriptPath, + }, + } +} diff --git a/src/utils/config.mts b/src/utils/config.mts new file mode 100644 index 000000000..43c1a0b48 --- /dev/null +++ b/src/utils/config.mts @@ -0,0 +1,267 @@ +import fs from 'node:fs' +import path from 'node:path' + +import config from '@socketsecurity/config' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../constants.mts' +import { safeReadFileSync } from './fs.mts' + +import type { CResult } from '../types.mts' + +export interface LocalConfig { + apiBaseUrl?: string | null | undefined + // @deprecated ; use apiToken. when loading a config, if this prop exists it + // is deleted and set to apiToken instead, and then persisted. + // should only happen once for legacy users. + apiKey?: string | null | undefined + apiProxy?: string | null | undefined + apiToken?: string | null | undefined + defaultOrg?: string + enforcedOrgs?: string[] | readonly string[] | null | undefined + skipAskToPersistDefaultOrg?: boolean + org?: string // convenience alias for defaultOrg +} + +export const sensitiveConfigKeys: Set<keyof LocalConfig> = new Set(['apiToken']) + +export const supportedConfigKeys: Map<keyof LocalConfig, string> = new Map([ + ['apiBaseUrl', 'Base URL of the API endpoint'], + ['apiProxy', 'A proxy through which to access the API'], + ['apiToken', 'The API token required to access most API endpoints'], + [ + 'defaultOrg', + 'The default org slug to use; usually the org your API token has access to. When set, all orgSlug arguments are implied to be this value.', + ], + [ + 'enforcedOrgs', + 'Orgs in this list have their security policies enforced on this machine', + ], + [ + 'skipAskToPersistDefaultOrg', + 'This flag prevents the CLI from asking you to persist the org slug when you selected one interactively', + ], + ['org', 'Alias for defaultOrg'], +]) + +function getConfigValues(): LocalConfig { + if (_cachedConfig === undefined) { + // Order: env var > --config flag > file + _cachedConfig = {} as LocalConfig + // Lazily access constants.socketAppDataPath. + const { socketAppDataPath } = constants + if (socketAppDataPath) { + const raw = safeReadFileSync(socketAppDataPath) + if (raw) { + try { + Object.assign( + _cachedConfig, + JSON.parse(Buffer.from(raw, 'base64').toString()), + ) + } catch { + logger.warn(`Failed to parse config at ${socketAppDataPath}`) + } + // Normalize apiKey to apiToken and persist it. + // This is a one time migration per user. + if (_cachedConfig['apiKey']) { + const token = _cachedConfig['apiKey'] + delete _cachedConfig['apiKey'] + updateConfigValue('apiToken', token) + } + } else { + fs.mkdirSync(path.dirname(socketAppDataPath), { recursive: true }) + } + } + } + return _cachedConfig +} + +function normalizeConfigKey( + key: keyof LocalConfig, +): CResult<keyof LocalConfig> { + // Note: apiKey was the old name of the token. When we load a config with + // property apiKey, we'll copy that to apiToken and delete the old property. + // We added `org` as a convenience alias for `defaultOrg` + const normalizedKey = + key === 'apiKey' ? 'apiToken' : key === 'org' ? 'defaultOrg' : key + if (!supportedConfigKeys.has(normalizedKey)) { + return { + ok: false, + message: `Invalid config key: ${normalizedKey}`, + data: undefined, + } + } + return { ok: true, data: normalizedKey } +} + +export function findSocketYmlSync(dir = process.cwd()) { + let prevDir = null + while (dir !== prevDir) { + let ymlPath = path.join(dir, 'socket.yml') + let yml = safeReadFileSync(ymlPath) + if (yml === undefined) { + ymlPath = path.join(dir, 'socket.yaml') + yml = safeReadFileSync(ymlPath) + } + if (typeof yml === 'string') { + try { + return { + path: ymlPath, + parsed: config.parseSocketConfig(yml), + } + } catch { + throw new Error(`Found file but was unable to parse ${ymlPath}`) + } + } + prevDir = dir + dir = path.join(dir, '..') + } + return null +} + +export function getConfigValue<Key extends keyof LocalConfig>( + key: Key, +): CResult<LocalConfig[Key]> { + const localConfig = getConfigValues() + const keyResult = normalizeConfigKey(key) + if (!keyResult.ok) { + return keyResult + } + return { ok: true, data: localConfig[keyResult.data as Key] } +} + +// This version squashes errors, returning undefined instead. +// Should be used when we can reasonably predict the call can't fail. +export function getConfigValueOrUndef<Key extends keyof LocalConfig>( + key: Key, +): LocalConfig[Key] | undefined { + const localConfig = getConfigValues() + const keyResult = normalizeConfigKey(key) + if (!keyResult.ok) { + return undefined + } + return localConfig[keyResult.data as Key] +} + +export function isReadOnlyConfig() { + return _readOnlyConfig +} + +let _cachedConfig: LocalConfig | undefined +// When using --config or SOCKET_CLI_CONFIG, do not persist the config. +let _readOnlyConfig = false + +export function overrideCachedConfig(jsonConfig: unknown): CResult<undefined> { + debugFn('override: full config (not stored)') + + let config + try { + config = JSON.parse(String(jsonConfig)) + if (!config || typeof config !== 'object') { + // `null` is valid json, so are primitive values. They're not valid config objects :) + return { + ok: false, + message: 'Could not parse Config as JSON', + cause: + "Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.", + } + } + } catch { + // Force set an empty config to prevent accidentally using system settings + _cachedConfig = {} as LocalConfig + _readOnlyConfig = true + + return { + ok: false, + message: 'Could not parse Config as JSON', + cause: + "Could not JSON parse the config override. Make sure it's a proper JSON object (double-quoted keys and strings, no unquoted `undefined`) and try again.", + } + } + + // @ts-ignore Override an illegal object. + _cachedConfig = config as LocalConfig + _readOnlyConfig = true + + // Normalize apiKey to apiToken + if (_cachedConfig['apiKey']) { + if (_cachedConfig['apiToken']) { + logger.warn( + 'Note: The config override had both apiToken and apiKey. Using the apiToken value. Remove the apiKey to get rid of this message.', + ) + } + _cachedConfig['apiToken'] = _cachedConfig['apiKey'] + delete _cachedConfig['apiKey'] + } + + return { ok: true, data: undefined } +} + +export function overrideConfigApiToken(apiToken: unknown) { + debugFn('override: API token (not stored)') + + // Set token to the local cached config and mark it read-only so it doesn't persist + _cachedConfig = { + ...config, + ...(apiToken === undefined ? {} : { apiToken: String(apiToken) }), + } as LocalConfig + _readOnlyConfig = true +} + +let _pendingSave = false +export function updateConfigValue<Key extends keyof LocalConfig>( + configKey: keyof LocalConfig, + value: LocalConfig[Key], +): CResult<undefined | string> { + const localConfig = getConfigValues() + const keyResult = normalizeConfigKey(configKey) + if (!keyResult.ok) { + return keyResult + } + const key: Key = keyResult.data as Key + let wasDeleted = value === undefined // implicitly when serializing + if (key === 'skipAskToPersistDefaultOrg') { + if (value === 'true' || value === 'false') { + localConfig['skipAskToPersistDefaultOrg'] = value === 'true' + } else { + delete localConfig['skipAskToPersistDefaultOrg'] + wasDeleted = true + } + } else { + if (value === 'undefined' || value === 'true' || value === 'false') { + logger.warn( + `Note: The value is set to "${value}", as a string (!). Use \`socket config unset\` to reset a key.`, + ) + } + localConfig[key] = value + } + if (_readOnlyConfig) { + return { + ok: true, + message: `Config key '${key}' was ${wasDeleted ? 'deleted' : `updated`}`, + data: 'Change applied but not persisted; current config is overridden through env var or flag', + } + } + + if (!_pendingSave) { + _pendingSave = true + process.nextTick(() => { + _pendingSave = false + // Lazily access constants.socketAppDataPath. + const { socketAppDataPath } = constants + if (socketAppDataPath) { + fs.writeFileSync( + socketAppDataPath, + Buffer.from(JSON.stringify(localConfig)).toString('base64'), + ) + } + }) + } + + return { + ok: true, + message: `Config key '${key}' was ${wasDeleted ? 'deleted' : `updated`}`, + data: undefined, + } +} diff --git a/src/utils/config.test.mts b/src/utils/config.test.mts new file mode 100644 index 000000000..6ef857ec0 --- /dev/null +++ b/src/utils/config.test.mts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { overrideCachedConfig, updateConfigValue } from './config.mts' + +describe('utils/config', () => { + describe('updateConfigValue', () => { + beforeEach(() => { + overrideCachedConfig({}) + }) + + it('should return object for applying a change', () => { + expect( + updateConfigValue('defaultOrg', 'fake_test_org'), + ).toMatchInlineSnapshot(` + { + "data": "Change applied but not persisted; current config is overridden through env var or flag", + "message": "Config key 'defaultOrg' was updated", + "ok": true, + } + `) + }) + + it('should warn for invalid key', () => { + expect( + updateConfigValue( + // @ts-ignore + 'nawthiswontwork', + 'fake_test_org', + ), + ).toMatchInlineSnapshot(` + { + "data": undefined, + "message": "Invalid config key: nawthiswontwork", + "ok": false, + } + `) + }) + }) +}) diff --git a/src/utils/determine-org-slug.mts b/src/utils/determine-org-slug.mts new file mode 100644 index 000000000..cdb50a53b --- /dev/null +++ b/src/utils/determine-org-slug.mts @@ -0,0 +1,67 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { getConfigValueOrUndef } from './config.mts' +import { suggestOrgSlug } from '../commands/scan/suggest-org-slug.mts' +import { suggestToPersistOrgSlug } from '../commands/scan/suggest-to-persist-orgslug.mts' + +export async function determineOrgSlug( + orgFlag: string, + interactive: boolean, + dryRun: boolean, +): Promise<[string, string | undefined]> { + const defaultOrgSlug = getConfigValueOrUndef('defaultOrg') + let orgSlug = String(orgFlag || defaultOrgSlug || '') + if (!orgSlug) { + if (!interactive) { + logger.warn( + 'Note: This command requires an org slug because the remote API endpoint does.', + ) + logger.warn('') + logger.warn( + 'It seems no default org was setup and the `--org` flag was not used.', + ) + logger.warn( + "Additionally, `--no-interactive` was set so we can't ask for it.", + ) + logger.warn( + 'Since v1.0.0 the org _argument_ for all commands was dropped in favor of an', + ) + logger.warn( + 'implicit default org setting, which will be setup when you run `socket login`.', + ) + logger.warn('') + logger.warn( + 'Note: When running in CI, you probably want to set the `--org` flag.', + ) + logger.warn('') + logger.warn( + 'For details, see: https://docs.socket.dev/docs/v1-migration-guide', + ) + logger.warn('') + logger.warn( + 'This command will exit now because the org slug is required to proceed.', + ) + return ['', undefined] + } + + // ask from server + logger.warn( + 'Unable to determine the target org. Trying to auto-discover it now...', + ) + logger.info( + 'Note: you can run `socket login` to set a default org. You can also override it with the --org flag.', + ) + logger.error('') // spacing in stderr + if (dryRun) { + logger.fail('Skipping auto-discovery of org in dry-run mode') + } else { + orgSlug = (await suggestOrgSlug()) || '' + + if (orgSlug) { + await suggestToPersistOrgSlug(orgSlug) + } + } + } + + return [orgSlug, defaultOrgSlug] +} diff --git a/src/utils/errors.mts b/src/utils/errors.mts new file mode 100644 index 000000000..c2281bad6 --- /dev/null +++ b/src/utils/errors.mts @@ -0,0 +1,54 @@ +import { setTimeout as wait } from 'node:timers/promises' + +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import constants from '../constants.mts' + +const { + kInternalsSymbol, + [kInternalsSymbol as unknown as 'Symbol(kInternalsSymbol)']: { getSentry }, +} = constants + +type EventHintOrCaptureContext = { [key: string]: any } | Function + +export class AuthError extends Error {} + +export class InputError extends Error { + public body: string | undefined + + constructor(message: string, body?: string) { + super(message) + this.body = body + } +} + +export async function captureException( + exception: unknown, + hint?: EventHintOrCaptureContext | undefined, +): Promise<string> { + const result = captureExceptionSync(exception, hint) + // "Sleep" for a second, just in case, hopefully enough time to initiate fetch. + await wait(1000) + return result +} + +export function captureExceptionSync( + exception: unknown, + hint?: EventHintOrCaptureContext | undefined, +): string { + const Sentry = getSentry() + if (!Sentry) { + return '' + } + debugFn('send: exception to Sentry') + return Sentry.captureException(exception, hint) as string +} + +export function isErrnoException( + value: unknown, +): value is NodeJS.ErrnoException { + if (!(value instanceof Error)) { + return false + } + return (value as NodeJS.ErrnoException).code !== undefined +} diff --git a/packages/cli/src/util/error/fail-msg-with-badge.mts b/src/utils/fail-msg-with-badge.mts similarity index 88% rename from packages/cli/src/util/error/fail-msg-with-badge.mts rename to src/utils/fail-msg-with-badge.mts index fab840f3a..e60f1b844 100644 --- a/packages/cli/src/util/error/fail-msg-with-badge.mts +++ b/src/utils/fail-msg-with-badge.mts @@ -4,7 +4,7 @@ export function failMsgWithBadge( badge: string, message: string | undefined, ): string { - const prefix = colors.bgRedBright( + const prefix = colors.bgRed( colors.bold(colors.white(` ${badge}${message ? ': ' : ''}`)), ) const postfix = message ? ` ${colors.bold(message)}` : '' diff --git a/src/utils/fs.mts b/src/utils/fs.mts new file mode 100644 index 000000000..68c985809 --- /dev/null +++ b/src/utils/fs.mts @@ -0,0 +1,133 @@ +import { promises as fs, readFileSync as fsReadFileSync } from 'node:fs' +import path from 'node:path' + +import { remove } from '@socketsecurity/registry/lib/fs' +import { pEach } from '@socketsecurity/registry/lib/promises' + +import constants from '../constants.mts' +import { globNodeModules } from './glob.mts' + +import type { Remap } from '@socketsecurity/registry/lib/objects' +import type { Abortable } from 'node:events' +import type { + ObjectEncodingOptions, + OpenMode, + PathLike, + PathOrFileDescriptor, +} from 'node:fs' +import type { FileHandle } from 'node:fs/promises' + +const { abortSignal } = constants + +export async function removeNodeModules(cwd = process.cwd()) { + const nodeModulesPaths = await globNodeModules(cwd) + await pEach( + nodeModulesPaths, + 3, + p => remove(p, { force: true, recursive: true }), + { retries: 3 }, + ) +} + +export type FindUpOptions = { + cwd?: string | undefined + signal?: AbortSignal | undefined +} + +export async function findUp( + name: string | string[], + { cwd = process.cwd(), signal = abortSignal }: FindUpOptions, +): Promise<string | undefined> { + let dir = path.resolve(cwd) + const { root } = path.parse(dir) + const names = [name].flat() + while (dir && dir !== root) { + for (const name of names) { + if (signal?.aborted) { + return undefined + } + const filePath = path.join(dir, name) + try { + // eslint-disable-next-line no-await-in-loop + const stats = await fs.stat(filePath) + if (stats.isFile()) { + return filePath + } + } catch {} + } + dir = path.dirname(dir) + } + return undefined +} + +export type ReadFileOptions = Remap< + ObjectEncodingOptions & + Abortable & { + flag?: OpenMode | undefined + } +> + +export async function readFileBinary( + filepath: PathLike | FileHandle, + options?: ReadFileOptions | undefined, +): Promise<Buffer> { + return (await fs.readFile(filepath, { + signal: abortSignal, + ...options, + encoding: 'binary', + } as ReadFileOptions)) as Buffer +} + +export async function readFileUtf8( + filepath: PathLike | FileHandle, + options?: ReadFileOptions | undefined, +): Promise<string> { + return await fs.readFile(filepath, { + signal: abortSignal, + ...options, + encoding: 'utf8', + }) +} + +export async function safeReadFile( + filepath: PathLike | FileHandle, + options?: 'utf8' | 'utf-8' | { encoding: 'utf8' | 'utf-8' } | undefined, +): Promise<string | undefined> + +export async function safeReadFile( + filepath: PathLike | FileHandle, + options?: ReadFileOptions | NodeJS.BufferEncoding | undefined, +): Promise<Awaited<ReturnType<typeof fs.readFile>> | undefined> { + try { + return await fs.readFile(filepath, { + encoding: 'utf8', + signal: abortSignal, + ...(typeof options === 'string' ? { encoding: options } : options), + }) + } catch {} + return undefined +} + +export function safeReadFileSync( + filepath: PathOrFileDescriptor, + options?: 'utf8' | 'utf-8' | { encoding: 'utf8' | 'utf-8' } | undefined, +): string | undefined + +export function safeReadFileSync( + filepath: PathOrFileDescriptor, + options?: + | { + encoding?: NodeJS.BufferEncoding | undefined + flag?: string | undefined + } + | NodeJS.BufferEncoding + | undefined, +): ReturnType<typeof fsReadFileSync> | undefined { + try { + return fsReadFileSync(filepath, { + encoding: 'utf8', + ...(typeof options === 'string' ? { encoding: options } : options), + }) + } catch {} + return undefined +} diff --git a/src/utils/get-output-kind.mts b/src/utils/get-output-kind.mts new file mode 100644 index 000000000..a2550582e --- /dev/null +++ b/src/utils/get-output-kind.mts @@ -0,0 +1,11 @@ +import type { OutputKind } from '../types.mts' + +export function getOutputKind(json: unknown, markdown: unknown): OutputKind { + if (json) { + return 'json' + } + if (markdown) { + return 'markdown' + } + return 'text' +} diff --git a/src/utils/glob.mts b/src/utils/glob.mts new file mode 100644 index 000000000..d65fcbb7e --- /dev/null +++ b/src/utils/glob.mts @@ -0,0 +1,275 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import ignore from 'ignore' +import micromatch from 'micromatch' +import { glob as tinyGlob } from 'tinyglobby' +import { parse as yamlParse } from 'yaml' + +import { readPackageJson } from '@socketsecurity/registry/lib/packages' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import constants from '../constants.mts' +import { safeReadFile } from './fs.mts' + +import type { Agent } from './package-environment.mts' +import type { SocketYml } from '@socketsecurity/config' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' +import type { GlobOptions } from 'tinyglobby' + +const { NPM, PNPM } = constants + +const PNPM_WORKSPACE = `${PNPM}-workspace` + +const ignoredDirs = [ + // Taken from ignore-by-default: + // https://github.com/novemberborn/ignore-by-default/blob/v2.1.0/index.js + '.git', // Git repository files, see <https://git-scm.com/> + '.log', // Log files emitted by tools such as `tsserver`, see <https://github.com/Microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29> + '.nyc_output', // Temporary directory where nyc stores coverage data, see <https://github.com/bcoe/nyc> + '.sass-cache', // Cache folder for node-sass, see <https://github.com/sass/node-sass> + '.yarn', // Where node modules are installed when using Yarn, see <https://yarnpkg.com/> + 'bower_components', // Where Bower packages are installed, see <http://bower.io/> + 'coverage', // Standard output directory for code coverage reports, see <https://github.com/gotwarlost/istanbul> + 'node_modules', // Where Node modules are installed, see <https://nodejs.org/> + // Taken from globby: + // https://github.com/sindresorhus/globby/blob/v14.0.2/ignore.js#L11-L16 + 'flow-typed', +] as const + +const ignoredDirPatterns = ignoredDirs.map(i => `**/${i}`) + +async function getWorkspaceGlobs( + agent: Agent, + cwd = process.cwd(), +): Promise<string[]> { + let workspacePatterns + if (agent === PNPM) { + for (const workspacePath of [ + path.join(cwd, `${PNPM_WORKSPACE}.yaml`), + path.join(cwd, `${PNPM_WORKSPACE}.yml`), + ]) { + // eslint-disable-next-line no-await-in-loop + const yml = await safeReadFile(workspacePath) + if (yml) { + try { + workspacePatterns = yamlParse(yml)?.packages + } catch {} + if (workspacePatterns) { + break + } + } + } + } else { + workspacePatterns = (await readPackageJson(cwd, { throws: false }))?.[ + 'workspaces' + ] + } + return Array.isArray(workspacePatterns) + ? workspacePatterns + .filter(isNonEmptyString) + .map(workspacePatternToGlobPattern) + : [] +} + +function ignoreFileLinesToGlobPatterns( + lines: string[] | readonly string[], + filepath: string, + cwd: string, +): string[] { + const base = path.relative(cwd, path.dirname(filepath)).replace(/\\/g, '/') + const patterns = [] + for (let i = 0, { length } = lines; i < length; i += 1) { + const pattern = lines[i]!.trim() + if (pattern.length > 0 && pattern.charCodeAt(0) !== 35 /*'#'*/) { + patterns.push( + ignorePatternToMinimatch( + pattern.length && pattern.charCodeAt(0) === 33 /*'!'*/ + ? `!${path.posix.join(base, pattern.slice(1))}` + : path.posix.join(base, pattern), + ), + ) + } + } + return patterns +} + +function ignoreFileToGlobPatterns( + content: string, + filepath: string, + cwd: string, +): string[] { + return ignoreFileLinesToGlobPatterns(content.split(/\r?\n/), filepath, cwd) +} + +// Based on `@eslint/compat` convertIgnorePatternToMinimatch. +// Apache v2.0 licensed +// Copyright Nicholas C. Zakas +// https://github.com/eslint/rewrite/blob/compat-v1.2.1/packages/compat/src/ignore-file.js#L28 +function ignorePatternToMinimatch(pattern: string): string { + const isNegated = pattern.startsWith('!') + const negatedPrefix = isNegated ? '!' : '' + const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd() + // Special cases. + if ( + patternToTest === '' || + patternToTest === '**' || + patternToTest === '/**' || + patternToTest === '**' + ) { + return `${negatedPrefix}${patternToTest}` + } + const firstIndexOfSlash = patternToTest.indexOf('/') + const matchEverywherePrefix = + firstIndexOfSlash === -1 || firstIndexOfSlash === patternToTest.length - 1 + ? '**/' + : '' + const patternWithoutLeadingSlash = + firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest + // Escape `{` and `(` because in gitignore patterns they are just + // literal characters without any specific syntactic meaning, + // while in minimatch patterns they can form brace expansion or extglob syntax. + // + // For example, gitignore pattern `src/{a,b}.js` ignores file `src/{a,b}.js`. + // But, the same minimatch pattern `src/{a,b}.js` ignores files `src/a.js` and `src/b.js`. + // Minimatch pattern `src/\{a,b}.js` is equivalent to gitignore pattern `src/{a,b}.js`. + const escapedPatternWithoutLeadingSlash = + patternWithoutLeadingSlash.replaceAll( + /(?=((?:\\.|[^{(])*))\1([{(])/guy, + '$1\\$2', + ) + const matchInsideSuffix = patternToTest.endsWith('/**') ? '/*' : '' + return `${negatedPrefix}${matchEverywherePrefix}${escapedPatternWithoutLeadingSlash}${matchInsideSuffix}` +} + +function workspacePatternToGlobPattern(workspace: string): string { + const { length } = workspace + if (!length) { + return '' + } + // If the workspace ends with "/" + if (workspace.charCodeAt(length - 1) === 47 /*'/'*/) { + return `${workspace}/*/package.json` + } + // If the workspace ends with "/**" + if ( + workspace.charCodeAt(length - 1) === 42 /*'*'*/ && + workspace.charCodeAt(length - 2) === 42 /*'*'*/ && + workspace.charCodeAt(length - 3) === 47 /*'/'*/ + ) { + return `${workspace}/*/**/package.json` + } + // Things like "packages/a" or "packages/*" + return `${workspace}/package.json` +} + +export async function filterGlobResultToSupportedFiles( + entries: string[] | readonly string[], + supportedFiles: SocketSdkReturnType<'getReportSupportedFiles'>['data'], +): Promise<string[]> { + const patterns = ['golang', NPM, 'maven', 'pypi', 'gem', 'nuget'].reduce( + (r: string[], n: string) => { + const supported = supportedFiles[n] + r.push( + ...(supported + ? Object.values(supported).map(p => `**/${p.pattern}`) + : []), + ) + return r + }, + [], + ) + return entries.filter(p => micromatch.some(p, patterns)) +} + +type GlobWithGitIgnoreOptions = GlobOptions & { + socketConfig?: SocketYml | undefined +} + +export async function globWithGitIgnore( + patterns: string[] | readonly string[], + options: GlobWithGitIgnoreOptions, +) { + const { + cwd = process.cwd(), + socketConfig, + ...additionalOptions + } = { __proto__: null, ...options } as GlobWithGitIgnoreOptions + const projectIgnorePaths = socketConfig?.projectIgnorePaths + const ignoreFiles = await tinyGlob(['**/.gitignore'], { + absolute: true, + cwd, + expandDirectories: true, + }) + const ignores = [ + ...ignoredDirPatterns, + ...(Array.isArray(projectIgnorePaths) + ? ignoreFileLinesToGlobPatterns( + projectIgnorePaths, + path.join(cwd, '.gitignore'), + cwd, + ) + : []), + ...( + await Promise.all( + ignoreFiles.map(async filepath => + ignoreFileToGlobPatterns( + await fs.readFile(filepath, 'utf8'), + filepath, + cwd, + ), + ), + ) + ).flat(), + ] + const hasNegatedPattern = ignores.some(p => p.charCodeAt(0) === 33 /*'!'*/) + const globOptions = { + absolute: true, + cwd, + expandDirectories: false, + ignore: hasNegatedPattern ? [] : ignores, + ...additionalOptions, + } + const result = await tinyGlob(patterns as string[], globOptions) + if (!hasNegatedPattern) { + return result + } + const { absolute } = globOptions + + // Note: the input files must be INSIDE the cwd. If you get strange looking + // relative path errors here, most likely your path is outside the given cwd. + const filtered = ignore() + .add(ignores) + .filter(absolute ? result.map(p => path.relative(cwd, p)) : result) + return absolute ? filtered.map(p => path.resolve(cwd, p)) : filtered +} + +export async function globNodeModules(cwd = process.cwd()): Promise<string[]> { + return await tinyGlob('**/node_modules', { + absolute: true, + cwd, + expandDirectories: false, + onlyDirectories: true, + }) +} + +export async function globWorkspace( + agent: Agent, + cwd = process.cwd(), +): Promise<string[]> { + const workspaceGlobs = await getWorkspaceGlobs(agent, cwd) + return workspaceGlobs.length + ? await tinyGlob(workspaceGlobs, { + absolute: true, + cwd, + ignore: ['**/node_modules/**', '**/bower_components/**'], + }) + : [] +} + +export function pathsToGlobPatterns( + paths: string[] | readonly string[], +): string[] { + // TODO: Does not support `~/` paths. + return paths.map(p => (p === '.' || p === './' ? '**/*' : p)) +} diff --git a/src/utils/lockfile.mts b/src/utils/lockfile.mts new file mode 100644 index 000000000..4c78e9d5c --- /dev/null +++ b/src/utils/lockfile.mts @@ -0,0 +1,9 @@ +import { existsSync } from 'node:fs' + +import { readFileUtf8 } from './fs.mts' + +export async function readLockfile( + lockfilePath: string, +): Promise<string | null> { + return existsSync(lockfilePath) ? await readFileUtf8(lockfilePath) : null +} diff --git a/packages/cli/src/util/data/map-to-object.mts b/src/utils/map-to-object.mts similarity index 82% rename from packages/cli/src/util/data/map-to-object.mts rename to src/utils/map-to-object.mts index ab1f2845f..6f3d7db4e 100644 --- a/packages/cli/src/util/data/map-to-object.mts +++ b/src/utils/map-to-object.mts @@ -3,8 +3,8 @@ interface NestedRecord<T> { } /** - * Convert a Map<string, Map|string> to a nested object of similar shape. The - * goal is to serialize it with JSON.stringify, which Map can't do. + * Convert a Map<string, Map|string> to a nested object of similar shape. + * The goal is to serialize it with JSON.stringify, which Map can't do. */ export function mapToObject<T>( map: Map<string, T | Map<string, T | Map<string, T>>>, diff --git a/packages/cli/test/unit/util/data/map-to-object.test.mts b/src/utils/map-to-object.test.mts similarity index 79% rename from packages/cli/test/unit/util/data/map-to-object.test.mts rename to src/utils/map-to-object.test.mts index 35a39f7ab..438e3ced7 100644 --- a/packages/cli/test/unit/util/data/map-to-object.test.mts +++ b/src/utils/map-to-object.test.mts @@ -1,20 +1,6 @@ -/** - * Unit tests for Map to object conversion. - * - * Purpose: Tests converting ES6 Maps to plain objects. Validates nested Map - * handling and type preservation. - * - * Test Coverage: - Simple Map conversion - Nested Map handling - Null prototype - * objects - Type preservation - Edge cases (empty, circular) - * - * Testing Approach: Tests data structure transformation utilities. - * - * Related Files: - util/data/map-to-object.mts (implementation) - */ - import { describe, expect, it } from 'vitest' -import { mapToObject } from '../../../../src/util/data/map-to-object.mts' +import { mapToObject } from './map-to-object.mts' describe('map-to-object', () => { it('should convert a map string string', () => { diff --git a/src/utils/markdown.mts b/src/utils/markdown.mts new file mode 100644 index 000000000..570a229a3 --- /dev/null +++ b/src/utils/markdown.mts @@ -0,0 +1,109 @@ +export function mdTableStringNumber( + title1: string, + title2: string, + obj: Record<string, number | string>, +): string { + // | Date | Counts | + // | ----------- | ------ | + // | Header | 201464 | + // | Paragraph | 18 | + let mw1 = title1.length + let mw2 = title2.length + for (const [key, value] of Object.entries(obj)) { + mw1 = Math.max(mw1, key.length) + mw2 = Math.max(mw2, String(value ?? '').length) + } + + const lines = [] + lines.push(`| ${title1.padEnd(mw1, ' ')} | ${title2.padEnd(mw2)} |`) + lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) + for (const [key, value] of Object.entries(obj)) { + lines.push( + `| ${key.padEnd(mw1, ' ')} | ${String(value ?? '').padStart(mw2, ' ')} |`, + ) + } + lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) + + return lines.join('\n') +} + +export function mdTable<T extends Array<Record<string, string>>>( + logs: T, + // This is saying "an array of strings and the strings are a valid key of elements of T" + // In turn, T is defined above as the audit log event type from our OpenAPI docs. + cols: Array<string & keyof T[number]>, + titles: string[] = cols, +): string { + // Max col width required to fit all data in that column + const cws = cols.map(col => col.length) + + for (const log of logs) { + for (let i = 0, { length } = cols; i < length; i += 1) { + // @ts-ignore + const val: unknown = log[cols[i] ?? ''] ?? '' + cws[i] = Math.max( + cws[i] ?? 0, + String(val).length, + (titles[i] || '').length, + ) + } + } + + let div = '|' + for (const cw of cws) { + div += ' ' + '-'.repeat(cw) + ' |' + } + + let header = '|' + for (let i = 0, { length } = titles; i < length; i += 1) { + header += ' ' + String(titles[i]).padEnd(cws[i] ?? 0, ' ') + ' |' + } + + let body = '' + for (const log of logs) { + body += '|' + for (let i = 0, { length } = cols; i < length; i += 1) { + // @ts-ignore + const val: unknown = log[cols[i] ?? ''] ?? '' + body += ' ' + String(val).padEnd(cws[i] ?? 0, ' ') + ' |' + } + body += '\n' + } + + return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n') +} + +export function mdTableOfPairs( + arr: Array<[string, string]>, + // This is saying "an array of strings and the strings are a valid key of elements of T" + // In turn, T is defined above as the audit log event type from our OpenAPI docs. + cols: string[], +): string { + // Max col width required to fit all data in that column + const cws = cols.map(col => col.length) + + for (const [key, val] of arr) { + cws[0] = Math.max(cws[0] ?? 0, String(key).length) + cws[1] = Math.max(cws[1] ?? 0, String(val ?? '').length) + } + + let div = '|' + for (const cw of cws) { + div += ' ' + '-'.repeat(cw) + ' |' + } + + let header = '|' + for (let i = 0, { length } = cols; i < length; i += 1) { + header += ' ' + String(cols[i]).padEnd(cws[i] ?? 0, ' ') + ' |' + } + + let body = '' + for (const [key, val] of arr) { + body += '|' + body += ' ' + String(key).padEnd(cws[0] ?? 0, ' ') + ' |' + body += ' ' + String(val ?? '').padEnd(cws[1] ?? 0, ' ') + ' |' + body += '\n' + } + + return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n') +} diff --git a/src/utils/markdown.test.mts b/src/utils/markdown.test.mts new file mode 100644 index 000000000..e91758114 --- /dev/null +++ b/src/utils/markdown.test.mts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { mdTableOfPairs } from './markdown.mts' + +describe('markdown', () => { + describe('mdTableOfPairs', () => { + it('should convert an array of tuples to markdown', () => { + expect( + mdTableOfPairs( + [ + ['apple', 'green'], + ['banana', 'yellow'], + ['orange', 'orange'], + ], + ['name', 'color'], + ), + ).toMatchInlineSnapshot(` + "| ------ | ------ | + | name | color | + | ------ | ------ | + | apple | green | + | banana | yellow | + | orange | orange | + | ------ | ------ |" + `) + }) + }) +}) diff --git a/src/utils/meow-with-subcommands.mts b/src/utils/meow-with-subcommands.mts new file mode 100644 index 000000000..6b88d1085 --- /dev/null +++ b/src/utils/meow-with-subcommands.mts @@ -0,0 +1,542 @@ +import meow from 'meow' + +import { joinAnd } from '@socketsecurity/registry/lib/arrays' +import { logger } from '@socketsecurity/registry/lib/logger' +import { toSortedObject } from '@socketsecurity/registry/lib/objects' +import { normalizePath } from '@socketsecurity/registry/lib/path' +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' + +import { + getConfigValueOrUndef, + isReadOnlyConfig, + overrideCachedConfig, + overrideConfigApiToken, +} from './config.mts' +import { getFlagListOutput, getHelpListOutput } from './output-formatting.mts' +import constants from '../constants.mts' +import { commonFlags } from '../flags.mts' +import { getVisibleTokenPrefix } from './sdk.mts' +import { tildify } from './tildify.mts' + +import type { MeowFlags } from '../flags.mts' +import type { Options, Result } from 'meow' + +interface CliAlias { + description: string + argv: readonly string[] + hidden?: boolean | undefined +} + +type CliAliases = Record<string, CliAlias> + +type CliSubcommandRun = ( + argv: string[] | readonly string[], + importMeta: ImportMeta, + context: { parentName: string }, +) => Promise<void> | void + +export interface CliSubcommand { + description: string + hidden?: boolean | undefined + run: CliSubcommandRun +} + +// Property names are picked such that the name is at the top when the props +// get ordered by alphabet while flags is near the bottom and the help text +// at the bottom, because they tend ot occupy the most lines of code. +export interface CliCommandConfig { + commandName: string // tmp optional while we migrate + description: string + hidden: boolean + flags: MeowFlags // tmp optional while we migrate + help: (command: string, config: CliCommandConfig) => string +} + +interface MeowOptions extends Options<any> { + aliases?: CliAliases | undefined + argv: readonly string[] + name: string + // When no sub-command is given, default to this sub-command + defaultSub?: string +} + +// For debugging. Whenever you call meowOrExit it will store the command here +// This module exports a getter that returns the current value. +let lastSeenCommand = '' + +export function getLastSeenCommand(): string { + return lastSeenCommand +} + +export async function meowWithSubcommands( + subcommands: Record<string, CliSubcommand>, + options: MeowOptions, +): Promise<void> { + const { + aliases = {}, + argv, + defaultSub, + importMeta, + name, + ...additionalOptions + } = { __proto__: null, ...options } + const [commandOrAliasName_, ...rawCommandArgv] = argv + let commandOrAliasName = commandOrAliasName_ + if (!commandOrAliasName && defaultSub) { + commandOrAliasName = defaultSub + } + + const flags: MeowFlags = { + ...commonFlags, + ...additionalOptions.flags, + } + + // No further args or first arg is a flag (shrug) + const isRootCommand = + name === 'socket' && + (!commandOrAliasName || commandOrAliasName?.startsWith('-')) + + // Try to support `socket <purl>` as a shorthand for `socket package score <purl>` + if (!isRootCommand) { + if (commandOrAliasName?.startsWith('pkg:')) { + logger.info('Note: Invoking `socket package score` now...') + return await meowWithSubcommands(subcommands, { + ...options, + argv: ['package', 'deep', ...argv], + }) + } + // Support `socket npm/babel` or whatever as a shorthand, too. + // Accept any ecosystem and let the remote sort it out. + if (/^[a-z]+\//.test(commandOrAliasName || '')) { + logger.info('Note: Invoking `socket package score` now...') + return await meowWithSubcommands(subcommands, { + ...options, + argv: [ + 'package', + 'deep', + `pkg:${commandOrAliasName}`, + ...rawCommandArgv, + ], + }) + } + } + + if (isRootCommand) { + flags['help'] = { + type: 'boolean', + hidden: false, // Only show on root + description: 'Give you detailed help information about any sub-command', + } + flags['config'] = { + type: 'string', + hidden: false, // Only show on root + description: 'Allows you to temp overrides the internal CLI config', + } + flags['dryRun'] = { + type: 'boolean', + hidden: false, // Only show on root + description: 'Do input validation for a sub-command and then exit', + } + flags['version'] = { + type: 'boolean', + hidden: false, // Only show on root + description: 'Show version of CLI', + } + delete flags['json'] + delete flags['markdown'] + } else { + delete flags['help'] + delete flags['version'] + } + + // This is basically a dry-run parse of cli args and flags. We use this to + // determine config overrides and expected output mode. + const cli1 = meow(`(this should never be printed)`, { + argv, + importMeta, + ...additionalOptions, + flags, + // Do not strictly check for flags here. + allowUnknownFlags: true, + booleanDefault: undefined, // We want to detect whether a bool flag is given at all. + // We will emit help when we're ready + // Plus, if we allow this then meow() can just exit here. + autoHelp: false, + }) + + const orgFlag = String(cli1.flags['org'] || '') || undefined + + // Hard override the config if instructed to do so. + // The env var overrides the --flag, which overrides the persisted config + // Also, when either of these are used, config updates won't persist. + let configOverrideResult + // Lazily access constants.ENV.SOCKET_CLI_CONFIG. + if (constants.ENV.SOCKET_CLI_CONFIG) { + configOverrideResult = overrideCachedConfig( + // Lazily access constants.ENV.SOCKET_CLI_CONFIG. + constants.ENV.SOCKET_CLI_CONFIG, + ) + } else if (cli1.flags['config']) { + configOverrideResult = overrideCachedConfig( + String(cli1.flags['config'] || ''), + ) + } + + // Lazily access constants.ENV.SOCKET_CLI_NO_API_TOKEN. + if (constants.ENV.SOCKET_CLI_NO_API_TOKEN) { + // This overrides the config override and even the explicit token env var. + // The config will be marked as readOnly to prevent persisting it. + overrideConfigApiToken(undefined) + } else { + // Lazily access constants.ENV.SOCKET_CLI_API_TOKEN. + const tokenOverride = constants.ENV.SOCKET_CLI_API_TOKEN + if (tokenOverride) { + // This will set the token (even if there was a config override) and + // set it to readOnly, making sure the temp token won't be persisted. + overrideConfigApiToken(tokenOverride) + } + } + + if (configOverrideResult?.ok === false) { + emitBanner(name, orgFlag) + logger.error('') // spacing in stderr + logger.fail(configOverrideResult.message) + process.exitCode = 2 + return + } + + // If we got at least some args, then lets find out if we can find a command. + if (commandOrAliasName) { + const alias = aliases[commandOrAliasName] + // First: Resolve argv data from alias if its an alias that's been given. + const [commandName, ...commandArgv] = alias + ? [...alias.argv, ...rawCommandArgv] + : [commandOrAliasName, ...rawCommandArgv] + // Second: Find a command definition using that data. + const commandDefinition = commandName ? subcommands[commandName] : undefined + // Third: If a valid command has been found, then we run it... + if (commandDefinition) { + return await commandDefinition.run(commandArgv, importMeta, { + parentName: name, + }) + } + } + + function formatCommandsForHelp(isRootCommand: boolean) { + if (!isRootCommand) { + return getHelpListOutput( + { + ...toSortedObject( + Object.fromEntries( + Object.entries(subcommands).filter( + ({ 1: subcommand }) => !subcommand.hidden, + ), + ), + ), + ...toSortedObject( + Object.fromEntries( + Object.entries(aliases).filter(({ 1: alias }) => { + const { hidden } = alias + const cmdName = hidden ? '' : alias.argv[0] + const subcommand = cmdName ? subcommands[cmdName] : undefined + return subcommand && !subcommand.hidden + }), + ), + ), + }, + 6, + ) + } + + // "Bucket" some commands for easier usage. + const commands = new Set([ + 'analytics', + 'audit-log', + 'config', + 'fix', + 'install', + 'login', + 'logout', + 'manifest', + 'npm', + 'npx', + 'optimize', + 'organization', + 'package', + 'raw-npm', + 'raw-npx', + 'repos', + 'scan', + 'threat-feed', + 'uninstall', + 'wrapper', + ]) + Object.entries(subcommands) + .filter(([_name, subcommand]) => !subcommand.hidden) + .map(([name]) => name) + .forEach(name => { + if (commands.has(name)) { + commands.delete(name) + } else { + logger.fail( + 'Received a visible command that was not added to the list here:', + name, + ) + } + }) + if (commands.size) { + logger.fail( + 'Found commands in the list that were not marked as public or not defined at all:', + // Node < 22 will print 'Object (n)' before the array. So to have + // consistent test snapshots we use joinAnd. + joinAnd( + Array.from(commands) + .sort(naturalCompare) + .map(c => `'${c}'`), + ), + ) + } + + const out = [] + out.push('All commands have their own --help page') + out.push('') + out.push(' Main commands') + out.push('') + out.push( + ' socket login Setup the CLI with an API Token and defaults', + ) + out.push(' socket scan create Create a new Scan and report') + out.push( + ' socket npm/eslint@1.0.0 Request the security score of a particular package', + ) + out.push( + ' socket ci Shorthand for CI; socket scan create --report --no-interactive', + ) + out.push('') + out.push(' Socket API') + out.push('') + out.push(' analytics Look up analytics data') + out.push( + ' audit-log Look up the audit log for an organization', + ) + out.push( + ' organization Manage organization account details', + ) + out.push( + ' package Look up published package details', + ) + out.push(' repository Manage registered repositories') + out.push(' scan Manage Socket scans') + out.push(' threat-feed [beta] View the threat feed') + out.push('') + out.push(' Local tools') + out.push('') + out.push( + ' fix Update dependencies with "fixable" Socket alerts', + ) + out.push( + ' manifest Generate a dependency manifest for certain languages', + ) + out.push(' npm npm wrapper functionality') + out.push(' npx npx wrapper functionality') + out.push( + ' optimize Optimize dependencies with @socketregistry overrides', + ) + out.push( + ' raw-npm Temporarily disable the Socket npm wrapper', + ) + out.push( + ' raw-npx Temporarily disable the Socket npx wrapper', + ) + out.push('') + out.push(' CLI configuration') + out.push('') + out.push( + ' config Manage the CLI configuration directly', + ) + out.push( + ' install Manually install CLI tab completion on your system', + ) + out.push(' login Socket API login and CLI setup') + out.push(' logout Socket API logout') + out.push( + ' uninstall Remove the CLI tab completion from your system', + ) + out.push( + ' wrapper Enable or disable the Socket npm/npx wrapper', + ) + + return out.join('\n') + } + + // Parse it again. Config overrides should now be applied (may affect help). + // Note: this is displayed as help screen if the command does not override it + // (which is the case for most sub-commands with sub-commands) + const cli2 = meow( + ` + Usage + $ ${name} <command> +${isRootCommand ? '' : '\n Commands'} + ${formatCommandsForHelp(isRootCommand)} + +${isRootCommand ? ' Options' : ' Options'}${isRootCommand ? ' (Note: all CLI commands have these flags even when not displayed in their help)\n' : ''} + ${getFlagListOutput(flags, 6, { padName: 25 })} + + Examples + $ ${name} --help +${isRootCommand ? ` $ ${name} scan create --json` : ''}${isRootCommand ? `\n $ ${name} package score npm left-pad --markdown` : ''}`, + { + argv, + importMeta, + ...additionalOptions, + flags, + // Do not strictly check for flags here. + allowUnknownFlags: true, + booleanDefault: undefined, // We want to detect whether a bool flag is given at all. + // We will emit help when we're ready + // Plus, if we allow this then meow() can just exit here. + autoHelp: false, + }, + ) + + // ...else we provide basic instructions and help. + if (!cli2.flags['nobanner']) { + emitBanner(name, orgFlag) + // meow will add newline so don't add stderr spacing here + } + if (!cli2.flags['help'] && cli2.flags['dryRun']) { + process.exitCode = 0 + // Lazily access constants.DRY_RUN_LABEL. + logger.log(`${constants.DRY_RUN_LABEL}: No-op, call a sub-command; ok`) + } else { + // When you explicitly request --help, the command should be successful + // so we exit(0). If we do it because we need more input, we exit(2). + cli2.showHelp(cli2.flags['help'] ? 0 : 2) + } +} + +/** + * Note: meow will exit immediately if it calls its .showHelp() + */ +export function meowOrExit({ + // allowUnknownFlags, // commands that pass-through args need to allow this + argv, + config, + importMeta, + parentName, +}: { + allowUnknownFlags?: boolean | undefined + argv: readonly string[] + config: CliCommandConfig + parentName: string + importMeta: ImportMeta +}): Result<MeowFlags> { + const command = `${parentName} ${config.commandName}` + lastSeenCommand = command + + // This exits if .printHelp() is called either by meow itself or by us. + const cli = meow({ + argv, + description: config.description, + help: config.help(command, config), + importMeta, + flags: config.flags, + allowUnknownFlags: true, // meow will exit(1) before printing the banner + booleanDefault: undefined, // We want to detect whether a bool flag is given at all. + autoHelp: false, // meow will exit(0) before printing the banner + }) + + if (!cli.flags['nobanner']) { + emitBanner(command, String(cli.flags['org'] || '') || undefined) + // Add spacing in stderr. meow.help adds a newline too so we do it here + logger.error('') + } + + // As per https://github.com/sindresorhus/meow/issues/178 + // Setting allowUnknownFlags:true makes it reject camel cased flags... + // if (!allowUnknownFlags) { + // // Run meow specifically with the flag setting. It will exit(2) if an + // // invalid flag is set and print a message. + // meow({ + // argv, + // description: config.description, + // help: config.help(command, config), + // importMeta, + // flags: config.flags, + // allowUnknownFlags: false, + // autoHelp: false, + // }) + // } + + if (cli.flags['help']) { + cli.showHelp(0) + } + // Now test for help state. Run meow again. If it exits now, it must be due + // to wanting to print the help screen. But it would exit(0) and we want a + // consistent exit(2) for that case (missing input). TODO: move away from meow + process.exitCode = 2 + meow({ + argv, + description: config.description, + help: config.help(command, config), + importMeta, + flags: config.flags, + // As per https://github.com/sindresorhus/meow/issues/178 + // Setting allowUnknownFlags:true makes it reject camel cased flags... + // allowUnknownFlags: Boolean(allowUnknownFlags), + autoHelp: false, + }) + // Ok, no help, reset to default. + process.exitCode = 0 + + return cli +} + +export function emitBanner(name: string, orgFlag: string | undefined) { + // Print a banner at the top of each command. + // This helps with brand recognition and marketing. + // It also helps with debugging since it contains version and command details. + // Note: print over stderr to preserve stdout for flags like --json and + // --markdown. If we don't do this, you can't use --json in particular + // and pipe the result to other tools. By emitting the banner over stderr + // you can do something like `socket scan view xyz | jq | process`. + // The spinner also emits over stderr for example. + logger.error(getAsciiHeader(name, orgFlag)) +} + +function getAsciiHeader(command: string, orgFlag: string | undefined) { + // Note: In tests we return <redacted> because otherwise snapshots will fail. + const { REDACTED } = constants + // Lazily access constants.ENV.VITEST. + const redacting = constants.ENV.VITEST + const cliVersion = redacting + ? REDACTED + : // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH. + constants.ENV.INLINED_SOCKET_CLI_VERSION_HASH + const nodeVersion = redacting ? REDACTED : process.version + const defaultOrg = getConfigValueOrUndef('defaultOrg') + const readOnlyConfig = isReadOnlyConfig() ? '*' : '.' + const shownToken = redacting + ? REDACTED + : getVisibleTokenPrefix() || '(not set)' + const relCwd = redacting ? REDACTED : normalizePath(tildify(process.cwd())) + // Note: we must redact org when creating snapshots because dev machine probably + // has a default org set but CI won't. Showing --org is fine either way. + const orgPart = orgFlag + ? `--org: ${orgFlag}` + : redacting + ? 'org: <redacted>' + : defaultOrg + ? `default org: ${defaultOrg}` + : '(org not set)' + // Note: We could draw these with ascii box art instead but I worry about + // portability and paste-ability. "simple" ascii chars just work. + const body = ` + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | Socket.dev CLI ver ${cliVersion} + |__ | ${readOnlyConfig} | _| '_| -_| _| | Node: ${nodeVersion}, API token: ${shownToken}, ${orgPart} + |_____|___|___|_,_|___|_|.dev | Command: \`${command}\`, cwd: ${relCwd} + `.trim() + + return ` ${body}` // Note: logger will auto-append a newline +} diff --git a/src/utils/ms-at-home.mts b/src/utils/ms-at-home.mts new file mode 100644 index 000000000..b57f5b4f1 --- /dev/null +++ b/src/utils/ms-at-home.mts @@ -0,0 +1,23 @@ +export function msAtHome(isoTimeStamp: string): string { + const timeStart = Date.parse(isoTimeStamp) + const timeEnd = Date.now() + + const rtf = new Intl.RelativeTimeFormat('en', { + numeric: 'always', + style: 'short', + }) + + const delta = timeEnd - timeStart + if (delta < 60 * 60 * 1000) { + return rtf.format(-Math.round(delta / (60 * 1000)), 'minute') + // return Math.round(delta / (60 * 1000)) + ' min ago' + } else if (delta < 24 * 60 * 60 * 1000) { + return rtf.format(-(delta / (60 * 60 * 1000)).toFixed(1), 'hour') + // return (delta / (60 * 60 * 1000)).toFixed(1) + ' hr ago' + } else if (delta < 7 * 24 * 60 * 60 * 1000) { + return rtf.format(-(delta / (24 * 60 * 60 * 1000)).toFixed(1), 'day') + // return (delta / (24 * 60 * 60 * 1000)).toFixed(1) + ' day ago' + } else { + return isoTimeStamp.slice(0, 10) + } +} diff --git a/src/utils/npm-package-arg.mts b/src/utils/npm-package-arg.mts new file mode 100644 index 000000000..732738dd8 --- /dev/null +++ b/src/utils/npm-package-arg.mts @@ -0,0 +1,20 @@ +import npmPackageArg from 'npm-package-arg' + +export type { + AliasResult, + FileResult, + HostedGit, + HostedGitResult, + RegistryResult, + Result, + URLResult, +} from 'npm-package-arg' + +export function npa( + ...args: Parameters<typeof npmPackageArg> +): ReturnType<typeof npmPackageArg> | null { + try { + return Reflect.apply(npmPackageArg, undefined, args) + } catch {} + return null +} diff --git a/src/utils/npm-paths.mts b/src/utils/npm-paths.mts new file mode 100755 index 000000000..2fe7c4742 --- /dev/null +++ b/src/utils/npm-paths.mts @@ -0,0 +1,102 @@ +import { existsSync } from 'node:fs' +import Module from 'node:module' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import constants from '../constants.mts' +import { findBinPathDetailsSync, findNpmPathSync } from './path-resolve.mts' + +const { NODE_MODULES, NPM, NPX, SOCKET_CLI_ISSUES_URL } = constants + +function exitWithBinPathError(binName: string): never { + logger.fail( + `Socket unable to locate ${binName}; ensure it is available in the PATH environment variable`, + ) + // The exit code 127 indicates that the command or binary being executed + // could not be found. + // eslint-disable-next-line n/no-process-exit + process.exit(127) +} + +let _npmBinPathDetails: ReturnType<typeof findBinPathDetailsSync> | undefined +function getNpmBinPathDetails(): ReturnType<typeof findBinPathDetailsSync> { + if (_npmBinPathDetails === undefined) { + _npmBinPathDetails = findBinPathDetailsSync(NPM) + } + return _npmBinPathDetails +} + +let _npxBinPathDetails: ReturnType<typeof findBinPathDetailsSync> | undefined +function getNpxBinPathDetails(): ReturnType<typeof findBinPathDetailsSync> { + if (_npxBinPathDetails === undefined) { + _npxBinPathDetails = findBinPathDetailsSync(NPX) + } + return _npxBinPathDetails +} + +export function isNpmBinPathShadowed() { + return getNpmBinPathDetails().shadowed +} + +export function isNpxBinPathShadowed() { + return getNpxBinPathDetails().shadowed +} + +let _npmBinPath: string | undefined +export function getNpmBinPath(): string { + if (_npmBinPath === undefined) { + _npmBinPath = getNpmBinPathDetails().path + if (!_npmBinPath) { + exitWithBinPathError(NPM) + } + } + return _npmBinPath +} + +let _npmPath: string | undefined +export function getNpmPath() { + if (_npmPath === undefined) { + const npmBinPath = getNpmBinPath() + _npmPath = npmBinPath ? findNpmPathSync(npmBinPath) : undefined + if (!_npmPath) { + let message = 'Unable to find npm CLI install directory.' + if (npmBinPath) { + message += `\nSearched parent directories of ${path.dirname(npmBinPath)}.` + } + message += `\n\nThis is may be a bug with socket-npm related to changes to the npm CLI.\nPlease report to ${SOCKET_CLI_ISSUES_URL}.` + logger.fail(message) + // The exit code 127 indicates that the command or binary being executed + // could not be found. + // eslint-disable-next-line n/no-process-exit + process.exit(127) + } + } + return _npmPath +} + +let _npmRequire: NodeJS.Require | undefined +export function getNpmRequire(): NodeJS.Require { + if (_npmRequire === undefined) { + const npmPath = getNpmPath() + const npmNmPath = path.join(npmPath, NODE_MODULES, NPM) + _npmRequire = Module.createRequire( + path.join( + existsSync(npmNmPath) ? npmNmPath : npmPath, + '<dummy-basename>', + ), + ) + } + return _npmRequire +} + +let _npxBinPath: string | undefined +export function getNpxBinPath(): string { + if (_npxBinPath === undefined) { + _npxBinPath = getNpxBinPathDetails().path + if (!_npxBinPath) { + exitWithBinPathError(NPX) + } + } + return _npxBinPath +} diff --git a/src/utils/objects.mts b/src/utils/objects.mts new file mode 100644 index 000000000..58e80600d --- /dev/null +++ b/src/utils/objects.mts @@ -0,0 +1,16 @@ +export function createEnum<const T extends Record<string, any>>( + obj: T, +): Readonly<T> { + return Object.freeze({ __proto__: null, ...obj }) as any +} + +export function pick<T extends Record<string, any>, K extends keyof T>( + input: T, + keys: K[] | readonly K[], +): Pick<T, K> { + const result: Partial<Pick<T, K>> = {} + for (const key of keys) { + result[key] = input[key] + } + return result as Pick<T, K> +} diff --git a/src/utils/output-formatting.mts b/src/utils/output-formatting.mts new file mode 100644 index 000000000..143c235bf --- /dev/null +++ b/src/utils/output-formatting.mts @@ -0,0 +1,49 @@ +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' + +import type { MeowFlags } from '../flags.mts' + +type HelpListOptions = { + keyPrefix?: string | undefined + padName?: number | undefined +} + +type ListDescription = + | { description: string } + | { description: string; hidden: boolean } + +export function getFlagListOutput( + list: MeowFlags, + indent: number, + { keyPrefix = '--', padName } = {} as HelpListOptions, +): string { + return getHelpListOutput( + { + ...list, + }, + indent, + { keyPrefix, padName }, + ) +} + +export function getHelpListOutput( + list: Record<string, ListDescription>, + indent: number, + { keyPrefix = '', padName = 18 } = {} as HelpListOptions, +): string { + let result = '' + const names = Object.keys(list).sort(naturalCompare) + for (const name of names) { + const entry = list[name] + if (entry && 'hidden' in entry && entry?.hidden) { + continue + } + const description = + (typeof entry === 'object' ? entry.description : entry) || '' + result += + ''.padEnd(indent) + + (keyPrefix + name).padEnd(padName) + + description + + '\n' + } + return result.trim() || '(none)' +} diff --git a/src/utils/package-environment.mts b/src/utils/package-environment.mts new file mode 100644 index 000000000..a93e361f4 --- /dev/null +++ b/src/utils/package-environment.mts @@ -0,0 +1,519 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import browserslist from 'browserslist' +import semver from 'semver' +import which from 'which' + +import { parse as parseBunLockb } from '@socketregistry/hyrious__bun.lockb/index.cjs' +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { Logger } from '@socketsecurity/registry/lib/logger' +import { readPackageJson } from '@socketsecurity/registry/lib/packages' +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' +import { spawn } from '@socketsecurity/registry/lib/spawn' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import { cmdPrefixMessage } from './cmd.mts' +import { findUp, readFileBinary, readFileUtf8 } from './fs.mts' +import constants from '../constants.mts' + +import type { CResult } from '../types.mts' +import type { Remap } from '@socketsecurity/registry/lib/objects' +import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' +import type { SemVer } from 'semver' + +const { + BINARY_LOCK_EXT, + BUN, + HIDDEN_PACKAGE_LOCK_JSON, + LOCK_EXT, + NPM, + NPM_BUGGY_OVERRIDES_PATCHED_VERSION, + PACKAGE_JSON, + PNPM, + VLT, + YARN, + YARN_BERRY, + YARN_CLASSIC, +} = constants + +export const AGENTS = [BUN, NPM, PNPM, YARN_BERRY, YARN_CLASSIC, VLT] as const + +const binByAgent = new Map<Agent, string>([ + [BUN, BUN], + [NPM, NPM], + [PNPM, PNPM], + [YARN_BERRY, YARN], + [YARN_CLASSIC, YARN], + [VLT, VLT], +]) + +export type Agent = (typeof AGENTS)[number] + +export type EnvBase = { + agent: Agent + agentExecPath: string + agentSupported: boolean + features: { + // Fixed by https://github.com/npm/cli/pull/8089. + // Landed in npm v11.2.0. + npmBuggyOverrides: boolean + } + nodeSupported: boolean + nodeVersion: SemVer + npmExecPath: string + pkgRequirements: { + agent: string + node: string + } + pkgSupports: { + agent: boolean + node: boolean + } +} + +export type EnvDetails = Readonly< + Remap< + EnvBase & { + agentVersion: SemVer + editablePkgJson: EditablePackageJson + lockName: string + lockPath: string + lockSrc: string + pkgPath: string + } + > +> + +export type DetectAndValidateOptions = { + cmdName?: string | undefined + logger?: Logger | undefined + prod?: boolean | undefined +} + +export type DetectOptions = { + cwd?: string | undefined + onUnknown?: (pkgManager: string | undefined) => void +} + +export type PartialEnvDetails = Readonly< + Remap< + EnvBase & { + agentVersion: SemVer | undefined + editablePkgJson: EditablePackageJson | undefined + lockName: string | undefined + lockPath: string | undefined + lockSrc: string | undefined + pkgPath: string | undefined + } + > +> + +export type ReadLockFile = + | ((lockPath: string) => Promise<string | undefined>) + | ((lockPath: string, agentExecPath: string) => Promise<string | undefined>) + | (( + lockPath: string, + agentExecPath: string, + cwd: string, + ) => Promise<string | undefined>) + +const readLockFileByAgent: Map<Agent, ReadLockFile> = (() => { + function wrapReader<T extends (...args: any[]) => Promise<any>>( + reader: T, + ): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined> { + return async (...args: any[]): Promise<any> => { + try { + return await reader(...args) + } catch {} + return undefined + } + } + + const binaryReader = wrapReader(readFileBinary) + + const defaultReader = wrapReader( + async (lockPath: string) => await readFileUtf8(lockPath), + ) + + return new Map([ + [ + BUN, + wrapReader( + async ( + lockPath: string, + agentExecPath: string, + cwd = process.cwd(), + ) => { + const ext = path.extname(lockPath) + if (ext === LOCK_EXT) { + return await defaultReader(lockPath) + } + if (ext === BINARY_LOCK_EXT) { + const lockBuffer = await binaryReader(lockPath) + if (lockBuffer) { + try { + return parseBunLockb(lockBuffer) + } catch {} + } + // To print a Yarn lockfile to your console without writing it to disk + // use `bun bun.lockb`. + // https://bun.sh/guides/install/yarnlock + return ( + await spawn(agentExecPath, [lockPath], { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }) + ).stdout.trim() + } + return undefined + }, + ), + ], + [NPM, defaultReader], + [PNPM, defaultReader], + [VLT, defaultReader], + [YARN_BERRY, defaultReader], + [YARN_CLASSIC, defaultReader], + ]) +})() + +// The order of LOCKS properties IS significant as it affects iteration order. +const LOCKS: Record<string, Agent> = { + [`bun${LOCK_EXT}`]: BUN, + [`bun${BINARY_LOCK_EXT}`]: BUN, + // If both package-lock.json and npm-shrinkwrap.json are present in the root + // of a project, npm-shrinkwrap.json will take precedence and package-lock.json + // will be ignored. + // https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#package-lockjson-vs-npm-shrinkwrapjson + 'npm-shrinkwrap.json': NPM, + 'package-lock.json': NPM, + 'pnpm-lock.yaml': PNPM, + 'pnpm-lock.yml': PNPM, + [`yarn${LOCK_EXT}`]: YARN_CLASSIC, + 'vlt-lock.json': VLT, + // Lastly, look for a hidden lock file which is present if .npmrc has package-lock=false: + // https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#hidden-lockfiles + // + // Unlike the other LOCKS keys this key contains a directory AND filename so + // it has to be handled differently. + 'node_modules/.package-lock.json': NPM, +} + +async function getAgentExecPath(agent: Agent): Promise<string> { + const binName = binByAgent.get(agent)! + if (binName === NPM) { + // Lazily access constants.npmExecPath. + return constants.npmExecPath + } + return (await which(binName, { nothrow: true })) ?? binName +} + +async function getAgentVersion( + agentExecPath: string, + cwd: string, +): Promise<SemVer | undefined> { + let result + try { + result = + // Coerce version output into a valid semver version by passing it through + // semver.coerce which strips leading v's, carets (^), comparators (<,<=,>,>=,=), + // and tildes (~). + semver.coerce( + // All package managers support the "--version" flag. + ( + await spawn(agentExecPath, ['--version'], { + cwd, + // Lazily access constants.WIN32. + shell: constants.WIN32, + }) + ).stdout.trim(), + ) ?? undefined + } catch (e) { + debugFn('catch: unexpected\n', e) + } + return result +} + +export async function detectPackageEnvironment({ + cwd = process.cwd(), + onUnknown, +}: DetectOptions = {}): Promise<EnvDetails | PartialEnvDetails> { + let lockPath = await findUp(Object.keys(LOCKS), { cwd }) + let lockName = lockPath ? path.basename(lockPath) : undefined + const isHiddenLockFile = lockName === HIDDEN_PACKAGE_LOCK_JSON + const pkgJsonPath = lockPath + ? path.resolve( + lockPath, + `${isHiddenLockFile ? '../' : ''}../${PACKAGE_JSON}`, + ) + : await findUp(PACKAGE_JSON, { cwd }) + const pkgPath = + pkgJsonPath && existsSync(pkgJsonPath) + ? path.dirname(pkgJsonPath) + : undefined + const editablePkgJson = pkgPath + ? await readPackageJson(pkgPath, { editable: true }) + : undefined + // Read Corepack `packageManager` field in package.json: + // https://nodejs.org/api/packages.html#packagemanager + const pkgManager = isNonEmptyString(editablePkgJson?.content?.packageManager) + ? editablePkgJson.content.packageManager + : undefined + + let agent: Agent | undefined + if (pkgManager) { + // A valid "packageManager" field value is "<package manager name>@<version>". + // https://nodejs.org/api/packages.html#packagemanager + const atSignIndex = pkgManager.lastIndexOf('@') + if (atSignIndex !== -1) { + const name = pkgManager.slice(0, atSignIndex) as Agent + const version = pkgManager.slice(atSignIndex + 1) + if (version && AGENTS.includes(name)) { + agent = name + } + } + } + if ( + agent === undefined && + !isHiddenLockFile && + typeof pkgJsonPath === 'string' && + typeof lockName === 'string' + ) { + agent = LOCKS[lockName] as Agent + } + if (agent === undefined) { + agent = NPM + onUnknown?.(pkgManager) + } + const agentExecPath = await getAgentExecPath(agent) + const agentVersion = await getAgentVersion(agentExecPath, cwd) + if (agent === YARN_CLASSIC && (agentVersion?.major ?? 0) > 1) { + agent = YARN_BERRY + } + // Lazily access constants.maintainedNodeVersions. + const { maintainedNodeVersions } = constants + // Lazily access constants.minimumVersionByAgent. + const minSupportedAgentVersion = constants.minimumVersionByAgent.get(agent)! + const minSupportedNodeVersion = maintainedNodeVersions.last + const nodeVersion = semver.coerce(process.version)! + let lockSrc: string | undefined + let pkgAgentRange: string | undefined + let pkgNodeRange: string | undefined + let pkgMinAgentVersion = minSupportedAgentVersion + let pkgMinNodeVersion = minSupportedNodeVersion + if (editablePkgJson?.content) { + const { engines } = editablePkgJson.content + const engineAgentRange = engines?.[agent] + const engineNodeRange = engines?.['node'] + if (isNonEmptyString(engineAgentRange)) { + pkgAgentRange = engineAgentRange + // Roughly check agent range as semver.coerce will strip leading + // v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~). + const coerced = semver.coerce(pkgAgentRange) + if (coerced && semver.lt(coerced, pkgMinAgentVersion)) { + pkgMinAgentVersion = coerced.version + } + } + if (isNonEmptyString(engineNodeRange)) { + pkgNodeRange = engineNodeRange + // Roughly check Node range as semver.coerce will strip leading + // v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~). + const coerced = semver.coerce(pkgNodeRange) + if (coerced && semver.lt(coerced, pkgMinNodeVersion)) { + pkgMinNodeVersion = coerced.version + } + } + const browserslistQuery = editablePkgJson.content['browserslist'] as + | string[] + | undefined + if (Array.isArray(browserslistQuery)) { + // List Node targets in ascending version order. + const browserslistNodeTargets = browserslist(browserslistQuery) + .filter(v => /^node /i.test(v)) + .map(v => v.slice(5 /*'node '.length*/)) + .sort(naturalCompare) + if (browserslistNodeTargets.length) { + // browserslistNodeTargets[0] is the lowest Node target version. + const coerced = semver.coerce(browserslistNodeTargets[0]) + if (coerced && semver.lt(coerced, pkgMinNodeVersion)) { + pkgMinNodeVersion = coerced.version + } + } + } + lockSrc = + typeof lockPath === 'string' + ? await readLockFileByAgent.get(agent)!(lockPath, agentExecPath, cwd) + : undefined + } else { + lockName = undefined + lockPath = undefined + } + // Does the system agent version meet our minimum supported agent version? + const agentSupported = + !!agentVersion && + semver.satisfies(agentVersion, `>=${minSupportedAgentVersion}`) + + // Does the system Node version meet our minimum supported Node version? + const nodeSupported = semver.satisfies( + nodeVersion, + `>=${minSupportedNodeVersion}`, + ) + + const npmExecPath = + agent === NPM ? agentExecPath : await getAgentExecPath(NPM) + + const npmBuggyOverrides = + agent === NPM && + !!agentVersion && + semver.lt(agentVersion, NPM_BUGGY_OVERRIDES_PATCHED_VERSION) + + return { + agent, + agentExecPath, + agentSupported, + agentVersion, + editablePkgJson, + features: { npmBuggyOverrides }, + lockName, + lockPath, + lockSrc, + nodeSupported, + nodeVersion, + npmExecPath, + pkgPath, + pkgRequirements: { + agent: pkgAgentRange ?? `>=${pkgMinAgentVersion}`, + node: pkgNodeRange ?? `>=${pkgMinNodeVersion}`, + }, + pkgSupports: { + // Does our minimum supported agent version meet the package's requirements? + agent: semver.satisfies( + minSupportedAgentVersion, + `>=${pkgMinAgentVersion}`, + ), + // Does our supported Node versions meet the package's requirements? + node: maintainedNodeVersions.some(v => + semver.satisfies(v, `>=${pkgMinNodeVersion}`), + ), + }, + } +} + +export async function detectAndValidatePackageEnvironment( + cwd: string, + options?: DetectAndValidateOptions | undefined, +): Promise<CResult<EnvDetails>> { + const { + cmdName = '', + logger, + prod, + } = { + __proto__: null, + ...options, + } as DetectAndValidateOptions + const details = await detectPackageEnvironment({ + cwd, + onUnknown(pkgManager: string | undefined) { + logger?.warn( + cmdPrefixMessage( + cmdName, + `Unknown package manager${pkgManager ? ` ${pkgManager}` : ''}, defaulting to npm`, + ), + ) + }, + }) + const { agent, nodeVersion, pkgRequirements } = details + const agentVersion = details.agentVersion ?? 'unknown' + if (!details.agentSupported) { + const minVersion = constants.minimumVersionByAgent.get(agent)! + return { + ok: false, + message: 'Version Mismatch', + cause: cmdPrefixMessage( + cmdName, + `Requires ${agent} >=${minVersion}. Current version: ${agentVersion}.`, + ), + } + } + if (!details.nodeSupported) { + const minVersion = constants.maintainedNodeVersions.last + return { + ok: false, + message: 'Version Mismatch', + cause: cmdPrefixMessage( + cmdName, + `Requires Node >=${minVersion}. Current version: ${nodeVersion}.`, + ), + } + } + if (!details.pkgSupports.agent) { + return { + ok: false, + message: 'Engine Mismatch', + cause: cmdPrefixMessage( + cmdName, + `Package engine "${agent}" requires ${pkgRequirements.agent}. Current version: ${agentVersion}`, + ), + } + } + if (!details.pkgSupports.node) { + return { + ok: false, + message: 'Version Mismatch', + cause: cmdPrefixMessage( + cmdName, + `Package engine "node" requires ${pkgRequirements.node}. Current version: ${nodeVersion}`, + ), + } + } + const lockName = details.lockName ?? 'lock file' + if (details.lockName === undefined || details.lockSrc === undefined) { + return { + ok: false, + message: 'Missing Lock File', + cause: cmdPrefixMessage(cmdName, `No ${lockName} found`), + } + } + if (details.lockSrc.trim() === '') { + return { + ok: false, + message: 'Empty Lock File', + cause: cmdPrefixMessage(cmdName, `${lockName} is empty`), + } + } + if (details.pkgPath === undefined) { + return { + ok: false, + message: 'Missing package.json', + cause: cmdPrefixMessage(cmdName, `No ${PACKAGE_JSON} found`), + } + } + if (prod && (agent === BUN || agent === YARN_BERRY)) { + return { + ok: false, + message: 'Bad input', + cause: cmdPrefixMessage( + cmdName, + `--prod not supported for ${agent}${agentVersion ? `@${agentVersion}` : ''}`, + ), + } + } + if ( + details.lockPath && + path.relative(cwd, details.lockPath).startsWith('.') + ) { + // Note: In tests we return <redacted> because otherwise snapshots will fail. + const { REDACTED } = constants + // Lazily access constants.ENV.VITEST. + const redacting = constants.ENV.VITEST + logger?.warn( + cmdPrefixMessage( + cmdName, + `Package ${lockName} found at ${redacting ? REDACTED : details.lockPath}`, + ), + ) + } + return { ok: true, data: details as EnvDetails } +} diff --git a/src/utils/path-resolve.mts b/src/utils/path-resolve.mts new file mode 100644 index 000000000..97e0482dd --- /dev/null +++ b/src/utils/path-resolve.mts @@ -0,0 +1,142 @@ +import { existsSync, statSync } from 'node:fs' +import path from 'node:path' + +import which from 'which' + +import { debugFn, isDebug } from '@socketsecurity/registry/lib/debug' +import { resolveBinPath } from '@socketsecurity/registry/lib/npm' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import constants from '../constants.mts' +import { + filterGlobResultToSupportedFiles, + globWithGitIgnore, + pathsToGlobPatterns, +} from './glob.mts' + +import type { SocketYml } from '@socketsecurity/config' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +const { NODE_MODULES, NPM, shadowBinPath } = constants + +export function findBinPathDetailsSync(binName: string): { + name: string + path: string | undefined + shadowed: boolean +} { + const binPaths = + which.sync(binName, { + all: true, + nothrow: true, + }) ?? [] + let shadowIndex = -1 + let theBinPath: string | undefined + for (let i = 0, { length } = binPaths; i < length; i += 1) { + const binPath = binPaths[i]! + // Skip our bin directory if it's in the front. + if (path.dirname(binPath) === shadowBinPath) { + shadowIndex = i + } else { + theBinPath = resolveBinPath(binPath) + break + } + } + return { name: binName, path: theBinPath, shadowed: shadowIndex !== -1 } +} + +export function findNpmPathSync(npmBinPath: string): string | undefined { + // Lazily access constants.WIN32. + const { WIN32 } = constants + let thePath = npmBinPath + while (true) { + const libNmNpmPath = path.join(thePath, 'lib', NODE_MODULES, NPM) + // mise puts its npm bin in a path like: + // /Users/SomeUsername/.local/share/mise/installs/node/vX.X.X/bin/npm. + // HOWEVER, the location of the npm install is: + // /Users/SomeUsername/.local/share/mise/installs/node/vX.X.X/lib/node_modules/npm. + if ( + // Use existsSync here because statsSync, even with { throwIfNoEntry: false }, + // will throw an ENOTDIR error for paths like ./a-file-that-exists/a-directory-that-does-not. + // See https://github.com/nodejs/node/issues/56993. + existsSync(libNmNpmPath) && + statSync(libNmNpmPath, { throwIfNoEntry: false })?.isDirectory() + ) { + thePath = path.join(libNmNpmPath, NPM) + } + const nmPath = path.join(thePath, NODE_MODULES) + if ( + // npm bin paths may look like: + // /usr/local/share/npm/bin/npm + // /Users/SomeUsername/.nvm/versions/node/vX.X.X/bin/npm + // C:\Users\SomeUsername\AppData\Roaming\npm\bin\npm.cmd + // OR + // C:\Program Files\nodejs\npm.cmd + // + // In practically all cases the npm path contains a node_modules folder: + // /usr/local/share/npm/bin/npm/node_modules + // C:\Program Files\nodejs\node_modules + existsSync(nmPath) && + statSync(nmPath, { throwIfNoEntry: false })?.isDirectory() && + // Optimistically look for the default location. + (path.basename(thePath) === NPM || + // Chocolatey installs npm bins in the same directory as node bins. + (WIN32 && existsSync(path.join(thePath, `${NPM}.cmd`)))) + ) { + return thePath + } + const parent = path.dirname(thePath) + if (parent === thePath) { + return undefined + } + thePath = parent + } +} + +export async function getPackageFilesForScan( + cwd: string, + inputPaths: string[], + supportedFiles: SocketSdkReturnType<'getReportSupportedFiles'>['data'], + config?: SocketYml | undefined, +): Promise<string[]> { + debugFn(`resolve: ${inputPaths.length} paths`, inputPaths) + + // Lazily access constants.spinner. + const { spinner } = constants + + const patterns = pathsToGlobPatterns(inputPaths) + + spinner.start('Searching for local files to include in scan...') + + const entries = await globWithGitIgnore(patterns, { + cwd, + socketConfig: config, + }) + + if (isDebug()) { + spinner.stop() + + debugFn( + `Resolved ${inputPaths.length} paths to ${entries.length} local paths:\n`, + entries, + ) + + spinner.start('Searching for files now...') + } else { + spinner.start( + `Resolved ${inputPaths.length} paths to ${entries.length} local paths, searching for files now...`, + ) + } + + const packageFiles = await filterGlobResultToSupportedFiles( + entries, + supportedFiles, + ) + + spinner.successAndStop( + `Found ${packageFiles.length} local ${pluralize('file', packageFiles.length)}`, + ) + + debugFn('paths: absolute', packageFiles) + + return packageFiles +} diff --git a/src/utils/pnpm.mts b/src/utils/pnpm.mts new file mode 100644 index 000000000..db3386e80 --- /dev/null +++ b/src/utils/pnpm.mts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs' + +import yaml from 'js-yaml' +import semver from 'semver' + +import { isObjectObject } from '@socketsecurity/registry/lib/objects' +import { stripBom } from '@socketsecurity/registry/lib/strings' + +import { readFileUtf8 } from './fs.mts' +import { idToNpmPurl } from './spec.mts' + +import type { LockfileObject, PackageSnapshot } from '@pnpm/lockfile.fs' +import type { SemVer } from 'semver' + +export function extractOverridesFromPnpmLockSrc(lockfileContent: any): string { + return typeof lockfileContent === 'string' + ? (/^overrides:(\r?\n {2}.+)+(?:\r?\n)*/m.exec(lockfileContent)?.[0] ?? '') + : '' +} + +export async function extractPurlsFromPnpmLockfile( + lockfile: LockfileObject, +): Promise<string[]> { + const packages = lockfile?.packages ?? {} + const seen = new Set<string>() + const visit = (pkgPath: string) => { + if (seen.has(pkgPath)) { + return + } + const pkg = (packages as any)[pkgPath] as PackageSnapshot + if (!pkg) { + return + } + seen.add(pkgPath) + const deps: { [name: string]: string } = { + __proto__: null, + ...pkg.dependencies, + ...pkg.optionalDependencies, + ...(pkg as any).devDependencies, + } + for (const depName in deps) { + const ref = deps[depName]! + const subKey = isPnpmDepPath(ref) ? ref : `/${depName}@${ref}` + visit(subKey) + } + } + for (const pkgPath of Object.keys(packages)) { + visit(pkgPath) + } + return Array.from(seen).map(p => + idToNpmPurl(stripPnpmPeerSuffix(stripLeadingPnpmDepPathSlash(p))), + ) +} + +export function isPnpmDepPath(maybeDepPath: string): boolean { + return maybeDepPath.length > 0 && maybeDepPath.charCodeAt(0) === 47 /*'/'*/ +} + +export function parsePnpmLockfile( + lockfileContent: unknown, +): LockfileObject | null { + let result + if (typeof lockfileContent === 'string') { + try { + result = yaml.load(stripBom(lockfileContent)) + } catch {} + } + return isObjectObject(result) ? (result as LockfileObject) : null +} + +export function parsePnpmLockfileVersion(version: unknown): SemVer | null { + try { + return semver.coerce(version as string) + } catch {} + return null +} + +export async function readPnpmLockfile( + lockfilePath: string, +): Promise<string | null> { + return existsSync(lockfilePath) ? await readFileUtf8(lockfilePath) : null +} + +export function stripLeadingPnpmDepPathSlash(depPath: string): string { + return isPnpmDepPath(depPath) ? depPath.slice(1) : depPath +} + +export function stripPnpmPeerSuffix(depPath: string): string { + const parenIndex = depPath.indexOf('(') + const index = parenIndex === -1 ? depPath.indexOf('_') : parenIndex + return index === -1 ? depPath : depPath.slice(0, index) +} diff --git a/src/utils/purl.mts b/src/utils/purl.mts new file mode 100644 index 000000000..47501c1e8 --- /dev/null +++ b/src/utils/purl.mts @@ -0,0 +1,21 @@ +import { PackageURL } from '@socketregistry/packageurl-js' + +import type { PURL_Type, SocketArtifact } from './alert/artifact.mts' + +export function getPurlObject(purl: string): PackageURL & { type: PURL_Type } +export function getPurlObject( + purl: PackageURL, +): PackageURL & { type: PURL_Type } +export function getPurlObject( + purl: SocketArtifact, +): SocketArtifact & { type: PURL_Type } +export function getPurlObject( + purl: string | PackageURL | SocketArtifact, +): (PackageURL | SocketArtifact) & { type: PURL_Type } +export function getPurlObject( + purl: string | PackageURL | SocketArtifact, +): (PackageURL | SocketArtifact) & { type: PURL_Type } { + return typeof purl === 'string' + ? (PackageURL.fromString(purl) as PackageURL & { type: PURL_Type }) + : (purl as (PackageURL | SocketArtifact) & { type: PURL_Type }) +} diff --git a/src/utils/sdk.mts b/src/utils/sdk.mts new file mode 100644 index 000000000..8ff2bde7c --- /dev/null +++ b/src/utils/sdk.mts @@ -0,0 +1,105 @@ +import { HttpsProxyAgent } from 'hpagent' + +import isInteractive from '@socketregistry/is-interactive/index.cjs' +import { password } from '@socketsecurity/registry/lib/prompts' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' +import { SocketSdk, createUserAgentFromPkgJson } from '@socketsecurity/sdk' + +import { getConfigValueOrUndef } from './config.mts' +import constants from '../constants.mts' + +import type { CResult } from '../types.mts' + +const { SOCKET_PUBLIC_API_TOKEN } = constants + +const TOKEN_PREFIX = 'sktsec_' + +const { length: TOKEN_PREFIX_LENGTH } = TOKEN_PREFIX + +// The API server that should be used for operations. +function getDefaultApiBaseUrl(): string | undefined { + const baseUrl = + // Lazily access constants.ENV.SOCKET_CLI_API_BASE_URL. + constants.ENV.SOCKET_CLI_API_BASE_URL || getConfigValueOrUndef('apiBaseUrl') + return isNonEmptyString(baseUrl) ? baseUrl : undefined +} + +// The API server that should be used for operations. +function getDefaultHttpProxy(): string | undefined { + const apiProxy = + // Lazily access constants.ENV.SOCKET_CLI_API_PROXY. + constants.ENV.SOCKET_CLI_API_PROXY || getConfigValueOrUndef('apiProxy') + return isNonEmptyString(apiProxy) ? apiProxy : undefined +} + +// This API key should be stored globally for the duration of the CLI execution. +let _defaultToken: string | undefined +export function getDefaultToken(): string | undefined { + // Lazily access constants.ENV.SOCKET_CLI_NO_API_TOKEN. + if (constants.ENV.SOCKET_CLI_NO_API_TOKEN) { + _defaultToken = undefined + } else { + const key = + // Lazily access constants.ENV.SOCKET_CLI_API_TOKEN. + constants.ENV.SOCKET_CLI_API_TOKEN || + getConfigValueOrUndef('apiToken') || + _defaultToken + _defaultToken = isNonEmptyString(key) ? key : undefined + } + return _defaultToken +} + +export function getVisibleTokenPrefix(): string { + const apiToken = getDefaultToken() + return apiToken + ? apiToken.slice(TOKEN_PREFIX_LENGTH, TOKEN_PREFIX_LENGTH + 5) + : '' +} + +export function hasDefaultToken(): boolean { + return !!getDefaultToken() +} + +export function getPublicToken(): string { + return ( + // Lazily access constants.ENV.SOCKET_CLI_API_TOKEN. + (constants.ENV.SOCKET_CLI_API_TOKEN || getDefaultToken()) ?? + SOCKET_PUBLIC_API_TOKEN + ) +} + +export async function setupSdk( + apiToken: string | undefined = getDefaultToken(), + apiBaseUrl: string | undefined = getDefaultApiBaseUrl(), + proxy: string | undefined = getDefaultHttpProxy(), +): Promise<CResult<SocketSdk>> { + if (typeof apiToken !== 'string' && isInteractive()) { + apiToken = await password({ + message: + 'Enter your Socket.dev API key (not saved, use socket login to persist)', + }) + _defaultToken = apiToken + } + if (!apiToken) { + return { + ok: false, + message: 'Auth Error', + cause: 'You need to provide an API Token. Run `socket login` first.', + } + } + return { + ok: true, + data: new SocketSdk(apiToken, { + agent: proxy ? new HttpsProxyAgent({ proxy }) : undefined, + baseUrl: apiBaseUrl, + userAgent: createUserAgentFromPkgJson({ + // Lazily access constants.ENV.INLINED_SOCKET_CLI_NAME. + name: constants.ENV.INLINED_SOCKET_CLI_NAME, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_VERSION. + version: constants.ENV.INLINED_SOCKET_CLI_VERSION, + // Lazily access constants.ENV.INLINED_SOCKET_CLI_HOMEPAGE. + homepage: constants.ENV.INLINED_SOCKET_CLI_HOMEPAGE, + }), + }), + } +} diff --git a/src/utils/semver.mts b/src/utils/semver.mts new file mode 100644 index 000000000..e2602582d --- /dev/null +++ b/src/utils/semver.mts @@ -0,0 +1,83 @@ +import semver from 'semver' + +import type { SemVer } from 'semver' + +export const RangeStyles = [ + 'caret', + 'gt', + 'gte', + 'lt', + 'lte', + 'pin', + 'preserve', + 'tilde', +] + +export type RangeStyle = + | 'caret' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'pin' + | 'preserve' + | 'tilde' + +export type { SemVer } + +export function applyRange( + refRange: string, + version: string, + style: RangeStyle = 'preserve', +): string { + switch (style) { + case 'caret': + return `^${version}` + case 'gt': + return `>${version}` + case 'gte': + return `>=${version}` + case 'lt': + return `<${version}` + case 'lte': + return `<=${version}` + case 'preserve': { + const range = new semver.Range(refRange) + const { raw } = range + const comparators = range.set.flat() + const { length } = comparators + if (length === 1) { + const char = /^[<>]=?/.exec(raw)?.[0] + if (char) { + return `${char}${version}` + } + } else if (length === 2) { + const char = /^[~^]/.exec(raw)?.[0] + if (char) { + return `${char}${version}` + } + } + return version + } + case 'tilde': + return `~${version}` + case 'pin': + default: + return version + } +} + +export function getMajor(version: unknown): number | null { + try { + const coerced = semver.coerce(version as string) + return coerced ? semver.major(coerced) : null + } catch {} + return null +} + +export function getMinVersion(range: unknown): SemVer | null { + try { + return semver.minVersion(range as string) + } catch {} + return null +} diff --git a/src/utils/serialize-result-json.mts b/src/utils/serialize-result-json.mts new file mode 100644 index 000000000..e5b5038fd --- /dev/null +++ b/src/utils/serialize-result-json.mts @@ -0,0 +1,49 @@ +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import type { CResult } from '../types.mts' + +// Serialize the final result object before printing it +// All commands that support the --json flag should call this before printing +export function serializeResultJson(data: CResult<unknown>): string { + if (typeof data !== 'object' || !data) { + process.exitCode = 1 + debugFn('typeof data=', typeof data) + + if (typeof data !== 'object' && data) { + debugFn('data:\n', data) + } + + // We should not allow the json value to be "null", or a boolean/number/string, + // even if they are valid "json". + const message = + 'There was a problem converting the data set to JSON. The JSON was not an object. Please try again without --json' + + return ( + JSON.stringify({ + ok: false, + message: 'Unable to serialize JSON', + data: message, + }).trim() + '\n' + ) + } + + try { + return JSON.stringify(data, null, 2).trim() + '\n' + } catch (e) { + debugFn('catch: unexpected\n', e) + process.exitCode = 1 + + // This could be caused by circular references, which is an "us" problem + const message = + 'There was a problem converting the data set to JSON. Please try again without --json' + logger.fail(message) + return ( + JSON.stringify({ + ok: false, + message: 'Unable to serialize JSON', + data: message, + }).trim() + '\n' + ) + } +} diff --git a/src/utils/socket-package-alert.mts b/src/utils/socket-package-alert.mts new file mode 100644 index 000000000..9cf28b289 --- /dev/null +++ b/src/utils/socket-package-alert.mts @@ -0,0 +1,611 @@ +import { PackageURL } from 'packageurl-js' +import semver from 'semver' +import colors from 'yoctocolors-cjs' + +import { getManifestData } from '@socketsecurity/registry' +import { debugFn, debugLog } from '@socketsecurity/registry/lib/debug' +import { hasOwn } from '@socketsecurity/registry/lib/objects' +import { resolvePackageName } from '@socketsecurity/registry/lib/packages' +import { naturalCompare } from '@socketsecurity/registry/lib/sorts' + +import { isArtifactAlertCve } from './alert/artifact.mts' +import { ALERT_FIX_TYPE } from './alert/fix.mts' +import { ALERT_SEVERITY } from './alert/severity.mts' +import { ColorOrMarkdown } from './color-or-markdown.mts' +import { findSocketYmlSync } from './config.mts' +import { createEnum } from './objects.mts' +import { getPurlObject } from './purl.mts' +import { getMajor } from './semver.mts' +import { getSocketDevPackageOverviewUrl } from './socket-url.mts' +import { getTranslations } from './translations.mts' + +import type { + ALERT_ACTION, + ALERT_TYPE, + CompactSocketArtifact, + CompactSocketArtifactAlert, + CveProps, + PURL_Type, +} from './alert/artifact.mts' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +export const ALERT_SEVERITY_COLOR = createEnum({ + critical: 'magenta', + high: 'red', + middle: 'yellow', + low: 'white', +}) + +export const ALERT_SEVERITY_ORDER = createEnum({ + critical: 0, + high: 1, + middle: 2, + low: 3, + none: 4, +}) + +export type SocketPackageAlert = { + name: string + version: string + key: string + type: string + blocked: boolean + critical: boolean + ecosystem: PURL_Type + fixable: boolean + raw: CompactSocketArtifactAlert + upgradable: boolean +} + +export type AlertsByPurl = Map<string, SocketPackageAlert[]> + +const MIN_ABOVE_THE_FOLD_COUNT = 3 + +const MIN_ABOVE_THE_FOLD_ALERT_COUNT = 1 + +const format = new ColorOrMarkdown(false) + +export type RiskCounts = { + critical: number + high: number + middle: number + low: number +} + +function getHiddenRiskCounts(hiddenAlerts: SocketPackageAlert[]): RiskCounts { + const riskCounts = { + critical: 0, + high: 0, + middle: 0, + low: 0, + } + for (const alert of hiddenAlerts) { + switch (getAlertSeverityOrder(alert)) { + case ALERT_SEVERITY_ORDER.critical: + riskCounts.critical += 1 + break + case ALERT_SEVERITY_ORDER.high: + riskCounts.high += 1 + break + case ALERT_SEVERITY_ORDER.middle: + riskCounts.middle += 1 + break + case ALERT_SEVERITY_ORDER.low: + riskCounts.low += 1 + break + } + } + return riskCounts +} + +function getHiddenRisksDescription(riskCounts: RiskCounts): string { + const descriptions: string[] = [] + if (riskCounts.critical) { + descriptions.push(`${riskCounts.critical} ${getSeverityLabel('critical')}`) + } + if (riskCounts.high) { + descriptions.push(`${riskCounts.high} ${getSeverityLabel('high')}`) + } + if (riskCounts.middle) { + descriptions.push(`${riskCounts.middle} ${getSeverityLabel('middle')}`) + } + if (riskCounts.low) { + descriptions.push(`${riskCounts.low} ${getSeverityLabel('low')}`) + } + return `(${descriptions.join('; ')})` +} + +export type AlertIncludeFilter = { + actions?: ALERT_ACTION[] | undefined + blocked?: boolean | undefined + critical?: boolean | undefined + cve?: boolean | undefined + existing?: boolean | undefined + unfixable?: boolean | undefined + upgradable?: boolean | undefined +} + +export type AddArtifactToAlertsMapOptions = { + consolidate?: boolean | undefined + include?: AlertIncludeFilter | undefined + overrides?: { [key: string]: string } | undefined + spinner?: Spinner | undefined +} + +export async function addArtifactToAlertsMap<T extends AlertsByPurl>( + artifact: CompactSocketArtifact, + alertsByPurl: T, + options?: AddArtifactToAlertsMapOptions | undefined, +): Promise<T> { + // Make TypeScript happy. + if (!artifact.name || !artifact.version || !artifact.alerts?.length) { + return alertsByPurl + } + const { + consolidate = false, + include: _include, + overrides, + } = { + __proto__: null, + ...options, + } as AddArtifactToAlertsMapOptions + + const socketYml = findSocketYmlSync() + const localRules = socketYml?.parsed.issueRules + + const include = { + __proto__: null, + actions: localRules ? undefined : 'error,monitor,warn', + blocked: true, + critical: true, + cve: true, + existing: false, + unfixable: true, + upgradable: false, + ..._include, + } as AlertIncludeFilter + + const name = resolvePackageName(artifact) + const { type: ecosystem, version } = artifact + const enabledState = { + __proto__: null, + ...localRules, + } as Partial<Record<ALERT_TYPE, boolean>> + let sockPkgAlerts: SocketPackageAlert[] = [] + for (const alert of artifact.alerts) { + const action = alert.action ?? '' + const enabledFlag = enabledState[alert.type] + if ( + (action === 'ignore' && enabledFlag !== true) || + enabledFlag === false + ) { + continue + } + const blocked = action === 'error' + const critical = alert.severity === ALERT_SEVERITY.critical + const cve = isArtifactAlertCve(alert) + const fixType = alert.fix?.type ?? '' + const fixableCve = fixType === ALERT_FIX_TYPE.cve + const fixableUpgrade = fixType === ALERT_FIX_TYPE.upgrade + const fixable = fixableCve || fixableUpgrade + const upgradable = fixableUpgrade && !hasOwn(overrides, name) + if ( + (include.blocked && blocked) || + (include.critical && critical) || + (include.cve && cve) || + (include.unfixable && !fixable) || + (include.upgradable && upgradable) + ) { + sockPkgAlerts.push({ + name, + version, + key: alert.key, + type: alert.type, + blocked, + critical, + ecosystem, + fixable, + raw: alert, + upgradable, + }) + } + } + if (!sockPkgAlerts.length) { + return alertsByPurl + } + const purl = `pkg:${ecosystem}/${name}@${version}` + const major = getMajor(version)! + if (consolidate) { + type HighestVersionByMajor = Map< + number, + { alert: SocketPackageAlert; version: string } + > + const highestForCve: HighestVersionByMajor = new Map() + const highestForUpgrade: HighestVersionByMajor = new Map() + const unfixableAlerts: SocketPackageAlert[] = [] + for (const sockPkgAlert of sockPkgAlerts) { + const alert = sockPkgAlert.raw + const fixType = alert.fix?.type ?? '' + if (fixType === ALERT_FIX_TYPE.cve) { + // An alert with alert.fix.type of 'cve' should have a + // alert.props.firstPatchedVersionIdentifier property value. + // We're just being cautious. + const firstPatchedVersionIdentifier = (alert.props as CveProps) + ?.firstPatchedVersionIdentifier + const patchedMajor = firstPatchedVersionIdentifier + ? getMajor(firstPatchedVersionIdentifier) + : null + if (typeof patchedMajor === 'number') { + // Consolidate to the highest "first patched version" by each major + // version number. + const highest = highestForCve.get(patchedMajor)?.version ?? '0.0.0' + if (semver.gt(firstPatchedVersionIdentifier!, highest)) { + highestForCve.set(patchedMajor, { + alert: sockPkgAlert, + version: firstPatchedVersionIdentifier!, + }) + } + } else { + unfixableAlerts.push(sockPkgAlert) + } + } else if (fixType === ALERT_FIX_TYPE.upgrade) { + // For Socket Optimize upgrades we assume the highest version available + // is compatible. This may change in the future. + const highest = highestForUpgrade.get(major)?.version ?? '0.0.0' + if (semver.gt(version, highest)) { + highestForUpgrade.set(major, { alert: sockPkgAlert, version }) + } + } else { + unfixableAlerts.push(sockPkgAlert) + } + } + sockPkgAlerts = [ + // Sort CVE alerts by severity: critical, high, middle, then low. + ...Array.from(highestForCve.values()) + .map(d => d.alert) + .sort(alertSeverityComparator), + ...Array.from(highestForUpgrade.values()).map(d => d.alert), + ...unfixableAlerts, + ] + } else { + sockPkgAlerts.sort((a, b) => naturalCompare(a.type, b.type)) + } + if (sockPkgAlerts.length) { + alertsByPurl.set(purl, sockPkgAlerts) + } + return alertsByPurl +} + +export function alertsHaveBlocked(alerts: SocketPackageAlert[]): boolean { + return alerts.find(a => a.blocked) !== undefined +} + +export function alertsHaveSeverity( + alerts: SocketPackageAlert[], + severity: `${keyof typeof ALERT_SEVERITY}`, +): boolean { + return alerts.find(a => a.raw.severity === severity) !== undefined +} + +export function alertSeverityComparator( + a: SocketPackageAlert, + b: SocketPackageAlert, +): number { + // Put the most severe first. + return getAlertSeverityOrder(a) - getAlertSeverityOrder(b) +} + +export function getAlertSeverityOrder(alert: SocketPackageAlert): number { + // The more severe, the lower the sort number. + const { severity } = alert.raw + return severity === ALERT_SEVERITY.critical + ? 0 + : severity === ALERT_SEVERITY.high + ? 1 + : severity === ALERT_SEVERITY.middle + ? 2 + : severity === ALERT_SEVERITY.low + ? 3 + : 4 +} + +export function getAlertsSeverityOrder(alerts: SocketPackageAlert[]): number { + return alertsHaveBlocked(alerts) || + alertsHaveSeverity(alerts, ALERT_SEVERITY.critical) + ? 0 + : alertsHaveSeverity(alerts, ALERT_SEVERITY.high) + ? 1 + : alertsHaveSeverity(alerts, ALERT_SEVERITY.middle) + ? 2 + : alertsHaveSeverity(alerts, ALERT_SEVERITY.low) + ? 3 + : 4 +} + +export type CveExcludeFilter = { + upgradable?: boolean | undefined +} + +export type CveInfoByAlertKey = Map< + string, + { + firstPatchedVersionIdentifier: string + vulnerableVersionRange: string + } +> + +export type CveInfoByPartialPurl = Map<string, CveInfoByAlertKey> + +export type GetCveInfoByPackageOptions = { + exclude?: CveExcludeFilter | undefined + limit?: number | undefined +} + +export function getCveInfoFromAlertsMap( + alertsMap: AlertsByPurl, + options_?: GetCveInfoByPackageOptions | undefined, +): CveInfoByPartialPurl | null { + const options = { + __proto__: null, + exclude: undefined, + limit: Infinity, + ...options_, + } as GetCveInfoByPackageOptions + + options.exclude = { + __proto__: null, + ...options.exclude, + } as CveExcludeFilter + + let count = 0 + let infoByPartialPurl: CveInfoByPartialPurl | null = null + alertsMapLoop: for (const { 0: purl, 1: sockPkgAlerts } of alertsMap) { + const purlObj = getPurlObject(purl) + const partialPurl = new PackageURL( + purlObj.type, + purlObj.namespace, + purlObj.name, + ).toString() + const name = resolvePackageName(purlObj) + sockPkgAlertsLoop: for (const sockPkgAlert of sockPkgAlerts) { + const alert = sockPkgAlert.raw + if ( + alert.fix?.type !== ALERT_FIX_TYPE.cve || + (options.exclude.upgradable && + getManifestData(sockPkgAlert.ecosystem as any, name)) + ) { + continue sockPkgAlertsLoop + } + if (!infoByPartialPurl) { + infoByPartialPurl = new Map() + } + let infos = infoByPartialPurl.get(partialPurl) + if (!infos) { + infos = new Map() + infoByPartialPurl.set(partialPurl, infos) + } + const { key } = alert + if (!infos.has(key)) { + // An alert with alert.fix.type of 'cve' should have a + // alert.props.firstPatchedVersionIdentifier property value. + // We're just being cautious. + const firstPatchedVersionIdentifier = (alert.props as CveProps) + ?.firstPatchedVersionIdentifier + const vulnerableVersionRange = (alert.props as CveProps) + ?.vulnerableVersionRange + let error: unknown + if (firstPatchedVersionIdentifier && vulnerableVersionRange) { + try { + infos.set(key, { + firstPatchedVersionIdentifier, + vulnerableVersionRange: new semver.Range( + // Replace ', ' in a range like '>= 1.0.0, < 1.8.2' with ' ' so that + // semver.Range will parse it without erroring. + vulnerableVersionRange + .replace(/, +/g, ' ') + .replace(/; +/g, ' || '), + ).format(), + }) + if (++count >= options.limit!) { + break alertsMapLoop + } + continue sockPkgAlertsLoop + } catch (e) { + error = e + } + } + + debugFn('fail: invalid SocketPackageAlert\n', alert) + + if (error) { + // Explicitly use debugLog here. + debugLog((error as Error).message ?? error) + } + } + } + } + return infoByPartialPurl +} + +export function getSeverityLabel( + severity: `${keyof typeof ALERT_SEVERITY}`, +): string { + return severity === 'middle' ? 'moderate' : severity +} + +export type LogAlertsMapOptions = { + hideAt?: `${keyof typeof ALERT_SEVERITY}` | 'none' | undefined + output?: NodeJS.WriteStream | undefined +} + +export function logAlertsMap( + alertsMap: AlertsByPurl, + options: LogAlertsMapOptions, +) { + const { hideAt = 'middle', output = process.stderr } = { + __proto__: null, + ...options, + } as LogAlertsMapOptions + + const translations = getTranslations() + const sortedEntries = Array.from(alertsMap.entries()).sort( + (a, b) => getAlertsSeverityOrder(a[1]) - getAlertsSeverityOrder(b[1]), + ) + + const aboveTheFoldPurls = new Set<string>() + const viewableAlertsByPurl = new Map<string, SocketPackageAlert[]>() + const hiddenAlertsByPurl = new Map<string, SocketPackageAlert[]>() + + for (let i = 0, { length } = sortedEntries; i < length; i += 1) { + const { 0: purl, 1: alerts } = sortedEntries[i]! + const hiddenAlerts: typeof alerts = [] + const viewableAlerts = alerts.filter(a => { + const keep = + a.blocked || getAlertSeverityOrder(a) < ALERT_SEVERITY_ORDER[hideAt] + if (!keep) { + hiddenAlerts.push(a) + } + return keep + }) + if (hiddenAlerts.length) { + hiddenAlertsByPurl.set(purl, hiddenAlerts.sort(alertSeverityComparator)) + } + if (!viewableAlerts.length) { + continue + } + viewableAlerts.sort(alertSeverityComparator) + viewableAlertsByPurl.set(purl, viewableAlerts) + if ( + viewableAlerts.find( + (a: SocketPackageAlert) => + a.blocked || getAlertSeverityOrder(a) < ALERT_SEVERITY_ORDER.middle, + ) + ) { + aboveTheFoldPurls.add(purl) + } + } + + // If MIN_ABOVE_THE_FOLD_COUNT is NOT met add more from viewable pkg ids. + for (const { 0: purl } of viewableAlertsByPurl.entries()) { + if (aboveTheFoldPurls.size >= MIN_ABOVE_THE_FOLD_COUNT) { + break + } + aboveTheFoldPurls.add(purl) + } + // If MIN_ABOVE_THE_FOLD_COUNT is STILL NOT met add more from hidden pkg ids. + for (const { 0: purl, 1: hiddenAlerts } of hiddenAlertsByPurl.entries()) { + if (aboveTheFoldPurls.size >= MIN_ABOVE_THE_FOLD_COUNT) { + break + } + aboveTheFoldPurls.add(purl) + const viewableAlerts = viewableAlertsByPurl.get(purl) ?? [] + if (viewableAlerts.length < MIN_ABOVE_THE_FOLD_ALERT_COUNT) { + const neededCount = MIN_ABOVE_THE_FOLD_ALERT_COUNT - viewableAlerts.length + let removedHiddenAlerts: SocketPackageAlert[] | undefined + if (hiddenAlerts.length - neededCount > 0) { + removedHiddenAlerts = hiddenAlerts.splice( + 0, + MIN_ABOVE_THE_FOLD_ALERT_COUNT, + ) + } else { + removedHiddenAlerts = hiddenAlerts + hiddenAlertsByPurl.delete(purl) + } + viewableAlertsByPurl.set(purl, [ + ...viewableAlerts, + ...removedHiddenAlerts, + ]) + } + } + + const mentionedPurlsWithHiddenAlerts = new Set<string>() + for ( + let i = 0, + prevAboveTheFold = true, + entries = Array.from(viewableAlertsByPurl.entries()), + { length } = entries; + i < length; + i += 1 + ) { + const { 0: purl, 1: alerts } = entries[i]! + const lines = new Set<string>() + for (const alert of alerts) { + const { type } = alert + const severity = alert.raw.severity ?? '' + const attributes = [ + ...(severity + ? [colors[ALERT_SEVERITY_COLOR[severity]](getSeverityLabel(severity))] + : []), + ...(alert.blocked ? [colors.bold(colors.red('blocked'))] : []), + ...(alert.fixable ? ['fixable'] : []), + ] + const maybeAttributes = attributes.length + ? ` ${colors.italic(`(${attributes.join('; ')})`)}` + : '' + // Based data from { pageProps: { alertTypes } } of: + // https://socket.dev/_next/data/94666139314b6437ee4491a0864e72b264547585/en-US.json + const info = (translations.alerts as any)[type] + const title = info?.title ?? type + const maybeDesc = info?.description ? ` - ${info.description}` : '' + const content = `${title}${maybeAttributes}${maybeDesc}` + // TODO: emoji seems to mis-align terminals sometimes + lines.add(` ${content}`) + } + const purlObj = getPurlObject(purl) + const pkgName = resolvePackageName(purlObj) + const hyperlink = format.hyperlink( + pkgName, + getSocketDevPackageOverviewUrl(purlObj.type, pkgName, purlObj.version), + ) + const isAboveTheFold = aboveTheFoldPurls.has(purl) + if (isAboveTheFold) { + aboveTheFoldPurls.add(purl) + output.write(`${i ? '\n' : ''}${hyperlink}:\n`) + } else { + output.write(`${prevAboveTheFold ? '\n' : ''}${hyperlink}:\n`) + } + for (const line of lines) { + output.write(`${line}\n`) + } + const hiddenAlerts = hiddenAlertsByPurl.get(purl) ?? [] + const { length: hiddenAlertsCount } = hiddenAlerts + if (hiddenAlertsCount) { + mentionedPurlsWithHiddenAlerts.add(purl) + if (hiddenAlertsCount === 1) { + output.write( + ` ${colors.dim(`+1 Hidden ${getSeverityLabel(hiddenAlerts[0]!.raw.severity ?? 'low')} risk alert`)}\n`, + ) + } else { + output.write( + ` ${colors.dim(`+${hiddenAlertsCount} Hidden alerts ${colors.italic(getHiddenRisksDescription(getHiddenRiskCounts(hiddenAlerts)))}`)}\n`, + ) + } + } + prevAboveTheFold = isAboveTheFold + } + + const additionalHiddenCount = + hiddenAlertsByPurl.size - mentionedPurlsWithHiddenAlerts.size + if (additionalHiddenCount) { + const totalRiskCounts = { + critical: 0, + high: 0, + middle: 0, + low: 0, + } + for (const { 0: purl, 1: alerts } of hiddenAlertsByPurl.entries()) { + if (mentionedPurlsWithHiddenAlerts.has(purl)) { + continue + } + const riskCounts = getHiddenRiskCounts(alerts) + totalRiskCounts.critical += riskCounts.critical + totalRiskCounts.high += riskCounts.high + totalRiskCounts.middle += riskCounts.middle + totalRiskCounts.low += riskCounts.low + } + output.write( + `${aboveTheFoldPurls.size ? '\n' : ''}${colors.dim(`${aboveTheFoldPurls.size ? '+' : ''}${additionalHiddenCount} Packages with hidden alerts ${colors.italic(getHiddenRisksDescription(totalRiskCounts))}`)}\n`, + ) + } + output.write('\n') +} diff --git a/src/utils/socket-url.mts b/src/utils/socket-url.mts new file mode 100644 index 000000000..96057a920 --- /dev/null +++ b/src/utils/socket-url.mts @@ -0,0 +1,40 @@ +import constants from '../constants.mts' +import { getPurlObject } from './purl.mts' + +import type { PURL_Type, SocketArtifact } from './alert/artifact.mts' +import type { PackageURL } from '@socketregistry/packageurl-js' + +const { SOCKET_WEBSITE_URL } = constants + +export function getPkgFullNameFromPurl( + purl: string | PackageURL | SocketArtifact, +): string { + const purlObj = getPurlObject(purl) + const { name, namespace } = purlObj + return namespace + ? `${namespace}${purlObj.type === 'maven' ? ':' : '/'}${name}` + : name +} + +export function getSocketDevAlertUrl(alertType: string): string { + return `${SOCKET_WEBSITE_URL}/alerts/${alertType}` +} + +export function getSocketDevPackageOverviewUrlFromPurl( + purl: string | PackageURL | SocketArtifact, +): string { + const purlObj = getPurlObject(purl) + const fullName = getPkgFullNameFromPurl(purlObj) + return getSocketDevPackageOverviewUrl(purlObj.type, fullName, purlObj.version) +} + +export function getSocketDevPackageOverviewUrl( + ecosystem: PURL_Type, + fullName: string, + version?: string | undefined, +): string { + const url = `${SOCKET_WEBSITE_URL}/${ecosystem}/package/${fullName}` + return ecosystem === 'golang' + ? `${url}${version ? `?section=overview&version=${version}` : ''}` + : `${url}${version ? `/overview/${version}` : ''}` +} diff --git a/src/utils/socketjson.mts b/src/utils/socketjson.mts new file mode 100644 index 000000000..69c3d96e2 --- /dev/null +++ b/src/utils/socketjson.mts @@ -0,0 +1,171 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { debugLog } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import type { CResult } from '../types.mts' + +export interface SocketJson { + ' _____ _ _ ': string + '| __|___ ___| |_ ___| |_ ': string + "|__ | . | _| '_| -_| _| ": string + '|_____|___|___|_,_|___|_|.dev': string + version: number + + defaults?: { + manifest?: { + conda?: { + disabled?: boolean + infile?: string + outfile?: string + stdin?: boolean + stdout?: boolean + target?: string + verbose?: boolean + } + gradle?: { + disabled?: boolean + bin?: string + gradleOpts?: string + verbose?: boolean + } + sbt?: { + disabled?: boolean + infile?: string + stdin?: boolean + bin?: string + outfile?: string + sbtOpts?: string + stdout?: boolean + verbose?: boolean + } + } + scan?: { + create?: { + autoManifest?: boolean + repo?: string + report?: boolean + branch?: string + } + github?: { + all?: boolean + githubApiUrl?: string + orgGithub?: string + repos?: string + } + } + } +} + +export async function readOrDefaultSocketJson(cwd: string) { + const result = await readSocketJson(cwd, true) + if (result.ok) { + return result.data + } + // This should be unreachable but it makes TS happy + return getDefaultSocketJson() +} + +export function getDefaultSocketJson(): SocketJson { + return { + ' _____ _ _ ': + 'Local config file for Socket CLI tool ( https://npmjs.org/socket ), to work with https://socket.dev', + '| __|___ ___| |_ ___| |_ ': + ' The config in this file is used to set as defaults for flags or command args when using the CLI', + "|__ | . | _| '_| -_| _| ": + ' in this dir, often a repo root. You can choose commit or .ignore this file, both works.', + '|_____|___|___|_,_|___|_|.dev': + 'Warning: This file may be overwritten without warning by `socket manifest setup` or other commands', + version: 1, + } +} + +export async function readSocketJson( + cwd: string, + defaultOnError = false, +): Promise<CResult<SocketJson>> { + const filepath = path.join(cwd, 'socket.json') + if (!fs.existsSync(filepath)) { + debugLog(`[DEBUG] File not found: ${filepath}`) + + return { ok: true, data: getDefaultSocketJson() } + } + + let json = null + try { + json = await fs.promises.readFile(filepath, 'utf8') + } catch (e) { + debugLog('[DEBUG] Raw error:') + debugLog(e) + + if (defaultOnError) { + logger.warn('Warning: failed to parse file, using default') + return { ok: true, data: getDefaultSocketJson() } + } + const msg = (e as { message: string })?.message || '(none)' + return { + ok: false, + message: 'Failed to read file', + cause: `An error was thrown while trying to read your socket.json: ${msg}`, + } + } + + let obj + try { + obj = JSON.parse(json) + } catch { + debugLog('[DEBUG] Failed to parse content as JSON') + debugLog(`[DEBUG] File contents ${json?.length ?? 0}:`) + debugLog(json) + + if (defaultOnError) { + logger.warn('Warning: failed to read file, using default') + return { ok: true, data: getDefaultSocketJson() } + } + + return { + ok: false, + message: 'Failed to parse socket.json', + cause: + 'It seems your socket.json did not contain valid JSON, please verify', + } + } + + if (!obj) { + logger.warn('Warning: file contents was empty, using default') + return { ok: true, data: getDefaultSocketJson() } + } + + // Do we really care to validate? All properties are optional so code will have + // to check every step of the way regardless. Who cares about validation here...? + + return { ok: true, data: obj } +} + +export async function writeSocketJson( + cwd: string, + socketJson: SocketJson, +): Promise<CResult<undefined>> { + let json = '' + try { + json = JSON.stringify(socketJson, null, 2) + } catch (e) { + debugLog('[DEBUG] JSON.stringify failed:') + debugLog(e) + debugLog('[DEBUG] Object:') + debugLog(socketJson) + + return { + ok: false, + message: 'Failed to serialize to JSON', + cause: + 'There was an unexpected problem converting the socket json object to a JSON string. Unable to store it.', + } + } + + const filepath = path.join(cwd, 'socket.json') + await fs.promises.writeFile(filepath, json + '\n', 'utf8') + + return { ok: true, data: undefined } +} diff --git a/src/utils/spec.mts b/src/utils/spec.mts new file mode 100644 index 000000000..894f748fc --- /dev/null +++ b/src/utils/spec.mts @@ -0,0 +1,25 @@ +import semver from 'semver' + +import { PackageURL } from '@socketregistry/packageurl-js' + +import { stripPnpmPeerSuffix } from './pnpm.mts' + +export function idToNpmPurl(id: string): string { + return `pkg:npm/${id}` +} + +export function idToPurl(id: string, type: string): string { + return `pkg:${type}/${id}` +} + +export function resolvePackageVersion(purlObj: PackageURL): string { + const { version } = purlObj + if (!version) { + return '' + } + const { type } = purlObj + return ( + semver.coerce(type === 'npm' ? stripPnpmPeerSuffix(version) : version) + ?.version ?? '' + ) +} diff --git a/src/utils/strings.mts b/src/utils/strings.mts new file mode 100644 index 000000000..d59a9f37c --- /dev/null +++ b/src/utils/strings.mts @@ -0,0 +1,15 @@ +export function stringJoinWithSeparateFinalSeparator( + list: string[], + separator: string = ' and ', +): string { + const values = list.filter(Boolean) + const { length } = values + if (!length) { + return '' + } + if (length === 1) { + return values[0]! + } + const finalValue = values.pop() + return `${values.join(', ')}${separator}${finalValue}` +} diff --git a/src/utils/tildify.mts b/src/utils/tildify.mts new file mode 100644 index 000000000..88bfe4362 --- /dev/null +++ b/src/utils/tildify.mts @@ -0,0 +1,14 @@ +import path from 'node:path' + +import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' + +import constants from '../constants.mts' + +// Replace the start of a path with ~/ when it starts with your home dir. +// A common way to abbreviate the user home dir (though not strictly posix). +export function tildify(cwd: string) { + return cwd.replace( + new RegExp(`^${escapeRegExp(constants.homePath)}(?:${path.sep}|$)`, 'i'), + '~/', + ) +} diff --git a/src/utils/translations.mts b/src/utils/translations.mts new file mode 100644 index 000000000..382cb4e85 --- /dev/null +++ b/src/utils/translations.mts @@ -0,0 +1,18 @@ +import { createRequire } from 'node:module' +import path from 'node:path' + +import constants from '../constants.mts' + +const require = createRequire(import.meta.url) + +let _translations: typeof import('../../translations.json') | undefined + +export function getTranslations() { + if (_translations === undefined) { + _translations = require( + // Lazily access constants.rootPath. + path.join(constants.rootPath, 'translations.json'), + ) + } + return _translations! +} diff --git a/src/utils/walk-nested-map.mts b/src/utils/walk-nested-map.mts new file mode 100644 index 000000000..234bd24e9 --- /dev/null +++ b/src/utils/walk-nested-map.mts @@ -0,0 +1,14 @@ +type NestedMap<T> = Map<string, T | NestedMap<T>> + +export function* walkNestedMap<T>( + map: NestedMap<T>, + keys: string[] = [], +): Generator<{ keys: string[]; value: T }> { + for (const [key, value] of map.entries()) { + if (value instanceof Map) { + yield* walkNestedMap(value as NestedMap<T>, keys.concat(key)) + } else { + yield { keys: keys.concat(key), value: value } + } + } +} diff --git a/packages/cli/test/unit/util/data/walk-nested-map.test.mts b/src/utils/walk-nested-map.test.mts similarity index 87% rename from packages/cli/test/unit/util/data/walk-nested-map.test.mts rename to src/utils/walk-nested-map.test.mts index 353cb23d1..dd2dae098 100644 --- a/packages/cli/test/unit/util/data/walk-nested-map.test.mts +++ b/src/utils/walk-nested-map.test.mts @@ -1,20 +1,6 @@ -/** - * Unit tests for nested Map traversal. - * - * Purpose: Tests recursive traversal of nested Maps. Validates visitor pattern - * implementation for Map data structures. - * - * Test Coverage: - Nested Map traversal - Visitor callback invocation - - * Depth-first traversal - Parent tracking - Early termination. - * - * Testing Approach: Tests recursive algorithms for Map data structures. - * - * Related Files: - util/data/walk-nested-map.mts (implementation) - */ - import { describe, expect, it } from 'vitest' -import { walkNestedMap } from '../../../../src/util/data/walk-nested-map.mts' +import { walkNestedMap } from './walk-nested-map.mts' describe('walkNestedMap', () => { it('should walk a flat map', () => { diff --git a/test/errors.test.mts b/test/errors.test.mts new file mode 100644 index 000000000..410842434 --- /dev/null +++ b/test/errors.test.mts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { isErrnoException } from '../src/utils/errors.mts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const testPath = __dirname + +describe('Error Narrowing', () => { + it('should properly detect node errors', () => { + try { + readFileSync(path.join(testPath, 'enoent')) + } catch (e) { + expect(isErrnoException(e)).toBe(true) + } + }) + it('should properly only detect node errors', () => { + expect(isErrnoException(new Error())).toBe(false) + expect(isErrnoException({ ...new Error() })).toBe(false) + }) +}) diff --git a/test/path-resolve.test.mts b/test/path-resolve.test.mts new file mode 100644 index 000000000..2a10cce38 --- /dev/null +++ b/test/path-resolve.test.mts @@ -0,0 +1,312 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import mockFs from 'mock-fs' +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { normalizePath } from '@socketsecurity/registry/lib/path' + +import { getPackageFilesForScan } from '../src/utils/path-resolve.mts' + +import type FileSystem from 'mock-fs/lib/filesystem' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const testPath = __dirname +const rootNmPath = path.join(testPath, '../node_modules') + +const mockFixturePath = normalizePath(path.join(testPath, 'mock')) +const mockNmPath = normalizePath(rootNmPath) +const mockedNmCallback = mockFs.load(rootNmPath) + +function mockTestFs(config: FileSystem.DirectoryItems) { + return mockFs({ + ...config, + [mockNmPath]: mockedNmCallback, + }) +} + +const globPatterns = { + general: { + readme: { + pattern: '*readme*', + }, + notice: { + pattern: '*notice*', + }, + license: { + pattern: '{licen{s,c}e{,-*},copying}', + }, + }, + npm: { + packagejson: { + pattern: 'package.json', + }, + packagelockjson: { + pattern: 'package-lock.json', + }, + npmshrinkwrap: { + pattern: 'npm-shrinkwrap.json', + }, + yarnlock: { + pattern: 'yarn.lock', + }, + pnpmlock: { + pattern: 'pnpm-lock.yaml', + }, + pnpmworkspace: { + pattern: 'pnpm-workspace.yaml', + }, + }, + pypi: { + pipfile: { + pattern: 'pipfile', + }, + pyproject: { + pattern: 'pyproject.toml', + }, + requirements: { + pattern: + '{*requirements.txt,requirements/*.txt,requirements-*.txt,requirements.frozen}', + }, + setuppy: { + pattern: 'setup.py', + }, + }, +} + +type Fn = (...args: any[]) => Promise<any[]> + +const sortedPromise = + (fn: Fn) => + async (...args: any[]) => { + const result = await fn(...args) + return result.sort() + } +const sortedGetPackageFilesFullScans = sortedPromise(getPackageFilesForScan) + +describe('Path Resolve', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + mockFs.restore() + if (!nock.isDone()) { + throw new Error(`pending nock mocks: ${nock.pendingMocks()}`) + } + }) + + describe('getPackageFilesForScan()', () => { + it('should handle a "." inputPath', async () => { + mockTestFs({ + [`${mockFixturePath}/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['.'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/package.json`, + ]) + }) + + it('should respect ignores from socket config', async () => { + mockTestFs({ + [`${mockFixturePath}/bar/package-lock.json`]: '{}', + [`${mockFixturePath}/bar/package.json`]: '{}', + [`${mockFixturePath}/foo/package-lock.json`]: '{}', + [`${mockFixturePath}/foo/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + { + version: 2, + projectIgnorePaths: ['bar/*', '!bar/package.json'], + issueRules: {}, + githubApp: {}, + }, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/bar/package.json`, + `${mockFixturePath}/foo/package-lock.json`, + `${mockFixturePath}/foo/package.json`, + ]) + }) + + it('should respect .gitignore', async () => { + mockTestFs({ + [`${mockFixturePath}/.gitignore`]: 'bar/*\n!bar/package.json', + [`${mockFixturePath}/bar/package-lock.json`]: '{}', + [`${mockFixturePath}/bar/package.json`]: '{}', + [`${mockFixturePath}/foo/package-lock.json`]: '{}', + [`${mockFixturePath}/foo/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/bar/package.json`, + `${mockFixturePath}/foo/package-lock.json`, + `${mockFixturePath}/foo/package.json`, + ]) + }) + + it('should always ignore some paths', async () => { + mockTestFs({ + // Mirrors the list from + // https://github.com/novemberborn/ignore-by-default/blob/v2.1.0/index.js + [`${mockFixturePath}/.git/some/dir/package.json`]: '{}', + [`${mockFixturePath}/.log/some/dir/package.json`]: '{}', + [`${mockFixturePath}/.nyc_output/some/dir/package.json`]: '{}', + [`${mockFixturePath}/.sass-cache/some/dir/package.json`]: '{}', + [`${mockFixturePath}/.yarn/some/dir/package.json`]: '{}', + [`${mockFixturePath}/bower_components/some/dir/package.json`]: '{}', + [`${mockFixturePath}/coverage/some/dir/package.json`]: '{}', + [`${mockFixturePath}/node_modules/socket/package.json`]: '{}', + [`${mockFixturePath}/foo/package-lock.json`]: '{}', + [`${mockFixturePath}/foo/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/foo/package-lock.json`, + `${mockFixturePath}/foo/package.json`, + ]) + }) + + it('should ignore irrelevant matches', async () => { + mockTestFs({ + [`${mockFixturePath}/foo/package-foo.json`]: '{}', + [`${mockFixturePath}/foo/package-lock.json`]: '{}', + [`${mockFixturePath}/foo/package.json`]: '{}', + [`${mockFixturePath}/foo/random.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/foo/package-lock.json`, + `${mockFixturePath}/foo/package.json`, + ]) + }) + + it('should be lenient on oddities', async () => { + mockTestFs({ + [`${mockFixturePath}/package.json`]: { + /* Empty directory */ + }, + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([]) + }) + + it('should resolve package and lock file', async () => { + mockTestFs({ + [`${mockFixturePath}/package-lock.json`]: '{}', + [`${mockFixturePath}/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/package-lock.json`, + `${mockFixturePath}/package.json`, + ]) + }) + + it('should resolve package without lock file', async () => { + mockTestFs({ + [`${mockFixturePath}/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/package.json`, + ]) + }) + + it('should support alternative lock files', async () => { + mockTestFs({ + [`${mockFixturePath}/yarn.lock`]: '{}', + [`${mockFixturePath}/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/package.json`, + `${mockFixturePath}/yarn.lock`, + ]) + }) + + it('should handle all variations', async () => { + mockTestFs({ + [`${mockFixturePath}/package-lock.json`]: '{}', + [`${mockFixturePath}/package.json`]: '{}', + [`${mockFixturePath}/foo/package-lock.json`]: '{}', + [`${mockFixturePath}/foo/package.json`]: '{}', + [`${mockFixturePath}/bar/yarn.lock`]: '{}', + [`${mockFixturePath}/bar/package.json`]: '{}', + [`${mockFixturePath}/abc/package.json`]: '{}', + }) + + const actual = await sortedGetPackageFilesFullScans( + mockFixturePath, + ['**/*'], + globPatterns, + undefined, + ) + expect(actual.map(normalizePath)).toEqual([ + `${mockFixturePath}/abc/package.json`, + `${mockFixturePath}/bar/package.json`, + `${mockFixturePath}/bar/yarn.lock`, + `${mockFixturePath}/foo/package-lock.json`, + `${mockFixturePath}/foo/package.json`, + `${mockFixturePath}/package-lock.json`, + `${mockFixturePath}/package.json`, + ]) + }) + }) +}) diff --git a/test/smoke.sh b/test/smoke.sh new file mode 100755 index 000000000..fcc77fc27 --- /dev/null +++ b/test/smoke.sh @@ -0,0 +1,721 @@ +#!/bin/bash + +##### Smoke test +## Usage: +## +## ./test/smoke [subcommand] +## +## Example: +## +## ./test/smoke +## ./test/smoke scan +## +###### + +## +# Adding commands: +# +# All run functions accept an exit code as first arg and the command to run as remaining args. +# +# - `run_socket` Use for most commands. +# - `run_json` Use for commands that return JSON. It will confirm that stdout contains valid JSON and assert the toplevel structure matches exit code logic. +# + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +WHITE_BG='\033[47m' +BLACK_FG='\033[30m' +DIM='\033[2m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Initialize counters and arrays +PASSED=0 +FAILED=0 +FAILED_TESTS=() +TEST_COUNTER=0 + +# node 20 or anything +# COMMAND_PREFIX="npm run --silent s --" +# node 22+ +COMMAND_PREFIX="./sd" + +# Function to restore config on exit +restore_config() { + echo -e "\n${YELLOW}Restoring backed up configuration values...${NC}" + eval "${COMMAND_PREFIX} config set defaultOrg ${DEFORG_BAK}" + eval "${COMMAND_PREFIX} config set apiToken ${TOKEN_BAK}" + echo -e "${GREEN}Configuration restored!${NC}" +} + +# Get target subcommand from first argument +TARGET_SUBCOMMAND="$1" + +# Function to check if a section should be run +should_run_section() { + local section="$1" + if [ -z "$TARGET_SUBCOMMAND" ]; then + return 0 # Run all sections if no subcommand specified + fi + if [ "$section" = "$TARGET_SUBCOMMAND" ]; then + return 0 + fi + return 1 +} + +# Function to check git status +check_git_status() { + if [ -d .git ]; then + if [ -n "$(git status --porcelain)" ]; then + echo -e "${YELLOW}Warning: Git repository is not clean${NC}" + git status --porcelain + echo -e "\n${YELLOW}Running tests may modify files. Continue? [y/N]${NC}" + read -r response + if [[ ! "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then + echo "Aborting..." + exit 1 + fi + else + echo -e "${GREEN}Git repository is clean${NC}" + fi + else + echo -e "${YELLOW}Not a git repository${NC}" + fi +} + +# Function to validate JSON +validate_json() { + local json_output + local expected_exit="$1" + json_output=$(cat) # Read from stdin + + # First check if it's valid JSON + if ! echo "$json_output" | jq . > /dev/null 2>&1; then + echo -e "${RED}✗ Invalid JSON output${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + + # Check for required fields and type structure + # type: `{ ok: true, data: unknown, message?: string } | { ok: false, data?: unknown, message: string, cause?: string, code?: number }` + local ok_field + local data_field + local message_field + local cause_field + local code_field + + ok_field=$(echo "$json_output" | jq -r '.ok') + data_field=$(echo "$json_output" | jq -r '.data') + message_field=$(echo "$json_output" | jq -r '.message // empty') + cause_field=$(echo "$json_output" | jq -r '.cause // empty') + code_field=$(echo "$json_output" | jq -r '.code // empty') + + # Check if ok field matches expected exit code + if [ "$expected_exit" -eq 0 ] && [ "$ok_field" != "true" ]; then + echo -e "${RED}✗ JSON output 'ok' should be true when exit code is 0${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + if [ "$expected_exit" -ne 0 ] && [ "$ok_field" != "false" ]; then + echo -e "${RED}✗ JSON output 'ok' should be false when exit code is non-zero${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + + # Check if data field exists (required when ok is true, optional when false) + if [ "$ok_field" = "true" ] && [ "$data_field" = "null" ]; then + echo -e "${RED}✗ JSON output missing required 'data' field when ok is true${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + + # If ok is false, message is required + if [ "$ok_field" = "false" ] && [ -z "$message_field" ]; then + echo -e "${RED}✗ JSON output missing required 'message' field when ok is false${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + + # If code exists, it must be a number + if [ -n "$code_field" ] && ! [[ "$code_field" =~ ^[0-9]+$ ]]; then + echo -e "${RED}✗ JSON output 'code' field must be a number${NC}" + echo -e "Received:" + echo -e "$json_output" + return 1 + fi + + return 0 +} + +# Function to run a test with JSON validation +run_json() { + local expected_exit="$1" + shift # Remove the first argument + local command="${COMMAND_PREFIX} $*" # Get all remaining arguments and prepend the common prefix + ((TEST_COUNTER++)) + + echo -e "\n${WHITE_BG}${BLACK_FG}=== Test #$TEST_COUNTER ===${NC}" + echo -e "Command: ${DIM}${COMMAND_PREFIX}${NC}${BOLD} $*${NC}" + echo "Expected exit code: $expected_exit" + + # Run the command and capture its output + local output + output=$(eval "$command") + local exit_code=$? + + if [ $exit_code -eq $expected_exit ]; then + # Validate JSON output + if ! echo "$output" | validate_json "$expected_exit"; then + echo -e "${RED}✗ Test #$TEST_COUNTER failed (invalid JSON)${NC} ${DIM}Command: $command${NC}" + ((FAILED++)) + FAILED_TESTS+=("$TEST_COUNTER|$command|$expected_exit|$exit_code|invalid_json") + return + fi + echo -e "${GREEN}✓ Test #$TEST_COUNTER passed${NC} ${DIM}Command: $command${NC}" + ((PASSED++)) + else + echo -e "${RED}✗ Test #$TEST_COUNTER failed${NC} ${DIM}Command: $command${NC}" + echo "Expected exit code: $expected_exit, got: $exit_code" + ((FAILED++)) + # Store failed test details + FAILED_TESTS+=("$TEST_COUNTER|$command|$expected_exit|$exit_code") + fi +} + +# Function to run a test +run_socket() { + local expected_exit="$1" + shift # Remove the first argument + local command="${COMMAND_PREFIX} $*" # Get all remaining arguments and prepend the common prefix + ((TEST_COUNTER++)) + + echo -e "\n${WHITE_BG}${BLACK_FG}=== Test #$TEST_COUNTER ===${NC}" + echo -e "Command: ${DIM}${COMMAND_PREFIX}${NC}${BOLD} $*${NC}" + echo "Expected exit code: $expected_exit" + + # Run the command and capture its exit code + eval "$command" + local exit_code=$? + + if [ $exit_code -eq $expected_exit ]; then + echo -e "${GREEN}✓ Test #$TEST_COUNTER passed${NC} ${DIM}Command: $command${NC}" + ((PASSED++)) + else + echo -e "${RED}✗ Test #$TEST_COUNTER failed${NC} ${DIM}Command: $command${NC}" + echo "Expected exit code: $expected_exit, got: $exit_code" + ((FAILED++)) + # Store failed test details + FAILED_TESTS+=("$TEST_COUNTER|$command|$expected_exit|$exit_code") + fi +} + +# Function to print test summary +print_test_summary() { + echo -e "\n=== Test Summary ===" + echo -e "${GREEN}Passed: $PASSED${NC}" + echo -e "${RED}Failed: $FAILED${NC}" + echo -e "Total: $((PASSED + FAILED))" + + if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + else + echo -e "\n${RED}Failed Tests:${NC}" + for test in "${FAILED_TESTS[@]}"; do + IFS='|' read -r test_id command expected actual reason <<< "$test" + echo -e "\n${RED}✗ Test #$test_id${NC}" + echo "Command: $command" + echo "Expected exit code: $expected" + echo "Actual exit code: $actual" + if [ -n "$reason" ]; then + echo "Reason: $reason" + fi + done + fi +} + +## Check git status before proceeding +#check_git_status + +## Initialize + +if [ "$(node -v | cut -d'v' -f2 | cut -d'.' -f1)" -lt 22 ]; then + # In node < v22 we need to run through npm, so we must build it first. + # ./sd will use the built result through `npm run s`. + npm run bs +else + # We do still need some stuff built, apparently + npm run build +fi + +# Backup config +echo "Backing up default org and apitoken..." +DEFORG_BAK=$(eval "$COMMAND_PREFIX config get defaultOrg --json" | jq -r '.data' ) +TOKEN_BAK=$(eval "$COMMAND_PREFIX config get apiToken --json" | jq -r '.data' ) +echo "Backing complete!" + +# Set up trap to restore config on any exit +trap restore_config EXIT + +### Analytics + +if should_run_section "analytics"; then + run_socket 0 analytics --help + run_socket 0 analytics --dry-run + run_socket 0 analytics # interactive + run_socket 0 analytics --markdown + run_json 0 analytics --json + run_socket 0 analytics org --json + run_json 0 analytics repo socket-cli --json + run_socket 0 analytics org 7 --markdown + run_socket 0 analytics repo socket-cli 30 --markdown + run_json 0 analytics 90 --json + run_socket 0 analytics --file smoke.txt --json + run_socket 0 analytics --file smoke.txt --markdown + + run_socket 2 analytics --whatnow + run_socket 2 analytics --file smoke.txt + run_socket 2 analytics rainbow --json + run_socket 1 analytics repo veryunknownrepo --json + run_socket 2 analytics repo 30 --markdown + run_socket 2 analytics org 25 --markdown + run_socket 2 analytics 123 --json +fi + +### audit-log + +if should_run_section "audit-log"; then + run_socket 0 audit-log --help + run_socket 0 audit-log --dry-run + run_socket 0 audit-log +fi + +### cdxgen + +if should_run_section "cdxgen"; then + run_socket 0 cdxgen --help + run_socket 2 cdxgen --dry-run + run_socket 1 cdxgen +fi + +### ci + +if should_run_section "ci"; then + run_socket 0 ci --help + run_socket 0 ci --dry-run + run_socket 0 ci +fi + +### config + +if should_run_section "config"; then + run_socket 2 config + run_socket 0 config --help + run_socket 0 config --dry-run + run_socket 0 config get --help + run_socket 2 config get --dry-run + run_socket 0 config get defaultOrg + run_socket 0 config set --help + run_socket 2 config set --dry-run + run_socket 0 config set defaultOrg mydev + run_socket 0 config unset --help + run_socket 2 config unset --dry-run + run_socket 0 config unset defaultOrg + run_socket 0 config auto --help + run_socket 2 config auto --dry-run + run_socket 0 config auto defaultOrg + + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" +fi + +### dependencies + +if should_run_section "dependencies"; then + run_socket 0 organization dependencies + run_socket 0 organization dependencies --help + run_socket 0 organization dependencies --dry-run + run_json 0 organization dependencies --json + run_socket 0 organization dependencies --markdown + + run_socket 0 organization dependencies --limit 1 + run_socket 0 organization dependencies --offset 5 + run_socket 0 organization dependencies --limit 1 --offset 10 + + #run_json 2 organization dependencies --json --wat foo + run_json 0 organization dependencies --json --limit -200 + run_json 0 organization dependencies --json --limit NaN + run_json 0 organization dependencies --json --limit foo +fi + +### fix + +if should_run_section "fix"; then + run_socket 0 fix + run_socket 0 fix --help + run_socket 0 fix --dry-run +fi + +### login + +if should_run_section "login"; then + run_socket 0 login + run_socket 0 login --help + run_socket 0 login --dry-run + + #run_socket 1 login --wat + run_socket 1 login --api-base-url fail + run_socket 1 login --api-proxy fail + + echo "Restoring api token" + eval "${COMMAND_PREFIX} config set apiToken $TOKEN_BAK" + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" +fi + +### logout + +if should_run_section "logout"; then + run_socket 0 logout + run_socket 0 logout --help + run_socket 0 logout --dry-run + #run_socket 0 logout --wat + + echo "Restoring api token" + eval "${COMMAND_PREFIX} config set apiToken $TOKEN_BAK" + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" +fi + +### manifest + +if should_run_section "manifest"; then + run_socket 2 manifest + run_socket 0 manifest --help + run_socket 0 manifest --dry-run + run_socket 1 manifest auto + run_socket 0 manifest auto --help + run_socket 0 manifest auto --dry-run + run_socket 1 manifest conda + run_socket 0 manifest conda --help + run_socket 0 manifest conda --dry-run + run_socket 1 manifest gradle + run_socket 0 manifest gradle --help + run_socket 1 manifest gradle --dry-run + run_socket 1 manifest kotlin + run_socket 0 manifest kotlin --help + run_socket 0 manifest kotlin --dry-run + run_socket 1 manifest scala + run_socket 0 manifest scala --help + run_socket 0 manifest scala --dry-run +fi + +### npm + +if should_run_section "npm"; then + run_socket 0 npm info + run_socket 0 npm --help + run_socket 0 npm --dry-run + run_socket 0 npm info +fi + +### npx + +if should_run_section "npx"; then + run_socket 0 npx cowsay moo + run_socket 0 npx --help + run_socket 0 npx --dry-run + run_socket 0 npx socket --dry-run +fi + +### oops + +if should_run_section "oops"; then + run_socket 1 oops + run_socket 0 oops --help + run_socket 0 oops --dry-run + #run_socket 0 oops --wat +fi + +### optimize + +if should_run_section "optimize"; then + run_socket 0 optimize + run_socket 0 optimize --prod + run_socket 0 optimize --pin + run_socket 0 optimize --help + run_socket 0 optimize --dry-run +fi + +### organization + +if should_run_section "organization"; then + run_socket 2 organization + run_socket 0 organization --help + run_socket 0 organization --dry-run + run_socket 0 organization list + run_socket 0 organization list --help + run_socket 0 organization list --dry-run + run_socket 2 organization policy + run_socket 0 organization policy --help + run_socket 0 organization policy --dry-run + run_socket 0 organization policy license + run_socket 0 organization policy license --help + run_socket 0 organization policy license --dry-run + run_socket 0 organization policy security + run_socket 0 organization policy security --help + run_socket 0 organization policy security --dry-run + run_socket 0 organization quota + run_socket 0 organization quota --help + run_socket 0 organization quota --dry-run + + run_socket 0 organization policy security --markdown + run_socket 0 organization policy security --json + run_json 0 organization policy security --json + run_socket 1 organization policy security --org trash + run_socket 1 organization policy security --org trash --markdown + run_socket 1 organization policy security --org trash --json + run_json 1 organization policy security --org trash --json + run_socket 0 organization policy security --org $DEFORG_BAK + + run_socket 0 organization policy license --markdown + run_json 0 organization policy license --json + run_socket 1 organization policy license --org trash + run_socket 1 organization policy license --org trash --markdown + run_socket 1 organization policy license --org trash --json + run_json 1 organization policy license --org trash --json + run_socket 0 organization policy license --org $DEFORG_BAK + + echo "" + echo "" + echo "Clearing defaultOrg for next tests" + eval "$COMMAND_PREFIX config unset defaultOrg" + run_json 1 organization policy security --json --no-interactive + run_json 1 organization policy license --json --no-interactive + echo "" + echo "" + echo "Setting defaultOrg to an invalid org for the next tests" + eval "$COMMAND_PREFIX config set defaultOrg fake_org" + run_json 1 organization policy security --json --no-interactive + run_json 1 organization policy license --json --no-interactive + echo "" + echo "" + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" +fi + +### package + +if should_run_section "package"; then + run_socket 2 package + run_socket 0 package --help + run_socket 0 package --dry-run + run_socket 0 package score --help + run_socket 2 package score --dry-run + run_socket 0 package score npm tenko + run_socket 0 package shallow --help + run_socket 2 package shallow --dry-run + run_socket 0 package shallow npm socket + + run_socket 0 package shallow npm socket # 500 + run_socket 0 package shallow npm babel # ok + run_socket 0 package shallow npm nope # stuck? + run_socket 0 package shallow npm mostdefinitelynotworkingletskeepitthatway # server won't report an error or 404, just won't report anything for this... + + run_socket 0 package score npm socket # 500 + run_socket 0 package score npm babel # ok + run_socket 0 package score npm nope # stuck? + run_socket 1 package score npm mostdefinitelynotworkingletskeepitthatway + + run_json 0 package shallow npm socket --json # 500 + run_json 0 package shallow npm babel --json # ok + run_json 0 package shallow npm nope --json # stuck? + run_json 0 package shallow npm mostdefinitelynotworkingletskeepitthatway --json + + run_json 0 package score npm socket --json # 500 + run_json 0 package score npm babel --json # ok + run_json 0 package score npm nope --json # stuck? + run_json 1 package score npm mostdefinitelynotworkingletskeepitthatway --json +fi + +### raw-npm + +if should_run_section "raw-npm"; then + run_socket 1 raw-npm + run_socket 0 raw-npm --help + run_socket 0 raw-npm --dry-run + run_socket 0 raw-npm info +fi + +### raw-npx + +if should_run_section "raw-npx"; then + run_socket 0 raw-npx cowsay moo + run_socket 0 raw-npx --help + run_socket 0 raw-npx --dry-run + run_socket 0 raw-npx socket --dry-run +fi + +### repos + +if should_run_section "repos"; then + eval "${COMMAND_PREFIX} config set apiToken ${TOKEN_BAK}" + + run_socket 2 repos + run_socket 0 repos --help + run_socket 0 repos --dry-run + run_socket 0 repos create --help + run_socket 2 repos create --dry-run + run_socket 0 repos create cli-smoke-test + run_socket 1 repos create '%$#' + run_socket 1 repos create '%$#' --json + run_socket 0 repos update --help + run_socket 2 repos update --dry-run + run_socket 0 repos update cli-smoke-test --homepage "socket.dev" + run_socket 0 repos view --help + run_socket 2 repos view --dry-run + run_socket 0 repos view cli-smoke-test + run_socket 0 repos del --help + run_socket 2 repos del --dry-run + run_socket 0 repos del cli-smoke-test + + echo "" + echo "" + echo "Clearing defaultOrg for next tests" + eval "$COMMAND_PREFIX config unset defaultOrg" + run_json 2 repos create 'cli_donotcreate' --json --no-interactive + run_json 2 repos del 'cli_donotcreate' --json --no-interactive + run_json 2 repos view 'cli_donotcreate' --json --no-interactive + run_json 2 repos list --json --no-interactive + run_json 2 repos update 'cli_donotcreate' --homepage evil --json --no-interactive + echo "" + echo "" + echo "Setting defaultOrg to an invalid org for the next tests" + eval "$COMMAND_PREFIX config set defaultOrg fake_org" + run_json 1 repos create 'cli_donotcreate' --json + run_json 1 repos del 'cli_donotcreate' --json + run_json 1 repos view 'cli_donotcreate' --json + run_json 1 repos list --json + run_json 1 repos update 'cli_donotcreate' --homepage evil --json + echo "" + echo "" + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" + run_json 1 repos view 'cli_donotcreate' --json + run_json 1 repos update 'cli_donotcreate' --homepage evil --json +fi + +### scan + +if should_run_section "scan"; then + run_socket 2 scan + run_socket 0 scan --help + run_socket 0 scan --dry-run + run_socket 0 scan create --help + run_socket 2 scan create --dry-run + run_socket 0 scan create . + run_socket 0 scan create --json + run_json 0 scan create . --json + run_json 2 scan create --json --no-interactive + run_json 0 scan create . --json --no-interactive + run_socket 0 scan del --help + run_socket 2 scan del --dry-run + run_socket 0 scan list + run_socket 0 scan list --help + run_socket 0 scan list --dry-run + run_json 0 scan list --json + run_socket 0 scan list --markdown + run_socket 2 scan view + run_socket 0 scan view --help + run_socket 2 scan view --dry-run + # view the last scan of the current org + SBOM_ID=$(eval "$COMMAND_PREFIX scan list --json" | jq -r '.data.results[0].id' ) + run_socket 0 scan view "$SBOM_ID" + run_json 0 scan view "$SBOM_ID" --json + run_socket 0 scan view "$SBOM_ID" --markdown + run_socket 0 scan metadata --help + run_socket 2 scan metadata --dry-run + # view the metadata of the last scan of the current org + run_socket 0 scan metadata "$SBOM_ID" + run_json 0 scan metadata "$SBOM_ID" --json + run_socket 0 scan metadata "$SBOM_ID" --markdown + run_socket 0 scan report --help + run_socket 2 scan report --dry-run + # view the report of the last scan of the current org + run_socket 0 scan report "$SBOM_ID" + run_json 0 scan report "$SBOM_ID" --json + run_socket 0 scan report "$SBOM_ID" --markdown + run_socket 0 scan diff --help + run_socket 2 scan diff --dry-run + # diff on the last two scans in the current org + SBOM_IDS=$( eval "$COMMAND_PREFIX scan list --json" | jq -r '.data.results[0,1].id' | tr '\n' ' ' ) + run_socket 0 scan diff "$SBOM_IDS" + run_json 0 scan diff "$SBOM_IDS" --json + run_socket 0 scan diff "$SBOM_IDS" --markdown + + run_socket 1 scan create . --org fake_org + run_json 1 scan create . --org fake_org --json + run_socket 1 scan view "$SBOM_ID" --org fake_org + run_json 1 scan view "$SBOM_ID" --org fake_org --json + run_socket 1 scan report "$SBOM_ID" --org fake_org + run_json 1 scan report "$SBOM_ID" --org fake_org --json + run_socket 1 scan metadata "$SBOM_ID" --org fake_org + run_json 1 scan metadata "$SBOM_ID" --org fake_org --json + run_socket 1 scan diff "$SBOM_ID" "$SBOM_ID" --org fake_org + run_json 1 scan diff "$SBOM_ID" "$SBOM_ID" --org fake_org --json + + echo "" + echo "" + echo "Clearing defaultOrg for the next tests" + eval "$COMMAND_PREFIX config unset defaultOrg" + run_json 2 scan create . --json --no-interactive + run_json 2 scan view "$SBOM_ID" --json --no-interactive + run_json 2 scan report "$SBOM_ID" --json --no-interactive + run_json 2 scan metadata "$SBOM_ID" --json --no-interactive + run_json 2 scan diff "$SBOM_ID" "$SBOM_ID" --json --no-interactive + echo "" + echo "" + echo "Setting defaultOrg to an invalid org for the next tests" + eval "$COMMAND_PREFIX config set defaultOrg fake_org" + run_json 1 scan create . --json + run_json 1 scan view "$SBOM_ID" --json + run_json 1 scan report "$SBOM_ID" --json + run_json 1 scan metadata "$SBOM_ID" --json + run_json 1 scan diff "$SBOM_ID" "$SBOM_ID" --json + echo "" + echo "" + echo "Restoring default org to $DEFORG_BAK" + eval "${COMMAND_PREFIX} config set defaultOrg $DEFORG_BAK" +fi + +### threat-feed + +if should_run_section "threat-feed"; then + # by default interactive so use flags + run_socket 0 threat-feed # potential caching issue? first run tends to show empty window with top of "window" scrolled down + run_socket 0 threat-feed --help + run_socket 0 threat-feed --dry-run + run_json 0 threat-feed --json + run_socket 0 threat-feed --markdown + run_socket 0 threat-feed --no-interactive +fi + +### wrapper + +if should_run_section "wrapper"; then + run_socket 2 wrapper + run_socket 0 wrapper --help + run_socket 2 wrapper --dry-run + run_socket 0 wrapper on + run_socket 0 wrapper off +fi + +### The end + +print_test_summary diff --git a/test/socket-cdxgen.test.mts b/test/socket-cdxgen.test.mts new file mode 100644 index 000000000..9a92deb6e --- /dev/null +++ b/test/socket-cdxgen.test.mts @@ -0,0 +1,116 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { LOG_SYMBOLS } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../src/constants.mts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const testPath = __dirname +const npmFixturesPath = path.join(testPath, 'socket-npm-fixtures') + +type PromiseSpawnOptions = Exclude<Parameters<typeof spawn>[2], undefined> & { + encoding?: BufferEncoding | undefined +} + +const spawnOpts: PromiseSpawnOptions = { + cwd: npmFixturesPath, + env: { + ...process.env, + SOCKET_CLI_DEBUG: '1', + }, +} + +describe('Socket manifest cdxgen command', async () => { + // Lazily access constants.binCliPath. + const { binCliPath } = constants + + it( + 'should forwards known commands to cdxgen', + { + // Takes ~10s in CI + timeout: 20_000, + }, + async () => { + for (const command of ['-h', '--help']) { + // eslint-disable-next-line no-await-in-loop + const output = await spawn( + // Lazily access constants.execPath. + constants.execPath, + [binCliPath, 'manifest', 'cdxgen', '--yes', command], + spawnOpts, + ) + expect( + output.stdout.includes('cdxgen'), + 'forwards commands to cdxgen', + ).toBe(true) + } + }, + ) + + describe('command forwarding', async () => { + expect.extend({ + toHaveStderrInclude(received, expected) { + const { isNot } = this + return { + // do not alter your "pass" based on isNot. Vitest does it for you + pass: received?.stderr?.includes?.(expected) ?? false, + message: () => + `spawn.stderr ${isNot ? 'does NOT include' : 'includes'} \`${expected}\`: ${received?.stderr}`, + } + }, + }) + + it('should not forward -u to cdxgen', async () => { + const command = '-u' + await expect( + () => + spawn( + // Lazily access constants.execPath. + constants.execPath, + [binCliPath, 'manifest', 'cdxgen', '-y', command], + spawnOpts, + ), + // @ts-ignore toHaveStderrInclude is defined above + ).rejects.toHaveStderrInclude( + `${LOG_SYMBOLS.fail} Unknown argument: ${command}`, + ) + }) + + it('should not forward --unknown to cdxgen', async () => { + const command = '--unknown' + await expect( + () => + spawn( + // Lazily access constants.execPath. + constants.execPath, + [binCliPath, 'manifest', 'cdxgen', '--yes', command], + spawnOpts, + ), + // @ts-ignore toHaveStderrInclude is defined above + ).rejects.toHaveStderrInclude( + `${LOG_SYMBOLS.fail} Unknown argument: ${command}`, + ) + }) + + it('should not forward multiple unknown commands to cdxgen', async () => { + await expect( + () => + spawn( + // Lazily access constants.execPath. + constants.execPath, + [binCliPath, 'manifest', 'cdxgen', '-y', '-u', '-h', '--unknown'], + spawnOpts, + ), + // @ts-ignore toHaveStderrInclude is defined above + ).rejects.toHaveStderrInclude( + `${LOG_SYMBOLS.fail} Unknown arguments: -u, --unknown`, + ) + }) + }) +}) diff --git a/test/socket-npm-fixtures/lacking-typosquat/package.json b/test/socket-npm-fixtures/lacking-typosquat/package.json new file mode 100644 index 000000000..18a1e415e --- /dev/null +++ b/test/socket-npm-fixtures/lacking-typosquat/package.json @@ -0,0 +1,3 @@ +{ + "dependencies": {} +} diff --git a/packages/cli/test/fixtures/commands/manifest/conda/environment.yml b/test/socket-npm-fixtures/manifest-conda/environment.yml similarity index 100% rename from packages/cli/test/fixtures/commands/manifest/conda/environment.yml rename to test/socket-npm-fixtures/manifest-conda/environment.yml diff --git a/test/socket-npm-fixtures/npm10/package-lock.json b/test/socket-npm-fixtures/npm10/package-lock.json new file mode 100644 index 000000000..3ab594baa --- /dev/null +++ b/test/socket-npm-fixtures/npm10/package-lock.json @@ -0,0 +1,2560 @@ +{ + "name": "npm10", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm10", + "version": "1.0.0", + "dependencies": { + "npm": "^10.9.2" + } + }, + "node_modules/npm": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz", + "integrity": "sha512-iriPEPIkoMYUy3F6f3wwSZAU93E0Eg6cHwIR6jzzOXWSy+SD/rOODEs74cVONHKSx2obXtuUoyidVEhISrisgQ==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^8.0.0", + "@npmcli/config": "^9.0.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.1.0", + "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "@sigstore/tuf": "^3.0.0", + "abbrev": "^3.0.0", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.3.0", + "ci-info": "^4.1.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.5", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.0.2", + "ini": "^5.0.0", + "init-package-json": "^7.0.2", + "is-cidr": "^5.1.0", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^9.0.0", + "libnpmdiff": "^7.0.0", + "libnpmexec": "^9.0.0", + "libnpmfund": "^6.0.0", + "libnpmhook": "^11.0.0", + "libnpmorg": "^7.0.0", + "libnpmpack": "^8.0.0", + "libnpmpublish": "^10.0.1", + "libnpmsearch": "^8.0.0", + "libnpmteam": "^7.0.0", + "libnpmversion": "^7.0.0", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.5", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.0.0", + "nopt": "^8.0.0", + "normalize-package-data": "^7.0.0", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.1", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^4.0.0", + "pacote": "^19.0.1", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.0.0", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^9.4.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.0", + "which": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^8.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^19.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "8.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^20.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { + "version": "20.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "normalize-package-data": "^7.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.3.2", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/aggregate-error": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/p-map": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/clean-stack": { + "version": "2.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.3.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "5.2.0", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.1", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.3.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.4.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/indent-string": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "7.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.0.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^2.3.0", + "diff": "^5.1.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "tar": "^6.2.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "11.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^8.0.0", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^19.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "10.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^7.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "11.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/nopt/node_modules/abbrev": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/npm/node_modules/pacote": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-inflight": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/rimraf": { + "version": "5.0.10", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.6.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^3.0.0", + "@sigstore/tuf": "^3.0.0", + "@sigstore/verify": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/bundle": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/core": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/sign": { + "version": "3.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^14.0.1", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/sigstore/node_modules/@sigstore/verify": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.0.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.20", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "9.4.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/tuf-js/node_modules/@tufjs/models": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/which": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + } + } +} diff --git a/test/socket-npm-fixtures/npm10/package.json b/test/socket-npm-fixtures/npm10/package.json new file mode 100644 index 000000000..9cd7371c3 --- /dev/null +++ b/test/socket-npm-fixtures/npm10/package.json @@ -0,0 +1,11 @@ +{ + "name": "npm10", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "npm": "^10.9.2" + } +} diff --git a/test/socket-npm-fixtures/npm11/package-lock.json b/test/socket-npm-fixtures/npm11/package-lock.json new file mode 100644 index 000000000..21a6e4195 --- /dev/null +++ b/test/socket-npm-fixtures/npm11/package-lock.json @@ -0,0 +1,2458 @@ +{ + "name": "npm11", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm11", + "version": "1.0.0", + "dependencies": { + "npm": "^11.2.0" + } + }, + "node_modules/npm": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.2.0.tgz", + "integrity": "sha512-PcnFC6gTo9VDkxVaQ1/mZAS3JoWrDjAI+a6e2NgfYQSGDwftJlbdV0jBMi2V8xQPqbGcWaa7p3UP0SKF+Bhm2g==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.0.1", + "@npmcli/config": "^10.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.1.1", + "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/redact": "^3.1.1", + "@npmcli/run-script": "^9.0.1", + "@sigstore/tuf": "^3.0.0", + "abbrev": "^3.0.0", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.4.1", + "ci-info": "^4.1.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.5", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.0.2", + "ini": "^5.0.0", + "init-package-json": "^8.0.0", + "is-cidr": "^5.1.1", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^10.0.0", + "libnpmdiff": "^8.0.1", + "libnpmexec": "^10.1.0", + "libnpmfund": "^7.0.1", + "libnpmorg": "^8.0.0", + "libnpmpack": "^9.0.1", + "libnpmpublish": "^11.0.0", + "libnpmsearch": "^9.0.0", + "libnpmteam": "^8.0.0", + "libnpmversion": "^8.0.0", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.5", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.1.0", + "nopt": "^8.1.0", + "normalize-package-data": "^7.0.0", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.1", + "npm-package-arg": "^12.0.2", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^7.0.3", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.1.0", + "semver": "^7.7.1", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^10.0.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.0", + "which": "^5.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^9.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "10.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.1.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.4.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "2.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.3", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.3.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.4.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.1.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "8.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.1", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^3.0.0", + "diff": "^7.0.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "tar": "^6.2.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "10.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.1", + "@npmcli/package-json": "^6.1.1", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "9.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.1", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "11.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^7.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "11.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "8.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/npm/node_modules/pacote": { + "version": "21.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^10.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/rimraf": { + "version": "5.0.10", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.7.1", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.21", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + } + } +} diff --git a/test/socket-npm-fixtures/npm11/package.json b/test/socket-npm-fixtures/npm11/package.json new file mode 100644 index 000000000..327a5f7e5 --- /dev/null +++ b/test/socket-npm-fixtures/npm11/package.json @@ -0,0 +1,11 @@ +{ + "name": "npm11", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "npm": "^11.2.0" + } +} diff --git a/test/socket-npm-fixtures/npm9/package-lock.json b/test/socket-npm-fixtures/npm9/package-lock.json new file mode 100644 index 000000000..a9db4eb90 --- /dev/null +++ b/test/socket-npm-fixtures/npm9/package-lock.json @@ -0,0 +1,3039 @@ +{ + "name": "npm9", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm9", + "version": "1.0.0", + "dependencies": { + "npm": "^9.9.4" + } + }, + "node_modules/npm": { + "version": "9.9.4", + "resolved": "https://registry.npmjs.org/npm/-/npm-9.9.4.tgz", + "integrity": "sha512-NzcQiLpqDuLhavdyJ2J3tGJ/ni/ebcqHVFZkv1C4/6lblraUPbPgCJ4Vhb4oa3FFhRa2Yj9gA58jGH/ztKueNQ==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/run-script", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "cli-table3", + "columnify", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "npmlog", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "sigstore", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^6.5.0", + "@npmcli/config": "^6.4.0", + "@npmcli/fs": "^3.1.0", + "@npmcli/map-workspaces": "^3.0.4", + "@npmcli/package-json": "^4.0.1", + "@npmcli/promise-spawn": "^6.0.2", + "@npmcli/run-script": "^6.0.2", + "abbrev": "^2.0.0", + "archy": "~1.0.0", + "cacache": "^17.1.4", + "chalk": "^5.3.0", + "ci-info": "^4.0.0", + "cli-columns": "^4.0.0", + "cli-table3": "^0.6.3", + "columnify": "^1.6.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^6.1.3", + "ini": "^4.1.1", + "init-package-json": "^5.0.0", + "is-cidr": "^4.0.2", + "json-parse-even-better-errors": "^3.0.1", + "libnpmaccess": "^7.0.2", + "libnpmdiff": "^5.0.20", + "libnpmexec": "^6.0.4", + "libnpmfund": "^4.2.1", + "libnpmhook": "^9.0.3", + "libnpmorg": "^5.0.4", + "libnpmpack": "^5.0.20", + "libnpmpublish": "^7.5.1", + "libnpmsearch": "^6.0.2", + "libnpmteam": "^5.0.3", + "libnpmversion": "^4.0.2", + "make-fetch-happen": "^11.1.1", + "minimatch": "^9.0.3", + "minipass": "^7.0.4", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^9.4.1", + "nopt": "^7.2.0", + "normalize-package-data": "^5.0.0", + "npm-audit-report": "^5.0.0", + "npm-install-checks": "^6.3.0", + "npm-package-arg": "^10.1.0", + "npm-pick-manifest": "^8.0.2", + "npm-profile": "^7.0.1", + "npm-registry-fetch": "^14.0.5", + "npm-user-validate": "^2.0.0", + "npmlog": "^7.0.1", + "p-map": "^4.0.0", + "pacote": "^15.2.0", + "parse-conflict-json": "^3.0.1", + "proc-log": "^3.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^2.1.0", + "semver": "^7.6.0", + "sigstore": "^1.9.0", + "spdx-expression-parse": "^3.0.1", + "ssri": "^10.0.5", + "supports-color": "^9.4.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^5.0.0", + "which": "^3.0.1", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@colors/colors": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/npm/node_modules/@gar/promisify": { + "version": "1.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "6.5.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^3.1.0", + "@npmcli/installed-package-contents": "^2.0.2", + "@npmcli/map-workspaces": "^3.0.2", + "@npmcli/metavuln-calculator": "^5.0.0", + "@npmcli/name-from-folder": "^2.0.0", + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^4.0.0", + "@npmcli/query": "^3.1.0", + "@npmcli/run-script": "^6.0.0", + "bin-links": "^4.0.1", + "cacache": "^17.0.4", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^6.1.1", + "json-parse-even-better-errors": "^3.0.0", + "json-stringify-nice": "^1.1.4", + "minimatch": "^9.0.0", + "nopt": "^7.0.0", + "npm-install-checks": "^6.2.0", + "npm-package-arg": "^10.1.0", + "npm-pick-manifest": "^8.0.1", + "npm-registry-fetch": "^14.0.3", + "npmlog": "^7.0.1", + "pacote": "^15.0.8", + "parse-conflict-json": "^3.0.0", + "proc-log": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^1.0.2", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^10.0.1", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "6.4.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^3.0.2", + "ci-info": "^4.0.0", + "ini": "^4.1.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/disparity-colors": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ansi-styles": "^4.3.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "3.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^6.0.0", + "lru-cache": "^7.4.4", + "npm-pick-manifest": "^8.0.0", + "proc-log": "^3.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "lib/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "3.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^2.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0", + "read-package-json-fast": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "5.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^17.0.0", + "json-parse-even-better-errors": "^3.0.0", + "pacote": "^15.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/move-file": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^4.1.0", + "glob": "^10.2.2", + "hosted-git-info": "^6.1.1", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "proc-log": "^3.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "3.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/promise-spawn": "^6.0.0", + "node-gyp": "^9.0.0", + "read-package-json-fast": "^3.0.0", + "which": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "1.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.2.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.2.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "1.0.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^1.1.0", + "@sigstore/protobuf-specs": "^0.2.0", + "make-fetch-happen": "^11.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "1.0.3", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.2.0", + "tuf-js": "^1.1.7" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tootallnate/once": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "1.0.0", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/npm/node_modules/agentkeepalive": { + "version": "4.5.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/npm/node_modules/aggregate-error": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/are-we-there-yet": { + "version": "4.0.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "4.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "read-cmd-shim": "^4.0.0", + "write-file-atomic": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/builtins": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "17.1.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^7.7.1", + "minipass": "^7.0.3", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "3.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^4.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/clean-stack": { + "version": "2.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cli-table3": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/npm/node_modules/clone": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/color-support": { + "version": "1.1.3", + "inBundle": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/npm/node_modules/columnify": { + "version": "1.6.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/console-control-strings": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.3.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/defaults": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/delegates": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/diff": { + "version": "5.2.0", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.1", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/fs.realpath": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/function-bind": { + "version": "1.1.2", + "inBundle": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm/node_modules/gauge": { + "version": "5.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^4.0.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.3.10", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/has-unicode": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hasown": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "6.1.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^7.5.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/humanize-ms": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "6.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/indent-string": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/infer-owner": { + "version": "1.0.4", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/inflight": { + "version": "1.0.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/npm/node_modules/inherits": { + "version": "2.0.4", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/ini": { + "version": "4.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^10.0.0", + "promzard": "^1.0.0", + "read": "^2.0.0", + "read-package-json": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "4.0.2", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^3.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/is-core-module": { + "version": "2.13.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/is-lambda": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "2.3.6", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "7.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^10.1.0", + "npm-registry-fetch": "^14.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "5.0.21", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^6.5.0", + "@npmcli/disparity-colors": "^3.0.0", + "@npmcli/installed-package-contents": "^2.0.2", + "binary-extensions": "^2.2.0", + "diff": "^5.1.0", + "minimatch": "^9.0.0", + "npm-package-arg": "^10.1.0", + "pacote": "^15.0.8", + "tar": "^6.1.13" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "6.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^6.5.0", + "@npmcli/run-script": "^6.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^10.1.0", + "npmlog": "^7.0.1", + "pacote": "^15.0.8", + "proc-log": "^3.0.0", + "read": "^2.0.0", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "4.2.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^6.5.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "9.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^14.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "5.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^14.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "5.0.21", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^6.5.0", + "@npmcli/run-script": "^6.0.0", + "npm-package-arg": "^10.1.0", + "pacote": "^15.0.8" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "7.5.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^5.0.0", + "npm-package-arg": "^10.1.0", + "npm-registry-fetch": "^14.0.3", + "proc-log": "^3.0.0", + "semver": "^7.3.7", + "sigstore": "^1.4.0", + "ssri": "^10.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "6.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^14.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "5.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^14.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "4.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^4.0.1", + "@npmcli/run-script": "^6.0.0", + "json-parse-even-better-errors": "^3.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "7.18.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "11.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^17.0.0", + "http-cache-semantics": "^4.1.1", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/make-fetch-happen/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.0.4", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "1.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "3.0.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-json-stream": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/npm/node_modules/minipass-json-stream/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "9.4.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/fs": { + "version": "2.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/abbrev": { + "version": "1.1.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/node-gyp/node_modules/are-we-there-yet": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.11", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { + "version": "16.1.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/gauge": { + "version": "4.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { + "version": "10.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minipass-fetch": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/nopt": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/npmlog": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/signal-exit": { + "version": "3.0.7", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/node-gyp/node_modules/ssri": { + "version": "9.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/unique-filename": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/unique-slug": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "7.2.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "5.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^6.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "6.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "10.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "7.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^6.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^10.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "7.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^14.0.0", + "proc-log": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "14.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "make-fetch-happen": "^11.0.0", + "minipass": "^5.0.0", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^10.0.0", + "proc-log": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "2.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npmlog": { + "version": "7.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^4.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^5.0.0", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/once": { + "version": "1.4.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "15.2.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^4.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/promise-spawn": "^6.0.1", + "@npmcli/run-script": "^6.0.0", + "cacache": "^17.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^5.0.0", + "npm-package-arg": "^10.0.0", + "npm-packlist": "^7.0.0", + "npm-pick-manifest": "^8.0.0", + "npm-registry-fetch": "^14.0.0", + "proc-log": "^3.0.0", + "promise-retry": "^2.0.1", + "read-package-json": "^6.0.0", + "read-package-json-fast": "^3.0.0", + "sigstore": "^1.3.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "lib/bin.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/pacote/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/path-is-absolute": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.10.1", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.0.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "1.0.2", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-inflight": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~1.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/read-package-json": { + "version": "6.0.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^10.2.2", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^5.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/readable-stream": { + "version": "3.6.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/rimraf": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm/node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/npm/node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.6.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/set-blocking": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "1.9.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^1.1.0", + "@sigstore/protobuf-specs": "^0.2.0", + "@sigstore/sign": "^1.0.0", + "@sigstore/tuf": "^1.0.3", + "make-fetch-happen": "^11.0.1" + }, + "bin": { + "sigstore": "bin/sigstore.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.17", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "10.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/string_decoder": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "9.4.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "1.1.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "1.0.4", + "debug": "^4.3.4", + "make-fetch-happen": "^11.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "builtins": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/wcwidth": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/npm/node_modules/which": { + "version": "3.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/wide-align": { + "version": "1.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrappy": { + "version": "1.0.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "5.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + } + } +} diff --git a/test/socket-npm-fixtures/npm9/package.json b/test/socket-npm-fixtures/npm9/package.json new file mode 100644 index 000000000..9b5282624 --- /dev/null +++ b/test/socket-npm-fixtures/npm9/package.json @@ -0,0 +1,11 @@ +{ + "name": "npm9", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "npm": "^9.9.4" + } +} diff --git a/packages/cli/test/fixtures/commands/json/socket.json b/test/socket-npm-fixtures/sjtest/socket.json similarity index 100% rename from packages/cli/test/fixtures/commands/json/socket.json rename to test/socket-npm-fixtures/sjtest/socket.json diff --git a/test/socket-npm.test.mts b/test/socket-npm.test.mts new file mode 100644 index 000000000..5c46c9800 --- /dev/null +++ b/test/socket-npm.test.mts @@ -0,0 +1,88 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { isDebug } from '@socketsecurity/registry/lib/debug' +import { spawn, spawnSync } from '@socketsecurity/registry/lib/spawn' + +import constants from '../src/constants.mts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const testPath = __dirname +const npmFixturesPath = path.join(testPath, 'socket-npm-fixtures') + +// These aliases are defined in package.json. +for (const npmDir of ['npm9', 'npm10', 'npm11']) { + if (constants.ENV.CI) { + // Skip this test in CI. + describe('skipme', () => it('should skip', () => expect(true).toBe(true))) + continue + } + const npmPath = path.join(npmFixturesPath, npmDir) + const npmBinPath = path.join(npmPath, 'node_modules/.bin') + + describe(`Socket npm wrapper for ${npmDir}`, () => { + const usDebug = isDebug() + spawnSync('npm', ['install', ...(usDebug ? [] : ['--silent'])], { + cwd: npmPath, + stdio: usDebug ? 'inherit' : 'ignore', + }) + + // Lazily access constants.binPath. + const entryPath = path.join(constants.binPath, 'cli.js') + + it( + 'should bail on new typosquat', + { + // About 5s on my machine. Will be slow in CI. Extend if too flaky. + timeout: 30_000, + }, + async () => { + const result = await new Promise<string>((resolve, reject) => { + const spawnPromise = spawn( + // Lazily access constants.execPath. + constants.execPath, + [ + entryPath, + 'npm', + 'install', + 'bowserify', + '--no-audit', + '--no-fund', + ], + { + cwd: path.join(npmFixturesPath, 'lacking-typosquat'), + env: { + // Lazily access constants.ENV.PATH. + PATH: `${npmBinPath}:${constants.ENV.PATH}`, + }, + }, + ) + spawnPromise.process.stdout!.on('data', () => { + reject( + new Error( + 'It seems npm ran anyways so the test failed to invoke socket', + ), + ) + }) + spawnPromise.catch((e: unknown) => { + spawnPromise.process.kill('SIGINT') + if (e?.['stderr'].includes('typosquat')) { + resolve('OK') + } else { + reject(e) + } + }) + }) + + expect( + result, + 'if the promise resolves then the typo-squat attack message was seen, the promise should not reject in any way', + ).toBe('OK') + }, + ) + }) +} diff --git a/test/utils.mts b/test/utils.mts new file mode 100644 index 000000000..33825edf5 --- /dev/null +++ b/test/utils.mts @@ -0,0 +1,91 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { it } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../src/constants.mts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Note: the fixture dir is in the same dir as this utils file +const npmFixturesPath = path.join(__dirname, 'socket-npm-fixtures') + +type TestCollectorOptions = Exclude<Parameters<typeof it>[1], undefined> + +/** + * This is a simple template wrapper for this pattern: + * `it('should do: socket scan', (['socket', 'scan']) => {})` + */ +export function cmdit( + cmd: string[], + title: string, + cb: (cmd: string[]) => Promise<void>, + options?: TestCollectorOptions | undefined, +) { + it( + `${title}: \`${cmd.join(' ')}\``, + { + timeout: 30_000, + ...options, + }, + cb.bind(null, cmd), + ) +} + +export async function invokeNpm( + entryPath: string, + args: string[], + env = {}, +): Promise<{ + status: boolean + code: number + stdout: string + stderr: string +}> { + try { + const output = await spawn( + // Lazily access constants.execPath. + constants.execPath, + [entryPath, ...args], + { + cwd: npmFixturesPath, + env: { ...process.env, ...env }, + }, + ) + return { + status: true, + code: 0, + stdout: toAsciiSafeString(normalizeLogSymbols(output.stdout)), + stderr: toAsciiSafeString(normalizeLogSymbols(output.stderr)), + } + } catch (e: unknown) { + return { + status: false, + code: e?.['code'], + stdout: toAsciiSafeString(normalizeLogSymbols(e?.['stdout'] ?? '')), + stderr: toAsciiSafeString(normalizeLogSymbols(e?.['stderr'] ?? '')), + } + } +} + +function normalizeLogSymbols(str: string): string { + return str + .replaceAll('✖', '×') + .replaceAll('ℹ', 'i') + .replaceAll('✔', '√') + .replaceAll('⚠', '‼') +} + +function toAsciiSafeString(str: string): string { + // eslint-disable-next-line no-control-regex + const asciiSafeRegex = /[\u0000-\u0007\u0009\u000b-\u001f\u0080-\uffff]/g + return str.replace(asciiSafeRegex, (m: string) => { + const code = m.charCodeAt(0) + return code < 255 + ? `\\x${code.toString(16).padStart(2, '0')}` + : `\\u${code.toString(16).padStart(4, '0')}` + }) +} diff --git a/translations.json b/translations.json new file mode 100644 index 000000000..aa4e4ca61 --- /dev/null +++ b/translations.json @@ -0,0 +1,592 @@ +{ + "alerts": { + "badEncoding": { + "description": "Source files are encoded using a non-standard text encoding.", + "suggestion": "Ensure all published files are encoded using a standard encoding such as UTF8, UTF16, UTF32, SHIFT-JIS, etc.", + "title": "Bad text encoding", + "emoji": "⚠️" + }, + "badSemver": { + "description": "Package version is not a valid semantic version (semver).", + "suggestion": "All versions of all packages on npm should use use a valid semantic version. Publish a new version of the package with a valid semantic version. Semantic version ranges do not work with invalid semantic versions.", + "title": "Bad semver", + "emoji": "⚠️" + }, + "badSemverDependency": { + "description": "Package has dependencies with an invalid semantic version. This could be a sign of beta, low quality, or unmaintained dependencies.", + "suggestion": "Switch to a version of the dependency with valid semver or override the dependency version if it is determined to be problematic.", + "title": "Bad dependency semver", + "emoji": "⚠️" + }, + "bidi": { + "description": "Source files contain bidirectional unicode control characters. This could indicate a Trojan source supply chain attack. See: trojansource.codes for more information.", + "suggestion": "Remove bidirectional unicode control characters, or clearly document what they are used for.", + "title": "Bidirectional unicode control characters", + "emoji": "⚠️" + }, + "binScriptConfusion": { + "description": "This package has multiple bin scripts with the same name. This can cause non-deterministic behavior when installing or could be a sign of a supply chain attack.", + "suggestion": "Consider removing one of the conflicting packages. Packages should only export bin scripts with their name.", + "title": "Bin script confusion", + "emoji": "😵‍💫" + }, + "chronoAnomaly": { + "description": "Semantic versions published out of chronological order.", + "suggestion": "This could either indicate dependency confusion or a patched vulnerability.", + "title": "Chronological version anomaly", + "emoji": "⚠️" + }, + "compromisedSSHKey": { + "description": "Project maintainer's SSH key has been compromised.", + "suggestion": "The maintainer should revoke the compromised key and generate a new one.", + "title": "Compromised SSH key", + "emoji": "🔑" + }, + "criticalCVE": { + "description": "Contains a Critical Common Vulnerability and Exposure (CVE).", + "suggestion": "Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", + "title": "Critical CVE", + "emoji": "⚠️" + }, + "cve": { + "description": "Contains a high severity Common Vulnerability and Exposure (CVE).", + "suggestion": "Remove or replace dependencies that include known high severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", + "title": "High CVE", + "emoji": "⚠️" + }, + "debugAccess": { + "description": "Uses debug, reflection and dynamic code execution features.", + "suggestion": "Removing the use of debug will reduce the risk of any reflection and dynamic code execution.", + "title": "Debug access", + "emoji": "⚠️" + }, + "deprecated": { + "description": "The maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.", + "suggestion": "Research the state of the package and determine if there are non-deprecated versions that can be used, or if it should be replaced with a new, supported solution.", + "title": "Deprecated", + "emoji": "⚠️" + }, + "deprecatedException": { + "description": "(Experimental) Contains a known deprecated SPDX license exception.", + "suggestion": "Fix the license so that it no longer contains deprecated SPDX license exceptions.", + "title": "Deprecated SPDX exception", + "emoji": "⚠️" + }, + "explicitlyUnlicensedItem": { + "description": "(Experimental) Something was found which is explicitly marked as unlicensed.", + "suggestion": "Manually review your policy on such materials", + "title": "Explicitly Unlicensed Item", + "emoji": "⚠️" + }, + "unidentifiedLicense": { + "description": "(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license.", + "suggestion": "Manually review the license contents.", + "title": "Unidentified License", + "emoji": "⚠️" + }, + "noLicenseFound": { + "description": "(Experimental) License information could not be found.", + "suggestion": "Manually review the licensing", + "title": "No License Found", + "emoji": "⚠️" + }, + "copyleftLicense": { + "description": "(Experimental) Copyleft license information was found.", + "suggestion": "Determine whether use of copyleft material works for you", + "title": "Copyleft License", + "emoji": "⚠️" + }, + "licenseSpdxDisj": { + "description": "This package is not allowed per your license policy. Review the package's license to ensure compliance.", + "suggestion": "Find a package that does not violate your license policy or adjust your policy to allow this package's license.", + "title": "License Policy Violation", + "emoji": "⚠️" + }, + "nonpermissiveLicense": { + "description": "(Experimental) A license not known to be considered permissive was found.", + "suggestion": "Determine whether use of material not offered under a known permissive license works for you", + "title": "Non-permissive License", + "emoji": "⚠️" + }, + "miscLicenseIssues": { + "description": "(Experimental) A package's licensing information has fine-grained problems.", + "suggestion": "Consult the alert's description and location information for more information", + "title": "Misc. License Issues", + "emoji": "⚠️" + }, + "deprecatedLicense": { + "description": "(Experimental) License is deprecated which may have legal implications regarding the package's use.", + "suggestion": "Update or change the license to a well-known or updated license.", + "title": "Deprecated license", + "emoji": "⚠️" + }, + "didYouMean": { + "description": "Package name is similar to other popular packages and may not be the package you want.", + "suggestion": "Use care when consuming similarly named packages and ensure that you did not intend to consume a different package. Malicious packages often publish using similar names as existing popular packages.", + "title": "Possible typosquat attack", + "emoji": "🧐" + }, + "dynamicRequire": { + "description": "Dynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.", + "suggestion": "Packages should avoid dynamic imports when possible. Audit the use of dynamic require to ensure it is not executing malicious or vulnerable code.", + "title": "Dynamic require", + "emoji": "⚠️" + }, + "emptyPackage": { + "description": "Package does not contain any code. It may be removed, is name squatting, or the result of a faulty package publish.", + "suggestion": "Remove dependencies that do not export any code or functionality and ensure the package version includes all of the files it is supposed to.", + "title": "Empty package", + "emoji": "⚠️" + }, + "envVars": { + "description": "Package accesses environment variables, which may be a sign of credential stuffing or data theft.", + "suggestion": "Packages should be clear about which environment variables they access, and care should be taken to ensure they only access environment variables they claim to.", + "title": "Environment variable access", + "emoji": "⚠️" + }, + "extraneousDependency": { + "description": "Package optionally loads a dependency which is not specified within any of the package.json dependency fields. It may inadvertently be importing dependencies specified by other packages.", + "suggestion": "Specify all optionally loaded dependencies in optionalDependencies within package.json.", + "title": "Extraneous dependency", + "emoji": "⚠️" + }, + "fileDependency": { + "description": "Contains a dependency which resolves to a file. This can obfuscate analysis and serves no useful purpose.", + "suggestion": "Remove the dependency specified by a file resolution string from package.json and update any bare name imports that referenced it before to use relative path strings.", + "title": "File dependency", + "emoji": "⚠️" + }, + "filesystemAccess": { + "description": "Accesses the file system, and could potentially read sensitive data.", + "suggestion": "If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead.", + "title": "Filesystem access", + "emoji": "⚠️" + }, + "floatingDependency": { + "description": "Package has a dependency with a floating version range. This can cause issues if the dependency publishes a new major version.", + "suggestion": "Packages should specify properly semver ranges to avoid version conflicts.", + "title": "Wildcard dependency", + "emoji": "🎈" + }, + "gitDependency": { + "description": "Contains a dependency which resolves to a remote git URL. Dependencies fetched from git URLs are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install.", + "suggestion": "Publish the git dependency to npm or a private package repository and consume it from there.", + "title": "Git dependency", + "emoji": "🍣" + }, + "gitHubDependency": { + "description": "Contains a dependency which resolves to a GitHub URL. Dependencies fetched from GitHub specifiers are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install.", + "suggestion": "Publish the GitHub dependency to npm or a private package repository and consume it from there.", + "title": "GitHub dependency", + "emoji": "⚠️" + }, + "gptAnomaly": { + "description": "AI has identified unusual behaviors that may pose a security risk.", + "suggestion": "An AI system found a low-risk anomaly in this package. It may still be fine to use, but you should check that it is safe before proceeding.", + "title": "AI-detected potential code anomaly", + "emoji": "🤔" + }, + "gptDidYouMean": { + "description": "AI has identified this package as a potential typosquat of a more popular package. This suggests that the package may be intentionally mimicking another package's name, description, or other metadata.", + "suggestion": "Given the AI system's identification of this package as a potential typosquat, please verify that you did not intend to install a different package. Be cautious, as malicious packages often use names similar to popular ones.", + "title": "AI-detected possible typosquat", + "emoji": "🤖" + }, + "gptMalware": { + "description": "AI has identified this package as malware. This is a strong signal that the package may be malicious.", + "suggestion": "Given the AI system's identification of this package as malware, extreme caution is advised. It is recommended to avoid downloading or installing this package until the threat is confirmed or flagged as a false positive.", + "title": "AI-detected potential malware", + "emoji": "🤖" + }, + "gptSecurity": { + "description": "AI has determined that this package may contain potential security issues or vulnerabilities.", + "suggestion": "An AI system identified potential security problems in this package. It is advised to review the package thoroughly and assess the potential risks before installation. You may also consider reporting the issue to the package maintainer or seeking alternative solutions with a stronger security posture.", + "title": "AI-detected potential security risk", + "emoji": "🤖" + }, + "hasNativeCode": { + "description": "Contains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.", + "suggestion": "Verify that the inclusion of native code is expected and necessary for this package's functionality. If it is unnecessary or unexpected, consider using alternative packages without native code to mitigate potential risks.", + "title": "Native code", + "emoji": "🛠️" + }, + "highEntropyStrings": { + "description": "Contains high entropy strings. This could be a sign of encrypted data, leaked secrets or obfuscated code.", + "suggestion": "Please inspect these strings to check if these strings are benign. Maintainers should clarify the purpose and existence of high entropy strings if there is a legitimate purpose.", + "title": "High entropy strings", + "emoji": "⚠️" + }, + "homoglyphs": { + "description": "Contains unicode homoglyphs which can be used in supply chain confusion attacks.", + "suggestion": "Remove unicode homoglyphs if they are unnecessary, and audit their presence to confirm legitimate use.", + "title": "Unicode homoglyphs", + "emoji": "⚠️" + }, + "httpDependency": { + "description": "Contains a dependency which resolves to a remote HTTP URL which could be used to inject untrusted code and reduce overall package reliability.", + "suggestion": "Publish the HTTP URL dependency to npm or a private package repository and consume it from there.", + "title": "HTTP dependency", + "emoji": "🥩" + }, + "installScripts": { + "description": "Install scripts are run when the package is installed. The majority of malware in npm is hidden in install scripts.", + "suggestion": "Packages should not be running non-essential scripts during install and there are often solutions to problems people solve with install scripts that can be run at publish time instead.", + "title": "Install scripts", + "emoji": "📜" + }, + "invalidPackageJSON": { + "description": "Package has an invalid manifest file and can cause installation problems if you try to use it.", + "suggestion": "Fix syntax errors in the manifest file and publish a new version. Consumers can use npm overrides to force a version that does not have this problem if one exists.", + "title": "Invalid manifest file", + "emoji": "🤒" + }, + "invisibleChars": { + "description": "Source files contain invisible characters. This could indicate source obfuscation or a supply chain attack.", + "suggestion": "Remove invisible characters. If their use is justified, use their visible escaped counterparts.", + "title": "Invisible chars", + "emoji": "⚠️" + }, + "licenseChange": { + "description": "(Experimental) Package license has recently changed.", + "suggestion": "License changes should be reviewed carefully to inform ongoing use. Packages should avoid making major changes to their license type.", + "title": "License change", + "emoji": "⚠️" + }, + "licenseException": { + "description": "(Experimental) Contains an SPDX license exception.", + "suggestion": "License exceptions should be carefully reviewed.", + "title": "License exception", + "emoji": "⚠️" + }, + "longStrings": { + "description": "Contains long string literals, which may be a sign of obfuscated or packed code.", + "suggestion": "Avoid publishing or consuming obfuscated or bundled code. It makes dependencies difficult to audit and undermines the module resolution system.", + "title": "Long strings", + "emoji": "⚠️" + }, + "missingTarball": { + "description": "This package is missing it's tarball. It could be removed from the npm registry or there may have been an error when publishing.", + "suggestion": "This package cannot be analyzed or installed due to missing data.", + "title": "Missing package tarball", + "emoji": "❔" + }, + "majorRefactor": { + "description": "Package has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.", + "suggestion": "Consider waiting before upgrading to see if any issues are discovered, or be prepared to scrutinize any bugs or subtle changes the major refactor may bring. Publishers my consider publishing beta versions of major refactors to limit disruption to parties interested in the new changes.", + "title": "Major refactor", + "emoji": "⚠️" + }, + "malware": { + "description": "This package is malware. We have asked the package registry to remove it.", + "title": "Known malware", + "suggestion": "It is strongly recommended that malware is removed from your codebase.", + "emoji": "☠️" + }, + "manifestConfusion": { + "description": "This package has inconsistent metadata. This could be malicious or caused by an error when publishing the package.", + "title": "Manifest confusion", + "suggestion": "Packages with inconsistent metadata may be corrupted or malicious.", + "emoji": "🥸" + }, + "mediumCVE": { + "description": "Contains a medium severity Common Vulnerability and Exposure (CVE).", + "suggestion": "Remove or replace dependencies that include known medium severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", + "title": "Medium CVE", + "emoji": "⚠️" + }, + "mildCVE": { + "description": "Contains a low severity Common Vulnerability and Exposure (CVE).", + "suggestion": "Remove or replace dependencies that include known low severity CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.", + "title": "Low CVE", + "emoji": "⚠️" + }, + "minifiedFile": { + "description": "This package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.", + "suggestion": "In many cases minified code is harmless, however minified code can be used to hide a supply chain attack. Consider not shipping minified code on npm.", + "title": "Minified code", + "emoji": "⚠️" + }, + "missingAuthor": { + "description": "The package was published by an npm account that no longer exists.", + "suggestion": "Packages should have active and identified authors.", + "title": "Non-existent author", + "emoji": "🫥" + }, + "missingDependency": { + "description": "A required dependency is not declared in package.json and may prevent the package from working.", + "suggestion": "The package should define the missing dependency inside of package.json and publish a new version. Consumers may have to install the missing dependency themselves as long as the dependency remains missing. If the dependency is optional, add it to optionalDependencies and handle the missing case.", + "title": "Missing dependency", + "emoji": "⚠️" + }, + "missingLicense": { + "description": "(Experimental) Package does not have a license and consumption legal status is unknown.", + "suggestion": "A new version of the package should be published that includes a valid SPDX license in a license file, package.json license field or mentioned in the README.", + "title": "Missing license", + "emoji": "⚠️" + }, + "mixedLicense": { + "description": "(Experimental) Package contains multiple licenses.", + "suggestion": "A new version of the package should be published that includes a single license. Consumers may seek clarification from the package author. Ensure that the license details are consistent across the LICENSE file, package.json license field and license details mentioned in the README.", + "title": "Mixed license", + "emoji": "⚠️" + }, + "ambiguousClassifier": { + "description": "(Experimental) An ambiguous license classifier was found.", + "suggestion": "A specific license or licenses should be identified", + "title": "Ambiguous License Classifier", + "emoji": "⚠️" + }, + "modifiedException": { + "description": "(Experimental) Package contains a modified version of an SPDX license exception. Please read carefully before using this code.", + "suggestion": "Packages should avoid making modifications to standard license exceptions.", + "title": "Modified license exception", + "emoji": "⚠️" + }, + "modifiedLicense": { + "description": "(Experimental) Package contains a modified version of an SPDX license. Please read carefully before using this code.", + "suggestion": "Packages should avoid making modifications to standard licenses.", + "title": "Modified license", + "emoji": "⚠️" + }, + "networkAccess": { + "description": "This module accesses the network.", + "suggestion": "Packages should remove all network access that is functionally unnecessary. Consumers should audit network access to ensure legitimate use.", + "title": "Network access", + "emoji": "⚠️" + }, + "newAuthor": { + "description": "A new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.", + "suggestion": "Scrutinize new collaborator additions to packages because they now have the ability to publish code into your dependency tree. Packages should avoid frequent or unnecessary additions or changes to publishing rights.", + "title": "New author", + "emoji": "⚠️" + }, + "noAuthorData": { + "description": "Package does not specify a list of contributors or an author in package.json.", + "suggestion": "Add a author field or contributors array to package.json.", + "title": "No contributors or author data", + "emoji": "⚠️" + }, + "noBugTracker": { + "description": "Package does not have a linked bug tracker in package.json.", + "suggestion": "Add a bugs field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#bugs", + "title": "No bug tracker", + "emoji": "⚠️" + }, + "noREADME": { + "description": "Package does not have a README. This may indicate a failed publish or a low quality package.", + "suggestion": "Add a README to to the package and publish a new version.", + "title": "No README", + "emoji": "⚠️" + }, + "noRepository": { + "description": "Package does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.", + "suggestion": "Add a repository field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#repository", + "title": "No repository", + "emoji": "⚠️" + }, + "noTests": { + "description": "Package does not have any tests. This is a strong signal of a poorly maintained or low quality package.", + "suggestion": "Add tests and publish a new version of the package. Consumers may look for an alternative package with better testing.", + "title": "No tests", + "emoji": "⚠️" + }, + "noV1": { + "description": "Package is not semver >=1. This means it is not stable and does not support ^ ranges.", + "suggestion": "If the package sees any general use, it should begin releasing at version 1.0.0 or later to benefit from semver.", + "title": "No v1", + "emoji": "⚠️" + }, + "noWebsite": { + "description": "Package does not have a website.", + "suggestion": "Add a homepage field to package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#homepage", + "title": "No website", + "emoji": "⚠️" + }, + "nonFSFLicense": { + "description": "(Experimental) Package has a non-FSF-approved license.", + "title": "Non FSF license", + "suggestion": "Consider the terms of the license for your given use case.", + "emoji": "⚠️" + }, + "nonOSILicense": { + "description": "(Experimental) Package has a non-OSI-approved license.", + "title": "Non OSI license", + "suggestion": "Consider the terms of the license for your given use case.", + "emoji": "⚠️" + }, + "nonSPDXLicense": { + "description": "(Experimental) Package contains a non-standard license somewhere. Please read carefully before using.", + "suggestion": "Package should adopt a standard SPDX license consistently across all license locations (LICENSE files, package.json license fields, and READMEs).", + "title": "Non SPDX license", + "emoji": "⚠️" + }, + "notice": { + "description": "(Experimental) Package contains a legal notice. This could increase your exposure to legal risk when using this project.", + "title": "Legal notice", + "suggestion": "Consider the implications of the legal notice for your given use case.", + "emoji": "⚠️" + }, + "obfuscatedFile": { + "description": "Obfuscated files are intentionally packed to hide their behavior. This could be a sign of malware.", + "suggestion": "Packages should not obfuscate their code. Consider not using packages with obfuscated code", + "title": "Obfuscated code", + "emoji": "⚠️" + }, + "obfuscatedRequire": { + "description": "Package accesses dynamic properties of require and may be obfuscating code execution.", + "suggestion": "The package should not access dynamic properties of module. Instead use import or require directly.", + "title": "Obfuscated require", + "emoji": "⚠️" + }, + "peerDependency": { + "description": "Package specifies peer dependencies in package.json.", + "suggestion": "Peer dependencies are fragile and can cause major problems across version changes. Be careful when updating this dependency and its peers.", + "title": "Peer dependency", + "emoji": "⚠️" + }, + "potentialVulnerability": { + "description": "Initial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.", + "suggestion": "It is advisable to proceed with caution. Engage in a review of the package's security aspects and consider reaching out to the package maintainer for the latest information or patches.", + "title": "Potential vulnerability", + "emoji": "🚧" + }, + "semverAnomaly": { + "description": "Package semver skipped several versions, this could indicate a dependency confusion attack or indicate the intention of disruptive breaking changes or major priority shifts for the project.", + "suggestion": "Packages should follow semantic versions conventions by not skipping subsequent version numbers. Consumers should research the purpose of the skipped version number.", + "title": "Semver anomaly", + "emoji": "⚠️" + }, + "shellAccess": { + "description": "This module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.", + "suggestion": "Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced.", + "title": "Shell access", + "emoji": "⚠️" + }, + "shellScriptOverride": { + "description": "This package re-exports a well known shell command via an npm bin script. This is possibly a supply chain attack.", + "suggestion": "Packages should not export bin scripts which conflict with well known shell commands", + "title": "Bin script shell injection", + "emoji": "🦀" + }, + "shrinkwrap": { + "description": "Package contains a shrinkwrap file. This may allow the package to bypass normal install procedures.", + "suggestion": "Packages should never use npm shrinkwrap files due to the dangers they pose.", + "title": "NPM Shrinkwrap", + "emoji": "🧊" + }, + "socketUpgradeAvailable": { + "description": "Package can be replaced with a Socket optimized override.", + "suggestion": "Run `npx socket optimize` in your repository to optimize your dependencies.", + "title": "Socket optimized override available", + "emoji": "🔄" + }, + "suspiciousStarActivity": { + "description": "The GitHub repository of this package may have been artificially inflated with stars (from bots, crowdsourcing, etc.).", + "title": "Suspicious Stars on GitHub", + "suggestion": "This could be a sign of spam, fraud, or even a supply chain attack. The package should be carefully reviewed before installing.", + "emoji": "⚠️" + }, + "suspiciousString": { + "description": "This package contains suspicious text patterns which are commonly associated with bad behavior.", + "suggestion": "The package code should be reviewed before installing", + "title": "Suspicious strings", + "emoji": "⚠️" + }, + "telemetry": { + "description": "This package contains telemetry which tracks how it is used.", + "title": "Telemetry", + "suggestion": "Most telemetry comes with settings to disable it. Consider disabling telemetry if you do not want to be tracked.", + "emoji": "📞" + }, + "trivialPackage": { + "description": "Packages less than 10 lines of code are easily copied into your own project and may not warrant the additional supply chain risk of an external dependency.", + "suggestion": "Removing this package as a dependency and implementing its logic will reduce supply chain risk.", + "title": "Trivial Package", + "emoji": "⚠️" + }, + "troll": { + "description": "This package is a joke, parody, or includes undocumented or hidden behavior unrelated to its primary function.", + "title": "Protestware or potentially unwanted behavior", + "suggestion": "Consider that consuming this package may come along with functionality unrelated to its primary purpose.", + "emoji": "🧌" + }, + "typeModuleCompatibility": { + "description": "Package is CommonJS, but has a dependency which is type: \"module\". The two are likely incompatible.", + "suggestion": "The package needs to switch to dynamic import on the esmodule dependency, or convert to esm itself. Consumers may experience errors resulting from this incompatibility.", + "title": "CommonJS depending on ESModule", + "emoji": "⚠️" + }, + "uncaughtOptionalDependency": { + "description": "Package uses an optional dependency without handling a missing dependency exception. If you install it without the optional dependencies then it could cause runtime errors.", + "suggestion": "Package should handle the loading of the dependency when it is not present, or convert the optional dependency into a regular dependency.", + "title": "Uncaught optional dependency", + "emoji": "⚠️" + }, + "unclearLicense": { + "description": "Package contains a reference to a license without a matching LICENSE file.", + "suggestion": "Add a LICENSE file that matches the license field in package.json. https://docs.npmjs.com/cli/v8/configuring-npm/package-json#license", + "title": "Unclear license", + "emoji": "⚠️" + }, + "unmaintained": { + "description": "Package has not been updated in more than 5 years and may be unmaintained. Problems with the package may go unaddressed.", + "suggestion": "Package should publish periodic maintenance releases if they are maintained, or deprecate if they have no intention in further maintenance.", + "title": "Unmaintained", + "emoji": "⚠️" + }, + "unpopularPackage": { + "description": "This package is not very popular.", + "suggestion": "Unpopular packages may have less maintenance and contain other problems.", + "title": "Unpopular package", + "emoji": "🏚️" + }, + "unpublished": { + "description": "Package version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.", + "suggestion": "Packages can be removed from the registry by manually un-publishing, a security issue removal, or may simply never have been published to the registry. Reliance on these packages will cause problem when they are not found.", + "title": "Unpublished package", + "emoji": "⚠️" + }, + "unresolvedRequire": { + "description": "Package imports a file which does not exist and may not work as is. It could also be importing a file that will be created at runtime which could be a vector for running malicious code.", + "suggestion": "Fix imports so that they require declared dependencies or existing files.", + "title": "Unresolved require", + "emoji": "🕵️" + }, + "unsafeCopyright": { + "description": "(Experimental) Package contains a copyright but no license. Using this package may expose you to legal risk.", + "suggestion": "Clarify the license type by adding a license field to package.json and a LICENSE file.", + "title": "Unsafe copyright", + "emoji": "⚠️" + }, + "unstableOwnership": { + "description": "A new collaborator has begun publishing package versions. Package stability and security risk may be elevated.", + "suggestion": "Try to reduce the amount of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm.", + "title": "Unstable ownership", + "emoji": "⚠️" + }, + "unusedDependency": { + "description": "Package has unused dependencies. This package depends on code that it does not use. This can increase the attack surface for malware and slow down installation.", + "suggestion": "Packages should only specify dependencies that they use directly.", + "title": "Unused dependency", + "emoji": "⚠️" + }, + "urlStrings": { + "description": "Package contains fragments of external URLs or IP addresses, which may indicate that it covertly exfiltrates data.", + "suggestion": "Avoid using packages that make connections to the network, since this helps to leak data.", + "title": "URL strings", + "emoji": "⚠️" + }, + "usesEval": { + "description": "Package uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.", + "suggestion": "Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.", + "title": "Uses eval", + "emoji": "⚠️" + }, + "zeroWidth": { + "description": "Package files contain zero width unicode characters. This could indicate a supply chain attack.", + "suggestion": "Packages should remove unnecessary zero width unicode characters and use their visible counterparts.", + "title": "Zero width unicode chars", + "emoji": "⚠️" + } + } +} diff --git a/tsconfig.dts.json b/tsconfig.dts.json new file mode 100644 index 000000000..f7122e96d --- /dev/null +++ b/tsconfig.dts.json @@ -0,0 +1,19 @@ +{ + "extends": "./.config/tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist/types", + "declarationMap": true, + "emitDeclarationOnly": true, + "module": "preserve", + "moduleResolution": "bundler", + "noEmit": false, + "outDir": "dist/types", + "rootDir": "src" + }, + // @typescript/native-preview currently cannot resolve paths for "include" if + // the config is not in the root of the repository. This is why tsconfig.dts.json + // is in the repository root with ./tsconfig.json instead of the ./config folder. + "include": ["src/**/*.mts"], + "exclude": ["test", "**/*.test.mts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..635f5bd0b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./.config/tsconfig.base.json", + "include": ["src/**/*.mts"], + "exclude": ["src/**/*.test.mts"] +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 000000000..c9086b868 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + exclude: [ + '**/{eslint,vitest}.config.*', + '**/node_modules/**', + '**/[.]**', + '**/*.d.mts', + '**/virtual:*', + 'coverage/**', + 'dist/**', + 'scripts/**', + 'src/**/types.mts', + 'test/**', + ], + }, + }, +})